Files
openmaic/OpenMAIC/lib/document-store/migration.ts
2026-08-16 14:58:47 +08:00

411 lines
15 KiB
TypeScript

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<LegacyDocumentSnapshot | null>;
listStages(): Promise<StageRecord[]>;
}
export interface DocumentMigrationDeps {
store?: DocumentStore<AppScene, AppStage>;
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<AppScene, AppStage> {
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<T>(
stageId: string,
work: () => Promise<T>,
deps: Pick<DocumentMigrationDeps, 'lockManager'> = {},
): Promise<T> {
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<DocumentMigrationDeps, 'legacyStore'> = {},
): 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<string>();
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<void> {
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<void> {
const markerKey = `${MARKER_PREFIX}${snapshot.stage.id}`;
if (await kv.get<MigrationMarker>(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<DocumentAccessResult> {
// 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<MigrationMarker>(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<AppScene, AppStage> {
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<unknown>;
const guarded = async (...args: unknown[]): Promise<unknown> => {
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<DocumentAccessResult> {
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<T>(
stageId: string,
work: (document: AppDocument | null, store: DocumentStore<AppScene, AppStage>) => Promise<T>,
deps: DocumentMigrationDeps = {},
): Promise<T> {
const entryGeneration = readGeneration(deps.kv);
const mutateLocked = async (): Promise<T> => {
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 };