// Implementation-agnostic contract for `RuntimeStore`. Every backend is proven // equivalent by running this same suite against it, so a new backend cannot // silently diverge from the store's semantics. // // Backend-specific behaviours live in the backend's own test file, not here: // seeding raw rows (migrate-on-read, future-stamp skew) and — mirroring how // document-contract handles injected scene validators — building a store with // non-default options (`payloadValidators` overrides). import { describe, expect, test } from 'vitest'; import { RUNTIME_DSL_VERSION } from '@openmaic/dsl'; import type { RuntimeRecordInit } from '@openmaic/dsl'; import type { RuntimeStore, RuntimeSessionInit } from '../src/index.js'; // --- fixtures --------------------------------------------------------------- const T0 = '2026-01-01T00:00:00.000Z'; const T1 = '2026-01-01T00:01:00.000Z'; /** A valid session init (the pre-stamp shape `createSession` takes). */ export function makeSession(overrides: Partial = {}): RuntimeSessionInit { return { id: 'sess-1', kind: 'chat', stageId: 'stage-1', learnerKey: 'anon:device-1', status: 'active', createdAt: T0, updatedAt: T0, ...overrides, }; } // Deterministic id counter: fixture ids must be unique per call (records are // append-only under one session) but stable across runs — no randomness. let recordCounter = 0; /** A valid record init (the pre-`seq` shape `appendRecord` takes). */ export function makeRecordInit( sessionId: string, overrides: Record = {}, ): RuntimeRecordInit { recordCounter += 1; return { id: `rec-${recordCounter}`, sessionId, createdAt: T1, payload: { role: 'user', content: 'hello' }, ...overrides, } as RuntimeRecordInit; } // --- contract --------------------------------------------------------------- export function runRuntimeStoreContract(name: string, makeStore: () => RuntimeStore): void { describe(`RuntimeStore contract: ${name}`, () => { describe('sessions', () => { test('createSession stamps the runtime version; getSession round-trips it', async () => { const store = makeStore(); const created = await store.createSession(makeSession()); expect(created.runtimeDslVersion).toBe(RUNTIME_DSL_VERSION); expect(created).toMatchObject(makeSession()); const loaded = await store.getSession('sess-1'); expect(loaded).toEqual(created); }); test('createSession rejects a duplicate id — creating twice is a caller bug', async () => { const store = makeStore(); await store.createSession(makeSession()); await expect(store.createSession(makeSession())).rejects.toThrow(/already exists/); }); test('createSession rejects an invalid envelope and stores nothing', async () => { const store = makeStore(); await expect(store.createSession(makeSession({ learnerKey: '' }))).rejects.toThrow( /learnerKey/, ); expect(await store.getSession('sess-1')).toBeUndefined(); }); test('getSession resolves undefined for an absent id', async () => { const store = makeStore(); expect(await store.getSession('nope')).toBeUndefined(); }); test('listSessions returns only the (stageId, learnerKey) partition, ordered by createdAt', async () => { const store = makeStore(); // Insert the later-created session first: order must come from // `createdAt`, not insertion order. await store.createSession(makeSession({ id: 'a2', createdAt: T1, updatedAt: T1 })); await store.createSession(makeSession({ id: 'a1' })); await store.createSession(makeSession({ id: 'b1', learnerKey: 'anon:device-2' })); await store.createSession(makeSession({ id: 'c1', stageId: 'stage-2' })); const partition = await store.listSessions('stage-1', 'anon:device-1'); expect(partition.map((s) => s.id)).toEqual(['a1', 'a2']); // isolation both ways: other learner, other stage expect((await store.listSessions('stage-1', 'anon:device-2')).map((s) => s.id)).toEqual([ 'b1', ]); expect((await store.listSessions('stage-2', 'anon:device-1')).map((s) => s.id)).toEqual([ 'c1', ]); }); test('listSessions orders by the instant a timestamp denotes, not by the string', async () => { const store = makeStore(); // As strings the Z form sorts first ('2025-…' < '2026-…'); as instants // the offset form is earlier (2026-01-01T00:30+02:00 = 2025-12-31T22:30Z). await store.createSession( makeSession({ id: 'zulu', createdAt: '2025-12-31T23:00:00Z', updatedAt: '2025-12-31T23:00:00Z', }), ); await store.createSession( makeSession({ id: 'offset', createdAt: '2026-01-01T00:30:00+02:00', updatedAt: '2026-01-01T00:30:00+02:00', }), ); expect((await store.listSessions('stage-1', 'anon:device-1')).map((s) => s.id)).toEqual([ 'offset', 'zulu', ]); }); test('setSessionStatus transitions active → completed with the supplied updatedAt', async () => { const store = makeStore(); await store.createSession(makeSession()); await store.setSessionStatus('sess-1', 'completed', T1); const loaded = await store.getSession('sess-1'); expect(loaded!.status).toBe('completed'); expect(loaded!.updatedAt).toBe(T1); }); test('setSessionStatus rejects an absent session', async () => { const store = makeStore(); await expect(store.setSessionStatus('ghost', 'completed', T1)).rejects.toThrow( /no session/i, ); }); test('deleteSession removes the session and its records; idempotent', async () => { const store = makeStore(); await store.createSession(makeSession()); await store.appendRecord(makeRecordInit('sess-1')); await store.deleteSession('sess-1'); expect(await store.getSession('sess-1')).toBeUndefined(); expect(await store.listRecords('sess-1')).toEqual([]); await expect(store.deleteSession('sess-1')).resolves.toBeUndefined(); }); }); describe('records', () => { test('appendRecord assigns monotonic seq from 0 and stamps nothing on the record', async () => { const store = makeStore(); await store.createSession(makeSession()); const r0 = await store.appendRecord(makeRecordInit('sess-1')); const r1 = await store.appendRecord(makeRecordInit('sess-1')); const r2 = await store.appendRecord(makeRecordInit('sess-1')); expect([r0.seq, r1.seq, r2.seq]).toEqual([0, 1, 2]); // records ride the parent session's version — no stamp of their own expect('runtimeDslVersion' in r0).toBe(false); }); test('appendRecord round-trips a null payload and rejects an undefined one', async () => { const store = makeStore(); await store.createSession(makeSession({ kind: 'playback' })); const stored = await store.appendRecord(makeRecordInit('sess-1', { payload: null })); expect(stored.payload).toBeNull(); expect((await store.listRecords('sess-1'))[0]!.payload).toBeNull(); await expect( store.appendRecord(makeRecordInit('sess-1', { payload: undefined })), ).rejects.toThrow(/payload/); }); test('appendRecord rejects an absent parent and a non-active parent', async () => { const store = makeStore(); await expect(store.appendRecord(makeRecordInit('ghost'))).rejects.toThrow(/no session/i); await store.createSession(makeSession()); await store.setSessionStatus('sess-1', 'completed', T1); await expect(store.appendRecord(makeRecordInit('sess-1'))).rejects.toThrow(/active/); }); test('appendRecord validates skeleton payloads for skeleton kinds by default', async () => { const store = makeStore(); await store.createSession(makeSession()); // kind: 'chat' await expect( store.appendRecord(makeRecordInit('sess-1', { payload: { role: 'user', content: 'x' } })), ).resolves.toMatchObject({ seq: 0 }); // a quiz-shaped payload on a chat session fails the chat skeleton guard await expect( store.appendRecord(makeRecordInit('sess-1', { payload: { phase: 'draft' } })), ).rejects.toThrow(/payload/); // `playback` has no DSL skeleton: an arbitrary object payload is accepted await store.createSession(makeSession({ id: 'sess-2', kind: 'playback' })); await expect( store.appendRecord(makeRecordInit('sess-2', { payload: { position: 42 } })), ).resolves.toMatchObject({ seq: 0 }); }); // Injected `payloadValidators` overrides need a store built with // non-default options, which this factory cannot express — they are // covered in the backend's own test file (as document-contract does for // injected scene validators). test('listRecords orders by seq and narrows to anchored records with { sceneId }', async () => { const store = makeStore(); await store.createSession(makeSession({ kind: 'playback' })); const a = await store.appendRecord( makeRecordInit('sess-1', { sceneId: 'scene-1', payload: { n: 0 } }), ); const b = await store.appendRecord( makeRecordInit('sess-1', { sceneId: 'scene-2', payload: { n: 1 } }), ); const c = await store.appendRecord( makeRecordInit('sess-1', { sceneId: 'scene-1', payload: { n: 2 } }), ); const unanchored = await store.appendRecord( makeRecordInit('sess-1', { payload: { n: 3 } }), ); const all = await store.listRecords('sess-1'); expect(all.map((r) => r.id)).toEqual([a.id, b.id, c.id, unanchored.id]); const anchored = await store.listRecords('sess-1', { sceneId: 'scene-1' }); // only records anchored to scene-1; the un-anchored record is excluded expect(anchored.map((r) => r.id)).toEqual([a.id, c.id]); }); }); describe('learner ops', () => { test('mergeLearner re-keys every session of the source learner across all stages', async () => { const store = makeStore(); await store.createSession(makeSession({ id: 'from-1' })); await store.createSession(makeSession({ id: 'from-2', stageId: 'stage-2' })); await store.createSession(makeSession({ id: 'kept', learnerKey: 'user:42' })); const moved = await store.mergeLearner('anon:device-1', 'user:42'); expect(moved).toBe(2); // the target learner now owns both partitions, existing sessions untouched expect((await store.listSessions('stage-1', 'user:42')).map((s) => s.id).sort()).toEqual([ 'from-1', 'kept', ]); expect((await store.listSessions('stage-2', 'user:42')).map((s) => s.id)).toEqual([ 'from-2', ]); expect(await store.listSessions('stage-1', 'anon:device-1')).toEqual([]); // idempotent: a second run finds nothing to move expect(await store.mergeLearner('anon:device-1', 'user:42')).toBe(0); }); test('mergeLearner rejects empty learner keys', async () => { const store = makeStore(); // a merge is a write: keys createSession would reject must not be // written by the merge either await expect(store.mergeLearner('', 'user:42')).rejects.toThrow(/non-empty/); await expect(store.mergeLearner('anon:device-1', '')).rejects.toThrow(/non-empty/); }); test('a self-merge moves nothing and returns 0, repeatably', async () => { const store = makeStore(); await store.createSession(makeSession()); expect(await store.mergeLearner('anon:device-1', 'anon:device-1')).toBe(0); expect(await store.mergeLearner('anon:device-1', 'anon:device-1')).toBe(0); // the session is untouched under its original key expect((await store.listSessions('stage-1', 'anon:device-1')).map((s) => s.id)).toEqual([ 'sess-1', ]); }); test('deleteLearnerRuntime removes exactly one learner on one stage; idempotent', async () => { const store = makeStore(); await store.createSession(makeSession({ id: 'target' })); await store.appendRecord(makeRecordInit('target')); await store.createSession(makeSession({ id: 'other-learner', learnerKey: 'user:42' })); await store.createSession(makeSession({ id: 'other-stage', stageId: 'stage-2' })); await store.deleteLearnerRuntime('stage-1', 'anon:device-1'); expect(await store.getSession('target')).toBeUndefined(); expect(await store.listRecords('target')).toEqual([]); // other learner and other stage survive expect(await store.getSession('other-learner')).toBeDefined(); expect(await store.getSession('other-stage')).toBeDefined(); await expect( store.deleteLearnerRuntime('stage-1', 'anon:device-1'), ).resolves.toBeUndefined(); }); test('deleteStageRuntime removes every learner on the stage; other stages survive', async () => { const store = makeStore(); await store.createSession(makeSession({ id: 's1' })); await store.appendRecord(makeRecordInit('s1')); await store.createSession(makeSession({ id: 's2', learnerKey: 'user:42' })); await store.createSession(makeSession({ id: 's3', stageId: 'stage-2' })); await store.deleteStageRuntime('stage-1'); expect(await store.getSession('s1')).toBeUndefined(); expect(await store.getSession('s2')).toBeUndefined(); expect(await store.listRecords('s1')).toEqual([]); expect(await store.getSession('s3')).toBeDefined(); await expect(store.deleteStageRuntime('stage-1')).resolves.toBeUndefined(); }); }); describe('version line', () => { test('a stored session carries the runtime stamp only — never a dslVersion key', async () => { const store = makeStore(); await store.createSession(makeSession()); const stored = await store.getSession('sess-1'); expect(stored!.runtimeDslVersion).toBe(RUNTIME_DSL_VERSION); // the document line's stamp must not leak onto the runtime line expect('dslVersion' in stored!).toBe(false); }); }); }); }