414 lines
16 KiB
TypeScript
414 lines
16 KiB
TypeScript
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<void> {
|
|
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
|
const req = idb.open(dbName);
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
await new Promise<void>((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<string, unknown>;
|
|
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<InteractiveScene> = {
|
|
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: '<div/>' },
|
|
},
|
|
],
|
|
};
|
|
|
|
test('persists an app-only interactive scene via an injected validator', async () => {
|
|
const store = new BrowserDocumentStore<InteractiveScene>({
|
|
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: '<div/>' },
|
|
});
|
|
});
|
|
|
|
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<InteractiveScene>({
|
|
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: '<span/>' },
|
|
};
|
|
await store.putScene('stage-1', added);
|
|
expect((await store.getScene('stage-1', 'i2'))!.content.html).toBe('<span/>');
|
|
|
|
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<InteractiveScene>({
|
|
indexedDB: new IDBFactory(),
|
|
validateScene: () => ({ valid: true }),
|
|
});
|
|
const mismatched: MaicDocument<InteractiveScene> = {
|
|
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<string, number> };
|
|
}
|
|
const store = new BrowserDocumentStore<MapScene>({
|
|
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<string, number>; s: Set<string> };
|
|
}
|
|
const store = new BrowserDocumentStore<OrderScene>({
|
|
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<string, number> };
|
|
}
|
|
const store = new BrowserDocumentStore<ObjScene>({
|
|
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']);
|
|
});
|
|
});
|