80 lines
9.4 KiB
TypeScript
80 lines
9.4 KiB
TypeScript
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<Id,number>):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<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?.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<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)||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===2&&(!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&&(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>>();
|
|
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.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]):[]};
|
|
}
|