diff --git a/App.tsx b/App.tsx index deda0bc..20f783c 100644 --- a/App.tsx +++ b/App.tsx @@ -6,10 +6,13 @@ import AmendmentModal from './components/AmendmentModal'; import SessionInfo from './components/SessionInfo'; import SettingsModal from './components/SettingsModal'; import Loader from './components/Loader'; +import MigrationRecovery from './components/MigrationRecovery'; import { LandingPage } from './components/LandingPage'; import { DefaultKeyBanner } from './components/DefaultKeyBanner'; import { validateApiKey } from './services/aiService'; +import { prepareConnection } from './services/db/core/connection'; +import { shouldBlockApp, type VersionCheckResult } from './services/db/core/versionGate'; import { Analytics } from '@vercel/analytics/react'; // Initialize diff trigger service for automatic semantic diff analysis @@ -19,6 +22,11 @@ import './services/diff/DiffTriggerService'; import './styles/diff-colors.css'; const App: React.FC = () => { +const [dbGate, setDbGate] = React.useState<{ + status: 'checking' | 'blocked' | 'ready'; + result: VersionCheckResult | null; +}>({ status: 'checking', result: null }); + // Browser-side env diagnostics (masked) when LF_AI_DEBUG=1 useEffect(() => { try { @@ -127,6 +135,14 @@ const settingsFingerprint = React.useMemo( // Initialize store on first render, then handle URL params useEffect(() => { const init = async () => { + const versionCheck = await prepareConnection(); + if (shouldBlockApp(versionCheck)) { + setDbGate({ status: 'blocked', result: versionCheck }); + return; + } + + setDbGate({ status: 'ready', result: versionCheck }); + await initializeStore(); // Now that the store is initialized, handle any URL parameters const urlParams = new URLSearchParams(window.location.search); @@ -250,13 +266,30 @@ const settingsFingerprint = React.useMemo( previousChapterIdRef.current = currentChapterId; }, [currentChapterId]); - if (!isInitialized) { - return ( -
- -
- ); - } + if (dbGate.status === 'checking') { + return ( +
+ +
+ ); + } + + if (dbGate.status === 'blocked' && dbGate.result) { + return ( + window.location.reload()} + /> + ); + } + + if (!isInitialized) { + return ( +
+ +
+ ); + } // Show landing page if no session is loaded if (!hasSession) { diff --git a/components/MigrationRecovery.tsx b/components/MigrationRecovery.tsx new file mode 100644 index 0000000..a3d414f --- /dev/null +++ b/components/MigrationRecovery.tsx @@ -0,0 +1,176 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { deleteDatabase } from '../services/db/core/connection'; +import { cleanupStorageTier } from '../services/db/core/backupStorage'; +import { getBackupMetadata, clearBackupMetadata } from '../services/db/core/migrationTypes'; +import { emergencyRestore, restoreFromBackup } from '../services/db/core/migrationRestore'; +import { getStatusTitle, type VersionCheckResult } from '../services/db/core/versionGate'; + +interface MigrationRecoveryProps { + versionCheck: VersionCheckResult; + onRetry: () => void; + onRecovered?: () => void; +} + +type BusyState = 'idle' | 'restoring' | 'starting-fresh' | 'uploading'; + +const defaultRecovered = () => window.location.reload(); + +export function MigrationRecovery({ versionCheck, onRetry, onRecovered }: MigrationRecoveryProps) { + const recovered = onRecovered ?? defaultRecovered; + const [busy, setBusy] = useState('idle'); + const [error, setError] = useState(null); + + const canRestore = versionCheck.status === 'migration-failed'; + const canUpload = versionCheck.status === 'migration-failed' || versionCheck.status === 'db-corrupted'; + const canStartFresh = versionCheck.status !== 'blocked'; + + const title = useMemo(() => getStatusTitle(versionCheck.status), [versionCheck.status]); + + const handleRestore = useCallback(async () => { + setBusy('restoring'); + setError(null); + try { + const result = await restoreFromBackup(); + if (!result.success) { + setError(result.message); + setBusy('idle'); + return; + } + recovered(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setBusy('idle'); + } + }, [recovered]); + + const handleUploadBackup = useCallback(async () => { + setError(null); + + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json,application/json'; + + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) return; + + setBusy('uploading'); + try { + const text = await file.text(); + const result = await emergencyRestore(text); + if (!result.success) { + setError(result.message); + setBusy('idle'); + return; + } + recovered(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setBusy('idle'); + } + }; + + input.click(); + }, [recovered]); + + const handleStartFresh = useCallback(async () => { + if ( + !window.confirm( + 'This will delete the local database and start fresh.\n\n' + + 'If you have important data, try “Restore from Backup” or export from the newer app first.\n\n' + + 'Continue?' + ) + ) { + return; + } + + setBusy('starting-fresh'); + setError(null); + + try { + const metadata = getBackupMetadata(); + if (metadata) { + await cleanupStorageTier(metadata); + } + clearBackupMetadata(); + } catch (e) { + console.warn('[MigrationRecovery] Failed to clean up backup artifacts:', e); + } + + try { + await deleteDatabase(); + recovered(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setBusy('idle'); + } + }, [recovered]); + + return ( +
+
+
+

{title}

+
+ +
+

{versionCheck.message}

