Skip to content

Commit 547f704

Browse files
Aditya A PAditya A P
authored andcommitted
feat(db): add migration recovery gate
1 parent 61e43bd commit 547f704

4 files changed

Lines changed: 372 additions & 7 deletions

File tree

App.tsx

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ import AmendmentModal from './components/AmendmentModal';
66
import SessionInfo from './components/SessionInfo';
77
import SettingsModal from './components/SettingsModal';
88
import Loader from './components/Loader';
9+
import MigrationRecovery from './components/MigrationRecovery';
910
import { LandingPage } from './components/LandingPage';
1011
import { DefaultKeyBanner } from './components/DefaultKeyBanner';
1112

1213
import { validateApiKey } from './services/aiService';
14+
import { prepareConnection } from './services/db/core/connection';
15+
import { shouldBlockApp, type VersionCheckResult } from './services/db/core/versionGate';
1316
import { Analytics } from '@vercel/analytics/react';
1417

1518
// Initialize diff trigger service for automatic semantic diff analysis
@@ -19,6 +22,11 @@ import './services/diff/DiffTriggerService';
1922
import './styles/diff-colors.css';
2023

2124
const App: React.FC = () => {
25+
const [dbGate, setDbGate] = React.useState<{
26+
status: 'checking' | 'blocked' | 'ready';
27+
result: VersionCheckResult | null;
28+
}>({ status: 'checking', result: null });
29+
2230
// Browser-side env diagnostics (masked) when LF_AI_DEBUG=1
2331
useEffect(() => {
2432
try {
@@ -127,6 +135,14 @@ const settingsFingerprint = React.useMemo(
127135
// Initialize store on first render, then handle URL params
128136
useEffect(() => {
129137
const init = async () => {
138+
const versionCheck = await prepareConnection();
139+
if (shouldBlockApp(versionCheck)) {
140+
setDbGate({ status: 'blocked', result: versionCheck });
141+
return;
142+
}
143+
144+
setDbGate({ status: 'ready', result: versionCheck });
145+
130146
await initializeStore();
131147
// Now that the store is initialized, handle any URL parameters
132148
const urlParams = new URLSearchParams(window.location.search);
@@ -250,13 +266,30 @@ const settingsFingerprint = React.useMemo(
250266
previousChapterIdRef.current = currentChapterId;
251267
}, [currentChapterId]);
252268

253-
if (!isInitialized) {
254-
return (
255-
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 flex items-center justify-center">
256-
<Loader text="Initializing Session..." />
257-
</div>
258-
);
259-
}
269+
if (dbGate.status === 'checking') {
270+
return (
271+
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 flex items-center justify-center">
272+
<Loader text="Checking database..." />
273+
</div>
274+
);
275+
}
276+
277+
if (dbGate.status === 'blocked' && dbGate.result) {
278+
return (
279+
<MigrationRecovery
280+
versionCheck={dbGate.result}
281+
onRetry={() => window.location.reload()}
282+
/>
283+
);
284+
}
285+
286+
if (!isInitialized) {
287+
return (
288+
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 flex items-center justify-center">
289+
<Loader text="Initializing Session..." />
290+
</div>
291+
);
292+
}
260293

261294
// Show landing page if no session is loaded
262295
if (!hasSession) {

components/MigrationRecovery.tsx

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import React, { useCallback, useMemo, useState } from 'react';
2+
import { deleteDatabase } from '../services/db/core/connection';
3+
import { cleanupStorageTier } from '../services/db/core/backupStorage';
4+
import { getBackupMetadata, clearBackupMetadata } from '../services/db/core/migrationTypes';
5+
import { emergencyRestore, restoreFromBackup } from '../services/db/core/migrationRestore';
6+
import { getStatusTitle, type VersionCheckResult } from '../services/db/core/versionGate';
7+
8+
interface MigrationRecoveryProps {
9+
versionCheck: VersionCheckResult;
10+
onRetry: () => void;
11+
onRecovered?: () => void;
12+
}
13+
14+
type BusyState = 'idle' | 'restoring' | 'starting-fresh' | 'uploading';
15+
16+
const defaultRecovered = () => window.location.reload();
17+
18+
export function MigrationRecovery({ versionCheck, onRetry, onRecovered }: MigrationRecoveryProps) {
19+
const recovered = onRecovered ?? defaultRecovered;
20+
const [busy, setBusy] = useState<BusyState>('idle');
21+
const [error, setError] = useState<string | null>(null);
22+
23+
const canRestore = versionCheck.status === 'migration-failed';
24+
const canUpload = versionCheck.status === 'migration-failed' || versionCheck.status === 'db-corrupted';
25+
const canStartFresh = versionCheck.status !== 'blocked';
26+
27+
const title = useMemo(() => getStatusTitle(versionCheck.status), [versionCheck.status]);
28+
29+
const handleRestore = useCallback(async () => {
30+
setBusy('restoring');
31+
setError(null);
32+
try {
33+
const result = await restoreFromBackup();
34+
if (!result.success) {
35+
setError(result.message);
36+
setBusy('idle');
37+
return;
38+
}
39+
recovered();
40+
} catch (e) {
41+
setError(e instanceof Error ? e.message : String(e));
42+
setBusy('idle');
43+
}
44+
}, [recovered]);
45+
46+
const handleUploadBackup = useCallback(async () => {
47+
setError(null);
48+
49+
const input = document.createElement('input');
50+
input.type = 'file';
51+
input.accept = '.json,application/json';
52+
53+
input.onchange = async () => {
54+
const file = input.files?.[0];
55+
if (!file) return;
56+
57+
setBusy('uploading');
58+
try {
59+
const text = await file.text();
60+
const result = await emergencyRestore(text);
61+
if (!result.success) {
62+
setError(result.message);
63+
setBusy('idle');
64+
return;
65+
}
66+
recovered();
67+
} catch (e) {
68+
setError(e instanceof Error ? e.message : String(e));
69+
setBusy('idle');
70+
}
71+
};
72+
73+
input.click();
74+
}, [recovered]);
75+
76+
const handleStartFresh = useCallback(async () => {
77+
if (
78+
!window.confirm(
79+
'This will delete the local database and start fresh.\n\n' +
80+
'If you have important data, try “Restore from Backup” or export from the newer app first.\n\n' +
81+
'Continue?'
82+
)
83+
) {
84+
return;
85+
}
86+
87+
setBusy('starting-fresh');
88+
setError(null);
89+
90+
try {
91+
const metadata = getBackupMetadata();
92+
if (metadata) {
93+
await cleanupStorageTier(metadata);
94+
}
95+
clearBackupMetadata();
96+
} catch (e) {
97+
console.warn('[MigrationRecovery] Failed to clean up backup artifacts:', e);
98+
}
99+
100+
try {
101+
await deleteDatabase();
102+
recovered();
103+
} catch (e) {
104+
setError(e instanceof Error ? e.message : String(e));
105+
setBusy('idle');
106+
}
107+
}, [recovered]);
108+
109+
return (
110+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
111+
<div className="w-full max-w-lg overflow-hidden rounded-xl bg-white shadow-2xl dark:bg-gray-800">
112+
<div className="bg-amber-500 px-6 py-4">
113+
<h2 className="text-lg font-semibold text-white">{title}</h2>
114+
</div>
115+
116+
<div className="space-y-4 px-6 py-5">
117+
<p className="text-sm text-gray-800 dark:text-gray-200">{versionCheck.message}</p>
118+
119+
{error ? (
120+
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-900/40 dark:bg-red-900/20 dark:text-red-200">
121+
{error}
122+
</div>
123+
) : null}
124+
</div>
125+
126+
<div className="flex flex-col gap-2 border-t border-gray-200 bg-gray-50 px-6 py-4 dark:border-gray-700 dark:bg-gray-900/20">
127+
{versionCheck.status === 'blocked' ? (
128+
<button
129+
type="button"
130+
onClick={onRetry}
131+
className="w-full rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700"
132+
>
133+
Retry
134+
</button>
135+
) : null}
136+
137+
{canRestore ? (
138+
<button
139+
type="button"
140+
onClick={handleRestore}
141+
disabled={busy !== 'idle'}
142+
className="w-full rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 disabled:opacity-60"
143+
>
144+
{busy === 'restoring' ? 'Restoring…' : 'Restore from Backup'}
145+
</button>
146+
) : null}
147+
148+
{canUpload ? (
149+
<button
150+
type="button"
151+
onClick={handleUploadBackup}
152+
disabled={busy !== 'idle'}
153+
className="w-full rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-800 hover:bg-gray-100 disabled:opacity-60 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700"
154+
>
155+
{busy === 'uploading' ? 'Uploading…' : 'Upload Backup File'}
156+
</button>
157+
) : null}
158+
159+
{canStartFresh ? (
160+
<button
161+
type="button"
162+
onClick={handleStartFresh}
163+
disabled={busy !== 'idle'}
164+
className="w-full rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-100 disabled:opacity-60 dark:border-red-900/40 dark:bg-red-900/20 dark:text-red-200 dark:hover:bg-red-900/30"
165+
>
166+
{busy === 'starting-fresh' ? 'Starting fresh…' : 'Start Fresh'}
167+
</button>
168+
) : null}
169+
</div>
170+
</div>
171+
</div>
172+
);
173+
}
174+
175+
export default MigrationRecovery;
176+

docs/WORKLOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
2025-12-24 11:23 UTC - Migration recovery UI gate
2+
- Files: App.tsx; components/MigrationRecovery.tsx; tests/components/MigrationRecovery.test.tsx; docs/WORKLOG.md
3+
- 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.
4+
- Details: `App.tsx` calls `prepareConnection()` before store init and blocks into a full-screen `MigrationRecovery` overlay when `shouldBlockApp()` is true.
5+
- Tests: `npx tsc --noEmit`; `npx vitest run tests/components/MigrationRecovery.test.tsx`
6+
17
2025-11-21 01:00 UTC - TranslationStatusPanel spacing tweak
28
- Files: components/chapter/TranslationStatusPanel.tsx (wrapper div class)
39
- Why: Chapter body sat flush against the translation/image metric lines; user requested extra spacing after the status rows.

0 commit comments

Comments
 (0)