Persist staged image uploads alongside document operations
This commit is contained in:
@@ -5,7 +5,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
const allowed =
|
||||
/^(profile|auth\/(login|register|verify-email|exchange|me|logout)|workspaces(?:\/[a-zA-Z0-9_-]+(?:\/(?:materials(?:\/[a-zA-Z0-9_-]+(?:\/canvas)?)?|search))?)?)$/;
|
||||
const canvasRoute = /^workspaces\/[a-zA-Z0-9_-]+\/materials\/[a-zA-Z0-9_-]+\/canvas(?:\/(?:model(?:\/(?:migrate|operations(?:\/[a-zA-Z0-9_-]+)?))?|operations(?:\/[a-zA-Z0-9_-]+)?|connections|capabilities|subgraphs(?:\/(?:migrate|[a-zA-Z0-9_-]+\/(?:members|dissolve)))?|transactions\/[a-zA-Z0-9_-]+\/undo|ai\/(?:models|runs(?:\/[a-zA-Z0-9_-]+(?:\/(?:cancel|apply))?)?)))?$/;
|
||||
const assetRoute = /^workspaces\/[a-zA-Z0-9_-]+\/assets(?:\/[a-zA-Z0-9_-]+)?$/;
|
||||
const assetRoute = /^workspaces\/[a-zA-Z0-9_-]+\/assets(?:\/(?:operations\/[a-zA-Z0-9_-]+|[a-zA-Z0-9_-]+))?$/;
|
||||
const bodyLimit = 1024 * 1024;
|
||||
|
||||
// Route Handlers do not have the Pages Router bodyParser size limit.
|
||||
@@ -53,7 +53,8 @@ async function handler(
|
||||
}
|
||||
const path = (await params).path.join("/");
|
||||
if (!allowed.test(path) && !canvasRoute.test(path) && !assetRoute.test(path)) return json({ error: "not_found" }, 404);
|
||||
const asset = assetRoute.test(path);
|
||||
const assetReceipt = assetRoute.test(path) && /\/assets\/operations\//.test(path);
|
||||
const asset = assetRoute.test(path) && !assetReceipt;
|
||||
const canvas = canvasRoute.test(path);
|
||||
const canonical = canvas && /\/canvas\/model(?:\/|$)/.test(path);
|
||||
let requestBody: unknown;
|
||||
@@ -72,7 +73,7 @@ async function handler(
|
||||
if (canvas) headers.Accept = "application/vnd.foundry.canvas+json;version=2";
|
||||
const session = req.cookies.get("foundry_session")?.value;
|
||||
if (session) headers.Authorization = "Bearer " + session;
|
||||
for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match", "idempotency-key", "x-expected-account"]) {
|
||||
for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match", "idempotency-key", "x-expected-account", "x-resource-id"]) {
|
||||
const value = req.headers.get(h);
|
||||
if (value !== null) headers[h] = value;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export type EditorOptions<Runtime = undefined> = {
|
||||
runtime?: Runtime;
|
||||
/** Installed by the engine. Pure preparation: an error rejects the entire commit. */
|
||||
reduceRuntime?: (previous: Runtime, next: CoreSnapshot, commit: Commit) => Runtime;
|
||||
nextOperationSequence?: (runtime: Runtime, fallback: number) => number;
|
||||
};
|
||||
export type Transaction = { dispatch(command: Command): Availability; getDocument(): Document };
|
||||
export type Editor = ReturnType<typeof createEditor>;
|
||||
@@ -113,6 +114,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
const outbound = !['remote', 'ack', 'replay'].includes(origin);
|
||||
const operationIds = Object.freeze(outbound ? [uniqueId(used)] : []);
|
||||
const operations = new Map(state.operations);
|
||||
const nextSequence = outbound ? options.nextOperationSequence?.(state.runtime, sequence + 1) ?? sequence + 1 : sequence;
|
||||
if (!Number.isSafeInteger(nextSequence) || outbound && nextSequence <= sequence) throw fault('invalid_operation_sequence');
|
||||
if (outbound) {
|
||||
const canvasIds = new Set<Id>();
|
||||
for (const change of changes) {
|
||||
@@ -143,7 +146,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
}
|
||||
}
|
||||
}
|
||||
const intent: OperationIntent = freeze({ id: operationIds[0], operationType: 'document.change', operationSchemaVersion: 1, sequence: sequence + 1, origin, canvasIds: [...canvasIds].sort(), changes, dependencies: [] });
|
||||
const intent: OperationIntent = freeze({ id: operationIds[0], operationType: 'document.change', operationSchemaVersion: 1, sequence: nextSequence, origin, canvasIds: [...canvasIds].sort(), changes, dependencies: [] });
|
||||
operations.set(intent.id, intent);
|
||||
}
|
||||
if (historyEntryId) {
|
||||
@@ -156,7 +159,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
const runtime = options.reduceRuntime ? options.reduceRuntime(state.runtime, next, commit) : state.runtime;
|
||||
if (isThenable(runtime)) throw fault('async_transaction');
|
||||
state = Object.freeze({ ...next, rootRevision: state.rootRevision + 1, runtime });
|
||||
if (outbound) sequence++;
|
||||
if (outbound) sequence = nextSequence;
|
||||
emit(commit);
|
||||
return Object.freeze({ status: 'applied', revision: state.revision, operationIds, ...(historyEntryId ? { historyEntryId } : {}) });
|
||||
}
|
||||
|
||||
@@ -46,6 +46,6 @@ export function builtinComponents(): readonly ComponentDefinition[] {
|
||||
return ['text', 'image', 'script', 'plain-text', 'plain-image', 'icon', 'frame'].map(id => defineComponent({
|
||||
id, dataVersion: 1, structure: 'node', validate: textData,
|
||||
features: [movable(), resizable(), ...(id === 'frame' ? [] : [rotatable()]), editable()],
|
||||
resourceFields: ['image', 'plain-image'].includes(id) ? ['asset'] : [],
|
||||
})).concat([defineComponent({ id: 'connection', dataVersion: 1, structure: 'connection', validate: data => validJson(data), features: [editable()] })]);
|
||||
resourceFields: ['image', 'plain-image'].includes(id) ? ['assetId','asset'] : [],
|
||||
})).concat([defineComponent({ id: 'connection', dataVersion: 1, structure: 'connection', validate: data => validJson(data), features: [editable()], resourceFields: [] })]);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export function defineFeature(id: string, handlers: Partial<Record<Capability, C
|
||||
}
|
||||
export function defineComponent(definition: ComponentDefinition): ComponentDefinition {
|
||||
if (!definition.id || !Number.isSafeInteger(definition.dataVersion) || definition.dataVersion < 1 || typeof definition.validate !== 'function') throw fault('invalid_component');
|
||||
return freeze({ ...definition, features: definition.features.map(feature => defineFeature(feature.id, feature.handlers)), migrations: definition.migrations ? { ...definition.migrations } : undefined, resourceFields: [...definition.resourceFields || []] });
|
||||
return freeze({ ...definition, features: definition.features.map(feature => defineFeature(feature.id, feature.handlers)), migrations: definition.migrations ? { ...definition.migrations } : undefined, resourceFields: definition.resourceFields ? [...definition.resourceFields] : undefined });
|
||||
}
|
||||
export function createRegistry(definitions: readonly ComponentDefinition[]): Registry {
|
||||
const components = new Map<string, ComponentDefinition>(), capabilities = new Map<string, ReadonlyValueSet<Capability>>(), handlers = new Map<string, ReadonlyMap<Capability, CapabilityHandler>>();
|
||||
|
||||
@@ -33,7 +33,8 @@ export type Applied = { readonly status: 'applied'; readonly revision: number; r
|
||||
export type Result = Applied | Rejected;
|
||||
export type HistoryEntry = { readonly id: Id; readonly label: string; readonly changes: readonly Change[]; readonly operationIds: readonly Id[]; readonly groupId?: Id };
|
||||
export type HistoryState = { readonly undo: readonly Id[]; readonly redo: readonly Id[]; readonly entries: ReadonlyMap<Id, HistoryEntry> };
|
||||
export type OperationIntent = { readonly id: Id; readonly operationType: 'document.change'; readonly operationSchemaVersion: 1; readonly sequence: number; readonly origin: Origin; readonly canvasIds: readonly Id[]; readonly changes: readonly Change[]; readonly dependencies: readonly Id[] };
|
||||
type IntentBase = { readonly id: Id; readonly operationSchemaVersion: 1; readonly sequence: number; readonly origin: Origin; readonly canvasIds: readonly Id[]; readonly changes: readonly Change[]; readonly dependencies: readonly Id[] };
|
||||
export type OperationIntent = IntentBase & ({ readonly operationType: 'document.change' } | { readonly operationType: 'asset.upload'; readonly resourceId: Id });
|
||||
export type Commit = { readonly revision: number; readonly origin: Origin; readonly changes: readonly Change[]; readonly operationIds: readonly Id[] };
|
||||
export type CoreSnapshot = { readonly revision: number; readonly document: Document; readonly history: HistoryState; readonly operations: ReadonlyMap<Id, OperationIntent> };
|
||||
/** One publication boundary. Runtime metadata changes do not dirty the document revision. */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
import type { ResourceRecord } from './resources.ts';
|
||||
|
||||
export type RuntimeState = {
|
||||
readonly ledger: LedgerSnapshot;
|
||||
@@ -8,6 +9,8 @@ export type RuntimeState = {
|
||||
readonly versions: ReadonlyMap<Id, number>;
|
||||
readonly receipts: ReadonlyMap<Id, Json>;
|
||||
readonly conflicts: ReadonlyMap<Id, string>;
|
||||
readonly resources: ReadonlyMap<Id,ResourceRecord>;
|
||||
readonly resourcePins: ReadonlyMap<Id,readonly Id[]>;
|
||||
};
|
||||
export type EngineSnapshot = Snapshot<RuntimeState>;
|
||||
export type CanonicalProposal = { readonly document: Document; readonly versions: ReadonlyMap<Id, number> };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Document, Id, Registry } from '../canvas-core/types.ts';
|
||||
import { createEditor, type EditorOptions } from '../canvas-core/editor.ts';
|
||||
import { createEditor, type EditorOptions, type Transaction } 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';
|
||||
@@ -8,11 +8,12 @@ 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 { conflictsWith, maxResourceBytes, operationFootprint, referencedResources, type AssetPort, type ResourceRecord, type ResourceHandle, type StagedFile } from './resources.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;
|
||||
storage: StoragePort; persistence: PersistencePort; assets?: AssetPort; createId?: () => Id;
|
||||
historyLimit?: number; policy?: EditorOptions['policy']; clock?: Clock;
|
||||
onError?: (error: unknown) => void;
|
||||
};
|
||||
@@ -27,25 +28,39 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
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 createId = options.createId ?? (() => crypto.randomUUID());
|
||||
const displays = new Set<() => void>();
|
||||
const fileWrites = new Map<Id, Blob>(), digestTasks = new Map<Id, Promise<void>>();
|
||||
let staged: readonly ResourceRecord[] = [];
|
||||
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()), resources: readMap(new Map()), resourcePins: 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,
|
||||
nextOperationSequence(runtime, fallback) { let next = fallback; for (const record of runtime.ledger.operations.values()) next = Math.max(next, record.intent.sequence + 1); return next + staged.length; },
|
||||
reduceRuntime(previous, next, commit) {
|
||||
if (!commit.operationIds.length) return previous;
|
||||
const ledger = createLedger(previous.ledger);
|
||||
const ledger = createLedger(previous.ledger), resources = new Map(previous.resources);
|
||||
const firstSequence = next.operations.get(commit.operationIds[0])!.sequence - staged.length;
|
||||
staged.forEach((resource, index) => {
|
||||
if (resources.has(resource.id) || ledger.getSnapshot().operations.has(resource.operationId)) throw fault('resource_identity_conflict', [resource.id]);
|
||||
resources.set(resource.id, resource);
|
||||
ledger.add(freeze({ id: resource.operationId, operationType: 'asset.upload', operationSchemaVersion: 1, sequence: firstSequence + index, origin: 'user', canvasIds: [], changes: [], dependencies: [], resourceId: resource.id }), {});
|
||||
ledger.block(resource.operationId, ['waiting_digest']);
|
||||
});
|
||||
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 intent = next.operations.get(id)!, footprint = operationFootprint(intent, next.document);
|
||||
const dependencies = new Set(intent.dependencies);
|
||||
for (const record of ledger.getSnapshot().operations.values()) if (record.phase !== 'resolved' && record.intent.operationType === 'document.change' && conflictsWith(operationFootprint(record.intent, next.document), footprint)) dependencies.add(record.intent.id);
|
||||
for (const id of referencedResources(intent.changes, options.registry)) {
|
||||
const resource = resources.get(id);
|
||||
if (resource && !resource.stored) dependencies.add(resource.operationId);
|
||||
}
|
||||
const basePresence: Record<Id, 'absent' | 'exists'> = Object.create(null);
|
||||
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);
|
||||
ledger.add(freeze({ ...intent, dependencies: [...dependencies] }), basePresence);
|
||||
}
|
||||
return Object.freeze({ ...previous, ledger: ledger.getSnapshot() });
|
||||
return Object.freeze({ ...previous, resources: readMap(resources), ledger: ledger.getSnapshot() });
|
||||
},
|
||||
});
|
||||
const pending = new Map<number, EngineSnapshot>();
|
||||
@@ -56,9 +71,11 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
const task = (async () => {
|
||||
while (pending.size) {
|
||||
const [revision, snapshot] = pending.entries().next().value!;
|
||||
const receipt = await options.storage.commit(leader, { image: encodeRecovery(snapshot) });
|
||||
const files = [...fileWrites].filter(([id]) => snapshot.runtime.resources.has(id)).map(([id, bytes]) => ({ id, bytes }));
|
||||
const receipt = await options.storage.commit(leader, { image: encodeRecovery(snapshot), files });
|
||||
if (receipt.durableRootRevision !== revision) throw fault('invalid_durable_receipt');
|
||||
durableRootRevision = revision; pending.delete(revision);
|
||||
for (const file of files) fileWrites.delete(file.id);
|
||||
if (editor.getSnapshot().runtime.ledger.durableGeneration < snapshot.runtime.ledger.generation) mutate(ledger => ledger.markDurable(snapshot.runtime.ledger.generation));
|
||||
}
|
||||
})();
|
||||
@@ -73,6 +90,27 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
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; }
|
||||
if (request.kind === 'asset.upload') {
|
||||
const receipts = new Map(previous.receipts); if (status.canonical !== undefined) receipts.set(request.id, freeze(status.canonical));
|
||||
try {
|
||||
const intent = previous.ledger.operations.get(request.operationIds[0])?.intent;
|
||||
if (intent?.operationType !== 'asset.upload' || !options.assets) throw fault('asset_interpreter_unavailable');
|
||||
const resource = previous.resources.get(intent.resourceId);
|
||||
if (!resource) throw fault('resource_missing');
|
||||
const stored = freeze(options.assets.accept(request, status, resource));
|
||||
if (stored.id !== resource.id || stored.bytes !== resource.byteLength) throw fault('asset_receipt_mismatch');
|
||||
ledger.result(request.id, 'applied');
|
||||
const resources = new Map(previous.resources); resources.set(resource.id, freeze({ ...resource, stored }));
|
||||
const result = editor.updateRuntime(() => Object.freeze({ ...previous, receipts: readMap(receipts), resources: readMap(resources), ledger: ledger.getSnapshot() }), root.rootRevision);
|
||||
if (result.status === 'rejected') throw fault(result.code, result.targets);
|
||||
return;
|
||||
} catch (error) {
|
||||
ledger.result(request.id, 'applied', (error as Error).message, false);
|
||||
const result = editor.updateRuntime(() => Object.freeze({ ...previous, receipts: readMap(receipts), ledger: ledger.getSnapshot() }), root.rootRevision);
|
||||
if (result.status === 'rejected') throw fault(result.code, result.targets);
|
||||
report(error); return { blocked: true };
|
||||
}
|
||||
}
|
||||
const receipts = new Map(previous.receipts), conflicts = new Map(previous.conflicts);
|
||||
if (status.canonical !== undefined) receipts.set(request.id, freeze(status.canonical));
|
||||
try {
|
||||
@@ -90,7 +128,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
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 runtime: RuntimeState = Object.freeze({ ...previous, 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) {
|
||||
@@ -105,25 +143,93 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
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,
|
||||
compile(intent, id) {
|
||||
if (intent.operationType === 'document.change') return options.persistence.compile(intent, id, editor.getSnapshot());
|
||||
const resource = editor.getSnapshot().runtime.resources.get(intent.resourceId);
|
||||
if (!options.assets || !resource?.digest) throw fault('asset_not_ready');
|
||||
return { kind: 'asset.upload', protocolVersion: 2, canvasIds: [], expectedVersions: {}, payload: { protocol_version: 2, request_id: id, resource_id: resource.id, operation_id: resource.operationId, sha256: resource.digest, byte_length: resource.byteLength, name: resource.name, mime_type: resource.mimeType } };
|
||||
},
|
||||
async submit(request, signal) {
|
||||
if (request.kind !== 'asset.upload') return options.persistence.submit(request, signal);
|
||||
if (!options.assets) return { outcome: 'unresolved', blocked: true, error: 'asset_interpreter_unavailable' };
|
||||
const payload = JSON.parse(request.body), bytes = await options.storage.readFile(leader, payload.resource_id);
|
||||
if (!bytes || bytes.size !== payload.byte_length || await digest(bytes) !== payload.sha256) return { outcome: 'unresolved', blocked: true, error: 'resource_bytes_unavailable' };
|
||||
return options.assets.upload(request, bytes, signal);
|
||||
},
|
||||
query: (request, signal) => request.kind === 'asset.upload' ? options.assets?.query(request, signal) ?? Promise.resolve({ outcome: 'unresolved', blocked: true, error: 'asset_interpreter_unavailable' }) : options.persistence.query(request, signal),
|
||||
supportsRequest: request => request.kind === 'asset.upload' ? !!options.assets && request.protocolVersion === 2 : options.persistence.supportsRequest?.(request) ?? request.protocolVersion === 1, 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); });
|
||||
const operationIds = [...commit.operationIds, ...staged.map(resource => resource.operationId)];
|
||||
queueMicrotask(() => { if (!closing && !closed) { scheduler.notify(operationIds); if (operationIds.length) prepareDigests(); } });
|
||||
});
|
||||
scheduler.start();
|
||||
async function digest(bytes: Blob) { return [...new Uint8Array(await crypto.subtle.digest('SHA-256', await bytes.arrayBuffer()))].map(value => value.toString(16).padStart(2, '0')).join(''); }
|
||||
function prepareDigests() {
|
||||
if (closing || closed) return;
|
||||
for (const resource of editor.getSnapshot().runtime.resources.values()) {
|
||||
if (digestTasks.size >= 2) break;
|
||||
if (resource.digest || digestTasks.has(resource.id)) continue;
|
||||
const operation = editor.getSnapshot().runtime.ledger.operations.get(resource.operationId);
|
||||
if (operation?.phase !== 'blocked' || !operation.blockers.includes('waiting_digest')) continue;
|
||||
const task = Promise.resolve().then(async () => {
|
||||
const bytes = fileWrites.get(resource.id) ?? await options.storage.readFile(leader, resource.id);
|
||||
if (!bytes || bytes.size !== resource.byteLength) throw fault('resource_bytes_unavailable', [resource.id]);
|
||||
const hash = await digest(bytes);
|
||||
if (closing || closed) return;
|
||||
const result = editor.updateRuntime(previous => {
|
||||
if (previous.resources.get(resource.id) !== resource) throw fault('resource_identity_conflict', [resource.id]);
|
||||
const resources = new Map(previous.resources), ledger = createLedger(previous.ledger);
|
||||
resources.set(resource.id, freeze({ ...resource, digest: hash })); ledger.block(resource.operationId, []);
|
||||
return Object.freeze({ ...previous, resources: readMap(resources), ledger: ledger.getSnapshot() });
|
||||
});
|
||||
if (result.status === 'rejected') throw fault(result.code, result.targets);
|
||||
scheduler.notify([resource.operationId]);
|
||||
}).catch(error => { if (!closing && !closed) { mutate(ledger => ledger.block(resource.operationId, ['resource_bytes_unavailable'])); report(error); } }).finally(() => { digestTasks.delete(resource.id); prepareDigests(); });
|
||||
digestTasks.set(resource.id, task);
|
||||
}
|
||||
}
|
||||
scheduler.start(); prepareDigests();
|
||||
return Object.freeze({
|
||||
editor, sessions, getSnapshot: editor.getSnapshot, persist,
|
||||
editor, sessions,
|
||||
stageFiles(files: readonly StagedFile[], callback: (resources: readonly ResourceHandle[], transaction: Transaction) => unknown) {
|
||||
if (closing || closed) throw fault('engine_closing');
|
||||
if (staged.length) throw fault('reentrant_dispatch');
|
||||
if (!options.assets) throw fault('asset_interpreter_unavailable');
|
||||
const resources = files.map(file => {
|
||||
if (!(file.bytes instanceof Blob) || !file.bytes.size || file.bytes.size > maxResourceBytes || !file.bytes.type.startsWith('image/') || typeof file.name !== 'string' || !file.name) throw fault('invalid_staged_file');
|
||||
return freeze({ id: createId(), operationId: createId(), name: file.name, mimeType: file.bytes.type, byteLength: file.bytes.size });
|
||||
});
|
||||
if (new Set(resources.flatMap(resource => [resource.id, resource.operationId])).size !== resources.length * 2 || resources.some(resource => editor.getSnapshot().runtime.resources.has(resource.id) || fileWrites.has(resource.id))) throw fault('resource_identity_conflict');
|
||||
staged = resources; resources.forEach((resource, index) => fileWrites.set(resource.id, files[index].bytes));
|
||||
try {
|
||||
const handles = freeze(resources.map(resource => ({ id: resource.id, src: options.assets!.sourceURL(resource.id) })));
|
||||
const result = editor.transaction(tx => callback(handles, tx), { label: 'Add images' });
|
||||
if (result.status === 'rejected' || !result.operationIds.length) for (const resource of resources) fileWrites.delete(resource.id);
|
||||
return result;
|
||||
} finally { staged = []; for (const resource of resources) if (!editor.getSnapshot().runtime.resources.has(resource.id)) fileWrites.delete(resource.id); }
|
||||
},
|
||||
async displayResource(id: Id) {
|
||||
if (closing || closed) throw fault('engine_closing');
|
||||
const bytes = fileWrites.get(id) ?? await options.storage.readFile(leader, id);
|
||||
if (closing || closed) throw fault('engine_closing');
|
||||
if (!bytes && !options.assets) throw fault('resource_missing', [id]);
|
||||
const url = bytes ? URL.createObjectURL(bytes) : options.assets!.displayURL(id);
|
||||
let released = false;
|
||||
const release = () => { if (!released) { released = true; displays.delete(release); if (bytes) URL.revokeObjectURL(url); } };
|
||||
displays.add(release); return Object.freeze({ url, release });
|
||||
},
|
||||
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 settle() { await Promise.resolve(); while (digestTasks.size) await Promise.all([...digestTasks.values()]); await scheduler.settle(); await persist(); },
|
||||
async close() {
|
||||
if (closed) return;
|
||||
closing = true; sessions.dispose(); scheduler.stop();
|
||||
for (const release of [...displays]) release();
|
||||
// Failure keeps the leader/recovery material available for an explicit retry of close.
|
||||
await persist(); unsubscribe(); editor.dispose(); await leader.release(); closed = true;
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 kind?: 'document.change' | 'asset.upload';
|
||||
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;
|
||||
@@ -72,13 +73,14 @@ export function createLedger(initial?: LedgerSnapshot) {
|
||||
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; protocolVersion?: number; operationIds: readonly Id[]; canvasIds: readonly Id[]; expectedVersions: Readonly<Record<Id, number>>; payload: Json }) {
|
||||
if (!input.id || requests.has(input.id) || !Number.isSafeInteger(input.protocolVersion ?? 1) || (input.protocolVersion ?? 1) < 1 || !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] < 0)) throw fault('invalid_frozen_request');
|
||||
freeze(input: { id: Id; kind?: FrozenRequest['kind']; protocolVersion?: number; operationIds: readonly Id[]; canvasIds: readonly Id[]; expectedVersions: Readonly<Record<Id, number>>; payload: Json }) {
|
||||
if (!input.id || requests.has(input.id) || !Number.isSafeInteger(input.protocolVersion ?? 1) || (input.protocolVersion ?? 1) < 1 || !input.operationIds.length || (!input.canvasIds.length && input.kind!=='asset.upload') || !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] < 0)) throw fault('invalid_frozen_request');
|
||||
for (const id of input.operationIds) {
|
||||
const operation = operations.get(id);
|
||||
if ((input.kind ?? 'document.change') !== operation?.intent.operationType) throw fault('request_kind_mismatch',[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: input.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 });
|
||||
const request: FrozenRequest = freeze({ id: input.id, kind: input.kind ?? 'document.change', protocolVersion: input.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;
|
||||
|
||||
@@ -5,6 +5,7 @@ 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.operationType==='asset.upload')return[];
|
||||
if (intent.canvasIds?.length) return intent.canvasIds;
|
||||
const ids = new Set<Id>();
|
||||
for (const change of intent.changes) {
|
||||
@@ -29,7 +30,7 @@ export function operationCanvases(intent: OperationIntent, document: Document):
|
||||
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;
|
||||
if (record.phase === 'resolved' || record.intent.operationType!=='document.change') continue;
|
||||
for (const change of record.intent.changes) {
|
||||
if (satisfied(document, change)) continue;
|
||||
document = applyChanges(document, [change]);
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { ResourceRecord } from './resources.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] });
|
||||
@@ -12,7 +13,7 @@ export function encodeRecovery(snapshot: EngineSnapshot): RecoveryImage {
|
||||
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] } },
|
||||
runtime: { resources: [...snapshot.runtime.resources], resourcePins: [...snapshot.runtime.resourcePins], 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 });
|
||||
@@ -53,7 +54,9 @@ function changes(value: unknown): readonly 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);
|
||||
const kind = string(raw.operationType);
|
||||
if (kind === 'asset.upload') { string(raw.resourceId); if (array(raw.changes).length || array(raw.canvasIds).length) throw fault('corrupt_recovery_image'); }
|
||||
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);
|
||||
}
|
||||
@@ -73,10 +76,10 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
|
||||
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');
|
||||
if ((object(raw.intent).operationType !== 'asset.upload' && !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');
|
||||
if (operations.size !== [...records.values()].filter(record => record.intent.operationType !== 'asset.upload').length) 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) {
|
||||
@@ -92,8 +95,9 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
|
||||
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 (raw.kind === 'asset.upload' && (array(raw.canvasIds).length || Object.keys(object(raw.expectedVersions)).length || array(raw.operationIds).length !== 1)) throw fault('corrupt_recovery_image');
|
||||
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');
|
||||
for (const operationId of strings(raw.operationIds)) if (records.get(operationId)?.requestId !== id || (raw.kind ?? 'document.change') !== records.get(operationId)?.intent.operationType) 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);
|
||||
@@ -105,8 +109,20 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
|
||||
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 resources = table<ResourceRecord>(runtime.resources ?? [], (value, id) => {
|
||||
const raw = object(value);
|
||||
if (string(raw.id) !== id || string(raw.operationId) === id) throw fault('corrupt_recovery_image');
|
||||
string(raw.name); string(raw.mimeType); integer(raw.byteLength, 1);
|
||||
if (raw.digest !== undefined && !/^[a-f0-9]{64}$/.test(string(raw.digest))) throw fault('corrupt_recovery_image');
|
||||
const operation = records.get(raw.operationId as string)?.intent;
|
||||
if (operation?.operationType !== 'asset.upload' || operation.resourceId !== id) throw fault('corrupt_recovery_image');
|
||||
if (raw.stored !== undefined) { const stored = object(raw.stored); if (string(stored.id) !== id || integer(stored.bytes, 1) !== raw.byteLength) throw fault('corrupt_recovery_image'); string(stored.url); string(stored.mime_type); integer(stored.width, 1); integer(stored.height, 1); }
|
||||
return freeze(value as ResourceRecord);
|
||||
});
|
||||
for (const record of records.values()) if (record.intent.operationType === 'asset.upload' && resources.get(record.intent.resourceId)?.operationId !== record.intent.id) throw fault('corrupt_recovery_image');
|
||||
const resourcePins = table(runtime.resourcePins ?? [], strings);
|
||||
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) }),
|
||||
runtime: Object.freeze({ resources, resourcePins, ledger: restoredLedger, acknowledged, versions, receipts: table(runtime.receipts, value => freeze(value as Json)), conflicts: table(runtime.conflicts, string) }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Change, CoreSnapshot, Document, ElementRecord, Id, OperationIntent, Registry } from '../canvas-core/types.ts';
|
||||
import { ancestors } from '../canvas-core/document.ts';
|
||||
import type { FrozenRequest, LedgerSnapshot } from './ledger.ts';
|
||||
import type { OperationStatus } from './scheduler.ts';
|
||||
import { readMap, readSet } from '../canvas-core/values.ts';
|
||||
|
||||
export type StoredAsset = { readonly id: Id; readonly url: string; readonly width: number; readonly height: number; readonly bytes: number; readonly mime_type: string };
|
||||
export type ResourceRecord = { readonly id: Id; readonly operationId: Id; readonly name: string; readonly mimeType: string; readonly byteLength: number; readonly digest?: string; readonly stored?: StoredAsset };
|
||||
export type DisplayHandle = { readonly url: string; release(): void };
|
||||
export type AssetPort = {
|
||||
sourceURL(resourceId: Id): string;
|
||||
displayURL(resourceId: Id): string;
|
||||
upload(request: FrozenRequest, bytes: Blob, signal: AbortSignal): Promise<OperationStatus>;
|
||||
query(request: FrozenRequest, signal: AbortSignal): Promise<OperationStatus>;
|
||||
accept(request: FrozenRequest, status: OperationStatus, resource: ResourceRecord): StoredAsset;
|
||||
};
|
||||
export type StagedFile = { readonly bytes: Blob; readonly name: string };
|
||||
export type ResourceHandle = { readonly id: Id; readonly src: string };
|
||||
export const maxResourceBytes = 20 * 1024 * 1024;
|
||||
|
||||
export function referencedResources(changes: readonly Change[], registry: Registry): Set<Id> {
|
||||
const result = new Set<Id>(), fields = new Set([...registry.components.values()].flatMap(component => component.resourceFields ?? []));
|
||||
const collect = (element: ElementRecord | null) => {
|
||||
if (!element) return;
|
||||
for (const field of registry.components.get(element.componentId)?.resourceFields ?? []) if (typeof element.data[field] === 'string') result.add(element.data[field] as string);
|
||||
};
|
||||
for (const change of changes) {
|
||||
if (change.kind === 'element') { collect(change.before); collect(change.after); }
|
||||
else if (change.kind === 'field' && change.field.startsWith('data.') && fields.has(change.field.slice(5))) {
|
||||
if (typeof change.before === 'string') result.add(change.before);
|
||||
if (typeof change.after === 'string') result.add(change.after);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Read/write dependencies use object identities, rather than blocking every edit behind a pending image. */
|
||||
export function operationFootprint(intent: OperationIntent, document: Document) {
|
||||
const reads = new Set<string>(), writes = new Set<string>();
|
||||
if (intent.operationType === 'asset.upload') { writes.add(`resource:${intent.resourceId}`); return { reads, writes }; }
|
||||
const readCanvas = (id: Id) => {
|
||||
if (document.canvases.has(id)) for (const ancestor of ancestors(document, id)) reads.add(`canvas:${ancestor}`);
|
||||
else reads.add(`canvas:${id}`);
|
||||
};
|
||||
const record = (element: ElementRecord | null) => {
|
||||
if (!element) return;
|
||||
readCanvas(element.ownerCanvasId);
|
||||
if (element.structure === 'connection') { reads.add(`element:${element.source.elementId}`); reads.add(`element:${element.target.elementId}`); }
|
||||
};
|
||||
for (const change of intent.changes) {
|
||||
if (change.kind === 'canvas') {
|
||||
writes.add(`canvas:${change.id}`);
|
||||
for (const canvas of [change.before, change.after]) if (canvas?.parent) { readCanvas(canvas.parent.canvasId); reads.add(`element:${canvas.parent.entryElementId}`); }
|
||||
} else if (change.kind === 'element') {
|
||||
writes.add(`element:${change.id}`); record(change.before); record(change.after);
|
||||
} else {
|
||||
writes.add(`element:${change.target.id}`); record(document.elements.get(change.target.id) ?? null);
|
||||
if (change.field === 'ownerCanvasId') { readCanvas(change.before as string); readCanvas(change.after as string); }
|
||||
if (change.field === 'source' || change.field === 'target') for (const endpoint of [change.before, change.after]) {
|
||||
if (endpoint && typeof endpoint === 'object' && !Array.isArray(endpoint) && typeof endpoint.elementId === 'string') reads.add(`element:${endpoint.elementId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { reads, writes };
|
||||
}
|
||||
export function conflictsWith(prior: ReturnType<typeof operationFootprint>, next: ReturnType<typeof operationFootprint>) {
|
||||
return [...prior.writes].some(id => next.writes.has(id) || next.reads.has(id)) || [...prior.reads].some(id => next.writes.has(id));
|
||||
}
|
||||
|
||||
/** Pins are derived from their owners. There is no second mutable reference count. */
|
||||
export function resourcePins(snapshot: CoreSnapshot, ledger: LedgerSnapshot, registry: Registry, explicit: ReadonlyMap<Id, readonly Id[]>) {
|
||||
const pins = new Map<Id, Set<string>>();
|
||||
let complete = true;
|
||||
const pin = (id: Id, owner: string) => { const set = pins.get(id) ?? new Set<string>(); set.add(owner); pins.set(id, set); };
|
||||
const element = (record: ElementRecord | null, owner: string) => {
|
||||
if (!record) return;
|
||||
const component = registry.components.get(record.componentId);
|
||||
if (registry.resolve(record) !== 'known' || component?.resourceFields === undefined) { complete = false; return; }
|
||||
for (const field of component.resourceFields) { const ref = record.data[field]; if (typeof ref === 'string') pin(ref, owner); }
|
||||
};
|
||||
const changes = (values: readonly Change[], owner: string) => {
|
||||
for (const change of values) if (change.kind === 'element') { element(change.before, owner); element(change.after, owner); }
|
||||
for (const id of referencedResources(values, registry)) pin(id, owner);
|
||||
};
|
||||
for (const record of snapshot.document.elements.values()) element(record, `element:${record.id}:${record.incarnation}`);
|
||||
for (const entry of snapshot.history.entries.values()) changes(entry.changes, `history:${entry.id}`);
|
||||
for (const record of ledger.operations.values()) if (record.phase !== 'resolved') {
|
||||
if (record.intent.operationType === 'asset.upload') pin(record.intent.resourceId, `operation:${record.intent.id}`);
|
||||
else changes(record.intent.changes, `operation:${record.intent.id}`);
|
||||
}
|
||||
for (const [owner, ids] of explicit) for (const id of ids) pin(id, owner);
|
||||
return Object.freeze({ complete, pins: readMap(new Map([...pins].map(([id, owners]) => [id, readSet(owners)]))) });
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { createLedger, type FrozenRequest, type LedgerSnapshot, type Outcome } f
|
||||
import { createDeque, createMinHeap } from './structures.ts';
|
||||
|
||||
export type OperationStatus = { readonly outcome: Outcome; readonly error?: string; readonly retryAfterMs?: number; readonly canonical?: Json; readonly blocked?: boolean };
|
||||
export type CompiledRequest = { readonly protocolVersion?: number; readonly payload: Json; readonly canvasIds: readonly Id[]; readonly expectedVersions: Readonly<Record<Id, number>> };
|
||||
export type CompiledRequest = { readonly kind?: FrozenRequest['kind']; readonly protocolVersion?: number; 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>;
|
||||
@@ -33,11 +33,13 @@ export function createScheduler(options: SchedulerOptions) {
|
||||
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 tokens = new Map<Id, number>(), active = new Map<Id, { controller: AbortController; promise: Promise<void>; kind: FrozenRequest['kind'] }>();
|
||||
const laneWaiters = new Map<NonNullable<FrozenRequest['kind']>, Set<Id>>(), deferredRetries = new Map<Id, { id: Id; dueAt: number; sequence: number; token: number }>();
|
||||
let timer: unknown, pumpTimer: 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;
|
||||
const canRun = (kind: FrozenRequest['kind']) => active.size < concurrency && (kind !== 'asset.upload' || ![...active.values()].some(task => task.kind === 'asset.upload'));
|
||||
|
||||
function enqueue(id: Id) {
|
||||
if (queued.has(id)) return;
|
||||
@@ -82,7 +84,7 @@ export function createScheduler(options: SchedulerOptions) {
|
||||
function armTimer() {
|
||||
if (timer !== undefined) { clock.clearTimer(timer); timer = undefined; }
|
||||
while (retry.peek() && !validRetry(retry.peek()!)) retry.pop();
|
||||
if (stopped || !retry.peek()) return;
|
||||
if (stopped || active.size >= concurrency || !retry.peek()) return;
|
||||
timer = clock.setTimer(() => { timer = undefined; pump(); }, Math.max(0, retry.peek()!.dueAt - clock.now()));
|
||||
}
|
||||
function launch(id: Id, action: 'submit' | 'query') {
|
||||
@@ -112,8 +114,16 @@ export function createScheduler(options: SchedulerOptions) {
|
||||
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 });
|
||||
}).finally(() => {
|
||||
active.delete(id);
|
||||
if (!stopped) {
|
||||
for (const item of deferredRetries.values()) retry.push(item);
|
||||
deferredRetries.clear();
|
||||
for (const [kind, waiting] of laneWaiters) if (canRun(kind)) { for (const operationId of waiting) enqueue(operationId); laneWaiters.delete(kind); }
|
||||
pump();
|
||||
}
|
||||
});
|
||||
active.set(id, { controller, promise, kind: options.getLedger().requests.get(id)?.kind });
|
||||
}
|
||||
function pump() {
|
||||
if (stopped || pumping) return;
|
||||
@@ -123,17 +133,23 @@ export function createScheduler(options: SchedulerOptions) {
|
||||
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');
|
||||
if (request && !active.has(item.id)) {
|
||||
if (!canRun(request.kind)) { deferredRetries.set(item.id, item); continue; }
|
||||
launch(item.id, request.retryAction ?? 'query');
|
||||
}
|
||||
}
|
||||
let budget = ready.size;
|
||||
let budget = Math.min(ready.size, 64);
|
||||
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 {
|
||||
if (!canRun(record.intent.operationType)) {
|
||||
const set = laneWaiters.get(record.intent.operationType) ?? new Set<Id>(); set.add(id); laneWaiters.set(record.intent.operationType, set); continue;
|
||||
}
|
||||
const canvases = [...new Set(options.participants(record.intent))].sort();
|
||||
if (!canvases.length) throw fault('missing_participants');
|
||||
if (!canvases.length && record.intent.operationType !== 'asset.upload') 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); }
|
||||
@@ -145,7 +161,10 @@ export function createScheduler(options: SchedulerOptions) {
|
||||
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(); }
|
||||
} finally {
|
||||
pumping = false; armTimer();
|
||||
if (!stopped && ready.size && active.size < concurrency && pumpTimer === undefined) pumpTimer = clock.setTimer(() => { pumpTimer = undefined; pump(); }, 0);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
start() {
|
||||
@@ -179,6 +198,6 @@ export function createScheduler(options: SchedulerOptions) {
|
||||
},
|
||||
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(); },
|
||||
stop() { closed = true; stopped = true; if (timer !== undefined) clock.clearTimer(timer); if (pumpTimer !== undefined) clock.clearTimer(pumpTimer); timer = pumpTimer = undefined; for (const item of active.values()) item.controller.abort(); },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Id, Json } from '../canvas-core/types.ts';
|
||||
import { fault, freeze, validJson } from '../canvas-core/values.ts';
|
||||
import type { AssetPort, ResourceRecord, StoredAsset } from '../canvas-runtime/resources.ts';
|
||||
import type { FrozenRequest } from '../canvas-runtime/ledger.ts';
|
||||
import type { OperationStatus } from '../canvas-runtime/scheduler.ts';
|
||||
import type { ServiceResponse, ServiceTransport } from './foundry.ts';
|
||||
|
||||
const uuid = /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i;
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
if (!value || Array.isArray(value) || typeof value !== 'object') throw fault('invalid_asset_response');
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
function payload(request: FrozenRequest) {
|
||||
const raw = object(JSON.parse(request.body));
|
||||
if (request.kind !== 'asset.upload' || request.protocolVersion !== 2 || raw.protocol_version !== 2 || raw.request_id !== request.id || !uuid.test(request.id) || typeof raw.resource_id !== 'string' || !uuid.test(raw.resource_id) || request.operationIds.length !== 1 || raw.operation_id !== request.operationIds[0] || request.canvasIds.length || Object.keys(request.expectedVersions).length || typeof raw.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(raw.sha256) || typeof raw.byte_length !== 'number' || !Number.isSafeInteger(raw.byte_length) || raw.byte_length < 1 || raw.byte_length > 20 * 1024 * 1024 || typeof raw.name !== 'string' || !raw.name || typeof raw.mime_type !== 'string') throw fault('invalid_asset_request');
|
||||
return raw;
|
||||
}
|
||||
function status(request: FrozenRequest, response: ServiceResponse, query: boolean): OperationStatus {
|
||||
const body = object(response.body), matching = body.protocol_version === 2 && body.request_id === request.id;
|
||||
if (matching && body.status === 'applied' && response.status >= 200 && response.status < 300) {
|
||||
if (!validJson(body)) throw fault('invalid_asset_response');
|
||||
return freeze({ outcome: 'applied', canonical: JSON.parse(JSON.stringify(body)) as Json });
|
||||
}
|
||||
if (query && matching && body.status === 'unknown' && response.status === 404) return { outcome: 'unresolved', error: 'asset_receipt_unknown', retryAfterMs: response.retryAfterMs };
|
||||
const error = typeof body.error === 'string' ? body.error : 'asset_transport_unknown';
|
||||
// A bare rejection does not prove whether an earlier upload committed.
|
||||
return { outcome: 'unresolved', error, retryAfterMs: response.retryAfterMs, ...(response.status >= 400 && response.status < 500 ? { blocked: true } : {}) };
|
||||
}
|
||||
|
||||
/** Immutable bytes and a preallocated resource ID survive response loss and view changes. */
|
||||
export function createFoundryAssets(options: { workspaceId: Id; transport: ServiceTransport; project?: string; origin?: string }): AssetPort {
|
||||
if (!uuid.test(options.workspaceId)) throw fault('invalid_service_scope');
|
||||
const origin = new URL(options.origin ?? 'https://api.foundry.entroia.com');
|
||||
if (origin.protocol !== 'https:' || origin.username || origin.password || origin.search || origin.hash || origin.pathname !== '/') throw fault('invalid_asset_origin');
|
||||
const path = `workspaces/${encodeURIComponent(options.workspaceId)}/assets`;
|
||||
const sourceURL = (id: Id) => `${origin.origin}/projects/${encodeURIComponent(options.project ?? 'endoleap')}/${path}/${encodeURIComponent(id)}`;
|
||||
const accept = (request: FrozenRequest, result: OperationStatus, resource: ResourceRecord): StoredAsset => {
|
||||
const raw = payload(request), receipt = object(result.canonical), asset = object(receipt.asset);
|
||||
if (result.outcome !== 'applied' || receipt.protocol_version !== 2 || receipt.request_id !== request.id || receipt.status !== 'applied' || resource.id !== raw.resource_id || resource.operationId !== raw.operation_id || resource.digest !== raw.sha256 || resource.byteLength !== raw.byte_length || asset.id !== resource.id || asset.url !== sourceURL(resource.id) || asset.bytes !== resource.byteLength || typeof asset.width !== 'number' || typeof asset.height !== 'number' || !Number.isSafeInteger(asset.width) || !Number.isSafeInteger(asset.height) || asset.width <= 0 || asset.height <= 0 || asset.width * asset.height > 40_000_000 || !['image/png', 'image/jpeg', 'image/gif', 'image/webp'].includes(asset.mime_type as string)) throw fault('asset_receipt_mismatch');
|
||||
return freeze(JSON.parse(JSON.stringify(asset)) as StoredAsset);
|
||||
};
|
||||
async function execute(request: FrozenRequest, bytes: Blob | undefined, signal: AbortSignal) {
|
||||
try {
|
||||
const raw = payload(request);
|
||||
let body: FormData | undefined;
|
||||
if (bytes) {
|
||||
const digest = [...new Uint8Array(await crypto.subtle.digest('SHA-256', await bytes.arrayBuffer()))].map(value => value.toString(16).padStart(2, '0')).join('');
|
||||
if (bytes.size !== raw.byte_length || digest !== raw.sha256) throw fault('resource_bytes_mismatch');
|
||||
body = new FormData(); body.append('image', bytes, raw.name as string);
|
||||
}
|
||||
const response = await options.transport({ method: bytes ? 'POST' : 'GET', path: bytes ? path : `${path}/operations/${encodeURIComponent(request.id)}`, requestId: bytes ? request.id : undefined, resourceId: bytes ? raw.resource_id as string : undefined, body, signal });
|
||||
return status(request, response, !bytes);
|
||||
} catch (error) {
|
||||
const code = (error as { code?: string }).code;
|
||||
return { outcome: 'unresolved' as const, error: code ?? 'asset_transport_unknown', ...(code && code !== 'invalid_asset_response' ? { blocked: true } : {}) };
|
||||
}
|
||||
}
|
||||
return Object.freeze({ sourceURL, displayURL: (id: Id) => `/api/foundry/${path}/${encodeURIComponent(id)}?variant=preview`, accept,
|
||||
upload: (request: FrozenRequest, bytes: Blob, signal: AbortSignal) => execute(request, bytes, signal),
|
||||
query: (request: FrozenRequest, signal: AbortSignal) => execute(request, undefined, signal),
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import type { EngineSnapshot, PersistencePort } from '../canvas-runtime/contract
|
||||
import type { FrozenRequest } from '../canvas-runtime/ledger.ts';
|
||||
import type { OperationStatus } from '../canvas-runtime/scheduler.ts';
|
||||
|
||||
export type ServiceRequest = { readonly path: string; readonly method: 'GET' | 'POST'; readonly requestId?: Id; readonly body?: string; readonly signal?: AbortSignal };
|
||||
export type ServiceRequest = { readonly path: string; readonly method: 'GET' | 'POST'; readonly requestId?: Id; readonly body?: string | FormData; readonly resourceId?: Id; readonly signal?: AbortSignal };
|
||||
export type ServiceResponse = { readonly status: number; readonly body: unknown; readonly retryAfterMs?: number };
|
||||
export type ServiceTransport = (request: ServiceRequest) => Promise<ServiceResponse>;
|
||||
const uuid = /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i;
|
||||
@@ -26,7 +26,7 @@ export function createFoundryTransport(options: { accountId: Id; isCurrentIdenti
|
||||
return async input => {
|
||||
if (!options.isCurrentIdentity()) throw fault('identity_changed');
|
||||
const response = await requestFetch(`${options.basePath ?? '/api/foundry/'}${input.path}`, { method: input.method, body: input.body, signal: input.signal, credentials: 'same-origin', cache: 'no-store', redirect: 'error', headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json', 'X-Expected-Account': options.accountId, ...(input.requestId ? { 'Idempotency-Key': input.requestId } : {}),
|
||||
Accept: 'application/json', ...(input.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), ...(input.resourceId ? { 'X-Resource-Id': input.resourceId } : {}), 'X-Expected-Account': options.accountId, ...(input.requestId ? { 'Idempotency-Key': input.requestId } : {}),
|
||||
} });
|
||||
if (!options.isCurrentIdentity()) { await response.body?.cancel(); throw fault('identity_changed'); }
|
||||
let body: unknown;
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './foundry.ts';
|
||||
export * from './assets.ts';
|
||||
|
||||
Reference in New Issue
Block a user