Files
Endoleap/src/lib/canvas-runtime/loading.ts
T

147 lines
20 KiB
TypeScript

import {loadingAssetURLs,validateLoadingAssetAdmissions,validateLoadingAssetUse} from './loading-assets.ts';
import {validateLoadingImportAdmissions} from './loading-import.ts';
import {validateStructuralIntent,structuralCommand,assertStructuralChanges,appliedSource,ownerReady} from './loading-structure.ts';
import {validateCanvasTopology,validateElementOwners,type ElementOwners,validateCanvasElementCounts,type CanvasElementCounts} from './scope-counts.ts';
import type {Availability,Change,Command,Document,Id,Registry,CanvasRecord} from '../canvas-core/types.ts';
import {createDocument,validateDocument} from '../canvas-core/document.ts';
import {equal,fault,readSet} from '../canvas-core/values.ts';
import type {CanonicalProposal,RuntimeState} from './contracts.ts';
/** Durable marker for a verified read prefix. Versions 1/2 hold all writes;
* version 3 admits certified scopes with a retained read baseline. No HTTP cursor. */
type LoadingBase={readonly sourceToken:string;readonly declaredCanvasIds:readonly Id[];readonly receivedElements:number;readonly totalElements:number};
export type LoadingState=LoadingBase&({readonly version:1;readonly canvasElementCounts?:never;readonly elementOwners?:never;readonly sourceCanvases?:never}|{readonly version:2|3;readonly canvasElementCounts:CanvasElementCounts;readonly elementOwners?:never;readonly sourceCanvases?:never}|{readonly version:4;readonly canvasElementCounts:CanvasElementCounts;readonly elementOwners:ElementOwners;readonly sourceCanvases?:never}|{readonly version:5|6;readonly canvasElementCounts:CanvasElementCounts;readonly elementOwners:ElementOwners;readonly sourceCanvases:readonly CanvasRecord[]});
export function validateLoadingState(value:LoadingState,document:Document,versions:ReadonlyMap<Id,number>):void{
if(!value||![1,2,3,4,5,6].includes(value.version))throw fault('loading_interpreter_unavailable');
for(const [id,version]of versions)if(!Number.isSafeInteger(version)||version<1)throw fault('invalid_canonical_version',[id]);
if(typeof value.sourceToken!=='string'||!value.sourceToken||value.sourceToken.length>256||!Array.isArray(value.declaredCanvasIds)||!value.declaredCanvasIds.length||value.declaredCanvasIds.some(id=>typeof id!=='string'||!id||!versions.has(id))||new Set(value.declaredCanvasIds).size!==value.declaredCanvasIds.length||!value.declaredCanvasIds.includes(document.projectId)||[...document.canvases.keys()].some(id=>!value.declaredCanvasIds.includes(id))||![value.receivedElements,value.totalElements].every(value=>Number.isSafeInteger(value)&&value>=0)||value.receivedElements>value.totalElements||document.elements.size>value.receivedElements)throw fault('invalid_loading_state');
if(value.version===1&&value.canvasElementCounts!==undefined)throw fault('invalid_loading_state');
if(value.version<4&&value.elementOwners!==undefined)throw fault('invalid_loading_state');
if(value.version===4||value.version===5||value.version===6){validateElementOwners(value.elementOwners,value.canvasElementCounts,value.totalElements);for(const element of document.elements.values())if(!Object.hasOwn(value.elementOwners,element.id)||value.elementOwners[element.id]!==element.ownerCanvasId)throw fault('invalid_model_identity');}
if(value.version<5&&value.sourceCanvases!==undefined)throw fault('invalid_loading_state');
if(value.version===5||value.version===6){validateCanvasTopology(value.sourceCanvases,value.elementOwners,document.projectId);if(!equal(value.sourceCanvases.map(canvas=>canvas.id).sort(),[...value.declaredCanvasIds].sort())||[...document.canvases].some(([id,record])=>!equal(record,value.sourceCanvases.find(canvas=>canvas.id===id))))throw fault('invalid_model_topology');}
if(value.version!==1){validateCanvasElementCounts(value.canvasElementCounts,value.declaredCanvasIds,value.totalElements);const counts=new Map<Id,number>();for(const element of document.elements.values()){const count=(counts.get(element.ownerCanvasId)??0)+1;if(count>value.canvasElementCounts[element.ownerCanvasId])throw fault('invalid_model_coverage');counts.set(element.ownerCanvasId,count);}}
}
const denied:Availability=Object.freeze({enabled:false,code:'model_loading',targets:[]});
function dataKeyAllowed(componentId:Id,key:string,registry:Registry):boolean{
const definition=registry.components.get(componentId);
return !!definition?.copy&&!['src','assetId','presentation','ports','ai','taskId','runId'].includes(key)&&!definition.resourceFields?.includes(key)&&!definition.copy.references.some(ref=>ref.path[0]===key||ref.path[0]==='*');
}
function pathAllowed(id:Id,document:Document,registry:Registry,loading?:LoadingState,base=document):boolean{
const element=document.elements.get(id);if((!loading||loading.version===1)||element?.structure!=='connection'||registry.resolve(element)!=='known')return false;
return (directCounts(base).get(element.ownerCanvasId)??0)===loading.canvasElementCounts[element.ownerCanvasId];
}
export function loadingCommand(command:Command,document:Document,registry:Registry,loading?:LoadingState,base=document,source?:CanonicalProposal,assets?:ReadonlyMap<Id,string>):Availability{
if(loading&&(loading.version===4||loading.version===5||loading.version===6)&&source){const scoped=structuralCommand(command,document,registry,loading,source,assets);if(scoped)return scoped;}
if(command.type==='connection.path')return pathAllowed(command.target.id,document,registry,loading,base)?{enabled:true}:denied;
if(command.type!=='element.data'&&command.type!=='element.geometry')return denied;
const element=document.elements.get(command.target.id);if(!element||registry.resolve(element)!=='known')return denied;
if(command.type==='element.geometry')return element.structure==='node'?{enabled:true}:denied;
return Object.keys(command.patch).every(key=>dataKeyAllowed(element.componentId,key,registry))?{enabled:true}:denied;
}
/** Check actual changes too: a feature handler cannot evade command admission by
* modifying a protected reference field as a side effect of an allowed edit. */
export function assertLoadingChanges(changes:readonly Change[],document:Document,registry:Registry,loading?:LoadingState,source?:CanonicalProposal,assets?:ReadonlyMap<Id,string>):void{
if(loading&&(loading.version===4||loading.version===5||loading.version===6)&&source){assertStructuralChanges(changes,document,registry,loading,source,assets);return;}
for(const change of changes){
if(change.kind!=='field')throw fault('model_loading');
const element=document.elements.get(change.target.id);
if(!element||element.incarnation!==change.target.incarnation||registry.resolve(element)!=='known'||!(change.field==='path'&&pathAllowed(change.target.id,document,registry,loading)||change.field==='geometry'&&element.structure==='node'||change.field.startsWith('data.')&&dataKeyAllowed(element.componentId,change.field.slice(5),registry)))throw fault('model_loading',[change.target.id]);
}
}
export function validateLoadingRuntime(runtime:RuntimeState,registry:Registry):void{
if(!runtime.loading){if(runtime.loadingRead)throw fault('invalid_loading_state');return;}
const source=runtime.loading.version>=3?runtime.loadingRead:undefined;
if(runtime.loading.version>=3&&!source||runtime.loading.version<3&&runtime.loadingRead)throw fault('invalid_loading_state');
validateLoadingState(runtime.loading,source?.document??runtime.acknowledged,source?.versions??runtime.versions);
if(source)validateLoadingRead(runtime,registry);
if(runtime.loading.version<3&&runtime.ledger.requests.size||runtime.loading.version<6&&runtime.resources.size||runtime.runs.size||runtime.loading.version<5&&runtime.imports?.size||runtime.orphanFiles?.size||runtime.recoveries.size)throw fault('invalid_loading_state');
validateLoadingImportAdmissions(runtime);validateLoadingAssetAdmissions(runtime,registry);
const assets=loadingAssetURLs(runtime),assetUses=new Set<Id>();
let logical=source?.document??runtime.acknowledged;
for(const record of [...runtime.ledger.operations.values()].sort((a,b)=>a.intent.sequence-b.intent.sequence)){
if(record.intent.operationType==='asset.upload'&&runtime.loading.version===6){if(!assets.has(record.intent.resourceId))throw fault('invalid_loading_asset');continue;}
if(record.intent.operationType!=='document.change'||runtime.loading.version<3&&(record.requestId||record.resolution||!['pending','blocked'].includes(record.phase)))throw fault('invalid_loading_state');
if(runtime.loading.version>=4){logical=validateStructuralIntent(record.intent,logical,registry,runtime.loading,source!,runtime.loading.version===6?assets:undefined);}else assertLoadingChanges(record.intent.changes,source?.document??runtime.acknowledged,registry,runtime.loading);
validateLoadingAssetUse(record.intent,logical,runtime,registry,assetUses);
}
}
export function validatePrefixExtension(previous:Document,previousVersions:ReadonlyMap<Id,number>,state:LoadingState,next:Document,versions:ReadonlyMap<Id,number>,nextState:LoadingState,registry:Registry):void{
validateDocument(next,registry);validateLoadingState(nextState,next,versions);
if(next.projectId!==previous.projectId||state.sourceToken!==nextState.sourceToken||state.version!==nextState.version||!equal(state.canvasElementCounts,nextState.canvasElementCounts)||!equal(state.elementOwners,nextState.elementOwners)||!equal(state.sourceCanvases,nextState.sourceCanvases)||state.totalElements!==nextState.totalElements||nextState.receivedElements<state.receivedElements||!equal(state.declaredCanvasIds,nextState.declaredCanvasIds)||!equal(Object.fromEntries(previousVersions),Object.fromEntries(versions))||!equal(Object.fromEntries(previous.tombstones),Object.fromEntries(next.tombstones))||[...previous.canvases].some(([id,record])=>!equal(record,next.canvases.get(id)))||[...previous.elements].some(([id,record])=>!equal(record,next.elements.get(id))))throw fault('loading_source_mismatch');
}
/** Full fresh reads may include new records in an unchanged-version scope. Known
* records still cannot change without an owner version advancing. */
export function validateLoadingCompletion(previous:Document,previousVersions:ReadonlyMap<Id,number>,state:LoadingState,next:Document,versions:ReadonlyMap<Id,number>,registry:Registry,fresh=false,sourceToken?:string):void{
validateDocument(next,registry);if(previous.projectId!==next.projectId)throw fault('project_mismatch');
for(const [id,version]of versions)if(!Number.isSafeInteger(version)||version<1)throw fault('invalid_canonical_version',[id]);
for(const id of next.canvases.keys())if(!versions.has(id))throw fault('missing_server_version',[id]);
for(const [id,version]of previousVersions){if((versions.get(id)??-1)<version)throw fault('stale_canonical_version',[id]);if(versions.get(id)===version&&previous.canvases.has(id)&&!equal(previous.canvases.get(id),next.canvases.get(id)))throw fault('canonical_version_mismatch',[id]);}
for(const [id,record]of previous.elements)if(previousVersions.get(record.ownerCanvasId)===versions.get(record.ownerCanvasId)&&!equal(record,next.elements.get(id)))throw fault('canonical_version_mismatch',[record.ownerCanvasId]);
for(const [id,dead]of previous.tombstones)if((next.tombstones.get(id)??0)<dead)throw fault('stale_canonical_lifecycle',[id]);
const same=equal(Object.fromEntries(previousVersions),Object.fromEntries(versions));
if(state.version!==1&&!(state.version>=4&&fresh)&&(!fresh||same)){const counts=new Map<Id,number>();for(const element of next.elements.values())counts.set(element.ownerCanvasId,(counts.get(element.ownerCanvasId)??0)+1);if(state.declaredCanvasIds.some(id=>(counts.get(id)??0)!==state.canvasElementCounts[id]))throw fault('invalid_model_coverage');}
if(!fresh&&(!same||sourceToken!==state.sourceToken))throw fault('loading_source_mismatch');
if(same&&!(state.version>=4&&fresh)&&(next.elements.size!==state.totalElements||next.canvases.size!==state.declaredCanvasIds.length||state.declaredCanvasIds.some(id=>!next.canvases.has(id))||!equal(Object.fromEntries(previous.tombstones),Object.fromEntries(next.tombstones))))throw fault('incomplete_loading_model');
}
/** Counts refer to the read baseline, never to optimistic working membership. */
const membershipCounts=new WeakMap<Document,ReadonlyMap<Id,number>>();
const loadedNamespaces=new WeakMap<Document,import('../canvas-core/types.ts').ReadonlyValueSet<Id>>();
function loadedNamespace(document:Document){let ids=loadedNamespaces.get(document);if(!ids){ids=readSet(document.elements.keys());loadedNamespaces.set(document,ids);}return ids;}
const namespaceSets=new WeakMap<ElementOwners,import('../canvas-core/types.ts').ReadonlyValueSet<Id>>();
function namespace(owners:ElementOwners){let value=namespaceSets.get(owners);if(!value){value=readSet(Object.keys(owners));namespaceSets.set(owners,value);}return value;}
function directCounts(document:Document):ReadonlyMap<Id,number>{let counts=membershipCounts.get(document);if(!counts){const next=new Map<Id,number>();for(const element of document.elements.values())next.set(element.ownerCanvasId,(next.get(element.ownerCanvasId)??0)+1);counts=next;membershipCounts.set(document,counts);}return counts;}
export function loadingQueryCoverage(snapshot:import('./contracts.ts').EngineSnapshot):import('../canvas-core/queries.ts').QueryCoverage {
const loading=snapshot.runtime.loading,base=snapshot.runtime.loadingRead?.document??snapshot.runtime.acknowledged;
if(!loading)return{projectComplete:true,topologyComplete:true,completeCanvasIds:[...snapshot.document.canvases.keys()]};
const counts=directCounts(base);
return{...((loading.version===4||loading.version===5||loading.version===6)?{elementIds:namespace(loading.elementOwners),loadedElementIds:loadedNamespace(base)}:{}),projectComplete:false,topologyComplete:base.canvases.size===loading.declaredCanvasIds.length,completeCanvasIds:(loading.version===4||loading.version===5||loading.version===6)?[...snapshot.document.canvases.keys()].filter(id=>ownerReady(loading,snapshot.runtime.loadingRead!,snapshot.document,id)):loading.version!==1?[...base.canvases.keys()].filter(id=>snapshot.document.canvases.has(id)&&(counts.get(id)??0)===loading.canvasElementCounts[id]):[]};
}
/** Validate the retained source separately from the moving ACK baseline. Only
* field edits in certified scopes may account for differences during loading. */
function validateLoadingRead(runtime:RuntimeState,registry:Registry):void {
const source=runtime.loadingRead!,loading=runtime.loading!,base=source.document,ack=runtime.acknowledged;
validateDocument(base,registry);
if(loading.version>=4){validateStructuralRead(runtime,registry);return;}
if(base.projectId!==ack.projectId||!equal(Object.fromEntries(base.canvases),Object.fromEntries(ack.canvases))||!equal(Object.fromEntries(base.tombstones),Object.fromEntries(ack.tombstones))||base.elements.size!==ack.elements.size||source.versions.size!==runtime.versions.size)throw fault('invalid_loading_read');
const ready=new Set(loadingQueryCoverage({runtime,document:ack} as import('./contracts.ts').EngineSnapshot).completeCanvasIds);
for(const [id,version]of source.versions){const current=runtime.versions.get(id);if(current===undefined||!Number.isSafeInteger(current)||current<version||current!==version&&!ready.has(id))throw fault('invalid_loading_read',[id]);}
for(const [id,before]of base.elements){const after=ack.elements.get(id);if(!after)throw fault('invalid_loading_read',[id]);if(equal(before,after))continue;
if(runtime.versions.get(before.ownerCanvasId)===source.versions.get(before.ownerCanvasId)||!ready.has(before.ownerCanvasId))throw fault('invalid_loading_read',[id]);
const {data:oldData,geometry:oldGeometry,path:oldPath,...oldEnvelope}=before as typeof before&{geometry?:unknown;path?:unknown};
const {data:newData,geometry:newGeometry,path:newPath,...newEnvelope}=after as typeof after&{geometry?:unknown;path?:unknown};
if(!equal(oldEnvelope,newEnvelope)||!equal(oldGeometry,newGeometry)&&before.structure!=='node'||!equal(oldPath,newPath)&&!pathAllowed(id,base,registry,loading))throw fault('invalid_loading_read',[id]);
for(const key of new Set([...Object.keys(oldData),...Object.keys(newData)]))if(!equal(oldData[key],newData[key])&&!dataKeyAllowed(before.componentId,key,registry))throw fault('invalid_loading_read',[id]);
}
for(const request of runtime.ledger.requests.values())if(request.kind!==undefined&&request.kind!=='document.change'||!request.canvasIds.length||new Set(request.canvasIds).size!==request.canvasIds.length||Object.keys(request.expectedVersions).length!==request.canvasIds.length||request.canvasIds.some(id=>!ready.has(id)||!Number.isSafeInteger(request.expectedVersions[id])||request.expectedVersions[id]<(source.versions.get(id)??Infinity)||request.expectedVersions[id]>(runtime.versions.get(id)??-1)))throw fault('invalid_loading_request');
}
/** Overlay only scopes whose ACK version advanced since this immutable capture. */
export function mergeLoadingRead(runtime:RuntimeState,read:CanonicalProposal,registry:Registry):CanonicalProposal {
const source=runtime.loadingRead;if((!runtime.loading||runtime.loading.version<3)||!source)return read;
if(runtime.loading.version>=4){const projected=appliedSource(runtime,read);validateDocument(projected.document,registry);return projected;}
const advanced=new Set([...runtime.versions].filter(([id,version])=>version>(source.versions.get(id)??Infinity)).map(([id])=>id));
const elements=[...read.document.elements.values()].filter(record=>!advanced.has(record.ownerCanvasId));
for(const element of runtime.acknowledged.elements.values())if(advanced.has(element.ownerCanvasId))elements.push(element);
const document=importDocument(read.document,elements),versions=new Map(read.versions);for(const id of advanced)versions.set(id,runtime.versions.get(id)!);
validateDocument(document,registry);return{document,versions};
}
function importDocument(document:Document,elements:readonly import('../canvas-core/types.ts').ElementRecord[]):Document{return createDocument(document.projectId,[...document.canvases.values()],elements,document.tombstones);}
export function loadingCanSend(runtime:RuntimeState,canvasIds:readonly Id[],document=runtime.acknowledged):boolean {
if(!runtime.loading)return true;if(runtime.loading.version<3)return false;if(runtime.loading.version>=4)return canvasIds.length>0&&canvasIds.every(id=>structuralRequestScope(runtime,runtime.loadingRead!,id));
const ready=new Set(loadingQueryCoverage({runtime,document} as import('./contracts.ts').EngineSnapshot).completeCanvasIds);return canvasIds.length>0&&canvasIds.every(id=>ready.has(id));
}
function validateStructuralRead(runtime:RuntimeState,registry:Registry){
const source=runtime.loadingRead!,expected=appliedSource(runtime,source),ack=runtime.acknowledged;
if(expected.document.projectId!==ack.projectId||!equal(Object.fromEntries(expected.document.canvases),Object.fromEntries(ack.canvases))||!equal(Object.fromEntries(expected.document.elements),Object.fromEntries(ack.elements))||!equal(Object.fromEntries(expected.document.tombstones),Object.fromEntries(ack.tombstones))||!equal(Object.fromEntries(expected.versions),Object.fromEntries(runtime.versions)))throw fault('invalid_loading_read');
validateDocument(ack,registry);
for(const request of runtime.ledger.requests.values())if(!(runtime.loading?.version===6&&request.kind==='asset.upload')&&(request.protocolVersion!==2||!request.canvasIds.length||Object.keys(request.expectedVersions).length!==request.canvasIds.length||request.canvasIds.some(id=>!structuralRequestScope(runtime,source,id)||!Number.isSafeInteger(request.expectedVersions[id])||request.expectedVersions[id]<(source.versions.get(id)??0)||request.expectedVersions[id]>(runtime.versions.get(id)??0))))throw fault('invalid_loading_request');
}
export function structuralRequestScope(runtime:RuntimeState,source:CanonicalProposal,id:Id):boolean {
if(source.versions.has(id))return ownerReady(runtime.loading!,source,source.document,id);
return [...runtime.ledger.operations.values()].some(record=>record.intent.changes.some(change=>change.kind==='canvas'&&change.id===id&&!change.before&&!!change.after));
}