207 lines
18 KiB
TypeScript
207 lines
18 KiB
TypeScript
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 { AppliedModelProof, 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 expectedVersion?: number; 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;
|
|
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');
|
|
if (input.expectedVersion !== undefined && (!Number.isSafeInteger(input.expectedVersion) || input.expectedVersion < 1)) throw fault('invalid_server_version');
|
|
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', ...(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 } : {}), ...(input.expectedVersion !== undefined ? { 'If-Match': `"${input.expectedVersion}"` } : {}),
|
|
} });
|
|
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,
|
|
verifyApplied(request:FrozenRequest,receipt:Json){
|
|
validateFrozen(request);
|
|
if(statusFor(request,{status:200,body:receipt},false).outcome!=='applied')throw fault('receipt_identity_mismatch');
|
|
const proof=verifyFoundryModelProof(record(receipt).model,request.canvasIds,options.rootId);
|
|
for(const [canvas,version]of proof.versions)if(version!==request.expectedVersions[canvas]+1)throw fault('receipt_version_mismatch',[canvas]);
|
|
return proof;
|
|
},
|
|
async read(signal: AbortSignal) {
|
|
const loaded=await loadFoundryModel(options.transport,options.workspaceId,options.rootId,options.registry,signal);
|
|
if(loaded.status!=='ready')throw fault('canonical_migration_required');
|
|
if(loaded.document.projectId!==options.rootId)throw fault('project_mismatch');
|
|
return loaded;
|
|
},
|
|
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');
|
|
return mergeFoundryModel(body.model, request.canvasIds, snapshot, options.registry, options.rootId);
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Verify coverage independently of the current ACK graph. Only a complete fresh model is adopted. */
|
|
export function verifyFoundryModelProof(value:unknown,participants:readonly Id[],rootId:Id):AppliedModelProof {
|
|
const chunk=modelChunk(value);
|
|
if(chunk.projectId!==rootId||chunk.complete||chunk.versions.size!==participants.length||participants.some(id=>!chunk.versions.has(id)))throw fault('receipt_coverage_mismatch');
|
|
return Object.freeze({versions:chunk.versions,assertCanonical(proposal){
|
|
if(proposal.document.projectId!==rootId)throw fault('project_mismatch');
|
|
const unchanged=new Set<Id>(),canvases=new Map(chunk.canvases.map(canvas=>[canvas.id,canvas]));
|
|
for(const [canvas,version]of chunk.versions){
|
|
const fresh=proposal.versions.get(canvas);if(fresh===undefined||fresh<version)throw fault('recovery_read_behind_receipt',[canvas]);
|
|
if(fresh!==version)continue;
|
|
if(!equal(canvases.get(canvas),proposal.document.canvases.get(canvas)))throw fault('canonical_version_mismatch',[canvas]);
|
|
unchanged.add(canvas);
|
|
}
|
|
const expected=new Map(chunk.elements.filter(element=>unchanged.has(element.ownerCanvasId)).map(element=>[element.id,element]));
|
|
let count=0;for(const element of proposal.document.elements.values())if(unchanged.has(element.ownerCanvasId)){count++;if(!equal(expected.get(element.id),element))throw fault('canonical_version_mismatch',[element.ownerCanvasId]);}
|
|
if(count!==expected.size)throw fault('canonical_version_mismatch');
|
|
for(const [id,incarnation]of chunk.tombstones)if((proposal.document.tombstones.get(id)??0)<incarnation)throw fault('stale_canonical_lifecycle',[id]);
|
|
}});
|
|
}
|
|
|
|
/** Shared participant replacement for document receipts and canonical run events. */
|
|
export function mergeFoundryModel(value: unknown, participants: readonly Id[], snapshot: EngineSnapshot, registry: Registry, rootId: Id) {
|
|
const chunk = modelChunk(value);
|
|
if (chunk.projectId !== rootId || chunk.complete || chunk.versions.size !== participants.length || participants.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 participants) canvases.delete(canvas);
|
|
for (const canvas of chunk.canvases) canvases.set(canvas.id, canvas);
|
|
for (const [id, element] of elements) if (participants.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, registry);
|
|
return Object.freeze({ document, versions: readMap(serverVersions) });
|
|
}
|
|
|
|
/** A relocated result carries a complete ownership context. Retain later ACKs
|
|
* per canvas; validate the combined tree before publishing any ownership change. */
|
|
export function mergeFoundryRunContext(value: unknown, snapshot: EngineSnapshot, registry: Registry, rootId: Id) {
|
|
const chunk=modelChunk(value);
|
|
if(!chunk.complete||chunk.projectId!==rootId)throw fault('invalid_run_context');
|
|
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),replace=new Set<Id>();
|
|
for(const [id,version] of chunk.versions)if(version>=(snapshot.runtime.versions.get(id)??0)){
|
|
if(version===snapshot.runtime.versions.get(id)){
|
|
const incoming=chunk.elements.filter(element=>element.ownerCanvasId===id).sort((a,b)=>a.id.localeCompare(b.id)),known=[...previous.elements.values()].filter(element=>element.ownerCanvasId===id).sort((a,b)=>a.id.localeCompare(b.id));
|
|
if(!equal(chunk.canvases.find(canvas=>canvas.id===id),previous.canvases.get(id))||!equal(incoming,known))throw fault('same_version_context_conflict',[id]);
|
|
}
|
|
replace.add(id);serverVersions.set(id,version);canvases.delete(id);
|
|
}
|
|
for(const canvas of chunk.canvases)if(replace.has(canvas.id))canvases.set(canvas.id,canvas);
|
|
for(const [id,element] of elements)if(replace.has(element.ownerCanvasId))elements.delete(id);
|
|
for(const element of chunk.elements)if(replace.has(element.ownerCanvasId)){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));
|
|
const document=createDocument(rootId,[...canvases.values()],[...elements.values()],tombstones);validateDocument(document,registry);
|
|
return Object.freeze({document,versions:readMap(serverVersions)});
|
|
}
|