import {validateCanvasElementCounts,type CanvasElementCounts} from './scope-counts.ts'; import type {Availability,Change,Command,Document,Id,Registry} from '../canvas-core/types.ts'; import {validateDocument} from '../canvas-core/document.ts'; import {equal,fault} from '../canvas-core/values.ts'; import type {RuntimeState} from './contracts.ts'; /** Durable marker for a verified read prefix. No network write is admitted while * this exists. The token binds prefix extensions, not a resumable 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 version:2;readonly canvasElementCounts:CanvasElementCounts}); export function validateLoadingState(value:LoadingState,document:Document,versions:ReadonlyMap):void{ if(!value||![1,2].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===2){validateCanvasElementCounts(value.canvasElementCounts,value.declaredCanvasIds,value.totalElements);const counts=new Map();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?.version!==2||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):Availability{ 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):void{ 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)return; validateLoadingState(runtime.loading,runtime.acknowledged,runtime.versions); if(runtime.ledger.requests.size||runtime.resources.size||runtime.runs.size||runtime.imports?.size||runtime.orphanFiles?.size||runtime.recoveries.size)throw fault('invalid_loading_state'); for(const record of runtime.ledger.operations.values()){ if(record.intent.operationType!=='document.change'||record.requestId||record.resolution||!['pending','blocked'].includes(record.phase))throw fault('invalid_loading_state'); assertLoadingChanges(record.intent.changes,runtime.acknowledged,registry,runtime.loading); } } export function validatePrefixExtension(previous:Document,previousVersions:ReadonlyMap,state:LoadingState,next:Document,versions:ReadonlyMap,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)||state.totalElements!==nextState.totalElements||nextState.receivedElements!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,state:LoadingState,next:Document,versions:ReadonlyMap,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)();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&&(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>(); function directCounts(document:Document):ReadonlyMap{let counts=membershipCounts.get(document);if(!counts){const next=new Map();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.acknowledged; if(!loading)return{projectComplete:true,topologyComplete:true,completeCanvasIds:[...snapshot.document.canvases.keys()]}; const counts=directCounts(base); return{projectComplete:false,topologyComplete:base.canvases.size===loading.declaredCanvasIds.length,completeCanvasIds:loading.version===2?[...base.canvases.keys()].filter(id=>(counts.get(id)??0)===loading.canvasElementCounts[id]):[]}; }