Synchronize certified canvas scopes while preserving the original loading snapshot
This commit is contained in:
@@ -1,19 +1,19 @@
|
||||
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 {createDocument,validateDocument} from '../canvas-core/document.ts';
|
||||
import {equal,fault} from '../canvas-core/values.ts';
|
||||
import type {RuntimeState} from './contracts.ts';
|
||||
import type {CanonicalProposal,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. */
|
||||
/** 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 version:2;readonly canvasElementCounts:CanvasElementCounts});
|
||||
export type LoadingState=LoadingBase&({readonly version:1;readonly canvasElementCounts?:never}|{readonly version:2|3;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');
|
||||
if(!value||![1,2,3].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);}}
|
||||
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{
|
||||
@@ -21,7 +21,7 @@ function dataKeyAllowed(componentId:Id,key:string,registry:Registry):boolean{
|
||||
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;
|
||||
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):Availability{
|
||||
@@ -41,12 +41,15 @@ export function assertLoadingChanges(changes:readonly Change[],document:Document
|
||||
}
|
||||
}
|
||||
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');
|
||||
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.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);
|
||||
if(record.intent.operationType!=='document.change'||runtime.loading.version!==3&&(record.requestId||record.resolution||!['pending','blocked'].includes(record.phase)))throw fault('invalid_loading_state');
|
||||
assertLoadingChanges(record.intent.changes,source?.document??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{
|
||||
@@ -63,7 +66,7 @@ export function validateLoadingCompletion(previous:Document,previousVersions:Rea
|
||||
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(state.version!==1&&(!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');
|
||||
}
|
||||
@@ -72,8 +75,40 @@ export function validateLoadingCompletion(previous:Document,previousVersions:Rea
|
||||
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;
|
||||
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{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]):[]};
|
||||
return{projectComplete:false,topologyComplete:base.canvases.size===loading.declaredCanvasIds.length,completeCanvasIds:loading.version!==1?[...base.canvases.keys()].filter(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(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?.version!==3||!source)return read;
|
||||
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[]):boolean {
|
||||
if(!runtime.loading)return true;if(runtime.loading.version!==3)return false;
|
||||
const ready=new Set(loadingQueryCoverage({runtime,document:runtime.acknowledged} as import('./contracts.ts').EngineSnapshot).completeCanvasIds);return canvasIds.length>0&&canvasIds.every(id=>ready.has(id));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user