Connect durable canvas runtime to canonical Foundry protocol

This commit is contained in:
2026-11-07 22:53:00 +08:00
parent 10374c3696
commit 8a19909716
16 changed files with 406 additions and 19 deletions
+8 -6
View File
@@ -4,7 +4,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(?:\/(?: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 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 bodyLimit = 1024 * 1024;
@@ -55,10 +55,11 @@ async function handler(
if (!allowed.test(path) && !canvasRoute.test(path) && !assetRoute.test(path)) return json({ error: "not_found" }, 404);
const asset = assetRoute.test(path);
const canvas = canvasRoute.test(path);
const canonical = canvas && /\/canvas\/model(?:\/|$)/.test(path);
let requestBody: unknown;
if (req.method !== "GET") {
try {
requestBody = await readBody(req, asset ? 21 * 1024 * 1024 : path.endsWith("/canvas") ? 5 * 1024 * 1024 : bodyLimit, asset);
requestBody = await readBody(req, asset ? 21 * 1024 * 1024 : canonical || path.endsWith("/canvas") || path.endsWith("/canvas/operations") ? 5 * 1024 * 1024 : bodyLimit, asset || canonical);
} catch (error) {
const tooLarge = error instanceof Error && error.message === "body_too_large";
return json({ error: tooLarge ? "body_too_large" : "invalid_json" }, tooLarge ? 413 : 400);
@@ -71,7 +72,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"]) {
for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match", "idempotency-key", "x-expected-account"]) {
const value = req.headers.get(h);
if (value !== null) headers[h] = value;
}
@@ -81,11 +82,12 @@ async function handler(
if (values.length === 1 && values[0].length < 300) query.set(key, values[0]);
}
const encodingStarted = performance.now();
const outboundBody = req.method === "GET" ? undefined : asset ? requestBody as BodyInit : JSON.stringify(requestBody || {});
// Frozen canonical requests cross the proxy byte-for-byte; do not rebuild their JSON.
const outboundBody = req.method === "GET" ? undefined : asset || canonical ? requestBody as BodyInit : JSON.stringify(requestBody || {});
const encodeMs = performance.now() - encodingStarted;
const started = performance.now();
try {
const snapshotRead = canvas && path.endsWith("/canvas") && req.method === "GET";
const snapshotRead = canvas && (path.endsWith("/canvas") || canonical) && req.method === "GET";
const streamedRead = snapshotRead || asset && req.method === "GET";
const snapshotWrite = canvas && path.endsWith("/canvas") && req.method === "PATCH";
const traceId = streamedRead || snapshotWrite ? crypto.randomUUID() : "";
@@ -128,7 +130,7 @@ async function handler(
const headersReceived = performance.now();
// Snapshots contain no login exchange payload. Forward them without buffering
// and parsing/re-encoding multi-megabyte inline image JSON in the proxy.
if (canvas && path.endsWith("/canvas") && req.method === "GET" && upstream.ok) {
if (canonical || canvas && path.endsWith("/canvas") && req.method === "GET" && upstream.ok) {
responseHeaders.set("Content-Type", "application/json; charset=utf-8");
responseHeaders.set("Server-Timing", `upstream;dur=${(headersReceived - started).toFixed(1)}, proxy_headers;dur=${(performance.now() - headersReceived).toFixed(1)}`);
return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders });
+9 -4
View File
@@ -2,12 +2,13 @@ import type { CanvasRecord, Change, Document, ElementRecord, Geometry, Id, Regis
import { clone, equal, fault, freeze, readMap, validJson } from './values.ts';
export function createDocument(projectId: Id, canvases: readonly CanvasRecord[], elements: readonly ElementRecord[] = [], tombstones: ReadonlyMap<Id, number> = new Map()): Document {
for (const element of elements) if (!validJson(element.data)) throw fault('invalid_element', [element.id]);
for (const element of elements) { const id = element.id; if (!validJson(element)) throw fault('invalid_element', [id]); }
for (const canvas of canvases) if (!validJson(canvas)) throw fault('invalid_canvas');
if (!projectId || new Set(canvases.map(item => item.id)).size !== canvases.length || new Set(elements.map(item => item.id)).size !== elements.length) throw fault('duplicate_identity');
return Object.freeze({ projectId, canvases: readMap(new Map(canvases.map(item => [item.id, freeze(clone(item))]))), elements: readMap(new Map(elements.map(item => [item.id, freeze(clone(item))]))), tombstones: readMap(tombstones) });
}
export function geometryValid(value: Geometry): boolean {
return [value.x, value.y, value.width, value.height, value.rotation].every(Number.isFinite) && value.width > 0 && value.height > 0 && value.rotation >= 0 && value.rotation < 360;
return !!value && typeof value === 'object' && [value.x, value.y, value.width, value.height, value.rotation].every(Number.isFinite) && value.width > 0 && value.height > 0 && value.rotation >= 0 && value.rotation < 360;
}
export function targetElement(document: Document, target: Target): ElementRecord {
const element = document.elements.get(target.id);
@@ -39,7 +40,7 @@ export function validateDocument(document: Document, registry: Registry): void {
const entries = new Set<Id>();
let roots = 0;
for (const canvas of document.canvases.values()) {
if (!canvas.id) throw fault('invalid_canvas');
if (typeof canvas.id !== 'string' || !canvas.id || !Object.hasOwn(canvas,'parent') || canvas.parent !== null && (!canvas.parent || typeof canvas.parent.canvasId !== 'string' || typeof canvas.parent.entryElementId !== 'string')) throw fault('invalid_canvas');
ancestors(document, canvas.id);
if (!canvas.parent) { roots++; continue; }
const entry = document.elements.get(canvas.parent.entryElementId);
@@ -48,7 +49,7 @@ export function validateDocument(document: Document, registry: Registry): void {
}
if (roots !== 1) throw fault('invalid_roots');
for (const element of document.elements.values()) {
if (!element.id || !document.canvases.has(element.ownerCanvasId) || !Number.isSafeInteger(element.incarnation) || element.incarnation < 1 || !Number.isSafeInteger(element.dataVersion) || element.dataVersion < 1 || !validJson(element.data)) throw fault('invalid_element', [element.id]);
if (typeof element.id !== 'string' || !element.id || typeof element.componentId !== 'string' || !element.componentId || !['node','connection'].includes(element.structure) || !document.canvases.has(element.ownerCanvasId) || !Number.isSafeInteger(element.incarnation) || element.incarnation < 1 || !Number.isSafeInteger(element.dataVersion) || element.dataVersion < 1 || !element.data || typeof element.data !== 'object' || Array.isArray(element.data) || !validJson(element.data)) throw fault('invalid_element', [element.id]);
const dead = document.tombstones.get(element.id);
if (dead !== undefined && element.incarnation <= dead) throw fault('stale_incarnation', [element.id]);
const definition = registry.components.get(element.componentId);
@@ -56,6 +57,7 @@ export function validateDocument(document: Document, registry: Registry): void {
if (element.structure === 'node') {
if (!geometryValid(element.geometry)) throw fault('invalid_geometry', [element.id]);
} else {
if (!element.source || !element.target || typeof element.source.elementId !== 'string' || typeof element.target.elementId !== 'string' || [element.source,element.target].some(endpoint => endpoint.portId !== undefined && (typeof endpoint.portId !== 'string' || !endpoint.portId))) throw fault('invalid_endpoint', [element.id]);
const source = document.elements.get(element.source.elementId), target = document.elements.get(element.target.elementId);
if (!source || !target || source.structure !== 'node' || target.structure !== 'node' || !validJson(element.path)) throw fault('invalid_endpoint', [element.id]);
if (commonCanvas(document, source.ownerCanvasId, target.ownerCanvasId) !== element.ownerCanvasId) throw fault('invalid_connection_owner', [element.id]);
@@ -66,6 +68,7 @@ export function applyChanges(document: Document, changes: readonly Change[], con
const elements = new Map(document.elements), canvases = new Map(document.canvases), tombstones = new Map(document.tombstones);
for (const change of changes) {
if (change.kind === 'field') {
if (!validJson(change.after)) throw fault('invalid_change');
if (!change.field.startsWith('data.') && !['geometry', 'ownerCanvasId', 'source', 'target', 'path'].includes(change.field)) throw fault('invalid_field');
const element = elements.get(change.target.id);
if (!element || element.incarnation !== change.target.incarnation) throw fault('stale_incarnation', [change.target.id]);
@@ -81,6 +84,7 @@ export function applyChanges(document: Document, changes: readonly Change[], con
} else if (change.kind === 'element') {
if (conditional && !equal(elements.get(change.id) || null, change.before)) throw fault('element_conflict', [change.id]);
if (change.after) {
if (!validJson(change.after)) throw fault('invalid_element', [change.id]);
if (change.after.id !== change.id) throw fault('identity_mismatch', [change.id]);
elements.set(change.id, freeze(clone(change.after)));
} else {
@@ -91,6 +95,7 @@ export function applyChanges(document: Document, changes: readonly Change[], con
} else if (change.kind === 'canvas') {
if (conditional && !equal(canvases.get(change.id) || null, change.before)) throw fault('canvas_conflict', [change.id]);
if (change.after) {
if (!validJson(change.after)) throw fault('invalid_canvas', [change.id]);
if (change.after.id !== change.id) throw fault('identity_mismatch', [change.id]);
canvases.set(change.id, freeze(clone(change.after)));
} else canvases.delete(change.id);
+13
View File
@@ -123,11 +123,24 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
} else if (change.kind === 'element') {
if (change.before) canvasIds.add(change.before.ownerCanvasId);
if (change.after) canvasIds.add(change.after.ownerCanvasId);
if (!change.before && change.after?.structure === 'connection') {
for (const endpoint of [change.after.source, change.after.target]) {
const element = document.elements.get(endpoint.elementId) ?? state.document.elements.get(endpoint.elementId);
if (element) canvasIds.add(element.ownerCanvasId);
}
}
} else {
const before = state.document.elements.get(change.target.id), after = document.elements.get(change.target.id);
if (before) canvasIds.add(before.ownerCanvasId);
if (after) canvasIds.add(after.ownerCanvasId);
if (change.field === 'ownerCanvasId') { canvasIds.add(change.before as Id); canvasIds.add(change.after as Id); }
if (change.field === 'source' || change.field === 'target') {
for (const endpoint of [change.before, change.after]) {
const id = (endpoint as { elementId?: Id })?.elementId;
const element = id ? document.elements.get(id) ?? state.document.elements.get(id) : undefined;
if (element) canvasIds.add(element.ownerCanvasId);
}
}
}
}
const intent: OperationIntent = freeze({ id: operationIds[0], operationType: 'document.change', operationSchemaVersion: 1, sequence: sequence + 1, origin, canvasIds: [...canvasIds].sort(), changes, dependencies: [] });
+1 -1
View File
@@ -48,5 +48,5 @@ export function validJson(value: unknown, depth = 0): value is Json {
if (value === null || typeof value === 'string' || typeof value === 'boolean') return true;
if (typeof value === 'number') return Number.isFinite(value);
if (Array.isArray(value)) return value.every(item => validJson(item, depth + 1));
return !!value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype && Object.entries(value).every(([key, child]) => !['__proto__', 'prototype', 'constructor'].includes(key) && validJson(child, depth + 1));
return !!value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype && Object.values(value).every(child => validJson(child, depth + 1));
}
+1 -1
View File
@@ -41,7 +41,7 @@ export async function openProjectEngine(options: EngineOptions) {
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'> = {};
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);
}
+4 -3
View File
@@ -26,6 +26,7 @@ export function compressChanges(changes: readonly Change[]): readonly Change[] {
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 (!key || ['__proto__','prototype','constructor'].includes(key)) throw fault('invalid_field');
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;
@@ -71,13 +72,13 @@ 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; 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');
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');
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 });
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 });
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;
+3 -3
View File
@@ -3,8 +3,8 @@ 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 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 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>;
@@ -104,7 +104,7 @@ export function createScheduler(options: SchedulerOptions) {
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 if (completion?.blocked || status.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';
+146
View File
@@ -0,0 +1,146 @@
import type { CanvasRecord, Document, ElementRecord, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts';
import { createDocument, validateDocument } from '../canvas-core/document.ts';
import { equal, fault, freeze, readMap, validJson } from '../canvas-core/values.ts';
import type { EngineSnapshot, PersistencePort } from '../canvas-runtime/contracts.ts';
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 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;
const record = (value: unknown): Record<string, unknown> => { if (!value || Array.isArray(value) || typeof value !== 'object') throw fault('invalid_canonical_response'); return value as Record<string, unknown>; };
const list = (value: unknown): unknown[] => { if (!Array.isArray(value)) throw fault('invalid_canonical_response'); return value; };
const id = (value: unknown): string => { if (typeof value !== 'string' || !value) throw fault('invalid_canonical_response'); return value; };
function versions(value: unknown, minimum = 1): ReadonlyMap<Id, number> {
const result = new Map<Id, number>();
for (const [key, version] of Object.entries(record(value))) { if (!uuid.test(key) || typeof version !== 'number' || !Number.isSafeInteger(version) || version < minimum) throw fault('invalid_canonical_version'); result.set(key, version); }
return readMap(result);
}
const pathFor = (workspace: Id, canvas: Id) => `workspaces/${encodeURIComponent(workspace)}/materials/${encodeURIComponent(canvas)}/canvas/model`;
/** Credentials stay in the host/session. Every write additionally binds the expected account. */
export function createFoundryTransport(options: { accountId: Id; isCurrentIdentity(): boolean; fetch?: typeof fetch; basePath?: string }): ServiceTransport {
if (!uuid.test(options.accountId)) throw fault('invalid_account_identity');
const requestFetch = options.fetch ?? globalThis.fetch;
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 } : {}),
} });
if (!options.isCurrentIdentity()) { await response.body?.cancel(); throw fault('identity_changed'); }
let body: unknown;
try { body = await response.json(); } catch { throw fault('invalid_canonical_response'); }
if (!options.isCurrentIdentity()) throw fault('identity_changed');
const retry = response.headers.get('retry-after'), seconds = retry === null ? NaN : Number(retry);
return { status: response.status, body, ...(Number.isFinite(seconds) && seconds >= 0 ? { retryAfterMs: seconds * 1000 } : {}) };
};
}
type ModelChunk = { projectId: Id; complete: boolean; canvases: readonly CanvasRecord[]; elements: readonly ElementRecord[]; tombstones: ReadonlyMap<Id, number>; versions: ReadonlyMap<Id, number>; removed: readonly Id[] };
function modelChunk(value: unknown): ModelChunk {
if (!validJson(value)) throw fault('invalid_canonical_response');
const raw = record(JSON.parse(JSON.stringify(value)));
if (raw.documentFormatVersion !== 1) throw fault('unsupported_document_format');
const projectId = id(raw.projectId);
if (!uuid.test(projectId) || typeof raw.complete !== 'boolean') throw fault('invalid_canonical_response');
const canvases = list(raw.canvases) as CanvasRecord[], elements = list(raw.elements) as ElementRecord[], removed = list(raw.removedCanvasIds).map(id), serverVersions = versions(raw.versions), tombstones = new Map<Id, number>();
const canvasIds = new Set<Id>(), elementIds = new Set<Id>();
for (const canvas of canvases) {
record(canvas);
if (!uuid.test(canvas.id) || canvasIds.has(canvas.id) || !serverVersions.has(canvas.id)) throw fault('invalid_canonical_response');
canvasIds.add(canvas.id);
}
for (const element of elements) {
record(element);
if (!element.id || elementIds.has(element.id) || !canvasIds.has(element.ownerCanvasId)) throw fault('invalid_canonical_response');
elementIds.add(element.id);
}
for (const [key, value] of Object.entries(record(raw.tombstones))) { if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) throw fault('invalid_canonical_response'); tombstones.set(key, value); }
if (new Set(removed).size !== removed.length || removed.some(value => canvasIds.has(value) || !serverVersions.has(value)) || serverVersions.size !== canvasIds.size + removed.length) throw fault('invalid_canonical_response');
return freeze({ projectId, complete: raw.complete, canvases, elements, tombstones: readMap(tombstones), versions: serverVersions, removed });
}
export type ModelLoad = { readonly status: 'ready'; readonly document: Document; readonly versions: ReadonlyMap<Id, number> } | { readonly status: 'migration-required'; readonly rootId: Id; readonly sourceVersions: ReadonlyMap<Id, number> };
export async function loadFoundryModel(transport: ServiceTransport, workspaceId: Id, canvasId: Id, registry: Registry, signal?: AbortSignal): Promise<ModelLoad> {
const response = await transport({ method: 'GET', path: pathFor(workspaceId, canvasId), signal }), body = record(response.body);
if (response.status === 409 && body.error === 'canonical_migration_required') {
if (body.document_format_version !== 1) throw fault('unsupported_document_format');
const rootId = id(body.root_id); if (!uuid.test(rootId)) throw fault('invalid_canonical_response');
return freeze({ status: 'migration-required', rootId, sourceVersions: versions(body.source_versions) });
}
if (response.status !== 200) throw fault(typeof body.error === 'string' ? body.error : 'model_load_failed');
const chunk = modelChunk(body.model);
if (!chunk.complete) throw fault('incomplete_initial_model');
const document = createDocument(chunk.projectId, chunk.canvases, chunk.elements, chunk.tombstones); validateDocument(document, registry);
return Object.freeze({ status: 'ready', document, versions: chunk.versions });
}
function statusFor(request: FrozenRequest, response: ServiceResponse, query: boolean): OperationStatus {
const body = record(response.body), error = typeof body.error === 'string' ? body.error : undefined;
const matching = body.protocol_version === 2 && body.request_id === request.id;
if (matching && body.status === 'applied' && response.status >= 200 && response.status < 300) {
const covered = list(body.operation_ids).map(id);
if (covered.length !== request.operationIds.length || new Set(covered).size !== covered.length || covered.some(id => !request.operationIds.includes(id))) throw fault('receipt_coverage_mismatch');
if (!validJson(body)) throw fault('invalid_canonical_response');
return freeze({ outcome: 'applied', canonical: JSON.parse(JSON.stringify(body)) as Json });
}
if (matching && body.status === 'accepted' && response.status >= 200 && response.status < 300) return { outcome: 'accepted', retryAfterMs: response.retryAfterMs };
if (matching && body.status === 'not-applied' && body.terminal === true) return { outcome: 'not-applied', error: error ?? 'not_applied' };
if (query && response.status === 404 && body.status === 'unknown' && body.request_id === request.id) return { outcome: 'unresolved', error: 'operation_receipt_unknown', retryAfterMs: response.retryAfterMs };
if (response.status >= 400 && response.status < 500) return { outcome: 'unresolved', blocked: true, error: error ?? 'protocol_rejected' };
return { outcome: 'unresolved', error: error ?? 'transport_unknown', retryAfterMs: response.retryAfterMs };
}
function validateFrozen(request: FrozenRequest) {
let body: Record<string, unknown>;
try { body = record(JSON.parse(request.body)); } catch { throw fault('invalid_frozen_request'); }
const operations = list(body.operations).map(value => id(record(value).id));
if (request.protocolVersion !== 2 || body.protocol_version !== 2 || body.request_id !== request.id || !equal(body.expected_versions, request.expectedVersions) || !equal(Object.keys(request.expectedVersions).sort(), [...request.canvasIds].sort()) || !equal([...operations].sort(), [...request.operationIds].sort())) throw fault('frozen_request_contract_mismatch');
}
/** Operations/conditional fields are sent; full snapshots are only reads and ACK participant views. */
export function createFoundryPersistence(options: { workspaceId: Id; rootId: Id; registry: Registry; transport: ServiceTransport }): PersistencePort {
if (!uuid.test(options.workspaceId) || !uuid.test(options.rootId)) throw fault('invalid_service_scope');
const path = pathFor(options.workspaceId, options.rootId);
async function execute(request: FrozenRequest, query: boolean, signal: AbortSignal): Promise<OperationStatus> {
try {
validateFrozen(request);
const response = await options.transport({ method: query ? 'GET' : 'POST', path: `${path}/operations${query ? `/${encodeURIComponent(request.id)}` : ''}`, requestId: query ? undefined : request.id, body: query ? undefined : request.body, signal });
return statusFor(request, response, query);
} catch (error) {
const code = (error as { code?: string }).code;
return { outcome: 'unresolved', error: code ?? 'transport_unknown', ...(code && code !== 'invalid_canonical_response' ? { blocked: true } : {}) };
}
}
return Object.freeze({
supportsRequest: (request: FrozenRequest) => request.protocolVersion === 2,
compile(intent: OperationIntent, requestId: Id, snapshot: EngineSnapshot) {
if (snapshot.document.projectId !== options.rootId || !uuid.test(intent.id) || !uuid.test(requestId) || intent.operationType !== 'document.change' || intent.operationSchemaVersion !== 1) throw fault('operation_interpreter_unavailable');
const canvasIds = [...new Set(intent.canvasIds)].sort(), expectedVersions: Record<Id, number> = {};
for (const canvas of canvasIds) {
const version = snapshot.runtime.versions.get(canvas);
if (version === undefined && !intent.changes.some(change => change.kind === 'canvas' && change.id === canvas && change.before === null && change.after)) throw fault('missing_server_version', [canvas]);
expectedVersions[canvas] = version ?? 0;
}
const payload = JSON.parse(JSON.stringify({ protocol_version: 2, request_id: requestId, expected_versions: expectedVersions, operations: [{ id: intent.id, changes: intent.changes }] })) as Json;
if (!validJson(payload)) throw fault('invalid_operation_payload');
return freeze({ protocolVersion: 2, canvasIds, expectedVersions, payload });
},
submit: (request: FrozenRequest, signal: AbortSignal) => execute(request, false, signal),
query: (request: FrozenRequest, signal: AbortSignal) => execute(request, true, signal),
canonical(request: FrozenRequest, status: OperationStatus, snapshot: EngineSnapshot) {
const body = record(status.canonical);
if (body.request_id !== request.id || body.protocol_version !== 2) throw fault('receipt_identity_mismatch');
const chunk = modelChunk(body.model);
if (chunk.projectId !== options.rootId || chunk.complete || chunk.versions.size !== request.canvasIds.length || request.canvasIds.some(id => !chunk.versions.has(id))) throw fault('receipt_coverage_mismatch');
const previous = snapshot.runtime.acknowledged, canvases = new Map(previous.canvases), elements = new Map(previous.elements), tombstones = new Map(previous.tombstones), serverVersions = new Map(snapshot.runtime.versions);
for (const canvas of request.canvasIds) canvases.delete(canvas);
for (const canvas of chunk.canvases) canvases.set(canvas.id, canvas);
for (const [id, element] of elements) if (request.canvasIds.includes(element.ownerCanvasId)) elements.delete(id);
for (const element of chunk.elements) { if (elements.has(element.id)) throw fault('canonical_scope_mismatch', [element.id]); elements.set(element.id, element); }
for (const [id, generation] of chunk.tombstones) tombstones.set(id, Math.max(tombstones.get(id) ?? 0, generation));
for (const [id, version] of chunk.versions) serverVersions.set(id, version);
const document = createDocument(previous.projectId, [...canvases.values()], [...elements.values()], tombstones); validateDocument(document, options.registry);
return Object.freeze({ document, versions: readMap(serverVersions) });
},
});
}
+1
View File
@@ -0,0 +1 @@
export * from './foundry.ts';