import { DSL_VERSION, migrate } from '@openmaic/dsl'; import { BrowserKVStore, type DocumentStore, type KVStore } from '@openmaic/storage'; import isEqual from 'lodash/isEqual'; import type { AppScene } from '@/lib/types/stage'; import { createLogger } from '@/lib/logger'; import { omitUndefinedObjectMembers } from '@/lib/persistence/plain-json'; import { withRuntimeStorageSharedLock } from '@/lib/utils/chat-storage-lock'; import { db, type SceneRecord, type StageOutlinesRecord, type StageRecord, } from '@/lib/utils/database'; import { canonicalizeLegacyOutline, canonicalizeLegacyScene, canonicalizeLegacyStage, } from './canonicalize'; import { loadCurrentSceneValue, saveCurrentSceneValue, type CurrentSceneValue, } from './current-scene'; import type { AppDocument, AppDocumentOutline, AppStage } from './persistence-types'; import { readGeneration } from './storage-generation'; import { getDocumentStore } from './store'; import { validateAppScene, validateAppStage } from './validators'; export interface LegacyDocumentSnapshot { stage: StageRecord; scenes: SceneRecord[]; outline?: StageOutlinesRecord; } export interface LegacyDocumentStore { read(stageId: string): Promise; listStages(): Promise; } export interface DocumentMigrationDeps { store?: DocumentStore; kv?: KVStore; legacyStore?: LegacyDocumentStore; lockManager?: LockManager | null; migrateDsl?: (document: unknown) => unknown; /** The mutation callback acquires the runtime shared epoch before writing. */ storageSharedLockHeld?: boolean; } export interface DocumentAccessResult { document: AppDocument | null; legacyCurrentSceneId?: string; readOnlyLegacy: boolean; } interface MigrationMarker { sourceUpdatedAt: number; sourceHash: string; migratedAt: string; } export class DocumentLockUnavailableError extends Error {} export class DocumentStorageGenerationChangedError extends Error { constructor(stageId: string) { super( `Document ${JSON.stringify(stageId)} was not saved because storage was cleared during the mutation`, ); this.name = 'DocumentStorageGenerationChangedError'; } } const MARKER_PREFIX = 'document-migration:'; let defaultKv: KVStore | undefined; const log = createLogger('DocumentMigration'); function resolveStore(deps: DocumentMigrationDeps): DocumentStore { return deps.store ? getDocumentStore({ store: deps.store }) : getDocumentStore(); } function resolveKv(deps: DocumentMigrationDeps): KVStore { if (deps.kv) return deps.kv; if (typeof localStorage === 'undefined') throw new Error('Document migration KV requires localStorage (client-only)'); return (defaultKv ??= new BrowserKVStore()); } function resolveLocks(deps: DocumentMigrationDeps): LockManager | undefined { if (deps.lockManager === null) return undefined; return deps.lockManager ?? (typeof navigator !== 'undefined' ? navigator.locks : undefined); } export function documentLockName(stageId: string): string { return `openmaic:document:${encodeURIComponent(stageId)}`; } /** Cross-realm serialization for migration and aggregate read-modify-write. */ export async function withDocumentLock( stageId: string, work: () => Promise, deps: Pick = {}, ): Promise { const locks = resolveLocks(deps); if (locks) { return await locks.request(documentLockName(stageId), { mode: 'exclusive' }, async () => work(), ); } throw new DocumentLockUnavailableError( `Web Locks are required to mutate document ${JSON.stringify(stageId)}`, ); } function defaultLegacyStore(): LegacyDocumentStore { return { async read(stageId) { // Dexie disables auto-open after db.delete(). A migration that was // queued behind clearDatabase must reopen the now-empty legacy database // so it observes a missing source instead of surfacing DatabaseClosedError. if (!db.isOpen()) await db.open(); return db.transaction('r', [db.stages, db.scenes, db.stageOutlines], async () => { const [stage, scenes, outline] = await Promise.all([ db.stages.get(stageId), db.scenes.where('stageId').equals(stageId).sortBy('order'), db.stageOutlines.get(stageId), ]); return stage ? { stage, scenes, outline } : null; }); }, async listStages() { if (!db.isOpen()) await db.open(); return db.stages.toArray(); }, }; } export function getLegacyDocumentStore( deps: Pick = {}, ): LegacyDocumentStore { return deps.legacyStore ?? defaultLegacyStore(); } function canonicalize(snapshot: LegacyDocumentSnapshot): AppDocument { const { stage } = canonicalizeLegacyStage(snapshot.stage); const scenes = snapshot.scenes.map(canonicalizeLegacyScene).sort((a, b) => a.order - b.order); const document: AppDocument = { stage, scenes }; if (snapshot.outline) document.outline = canonicalizeLegacyOutline(snapshot.outline); return document; } function assertValidDestination(stageId: string, document: AppDocument): void { if (document.dslVersion !== DSL_VERSION) { throw new Error( `Document ${JSON.stringify(stageId)} has unsupported DSL version ${JSON.stringify(document.dslVersion)}`, ); } if (document.stage.id !== stageId) throw new Error(`Document ${JSON.stringify(stageId)} has a mismatched stage id`); const stageValidation = validateAppStage(document.stage); if (!stageValidation.valid) throw new Error(`Document ${JSON.stringify(stageId)} has an invalid stage`); const ids = new Set(); for (const scene of document.scenes) { const validation = validateAppScene(scene); if (!validation.valid || scene.stageId !== stageId || ids.has(scene.id)) { throw new Error( `Document ${JSON.stringify(stageId)} has an invalid scene ${JSON.stringify(scene.id)}`, ); } ids.add(scene.id); } } /** * Migrate a document forward to the current DSL version, leaving the opaque, * app-owned `outline` untouched. The outline is not DSL-shaped, so migrations * must never see it — splitting it out here makes the "outline is not migrated" * contract literally true and stops a future migration transform from silently * dropping the snapshot while rebuilding the aggregate. Throws (via `migrate`) * if the document's version has no path up the ladder. * * Scenes (including an app-widened union) *do* stay inside the migrated core: * the DSL owns the scene contract, so its migrations are the authority on scene * shape and are expected to key on `scene.type` and pass app kinds through * untouched. This is the deliberate asymmetry with `outline`, which the DSL owns * no contract for at all. * This mirrors `@openmaic/storage`'s private `migrateDocument` in * `document/browser.ts`; changes must be kept in sync. */ export function migrateDocumentForVerification( document: AppDocument, migrateDsl: (document: unknown) => unknown = migrate, ): AppDocument { const { outline, ...core } = document; const migrated = migrateDsl(core) as AppDocument; return outline === undefined ? migrated : { ...migrated, outline }; } function assertMigrationVerified( expected: AppDocument, actual: AppDocument, migrateDsl: (document: unknown) => unknown = migrate, ): void { assertValidDestination(expected.stage.id, actual); const migratedExpected = migrateDocumentForVerification(expected, migrateDsl); const strip = (document: AppDocument) => ({ stage: document.stage, scenes: [...document.scenes].sort((a, b) => a.order - b.order), outline: document.outline, }); if ( !isEqual( omitUndefinedObjectMembers(strip(actual)), omitUndefinedObjectMembers(strip(migratedExpected)), ) ) { throw new Error( `Legacy migration verification failed for document ${JSON.stringify(expected.stage.id)}`, ); } } function sourceHash(snapshot: LegacyDocumentSnapshot): string { const text = JSON.stringify(snapshot); let hash = 0x811c9dc5; for (let i = 0; i < text.length; i += 1) { hash ^= text.charCodeAt(i); hash = Math.imul(hash, 0x01000193); } return (hash >>> 0).toString(16).padStart(8, '0'); } async function migrateCurrentScene(snapshot: LegacyDocumentSnapshot, kv: KVStore): Promise { if (!snapshot.stage.currentSceneId) return; const existing = await loadCurrentSceneValue(snapshot.stage.id, kv); const sourceTime = snapshot.stage.updatedAt; if (existing && Date.parse(existing.updatedAt) > sourceTime) return; const value: CurrentSceneValue = { sceneId: snapshot.stage.currentSceneId, updatedAt: new Date(sourceTime).toISOString(), }; await saveCurrentSceneValue(snapshot.stage.id, value, kv); } async function finishMigrationMetadata( snapshot: LegacyDocumentSnapshot, kv: KVStore, ): Promise { const markerKey = `${MARKER_PREFIX}${snapshot.stage.id}`; if (await kv.get(markerKey, 'device')) return; await migrateCurrentScene(snapshot, kv); const marker: MigrationMarker = { sourceUpdatedAt: snapshot.stage.updatedAt, sourceHash: sourceHash(snapshot), migratedAt: new Date().toISOString(), }; await kv.set(markerKey, marker, 'device'); } async function migrateLocked( stageId: string, deps: DocumentMigrationDeps, expectedGeneration: number, ): Promise { // Lock order: the per-stage document lock is acquired by the caller before // this global shared epoch. This matches the established per-stage -> global // order and prevents clearDatabase from interleaving with migration commit. return withRuntimeStorageSharedLock(async () => { const store = resolveStore(deps); const existing = await store.loadDocument(stageId); if (existing) { assertValidDestination(stageId, existing); const snapshot = await getLegacyDocumentStore(deps).read(stageId); if (snapshot) { const kv = resolveKv(deps); const markerKey = `${MARKER_PREFIX}${stageId}`; if (!(await kv.get(markerKey, 'device'))) { try { assertMigrationVerified(canonicalize(snapshot), existing, deps.migrateDsl); } catch (error) { log.warn( `Legacy snapshot diverges from authoritative destination for stage ${stageId}; migration marker was not written`, error, ); return { document: existing, readOnlyLegacy: false }; } } await finishMigrationMetadata(snapshot, kv); } return { document: existing, readOnlyLegacy: false }; } const snapshot = await getLegacyDocumentStore(deps).read(stageId); if (!snapshot) return { document: null, readOnlyLegacy: false }; const expected = canonicalize(snapshot); if ((await readGeneration(deps.kv)) !== expectedGeneration) { throw new DocumentStorageGenerationChangedError(stageId); } await store.saveDocument(expected); const actual = await store.loadDocument(stageId); if (!actual) throw new Error(`Legacy migration lost document ${JSON.stringify(stageId)}`); assertMigrationVerified(expected, actual, deps.migrateDsl); await finishMigrationMetadata(snapshot, resolveKv(deps)); return { document: actual, readOnlyLegacy: false }; }); } function generationGuardedStore( stageId: string, expectedGeneration: number, deps: DocumentMigrationDeps, ): DocumentStore { const store = resolveStore(deps); // Every mutating method takes the fence, not just saveDocument: the others // are not currently exploitable after a clear (puts throw on the missing // parent, deletes are idempotent), but that safety is incidental — a future // store method or semantic change must not silently bypass the fence. const MUTATING_METHODS = new Set([ 'saveDocument', 'putStage', 'putScene', 'deleteScene', 'deleteDocument', ]); return new Proxy(store, { get(target, property) { if (typeof property === 'string' && MUTATING_METHODS.has(property)) { const method = Reflect.get(target, property, target) as ( ...args: unknown[] ) => Promise; const guarded = async (...args: unknown[]): Promise => { if ((await readGeneration(deps.kv)) !== expectedGeneration) { throw new DocumentStorageGenerationChangedError(stageId); } return method.apply(target, args); }; return deps.storageSharedLockHeld ? guarded : (...args: unknown[]) => withRuntimeStorageSharedLock(() => guarded(...args)); } const value = Reflect.get(target, property, target) as unknown; return typeof value === 'function' ? value.bind(target) : value; }, }); } /** Load the authoritative destination, lazily migrating one coherent legacy snapshot. */ export async function accessDocument( stageId: string, deps: DocumentMigrationDeps = {}, ): Promise { try { return await withDocumentLock( stageId, async () => migrateLocked(stageId, deps, await readGeneration(deps.kv)), deps, ); } catch (error) { if (!(error instanceof DocumentLockUnavailableError)) throw error; const destination = await resolveStore(deps).loadDocument(stageId); if (destination) { assertValidDestination(stageId, destination); return { document: destination, readOnlyLegacy: false }; } const snapshot = await getLegacyDocumentStore(deps).read(stageId); if (!snapshot) return { document: null, readOnlyLegacy: false }; return { document: canonicalize(snapshot), legacyCurrentSceneId: snapshot.stage.currentSceneId, readOnlyLegacy: true, }; } } /** Aggregate mutation entry point; migration and the caller's RMW share one lock. */ export function mutateDocument( stageId: string, work: (document: AppDocument | null, store: DocumentStore) => Promise, deps: DocumentMigrationDeps = {}, ): Promise { const entryGeneration = readGeneration(deps.kv); const mutateLocked = async (): Promise => { const expectedGeneration = await entryGeneration; const access = await migrateLocked(stageId, deps, expectedGeneration); return work(access.document, generationGuardedStore(stageId, expectedGeneration, deps)); }; return withDocumentLock(stageId, mutateLocked, deps).catch(async (error: unknown) => { if (!(error instanceof DocumentLockUnavailableError)) throw error; // Migration is never attempted without cross-realm exclusion. A // destination-backed document (or a genuinely new id) can still accept // the product's established lock-free/LWW risk; a legacy-only document // stays read-only so two authorities cannot fork. const store = resolveStore(deps); const destination = await store.loadDocument(stageId); if (destination) { assertValidDestination(stageId, destination); const expectedGeneration = await entryGeneration; return work(destination, generationGuardedStore(stageId, expectedGeneration, deps)); } if (await getLegacyDocumentStore(deps).read(stageId)) throw error; const expectedGeneration = await entryGeneration; return work(null, generationGuardedStore(stageId, expectedGeneration, deps)); }); } export type { AppDocumentOutline };