import { describe, expect, test } from 'vitest'; import { IDBFactory } from 'fake-indexeddb'; import { DSL_VERSION, DSL_VERSION_KEY, validateScene } from '@openmaic/dsl'; import { BrowserDocumentStore, type MaicDocument } from '../src/index.js'; import { runDocumentStoreContract, makeDocument, slideScene } from './document-contract.js'; // Each store gets its own in-memory IndexedDB factory so contract cases stay // isolated without leaning on an ambient global. function makeStore(): BrowserDocumentStore { return new BrowserDocumentStore({ indexedDB: new IDBFactory() }); } runDocumentStoreContract('BrowserDocumentStore', makeStore); // --- backend-specific: migrate-on-read needs to seed a raw row at an old // version, which the public API (which always stamps the current version) can't // express. --- describe('BrowserDocumentStore migrate-on-read', () => { // Open the raw DB the store uses and overwrite the stage row's version stamp, // simulating a document written by an older client. async function reStampStage( idb: IDBFactory, dbName: string, stageId: string, version: string | undefined, ): Promise { const db = await new Promise((resolve, reject) => { const req = idb.open(dbName); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); await new Promise((resolve, reject) => { const tx = db.transaction('stages', 'readwrite'); const store = tx.objectStore('stages'); const get = store.get(stageId); get.onsuccess = () => { const row = get.result as Record; if (version === undefined) delete row[DSL_VERSION_KEY]; else row[DSL_VERSION_KEY] = version; store.put(row); }; tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); db.close(); } test('stamps a legacy (unversioned) document forward on load', async () => { const idb = new IDBFactory(); const dbName = 'maic-documents-legacy'; const store = new BrowserDocumentStore({ indexedDB: idb, dbName }); await store.saveDocument(makeDocument()); await reStampStage(idb, dbName, 'stage-1', undefined); const loaded = await store.loadDocument('stage-1'); expect(loaded!.dslVersion).toBe(DSL_VERSION); }); test('returns a future-versioned document untouched (no downgrade)', async () => { const idb = new IDBFactory(); const dbName = 'maic-documents-future'; const store = new BrowserDocumentStore({ indexedDB: idb, dbName }); await store.saveDocument(makeDocument()); await reStampStage(idb, dbName, 'stage-1', '99.0.0'); const loaded = await store.loadDocument('stage-1'); expect(loaded!.dslVersion).toBe('99.0.0'); }); test('getScene reads a future-versioned document without downgrade', async () => { const idb = new IDBFactory(); const dbName = 'maic-documents-future-scene'; const store = new BrowserDocumentStore({ indexedDB: idb, dbName }); await store.saveDocument(makeDocument()); await reStampStage(idb, dbName, 'stage-1', '99.0.0'); // A document at/above the current version needs no migration, so getScene // returns the row directly (never downgraded). const scene = await store.getScene('stage-1', 'scene-a'); expect(scene!.id).toBe('scene-a'); }); test('getScene migrates a stale (legacy) document on read', async () => { const idb = new IDBFactory(); const dbName = 'maic-documents-stale-scene'; const store = new BrowserDocumentStore({ indexedDB: idb, dbName }); await store.saveDocument(makeDocument()); await reStampStage(idb, dbName, 'stage-1', undefined); // legacy // A stale document routes getScene through the whole-document migrate path. const scene = await store.getScene('stage-1', 'scene-a'); expect(scene!.id).toBe('scene-a'); }); test('putScene rejects when the stored document is not current', async () => { const idb = new IDBFactory(); const dbName = 'maic-documents-putscene-noncurrent'; const store = new BrowserDocumentStore({ indexedDB: idb, dbName }); await store.saveDocument(makeDocument()); // Legacy stored document: the other scenes have not been migrated, so // stamping the whole document current off one incremental write would // corrupt them — reject and require a full load + save first. await reStampStage(idb, dbName, 'stage-1', undefined); await expect(store.putScene('stage-1', slideScene('stage-1', 'new', 2))).rejects.toThrow(); // rejected write left nothing behind (a legacy doc still migrates on read) expect(await store.getScene('stage-1', 'new')).toBeNull(); // Future stored document: an old client must not downgrade it. await reStampStage(idb, dbName, 'stage-1', '99.0.0'); await expect(store.putScene('stage-1', slideScene('stage-1', 'new2', 3))).rejects.toThrow(); expect(await store.getScene('stage-1', 'new2')).toBeNull(); }); test('refuses to overwrite a stored document written by a newer client', async () => { const idb = new IDBFactory(); const dbName = 'maic-documents-overwrite-future'; const store = new BrowserDocumentStore({ indexedDB: idb, dbName }); await store.saveDocument(makeDocument()); // current await reStampStage(idb, dbName, 'stage-1', '99.0.0'); // stored as future // A fresh/current document for the same id (its own dslVersion is not future, // so the incoming-version guard passes) must still not clobber the newer // stored document — the store checks the stored row inside the write tx. const fresh = makeDocument(); fresh.stage.name = 'Old Client Overwrite'; await expect(store.saveDocument(fresh)).rejects.toThrow(); const loaded = await store.loadDocument('stage-1'); expect(loaded!.dslVersion).toBe('99.0.0'); expect(loaded!.stage.name).toBe('Intro Course'); }); test('deleteScene rejects when the stored document is not current', async () => { const idb = new IDBFactory(); const dbName = 'maic-documents-deletescene-noncurrent'; const store = new BrowserDocumentStore({ indexedDB: idb, dbName }); await store.saveDocument(makeDocument()); // Future stored document: an old client must not mutate newer-versioned data // (mirrors the putScene guard — deleting a scene is an incremental mutation). await reStampStage(idb, dbName, 'stage-1', '99.0.0'); await expect(store.deleteScene('stage-1', 'scene-a')).rejects.toThrow(); expect((await store.loadDocument('stage-1'))!.scenes.map((s) => s.id)).toEqual([ 'scene-a', 'scene-b', ]); // Stale stored document must be normalized (load + save) before incremental ops. await reStampStage(idb, dbName, 'stage-1', undefined); await expect(store.deleteScene('stage-1', 'scene-a')).rejects.toThrow(); }); test('fails loud on a malformed stored version stamp', async () => { const idb = new IDBFactory(); const dbName = 'maic-documents-malformed'; const store = new BrowserDocumentStore({ indexedDB: idb, dbName }); await store.saveDocument(makeDocument()); // A present-but-corrupt stamp makes a false version claim; reads must throw // rather than silently comparing it as some arbitrary version. await reStampStage(idb, dbName, 'stage-1', 'not-a-version'); await expect(store.loadDocument('stage-1')).rejects.toThrow(); await expect(store.getScene('stage-1', 'scene-a')).rejects.toThrow(); // saveDocument reads the stored stamp inside its future-overwrite guard, so a // corrupt stored stamp also fails loud (recoverable via deleteDocument). await expect(store.saveDocument(makeDocument())).rejects.toThrow(); }); test('listDocuments tolerates a malformed version stamp', async () => { const idb = new IDBFactory(); const dbName = 'maic-documents-list-malformed'; const store = new BrowserDocumentStore({ indexedDB: idb, dbName }); await store.saveDocument(makeDocument()); await reStampStage(idb, dbName, 'stage-1', 'not-a-version'); // listDocuments returns only version-independent summary fields and never // migrates or reads content, so a corrupt stamp does not break the whole list // (one bad row must not make the picker unusable) — content reads still fail loud. const list = await store.listDocuments(); expect(list.map((d) => d.id)).toEqual(['stage-1']); expect(list[0]).toMatchObject({ name: 'Intro Course', sceneCount: 2 }); await expect(store.loadDocument('stage-1')).rejects.toThrow(); }); }); // The store is generic over the scene type: an app can persist its own widened // scene union (kinds the DSL does not own, e.g. `interactive`) by injecting a // matching validator. The store treats scene content opaquely. describe('BrowserDocumentStore with an app-widened scene union', () => { interface InteractiveScene { id: string; stageId: string; title: string; order: number; type: 'interactive'; content: { type: 'interactive'; html: string }; } // Accept the app's own kind, else fall back to the DSL validator (slide/quiz). const validateAppScene = (scene: unknown) => { const s = scene as { type?: unknown; id?: unknown }; if (s.type === 'interactive') { return typeof s.id === 'string' ? { valid: true as const } : { valid: false as const, errors: [{ path: '/id', message: 'expected string id' }] }; } return validateScene(scene); }; const interactiveDoc: MaicDocument = { stage: { id: 'stage-1', name: 'Interactive Course', createdAt: 1, updatedAt: 2 }, scenes: [ { id: 'i1', stageId: 'stage-1', title: 'Widget', order: 0, type: 'interactive', content: { type: 'interactive', html: '
' }, }, ], }; test('persists an app-only interactive scene via an injected validator', async () => { const store = new BrowserDocumentStore({ indexedDB: new IDBFactory(), validateScene: validateAppScene, }); await store.saveDocument(interactiveDoc); const loaded = await store.loadDocument('stage-1'); expect(loaded!.scenes[0]).toMatchObject({ type: 'interactive', content: { type: 'interactive', html: '
' }, }); }); test('the default store (DSL validator) rejects an app-only scene kind', async () => { const store = new BrowserDocumentStore({ indexedDB: new IDBFactory() }); await expect(store.saveDocument(interactiveDoc as unknown as MaicDocument)).rejects.toThrow(); }); test('supports incremental scene ops for an app scene union', async () => { const store = new BrowserDocumentStore({ indexedDB: new IDBFactory(), validateScene: validateAppScene, }); await store.saveDocument(interactiveDoc); const added: InteractiveScene = { id: 'i2', stageId: 'stage-1', title: 'Widget 2', order: 1, type: 'interactive', content: { type: 'interactive', html: '' }, }; await store.putScene('stage-1', added); expect((await store.getScene('stage-1', 'i2'))!.content.html).toBe(''); await store.deleteScene('stage-1', 'i1'); expect((await store.loadDocument('stage-1'))!.scenes.map((s) => s.id)).toEqual(['i2']); }); test('enforces the phantom-partition guard even under a permissive validator', async () => { // A validator that accepts everything must NOT be able to weaken the store's // own key invariant: a scene whose stageId disagrees with its document is // still rejected (assertStorableScene runs independently of the validator). const store = new BrowserDocumentStore({ indexedDB: new IDBFactory(), validateScene: () => ({ valid: true }), }); const mismatched: MaicDocument = { stage: { id: 'stage-1', name: 'C', createdAt: 1, updatedAt: 2 }, scenes: [ { id: 'x', stageId: 'other-stage', title: 'W', order: 0, type: 'interactive', content: { type: 'interactive', html: '' }, }, ], }; await expect(store.saveDocument(mismatched)).rejects.toThrow(); expect(await store.loadDocument('stage-1')).toBeNull(); }); test('re-saves a scene whose opaque structured-clone content changed', async () => { // IndexedDB persists Map/Set/Date/… that JSON.stringify flattens to `{}`, so the // write-diff must not compare opaque app content via JSON — a real edit to a // Map value would otherwise stringify identically and be silently skipped. interface MapScene { id: string; stageId: string; title: string; order: number; type: 'interactive'; content: { type: 'interactive'; data: Map }; } const store = new BrowserDocumentStore({ indexedDB: new IDBFactory(), validateScene: () => ({ valid: true }), }); const base: MapScene = { id: 'm1', stageId: 'stage-1', title: 'W', order: 0, type: 'interactive', content: { type: 'interactive', data: new Map([['k', 1]]) }, }; const stage = { id: 'stage-1', name: 'C', createdAt: 1, updatedAt: 2 }; await store.saveDocument({ stage, scenes: [base] }); // Only the Map value changes; JSON.stringify(base) === JSON.stringify(edited). const edited: MapScene = { ...base, content: { type: 'interactive', data: new Map([['k', 2]]) }, }; await store.saveDocument({ stage: { ...stage, updatedAt: 3 }, scenes: [edited] }); const got = await store.getScene('stage-1', 'm1'); expect(got!.content.data.get('k')).toBe(2); }); test('re-saves a scene whose Map/Set was only reordered', async () => { // Map/Set iteration order is observable and IndexedDB preserves it, so an // order-only edit (same entries, reordered) is a real change and must not be // skipped by the write-diff. interface OrderScene { id: string; stageId: string; title: string; order: number; type: 'interactive'; content: { type: 'interactive'; m: Map; s: Set }; } const store = new BrowserDocumentStore({ indexedDB: new IDBFactory(), validateScene: () => ({ valid: true }), }); const base: OrderScene = { id: 'o1', stageId: 'stage-1', title: 'W', order: 0, type: 'interactive', content: { type: 'interactive', m: new Map([ ['a', 1], ['b', 2], ]), s: new Set(['x', 'y']), }, }; const stage = { id: 'stage-1', name: 'C', createdAt: 1, updatedAt: 2 }; await store.saveDocument({ stage, scenes: [base] }); // Same entries, reversed order — no membership/value change. const reordered: OrderScene = { ...base, content: { type: 'interactive', m: new Map([ ['b', 2], ['a', 1], ]), s: new Set(['y', 'x']), }, }; await store.saveDocument({ stage: { ...stage, updatedAt: 3 }, scenes: [reordered] }); const got = await store.getScene('stage-1', 'o1'); expect([...got!.content.m.keys()]).toEqual(['b', 'a']); expect([...got!.content.s]).toEqual(['y', 'x']); }); test('re-saves a scene whose plain-object key order changed', async () => { // Own string-key insertion order is observable and IndexedDB preserves it, so // a key-order-only change is a real edit (same treatment as Map/Set). interface ObjScene { id: string; stageId: string; title: string; order: number; type: 'interactive'; content: { type: 'interactive'; cfg: Record }; } const store = new BrowserDocumentStore({ indexedDB: new IDBFactory(), validateScene: () => ({ valid: true }), }); const base: ObjScene = { id: 'p1', stageId: 'stage-1', title: 'W', order: 0, type: 'interactive', content: { type: 'interactive', cfg: { a: 1, b: 2 } }, }; const stage = { id: 'stage-1', name: 'C', createdAt: 1, updatedAt: 2 }; await store.saveDocument({ stage, scenes: [base] }); const reordered: ObjScene = { ...base, content: { type: 'interactive', cfg: { b: 2, a: 1 } } }; await store.saveDocument({ stage: { ...stage, updatedAt: 3 }, scenes: [reordered] }); const got = await store.getScene('stage-1', 'p1'); expect(Object.keys(got!.content.cfg)).toEqual(['b', 'a']); }); });