745 lines
26 KiB
TypeScript
745 lines
26 KiB
TypeScript
import { IDBFactory, IDBKeyRange } from 'fake-indexeddb';
|
|
import type { PPTElement, Whiteboard } from '@openmaic/dsl';
|
|
import {
|
|
BrowserRuntimeStore,
|
|
RuntimeAppendConflictError,
|
|
type RuntimeStore,
|
|
} from '@openmaic/storage';
|
|
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { APP_RUNTIME_PAYLOAD_VALIDATORS } from '@/lib/runtime/payload-validators';
|
|
import { deleteStageRuntimeSafely } from '@/lib/runtime/store';
|
|
import { withRuntimeStorageExclusiveLock } from '@/lib/utils/chat-storage-lock';
|
|
import {
|
|
createWhiteboardRuntimeService,
|
|
WhiteboardRuntimeSessionAmbiguousError,
|
|
WhiteboardRuntimeSessionInvariantError,
|
|
whiteboardRuntimeSessionId,
|
|
} from '@/lib/whiteboard/runtime/store';
|
|
import {
|
|
LEGACY_WHITEBOARD_SOURCE_KIND,
|
|
WHITEBOARD_RUNTIME_PAYLOAD_VERSION,
|
|
type LegacySnapshotImportedOperation,
|
|
type WhiteboardElementAddedOperation,
|
|
type WhiteboardRuntimePayloadV1,
|
|
} from '@/lib/whiteboard/runtime/types';
|
|
|
|
beforeAll(() => {
|
|
vi.stubGlobal('IDBKeyRange', IDBKeyRange);
|
|
});
|
|
|
|
function board(id = 'board-1'): Whiteboard {
|
|
return { id, viewportSize: 1000, viewportRatio: 0.5625, elements: [] };
|
|
}
|
|
|
|
type LegacyPayload = WhiteboardRuntimePayloadV1 & {
|
|
operation: LegacySnapshotImportedOperation;
|
|
};
|
|
|
|
type ElementAddedPayload = WhiteboardRuntimePayloadV1 & {
|
|
operation: WhiteboardElementAddedOperation;
|
|
};
|
|
|
|
function textElement(id = 'text-added', content = 'learner text'): PPTElement {
|
|
return {
|
|
id,
|
|
type: 'text',
|
|
left: 40,
|
|
top: 50,
|
|
width: 240,
|
|
height: 60,
|
|
rotate: 0,
|
|
content,
|
|
defaultFontName: 'Inter',
|
|
defaultColor: '#000000',
|
|
};
|
|
}
|
|
|
|
function payload(id = 'operation-1', boardId = 'board-1'): LegacyPayload {
|
|
return {
|
|
payloadVersion: WHITEBOARD_RUNTIME_PAYLOAD_VERSION,
|
|
operationId: id,
|
|
operation: {
|
|
kind: 'legacy_snapshot_imported',
|
|
source: {
|
|
kind: LEGACY_WHITEBOARD_SOURCE_KIND,
|
|
fingerprint: `sha256:${'1'.repeat(64)}`,
|
|
},
|
|
whiteboard: board(boardId),
|
|
},
|
|
};
|
|
}
|
|
|
|
function elementPayload(id = 'element-operation-1', element = textElement()): ElementAddedPayload {
|
|
return {
|
|
payloadVersion: WHITEBOARD_RUNTIME_PAYLOAD_VERSION,
|
|
operationId: id,
|
|
operation: { kind: 'element_added', element },
|
|
};
|
|
}
|
|
|
|
function runtimeStore(): BrowserRuntimeStore {
|
|
return new BrowserRuntimeStore({
|
|
indexedDB: new IDBFactory(),
|
|
payloadValidators: APP_RUNTIME_PAYLOAD_VALIDATORS,
|
|
});
|
|
}
|
|
|
|
function service(store: RuntimeStore, learnerKey = 'learner-1') {
|
|
return createWhiteboardRuntimeService({
|
|
store,
|
|
resolveLearnerKey: () => learnerKey,
|
|
now: () => '2026-08-06T00:00:00.000Z',
|
|
withMaintenanceLock: (work) => work(),
|
|
});
|
|
}
|
|
|
|
describe('whiteboard RuntimeStore service', () => {
|
|
it('creates one deterministic session and commits through required CAS', async () => {
|
|
const store = runtimeStore();
|
|
const runtime = service(store);
|
|
await expect(runtime.read('stage-1')).resolves.toEqual({
|
|
sessionId: null,
|
|
whiteboard: null,
|
|
lastSeq: null,
|
|
});
|
|
const result = await runtime.append({
|
|
stageId: 'stage-1',
|
|
expectedLastSeq: null,
|
|
payload: payload(),
|
|
});
|
|
expect(result).toMatchObject({ committedSeq: 0, replayed: false });
|
|
expect(result.state.whiteboard?.id).toBe('board-1');
|
|
expect(
|
|
await store.getSession(whiteboardRuntimeSessionId('stage-1', 'learner-1')),
|
|
).toMatchObject({ kind: 'whiteboard', status: 'active' });
|
|
});
|
|
|
|
it('commits learner elements into one deterministic session-owned board', async () => {
|
|
const store = runtimeStore();
|
|
const runtime = service(store, 'private-learner');
|
|
const firstInput = {
|
|
stageId: 'stage-1',
|
|
expectedLastSeq: null,
|
|
payload: elementPayload(),
|
|
};
|
|
const first = await runtime.append(firstInput);
|
|
expect(first).toMatchObject({
|
|
committedSeq: 0,
|
|
replayed: false,
|
|
state: {
|
|
whiteboard: {
|
|
id: expect.stringMatching(/^runtime-whiteboard:[0-9a-f]{64}$/u),
|
|
viewportSize: 1000,
|
|
viewportRatio: 0.5625,
|
|
background: { type: 'solid', color: '#ffffff' },
|
|
animations: [],
|
|
elements: [{ id: 'text-added' }],
|
|
},
|
|
},
|
|
});
|
|
expect(first.state.whiteboard?.id).not.toContain('private-learner');
|
|
await expect(runtime.append({ ...firstInput, expectedLastSeq: 99 })).resolves.toMatchObject({
|
|
committedSeq: 0,
|
|
replayed: true,
|
|
});
|
|
|
|
const second = await runtime.append({
|
|
stageId: 'stage-1',
|
|
expectedLastSeq: 0,
|
|
payload: elementPayload('element-operation-2', textElement('text-2', 'second')),
|
|
});
|
|
expect(second.state.whiteboard?.id).toBe(first.state.whiteboard?.id);
|
|
expect(second.state.whiteboard?.elements.map((element) => element.id)).toEqual([
|
|
'text-added',
|
|
'text-2',
|
|
]);
|
|
expect(Object.isFrozen(second.state.whiteboard)).toBe(true);
|
|
|
|
const sameIdentity = await service(runtimeStore(), 'private-learner').append(firstInput);
|
|
const otherIdentity = await service(runtimeStore(), 'other-learner').append(firstInput);
|
|
expect(sameIdentity.state.whiteboard?.id).toBe(first.state.whiteboard?.id);
|
|
expect(otherIdentity.state.whiteboard?.id).not.toBe(first.state.whiteboard?.id);
|
|
});
|
|
|
|
it('rejects duplicate elements and Legacy-after-learner before persistence', async () => {
|
|
const store = runtimeStore();
|
|
const runtime = service(store);
|
|
await runtime.append({
|
|
stageId: 'stage-1',
|
|
expectedLastSeq: null,
|
|
payload: elementPayload(),
|
|
});
|
|
await expect(
|
|
runtime.append({
|
|
stageId: 'stage-1',
|
|
expectedLastSeq: null,
|
|
payload: elementPayload('element-operation-stale'),
|
|
}),
|
|
).rejects.toBeInstanceOf(RuntimeAppendConflictError);
|
|
await expect(
|
|
runtime.append({
|
|
stageId: 'stage-1',
|
|
expectedLastSeq: 0,
|
|
payload: elementPayload('element-operation-2'),
|
|
}),
|
|
).rejects.toThrow('WHITEBOARD_RUNTIME_ELEMENT_ALREADY_EXISTS');
|
|
await expect(
|
|
runtime.append({
|
|
stageId: 'stage-1',
|
|
expectedLastSeq: 0,
|
|
payload: payload('legacy-after-learner'),
|
|
}),
|
|
).rejects.toThrow('WHITEBOARD_RUNTIME_IMPORT_AFTER_STATE');
|
|
|
|
const sessionId = whiteboardRuntimeSessionId('stage-1', 'learner-1');
|
|
expect(await store.listRecords(sessionId)).toHaveLength(1);
|
|
await expect(runtime.read('stage-1')).resolves.toMatchObject({
|
|
lastSeq: 0,
|
|
whiteboard: { elements: [{ id: 'text-added' }] },
|
|
});
|
|
});
|
|
|
|
it('preserves imported board metadata when applying a learner element', async () => {
|
|
const imported = payload();
|
|
imported.operation.whiteboard = {
|
|
id: 'board-1',
|
|
viewportSize: 2048,
|
|
viewportRatio: 0.75,
|
|
elements: [textElement('legacy-text', 'legacy')],
|
|
background: { type: 'solid', color: '#123456' },
|
|
animations: [
|
|
{
|
|
id: 'animation-1',
|
|
elId: 'legacy-text',
|
|
effect: 'fadeIn',
|
|
type: 'in',
|
|
duration: 500,
|
|
trigger: 'click',
|
|
},
|
|
],
|
|
script: 'preserve this script',
|
|
};
|
|
const runtime = service(runtimeStore());
|
|
await runtime.append({ stageId: 'stage-1', expectedLastSeq: null, payload: imported });
|
|
const result = await runtime.append({
|
|
stageId: 'stage-1',
|
|
expectedLastSeq: 0,
|
|
payload: elementPayload(),
|
|
});
|
|
expect(result.state.whiteboard).toMatchObject({
|
|
id: 'board-1',
|
|
viewportSize: 2048,
|
|
viewportRatio: 0.75,
|
|
background: { type: 'solid', color: '#123456' },
|
|
animations: [
|
|
{
|
|
id: 'animation-1',
|
|
elId: 'legacy-text',
|
|
effect: 'fadeIn',
|
|
type: 'in',
|
|
duration: 500,
|
|
trigger: 'click',
|
|
},
|
|
],
|
|
script: 'preserve this script',
|
|
});
|
|
expect(result.state.whiteboard?.elements.map((element) => element.id)).toEqual([
|
|
'legacy-text',
|
|
'text-added',
|
|
]);
|
|
});
|
|
|
|
it('detaches a learner element before the first async boundary', async () => {
|
|
const runtime = service(runtimeStore());
|
|
const mutable = elementPayload();
|
|
const pending = runtime.append({
|
|
stageId: 'stage-1',
|
|
expectedLastSeq: null,
|
|
payload: mutable,
|
|
});
|
|
mutable.operation.element.id = 'mutated-after-call';
|
|
mutable.operation.element.left = 999;
|
|
const result = await pending;
|
|
expect(result.state.whiteboard?.elements[0]).toMatchObject({ id: 'text-added', left: 40 });
|
|
});
|
|
|
|
it('replays an exact committed operation and rejects a conflicting retry', async () => {
|
|
const runtime = service(runtimeStore());
|
|
const input = { stageId: 'stage-1', expectedLastSeq: null, payload: payload() };
|
|
await runtime.append(input);
|
|
await expect(runtime.append(input)).resolves.toMatchObject({ committedSeq: 0, replayed: true });
|
|
await expect(
|
|
runtime.append({ ...input, payload: payload('operation-1', 'board-other') }),
|
|
).rejects.toThrow('WHITEBOARD_RUNTIME_OPERATION_CONFLICT');
|
|
});
|
|
|
|
it('rejects a conflicting learner operation id without changing committed state', async () => {
|
|
const store = runtimeStore();
|
|
const runtime = service(store);
|
|
const first = elementPayload('element-operation-conflict', textElement('text-a', 'first'));
|
|
await runtime.append({ stageId: 'stage-1', expectedLastSeq: null, payload: first });
|
|
|
|
await expect(
|
|
runtime.append({
|
|
stageId: 'stage-1',
|
|
expectedLastSeq: 0,
|
|
payload: elementPayload('element-operation-conflict', textElement('text-b', 'conflicting')),
|
|
}),
|
|
).rejects.toThrow('WHITEBOARD_RUNTIME_OPERATION_CONFLICT');
|
|
|
|
const sessionId = whiteboardRuntimeSessionId('stage-1', 'learner-1');
|
|
expect(await store.listRecords(sessionId)).toHaveLength(1);
|
|
await expect(runtime.read('stage-1')).resolves.toMatchObject({
|
|
lastSeq: 0,
|
|
whiteboard: { elements: [{ id: 'text-a', content: 'first' }] },
|
|
});
|
|
});
|
|
|
|
it('rejects inherited payload fields before persistence', async () => {
|
|
const store = runtimeStore();
|
|
const runtime = service(store);
|
|
const inherited = {
|
|
payloadVersion: WHITEBOARD_RUNTIME_PAYLOAD_VERSION,
|
|
operationId: 'inherited-operation',
|
|
operation: elementPayload().operation,
|
|
};
|
|
const previous = new Map(
|
|
Object.keys(inherited).map((key) => [
|
|
key,
|
|
Object.getOwnPropertyDescriptor(Object.prototype, key),
|
|
]),
|
|
);
|
|
let attempt: ReturnType<typeof runtime.append>;
|
|
try {
|
|
for (const [key, value] of Object.entries(inherited)) {
|
|
Object.defineProperty(Object.prototype, key, {
|
|
value,
|
|
enumerable: true,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
}
|
|
attempt = runtime.append({
|
|
stageId: 'stage-1',
|
|
expectedLastSeq: null,
|
|
payload: {} as WhiteboardRuntimePayloadV1,
|
|
});
|
|
} finally {
|
|
for (const [key, descriptor] of previous) {
|
|
if (descriptor) Object.defineProperty(Object.prototype, key, descriptor);
|
|
else delete (Object.prototype as Record<string, unknown>)[key];
|
|
}
|
|
}
|
|
|
|
await expect(attempt!).rejects.toThrow('Invalid whiteboard runtime payload');
|
|
expect(
|
|
await store.listRecords(whiteboardRuntimeSessionId('stage-1', 'learner-1')),
|
|
).toHaveLength(0);
|
|
});
|
|
|
|
it('preserves RuntimeAppendConflictError and commits no stale record', async () => {
|
|
const store = runtimeStore();
|
|
const runtime = service(store);
|
|
await runtime.append({ stageId: 'stage-1', expectedLastSeq: null, payload: payload() });
|
|
await expect(
|
|
runtime.append({
|
|
stageId: 'stage-1',
|
|
expectedLastSeq: null,
|
|
payload: payload('operation-2', 'board-2'),
|
|
}),
|
|
).rejects.toBeInstanceOf(RuntimeAppendConflictError);
|
|
expect(
|
|
await store.listRecords(whiteboardRuntimeSessionId('stage-1', 'learner-1')),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it('rejects an anticipated tail before persistence while preserving exact replay', async () => {
|
|
const store = runtimeStore();
|
|
const runtime = service(store);
|
|
await expect(
|
|
runtime.append({
|
|
stageId: 'stage-1',
|
|
expectedLastSeq: 0,
|
|
payload: payload(),
|
|
}),
|
|
).rejects.toBeInstanceOf(RuntimeAppendConflictError);
|
|
expect(
|
|
await store.listRecords(whiteboardRuntimeSessionId('stage-1', 'learner-1')),
|
|
).toHaveLength(0);
|
|
|
|
await runtime.append({ stageId: 'stage-1', expectedLastSeq: null, payload: payload() });
|
|
await expect(
|
|
runtime.append({ stageId: 'stage-1', expectedLastSeq: 99, payload: payload() }),
|
|
).resolves.toMatchObject({ committedSeq: 0, replayed: true });
|
|
expect(
|
|
await store.listRecords(whiteboardRuntimeSessionId('stage-1', 'learner-1')),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it('rejects a second distinct legacy import before persistence', async () => {
|
|
const store = runtimeStore();
|
|
const runtime = service(store);
|
|
await runtime.append({ stageId: 'stage-1', expectedLastSeq: null, payload: payload() });
|
|
|
|
await expect(
|
|
runtime.append({
|
|
stageId: 'stage-1',
|
|
expectedLastSeq: 0,
|
|
payload: payload('operation-2', 'board-2'),
|
|
}),
|
|
).rejects.toThrow('WHITEBOARD_RUNTIME_IMPORT_AFTER_STATE');
|
|
|
|
const sessionId = whiteboardRuntimeSessionId('stage-1', 'learner-1');
|
|
expect(await store.listRecords(sessionId)).toHaveLength(1);
|
|
await expect(runtime.read('stage-1')).resolves.toMatchObject({
|
|
sessionId,
|
|
whiteboard: { id: 'board-1' },
|
|
lastSeq: 0,
|
|
});
|
|
});
|
|
|
|
it('recovers a committed response loss without appending twice', async () => {
|
|
const backing = runtimeStore();
|
|
let first = true;
|
|
const lossy: RuntimeStore = {
|
|
...backing,
|
|
createSession: backing.createSession.bind(backing),
|
|
getSession: backing.getSession.bind(backing),
|
|
listSessions: backing.listSessions.bind(backing),
|
|
setSessionStatus: backing.setSessionStatus.bind(backing),
|
|
deleteSession: backing.deleteSession.bind(backing),
|
|
listRecords: backing.listRecords.bind(backing),
|
|
mergeLearner: backing.mergeLearner.bind(backing),
|
|
deleteLearnerRuntime: backing.deleteLearnerRuntime.bind(backing),
|
|
deleteStageRuntime: backing.deleteStageRuntime.bind(backing),
|
|
deleteAllRuntime: backing.deleteAllRuntime.bind(backing),
|
|
appendRecord: async (...args) => {
|
|
const committed = await backing.appendRecord(...args);
|
|
if (first) {
|
|
first = false;
|
|
throw new Error('response lost');
|
|
}
|
|
return committed;
|
|
},
|
|
};
|
|
await expect(
|
|
service(lossy).append({
|
|
stageId: 'stage-1',
|
|
expectedLastSeq: null,
|
|
payload: payload(),
|
|
}),
|
|
).resolves.toMatchObject({ committedSeq: 0, replayed: true });
|
|
expect(
|
|
await backing.listRecords(whiteboardRuntimeSessionId('stage-1', 'learner-1')),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it('keeps a recoverable empty shell when the first append fails and always supplies CAS', async () => {
|
|
const backing = runtimeStore();
|
|
let fail = true;
|
|
const seenOptions: unknown[] = [];
|
|
const wrapped: RuntimeStore = {
|
|
...backing,
|
|
createSession: backing.createSession.bind(backing),
|
|
getSession: backing.getSession.bind(backing),
|
|
listSessions: backing.listSessions.bind(backing),
|
|
setSessionStatus: backing.setSessionStatus.bind(backing),
|
|
deleteSession: backing.deleteSession.bind(backing),
|
|
listRecords: backing.listRecords.bind(backing),
|
|
mergeLearner: backing.mergeLearner.bind(backing),
|
|
deleteLearnerRuntime: backing.deleteLearnerRuntime.bind(backing),
|
|
deleteStageRuntime: backing.deleteStageRuntime.bind(backing),
|
|
deleteAllRuntime: backing.deleteAllRuntime.bind(backing),
|
|
appendRecord: async (init, options) => {
|
|
seenOptions.push(options);
|
|
if (fail) {
|
|
fail = false;
|
|
throw new Error('append unavailable');
|
|
}
|
|
return backing.appendRecord(init, options);
|
|
},
|
|
};
|
|
const runtime = service(wrapped);
|
|
await expect(
|
|
runtime.append({ stageId: 'stage-1', expectedLastSeq: null, payload: payload() }),
|
|
).rejects.toThrow('append unavailable');
|
|
await expect(runtime.read('stage-1')).resolves.toMatchObject({
|
|
sessionId: whiteboardRuntimeSessionId('stage-1', 'learner-1'),
|
|
whiteboard: null,
|
|
lastSeq: null,
|
|
});
|
|
await expect(
|
|
runtime.append({ stageId: 'stage-1', expectedLastSeq: null, payload: payload() }),
|
|
).resolves.toMatchObject({ committedSeq: 0 });
|
|
expect(seenOptions).toEqual([{ expectedLastSeq: null }, { expectedLastSeq: null }]);
|
|
});
|
|
|
|
it('detaches caller-owned payload before the first async boundary', async () => {
|
|
const store = runtimeStore();
|
|
const runtime = service(store);
|
|
const mutable = payload();
|
|
const pending = runtime.append({ stageId: 'stage-1', expectedLastSeq: null, payload: mutable });
|
|
mutable.operation.whiteboard.id = 'mutated';
|
|
await pending;
|
|
expect((await runtime.read('stage-1')).whiteboard?.id).toBe('board-1');
|
|
});
|
|
|
|
it.each(['payload', 'whiteboard'] as const)(
|
|
'rejects an own __proto__ key on the %s before persistence',
|
|
async (target) => {
|
|
const store = runtimeStore();
|
|
const runtime = service(store);
|
|
const candidate = payload();
|
|
const object = target === 'payload' ? candidate : candidate.operation.whiteboard;
|
|
Object.defineProperty(object, '__proto__', {
|
|
value: null,
|
|
enumerable: true,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
|
|
await expect(
|
|
runtime.append({
|
|
stageId: 'stage-1',
|
|
expectedLastSeq: null,
|
|
payload: candidate,
|
|
}),
|
|
).rejects.toThrow('Invalid whiteboard runtime payload');
|
|
expect(
|
|
await store.listRecords(whiteboardRuntimeSessionId('stage-1', 'learner-1')),
|
|
).toHaveLength(0);
|
|
},
|
|
);
|
|
|
|
it('uses only the trusted service learner identity even if input has an extra learnerKey', async () => {
|
|
const store = runtimeStore();
|
|
const runtime = service(store, 'trusted-learner');
|
|
await runtime.append({
|
|
stageId: 'stage-1',
|
|
expectedLastSeq: null,
|
|
payload: payload(),
|
|
learnerKey: 'attacker-selected-learner',
|
|
} as Parameters<typeof runtime.append>[0] & { learnerKey: string });
|
|
|
|
expect(await store.listSessions('stage-1', 'trusted-learner')).toHaveLength(1);
|
|
expect(await store.listSessions('stage-1', 'attacker-selected-learner')).toEqual([]);
|
|
});
|
|
|
|
it('converges concurrent deterministic session creation and recovers a lost create response', async () => {
|
|
const backing = runtimeStore();
|
|
let entrants = 0;
|
|
let releaseCreates!: () => void;
|
|
const createGate = new Promise<void>((resolve) => {
|
|
releaseCreates = resolve;
|
|
});
|
|
const racing = new Proxy(backing, {
|
|
get(target, property) {
|
|
if (property === 'createSession') {
|
|
return async (...args: Parameters<RuntimeStore['createSession']>) => {
|
|
entrants += 1;
|
|
if (entrants === 2) releaseCreates();
|
|
await createGate;
|
|
return target.createSession(...args);
|
|
};
|
|
}
|
|
const value = Reflect.get(target, property, target) as unknown;
|
|
return typeof value === 'function' ? value.bind(target) : value;
|
|
},
|
|
}) as RuntimeStore;
|
|
const first = service(racing);
|
|
const second = service(racing);
|
|
const results = await Promise.all([
|
|
first.append({ stageId: 'stage-1', expectedLastSeq: null, payload: payload() }),
|
|
second.append({ stageId: 'stage-1', expectedLastSeq: null, payload: payload() }),
|
|
]);
|
|
expect(results.every((result) => result.committedSeq === 0)).toBe(true);
|
|
expect(await backing.listSessions('stage-1', 'learner-1')).toHaveLength(1);
|
|
expect(
|
|
await backing.listRecords(whiteboardRuntimeSessionId('stage-1', 'learner-1')),
|
|
).toHaveLength(1);
|
|
|
|
const lostBacking = runtimeStore();
|
|
let loseResponse = true;
|
|
const lossyCreate = new Proxy(lostBacking, {
|
|
get(target, property) {
|
|
if (property === 'createSession') {
|
|
return async (...args: Parameters<RuntimeStore['createSession']>) => {
|
|
const created = await target.createSession(...args);
|
|
if (loseResponse) {
|
|
loseResponse = false;
|
|
throw new Error('create response lost');
|
|
}
|
|
return created;
|
|
};
|
|
}
|
|
const value = Reflect.get(target, property, target) as unknown;
|
|
return typeof value === 'function' ? value.bind(target) : value;
|
|
},
|
|
}) as RuntimeStore;
|
|
await expect(
|
|
service(lossyCreate).append({
|
|
stageId: 'stage-2',
|
|
expectedLastSeq: null,
|
|
payload: payload('lost-create-operation'),
|
|
}),
|
|
).resolves.toMatchObject({ committedSeq: 0, replayed: false });
|
|
expect(await lostBacking.listSessions('stage-2', 'learner-1')).toHaveLength(1);
|
|
});
|
|
|
|
it('fails closed for multiple active and inactive whiteboard sessions', async () => {
|
|
const ambiguous = runtimeStore();
|
|
for (const id of ['one', 'two']) {
|
|
await ambiguous.createSession({
|
|
id,
|
|
kind: 'whiteboard',
|
|
stageId: 'stage-1',
|
|
learnerKey: 'learner-1',
|
|
status: 'active',
|
|
createdAt: '2026-08-06T00:00:00.000Z',
|
|
updatedAt: '2026-08-06T00:00:00.000Z',
|
|
});
|
|
}
|
|
await expect(service(ambiguous).read('stage-1')).rejects.toBeInstanceOf(
|
|
WhiteboardRuntimeSessionAmbiguousError,
|
|
);
|
|
|
|
const inactive = runtimeStore();
|
|
await inactive.createSession({
|
|
id: whiteboardRuntimeSessionId('stage-1', 'learner-1'),
|
|
kind: 'whiteboard',
|
|
stageId: 'stage-1',
|
|
learnerKey: 'learner-1',
|
|
status: 'completed',
|
|
createdAt: '2026-08-06T00:00:00.000Z',
|
|
updatedAt: '2026-08-06T00:00:00.000Z',
|
|
});
|
|
await expect(service(inactive).read('stage-1')).rejects.toBeInstanceOf(
|
|
WhiteboardRuntimeSessionInvariantError,
|
|
);
|
|
|
|
const mixed = runtimeStore();
|
|
await mixed.createSession({
|
|
id: 'active',
|
|
kind: 'whiteboard',
|
|
stageId: 'stage-1',
|
|
learnerKey: 'learner-1',
|
|
status: 'active',
|
|
createdAt: '2026-08-06T00:00:00.000Z',
|
|
updatedAt: '2026-08-06T00:00:00.000Z',
|
|
});
|
|
await mixed.createSession({
|
|
id: 'inactive',
|
|
kind: 'whiteboard',
|
|
stageId: 'stage-1',
|
|
learnerKey: 'learner-1',
|
|
status: 'archived',
|
|
createdAt: '2026-08-06T00:00:00.000Z',
|
|
updatedAt: '2026-08-06T00:00:00.000Z',
|
|
});
|
|
await expect(service(mixed).read('stage-1')).rejects.toBeInstanceOf(
|
|
WhiteboardRuntimeSessionInvariantError,
|
|
);
|
|
});
|
|
|
|
it('validates a deterministic create-race winner and reuses a merge-rekeyed active session', async () => {
|
|
const wrong = runtimeStore();
|
|
await wrong.createSession({
|
|
id: whiteboardRuntimeSessionId('stage-1', 'learner-1'),
|
|
kind: 'chat',
|
|
stageId: 'stage-1',
|
|
learnerKey: 'learner-1',
|
|
status: 'active',
|
|
createdAt: '2026-08-06T00:00:00.000Z',
|
|
updatedAt: '2026-08-06T00:00:00.000Z',
|
|
});
|
|
await expect(service(wrong).read('stage-1')).rejects.toBeInstanceOf(
|
|
WhiteboardRuntimeSessionInvariantError,
|
|
);
|
|
|
|
const merged = runtimeStore();
|
|
const oldRuntime = service(merged, 'old-learner');
|
|
await oldRuntime.append({ stageId: 'stage-1', expectedLastSeq: null, payload: payload() });
|
|
await merged.mergeLearner('old-learner', 'new-learner');
|
|
expect((await service(merged, 'new-learner').read('stage-1')).whiteboard?.id).toBe('board-1');
|
|
expect(await merged.listSessions('stage-1', 'new-learner')).toHaveLength(1);
|
|
});
|
|
|
|
it('successful same-ID cleanup yields empty state while failed cleanup retains state', async () => {
|
|
const store = runtimeStore();
|
|
const runtime = service(store);
|
|
await runtime.append({ stageId: 'stage-1', expectedLastSeq: null, payload: payload() });
|
|
await store.deleteStageRuntime('stage-1');
|
|
await expect(runtime.read('stage-1')).resolves.toEqual({
|
|
sessionId: null,
|
|
whiteboard: null,
|
|
lastSeq: null,
|
|
});
|
|
|
|
await runtime.append({ stageId: 'stage-1', expectedLastSeq: null, payload: payload() });
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const failed = { ...store, deleteStageRuntime: vi.fn().mockRejectedValue(new Error('failed')) };
|
|
await expect(
|
|
deleteStageRuntimeSafely('stage-1', failed as unknown as RuntimeStore),
|
|
).resolves.toBeUndefined();
|
|
expect((await runtime.read('stage-1')).whiteboard?.id).toBe('board-1');
|
|
expect(warn).toHaveBeenCalledOnce();
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it('shares the maintenance lock with destructive Stage cleanup', async () => {
|
|
vi.stubGlobal('window', {});
|
|
const backing = runtimeStore();
|
|
let releaseAppend!: () => void;
|
|
const appendGate = new Promise<void>((resolve) => {
|
|
releaseAppend = resolve;
|
|
});
|
|
let appendEntered = false;
|
|
const delayed: RuntimeStore = {
|
|
...backing,
|
|
createSession: backing.createSession.bind(backing),
|
|
getSession: backing.getSession.bind(backing),
|
|
listSessions: backing.listSessions.bind(backing),
|
|
setSessionStatus: backing.setSessionStatus.bind(backing),
|
|
deleteSession: backing.deleteSession.bind(backing),
|
|
listRecords: backing.listRecords.bind(backing),
|
|
mergeLearner: backing.mergeLearner.bind(backing),
|
|
deleteLearnerRuntime: backing.deleteLearnerRuntime.bind(backing),
|
|
deleteStageRuntime: backing.deleteStageRuntime.bind(backing),
|
|
deleteAllRuntime: backing.deleteAllRuntime.bind(backing),
|
|
appendRecord: async (...args) => {
|
|
appendEntered = true;
|
|
await appendGate;
|
|
return backing.appendRecord(...args);
|
|
},
|
|
};
|
|
const runtime = createWhiteboardRuntimeService({
|
|
store: delayed,
|
|
resolveLearnerKey: () => 'learner-1',
|
|
now: () => '2026-08-06T00:00:00.000Z',
|
|
});
|
|
const append = runtime.append({
|
|
stageId: 'stage-1',
|
|
expectedLastSeq: null,
|
|
payload: payload(),
|
|
});
|
|
await vi.waitFor(() => expect(appendEntered).toBe(true));
|
|
let cleanupEntered = false;
|
|
const cleanup = withRuntimeStorageExclusiveLock(async () => {
|
|
cleanupEntered = true;
|
|
await backing.deleteStageRuntime('stage-1');
|
|
});
|
|
await Promise.resolve();
|
|
expect(cleanupEntered).toBe(false);
|
|
releaseAppend();
|
|
await append;
|
|
await cleanup;
|
|
expect(cleanupEntered).toBe(true);
|
|
await expect(runtime.read('stage-1')).resolves.toEqual({
|
|
sessionId: null,
|
|
whiteboard: null,
|
|
lastSeq: null,
|
|
});
|
|
});
|
|
});
|