84 lines
10 KiB
TypeScript
84 lines
10 KiB
TypeScript
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<Document,ReadonlyMap<Id,number>>();
|
|
function membership(document:Document){let result=counts.get(document);if(!result){const next=new Map<Id,number>();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<Id,string>){
|
|
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<Id,string>):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<Id,string>):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<Capture,{intents:readonly OperationIntent[];document:Document}>();
|
|
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;index<applied.length;index++)document=applyChanges(document,applied[index].intent.changes,false);
|
|
projectionCache.set(source,{intents:applied.map(record=>record.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<Capture,WeakMap<Registry,WeakMap<OperationIntent,{before:Document;after:Document;assets?:ReadonlyMap<Id,string>}>>>();
|
|
export function validateStructuralIntent(intent:OperationIntent,document:Document,registry:Registry,state:LoadingState,source:Capture,assets?:ReadonlyMap<Id,string>):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;
|
|
}
|