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
+2 -1
View File
@@ -8,7 +8,8 @@
"start": "next start --hostname localhost --port 17321",
"typecheck": "tsc --noEmit",
"test:headless": "node --test tests/headless/*.test.mjs",
"test:headless:browser": "node tests/headless/browser-storage.mjs"
"test:headless:browser": "node tests/headless/browser-storage.mjs",
"test:headless:proxy": "node tests/headless/proxy.integration.mjs"
},
"dependencies": {
"@radix-ui/react-dialog": "1.3.1",
+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';
+29
View File
@@ -1,6 +1,33 @@
import { createBrowserStorage } from '../../src/lib/canvas-runtime/storage.ts';
import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts';
import { createDocument, createRegistry, builtinComponents, applyChanges } from '../../src/lib/canvas-core/index.ts';
import { createFoundryPersistence } from '../../src/lib/canvas-service/foundry.ts';
async function protocolRecoveryCase(name,assert){
const root='10000000-0000-4000-8000-000000000001',workspace='10000000-0000-4000-8000-000000000003',account='10000000-0000-4000-8000-000000000004';
const registry=createRegistry(builtinComponents()),document=createDocument(root,[{id:root,parent:null}],[{id:'a',incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:260,height:200,rotation:0},data:{title:'a',text:''}}]);
const calls=[],receipts=new Map();let server=document;
const transport=async input=>{
calls.push(input);
if(input.method==='POST'){
const batch=JSON.parse(input.body);server=applyChanges(server,batch.operations[0].changes);
receipts.set(batch.request_id,{protocol_version:2,request_id:batch.request_id,status:'applied',operation_ids:batch.operations.map(operation=>operation.id),model:{documentFormatVersion:1,projectId:root,canvases:[...server.canvases.values()],elements:[...server.elements.values()],tombstones:{},versions:{[root]:2},complete:false,removedCanvasIds:[]}});
return{status:502,body:{error:'upstream_timeout'}};
}
return{status:200,body:receipts.get(input.path.split('/').at(-1))};
};
const storage=createBrowserStorage({name:`${name}-protocol`}),persistence=createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport});
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document,versions:new Map([[root,1]]),storage,persistence};let engine;
try{
engine=await openProjectEngine(options);engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'durable-v2'}});await engine.settle();
const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert(request.protocolVersion===2,'new protocol metadata was replaced by a legacy default');await engine.close();
engine=await openProjectEngine({...options,clock:{now:()=>Date.now()+100000,setTimer:(callback,delay)=>setTimeout(callback,delay),clearTimer:handle=>clearTimeout(handle)}});
await new Promise(resolve=>setTimeout(resolve,10));await engine.settle();
assert(calls.length===2&&calls[1].method==='GET','protocol 2 recovery must query the original request');
assert(engine.getSnapshot().document.elements.get('a').data.title==='durable-v2'&&engine.getSnapshot().history.undo.length===1,'protocol recovery changed working content or duplicated history');
const recovered=engine.getSnapshot().runtime.ledger.requests.get(request.id);assert(recovered.body===request.body&&recovered.outcome==='applied','protocol recovery rewrote request bytes or lost the receipt');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-protocol`)}
}
async function engineRecoveryCase(name, assert) {
const scope = { accountId: 'account', workspaceId: 'workspace', projectId: 'project' }, registry = createRegistry(builtinComponents());
@@ -96,6 +123,8 @@ export async function run() {
results.push('journal gap is reported as corruption and never converted to an empty document');
await engineRecoveryCase(name, assert);
results.push('project engine reopens real IndexedDB with one history unit and the original request; ACK resolves without duplicate edit');
await protocolRecoveryCase(name,assert);
results.push('protocol 2 service adapter recovers exact frozen bytes and reconciles a lost ACK through real IndexedDB');
return results;
} finally { await leader?.release(); other?.close(); storage.close(); indexedDB.deleteDatabase(name); }
}
+15
View File
@@ -48,6 +48,21 @@ test('can is pure and command handlers come from the component capability table'
assert.equal(ids, 0); assert.strictEqual(editor.getSnapshot(), before);
});
test('connection creation guards endpoint canvases and non-JSON envelopes reject without silent truncation',()=>{
const document=createDocument('p',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'f'}}],[node('a'),node('f','root','frame'),node('b','child')]);
const editor=createEditor({registry:createRegistry(builtinComponents()),document});
const edge={id:'e',incarnation:1,ownerCanvasId:'root',componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'a'},target:{elementId:'b'},path:null};
const result=editor.dispatch({type:'element.create',element:edge});assert.equal(result.status,'applied');assert.deepEqual(editor.getSnapshot().operations.get(result.operationIds[0]).canvasIds,['child','root']);
const before=editor.getSnapshot();assert.equal(editor.dispatch({type:'element.create',element:{...node('invalid'),unserializable:()=>42}}).status,'rejected');assert.strictEqual(editor.getSnapshot(),before);
});
test('opaque JSON keys survive unrelated edits while unsafe field setters remain rejected',()=>{
const future={...node('opaque'),componentId:'future.plugin',dataVersion:99,data:JSON.parse('{"constructor":{"retain":true},"__proto__":{"ordinary":"json data"}}')};
const editor=setup([node('a'),future]);editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});
assert.deepEqual(editor.getSnapshot().document.elements.get('opaque').data,future.data);
const before=editor.getSnapshot();assert.equal(editor.dispatch({type:'element.data',target:target('a'),patch:JSON.parse('{"__proto__":{"polluted":true}}')}).status,'rejected');assert.strictEqual(editor.getSnapshot(),before);assert.equal(Object.prototype.polluted,undefined);
});
test('absent and explicit null are distinct history values; view selection follows element deletion', async () => {
const editor = setup();
editor.dispatch({ type: 'element.data', target: target('a'), patch: { optional: null } });
@@ -0,0 +1,26 @@
{
"protocol_version": 2,
"request_id": "90000000-0000-4000-8000-000000000002",
"expected_versions": {
"10000000-0000-4000-8000-000000000001": 3
},
"operations": [
{
"id": "90000000-0000-4000-8000-000000000001",
"changes": [
{
"kind": "field",
"target": {
"id": "a",
"incarnation": 1
},
"field": "data.title",
"before": "a",
"after": "renamed",
"beforePresent": true,
"afterPresent": true
}
]
}
]
}
+51
View File
@@ -0,0 +1,51 @@
/** Compile and exercise the actual Next route in a disposable frontend project. */
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { mkdtemp, mkdir, copyFile, writeFile, symlink, rm } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { spawn } from 'node:child_process';
import { setTimeout as pause } from 'node:timers/promises';
const source=resolve(import.meta.dirname,'../..'),directory=await mkdtemp(join(tmpdir(),'endoleap-proxy-check-')),received=[];
const account='10000000-0000-4000-8000-000000000004',key='90000000-0000-4000-8000-000000000002';
const responseBytes=' { "protocol_version":2, "request_id":"'+key+'", "status":"not-applied", "terminal":true, "error":"fixture_rejection", "extension":{"retained":true} }\n';
const upstream=createServer(async(request,response)=>{
const chunks=[];for await(const chunk of request)chunks.push(chunk);
received.push({path:request.url,body:Buffer.concat(chunks),headers:request.headers});
response.writeHead(request.url.endsWith('/model/operations')?409:200,{'Content-Type':'application/json'});
response.end(request.url.endsWith('/model/operations')?responseBytes:'{"id":"fixture"}');
});
await new Promise(resolve=>upstream.listen(0,'127.0.0.1',resolve));
const reservation=createServer();await new Promise(resolve=>reservation.listen(0,'127.0.0.1',resolve));const port=reservation.address().port;await new Promise(resolve=>reservation.close(resolve));
let next,logs='',launchError;
try{
for(const filename of ['package.json','tsconfig.json','src/app/api/foundry/[...path]/route.ts','src/lib/canvas-response.ts','src/lib/session-cookie.ts']){
const target=join(directory,filename);await mkdir(resolve(target,'..'),{recursive:true});await copyFile(join(source,filename),target);
}
await symlink(join(source,'node_modules'),join(directory,'node_modules'));
await writeFile(join(directory,'src/app/layout.tsx'),'export default function Layout({children}:{children:React.ReactNode}){return <html><body>{children}</body></html>}');
await writeFile(join(directory,'src/app/page.tsx'),'export default function Page(){return <main>Proxy verification fixture</main>}');
next=spawn(process.execPath,[join(source,'node_modules/next/dist/bin/next'),'dev','--webpack','--hostname','127.0.0.1','--port',String(port)],{cwd:directory,detached:true,env:{...process.env,NEXT_TELEMETRY_DISABLED:'1',FOUNDRY_API_ORIGIN:`http://127.0.0.1:${upstream.address().port}`,NEXT_PUBLIC_FOUNDRY_PROJECT:'fixture'},stdio:['ignore','pipe','pipe']});
next.stdout.on('data',chunk=>{logs=(logs+chunk).slice(-12000)});next.stderr.on('data',chunk=>{logs=(logs+chunk).slice(-12000)});next.on('error',error=>{launchError=error});
let ready=false;
for(let attempt=0;attempt<90;attempt++){
if(launchError)throw launchError;if(next.exitCode!==null)throw new Error('Next exited during startup:\n'+logs);
try{const response=await fetch(`http://127.0.0.1:${port}/api/foundry/profile`,{signal:AbortSignal.timeout(1500)});await response.text();if(response.status===200){ready=true;break}}catch{}
await pause(250);
}
if(!ready)throw new Error('Next did not become ready:\n'+logs);
const path='workspaces/10000000-0000-4000-8000-000000000003/materials/10000000-0000-4000-8000-000000000001/canvas/model/operations';
// More than the generic 1 MiB proxy cap, below the canonical 5 MiB cap.
const body=' {\n "protocol_version":2,"request_id":"'+key+'","fixture":"'+ 'x'.repeat(1100000)+'"\n} ';
const response=await fetch(`http://127.0.0.1:${port}/api/foundry/${path}`,{method:'POST',body,headers:{'Content-Type':'application/json','Idempotency-Key':key,'X-Expected-Account':account,Cookie:'foundry_session=fixture-session',Origin:`http://127.0.0.1:${port}`},signal:AbortSignal.timeout(20000)});
assert.equal(response.status,409);assert.equal(await response.text(),responseBytes);
const request=received.find(item=>item.path.endsWith('/model/operations'));
assert.ok(request);assert.equal(request.body.toString(),body);assert.equal(request.headers['idempotency-key'],key);assert.equal(request.headers['x-expected-account'],account);assert.equal(request.headers.authorization,'Bearer fixture-session');
const before=received.length,invalid=await fetch(`http://127.0.0.1:${port}/api/foundry/${path}/unexpected/extra`,{signal:AbortSignal.timeout(10000)});await invalid.text();assert.equal(invalid.status,404);assert.equal(received.length,before);
console.log('PASS actual Next proxy preserves frozen request bytes/key/account and raw terminal receipt above 1 MiB');
console.log('PASS canonical route allowlist rejects unexpected paths without contacting the upstream');
}finally{
if(next?.pid){const exited=new Promise(resolve=>{if(next.exitCode!==null)resolve();else next.once('exit',resolve)});try{process.kill(-next.pid,'SIGTERM')}catch{}await Promise.race([exited,pause(3000)]);if(next.exitCode===null){try{process.kill(-next.pid,'SIGKILL')}catch{}}}
upstream.closeAllConnections();await new Promise(resolve=>upstream.close(resolve));await rm(directory,{recursive:true,force:true,maxRetries:5});
}
+7
View File
@@ -98,3 +98,10 @@ test('missing frozen-request interpreter preserves bytes and claims while unrela
assert.equal(restored.ledger().getSnapshot().requests.get(old.id).error, 'request_interpreter_unavailable');
assert.equal(restored.scheduler.getClaims().get('canvas'), old.id); restored.scheduler.stop();
});
test('permission/protocol rejection keeps unknown claims without an automatic retry loop', async()=>{
const calls=[],state=setup({submit:async request=>{calls.push(request);return{outcome:'unresolved',blocked:true,error:'identity_changed'}}});
state.add('create');state.scheduler.start();await state.scheduler.settle();
assert.equal(state.ledger().getSnapshot().requests.get('request-1').outcome,'unresolved');assert.equal(state.scheduler.getClaims().size,1);assert.equal(state.timerCount(),0);
await state.advance(10000);assert.equal(calls.length,1);state.scheduler.stop();
});
+90
View File
@@ -0,0 +1,90 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { createDocument, createRegistry, builtinComponents } from '../../src/lib/canvas-core/index.ts';
import { createLedger } from '../../src/lib/canvas-runtime/ledger.ts';
import { createFoundryPersistence, createFoundryTransport, loadFoundryModel } from '../../src/lib/canvas-service/foundry.ts';
const root='10000000-0000-4000-8000-000000000001',child='10000000-0000-4000-8000-000000000002',workspace='10000000-0000-4000-8000-000000000003',account='10000000-0000-4000-8000-000000000004';
const operationId='90000000-0000-4000-8000-000000000001',requestId='90000000-0000-4000-8000-000000000002';
const registry=createRegistry(builtinComponents());
const node=(id,owner=root)=>({id,incarnation:1,ownerCanvasId:owner,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:'preserved'},geometry:{x:0,y:0,width:260,height:200,rotation:0}});
const a=node('a'),f={...node('f'),componentId:'frame'},future={...node('future',child),componentId:'plugin.future',dataVersion:9,data:{custom:{keep:[1,'x']}},extension:{retained:true}};
const canvases=[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'f'}}];
const document=createDocument(root,canvases,[a,f,future]);
const snapshot={revision:1,rootRevision:1,document,history:{undo:[],redo:[],entries:new Map()},operations:new Map(),runtime:{acknowledged:document,versions:new Map([[root,3],[child,7]]),ledger:createLedger().getSnapshot(),receipts:new Map(),conflicts:new Map()}};
const intent={id:operationId,operationType:'document.change',operationSchemaVersion:1,sequence:1,origin:'user',canvasIds:[root],dependencies:[],changes:[{kind:'field',target:{id:'a',incarnation:1},field:'data.title',before:'a',after:'renamed',beforePresent:true,afterPresent:true}]};
const chunk=(complete=true)=>({documentFormatVersion:1,projectId:root,canvases:complete?canvases:[canvases[0]],elements:complete?[a,f,future]:[{...a,data:{...a.data,title:'renamed'}},f],tombstones:{},versions:complete?{[root]:3,[child]:7}:{[root]:4},complete,removedCanvasIds:[]});
function setup(transport=async()=>({status:200,body:{protocol_version:2,request_id:requestId,status:'applied',operation_ids:[operationId],model:chunk(false)}})){
const port=createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport});
const compiled=port.compile(intent,requestId,snapshot),ledger=createLedger();ledger.add(intent,{a:'exists'});
const request=ledger.freeze({id:requestId,operationIds:[operationId],...compiled});return{port,request,compiled};
}
test('initial model read is explicit about migration/coverage and owns opaque records',async()=>{
const calls=[];
const required=await loadFoundryModel(async request=>{calls.push(request);return{status:409,body:{error:'canonical_migration_required',document_format_version:1,root_id:root,source_versions:{[root]:1}}}},workspace,root,registry);
assert.equal(required.status,'migration-required');assert.equal(required.sourceVersions.get(root),1);assert.equal(calls[0].method,'GET');
const source=chunk(),loaded=await loadFoundryModel(async()=>({status:200,body:{model:source}}),workspace,child,registry);
assert.equal(loaded.document.projectId,root);assert.deepEqual(loaded.document.elements.get('future'),future);
assert.equal(Object.isFrozen(source.elements[0]),false);source.elements[0]={...a,data:{title:'caller mutation',text:''}};
assert.equal(loaded.document.elements.get('a').data.title,'a');
await assert.rejects(()=>loadFoundryModel(async()=>({status:200,body:{model:chunk(false)}}),workspace,root,registry),/incomplete_initial_model/);
});
test('compiler freezes protocol 2 conditional changes and transports the exact original bytes',async()=>{
const calls=[],{port,request,compiled}=setup(async input=>{calls.push(input);return{status:200,body:{protocol_version:2,request_id:requestId,status:'applied',operation_ids:[operationId],model:chunk(false)}}});
assert.equal(compiled.protocolVersion,2);assert.equal(request.protocolVersion,2);
const body=JSON.parse(request.body);assert.deepEqual(body.operations,[{id:operationId,changes:intent.changes}]);assert.equal(Object.hasOwn(body,'document'),false);
assert.deepEqual(body,JSON.parse(await readFile(new URL('./fixtures/foundry-model-operation-v2.json',import.meta.url),'utf8')));
const originalBytes=' \n'+request.body+' ',frozen={...request,body:originalBytes};
assert.equal((await port.submit(frozen,new AbortController().signal)).outcome,'applied');
assert.equal(calls[0].body,originalBytes);assert.equal(calls[0].requestId,requestId);
await port.query(frozen,new AbortController().signal);assert.equal(calls[1].method,'GET');assert.equal(calls[1].body,undefined);assert.ok(calls[1].path.endsWith('/'+requestId));
});
test('only explicit identity-matched terminal proof is zero-write; bare 4xx stays unresolved and blocked',async()=>{
for(const [response,outcome,blocked]of[
[{status:409,body:{protocol_version:2,request_id:requestId,status:'not-applied',terminal:true,error:'version_conflict'}},'not-applied',false],
[{status:409,body:{error:'idempotency_conflict'}},'unresolved',true],
[{status:403,body:{error:'forbidden'}},'unresolved',true],
[{status:404,body:{status:'unknown',request_id:requestId}},'unresolved',false],
]){
const{port,request}=setup(async()=>response);const status=await port.query(request,new AbortController().signal);
assert.equal(status.outcome,outcome);assert.equal(!!status.blocked,blocked);
}
});
test('partial canonical receipts replace participants and retain unrelated future components and versions',async()=>{
const{port,request}=setup();const status=await port.submit(request,new AbortController().signal),proposal=port.canonical(request,status,snapshot);
assert.equal(proposal.document.elements.get('a').data.title,'renamed');assert.deepEqual(proposal.document.elements.get('future'),future);assert.equal(proposal.versions.get(child),7);assert.equal(proposal.versions.get(root),4);
const broken=structuredClone(status);broken.canonical.model.versions={[root]:4,[child]:8};
assert.throws(()=>port.canonical(request,broken,snapshot),/invalid_canonical_response|receipt_coverage_mismatch/);
const wrong=structuredClone(status);wrong.canonical.model.elements.push({...future,ownerCanvasId:root});
assert.throws(()=>port.canonical(request,wrong,snapshot),/canonical_scope_mismatch/);
assert.equal(snapshot.runtime.acknowledged.elements.get('a').data.title,'a');
});
test('missing logical coverage and altered frozen metadata do not masquerade as successful requests',async()=>{
const calls=[],{port,request}=setup(async input=>{calls.push(input);return{status:200,body:{protocol_version:2,request_id:requestId,status:'applied',operation_ids:[],model:chunk(false)}}});
const incomplete=await port.submit(request,new AbortController().signal);assert.equal(incomplete.outcome,'unresolved');assert.equal(incomplete.blocked,true);assert.equal(incomplete.error,'receipt_coverage_mismatch');
const corrupt=await port.submit({...request,expectedVersions:{[root]:99}},new AbortController().signal);
assert.equal(corrupt.blocked,true);assert.equal(calls.length,1);assert.equal(corrupt.error,'frozen_request_contract_mismatch');
});
test('new canvases carry an explicit absent version without confusing format/protocol/document clocks',()=>{
const canvas='10000000-0000-4000-8000-000000000005',{port}=setup();
const create={...intent,canvasIds:[root,canvas],changes:[{kind:'canvas',id:canvas,before:null,after:{id:canvas,parent:{canvasId:root,entryElementId:'f'}}}]};
const compiled=port.compile(create,requestId,snapshot);assert.equal(compiled.expectedVersions[canvas],0);assert.equal(compiled.expectedVersions[root],3);assert.equal(compiled.protocolVersion,2);
const ledger=createLedger();ledger.add(create,{});assert.equal(ledger.freeze({id:requestId,operationIds:[operationId],...compiled}).expectedVersions[canvas],0);
});
test('browser transport fences identity changes and never rebuilds a frozen body',async()=>{
let current=true;const calls=[];
const transport=createFoundryTransport({accountId:account,isCurrentIdentity:()=>current,fetch:async(url,init)=>{calls.push([url,init]);return new Response('{"status":"unknown"}',{status:404,headers:{'Content-Type':'application/json'}})}});
await transport({path:'fixture',method:'POST',requestId,body:' {"literal":"bytes"} '});
assert.equal(calls[0][1].headers['X-Expected-Account'],account);assert.equal(calls[0][1].body,' {"literal":"bytes"} ');assert.equal(calls[0][1].headers['Idempotency-Key'],requestId);
current=false;await assert.rejects(()=>transport({path:'fixture',method:'GET'}),/identity_changed/);assert.equal(calls.length,1);
const late=createFoundryTransport({accountId:account,isCurrentIdentity:()=>current,fetch:async()=>{current=false;return new Response('{}')}});current=true;
await assert.rejects(()=>late({path:'fixture',method:'GET'}),/identity_changed/);
});