1925 lines
68 KiB
TypeScript
1925 lines
68 KiB
TypeScript
import { IDBFactory, IDBKeyRange } from 'fake-indexeddb';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { BrowserRuntimeStore, type KVStore, type RuntimeStore } from '@openmaic/storage';
|
|
|
|
import type { ChatSession } from '@/lib/types/chat';
|
|
import type { ChatStorageSnapshot } from '@/lib/utils/chat-storage';
|
|
|
|
if (!('IDBKeyRange' in globalThis)) {
|
|
Object.defineProperty(globalThis, 'IDBKeyRange', { value: IDBKeyRange, configurable: true });
|
|
}
|
|
|
|
const learnerKey = 'anon:database-cutover';
|
|
|
|
function serialLockManager(): Pick<LockManager, 'request'> {
|
|
const tails = new Map<string, Promise<void>>();
|
|
const manager = {
|
|
async request<T>(
|
|
name: string,
|
|
optionsOrCallback: LockOptions | (() => Promise<T> | T),
|
|
maybeCallback?: () => Promise<T> | T,
|
|
): Promise<T> {
|
|
const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback!;
|
|
const previous = tails.get(name) ?? Promise.resolve();
|
|
let release!: () => void;
|
|
const current = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
tails.set(name, current);
|
|
await previous;
|
|
try {
|
|
return await callback();
|
|
} finally {
|
|
release();
|
|
if (tails.get(name) === current) tails.delete(name);
|
|
}
|
|
},
|
|
};
|
|
return manager as unknown as Pick<LockManager, 'request'>;
|
|
}
|
|
|
|
function fairLockManager(): Pick<LockManager, 'request'> {
|
|
type Mode = 'shared' | 'exclusive';
|
|
interface Waiter {
|
|
mode: Mode;
|
|
callback: () => Promise<unknown> | unknown;
|
|
resolve: (value: unknown) => void;
|
|
reject: (reason?: unknown) => void;
|
|
signal?: AbortSignal;
|
|
onAbort?: () => void;
|
|
}
|
|
interface State {
|
|
readers: number;
|
|
writer: boolean;
|
|
waiters: Waiter[];
|
|
}
|
|
|
|
const states = new Map<string, State>();
|
|
const pump = (name: string): void => {
|
|
const state = states.get(name)!;
|
|
if (state.writer || state.waiters.length === 0) return;
|
|
if (state.readers > 0 && state.waiters[0]!.mode === 'exclusive') return;
|
|
|
|
const start = (waiter: Waiter): void => {
|
|
waiter.signal?.removeEventListener('abort', waiter.onAbort!);
|
|
if (waiter.mode === 'shared') state.readers += 1;
|
|
else state.writer = true;
|
|
void Promise.resolve()
|
|
.then(waiter.callback)
|
|
.then(waiter.resolve, waiter.reject)
|
|
.finally(() => {
|
|
if (waiter.mode === 'shared') state.readers -= 1;
|
|
else state.writer = false;
|
|
pump(name);
|
|
});
|
|
};
|
|
|
|
if (state.readers === 0 && state.waiters[0]!.mode === 'exclusive') {
|
|
start(state.waiters.shift()!);
|
|
return;
|
|
}
|
|
while (!state.writer && state.waiters[0]?.mode === 'shared') {
|
|
start(state.waiters.shift()!);
|
|
}
|
|
};
|
|
|
|
return {
|
|
request<T>(
|
|
name: string,
|
|
optionsOrCallback: LockOptions | (() => Promise<T> | T),
|
|
maybeCallback?: () => Promise<T> | T,
|
|
): Promise<T> {
|
|
const mode: Mode =
|
|
typeof optionsOrCallback === 'function'
|
|
? 'exclusive'
|
|
: (optionsOrCallback.mode ?? 'exclusive');
|
|
const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback!;
|
|
const signal = typeof optionsOrCallback === 'function' ? undefined : optionsOrCallback.signal;
|
|
const state = states.get(name) ?? { readers: 0, writer: false, waiters: [] };
|
|
states.set(name, state);
|
|
return new Promise<T>((resolve, reject) => {
|
|
const waiter: Waiter = {
|
|
mode,
|
|
callback,
|
|
resolve: resolve as (value: unknown) => void,
|
|
reject,
|
|
signal,
|
|
};
|
|
waiter.onAbort = () => {
|
|
const index = state.waiters.indexOf(waiter);
|
|
if (index >= 0) state.waiters.splice(index, 1);
|
|
reject(signal?.reason);
|
|
pump(name);
|
|
};
|
|
if (signal?.aborted) {
|
|
reject(signal.reason);
|
|
return;
|
|
}
|
|
signal?.addEventListener('abort', waiter.onAbort, { once: true });
|
|
state.waiters.push(waiter);
|
|
pump(name);
|
|
});
|
|
},
|
|
} as Pick<LockManager, 'request'>;
|
|
}
|
|
|
|
function chatSession(): ChatSession {
|
|
return {
|
|
id: 'chat-backup',
|
|
type: 'qa',
|
|
title: 'Persisted chat',
|
|
status: 'completed',
|
|
messages: [{ id: 'message-1', role: 'user', parts: [{ type: 'text', text: 'Hello' }] }],
|
|
config: { agentIds: ['default-1'] },
|
|
toolCalls: [],
|
|
pendingToolCalls: [],
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
};
|
|
}
|
|
|
|
function stubMemoryLocalStorage(): void {
|
|
const values = new Map<string, string>();
|
|
vi.stubGlobal('localStorage', {
|
|
get length() {
|
|
return values.size;
|
|
},
|
|
clear: () => values.clear(),
|
|
getItem: (key: string) => values.get(key) ?? null,
|
|
key: (index: number) => [...values.keys()][index] ?? null,
|
|
removeItem: (key: string) => void values.delete(key),
|
|
setItem: (key: string, value: string) => void values.set(key, String(value)),
|
|
} satisfies Storage);
|
|
}
|
|
|
|
describe('database runtime chat integration', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.stubGlobal('indexedDB', new IDBFactory());
|
|
vi.stubGlobal('navigator', { locks: serialLockManager() });
|
|
stubMemoryLocalStorage();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('exports RuntimeStore chats and clears them with the main database', async () => {
|
|
const runtimeStore = new BrowserRuntimeStore({ indexedDB: globalThis.indexedDB });
|
|
const { db, clearDatabase, exportDatabase, importDatabase } =
|
|
await import('@/lib/utils/database');
|
|
const { loadChatSessions, saveChatSessions } = await import('@/lib/utils/chat-storage');
|
|
await db.stages.put({
|
|
id: 'stage-backup',
|
|
name: 'Backup stage',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
});
|
|
await saveChatSessions('stage-backup', [chatSession()], {
|
|
store: runtimeStore,
|
|
learnerKey,
|
|
});
|
|
await saveChatSessions('orphaned-runtime-stage', [{ ...chatSession(), id: 'orphaned-chat' }], {
|
|
store: runtimeStore,
|
|
learnerKey,
|
|
});
|
|
|
|
const exported = await exportDatabase({
|
|
store: runtimeStore,
|
|
learnerKey,
|
|
});
|
|
expect(exported.chatSessions).toMatchObject([
|
|
{ id: 'chat-backup', stageId: 'stage-backup', title: 'Persisted chat' },
|
|
]);
|
|
|
|
await saveChatSessions(
|
|
'stage-backup',
|
|
[
|
|
{ ...chatSession(), title: 'Newer local chat', updatedAt: 3_000 },
|
|
{ ...chatSession(), id: 'chat-not-in-backup', title: 'Not in backup', updatedAt: 3_100 },
|
|
],
|
|
{ store: runtimeStore, learnerKey },
|
|
);
|
|
await importDatabase(exported, { store: runtimeStore, learnerKey });
|
|
await expect(
|
|
loadChatSessions('stage-backup', { store: runtimeStore, learnerKey }),
|
|
).resolves.toMatchObject([{ id: 'chat-backup', title: 'Persisted chat' }]);
|
|
|
|
await clearDatabase(runtimeStore);
|
|
await expect(runtimeStore.listSessions('stage-backup', learnerKey)).resolves.toEqual([]);
|
|
await expect(runtimeStore.listSessions('orphaned-runtime-stage', learnerKey)).resolves.toEqual(
|
|
[],
|
|
);
|
|
});
|
|
|
|
it('keeps same-id legacy chats from a different stage in backup export', async () => {
|
|
const runtimeStore = new BrowserRuntimeStore({ indexedDB: globalThis.indexedDB });
|
|
const { db, exportDatabase, importDatabase } = await import('@/lib/utils/database');
|
|
const { loadChatSessions, saveChatSessions } = await import('@/lib/utils/chat-storage');
|
|
await db.stages.put({
|
|
id: 'stage-runtime-export',
|
|
name: 'Runtime stage',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
});
|
|
await saveChatSessions(
|
|
'stage-runtime-export',
|
|
[{ ...chatSession(), id: 'shared-chat-id', title: 'Runtime chat' }],
|
|
{ store: runtimeStore, learnerKey },
|
|
);
|
|
await db.chatSessions.put({
|
|
...chatSession(),
|
|
id: 'shared-chat-id',
|
|
stageId: 'stage-legacy-export',
|
|
title: 'Legacy chat',
|
|
});
|
|
|
|
const exported = await exportDatabase({ store: runtimeStore, learnerKey });
|
|
|
|
expect(exported.chatSessions).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: 'shared-chat-id',
|
|
stageId: 'stage-runtime-export',
|
|
title: 'Runtime chat',
|
|
}),
|
|
expect.objectContaining({
|
|
id: 'shared-chat-id',
|
|
stageId: 'stage-legacy-export',
|
|
title: 'Legacy chat',
|
|
}),
|
|
]),
|
|
);
|
|
await importDatabase(exported, { store: runtimeStore, learnerKey });
|
|
await expect(
|
|
loadChatSessions('stage-runtime-export', { store: runtimeStore, learnerKey }),
|
|
).resolves.toMatchObject([{ title: 'Runtime chat' }]);
|
|
await expect(
|
|
loadChatSessions('stage-legacy-export', { store: runtimeStore, learnerKey }),
|
|
).resolves.toMatchObject([{ title: 'Legacy chat' }]);
|
|
await expect(db.chatRestoreStaging.count()).resolves.toBe(0);
|
|
await db.stages.delete('stage-runtime-export');
|
|
});
|
|
|
|
it('does not let a pre-restore autosave replace the restored chat snapshot', async () => {
|
|
const indexedDB = globalThis.indexedDB;
|
|
const staleStore = new BrowserRuntimeStore({ indexedDB, dbName: 'restore-stale-autosave' });
|
|
const restoringStore = new BrowserRuntimeStore({
|
|
indexedDB,
|
|
dbName: 'restore-stale-autosave',
|
|
});
|
|
const freshStore = new BrowserRuntimeStore({ indexedDB, dbName: 'restore-stale-autosave' });
|
|
const staleSnapshot = { ...chatSession(), title: 'Pre-restore chat', updatedAt: 3_000 };
|
|
const restoredSnapshot = {
|
|
...chatSession(),
|
|
stageId: 'stage-restore-stale',
|
|
title: 'Restored chat',
|
|
updatedAt: 2_000,
|
|
};
|
|
const { exportDatabase, importDatabase } = await import('@/lib/utils/database');
|
|
const { loadChatSessions, saveChatSessions } = await import('@/lib/utils/chat-storage');
|
|
const callerSnapshot = { sessions: [structuredClone(staleSnapshot)], restoreMarker: null };
|
|
|
|
await saveChatSessions('stage-restore-stale', [staleSnapshot], {
|
|
store: staleStore,
|
|
learnerKey,
|
|
});
|
|
await importDatabase(
|
|
{
|
|
stages: [
|
|
{
|
|
id: 'stage-restore-stale',
|
|
name: 'Restored stage',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
},
|
|
],
|
|
chatSessions: [restoredSnapshot],
|
|
},
|
|
{ store: restoringStore, learnerKey },
|
|
);
|
|
|
|
// A backup/export read on the same RuntimeStore must not advance the
|
|
// mounted editor's caller-bound snapshot authority.
|
|
await expect(exportDatabase({ store: staleStore, learnerKey })).resolves.toMatchObject({
|
|
chatSessions: [{ id: 'chat-backup', title: 'Restored chat' }],
|
|
});
|
|
|
|
// A mounted tab can echo the exact snapshot it observed before restore
|
|
// when an unrelated document autosave runs.
|
|
await saveChatSessions('stage-restore-stale', [staleSnapshot], {
|
|
store: staleStore,
|
|
learnerKey,
|
|
snapshot: callerSnapshot,
|
|
});
|
|
|
|
await expect(
|
|
saveChatSessions(
|
|
'stage-restore-stale',
|
|
[{ ...staleSnapshot, title: 'Edited stale chat', updatedAt: 3_001 }],
|
|
{ store: staleStore, learnerKey, snapshot: callerSnapshot },
|
|
),
|
|
).rejects.toThrow('invalidated by backup restore');
|
|
|
|
let reloadedSnapshot: ChatStorageSnapshot | undefined;
|
|
const [restored] = await loadChatSessions('stage-restore-stale', {
|
|
store: freshStore,
|
|
learnerKey,
|
|
onSnapshot: (snapshot) => {
|
|
reloadedSnapshot = snapshot;
|
|
},
|
|
});
|
|
expect(restored).toMatchObject({
|
|
id: 'chat-backup',
|
|
title: 'Restored chat',
|
|
updatedAt: 2_000,
|
|
});
|
|
|
|
await saveChatSessions(
|
|
'stage-restore-stale',
|
|
[{ ...restored!, title: 'Edited after reload', updatedAt: 2_001 }],
|
|
{ store: freshStore, learnerKey, snapshot: reloadedSnapshot },
|
|
);
|
|
await expect(
|
|
loadChatSessions('stage-restore-stale', { store: freshStore, learnerKey }),
|
|
).resolves.toMatchObject([{ title: 'Edited after reload', updatedAt: 2_001 }]);
|
|
});
|
|
|
|
it('does not let a pre-restore autosave repopulate an empty backup', async () => {
|
|
const indexedDB = globalThis.indexedDB;
|
|
const staleStore = new BrowserRuntimeStore({ indexedDB, dbName: 'restore-empty-autosave' });
|
|
const restoringStore = new BrowserRuntimeStore({ indexedDB, dbName: 'restore-empty-autosave' });
|
|
const freshStore = new BrowserRuntimeStore({ indexedDB, dbName: 'restore-empty-autosave' });
|
|
const staleSnapshot = { ...chatSession(), title: 'Removed by restore', updatedAt: 3_000 };
|
|
const callerSnapshot = {
|
|
sessions: [structuredClone(staleSnapshot)],
|
|
restoreMarker: null,
|
|
} satisfies ChatStorageSnapshot;
|
|
const { importDatabase } = await import('@/lib/utils/database');
|
|
const { loadChatSessions, saveChatSessions } = await import('@/lib/utils/chat-storage');
|
|
|
|
await saveChatSessions('stage-restore-empty', [staleSnapshot], {
|
|
store: staleStore,
|
|
learnerKey,
|
|
snapshot: callerSnapshot,
|
|
});
|
|
await importDatabase(
|
|
{
|
|
stages: [
|
|
{
|
|
id: 'stage-restore-empty',
|
|
name: 'Empty restored stage',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
},
|
|
],
|
|
chatSessions: [],
|
|
},
|
|
{ store: restoringStore, learnerKey },
|
|
);
|
|
|
|
await saveChatSessions('stage-restore-empty', [staleSnapshot], {
|
|
store: staleStore,
|
|
learnerKey,
|
|
snapshot: callerSnapshot,
|
|
});
|
|
|
|
await expect(
|
|
saveChatSessions(
|
|
'stage-restore-empty',
|
|
[{ ...staleSnapshot, title: 'Edited after empty restore', updatedAt: 3_001 }],
|
|
{ store: staleStore, learnerKey, snapshot: callerSnapshot },
|
|
),
|
|
).rejects.toThrow('invalidated by backup restore');
|
|
|
|
await expect(
|
|
loadChatSessions('stage-restore-empty', { store: freshStore, learnerKey }),
|
|
).resolves.toEqual([]);
|
|
});
|
|
|
|
it('does not let a snapshot from a failed chat load clear a later backup restore', async () => {
|
|
const indexedDB = globalThis.indexedDB;
|
|
const restoringStore = new BrowserRuntimeStore({ indexedDB, dbName: 'failed-load-restore' });
|
|
const savingStore = new BrowserRuntimeStore({ indexedDB, dbName: 'failed-load-restore' });
|
|
const { importDatabase } = await import('@/lib/utils/database');
|
|
const { loadChatSessions, saveChatSessions } = await import('@/lib/utils/chat-storage');
|
|
|
|
await importDatabase(
|
|
{
|
|
stages: [
|
|
{
|
|
id: 'stage-failed-load',
|
|
name: 'Restored stage',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
},
|
|
],
|
|
chatSessions: [{ ...chatSession(), stageId: 'stage-failed-load' }],
|
|
},
|
|
{ store: restoringStore, learnerKey },
|
|
);
|
|
|
|
await saveChatSessions('stage-failed-load', [], {
|
|
store: savingStore,
|
|
learnerKey,
|
|
snapshot: { sessions: [], restoreMarker: undefined },
|
|
});
|
|
|
|
await saveChatSessions(
|
|
'stage-failed-load',
|
|
[{ ...chatSession(), id: 'chat-created-after-recovery', title: 'Created after recovery' }],
|
|
{
|
|
store: savingStore,
|
|
learnerKey,
|
|
snapshot: { sessions: [], restoreMarker: undefined },
|
|
},
|
|
);
|
|
|
|
await expect(
|
|
loadChatSessions('stage-failed-load', { store: savingStore, learnerKey }),
|
|
).resolves.toMatchObject([{ title: 'Persisted chat' }, { title: 'Created after recovery' }]);
|
|
});
|
|
|
|
it('rolls back imported Dexie rows when restore-marker creation fails', async () => {
|
|
const backing = new BrowserRuntimeStore({ indexedDB: globalThis.indexedDB });
|
|
const { db, importDatabase } = await import('@/lib/utils/database');
|
|
const { getDocumentStore, loadCurrentScene, saveCurrentScene } =
|
|
await import('@/lib/document-store');
|
|
const { loadChatSessions, saveChatSessions } = await import('@/lib/utils/chat-storage');
|
|
await db.stages.put({
|
|
id: 'stage-marker-failure',
|
|
name: 'Original stage',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
});
|
|
const documentStore = getDocumentStore();
|
|
await documentStore.saveDocument({
|
|
stage: {
|
|
id: 'stage-marker-failure',
|
|
name: 'Original destination document',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
},
|
|
scenes: [],
|
|
});
|
|
await saveCurrentScene('stage-marker-failure', 'original-current-scene');
|
|
const originalCurrentScene = await loadCurrentScene('stage-marker-failure');
|
|
await saveChatSessions(
|
|
'stage-marker-failure',
|
|
[{ ...chatSession(), title: 'Original runtime chat' }],
|
|
{ store: backing, learnerKey },
|
|
);
|
|
const markerFailureStore = new Proxy(backing, {
|
|
get(target, property) {
|
|
if (property === 'createSession') {
|
|
return async (init: Parameters<RuntimeStore['createSession']>[0]) => {
|
|
if (init.id.startsWith('chat-restore-marker:')) {
|
|
throw new Error('restore marker failed');
|
|
}
|
|
return target.createSession(init);
|
|
};
|
|
}
|
|
const value = Reflect.get(target, property, target) as unknown;
|
|
return typeof value === 'function' ? value.bind(target) : value;
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
importDatabase(
|
|
{
|
|
stages: [
|
|
{
|
|
id: 'stage-marker-failure',
|
|
name: 'Imported stage',
|
|
createdAt: 1_000,
|
|
updatedAt: 3_000,
|
|
currentSceneId: 'imported-current-scene',
|
|
},
|
|
{
|
|
id: 'stage-created-by-import',
|
|
name: 'Created by import',
|
|
createdAt: 1_000,
|
|
updatedAt: 3_000,
|
|
currentSceneId: 'created-current-scene',
|
|
},
|
|
],
|
|
chatSessions: [
|
|
{ ...chatSession(), stageId: 'stage-marker-failure', title: 'Imported chat' },
|
|
],
|
|
},
|
|
{ store: markerFailureStore, learnerKey },
|
|
),
|
|
).rejects.toThrow('restore marker failed');
|
|
|
|
await expect(db.stages.get('stage-marker-failure')).resolves.toMatchObject({
|
|
name: 'Original stage',
|
|
});
|
|
await expect(documentStore.loadDocument('stage-marker-failure')).resolves.toMatchObject({
|
|
stage: { name: 'Original destination document' },
|
|
});
|
|
await expect(documentStore.loadDocument('stage-created-by-import')).resolves.toBeNull();
|
|
await expect(loadCurrentScene('stage-marker-failure')).resolves.toEqual(originalCurrentScene);
|
|
await expect(loadCurrentScene('stage-created-by-import')).resolves.toBeNull();
|
|
await expect(
|
|
loadChatSessions('stage-marker-failure', { store: backing, learnerKey }),
|
|
).resolves.toMatchObject([{ title: 'Original runtime chat' }]);
|
|
});
|
|
|
|
it('lifts the deletion state when a backup import recreates a deleted stage', async () => {
|
|
const { importDatabase } = await import('@/lib/utils/database');
|
|
const { getDocumentStore } = await import('@/lib/document-store');
|
|
const { isStageDeleted, markStageDeleted, stageDeletionEpoch } =
|
|
await import('@/lib/utils/deleted-stages');
|
|
|
|
// The stage was deleted earlier this session; the backup restores its id.
|
|
markStageDeleted('stage-import-revive');
|
|
const epochAfterDelete = stageDeletionEpoch('stage-import-revive');
|
|
|
|
await importDatabase({
|
|
stages: [
|
|
{
|
|
id: 'stage-import-revive',
|
|
name: 'Restored by import',
|
|
createdAt: 1_000,
|
|
updatedAt: 3_000,
|
|
},
|
|
],
|
|
});
|
|
|
|
await expect(getDocumentStore().loadDocument('stage-import-revive')).resolves.toMatchObject({
|
|
stage: { name: 'Restored by import' },
|
|
});
|
|
// The deleted flag is lifted so later edits persist; the deletion epoch
|
|
// stays bumped so pre-delete in-flight writes remain fenced.
|
|
expect(isStageDeleted('stage-import-revive')).toBe(false);
|
|
expect(stageDeletionEpoch('stage-import-revive')).toBe(epochAfterDelete);
|
|
});
|
|
|
|
it('reinstates the deletion state when a failed import rolls back a restored document', async () => {
|
|
const backing = new BrowserRuntimeStore({ indexedDB: globalThis.indexedDB });
|
|
const { importDatabase } = await import('@/lib/utils/database');
|
|
const { getDocumentStore } = await import('@/lib/document-store');
|
|
const { isStageDeleted, markStageDeleted, stageDeletionEpoch } =
|
|
await import('@/lib/utils/deleted-stages');
|
|
|
|
// Deleted earlier this session: no document, deletion in effect.
|
|
markStageDeleted('stage-import-rollback');
|
|
const epochAfterDelete = stageDeletionEpoch('stage-import-rollback');
|
|
|
|
const markerFailureStore = new Proxy(backing, {
|
|
get(target, property) {
|
|
if (property === 'createSession') {
|
|
return async (init: Parameters<RuntimeStore['createSession']>[0]) => {
|
|
if (init.id.startsWith('chat-restore-marker:')) {
|
|
throw new Error('restore marker failed');
|
|
}
|
|
return target.createSession(init);
|
|
};
|
|
}
|
|
const value = Reflect.get(target, property, target) as unknown;
|
|
return typeof value === 'function' ? value.bind(target) : value;
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
importDatabase(
|
|
{
|
|
stages: [
|
|
{
|
|
id: 'stage-import-rollback',
|
|
name: 'Restored by import',
|
|
createdAt: 1_000,
|
|
updatedAt: 3_000,
|
|
},
|
|
],
|
|
chatSessions: [],
|
|
},
|
|
{ store: markerFailureStore, learnerKey },
|
|
),
|
|
).rejects.toThrow('restore marker failed');
|
|
|
|
// The rollback removed the imported document again (pre-image was absent)…
|
|
await expect(getDocumentStore().loadDocument('stage-import-rollback')).resolves.toBeNull();
|
|
// …and reinstated the deletion state the import had lifted, so an
|
|
// outstanding flush cannot recreate the rolled-back document.
|
|
expect(isStageDeleted('stage-import-rollback')).toBe(true);
|
|
expect(stageDeletionEpoch('stage-import-rollback')).toBeGreaterThan(epochAfterDelete);
|
|
});
|
|
|
|
it('blocks lazy migration behind a runtime-wide clear and observes the deleted source', async () => {
|
|
vi.stubGlobal('navigator', { locks: fairLockManager() });
|
|
const backing = new BrowserRuntimeStore({ indexedDB: globalThis.indexedDB });
|
|
const { clearDatabase, db } = await import('@/lib/utils/database');
|
|
const { loadStageData } = await import('@/lib/utils/stage-storage');
|
|
await db.stages.put({
|
|
id: 'stage-clear-migration-race',
|
|
name: 'Legacy source',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
});
|
|
|
|
let releaseDeleteAll!: () => void;
|
|
const deleteAllGate = new Promise<void>((resolve) => {
|
|
releaseDeleteAll = resolve;
|
|
});
|
|
let clearAcquired!: () => void;
|
|
const clearHasLock = new Promise<void>((resolve) => {
|
|
clearAcquired = resolve;
|
|
});
|
|
const gatedStore = new Proxy(backing, {
|
|
get(target, property) {
|
|
if (property === 'deleteAllRuntime') {
|
|
return async () => {
|
|
clearAcquired();
|
|
await deleteAllGate;
|
|
return target.deleteAllRuntime();
|
|
};
|
|
}
|
|
const value = Reflect.get(target, property, target) as unknown;
|
|
return typeof value === 'function' ? value.bind(target) : value;
|
|
},
|
|
});
|
|
|
|
const clearing = clearDatabase(gatedStore);
|
|
await clearHasLock;
|
|
let migrationSettled = false;
|
|
const migrating = loadStageData('stage-clear-migration-race').finally(() => {
|
|
migrationSettled = true;
|
|
});
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(migrationSettled).toBe(false);
|
|
|
|
releaseDeleteAll();
|
|
await clearing;
|
|
await expect(migrating).resolves.toBeNull();
|
|
});
|
|
|
|
it('rejects a stage save that resumes after a database clear and allows a fresh save', async () => {
|
|
vi.stubGlobal('navigator', { locks: fairLockManager() });
|
|
const runtimeStore = new BrowserRuntimeStore({
|
|
indexedDB: globalThis.indexedDB,
|
|
dbName: 'clear-document-save-race',
|
|
});
|
|
const { clearDatabase } = await import('@/lib/utils/database');
|
|
const { getDocumentStore } = await import('@/lib/document-store');
|
|
const { saveStageData } = await import('@/lib/utils/stage-storage');
|
|
const documentStore = getDocumentStore();
|
|
const stage = {
|
|
id: 'stage-clear-save-race',
|
|
name: 'Before clear',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
};
|
|
await documentStore.saveDocument({ stage, scenes: [] });
|
|
|
|
let releaseMigrationLoad!: () => void;
|
|
const migrationLoadGate = new Promise<void>((resolve) => {
|
|
releaseMigrationLoad = resolve;
|
|
});
|
|
let migrationLoadEntered!: () => void;
|
|
const migrationLoadStarted = new Promise<void>((resolve) => {
|
|
migrationLoadEntered = resolve;
|
|
});
|
|
const originalLoad = documentStore.loadDocument.bind(documentStore);
|
|
let gateNextLoad = true;
|
|
vi.spyOn(documentStore, 'loadDocument').mockImplementation(async (stageId) => {
|
|
const document = await originalLoad(stageId);
|
|
if (stageId === stage.id && gateNextLoad) {
|
|
gateNextLoad = false;
|
|
migrationLoadEntered();
|
|
await migrationLoadGate;
|
|
}
|
|
return document;
|
|
});
|
|
|
|
const staleSave = saveStageData(
|
|
stage.id,
|
|
{
|
|
stage: { ...stage, name: 'Stale save' },
|
|
scenes: [],
|
|
currentSceneId: null,
|
|
chats: [],
|
|
},
|
|
0,
|
|
);
|
|
await migrationLoadStarted;
|
|
const clearing = clearDatabase(runtimeStore);
|
|
releaseMigrationLoad();
|
|
|
|
await expect(clearing).resolves.toBeUndefined();
|
|
await expect(staleSave).rejects.toThrow('storage was cleared during the mutation');
|
|
await expect(documentStore.loadDocument(stage.id)).resolves.toBeNull();
|
|
|
|
await expect(
|
|
saveStageData(
|
|
stage.id,
|
|
{
|
|
stage: { ...stage, name: 'Fresh save' },
|
|
scenes: [],
|
|
currentSceneId: null,
|
|
chats: [],
|
|
},
|
|
0,
|
|
),
|
|
).resolves.toBeUndefined();
|
|
await expect(documentStore.loadDocument(stage.id)).resolves.toMatchObject({
|
|
stage: { name: 'Fresh save' },
|
|
});
|
|
});
|
|
|
|
it('loads a divergent destination without marking or deleting its legacy source', async () => {
|
|
const { db } = await import('@/lib/utils/database');
|
|
const { getDocumentStore } = await import('@/lib/document-store');
|
|
const { loadStageData } = await import('@/lib/utils/stage-storage');
|
|
const kv = new (await import('@openmaic/storage')).BrowserKVStore();
|
|
await getDocumentStore().saveDocument({
|
|
stage: {
|
|
id: 'stage-divergent-snapshot',
|
|
name: 'Destination V1',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
},
|
|
scenes: [],
|
|
});
|
|
await db.stages.put({
|
|
id: 'stage-divergent-snapshot',
|
|
name: 'Legacy V2',
|
|
createdAt: 1_000,
|
|
updatedAt: 3_000,
|
|
});
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
|
|
await expect(loadStageData('stage-divergent-snapshot')).resolves.toMatchObject({
|
|
stage: { name: 'Destination V1' },
|
|
});
|
|
await expect(
|
|
kv.get('document-migration:stage-divergent-snapshot', 'device'),
|
|
).resolves.toBeNull();
|
|
await expect(db.stages.get('stage-divergent-snapshot')).resolves.toMatchObject({
|
|
name: 'Legacy V2',
|
|
});
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining('stage stage-divergent-snapshot'));
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it('finishes an interrupted runtime clear from the durable restore marker', async () => {
|
|
const backing = new BrowserRuntimeStore({ indexedDB: globalThis.indexedDB });
|
|
const { importDatabase } = await import('@/lib/utils/database');
|
|
const { getDocumentStore } = await import('@/lib/document-store');
|
|
const { loadChatSessions, saveChatSessions } = await import('@/lib/utils/chat-storage');
|
|
await saveChatSessions(
|
|
'stage-interrupted-restore',
|
|
[{ ...chatSession(), title: 'Pre-restore runtime chat' }],
|
|
{ store: backing, learnerKey },
|
|
);
|
|
const [oldRuntime] = (
|
|
await backing.listSessions('stage-interrupted-restore', learnerKey)
|
|
).filter((session) => session.kind === 'chat');
|
|
const failingDeleteStore = new Proxy(backing, {
|
|
get(target, property) {
|
|
if (property === 'deleteSession') {
|
|
return async (runtimeSessionId: string) => {
|
|
if (runtimeSessionId === oldRuntime?.id) throw new Error('restore delete failed');
|
|
return target.deleteSession(runtimeSessionId);
|
|
};
|
|
}
|
|
const value = Reflect.get(target, property, target) as unknown;
|
|
return typeof value === 'function' ? value.bind(target) : value;
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
importDatabase(
|
|
{
|
|
stages: [
|
|
{
|
|
id: 'stage-interrupted-restore',
|
|
name: 'Imported stage',
|
|
createdAt: 1_000,
|
|
updatedAt: 3_000,
|
|
},
|
|
],
|
|
chatSessions: [
|
|
{
|
|
...chatSession(),
|
|
stageId: 'stage-interrupted-restore',
|
|
title: 'Restored backup chat',
|
|
},
|
|
],
|
|
},
|
|
{ store: failingDeleteStore, learnerKey },
|
|
),
|
|
).rejects.toThrow('restore delete failed');
|
|
await expect(getDocumentStore().loadDocument('stage-interrupted-restore')).resolves.toBeNull();
|
|
|
|
await expect(
|
|
loadChatSessions('stage-interrupted-restore', { store: backing, learnerKey }),
|
|
).resolves.toMatchObject([{ title: 'Restored backup chat' }]);
|
|
});
|
|
|
|
it('fails backup export instead of returning legacy-only chats after a runtime read error', async () => {
|
|
const runtimeStore = new BrowserRuntimeStore({ indexedDB: globalThis.indexedDB });
|
|
const failingStore = new Proxy(runtimeStore, {
|
|
get(target, property) {
|
|
if (property === 'listSessions') {
|
|
return async () => {
|
|
throw new Error('runtime read failed');
|
|
};
|
|
}
|
|
const value = Reflect.get(target, property);
|
|
return typeof value === 'function' ? value.bind(target) : value;
|
|
},
|
|
}) as BrowserRuntimeStore;
|
|
const { db, exportDatabase } = await import('@/lib/utils/database');
|
|
await db.stages.put({
|
|
id: 'stage-backup',
|
|
name: 'Backup stage',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
});
|
|
await db.chatSessions.put({ ...chatSession(), stageId: 'stage-backup' });
|
|
|
|
await expect(exportDatabase({ store: failingStore, learnerKey })).rejects.toThrow(
|
|
'runtime read failed',
|
|
);
|
|
});
|
|
|
|
it('upgrades past the abandoned lease schema and adds compound chat restore staging', async () => {
|
|
const { default: Dexie } = await import('dexie');
|
|
const intermediate = new Dexie('MAIC-Database', {
|
|
indexedDB: globalThis.indexedDB,
|
|
IDBKeyRange: globalThis.IDBKeyRange,
|
|
});
|
|
intermediate.version(13).stores({ chatStorageLocks: 'key, expiresAt' });
|
|
await intermediate.open();
|
|
await intermediate.table('chatStorageLocks').put({
|
|
key: 'stage-old-lock',
|
|
owner: 'old-tab',
|
|
expiresAt: Date.now() + 30_000,
|
|
});
|
|
intermediate.close();
|
|
|
|
const { db } = await import('@/lib/utils/database');
|
|
await db.open();
|
|
|
|
expect(db.verno).toBe(17);
|
|
expect([...db.backendDB().objectStoreNames]).not.toContain('chatStorageLocks');
|
|
expect([...db.backendDB().objectStoreNames]).toContain('chatRestoreStaging');
|
|
});
|
|
|
|
it('waits for active and locally queued chat writers before clearing all runtime data', async () => {
|
|
const indexedDB = globalThis.indexedDB;
|
|
const dbName = 'clear-writer-lock';
|
|
const writerBacking = new BrowserRuntimeStore({ indexedDB, dbName });
|
|
const clearingStore = new BrowserRuntimeStore({ indexedDB, dbName });
|
|
let writerStarted!: () => void;
|
|
const didStartWriter = new Promise<void>((resolve) => {
|
|
writerStarted = resolve;
|
|
});
|
|
let releaseWriter!: () => void;
|
|
const writerMayContinue = new Promise<void>((resolve) => {
|
|
releaseWriter = resolve;
|
|
});
|
|
const writerStore = new Proxy(writerBacking, {
|
|
get(target, property) {
|
|
if (property === 'createSession') {
|
|
return async (...args: Parameters<BrowserRuntimeStore['createSession']>) => {
|
|
writerStarted();
|
|
await writerMayContinue;
|
|
return writerBacking.createSession(...args);
|
|
};
|
|
}
|
|
const value = Reflect.get(target, property);
|
|
return typeof value === 'function' ? value.bind(target) : value;
|
|
},
|
|
}) as BrowserRuntimeStore;
|
|
const { clearDatabase } = await import('@/lib/utils/database');
|
|
const { saveChatSessions } = await import('@/lib/utils/chat-storage');
|
|
|
|
const firstSave = saveChatSessions('stage-clear-race', [chatSession()], {
|
|
store: writerStore,
|
|
learnerKey,
|
|
});
|
|
await didStartWriter;
|
|
const secondSave = saveChatSessions(
|
|
'stage-clear-race',
|
|
[{ ...chatSession(), title: 'Queued chat save', updatedAt: 3_000 }],
|
|
{
|
|
store: writerStore,
|
|
learnerKey,
|
|
},
|
|
);
|
|
await Promise.resolve();
|
|
const clearing = clearDatabase(clearingStore);
|
|
const earlyOutcome = await Promise.race([
|
|
clearing.then(() => 'cleared' as const),
|
|
new Promise<'blocked'>((resolve) => setTimeout(() => resolve('blocked'), 50)),
|
|
]);
|
|
|
|
expect(earlyOutcome).toBe('blocked');
|
|
releaseWriter();
|
|
await expect(firstSave).resolves.toBeUndefined();
|
|
await expect(secondSave).resolves.toBeUndefined();
|
|
await expect(clearing).resolves.toBeUndefined();
|
|
await expect(clearingStore.listSessions('stage-clear-race', learnerKey)).resolves.toEqual([]);
|
|
});
|
|
|
|
it('keeps backup staging and runtime clearing in the same cross-tab lock', async () => {
|
|
const indexedDB = globalThis.indexedDB;
|
|
const importingStore = new BrowserRuntimeStore({ indexedDB, dbName: 'restore-lock' });
|
|
const loadingStore = new BrowserRuntimeStore({ indexedDB, dbName: 'restore-lock' });
|
|
const { db, exportDatabase, importDatabase } = await import('@/lib/utils/database');
|
|
const { loadChatSessions, saveChatSessions } = await import('@/lib/utils/chat-storage');
|
|
await db.stages.bulkPut([
|
|
{
|
|
id: 'stage-backup',
|
|
name: 'Backup stage',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
},
|
|
{
|
|
id: 'stage-z-backup',
|
|
name: 'Later backup stage',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
},
|
|
]);
|
|
await saveChatSessions('stage-backup', [chatSession()], {
|
|
store: importingStore,
|
|
learnerKey,
|
|
});
|
|
await saveChatSessions(
|
|
'stage-z-backup',
|
|
[{ ...chatSession(), id: 'chat-z-backup', title: 'Later persisted chat' }],
|
|
{ store: importingStore, learnerKey },
|
|
);
|
|
const exported = await exportDatabase({ store: importingStore, learnerKey });
|
|
await saveChatSessions(
|
|
'stage-backup',
|
|
[{ ...chatSession(), title: 'Newer local chat', updatedAt: 3_000 }],
|
|
{ store: importingStore, learnerKey },
|
|
);
|
|
await saveChatSessions(
|
|
'stage-z-backup',
|
|
[
|
|
{
|
|
...chatSession(),
|
|
id: 'chat-z-backup',
|
|
title: 'Later newer local chat',
|
|
updatedAt: 3_000,
|
|
},
|
|
],
|
|
{ store: importingStore, learnerKey },
|
|
);
|
|
|
|
const originalTransaction = db.transaction.bind(db) as (...args: unknown[]) => Promise<unknown>;
|
|
let transactionCommitted!: () => void;
|
|
const didCommit = new Promise<void>((resolve) => {
|
|
transactionCommitted = resolve;
|
|
});
|
|
let releaseImport!: () => void;
|
|
const importMayContinue = new Promise<void>((resolve) => {
|
|
releaseImport = resolve;
|
|
});
|
|
vi.spyOn(db, 'transaction').mockImplementation(((...args: unknown[]) =>
|
|
originalTransaction(...args).then(async (result) => {
|
|
if (args[0] !== 'rw' || !Array.isArray(args[1])) return result;
|
|
transactionCommitted();
|
|
await importMayContinue;
|
|
return result;
|
|
})) as typeof db.transaction);
|
|
|
|
const importing = importDatabase(exported, { store: importingStore, learnerKey });
|
|
await didCommit;
|
|
const loading = loadChatSessions('stage-z-backup', { store: loadingStore, learnerKey });
|
|
const earlyOutcome = await Promise.race([
|
|
loading.then(() => 'read' as const),
|
|
new Promise<'blocked'>((resolve) => setTimeout(() => resolve('blocked'), 50)),
|
|
]);
|
|
expect(earlyOutcome).toBe('blocked');
|
|
|
|
releaseImport();
|
|
await importing;
|
|
await expect(loading).resolves.toMatchObject([{ title: 'Later persisted chat' }]);
|
|
await expect(
|
|
loadChatSessions('stage-backup', { store: loadingStore, learnerKey }),
|
|
).resolves.toMatchObject([{ title: 'Persisted chat' }]);
|
|
});
|
|
|
|
it('does not deadlock backup restore behind a queued maintenance lock and later autosave', async () => {
|
|
vi.stubGlobal('navigator', { locks: fairLockManager() });
|
|
const runtimeBacking = new BrowserRuntimeStore({
|
|
indexedDB: globalThis.indexedDB,
|
|
dbName: 'restore-queue-order',
|
|
});
|
|
let firstSaveStarted!: () => void;
|
|
const didStartFirstSave = new Promise<void>((resolve) => {
|
|
firstSaveStarted = resolve;
|
|
});
|
|
let releaseFirstSave!: () => void;
|
|
const firstSaveMayContinue = new Promise<void>((resolve) => {
|
|
releaseFirstSave = resolve;
|
|
});
|
|
const runtimeStore = new Proxy(runtimeBacking, {
|
|
get(target, property) {
|
|
if (property === 'createSession') {
|
|
return async (...args: Parameters<BrowserRuntimeStore['createSession']>) => {
|
|
if (args[0].stageId === 'stage-a') {
|
|
firstSaveStarted();
|
|
await firstSaveMayContinue;
|
|
}
|
|
return runtimeBacking.createSession(...args);
|
|
};
|
|
}
|
|
const value = Reflect.get(target, property);
|
|
return typeof value === 'function' ? value.bind(target) : value;
|
|
},
|
|
}) as BrowserRuntimeStore;
|
|
const { withRuntimeStorageExclusiveLock } = await import('@/lib/utils/chat-storage-lock');
|
|
const { restoreChatSessionsFromBackup, saveChatSessions } =
|
|
await import('@/lib/utils/chat-storage');
|
|
|
|
const firstSave = saveChatSessions('stage-a', [chatSession()], {
|
|
store: runtimeStore,
|
|
learnerKey,
|
|
});
|
|
await didStartFirstSave;
|
|
const restoring = restoreChatSessionsFromBackup(['stage-a', 'stage-b'], async () => {}, {
|
|
store: runtimeStore,
|
|
learnerKey,
|
|
});
|
|
await Promise.resolve();
|
|
const maintaining = withRuntimeStorageExclusiveLock(async () => {});
|
|
const laterSave = saveChatSessions('stage-b', [], {
|
|
store: runtimeStore,
|
|
learnerKey,
|
|
snapshot: { sessions: [], restoreMarker: null },
|
|
});
|
|
|
|
releaseFirstSave();
|
|
const outcome = await Promise.race([
|
|
Promise.all([firstSave, restoring, maintaining, laterSave]).then(() => 'completed' as const),
|
|
new Promise<'blocked'>((resolve) => setTimeout(() => resolve('blocked'), 100)),
|
|
]);
|
|
|
|
expect(outcome).toBe('completed');
|
|
});
|
|
|
|
it('enrolls a stage save before document writes so maintenance cannot overtake it', async () => {
|
|
stubMemoryLocalStorage();
|
|
const { getDocumentStore } = await import('@/lib/document-store');
|
|
const { saveStageData } = await import('@/lib/utils/stage-storage');
|
|
const { withRuntimeStorageExclusiveLock } = await import('@/lib/utils/chat-storage-lock');
|
|
const documentStore = getDocumentStore();
|
|
const originalSave = documentStore.saveDocument.bind(documentStore);
|
|
let documentWriteStarted!: () => void;
|
|
const didStartDocumentWrite = new Promise<void>((resolve) => {
|
|
documentWriteStarted = resolve;
|
|
});
|
|
let releaseDocumentWrite!: () => void;
|
|
const documentWriteMayContinue = new Promise<void>((resolve) => {
|
|
releaseDocumentWrite = resolve;
|
|
});
|
|
vi.spyOn(documentStore, 'saveDocument').mockImplementation(async (document) => {
|
|
documentWriteStarted();
|
|
await documentWriteMayContinue;
|
|
return originalSave(document);
|
|
});
|
|
|
|
const saving = saveStageData(
|
|
'stage-save-enrollment',
|
|
{
|
|
stage: {
|
|
id: 'stage-save-enrollment',
|
|
name: 'Enrolled save',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
},
|
|
scenes: [],
|
|
currentSceneId: null,
|
|
chats: [chatSession()],
|
|
},
|
|
0,
|
|
);
|
|
await didStartDocumentWrite;
|
|
let maintenanceStarted = false;
|
|
const maintenance = withRuntimeStorageExclusiveLock(async () => {
|
|
maintenanceStarted = true;
|
|
});
|
|
await Promise.resolve();
|
|
|
|
expect(maintenanceStarted).toBe(false);
|
|
releaseDocumentWrite();
|
|
await Promise.all([saving, maintenance]);
|
|
});
|
|
|
|
it('does not wait inside a shared epoch for a partition read queued after maintenance', async () => {
|
|
vi.stubGlobal('navigator', { locks: fairLockManager() });
|
|
stubMemoryLocalStorage();
|
|
const { getDocumentStore } = await import('@/lib/document-store');
|
|
const { loadChatSessions } = await import('@/lib/utils/chat-storage');
|
|
const { withRuntimeStorageExclusiveLock } = await import('@/lib/utils/chat-storage-lock');
|
|
const { saveStageData } = await import('@/lib/utils/stage-storage');
|
|
const documentStore = getDocumentStore();
|
|
const originalSave = documentStore.saveDocument.bind(documentStore);
|
|
let documentWriteStarted!: () => void;
|
|
const didStartDocumentWrite = new Promise<void>((resolve) => {
|
|
documentWriteStarted = resolve;
|
|
});
|
|
let releaseDocumentWrite!: () => void;
|
|
const documentWriteMayContinue = new Promise<void>((resolve) => {
|
|
releaseDocumentWrite = resolve;
|
|
});
|
|
vi.spyOn(documentStore, 'saveDocument').mockImplementation(async (document) => {
|
|
documentWriteStarted();
|
|
await documentWriteMayContinue;
|
|
return originalSave(document);
|
|
});
|
|
|
|
const saving = saveStageData(
|
|
'stage-epoch-order',
|
|
{
|
|
stage: {
|
|
id: 'stage-epoch-order',
|
|
name: 'Epoch ordering',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
},
|
|
scenes: [],
|
|
currentSceneId: null,
|
|
chats: [chatSession()],
|
|
},
|
|
0,
|
|
);
|
|
await didStartDocumentWrite;
|
|
const boundedExclusive = withRuntimeStorageExclusiveLock as <T>(
|
|
work: () => Promise<T>,
|
|
options: { acquireTimeoutMs: number },
|
|
) => Promise<T>;
|
|
const maintenance = boundedExclusive(async () => {}, { acquireTimeoutMs: 50 });
|
|
await Promise.resolve();
|
|
const loading = loadChatSessions('stage-epoch-order');
|
|
|
|
releaseDocumentWrite();
|
|
const [, , loaded] = await Promise.all([saving, maintenance, loading]);
|
|
expect(loaded).toMatchObject([{ id: 'chat-backup' }]);
|
|
});
|
|
|
|
it('bounds maintenance lock acquisition without running delayed destructive work', async () => {
|
|
vi.useFakeTimers();
|
|
vi.stubGlobal('navigator', { locks: fairLockManager() });
|
|
const { withRuntimeStorageExclusiveLock, withRuntimeStorageSharedLock } =
|
|
await import('@/lib/utils/chat-storage-lock');
|
|
let sharedStarted!: () => void;
|
|
const didStartShared = new Promise<void>((resolve) => {
|
|
sharedStarted = resolve;
|
|
});
|
|
let releaseShared!: () => void;
|
|
const sharedMayContinue = new Promise<void>((resolve) => {
|
|
releaseShared = resolve;
|
|
});
|
|
const shared = withRuntimeStorageSharedLock(async () => {
|
|
sharedStarted();
|
|
await sharedMayContinue;
|
|
});
|
|
await didStartShared;
|
|
let maintenanceRan = false;
|
|
const boundedExclusive = withRuntimeStorageExclusiveLock as <T>(
|
|
work: () => Promise<T>,
|
|
options: { acquireTimeoutMs: number },
|
|
) => Promise<T>;
|
|
const maintenance = boundedExclusive(
|
|
async () => {
|
|
maintenanceRan = true;
|
|
},
|
|
{ acquireTimeoutMs: 50 },
|
|
);
|
|
const outcome = Promise.race([
|
|
maintenance.then(
|
|
() => 'completed' as const,
|
|
() => 'rejected' as const,
|
|
),
|
|
new Promise<'still-pending'>((resolve) => setTimeout(() => resolve('still-pending'), 51)),
|
|
]);
|
|
|
|
try {
|
|
await vi.advanceTimersByTimeAsync(51);
|
|
await expect(outcome).resolves.toBe('rejected');
|
|
let laterSharedStarted = false;
|
|
const laterShared = withRuntimeStorageSharedLock(async () => {
|
|
laterSharedStarted = true;
|
|
});
|
|
await Promise.resolve();
|
|
expect(laterSharedStarted).toBe(true);
|
|
await laterShared;
|
|
} finally {
|
|
releaseShared();
|
|
await shared;
|
|
await maintenance.catch(() => {});
|
|
vi.useRealTimers();
|
|
}
|
|
expect(maintenanceRan).toBe(false);
|
|
});
|
|
|
|
it('enrolls backup import before learner context setup', async () => {
|
|
const runtimeStore = new BrowserRuntimeStore({
|
|
indexedDB: globalThis.indexedDB,
|
|
dbName: 'import-setup-enrollment',
|
|
});
|
|
let learnerLookupStarted!: () => void;
|
|
const didStartLearnerLookup = new Promise<void>((resolve) => {
|
|
learnerLookupStarted = resolve;
|
|
});
|
|
let releaseLearnerLookup!: () => void;
|
|
const learnerLookupMayContinue = new Promise<void>((resolve) => {
|
|
releaseLearnerLookup = resolve;
|
|
});
|
|
const kv: KVStore = {
|
|
async get<T>() {
|
|
learnerLookupStarted();
|
|
await learnerLookupMayContinue;
|
|
return learnerKey as T;
|
|
},
|
|
async set() {},
|
|
async remove() {},
|
|
async keys() {
|
|
return [];
|
|
},
|
|
};
|
|
const { importDatabase } = await import('@/lib/utils/database');
|
|
const { withRuntimeStorageExclusiveLock } = await import('@/lib/utils/chat-storage-lock');
|
|
const importing = importDatabase(
|
|
{
|
|
stages: [
|
|
{
|
|
id: 'stage-import-enrollment',
|
|
name: 'Imported stage',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
},
|
|
],
|
|
chatSessions: [
|
|
{
|
|
...chatSession(),
|
|
stageId: 'stage-import-enrollment',
|
|
},
|
|
],
|
|
},
|
|
{ store: runtimeStore, kv },
|
|
);
|
|
await didStartLearnerLookup;
|
|
let maintenanceStarted = false;
|
|
const maintenance = withRuntimeStorageExclusiveLock(async () => {
|
|
maintenanceStarted = true;
|
|
});
|
|
await Promise.resolve();
|
|
|
|
expect(maintenanceStarted).toBe(false);
|
|
releaseLearnerLookup();
|
|
await Promise.all([importing, maintenance]);
|
|
});
|
|
|
|
it('waits for an active runtime writer before deleting a stage cascade', async () => {
|
|
const { getRuntimeStore } = await import('@/lib/runtime/store');
|
|
const backing = getRuntimeStore() as BrowserRuntimeStore;
|
|
let writerStarted!: () => void;
|
|
const didStartWriter = new Promise<void>((resolve) => {
|
|
writerStarted = resolve;
|
|
});
|
|
let releaseWriter!: () => void;
|
|
const writerMayContinue = new Promise<void>((resolve) => {
|
|
releaseWriter = resolve;
|
|
});
|
|
const writerStore = new Proxy(backing, {
|
|
get(target, property) {
|
|
if (property === 'createSession') {
|
|
return async (...args: Parameters<BrowserRuntimeStore['createSession']>) => {
|
|
writerStarted();
|
|
await writerMayContinue;
|
|
return backing.createSession(...args);
|
|
};
|
|
}
|
|
const value = Reflect.get(target, property);
|
|
return typeof value === 'function' ? value.bind(target) : value;
|
|
},
|
|
}) as BrowserRuntimeStore;
|
|
const { db, deleteStageWithRelatedData } = await import('@/lib/utils/database');
|
|
const { saveChatSessions } = await import('@/lib/utils/chat-storage');
|
|
await db.stages.put({
|
|
id: 'stage-delete-race',
|
|
name: 'Delete race',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
});
|
|
|
|
const saving = saveChatSessions('stage-delete-race', [chatSession()], {
|
|
store: writerStore,
|
|
learnerKey,
|
|
});
|
|
await didStartWriter;
|
|
const deleting = deleteStageWithRelatedData('stage-delete-race');
|
|
const earlyOutcome = await Promise.race([
|
|
deleting.then(() => 'deleted' as const),
|
|
new Promise<'blocked'>((resolve) => setTimeout(() => resolve('blocked'), 50)),
|
|
]);
|
|
|
|
expect(earlyOutcome).toBe('blocked');
|
|
releaseWriter();
|
|
await saving;
|
|
await deleting;
|
|
await expect(backing.listSessions('stage-delete-race', learnerKey)).resolves.toEqual([]);
|
|
});
|
|
|
|
it('deletes interrupted-restore staging rows with the stage cascade', async () => {
|
|
stubMemoryLocalStorage();
|
|
const { db, deleteStageWithRelatedData, exportDatabase } = await import('@/lib/utils/database');
|
|
await db.stages.put({
|
|
id: 'stage-delete-staging',
|
|
name: 'Delete staged restore',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
});
|
|
await db.chatRestoreStaging.put({
|
|
...chatSession(),
|
|
stageId: 'stage-delete-staging',
|
|
});
|
|
|
|
await deleteStageWithRelatedData('stage-delete-staging');
|
|
|
|
await expect(
|
|
db.chatRestoreStaging.where('stageId').equals('stage-delete-staging').count(),
|
|
).resolves.toBe(0);
|
|
const exported = await exportDatabase();
|
|
expect(
|
|
exported.chatSessions.some((session) => session.stageId === 'stage-delete-staging'),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('deletes stage-owned media rows when the document has no asset references', async () => {
|
|
const { db, deleteStageWithRelatedData } = await import('@/lib/utils/database');
|
|
const stageId = 'stage-delete-media-row';
|
|
await db.stages.put({
|
|
id: stageId,
|
|
name: 'Media row only',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
});
|
|
await db.mediaFiles.put({
|
|
id: `${stageId}:ast_orphan`,
|
|
stageId,
|
|
type: 'image',
|
|
blob: new Blob(['image']),
|
|
mimeType: 'image/png',
|
|
size: 5,
|
|
prompt: '',
|
|
params: '{}',
|
|
createdAt: 1_000,
|
|
});
|
|
|
|
await deleteStageWithRelatedData(stageId);
|
|
|
|
await expect(db.mediaFiles.where('stageId').equals(stageId).count()).resolves.toBe(0);
|
|
});
|
|
|
|
it('keeps the maintenance lock until a timed-out stage cascade actually settles', async () => {
|
|
vi.stubGlobal('navigator', { locks: fairLockManager() });
|
|
stubMemoryLocalStorage();
|
|
const { getRuntimeStore } = await import('@/lib/runtime/store');
|
|
const backing = getRuntimeStore() as BrowserRuntimeStore;
|
|
const originalDelete = backing.deleteStageRuntime.bind(backing);
|
|
let cascadeStarted!: () => void;
|
|
const didStartCascade = new Promise<void>((resolve) => {
|
|
cascadeStarted = resolve;
|
|
});
|
|
let releaseCascade!: () => void;
|
|
const cascadeMayContinue = new Promise<void>((resolve) => {
|
|
releaseCascade = resolve;
|
|
});
|
|
vi.spyOn(backing, 'deleteStageRuntime').mockImplementation(async (stageId) => {
|
|
cascadeStarted();
|
|
await cascadeMayContinue;
|
|
await originalDelete(stageId);
|
|
});
|
|
const { db } = await import('@/lib/utils/database');
|
|
const { deleteStageData } = await import('@/lib/utils/stage-storage');
|
|
const { loadChatSessions, saveChatSessions } = await import('@/lib/utils/chat-storage');
|
|
await db.stages.put({
|
|
id: 'stage-timeout-delete',
|
|
name: 'Timed-out delete',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
});
|
|
await saveChatSessions('stage-timeout-delete', [chatSession()], {
|
|
store: backing,
|
|
learnerKey,
|
|
});
|
|
await Promise.resolve();
|
|
|
|
const deleting = deleteStageData('stage-timeout-delete');
|
|
await didStartCascade;
|
|
await expect(deleting).resolves.toBeUndefined();
|
|
|
|
let replacementStarted!: () => void;
|
|
const didStartReplacement = new Promise<void>((resolve) => {
|
|
replacementStarted = resolve;
|
|
});
|
|
let replacementReadStarted = false;
|
|
const replacementStore = new Proxy(backing, {
|
|
get(target, property) {
|
|
if (property === 'listSessions') {
|
|
return async (...args: Parameters<BrowserRuntimeStore['listSessions']>) => {
|
|
if (!replacementReadStarted) {
|
|
replacementReadStarted = true;
|
|
replacementStarted();
|
|
}
|
|
return backing.listSessions(...args);
|
|
};
|
|
}
|
|
const value = Reflect.get(target, property);
|
|
return typeof value === 'function' ? value.bind(target) : value;
|
|
},
|
|
}) as BrowserRuntimeStore;
|
|
const replacement = saveChatSessions(
|
|
'stage-timeout-delete',
|
|
[{ ...chatSession(), title: 'Saved after timeout', updatedAt: 4_000 }],
|
|
{ store: replacementStore, learnerKey },
|
|
);
|
|
const replacementOutcome = await Promise.race([
|
|
didStartReplacement.then(() => 'started' as const),
|
|
new Promise<'blocked'>((resolve) => setTimeout(() => resolve('blocked'), 50)),
|
|
]);
|
|
|
|
releaseCascade();
|
|
await replacement;
|
|
expect(replacementOutcome).toBe('blocked');
|
|
await expect(
|
|
loadChatSessions('stage-timeout-delete', { store: backing, learnerKey }),
|
|
).resolves.toMatchObject([{ title: 'Saved after timeout', updatedAt: 4_000 }]);
|
|
}, 10_000);
|
|
|
|
it('acquires both the stage-wide and legacy partition Web Lock names', async () => {
|
|
const requested: string[] = [];
|
|
vi.stubGlobal('navigator', {
|
|
locks: {
|
|
async request<T>(
|
|
name: string,
|
|
optionsOrCallback: LockOptions | (() => Promise<T> | T),
|
|
maybeCallback?: () => Promise<T> | T,
|
|
): Promise<T> {
|
|
requested.push(name);
|
|
const callback =
|
|
typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback!;
|
|
return callback();
|
|
},
|
|
},
|
|
});
|
|
const runtimeStore = new BrowserRuntimeStore({
|
|
indexedDB: globalThis.indexedDB,
|
|
dbName: 'compatible-restore-lock',
|
|
});
|
|
const { loadChatSessions } = await import('@/lib/utils/chat-storage');
|
|
|
|
await loadChatSessions('stage-compatible-lock', { store: runtimeStore, learnerKey });
|
|
|
|
expect(requested).toEqual([
|
|
'openmaic:chat-storage:all',
|
|
`openmaic:chat-storage:${encodeURIComponent('stage-compatible-lock')}`,
|
|
`openmaic:chat-storage:${encodeURIComponent(`stage-compatible-lock\0${learnerKey}`)}`,
|
|
]);
|
|
});
|
|
|
|
it('keeps the global maintenance lock disjoint from a stage named all', async () => {
|
|
const runtimeStore = new BrowserRuntimeStore({
|
|
indexedDB: globalThis.indexedDB,
|
|
dbName: 'stage-all-lock',
|
|
});
|
|
const { saveChatSessions } = await import('@/lib/utils/chat-storage');
|
|
|
|
const outcome = await Promise.race([
|
|
saveChatSessions('all', [chatSession()], { store: runtimeStore, learnerKey }).then(
|
|
() => 'saved' as const,
|
|
),
|
|
new Promise<'blocked'>((resolve) => setTimeout(() => resolve('blocked'), 50)),
|
|
]);
|
|
|
|
expect(outcome).toBe('saved');
|
|
});
|
|
|
|
it('fails before mutating backup data when the default legacy store has no Web Locks', async () => {
|
|
vi.stubGlobal('navigator', {});
|
|
const runtimeStore = new BrowserRuntimeStore({
|
|
indexedDB: globalThis.indexedDB,
|
|
dbName: 'missing-web-locks',
|
|
});
|
|
const { db, importDatabase } = await import('@/lib/utils/database');
|
|
await db.stages.put({
|
|
id: 'stage-no-lock',
|
|
name: 'Existing stage',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
});
|
|
|
|
await expect(
|
|
importDatabase(
|
|
{
|
|
stages: [
|
|
{
|
|
id: 'stage-no-lock',
|
|
name: 'Restored stage',
|
|
createdAt: 3_000,
|
|
updatedAt: 4_000,
|
|
},
|
|
],
|
|
chatSessions: [{ ...chatSession(), stageId: 'stage-no-lock' }],
|
|
},
|
|
{ store: runtimeStore, learnerKey },
|
|
),
|
|
).rejects.toThrow(/Web Locks/);
|
|
await expect(db.stages.get('stage-no-lock')).resolves.toMatchObject({
|
|
name: 'Existing stage',
|
|
});
|
|
await expect(
|
|
db.chatSessions.where('stageId').equals('stage-no-lock').toArray(),
|
|
).resolves.toEqual([]);
|
|
});
|
|
|
|
it('keeps empty-chat document saves available without Web Locks', async () => {
|
|
vi.stubGlobal('navigator', {});
|
|
stubMemoryLocalStorage();
|
|
const { loadStageData, saveStageData } = await import('@/lib/utils/stage-storage');
|
|
|
|
await expect(
|
|
saveStageData(
|
|
'stage-no-chat',
|
|
{
|
|
stage: {
|
|
id: 'stage-no-chat',
|
|
name: 'No chat stage',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
},
|
|
scenes: [],
|
|
currentSceneId: null,
|
|
chats: [],
|
|
},
|
|
0,
|
|
),
|
|
).resolves.toBeUndefined();
|
|
await expect(loadStageData('stage-no-chat')).resolves.toMatchObject({
|
|
stage: { name: 'No chat stage' },
|
|
});
|
|
});
|
|
|
|
it('keeps legacy chats visible without Web Locks without migrating them', async () => {
|
|
vi.stubGlobal('navigator', {});
|
|
stubMemoryLocalStorage();
|
|
const { db } = await import('@/lib/utils/database');
|
|
const { loadChatSessions } = await import('@/lib/utils/chat-storage');
|
|
await db.chatSessions.put({
|
|
...chatSession(),
|
|
stageId: 'stage-legacy-no-lock',
|
|
});
|
|
|
|
await expect(loadChatSessions('stage-legacy-no-lock')).resolves.toMatchObject([
|
|
{ id: 'chat-backup', title: 'Persisted chat' },
|
|
]);
|
|
await expect(
|
|
db.chatSessions.where('stageId').equals('stage-legacy-no-lock').count(),
|
|
).resolves.toBe(1);
|
|
});
|
|
|
|
it('exports a canonical legacy-only document without Web Locks and leaves legacy data untouched', async () => {
|
|
vi.stubGlobal('navigator', {});
|
|
stubMemoryLocalStorage();
|
|
const runtimeStore = new BrowserRuntimeStore({
|
|
indexedDB: globalThis.indexedDB,
|
|
dbName: 'legacy-only-export-no-locks',
|
|
});
|
|
const { db, exportDatabase } = await import('@/lib/utils/database');
|
|
const { getDocumentStore } = await import('@/lib/document-store');
|
|
const stage = {
|
|
id: 'stage-legacy-only-export',
|
|
name: 'Legacy-only export',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
currentSceneId: 'scene-legacy-only-export',
|
|
};
|
|
const scene = {
|
|
id: 'scene-legacy-only-export',
|
|
stageId: stage.id,
|
|
type: 'quiz' as const,
|
|
title: 'Legacy scene',
|
|
order: 0,
|
|
content: { type: 'slide' as const, canvas: { id: 'canvas-legacy', elements: [] } as never },
|
|
whiteboard: [{ id: 'whiteboard-legacy', elements: [] } as never],
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
};
|
|
await db.stages.put(stage);
|
|
await db.scenes.put(scene);
|
|
// Chat history lives on the RuntimeStore seam, independent of document
|
|
// migration — a legacy-only document's chats must still reach the backup.
|
|
// Write it while locks exist, then export from the no-locks environment.
|
|
vi.stubGlobal('navigator', { locks: serialLockManager() });
|
|
const { saveChatSessions } = await import('@/lib/utils/chat-storage');
|
|
await saveChatSessions(stage.id, [{ ...chatSession(), id: 'legacy-only-chat' }], {
|
|
store: runtimeStore,
|
|
learnerKey,
|
|
});
|
|
vi.stubGlobal('navigator', {});
|
|
|
|
const backup = await exportDatabase({ store: runtimeStore, learnerKey });
|
|
const exportedDocument = backup.documents.find((document) => document.stage.id === stage.id);
|
|
expect(
|
|
backup.chatSessions.filter(
|
|
(session) => session.stageId === stage.id && session.id === 'legacy-only-chat',
|
|
),
|
|
).toHaveLength(1);
|
|
|
|
expect(exportedDocument).toMatchObject({
|
|
dslVersion: '0.1.0',
|
|
stage: { id: stage.id, name: 'Legacy-only export' },
|
|
scenes: [
|
|
{
|
|
id: scene.id,
|
|
type: 'slide',
|
|
whiteboards: [{ id: 'whiteboard-legacy' }],
|
|
},
|
|
],
|
|
});
|
|
expect(exportedDocument!.stage).not.toHaveProperty('currentSceneId');
|
|
await expect(getDocumentStore().loadDocument(stage.id)).resolves.toBeNull();
|
|
await expect(db.stages.get(stage.id)).resolves.toEqual(stage);
|
|
await expect(db.scenes.get(scene.id)).resolves.toEqual(scene);
|
|
});
|
|
|
|
it('keeps unrelated document saves available for an unchanged read-only legacy snapshot', async () => {
|
|
vi.stubGlobal('navigator', {});
|
|
stubMemoryLocalStorage();
|
|
const { getDocumentStore } = await import('@/lib/document-store');
|
|
const { db } = await import('@/lib/utils/database');
|
|
const { loadStageData, saveStageData } = await import('@/lib/utils/stage-storage');
|
|
const legacyStage = {
|
|
id: 'stage-legacy-autosave',
|
|
name: 'Existing stage',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
};
|
|
await db.stages.put(legacyStage);
|
|
await getDocumentStore().saveDocument({ stage: legacyStage, scenes: [] });
|
|
await db.chatSessions.put({
|
|
...chatSession(),
|
|
stageId: 'stage-legacy-autosave',
|
|
});
|
|
const loaded = await loadStageData('stage-legacy-autosave');
|
|
|
|
await expect(
|
|
saveStageData(
|
|
'stage-legacy-autosave',
|
|
{
|
|
...loaded!,
|
|
stage: { ...loaded!.stage, name: 'Updated stage' },
|
|
},
|
|
0,
|
|
),
|
|
).resolves.toBeUndefined();
|
|
await expect(loadStageData('stage-legacy-autosave')).resolves.toMatchObject({
|
|
stage: { name: 'Updated stage' },
|
|
});
|
|
await expect(db.stages.get('stage-legacy-autosave')).resolves.toMatchObject({
|
|
name: 'Existing stage',
|
|
});
|
|
await expect(
|
|
db.chatSessions.where('stageId').equals('stage-legacy-autosave').count(),
|
|
).resolves.toBe(1);
|
|
|
|
vi.stubGlobal('navigator', { locks: serialLockManager() });
|
|
await expect(loadStageData('stage-legacy-autosave')).resolves.toMatchObject({
|
|
chats: [{ id: 'chat-backup', title: 'Persisted chat' }],
|
|
});
|
|
await expect(
|
|
db.chatSessions.where('stageId').equals('stage-legacy-autosave').count(),
|
|
).resolves.toBe(0);
|
|
});
|
|
|
|
it('still fails edits to a read-only legacy snapshot without Web Locks', async () => {
|
|
vi.stubGlobal('navigator', {});
|
|
stubMemoryLocalStorage();
|
|
const { db } = await import('@/lib/utils/database');
|
|
const { loadStageData, saveStageData } = await import('@/lib/utils/stage-storage');
|
|
await db.stages.put({
|
|
id: 'stage-legacy-edit',
|
|
name: 'Existing stage',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
});
|
|
await db.chatSessions.put({
|
|
...chatSession(),
|
|
stageId: 'stage-legacy-edit',
|
|
});
|
|
const loaded = await loadStageData('stage-legacy-edit');
|
|
|
|
await expect(
|
|
saveStageData(
|
|
'stage-legacy-edit',
|
|
{
|
|
...loaded!,
|
|
chats: [{ ...loaded!.chats[0]!, title: 'Unsaved edit', updatedAt: 3_000 }],
|
|
},
|
|
0,
|
|
),
|
|
).rejects.toThrow(/Web Locks/);
|
|
});
|
|
|
|
it('still fails deletion of a read-only legacy snapshot without Web Locks', async () => {
|
|
vi.stubGlobal('navigator', {});
|
|
stubMemoryLocalStorage();
|
|
const { db } = await import('@/lib/utils/database');
|
|
const { loadStageData, saveStageData } = await import('@/lib/utils/stage-storage');
|
|
await db.stages.put({
|
|
id: 'stage-legacy-delete',
|
|
name: 'Existing stage',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
});
|
|
await db.chatSessions.put({
|
|
...chatSession(),
|
|
stageId: 'stage-legacy-delete',
|
|
});
|
|
const loaded = await loadStageData('stage-legacy-delete');
|
|
|
|
await expect(
|
|
saveStageData('stage-legacy-delete', { ...loaded!, chats: [] }, 0),
|
|
).rejects.toThrow(/Web Locks/);
|
|
});
|
|
|
|
it('still fails non-empty chat document saves without Web Locks', async () => {
|
|
vi.stubGlobal('navigator', {});
|
|
stubMemoryLocalStorage();
|
|
const { saveStageData } = await import('@/lib/utils/stage-storage');
|
|
|
|
await expect(
|
|
saveStageData(
|
|
'stage-with-chat',
|
|
{
|
|
stage: {
|
|
id: 'stage-with-chat',
|
|
name: 'Chat stage',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
},
|
|
scenes: [],
|
|
currentSceneId: null,
|
|
chats: [chatSession()],
|
|
},
|
|
0,
|
|
),
|
|
).rejects.toThrow(/Web Locks/);
|
|
});
|
|
|
|
it('clears document and runtime databases without Web Locks', async () => {
|
|
vi.stubGlobal('navigator', {});
|
|
const runtimeStore = new BrowserRuntimeStore({
|
|
indexedDB: globalThis.indexedDB,
|
|
dbName: 'clear-without-web-locks',
|
|
});
|
|
const { clearDatabase, db } = await import('@/lib/utils/database');
|
|
const { putAsset } = await import('@/lib/media/asset-pool');
|
|
const { getDocumentStore } = await import('@/lib/document-store');
|
|
await db.stages.put({
|
|
id: 'stage-clear-no-lock',
|
|
name: 'Clear without locks',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
});
|
|
await runtimeStore.createSession({
|
|
id: 'runtime-clear-no-lock',
|
|
kind: 'chat',
|
|
stageId: 'stage-clear-no-lock',
|
|
learnerKey,
|
|
status: 'active',
|
|
createdAt: new Date(1_000).toISOString(),
|
|
updatedAt: new Date(2_000).toISOString(),
|
|
});
|
|
await getDocumentStore().saveDocument({
|
|
stage: {
|
|
id: 'stage-clear-document',
|
|
name: 'Document to clear',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
},
|
|
scenes: [],
|
|
});
|
|
localStorage.setItem('maic:device:document-migration:stage-clear-document', '{}');
|
|
localStorage.setItem('maic:device:editor-current-scene:stage-clear-document', '{}');
|
|
await putAsset(new Blob(['private generated media'], { type: 'text/plain' }));
|
|
expect((await indexedDB.databases()).map((entry) => entry.name)).toContain('maic-asset-pool');
|
|
|
|
await expect(clearDatabase(runtimeStore)).resolves.toBeUndefined();
|
|
await expect(runtimeStore.listSessions('stage-clear-no-lock', learnerKey)).resolves.toEqual([]);
|
|
await db.open();
|
|
await expect(db.stages.count()).resolves.toBe(0);
|
|
await expect(getDocumentStore().loadDocument('stage-clear-document')).resolves.toBeNull();
|
|
expect(localStorage.getItem('maic:device:document-migration:stage-clear-document')).toBeNull();
|
|
expect(
|
|
localStorage.getItem('maic:device:editor-current-scene:stage-clear-document'),
|
|
).toBeNull();
|
|
expect((await indexedDB.databases()).map((entry) => entry.name)).not.toContain(
|
|
'maic-asset-pool',
|
|
);
|
|
});
|
|
|
|
it('round-trips versioned document backups including outline envelopes', async () => {
|
|
const runtimeStore = new BrowserRuntimeStore({ indexedDB: globalThis.indexedDB });
|
|
const { exportDatabase, importDatabase } = await import('@/lib/utils/database');
|
|
const { getDocumentStore } = await import('@/lib/document-store');
|
|
const documentStore = getDocumentStore();
|
|
await documentStore.saveDocument({
|
|
stage: {
|
|
id: 'stage-document-backup',
|
|
name: 'Aggregate backup',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
},
|
|
scenes: [],
|
|
outline: { outlines: [], generationComplete: true, createdAt: 1_000, updatedAt: 2_000 },
|
|
});
|
|
|
|
const backup = await exportDatabase({ store: runtimeStore, learnerKey });
|
|
expect(backup).not.toHaveProperty('stages');
|
|
expect(backup).not.toHaveProperty('scenes');
|
|
expect(backup.documents).toMatchObject([
|
|
{
|
|
stage: { id: 'stage-document-backup', name: 'Aggregate backup' },
|
|
outline: { generationComplete: true },
|
|
dslVersion: '0.1.0',
|
|
},
|
|
]);
|
|
|
|
await documentStore.deleteDocument('stage-document-backup');
|
|
await importDatabase(backup, { store: runtimeStore, learnerKey });
|
|
await expect(documentStore.loadDocument('stage-document-backup')).resolves.toMatchObject({
|
|
stage: { name: 'Aggregate backup' },
|
|
outline: { generationComplete: true },
|
|
dslVersion: '0.1.0',
|
|
});
|
|
});
|
|
|
|
it('accepts legacy stage/scene backups and canonicalizes whiteboards', async () => {
|
|
const { importDatabase } = await import('@/lib/utils/database');
|
|
const { getDocumentStore, loadCurrentScene } = await import('@/lib/document-store');
|
|
await importDatabase({
|
|
stages: [
|
|
{
|
|
id: 'stage-legacy-backup',
|
|
name: 'Legacy backup',
|
|
currentSceneId: 'scene-legacy-backup',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
},
|
|
],
|
|
scenes: [
|
|
{
|
|
id: 'scene-legacy-backup',
|
|
stageId: 'stage-legacy-backup',
|
|
type: 'quiz',
|
|
title: 'Legacy scene',
|
|
order: 0,
|
|
content: {
|
|
type: 'quiz',
|
|
questions: [],
|
|
},
|
|
whiteboard: [],
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
},
|
|
],
|
|
});
|
|
|
|
const restored = await getDocumentStore().loadDocument('stage-legacy-backup');
|
|
expect(restored?.stage).not.toHaveProperty('currentSceneId');
|
|
expect(restored?.scenes[0]).toHaveProperty('whiteboards');
|
|
expect(restored?.scenes[0]).not.toHaveProperty('whiteboard');
|
|
await expect(loadCurrentScene('stage-legacy-backup')).resolves.toMatchObject({
|
|
sceneId: 'scene-legacy-backup',
|
|
});
|
|
});
|
|
|
|
it('fails loud without deleting documents when the runtime-wide clear fails', async () => {
|
|
const { clearDatabase, db } = await import('@/lib/utils/database');
|
|
await db.stages.put({
|
|
id: 'stage-retained',
|
|
name: 'Retained stage',
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
});
|
|
const runtimeStore = {
|
|
deleteAllRuntime: vi.fn().mockRejectedValue(new Error('runtime clear failed')),
|
|
} as unknown as BrowserRuntimeStore;
|
|
|
|
await expect(clearDatabase(runtimeStore)).rejects.toThrow('runtime clear failed');
|
|
await expect(db.stages.get('stage-retained')).resolves.toMatchObject({ id: 'stage-retained' });
|
|
});
|
|
});
|