Add durable canvas runtime, scheduling and crash recovery

This commit is contained in:
2026-11-07 22:01:42 +08:00
parent 7b4d4c0ad3
commit 10374c3696
14 changed files with 1250 additions and 1 deletions
+21
View File
@@ -0,0 +1,21 @@
import type { Document, Id, Json, OperationIntent, Snapshot } from '../canvas-core/types.ts';
import type { LedgerSnapshot, FrozenRequest } from './ledger.ts';
import type { CompiledRequest, OperationStatus } from './scheduler.ts';
export type RuntimeState = {
readonly ledger: LedgerSnapshot;
readonly acknowledged: Document;
readonly versions: ReadonlyMap<Id, number>;
readonly receipts: ReadonlyMap<Id, Json>;
readonly conflicts: ReadonlyMap<Id, string>;
};
export type EngineSnapshot = Snapshot<RuntimeState>;
export type CanonicalProposal = { readonly document: Document; readonly versions: ReadonlyMap<Id, number> };
export type PersistencePort = {
supportsRequest?: (request: FrozenRequest) => boolean;
compile(intent: OperationIntent, requestId: Id, snapshot: EngineSnapshot): CompiledRequest;
submit(request: FrozenRequest, signal: AbortSignal): Promise<OperationStatus>;
query(request: FrozenRequest, signal: AbortSignal): Promise<OperationStatus>;
/** Pure codec, merging only participants into the latest base, never an old working snapshot. */
canonical(request: FrozenRequest, status: OperationStatus, snapshot: EngineSnapshot): CanonicalProposal;
};
+132
View File
@@ -0,0 +1,132 @@
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<Id, number>;
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<Id, Id>();
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<Id, 'absent' | 'exists'> = {};
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<number, EngineSnapshot>();
if (!recovered) pending.set(editor.getSnapshot().rootRevision, editor.getSnapshot());
let writing: Promise<void> | undefined, scheduled = false;
function persist(): Promise<void> {
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<typeof createLedger>) => 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; }
}
+108
View File
@@ -0,0 +1,108 @@
import type { Change, Id, Json, OperationIntent } from '../canvas-core/types.ts';
import { clone, equal, fault, freeze, readMap, validJson } from '../canvas-core/values.ts';
export type Presence = 'absent' | 'exists' | 'unknown';
export type RequestPhase = 'frozen' | 'dispatch-marked' | 'waiting' | 'resolved';
export type Outcome = 'unresolved' | 'accepted' | 'applied' | 'not-applied';
export type FrozenRequest = {
readonly id: Id; readonly protocolVersion: number; readonly generation: number; readonly body: string;
readonly operationIds: readonly Id[]; readonly canvasIds: readonly Id[]; readonly expectedVersions: Readonly<Record<Id, number>>;
readonly phase: RequestPhase; readonly outcome: Outcome; readonly error?: string; readonly attempt: number;
readonly retryAt?: number; readonly retryAction?: 'submit' | 'query';
};
export type OperationRecord = { readonly intent: OperationIntent; readonly phase: 'pending' | 'frozen' | 'resolved' | 'blocked'; readonly requestId?: Id; readonly blockers: readonly string[]; readonly basePresence: Readonly<Record<Id, Presence>> };
export type LedgerSnapshot = { readonly generation: number; readonly durableGeneration: number; readonly operations: ReadonlyMap<Id, OperationRecord>; readonly requests: ReadonlyMap<Id, FrozenRequest> };
/** A field-safe compressor for a wholly unsent segment. Frozen records never enter here. */
export function compressChanges(changes: readonly Change[]): readonly Change[] {
const result: Change[] = [];
for (const change of changes) {
const id = change.kind === 'field' ? change.target.id : change.id;
const previousIndex = result.findLastIndex(item => (item.kind === 'field' ? item.target.id : item.id) === id);
const previous = result[previousIndex];
if (previous?.kind === 'element' && previous.after && !previous.before && change.kind === 'field') {
// Reuse the same controlled field application semantics; no full-node stale overwrite.
const after = clone(previous.after);
if (change.target.incarnation !== after.incarnation) throw fault('stale_incarnation', [id]);
if (change.field.startsWith('data.')) {
const key = change.field.slice(5), data = { ...after.data };
if (change.afterPresent === false) delete data[key]; else data[key] = clone(change.after);
result[previousIndex] = freeze({ ...previous, after: { ...after, data } });
} else result[previousIndex] = freeze({ ...previous, after: { ...after, [change.field]: clone(change.after) } }) as Change;
} else if (previous?.kind === 'element' && previous.after && !previous.before && change.kind === 'element' && !change.after && equal(previous.after, change.before)) {
result.splice(previousIndex, 1);
} else if (previous?.kind === 'field' && change.kind === 'field' && previous.field === change.field && previous.target.incarnation === change.target.incarnation && equal(previous.after, change.before) && previous.afterPresent === change.beforePresent) {
const merged = freeze({ ...previous, after: change.after, afterPresent: change.afterPresent });
if (equal(merged.before, merged.after) && merged.beforePresent === merged.afterPresent) result.splice(previousIndex, 1); else result[previousIndex] = merged;
} else result.push(change);
}
return freeze(result);
}
export function createLedger(initial?: LedgerSnapshot) {
let generation = initial?.generation ?? 0, durableGeneration = initial?.durableGeneration ?? 0;
const operations = new Map(initial?.operations), requests = new Map(initial?.requests);
let cached: LedgerSnapshot;
const refresh = () => { cached = Object.freeze({ generation, durableGeneration, operations: readMap(operations), requests: readMap(requests) }); };
refresh();
function changed() { generation++; refresh(); }
function requireRequest(id: Id) { const request = requests.get(id); if (!request) throw fault('request_missing', [id]); return request; }
function assertAcyclic(intent: OperationIntent) {
const visiting = new Set<Id>();
const visit = (id: Id): void => {
if (id === intent.id || visiting.has(id)) throw fault('dependency_cycle', [id]);
const operation = operations.get(id);
if (!operation) throw fault('dependency_missing', [id]);
visiting.add(id); for (const dependency of operation.intent.dependencies) visit(dependency); visiting.delete(id);
};
for (const id of intent.dependencies) visit(id);
}
return Object.freeze({
getSnapshot: () => cached,
add(intent: OperationIntent, basePresence: Readonly<Record<Id, Presence>>) {
if (!intent.id || !Number.isSafeInteger(intent.sequence) || intent.sequence < 1 || new Set(intent.dependencies).size !== intent.dependencies.length || Object.values(basePresence).some(value => !['absent', 'exists', 'unknown'].includes(value))) throw fault('invalid_operation');
const existing = operations.get(intent.id);
if (existing) { if (!equal(existing.intent, intent)) throw fault('operation_identity_conflict', [intent.id]); return; }
assertAcyclic(intent);
operations.set(intent.id, freeze({ intent: clone(intent), phase: 'pending', blockers: [], basePresence: { ...basePresence } })); changed();
},
eligible(id: Id) {
const operation = operations.get(id);
return !!operation && operation.phase === 'pending' && !operation.blockers.length && operation.intent.dependencies.every(dependency => operations.get(dependency)?.phase === 'resolved');
},
block(id: Id, reasons: readonly string[]) { const operation = operations.get(id); if (!operation || !['pending', 'blocked'].includes(operation.phase)) return; operations.set(id, freeze({ ...operation, phase: reasons.length ? 'blocked' : 'pending', blockers: [...reasons] })); changed(); },
freeze(input: { id: Id; operationIds: readonly Id[]; canvasIds: readonly Id[]; expectedVersions: Readonly<Record<Id, number>>; payload: Json }) {
if (!input.id || requests.has(input.id) || !input.operationIds.length || !input.canvasIds.length || !validJson(input.payload) || new Set(input.operationIds).size !== input.operationIds.length || new Set(input.canvasIds).size !== input.canvasIds.length || Object.keys(input.expectedVersions).length !== input.canvasIds.length || input.canvasIds.some(id => !id || !Number.isSafeInteger(input.expectedVersions[id]) || input.expectedVersions[id] < 1)) throw fault('invalid_frozen_request');
for (const id of input.operationIds) {
const operation = operations.get(id);
if (!operation || operation.phase !== 'pending' || operation.blockers.length || operation.intent.dependencies.some(dependency => operations.get(dependency)?.phase !== 'resolved' && !input.operationIds.includes(dependency))) throw fault('operation_not_ready', [id]);
}
const request: FrozenRequest = freeze({ id: input.id, protocolVersion: 1, generation: generation + 1, body: JSON.stringify(input.payload), operationIds: [...input.operationIds], canvasIds: [...input.canvasIds].sort(), expectedVersions: { ...input.expectedVersions }, phase: 'frozen', outcome: 'unresolved', attempt: 0 });
requests.set(request.id, request);
for (const id of input.operationIds) operations.set(id, freeze({ ...operations.get(id)!, phase: 'frozen', requestId: request.id }));
changed(); return request;
},
markDispatch(id: Id) {
const request = requireRequest(id);
if (request.phase === 'resolved') throw fault('request_resolved', [id]);
const next: FrozenRequest = freeze({ ...request, phase: 'dispatch-marked', attempt: request.attempt + 1, retryAt: undefined, retryAction: undefined }); requests.set(id, next); changed(); return next;
},
result(id: Id, outcome: Outcome, error?: string, reconciled = true) {
const request = requireRequest(id);
if ((request.phase === 'resolved' || request.outcome === 'applied') && request.outcome !== outcome) throw fault('receipt_conflict', [id]);
const phase = (outcome === 'applied' && reconciled) || outcome === 'not-applied' ? 'resolved' : 'waiting';
requests.set(id, freeze({ ...request, phase, outcome, error, retryAt: undefined, retryAction: undefined }));
if (outcome === 'applied' && reconciled) for (const operationId of request.operationIds) operations.set(operationId, freeze({ ...operations.get(operationId)!, phase: 'resolved' }));
if (outcome === 'not-applied') for (const operationId of request.operationIds) operations.set(operationId, freeze({ ...operations.get(operationId)!, phase: 'blocked', blockers: [error || 'not_applied'] }));
changed();
},
scheduleRetry(id: Id, dueAt: number, action: 'submit' | 'query') {
const request = requireRequest(id);
if (request.phase === 'resolved' || !Number.isFinite(dueAt)) throw fault('invalid_retry', [id]);
requests.set(id, freeze({ ...request, retryAt: dueAt, retryAction: action })); changed();
},
blockRequest(id: Id, error: string) { const request = requireRequest(id); if (request.phase === 'resolved' || request.error === error) return; requests.set(id, freeze({ ...request, error, retryAt: undefined, retryAction: undefined })); changed(); },
markDurable(through: number) { if (!Number.isSafeInteger(through) || through < durableGeneration || through > generation) throw fault('invalid_durable_generation'); durableGeneration = through; refresh(); },
possiblyApplied(id: Id) { const request = requireRequest(id); return request.phase !== 'frozen' && request.outcome !== 'not-applied'; },
});
}
+51
View File
@@ -0,0 +1,51 @@
import type { Change, Document, Id, OperationIntent, Registry } from '../canvas-core/types.ts';
import { applyChanges, validateDocument } from '../canvas-core/document.ts';
import { equal, fault } from '../canvas-core/values.ts';
import type { LedgerSnapshot } from './ledger.ts';
/** The footprint follows ownership; geometry does not decide which canvas owns an object. */
export function operationCanvases(intent: OperationIntent, document: Document): readonly Id[] {
if (intent.canvasIds?.length) return intent.canvasIds;
const ids = new Set<Id>();
for (const change of intent.changes) {
if (change.kind === 'canvas') {
ids.add(change.id);
if (change.before?.parent) ids.add(change.before.parent.canvasId);
if (change.after?.parent) ids.add(change.after.parent.canvasId);
} else if (change.kind === 'element') {
if (change.before) ids.add(change.before.ownerCanvasId);
if (change.after) ids.add(change.after.ownerCanvasId);
} else {
const owner = document.elements.get(change.target.id)?.ownerCanvasId;
if (owner) ids.add(owner);
if (change.field === 'ownerCanvasId') { ids.add(change.before as Id); ids.add(change.after as Id); }
}
}
if (!ids.size) throw fault('missing_participants', [intent.id]);
return [...ids].sort();
}
/** Replay once, by logical sequence. A receipt can already contain a desired field value. */
export function replayPending(base: Document, ledger: LedgerSnapshot, registry: Registry): Document {
let document = base;
for (const record of [...ledger.operations.values()].sort((a, b) => a.intent.sequence - b.intent.sequence)) {
if (record.phase === 'resolved') continue;
for (const change of record.intent.changes) {
if (satisfied(document, change)) continue;
document = applyChanges(document, [change]);
}
}
validateDocument(document, registry);
return document;
}
function satisfied(document: Document, change: Change) {
if (change.kind === 'canvas') return equal(document.canvases.get(change.id) ?? null, change.after);
if (change.kind === 'element') return equal(document.elements.get(change.id) ?? null, change.after);
const element = document.elements.get(change.target.id);
if (!element || element.incarnation !== change.target.incarnation) return false;
if (change.field.startsWith('data.')) {
const key = change.field.slice(5);
return Object.hasOwn(element.data, key) === (change.afterPresent ?? true) && equal(element.data[key] ?? null, change.after);
}
return equal((element as unknown as Record<string, unknown>)[change.field], change.after);
}
+112
View File
@@ -0,0 +1,112 @@
import type { Change, CoreSnapshot, Document, HistoryEntry, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts';
import { createDocument, validateDocument } from '../canvas-core/document.ts';
import { fault, freeze, readMap, validJson } from '../canvas-core/values.ts';
import type { EngineSnapshot, RuntimeState } from './contracts.ts';
import type { FrozenRequest, LedgerSnapshot, OperationRecord } from './ledger.ts';
import type { RecoveryImage } from './storage.ts';
const documentValue = (document: Document) => ({ projectId: document.projectId, canvases: [...document.canvases.values()], elements: [...document.elements.values()], tombstones: [...document.tombstones] });
/** Plain records only: functions, credentials, renderer objects, URLs and Map methods never serialize. */
export function encodeRecovery(snapshot: EngineSnapshot): RecoveryImage {
const payload = JSON.parse(JSON.stringify({
document: documentValue(snapshot.document),
history: { undo: snapshot.history.undo, redo: snapshot.history.redo, entries: [...snapshot.history.entries] },
operations: [...snapshot.operations],
runtime: { acknowledged: documentValue(snapshot.runtime.acknowledged), versions: [...snapshot.runtime.versions], receipts: [...snapshot.runtime.receipts], conflicts: [...snapshot.runtime.conflicts], ledger: { ...snapshot.runtime.ledger, operations: [...snapshot.runtime.ledger.operations], requests: [...snapshot.runtime.ledger.requests] } },
})) as Json;
if (!validJson(payload)) throw fault('invalid_recovery_payload');
return freeze({ formatVersion: 1, rootRevision: snapshot.rootRevision, localRevision: snapshot.revision, payload });
}
type ObjectValue = Record<string, unknown>;
function object(value: unknown): ObjectValue { if (!value || Array.isArray(value) || typeof value !== 'object') throw fault('corrupt_recovery_image'); return value as ObjectValue; }
function array(value: unknown): unknown[] { if (!Array.isArray(value)) throw fault('corrupt_recovery_image'); return value; }
function string(value: unknown): string { if (typeof value !== 'string' || !value) throw fault('corrupt_recovery_image'); return value; }
function integer(value: unknown, min = 0): number { if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < min) throw fault('corrupt_recovery_image'); return value; }
function strings(value: unknown): string[] { return array(value).map(string); }
function table<T>(value: unknown, decode: (entry: unknown, id: Id) => T): ReadonlyMap<Id, T> {
const result = new Map<Id, T>();
for (const item of array(value)) { const pair = array(item), id = string(pair[0]); if (pair.length !== 2 || result.has(id)) throw fault('corrupt_recovery_image'); result.set(id, decode(pair[1], id)); }
return readMap(result);
}
function readDocument(value: unknown, registry: Registry): Document {
const raw = object(value);
const tombstones = table(raw.tombstones, entry => integer(entry, 1));
const document = createDocument(string(raw.projectId), array(raw.canvases) as never, array(raw.elements) as never, tombstones);
validateDocument(document, registry); return document;
}
function changes(value: unknown): readonly Change[] {
for (const item of array(value)) {
const change = object(item);
if (change.kind === 'field') {
const target = object(change.target); string(target.id); integer(target.incarnation, 1);
const field = string(change.field);
if (!['geometry', 'ownerCanvasId', 'source', 'target', 'path'].includes(field) && (!field.startsWith('data.') || !field.slice(5))) throw fault('corrupt_recovery_image');
for (const flag of ['beforePresent', 'afterPresent']) if (change[flag] !== undefined && typeof change[flag] !== 'boolean') throw fault('corrupt_recovery_image');
} else if (change.kind === 'element' || change.kind === 'canvas') {
const id = string(change.id);
for (const key of ['before', 'after']) if (change[key] !== null && string(object(change[key]).id) !== id) throw fault('corrupt_recovery_image');
} else throw fault('corrupt_recovery_image');
if (!Object.hasOwn(change, 'before') || !Object.hasOwn(change, 'after')) throw fault('corrupt_recovery_image');
}
return freeze(value as Change[]);
}
function intent(value: unknown, id: Id): OperationIntent {
const raw = object(value);
if (string(raw.id) !== id) throw fault('corrupt_recovery_image');
string(raw.operationType); integer(raw.operationSchemaVersion, 1); integer(raw.sequence, 1); string(raw.origin);
strings(raw.canvasIds); strings(raw.dependencies); changes(raw.changes);
return freeze(value as OperationIntent);
}
export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSnapshot & { readonly rootRevision: number; readonly runtime: RuntimeState } {
if (image.formatVersion !== 1) throw fault('unsupported_recovery_version');
integer(image.rootRevision); integer(image.localRevision);
if (image.localRevision > image.rootRevision || !validJson(image.payload)) throw fault('corrupt_recovery_image');
// Own the parsed records; mutating a caller's RecoveryImage must not mutate live state.
const raw = object(JSON.parse(JSON.stringify(image.payload))), document = readDocument(raw.document, registry), history = object(raw.history), runtime = object(raw.runtime), ledger = object(runtime.ledger);
const operations = table(raw.operations, intent), historyEntries = table<HistoryEntry>(history.entries, (value, id) => {
const raw = object(value); if (string(raw.id) !== id || typeof raw.label !== 'string') throw fault('corrupt_recovery_image');
changes(raw.changes); for (const operationId of strings(raw.operationIds)) if (!operations.has(operationId)) throw fault('corrupt_recovery_image');
if (raw.groupId !== undefined) string(raw.groupId);
return freeze(value as HistoryEntry);
});
const undo = strings(history.undo), redo = strings(history.redo), stackIds = [...undo, ...redo];
if (new Set(stackIds).size !== stackIds.length || stackIds.length !== historyEntries.size || stackIds.some(id => !historyEntries.has(id))) throw fault('corrupt_recovery_image');
const records = table<OperationRecord>(ledger.operations, (value, id) => {
const raw = object(value); intent(raw.intent, id); strings(raw.blockers); object(raw.basePresence);
if (!operations.has(id) || !['pending', 'frozen', 'resolved', 'blocked'].includes(string(raw.phase))) throw fault('corrupt_recovery_image');
return freeze(value as OperationRecord);
});
if (operations.size !== records.size) throw fault('corrupt_recovery_image');
// Validate the dependency DAG iteratively, including missing dependencies, without recursion overflow.
const counts = new Map<Id, number>(), dependents = new Map<Id, Id[]>(), ready: Id[] = [];
for (const [id, record] of records) {
const dependencies = record.intent.dependencies;
if (new Set(dependencies).size !== dependencies.length) throw fault('corrupt_recovery_image');
counts.set(id, dependencies.length); if (!dependencies.length) ready.push(id);
for (const dependency of dependencies) { if (!records.has(dependency)) throw fault('corrupt_recovery_image'); dependents.set(dependency, [...(dependents.get(dependency) ?? []), id]); }
}
for (let index = 0; index < ready.length; index++) for (const id of dependents.get(ready[index]) ?? []) { counts.set(id, counts.get(id)! - 1); if (!counts.get(id)) ready.push(id); }
if (ready.length !== records.size) throw fault('corrupt_recovery_image');
const requests = table<FrozenRequest>(ledger.requests, (value, id) => {
const raw = object(value);
if (string(raw.id) !== id || typeof raw.body !== 'string') throw fault('corrupt_recovery_image');
integer(raw.protocolVersion, 1);
integer(raw.generation, 1); integer(raw.attempt); strings(raw.canvasIds); object(raw.expectedVersions);
if (!['frozen', 'dispatch-marked', 'waiting', 'resolved'].includes(string(raw.phase)) || !['unresolved', 'accepted', 'applied', 'not-applied'].includes(string(raw.outcome))) throw fault('corrupt_recovery_image');
for (const operationId of strings(raw.operationIds)) if (records.get(operationId)?.requestId !== id) throw fault('corrupt_recovery_image');
if (raw.retryAt !== undefined && (typeof raw.retryAt !== 'number' || !Number.isFinite(raw.retryAt))) throw fault('corrupt_recovery_image');
if (raw.retryAction !== undefined && !['submit', 'query'].includes(string(raw.retryAction))) throw fault('corrupt_recovery_image');
return freeze(value as FrozenRequest);
});
for (const record of records.values()) if (record.requestId && !requests.has(record.requestId) || record.phase === 'frozen' && !record.requestId) throw fault('corrupt_recovery_image');
const generation = integer(ledger.generation), durableGeneration = integer(ledger.durableGeneration);
if (durableGeneration > generation) throw fault('corrupt_recovery_image');
const acknowledged = readDocument(runtime.acknowledged, registry);
if (acknowledged.projectId !== document.projectId) throw fault('project_mismatch');
const versions = table(runtime.versions, value => integer(value, 1));
for (const id of acknowledged.canvases.keys()) if (!versions.has(id)) throw fault('corrupt_recovery_image');
const restoredLedger: LedgerSnapshot = Object.freeze({ generation, durableGeneration, operations: records, requests });
return Object.freeze({ revision: image.localRevision, rootRevision: image.rootRevision, document, history: Object.freeze({ undo: freeze(undo), redo: freeze(redo), entries: historyEntries }), operations,
runtime: Object.freeze({ ledger: restoredLedger, acknowledged, versions, receipts: table(runtime.receipts, value => freeze(value as Json)), conflicts: table(runtime.conflicts, string) }),
});
}
+184
View File
@@ -0,0 +1,184 @@
import type { Id, Json, OperationIntent } from '../canvas-core/types.ts';
import { fault, readMap } from '../canvas-core/values.ts';
import { createLedger, type FrozenRequest, type LedgerSnapshot, type Outcome } from './ledger.ts';
import { createDeque, createMinHeap } from './structures.ts';
export type OperationStatus = { readonly outcome: Outcome; readonly error?: string; readonly retryAfterMs?: number; readonly canonical?: Json };
export type CompiledRequest = { readonly payload: Json; readonly canvasIds: readonly Id[]; readonly expectedVersions: Readonly<Record<Id, number>> };
export type Clock = { now(): number; setTimer(callback: () => void, delay: number): unknown; clearTimer(handle: unknown): void };
const browserClock: Clock = { now: () => Date.now(), setTimer: (fn, delay) => setTimeout(fn, delay), clearTimer: handle => clearTimeout(handle as ReturnType<typeof setTimeout>) };
type Ledger = ReturnType<typeof createLedger>;
export type SchedulerOptions = {
getLedger(): LedgerSnapshot;
mutate(reduce: (ledger: Ledger) => void): void;
persist(): Promise<void>;
createId(): Id;
participants(intent: OperationIntent): readonly Id[];
compile(intent: OperationIntent, requestId: Id): CompiledRequest;
submit(request: FrozenRequest, signal: AbortSignal): Promise<OperationStatus>;
query(request: FrozenRequest, signal: AbortSignal): Promise<OperationStatus>;
supportsRequest?: (request: FrozenRequest) => boolean;
/** Atomically reconcile the receipt/base/working document and the ledger outcome. */
complete(request: FrozenRequest, status: OperationStatus): { readonly blocked: true } | void;
onError(error: unknown): void;
clock?: Clock;
concurrency?: number;
retryDelay?: (attempt: number) => number;
};
/** Queue/heap/claims are indexes only. Every authoritative transition lives in the ledger. */
export function createScheduler(options: SchedulerOptions) {
const clock = options.clock ?? browserClock, concurrency = options.concurrency ?? 2;
if (!Number.isSafeInteger(concurrency) || concurrency < 1) throw fault('invalid_concurrency');
const ready = createDeque<Id>(), queued = new Set<Id>(), claims = new Map<Id, Id>();
const waiters = new Map<Id, Set<Id>>(), dependents = new Map<Id, Set<Id>>(), indexed = new Set<Id>();
const retry = createMinHeap<{ id: Id; dueAt: number; sequence: number; token: number }>((a, b) => a.dueAt - b.dueAt || a.sequence - b.sequence);
const tokens = new Map<Id, number>(), active = new Map<Id, { controller: AbortController; promise: Promise<void> }>();
let timer: unknown, enqueueSequence = 0, stopped = true, closed = false, pumping = false;
const delay = (attempt: number) => Math.max(1, options.retryDelay?.(attempt) ?? Math.min(30_000, 500 * 2 ** Math.min(attempt, 6)));
const report = (error: unknown) => { try { options.onError(error); } catch { /* Reporting cannot change a receipt. */ } };
const supported = (request: FrozenRequest) => options.supportsRequest?.(request) ?? request.protocolVersion === 1;
function enqueue(id: Id) {
if (queued.has(id)) return;
const record = options.getLedger().operations.get(id);
if (!record || record.phase !== 'pending' || record.blockers.length || record.intent.dependencies.some(dependency => options.getLedger().operations.get(dependency)?.phase !== 'resolved')) return;
queued.add(id); ready.push(id);
}
function index(id: Id) {
const record = options.getLedger().operations.get(id);
if (!record) return;
if (!indexed.has(id)) {
indexed.add(id);
for (const dependency of record.intent.dependencies) {
const set = dependents.get(dependency) ?? new Set<Id>(); set.add(id); dependents.set(dependency, set);
}
}
enqueue(id);
}
function own(request: FrozenRequest) {
for (const canvas of request.canvasIds) {
const prior = claims.get(canvas);
if (prior && prior !== request.id) throw fault('recovery_claim_conflict', [canvas]);
}
for (const canvas of request.canvasIds) claims.set(canvas, request.id);
}
function release(request: FrozenRequest) {
for (const canvas of request.canvasIds) if (claims.get(canvas) === request.id) {
claims.delete(canvas);
const waiting = waiters.get(canvas); waiters.delete(canvas);
for (const id of waiting ?? []) enqueue(id);
}
for (const id of request.operationIds) for (const dependent of dependents.get(id) ?? []) enqueue(dependent);
}
function schedule(id: Id, dueAt: number, action: 'submit' | 'query', persist = true) {
if (persist) options.mutate(ledger => ledger.scheduleRetry(id, dueAt, action));
const token = (tokens.get(id) ?? 0) + 1; tokens.set(id, token);
retry.push({ id, dueAt, sequence: ++enqueueSequence, token }); armTimer();
}
function validRetry(item: { id: Id; token: number }) {
return tokens.get(item.id) === item.token && options.getLedger().requests.get(item.id)?.phase !== 'resolved';
}
function armTimer() {
if (timer !== undefined) { clock.clearTimer(timer); timer = undefined; }
while (retry.peek() && !validRetry(retry.peek()!)) retry.pop();
if (stopped || !retry.peek()) return;
timer = clock.setTimer(() => { timer = undefined; pump(); }, Math.max(0, retry.peek()!.dueAt - clock.now()));
}
function launch(id: Id, action: 'submit' | 'query') {
if (active.has(id) || stopped) return;
const controller = new AbortController();
// Start in a microtask so the handle exists before any synchronous adapter error.
const promise = Promise.resolve().then(async () => {
try {
if (stopped) return;
if (action === 'submit') options.mutate(ledger => ledger.markDispatch(id));
// This barrier includes the exact body/key and the conservative dispatch marker.
await options.persist();
if (stopped) return;
const request = options.getLedger().requests.get(id)!;
let status: OperationStatus;
try { status = await (action === 'submit' ? options.submit(request, controller.signal) : options.query(request, controller.signal)); }
catch { status = { outcome: 'unresolved', error: 'transport_unknown' }; }
if (stopped) return; // A stale identity/runtime cannot accept a late callback.
const completion = options.complete(request, status);
const current = options.getLedger().requests.get(id)!;
if (current.phase === 'resolved') { tokens.delete(id); await options.persist(); if (!stopped) release(current); }
else if (completion?.blocked) { await options.persist(); }
else {
// A missing receipt is unknown, so the only write retry reuses this frozen request.
const nextAction = status.outcome === 'accepted' || action === 'submit' ? 'query' : 'submit';
schedule(id, clock.now() + Math.max(1, status.retryAfterMs ?? delay(current.attempt)), nextAction);
await options.persist();
}
} catch (error) { report(error); } // Storage/reconciliation failure keeps its claims held.
}).finally(() => { active.delete(id); if (!stopped) pump(); });
active.set(id, { controller, promise });
}
function pump() {
if (stopped || pumping) return;
pumping = true;
try {
while (active.size < concurrency && retry.peek() && retry.peek()!.dueAt <= clock.now()) {
const item = retry.pop()!;
if (!validRetry(item)) continue;
const request = options.getLedger().requests.get(item.id);
if (request && !active.has(item.id)) launch(item.id, request.retryAction ?? 'query');
}
let budget = ready.size;
while (active.size < concurrency && budget-- > 0) {
const id = ready.pop(); if (!id) break;
queued.delete(id);
const record = options.getLedger().operations.get(id);
if (!record || record.phase !== 'pending' || record.blockers.length || record.intent.dependencies.some(dependency => options.getLedger().operations.get(dependency)?.phase !== 'resolved')) continue;
try {
const canvases = [...new Set(options.participants(record.intent))].sort();
if (!canvases.length) throw fault('missing_participants');
const occupied = canvases.filter(canvas => claims.has(canvas));
if (occupied.length) {
for (const canvas of occupied) { const set = waiters.get(canvas) ?? new Set<Id>(); set.add(id); waiters.set(canvas, set); }
continue;
}
const requestId = options.createId(), compiled = options.compile(record.intent, requestId);
if (canvases.length !== compiled.canvasIds.length || canvases.some(canvas => !compiled.canvasIds.includes(canvas))) throw fault('participant_mismatch');
options.mutate(ledger => ledger.freeze({ id: requestId, operationIds: [id], ...compiled }));
own(options.getLedger().requests.get(requestId)!); launch(requestId, 'submit');
} catch (error) { options.mutate(ledger => ledger.block(id, [(error as Error).message || 'compile_failed'])); report(error); }
}
} finally { pumping = false; armTimer(); }
}
return Object.freeze({
start() {
if (closed) throw fault('scheduler_closed');
if (!stopped) return;
// Validate the entire recovered claim set before sending anything.
claims.clear();
for (const request of options.getLedger().requests.values()) if (request.phase !== 'resolved') own(request);
stopped = false;
for (const operation of [...options.getLedger().operations.values()].sort((a, b) => a.intent.sequence - b.intent.sequence)) index(operation.intent.id);
for (const request of options.getLedger().requests.values()) if (request.phase !== 'resolved') {
if (!supported(request)) { options.mutate(ledger => ledger.blockRequest(request.id, 'request_interpreter_unavailable')); continue; }
schedule(request.id, request.retryAt ?? clock.now(), request.retryAction ?? (request.phase === 'frozen' ? 'submit' : 'query'), false);
}
pump();
},
notify(operationIds: readonly Id[]) { for (const id of operationIds) index(id); pump(); },
/** Explicit retry after storage/auth/permission recovery; preserves frozen bodies. */
async resume() {
if (stopped) return;
// A receipt may be in memory after its durable write failed. Keep claims until retry succeeds.
await options.persist();
if (stopped) return;
for (const request of options.getLedger().requests.values()) {
if (active.has(request.id)) continue;
if (request.phase === 'resolved') release(request);
else if (supported(request)) schedule(request.id, request.retryAt ?? clock.now(), request.retryAction ?? 'query', false);
}
for (const id of indexed) enqueue(id);
pump();
},
getClaims: () => readMap(claims),
async settle() { while (active.size) await Promise.all([...active.values()].map(item => item.promise)); },
stop() { closed = true; stopped = true; if (timer !== undefined) clock.clearTimer(timer); timer = undefined; for (const item of active.values()) item.controller.abort(); },
});
}
+150
View File
@@ -0,0 +1,150 @@
import type { Json } from '../canvas-core/types.ts';
import { clone, equal, fault, validJson } from '../canvas-core/values.ts';
export type StorageScope = { readonly accountId: string; readonly workspaceId: string; readonly projectId: string };
export type RecoveryImage = { readonly formatVersion: 1; readonly rootRevision: number; readonly localRevision: number; readonly payload: Json };
export type DurableBatch = { readonly image: RecoveryImage; readonly files?: readonly { readonly id: string; readonly bytes: Blob }[] };
export type StorageLeader = { readonly scope: StorageScope; readonly fence: number; readonly active: boolean; release(): Promise<void> };
export type StoragePort = {
claim(scope: StorageScope): Promise<StorageLeader>;
recover(leader: StorageLeader): Promise<RecoveryImage | null>;
commit(leader: StorageLeader, batch: DurableBatch): Promise<{ readonly durableRootRevision: number }>;
readFile(leader: StorageLeader, id: string): Promise<Blob | undefined>;
close(): void;
};
type Metadata = { fence: number; throughRevision?: number; checkpointRevision?: number };
type StoredImage = { readonly image: RecoveryImage; readonly checksum: string };
async function checksum(image: RecoveryImage) {
const bytes = new TextEncoder().encode(JSON.stringify(image));
return [...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))].map(value => value.toString(16).padStart(2, '0')).join('');
}
const keyFor = (scope: StorageScope) => {
if (![scope.accountId, scope.workspaceId, scope.projectId].every(value => typeof value === 'string' && value.length > 0)) throw fault('invalid_storage_scope');
return JSON.stringify([scope.accountId, scope.workspaceId, scope.projectId]);
};
function validateImage(image: RecoveryImage) {
if (image.formatVersion !== 1) throw fault('unsupported_recovery_version');
if (!Number.isSafeInteger(image.rootRevision) || image.rootRevision < 0 || !Number.isSafeInteger(image.localRevision) || image.localRevision < 0 || image.localRevision > image.rootRevision || !validJson(image.payload)) throw fault('corrupt_recovery_image');
}
const requestValue = <T>(request: IDBRequest<T>) => new Promise<T>((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });
const transactionDone = (transaction: IDBTransaction) => new Promise<void>((resolve, reject) => { transaction.oncomplete = () => resolve(); transaction.onabort = () => reject(transaction.error ?? fault('storage_aborted')); transaction.onerror = () => { /* onabort is the terminal barrier. */ }; });
/** Single-origin browser adapter. Web Locks serialize tabs; a DB fence rejects stale handles. */
export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?: LockManager; name?: string; checkpointEvery?: number } = {}): StoragePort {
const factory = options.indexedDB ?? globalThis.indexedDB, locks = options.locks ?? globalThis.navigator?.locks;
if (!factory || !locks) throw fault('persistent_storage_unavailable');
const checkpointEvery = options.checkpointEvery ?? 32;
if (!Number.isSafeInteger(checkpointEvery) || checkpointEvery < 1) throw fault('invalid_checkpoint_interval');
let closed = false;
const handles = new Set<StorageLeader>();
const opening = new Promise<IDBDatabase>((resolve, reject) => {
const request = factory.open(options.name ?? 'endoleap-headless', 1);
request.onupgradeneeded = () => {
for (const name of ['metadata', 'checkpoints', 'journal', 'files']) if (!request.result.objectStoreNames.contains(name)) request.result.createObjectStore(name);
};
request.onerror = () => reject(request.error);
request.onblocked = () => reject(fault('storage_upgrade_blocked'));
request.onsuccess = () => {
if (closed) { request.result.close(); reject(fault('storage_closed')); return; }
request.result.onversionchange = () => { closed = true; request.result.close(); for (const handle of handles) void handle.release(); };
resolve(request.result);
};
});
// Opening failures are observable through operations without an unhandled background rejection.
void opening.catch(() => {});
function check(leader: StorageLeader) {
if (closed) throw fault('storage_closed');
if (!handles.has(leader) || !leader.active) throw fault('storage_leader_lost');
return keyFor(leader.scope);
}
async function execute<T>(leader: StorageLeader, stores: string[], mode: IDBTransactionMode, run: (transaction: IDBTransaction, metadata: Metadata, key: string) => Promise<T>): Promise<T> {
const key = check(leader), database = await opening;
check(leader);
const transaction = database.transaction([...new Set(['metadata', ...stores])], mode), done = transactionDone(transaction);
try {
const metadata = await requestValue<Metadata | undefined>(transaction.objectStore('metadata').get(key));
if (!metadata || metadata.fence !== leader.fence) throw fault('storage_fence_lost');
check(leader);
const result = await run(transaction, metadata, key);
await done; // Never resolve durability on an individual put callback.
return result;
} catch (error) { try { transaction.abort(); } catch { /* Already terminal. */ } await done.catch(() => {}); throw error; }
}
return Object.freeze({
async claim(scope: StorageScope) {
const key = keyFor(scope);
if (closed) throw fault('storage_closed');
let unlock!: () => void, acquired!: (leader: StorageLeader) => void, failed!: (error: unknown) => void;
const released = new Promise<void>(resolve => { unlock = resolve; });
const result = new Promise<StorageLeader>((resolve, reject) => { acquired = resolve; failed = reject; });
const held = locks.request(`endoleap-headless:${options.name ?? 'endoleap-headless'}:${key}`, { mode: 'exclusive', ifAvailable: true }, async lock => {
if (!lock) throw fault('storage_scope_in_use');
const database = await opening;
if (closed) throw fault('storage_closed');
const transaction = database.transaction('metadata', 'readwrite'), done = transactionDone(transaction);
try {
const store = transaction.objectStore('metadata'), previous = await requestValue<Metadata | undefined>(store.get(key));
const fence = (previous?.fence ?? 0) + 1;
if (!Number.isSafeInteger(fence)) throw fault('storage_fence_exhausted');
store.put({ ...previous, fence }, key); await done;
let active = true;
const handle: StorageLeader = Object.freeze({ scope: Object.freeze({ ...scope }), fence, get active() { return active; }, async release() { if (!active) return; active = false; handles.delete(handle); unlock(); await held; } });
handles.add(handle); acquired(handle);
await released;
} catch (error) { try { transaction.abort(); } catch { /* Already terminal. */ } await done.catch(() => {}); throw error; }
});
void held.catch(failed);
return result;
},
async recover(leader: StorageLeader) {
const images = await execute(leader, ['checkpoints', 'journal'], 'readonly', async (transaction, metadata, key) => {
if (metadata.throughRevision === undefined) return [];
if (metadata.checkpointRevision === undefined) throw fault('recovery_checkpoint_missing');
const checkpoint = await requestValue<StoredImage | undefined>(transaction.objectStore('checkpoints').get([key, metadata.checkpointRevision]));
if (!checkpoint?.image) throw fault('recovery_checkpoint_missing');
let image = checkpoint.image;
validateImage(image);
if (image.rootRevision !== metadata.checkpointRevision) throw fault('corrupt_recovery_image');
const journal = metadata.checkpointRevision === metadata.throughRevision ? [] : await requestValue<StoredImage[]>(transaction.objectStore('journal').getAll(IDBKeyRange.bound([key, metadata.checkpointRevision + 1], [key, metadata.throughRevision])));
for (const next of journal) { validateImage(next.image); if (next.image.rootRevision !== image.rootRevision + 1 || next.image.localRevision < image.localRevision) throw fault('recovery_journal_gap'); image = next.image; }
if (image.rootRevision !== metadata.throughRevision) throw fault('recovery_journal_gap');
return [checkpoint, ...journal];
});
// Hashing must run after the IDB transaction, which would otherwise auto-close while awaiting it.
for (const stored of images) if (stored.checksum !== await checksum(stored.image)) throw fault('recovery_checksum_mismatch');
check(leader);
return images.at(-1)?.image ?? null;
},
async commit(leader: StorageLeader, batch: DurableBatch) {
// Copy before awaiting: callers cannot change the durable cut while a transaction queues.
const image = clone(batch.image), files = [...(batch.files ?? [])]; validateImage(image);
const stored: StoredImage = { image, checksum: await checksum(image) };
return execute(leader, ['checkpoints', 'journal', 'files'], 'readwrite', async (transaction, metadata, key) => {
const previous = metadata.throughRevision;
if (previous !== undefined && image.rootRevision !== previous + 1) {
if (image.rootRevision !== previous) throw fault('storage_revision_gap');
const saved = await requestValue<StoredImage>(transaction.objectStore(metadata.checkpointRevision === previous ? 'checkpoints' : 'journal').get([key, previous]));
if (!equal(saved, stored) || files.length) throw fault('storage_revision_conflict');
return { durableRootRevision: previous };
}
if (previous !== undefined && image.localRevision < (await requestValue<StoredImage>(transaction.objectStore(metadata.checkpointRevision === previous ? 'checkpoints' : 'journal').get([key, previous]))).image.localRevision) throw fault('storage_revision_conflict');
for (const file of files) {
if (!file.id || !(file.bytes instanceof Blob)) throw fault('invalid_staged_file');
// Immutable resource identity: replacing bytes requires a new resource ID.
if (await requestValue(transaction.objectStore('files').getKey([key, file.id]))) throw fault('resource_identity_conflict', [file.id]);
transaction.objectStore('files').put(file.bytes, [key, file.id]);
}
if (previous === undefined || image.rootRevision - (metadata.checkpointRevision ?? 0) >= checkpointEvery) {
transaction.objectStore('checkpoints').put(stored, [key, image.rootRevision]);
if (metadata.checkpointRevision !== undefined) transaction.objectStore('checkpoints').delete([key, metadata.checkpointRevision]);
transaction.objectStore('journal').delete(IDBKeyRange.bound([key, 0], [key, image.rootRevision]));
metadata.checkpointRevision = image.rootRevision;
} else transaction.objectStore('journal').put(stored, [key, image.rootRevision]);
transaction.objectStore('metadata').put({ ...metadata, throughRevision: image.rootRevision }, key);
return { durableRootRevision: image.rootRevision };
});
},
readFile(leader: StorageLeader, id: string) { return execute(leader, ['files'], 'readonly', (transaction, _metadata, key) => requestValue<Blob | undefined>(transaction.objectStore('files').get([key, id]))); },
close() { closed = true; for (const handle of handles) void handle.release(); void opening.then(database => database.close()).catch(() => {}); },
});
}
+45
View File
@@ -0,0 +1,45 @@
/** FIFO with amortized O(1) removal, without Array.shift on each item. */
export function createDeque<T>() {
let items: T[] = [], head = 0;
return {
get size() { return items.length - head; },
push(value: T) { items.push(value); },
peek(): T | undefined { return items[head]; },
pop(): T | undefined {
if (head === items.length) return undefined;
const value = items[head++];
if (head > 1024 && head * 2 >= items.length) { items = items.slice(head); head = 0; }
return value;
},
clear() { items = []; head = 0; },
values() { return items.slice(head); },
};
}
export function createMinHeap<T>(compare: (left: T, right: T) => number) {
const items: T[] = [];
return {
get size() { return items.length; },
peek(): T | undefined { return items[0]; },
push(value: T) {
let index = items.length; items.push(value);
while (index > 0) { const parent = (index - 1) >> 1; if (compare(items[parent], value) <= 0) break; items[index] = items[parent]; index = parent; }
items[index] = value;
},
pop(): T | undefined {
if (!items.length) return undefined;
const first = items[0], last = items.pop()!;
if (items.length) {
let index = 0;
while (index * 2 + 1 < items.length) {
let child = index * 2 + 1;
if (child + 1 < items.length && compare(items[child + 1], items[child]) < 0) child++;
if (compare(last, items[child]) <= 0) break;
items[index] = items[child]; index = child;
}
items[index] = last;
}
return first;
},
clear() { items.length = 0; },
};
}