Synchronize certified canvas scopes while preserving the original loading snapshot

This commit is contained in:
2026-11-09 04:02:22 +08:00
parent cabf2ec667
commit 6c0a421f59
19 changed files with 199 additions and 64 deletions
+51 -16
View File
@@ -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));
}