Files
openmaic/OpenMAIC/lib/utils/chat-storage.ts
2026-08-16 14:58:47 +08:00

1456 lines
52 KiB
TypeScript

/**
* Chat persistence on the learner RuntimeStore.
*
* The legacy Dexie table remains as a one-time migration source. Runtime
* records are append-only, while the latest session-state record describes
* the current message window and mutable chat metadata.
*/
import type { RuntimeSession } from '@openmaic/dsl';
import type { KVStore, RuntimeStore } from '@openmaic/storage';
import { HttpRuntimeStoreError } from '@openmaic/storage/runtime/http';
import { isEqual } from 'lodash';
import { nanoid } from 'nanoid';
import { getLearnerKey } from '@/lib/runtime/learner-key';
import { getRuntimeStore } from '@/lib/runtime/store';
import type { ChatSession } from '@/lib/types/chat';
import { db } from './database';
import {
buildChatRecordInit,
chatRuntimeCandidates,
chatRuntimeIdentity,
foldRecords,
fromLegacyRecords,
generationRuntimeSessionId,
iso,
newestRuntimeCandidate,
normalizeSession,
planChatSync,
type ChatMessagePayload,
type FoldedChat,
type ChatRuntimeCandidate,
type ChatRuntimeView,
type ChatSessionStatePayload,
type LegacyChatConversion,
type SkippedLegacyChatRow,
} from './chat-storage-core';
import {
chatStoragePartitionLockName,
withChatStorageExclusiveLock,
withChatStorageSharedLock,
} from './chat-storage-lock';
const RESTORE_MARKER_PREFIX = 'chat-restore-marker:';
const DELETION_MARKER_PREFIX = 'chat-deletion:';
const CHAT_DELETION_KIND = 'chat-deletion';
const MAX_CHAT_SYNC_ATTEMPTS = 8;
const MAX_CHAT_PLAN_STEPS_PER_ATTEMPT = 8;
const MAX_CHAT_RETRY_DELAY_MS = 500;
const defaultChatSyncSleep: ChatSyncSleep = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
interface LegacyChatStore {
load(stageId: string): Promise<unknown>;
clear(stageId: string): Promise<void>;
}
export interface ChatStorageOptions {
store?: RuntimeStore;
kv?: KVStore;
learnerKey?: string;
legacyStore?: LegacyChatStore;
globalLockHeld?: boolean;
snapshot?: ChatStorageSnapshot;
/** Override retry delays in tests or non-browser runtimes. */
sleep?: ChatSyncSleep;
}
export type ChatSyncSleep = (milliseconds: number) => Promise<void>;
interface ChatStorageRestoreOptions extends ChatStorageOptions {
rollbackLegacyRows?: () => Promise<void>;
}
export interface ChatStorageReadOptions extends ChatStorageOptions {
fallbackToLegacyOnError?: boolean;
observe?: boolean;
onSnapshot?: (snapshot: ChatStorageSnapshot) => void;
}
export interface ChatStorageSnapshot {
sessions: ChatSession[];
/** `undefined` means the runtime generation could not be read authoritatively. */
restoreMarker?: string | null;
}
export * from './chat-storage-core';
const dexieLegacyStore: LegacyChatStore = {
async load(stageId) {
const staged = await db.chatRestoreStaging.where('stageId').equals(stageId).sortBy('createdAt');
const records =
staged.length > 0
? staged
: await db.chatSessions.where('stageId').equals(stageId).sortBy('createdAt');
return records;
},
async clear(stageId) {
await db.transaction('rw', [db.chatSessions, db.chatRestoreStaging], async () => {
await db.chatSessions.where('stageId').equals(stageId).delete();
await db.chatRestoreStaging.where('stageId').equals(stageId).delete();
});
},
};
// Stage saves are debounced but can overlap. Keep each RuntimeStore partition
// sequential locally. The shared legacy table requires Web Locks across realms;
// injected legacy stores retain the isolated generation fallback used by
// concurrency tests and non-browser adapters.
const storeQueues = new WeakMap<RuntimeStore, Map<string, Promise<void>>>();
const observedChatSessionIds = new WeakMap<RuntimeStore, Map<string, Set<string>>>();
const observedChatSessions = new WeakMap<RuntimeStore, Map<string, Map<string, ChatSession>>>();
const skippedLegacyRowsByPartition = new WeakMap<
RuntimeStore,
Map<string, readonly SkippedLegacyChatRow[]>
>();
export class ChatStorageLockUnavailableError extends Error {}
export class ChatStorageSnapshotInvalidatedByRestoreError extends Error {}
export class ChatStorageSnapshotInvalidatedByDeletionError extends Error {}
function observedIds(store: RuntimeStore, key: string): Set<string> {
return observedChatSessionIds.get(store)?.get(key) ?? new Set();
}
function rememberObservedIds(store: RuntimeStore, key: string, ids: Iterable<string>): void {
let partitions = observedChatSessionIds.get(store);
if (!partitions) {
partitions = new Map();
observedChatSessionIds.set(store, partitions);
}
partitions.set(key, new Set(ids));
}
function observedSessions(store: RuntimeStore, key: string): Map<string, ChatSession> | undefined {
return observedChatSessions.get(store)?.get(key);
}
function rememberObservedSessions(
store: RuntimeStore,
key: string,
sessions: readonly ChatSession[],
): void {
let partitions = observedChatSessions.get(store);
if (!partitions) {
partitions = new Map();
observedChatSessions.set(store, partitions);
}
partitions.set(
key,
new Map(sessions.map((session) => [session.id, structuredClone(normalizeSession(session))])),
);
}
function skippedLegacyRows(
store: RuntimeStore,
key: string,
): readonly SkippedLegacyChatRow[] | undefined {
return skippedLegacyRowsByPartition.get(store)?.get(key);
}
function rememberSkippedLegacyRows(
store: RuntimeStore,
key: string,
rows: readonly SkippedLegacyChatRow[],
): void {
const partitions = skippedLegacyRowsByPartition.get(store);
if (rows.length === 0) {
partitions?.delete(key);
return;
}
if (partitions) {
partitions.set(key, rows);
return;
}
skippedLegacyRowsByPartition.set(store, new Map([[key, rows]]));
}
function matchesObservedSessions(
store: RuntimeStore,
key: string,
sessions: readonly ChatSession[],
): boolean {
const observed = observedSessions(store, key);
if (!observed) return sessions.length === 0;
if (observed.size !== sessions.length) return false;
return sessions.every((session) => isEqual(observed.get(session.id), normalizeSession(session)));
}
function sessionMap(sessions: readonly ChatSession[]): Map<string, ChatSession> {
return new Map(
sessions.map((session) => [session.id, structuredClone(normalizeSession(session))]),
);
}
function matchesSnapshot(snapshot: ChatStorageSnapshot, sessions: readonly ChatSession[]): boolean {
const baseline = sessionMap(snapshot.sessions);
if (baseline.size !== sessions.length) return false;
return sessions.every((session) => isEqual(baseline.get(session.id), normalizeSession(session)));
}
function reportSnapshot(
options: ChatStorageReadOptions,
sessions: readonly ChatSession[],
restoreMarker: string | null | undefined,
): void {
options.onSnapshot?.({
sessions: structuredClone([...sessions]),
restoreMarker,
});
}
function withPartitionLocks<T>(
crossRealmKey: string,
key: string,
requiresCrossRealmLock: boolean,
work: (isolatedWrites: boolean) => Promise<T>,
): Promise<T> {
if (typeof navigator !== 'undefined' && navigator.locks) {
const locks = navigator.locks;
return locks.request<Promise<T>>(
chatStoragePartitionLockName(crossRealmKey),
() =>
locks.request<Promise<T>>(chatStoragePartitionLockName(key), () =>
work(false),
) as unknown as Promise<T>,
) as unknown as Promise<T>;
}
if (requiresCrossRealmLock) {
throw new ChatStorageLockUnavailableError(
'Chat storage requires the Web Locks API in this browser',
);
}
return work(true);
}
function enqueue<T>(
store: RuntimeStore,
key: string,
crossRealmKey: string,
requiresCrossRealmLock: boolean,
work: (isolatedWrites: boolean) => Promise<T>,
globalLockHeld = false,
): Promise<T> {
const enqueueInGlobalEpoch = (): Promise<T> => {
let queues = storeQueues.get(store);
if (!queues) {
queues = new Map();
storeQueues.set(store, queues);
}
const previous = queues.get(key) ?? Promise.resolve();
const run = () => withPartitionLocks(crossRealmKey, key, requiresCrossRealmLock, work);
const current = previous.catch(() => undefined).then(run);
const settled = current.then(
() => undefined,
() => undefined,
);
queues.set(key, settled);
void settled.finally(() => {
if (queues?.get(key) === settled) queues.delete(key);
});
return current;
};
// Register in the local partition queue only after this operation's global
// shared lock is granted. Otherwise a caller already holding a shared lock
// can wait for a later operation queued behind maintenance, creating a
// shared -> later shared -> exclusive -> shared inversion.
return globalLockHeld ? enqueueInGlobalEpoch() : withChatStorageSharedLock(enqueueInGlobalEpoch);
}
async function context(options: ChatStorageOptions): Promise<{
store: RuntimeStore;
learnerKey: string;
legacyStore: LegacyChatStore;
requiresCrossRealmLock: boolean;
sleep: ChatSyncSleep;
}> {
const legacyStore = options.legacyStore ?? dexieLegacyStore;
return {
store: options.store ?? getRuntimeStore(),
learnerKey: options.learnerKey ?? (await getLearnerKey(options.kv)),
legacyStore,
sleep: options.sleep ?? defaultChatSyncSleep,
requiresCrossRealmLock: legacyStore === dexieLegacyStore,
};
}
function restoreMarkerPrefix(stageId: string): string {
return `${RESTORE_MARKER_PREFIX}${encodeURIComponent(stageId)}:`;
}
function deletionMarkerPrefix(stageId: string): string {
return `${DELETION_MARKER_PREFIX}${encodeURIComponent(stageId)}:`;
}
function deletionMarkerId(stageId: string, learnerKey: string, chatSessionId: string): string {
return `${deletionMarkerPrefix(stageId)}${encodeURIComponent(chatSessionId)}:${encodeURIComponent(learnerKey)}`;
}
function deletionMarkerChatId(view: ChatRuntimeView, stageId: string): string | undefined {
if (view.runtimeSession.kind !== CHAT_DELETION_KIND) return undefined;
const prefix = deletionMarkerPrefix(stageId);
if (!view.runtimeSession.id.startsWith(prefix)) return undefined;
const encodedChatId = view.runtimeSession.id.slice(prefix.length).split(':', 1)[0];
if (!encodedChatId) return undefined;
try {
return decodeURIComponent(encodedChatId);
} catch {
return undefined;
}
}
function deletionMarkersByChatId(
views: readonly ChatRuntimeView[],
stageId: string,
): Map<string, ChatRuntimeView[]> {
const markers = new Map<string, ChatRuntimeView[]>();
for (const view of views) {
const chatSessionId = deletionMarkerChatId(view, stageId);
if (!chatSessionId) continue;
const current = markers.get(chatSessionId) ?? [];
current.push(view);
markers.set(chatSessionId, current);
}
return markers;
}
function currentRestoreMarker(
views: readonly ChatRuntimeView[],
stageId: string,
): string | undefined {
// RuntimeStore already scopes `views` to one learner. Keep marker discovery
// stage-scoped so a marker remains recognizable after mergeLearner re-keys
// the session without rewriting its immutable id.
const prefix = restoreMarkerPrefix(stageId);
return views
.filter(
(view) =>
view.runtimeSession.status === 'completed' && view.runtimeSession.id.startsWith(prefix),
)
.sort(
(left, right) =>
Date.parse(left.runtimeSession.createdAt) - Date.parse(right.runtimeSession.createdAt) ||
left.runtimeSession.id.localeCompare(right.runtimeSession.id),
)
.at(-1)?.runtimeSession.id;
}
function restoreMarkerTargets(view: ChatRuntimeView | undefined): string[] {
if (!view) return [];
for (const record of view.records) {
const payload = record.payload as { kind?: string; runtimeSessionIds?: unknown };
if (payload.kind !== 'chat_restore_marker' || !Array.isArray(payload.runtimeSessionIds)) {
continue;
}
return payload.runtimeSessionIds.filter(
(runtimeSessionId): runtimeSessionId is string => typeof runtimeSessionId === 'string',
);
}
return [];
}
async function createRestoreMarker(
store: RuntimeStore,
stageId: string,
learnerKey: string,
runtimeSessionIds: readonly string[],
afterCreatedAt?: string,
): Promise<RuntimeSession> {
const now = new Date(
Math.max(Date.now(), afterCreatedAt ? Date.parse(afterCreatedAt) + 1 : 0),
).toISOString();
const marker = await store.createSession({
id: `${restoreMarkerPrefix(stageId)}${encodeURIComponent(learnerKey)}:${nanoid()}`,
kind: 'chat',
stageId,
learnerKey,
status: 'active',
createdAt: now,
updatedAt: now,
});
try {
await store.appendRecord({
id: `${marker.id}:targets`,
sessionId: marker.id,
createdAt: now,
payload: {
role: 'system',
content: '',
kind: 'chat_restore_marker',
runtimeSessionIds: [...runtimeSessionIds],
},
});
await store.setSessionStatus(marker.id, 'completed', now);
return { ...marker, status: 'completed' };
} catch (error) {
await store.deleteSession(marker.id).catch(() => {});
throw error;
}
}
async function finalizeRestoreMarker(store: RuntimeStore, marker: RuntimeSession): Promise<void> {
const finalized = await createRestoreMarker(
store,
marker.stageId,
marker.learnerKey,
[],
marker.createdAt,
);
try {
await store.deleteSession(marker.id);
} catch (error) {
// The newer empty marker sorts after the target-bearing marker, so cleanup
// is already logically complete even if retiring the older marker fails.
const persisted = await store.getSession(finalized.id).catch(() => undefined);
if (!persisted) throw error;
}
}
function matchesChatPartition(
session: RuntimeSession,
id: string,
stageId: string,
learnerKey: string,
): boolean {
return (
session.id === id &&
session.kind === 'chat' &&
session.stageId === stageId &&
session.learnerKey === learnerKey
);
}
async function createOrGetRuntimeSession(
store: RuntimeStore,
init: Parameters<RuntimeStore['createSession']>[0],
): Promise<RuntimeSession> {
try {
return await store.createSession(init);
} catch (error) {
let raced: RuntimeSession | undefined;
try {
raced = await store.getSession(init.id);
} catch {
throw error;
}
if (!raced || !matchesChatPartition(raced, init.id, init.stageId, init.learnerKey)) {
throw error;
}
return raced;
}
}
async function ensureDeletionMarker(
store: RuntimeStore,
stageId: string,
learnerKey: string,
chatSessionId: string,
existingMarkers: readonly ChatRuntimeView[],
): Promise<void> {
if (existingMarkers.some((view) => deletionMarkerChatId(view, stageId) === chatSessionId)) {
return;
}
const id = deletionMarkerId(stageId, learnerKey, chatSessionId);
const now = new Date().toISOString();
try {
await store.createSession({
id,
kind: CHAT_DELETION_KIND,
stageId,
learnerKey,
status: 'completed',
createdAt: now,
updatedAt: now,
});
} catch (error) {
const raced = await store.getSession(id).catch(() => undefined);
if (
!raced ||
raced.kind !== CHAT_DELETION_KIND ||
raced.stageId !== stageId ||
raced.learnerKey !== learnerKey
) {
throw error;
}
}
}
async function runtimeViews(
store: RuntimeStore,
stageId: string,
learnerKey: string,
): Promise<ChatRuntimeView[]> {
const sessions = (await store.listSessions(stageId, learnerKey)).filter(
(session) => session.kind === 'chat' || session.kind === CHAT_DELETION_KIND,
);
return Promise.all(
sessions.map(async (runtimeSession) => {
const records = await store.listRecords(runtimeSession.id);
return { runtimeSession, records, folded: foldRecords(records) };
}),
);
}
async function appendPayload(
store: RuntimeStore,
runtimeId: string,
payload: ChatMessagePayload | ChatSessionStatePayload,
session: ChatSession,
suffix: string,
): Promise<void> {
await store.appendRecord(buildChatRecordInit(runtimeId, payload, session, suffix));
}
function isInactiveSessionAppendError(error: HttpRuntimeStoreError): boolean {
if (error.code !== 'VALIDATION_FAILED') {
return false;
}
return (
(error.message.includes('cannot append to session ') &&
error.message.includes('records may only be appended to an active session')) ||
(error.message.includes(' is no longer active; ') &&
error.message.includes('its current status is'))
);
}
function isDeterministicChatSyncFailure(error: unknown): error is HttpRuntimeStoreError {
return (
error instanceof HttpRuntimeStoreError &&
!isInactiveSessionAppendError(error) &&
(error.status === 400 ||
error.status === 401 ||
error.status === 403 ||
error.status === 413 ||
error.code === 'VALIDATION_FAILED' ||
(error.status === 409 && error.code === 'FUTURE_VERSION'))
);
}
function normalizeLegacyConversion(records: unknown): LegacyChatConversion {
return fromLegacyRecords(records);
}
function legacyRowLabels(rows: readonly SkippedLegacyChatRow[]): string {
return rows
.map((row) => (row.id === undefined ? `index ${row.index}` : JSON.stringify(row.id)))
.join(', ');
}
function warnSkippedLegacyRows(stageId: string, rows: readonly SkippedLegacyChatRow[]): void {
if (rows.length === 0) return;
console.warn(
`Skipped malformed legacy chat rows for stage ${JSON.stringify(stageId)}; retaining the legacy source: ${legacyRowLabels(rows)}`,
);
}
function chatSyncValidationError(sessionId: string, error: HttpRuntimeStoreError): Error {
return new Error(
`Chat sync rejected for session ${JSON.stringify(sessionId)}: ${error.code} (HTTP ${error.status}): ${error.message}`,
{ cause: error },
);
}
async function completeRuntimeCandidate(
store: RuntimeStore,
candidate: ChatRuntimeCandidate,
session: ChatSession,
): Promise<boolean> {
const runtimeId = candidate.runtimeSession.id;
const runtimeSession = await store.getSession(runtimeId);
if (!runtimeSession) return false;
if (runtimeSession.status !== 'completed') {
try {
await store.setSessionStatus(runtimeId, 'completed', iso(session.updatedAt));
} catch (error) {
let latest: RuntimeSession | undefined;
try {
latest = await store.getSession(runtimeId);
} catch {
throw error;
}
if (latest) throw error;
return false;
}
}
return true;
}
async function retireRuntimeCandidates(
store: RuntimeStore,
stageId: string,
learnerKey: string,
candidateIds: string[],
successor: ChatSession,
successorRuntimeId: string,
): Promise<void> {
const ids = new Set(candidateIds);
if (ids.size === 0) return;
const successorIdentity = chatRuntimeIdentity(successorRuntimeId, stageId, successor.id);
if (!successorIdentity) {
throw new Error(`Invalid chat runtime successor ${JSON.stringify(successorRuntimeId)}`);
}
const currentViews = await runtimeViews(store, stageId, learnerKey);
await Promise.all(
currentViews.flatMap((view) => {
if (!ids.has(view.runtimeSession.id) || view.runtimeSession.status !== 'completed') return [];
const state = view.folded.state;
const identity = chatRuntimeIdentity(view.runtimeSession.id, stageId, successor.id);
if (
state &&
(state.updatedAt > successor.updatedAt ||
(state.updatedAt === successor.updatedAt &&
(!identity ||
identity.generation > successorIdentity.generation ||
(identity.generation === successorIdentity.generation &&
view.runtimeSession.id.localeCompare(successorRuntimeId) > 0))))
) {
return [];
}
return [store.deleteSession(view.runtimeSession.id)];
}),
);
}
async function syncOne(
store: RuntimeStore,
stageId: string,
learnerKey: string,
session: ChatSession,
existingViews: ChatRuntimeView[],
isolatedWrites: boolean,
observed: ChatSession | undefined,
sleep: ChatSyncSleep,
): Promise<string> {
let desired = normalizeSession(session);
let views = existingViews;
let retryError: unknown;
let backoffBeforeAttempt = false;
let refreshBeforeAttempt = false;
retryLoop: for (let attempt = 0; attempt < MAX_CHAT_SYNC_ATTEMPTS; attempt += 1) {
if (backoffBeforeAttempt) {
await sleep(Math.min(100 * 2 ** Math.max(0, attempt - 1), MAX_CHAT_RETRY_DELAY_MS));
backoffBeforeAttempt = false;
}
if (refreshBeforeAttempt) {
views = await runtimeViews(store, stageId, learnerKey);
refreshBeforeAttempt = false;
}
let reconcileSource = true;
for (let planStep = 0; planStep < MAX_CHAT_PLAN_STEPS_PER_ATTEMPT; planStep += 1) {
const candidates = chatRuntimeCandidates(views, stageId, desired.id);
const source = newestRuntimeCandidate(candidates);
if (
reconcileSource &&
source?.folded.session &&
source.folded.session.updatedAt > desired.updatedAt
) {
const localEditAdvanced =
observed !== undefined &&
desired.updatedAt > observed.updatedAt &&
!isEqual(desired, observed);
desired = localEditAdvanced
? { ...desired, updatedAt: source.folded.session.updatedAt + 1 }
: source.folded.session;
}
const plan = planChatSync({ session: desired, stageId, learnerKey, isolatedWrites }, views);
if (plan.kind === 'create-session') {
const runtimeSession = await createOrGetRuntimeSession(store, plan.init);
const records = await store.listRecords(runtimeSession.id);
views = [
...views.filter((view) => view.runtimeSession.id !== runtimeSession.id),
{ runtimeSession, records, folded: foldRecords(records) },
];
// The original single-pass branch did not re-resolve a raced session's
// logical source after createOrGet; preserve that conflict behavior.
reconcileSource = false;
continue;
}
if (plan.kind === 'reuse-isolated') {
const destinationId = plan.destination.runtimeSession.id;
if (
plan.completeDestination &&
!(await completeRuntimeCandidate(store, plan.destination, desired))
) {
views = await runtimeViews(store, stageId, learnerKey);
continue retryLoop;
}
const completed = await Promise.all(
plan.retired.map((candidate) => completeRuntimeCandidate(store, candidate, desired)),
);
if (completed.some((candidate) => !candidate)) {
views = await runtimeViews(store, stageId, learnerKey);
continue retryLoop;
}
await retireRuntimeCandidates(
store,
stageId,
learnerKey,
plan.retired.map((candidate) => candidate.runtimeSession.id),
desired,
destinationId,
);
return destinationId;
}
if (plan.kind === 'replace-isolated') {
// A unique generation is safe across realms without a shared mutex:
// every writer stores at most the normalized 200 messages plus state.
const runtimeId = generationRuntimeSessionId(plan.baseRuntimeId, plan.generation, nanoid());
try {
await Promise.all(
plan.candidates.map((candidate) => completeRuntimeCandidate(store, candidate, desired)),
);
let runtimeSession = await createOrGetRuntimeSession(store, {
id: runtimeId,
kind: 'chat',
stageId,
learnerKey,
status: 'active',
createdAt: iso(desired.createdAt),
updatedAt: iso(desired.updatedAt),
});
for (const append of plan.appends) {
await appendPayload(store, runtimeId, append.payload, desired, append.suffix);
}
if (plan.finalStatus) {
await store.setSessionStatus(runtimeId, plan.finalStatus, iso(desired.updatedAt));
runtimeSession = { ...runtimeSession, status: plan.finalStatus };
}
await retireRuntimeCandidates(
store,
stageId,
learnerKey,
plan.candidates.map((candidate) => candidate.runtimeSession.id),
desired,
runtimeId,
);
return runtimeSession.id;
} catch (error) {
retryError = error;
try {
await store.deleteSession(runtimeId);
views = await runtimeViews(store, stageId, learnerKey);
} catch {
throw error;
}
if (isDeterministicChatSyncFailure(error)) {
throw chatSyncValidationError(desired.id, error);
}
backoffBeforeAttempt = true;
continue retryLoop;
}
}
if (plan.kind === 'complete-and-refresh') {
await completeRuntimeCandidate(store, plan.destination, desired);
views = await runtimeViews(store, stageId, learnerKey);
continue retryLoop;
}
if (plan.kind === 'start-generation') {
await createOrGetRuntimeSession(store, plan.init);
views = await runtimeViews(store, stageId, learnerKey);
continue retryLoop;
}
const destination = plan.destination;
const runtimeId = destination.runtimeSession.id;
try {
if (plan.preStatus) {
await store.setSessionStatus(runtimeId, plan.preStatus, iso(desired.updatedAt));
}
for (const append of plan.appends) {
await appendPayload(store, runtimeId, append.payload, desired, append.suffix);
}
if (plan.finalStatus) {
await store.setSessionStatus(runtimeId, plan.finalStatus, iso(desired.updatedAt));
}
return runtimeId;
} catch (error) {
retryError = error;
const deterministic = isDeterministicChatSyncFailure(error);
let latest: RuntimeSession | undefined;
try {
latest = await store.getSession(runtimeId);
} catch {
throw error;
}
if (latest && !matchesChatPartition(latest, runtimeId, stageId, learnerKey)) {
throw error;
}
if (latest?.status === 'active') {
// A generation without a committed state is not externally visible.
// Remove its partial records before retrying instead of exposing them
// through a later state append or retaining an orphaned session.
if (!destination.folded.state) {
let latestFolded: FoldedChat;
try {
latestFolded = foldRecords(await store.listRecords(runtimeId));
} catch {
throw error;
}
if (latestFolded.state) {
if (deterministic) throw chatSyncValidationError(desired.id, error);
views = await runtimeViews(store, stageId, learnerKey);
backoffBeforeAttempt = true;
continue retryLoop;
}
try {
await store.deleteSession(runtimeId);
} catch {
throw error;
}
if (deterministic) throw chatSyncValidationError(desired.id, error);
views = await runtimeViews(store, stageId, learnerKey);
backoffBeforeAttempt = true;
continue retryLoop;
}
if (deterministic) throw chatSyncValidationError(desired.id, error);
views = await runtimeViews(store, stageId, learnerKey);
backoffBeforeAttempt = true;
continue retryLoop;
}
if (deterministic) throw chatSyncValidationError(desired.id, error);
views = await runtimeViews(store, stageId, learnerKey);
backoffBeforeAttempt = true;
continue retryLoop;
}
}
retryError ??= new Error(
`Exceeded chat sync plan-step limit for ${JSON.stringify(desired.id)}`,
);
backoffBeforeAttempt = true;
refreshBeforeAttempt = true;
}
if (!isolatedWrites) {
// A failed locked write may exhaust its retries immediately after
// creating the next generation. Such state-less generations are never a
// committed snapshot and no other partition writer can be active here.
try {
const unresolved = (await runtimeViews(store, stageId, learnerKey)).filter(
(view) =>
chatRuntimeIdentity(view.runtimeSession.id, stageId, desired.id) !== undefined &&
!view.folded.state,
);
await Promise.all(unresolved.map((view) => store.deleteSession(view.runtimeSession.id)));
} catch {
// Preserve the append failure that made the save fail; cleanup remains
// best-effort if the RuntimeStore itself is unavailable.
}
}
throw (
retryError ?? new Error(`Failed to resolve chat generation for ${JSON.stringify(desired.id)}`)
);
}
async function syncSessions(
store: RuntimeStore,
stageId: string,
learnerKey: string,
sessions: ChatSession[],
deleteOmitted: boolean,
isolatedWrites: boolean,
knownSessionIds: ReadonlySet<string> = new Set(),
observed: ReadonlyMap<string, ChatSession> = new Map(),
existingViews?: ChatRuntimeView[],
sleep: ChatSyncSleep = defaultChatSyncSleep,
): Promise<ChatSession[]> {
const existing = existingViews ?? (await runtimeViews(store, stageId, learnerKey));
const desiredRuntimeIds = new Map<string, string>();
for (const session of sessions) {
desiredRuntimeIds.set(
session.id,
await syncOne(
store,
stageId,
learnerKey,
session,
existing,
isolatedWrites,
observed.get(session.id),
sleep,
),
);
}
if (deleteOmitted) {
const omittedKnownIds = new Set(
existing.flatMap((view) => {
const chatSessionId = view.folded.session?.id;
return chatSessionId &&
knownSessionIds.has(chatSessionId) &&
!desiredRuntimeIds.has(chatSessionId)
? [chatSessionId]
: [];
}),
);
await Promise.all(
[...omittedKnownIds].map((chatSessionId) =>
ensureDeletionMarker(store, stageId, learnerKey, chatSessionId, existing),
),
);
const afterSync = await runtimeViews(store, stageId, learnerKey);
const afterSyncById = new Map(afterSync.map((view) => [view.runtimeSession.id, view]));
await Promise.all(
existing.flatMap((view) => {
const chatSessionId = view.folded.session?.id;
const desiredRuntimeId = chatSessionId ? desiredRuntimeIds.get(chatSessionId) : undefined;
const current = afterSyncById.get(view.runtimeSession.id);
// A full snapshot may have been captured in another tab before this
// runtime session existed. Only treat omission as deletion for chat
// IDs this RuntimeStore instance has actually observed; otherwise a
// stale snapshot could erase a newer tab's session. State-less views
// may still be in flight, so leave them to their writer's retry path.
if (!chatSessionId) return [];
if (!desiredRuntimeId) {
return knownSessionIds.has(chatSessionId)
? [store.deleteSession(view.runtimeSession.id)]
: [];
}
if (
desiredRuntimeId === view.runtimeSession.id ||
!current ||
current.runtimeSession.status !== 'completed'
) {
return [];
}
const successor = afterSyncById.get(desiredRuntimeId);
const currentIdentity = chatRuntimeIdentity(
current.runtimeSession.id,
stageId,
chatSessionId,
);
const successorIdentity = successor
? chatRuntimeIdentity(successor.runtimeSession.id, stageId, chatSessionId)
: undefined;
if (
current.folded.state &&
(!successor?.folded.state ||
successor.folded.state.updatedAt < current.folded.state.updatedAt ||
(successor.folded.state.updatedAt === current.folded.state.updatedAt &&
(!currentIdentity ||
!successorIdentity ||
currentIdentity.generation > successorIdentity.generation ||
(currentIdentity.generation === successorIdentity.generation &&
current.runtimeSession.id.localeCompare(successor.runtimeSession.id) > 0))))
) {
return [];
}
return [store.deleteSession(view.runtimeSession.id)];
}),
);
}
return loadRuntimeSessions(store, stageId, learnerKey);
}
async function loadRuntimeSessions(
store: RuntimeStore,
stageId: string,
learnerKey: string,
): Promise<ChatSession[]> {
const views = await runtimeViews(store, stageId, learnerKey);
const deletedChatSessionIds = new Set(deletionMarkersByChatId(views, stageId).keys());
const restoreMarker = currentRestoreMarker(views, stageId);
const supersededRuntimeSessionIds = new Set(
restoreMarkerTargets(views.find((view) => view.runtimeSession.id === restoreMarker)),
);
const newestByChatSession = new Map<string, ChatRuntimeView>();
for (const view of views) {
if (supersededRuntimeSessionIds.has(view.runtimeSession.id)) continue;
const chatSession = view.folded.session;
if (!chatSession) continue;
// The marker is committed before destructive cleanup. It remains the
// authoritative deletion intent if removing an old generation fails.
if (deletedChatSessionIds.has(chatSession.id)) continue;
const identity = chatRuntimeIdentity(view.runtimeSession.id, stageId, chatSession.id);
if (!identity) continue;
const current = newestByChatSession.get(chatSession.id);
const currentGeneration = current
? chatRuntimeIdentity(current.runtimeSession.id, stageId, chatSession.id)!.generation
: -1;
if (
!current ||
chatSession.updatedAt > current.folded.session!.updatedAt ||
(chatSession.updatedAt === current.folded.session!.updatedAt &&
(identity.generation > currentGeneration ||
(identity.generation === currentGeneration &&
view.runtimeSession.id.localeCompare(current.runtimeSession.id) > 0)))
) {
newestByChatSession.set(chatSession.id, view);
}
}
return [...newestByChatSession.values()]
.map((view) => view.folded.session!)
.sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id));
}
/** Persist the complete chat-session set for a stage. */
export async function saveChatSessions(
stageId: string,
sessions: ChatSession[],
options: ChatStorageOptions = {},
): Promise<void> {
const resolved = await context(options);
const queueKey = `${stageId}\0${resolved.learnerKey}`;
const nextSessions = sessions ?? [];
try {
await enqueue(
resolved.store,
queueKey,
stageId,
resolved.requiresCrossRealmLock,
async (isolatedWrites) => {
let beforeSave = await runtimeViews(resolved.store, stageId, resolved.learnerKey);
const restoreMarker = currentRestoreMarker(beforeSave, stageId) ?? null;
const callerSnapshot = options.snapshot;
if (
callerSnapshot &&
callerSnapshot.restoreMarker !== undefined &&
callerSnapshot.restoreMarker !== restoreMarker
) {
// Restore invalidates snapshots captured by already-mounted callers.
// Unchanged stage autosaves are harmless no-ops; real chat mutations
// fail loud until the caller reloads instead of overwriting backup data.
if (matchesSnapshot(callerSnapshot, nextSessions)) return;
throw new ChatStorageSnapshotInvalidatedByRestoreError(
`Chat snapshot for stage ${JSON.stringify(stageId)} was invalidated by backup restore`,
);
}
if (!callerSnapshot && restoreMarker !== null) {
throw new ChatStorageSnapshotInvalidatedByRestoreError(
`Chat snapshot for stage ${JSON.stringify(stageId)} must be reloaded after backup restore`,
);
}
if (
callerSnapshot?.restoreMarker === undefined &&
callerSnapshot !== undefined &&
matchesSnapshot(callerSnapshot, nextSessions)
) {
return;
}
let effectiveNextSessions = nextSessions;
if (
callerSnapshot !== undefined &&
callerSnapshot.restoreMarker === undefined &&
!matchesSnapshot(callerSnapshot, nextSessions)
) {
// The caller loaded no authoritative runtime snapshot. Once storage
// recovers, preserve any still-staged legacy rows while merging the
// caller's new chats. The per-partition clear guard below separately
// protects a legacy source whose last load skipped malformed rows.
const recoveredConversion = normalizeLegacyConversion(
await resolved.legacyStore.load(stageId),
);
rememberSkippedLegacyRows(resolved.store, queueKey, recoveredConversion.skippedRows);
const recoveredLegacy = recoveredConversion.sessions;
if (recoveredLegacy.length > 0) {
const recoveredById = new Map(recoveredLegacy.map((session) => [session.id, session]));
for (const session of nextSessions) recoveredById.set(session.id, session);
effectiveNextSessions = [...recoveredById.values()];
}
}
if (callerSnapshot?.restoreMarker === undefined && restoreMarker !== null) {
const markerView = beforeSave.find((view) => view.runtimeSession.id === restoreMarker);
const targets = restoreMarkerTargets(markerView);
await Promise.all(
targets.map((runtimeSessionId) => resolved.store.deleteSession(runtimeSessionId)),
);
if (markerView && targets.length > 0) {
await finalizeRestoreMarker(resolved.store, markerView.runtimeSession);
}
beforeSave = await runtimeViews(resolved.store, stageId, resolved.learnerKey);
}
const deletionMarkers = deletionMarkersByChatId(beforeSave, stageId);
const callerBaseline = callerSnapshot ? sessionMap(callerSnapshot.sessions) : undefined;
const ignoredStaleSessionIds = new Set<string>();
const supersededMarkerViews: ChatRuntimeView[] = [];
for (const session of nextSessions) {
const markers = deletionMarkers.get(session.id);
if (!markers?.length) continue;
const baseline = callerBaseline?.get(session.id);
if (baseline) {
if (isEqual(baseline, normalizeSession(session))) {
ignoredStaleSessionIds.add(session.id);
continue;
}
throw new ChatStorageSnapshotInvalidatedByDeletionError(
`Chat ${JSON.stringify(session.id)} was deleted by another caller`,
);
}
if (!callerSnapshot) {
throw new ChatStorageSnapshotInvalidatedByDeletionError(
`Chat ${JSON.stringify(session.id)} must be reloaded after deletion`,
);
}
// The caller did not observe the deleted chat, so this is a new
// creation that deliberately reuses the id. Retire the tombstone.
supersededMarkerViews.push(...markers);
}
if (ignoredStaleSessionIds.size > 0) {
effectiveNextSessions = effectiveNextSessions.filter(
(session) => !ignoredStaleSessionIds.has(session.id),
);
}
const knownSessionIds = callerSnapshot
? new Set(callerSnapshot.sessions.map((session) => session.id))
: observedIds(resolved.store, queueKey);
const priorObservedSessions = callerSnapshot
? sessionMap(callerSnapshot.sessions)
: observedSessions(resolved.store, queueKey);
await syncSessions(
resolved.store,
stageId,
resolved.learnerKey,
effectiveNextSessions,
true,
isolatedWrites,
knownSessionIds,
priorObservedSessions,
beforeSave,
resolved.sleep,
);
// Keep the tombstone authoritative until the deliberately reused chat
// id has been durably synced. If marker cleanup fails, this save fails
// and readers continue to hide both stale and partially replaced data.
await Promise.all(
supersededMarkerViews.map((view) => resolved.store.deleteSession(view.runtimeSession.id)),
);
rememberObservedIds(
resolved.store,
queueKey,
nextSessions.map((session) => session.id),
);
// saveChatSessions does not return a reconciled snapshot to its caller.
// Keep conflict observations aligned with the state the caller really
// saw, even when this save silently preserved a newer cross-tab value.
rememberObservedSessions(resolved.store, queueKey, nextSessions);
const unsafeToClear = skippedLegacyRows(resolved.store, queueKey);
if (unsafeToClear) {
warnSkippedLegacyRows(stageId, unsafeToClear);
} else {
await resolved.legacyStore.clear(stageId);
}
},
options.globalLockHeld,
);
} catch (error) {
// A stage autosave echoes the caller-visible chat snapshot even when the
// user only changed document data. Without Web Locks, an unchanged echo is
// a safe no-op; any chat creation, edit, or deletion must still fail loud.
if (
error instanceof ChatStorageLockUnavailableError &&
(options.snapshot
? matchesSnapshot(options.snapshot, nextSessions)
: matchesObservedSessions(resolved.store, queueKey, nextSessions))
) {
return;
}
throw error;
}
}
/** Load chat sessions, migrating legacy Dexie rows on first access. */
export async function loadChatSessions(
stageId: string,
options: ChatStorageReadOptions = {},
): Promise<ChatSession[]> {
const resolved = await context(options);
const queueKey = `${stageId}\0${resolved.learnerKey}`;
let legacy: ChatSession[] = [];
let runtimeReadSucceeded = false;
let readRestoreMarker: string | null | undefined;
try {
return await enqueue(
resolved.store,
queueKey,
stageId,
resolved.requiresCrossRealmLock,
async (isolatedWrites) => {
// Read legacy rows only after entering the same partition queue/lock as
// saves. Otherwise a delayed migration can replay a snapshot captured
// before a concurrent save cleared it and resurrect deleted chats.
const rawLegacy = await resolved.legacyStore.load(stageId);
const conversion = normalizeLegacyConversion(rawLegacy);
legacy = conversion.sessions;
if (options.observe !== false) {
rememberSkippedLegacyRows(resolved.store, queueKey, conversion.skippedRows);
}
// Rows the stricter serializer refuses stay in the legacy source (clear
// is blocked on both load and save paths by the per-partition guard)
// and are excluded from the snapshot until the shape is supported.
warnSkippedLegacyRows(stageId, conversion.skippedRows);
let beforeLoad = await runtimeViews(resolved.store, stageId, resolved.learnerKey);
let restoreMarker = currentRestoreMarker(beforeLoad, stageId) ?? null;
readRestoreMarker = restoreMarker;
if (restoreMarker !== null) {
const markerView = beforeLoad.find((view) => view.runtimeSession.id === restoreMarker);
const targets = restoreMarkerTargets(markerView);
if (markerView && targets.length > 0) {
await Promise.all(
targets.map((runtimeSessionId) => resolved.store.deleteSession(runtimeSessionId)),
);
await finalizeRestoreMarker(resolved.store, markerView.runtimeSession);
beforeLoad = await runtimeViews(resolved.store, stageId, resolved.learnerKey);
restoreMarker = currentRestoreMarker(beforeLoad, stageId) ?? null;
readRestoreMarker = restoreMarker;
}
}
if (legacy.length === 0) {
const loaded = await loadRuntimeSessions(resolved.store, stageId, resolved.learnerKey);
runtimeReadSucceeded = true;
if (options.observe !== false) {
rememberObservedIds(
resolved.store,
queueKey,
loaded.map((session) => session.id),
);
rememberObservedSessions(resolved.store, queueKey, loaded);
}
reportSnapshot(options, loaded, restoreMarker);
return loaded;
}
const migrated = await syncSessions(
resolved.store,
stageId,
resolved.learnerKey,
legacy,
false,
isolatedWrites,
undefined,
undefined,
beforeLoad,
resolved.sleep,
);
runtimeReadSucceeded = true;
if (options.observe !== false) {
rememberObservedIds(
resolved.store,
queueKey,
migrated.map((session) => session.id),
);
rememberObservedSessions(resolved.store, queueKey, migrated);
}
if (conversion.skippedRows.length === 0) await resolved.legacyStore.clear(stageId);
reportSnapshot(options, migrated, restoreMarker);
return migrated;
},
);
} catch (error) {
if (error instanceof ChatStorageLockUnavailableError) {
// No-lock environments cannot safely migrate or clear the shared legacy
// table, but a read-only legacy snapshot keeps pre-cutover history
// visible. Strict callers such as backup export still fail loud.
if (options.fallbackToLegacyOnError === false) throw error;
const conversion = normalizeLegacyConversion(await resolved.legacyStore.load(stageId));
const readOnlyLegacy = conversion.sessions;
rememberSkippedLegacyRows(resolved.store, queueKey, conversion.skippedRows);
warnSkippedLegacyRows(stageId, conversion.skippedRows);
if (readOnlyLegacy.length === 0) throw error;
if (options.observe !== false) {
rememberObservedIds(
resolved.store,
queueKey,
readOnlyLegacy.map((session) => session.id),
);
rememberObservedSessions(resolved.store, queueKey, readOnlyLegacy);
}
reportSnapshot(options, readOnlyLegacy, undefined);
console.warn(`Loaded legacy chat sessions without migration for stage ${stageId}:`, error);
return readOnlyLegacy;
}
// A failed runtime read is not an authoritative empty snapshot. Forget the
// prior observation so a later stage save cannot retire unseen data. A
// legacy-clear failure happens after migration succeeded, so retain it.
if (options.observe !== false && !runtimeReadSucceeded) {
rememberObservedIds(resolved.store, queueKey, []);
rememberObservedSessions(resolved.store, queueKey, []);
} else if (options.observe !== false) {
// The fallback returns only the legacy rows. Runtime-only sessions that
// were discovered during sync were not exposed to the caller, so their
// omission from the next UI snapshot must not be treated as deletion.
rememberObservedIds(
resolved.store,
queueKey,
legacy.map((session) => session.id),
);
rememberObservedSessions(resolved.store, queueKey, legacy);
}
if (options.fallbackToLegacyOnError === false) throw error;
if (legacy.length === 0) throw error;
reportSnapshot(options, legacy, runtimeReadSucceeded ? readRestoreMarker : undefined);
console.warn(`Failed to migrate chat sessions for stage ${stageId}:`, error);
return legacy;
}
}
/** Remove this learner's runtime chat partition before restoring a backup. */
export async function clearRuntimeChatSessions(
stageId: string,
options: ChatStorageOptions = {},
): Promise<void> {
const resolved = await context(options);
const queueKey = `${stageId}\0${resolved.learnerKey}`;
await enqueue(resolved.store, queueKey, stageId, resolved.requiresCrossRealmLock, async () => {
await clearRuntimeChatSessionsUnlocked(resolved.store, stageId, resolved.learnerKey, queueKey);
});
}
async function clearRuntimeChatSessionsUnlocked(
store: RuntimeStore,
stageId: string,
learnerKey: string,
queueKey: string,
): Promise<void> {
const views = await runtimeViews(store, stageId, learnerKey);
await Promise.all(views.map((view) => store.deleteSession(view.runtimeSession.id)));
rememberObservedIds(store, queueKey, []);
rememberObservedSessions(store, queueKey, []);
}
/** Stage legacy backup rows and clear their runtime partitions under the same locks. */
export async function restoreChatSessionsFromBackup(
stageIds: string[],
restoreLegacyRows: () => Promise<void>,
options: ChatStorageRestoreOptions = {},
): Promise<void> {
const resolved = await context(options);
const orderedStageIds = [...new Set(stageIds)].sort();
const queueKeys = orderedStageIds.map((stageId) => `${stageId}\0${resolved.learnerKey}`);
// Snapshot only work that predates this restore. A later save may be queued
// behind an exclusive maintenance request; awaiting it while holding the
// shared global lock would create a cycle (restore -> save -> maintenance -> restore).
const existingQueues = storeQueues.get(resolved.store);
const precedingWrites = queueKeys
.map((queueKey) => existingQueues?.get(queueKey))
.filter((pending): pending is Promise<void> => pending !== undefined);
async function withStageLock(index: number, isolatedWrites = false): Promise<void> {
if (index < orderedStageIds.length) {
const stageId = orderedStageIds[index]!;
const queueKey = queueKeys[index]!;
await withPartitionLocks(stageId, queueKey, resolved.requiresCrossRealmLock, (isolated) =>
withStageLock(index + 1, isolatedWrites || isolated),
);
return;
}
const existingByStage = new Map<string, ChatRuntimeView[]>();
for (const stageId of orderedStageIds) {
existingByStage.set(
stageId,
await runtimeViews(resolved.store, stageId, resolved.learnerKey),
);
}
await restoreLegacyRows();
const restoredByStage = new Map<string, ChatSession[]>();
const invalidRestoreRows: string[] = [];
for (const stageId of orderedStageIds) {
const conversion = normalizeLegacyConversion(await resolved.legacyStore.load(stageId));
restoredByStage.set(stageId, conversion.sessions);
if (conversion.skippedRows.length > 0) {
invalidRestoreRows.push(
`${JSON.stringify(stageId)}: ${legacyRowLabels(conversion.skippedRows)}`,
);
}
}
if (invalidRestoreRows.length > 0) {
await options.rollbackLegacyRows?.();
throw new Error(
`Cannot restore malformed legacy chat rows (${invalidRestoreRows.join('; ')})`,
);
}
const restoreMarkers: RuntimeSession[] = [];
try {
for (const stageId of orderedStageIds) {
restoreMarkers.push(
await createRestoreMarker(
resolved.store,
stageId,
resolved.learnerKey,
(existingByStage.get(stageId) ?? []).map((view) => view.runtimeSession.id),
),
);
}
} catch (error) {
await Promise.allSettled(
restoreMarkers.map((marker) => resolved.store.deleteSession(marker.id)),
);
await options.rollbackLegacyRows?.();
throw error;
}
for (const stageId of orderedStageIds) {
await Promise.all(
(existingByStage.get(stageId) ?? []).map((view) =>
resolved.store.deleteSession(view.runtimeSession.id),
),
);
}
for (const stageId of orderedStageIds) {
const marker = restoreMarkers.find((candidate) => candidate.stageId === stageId)!;
await finalizeRestoreMarker(resolved.store, marker);
const beforeMigration = await runtimeViews(resolved.store, stageId, resolved.learnerKey);
const restored = restoredByStage.get(stageId)!;
await syncSessions(
resolved.store,
stageId,
resolved.learnerKey,
restored,
false,
isolatedWrites,
undefined,
undefined,
beforeMigration,
resolved.sleep,
);
await resolved.legacyStore.clear(stageId);
}
}
const restoreAfterPrecedingWrites = async (): Promise<void> => {
await Promise.all(precedingWrites);
await withStageLock(0);
};
if (options.globalLockHeld) {
await restoreAfterPrecedingWrites();
return;
}
if (typeof navigator !== 'undefined' && navigator.locks) {
// The queue snapshot and shared-lock request are synchronous with respect
// to other JavaScript tasks. New saves either join this shared epoch and
// coordinate on partition locks, or wait behind a later exclusive request.
await withChatStorageSharedLock(restoreAfterPrecedingWrites);
return;
}
if (resolved.requiresCrossRealmLock) {
throw new ChatStorageLockUnavailableError(
'Chat storage requires the Web Locks API in this browser',
);
}
// Without Web Locks, serialize the whole restore against same-realm writers.
await Promise.all(precedingWrites);
await withChatStorageExclusiveLock(() => withStageLock(0));
}
/** Clear the legacy table during stage deletion; RuntimeStore cascades separately. */
export async function deleteChatSessions(stageId: string): Promise<void> {
await dexieLegacyStore.clear(stageId);
}