2287 lines
78 KiB
TypeScript
2287 lines
78 KiB
TypeScript
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,
|
|
});
|
|
// A normal stage autosave still carries the caller-visible old snapshot.
|
|
// It may preserve the newer runtime value, but must not pretend the caller
|
|
// observed that value or the next deliberate local edit will look stale.
|
|
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: [] }]);
|
|
});
|
|
});
|