import type {Change,Command,Document,ElementRecord,Id,Registry,Availability,OperationIntent} from '../canvas-core/types.ts'; import {applyChanges} from '../canvas-core/document.ts'; import {copyReferenceIds} from '../canvas-core/copy-paths.ts'; import {equal,fault} from '../canvas-core/values.ts'; import type {CanonicalProposal,RuntimeState} from './contracts.ts'; import type {LoadingState} from './loading.ts'; type Capture=CanonicalProposal; const counts=new WeakMap>(); function membership(document:Document){let result=counts.get(document);if(!result){const next=new Map();for(const element of document.elements.values())next.set(element.ownerCanvasId,(next.get(element.ownerCanvasId)??0)+1);result=next;counts.set(document,result);}return result;} export function sourceClosed(state:LoadingState,source:Capture){return source.document.elements.size===state.totalElements&&source.document.canvases.size===state.declaredCanvasIds.length;} export function ownerReady(state:LoadingState,source:Capture,document:Document,id:Id){return document.canvases.has(id)&&(!source.versions.has(id)||state.version!==1&&(membership(source.document).get(id)??0)===state.canvasElementCounts[id]);} function born(state:LoadingState,id:Id){return (state.version===4||state.version===5||state.version===6)&&!Object.hasOwn(state.elementOwners,id);} function safeData(data:ElementRecord['data'],registry:Registry,component:Id,document:Document,source?:Document,prior?:ElementRecord['data'],assets?:ReadonlyMap){ const definition=registry.components.get(component);if(!definition?.copy)return false; for(const key of ['ai','taskId','runId'])if(data[key]!==undefined&&data[key]!==null&&!equal(data[key],prior?.[key]))return false; for(const key of new Set(['src','assetId',...(definition.resourceFields??[])]))if(data[key]!==undefined&&data[key]!==null&&data[key]!==''){ if((key==='src'&&[...(assets?.values()??[])].some(url=>url===data[key]))||(key!=='src'&&typeof data[key]==='string'&&assets?.has(data[key] as string)))continue; if(![...document.elements.values(),...(source?.elements.values()??[])].some(element=>registry.resolve(element)==='known'&&equal(element.data[key],data[key])&&(key==='src'||key==='assetId'||registry.components.get(element.componentId)?.resourceFields?.includes(key))))return false; } return true; } function references(document:Document,registry:Registry){for(const element of document.elements.values())if(registry.resolve(element)==='known'){const schema=registry.components.get(element.componentId)?.copy;for(const reference of schema?.references??[])for(const id of copyReferenceIds(element.data,reference))if(!(reference.target==='element'?document.elements:document.canvases).has(id))throw fault('loading_reference_incomplete',[element.id,id]);}} function assetPatch(patch:ElementRecord['data'],registry:Registry,component:Id){ const definition=registry.components.get(component),fields=['src','assetId',...(definition?.resourceFields??[])],keys=Object.keys(patch); return keys.some(key=>fields.includes(key))&&keys.every(key=>fields.includes(key)||!['presentation','ports','ai','taskId','runId'].includes(key)&&!definition?.copy?.references.some(ref=>ref.path[0]===key||ref.path[0]==='*')); } export function structuralCommand(command:Command,document:Document,registry:Registry,state:LoadingState,source:Capture,assets?:ReadonlyMap):Availability|undefined { if(state.version<4)return; const deny:Availability={enabled:false,code:'model_loading',targets:[]},yes:Availability={enabled:true}; if(command.type==='element.create'){const element=command.element;return born(state,element.id)&&!source.document.tombstones.has(element.id)&&ownerReady(state,source,document,element.ownerCanvasId)&®istry.resolve(element)==='known'&&safeData(element.data,registry,element.componentId,document,source.document,undefined,assets)?yes:deny;} if(command.type==='canvas.create'){const canvas=command.canvas,parent=canvas.parent;return !source.versions.has(canvas.id)&&!!parent&&born(state,parent.entryElementId)&&ownerReady(state,source,document,parent.canvasId)?yes:deny;} if(command.type==='canvas.reparent')return (!source.versions.has(command.canvasId)||sourceClosed(state,source))&&!!command.parent&&ownerReady(state,source,document,command.parent.canvasId)?yes:deny; if(command.type==='element.remove'||command.type==='element.move'){const element=document.elements.get(command.target.id);return element&&(born(state,element.id)||sourceClosed(state,source))&&ownerReady(state,source,document,element.ownerCanvasId)&&(command.type!=='element.move'||ownerReady(state,source,document,command.canvasId))?yes:deny;} if(command.type==='connection.endpoints'){const element=document.elements.get(command.target.id);return element&&ownerReady(state,source,document,element.ownerCanvasId)?yes:deny;} if(command.type==='element.data'&&(born(state,command.target.id)||sourceClosed(state,source)||state.version===6&&assetPatch(command.patch,registry,document.elements.get(command.target.id)?.componentId??'')&&ownerReady(state,source,document,document.elements.get(command.target.id)?.ownerCanvasId??''))){const element=document.elements.get(command.target.id);return element&®istry.resolve(element)==='known'&&safeData({...element.data,...command.patch},registry,element.componentId,document,source.document,element.data,assets)?yes:deny;} } /** Actual changes, not a command name, establish the mutation's required closure. */ export function assertStructuralChanges(changes:readonly Change[],document:Document,registry:Registry,state:LoadingState,source:Capture,assets?:ReadonlyMap):Document { if(state.version<4)throw fault('model_loading'); let cursor=document;const closed=sourceClosed(state,source); for(const change of changes){ if(change.kind==='canvas'){ if(source.versions.has(change.id)&&!closed)throw fault('model_loading',[change.id]); for(const canvas of [change.before,change.after])if(canvas?.parent&&!ownerReady(state,source,cursor,canvas.parent.canvasId))throw fault('model_loading',[canvas.parent.canvasId]); if(change.after&&!change.before&&source.versions.has(change.id)&&!closed)throw fault('model_loading',[change.id]); }else if(change.kind==='element'){ if(!born(state,change.id)&&!closed||source.document.tombstones.has(change.id)&&!closed)throw fault('model_loading',[change.id]); for(const element of [change.before,change.after])if(element&&!ownerReady(state,source,cursor,element.ownerCanvasId))throw fault('model_loading',[element.ownerCanvasId]); if(change.after&&(registry.resolve(change.after)!=='known'||!safeData(change.after.data,registry,change.after.componentId,cursor,source.document,undefined,assets)))throw fault('model_loading',[change.id]); }else{ const element=cursor.elements.get(change.target.id);if(!element||element.incarnation!==change.target.incarnation||registry.resolve(element)!=='known')throw fault('model_loading',[change.target.id]); if(change.field==='geometry'){if(element.structure!=='node')throw fault('model_loading',[element.id]);} else if(change.field==='path'){if(element.structure!=='connection'||!ownerReady(state,source,cursor,element.ownerCanvasId))throw fault('model_loading',[element.id]);} else if(['source','target'].includes(change.field)){if(element.structure!=='connection'||!ownerReady(state,source,cursor,element.ownerCanvasId))throw fault('model_loading',[element.id]);} else if(change.field==='ownerCanvasId'){if(!born(state,element.id)&&!closed||!ownerReady(state,source,cursor,element.ownerCanvasId)||!ownerReady(state,source,cursor,change.after as Id))throw fault('model_loading',[element.id]);} else if(change.field.startsWith('data.')){ const key=change.field.slice(5),definition=registry.components.get(element.componentId),protectedField=['presentation','ports','src','assetId','ai','taskId','runId'].includes(key)||definition?.resourceFields?.includes(key)||definition?.copy?.references.some(ref=>ref.path[0]===key||ref.path[0]==='*'); if(protectedField&&!born(state,element.id)&&!closed&&!(state.version===6&&['src','assetId',...(definition?.resourceFields??[])].includes(key)&&ownerReady(state,source,cursor,element.ownerCanvasId)))throw fault('model_loading',[element.id]); const data={...element.data};if(change.afterPresent===false)delete data[key];else data[key]=change.after;if(!safeData(data,registry,element.componentId,cursor,source.document,element.data,assets))throw fault('model_loading',[element.id]); }else throw fault('model_loading',[element.id]); } cursor=applyChanges(cursor,[change]); } references(cursor,registry);return cursor; } /** Protocol-2 receipts contain exactly these applied logical changes. */ const projectionCache=new WeakMap(); export function appliedSource(runtime:RuntimeState,source:Capture):Capture { let document=source.document;const versions=new Map(source.versions); const applied=[...runtime.ledger.operations.values()].filter(record=>record.phase==='resolved'&&record.resolution==='applied').sort((a,b)=>a.intent.sequence-b.intent.sequence); const previous=projectionCache.get(source),extend=!!previous&&previous.intents.length<=applied.length&&previous.intents.every((intent,index)=>intent===applied[index].intent);if(extend)document=previous!.document; for(let index=extend?previous!.intents.length:0;indexrecord.intent),document}); for(const request of runtime.ledger.requests.values())if(request.phase==='resolved'&&request.outcome==='applied')for(const id of request.canvasIds)versions.set(id,Math.max(versions.get(id)??0,request.expectedVersions[id]+1)); return{document,versions}; } const intentProofs=new WeakMap}>>>(); export function validateStructuralIntent(intent:OperationIntent,document:Document,registry:Registry,state:LoadingState,source:Capture,assets?:ReadonlyMap):Document { let registries=intentProofs.get(source);if(!registries){registries=new WeakMap();intentProofs.set(source,registries);}let intents=registries.get(registry);if(!intents){intents=new WeakMap();registries.set(registry,intents);}const previous=intents.get(intent);if(previous?.before===document&&previous.assets===assets)return previous.after; const after=assertStructuralChanges(intent.changes,document,registry,state,source,assets);intents.set(intent,{before:document,after,assets});return after; }