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);
+ });
+});