import type { Document, Id, Registry } from '../canvas-core/types.ts'; import { createEditor, type EditorOptions } from '../canvas-core/editor.ts'; import { createDocument } from '../canvas-core/document.ts'; import { createSessions } from '../canvas-core/gestures.ts'; import { equal, fault, freeze, readMap } from '../canvas-core/values.ts'; import type { EngineSnapshot, PersistencePort, RuntimeState } from './contracts.ts'; import { createLedger, type FrozenRequest } from './ledger.ts'; import { operationCanvases, replayPending } from './reconcile.ts'; import { decodeRecovery, encodeRecovery } from './recovery.ts'; import { createScheduler, type Clock, type OperationStatus } from './scheduler.ts'; import type { StoragePort, StorageScope } from './storage.ts'; export type EngineOptions = { scope: StorageScope; registry: Registry; document: Document; versions: ReadonlyMap; storage: StoragePort; persistence: PersistencePort; createId?: () => Id; historyLimit?: number; policy?: EditorOptions['policy']; clock?: Clock; onError?: (error: unknown) => void; }; /** One project owner. Detaching a view does not close this engine or abort persistent work. */ export async function openProjectEngine(options: EngineOptions) { if (options.scope.projectId !== options.document.projectId) throw fault('project_mismatch'); const leader = await options.storage.claim(options.scope); try { const image = await options.storage.recover(leader), recovered = image ? decodeRecovery(image, options.registry) : undefined; if (recovered && recovered.document.projectId !== options.scope.projectId) throw fault('project_mismatch'); for (const canvas of options.document.canvases.keys()) if (!Number.isSafeInteger(options.versions.get(canvas)) || options.versions.get(canvas)! < 1) throw fault('missing_server_version', [canvas]); let closing = false, closed = false, durableRootRevision = image?.rootRevision ?? -1; const report = (error: unknown) => { try { options.onError?.(error); } catch { /* Error reporting cannot alter state. */ } }; const initial: RuntimeState = recovered?.runtime ?? Object.freeze({ acknowledged: createDocument(options.document.projectId, [...options.document.canvases.values()], [...options.document.elements.values()], options.document.tombstones), versions: readMap(options.versions), ledger: createLedger().getSnapshot(), receipts: readMap(new Map()), conflicts: readMap(new Map()) }); const editor = createEditor({ registry: options.registry, document: options.document, recovery: recovered, runtime: initial, createId: options.createId, historyLimit: options.historyLimit, policy(command, document) { if (closing || closed) return { enabled: false, code: 'engine_closing', targets: [] }; return options.policy?.(command, document) ?? { enabled: true }; }, traversalPolicy() { return closing || closed ? { enabled: false, code: 'engine_closing', targets: [] } : { enabled: true }; }, onListenerError: report, reduceRuntime(previous, next, commit) { if (!commit.operationIds.length) return previous; const ledger = createLedger(previous.ledger); for (const id of commit.operationIds) { const intent = next.operations.get(id)!; // Last unresolved writer on every participating canvas preserves causal order. const last = new Map(); for (const record of [...previous.ledger.operations.values()].sort((a, b) => a.intent.sequence - b.intent.sequence)) if (record.phase !== 'resolved') for (const canvas of record.intent.canvasIds) last.set(canvas, record.intent.id); const dependencies = [...new Set([...intent.dependencies, ...intent.canvasIds.flatMap(canvas => last.has(canvas) ? [last.get(canvas)!] : [])])]; const basePresence: Record = {}; for (const change of intent.changes) if (change.kind === 'element') basePresence[change.id] = change.before ? 'exists' : 'absent'; else if (change.kind === 'field') basePresence[change.target.id] ??= 'exists'; ledger.add(freeze({ ...intent, dependencies }), basePresence); } return Object.freeze({ ...previous, ledger: ledger.getSnapshot() }); }, }); const pending = new Map(); if (!recovered) pending.set(editor.getSnapshot().rootRevision, editor.getSnapshot()); let writing: Promise | undefined, scheduled = false; function persist(): Promise { if (writing) return writing; const task = (async () => { while (pending.size) { const [revision, snapshot] = pending.entries().next().value!; const receipt = await options.storage.commit(leader, { image: encodeRecovery(snapshot) }); if (receipt.durableRootRevision !== revision) throw fault('invalid_durable_receipt'); durableRootRevision = revision; pending.delete(revision); if (editor.getSnapshot().runtime.ledger.durableGeneration < snapshot.runtime.ledger.generation) mutate(ledger => ledger.markDurable(snapshot.runtime.ledger.generation)); } })(); writing = task.finally(() => { writing = undefined; }); return writing; } function mutate(reduce: (ledger: ReturnType) => void) { const result = editor.updateRuntime(previous => { const ledger = createLedger(previous.ledger); reduce(ledger); return Object.freeze({ ...previous, ledger: ledger.getSnapshot() }); }); if (result.status === 'rejected') throw fault(result.code, result.targets); } function complete(request: FrozenRequest, status: OperationStatus): { readonly blocked: true } | void { const root = editor.getSnapshot(), previous = root.runtime, ledger = createLedger(previous.ledger); if (request.phase === 'resolved') return; if (status.outcome !== 'applied') { mutate(draft => draft.result(request.id, status.outcome, status.error)); return; } const receipts = new Map(previous.receipts), conflicts = new Map(previous.conflicts); if (status.canonical !== undefined) receipts.set(request.id, freeze(status.canonical)); try { const proposal = options.persistence.canonical(request, status, root); if (proposal.document.projectId !== root.document.projectId) throw fault('project_mismatch'); for (const canvas of request.canvasIds) { const version = proposal.versions.get(canvas); if (!version || version <= request.expectedVersions[canvas] || version < (previous.versions.get(canvas) ?? 0)) throw fault('stale_canonical_version', [canvas]); } // A codec cannot roll back an unrelated canvas while reconciling this request. for (const [canvas, version] of previous.versions) if (!request.canvasIds.includes(canvas)) { if (proposal.versions.get(canvas) !== version || !equal(proposal.document.canvases.get(canvas), previous.acknowledged.canvases.get(canvas)) || !equal([...proposal.document.elements.values()].filter(element => element.ownerCanvasId === canvas), [...previous.acknowledged.elements.values()].filter(element => element.ownerCanvasId === canvas))) throw fault('canonical_scope_mismatch', [canvas]); } ledger.result(request.id, 'applied'); const acknowledged = createDocument(proposal.document.projectId, [...proposal.document.canvases.values()], [...proposal.document.elements.values()], proposal.document.tombstones); const document = replayPending(acknowledged, ledger.getSnapshot(), options.registry); conflicts.delete(request.id); const runtime: RuntimeState = Object.freeze({ ledger: ledger.getSnapshot(), acknowledged, versions: readMap(proposal.versions), receipts: readMap(receipts), conflicts: readMap(conflicts) }); const result = editor.reconcile(document, runtime, root.rootRevision); if (result.status === 'rejected') throw fault(result.code, result.targets); } catch (error) { // Preserve both the known receipt and the user's working document. No rollback or lane release. const waiting = createLedger(previous.ledger); waiting.result(request.id, 'applied', (error as Error).message, false); conflicts.set(request.id, (error as Error).message || 'reconcile_conflict'); const result = editor.updateRuntime(() => Object.freeze({ ...previous, ledger: waiting.getSnapshot(), receipts: readMap(receipts), conflicts: readMap(conflicts) }), root.rootRevision); if (result.status === 'rejected') throw fault(result.code, result.targets); report(error); return { blocked: true }; } } const scheduler = createScheduler({ getLedger: () => editor.getSnapshot().runtime.ledger, mutate, persist, createId: options.createId ?? (() => crypto.randomUUID()), participants: intent => operationCanvases(intent, editor.getSnapshot().document), compile: (intent, id) => options.persistence.compile(intent, id, editor.getSnapshot()), submit: (request, signal) => options.persistence.submit(request, signal), query: (request, signal) => options.persistence.query(request, signal), supportsRequest: options.persistence.supportsRequest, complete, onError: report, clock: options.clock, }); const sessions = createSessions(editor); const unsubscribe = editor.subscribe((commit, snapshot) => { pending.set(snapshot.rootRevision, snapshot); if (!scheduled) { scheduled = true; queueMicrotask(() => { scheduled = false; void persist().catch(report); }); } // Intents already exist in this root; notification only schedules their IDs. queueMicrotask(() => { if (!closing && !closed) scheduler.notify(commit.operationIds); }); }); scheduler.start(); return Object.freeze({ editor, sessions, getSnapshot: editor.getSnapshot, persist, getDurability: () => Object.freeze({ rootRevision: editor.getSnapshot().rootRevision, durableRootRevision }), retry: () => scheduler.resume(), async settle() { await Promise.resolve(); await scheduler.settle(); await persist(); }, async close() { if (closed) return; closing = true; sessions.dispose(); scheduler.stop(); // Failure keeps the leader/recovery material available for an explicit retry of close. await persist(); unsubscribe(); editor.dispose(); await leader.release(); closed = true; }, }); } catch (error) { await leader.release(); throw error; } }