diff --git a/README.md b/README.md index 7b84b2030..a8510f65f 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ Release v1.28.3 IndexedDB v8 PWA v3.0 - i18n 19 locales — 2937 keys - 7357+ tests / 595 files + i18n 19 locales — 2938 keys + 7389+ tests / 597 files Codecov Coverage License MIT CI Status @@ -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 }) => ( 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 = () => { - 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-