+ + {error ? ( +
+ {error} +
+ ) : null} +
+ +
+ {versionCheck.status === 'blocked' ? ( + + ) : null} + + {canRestore ? ( + + ) : null} + + {canUpload ? ( + + ) : null} + + {canStartFresh ? ( + + ) : null} +
+
+
+ ); +} + +export default MigrationRecovery; + diff --git a/docs/WORKLOG.md b/docs/WORKLOG.md index e3a71ec..473ec08 100644 --- a/docs/WORKLOG.md +++ b/docs/WORKLOG.md @@ -1,3 +1,9 @@ +2025-12-24 11:23 UTC - Migration recovery UI gate +- Files: App.tsx; components/MigrationRecovery.tsx; tests/components/MigrationRecovery.test.tsx; docs/WORKLOG.md +- Why: When the DB is newer/corrupted/blocked or a migration failed, users need a clear recovery path (restore from backup, upload backup, or start fresh) instead of a silent failure. +- Details: `App.tsx` calls `prepareConnection()` before store init and blocks into a full-screen `MigrationRecovery` overlay when `shouldBlockApp()` is true. +- Tests: `npx tsc --noEmit`; `npx vitest run tests/components/MigrationRecovery.test.tsx` + 2025-12-24 11:15 UTC - Fix diffResults import + test hardening - Files: services/db/operations/imports.ts; tests/current-system/export-import.test.ts; tests/services/comparisonService.test.ts; tests/adapters/providers/OpenAIAdapter.test.ts; tests/contracts/provider.contract.test.ts; tests/hooks/useChapterTelemetry.test.tsx; docs/WORKLOG.md - Why: Imported diffResults could throw `DataError` because export emits `fanVersionId: null` but IndexedDB keys must be valid strings; plus expand coverage for provider/adversarial parsing paths. diff --git a/tests/components/MigrationRecovery.test.tsx b/tests/components/MigrationRecovery.test.tsx new file mode 100644 index 0000000..bba3abc --- /dev/null +++ b/tests/components/MigrationRecovery.test.tsx @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import MigrationRecovery from '../../components/MigrationRecovery'; +import type { VersionCheckResult } from '../../services/db/core/versionGate'; +import type { MigrationBackupMetadata, RestoreResult } from '../../services/db/core/migrationTypes'; + +const restoreFromBackupMock = vi.fn<() => Promise>(); +const emergencyRestoreMock = vi.fn<(json: string) => Promise>(); +const deleteDatabaseMock = vi.fn<() => Promise>(); +const cleanupStorageTierMock = vi.fn<(metadata: MigrationBackupMetadata) => Promise>(); +const getBackupMetadataMock = vi.fn<() => MigrationBackupMetadata | null>(); +const clearBackupMetadataMock = vi.fn<() => void>(); + +vi.mock('../../services/db/core/migrationRestore', () => ({ + restoreFromBackup: () => restoreFromBackupMock(), + emergencyRestore: (json: string) => emergencyRestoreMock(json), + canRestoreFromBackup: () => false, +})); + +vi.mock('../../services/db/core/connection', () => ({ + deleteDatabase: () => deleteDatabaseMock(), +})); + +vi.mock('../../services/db/core/backupStorage', () => ({ + cleanupStorageTier: (metadata: MigrationBackupMetadata) => cleanupStorageTierMock(metadata), +})); + +vi.mock('../../services/db/core/migrationTypes', async () => { + const actual = await vi.importActual( + '../../services/db/core/migrationTypes' + ); + return { + ...actual, + getBackupMetadata: () => getBackupMetadataMock(), + clearBackupMetadata: () => clearBackupMetadataMock(), + }; +}); + +const baseVersionCheck = (overrides: Partial): VersionCheckResult => ({ + status: 'blocked', + currentDbVersion: null, + expectedVersion: 12, + canProceed: false, + requiresBackup: false, + message: 'Database blocked.', + ...overrides, +}); + +describe('MigrationRecovery', () => { + beforeEach(() => { + restoreFromBackupMock.mockReset(); + emergencyRestoreMock.mockReset(); + deleteDatabaseMock.mockReset(); + cleanupStorageTierMock.mockReset(); + getBackupMetadataMock.mockReset(); + clearBackupMetadataMock.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders a retry action for blocked databases', () => { + const onRetry = vi.fn(); + render(); + + expect(screen.getByText('Database Busy')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('restores from backup when migration failed', async () => { + const onRecovered = vi.fn(); + restoreFromBackupMock.mockResolvedValueOnce({ success: true, message: 'ok' }); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Restore from Backup' })); + + await waitFor(() => expect(restoreFromBackupMock).toHaveBeenCalledTimes(1)); + expect(onRecovered).toHaveBeenCalledTimes(1); + }); + + it('shows an error when restore fails', async () => { + restoreFromBackupMock.mockResolvedValueOnce({ success: false, message: 'nope' }); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Restore from Backup' })); + + await waitFor(() => expect(screen.getByText('nope')).toBeInTheDocument()); + }); + + it('starts fresh after confirmation', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true); + const onRecovered = vi.fn(); + + getBackupMetadataMock.mockReturnValue({ + fromVersion: 11, + toVersion: 12, + timestamp: new Date().toISOString(), + chapterCount: 1, + translationCount: 1, + sizeBytes: 10, + status: 'failed', + storage: 'localStorage', + }); + + cleanupStorageTierMock.mockResolvedValueOnce(); + deleteDatabaseMock.mockResolvedValueOnce(); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Start Fresh' })); + + await waitFor(() => expect(deleteDatabaseMock).toHaveBeenCalledTimes(1)); + expect(cleanupStorageTierMock).toHaveBeenCalledTimes(1); + expect(clearBackupMetadataMock).toHaveBeenCalledTimes(1); + expect(onRecovered).toHaveBeenCalledTimes(1); + }); +});