diff --git a/README.md b/README.md index 7b84b2030..a8510f65f 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ - - + + @@ -401,7 +401,7 @@ Infrastructure-level features that keep the app fast and extensible as projects ### 🌐 Full Multi-Language Support -Shipped UI locales with **2937 i18n keys** across all 19 languages — zero hardcoded user-facing strings: +Shipped UI locales with **2938 i18n keys** across all 19 languages — zero hardcoded user-facing strings: - 🇩🇪 **German** (Deutsch) - 🇬🇧 **English** @@ -510,8 +510,8 @@ The Settings → AI panel shows a live GPU status badge with adapter details and | **PDF Export** | jsPDF | Client-side, configurable PDF document generation | | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | -| **i18n** | Custom React Context (`I18nContext.tsx`) | 2937 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence | -| **Testing** | Vitest 4.x (7357+ tests / 595 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **i18n** | Custom React Context (`I18nContext.tsx`) | 2938 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence | +| **Testing** | Vitest 4.x (7389+ tests / 597 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | | **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy | | **Visualization** | Force-directed graph | Interactive character relationship network | | **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` | @@ -549,7 +549,7 @@ WorldScript-Studio/ │ ├── sw.js # PWA Service Worker │ └── manifest.json # PWA Web App Manifest v3 ├── tests/ -│ ├── unit/ # Vitest unit tests (7357+ tests, 595 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7389+ tests, 597 files) — count spans tests/, components/, packages/*/tests/, not just this folder │ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths │ │ └── settings/ # WebLlmPanel, AiSections │ └── e2e/ # Playwright specs + helpers.ts @@ -710,10 +710,10 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt | `deploy` | `main` only | GitHub Pages after **`ci-success`** succeeds | | `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning | -**Current test metrics (2026-08-30, source-synchronized; CI remains authoritative for pass/fail):** -- **7357+ unit tests** across **595 test files** — CI is authoritative for pass/fail +**Current test metrics (2026-09-02, source-synchronized; CI remains authoritative for pass/fail):** +- **7389+ unit tests** across **597 test files** — CI is authoritative for pass/fail - Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics) -- i18n: **2937 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta) +- i18n: **2938 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta) **CI-cloud-first workflow (recommended):** On constrained hardware run **`pnpm run lint && pnpm run i18n:check && pnpm run typecheck`** locally, then push and let CI handle coverage, E2E, Lighthouse, and Stryker. Authoritative numbers come from CI artifacts (Codecov, JUnit). After CI goes green, update the README badges and `AUDIT.md` quality-gate line from the reported metrics. See **[`docs/CI.md`](docs/CI.md) § Cloud CI-first vs local development** for the full post-merge doc-update checklist. diff --git a/app/listenerMiddleware.ts b/app/listenerMiddleware.ts index fc76fb08a..7671cf4ee 100644 --- a/app/listenerMiddleware.ts +++ b/app/listenerMiddleware.ts @@ -21,8 +21,11 @@ import { saveEnvelopeFromProjectData } from '../services/storageBackend'; import { storageService } from '../services/storageService'; import type { Character, StorySection, World } from '../types'; import { isAnalyticsPersistenceAllowed } from './analyticsGate'; +import { + projectPersistenceCoordinator, + settingsPersistenceCoordinator, +} from './persistenceCoordinator'; import type { AppDispatch, RootState } from './store'; -import { projectPersistenceCoordinator, settingsPersistenceCoordinator } from './persistenceCoordinator'; import { appStoreRef } from './storeRef'; import { useTransientUiStore } from './transientUiStore'; @@ -688,26 +691,38 @@ function withLocalFirstLock(fn: () => Promise): Promise { return run; } +// QNBS-v3: extracted so getLocalFirstHandle's own body doesn't absorb this classification/teardown complexity (CodeScene hotspot). Mutates the module-level localFirstHandle directly; returns the still-valid handle to reuse, or null once any stale/mismatched handle has been torn down and cleared. +async function reconcileLocalFirstHandle( + projectId: string, + isIdbEncryptionReady: () => boolean, + noopPersistence: LocalFirstHandle['persistence'], +): Promise { + if (!localFirstHandle) return null; + if (localFirstHandle.projectId !== projectId) { + // Project switched — tear down the previous handle before creating a new one. + await localFirstHandle.persistence.destroy().catch(() => undefined); + localFirstHandle = null; + return null; + } + // QNBS-v3 (CodeAnt): the persistence backend is chosen at handle creation — if at-rest encryption became active after a plaintext-persisting handle was made, tear it down (wiping the plaintext already written) so no further plaintext is persisted. + if (isIdbEncryptionReady() && localFirstHandle.persistence.active) { + await localFirstHandle.persistence.clearData().catch(() => undefined); + await localFirstHandle.persistence.destroy().catch(() => undefined); + localFirstHandle = null; + return null; + } + // QNBS-v3: a dead reference, not an intentional NOOP — recreate rather than return a handle writes would silently go nowhere through. + if (localFirstHandle.persistence !== noopPersistence && !localFirstHandle.persistence.active) { + localFirstHandle = null; + return null; + } + return localFirstHandle; +} + function getLocalFirstHandle(project: ProjectData): Promise { return withLocalFirstLock(async () => { const projectId = project.id ?? 'default'; const { isIdbEncryptionReady } = await import('../services/storage/storageEncryptionService'); - if (localFirstHandle?.projectId === projectId) { - // QNBS-v3 (CodeAnt): the persistence backend (NOOP vs y-indexeddb) is chosen at handle - // creation. If at-rest encryption became active AFTER a plaintext-persisting handle was made, - // tear it down — wiping the plaintext already written — so no further plaintext is persisted. - if (isIdbEncryptionReady() && localFirstHandle.persistence.active) { - await localFirstHandle.persistence.clearData().catch(() => undefined); - await localFirstHandle.persistence.destroy().catch(() => undefined); - localFirstHandle = null; - } else { - return localFirstHandle; - } - } else if (localFirstHandle) { - // Project switched — tear down the previous handle before creating a new one. - await localFirstHandle.persistence.destroy().catch(() => undefined); - localFirstHandle = null; - } const [ { createBlankProjectDoc }, { ProjectDocBinding }, @@ -717,6 +732,12 @@ function getLocalFirstHandle(project: ProjectData): Promise { import('../services/localFirst/docBinding'), import('../services/localFirst/docPersistence'), ]); + const reused = await reconcileLocalFirstHandle( + projectId, + isIdbEncryptionReady, + NOOP_PERSISTENCE, + ); + if (reused) return reused; const doc = createBlankProjectDoc(); // QNBS-v3 (CodeAnt): never write a PLAINTEXT shadow copy to y-indexeddb when at-rest encryption // is active — the local-first doc is not encrypted yet. Keep it in-memory only so the privacy @@ -821,4 +842,4 @@ export async function initLocalFirstSyncOnStartup(enabled: boolean): Promise; \ No newline at end of file +>; diff --git a/components/SettingsView.tsx b/components/SettingsView.tsx index d294e9b12..9ed262bf2 100644 --- a/components/SettingsView.tsx +++ b/components/SettingsView.tsx @@ -38,14 +38,17 @@ import { ViewErrorBoundary } from './ui/ViewErrorBoundary'; // --- SUB-COMPONENTS --- +// QNBS-v3: stable data-testid lets E2E recovery navigation target a category without matching translated label text const NavButton: FC<{ + id: string; icon: React.ReactNode; label: string; isActive: boolean; onClick: () => void; -}> = React.memo(({ icon, label, isActive, onClick }) => ( +}> = React.memo(({ id, icon, label, isActive, onClick }) => ( { filteredNavCategories.map((cat) => ( { .map((cat) => ( { {groupCats.map((cat) => ( void; sectionId?: string; -}> = React.memo(({ icon, label, isActive, onClick, sectionId }) => { + /** Spotlight tour / stable E2E anchor (`data-tour`) */ + dataTour?: string; +}> = React.memo(({ icon, label, isActive, onClick, sectionId, dataTour }) => { // QNBS-v3: colored icon dot for mobile tab bar via section SSOT const sectionConfig = sectionId ? APP_SECTIONS[sectionId as keyof typeof APP_SECTIONS] : null; const iconColor = sectionConfig && !isActive ? sectionConfig.textColor : ''; @@ -86,6 +88,7 @@ const BottomTabItem: React.FC<{ = ({ label={t('common.more')} isActive={isSidebarOpen || !isTabBarView} onClick={() => setIsSidebarOpen(!isSidebarOpen)} + dataTour="nav-more" /> @@ -299,4 +303,4 @@ export const Sidebar: React.FC = ({ > ); -}; \ No newline at end of file +}; diff --git a/components/settings/DataSection.tsx b/components/settings/DataSection.tsx index e9cfa3e7f..c764666be 100644 --- a/components/settings/DataSection.tsx +++ b/components/settings/DataSection.tsx @@ -421,6 +421,7 @@ export const DataSection: FC = () => { setModal({ state: 'factoryReset', payload: {} })} className="shrink-0" > diff --git a/components/settings/FactoryResetDangerZone.tsx b/components/settings/FactoryResetDangerZone.tsx index 264e58f64..a2793bdf9 100644 --- a/components/settings/FactoryResetDangerZone.tsx +++ b/components/settings/FactoryResetDangerZone.tsx @@ -26,7 +26,14 @@ export const FactoryResetDangerZone: FC = ({ {t('settings.data.dangerZone.factoryReset.modalDescription')} - + {/* QNBS-v3: stable data-testid lets E2E recovery navigation target this button without matching translated label text */} + {t('settings.data.dangerZone.factoryReset.button')} > diff --git a/components/settings/SettingsModals.tsx b/components/settings/SettingsModals.tsx index 932c6cc2c..40b9b8794 100644 --- a/components/settings/SettingsModals.tsx +++ b/components/settings/SettingsModals.tsx @@ -138,7 +138,12 @@ export const SettingsModals: FC = () => { setModal({ state: 'closed', payload: {} })}> {t('common.cancel')} - void handleFactoryReset()}> + {/* QNBS-v3: stable data-testid lets E2E recovery navigation target this button without matching translated label text */} + void handleFactoryReset()} + data-testid="factory-reset-confirm-button" + > {t('settings.data.dangerZone.factoryReset.modalConfirm')} diff --git a/hooks/useFactoryReset.ts b/hooks/useFactoryReset.ts index 7307c5754..584117aa7 100644 --- a/hooks/useFactoryReset.ts +++ b/hooks/useFactoryReset.ts @@ -20,7 +20,8 @@ export function useFactoryReset({ t, setBusy, setError }: Options): () => Promis try { await wipeAllAppData(); } catch (err) { - setError(t('settings.privacy.encryptionRecoveryFailed')); + // QNBS-v3: a failed factory reset can leave partial cleanup behind — never reuse encryptionRecoveryFailed's "your data has not been lost" claim here. + setError(t('settings.data.dangerZone.factoryReset.failed')); logger.error('Factory reset failed', { error: err instanceof Error ? err.message : String(err), }); diff --git a/hooks/useSettingsView.ts b/hooks/useSettingsView.ts index c04e0fad4..ee86c36c7 100644 --- a/hooks/useSettingsView.ts +++ b/hooks/useSettingsView.ts @@ -54,6 +54,21 @@ import type { type ModalState = 'closed' | 'reset' | 'restore' | 'delete' | 'create' | 'factoryReset'; type ModalPayload = { id?: number; name?: string; date?: string; wordCount?: number }; +// QNBS-v3: the whole try/catch lives here, not in useSettingsView's own body, so this branch's complexity is never attributed to that already-flagged hotspot (CodeScene). +async function performFactoryReset( + t: (key: string) => string, + toast: ReturnType, +): Promise { + try { + await wipeAllAppData(); + } catch (error) { + logger.error('Factory reset failed', { + error: error instanceof Error ? error.message : String(error), + }); + toast.error(t('settings.data.dangerZone.factoryReset.failed')); + } +} + export const useSettingsView = () => { const { t, language, setLanguage } = useTranslation(); const dispatch = useAppDispatch(); @@ -349,9 +364,8 @@ export const useSettingsView = () => { const handleFactoryReset = useCallback(async () => { setModal({ state: 'closed', payload: {} }); - // QNBS-v3: wipes all IDB databases, localStorage, SW caches, then reloads. - await wipeAllAppData(); - }, []); + await performFactoryReset(t, toast); + }, [t, toast]); const handleRepeatOnboarding = useCallback(() => { // QNBS-v3: useApp.ts listens for this event and re-opens the WelcomePortal. diff --git a/locales/ar/settings.json b/locales/ar/settings.json index bce90db81..689fae7bf 100644 --- a/locales/ar/settings.json +++ b/locales/ar/settings.json @@ -414,6 +414,7 @@ "settings.data.createSnapshot": "إنشاء لقطة", "settings.data.dangerZone.description": "هذه الإجراءات لا رجعة فيها. تابع بحذر.", "settings.data.dangerZone.factoryReset.button": "إعادة ضبط المصنع", + "settings.data.dangerZone.factoryReset.failed": "لم تكتمل إعادة ضبط المصنع — قد يكون التطبيق الآن في حالة إعادة ضبط جزئية. أعد تشغيل التطبيق للتحقق، ثم أعد محاولة إعادة الضبط.", "settings.data.dangerZone.factoryReset.hint": "يحذف نهائيًا جميع المشاريع والإعدادات ومفاتيح API والبيانات المحلية. سيُعاد تشغيل التطبيق كتثبيت جديد.", "settings.data.dangerZone.factoryReset.label": "إعادة ضبط جميع بيانات التطبيق", "settings.data.dangerZone.factoryReset.modalConfirm": "حذف كل شيء وإعادة التشغيل", diff --git a/locales/ar/sidebar.json b/locales/ar/sidebar.json index e21612db7..0d50111cb 100644 --- a/locales/ar/sidebar.json +++ b/locales/ar/sidebar.json @@ -23,4 +23,4 @@ "sidebar.world": "بناء العالم", "sidebar.writer": "استوديو الكتابة بالذكاء الاصطناعي", "sidebar.scenario": "السيناريو / السيناريو السينمائي" -} \ No newline at end of file +} diff --git a/locales/de/settings.json b/locales/de/settings.json index bb435548d..d4ab5cc75 100644 --- a/locales/de/settings.json +++ b/locales/de/settings.json @@ -414,6 +414,7 @@ "settings.data.createSnapshot": "Schnappschuss erstellen", "settings.data.dangerZone.description": "Diese Aktionen sind unwiderruflich. Vorsicht!", "settings.data.dangerZone.factoryReset.button": "Werkseinstellungen", + "settings.data.dangerZone.factoryReset.failed": "Der Werksreset wurde nicht abgeschlossen – die App befindet sich möglicherweise in einem teilweise zurückgesetzten Zustand. Starten Sie die App neu, um dies zu überprüfen, und versuchen Sie den Reset erneut.", "settings.data.dangerZone.factoryReset.hint": "Löscht alle Projekte, Einstellungen, API-Schlüssel und lokalen Daten dauerhaft. Die App startet neu wie bei einer Erstinstallation.", "settings.data.dangerZone.factoryReset.label": "Alle App-Daten zurücksetzen", "settings.data.dangerZone.factoryReset.modalConfirm": "Alles löschen & neu starten", diff --git a/locales/de/sidebar.json b/locales/de/sidebar.json index 9dffefc26..e277c88f2 100644 --- a/locales/de/sidebar.json +++ b/locales/de/sidebar.json @@ -23,4 +23,4 @@ "sidebar.world": "Weltenbau", "sidebar.writer": "KI-Schreibstudio", "sidebar.scenario": "Szenario / Drehbuch" -} \ No newline at end of file +} diff --git a/locales/el/settings.json b/locales/el/settings.json index 426250242..db9777727 100644 --- a/locales/el/settings.json +++ b/locales/el/settings.json @@ -414,6 +414,7 @@ "settings.data.createSnapshot": "Δημιουργία Snapshot", "settings.data.dangerZone.description": "Αυτές οι ενέργειες είναι μη αναστρέψιμες. Προχωρήστε με προσοχή.", "settings.data.dangerZone.factoryReset.button": "Επαναφορά", + "settings.data.dangerZone.factoryReset.failed": "Η επαναφορά εργοστασιακών ρυθμίσεων δεν ολοκληρώθηκε — η εφαρμογή ενδέχεται να βρίσκεται τώρα σε κατάσταση μερικής επαναφοράς. Επανεκκινήστε την εφαρμογή για έλεγχο και δοκιμάστε ξανά την επαναφορά.", "settings.data.dangerZone.factoryReset.hint": "Διαγράφει οριστικά όλα τα έργα, τις ρυθμίσεις, τα κλειδιά API και τα τοπικά δεδομένα. Η εφαρμογή θα επανεκκινηθεί ως νέα εγκατάσταση.", "settings.data.dangerZone.factoryReset.label": "Επαναφορά όλων των δεδομένων εφαρμογής", "settings.data.dangerZone.factoryReset.modalConfirm": "Διαγραφή everything & restart", diff --git a/locales/en/settings.json b/locales/en/settings.json index 83e8d9331..a5968d4c7 100644 --- a/locales/en/settings.json +++ b/locales/en/settings.json @@ -414,6 +414,7 @@ "settings.data.createSnapshot": "Create Snapshot", "settings.data.dangerZone.description": "These actions are irreversible. Proceed with caution.", "settings.data.dangerZone.factoryReset.button": "Factory Reset", + "settings.data.dangerZone.factoryReset.failed": "Factory reset did not complete — the app may now be in a partially reset state. Restart the app to check, then try the reset again.", "settings.data.dangerZone.factoryReset.hint": "Permanently deletes all projects, settings, API keys, and local data. The app will restart as a fresh install.", "settings.data.dangerZone.factoryReset.label": "Reset all app data", "settings.data.dangerZone.factoryReset.modalConfirm": "Delete everything & restart", diff --git a/locales/es/settings.json b/locales/es/settings.json index f1e191844..4ed1a3607 100644 --- a/locales/es/settings.json +++ b/locales/es/settings.json @@ -414,6 +414,7 @@ "settings.data.createSnapshot": "Crear instantánea", "settings.data.dangerZone.description": "Estas acciones son irreversibles. Procede con precaución.", "settings.data.dangerZone.factoryReset.button": "Restablecimiento de fábrica", + "settings.data.dangerZone.factoryReset.failed": "El restablecimiento de fábrica no se completó — la aplicación puede estar en un estado parcialmente restablecido. Reinicia la aplicación para comprobarlo y vuelve a intentar el restablecimiento.", "settings.data.dangerZone.factoryReset.hint": "Elimina permanentemente todos los proyectos, configuraciones, claves API y datos locales. La app se reinicia como instalación nueva.", "settings.data.dangerZone.factoryReset.label": "Restablecer todos los datos", "settings.data.dangerZone.factoryReset.modalConfirm": "Eliminar todo y reiniciar", diff --git a/locales/es/sidebar.json b/locales/es/sidebar.json index 0d9a0b9e7..e6a82122e 100644 --- a/locales/es/sidebar.json +++ b/locales/es/sidebar.json @@ -23,4 +23,4 @@ "sidebar.world": "Mundo", "sidebar.writer": "Estudio de escritura IA", "sidebar.scenario": "Escenario / Guion" -} \ No newline at end of file +} diff --git a/locales/eu/settings.json b/locales/eu/settings.json index 203b31b3f..c9563f4bd 100644 --- a/locales/eu/settings.json +++ b/locales/eu/settings.json @@ -414,6 +414,7 @@ "settings.data.createSnapshot": "Sortu argazkia", "settings.data.dangerZone.description": "Ekintza hauek atzeraezinak dira. Kontuz ibili.", "settings.data.dangerZone.factoryReset.button": "Fabrika berrezarri", + "settings.data.dangerZone.factoryReset.failed": "Fabrikako berrezarpena ez da amaitu — aplikazioa erdi berrezarritako egoeran egon daiteke orain. Berrabiarazi aplikazioa egiaztatzeko, eta saiatu berrezarpena berriro.", "settings.data.dangerZone.factoryReset.hint": "Proiektu, ezarpen, API gako eta tokiko datu guztiak behin betiko ezabatzen ditu. Aplikazioa instalazio berri gisa berrabiaraziko da.", "settings.data.dangerZone.factoryReset.label": "Berrezarri aplikazioaren datu guztiak", "settings.data.dangerZone.factoryReset.modalConfirm": "Ezabatu dena eta berrabiarazi", diff --git a/locales/eu/sidebar.json b/locales/eu/sidebar.json index 10d591e02..380cf88e8 100644 --- a/locales/eu/sidebar.json +++ b/locales/eu/sidebar.json @@ -23,4 +23,4 @@ "sidebar.world": "Mundu-eraikuntza", "sidebar.writer": "AI idazketa-estudioa", "sidebar.scenario": "Eszenatokia / Gidoia" -} \ No newline at end of file +} diff --git a/locales/fa/settings.json b/locales/fa/settings.json index a5fc988ba..d2575e38c 100644 --- a/locales/fa/settings.json +++ b/locales/fa/settings.json @@ -414,6 +414,7 @@ "settings.data.createSnapshot": "ایجاد عکس فوری", "settings.data.dangerZone.description": "این اقدامات برگشت ناپذیر است. با احتیاط ادامه دهید", "settings.data.dangerZone.factoryReset.button": "تنظیم مجدد کارخانه", + "settings.data.dangerZone.factoryReset.failed": "تنظیم مجدد کارخانه کامل نشد — ممکن است برنامه اکنون در وضعیت بازنشانی جزئی باشد. برنامه را دوباره راهاندازی کنید تا بررسی شود، سپس بازنشانی را دوباره امتحان کنید.", "settings.data.dangerZone.factoryReset.hint": "تمام پروژه ها، تنظیمات، کلیدهای API و داده های محلی را برای همیشه حذف می کند. برنامه به عنوان یک نصب تازه راه اندازی مجدد می شود.", "settings.data.dangerZone.factoryReset.label": "تمام داده های برنامه را بازنشانی کنید", "settings.data.dangerZone.factoryReset.modalConfirm": "همه چیز را پاک کنید و دوباره راه اندازی کنید", diff --git a/locales/fa/sidebar.json b/locales/fa/sidebar.json index 1e0ddc6f2..d13fa817f 100644 --- a/locales/fa/sidebar.json +++ b/locales/fa/sidebar.json @@ -23,4 +23,4 @@ "sidebar.world": "جهانسازی", "sidebar.writer": "استودیوی نویسندگی هوش مصنوعی", "sidebar.scenario": "سناریو / فیلمنامه" -} \ No newline at end of file +} diff --git a/locales/fi/settings.json b/locales/fi/settings.json index 8a3adef02..d77ebe443 100644 --- a/locales/fi/settings.json +++ b/locales/fi/settings.json @@ -414,6 +414,7 @@ "settings.data.createSnapshot": "Luo tilannekuva", "settings.data.dangerZone.description": "Nämä toimet ovat peruuttamattomia. Jatka varovasti.", "settings.data.dangerZone.factoryReset.button": "Tehdasasetusten palautus", + "settings.data.dangerZone.factoryReset.failed": "Tehdasasetusten palautus ei valmistunut — sovellus saattaa nyt olla osittain palautetussa tilassa. Käynnistä sovellus uudelleen tarkistaaksesi tilanteen ja yritä palautusta sitten uudelleen.", "settings.data.dangerZone.factoryReset.hint": "Poistaa pysyvästi kaikki projektit, asetukset, API-avaimet ja paikalliset tiedot. Sovellus käynnistyy uudelleen uutena asennuksena.", "settings.data.dangerZone.factoryReset.label": "Nollaa kaikki sovellustiedot", "settings.data.dangerZone.factoryReset.modalConfirm": "Poista kaikki ja käynnistä uudelleen", diff --git a/locales/fi/sidebar.json b/locales/fi/sidebar.json index 0fc4fedf1..a7ec4d830 100644 --- a/locales/fi/sidebar.json +++ b/locales/fi/sidebar.json @@ -23,4 +23,4 @@ "sidebar.world": "Maailmanrakennus", "sidebar.writer": "AI-kirjoitusstudio", "sidebar.scenario": "Skenaario / Käsikirjoitus" -} \ No newline at end of file +} diff --git a/locales/fr/settings.json b/locales/fr/settings.json index 551eaf044..ce6aea0f2 100644 --- a/locales/fr/settings.json +++ b/locales/fr/settings.json @@ -414,6 +414,7 @@ "settings.data.createSnapshot": "Créer un instantané", "settings.data.dangerZone.description": "Ces actions sont irréversibles. Procédez avec précaution.", "settings.data.dangerZone.factoryReset.button": "Réinitialisation totale", + "settings.data.dangerZone.factoryReset.failed": "La réinitialisation d'usine ne s'est pas terminée — l'application peut être dans un état partiellement réinitialisé. Redémarrez l'application pour vérifier, puis réessayez la réinitialisation.", "settings.data.dangerZone.factoryReset.hint": "Supprime définitivement tous les projets, paramètres, clés API et données locales. L'application redémarre comme une installation vierge.", "settings.data.dangerZone.factoryReset.label": "Réinitialiser toutes les données", "settings.data.dangerZone.factoryReset.modalConfirm": "Tout supprimer et redémarrer", diff --git a/locales/fr/sidebar.json b/locales/fr/sidebar.json index ad0e69f2d..4d0064908 100644 --- a/locales/fr/sidebar.json +++ b/locales/fr/sidebar.json @@ -23,4 +23,4 @@ "sidebar.world": "Univers", "sidebar.writer": "Studio d’écriture IA", "sidebar.scenario": "Scénario / Scénarisation" -} \ No newline at end of file +} diff --git a/locales/he/settings.json b/locales/he/settings.json index acd181d34..c35b94081 100644 --- a/locales/he/settings.json +++ b/locales/he/settings.json @@ -414,6 +414,7 @@ "settings.data.createSnapshot": "יצירת תמונת מצב", "settings.data.dangerZone.description": "פעולות אלה בלתי הפיכות. המשיכו בזהירות.", "settings.data.dangerZone.factoryReset.button": "איפוס להגדרות יצרן", + "settings.data.dangerZone.factoryReset.failed": "איפוס להגדרות יצרן לא הושלם — ייתכן שהאפליקציה נמצאת כעת במצב איפוס חלקי. הפעל מחדש את האפליקציה כדי לבדוק, ולאחר מכן נסה שוב את האיפוס.", "settings.data.dangerZone.factoryReset.hint": "מוחק לצמיתות את כל הפרויקטים, ההגדרות, מפתחות ה‑API והנתונים המקומיים. האפליקציה תופעל מחדש כהתקנה חדשה.", "settings.data.dangerZone.factoryReset.label": "איפוס כל נתוני האפליקציה", "settings.data.dangerZone.factoryReset.modalConfirm": "מחיקת הכול והפעלה מחדש", diff --git a/locales/he/sidebar.json b/locales/he/sidebar.json index 2035151a4..af1228227 100644 --- a/locales/he/sidebar.json +++ b/locales/he/sidebar.json @@ -23,4 +23,4 @@ "sidebar.world": "בניית עולם", "sidebar.writer": "סטודיו כתיבה עם AI", "sidebar.scenario": "תרחיש / תסריט" -} \ No newline at end of file +} diff --git a/locales/hu/settings.json b/locales/hu/settings.json index 6450e6e42..55817d87f 100644 --- a/locales/hu/settings.json +++ b/locales/hu/settings.json @@ -414,6 +414,7 @@ "settings.data.createSnapshot": "Pillanatkép létrehozása", "settings.data.dangerZone.description": "Ezek a műveletek visszafordíthatatlanok. Óvatosan járjon el.", "settings.data.dangerZone.factoryReset.button": "Gyári visszaállítás", + "settings.data.dangerZone.factoryReset.failed": "A gyári visszaállítás nem fejeződött be — előfordulhat, hogy az alkalmazás most részlegesen visszaállított állapotban van. Indítsa újra az alkalmazást az ellenőrzéshez, majd próbálja meg újra a visszaállítást.", "settings.data.dangerZone.factoryReset.hint": "Véglegesen törli az összes projektet, beállítást, API-kulcsot és helyi adatot. Az alkalmazás újraindul új telepítésként.", "settings.data.dangerZone.factoryReset.label": "Állítsa vissza az összes alkalmazásadatot", "settings.data.dangerZone.factoryReset.modalConfirm": "Töröljön mindent és indítsa újra", diff --git a/locales/hu/sidebar.json b/locales/hu/sidebar.json index 93f0643e2..67bf2f52f 100644 --- a/locales/hu/sidebar.json +++ b/locales/hu/sidebar.json @@ -23,4 +23,4 @@ "sidebar.world": "Világépítés", "sidebar.writer": "AI-íróstúdió", "sidebar.scenario": "Forgatókönyv / filmforgatókönyv" -} \ No newline at end of file +} diff --git a/locales/is/settings.json b/locales/is/settings.json index 2fc65680a..bdb08704f 100644 --- a/locales/is/settings.json +++ b/locales/is/settings.json @@ -414,6 +414,7 @@ "settings.data.createSnapshot": "Búðu til skyndimynd", "settings.data.dangerZone.description": "Þessar aðgerðir eru óafturkræfar. Haltu áfram með varúð.", "settings.data.dangerZone.factoryReset.button": "Factory Reset", + "settings.data.dangerZone.factoryReset.failed": "Verksmiðjuendurstilling tókst ekki að fullu — forritið gæti nú verið í hálfendurstilltu ástandi. Endurræstu forritið til að athuga stöðuna og reyndu síðan endurstillinguna aftur.", "settings.data.dangerZone.factoryReset.hint": "Eyðir varanlega öllum verkefnum, stillingum, API lyklum og staðbundnum gögnum. Forritið mun endurræsa sem ný uppsetning.", "settings.data.dangerZone.factoryReset.label": "Endurstilla öll forritsgögn", "settings.data.dangerZone.factoryReset.modalConfirm": "Eyddu öllu og endurræstu", diff --git a/locales/is/sidebar.json b/locales/is/sidebar.json index e32391350..c11a1974f 100644 --- a/locales/is/sidebar.json +++ b/locales/is/sidebar.json @@ -23,4 +23,4 @@ "sidebar.world": "Heimasmíði", "sidebar.writer": "AI-ritunarstofa", "sidebar.scenario": "Sviðsmynd / handrit" -} \ No newline at end of file +} diff --git a/locales/it/settings.json b/locales/it/settings.json index d8bc5125d..7168c9f10 100644 --- a/locales/it/settings.json +++ b/locales/it/settings.json @@ -414,6 +414,7 @@ "settings.data.createSnapshot": "Crea istantanea", "settings.data.dangerZone.description": "Queste azioni sono irreversibili. Procedi con cautela.", "settings.data.dangerZone.factoryReset.button": "Ripristino di fabbrica", + "settings.data.dangerZone.factoryReset.failed": "Il ripristino delle impostazioni di fabbrica non è stato completato — l'app potrebbe trovarsi in uno stato parzialmente ripristinato. Riavvia l'app per verificare, quindi riprova il ripristino.", "settings.data.dangerZone.factoryReset.hint": "Elimina definitivamente tutti i progetti, le impostazioni, le chiavi API e i dati locali. L'app si riavvia come nuova installazione.", "settings.data.dangerZone.factoryReset.label": "Ripristina tutti i dati", "settings.data.dangerZone.factoryReset.modalConfirm": "Elimina tutto e riavvia", diff --git a/locales/it/sidebar.json b/locales/it/sidebar.json index 1a1896b7c..47f893655 100644 --- a/locales/it/sidebar.json +++ b/locales/it/sidebar.json @@ -23,4 +23,4 @@ "sidebar.world": "Mondo", "sidebar.writer": "Studio di scrittura IA", "sidebar.scenario": "Scenario / sceneggiatura" -} \ No newline at end of file +} diff --git a/locales/ja/settings.json b/locales/ja/settings.json index 165c8b495..26d2e63f2 100644 --- a/locales/ja/settings.json +++ b/locales/ja/settings.json @@ -414,6 +414,7 @@ "settings.data.createSnapshot": "作成 Snapshot", "settings.data.dangerZone.description": "これらの操作は元に戻すことができません。慎重に作業を進めてください。", "settings.data.dangerZone.factoryReset.button": "工場出荷時設定にリセット", + "settings.data.dangerZone.factoryReset.failed": "工場出荷時リセットが完了しませんでした — アプリが部分的にリセットされた状態になっている可能性があります。アプリを再起動して確認し、リセットをもう一度お試しください。", "settings.data.dangerZone.factoryReset.hint": "すべてのプロジェクト、設定、API キー、ローカル データを完全に削除します。アプリは新規インストールとして再起動されます。", "settings.data.dangerZone.factoryReset.label": "すべてのアプリデータをリセット", "settings.data.dangerZone.factoryReset.modalConfirm": "削除 everything & restart", diff --git a/locales/ja/sidebar.json b/locales/ja/sidebar.json index 83be5e77f..fbbfe23e0 100644 --- a/locales/ja/sidebar.json +++ b/locales/ja/sidebar.json @@ -23,4 +23,4 @@ "sidebar.world": "世界の建物", "sidebar.writer": "AIライティングスタジオ", "sidebar.scenario": "シナリオ / 脚本" -} \ No newline at end of file +} diff --git a/locales/ko/settings.json b/locales/ko/settings.json index 9243ef3ce..a156ba552 100644 --- a/locales/ko/settings.json +++ b/locales/ko/settings.json @@ -414,6 +414,7 @@ "settings.data.createSnapshot": "스냅샷 생성", "settings.data.dangerZone.description": "이러한 작업은 되돌릴 수 없습니다. 주의해서 진행하세요.", "settings.data.dangerZone.factoryReset.button": "공장 초기화", + "settings.data.dangerZone.factoryReset.failed": "공장 초기화가 완료되지 않았습니다 — 앱이 현재 부분적으로 초기화된 상태일 수 있습니다. 앱을 다시 시작하여 상태를 확인한 후 초기화를 다시 시도하세요.", "settings.data.dangerZone.factoryReset.hint": "모든 프로젝트, 설정, API 키, 로컬 데이터를 영구적으로 삭제합니다. 앱이 새로 설치되어 다시 시작됩니다.", "settings.data.dangerZone.factoryReset.label": "모든 앱 데이터 재설정", "settings.data.dangerZone.factoryReset.modalConfirm": "모두 삭제하고 다시 시작하세요", diff --git a/locales/ko/sidebar.json b/locales/ko/sidebar.json index f07720b34..9c5a2d406 100644 --- a/locales/ko/sidebar.json +++ b/locales/ko/sidebar.json @@ -23,4 +23,4 @@ "sidebar.world": "월드 빌딩", "sidebar.writer": "AI 글쓰기 스튜디오", "sidebar.scenario": "시나리오 / 각본" -} \ No newline at end of file +} diff --git a/locales/pt/settings.json b/locales/pt/settings.json index ef2d9934f..26dad793f 100644 --- a/locales/pt/settings.json +++ b/locales/pt/settings.json @@ -414,6 +414,7 @@ "settings.data.createSnapshot": "Criar Snapshot", "settings.data.dangerZone.description": "Essas ações são irreversíveis. Proceda com cautela.", "settings.data.dangerZone.factoryReset.button": "Redefinição de fábrica", + "settings.data.dangerZone.factoryReset.failed": "A redefinição de fábrica não foi concluída — o aplicativo pode estar agora em um estado parcialmente redefinido. Reinicie o aplicativo para verificar e tente a redefinição novamente.", "settings.data.dangerZone.factoryReset.hint": "Exclui permanentemente todos os projetos, configurações, chaves de API e dados locais. O aplicativo será reiniciado como uma nova instalação.", "settings.data.dangerZone.factoryReset.label": "Redefinir todos os dados do aplicativo", "settings.data.dangerZone.factoryReset.modalConfirm": "Excluir everything & restart", diff --git a/locales/pt/sidebar.json b/locales/pt/sidebar.json index 80f3865d8..bccffef9f 100644 --- a/locales/pt/sidebar.json +++ b/locales/pt/sidebar.json @@ -23,4 +23,4 @@ "sidebar.world": "Construção Mundial", "sidebar.writer": "Estúdio de redação de IA", "sidebar.scenario": "Cenário / Roteiro" -} \ No newline at end of file +} diff --git a/locales/ru/settings.json b/locales/ru/settings.json index 3b678e35e..06eb5eeab 100644 --- a/locales/ru/settings.json +++ b/locales/ru/settings.json @@ -414,6 +414,7 @@ "settings.data.createSnapshot": "Создать снимок", "settings.data.dangerZone.description": "Эти действия необратимы. Действуйте осторожно.", "settings.data.dangerZone.factoryReset.button": "Сброс к заводским настройкам", + "settings.data.dangerZone.factoryReset.failed": "Сброс к заводским настройкам не завершился — приложение может сейчас находиться в частично сброшенном состоянии. Перезапустите приложение, чтобы проверить, а затем повторите попытку сброса.", "settings.data.dangerZone.factoryReset.hint": "Безвозвратно удаляет все проекты, настройки, ключи API и локальные данные. Приложение будет перезапущено как новая установка.", "settings.data.dangerZone.factoryReset.label": "Сбросить все данные приложения", "settings.data.dangerZone.factoryReset.modalConfirm": "Удалить все и перезапустить", diff --git a/locales/ru/sidebar.json b/locales/ru/sidebar.json index 0042fe03b..632d41717 100644 --- a/locales/ru/sidebar.json +++ b/locales/ru/sidebar.json @@ -23,4 +23,4 @@ "sidebar.world": "Мировое строительство", "sidebar.writer": "Студия письма AI", "sidebar.scenario": "Сценарий / Киносценарий" -} \ No newline at end of file +} diff --git a/locales/sv/settings.json b/locales/sv/settings.json index 40a56d061..58fd99676 100644 --- a/locales/sv/settings.json +++ b/locales/sv/settings.json @@ -414,6 +414,7 @@ "settings.data.createSnapshot": "Skapa ögonblicksbild", "settings.data.dangerZone.description": "Dessa åtgärder är oåterkalleliga. Proceed with caution.", "settings.data.dangerZone.factoryReset.button": "Fabriksåterställning", + "settings.data.dangerZone.factoryReset.failed": "Fabriksåterställningen slutfördes inte — appen kan nu vara i ett delvis återställt tillstånd. Starta om appen för att kontrollera och försök sedan återställningen igen.", "settings.data.dangerZone.factoryReset.hint": "Tar permanent bort alla projekt, inställningar, API-nycklar och lokal data. The app will restart as a fresh install.", "settings.data.dangerZone.factoryReset.label": "Återställ all appdata", "settings.data.dangerZone.factoryReset.modalConfirm": "Radera allt och starta om", diff --git a/locales/sv/sidebar.json b/locales/sv/sidebar.json index 02c5f2069..0f68bccda 100644 --- a/locales/sv/sidebar.json +++ b/locales/sv/sidebar.json @@ -23,4 +23,4 @@ "sidebar.world": "Världsbygge", "sidebar.writer": "AI-skrivstudio", "sidebar.scenario": "Scenario / manus" -} \ No newline at end of file +} diff --git a/locales/zh/settings.json b/locales/zh/settings.json index 313113cde..7744ae7b3 100644 --- a/locales/zh/settings.json +++ b/locales/zh/settings.json @@ -414,6 +414,7 @@ "settings.data.createSnapshot": "创建 Snapshot", "settings.data.dangerZone.description": "这些行动是不可逆转的。谨慎行事。", "settings.data.dangerZone.factoryReset.button": "恢复出厂设置", + "settings.data.dangerZone.factoryReset.failed": "恢复出厂设置未完成——应用程序现在可能处于部分重置状态。请重新启动应用程序进行检查,然后重新尝试重置。", "settings.data.dangerZone.factoryReset.hint": "永久删除所有项目、设置、API 密钥和本地数据。该应用程序将作为全新安装重新启动。", "settings.data.dangerZone.factoryReset.label": "重置所有应用程序数据", "settings.data.dangerZone.factoryReset.modalConfirm": "删除 everything & restart", diff --git a/locales/zh/sidebar.json b/locales/zh/sidebar.json index 2e1e4d1f2..7d1f0d189 100644 --- a/locales/zh/sidebar.json +++ b/locales/zh/sidebar.json @@ -23,4 +23,4 @@ "sidebar.world": "世界大厦", "sidebar.writer": "人工智能写作工作室", "sidebar.scenario": "场景 / 剧本" -} \ No newline at end of file +} diff --git a/packages/worker-bus/src/deadLetterQueue.ts b/packages/worker-bus/src/deadLetterQueue.ts index cd92fc0a2..37000a10a 100644 --- a/packages/worker-bus/src/deadLetterQueue.ts +++ b/packages/worker-bus/src/deadLetterQueue.ts @@ -2,6 +2,11 @@ // Stores failed tasks for operator inspection. Not a retry queue. import { createLogger } from '../../../services/logger'; +import { + beginIdbOpenAdmission, + isIdbOpenStillValid, + registerIdbConnectionCloser, +} from '../../../services/storage/idbResetGate'; import { DEAD_LETTER_CAPACITY } from './constants'; import type { TaskResult, WorkerTask } from './types'; @@ -75,18 +80,65 @@ export class DeadLetterQueue { } } +let database: IDBDatabase | null = null; +let openPromise: Promise | null = null; + +// QNBS-v3: each call previously opened its own never-closed connection — now cached single-flight so a factory reset has exactly one connection to close instead of none it can reference. +registerIdbConnectionCloser(() => { + database?.close(); + database = null; + // QNBS-v3: without this, a reset-time closer leaves openPromise pointing at the pending (about-to-be-invalidated) flight, so the first post-reset caller reuses it and waits on its eventual generation-mismatch rejection instead of starting a fresh open immediately. + openPromise = null; +}); + function openDlqDb(): Promise { - return new Promise((resolve, reject) => { - const req = indexedDB.open(IDB_DB_NAME, 1); - req.onupgradeneeded = (e) => { - const db = (e.target as IDBOpenDBRequest).result; - if (!db.objectStoreNames.contains(IDB_STORE)) { - db.createObjectStore(IDB_STORE, { autoIncrement: true }); - } - }; - req.onsuccess = (e) => resolve((e.target as IDBOpenDBRequest).result); - req.onerror = (e) => reject((e.target as IDBOpenDBRequest).error); + if (database) return Promise.resolve(database); + if (openPromise) return openPromise; + // QNBS-v3: rejects immediately if a reset is currently draining — the generation check alone can't catch an open that STARTS mid-reset, since it would capture the reset's own already-bumped generation. + const openGeneration = beginIdbOpenAdmission(); + if (openGeneration === null) { + return Promise.reject(new Error('IndexedDB reset in progress')); + } + const thisOpen: Promise = new Promise((resolve, reject) => { + try { + const req = indexedDB.open(IDB_DB_NAME, 1); + req.onupgradeneeded = (e) => { + const db = (e.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(IDB_STORE)) { + db.createObjectStore(IDB_STORE, { autoIncrement: true }); + } + }; + req.onsuccess = (e) => { + const db = (e.target as IDBOpenDBRequest).result; + if (!isIdbOpenStillValid(openGeneration)) { + db.close(); + reject(new Error('IndexedDB reset in progress')); + return; + } + // QNBS-v3: another tab's factory reset (or any other deleteDatabase caller) fires versionchange here — close and invalidate so the next call re-opens fresh instead of blocking that deletion. + db.onversionchange = () => { + db.close(); + database = null; + }; + database = db; + resolve(db); + }; + req.onerror = (e) => { + reject((e.target as IDBOpenDBRequest).error); + }; + } catch (error) { + // QNBS-v3: indexedDB.open() itself can throw synchronously (private/restricted mode) — the .finally() below is what actually clears openPromise; clearing it here would just be overwritten by the unconditional assignment two lines down. + reject(error); + } }); + openPromise = thisOpen; + // QNBS-v3: single ownership-checked cleanup for every settlement (success, async onerror, reset-invalidation reject, AND a synchronous open throw) — .finally()'s callback always runs as a later microtask, so this always sees openPromise already set to thisOpen, even when the promise settled synchronously above. The trailing .catch(() => {}) is only to prevent an unhandled-rejection warning on this DISCARDED derived chain — thisOpen itself is returned separately and its rejection is handled by the actual caller. + thisOpen + .finally(() => { + if (openPromise === thisOpen) openPromise = null; + }) + .catch(() => {}); + return thisOpen; } function storeClear(store: IDBObjectStore): Promise { diff --git a/public/locales/ar/bundle.json b/public/locales/ar/bundle.json index faf17d4b5..91d3dcb63 100644 --- a/public/locales/ar/bundle.json +++ b/public/locales/ar/bundle.json @@ -2074,6 +2074,7 @@ "settings.data.createSnapshot": "إنشاء لقطة", "settings.data.dangerZone.description": "هذه الإجراءات لا رجعة فيها. تابع بحذر.", "settings.data.dangerZone.factoryReset.button": "إعادة ضبط المصنع", + "settings.data.dangerZone.factoryReset.failed": "لم تكتمل إعادة ضبط المصنع — قد يكون التطبيق الآن في حالة إعادة ضبط جزئية. أعد تشغيل التطبيق للتحقق، ثم أعد محاولة إعادة الضبط.", "settings.data.dangerZone.factoryReset.hint": "يحذف نهائيًا جميع المشاريع والإعدادات ومفاتيح API والبيانات المحلية. سيُعاد تشغيل التطبيق كتثبيت جديد.", "settings.data.dangerZone.factoryReset.label": "إعادة ضبط جميع بيانات التطبيق", "settings.data.dangerZone.factoryReset.modalConfirm": "حذف كل شيء وإعادة التشغيل", diff --git a/public/locales/de/bundle.json b/public/locales/de/bundle.json index f8c2e723c..2c9fbb5b7 100644 --- a/public/locales/de/bundle.json +++ b/public/locales/de/bundle.json @@ -2074,6 +2074,7 @@ "settings.data.createSnapshot": "Schnappschuss erstellen", "settings.data.dangerZone.description": "Diese Aktionen sind unwiderruflich. Vorsicht!", "settings.data.dangerZone.factoryReset.button": "Werkseinstellungen", + "settings.data.dangerZone.factoryReset.failed": "Der Werksreset wurde nicht abgeschlossen – die App befindet sich möglicherweise in einem teilweise zurückgesetzten Zustand. Starten Sie die App neu, um dies zu überprüfen, und versuchen Sie den Reset erneut.", "settings.data.dangerZone.factoryReset.hint": "Löscht alle Projekte, Einstellungen, API-Schlüssel und lokalen Daten dauerhaft. Die App startet neu wie bei einer Erstinstallation.", "settings.data.dangerZone.factoryReset.label": "Alle App-Daten zurücksetzen", "settings.data.dangerZone.factoryReset.modalConfirm": "Alles löschen & neu starten", diff --git a/public/locales/el/bundle.json b/public/locales/el/bundle.json index 9fbad20e1..5858a1cad 100644 --- a/public/locales/el/bundle.json +++ b/public/locales/el/bundle.json @@ -2074,6 +2074,7 @@ "settings.data.createSnapshot": "Δημιουργία Snapshot", "settings.data.dangerZone.description": "Αυτές οι ενέργειες είναι μη αναστρέψιμες. Προχωρήστε με προσοχή.", "settings.data.dangerZone.factoryReset.button": "Επαναφορά", + "settings.data.dangerZone.factoryReset.failed": "Η επαναφορά εργοστασιακών ρυθμίσεων δεν ολοκληρώθηκε — η εφαρμογή ενδέχεται να βρίσκεται τώρα σε κατάσταση μερικής επαναφοράς. Επανεκκινήστε την εφαρμογή για έλεγχο και δοκιμάστε ξανά την επαναφορά.", "settings.data.dangerZone.factoryReset.hint": "Διαγράφει οριστικά όλα τα έργα, τις ρυθμίσεις, τα κλειδιά API και τα τοπικά δεδομένα. Η εφαρμογή θα επανεκκινηθεί ως νέα εγκατάσταση.", "settings.data.dangerZone.factoryReset.label": "Επαναφορά όλων των δεδομένων εφαρμογής", "settings.data.dangerZone.factoryReset.modalConfirm": "Διαγραφή everything & restart", diff --git a/public/locales/en/bundle.json b/public/locales/en/bundle.json index abb0be733..b1030f228 100644 --- a/public/locales/en/bundle.json +++ b/public/locales/en/bundle.json @@ -2074,6 +2074,7 @@ "settings.data.createSnapshot": "Create Snapshot", "settings.data.dangerZone.description": "These actions are irreversible. Proceed with caution.", "settings.data.dangerZone.factoryReset.button": "Factory Reset", + "settings.data.dangerZone.factoryReset.failed": "Factory reset did not complete — the app may now be in a partially reset state. Restart the app to check, then try the reset again.", "settings.data.dangerZone.factoryReset.hint": "Permanently deletes all projects, settings, API keys, and local data. The app will restart as a fresh install.", "settings.data.dangerZone.factoryReset.label": "Reset all app data", "settings.data.dangerZone.factoryReset.modalConfirm": "Delete everything & restart", diff --git a/public/locales/es/bundle.json b/public/locales/es/bundle.json index d7f3bba7e..31e0b0550 100644 --- a/public/locales/es/bundle.json +++ b/public/locales/es/bundle.json @@ -2074,6 +2074,7 @@ "settings.data.createSnapshot": "Crear instantánea", "settings.data.dangerZone.description": "Estas acciones son irreversibles. Procede con precaución.", "settings.data.dangerZone.factoryReset.button": "Restablecimiento de fábrica", + "settings.data.dangerZone.factoryReset.failed": "El restablecimiento de fábrica no se completó — la aplicación puede estar en un estado parcialmente restablecido. Reinicia la aplicación para comprobarlo y vuelve a intentar el restablecimiento.", "settings.data.dangerZone.factoryReset.hint": "Elimina permanentemente todos los proyectos, configuraciones, claves API y datos locales. La app se reinicia como instalación nueva.", "settings.data.dangerZone.factoryReset.label": "Restablecer todos los datos", "settings.data.dangerZone.factoryReset.modalConfirm": "Eliminar todo y reiniciar", diff --git a/public/locales/eu/bundle.json b/public/locales/eu/bundle.json index 87d4676e6..fe818f776 100644 --- a/public/locales/eu/bundle.json +++ b/public/locales/eu/bundle.json @@ -2074,6 +2074,7 @@ "settings.data.createSnapshot": "Sortu argazkia", "settings.data.dangerZone.description": "Ekintza hauek atzeraezinak dira. Kontuz ibili.", "settings.data.dangerZone.factoryReset.button": "Fabrika berrezarri", + "settings.data.dangerZone.factoryReset.failed": "Fabrikako berrezarpena ez da amaitu — aplikazioa erdi berrezarritako egoeran egon daiteke orain. Berrabiarazi aplikazioa egiaztatzeko, eta saiatu berrezarpena berriro.", "settings.data.dangerZone.factoryReset.hint": "Proiektu, ezarpen, API gako eta tokiko datu guztiak behin betiko ezabatzen ditu. Aplikazioa instalazio berri gisa berrabiaraziko da.", "settings.data.dangerZone.factoryReset.label": "Berrezarri aplikazioaren datu guztiak", "settings.data.dangerZone.factoryReset.modalConfirm": "Ezabatu dena eta berrabiarazi", diff --git a/public/locales/fa/bundle.json b/public/locales/fa/bundle.json index b5f8d0a11..6815ca0fb 100644 --- a/public/locales/fa/bundle.json +++ b/public/locales/fa/bundle.json @@ -2074,6 +2074,7 @@ "settings.data.createSnapshot": "ایجاد عکس فوری", "settings.data.dangerZone.description": "این اقدامات برگشت ناپذیر است. با احتیاط ادامه دهید", "settings.data.dangerZone.factoryReset.button": "تنظیم مجدد کارخانه", + "settings.data.dangerZone.factoryReset.failed": "تنظیم مجدد کارخانه کامل نشد — ممکن است برنامه اکنون در وضعیت بازنشانی جزئی باشد. برنامه را دوباره راهاندازی کنید تا بررسی شود، سپس بازنشانی را دوباره امتحان کنید.", "settings.data.dangerZone.factoryReset.hint": "تمام پروژه ها، تنظیمات، کلیدهای API و داده های محلی را برای همیشه حذف می کند. برنامه به عنوان یک نصب تازه راه اندازی مجدد می شود.", "settings.data.dangerZone.factoryReset.label": "تمام داده های برنامه را بازنشانی کنید", "settings.data.dangerZone.factoryReset.modalConfirm": "همه چیز را پاک کنید و دوباره راه اندازی کنید", diff --git a/public/locales/fi/bundle.json b/public/locales/fi/bundle.json index de65cd7af..08c4c26b0 100644 --- a/public/locales/fi/bundle.json +++ b/public/locales/fi/bundle.json @@ -2074,6 +2074,7 @@ "settings.data.createSnapshot": "Luo tilannekuva", "settings.data.dangerZone.description": "Nämä toimet ovat peruuttamattomia. Jatka varovasti.", "settings.data.dangerZone.factoryReset.button": "Tehdasasetusten palautus", + "settings.data.dangerZone.factoryReset.failed": "Tehdasasetusten palautus ei valmistunut — sovellus saattaa nyt olla osittain palautetussa tilassa. Käynnistä sovellus uudelleen tarkistaaksesi tilanteen ja yritä palautusta sitten uudelleen.", "settings.data.dangerZone.factoryReset.hint": "Poistaa pysyvästi kaikki projektit, asetukset, API-avaimet ja paikalliset tiedot. Sovellus käynnistyy uudelleen uutena asennuksena.", "settings.data.dangerZone.factoryReset.label": "Nollaa kaikki sovellustiedot", "settings.data.dangerZone.factoryReset.modalConfirm": "Poista kaikki ja käynnistä uudelleen", diff --git a/public/locales/fr/bundle.json b/public/locales/fr/bundle.json index d6f017439..7692136cc 100644 --- a/public/locales/fr/bundle.json +++ b/public/locales/fr/bundle.json @@ -2074,6 +2074,7 @@ "settings.data.createSnapshot": "Créer un instantané", "settings.data.dangerZone.description": "Ces actions sont irréversibles. Procédez avec précaution.", "settings.data.dangerZone.factoryReset.button": "Réinitialisation totale", + "settings.data.dangerZone.factoryReset.failed": "La réinitialisation d'usine ne s'est pas terminée — l'application peut être dans un état partiellement réinitialisé. Redémarrez l'application pour vérifier, puis réessayez la réinitialisation.", "settings.data.dangerZone.factoryReset.hint": "Supprime définitivement tous les projets, paramètres, clés API et données locales. L'application redémarre comme une installation vierge.", "settings.data.dangerZone.factoryReset.label": "Réinitialiser toutes les données", "settings.data.dangerZone.factoryReset.modalConfirm": "Tout supprimer et redémarrer", diff --git a/public/locales/he/bundle.json b/public/locales/he/bundle.json index 7c484db70..4560726ec 100644 --- a/public/locales/he/bundle.json +++ b/public/locales/he/bundle.json @@ -2074,6 +2074,7 @@ "settings.data.createSnapshot": "יצירת תמונת מצב", "settings.data.dangerZone.description": "פעולות אלה בלתי הפיכות. המשיכו בזהירות.", "settings.data.dangerZone.factoryReset.button": "איפוס להגדרות יצרן", + "settings.data.dangerZone.factoryReset.failed": "איפוס להגדרות יצרן לא הושלם — ייתכן שהאפליקציה נמצאת כעת במצב איפוס חלקי. הפעל מחדש את האפליקציה כדי לבדוק, ולאחר מכן נסה שוב את האיפוס.", "settings.data.dangerZone.factoryReset.hint": "מוחק לצמיתות את כל הפרויקטים, ההגדרות, מפתחות ה‑API והנתונים המקומיים. האפליקציה תופעל מחדש כהתקנה חדשה.", "settings.data.dangerZone.factoryReset.label": "איפוס כל נתוני האפליקציה", "settings.data.dangerZone.factoryReset.modalConfirm": "מחיקת הכול והפעלה מחדש", diff --git a/public/locales/hu/bundle.json b/public/locales/hu/bundle.json index 7b6e27317..21594ab6d 100644 --- a/public/locales/hu/bundle.json +++ b/public/locales/hu/bundle.json @@ -2074,6 +2074,7 @@ "settings.data.createSnapshot": "Pillanatkép létrehozása", "settings.data.dangerZone.description": "Ezek a műveletek visszafordíthatatlanok. Óvatosan járjon el.", "settings.data.dangerZone.factoryReset.button": "Gyári visszaállítás", + "settings.data.dangerZone.factoryReset.failed": "A gyári visszaállítás nem fejeződött be — előfordulhat, hogy az alkalmazás most részlegesen visszaállított állapotban van. Indítsa újra az alkalmazást az ellenőrzéshez, majd próbálja meg újra a visszaállítást.", "settings.data.dangerZone.factoryReset.hint": "Véglegesen törli az összes projektet, beállítást, API-kulcsot és helyi adatot. Az alkalmazás újraindul új telepítésként.", "settings.data.dangerZone.factoryReset.label": "Állítsa vissza az összes alkalmazásadatot", "settings.data.dangerZone.factoryReset.modalConfirm": "Töröljön mindent és indítsa újra", diff --git a/public/locales/is/bundle.json b/public/locales/is/bundle.json index d42fc65ae..1b3bdcb79 100644 --- a/public/locales/is/bundle.json +++ b/public/locales/is/bundle.json @@ -2074,6 +2074,7 @@ "settings.data.createSnapshot": "Búðu til skyndimynd", "settings.data.dangerZone.description": "Þessar aðgerðir eru óafturkræfar. Haltu áfram með varúð.", "settings.data.dangerZone.factoryReset.button": "Factory Reset", + "settings.data.dangerZone.factoryReset.failed": "Verksmiðjuendurstilling tókst ekki að fullu — forritið gæti nú verið í hálfendurstilltu ástandi. Endurræstu forritið til að athuga stöðuna og reyndu síðan endurstillinguna aftur.", "settings.data.dangerZone.factoryReset.hint": "Eyðir varanlega öllum verkefnum, stillingum, API lyklum og staðbundnum gögnum. Forritið mun endurræsa sem ný uppsetning.", "settings.data.dangerZone.factoryReset.label": "Endurstilla öll forritsgögn", "settings.data.dangerZone.factoryReset.modalConfirm": "Eyddu öllu og endurræstu", diff --git a/public/locales/it/bundle.json b/public/locales/it/bundle.json index ec6d6fb4f..39707cbdc 100644 --- a/public/locales/it/bundle.json +++ b/public/locales/it/bundle.json @@ -2074,6 +2074,7 @@ "settings.data.createSnapshot": "Crea istantanea", "settings.data.dangerZone.description": "Queste azioni sono irreversibili. Procedi con cautela.", "settings.data.dangerZone.factoryReset.button": "Ripristino di fabbrica", + "settings.data.dangerZone.factoryReset.failed": "Il ripristino delle impostazioni di fabbrica non è stato completato — l'app potrebbe trovarsi in uno stato parzialmente ripristinato. Riavvia l'app per verificare, quindi riprova il ripristino.", "settings.data.dangerZone.factoryReset.hint": "Elimina definitivamente tutti i progetti, le impostazioni, le chiavi API e i dati locali. L'app si riavvia come nuova installazione.", "settings.data.dangerZone.factoryReset.label": "Ripristina tutti i dati", "settings.data.dangerZone.factoryReset.modalConfirm": "Elimina tutto e riavvia", diff --git a/public/locales/ja/bundle.json b/public/locales/ja/bundle.json index 8c78269ab..8b51d8a6a 100644 --- a/public/locales/ja/bundle.json +++ b/public/locales/ja/bundle.json @@ -2074,6 +2074,7 @@ "settings.data.createSnapshot": "作成 Snapshot", "settings.data.dangerZone.description": "これらの操作は元に戻すことができません。慎重に作業を進めてください。", "settings.data.dangerZone.factoryReset.button": "工場出荷時設定にリセット", + "settings.data.dangerZone.factoryReset.failed": "工場出荷時リセットが完了しませんでした — アプリが部分的にリセットされた状態になっている可能性があります。アプリを再起動して確認し、リセットをもう一度お試しください。", "settings.data.dangerZone.factoryReset.hint": "すべてのプロジェクト、設定、API キー、ローカル データを完全に削除します。アプリは新規インストールとして再起動されます。", "settings.data.dangerZone.factoryReset.label": "すべてのアプリデータをリセット", "settings.data.dangerZone.factoryReset.modalConfirm": "削除 everything & restart", diff --git a/public/locales/ko/bundle.json b/public/locales/ko/bundle.json index 4e4ae470d..5e6f518ac 100644 --- a/public/locales/ko/bundle.json +++ b/public/locales/ko/bundle.json @@ -2074,6 +2074,7 @@ "settings.data.createSnapshot": "스냅샷 생성", "settings.data.dangerZone.description": "이러한 작업은 되돌릴 수 없습니다. 주의해서 진행하세요.", "settings.data.dangerZone.factoryReset.button": "공장 초기화", + "settings.data.dangerZone.factoryReset.failed": "공장 초기화가 완료되지 않았습니다 — 앱이 현재 부분적으로 초기화된 상태일 수 있습니다. 앱을 다시 시작하여 상태를 확인한 후 초기화를 다시 시도하세요.", "settings.data.dangerZone.factoryReset.hint": "모든 프로젝트, 설정, API 키, 로컬 데이터를 영구적으로 삭제합니다. 앱이 새로 설치되어 다시 시작됩니다.", "settings.data.dangerZone.factoryReset.label": "모든 앱 데이터 재설정", "settings.data.dangerZone.factoryReset.modalConfirm": "모두 삭제하고 다시 시작하세요", diff --git a/public/locales/pt/bundle.json b/public/locales/pt/bundle.json index 6e84c85bd..5fb9722e0 100644 --- a/public/locales/pt/bundle.json +++ b/public/locales/pt/bundle.json @@ -2074,6 +2074,7 @@ "settings.data.createSnapshot": "Criar Snapshot", "settings.data.dangerZone.description": "Essas ações são irreversíveis. Proceda com cautela.", "settings.data.dangerZone.factoryReset.button": "Redefinição de fábrica", + "settings.data.dangerZone.factoryReset.failed": "A redefinição de fábrica não foi concluída — o aplicativo pode estar agora em um estado parcialmente redefinido. Reinicie o aplicativo para verificar e tente a redefinição novamente.", "settings.data.dangerZone.factoryReset.hint": "Exclui permanentemente todos os projetos, configurações, chaves de API e dados locais. O aplicativo será reiniciado como uma nova instalação.", "settings.data.dangerZone.factoryReset.label": "Redefinir todos os dados do aplicativo", "settings.data.dangerZone.factoryReset.modalConfirm": "Excluir everything & restart", diff --git a/public/locales/ru/bundle.json b/public/locales/ru/bundle.json index 9b9d0ad76..d8805ad86 100644 --- a/public/locales/ru/bundle.json +++ b/public/locales/ru/bundle.json @@ -2074,6 +2074,7 @@ "settings.data.createSnapshot": "Создать снимок", "settings.data.dangerZone.description": "Эти действия необратимы. Действуйте осторожно.", "settings.data.dangerZone.factoryReset.button": "Сброс к заводским настройкам", + "settings.data.dangerZone.factoryReset.failed": "Сброс к заводским настройкам не завершился — приложение может сейчас находиться в частично сброшенном состоянии. Перезапустите приложение, чтобы проверить, а затем повторите попытку сброса.", "settings.data.dangerZone.factoryReset.hint": "Безвозвратно удаляет все проекты, настройки, ключи API и локальные данные. Приложение будет перезапущено как новая установка.", "settings.data.dangerZone.factoryReset.label": "Сбросить все данные приложения", "settings.data.dangerZone.factoryReset.modalConfirm": "Удалить все и перезапустить", diff --git a/public/locales/sv/bundle.json b/public/locales/sv/bundle.json index aed1cfcf4..b1e3665ae 100644 --- a/public/locales/sv/bundle.json +++ b/public/locales/sv/bundle.json @@ -2074,6 +2074,7 @@ "settings.data.createSnapshot": "Skapa ögonblicksbild", "settings.data.dangerZone.description": "Dessa åtgärder är oåterkalleliga. Proceed with caution.", "settings.data.dangerZone.factoryReset.button": "Fabriksåterställning", + "settings.data.dangerZone.factoryReset.failed": "Fabriksåterställningen slutfördes inte — appen kan nu vara i ett delvis återställt tillstånd. Starta om appen för att kontrollera och försök sedan återställningen igen.", "settings.data.dangerZone.factoryReset.hint": "Tar permanent bort alla projekt, inställningar, API-nycklar och lokal data. The app will restart as a fresh install.", "settings.data.dangerZone.factoryReset.label": "Återställ all appdata", "settings.data.dangerZone.factoryReset.modalConfirm": "Radera allt och starta om", diff --git a/public/locales/zh/bundle.json b/public/locales/zh/bundle.json index 43abb3b5a..c4e7ed4b8 100644 --- a/public/locales/zh/bundle.json +++ b/public/locales/zh/bundle.json @@ -2074,6 +2074,7 @@ "settings.data.createSnapshot": "创建 Snapshot", "settings.data.dangerZone.description": "这些行动是不可逆转的。谨慎行事。", "settings.data.dangerZone.factoryReset.button": "恢复出厂设置", + "settings.data.dangerZone.factoryReset.failed": "恢复出厂设置未完成——应用程序现在可能处于部分重置状态。请重新启动应用程序进行检查,然后重新尝试重置。", "settings.data.dangerZone.factoryReset.hint": "永久删除所有项目、设置、API 密钥和本地数据。该应用程序将作为全新安装重新启动。", "settings.data.dangerZone.factoryReset.label": "重置所有应用程序数据", "settings.data.dangerZone.factoryReset.modalConfirm": "删除 everything & restart", diff --git a/services/ai/aiInferenceCacheService.ts b/services/ai/aiInferenceCacheService.ts index 1b3875a09..25515dce3 100644 --- a/services/ai/aiInferenceCacheService.ts +++ b/services/ai/aiInferenceCacheService.ts @@ -1,5 +1,10 @@ // QNBS-v3: Two-layer inference cache keeps hot reads in memory while the durable layer is encrypted. import { logger } from '../logger'; +import { + beginIdbOpenAdmission, + isIdbOpenStillValid, + registerIdbConnectionCloser, +} from '../storage/idbResetGate'; import { withProtectedWriteAdmission } from '../storage/protectedWriteAdmission'; import { assertSecureStorageReadable, @@ -73,10 +78,24 @@ function isCacheEntry(value: unknown): value is CacheEntry | LegacyCacheEntry { export class AiInferenceCacheService { private readonly inMemory = new Map(); private db: IDBDatabase | null = null; - private readonly dbReady: Promise; + private openPromise: Promise | null = null; constructor() { - this.dbReady = this.openDb(); + // QNBS-v3: this connection is cached for the service's lifetime — a factory reset must close it or deleteDatabase(worldscript-inference-cache-db) blocks. + registerIdbConnectionCloser(() => { + this.db?.close(); + this.db = null; + }); + } + + // QNBS-v3: retryable, not a one-shot constructor-time promise — the original design permanently disabled durable caching for the rest of the session (silently falling back to in-memory-only) if the very first open lost a race with a reset; every caller now re-attempts whenever there's no live connection and no attempt already in flight. + private ensureDb(): Promise { + if (this.db) return Promise.resolve(); + if (this.openPromise) return this.openPromise; + this.openPromise = this.openDb().finally(() => { + this.openPromise = null; + }); + return this.openPromise; } private openDb(): Promise { @@ -85,6 +104,12 @@ export class AiInferenceCacheService { resolve(); return; } + // QNBS-v3: skips opening entirely if a reset is currently draining — the generation check alone can't catch an open that STARTS mid-reset, since it would capture the reset's own already-bumped generation. + const openGeneration = beginIdbOpenAdmission(); + if (openGeneration === null) { + resolve(); + return; + } let request: IDBOpenDBRequest; try { request = indexedDB.open(IDB_DB_NAME, IDB_DB_VERSION); @@ -105,6 +130,11 @@ export class AiInferenceCacheService { }; request.onsuccess = () => { const opened = request.result; + if (!isIdbOpenStillValid(openGeneration)) { + opened.close(); + resolve(); + return; + } this.db = opened; opened.onversionchange = () => { this.db?.close(); @@ -208,7 +238,7 @@ export class AiInferenceCacheService { return memoryEntry.result; } - await this.dbReady; + await this.ensureDb(); if (!this.db) return null; return new Promise((resolve) => { const transaction = this.db!.transaction(IDB_STORE, 'readonly'); @@ -255,7 +285,7 @@ export class AiInferenceCacheService { const key = hashKey(prompt, modelId); this.evictLru(); this.inMemory.set(key, { result, lastUsed: Date.now() }); - await this.dbReady; + await this.ensureDb(); if (!this.db) return; try { // QNBS-v3: shares the writer-admission lock so eviction/persist cannot run mid-migration-batch and produce a false verification shortfall (#338). @@ -302,7 +332,7 @@ export class AiInferenceCacheService { async clearPersistentCache(): Promise { await assertSecureStorageWritableForMutation(); this.inMemory.clear(); - await this.dbReady; + await this.ensureDb(); if (!this.db) return; await new Promise((resolve) => { const transaction = this.db!.transaction(IDB_STORE, 'readwrite'); diff --git a/services/crossProjectIndexService.ts b/services/crossProjectIndexService.ts index 703b8571e..b4cfd9e44 100644 --- a/services/crossProjectIndexService.ts +++ b/services/crossProjectIndexService.ts @@ -8,6 +8,11 @@ import type { Character } from '../types'; import { cosineSimilarity, embedText } from './ai/localEmbeddingService'; import { DATA_DB_NAME, DB_VERSION, PROJECTS_INDEX_STORE } from './dbConstants'; import { loadDuckdbAnalytics } from './duckdb/duckdbListenerLoader'; +import { + beginIdbOpenAdmission, + isIdbOpenStillValid, + registerIdbConnectionCloser, +} from './storage/idbResetGate'; export interface ProjectSearchIndex { projectId: string; @@ -25,25 +30,59 @@ export interface ProjectSearchIndex { // QNBS-v3: Own connection to data-db — avoids circular import with dbService singleton. // IDB handles concurrent same-version opens gracefully; no upgrade runs again. let dbPromise: Promise | null = null; +let database: IDBDatabase | null = null; + +// QNBS-v3: a second, independent connection to worldscript-data-db (separate from dbService's own) — a factory reset must close this one too or deleteDatabase(worldscript-data-db) blocks even after dbService's connection is closed. +registerIdbConnectionCloser(() => { + database?.close(); + database = null; + dbPromise = null; +}); function getDb(): Promise { - if (!dbPromise) { - dbPromise = new Promise((resolve, reject) => { - const req = indexedDB.open(DATA_DB_NAME, DB_VERSION); - req.onupgradeneeded = () => { - // QNBS-v3: Upgrade handled by dbService; this connection should never need it. - // If reached (first open before dbService), store is created here too. - const db = req.result; - if (!db.objectStoreNames.contains(PROJECTS_INDEX_STORE)) { - const store = db.createObjectStore(PROJECTS_INDEX_STORE, { keyPath: 'projectId' }); - store.createIndex('lastIndexed', 'lastIndexed', { unique: false }); - } - }; - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); + if (database) return Promise.resolve(database); + if (dbPromise) return dbPromise; + // QNBS-v3: rejects immediately if a reset is currently draining — the generation check alone can't catch an open that STARTS mid-reset, since it would capture the reset's own already-bumped generation. + const openGeneration = beginIdbOpenAdmission(); + if (openGeneration === null) { + return Promise.reject(new Error('IndexedDB reset in progress')); } - return dbPromise; + const thisOpen: Promise = new Promise((resolve, reject) => { + const req = indexedDB.open(DATA_DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + // QNBS-v3: upgrade is normally handled by dbService — if reached (first open before dbService), the store is created here too. + const db = req.result; + if (!db.objectStoreNames.contains(PROJECTS_INDEX_STORE)) { + const store = db.createObjectStore(PROJECTS_INDEX_STORE, { keyPath: 'projectId' }); + store.createIndex('lastIndexed', 'lastIndexed', { unique: false }); + } + }; + req.onsuccess = () => { + const db = req.result; + if (!isIdbOpenStillValid(openGeneration)) { + db.close(); + reject(new Error('IndexedDB reset in progress')); + return; + } + db.onversionchange = () => { + db.close(); + database = null; + }; + database = db; + resolve(db); + }; + req.onerror = () => { + reject(req.error); + }; + }); + dbPromise = thisOpen; + // QNBS-v3: single ownership-checked cleanup for every settlement — .finally()'s callback always runs as a later microtask, so this always sees dbPromise already set to thisOpen. The trailing .catch(() => {}) only prevents an unhandled-rejection warning on this DISCARDED derived chain. + thisOpen + .finally(() => { + if (dbPromise === thisOpen) dbPromise = null; + }) + .catch(() => {}); + return thisOpen; } function extractCharacterNames(data: ProjectData): string[] { diff --git a/services/diagnostics/logSinks.ts b/services/diagnostics/logSinks.ts index 1bdf2340c..7b50f9737 100644 --- a/services/diagnostics/logSinks.ts +++ b/services/diagnostics/logSinks.ts @@ -1,6 +1,11 @@ // QNBS-v3: Keep browser/Tauri sink dispatch behind an adapter boundary around portable LogEntry. import { desktopPlatform } from '../desktopPlatform'; +import { + beginIdbOpenAdmission, + isIdbOpenStillValid, + registerIdbConnectionCloser, +} from '../storage/idbResetGate'; import { type LogEntry, safeStringify } from './logEntry'; const isDev = typeof import.meta !== 'undefined' && Boolean(import.meta.env?.DEV); @@ -16,10 +21,24 @@ let _idbOpenPromise: Promise | null = null; let _idbRecordCount: number | null = null; let _idbWriteQueue: Promise = Promise.resolve(); +// QNBS-v3: this connection is opened on the first log write and cached indefinitely — a factory reset must close it (and drop the cached record count, which describes this now-closed connection's contents) or its own logging call keeps worldscript-logs-db blocked. +registerIdbConnectionCloser(() => { + _idbDb?.close(); + _idbDb = null; + _idbRecordCount = null; + // QNBS-v3: without this, a reset-time closer leaves _idbOpenPromise pointing at the pending (about-to-be-invalidated) flight, so the first post-reset write reuses it and waits on its eventual generation-mismatch rejection instead of starting a fresh open immediately. + _idbOpenPromise = null; +}); + function openLogDb(): Promise { if (_idbDb) return Promise.resolve(_idbDb); if (_idbOpenPromise) return _idbOpenPromise; - _idbOpenPromise = new Promise((resolve, reject) => { + // QNBS-v3: rejects immediately if a reset is currently draining — the generation check alone can't catch an open that STARTS mid-reset, since it would capture the reset's own already-bumped generation. + const openGeneration = beginIdbOpenAdmission(); + if (openGeneration === null) { + return Promise.reject(new Error('IndexedDB reset in progress')); + } + const thisOpen: Promise = new Promise((resolve, reject) => { const req = indexedDB.open(IDB_DB_NAME, 1); req.onupgradeneeded = (e) => { const db = (e.target as IDBOpenDBRequest).result; @@ -28,16 +47,33 @@ function openLogDb(): Promise { } }; req.onsuccess = (e) => { - _idbDb = (e.target as IDBOpenDBRequest).result; - _idbOpenPromise = null; + const db = (e.target as IDBOpenDBRequest).result; + if (!isIdbOpenStillValid(openGeneration)) { + db.close(); + reject(new Error('IndexedDB reset in progress')); + return; + } + // QNBS-v3: another tab's factory reset fires versionchange here first — close and invalidate so this tab re-opens fresh next write instead of holding a connection that blocks that reset. + db.onversionchange = () => { + db.close(); + _idbDb = null; + _idbRecordCount = null; + }; + _idbDb = db; resolve(_idbDb); }; req.onerror = (e) => { - _idbOpenPromise = null; reject((e.target as IDBOpenDBRequest).error); }; }); - return _idbOpenPromise; + _idbOpenPromise = thisOpen; + // QNBS-v3: single ownership-checked cleanup for every settlement — .finally()'s callback always runs as a later microtask, so this always sees _idbOpenPromise already set to thisOpen. The trailing .catch(() => {}) only prevents an unhandled-rejection warning on this DISCARDED derived chain. + thisOpen + .finally(() => { + if (_idbOpenPromise === thisOpen) _idbOpenPromise = null; + }) + .catch(() => {}); + return thisOpen; } // QNBS-v3: serialize IDB writes and track a bounded count to prevent burst logging from blocking or exhausting storage. diff --git a/services/factoryResetService.ts b/services/factoryResetService.ts index efb04794d..cac382417 100644 --- a/services/factoryResetService.ts +++ b/services/factoryResetService.ts @@ -9,13 +9,15 @@ */ import { logger } from './logger'; +import { beginIdbReset, endIdbReset } from './storage/idbResetGate'; import { isTauriRuntime } from './tauriRuntime'; // QNBS-v3: mirrors public/sw.js's isWorldScriptOwnedCache/register-sw.ts's isWorldScriptOwnedCacheName — duplicated (not imported) since sw.js is a classic non-module script and register-sw.ts has its own load-time side effect. -const OWNED_CACHE_NAME_RE = /^worldscript-(?:static|dynamic|images)-v\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/; +const OWNED_CACHE_NAME_RE = + /^worldscript-(?:static|dynamic|images)-v\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/; const isWorldScriptOwnedCacheName = (name: string): boolean => OWNED_CACHE_NAME_RE.test(name); -/** All IDB databases the app may have created. */ +/** All IDB databases the app may have created under a fixed, exact name. */ const KNOWN_DB_NAMES = [ 'worldscript-db', // legacy — migrated to worldscript-data-db 'worldscript-state-db', @@ -25,29 +27,63 @@ const KNOWN_DB_NAMES = [ 'worldscript-lora-db', 'worldscript-inference-cache-db', 'proforge-memory-bank', + 'proforge-run-history', + 'worldscript-dead-letter-db', ]; +// QNBS-v3: the only prefix-based (non-exact) WorldScript-owned IDB name — services/localFirst/docPersistence.ts's per-project shadow store, dynamically named per projectId, so it can never appear in KNOWN_DB_NAMES. +const LOCAL_FIRST_DB_PREFIX = 'worldscript-localfirst-'; + +// QNBS-v3: a shared origin can host databases from an unrelated app/tool — indexedDB.databases() enumerates everything on the origin, so a real deletion target must be proven app-owned, never assumed just because enumeration returned it. +function isWorldScriptOwnedDatabaseName(name: string): boolean { + return KNOWN_DB_NAMES.includes(name) || name.startsWith(LOCAL_FIRST_DB_PREFIX); +} + async function deleteAllIndexedDBDatabases(): Promise { + // QNBS-v3: enumeration failure falls back to the known list, but a real deletion failure must propagate, not be silently retried through a different path that could mask it. + let names: string[] | null = null; // Prefer the native API if available (Chrome 73+, Firefox 126+). if (indexedDB.databases) { try { const all = await indexedDB.databases(); - await Promise.all(all.map((db) => db.name && deleteDatabase(db.name))); - return; + names = all.map((db) => db.name).filter((name): name is string => Boolean(name)); } catch { // Fall through to known-list approach } } - // Safari / older browsers: delete by known name list. - await Promise.all(KNOWN_DB_NAMES.map(deleteDatabase)); + // Safari / older browsers, or a failed enumeration: delete by known name list (already exact-owned, no filter needed). A successful native enumeration must still be filtered — it can see a foreign database on this origin. + const targets = names ? names.filter(isWorldScriptOwnedDatabaseName) : KNOWN_DB_NAMES; + // QNBS-v3: allSettled, not all — every deletion request must be given the chance to fully settle before this resolves/rejects, so wipeAllAppData()'s catch never releases the reset gate while another deletion is still outstanding in the background. + const results = await Promise.allSettled(targets.map(deleteDatabase)); + const failures = results.filter( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); + if (failures.length > 0) { + const messages = failures.map((failure) => + failure.reason instanceof Error ? failure.reason.message : String(failure.reason), + ); + throw new Error( + `[factoryReset] ${failures.length} of ${targets.length} database deletion(s) failed: ${messages.join('; ')}`, + ); + } } function deleteDatabase(name: string): Promise { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const req = indexedDB.deleteDatabase(name); req.onsuccess = () => resolve(); - req.onerror = () => resolve(); // ignore — DB may not exist - req.onblocked = () => resolve(); // resolve anyway; page reload will finish the job + // QNBS-v3: deleting a non-existent database succeeds per spec — a real onerror means deletion is genuinely unproven, so reject rather than assume "DB may not exist" and report a fresh install that isn't. + req.onerror = () => { + const message = `[factoryReset] deleteDatabase(${name}) failed`; + logger.warn(message, { error: req.error?.message }); + reject(req.error ?? new Error(message)); + }; + // QNBS-v3: a still-open connection means the database was NOT deleted — reject rather than resolve, so wipeAllAppData() never reports a "fresh install" that still has old data. + req.onblocked = () => { + const message = `[factoryReset] deleteDatabase(${name}) blocked by another open connection`; + logger.warn(message); + reject(new Error(message)); + }; }); } @@ -97,17 +133,25 @@ async function clearTauriAppData(): Promise { */ export async function wipeAllAppData(): Promise { logger.warn('[factoryReset] Wiping all app data…'); - // QNBS-v3: clear fallible desktop data first so a failed desktop reset never leaves a mixed wipe. - await clearTauriAppData(); - await deleteAllIndexedDBDatabases(); - await clearServiceWorkerCaches(); try { - localStorage.clear(); - sessionStorage.clear(); - } catch { - // Private browsing may throw + // QNBS-v3: awaited and can throw — beginIdbReset() fails closed on any closer failure, so a rejection here skips straight to the catch below and deletion never starts on an unproven teardown. + await beginIdbReset(); + // QNBS-v3: clear fallible desktop data first so a failed desktop reset never leaves a mixed wipe. + await clearTauriAppData(); + await deleteAllIndexedDBDatabases(); + await clearServiceWorkerCaches(); + try { + localStorage.clear(); + sessionStorage.clear(); + } catch { + // Private browsing may throw + } + // Small delay so async IDB deletions can settle before unload. + await new Promise((r) => setTimeout(r, 300)); + window.location.reload(); + } catch (error) { + // QNBS-v3: reload never runs on this path — release the gate so the still-live app can access IDB again. + endIdbReset(); + throw error; } - // Small delay so async IDB deletions can settle before unload. - await new Promise((r) => setTimeout(r, 300)); - window.location.reload(); } diff --git a/services/localFirst/docPersistence.ts b/services/localFirst/docPersistence.ts index 27e7d3b09..9617461b2 100644 --- a/services/localFirst/docPersistence.ts +++ b/services/localFirst/docPersistence.ts @@ -15,6 +15,7 @@ import { IndexeddbPersistence } from 'y-indexeddb'; import type * as Y from 'yjs'; +import { isIdbResetInProgress, registerIdbConnectionCloser } from '../storage/idbResetGate'; // QNBS-v3: Rebrand — canonical worldscript-* IndexedDB namespace. Safe to rename outright: // local-first sync is behind enableLocalFirstSync (off by default) and this is a pre-release @@ -49,12 +50,24 @@ export const NOOP_PERSISTENCE: DocPersistence = { clearData: () => Promise.resolve(), }; +// QNBS-v3: a fresh object every call, deliberately never the NOOP_PERSISTENCE singleton — this is a transient "reset denied this open" result, not an intentional environmental NOOP, so a caller that caches it (getLocalFirstHandle's reconcileLocalFirstHandle) can tell the two apart by identity and must not keep reusing it once the reset ends. +function createTransientResetDeniedPersistence(): DocPersistence { + return { + whenSynced: Promise.resolve(), + active: false, + destroy: () => Promise.resolve(), + clearData: () => Promise.resolve(), + }; +} + /** * Attach y-indexeddb persistence to a project doc. Returns a no-op handle when IndexedDB is * unavailable so callers never need to branch. */ export function persistProjectDoc(projectId: string, doc: Y.Doc): DocPersistence { if (!isIndexedDbAvailable()) return NOOP_PERSISTENCE; + // QNBS-v3: never open a fresh y-indexeddb provider while a reset is draining — it would immediately register a closer and get torn down again, for no benefit, and could race the reset's own deleteDatabase call. + if (isIdbResetInProgress()) return createTransientResetDeniedPersistence(); let provider: IndexeddbPersistence; try { @@ -69,10 +82,17 @@ export function persistProjectDoc(projectId: string, doc: Y.Doc): DocPersistence // in-flight destroy (no double-destroy, and no flag flipped to "destroyed" before destroy actually // finishes). Errors are swallowed so teardown never throws. let destroyPromise: Promise | null = null; + // QNBS-v3: starts as a no-op and gets replaced right after registration — a reset already in progress would otherwise invoke this closer synchronously while unregister is still mid-TDZ. + let unregister: () => void = () => {}; const destroy = (): Promise => { - if (!destroyPromise) destroyPromise = provider.destroy().catch(() => undefined); + if (!destroyPromise) { + unregister(); + destroyPromise = provider.destroy().catch(() => undefined); + } return destroyPromise; }; + // QNBS-v3: this project's own worldscript-localfirst- connection must close during a factory reset too, or deleteDatabase blocks on it — each open project doc registers/unregisters its own instance. Returns destroy()'s own promise (a block-bodied arrow here would silently discard it, so the reset gate would resolve before teardown actually finished). + unregister = registerIdbConnectionCloser(() => destroy()); // QNBS-v3 (CodeAnt): if IndexedDB fails *asynchronously* after construction, provider.whenSynced // rejects. Without handling, callers would receive a rejected promise and the provider would leak. diff --git a/services/loraAdapterService.ts b/services/loraAdapterService.ts index 54b5218e1..c69dbee77 100644 --- a/services/loraAdapterService.ts +++ b/services/loraAdapterService.ts @@ -1,4 +1,9 @@ import { logger } from './logger'; +import { + beginIdbOpenAdmission, + isIdbOpenStillValid, + registerIdbConnectionCloser, +} from './storage/idbResetGate'; export interface LoraAdapterMeta { id: string; @@ -37,8 +42,26 @@ const ACTIVE_STORE = 'lora-active'; const ACTIVE_KEY = 'active_adapter_id'; +let database: IDBDatabase | null = null; +let openPromise: Promise | null = null; + +// QNBS-v3: each call previously opened its own never-closed connection (unbounded leak); now cached single-flight so a factory reset has exactly one connection per store to close instead of none it can reference. +registerIdbConnectionCloser(() => { + database?.close(); + database = null; + // QNBS-v3: without this, a reset-time closer leaves openPromise pointing at the pending (about-to-be-invalidated) flight, so the first post-reset caller reuses it and waits on its eventual generation-mismatch rejection instead of starting a fresh open immediately. + openPromise = null; +}); + function openDb(): Promise { - return new Promise((resolve, reject) => { + if (database) return Promise.resolve(database); + if (openPromise) return openPromise; + // QNBS-v3: rejects immediately if a reset is currently draining — the generation check alone can't catch an open that STARTS mid-reset, since it would capture the reset's own already-bumped generation. + const openGeneration = beginIdbOpenAdmission(); + if (openGeneration === null) { + return Promise.reject(new Error('IndexedDB reset in progress')); + } + const thisOpen: Promise = new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, DB_VERSION); req.onupgradeneeded = (e) => { const db = (e.target as IDBOpenDBRequest).result; @@ -61,9 +84,38 @@ function openDb(): Promise { db.createObjectStore(ACTIVE_STORE); } }; - req.onsuccess = (e) => resolve((e.target as IDBOpenDBRequest).result); - req.onerror = () => reject(req.error); + req.onsuccess = (e) => { + const db = (e.target as IDBOpenDBRequest).result; + // QNBS-v3: a stale flight (e.g. _resetLoraDbForTest() swapped the fake IndexedDB factory while this open was still pending, clearing openPromise to null) must not publish — only proceed if this flight is STILL the one openPromise points to. + if (openPromise !== thisOpen) { + db.close(); + reject(new Error('Superseded by a newer open')); + return; + } + if (!isIdbOpenStillValid(openGeneration)) { + db.close(); + reject(new Error('IndexedDB reset in progress')); + return; + } + db.onversionchange = () => { + db.close(); + database = null; + }; + database = db; + resolve(db); + }; + req.onerror = () => { + reject(req.error); + }; }); + openPromise = thisOpen; + // QNBS-v3: single ownership-checked cleanup for every settlement (success, async onerror, AND a synchronous open throw) — .finally()'s callback always runs as a later microtask, so this always sees openPromise already set to thisOpen. The trailing .catch(() => {}) only prevents an unhandled-rejection warning on this DISCARDED derived chain — thisOpen itself is returned separately and its rejection is handled by the actual caller. + thisOpen + .finally(() => { + if (openPromise === thisOpen) openPromise = null; + }) + .catch(() => {}); + return thisOpen; } export async function listAdapters(): Promise { @@ -343,6 +395,10 @@ export async function listTrainingRuns(projectId: string): Promise IDBDatabase }; diff --git a/services/proForge/proForgeHistoryStore.ts b/services/proForge/proForgeHistoryStore.ts index b46deebda..c927e7421 100644 --- a/services/proForge/proForgeHistoryStore.ts +++ b/services/proForge/proForgeHistoryStore.ts @@ -6,6 +6,11 @@ */ import type { PipelineRun } from '../../features/proForge/types'; +import { + beginIdbOpenAdmission, + isIdbOpenStillValid, + registerIdbConnectionCloser, +} from '../storage/idbResetGate'; const HISTORY_DB = 'proforge-run-history'; const HISTORY_VERSION = 1; @@ -14,19 +19,42 @@ const STORE = 'history'; export const MAX_RUN_HISTORY = 20; let dbPromise: Promise | null = null; +let database: IDBDatabase | null = null; + +// QNBS-v3: this connection is cached indefinitely — a factory reset must close it or deleteDatabase(proforge-run-history) blocks. +registerIdbConnectionCloser(() => { + database?.close(); + database = null; + dbPromise = null; +}); function openHistoryDb(): Promise { + if (database) return Promise.resolve(database); if (dbPromise) return dbPromise; - dbPromise = new Promise((resolve, reject) => { + // QNBS-v3: rejects immediately if a reset is currently draining — the generation check alone can't catch an open that STARTS mid-reset, since it would capture the reset's own already-bumped generation. + const openGeneration = beginIdbOpenAdmission(); + if (openGeneration === null) { + return Promise.reject(new Error('IndexedDB reset in progress')); + } + const thisOpen: Promise = new Promise((resolve, reject) => { const request = indexedDB.open(HISTORY_DB, HISTORY_VERSION); request.onerror = () => { - // QNBS-v3: Don't memoize a rejected promise — a transient open failure (quota, locked DB) - // must not disable run-history for the rest of the session. Clear the cache so later - // calls retry the open. - dbPromise = null; reject(new Error('Failed to open ProForge history DB')); }; - request.onsuccess = () => resolve(request.result); + request.onsuccess = () => { + const db = request.result; + if (!isIdbOpenStillValid(openGeneration)) { + db.close(); + reject(new Error('IndexedDB reset in progress')); + return; + } + db.onversionchange = () => { + db.close(); + database = null; + }; + database = db; + resolve(db); + }; request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; if (!db.objectStoreNames.contains(STORE)) { @@ -34,7 +62,14 @@ function openHistoryDb(): Promise { } }; }); - return dbPromise; + dbPromise = thisOpen; + // QNBS-v3: single ownership-checked cleanup for every settlement — .finally()'s callback always runs as a later microtask, so this always sees dbPromise already set to thisOpen. The trailing .catch(() => {}) only prevents an unhandled-rejection warning on this DISCARDED derived chain. + thisOpen + .finally(() => { + if (dbPromise === thisOpen) dbPromise = null; + }) + .catch(() => {}); + return thisOpen; } interface HistoryRecord { @@ -70,5 +105,7 @@ export async function loadRunHistory(projectId: string): Promise /** Reset the DB connection — test-only. */ export function _resetHistoryDbForTest(): void { + database?.close(); + database = null; dbPromise = null; } diff --git a/services/proForge/proForgeMemoryBank.ts b/services/proForge/proForgeMemoryBank.ts index a3168a520..5289e3f07 100644 --- a/services/proForge/proForgeMemoryBank.ts +++ b/services/proForge/proForgeMemoryBank.ts @@ -5,6 +5,11 @@ */ import type { MemoryBankEntry, PipelineStage } from '../../features/proForge/types'; +import { + beginIdbOpenAdmission, + isIdbOpenStillValid, + registerIdbConnectionCloser, +} from '../storage/idbResetGate'; const MEMORY_BANK_STORE = 'proforge-memory-bank'; const MEMORY_BANK_VERSION = 1; @@ -27,14 +32,43 @@ function idbAvailable(): boolean { } let dbPromise: Promise | null = null; +let database: MemoryBankDb | null = null; + +// QNBS-v3: this connection is cached indefinitely — a factory reset must close it or deleteDatabase(proforge-memory-bank) blocks. +registerIdbConnectionCloser(() => { + database?.close(); + database = null; + dbPromise = null; +}); function openMemoryBankDb(): Promise { + if (database) return Promise.resolve(database); if (dbPromise) return dbPromise; + // QNBS-v3: rejects immediately if a reset is currently draining — the generation check alone can't catch an open that STARTS mid-reset, since it would capture the reset's own already-bumped generation. + const openGeneration = beginIdbOpenAdmission(); + if (openGeneration === null) { + return Promise.reject(new Error('IndexedDB reset in progress')); + } - dbPromise = new Promise((resolve, reject) => { + const thisOpen: Promise = new Promise((resolve, reject) => { const request = indexedDB.open(MEMORY_BANK_STORE, MEMORY_BANK_VERSION); - request.onerror = () => reject(new Error('Failed to open Memory Bank DB')); - request.onsuccess = () => resolve(request.result as MemoryBankDb); + request.onerror = () => { + reject(new Error('Failed to open Memory Bank DB')); + }; + request.onsuccess = () => { + const db = request.result as MemoryBankDb; + if (!isIdbOpenStillValid(openGeneration)) { + db.close(); + reject(new Error('IndexedDB reset in progress')); + return; + } + db.onversionchange = () => { + db.close(); + database = null; + }; + database = db; + resolve(db); + }; request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; if (!db.objectStoreNames.contains('entries')) { @@ -46,7 +80,14 @@ function openMemoryBankDb(): Promise { }; }); - return dbPromise; + dbPromise = thisOpen; + // QNBS-v3: single ownership-checked cleanup for every settlement — .finally()'s callback always runs as a later microtask, so this always sees dbPromise already set to thisOpen. The trailing .catch(() => {}) only prevents an unhandled-rejection warning on this DISCARDED derived chain. + thisOpen + .finally(() => { + if (dbPromise === thisOpen) dbPromise = null; + }) + .catch(() => {}); + return thisOpen; } // --------------------------------------------------------------------------- @@ -316,6 +357,8 @@ export function clearMemoryBankCache(): void { /** Reset DB connection and singleton cache — test-only. Allows fresh IDBFactory per test. */ export function _resetDbForTest(): void { + database?.close(); + database = null; dbPromise = null; bankCache.clear(); memFallback.clear(); diff --git a/services/sceneRevisionService.ts b/services/sceneRevisionService.ts index c5d761a05..9910c0e58 100644 --- a/services/sceneRevisionService.ts +++ b/services/sceneRevisionService.ts @@ -1,6 +1,11 @@ // QNBS-v3: Standalone IDB for scene revisions avoids a shared schema upgrade and keeps history bounded. import type { SceneRevision } from '../types'; import { createLogger } from './logger'; +import { + beginIdbOpenAdmission, + isIdbOpenStillValid, + registerIdbConnectionCloser, +} from './storage/idbResetGate'; import { withProtectedWriteAdmission } from './storage/protectedWriteAdmission'; import { assertSecureStorageReadable, @@ -38,11 +43,24 @@ interface StoredSceneRevision { let database: IDBDatabase | null = null; let openPromise: Promise | null = null; +// QNBS-v3: this connection is cached indefinitely across saves — a factory reset must close it or deleteDatabase(worldscript-revisions-db) blocks. +registerIdbConnectionCloser(() => { + database?.close(); + database = null; + // QNBS-v3: without this, a reset-time closer leaves openPromise pointing at the pending (about-to-be-invalidated) flight, so the first post-reset caller reuses it and waits on its eventual generation-mismatch rejection instead of starting a fresh open immediately. + openPromise = null; +}); + async function getDb(): Promise { if (database) return database; if (openPromise) return openPromise; + // QNBS-v3: rejects immediately if a reset is currently draining — the generation check alone can't catch an open that STARTS mid-reset, since it would capture the reset's own already-bumped generation. + const openGeneration = beginIdbOpenAdmission(); + if (openGeneration === null) { + return Promise.reject(new Error('IndexedDB reset in progress')); + } // QNBS-v3: single-flight open — concurrent saves must share one connection instead of leaking one per call. - openPromise = new Promise((resolve, reject) => { + const thisOpen: Promise = new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { const db = request.result; @@ -54,20 +72,30 @@ async function getDb(): Promise { }; request.onsuccess = () => { const opened = request.result; + if (!isIdbOpenStillValid(openGeneration)) { + opened.close(); + reject(new Error('IndexedDB reset in progress')); + return; + } database = opened; opened.onversionchange = () => { opened.close(); database = null; - openPromise = null; }; resolve(opened); }; request.onerror = () => { - openPromise = null; reject(request.error); }; }); - return openPromise; + openPromise = thisOpen; + // QNBS-v3: single ownership-checked cleanup for every settlement — .finally()'s callback always runs as a later microtask, so this always sees openPromise already set to thisOpen. The trailing .catch(() => {}) only prevents an unhandled-rejection warning on this DISCARDED derived chain. + thisOpen + .finally(() => { + if (openPromise === thisOpen) openPromise = null; + }) + .catch(() => {}); + return thisOpen; } function isStoredSceneRevision(value: unknown): value is StoredSceneRevision { diff --git a/services/storage/idbCore.ts b/services/storage/idbCore.ts index 274fd8d26..924016f2e 100644 --- a/services/storage/idbCore.ts +++ b/services/storage/idbCore.ts @@ -19,6 +19,11 @@ import { } from '../dbConstants'; import { migrateLegacyWorldscriptDbIfNeeded } from '../dbMigration'; import { logger } from '../logger'; +import { + beginIdbOpenAdmission, + isIdbOpenStillValid, + registerIdbConnectionCloser, +} from './idbResetGate'; // LZ-String threshold: compress payloads >10 KB const COMPRESS_THRESHOLD_BYTES = 10_240; @@ -96,6 +101,11 @@ export class IdbConnectionManager { protected stateDb: IDBDatabase | null = null; protected dataDb: IDBDatabase | null = null; + constructor() { + // QNBS-v3: auto-registers every subclass singleton with the shared reset gate, so factory reset closes it without a hand-written per-store wrapper. + registerIdbConnectionCloser(() => this.closeConnections()); + } + protected closeConnections(): void { // QNBS-v3: Test singletons must release old factories before another fake IndexedDB is installed. this.stateDb?.close(); @@ -119,6 +129,11 @@ export class IdbConnectionManager { } private openStateDb(): Promise { + // QNBS-v3: rejects immediately if a reset is currently draining — the generation check alone can't catch an open that STARTS mid-reset, since it would capture the reset's own already-bumped generation. + const openGeneration = beginIdbOpenAdmission(); + if (openGeneration === null) { + return Promise.reject(new Error('IndexedDB reset in progress')); + } return new Promise((resolve, reject) => { const request = indexedDB.open(STATE_DB_NAME, DB_VERSION); request.onupgradeneeded = (event) => { @@ -132,6 +147,11 @@ export class IdbConnectionManager { }; request.onsuccess = () => { const db = request.result; + if (!isIdbOpenStillValid(openGeneration)) { + db.close(); + reject(new Error('IndexedDB reset in progress')); + return; + } db.onversionchange = () => { db.close(); this.stateDb = null; @@ -144,6 +164,11 @@ export class IdbConnectionManager { } private openDataDb(): Promise { + // QNBS-v3: rejects immediately if a reset is currently draining — the generation check alone can't catch an open that STARTS mid-reset, since it would capture the reset's own already-bumped generation. + const openGeneration = beginIdbOpenAdmission(); + if (openGeneration === null) { + return Promise.reject(new Error('IndexedDB reset in progress')); + } return new Promise((resolve, reject) => { const request = indexedDB.open(DATA_DB_NAME, DB_VERSION); request.onupgradeneeded = (event) => { @@ -170,6 +195,11 @@ export class IdbConnectionManager { }; request.onsuccess = () => { const db = request.result; + if (!isIdbOpenStillValid(openGeneration)) { + db.close(); + reject(new Error('IndexedDB reset in progress')); + return; + } db.onversionchange = () => { db.close(); this.dataDb = null; diff --git a/services/storage/idbResetGate.ts b/services/storage/idbResetGate.ts new file mode 100644 index 000000000..dfdb6f455 --- /dev/null +++ b/services/storage/idbResetGate.ts @@ -0,0 +1,154 @@ +/** + * idbResetGate — shared "reset in progress" signal + async connection-closer registry, with a + * generation/epoch invariant so a connection open that started before or during a reset can never + * become cached/authoritative after that reset, even if the reset later fails and resetInProgress + * flips back to false. + * + * Every module that caches a long-lived IDBDatabase handle registers its own (possibly async) + * closer here once, at load time. beginIdbReset() awaits every registered closer's teardown — + * including any closer registered WHILE the drain is still running — before settling, and fails + * closed: if any closer threw or rejected, beginIdbReset() itself rejects so the caller (factory + * reset) never proceeds into destructive database deletion on an unproven teardown. + */ + +// QNBS-v3: logger is dynamically imported, never at module top level — a static import here creates a load-time circular dependency with services/diagnostics/logSinks.ts, one of the StructuredLogger's own sink-chain modules. + +export type IdbConnectionCloser = () => void | Promise; + +let resetInProgress = false; +let generation = 0; +const closers = new Set(); + +interface ResetBarrier { + pending: Set>; + failures: unknown[]; +} + +// QNBS-v3: set only while beginIdbReset() is draining — lets a closer registered mid-reset join THIS reset's awaited barrier instead of racing ahead of it as a fire-and-forget. +let activeBarrier: ResetBarrier | null = null; + +async function runCloser(closer: IdbConnectionCloser): Promise { + await closer(); +} + +// QNBS-v3: settled removes itself from barrier.pending via its own .then — safe because that callback only runs on a later microtask, after the synchronous `const settled = …` assignment below has completed. +function joinActiveBarrier(closer: IdbConnectionCloser): void { + const barrier = activeBarrier; + if (!barrier) return; + const settled: Promise = runCloser(closer) + .catch((error: unknown) => { + barrier.failures.push(error); + }) + .then(() => { + barrier.pending.delete(settled); + }); + barrier.pending.add(settled); +} + +/** + * Registers a closer, called once per module at load time. If a reset is already in progress, + * the closer joins that reset's own awaited barrier immediately instead of waiting for a future + * one — a connection opened mid-reset must not survive that same reset, and beginIdbReset() must + * not settle until this late closer has also settled. Returns an unregister function (used by + * modules whose connection lifetime is shorter than the app's, e.g. per-project y-indexeddb docs, + * and by tests). + */ +export function registerIdbConnectionCloser(closer: IdbConnectionCloser): () => void { + closers.add(closer); + if (resetInProgress) { + joinActiveBarrier(closer); + } + return () => closers.delete(closer); +} + +/** Every module that caches an IDBDatabase handle should consult this before starting a new open. */ +export function isIdbResetInProgress(): boolean { + return resetInProgress; +} + +/** + * Every module's open-completion handler must capture this at the START of an open attempt, then + * compare it again at completion: `capturedGeneration !== currentIdbResetGeneration()` means a + * reset happened (and possibly already ended) since the open began, so the result must be closed + * and discarded rather than cached — this is the authoritative check, stricter than + * isIdbResetInProgress(), which cannot distinguish "no reset ever happened" from "a reset + * happened, failed, and ended" once the boolean flips back to false. + */ +export function currentIdbResetGeneration(): number { + return generation; +} + +/** + * Marks a reset in progress and advances the generation synchronously (before anything else + * async runs, so no new open can slip in unobserved), then awaits every registered closer's + * teardown — including any closer registered WHILE this drain is still running, via the same + * barrier. Fails closed: if any closer threw or rejected, this rejects too (after every closer, + * including the failing ones, has had its chance to run) so the caller never proceeds into + * destructive deletion on an unproven teardown. The reset stays marked in progress either way — + * it is the caller's responsibility to call endIdbReset() once it decides whether to proceed with + * deletion or abort. + */ +export async function beginIdbReset(): Promise { + resetInProgress = true; + generation += 1; + const barrier: ResetBarrier = { pending: new Set(), failures: [] }; + activeBarrier = barrier; + for (const closer of closers) { + joinActiveBarrier(closer); + } + // QNBS-v3: re-checks pending after each drain round — a closer registered while we're draining adds itself to this same Set, so the loop only exits once nothing new has joined. + while (barrier.pending.size > 0) { + await Promise.allSettled(Array.from(barrier.pending)); + } + activeBarrier = null; + if (barrier.failures.length > 0) { + const messages = barrier.failures.map((failure) => + failure instanceof Error ? failure.message : String(failure), + ); + const { logger } = await import('../logger'); + logger.warn( + `[idbResetGate] ${barrier.failures.length} connection closer(s) failed during reset`, + { + errors: messages, + }, + ); + throw new Error( + `[idbResetGate] reset teardown incomplete — ${barrier.failures.length} closer(s) failed: ${messages.join('; ')}`, + ); + } +} + +/** + * Call before starting any indexedDB.open() in a reset-aware opener. Returns the generation to + * pass to isIdbOpenStillValid() once the open completes, or null when a reset is currently in + * progress — the generation check alone cannot catch an open that STARTS during an active reset + * (it captures the reset's own already-bumped generation, so a naive comparison at completion + * would still match): callers must not start a fresh indexedDB.open() when this returns null, and + * should reject/defer instead. + */ +export function beginIdbOpenAdmission(): number | null { + return resetInProgress ? null : generation; +} + +/** + * Call from an open's onsuccess handler with the token from beginIdbOpenAdmission(). False means + * the result must be closed and discarded/rejected rather than cached: either a reset is still + * running (started after admission, so the generation alone wouldn't yet show a mismatch), or one + * ran and ended with a different generation than the one captured at admission time. + */ +export function isIdbOpenStillValid(capturedGeneration: number): boolean { + return !resetInProgress && generation === capturedGeneration; +} + +/** Only needed if a reset attempt fails before reaching reload — restores normal DB access for the still-live app. */ +export function endIdbReset(): void { + resetInProgress = false; +} + +/** Test-only: clears the registry and generation between test files so leftover closers from one test don't fire in another. */ +export function _resetIdbResetGateForTest(): void { + resetInProgress = false; + generation = 0; + closers.clear(); + activeBarrier = null; +} diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 8ac3f72a8..cb09a4e31 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -29,6 +29,21 @@ export async function clickNavItem(page: Page, name: RegExp): Promise { await page.locator('#sidebar-mobile').getByRole('button', { name }).click(); } +/** Locale-independent Settings navigation: same mobile-aware fallback as clickNavItem, keyed on the stable `data-tour="nav-settings"` anchor instead of translated visible text. */ +async function clickSettingsNavItem(page: Page): Promise { + const desktopBtn = page.locator('#sidebar [data-tour="nav-settings"]'); + if (await desktopBtn.isVisible({ timeout: 1500 }).catch(() => false)) { + await desktopBtn.click(); + return; + } + // QNBS-v3: data-tour="nav-more" (not a translated /More/i label) so this stays locale-independent on a non-English mobile boot. + const moreBtn = page.locator('[data-tour="nav-more"]'); + await expect(moreBtn).toBeVisible({ timeout: 8000 }); + await moreBtn.click(); + await page.locator('#sidebar-mobile').waitFor({ state: 'visible' }); + await page.locator('#sidebar-mobile [data-tour="nav-settings"]').click(); +} + // QNBS-v3: Stable Writer `#writer-section-select` + option handling avoids Playwright strict-mode / native- visibility pitfalls that broke CI E2E. /** Writer section `` — stable id to avoid picking tone/tool comboboxes elsewhere on the page. */ @@ -147,6 +162,19 @@ export async function waitForMainChrome(page: Page): Promise { ]); } +/** QNBS-v3: explicit discriminated startup state, not boolean soup — repeatedly asking "is the portal visible?" via isVisible().catch(()=>false) can't distinguish "main chrome" from "still loading" and silently swallows genuine errors as false. */ +export type StartupState = 'WELCOME_PORTAL' | 'MAIN_CHROME'; + +/** Resolves which of waitForSpaReady()'s two shapes the current document actually reached. */ +export async function resolveStartupState(page: Page): Promise { + await waitForSpaReady(page); + const portal = page.getByTestId('welcome-portal'); + if (await portal.isVisible().catch(() => false)) { + return 'WELCOME_PORTAL'; + } + return 'MAIN_CHROME'; +} + /** Language toggle on the welcome portal (EN must be active for English copy in assertions). */ export async function selectEnglish(page: Page): Promise { const enBtn = page.getByRole('button', { name: /^EN$/i }).first(); @@ -179,36 +207,20 @@ export async function ensureBlankProject(page: Page): Promise { * waitForSpaReady()'s two success shapes the app actually booted into. A cold CI boot has landed * in an already-mounted main shell with a persisted project instead of the portal — a startup- * state precondition gap distinct from the (fixed) portal-activation auto-seed race. - * Contract: guarantees the portal is reached, locale-independently — it does NOT guarantee - * English. A caller needing English selects it itself (export.spec.ts already does this for the - * fresh-boot case). Recovers via the real Settings → Data & Backups → Factory Reset flow when - * main chrome is active so no React/Redux/storage internals are touched — only supported app - * behavior. + * Contract: guarantees the portal is reached, locale-independently. Recovers via the real + * Settings → Data & Backups → Factory Reset flow when main chrome is active so no React/Redux/ + * storage internals are touched — only supported app behavior. */ export async function ensureWelcomePortalEntry(page: Page): Promise { - await waitForSpaReady(page); const portal = page.getByTestId('welcome-portal'); - if (await portal.isVisible({ timeout: 3000 }).catch(() => false)) { + if ((await resolveStartupState(page)) === 'WELCOME_PORTAL') { return; } - // QNBS-v3: force English before the locale-dependent recovery flow below, or a persisted non-EN/DE language would hang it. - await page.evaluate(() => localStorage.setItem('worldscript-language', 'en')); - await page.reload(); - await waitForSpaReady(page); - // QNBS-v3: this reload can itself race a pending debounced autosave and land back in WelcomePortal instead of main chrome — accept either state again rather than assuming main chrome. - if (await portal.isVisible({ timeout: 3000 }).catch(() => false)) { - return; - } - await waitForMainChrome(page); - await clickNavItem(page, /Settings/i); - await page - .getByRole('button', { name: /Data & Backups|Daten & Backups/i }) - .first() - .click(); - await page.getByRole('button', { name: /Factory Reset|Werkseinstellungen/i }).click(); - await page - .getByRole('button', { name: /Delete everything & restart|Alles löschen & neu starten/i }) - .click(); + // QNBS-v3: every step below uses a stable data-tour/data-testid anchor, never translated text — Playwright's own docs say addInitScript execution order across registrations is unspecified, so this cannot rely on forcing a language first. + await clickSettingsNavItem(page); + await page.getByTestId('settings-nav-data').click(); + await page.getByTestId('factory-reset-button').click(); + await page.getByTestId('factory-reset-confirm-button').click(); await waitForSpaReady(page); await expect(portal).toBeVisible({ timeout: 15000 }); } diff --git a/tests/e2e/onboarding-entry-precondition.spec.ts b/tests/e2e/onboarding-entry-precondition.spec.ts index 6d6ad87e5..c397d5c0e 100644 --- a/tests/e2e/onboarding-entry-precondition.spec.ts +++ b/tests/e2e/onboarding-entry-precondition.spec.ts @@ -42,6 +42,22 @@ test.describe('WelcomePortal entry precondition (CI-only)', () => { await expect(page.getByRole('button', { name: /Start a New Project/i })).toBeVisible(); }); + test('reaches the entry point via the recovery flow with a persisted non-English language, on Mobile Chrome and desktop alike', async ({ + page, + }) => { + // QNBS-v3: a fresh boot lands on the portal regardless of locale — this combines a persisted main-chrome project with a non-English language so a mobile "More"-button locale regression actually fails, on every project including Mobile Chrome. + await page.goto('/'); + await ensureBlankProject(page); + await expect(page.getByText(/All changes saved/i)).toBeVisible({ timeout: 10000 }); + await page.addInitScript(() => localStorage.setItem('worldscript-language', 'es')); + await page.reload(); + await waitForMainChrome(page); + // QNBS-v3: proves the precondition actually took effect — without this, a broken addInitScript seed could pass this test vacuously in English, regardless of viewport/view. + expect(await page.evaluate(() => localStorage.getItem('worldscript-language'))).toBe('es'); + await ensureWelcomePortalEntry(page); + await expect(page.getByTestId('welcome-portal')).toBeVisible(); + }); + test('reaches the entry point when its own internal reload can race a pending autosave', async ({ page, }) => { diff --git a/tests/unit/aiInferenceCacheService.test.ts b/tests/unit/aiInferenceCacheService.test.ts index 78930ba11..c53541a0c 100644 --- a/tests/unit/aiInferenceCacheService.test.ts +++ b/tests/unit/aiInferenceCacheService.test.ts @@ -26,12 +26,13 @@ describe('aiInferenceCacheService — in-memory LRU', () => { it('keeps the in-memory result when non-authoritative durable cache encoding is blocked', async () => { type CacheInternals = { - dbReady: Promise; + ensureDb: () => Promise; db: IDBDatabase | null; encodeEntry: (key: string, result: string, timestamp: number) => Promise; }; const cache = service.aiInferenceCacheService as unknown as CacheInternals; - await cache.dbReady; + // QNBS-v3: dbReady was a one-shot constructor-time promise (replaced by the retryable ensureDb() fix) — this test needs the connection open before the forced db override below. + await cache.ensureDb(); cache.db = {} as IDBDatabase; vi.spyOn(cache, 'encodeEntry').mockRejectedValueOnce(new Error('storage locked')); @@ -173,11 +174,12 @@ describe('aiInferenceCacheService — protected-storage lifecycle', () => { vi.resetModules(); const mod = await import('../../services/ai/aiInferenceCacheService'); type CacheInternals = { - dbReady: Promise; + ensureDb: () => Promise; decodeEntry: (entry: { key: string; result: string; timestamp: number }) => Promise; }; const cache = mod.aiInferenceCacheService as unknown as CacheInternals; - await cache.dbReady; + // QNBS-v3: dbReady was a one-shot constructor-time promise (replaced by the retryable ensureDb() fix) — this test needs the connection open before decodeEntry's fire-and-forget reencrypt can persist anything. + await cache.ensureDb(); const decoded = await cache.decodeEntry({ key: 'legacy-key', diff --git a/tests/unit/factoryResetService.test.ts b/tests/unit/factoryResetService.test.ts index 8250b03ec..6772a4794 100644 --- a/tests/unit/factoryResetService.test.ts +++ b/tests/unit/factoryResetService.test.ts @@ -8,6 +8,8 @@ import { logger } from '../../services/logger'; const mockIsTauriRuntime = vi.fn(() => false); const mockLoadTauriApis = vi.fn(); +const mockBeginIdbReset = vi.fn(); +const mockEndIdbReset = vi.fn(); vi.mock('../../services/logger', () => ({ logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, @@ -20,6 +22,11 @@ vi.mock('../../services/fs/fsCore', () => ({ // QNBS-v3: pass-through — retry/backoff behavior is covered by fsCore.test.ts directly. retryFs: (fn: () => Promise) => fn(), })); +// QNBS-v3: the gate's own registry/generation behavior is covered directly by idbResetGate.test.ts — this suite only verifies factoryResetService calls begin/end at the right points. +vi.mock('../../services/storage/idbResetGate', () => ({ + beginIdbReset: () => mockBeginIdbReset(), + endIdbReset: () => mockEndIdbReset(), +})); function createDb(name: string): Promise { return new Promise((resolve, reject) => { @@ -85,6 +92,110 @@ describe('wipeAllAppData', () => { delSpy.mockRestore(); }); + // QNBS-v3: the reset gate must begin (closing every registered connection) before any delete, or a still-open connection silently blocks it while the code reports success anyway. + it('begins the reset gate before deleting any database', async () => { + await createDb('worldscript-data-db'); + const delSpy = vi.spyOn(indexedDB, 'deleteDatabase'); + + await runWipe(); + + expect(mockBeginIdbReset).toHaveBeenCalledTimes(1); + const beginOrder = mockBeginIdbReset.mock.invocationCallOrder[0]; + const firstDeleteOrder = delSpy.mock.invocationCallOrder[0]; + expect(beginOrder).toBeDefined(); + expect(firstDeleteOrder).toBeDefined(); + expect(beginOrder as number).toBeLessThan(firstDeleteOrder as number); + expect(mockEndIdbReset).not.toHaveBeenCalled(); + delSpy.mockRestore(); + }); + + // QNBS-v3: onblocked must reject, not resolve — the reset must never report a false "fresh install" success, and the gate must still release since reload never runs on this path. + it('rejects, never reloads, and releases the reset gate when a database deletion is blocked', async () => { + await createDb('worldscript-data-db'); + const delSpy = vi.spyOn(indexedDB, 'deleteDatabase').mockImplementation((_name: string) => { + const req = {} as IDBOpenDBRequest; + queueMicrotask(() => req.onblocked?.(new Event('blocked') as IDBVersionChangeEvent)); + return req; + }); + + vi.useFakeTimers(); + try { + await expect(wipeAllAppData()).rejects.toThrow(/blocked by another open connection/); + } finally { + vi.useRealTimers(); + } + + expect(reloadMock).not.toHaveBeenCalled(); + expect(mockBeginIdbReset).toHaveBeenCalledTimes(1); + expect(mockEndIdbReset).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining(`deleteDatabase(worldscript-data-db) blocked`), + ); + delSpy.mockRestore(); + }); + + // QNBS-v3: proves allSettled semantics — a fast rejection must not release the gate while another deletion is still outstanding; the gate only releases once every deletion has settled. + it('waits for every database deletion to settle before releasing the gate, even when one rejects quickly and another is deliberately delayed', async () => { + const dbSpy = vi.spyOn(indexedDB, 'databases').mockResolvedValue([ + { name: 'worldscript-data-db', version: 1 }, + { name: 'worldscript-logs-db', version: 1 }, + ]); + // QNBS-v3: a mutable object wrapper (not a reassigned `let`) avoids a tsgo control-flow narrowing artifact across the mock callback boundary. + const slow: { resolve: (() => void) | null } = { resolve: null }; + const calledNames: string[] = []; + const delSpy = vi.spyOn(indexedDB, 'deleteDatabase').mockImplementation((name: string) => { + calledNames.push(name); + const req = {} as IDBOpenDBRequest; + if (name === 'worldscript-data-db') { + queueMicrotask(() => req.onerror?.(new Event('error'))); + } else { + slow.resolve = () => req.onsuccess?.(new Event('success')); + } + return req; + }); + + let settled = false; + const wipePromise = wipeAllAppData(); + void wipePromise.catch(() => { + settled = true; + }); + + await vi.waitFor(() => { + expect(calledNames).toEqual( + expect.arrayContaining(['worldscript-data-db', 'worldscript-logs-db']), + ); + }); + // QNBS-v3: the fast rejection has already fired by now, but the slow deletion hasn't settled — the gate must not release yet. + await Promise.resolve(); + await Promise.resolve(); + expect(settled).toBe(false); + expect(mockEndIdbReset).not.toHaveBeenCalled(); + + slow.resolve?.(); + await expect(wipePromise).rejects.toThrow(/database deletion\(s\) failed/); + + expect(mockEndIdbReset).toHaveBeenCalledTimes(1); + expect(reloadMock).not.toHaveBeenCalled(); + dbSpy.mockRestore(); + delSpy.mockRestore(); + }); + + // QNBS-v3: the fail-closed contract's core proof — a closer failure must abort the wipe entirely, before any database deletion is attempted, while still releasing the gate for retry. + it('never deletes any database and releases the gate when beginIdbReset itself rejects', async () => { + await createDb('worldscript-data-db'); + const delSpy = vi.spyOn(indexedDB, 'deleteDatabase'); + mockBeginIdbReset.mockRejectedValueOnce( + new Error('[idbResetGate] reset teardown incomplete — 1 closer(s) failed: close failed'), + ); + + await expect(wipeAllAppData()).rejects.toThrow(/closer\(s\) failed/); + + expect(delSpy).not.toHaveBeenCalled(); + expect(reloadMock).not.toHaveBeenCalled(); + expect(mockEndIdbReset).toHaveBeenCalledTimes(1); + delSpy.mockRestore(); + }); + it('falls back to the known database list when indexedDB.databases() fails', async () => { const dbSpy = vi.spyOn(indexedDB, 'databases').mockRejectedValueOnce(new Error('not allowed')); const delSpy = vi.spyOn(indexedDB, 'deleteDatabase'); @@ -98,10 +209,32 @@ describe('wipeAllAppData', () => { delSpy.mockRestore(); }); - it('clears this app\'s own service-worker caches when the Cache API is available', async () => { + // QNBS-v3: hard preserve-first gate — a shared origin can host a database from an unrelated app/tool; indexedDB.databases() enumerates the whole origin, so factory reset must never construct a deletion target from anything it doesn't own. + it('never deletes a foreign, non-owned database on the shared origin, including when native enumeration succeeds', async () => { + const dbSpy = vi.spyOn(indexedDB, 'databases').mockResolvedValue([ + { name: 'worldscript-data-db', version: 1 }, + { name: 'worldscript-localfirst-proj-123', version: 1 }, + { name: 'some-other-tools-database', version: 1 }, + ]); + const delSpy = vi.spyOn(indexedDB, 'deleteDatabase'); + + await runWipe(); + + expect(delSpy).toHaveBeenCalledWith('worldscript-data-db'); + expect(delSpy).toHaveBeenCalledWith('worldscript-localfirst-proj-123'); + expect(delSpy).not.toHaveBeenCalledWith('some-other-tools-database'); + expect(delSpy).toHaveBeenCalledTimes(2); + expect(reloadMock).toHaveBeenCalledTimes(1); + dbSpy.mockRestore(); + delSpy.mockRestore(); + }); + + it("clears this app's own service-worker caches when the Cache API is available", async () => { const del = vi.fn().mockResolvedValue(true); vi.stubGlobal('caches', { - keys: vi.fn().mockResolvedValue(['worldscript-static-v1.28.2', 'worldscript-dynamic-v1.28.2']), + keys: vi + .fn() + .mockResolvedValue(['worldscript-static-v1.28.2', 'worldscript-dynamic-v1.28.2']), delete: del, }); diff --git a/tests/unit/hooks/useSettingsView.test.ts b/tests/unit/hooks/useSettingsView.test.ts index 8cf425d4a..7dc8af0cb 100644 --- a/tests/unit/hooks/useSettingsView.test.ts +++ b/tests/unit/hooks/useSettingsView.test.ts @@ -30,9 +30,11 @@ const mockListSnapshots = vi.fn().mockResolvedValue([]); const mockSaveSnapshot = vi.fn().mockResolvedValue(undefined); const mockDeleteSnapshot = vi.fn().mockResolvedValue(undefined); const mockLoggerWarn = vi.fn(); +const mockLoggerError = vi.fn(); // QNBS-v3 (#332/D5): aliased to stableToast's own methods (not fresh vi.fn()s) so the encryption tests below assert against the same stable mock useToast() actually returns. const mockToastInfo = stableToast.info; const mockToastSuccess = stableToast.success; +const mockWipeAllAppData = vi.fn().mockResolvedValue(undefined); const mockClearIdbEncryptionKey = vi.fn(); const mockIsIdbEncryptionReady = vi.fn(() => false); const mockSetupIdbEncryption = vi.fn().mockResolvedValue(undefined); @@ -193,8 +195,23 @@ vi.mock('../../../components/ui/Toast', () => ({ useToast: () => stableToast, })); +// QNBS-v3: createLogger mocked here too — the real ModuleLogger interface also has debug(), which withContext()'s returned logger must mirror or a module further down the transitive chain calling it would crash the test. vi.mock('../../../services/logger', () => ({ - logger: { warn: (...args: unknown[]) => mockLoggerWarn(...args) }, + logger: { + warn: (...args: unknown[]) => mockLoggerWarn(...args), + error: (...args: unknown[]) => mockLoggerError(...args), + }, + createLogger: () => ({ + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + withContext: () => ({ debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }), + }), +})); + +vi.mock('../../../services/factoryResetService', () => ({ + wipeAllAppData: () => mockWipeAllAppData(), })); vi.mock('../../../services/desktopPlatform', () => ({ @@ -286,6 +303,36 @@ describe('handleLanguageChange', () => { }); }); +describe('handleFactoryReset', () => { + it('wipes app data without surfacing an error toast on success', async () => { + mockWipeAllAppData.mockResolvedValueOnce(undefined); + const { result } = renderHook(() => useSettingsView()); + + await act(async () => { + await result.current.handleFactoryReset(); + }); + + expect(mockWipeAllAppData).toHaveBeenCalledTimes(1); + expect(stableToast.error).not.toHaveBeenCalled(); + }); + + // QNBS-v3: onblocked now rejects instead of silently reloading — the failure must reach the user, not just the console. + it('logs and surfaces a non-misleading error toast when wipeAllAppData rejects', async () => { + mockWipeAllAppData.mockRejectedValueOnce(new Error('blocked by another open connection')); + const { result } = renderHook(() => useSettingsView()); + + await act(async () => { + await result.current.handleFactoryReset(); + }); + + expect(mockLoggerError).toHaveBeenCalledWith( + 'Factory reset failed', + expect.objectContaining({ error: 'blocked by another open connection' }), + ); + expect(stableToast.error).toHaveBeenCalledWith('settings.data.dangerZone.factoryReset.failed'); + }); +}); + // --------------------------------------------------------------------------- // handleSettingChange — basic settings // --------------------------------------------------------------------------- diff --git a/tests/unit/listenerMiddleware.test.ts b/tests/unit/listenerMiddleware.test.ts index 8b489d327..ecf463576 100644 --- a/tests/unit/listenerMiddleware.test.ts +++ b/tests/unit/listenerMiddleware.test.ts @@ -85,9 +85,21 @@ class MockProjectDocBinding { vi.mock('../../services/localFirst/docBinding', () => ({ ProjectDocBinding: MockProjectDocBinding, })); +// QNBS-v3: destroy/clearData must be present since real listener teardown code can call either on any persistence handle. mockNoopDestroy is a stable reference because a test below asserts teardownLocalFirst() actually invoked it; the other three stay plain no-op closures since nothing currently asserts on them. +const mockNoopDestroy = vi.fn().mockResolvedValue(undefined); vi.mock('../../services/localFirst/docPersistence', () => ({ - NOOP_PERSISTENCE: { active: false, whenSynced: Promise.resolve() }, - persistProjectDoc: vi.fn(() => ({ active: true, whenSynced: Promise.resolve() })), + NOOP_PERSISTENCE: { + active: false, + whenSynced: Promise.resolve(), + destroy: (...args: unknown[]) => mockNoopDestroy(...args), + clearData: () => Promise.resolve(), + }, + persistProjectDoc: vi.fn(() => ({ + active: true, + whenSynced: Promise.resolve(), + destroy: () => Promise.resolve(), + clearData: () => Promise.resolve(), + })), })); vi.mock('../../services/storage/storageEncryptionService', () => ({ isIdbEncryptionReady: vi.fn(() => true), @@ -574,6 +586,54 @@ describe('local-first shadow sync (B1.1)', () => { expect(mockLoggerWarn).not.toHaveBeenCalled(); expect(mockLoggerError).not.toHaveBeenCalled(); }); + + // QNBS-v3: proves reconcileLocalFirstHandle tells a transient reset-denial NOOP (distinct identity, inactive) apart from the intentional encryption-driven NOOP_PERSISTENCE singleton — a handle cached during an active reset must not be reused forever once the reset ends. + it('does not permanently reuse a transient reset-denied persistence handle once real persistence becomes available', async () => { + const { isIdbEncryptionReady } = await import( + '../../services/storage/storageEncryptionService' + ); + const { persistProjectDoc } = await import('../../services/localFirst/docPersistence'); + + // QNBS-v3: localFirstHandle is module-level state that can carry a stale handle over from an earlier test in this file — force a clean teardown first so this test's own scenario starts from null. + const warmupStore = makeFullStore(); + warmupStore.dispatch(featureFlagsActions.setEnableLocalFirstSync(true)); + await vi.advanceTimersByTimeAsync(100); + warmupStore.dispatch(featureFlagsActions.setEnableLocalFirstSync(false)); + await vi.advanceTimersByTimeAsync(100); + // QNBS-v3: proves the warmup's OFF transition actually tore down the handle via teardownLocalFirst(), not merely dispatched an action that happened to do nothing. + expect(mockNoopDestroy).toHaveBeenCalledTimes(1); + vi.mocked(persistProjectDoc).mockClear(); + + // QNBS-v3: takes the persistProjectDoc branch instead of the encryption-driven NOOP branch, so this test controls exactly what persistProjectDoc returns. + vi.mocked(isIdbEncryptionReady).mockReturnValue(false); + const transientResetDenied = { + active: false, + whenSynced: Promise.resolve(), + destroy: () => Promise.resolve(), + clearData: () => Promise.resolve(), + }; + const realActive = { + active: true, + whenSynced: Promise.resolve(), + destroy: () => Promise.resolve(), + clearData: () => Promise.resolve(), + }; + vi.mocked(persistProjectDoc) + .mockReturnValueOnce(transientResetDenied) + .mockReturnValueOnce(realActive); + + const store = makeFullStore(); + store.dispatch(projectActions.updateTitle('Reset Denial Title')); + store.dispatch(featureFlagsActions.setEnableLocalFirstSync(true)); + await vi.advanceTimersByTimeAsync(100); + expect(persistProjectDoc).toHaveBeenCalledTimes(1); + + // A later edit to the SAME project re-triggers getLocalFirstHandle — the transient handle must + // not be reused as if it were an intentional NOOP; a fresh open must be attempted instead. + store.dispatch(projectActions.updateTitle('After Reset Ends')); + await vi.advanceTimersByTimeAsync(1300); + expect(persistProjectDoc).toHaveBeenCalledTimes(2); + }); }); // --------------------------------------------------------------------------- diff --git a/tests/unit/localFirst/docPersistence.test.ts b/tests/unit/localFirst/docPersistence.test.ts index adac112a6..d6d13bbaa 100644 --- a/tests/unit/localFirst/docPersistence.test.ts +++ b/tests/unit/localFirst/docPersistence.test.ts @@ -8,8 +8,10 @@ import * as Y from 'yjs'; import { dbNameForProject, isIndexedDbAvailable, + NOOP_PERSISTENCE, persistProjectDoc, } from '../../../services/localFirst/docPersistence'; +import { beginIdbReset, endIdbReset } from '../../../services/storage/idbResetGate'; // Open a fresh provider, read the persisted 'greeting' text, and tear it down. Used to probe what // has actually reached IndexedDB without depending on wall-clock delays. @@ -85,6 +87,32 @@ describe('B1.1 — docPersistence (y-indexeddb)', () => { } }); + // QNBS-v3: opening a fresh y-indexeddb provider while a reset is draining would just register a closer that gets immediately torn down again — degrading to NOOP avoids that pointless open/destroy race entirely. + it('degrades to a transient NOOP (distinct from the intentional NOOP_PERSISTENCE singleton) while a reset is in progress, instead of opening a new provider', async () => { + await beginIdbReset(); + let deniedPersistence: ReturnType; + try { + const doc = new Y.Doc(); + deniedPersistence = persistProjectDoc('reset-guard', doc); + // QNBS-v3: must NOT be the shared singleton — a caller that caches this (getLocalFirstHandle) needs to tell it apart from an intentional NOOP so it doesn't reuse it forever once the reset ends. + expect(deniedPersistence).not.toBe(NOOP_PERSISTENCE); + expect(deniedPersistence.active).toBe(false); + } finally { + endIdbReset(); + } + + // QNBS-v3: proves persistProjectDoc itself has no sticky memory of the denial — a call after the reset ends must attempt a real open, not keep degrading. + const doc = new Y.Doc(); + const persistence = persistProjectDoc('reset-guard', doc); + try { + expect(persistence.active).toBe(true); + await persistence.whenSynced; + } finally { + await persistence.destroy(); + await clearPersisted('reset-guard'); + } + }); + it('clearData wipes persisted state', async () => { const projectId = 'wipe'; await clearPersisted(projectId); // isolation: start from a clean slate diff --git a/tests/unit/loraAdapterService.test.ts b/tests/unit/loraAdapterService.test.ts index 07cdc4471..a8524f239 100644 --- a/tests/unit/loraAdapterService.test.ts +++ b/tests/unit/loraAdapterService.test.ts @@ -5,7 +5,7 @@ */ import { IDBFactory, IDBKeyRange } from 'fake-indexeddb'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../../services/logger', () => ({ logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, @@ -25,12 +25,18 @@ beforeEach(() => { // --------------------------------------------------------------------------- import { + _resetLoraDbForTest, deleteAdapter, getAdapterBlob, type LoraAdapterMeta, listAdapters, saveAdapter, } from '../../services/loraAdapterService'; +import { + _resetIdbResetGateForTest, + beginIdbReset, + endIdbReset, +} from '../../services/storage/idbResetGate'; // --------------------------------------------------------------------------- // Fixtures @@ -111,3 +117,83 @@ describe('getAdapterBlob', () => { expect(result?.byteLength).toBe(4); }); }); + +describe('_resetLoraDbForTest — stale in-flight open ownership', () => { + // QNBS-v3: proves a pending open from before _resetLoraDbForTest() runs cannot publish its (now-discarded-factory) connection once that helper has already cleared state. + it('discards a stale open that completes only after _resetLoraDbForTest() already reset state', async () => { + // QNBS-v3: clean slate — a database/openPromise cached by an earlier test in this file would otherwise short-circuit openDb() before it ever calls the mocked indexedDB.open() below. + _resetLoraDbForTest(); + // QNBS-v3: a mutable object wrapper (not a reassigned `let`) avoids a tsgo control-flow narrowing artifact across the mock callback boundary. + const stale: { fireSuccess: (() => void) | null } = { fireSuccess: null }; + const closeSpy = vi.fn(); + const staleDb = { close: closeSpy } as unknown as IDBDatabase; + const openSpy = vi.spyOn(indexedDB, 'open').mockImplementationOnce(() => { + const req = {} as IDBOpenDBRequest; + Object.defineProperty(req, 'result', { value: staleDb, configurable: true }); + // QNBS-v3: onsuccess reads e.target.result — a plain `new Event(...)` has no target, so the event must be a stand-in object with target set to req. + stale.fireSuccess = () => req.onsuccess?.({ target: req } as unknown as Event); + return req; + }); + + const stalePromise = saveAdapter(META, new ArrayBuffer(0)); + const rejectionCheck = expect(stalePromise).rejects.toThrow(); + + // The exact race: reset-for-test runs WHILE the open above is still pending (its onsuccess has not fired yet). + _resetLoraDbForTest(); + openSpy.mockRestore(); + + // Now let the OLD (stale) open complete, late. + stale.fireSuccess?.(); + await rejectionCheck; + + expect(closeSpy).toHaveBeenCalledTimes(1); + + // A fresh call after the stale completion must retry and durably succeed against the new factory. + await saveAdapter(META, new ArrayBuffer(4)); + const result = await listAdapters(); + expect(result).toHaveLength(1); + }); +}); + +describe('reset closer invalidates the pending flight (real beginIdbReset)', () => { + afterEach(() => { + _resetIdbResetGateForTest(); + }); + + // QNBS-v3: the root-cause scenario the closer fix exists for — a reset overlapping a pending open must let the very next caller start a genuinely fresh flight immediately, not wait on the pending one's eventual generation-mismatch rejection. + it('lets an immediate post-reset operation start a fresh flight, while the pre-reset open is discarded harmlessly when it completes late', async () => { + _resetLoraDbForTest(); + + const stale: { fireSuccess: (() => void) | null } = { fireSuccess: null }; + const closeSpy = vi.fn(); + const staleDb = { close: closeSpy } as unknown as IDBDatabase; + const openSpy = vi.spyOn(indexedDB, 'open').mockImplementationOnce(() => { + const req = {} as IDBOpenDBRequest; + Object.defineProperty(req, 'result', { value: staleDb, configurable: true }); + stale.fireSuccess = () => req.onsuccess?.({ target: req } as unknown as Event); + return req; + }); + + // Operation A starts — captures the IDB open synchronously, still pending (onsuccess not yet fired). + const staleWrite = saveAdapter(META, new ArrayBuffer(0)); + const staleRejection = expect(staleWrite).rejects.toThrow(); + + // A real reset overlaps the pending open — its closer must invalidate the pending flight, not just the (still-null) cached database. + await beginIdbReset(); + endIdbReset(); + openSpy.mockRestore(); + + // The first legitimate post-reset operation (B) must start a genuinely NEW flight immediately — + // it must not be handed A's stale, still-pending promise and forced to wait on its rejection. + await saveAdapter(META, new ArrayBuffer(4)); + const afterImmediateRetry = await listAdapters(); + expect(afterImmediateRetry).toHaveLength(1); + + // A's late completion must discard itself (closing the stale db) without disturbing B's state. + stale.fireSuccess?.(); + await staleRejection; + expect(closeSpy).toHaveBeenCalledTimes(1); + const afterStaleCompletion = await listAdapters(); + expect(afterStaleCompletion).toHaveLength(1); + }); +}); diff --git a/tests/unit/services/ai/aiInferenceCacheServiceResetRetry.test.ts b/tests/unit/services/ai/aiInferenceCacheServiceResetRetry.test.ts new file mode 100644 index 000000000..c25b85db2 --- /dev/null +++ b/tests/unit/services/ai/aiInferenceCacheServiceResetRetry.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment node +// QNBS-v3: node environment avoids jsdom's non-configurable indexedDB stub — real IDB is required to prove the reset-retry fix (ensureDb() replacing the old one-shot dbReady promise). +import { IDBFactory, IDBKeyRange } from 'fake-indexeddb'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AiInferenceCacheService } from '../../../../services/ai/aiInferenceCacheService'; +import { + _resetIdbResetGateForTest, + beginIdbReset, + endIdbReset, +} from '../../../../services/storage/idbResetGate'; + +beforeEach(() => { + global.indexedDB = new IDBFactory(); + global.IDBKeyRange = IDBKeyRange; + _resetIdbResetGateForTest(); +}); + +afterEach(() => { + _resetIdbResetGateForTest(); +}); + +describe('AiInferenceCacheService — reset retry', () => { + // QNBS-v3: the original one-shot dbReady promise permanently fell back to in-memory-only for the rest of the session once the first open lost a race with a reset; ensureDb() must retry. + // Reads go through a SEPARATE fresh instance (empty in-memory LRU) so this proves the write + // actually reached durable IDB, not just the writer's own in-memory cache. + it('durably caches to IDB again after a factory reset attempt fails and ends', async () => { + const writer = new AiInferenceCacheService(); + + // A reset begins (closing the not-yet-open connection is a no-op here) and then fails before + // reaching reload — exactly wipeAllAppData()'s catch path. + await beginIdbReset(); + endIdbReset(); + + await writer.setCachedInference('prompt-a', 'model-a', 'result-a'); + + const reader = new AiInferenceCacheService(); + expect(await reader.getCachedInference('prompt-a', 'model-a')).toBe('result-a'); + }); + + // QNBS-v3: unlike the tests above (open, THEN reset, THEN open again sequentially), this exercises the actual generation race: the reset begins WHILE this open is still in flight, before its onsuccess has fired. + it('discards an open that was already in flight when a reset begins before it completes, then durably retries after the reset ends', async () => { + const writer = new AiInferenceCacheService(); + + // Starts the IDB open synchronously (ensureDb() -> openDb() -> indexedDB.open(), all within + // this call's synchronous prefix before it yields on `await this.ensureDb()`). + const staleWrite = writer.setCachedInference('stale', 'model-a', 'stale-result'); + + // The generation bump inside beginIdbReset() happens synchronously, before the pending open's + // onsuccess can possibly fire — this is the actual race the admission/generation pair closes. + await beginIdbReset(); + endIdbReset(); + + // The in-flight open must have discarded itself (either still-resetting or generation-mismatch, + // depending on exactly when its onsuccess fired) rather than caching an invalidated connection — + // the write silently no-ops (cache is best-effort/non-authoritative) instead of throwing. + await expect(staleWrite).resolves.toBeUndefined(); + expect(await new AiInferenceCacheService().getCachedInference('stale', 'model-a')).toBeNull(); + + // A fresh attempt after the reset ended must retry and durably succeed. + await writer.setCachedInference('retry', 'model-a', 'retry-result'); + expect(await new AiInferenceCacheService().getCachedInference('retry', 'model-a')).toBe( + 'retry-result', + ); + }); + + it('discards a connection opened before a reset and durably re-opens fresh afterward', async () => { + type CacheInternals = { db: IDBDatabase | null }; + const writer = new AiInferenceCacheService(); + const internals = writer as unknown as CacheInternals; + + // Warm the connection before any reset exists. + await writer.setCachedInference('warm', 'model-a', 'warm-result'); + expect(await new AiInferenceCacheService().getCachedInference('warm', 'model-a')).toBe( + 'warm-result', + ); + // QNBS-v3: a durable post-reset round-trip alone doesn't prove the pre-reset connection actually closed — a still-open connection would pass it too. Capture identity to prove a genuine re-open happened. + const preResetDb = internals.db; + expect(preResetDb).not.toBeNull(); + + await beginIdbReset(); + endIdbReset(); + + // The reset's registered closer must have closed the pre-reset connection synchronously. + expect(internals.db).toBeNull(); + + // The pre-reset connection must be gone — a fresh write still durably round-trips. + await writer.setCachedInference('after-reset', 'model-a', 'after-reset-result'); + expect(await new AiInferenceCacheService().getCachedInference('after-reset', 'model-a')).toBe( + 'after-reset-result', + ); + // QNBS-v3: proves a genuinely NEW connection was opened, not the same pre-reset object somehow surviving. + expect(internals.db).not.toBeNull(); + expect(internals.db).not.toBe(preResetDb); + }); +}); diff --git a/tests/unit/settings/EncryptionRecoveryModal.test.tsx b/tests/unit/settings/EncryptionRecoveryModal.test.tsx index f7d135e0c..c688bbb1d 100644 --- a/tests/unit/settings/EncryptionRecoveryModal.test.tsx +++ b/tests/unit/settings/EncryptionRecoveryModal.test.tsx @@ -389,7 +389,9 @@ describe('EncryptionRecoveryModal', () => { screen.getByRole('button', { name: 'settings.data.dangerZone.factoryReset.button' }), ); await waitFor(() => - expect(screen.getByText('settings.privacy.encryptionRecoveryFailed')).toBeInTheDocument(), + expect( + screen.getByText('settings.data.dangerZone.factoryReset.failed'), + ).toBeInTheDocument(), ); expect(mockLoggerError).toHaveBeenCalledWith('Factory reset failed', { error: 'disk full' }); }); diff --git a/tests/unit/settings/IdbUnlockModal.test.tsx b/tests/unit/settings/IdbUnlockModal.test.tsx index 551981a65..92c222262 100644 --- a/tests/unit/settings/IdbUnlockModal.test.tsx +++ b/tests/unit/settings/IdbUnlockModal.test.tsx @@ -298,7 +298,9 @@ describe('IdbUnlockModal', () => { screen.getByRole('button', { name: 'settings.data.dangerZone.factoryReset.button' }), ); await waitFor(() => { - expect(screen.getByText('settings.privacy.encryptionRecoveryFailed')).toBeInTheDocument(); + expect( + screen.getByText('settings.data.dangerZone.factoryReset.failed'), + ).toBeInTheDocument(); }); expect(mockLoggerError).toHaveBeenCalledWith('Factory reset failed', { error: 'disk full', diff --git a/tests/unit/settings/SettingsModals.test.tsx b/tests/unit/settings/SettingsModals.test.tsx index 85b79ae24..2206d2ac2 100644 --- a/tests/unit/settings/SettingsModals.test.tsx +++ b/tests/unit/settings/SettingsModals.test.tsx @@ -16,6 +16,7 @@ const mockHandleResetProject = vi.fn(); const mockHandleCreateSnapshot = vi.fn(); const mockHandleRestoreSnapshot = vi.fn(); const mockHandleDeleteSnapshot = vi.fn(); +const mockHandleFactoryReset = vi.fn(); const mockSetSnapshotName = vi.fn(); let mockModal: { state: string; payload: Record } = { @@ -35,6 +36,7 @@ vi.mock('../../../contexts/SettingsViewContext', () => ({ handleCreateSnapshot: mockHandleCreateSnapshot, handleRestoreSnapshot: mockHandleRestoreSnapshot, handleDeleteSnapshot: mockHandleDeleteSnapshot, + handleFactoryReset: mockHandleFactoryReset, currentWordCount: 1500, }), })); @@ -159,4 +161,39 @@ describe('SettingsModals', () => { expect(mockHandleDeleteSnapshot).toHaveBeenCalled(); }); }); + + describe('factoryReset modal', () => { + beforeEach(() => { + mockModal = { state: 'factoryReset', payload: {} }; + }); + + it('renders factory reset modal title', () => { + render(); + expect( + screen.getByText('settings.data.dangerZone.factoryReset.modalTitle'), + ).toBeInTheDocument(); + }); + + it('renders the warning text', () => { + render(); + expect( + screen.getByText('settings.data.dangerZone.factoryReset.modalWarning'), + ).toBeInTheDocument(); + }); + + // QNBS-v3: this is the E2E recovery flow's own click target — it must stay findable by testid, not just visible label text. + it('calls handleFactoryReset when the stable-testid confirm button is clicked', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId('factory-reset-confirm-button')); + expect(mockHandleFactoryReset).toHaveBeenCalled(); + }); + + it('calls setModal with closed when cancel clicked', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByText('common.cancel')); + expect(mockSetModal).toHaveBeenCalledWith({ state: 'closed', payload: {} }); + }); + }); }); diff --git a/tests/unit/storage/idbResetGate.test.ts b/tests/unit/storage/idbResetGate.test.ts new file mode 100644 index 000000000..e7de5796a --- /dev/null +++ b/tests/unit/storage/idbResetGate.test.ts @@ -0,0 +1,256 @@ +/** + * Tests for services/storage/idbResetGate.ts + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + _resetIdbResetGateForTest, + beginIdbOpenAdmission, + beginIdbReset, + currentIdbResetGeneration, + endIdbReset, + isIdbOpenStillValid, + isIdbResetInProgress, + registerIdbConnectionCloser, +} from '../../../services/storage/idbResetGate'; + +vi.mock('../../../services/logger', () => ({ + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, +})); + +afterEach(() => { + _resetIdbResetGateForTest(); +}); + +describe('idbResetGate', () => { + it('reports no reset in progress by default', () => { + expect(isIdbResetInProgress()).toBe(false); + }); + + it('marks a reset in progress, advances the generation, and awaits every registered closer before resolving', async () => { + let resolveCloser: () => void = () => {}; + const closerA = vi.fn(); + const closerB = vi.fn( + () => + new Promise((resolve) => { + resolveCloser = resolve; + }), + ); + registerIdbConnectionCloser(closerA); + registerIdbConnectionCloser(closerB); + const startGeneration = currentIdbResetGeneration(); + + let resetSettled = false; + const resetPromise = beginIdbReset().then(() => { + resetSettled = true; + }); + + // QNBS-v3: beginIdbReset must not resolve while an async closer is still in flight. + await Promise.resolve(); + await Promise.resolve(); + expect(isIdbResetInProgress()).toBe(true); + expect(currentIdbResetGeneration()).toBe(startGeneration + 1); + expect(closerA).toHaveBeenCalledTimes(1); + expect(closerB).toHaveBeenCalledTimes(1); + expect(resetSettled).toBe(false); + + resolveCloser(); + await resetPromise; + expect(resetSettled).toBe(true); + }); + + it('clears the in-progress flag when a reset ends, without reverting the generation', async () => { + await beginIdbReset(); + const generationAfterReset = currentIdbResetGeneration(); + expect(isIdbResetInProgress()).toBe(true); + + endIdbReset(); + + expect(isIdbResetInProgress()).toBe(false); + expect(currentIdbResetGeneration()).toBe(generationAfterReset); + }); + + it('lets a closer unregister itself so a later reset does not call it again', async () => { + const closer = vi.fn(); + const unregister = registerIdbConnectionCloser(closer); + + unregister(); + await beginIdbReset(); + + expect(closer).not.toHaveBeenCalled(); + }); + + // QNBS-v3: a connection constructed while a reset is already iterating must not survive that same reset by registering for some future one instead. + it('invokes a closer registered while a reset is already in progress, against the current reset', async () => { + let resolveFirstCloser: () => void = () => {}; + registerIdbConnectionCloser( + () => + new Promise((resolve) => { + resolveFirstCloser = resolve; + }), + ); + const resetPromise = beginIdbReset(); + await Promise.resolve(); + expect(isIdbResetInProgress()).toBe(true); + + const lateCloser = vi.fn(); + registerIdbConnectionCloser(lateCloser); + + // QNBS-v3: invoked synchronously against the CURRENT reset — not merely enrolled for a future one. + expect(lateCloser).toHaveBeenCalledTimes(1); + + resolveFirstCloser(); + await resetPromise; + }); + + it('does not call a closer registered before any reset has ever begun, until beginIdbReset actually runs', () => { + const closer = vi.fn(); + registerIdbConnectionCloser(closer); + expect(closer).not.toHaveBeenCalled(); + }); + + it('rejects, logs, and stays fail-closed (in progress) when a closer rejects, without stopping other closers', async () => { + const { logger } = await import('../../../services/logger'); + const failingCloser = vi.fn().mockRejectedValue(new Error('close failed')); + const okCloser = vi.fn(); + registerIdbConnectionCloser(failingCloser); + registerIdbConnectionCloser(okCloser); + + // QNBS-v3: beginIdbReset() must fail closed — a caller like wipeAllAppData() relies on this rejection to skip database deletion entirely. + await expect(beginIdbReset()).rejects.toThrow(/1 closer\(s\) failed/); + + expect(okCloser).toHaveBeenCalledTimes(1); + expect(isIdbResetInProgress()).toBe(true); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('1 connection closer(s) failed'), + expect.objectContaining({ errors: ['close failed'] }), + ); + }); + + // QNBS-v3: proves every closer still gets its chance to run even when an earlier one fails — the aggregate rejection only surfaces after the full Promise.allSettled round completes. + it('runs every closer to completion even when an earlier one rejects, before aggregating the failure', async () => { + const order: string[] = []; + const failingCloser = vi.fn(async () => { + order.push('failing-start'); + throw new Error('close failed'); + }); + const slowOkCloser = vi.fn(async () => { + order.push('slow-start'); + await Promise.resolve(); + order.push('slow-end'); + }); + registerIdbConnectionCloser(failingCloser); + registerIdbConnectionCloser(slowOkCloser); + + await expect(beginIdbReset()).rejects.toThrow(); + + expect(order).toContain('slow-end'); + expect(slowOkCloser).toHaveBeenCalledTimes(1); + }); + + // QNBS-v3: the second required invariant — a closer registered mid-reset must join the SAME awaited barrier, not race ahead of it, so beginIdbReset cannot settle (resolve OR reject) while that late closer is still in flight. + it('does not settle beginIdbReset until a late-registered, deliberately delayed closer also finishes', async () => { + let resolveLateCloser: () => void = () => {}; + const order: string[] = []; + registerIdbConnectionCloser(() => { + order.push('early-closer-ran'); + }); + const resetPromise = beginIdbReset().then(() => { + order.push('reset-settled'); + }); + await Promise.resolve(); + await Promise.resolve(); + + // QNBS-v3: registered AFTER the reset started iterating — must not be deferred to some future reset. + registerIdbConnectionCloser( + () => + new Promise((resolve) => { + order.push('late-closer-started'); + resolveLateCloser = resolve; + }), + ); + + // Give any (incorrect) fire-and-forget path a chance to race ahead before we resolve the late closer. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(order).toEqual(['early-closer-ran', 'late-closer-started']); + expect(isIdbResetInProgress()).toBe(true); + + resolveLateCloser(); + await resetPromise; + + expect(order).toEqual(['early-closer-ran', 'late-closer-started', 'reset-settled']); + }); + + // QNBS-v3: the core invariant this module exists for — a stale open cannot become cached once the generation it was captured against is no longer current, even after the reset that advanced it has already ended. + it('generation mismatch persists after a failed reset ends, so a late-completing open from before it started stays invalidated', async () => { + const capturedGeneration = currentIdbResetGeneration(); + + await beginIdbReset(); + endIdbReset(); // simulates wipeAllAppData() failing before reload + + expect(isIdbResetInProgress()).toBe(false); + // QNBS-v3: isIdbResetInProgress() alone would wrongly say it's now safe to cache — the generation check is what actually catches this. + expect(currentIdbResetGeneration()).not.toBe(capturedGeneration); + }); + + describe('beginIdbOpenAdmission / isIdbOpenStillValid', () => { + it('admits an open with the current generation when no reset is in progress', () => { + const token = beginIdbOpenAdmission(); + expect(token).toBe(currentIdbResetGeneration()); + expect(isIdbOpenStillValid(token as number)).toBe(true); + }); + + // QNBS-v3: the P1 this pair exists to close — a naive generation-only check captures the reset's OWN already-bumped generation for an open that starts mid-reset, so the comparison at completion would wrongly still match. + it('refuses admission for an open that would start while a reset is already in progress', async () => { + let resolveCloser: () => void = () => {}; + registerIdbConnectionCloser( + () => + new Promise((resolve) => { + resolveCloser = resolve; + }), + ); + const resetPromise = beginIdbReset(); + await Promise.resolve(); + expect(isIdbResetInProgress()).toBe(true); + + // A caller that tries to start a fresh open mid-reset must be refused, not admitted against the reset's own current generation. + expect(beginIdbOpenAdmission()).toBeNull(); + + resolveCloser(); + await resetPromise; + }); + + it('invalidates an admitted open once a reset starts before that open completes, even while the reset is still running', async () => { + const token = beginIdbOpenAdmission() as number; + expect(token).not.toBeNull(); + + let resolveCloser: () => void = () => {}; + registerIdbConnectionCloser( + () => + new Promise((resolve) => { + resolveCloser = resolve; + }), + ); + const resetPromise = beginIdbReset(); + await Promise.resolve(); + expect(isIdbResetInProgress()).toBe(true); + + // QNBS-v3: generation alone wouldn't yet prove anything here if this open's completion raced ahead of the reset's own bump, but isIdbOpenStillValid also checks isIdbResetInProgress(). + expect(isIdbOpenStillValid(token)).toBe(false); + + resolveCloser(); + await resetPromise; + expect(isIdbOpenStillValid(token)).toBe(false); + }); + + it('stays invalid for a pre-reset token even after a failed reset ends and the flag flips back to false', async () => { + const token = beginIdbOpenAdmission() as number; + await beginIdbReset(); + endIdbReset(); + + expect(isIdbResetInProgress()).toBe(false); + expect(isIdbOpenStillValid(token)).toBe(false); + }); + }); +});
{t('settings.data.dangerZone.factoryReset.modalDescription')}