import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { IDBFactory, IDBKeyRange } from 'fake-indexeddb';
import {
BrowserRuntimeStore,
RuntimeAppendConflictError,
type RuntimeStore,
} from '@openmaic/storage';
import { HttpRuntimeStoreError } from '@openmaic/storage/runtime/http';
import type { RuntimeRecord } from '@openmaic/dsl';
import type { UIMessage } from 'ai';
import {
interruptActiveChatSessions,
nextChatUpdatedAt,
withChatSegmentReveal,
withChatSegmentSealed,
withChatSessionStatus,
type ChatMessageMetadata,
type ChatSession,
} from '@/lib/types/chat';
import {
loadChatSessions,
restoreChatSessionsFromBackup,
saveChatSessions,
type ChatStorageSnapshot,
} from '@/lib/utils/chat-storage';
import { buildChatRecordInit, runtimeSessionId, statePayload } from '@/lib/utils/chat-storage-core';
if (!('IDBKeyRange' in globalThis)) {
Object.defineProperty(globalThis, 'IDBKeyRange', { value: IDBKeyRange, configurable: true });
}
vi.mock('@/lib/utils/database', () => ({
db: {
chatSessions: {
where: vi.fn(() => ({
equals: vi.fn(() => ({
delete: vi.fn().mockResolvedValue(0),
sortBy: vi.fn().mockResolvedValue([]),
})),
})),
bulkPut: vi.fn().mockResolvedValue(undefined),
},
transaction: vi.fn(async (_mode: string, _table: unknown, work: () => Promise<void>) => work()),
},
}));
const STAGE_ID = 'stage-chat';
const LEARNER_KEY = 'anon:chat-test';
interface LegacyChatStore {
load(stageId: string): Promise<ChatSession[]>;
clear(stageId: string): Promise<void>;
}
class MemoryLegacyChatStore implements LegacyChatStore {
clearCalls = 0;
constructor(public sessions: ChatSession[] = []) {}
async load(): Promise<ChatSession[]> {
return structuredClone(this.sessions);
}
async clear(): Promise<void> {
this.clearCalls += 1;
this.sessions = [];
}
}
class FailingClearLegacyChatStore extends MemoryLegacyChatStore {
failNextClear = true;
override async clear(): Promise<void> {
if (this.failNextClear) {
this.failNextClear = false;
throw new Error('legacy clear failed');
}
await super.clear();
}
}
function message(
id: string,
role: 'user' | 'assistant',
text: string,
createdAt: number,
): UIMessage<ChatMessageMetadata> {
return {
id,
role,
parts: [{ type: 'text', text }],
metadata: { createdAt, originalRole: role === 'user' ? 'user' : 'agent' },
};
}
function session(overrides: Partial<ChatSession> = {}): ChatSession {
return {
id: 'session-1',
type: 'qa',
title: 'Q&A',
status: 'active',
messages: [message('message-1', 'user', 'Hello', 1_000)],
config: { agentIds: ['default-1'], defaultAgentId: 'default-1' },
toolCalls: [],
pendingToolCalls: [
{
toolCallId: 'pending-1',
toolName: 'spotlight',
args: {},
agentId: 'default-1',
status: 'pending',
requestedAt: 1_100,
},
],
createdAt: 900,
updatedAt: 1_200,
sceneId: 'scene-1',
lastActionIndex: 3,
...overrides,
};
}
function makeRuntimeStore(): RuntimeStore {
return new BrowserRuntimeStore({ indexedDB: new IDBFactory() });
}
function withCreateRace(backing: RuntimeStore): RuntimeStore {
const createSession = vi.fn(async (init: Parameters<RuntimeStore['createSession']>[0]) => {
await backing.createSession(init);
throw new Error('session already exists');
});
return new Proxy(backing, {
get(target, property) {
if (property === 'createSession') return createSession;
const value = Reflect.get(target, property, target) as unknown;
return typeof value === 'function' ? value.bind(target) : value;
},
});
}
async function runtimeChatRecords(store: RuntimeStore): Promise<RuntimeRecord[]> {
const sessions = (await store.listSessions(STAGE_ID, LEARNER_KEY)).filter(
(candidate) => candidate.kind === 'chat',
);
return (await Promise.all(sessions.map((candidate) => store.listRecords(candidate.id)))).flat();
}
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('chat RuntimeStore cutover', () => {
it('persists chat sessions as replayable RuntimeStore records and loads them back', async () => {
const store = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
const input = session();
await saveChatSessions(STAGE_ID, [input], { store, learnerKey: LEARNER_KEY, legacyStore });
const runtimeSessions = await store.listSessions(STAGE_ID, LEARNER_KEY);
expect(runtimeSessions).toHaveLength(1);
expect(runtimeSessions[0]).toMatchObject({ kind: 'chat', status: 'active' });
const records = await store.listRecords(runtimeSessions[0]!.id);
expect(records.map((record) => (record.payload as { kind?: string }).kind)).toEqual([
'chat_message',
'chat_session_state',
]);
expect(records[0]).toMatchObject({ sceneId: 'scene-1', actionIndex: 3 });
expect(records[0]?.payload).toMatchObject({ role: 'user', content: 'Hello' });
const loaded = await loadChatSessions(STAGE_ID, {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
expect(loaded).toEqual([
{
...input,
status: 'interrupted',
pendingToolCalls: [],
},
]);
});
it('round-trips a live save captured during the soft-close window', async () => {
const store = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
await saveChatSessions(STAGE_ID, [session({ status: 'soft-closing' })], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
await expect(
loadChatSessions(STAGE_ID, { store, learnerKey: LEARNER_KEY, legacyStore }),
).resolves.toMatchObject([{ id: 'session-1', status: 'interrupted' }]);
});
it('migrates a soft-closing legacy row as an interrupted runtime chat', async () => {
const store = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore([session({ status: 'soft-closing' })]);
await expect(
loadChatSessions(STAGE_ID, { store, learnerKey: LEARNER_KEY, legacyStore }),
).resolves.toMatchObject([{ id: 'session-1', status: 'interrupted' }]);
await expect(
loadChatSessions(STAGE_ID, { store, learnerKey: LEARNER_KEY, legacyStore }),
).resolves.toMatchObject([{ id: 'session-1', status: 'interrupted' }]);
});
it('finishes an empty save when a JavaScript caller passes no session array', async () => {
const store = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore([session()]);
await saveChatSessions(STAGE_ID, undefined as unknown as ChatSession[], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
expect(await store.listSessions(STAGE_ID, LEARNER_KEY)).toEqual([]);
expect(legacyStore.sessions).toEqual([]);
expect(legacyStore.clearCalls).toBe(1);
});
it('keeps the lecture not-started sentinel out of runtime action anchors', async () => {
const store = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
const lecture = session({
type: 'lecture',
lastActionIndex: -1,
messages: [message('lecture-message', 'assistant', '', 1_000)],
});
await saveChatSessions(STAGE_ID, [lecture], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
const records = await runtimeChatRecords(store);
expect(records).not.toHaveLength(0);
expect(records.every((record) => record.actionIndex === undefined)).toBe(true);
expect(
await loadChatSessions(STAGE_ID, { store, learnerKey: LEARNER_KEY, legacyStore }),
).toMatchObject([{ type: 'lecture', lastActionIndex: -1 }]);
});
it('writes only changed records and ignores an older save that arrives later', async () => {
const store = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
const latest = session({ title: 'Latest title', updatedAt: 2_000 });
await saveChatSessions(STAGE_ID, [latest], { store, learnerKey: LEARNER_KEY, legacyStore });
const recordCount = (await runtimeChatRecords(store)).length;
await saveChatSessions(STAGE_ID, [latest], { store, learnerKey: LEARNER_KEY, legacyStore });
await saveChatSessions(STAGE_ID, [session({ title: 'Stale title', updatedAt: 1_000 })], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
expect(await runtimeChatRecords(store)).toHaveLength(recordCount);
expect(
await loadChatSessions(STAGE_ID, { store, learnerKey: LEARNER_KEY, legacyStore }),
).toMatchObject([{ title: 'Latest title', updatedAt: 2_000 }]);
});
it('preserves a real edit when the local wall clock moves behind persisted state', async () => {
const store = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
const editedAt = nextChatUpdatedAt({ updatedAt: 10_000 }, 9_000);
expect(editedAt).toBe(10_001);
await saveChatSessions(
STAGE_ID,
[session({ title: 'Future persisted title', updatedAt: 10_000 })],
{ store, learnerKey: LEARNER_KEY, legacyStore },
);
await saveChatSessions(
STAGE_ID,
[
session({
title: 'Edited after clock rollback',
messages: [message('message-2', 'user', 'New edit', 9_000)],
updatedAt: editedAt,
}),
],
{ store, learnerKey: LEARNER_KEY, legacyStore },
);
await expect(
loadChatSessions(STAGE_ID, { store, learnerKey: LEARNER_KEY, legacyStore }),
).resolves.toMatchObject([
{
title: 'Edited after clock rollback',
messages: [{ id: 'message-2' }],
updatedAt: 10_001,
},
]);
});
it('rebases an explicitly observed edit when another tab advances the conflict clock', async () => {
const indexedDB = new IDBFactory();
const firstTab = new BrowserRuntimeStore({ indexedDB, dbName: 'chat-clock-rollback' });
const secondTab = new BrowserRuntimeStore({ indexedDB, dbName: 'chat-clock-rollback' });
const legacyStore = new MemoryLegacyChatStore();
await saveChatSessions(STAGE_ID, [session({ updatedAt: 1_000 })], {
store: firstTab,
learnerKey: LEARNER_KEY,
legacyStore,
});
const [observed] = await loadChatSessions(STAGE_ID, {
store: firstTab,
learnerKey: LEARNER_KEY,
legacyStore,
});
await saveChatSessions(STAGE_ID, [session({ title: 'Other tab edit', updatedAt: 10_000 })], {
store: secondTab,
learnerKey: LEARNER_KEY,
legacyStore,
});
await saveChatSessions(STAGE_ID, [observed], {
store: firstTab,
learnerKey: LEARNER_KEY,
legacyStore,
});
await saveChatSessions(
STAGE_ID,
[
{
...observed,
title: 'Local edit after rollback',
messages: [...observed.messages, message('message-2', 'user', 'New edit', 900)],
updatedAt: nextChatUpdatedAt(observed, 900),
},
],
{ store: firstTab, learnerKey: LEARNER_KEY, legacyStore },
);
await expect(
loadChatSessions(STAGE_ID, { store: firstTab, learnerKey: LEARNER_KEY, legacyStore }),
).resolves.toMatchObject([
{
title: 'Local edit after rollback',
messages: [{ id: 'message-1' }, { id: 'message-2' }],
updatedAt: 10_001,
},
]);
});
it('advances conflict order when completing or reactivating a session', () => {
const completed = withChatSessionStatus(session({ updatedAt: 10_000 }), 'completed', 9_000);
const reactivated = withChatSessionStatus(completed, 'active', 9_000);
expect(completed).toMatchObject({ status: 'completed', updatedAt: 10_001 });
expect(reactivated).toMatchObject({ status: 'active', updatedAt: 10_002 });
});
it('advances conflict order when a streamed message segment is sealed', () => {
expect(withChatSegmentSealed(session({ updatedAt: 10_000 }), 9_000)).toMatchObject({
updatedAt: 10_001,
});
});
it('advances streamed conflict order only after the segment is fully revealed', () => {
const partial = withChatSegmentReveal(session({ updatedAt: 10_000 }), false, 9_000);
const complete = withChatSegmentReveal(partial, true, 9_000);
expect(partial.updatedAt).toBe(10_000);
expect(complete.updatedAt).toBe(10_001);
});
it('advances conflict order when reload interrupts active sessions', () => {
const active = session({ id: 'active', status: 'active', updatedAt: 10_000 });
const completed = session({ id: 'completed', status: 'completed', updatedAt: 20_000 });
expect(interruptActiveChatSessions([active, completed], 9_000)).toMatchObject([
{ id: 'active', status: 'interrupted', updatedAt: 10_001 },
{ id: 'completed', status: 'completed', updatedAt: 20_000 },
]);
});
it('compares structured-clone tool results without losing Map or BigInt updates', async () => {
const store = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
const withResult = (result: unknown) =>
session({
toolCalls: [
{
toolCallId: 'tool-1',
toolName: 'grade',
args: {},
agentId: 'default-1',
result,
status: 'completed',
requestedAt: 1_000,
completedAt: 1_100,
},
],
});
await saveChatSessions(STAGE_ID, [withResult(new Map([['score', BigInt(1)]]))], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
const initialRecordCount = (await runtimeChatRecords(store)).length;
const updated = {
...withResult(new Map([['score', BigInt(2)]])),
updatedAt: 1_300,
};
await saveChatSessions(STAGE_ID, [updated], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
const updatedRecordCount = (await runtimeChatRecords(store)).length;
expect(updatedRecordCount).toBe(initialRecordCount);
expect(
(
await loadChatSessions(STAGE_ID, {
store,
learnerKey: LEARNER_KEY,
legacyStore,
})
)[0].toolCalls[0].result,
).toEqual(new Map([['score', BigInt(2)]]));
await saveChatSessions(STAGE_ID, [updated], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
expect(await runtimeChatRecords(store)).toHaveLength(updatedRecordCount);
});
it('does not rescan the fallback partition for empty retirement sets', async () => {
const backing = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
let listRecordCalls = 0;
const store = new Proxy(backing, {
get(target, property) {
const value = Reflect.get(target, property, target) as unknown;
if (property === 'listRecords') {
return async (...args: Parameters<RuntimeStore['listRecords']>) => {
listRecordCalls += 1;
return backing.listRecords(...args);
};
}
return typeof value === 'function' ? value.bind(target) : value;
},
});
const sessions = [
session({ id: 'session-1' }),
session({ id: 'session-2', createdAt: 950, updatedAt: 1_300 }),
];
await saveChatSessions(STAGE_ID, sessions, {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
listRecordCalls = 0;
await saveChatSessions(STAGE_ID, sessions, {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
expect(listRecordCalls).toBe(6);
});
it('recovers when another tab wins the deterministic session create race', async () => {
const backing = makeRuntimeStore();
const store = withCreateRace(backing);
const legacyStore = new MemoryLegacyChatStore();
await expect(
saveChatSessions(STAGE_ID, [session()], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
}),
).resolves.toBeUndefined();
expect(
await loadChatSessions(STAGE_ID, { store, learnerKey: LEARNER_KEY, legacyStore }),
).toMatchObject([{ id: 'session-1', title: 'Q&A' }]);
});
it('does not hide a create failure when the re-read session belongs to another partition', async () => {
const createError = new Error('runtime unavailable');
const store = {
listSessions: vi.fn().mockResolvedValue([]),
createSession: vi.fn().mockRejectedValue(createError),
getSession: vi.fn().mockResolvedValue({
id: 'chat:stage-chat:anon%3Achat-test:session-1',
kind: 'chat',
stageId: 'another-stage',
learnerKey: LEARNER_KEY,
}),
} as unknown as RuntimeStore;
const legacyStore = new MemoryLegacyChatStore();
await expect(
saveChatSessions(STAGE_ID, [session()], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
}),
).rejects.toBe(createError);
});
it('projects the newest logical state when a stale cross-tab record has a later seq', async () => {
const store = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
const latest = session({ title: 'Latest title', updatedAt: 2_000 });
await saveChatSessions(STAGE_ID, [latest], { store, learnerKey: LEARNER_KEY, legacyStore });
const [runtimeSession] = (await store.listSessions(STAGE_ID, LEARNER_KEY)).filter(
(candidate) => candidate.kind === 'chat',
);
await store.appendRecord({
id: 'stale-cross-tab-state',
sessionId: runtimeSession!.id,
createdAt: new Date(1_000).toISOString(),
payload: {
kind: 'chat_session_state',
payloadVersion: 1,
role: 'system',
content: 'Stale title',
chatSessionId: latest.id,
type: latest.type,
title: 'Stale title',
status: 'interrupted',
config: latest.config,
toolCalls: latest.toolCalls,
messageIds: latest.messages.map((candidate) => candidate.id),
createdAt: latest.createdAt,
updatedAt: 1_000,
},
});
expect(
await loadChatSessions(STAGE_ID, { store, learnerKey: LEARNER_KEY, legacyStore }),
).toMatchObject([{ title: 'Latest title', updatedAt: 2_000 }]);
});
it('keeps only the latest 200 messages without rewriting unchanged message records', async () => {
const store = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
const messages = Array.from({ length: 202 }, (_, index) =>
message(`message-${index}`, index % 2 === 0 ? 'user' : 'assistant', `Text ${index}`, index),
);
await saveChatSessions(STAGE_ID, [session({ messages, updatedAt: 5_000 })], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
const firstRecordCount = (await runtimeChatRecords(store)).length;
await saveChatSessions(STAGE_ID, [session({ messages, updatedAt: 5_000 })], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
const [loaded] = await loadChatSessions(STAGE_ID, {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
expect(loaded?.messages).toHaveLength(200);
expect(loaded?.messages[0]?.id).toBe('message-2');
expect(await runtimeChatRecords(store)).toHaveLength(firstRecordCount);
});
it('bounds stored and scanned records across a long-lived chat session', async () => {
const store = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
const listRecords = store.listRecords.bind(store);
let maxRecordsRead = 0;
vi.spyOn(store, 'listRecords').mockImplementation(async (...args) => {
const result = await listRecords(...args);
maxRecordsRead = Math.max(maxRecordsRead, result.length);
return result;
});
for (let index = 0; index < 300; index += 1) {
await saveChatSessions(
STAGE_ID,
[session({ title: `Q&A ${index}`, updatedAt: 2_000 + index })],
{ store, learnerKey: LEARNER_KEY, legacyStore },
);
}
const runtimeSessions = (await store.listSessions(STAGE_ID, LEARNER_KEY)).filter(
(candidate) => candidate.kind === 'chat',
);
expect(runtimeSessions).toHaveLength(1);
expect((await store.listRecords(runtimeSessions[0]!.id)).length).toBeLessThanOrEqual(256);
expect(maxRecordsRead).toBeLessThanOrEqual(256);
expect(
await loadChatSessions(STAGE_ID, { store, learnerKey: LEARNER_KEY, legacyStore }),
).toMatchObject([{ title: 'Q&A 299', updatedAt: 2_299 }]);
});
it('preserves the newer cross-tab write when another tab rolls the generation', async () => {
const indexedDB = new IDBFactory();
const firstTab = new BrowserRuntimeStore({ indexedDB, dbName: 'chat-rollover-race' });
const secondTab = new BrowserRuntimeStore({ indexedDB, dbName: 'chat-rollover-race' });
const legacyStore = new MemoryLegacyChatStore();
for (let index = 0; index < 255; index += 1) {
await saveChatSessions(
STAGE_ID,
[session({ title: `Base ${index}`, updatedAt: 1_000 + index })],
{ store: firstTab, learnerKey: LEARNER_KEY, legacyStore },
);
}
const staleMessages = Array.from({ length: 10 }, (_, index) =>
message(`stale-${index}`, 'user', `Stale ${index}`, index),
);
await Promise.all([
saveChatSessions(STAGE_ID, [session({ title: 'Newer', updatedAt: 5_000 })], {
store: firstTab,
learnerKey: LEARNER_KEY,
legacyStore,
}),
saveChatSessions(
STAGE_ID,
[session({ title: 'Stale', messages: staleMessages, updatedAt: 4_000 })],
{ store: secondTab, learnerKey: LEARNER_KEY, legacyStore },
),
]);
const runtimeSessions = (await firstTab.listSessions(STAGE_ID, LEARNER_KEY)).filter(
(candidate) => candidate.kind === 'chat',
);
const generations = await Promise.all(
runtimeSessions.map(async (candidate) => ({
records: await firstTab.listRecords(candidate.id),
})),
);
expect(runtimeSessions.some((candidate) => candidate.id.includes(':generation:'))).toBe(true);
expect(
Math.max(...generations.map((candidate) => candidate.records.length)),
).toBeLessThanOrEqual(256);
expect(
await loadChatSessions(STAGE_ID, {
store: firstTab,
learnerKey: LEARNER_KEY,
legacyStore,
}),
).toMatchObject([{ title: 'Newer', updatedAt: 5_000 }]);
});
it('keeps the record bound when two tabs roll the same full generation', async () => {
const indexedDB = new IDBFactory();
const firstTab = new BrowserRuntimeStore({ indexedDB, dbName: 'chat-double-rollover' });
const secondTab = new BrowserRuntimeStore({ indexedDB, dbName: 'chat-double-rollover' });
const legacyStore = new MemoryLegacyChatStore();
for (let index = 0; index < 254; index += 1) {
await saveChatSessions(
STAGE_ID,
[session({ title: `Base ${index}`, updatedAt: 1_000 + index })],
{ store: firstTab, learnerKey: LEARNER_KEY, legacyStore },
);
}
await Promise.all([
saveChatSessions(STAGE_ID, [session({ title: 'First tab', updatedAt: 5_000 })], {
store: firstTab,
learnerKey: LEARNER_KEY,
legacyStore,
}),
saveChatSessions(STAGE_ID, [session({ title: 'Second tab', updatedAt: 6_000 })], {
store: secondTab,
learnerKey: LEARNER_KEY,
legacyStore,
}),
]);
const runtimeSessions = (await firstTab.listSessions(STAGE_ID, LEARNER_KEY)).filter(
(candidate) => candidate.kind === 'chat',
);
const recordCounts = await Promise.all(
runtimeSessions.map(async (candidate) => (await firstTab.listRecords(candidate.id)).length),
);
expect(Math.max(...recordCounts)).toBeLessThanOrEqual(256);
expect(
await loadChatSessions(STAGE_ID, {
store: firstTab,
learnerKey: LEARNER_KEY,
legacyStore,
}),
).toMatchObject([{ title: 'Second tab', updatedAt: 6_000 }]);
});
it('keeps the record bound when concurrent fallback writers append large batches', async () => {
const indexedDB = new IDBFactory();
const firstBacking = new BrowserRuntimeStore({ indexedDB, dbName: 'chat-batch-race' });
const secondBacking = new BrowserRuntimeStore({ indexedDB, dbName: 'chat-batch-race' });
const legacyStore = new MemoryLegacyChatStore();
for (let index = 0; index < 239; index += 1) {
await saveChatSessions(
STAGE_ID,
[session({ title: `Base ${index}`, updatedAt: 1_000 + index })],
{ store: firstBacking, learnerKey: LEARNER_KEY, legacyStore },
);
}
let readers = 0;
let releaseReaders!: () => void;
const readersReleased = new Promise<void>((resolve) => {
releaseReaders = resolve;
});
function concurrentTab(backing: RuntimeStore): RuntimeStore {
let delayFirstRecordRead = true;
return new Proxy(backing, {
get(target, property) {
const value = Reflect.get(target, property, target) as unknown;
if (property === 'listRecords') {
return async (...args: Parameters<RuntimeStore['listRecords']>) => {
const records = await backing.listRecords(...args);
if (delayFirstRecordRead) {
delayFirstRecordRead = false;
readers += 1;
if (readers === 2) releaseReaders();
await readersReleased;
}
return records;
};
}
return typeof value === 'function' ? value.bind(target) : value;
},
});
}
const firstTab = concurrentTab(firstBacking);
const secondTab = concurrentTab(secondBacking);
const firstMessages = Array.from({ length: 14 }, (_, index) =>
message(`first-${index}`, 'user', `First ${index}`, 5_000 + index),
);
const secondMessages = Array.from({ length: 14 }, (_, index) =>
message(`second-${index}`, 'user', `Second ${index}`, 6_000 + index),
);
vi.resetModules();
const secondRealm = await import('@/lib/utils/chat-storage');
await Promise.all([
secondRealm.saveChatSessions(
STAGE_ID,
[session({ title: 'First batch', messages: firstMessages, updatedAt: 5_000 })],
{ store: firstTab, learnerKey: LEARNER_KEY, legacyStore },
),
saveChatSessions(
STAGE_ID,
[session({ title: 'Second batch', messages: secondMessages, updatedAt: 6_000 })],
{ store: secondTab, learnerKey: LEARNER_KEY, legacyStore },
),
]);
const runtimeSessions = (await firstBacking.listSessions(STAGE_ID, LEARNER_KEY)).filter(
(candidate) => candidate.kind === 'chat',
);
expect(runtimeSessions.length).toBeGreaterThan(0);
expect(runtimeSessions.every((candidate) => /:generation:\d+:[\w-]+$/.test(candidate.id))).toBe(
true,
);
const recordCounts = await Promise.all(
runtimeSessions.map(
async (candidate) => (await firstBacking.listRecords(candidate.id)).length,
),
);
expect(Math.max(...recordCounts)).toBeLessThanOrEqual(256);
expect(
await loadChatSessions(STAGE_ID, {
store: firstBacking,
learnerKey: LEARNER_KEY,
legacyStore,
}),
).toMatchObject([{ title: 'Second batch', updatedAt: 6_000 }]);
});
it('retires superseded fallback snapshots when streamed content keeps the same timestamp', async () => {
const store = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
for (let index = 0; index < 20; index += 1) {
await saveChatSessions(
STAGE_ID,
[
session({
messages: [message('message-1', 'assistant', `Streamed ${index}`, 1_000)],
updatedAt: 2_000,
}),
],
{ store, learnerKey: LEARNER_KEY, legacyStore },
);
}
const runtimeSessions = (await store.listSessions(STAGE_ID, LEARNER_KEY)).filter(
(candidate) => candidate.kind === 'chat',
);
expect(runtimeSessions).toHaveLength(1);
expect(
await loadChatSessions(STAGE_ID, { store, learnerKey: LEARNER_KEY, legacyStore }),
).toMatchObject([
{
messages: [{ parts: [{ type: 'text', text: 'Streamed 19' }] }],
updatedAt: 2_000,
},
]);
});
it('uses the runtime id tie-break when loading concurrent equal-generation snapshots', async () => {
const indexedDB = new IDBFactory();
const initialStore = new BrowserRuntimeStore({ indexedDB, dbName: 'chat-equal-generation' });
const legacyStore = new MemoryLegacyChatStore();
await saveChatSessions(STAGE_ID, [session({ title: 'Base', updatedAt: 1_000 })], {
store: initialStore,
learnerKey: LEARNER_KEY,
legacyStore,
});
let readers = 0;
let releaseReaders!: () => void;
const readersReleased = new Promise<void>((resolve) => {
releaseReaders = resolve;
});
function concurrentTab(backing: RuntimeStore): RuntimeStore {
let delayFirstRecordRead = true;
return new Proxy(backing, {
get(target, property) {
const value = Reflect.get(target, property, target) as unknown;
if (property === 'listRecords') {
return async (...args: Parameters<RuntimeStore['listRecords']>) => {
const records = await backing.listRecords(...args);
if (delayFirstRecordRead) {
delayFirstRecordRead = false;
readers += 1;
if (readers === 2) releaseReaders();
await readersReleased;
}
return records;
};
}
return typeof value === 'function' ? value.bind(target) : value;
},
});
}
const firstBacking = new BrowserRuntimeStore({
indexedDB,
dbName: 'chat-equal-generation',
});
const secondBacking = new BrowserRuntimeStore({
indexedDB,
dbName: 'chat-equal-generation',
});
vi.resetModules();
const secondRealm = await import('@/lib/utils/chat-storage');
await Promise.all([
saveChatSessions(STAGE_ID, [session({ title: 'First', updatedAt: 2_000 })], {
store: concurrentTab(firstBacking),
learnerKey: LEARNER_KEY,
legacyStore,
}),
secondRealm.saveChatSessions(STAGE_ID, [session({ title: 'Second', updatedAt: 2_000 })], {
store: concurrentTab(secondBacking),
learnerKey: LEARNER_KEY,
legacyStore,
}),
]);
const runtimeSessions = (await initialStore.listSessions(STAGE_ID, LEARNER_KEY)).filter(
(candidate) => candidate.kind === 'chat',
);
expect(runtimeSessions).toHaveLength(2);
const winner = [...runtimeSessions].sort((left, right) => right.id.localeCompare(left.id))[0]!;
const winnerState = (await initialStore.listRecords(winner.id)).find(
(record) => (record.payload as { kind?: string }).kind === 'chat_session_state',
);
expect(
await loadChatSessions(STAGE_ID, {
store: initialStore,
learnerKey: LEARNER_KEY,
legacyStore,
}),
).toMatchObject([{ title: (winnerState!.payload as { title: string }).title }]);
});
it('removes a partial isolated generation after a successful retry', async () => {
const backing = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
let appendCalls = 0;
const store = new Proxy(backing, {
get(target, property) {
const value = Reflect.get(target, property, target) as unknown;
if (property === 'appendRecord') {
return async (...args: Parameters<RuntimeStore['appendRecord']>) => {
appendCalls += 1;
if (appendCalls === 2) throw new Error('transient append failure');
return backing.appendRecord(...args);
};
}
return typeof value === 'function' ? value.bind(target) : value;
},
});
await saveChatSessions(STAGE_ID, [session()], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
await saveChatSessions(STAGE_ID, [session()], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
const runtimeSessions = (await backing.listSessions(STAGE_ID, LEARNER_KEY)).filter(
(candidate) => candidate.kind === 'chat',
);
expect(runtimeSessions).toHaveLength(1);
expect(await backing.listRecords(runtimeSessions[0]!.id)).toHaveLength(2);
});
it.each([
['HTTP 400', new HttpRuntimeStoreError(400, 'HTTP_ERROR', 'bad request')],
['HTTP 401', new HttpRuntimeStoreError(401, 'HTTP_ERROR', 'unauthorized')],
[
'VALIDATION_FAILED',
new HttpRuntimeStoreError(422, 'VALIDATION_FAILED', 'record validation failed'),
],
['HTTP 403', new HttpRuntimeStoreError(403, 'HTTP_ERROR', 'forbidden')],
['HTTP 413', new HttpRuntimeStoreError(413, 'HTTP_ERROR', 'too large')],
[
'FUTURE_VERSION',
new HttpRuntimeStoreError(409, 'FUTURE_VERSION', 'newer runtime DSL version'),
],
])('does not retry a deterministic %s append failure', async (_label, appendError) => {
const backing = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
const appendRecord = vi.fn().mockRejectedValue(appendError);
const store = new Proxy(backing, {
get(target, property) {
if (property === 'appendRecord') return appendRecord;
const value = Reflect.get(target, property, target) as unknown;
return typeof value === 'function' ? value.bind(target) : value;
},
});
await expect(
saveChatSessions(STAGE_ID, [session()], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
}),
).rejects.toThrow(
`Chat sync rejected for session "session-1": ${appendError.code} (HTTP ${appendError.status})`,
);
expect(appendRecord).toHaveBeenCalledTimes(1);
expect(
(await backing.listSessions(STAGE_ID, LEARNER_KEY)).filter(
(candidate) => candidate.kind === 'chat',
),
).toEqual([]);
});
it.each([
[
'pre-append status check',
`@openmaic/storage: cannot append to session "chat:stage-chat:anon%3Achat-test:session-1" with status 'completed' — records may only be appended to an active session`,
],
[
'post-race reclassification',
`@openmaic/storage: session "chat:stage-chat:anon%3Achat-test:session-1" is no longer active; its current status is 'completed'`,
],
])('retries the %s inactive-session append race with backoff', async (_label, message) => {
const backing = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
const appendError = new HttpRuntimeStoreError(400, 'VALIDATION_FAILED', message);
const sleep = vi.fn().mockResolvedValue(undefined);
let appendCalls = 0;
const store = new Proxy(backing, {
get(target, property) {
if (property === 'appendRecord') {
return async (...args: Parameters<RuntimeStore['appendRecord']>) => {
appendCalls += 1;
if (appendCalls === 1) throw appendError;
return backing.appendRecord(...args);
};
}
const value = Reflect.get(target, property, target) as unknown;
return typeof value === 'function' ? value.bind(target) : value;
},
});
await expect(
saveChatSessions(STAGE_ID, [session()], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
sleep,
}),
).resolves.toBeUndefined();
expect(appendCalls).toBeGreaterThan(1);
expect(sleep).toHaveBeenCalledWith(100);
});
it('retries inactive-session wording for any VALIDATION_FAILED status', async () => {
const backing = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
const appendError = new HttpRuntimeStoreError(
422,
'VALIDATION_FAILED',
`@openmaic/storage: cannot append to session "chat:stage-chat:anon%3Achat-test:session-1" with status 'completed' — records may only be appended to an active session`,
);
let appendCalls = 0;
const store = new Proxy(backing, {
get(target, property) {
if (property === 'appendRecord') {
return async (...args: Parameters<RuntimeStore['appendRecord']>) => {
appendCalls += 1;
if (appendCalls === 1) throw appendError;
return backing.appendRecord(...args);
};
}
const value = Reflect.get(target, property, target) as unknown;
return typeof value === 'function' ? value.bind(target) : value;
},
});
await expect(
saveChatSessions(STAGE_ID, [session()], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
sleep: async () => undefined,
}),
).resolves.toBeUndefined();
expect(appendCalls).toBeGreaterThan(1);
});
it.each([
['network failure', new Error('network unavailable')],
[
'append conflict',
new RuntimeAppendConflictError('chat:stage-chat:anon%3Achat-test:session-1', 1, 2),
],
['vanished session', new HttpRuntimeStoreError(404, 'SESSION_NOT_FOUND', 'not found')],
])('retries a transient %s in the locked WRITE branch', async (_label, appendError) => {
vi.stubGlobal('navigator', {
locks: {
request: async (
_name: string,
optionsOrWork: LockOptions | (() => Promise<unknown>),
maybeWork?: () => Promise<unknown>,
) => (typeof optionsOrWork === 'function' ? optionsOrWork : maybeWork!)(),
},
});
const backing = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
const original = session({ updatedAt: 2_000 });
await saveChatSessions(STAGE_ID, [original], {
store: backing,
learnerKey: LEARNER_KEY,
legacyStore,
});
const sleep = vi.fn().mockResolvedValue(undefined);
let failed = false;
const store = new Proxy(backing, {
get(target, property) {
if (property === 'appendRecord') {
return async (...args: Parameters<RuntimeStore['appendRecord']>) => {
if (!failed) {
failed = true;
throw appendError;
}
return backing.appendRecord(...args);
};
}
const value = Reflect.get(target, property, target) as unknown;
return typeof value === 'function' ? value.bind(target) : value;
},
});
await expect(
saveChatSessions(STAGE_ID, [{ ...original, title: 'Updated', updatedAt: 3_000 }], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
sleep,
}),
).resolves.toBeUndefined();
expect(sleep).toHaveBeenCalledWith(100);
await expect(
loadChatSessions(STAGE_ID, { store: backing, learnerKey: LEARNER_KEY, legacyStore }),
).resolves.toMatchObject([{ title: 'Updated', updatedAt: 3_000 }]);
});
it('does not retain partial isolated generations when appends keep failing', async () => {
const backing = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
const appendError = new Error('append storage unavailable');
const sleep = vi.fn().mockResolvedValue(undefined);
const store = new Proxy(backing, {
get(target, property) {
const value = Reflect.get(target, property, target) as unknown;
if (property === 'appendRecord') {
return async () => {
throw appendError;
};
}
return typeof value === 'function' ? value.bind(target) : value;
},
});
await expect(
saveChatSessions(STAGE_ID, [session()], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
sleep,
}),
).rejects.toBe(appendError);
const delays = sleep.mock.calls.map(([milliseconds]) => milliseconds as number);
expect(delays).toEqual([100, 200, 400, 500, 500, 500, 500]);
expect(Math.max(...delays)).toBe(500);
expect(delays.reduce((total, delay) => total + delay, 0)).toBe(2_700);
expect(
(await backing.listSessions(STAGE_ID, LEARNER_KEY)).filter(
(candidate) => candidate.kind === 'chat',
),
).toHaveLength(0);
});
it('uses the default setTimeout sleep when no retry sleep is injected', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
const backing = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
let appendCalls = 0;
let observedFirstFailure!: () => void;
const firstFailure = new Promise<void>((resolve) => {
observedFirstFailure = resolve;
});
const store = new Proxy(backing, {
get(target, property) {
if (property === 'appendRecord') {
return async (...args: Parameters<RuntimeStore['appendRecord']>) => {
appendCalls += 1;
if (appendCalls === 1) {
observedFirstFailure();
throw new Error('retry with the default sleep');
}
return backing.appendRecord(...args);
};
}
const value = Reflect.get(target, property, target) as unknown;
return typeof value === 'function' ? value.bind(target) : value;
},
});
const saving = saveChatSessions(STAGE_ID, [session()], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
await firstFailure;
for (let index = 0; index < 20 && vi.getTimerCount() === 0; index += 1) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
expect(vi.getTimerCount()).toBe(1);
await vi.advanceTimersByTimeAsync(100);
await expect(saving).resolves.toBeUndefined();
expect(appendCalls).toBeGreaterThan(1);
});
it('backs off and refreshes after plan-step exhaustion without hiding an earlier error', async () => {
vi.stubGlobal('navigator', {
locks: {
request: async (
_name: string,
optionsOrWork: LockOptions | (() => Promise<unknown>),
maybeWork?: () => Promise<unknown>,
) => (typeof optionsOrWork === 'function' ? optionsOrWork : maybeWork!)(),
},
});
const backing = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
const appendError = new Error('specific first append failure');
const sleep = vi.fn().mockResolvedValue(undefined);
let createCalls = 0;
const listSessions = vi.fn(backing.listSessions.bind(backing));
const store = new Proxy(backing, {
get(target, property) {
if (property === 'listSessions') return listSessions;
if (property === 'createSession') {
return async (...args: Parameters<RuntimeStore['createSession']>) => {
createCalls += 1;
if (createCalls === 1) return backing.createSession(...args);
return {
...args[0],
id: `not-a-chat-runtime-id-${createCalls}`,
runtimeDslVersion: '0.1.0',
};
};
}
if (property === 'appendRecord') {
return async () => {
throw appendError;
};
}
const value = Reflect.get(target, property, target) as unknown;
return typeof value === 'function' ? value.bind(target) : value;
},
});
await expect(
saveChatSessions(STAGE_ID, [session()], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
sleep,
}),
).rejects.toBe(appendError);
expect(sleep.mock.calls.map(([milliseconds]) => milliseconds)).toEqual([
100, 200, 400, 500, 500, 500, 500,
]);
expect(listSessions.mock.calls.length).toBeGreaterThanOrEqual(8);
});
it('keeps a historical soft-closing state-only session when a later save exhausts retries', async () => {
vi.stubGlobal('navigator', {
locks: {
request: async (
_name: string,
optionsOrWork: LockOptions | (() => Promise<unknown>),
maybeWork?: () => Promise<unknown>,
) => (typeof optionsOrWork === 'function' ? optionsOrWork : maybeWork!)(),
},
});
const backing = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
const historical = session({ status: 'soft-closing', messages: [] });
const runtimeId = runtimeSessionId(STAGE_ID, LEARNER_KEY, historical.id);
await backing.createSession({
id: runtimeId,
kind: 'chat',
stageId: STAGE_ID,
learnerKey: LEARNER_KEY,
status: 'active',
createdAt: new Date(historical.createdAt).toISOString(),
updatedAt: new Date(historical.updatedAt).toISOString(),
});
await backing.appendRecord(
buildChatRecordInit(runtimeId, statePayload(historical), historical, 'state'),
);
await expect(
loadChatSessions(STAGE_ID, { store: backing, learnerKey: LEARNER_KEY, legacyStore }),
).resolves.toMatchObject([{ id: historical.id, status: 'soft-closing' }]);
const failingStore = new Proxy(backing, {
get(target, property) {
if (property === 'appendRecord') {
return async () => {
throw new Error('persistent append failure');
};
}
const value = Reflect.get(target, property, target) as unknown;
return typeof value === 'function' ? value.bind(target) : value;
},
});
await expect(
saveChatSessions(STAGE_ID, [session({ status: 'idle', updatedAt: 5_000 })], {
store: failingStore,
learnerKey: LEARNER_KEY,
legacyStore,
sleep: async () => undefined,
}),
).rejects.toThrow('persistent append failure');
await expect(backing.listSessions(STAGE_ID, LEARNER_KEY)).resolves.toMatchObject([
{ id: runtimeId },
]);
});
it('does not expose partially appended messages when a locked snapshot save fails', async () => {
vi.stubGlobal('navigator', {
locks: {
request: async (
_name: string,
optionsOrWork: LockOptions | (() => Promise<unknown>),
maybeWork?: () => Promise<unknown>,
) => (typeof optionsOrWork === 'function' ? optionsOrWork : maybeWork!)(),
},
});
const backing = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
const original = session({
title: 'Original title',
messages: [message('message-1', 'user', 'Original message', 1_000)],
updatedAt: 2_000,
});
await saveChatSessions(STAGE_ID, [original], {
store: backing,
learnerKey: LEARNER_KEY,
legacyStore,
});
const appendError = new HttpRuntimeStoreError(
400,
'VALIDATION_FAILED',
'state payload validation failed',
);
const failingStore = new Proxy(backing, {
get(target, property) {
const value = Reflect.get(target, property, target) as unknown;
if (property === 'appendRecord') {
return async (...args: Parameters<RuntimeStore['appendRecord']>) => {
if ((args[0].payload as { kind?: string }).kind === 'chat_session_state') {
throw appendError;
}
return backing.appendRecord(...args);
};
}
return typeof value === 'function' ? value.bind(target) : value;
},
});
await expect(
saveChatSessions(
STAGE_ID,
[
session({
title: 'Updated title',
messages: [message('message-1', 'user', 'Updated message', 2_000)],
updatedAt: 3_000,
}),
],
{ store: failingStore, learnerKey: LEARNER_KEY, legacyStore },
),
).rejects.toThrow('Chat sync rejected for session "session-1": VALIDATION_FAILED (HTTP 400)');
await expect(
loadChatSessions(STAGE_ID, { store: backing, learnerKey: LEARNER_KEY, legacyStore }),
).resolves.toEqual([{ ...original, status: 'interrupted', pendingToolCalls: [] }]);
await expect(backing.listSessions(STAGE_ID, LEARNER_KEY)).resolves.toHaveLength(1);
});
it('retries an unchanged fallback save when a concurrent writer retires its destination', async () => {
const indexedDB = new IDBFactory();
const initialStore = new BrowserRuntimeStore({
indexedDB,
dbName: 'chat-unchanged-retired-destination',
});
const legacyStore = new MemoryLegacyChatStore();
const completed = session({ status: 'completed', updatedAt: 2_000 });
await saveChatSessions(STAGE_ID, [completed], {
store: initialStore,
learnerKey: LEARNER_KEY,
legacyStore,
});
const [destination] = (await initialStore.listSessions(STAGE_ID, LEARNER_KEY)).filter(
(candidate) => candidate.kind === 'chat',
);
await initialStore.setSessionStatus(
destination!.id,
'active',
new Date(completed.updatedAt).toISOString(),
);
const delayedBacking = new BrowserRuntimeStore({
indexedDB,
dbName: 'chat-unchanged-retired-destination',
});
let releaseStatusWrite!: () => void;
const statusWriteGate = new Promise<void>((resolve) => {
releaseStatusWrite = resolve;
});
let statusWriteReached!: () => void;
const statusWriteReady = new Promise<void>((resolve) => {
statusWriteReached = resolve;
});
let delayed = false;
const delayedStore = new Proxy(delayedBacking, {
get(target, property) {
const value = Reflect.get(target, property, target) as unknown;
if (property === 'setSessionStatus') {
return async (...args: Parameters<RuntimeStore['setSessionStatus']>) => {
if (!delayed && args[0] === destination!.id && args[1] === 'completed') {
delayed = true;
statusWriteReached();
await statusWriteGate;
}
return delayedBacking.setSessionStatus(...args);
};
}
return typeof value === 'function' ? value.bind(target) : value;
},
});
const unchangedSave = saveChatSessions(STAGE_ID, [completed], {
store: delayedStore,
learnerKey: LEARNER_KEY,
legacyStore,
});
await statusWriteReady;
const concurrentStore = new BrowserRuntimeStore({
indexedDB,
dbName: 'chat-unchanged-retired-destination',
});
const newer = session({
title: 'Concurrent newer',
status: 'completed',
updatedAt: 3_000,
});
await saveChatSessions(STAGE_ID, [newer], {
store: concurrentStore,
learnerKey: LEARNER_KEY,
legacyStore,
});
expect(await initialStore.getSession(destination!.id)).toBeUndefined();
releaseStatusWrite();
await expect(unchangedSave).resolves.toBeUndefined();
expect(
await loadChatSessions(STAGE_ID, {
store: initialStore,
learnerKey: LEARNER_KEY,
legacyStore,
}),
).toMatchObject([{ title: 'Concurrent newer', updatedAt: 3_000 }]);
});
it('does not hide a real unchanged fallback status-write failure', async () => {
const backing = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
const completed = session({ status: 'completed', updatedAt: 2_000 });
await saveChatSessions(STAGE_ID, [completed], {
store: backing,
learnerKey: LEARNER_KEY,
legacyStore,
});
const [destination] = (await backing.listSessions(STAGE_ID, LEARNER_KEY)).filter(
(candidate) => candidate.kind === 'chat',
);
await backing.setSessionStatus(
destination!.id,
'active',
new Date(completed.updatedAt).toISOString(),
);
const writeError = new Error('status storage unavailable');
const store = new Proxy(backing, {
get(target, property) {
const value = Reflect.get(target, property, target) as unknown;
if (property === 'setSessionStatus') {
return async () => {
throw writeError;
};
}
return typeof value === 'function' ? value.bind(target) : value;
},
});
await expect(
saveChatSessions(STAGE_ID, [completed], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
}),
).rejects.toBe(writeError);
});
it('does not let a stale isolated writer retire a newly completed newer snapshot', async () => {
const indexedDB = new IDBFactory();
const initialStore = new BrowserRuntimeStore({ indexedDB, dbName: 'chat-stale-retire' });
const legacyStore = new MemoryLegacyChatStore();
const chat = (title: string, updatedAt: number) =>
session({
title,
updatedAt,
messages: [message('message-1', 'assistant', title, updatedAt)],
});
await saveChatSessions(STAGE_ID, [chat('Base', 1_000)], {
store: initialStore,
learnerKey: LEARNER_KEY,
legacyStore,
});
const newerBacking = new BrowserRuntimeStore({
indexedDB,
dbName: 'chat-stale-retire',
});
let releaseNewerState!: () => void;
const newerStateGate = new Promise<void>((resolve) => {
releaseNewerState = resolve;
});
let newerStateReached!: () => void;
const newerStateReady = new Promise<void>((resolve) => {
newerStateReached = resolve;
});
const newerStore = new Proxy(newerBacking, {
get(target, property) {
const value = Reflect.get(target, property, target) as unknown;
if (property === 'appendRecord') {
return async (...args: Parameters<RuntimeStore['appendRecord']>) => {
if ((args[0].payload as { kind?: string }).kind === 'chat_session_state') {
newerStateReached();
await newerStateGate;
}
return newerBacking.appendRecord(...args);
};
}
return typeof value === 'function' ? value.bind(target) : value;
},
});
const newerSave = saveChatSessions(STAGE_ID, [chat('Newer', 3_000)], {
store: newerStore,
learnerKey: LEARNER_KEY,
legacyStore,
});
await newerStateReady;
const staleBacking = new BrowserRuntimeStore({
indexedDB,
dbName: 'chat-stale-retire',
});
let releaseStaleSnapshot!: () => void;
const staleSnapshotGate = new Promise<void>((resolve) => {
releaseStaleSnapshot = resolve;
});
let staleSnapshotTaken!: () => void;
const staleSnapshotReady = new Promise<void>((resolve) => {
staleSnapshotTaken = resolve;
});
let delayedPartialSnapshot = false;
const staleStore = new Proxy(staleBacking, {
get(target, property) {
const value = Reflect.get(target, property, target) as unknown;
if (property === 'listRecords') {
return async (...args: Parameters<RuntimeStore['listRecords']>) => {
const records = await staleBacking.listRecords(...args);
if (
!delayedPartialSnapshot &&
records.some((record) =>
['chat_message'].includes((record.payload as { kind?: string }).kind ?? ''),
) &&
!records.some(
(record) => (record.payload as { kind?: string }).kind === 'chat_session_state',
)
) {
delayedPartialSnapshot = true;
staleSnapshotTaken();
await staleSnapshotGate;
}
return records;
};
}
return typeof value === 'function' ? value.bind(target) : value;
},
});
const staleSave = saveChatSessions(STAGE_ID, [chat('Stale', 2_000)], {
store: staleStore,
learnerKey: LEARNER_KEY,
legacyStore,
});
await staleSnapshotReady;
releaseNewerState();
await newerSave;
releaseStaleSnapshot();
await staleSave;
expect(
await loadChatSessions(STAGE_ID, {
store: initialStore,
learnerKey: LEARNER_KEY,
legacyStore,
}),
).toMatchObject([{ title: 'Newer', updatedAt: 3_000 }]);
});
it('re-resolves the successor when a delayed writer targets a retired generation', async () => {
const indexedDB = new IDBFactory();
const firstTab = new BrowserRuntimeStore({ indexedDB, dbName: 'chat-retired-generation' });
const delayedBacking = new BrowserRuntimeStore({
indexedDB,
dbName: 'chat-retired-generation',
});
const legacyStore = new MemoryLegacyChatStore();
for (let index = 0; index < 254; index += 1) {
await saveChatSessions(
STAGE_ID,
[session({ title: `Base ${index}`, updatedAt: 1_000 + index })],
{ store: firstTab, learnerKey: LEARNER_KEY, legacyStore },
);
}
let releaseSnapshot!: () => void;
const snapshotGate = new Promise<void>((resolve) => {
releaseSnapshot = resolve;
});
let snapshotTaken!: () => void;
const snapshotReady = new Promise<void>((resolve) => {
snapshotTaken = resolve;
});
let delayFirstRecordRead = true;
const delayedTab = new Proxy(delayedBacking, {
get(target, property) {
const value = Reflect.get(target, property, target) as unknown;
if (property === 'listRecords') {
return async (...args: Parameters<RuntimeStore['listRecords']>) => {
const records = await delayedBacking.listRecords(...args);
if (delayFirstRecordRead) {
delayFirstRecordRead = false;
snapshotTaken();
await snapshotGate;
}
return records;
};
}
return typeof value === 'function' ? value.bind(target) : value;
},
});
const delayedSave = saveChatSessions(
STAGE_ID,
[session({ title: 'Delayed newer', updatedAt: 6_000 })],
{ store: delayedTab, learnerKey: LEARNER_KEY, legacyStore },
);
await snapshotReady;
await saveChatSessions(STAGE_ID, [session({ title: 'Rollover', updatedAt: 5_000 })], {
store: firstTab,
learnerKey: LEARNER_KEY,
legacyStore,
});
await saveChatSessions(STAGE_ID, [session({ title: 'Cleanup', updatedAt: 5_001 })], {
store: firstTab,
learnerKey: LEARNER_KEY,
legacyStore,
});
releaseSnapshot();
await expect(delayedSave).resolves.toBeUndefined();
expect(
await loadChatSessions(STAGE_ID, {
store: firstTab,
learnerKey: LEARNER_KEY,
legacyStore,
}),
).toMatchObject([{ title: 'Delayed newer', updatedAt: 6_000 }]);
});
it.each([
{ label: 'base', updates: 1, generated: false },
{ label: 'generated', updates: 260, generated: true },
])(
'loads a $label runtime id after its learner partition is merged',
async ({ updates, generated }) => {
if (!generated) {
vi.stubGlobal('navigator', {
...globalThis.navigator,
locks: {
request: async (
_name: string,
optionsOrWork: LockOptions | (() => Promise<unknown>),
maybeWork?: () => Promise<unknown>,
) => (typeof optionsOrWork === 'function' ? optionsOrWork : maybeWork!)(),
},
});
}
const store = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
try {
for (let index = 0; index < updates; index += 1) {
await saveChatSessions(
STAGE_ID,
[session({ title: `Before merge ${index}`, updatedAt: 2_000 + index })],
{ store, learnerKey: LEARNER_KEY, legacyStore },
);
}
const [beforeMerge] = (await store.listSessions(STAGE_ID, LEARNER_KEY)).filter(
(candidate) => candidate.kind === 'chat',
);
expect(beforeMerge?.id.includes(':generation:')).toBe(generated);
const accountLearnerKey = 'user:chat-test';
await store.mergeLearner(LEARNER_KEY, accountLearnerKey);
expect(
await loadChatSessions(STAGE_ID, { store, learnerKey: accountLearnerKey, legacyStore }),
).toMatchObject([{ title: `Before merge ${updates - 1}` }]);
} finally {
if (!generated) vi.unstubAllGlobals();
}
},
);
it('backfills legacy Dexie sessions before clearing the legacy rows', async () => {
const store = makeRuntimeStore();
const legacy = session({ status: 'completed', updatedAt: 3_000 });
const legacyStore = new MemoryLegacyChatStore([legacy]);
const loaded = await loadChatSessions(STAGE_ID, {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
expect(loaded).toEqual([{ ...legacy, pendingToolCalls: [] }]);
expect(legacyStore.sessions).toEqual([]);
expect(legacyStore.clearCalls).toBe(1);
expect(
(await store.listSessions(STAGE_ID, LEARNER_KEY)).filter(
(candidate) => candidate.kind === 'chat',
),
).toHaveLength(1);
});
it('retains the entire legacy source when any migration row is malformed', async () => {
const store = makeRuntimeStore();
const malformed = {
...session({ id: 'bad-row' }),
toolCalls: null,
} as unknown as ChatSession;
const legacyStore = new MemoryLegacyChatStore([
session({ id: 'good-row', status: 'completed' }),
malformed,
]);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const loaded = await loadChatSessions(STAGE_ID, {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
expect(loaded.map((chat) => chat.id)).toEqual(['good-row']);
expect(loaded.some((chat) => chat.id === malformed.id)).toBe(false);
expect(legacyStore.clearCalls).toBe(0);
expect(legacyStore.sessions.map((chat) => chat.id)).toEqual(['good-row', 'bad-row']);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('bad-row'));
await saveChatSessions(STAGE_ID, loaded, {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
expect(legacyStore.clearCalls).toBe(0);
expect(legacyStore.sessions.map((chat) => chat.id)).toEqual(['good-row', 'bad-row']);
expect(
(await store.listSessions(STAGE_ID, LEARNER_KEY)).some((runtime) =>
runtime.id.includes(encodeURIComponent(malformed.id)),
),
).toBe(false);
});
it.each([
['a null message', [null]],
[
'a message without a string id',
[{ ...message('message-1', 'user', 'Hello', 1_000), id: undefined }],
],
])('skips a legacy row with %s while migrating valid rows', async (_label, messages) => {
const store = makeRuntimeStore();
const malformed = {
...session({ id: 'bad-row' }),
messages,
} as unknown as ChatSession;
const valid = session({ id: 'good-row', status: 'completed' });
const legacyStore = new MemoryLegacyChatStore([malformed, valid]);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
await expect(
loadChatSessions(STAGE_ID, {
store,
learnerKey: LEARNER_KEY,
legacyStore,
}),
).resolves.toMatchObject([{ id: 'good-row' }]);
expect(legacyStore.clearCalls).toBe(0);
expect(legacyStore.sessions.map((chat) => chat.id)).toEqual(['bad-row', 'good-row']);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('bad-row'));
expect(
(await store.listSessions(STAGE_ID, LEARNER_KEY)).filter(
(candidate) => candidate.kind === 'chat',
),
).toHaveLength(1);
});
it('preserves the malformed-row clear guard across an unobserved runtime load', async () => {
const store = makeRuntimeStore();
const malformed = {
...session({ id: 'bad-row' }),
toolCalls: null,
} as unknown as ChatSession;
const legacyStore = new MemoryLegacyChatStore([
session({ id: 'good-row', status: 'completed' }),
malformed,
]);
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const loaded = await loadChatSessions(STAGE_ID, {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
await loadChatSessions(STAGE_ID, {
store,
learnerKey: LEARNER_KEY,
legacyStore: new MemoryLegacyChatStore(),
observe: false,
});
await saveChatSessions(STAGE_ID, loaded, {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
expect(legacyStore.clearCalls).toBe(0);
expect(legacyStore.sessions.map((chat) => chat.id)).toEqual(['good-row', 'bad-row']);
});
it('lets a genuinely newer post-cutover legacy row win the merge unchanged', async () => {
const store = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
const runtimeUpdatedAt = Date.UTC(2026, 7, 6);
await saveChatSessions(
STAGE_ID,
[session({ title: 'Runtime older', updatedAt: runtimeUpdatedAt })],
{ store, learnerKey: LEARNER_KEY, legacyStore },
);
legacyStore.sessions = [
session({
title: 'Legacy newer',
messages: [
message('message-1', 'user', 'Hello', 1_000),
message('legacy-only', 'assistant', 'Newer legacy edit', 2_000),
],
updatedAt: runtimeUpdatedAt + 24 * 60 * 60 * 1_000,
}),
];
await expect(
loadChatSessions(STAGE_ID, { store, learnerKey: LEARNER_KEY, legacyStore }),
).resolves.toMatchObject([
{
title: 'Legacy newer',
messages: [{ id: 'message-1' }, { id: 'legacy-only' }],
updatedAt: runtimeUpdatedAt + 24 * 60 * 60 * 1_000,
},
]);
});
it('preserves post-cutover timestamps while restoring legacy backup rows', async () => {
const store = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
const restoredAt = Date.UTC(2027, 0, 1);
await restoreChatSessionsFromBackup(
[STAGE_ID],
async () => {
legacyStore.sessions = [session({ status: 'completed', updatedAt: restoredAt })];
},
{ store, learnerKey: LEARNER_KEY, legacyStore },
);
await expect(
loadChatSessions(STAGE_ID, { store, learnerKey: LEARNER_KEY, legacyStore }),
).resolves.toMatchObject([{ updatedAt: restoredAt }]);
});
it('fails backup restore loudly before deleting runtime data when a row is malformed', async () => {
const store = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
const original = session({ title: 'Runtime before restore', status: 'completed' });
await saveChatSessions(STAGE_ID, [original], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
const rollbackLegacyRows = vi.fn().mockResolvedValue(undefined);
await expect(
restoreChatSessionsFromBackup(
[STAGE_ID],
async () => {
legacyStore.sessions = [
{ ...session({ id: 'invalid-backup-row' }), messages: null } as unknown as ChatSession,
];
},
{ store, learnerKey: LEARNER_KEY, legacyStore, rollbackLegacyRows },
),
).rejects.toThrow(/invalid-backup-row/);
expect(rollbackLegacyRows).toHaveBeenCalledOnce();
await expect(
loadChatSessions(STAGE_ID, {
store,
learnerKey: LEARNER_KEY,
legacyStore: new MemoryLegacyChatStore(),
}),
).resolves.toMatchObject([{ title: 'Runtime before restore' }]);
});
it('keeps a restore marker visible after the learner partition is merged', async () => {
const store = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
await saveChatSessions(STAGE_ID, [session()], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
await restoreChatSessionsFromBackup([STAGE_ID], async () => {}, {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
const accountLearnerKey = 'user:restored-chat-test';
await store.mergeLearner(LEARNER_KEY, accountLearnerKey);
let snapshot: ChatStorageSnapshot | undefined;
await loadChatSessions(STAGE_ID, {
store,
learnerKey: accountLearnerKey,
legacyStore,
onSnapshot: (loaded) => {
snapshot = loaded;
},
});
expect(snapshot?.restoreMarker).toMatch(/^chat-restore-marker:/);
});
it('retains migrated observations when clearing legacy rows fails', async () => {
const store = makeRuntimeStore();
const legacyStore = new FailingClearLegacyChatStore([session({ title: 'Legacy chat' })]);
const options = { store, learnerKey: LEARNER_KEY, legacyStore };
await expect(loadChatSessions(STAGE_ID, options)).resolves.toMatchObject([
{ title: 'Legacy chat' },
]);
await saveChatSessions(STAGE_ID, [], options);
await expect(loadChatSessions(STAGE_ID, options)).resolves.toEqual([]);
});
it('does not delete runtime-only chats after a legacy clear failure', async () => {
const store = makeRuntimeStore();
await saveChatSessions(STAGE_ID, [session({ id: 'runtime-only', title: 'Runtime only' })], {
store,
learnerKey: LEARNER_KEY,
legacyStore: new MemoryLegacyChatStore(),
});
const legacyStore = new FailingClearLegacyChatStore([
session({ id: 'legacy-only', title: 'Legacy only' }),
]);
const options = { store, learnerKey: LEARNER_KEY, legacyStore };
await expect(loadChatSessions(STAGE_ID, options)).resolves.toMatchObject([
{ id: 'legacy-only' },
]);
await saveChatSessions(STAGE_ID, [], options);
await expect(loadChatSessions(STAGE_ID, options)).resolves.toMatchObject([
{ id: 'runtime-only', title: 'Runtime only' },
]);
});
it('does not resurrect a legacy snapshot when a concurrent save removes it', async () => {
const store = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore([session({ status: 'completed' })]);
let captured!: () => void;
const didCapture = new Promise<void>((resolve) => {
captured = resolve;
});
let release!: () => void;
const mayReturn = new Promise<void>((resolve) => {
release = resolve;
});
legacyStore.load = async () => {
const snapshot = structuredClone(legacyStore.sessions);
captured();
await mayReturn;
return snapshot;
};
const options = { store, learnerKey: LEARNER_KEY, legacyStore };
const loading = loadChatSessions(STAGE_ID, options);
await didCapture;
const saving = saveChatSessions(STAGE_ID, [], options);
release();
await Promise.all([loading, saving]);
expect(legacyStore.sessions).toEqual([]);
expect(await loadChatSessions(STAGE_ID, options)).toEqual([]);
});
it('keeps legacy rows authoritative when the first RuntimeStore migration attempt fails', async () => {
const legacy = session({ status: 'completed' });
const legacyStore = new MemoryLegacyChatStore([legacy]);
const store = {
listSessions: vi.fn().mockRejectedValue(new Error('runtime unavailable')),
} as unknown as RuntimeStore;
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const loaded = await loadChatSessions(STAGE_ID, {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
expect(loaded).toEqual([{ ...legacy, pendingToolCalls: [] }]);
expect(legacyStore.sessions).toEqual([legacy]);
expect(legacyStore.clearCalls).toBe(0);
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it('fails loud after cutover when RuntimeStore is unavailable', async () => {
const legacyStore = new MemoryLegacyChatStore();
const store = {
listSessions: vi.fn().mockRejectedValue(new Error('runtime unavailable')),
} as unknown as RuntimeStore;
await expect(
loadChatSessions(STAGE_ID, { store, learnerKey: LEARNER_KEY, legacyStore }),
).rejects.toThrow('runtime unavailable');
});
it('does not treat a failed reload as authority to delete previously observed chats', async () => {
const backing = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
let failLists = false;
const store = new Proxy(backing, {
get(target, property) {
if (property === 'listSessions') {
return (...args: Parameters<RuntimeStore['listSessions']>) =>
failLists
? Promise.reject(new Error('runtime unavailable'))
: backing.listSessions(...args);
}
const value = Reflect.get(target, property, target) as unknown;
return typeof value === 'function' ? value.bind(target) : value;
},
});
const persisted = session();
await saveChatSessions(STAGE_ID, [persisted], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
await expect(
loadChatSessions(STAGE_ID, { store, learnerKey: LEARNER_KEY, legacyStore }),
).resolves.toHaveLength(1);
failLists = true;
await expect(
loadChatSessions(STAGE_ID, { store, learnerKey: LEARNER_KEY, legacyStore }),
).rejects.toThrow('runtime unavailable');
failLists = false;
await saveChatSessions(STAGE_ID, [], { store, learnerKey: LEARNER_KEY, legacyStore });
await expect(
loadChatSessions(STAGE_ID, { store, learnerKey: LEARNER_KEY, legacyStore }),
).resolves.toMatchObject([{ id: persisted.id }]);
});
it('deletes omitted chat sessions without touching other runtime kinds', async () => {
const store = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
const now = new Date().toISOString();
await store.createSession({
id: 'pbl-session',
kind: 'pbl',
stageId: STAGE_ID,
learnerKey: LEARNER_KEY,
status: 'active',
createdAt: now,
updatedAt: now,
});
const first = session({ id: 'session-1' });
const second = session({ id: 'session-2', createdAt: 950, updatedAt: 1_300 });
await saveChatSessions(STAGE_ID, [first, second], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
await saveChatSessions(STAGE_ID, [second], {
store,
learnerKey: LEARNER_KEY,
legacyStore,
});
const runtimeSessions = await store.listSessions(STAGE_ID, LEARNER_KEY);
expect(runtimeSessions.filter((candidate) => candidate.kind === 'chat')).toHaveLength(1);
expect(runtimeSessions.filter((candidate) => candidate.kind === 'pbl')).toHaveLength(1);
});
it('does not let a stale tab resurrect a chat deleted by another tab', async () => {
const indexedDB = new IDBFactory();
const deletingStore = new BrowserRuntimeStore({ indexedDB, dbName: 'chat-delete-tombstone' });
const staleStore = new BrowserRuntimeStore({ indexedDB, dbName: 'chat-delete-tombstone' });
const freshStore = new BrowserRuntimeStore({ indexedDB, dbName: 'chat-delete-tombstone' });
const deletingLegacy = new MemoryLegacyChatStore();
const staleLegacy = new MemoryLegacyChatStore();
const original = session({ status: 'completed' });
await saveChatSessions(STAGE_ID, [original], {
store: deletingStore,
learnerKey: LEARNER_KEY,
legacyStore: deletingLegacy,
});
let deletingSnapshot: ChatStorageSnapshot | undefined;
let staleSnapshot: ChatStorageSnapshot | undefined;
await loadChatSessions(STAGE_ID, {
store: deletingStore,
learnerKey: LEARNER_KEY,
legacyStore: deletingLegacy,
onSnapshot: (snapshot) => {
deletingSnapshot = snapshot;
},
});
const staleSessions = await loadChatSessions(STAGE_ID, {
store: staleStore,
learnerKey: LEARNER_KEY,
legacyStore: staleLegacy,
onSnapshot: (snapshot) => {
staleSnapshot = snapshot;
},
});
await saveChatSessions(STAGE_ID, [], {
store: deletingStore,
learnerKey: LEARNER_KEY,
legacyStore: deletingLegacy,
snapshot: deletingSnapshot,
});
await saveChatSessions(STAGE_ID, staleSessions, {
store: staleStore,
learnerKey: LEARNER_KEY,
legacyStore: staleLegacy,
snapshot: staleSnapshot,
});
await expect(
saveChatSessions(
STAGE_ID,
[{ ...staleSessions[0]!, title: 'Edited stale chat', updatedAt: 5_000 }],
{
store: staleStore,
learnerKey: LEARNER_KEY,
legacyStore: staleLegacy,
snapshot: staleSnapshot,
},
),
).rejects.toThrow(/deleted by another caller/);
await expect(
loadChatSessions(STAGE_ID, {
store: freshStore,
learnerKey: LEARNER_KEY,
legacyStore: new MemoryLegacyChatStore(),
}),
).resolves.toEqual([]);
});
it('honors a deletion tombstone when deleting the old runtime session fails', async () => {
const backing = makeRuntimeStore();
const legacyStore = new MemoryLegacyChatStore();
await saveChatSessions(STAGE_ID, [session()], {
store: backing,
learnerKey: LEARNER_KEY,
legacyStore,
});
const [runtimeChat] = (await backing.listSessions(STAGE_ID, LEARNER_KEY)).filter(
(candidate) => candidate.kind === 'chat',
);
let snapshot: ChatStorageSnapshot | undefined;
const failingDeleteStore = new Proxy(backing, {
get(target, property) {
if (property === 'deleteSession') {
return async (runtimeSessionId: string) => {
if (runtimeSessionId === runtimeChat?.id) throw new Error('chat delete failed');
return target.deleteSession(runtimeSessionId);
};
}
const value = Reflect.get(target, property, target) as unknown;
return typeof value === 'function' ? value.bind(target) : value;
},
});
await loadChatSessions(STAGE_ID, {
store: failingDeleteStore,
learnerKey: LEARNER_KEY,
legacyStore,
onSnapshot: (loaded) => {
snapshot = loaded;
},
});
await expect(
saveChatSessions(STAGE_ID, [], {
store: failingDeleteStore,
learnerKey: LEARNER_KEY,
legacyStore,
snapshot,
}),
).rejects.toThrow('chat delete failed');
await expect(
loadChatSessions(STAGE_ID, {
store: backing,
learnerKey: LEARNER_KEY,
legacyStore,
}),
).resolves.toEqual([]);
const accountLearnerKey = 'user:deleted-chat-test';
await backing.mergeLearner(LEARNER_KEY, accountLearnerKey);
await expect(
loadChatSessions(STAGE_ID, {
store: backing,
learnerKey: accountLearnerKey,
legacyStore,
}),
).resolves.toEqual([]);
});
it('does not let an unobserved stale snapshot delete a newer tab session', async () => {
const sharedIndexedDB = new IDBFactory();
const staleStore = new BrowserRuntimeStore({ indexedDB: sharedIndexedDB });
const freshStore = new BrowserRuntimeStore({ indexedDB: sharedIndexedDB });
const staleLegacy = new MemoryLegacyChatStore();
const freshLegacy = new MemoryLegacyChatStore();
const fresh = session({ id: 'fresh-session', updatedAt: 2_000 });
await saveChatSessions(STAGE_ID, [fresh], {
store: freshStore,
learnerKey: LEARNER_KEY,
legacyStore: freshLegacy,
});
await saveChatSessions(STAGE_ID, [], {
store: staleStore,
learnerKey: LEARNER_KEY,
legacyStore: staleLegacy,
});
await expect(
loadChatSessions(STAGE_ID, {
store: freshStore,
learnerKey: LEARNER_KEY,
legacyStore: freshLegacy,
}),
).resolves.toEqual([{ ...fresh, status: 'interrupted', pendingToolCalls: [] }]);
});
});