diff --git a/App.tsx b/App.tsx index ff4dbae..17560d7 100644 --- a/App.tsx +++ b/App.tsx @@ -22,7 +22,7 @@ import { createSavedAdId, deleteSavedAd, getLatestTopicArchive, - initializeDurableDataMirrors, + verifyDurableDataMirrors, saveTopicArchive, touchSavedAdLastUsed, upsertSavedAd, @@ -627,7 +627,9 @@ const App: React.FC = () => { // Check for API keys on mount; never show modal on load so user can see the app first useEffect(() => { - void initializeDurableDataMirrors(); + // Stage 2: asynchronously shadow-read, compare, and reconcile both IDB + // mirrors. All user-facing reads remain localStorage-backed. + void verifyDurableDataMirrors(); const checkApiKeys = async () => { setApiKeyCheckDone(true); diff --git a/__tests__/tefArchiveStage1Mirror.test.ts b/__tests__/tefArchiveStage1Mirror.test.ts index 42739dd..b0e7294 100644 --- a/__tests__/tefArchiveStage1Mirror.test.ts +++ b/__tests__/tefArchiveStage1Mirror.test.ts @@ -154,6 +154,7 @@ async function seedVersionTwo(params: { } async function seedVersionThree(params: { + ads?: TefSavedAd[]; archives?: TefTopicArchive[]; scenarios?: Scenario[]; } = {}): Promise { @@ -165,7 +166,8 @@ async function seedVersionThree(params: { request.result.createObjectStore('migrationMetadata', { keyPath: 'name' }); }; const db = await requestResult(request); - const tx = db.transaction(['topicArchives', 'scenarios'], 'readwrite'); + const tx = db.transaction(['savedAds', 'topicArchives', 'scenarios'], 'readwrite'); + for (const record of params.ads ?? []) tx.objectStore('savedAds').put(record); for (const record of params.archives ?? []) tx.objectStore('topicArchives').put(record); for (const record of params.scenarios ?? []) tx.objectStore('scenarios').put(record); await transactionDone(tx); @@ -176,6 +178,24 @@ async function openCurrentDatabase(): Promise { return await requestResult(indexedDB.open('parle-tef')); } +async function failReadWriteTransactionsForStore(storeName: string) { + const db = await openCurrentDatabase(); + const databasePrototype = Object.getPrototypeOf(db) as IDBDatabase; + db.close(); + const originalTransaction = databasePrototype.transaction; + return vi.spyOn(databasePrototype, 'transaction').mockImplementation(function ( + this: IDBDatabase, + ...args: Parameters + ) { + const [storeNames, mode] = args; + const names = typeof storeNames === 'string' ? [storeNames] : Array.from(storeNames); + if (mode === 'readwrite' && names.includes(storeName)) { + throw new Error(`Forced ${storeName} mirror transaction failure`); + } + return originalTransaction.apply(this, args); + }); +} + describe('Stage 1 durable exercise data IndexedDB mirrors', () => { beforeEach(() => { localStorage.clear(); @@ -465,3 +485,321 @@ describe('Stage 1 durable exercise data IndexedDB mirrors', () => { expect(mirrored.find((scenario) => scenario.id === current.id)).toEqual(current); }); }); + +describe('Stage 2 durable exercise data shadow verification', () => { + beforeEach(() => { + localStorage.clear(); + vi.resetModules(); + vi.stubGlobal('indexedDB', new IDBFactory()); + }); + + it('detects same-count content mismatches and repairs both datasets independently', async () => { + const ads = [savedAd('ad-1'), savedAd('ad-2')]; + const archives = [archive('archive-1', 'ad-1'), archive('archive-2', 'ad-2', 200)]; + const scenarios = [legacyScenario(), currentScenario()]; + const serializedArchives = JSON.stringify(archives); + const serializedScenarios = JSON.stringify(scenarios); + localStorage.setItem(TOPIC_ARCHIVES_KEY, serializedArchives); + localStorage.setItem(SCENARIOS_KEY, serializedScenarios); + await seedVersionThree({ + ads, + archives: [ + { ...archive('archive-1', 'missing-ad'), exerciseType: 'questioning' }, + archive('extra-archive', 'ad-1'), + ], + scenarios: [ + { ...legacyScenario(), name: 'Different legacy name' }, + currentScenario('extra-scenario'), + ], + }); + const service = await import('../services/tefArchiveService'); + + const result = await service.verifyDurableDataMirrors(); + + expect(result.topicArchives).toMatchObject({ + operation: 'shadow-verify', + success: true, + sourceRecordCount: 2, + destinationRecordCount: 2, + comparison: { + missingIds: ['archive-2'], + extraIds: ['extra-archive'], + differingIds: ['archive-1'], + relationshipInvalidIds: ['archive-1'], + legacyShapeIds: [], + }, + postRepairIntegrity: { + relationshipInvalidIds: [], + legacyShapeIds: [], + }, + repairs: { inserted: 1, updated: 1, deleted: 1 }, + }); + expect(result.scenarios).toMatchObject({ + operation: 'shadow-verify', + success: true, + comparison: { + missingIds: ['current-scenario'], + extraIds: ['extra-scenario'], + differingIds: ['legacy-scenario'], + legacyShapeIds: ['legacy-scenario'], + }, + repairs: { inserted: 1, updated: 1, deleted: 1 }, + }); + expect(await service.getTopicArchiveMirrorSnapshot()).toEqual(expect.arrayContaining(archives)); + expect(await service.getTopicArchiveMirrorSnapshot()).toHaveLength(2); + expect(await service.getScenarioMirrorSnapshot()).toEqual(expect.arrayContaining(scenarios)); + expect(await service.getScenarioMirrorSnapshot()).toHaveLength(2); + expect(localStorage.getItem(TOPIC_ARCHIVES_KEY)).toBe(serializedArchives); + expect(localStorage.getItem(SCENARIOS_KEY)).toBe(serializedScenarios); + expect(await service.getTopicArchiveMigrationMetadata()).toMatchObject({ + verificationStatus: 'verified', + mismatchCounts: { missing: 1, extra: 1, differing: 1 }, + repairCounts: { inserted: 1, updated: 1, deleted: 1 }, + preRepairIntegrityCounts: { relationshipInvalid: 1, legacyShape: 0 }, + postRepairIntegrityCounts: { relationshipInvalid: 0, legacyShape: 0 }, + relationshipInvalidRecordCount: 0, + }); + expect(await service.getScenarioMigrationMetadata()).toMatchObject({ + verificationStatus: 'verified', + mismatchCounts: { missing: 1, extra: 1, differing: 1 }, + repairCounts: { inserted: 1, updated: 1, deleted: 1 }, + legacyShapeRecordCount: 1, + }); + }); + + it('reports invalid archive relationships without altering the authoritative archive', async () => { + const orphan = archive('orphan-archive', 'deleted-ad'); + const serialized = JSON.stringify([orphan]); + localStorage.setItem(TOPIC_ARCHIVES_KEY, serialized); + const service = await import('../services/tefArchiveService'); + + const result = await service.verifyTopicArchiveMirror(); + + expect(result).toMatchObject({ + success: true, + comparison: { + missingIds: ['orphan-archive'], + relationshipInvalidIds: ['orphan-archive'], + }, + postRepairIntegrity: { + relationshipInvalidIds: ['orphan-archive'], + }, + }); + expect(await service.getTopicArchiveMirrorSnapshot()).toEqual([orphan]); + expect(localStorage.getItem(TOPIC_ARCHIVES_KEY)).toBe(serialized); + expect(await service.getTopicArchiveMigrationMetadata()).toMatchObject({ + verificationStatus: 'verified', + preRepairIntegrityCounts: { relationshipInvalid: 1, legacyShape: 0 }, + postRepairIntegrityCounts: { relationshipInvalid: 1, legacyShape: 0 }, + relationshipInvalidRecordCount: 1, + }); + }); + + it('persists an independent failed verification without mutating malformed localStorage', async () => { + const safeScenario = currentScenario(); + const serializedScenarios = JSON.stringify([safeScenario]); + localStorage.setItem(TOPIC_ARCHIVES_KEY, '{malformed'); + localStorage.setItem(SCENARIOS_KEY, serializedScenarios); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const service = await import('../services/tefArchiveService'); + + const result = await service.verifyDurableDataMirrors(); + + expect(result.topicArchives).toMatchObject({ + success: false, + operation: 'shadow-verify', + error: 'Authoritative localStorage TEF topic archives are unreadable', + }); + expect(result.scenarios.success).toBe(true); + expect(localStorage.getItem(TOPIC_ARCHIVES_KEY)).toBe('{malformed'); + expect(localStorage.getItem(SCENARIOS_KEY)).toBe(serializedScenarios); + expect(await service.getTopicArchiveMigrationMetadata()).toMatchObject({ + verificationStatus: 'failed', + verificationError: 'Authoritative localStorage TEF topic archives are unreadable', + }); + expect(await service.getScenarioMigrationMetadata()).toMatchObject({ + verificationStatus: 'verified', + }); + consoleError.mockRestore(); + }); + + it('already-matching archive and scenario stores verify independently with zero mismatches', async () => { + const ad = savedAd('ad-1'); + const archives = [archive('matching-archive', ad.id)]; + const scenarios = [currentScenario('matching-scenario')]; + localStorage.setItem(TOPIC_ARCHIVES_KEY, JSON.stringify(archives)); + localStorage.setItem(SCENARIOS_KEY, JSON.stringify(scenarios)); + await seedVersionThree({ ads: [ad], archives, scenarios }); + const service = await import('../services/tefArchiveService'); + + const archiveResult = await service.verifyTopicArchiveMirror(); + + expect(archiveResult).toMatchObject({ + success: true, + comparison: { + missingIds: [], + extraIds: [], + differingIds: [], + relationshipInvalidIds: [], + legacyShapeIds: [], + }, + postRepairIntegrity: { + relationshipInvalidIds: [], + legacyShapeIds: [], + }, + repairs: { inserted: 0, updated: 0, deleted: 0 }, + }); + expect(await service.getScenarioMigrationMetadata()).toBeNull(); + + const scenarioResult = await service.verifyScenarioMirror(); + + expect(scenarioResult).toMatchObject({ + success: true, + comparison: { + missingIds: [], + extraIds: [], + differingIds: [], + relationshipInvalidIds: [], + legacyShapeIds: [], + }, + postRepairIntegrity: { + relationshipInvalidIds: [], + legacyShapeIds: [], + }, + repairs: { inserted: 0, updated: 0, deleted: 0 }, + }); + expect(await service.getTopicArchiveMigrationMetadata()).toMatchObject({ + verificationStatus: 'verified', + mismatchCounts: { missing: 0, extra: 0, differing: 0 }, + repairCounts: { inserted: 0, updated: 0, deleted: 0 }, + postRepairIntegrityCounts: { relationshipInvalid: 0, legacyShape: 0 }, + }); + expect(await service.getScenarioMigrationMetadata()).toMatchObject({ + verificationStatus: 'verified', + mismatchCounts: { missing: 0, extra: 0, differing: 0 }, + repairCounts: { inserted: 0, updated: 0, deleted: 0 }, + postRepairIntegrityCounts: { relationshipInvalid: 0, legacyShape: 0 }, + }); + }); + + it('invalidates archive verification when a later save mirror fails, then re-verifies on repair', async () => { + const ad = savedAd('ad-1'); + const initial = archive('initial-archive', ad.id); + localStorage.setItem(TOPIC_ARCHIVES_KEY, JSON.stringify([initial])); + await seedVersionThree({ ads: [ad], archives: [initial] }); + const service = await import('../services/tefArchiveService'); + await service.verifyTopicArchiveMirror(); + expect(await service.getTopicArchiveMigrationMetadata()).toMatchObject({ + verificationStatus: 'verified', + }); + + const transactionSpy = await failReadWriteTransactionsForStore('topicArchives'); + service.saveTopicArchive({ + adId: ad.id, + exerciseType: 'persuasion', + topicSuggestions: topics, + }); + const failedMirror = await service.waitForTopicArchiveMirror(); + transactionSpy.mockRestore(); + + expect(failedMirror).toMatchObject({ + operation: 'save', + success: false, + error: 'Forced topicArchives mirror transaction failure', + }); + expect(service.listTopicArchives()).toHaveLength(2); + expect(await service.getTopicArchiveMigrationMetadata()).toMatchObject({ + verificationStatus: 'failed', + verificationError: 'Forced topicArchives mirror transaction failure', + }); + + expect((await service.verifyTopicArchiveMirror()).success).toBe(true); + expect(await service.getTopicArchiveMigrationMetadata()).toMatchObject({ + verificationStatus: 'verified', + sourceRecordCount: 2, + destinationRecordCount: 2, + }); + }); + + it('invalidates scenario verification when a later save mirror fails, then re-verifies on repair', async () => { + const initial = currentScenario('scenario-mutation-failure'); + localStorage.setItem(SCENARIOS_KEY, JSON.stringify([initial])); + await seedVersionThree({ scenarios: [initial] }); + const service = await import('../services/tefArchiveService'); + const scenarioService = await import('../services/scenarioService'); + await service.verifyScenarioMirror(); + expect(await service.getScenarioMigrationMetadata()).toMatchObject({ + verificationStatus: 'verified', + }); + + const transactionSpy = await failReadWriteTransactionsForStore('scenarios'); + scenarioService.saveScenario({ ...initial, name: 'Saved locally only at first' }); + const failedMirror = await service.waitForScenarioMirror(); + transactionSpy.mockRestore(); + + expect(failedMirror).toMatchObject({ + operation: 'save', + success: false, + error: 'Forced scenarios mirror transaction failure', + }); + expect(scenarioService.loadScenarios()[0].name).toBe('Saved locally only at first'); + expect(await service.getScenarioMigrationMetadata()).toMatchObject({ + verificationStatus: 'failed', + verificationError: 'Forced scenarios mirror transaction failure', + }); + + expect((await service.verifyScenarioMirror()).success).toBe(true); + expect(await service.getScenarioMigrationMetadata()).toMatchObject({ + verificationStatus: 'verified', + sourceRecordCount: 1, + destinationRecordCount: 1, + }); + }); + + it('keeps both datasets dirty when IndexedDB cannot persist failure metadata', async () => { + const ad = savedAd('ad-1'); + const initialArchive = archive('initial-offline-archive', ad.id); + const initialScenario = currentScenario('initial-offline-scenario'); + localStorage.setItem(TOPIC_ARCHIVES_KEY, JSON.stringify([initialArchive])); + localStorage.setItem(SCENARIOS_KEY, JSON.stringify([initialScenario])); + await seedVersionThree({ + ads: [ad], + archives: [initialArchive], + scenarios: [initialScenario], + }); + const service = await import('../services/tefArchiveService'); + const scenarioService = await import('../services/scenarioService'); + await service.verifyDurableDataMirrors(); + + const availableIndexedDb = indexedDB; + vi.stubGlobal('indexedDB', undefined); + service.saveTopicArchive({ + adId: ad.id, + exerciseType: 'persuasion', + topicSuggestions: topics, + }); + scenarioService.saveScenario({ ...initialScenario, name: 'Changed while IDB unavailable' }); + expect((await service.waitForTopicArchiveMirror())?.success).toBe(false); + expect((await service.waitForScenarioMirror())?.success).toBe(false); + + vi.stubGlobal('indexedDB', availableIndexedDb); + expect(await service.getTopicArchiveMigrationMetadata()).toMatchObject({ + verificationStatus: 'failed', + verificationError: 'Authoritative localStorage changed after the last verified reconciliation', + }); + expect(await service.getScenarioMigrationMetadata()).toMatchObject({ + verificationStatus: 'failed', + verificationError: 'Authoritative localStorage changed after the last verified reconciliation', + }); + + const repaired = await service.verifyDurableDataMirrors(); + expect(repaired.topicArchives.success).toBe(true); + expect(repaired.scenarios.success).toBe(true); + expect(await service.getTopicArchiveMigrationMetadata()).toMatchObject({ + verificationStatus: 'verified', + }); + expect(await service.getScenarioMigrationMetadata()).toMatchObject({ + verificationStatus: 'verified', + }); + }); +}); diff --git a/docs/data-portability/README.md b/docs/data-portability/README.md index 904426d..8ff324c 100644 --- a/docs/data-portability/README.md +++ b/docs/data-portability/README.md @@ -6,11 +6,11 @@ separate branches, deployments, and AI-agent chats. ## Current status -- Program status: **Stage 1 complete and verified in the deployed application** -- Current implementation behavior: **localStorage-authoritative IndexedDB mirror** +- Program status: **Stage 2 implemented and verified locally; deployment verification pending** +- Current implementation behavior: **localStorage-authoritative IndexedDB mirror with background shadow verification** - Deployed behavior: **Stage 1 verified; both durable datasets mirror to IndexedDB** - Last completed stage: **[Stage 1 — IndexedDB mirror](stages/01-idb-mirror.md)** -- Next action: **implement [Stage 2 — shadow verification and reconciliation](stages/02-shadow-verification.md)** +- Next action: **deploy and observe [Stage 2 — shadow verification and reconciliation](stages/02-shadow-verification.md)** - Current authoritative topic-archive source: `localStorage` - Current authoritative saved-scenario source: `localStorage` - IndexedDB topic-archive store exists in this implementation: **yes (schema version 3)** @@ -93,7 +93,7 @@ See [backup-format.md](backup-format.md), [migration-plan.md](migration-plan.md) |---|---|---| | 0 | Specify scope, invariants, and handoff process | Complete (documentation only) | | 1 | Add IndexedDB mirrors for topic archives and saved scenarios | Complete; deployed and verified | -| 2 | Shadow-read, compare, and reconcile both datasets | Next | +| 2 | Shadow-read, compare, and reconcile both datasets | Implemented/tested locally; deployment pending | | 3 | Make IndexedDB primary for both with localStorage fallback | Pending | | 4 | Maintain rollback windows and prove both datasets stable | Pending | | 5 | Implement versioned export/import | Pending | diff --git a/docs/data-portability/stages/02-shadow-verification.md b/docs/data-portability/stages/02-shadow-verification.md index 2be981a..02ba9f4 100644 --- a/docs/data-portability/stages/02-shadow-verification.md +++ b/docs/data-portability/stages/02-shadow-verification.md @@ -1,6 +1,6 @@ # Stage 2 — Shadow Verification and Reconciliation -Status: **next — Stage 1 is deployed and verified** +Status: **implemented and verified locally — deployment verification pending** ## Objective @@ -40,3 +40,66 @@ authoritative localStorage sources while the UI continues reading localStorage. Record branch, commits, tests, merge, deployment, observed verification results, mismatch counts, and repairs. Then update `../README.md` so Stage 3 is next only if evidence is clean. + +### Implementation handoff (2026-07-26) + +- Branch: `codex/data-portability-stage-2` +- Base commit: `424d614` (`Mark data portability Stage 1 complete`) +- Implementation commits: + - `cca0a41` (`Implement data portability Stage 2`) + - `0536344` (`Address Stage 2 verification review`) +- Pull request: [#46](https://github.com/CodeWithOz/parle/pull/46) (draft) +- Merge/deployment references: pending +- Implementation: + - App startup now runs `verifyDurableDataMirrors()` in the background. + - Topic archives and saved scenarios are still displayed exclusively from their existing + synchronous localStorage read paths. + - Each verifier first shadow-reads its IndexedDB store, compares records by ID and canonical + content, then reconciles missing, extra, and differing records from authoritative + localStorage. + - Diagnostics expose the exact missing, extra, differing, relationship-invalid, and + legacy-shape IDs observed before repair, the post-repair integrity IDs, plus + inserted/updated/deleted repair counts. + - Independent migration metadata records persist verification timestamps, mismatch counts, + repair counts, separate pre/post-repair integrity counts, and failed-verification details. + The backward-compatible flat relationship/legacy counts describe post-repair canonical + state. New fields are optional so Stage 1 metadata remains readable. + - Topic archive relationship diagnostics resolve `adId` against IndexedDB `savedAds`. + Orphaned authoritative archives are reported but retained and mirrored exactly; Stage 2 + does not silently delete or rewrite localStorage user data. + - Legacy scenarios are reported without adding absent `characters` or `steps` fields. + - Malformed/unreadable localStorage produces a failed diagnostic and, when IndexedDB is + available, durable failed-verification metadata. It never triggers reconciliation or a + mutation of the authoritative localStorage data values. + - Every failed mirror operation (including later saves and deletions) invalidates that + dataset's prior verified metadata. A per-dataset localStorage dirty latch covers the case + where IndexedDB is unavailable and cannot update its own metadata; successful + reconciliation clears only the matching latch, so overlapping queued writes remain safe. +- Automated verification: + - Focused migration suite: + `NODE_OPTIONS=--no-experimental-webstorage npm test -- --run __tests__/tefArchiveStage1Mirror.test.ts __tests__/tefArchiveService.test.ts __tests__/scenarioService.roadmapSteps.test.ts` + — 3 files / 32 tests passed. + - Full suite: + `NODE_OPTIONS=--no-experimental-webstorage npm test -- --run` + — 52 files / 625 tests passed (pre-existing warning output only). + - `npm run build` — passed (existing large-chunk warning only). + - Focused Stage 2 fixtures proved equal-count/different-content detection and exact repair + counts of one missing/inserted, one extra/deleted, and one differing/updated record for + each dataset. They also covered orphan relationship reporting, legacy-shape reporting, + independent failure metadata, verified-to-failed mutation transitions for both datasets, + the IndexedDB-unavailable dirty-latch path, independently matching zero-mismatch stores, + and byte-for-byte preservation of authoritative localStorage data values. +- Review: + - The full CodeRabbit review timed out without saving findings. A subsequent light review + completed successfully with no code findings and one documentation-only minor finding, + addressed in this record. + - A verification review then suggested wrapping the complete mirror flow in Web Locks. That + change was not adopted: the dirty latch must be written synchronously after a successful + localStorage mutation, before any asynchronous lock wait; cross-tab convergence is already + guarded by shared compare-and-clear tokens, IndexedDB transaction serialization, and + post-reconciliation source/mirror stability checks. Dirty tokens now include a random UUID + to prevent theoretical same-millisecond, same-sequence collisions across tabs. + - Manual review confirmed and addressed two pre-deployment findings: stale verified metadata + after mutation mirror failures, and ambiguous pre/post-repair integrity counts. +- Manual browser/deployment verification: pending. Stage 3 remains unauthorized until the + Stage 2 deployment is observed and this record is updated with real per-dataset results. diff --git a/services/tefArchiveService.ts b/services/tefArchiveService.ts index ff0c66c..74baaa4 100644 --- a/services/tefArchiveService.ts +++ b/services/tefArchiveService.ts @@ -1,6 +1,9 @@ import type { DurableDataMigrationMetadata, DurableDataMigrationName, + DurableDataIntegrityCounts, + DurableDataMismatchCounts, + DurableDataRepairCounts, Scenario, ScenarioMigrationMetadata, TefExerciseType, @@ -12,6 +15,8 @@ import type { const TOPIC_ARCHIVES_KEY = 'parle-tef-topic-archives'; const SCENARIOS_KEY = 'parle-scenarios'; +const TOPIC_ARCHIVES_MIRROR_DIRTY_KEY = 'parle-tef-topic-archives-mirror-dirty'; +const SCENARIOS_MIRROR_DIRTY_KEY = 'parle-scenarios-mirror-dirty'; const MAX_TOPIC_ARCHIVES = 50; const MAX_SAVED_ADS_PER_TYPE = 20; const DB_NAME = 'parle-tef'; @@ -25,12 +30,32 @@ const SCENARIO_MIGRATION_NAME = 'scenarios-localstorage-to-idb'; const MAX_MIRROR_STABILITY_ATTEMPTS = 5; export interface DurableDataMirrorDiagnostic { - operation: 'backfill' | 'save' | 'delete' | 'delete-for-ad'; + operation: 'backfill' | 'shadow-verify' | 'save' | 'delete' | 'delete-for-ad'; success: boolean; sourceRecordCount: number; destinationRecordCount?: number; completedAt: number; error?: string; + /** Stage 2 pre-repair shadow comparison. IDs make the result directly testable. */ + comparison?: DurableDataShadowComparison; + /** Integrity of the reconciled canonical IndexedDB mirror. */ + postRepairIntegrity?: DurableDataIntegrityDiagnostic; + repairs?: DurableDataRepairCounts; +} + +export interface DurableDataIntegrityDiagnostic { + relationshipInvalidIds: string[]; + legacyShapeIds: string[]; +} + +export interface DurableDataShadowComparison { + missingIds: string[]; + extraIds: string[]; + differingIds: string[]; + /** Topic archives whose adId does not resolve in the savedAds store. */ + relationshipInvalidIds: string[]; + /** Valid pre-roadmap/pre-character scenarios retained without normalization. */ + legacyShapeIds: string[]; } export type TopicArchiveMirrorDiagnostic = DurableDataMirrorDiagnostic; @@ -47,38 +72,51 @@ interface MirrorSource { interface MirrorConfig { storageKey: string; + dirtyKey: string; storeName: string; metadataName: DurableDataMigrationName; label: string; + inspectRelationships?: (records: T[]) => Promise; + inspectLegacyShapes?: (records: T[]) => string[]; } interface MirrorState { queue: Promise; lastDiagnostic: DurableDataMirrorDiagnostic | null; + dirtyTokenSequence: number; } const topicArchiveMirrorConfig: MirrorConfig = { storageKey: TOPIC_ARCHIVES_KEY, + dirtyKey: TOPIC_ARCHIVES_MIRROR_DIRTY_KEY, storeName: TOPIC_ARCHIVES_STORE, metadataName: TOPIC_ARCHIVE_MIGRATION_NAME, label: 'TEF topic archive', + inspectRelationships: findArchivesWithMissingAds, }; const scenarioMirrorConfig: MirrorConfig = { storageKey: SCENARIOS_KEY, + dirtyKey: SCENARIOS_MIRROR_DIRTY_KEY, storeName: SCENARIOS_STORE, metadataName: SCENARIO_MIGRATION_NAME, label: 'saved scenario', + inspectLegacyShapes: (records) => records + .filter((record) => !Object.prototype.hasOwnProperty.call(record, 'characters') + || !Object.prototype.hasOwnProperty.call(record, 'steps')) + .map((record) => record.id), }; const topicArchiveMirrorState: MirrorState = { queue: Promise.resolve(), lastDiagnostic: null, + dirtyTokenSequence: 0, }; const scenarioMirrorState: MirrorState = { queue: Promise.resolve(), lastDiagnostic: null, + dirtyTokenSequence: 0, }; function createArchiveId(): string { @@ -280,6 +318,31 @@ function recordCollectionsMatch( }); } +function compareRecordCollections( + authoritative: T[], + mirrored: T[] +): Pick { + const authoritativeById = new Map(authoritative.map((record) => [record.id, record])); + const mirroredById = new Map(mirrored.map((record) => [record.id, record])); + const missingIds: string[] = []; + const differingIds: string[] = []; + const extraIds: string[] = []; + + for (const record of authoritative) { + const mirroredRecord = mirroredById.get(record.id); + if (mirroredRecord === undefined) { + missingIds.push(record.id); + } else if (canonicalJson(record) !== canonicalJson(mirroredRecord)) { + differingIds.push(record.id); + } + } + for (const record of mirrored) { + if (!authoritativeById.has(record.id)) extraIds.push(record.id); + } + + return { missingIds, extraIds, differingIds }; +} + async function readAllFromStore(storeName: string): Promise { const db = await openDb(); try { @@ -296,6 +359,70 @@ async function readAllFromStore(storeName: string): Promise { } } +async function findArchivesWithMissingAds(archives: TefTopicArchive[]): Promise { + const ads = await readAllFromStore(SAVED_ADS_STORE); + const savedAdIds = new Set(ads.map((ad) => ad.id)); + return archives + .filter((archive) => !savedAdIds.has(archive.adId)) + .map((archive) => archive.id); +} + +async function createShadowComparison( + config: MirrorConfig, + authoritative: T[], + mirrored: T[] +): Promise { + const recordsSeenDuringComparison = [...authoritative, ...mirrored]; + const integrity = await inspectRecordIntegrity(config, recordsSeenDuringComparison); + return { + ...compareRecordCollections(authoritative, mirrored), + ...integrity, + }; +} + +async function inspectRecordIntegrity( + config: MirrorConfig, + records: T[] +): Promise { + return { + relationshipInvalidIds: config.inspectRelationships + ? [...new Set(await config.inspectRelationships(records))] + : [], + legacyShapeIds: config.inspectLegacyShapes + ? [...new Set(config.inspectLegacyShapes(records))] + : [], + }; +} + +function mismatchCounts( + comparison: DurableDataShadowComparison +): DurableDataMismatchCounts { + return { + missing: comparison.missingIds.length, + extra: comparison.extraIds.length, + differing: comparison.differingIds.length, + }; +} + +function repairCounts( + comparison: DurableDataShadowComparison +): DurableDataRepairCounts { + return { + inserted: comparison.missingIds.length, + updated: comparison.differingIds.length, + deleted: comparison.extraIds.length, + }; +} + +function integrityCounts( + integrity: DurableDataIntegrityDiagnostic +): DurableDataIntegrityCounts { + return { + relationshipInvalid: integrity.relationshipInvalidIds.length, + legacyShape: integrity.legacyShapeIds.length, + }; +} + async function reconcileMirror( config: MirrorConfig, authoritative: T[] @@ -336,16 +463,27 @@ async function reconcileMirror( async function writeVerifiedMirrorMetadata( metadataName: DurableDataMigrationName, sourceRecordCount: number, - destinationRecordCount: number + destinationRecordCount: number, + comparison: DurableDataShadowComparison, + postRepairIntegrity: DurableDataIntegrityDiagnostic, + repairs: DurableDataRepairCounts ): Promise { + const now = Date.now(); const metadata: DurableDataMigrationMetadata = { name: metadataName, version: 1, state: 'mirroring', - lastReconciledAt: Date.now(), + lastReconciledAt: now, sourceRecordCount, destinationRecordCount, verificationStatus: 'verified', + lastVerifiedAt: now, + mismatchCounts: mismatchCounts(comparison), + repairCounts: repairs, + preRepairIntegrityCounts: integrityCounts(comparison), + postRepairIntegrityCounts: integrityCounts(postRepairIntegrity), + relationshipInvalidRecordCount: postRepairIntegrity.relationshipInvalidIds.length, + legacyShapeRecordCount: postRepairIntegrity.legacyShapeIds.length, }; const metadataDb = await openDb(); @@ -362,12 +500,60 @@ async function writeVerifiedMirrorMetadata( } } +async function writeFailedVerificationMetadata( + config: MirrorConfig, + sourceRecordCount: number, + error: string +): Promise { + let destinationRecordCount = 0; + let previous: DurableDataMigrationMetadata | null = null; + try { + [destinationRecordCount, previous] = await Promise.all([ + readAllFromStore(config.storeName).then((records) => records.length), + getMigrationMetadata(config.metadataName), + ]); + } catch { + // If IndexedDB itself is unavailable, there is nowhere durable to record + // the failed verification. The returned diagnostic still exposes it. + return false; + } + + const metadata: DurableDataMigrationMetadata = { + name: config.metadataName, + version: 1, + state: 'mirroring', + lastReconciledAt: previous?.lastReconciledAt ?? 0, + sourceRecordCount, + destinationRecordCount, + verificationStatus: 'failed', + lastVerifiedAt: Date.now(), + verificationError: error, + }; + + const db = await openDb(); + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(MIGRATION_METADATA_STORE, 'readwrite'); + tx.objectStore(MIGRATION_METADATA_STORE).put(metadata); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error ?? new Error('Failed to save failed verification metadata')); + tx.onabort = () => reject(tx.error ?? new Error('Failed verification metadata write was aborted')); + }); + } finally { + db.close(); + } + return true; +} + async function mirrorLatestSource( config: MirrorConfig, onSourceRead: (recordCount: number) => void ): Promise<{ sourceRecordCount: number; destinationRecordCount: number; + comparison: DurableDataShadowComparison; + postRepairIntegrity: DurableDataIntegrityDiagnostic; + repairs: DurableDataRepairCounts; }> { for (let attempt = 1; attempt <= MAX_MIRROR_STABILITY_ATTEMPTS; attempt += 1) { // Read only when this task owns the queue turn. Capturing at enqueue time @@ -378,6 +564,15 @@ async function mirrorLatestSource( throw new Error(`Authoritative localStorage ${config.label}s are unreadable`); } + // Stage 2 shadow-read: capture exact discrepancies before repairing the + // mirror. Equal counts are insufficient because IDs/content may differ. + const mirrorBeforeReconcile = await readAllFromStore(config.storeName); + const comparison = await createShadowComparison( + config, + source.records, + mirrorBeforeReconcile + ); + const repairs = repairCounts(comparison); const destinationRecordCount = await reconcileMirror(config, source.records); const sourceAfterReconcile = readMirrorSource(config); if (!sourceAfterReconcile.readable) { @@ -387,12 +582,21 @@ async function mirrorLatestSource( continue; } + const mirrorAfterReconcile = await readAllFromStore(config.storeName); + if (!recordCollectionsMatch(source.records, mirrorAfterReconcile)) { + continue; + } + const postRepairIntegrity = await inspectRecordIntegrity(config, mirrorAfterReconcile); + // Metadata is only stamped verified after confirming localStorage still // matches the snapshot that was reconciled. await writeVerifiedMirrorMetadata( config.metadataName, source.records.length, - destinationRecordCount + destinationRecordCount, + comparison, + postRepairIntegrity, + repairs ); // Close the remaining cross-tab window: if localStorage or another tab's @@ -402,9 +606,11 @@ async function mirrorLatestSource( throw new Error(`Authoritative localStorage ${config.label}s are unreadable`); } const mirrorAfterMetadata = await readAllFromStore(config.storeName); + const integrityAfterMetadata = await inspectRecordIntegrity(config, mirrorAfterMetadata); if ( !recordCollectionsMatch(source.records, sourceAfterMetadata.records) || - !recordCollectionsMatch(source.records, mirrorAfterMetadata) + !recordCollectionsMatch(source.records, mirrorAfterMetadata) || + canonicalJson(postRepairIntegrity) !== canonicalJson(integrityAfterMetadata) ) { continue; } @@ -412,6 +618,9 @@ async function mirrorLatestSource( return { sourceRecordCount: source.records.length, destinationRecordCount: mirrorAfterMetadata.length, + comparison, + postRepairIntegrity, + repairs, }; } @@ -422,12 +631,62 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function readMirrorDirtyToken(config: MirrorConfig): string | null { + try { + return localStorage.getItem(config.dirtyKey); + } catch { + return null; + } +} + +function markMirrorDirty( + config: MirrorConfig, + state: MirrorState +): string | null { + state.dirtyTokenSequence += 1; + const uniqueSuffix = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : Math.random().toString(36).slice(2); + const token = `${Date.now()}-${state.dirtyTokenSequence}-${uniqueSuffix}`; + try { + localStorage.setItem(config.dirtyKey, token); + return token; + } catch (error) { + console.error(`Failed to mark ${config.label} mirror dirty:`, error); + return null; + } +} + +function clearMirrorDirtyToken( + config: MirrorConfig, + expectedToken: string | null +): void { + if (expectedToken === null) return; + try { + if (localStorage.getItem(config.dirtyKey) === expectedToken) { + localStorage.removeItem(config.dirtyKey); + } + } catch (error) { + console.error(`Failed to clear ${config.label} mirror dirty marker:`, error); + } +} + +function isMutationMirrorOperation( + operation: DurableDataMirrorDiagnostic['operation'] +): boolean { + return operation === 'save' || operation === 'delete' || operation === 'delete-for-ad'; +} + function enqueueMirror( config: MirrorConfig, state: MirrorState, operation: DurableDataMirrorDiagnostic['operation'] ): Promise { let sourceRecordCount = 0; + const durableConfig = config as MirrorConfig; + const dirtyToken = isMutationMirrorOperation(operation) + ? markMirrorDirty(durableConfig, state) + : readMirrorDirtyToken(durableConfig); const diagnosticPromise = state.queue.then(async () => { try { @@ -441,8 +700,12 @@ function enqueueMirror( sourceRecordCount, destinationRecordCount: result.destinationRecordCount, completedAt: Date.now(), + comparison: result.comparison, + postRepairIntegrity: result.postRepairIntegrity, + repairs: result.repairs, }; state.lastDiagnostic = diagnostic; + clearMirrorDirtyToken(durableConfig, dirtyToken); return diagnostic; } catch (error) { const diagnostic: DurableDataMirrorDiagnostic = { @@ -453,6 +716,26 @@ function enqueueMirror( error: errorMessage(error), }; state.lastDiagnostic = diagnostic; + let failureMetadataPersisted = false; + try { + // Any failed mirror operation invalidates prior verification. A later + // successful reconciliation writes a fresh verified record. + failureMetadataPersisted = await writeFailedVerificationMetadata( + durableConfig, + sourceRecordCount, + errorMessage(error) + ); + } catch (metadataError) { + // The diagnostic is still returned when IndexedDB cannot persist its + // own failure metadata. Authoritative localStorage data is never changed. + console.error(`Failed to persist ${config.label} verification failure:`, metadataError); + } + if (!failureMetadataPersisted && readMirrorDirtyToken(durableConfig) === null) { + // A localStorage latch is the only durable invalidation available when + // IndexedDB cannot update its own metadata. It never changes the + // authoritative archive/scenario data keys. + markMirrorDirty(durableConfig, state); + } // JSDOM and other non-browser runtimes may not provide IndexedDB at all. // The returned diagnostic remains observable without flooding their logs. if (typeof indexedDB !== 'undefined') { @@ -466,10 +749,7 @@ function enqueueMirror( return diagnosticPromise; } -/** - * Stage 1 startup backfill. localStorage remains authoritative; this only - * reconciles the IndexedDB mirror and records verified migration metadata. - */ +/** Stage 1-compatible backfill entry points retained for queued mirror writes/tests. */ export function initializeTopicArchiveMirror(): Promise { return enqueueMirror(topicArchiveMirrorConfig, topicArchiveMirrorState, 'backfill'); } @@ -489,6 +769,30 @@ export async function initializeDurableDataMirrors(): Promise<{ return { topicArchives, scenarios }; } +/** + * Stage 2 background verification. Each dataset is shadow-read, compared by ID + * and canonical content, diagnosed, and independently reconciled from its + * authoritative localStorage source. Production reads remain unchanged. + */ +export function verifyTopicArchiveMirror(): Promise { + return enqueueMirror(topicArchiveMirrorConfig, topicArchiveMirrorState, 'shadow-verify'); +} + +export function verifyScenarioMirror(): Promise { + return enqueueMirror(scenarioMirrorConfig, scenarioMirrorState, 'shadow-verify'); +} + +export async function verifyDurableDataMirrors(): Promise<{ + topicArchives: TopicArchiveMirrorDiagnostic; + scenarios: ScenarioMirrorDiagnostic; +}> { + const [topicArchives, scenarios] = await Promise.all([ + verifyTopicArchiveMirror(), + verifyScenarioMirror(), + ]); + return { topicArchives, scenarios }; +} + /** Waits for queued fire-and-forget mirrors from synchronous public mutations. */ export async function waitForTopicArchiveMirror(): Promise { await topicArchiveMirrorState.queue; @@ -508,7 +812,7 @@ export function getLastScenarioMirrorDiagnostic(): ScenarioMirrorDiagnostic | nu return scenarioMirrorState.lastDiagnostic; } -/** Diagnostic/test access only. Production topic-history reads stay on localStorage in Stage 1. */ +/** Diagnostic/test access only. Production reads stay on localStorage through Stage 2. */ export function getTopicArchiveMirrorSnapshot(): Promise { return readAllFromStore(TOPIC_ARCHIVES_STORE); } @@ -520,6 +824,9 @@ export function getScenarioMirrorSnapshot(): Promise { async function getMigrationMetadata( name: DurableDataMigrationName ): Promise { + const config = (name === TOPIC_ARCHIVE_MIGRATION_NAME + ? topicArchiveMirrorConfig + : scenarioMirrorConfig) as MirrorConfig; const db = await openDb(); try { const tx = db.transaction(MIGRATION_METADATA_STORE, 'readonly'); @@ -531,7 +838,15 @@ async function getMigrationMetadata( tx.onerror = () => reject(tx.error ?? new Error('Failed reading migration metadata')); tx.onabort = () => reject(tx.error ?? new Error('Migration metadata read was aborted')); }); - return result ?? null; + if (!result) return null; + if (readMirrorDirtyToken(config) !== null && result.verificationStatus === 'verified') { + return { + ...result, + verificationStatus: 'failed', + verificationError: 'Authoritative localStorage changed after the last verified reconciliation', + }; + } + return result; } finally { db.close(); } diff --git a/types.ts b/types.ts index 5d800b3..7f21307 100644 --- a/types.ts +++ b/types.ts @@ -168,7 +168,36 @@ export interface DurableDataMigrationMetadata { lastReconciledAt: number; sourceRecordCount: number; destinationRecordCount: number; - verificationStatus: 'verified'; + verificationStatus: 'verified' | 'failed'; + /** Stage 2 shadow-verification details. Absent on metadata written by Stage 1. */ + lastVerifiedAt?: number; + mismatchCounts?: DurableDataMismatchCounts; + repairCounts?: DurableDataRepairCounts; + /** Integrity observed before reconciliation, including stale mirror-only records. */ + preRepairIntegrityCounts?: DurableDataIntegrityCounts; + /** Current canonical integrity after reconciliation. Stage 3 readiness uses this field. */ + postRepairIntegrityCounts?: DurableDataIntegrityCounts; + /** Backward-compatible flat post-repair counts. */ + relationshipInvalidRecordCount?: number; + legacyShapeRecordCount?: number; + verificationError?: string; +} + +export interface DurableDataMismatchCounts { + missing: number; + extra: number; + differing: number; +} + +export interface DurableDataRepairCounts { + inserted: number; + updated: number; + deleted: number; +} + +export interface DurableDataIntegrityCounts { + relationshipInvalid: number; + legacyShape: number; } export type TopicArchiveMigrationMetadata = DurableDataMigrationMetadata & {