Certify loaded canvas scopes and publish completeness with runtime queries
This commit is contained in:
@@ -24,7 +24,7 @@ export type RuntimeState = {
|
||||
export type EngineSnapshot = Snapshot<RuntimeState>;
|
||||
export type CanonicalProposal = { readonly document: Document; readonly versions: ReadonlyMap<Id, number> };
|
||||
/** Receiving records are read deltas, not a validated Document or an ACK base. */
|
||||
export type CanonicalReadUpdate = CanonicalReadResult | { readonly status:'receiving'; readonly projectId:Id; readonly canvases:readonly CanvasRecord[]; readonly elements:readonly ElementRecord[]; readonly tombstones:ReadonlyMap<Id,number>; readonly versions:ReadonlyMap<Id,number>; readonly receivedElements:number; readonly totalElements:number };
|
||||
export type CanonicalReadUpdate = CanonicalReadResult | { readonly status:'receiving'; readonly canvasElementCounts?:import('./scope-counts.ts').CanvasElementCounts; readonly projectId:Id; readonly canvases:readonly CanvasRecord[]; readonly elements:readonly ElementRecord[]; readonly tombstones:ReadonlyMap<Id,number>; readonly versions:ReadonlyMap<Id,number>; readonly receivedElements:number; readonly totalElements:number };
|
||||
export type CanonicalReadResult = ({ readonly status: 'ready' } & CanonicalProposal) | { readonly status: 'migration-required'; readonly rootId: Id; readonly sourceVersions: ReadonlyMap<Id, number> };
|
||||
/** A validated historical execution receipt; its old model is never published over a fresh read. */
|
||||
export type AppliedModelProof = { readonly versions:ReadonlyMap<Id,number>; assertCanonical(proposal:CanonicalProposal):void };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {assertLoadingChanges,loadingCommand,validateLoadingRuntime,validatePrefixExtension,validateLoadingCompletion,type LoadingState} from './loading.ts';
|
||||
import {loadingQueryCoverage,assertLoadingChanges,loadingCommand,validateLoadingRuntime,validatePrefixExtension,validateLoadingCompletion,type LoadingState} from './loading.ts';
|
||||
import {validImageDimensions,type ImageDecoderPort} from './image-decoder.ts';
|
||||
import {unitScale} from '../canvas-core/units.ts';
|
||||
import { validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts';
|
||||
@@ -100,9 +100,9 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
const assertWritable = (complete=true) => { if(options.isCurrentIdentity&&!options.isCurrentIdentity())throw fault('identity_changed');if(complete&&editor.getSnapshot().runtime.loading)throw fault('model_loading');if(closing||closed)throw fault('engine_closing');if(recovery)throw fault('recovery_in_progress'); };
|
||||
const initial: RuntimeState = recovered?.runtime ?? Object.freeze({ imports:readMap(new Map()), recoveries:readMap(new Map()), runs: readMap(new Map()), acknowledged: createDocument(options.document.projectId, [...options.document.canvases.values()], [...options.document.elements.values()], options.document.tombstones), versions: openingVersions, ...(openingLoading?{loading:openingLoading}:{}), ledger: createLedger().getSnapshot(), receipts: readMap(new Map()), conflicts: readMap(new Map()), resources: readMap(new Map()), resourcePins: readMap(new Map()) });
|
||||
validateLoadingRuntime(initial,options.registry);
|
||||
const editor = createEditor({ registry: options.registry, document: options.document, recovery: recovered, runtime: initial, createId: options.createId, historyLimit: options.historyLimit,queryBudget:options.queryBudget,
|
||||
policy(command, document) { if(options.isCurrentIdentity&&!options.isCurrentIdentity())return{enabled:false,code:'identity_changed',targets:[]};if (closing || closed || recovery) return { enabled: false, code: closing||closed?'engine_closing':'recovery_in_progress', targets: [] }; if(editor.getSnapshot().runtime.loading){const allowed=loadingCommand(command,document,options.registry);if(!allowed.enabled)return allowed;}return options.policy?.(command, document) ?? { enabled: true }; },
|
||||
traversalPolicy(entry, direction) { if(options.isCurrentIdentity&&!options.isCurrentIdentity())return{enabled:false,code:'identity_changed',targets:[]};if(closing||closed||recovery)return{enabled:false,code:closing||closed?'engine_closing':'recovery_in_progress',targets:[]};if(editor.getSnapshot().runtime.loading){try{assertLoadingChanges(entry.changes,editor.getSnapshot().runtime.acknowledged,options.registry);}catch(error){return{enabled:false,code:(error as Error).message,targets:[]};}}return options.traversalPolicy?.(entry,direction)??{enabled:true}; },
|
||||
const editor = createEditor({ registry: options.registry, document: options.document, recovery: recovered, runtime: initial, createId: options.createId, historyLimit: options.historyLimit,queryBudget:options.queryBudget,queryCoverage:loadingQueryCoverage,
|
||||
policy(command, document) { if(options.isCurrentIdentity&&!options.isCurrentIdentity())return{enabled:false,code:'identity_changed',targets:[]};if (closing || closed || recovery) return { enabled: false, code: closing||closed?'engine_closing':'recovery_in_progress', targets: [] }; if(editor.getSnapshot().runtime.loading){const allowed=loadingCommand(command,document,options.registry,editor.getSnapshot().runtime.loading,editor.getSnapshot().runtime.acknowledged);if(!allowed.enabled)return allowed;}return options.policy?.(command, document) ?? { enabled: true }; },
|
||||
traversalPolicy(entry, direction) { if(options.isCurrentIdentity&&!options.isCurrentIdentity())return{enabled:false,code:'identity_changed',targets:[]};if(closing||closed||recovery)return{enabled:false,code:closing||closed?'engine_closing':'recovery_in_progress',targets:[]};if(editor.getSnapshot().runtime.loading){try{assertLoadingChanges(entry.changes,editor.getSnapshot().runtime.acknowledged,options.registry,editor.getSnapshot().runtime.loading);}catch(error){return{enabled:false,code:(error as Error).message,targets:[]};}}return options.traversalPolicy?.(entry,direction)??{enabled:true}; },
|
||||
onListenerError: report,
|
||||
prepareRuntime:(previous,next)=>{
|
||||
if(!acceptingLoading&&!recovery&&(previous.runtime.loading||next.runtime.loading)&&(!equal(previous.runtime.loading,next.runtime.loading)||previous.runtime.acknowledged!==next.runtime.acknowledged||!equal(Object.fromEntries(previous.runtime.versions),Object.fromEntries(next.runtime.versions))))throw fault('loading_state_owned');
|
||||
@@ -116,7 +116,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
prepareIntent: intent => applying && intent.origin==='user' ? {...intent,operationType:'run.apply',action:applying} : intent,
|
||||
nextOperationSequence(runtime, fallback) { let next = fallback; for (const record of runtime.ledger.operations.values()) next = Math.max(next, record.intent.sequence + 1); return next + staged.length; },
|
||||
reduceRuntime(previous, next, commit) {
|
||||
if(previous.loading)assertLoadingChanges(commit.changes,previous.acknowledged,options.registry);
|
||||
if(previous.loading)assertLoadingChanges(commit.changes,previous.acknowledged,options.registry,previous.loading);
|
||||
if (!commit.operationIds.length) return previous;
|
||||
const ledger = createLedger(previous.ledger), resources = new Map(previous.resources);
|
||||
const firstSequence = next.operations.get(commit.operationIds[0])!.sequence - staged.length;
|
||||
|
||||
@@ -37,7 +37,7 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou
|
||||
const preview=settings.onPreview?createModelPreview(options.registry):undefined;let seen=0,previewFailed=false;let declaredCanvasIds:readonly Id[]=[];
|
||||
const onUpdate=(state:import('./loader.ts').LoadSnapshot<CanonicalReadUpdate>)=>{
|
||||
if(!preview||previewFailed||disposed||accountId!==account||authEpoch!==readEpoch||settings.signal?.aborted)return;
|
||||
try{for(;seen<state.chunks.length;seen++){const chunk=state.chunks[seen],value=preview.update(chunk.value);if(chunk.value.status==='receiving'&&value){if(chunk.sequence===0)declaredCanvasIds=Object.freeze(chunk.value.canvases.map(canvas=>canvas.id));settings.onPreview?.(Object.freeze({...value,versions:readMap(new Map(Object.entries(chunk.serverVersions))),loading:Object.freeze({version:1 as const,sourceToken:chunk.snapshotToken,declaredCanvasIds,receivedElements:value.receivedElements,totalElements:value.totalElements})}));}}}
|
||||
try{for(;seen<state.chunks.length;seen++){const chunk=state.chunks[seen],value=preview.update(chunk.value);if(chunk.value.status==='receiving'&&value){if(chunk.sequence===0)declaredCanvasIds=Object.freeze(chunk.value.canvases.map(canvas=>canvas.id));settings.onPreview?.(Object.freeze({...value,versions:readMap(new Map(Object.entries(chunk.serverVersions))),loading:Object.freeze({...(value.canvasElementCounts?{version:2 as const,canvasElementCounts:value.canvasElementCounts}:{version:1 as const}),sourceToken:chunk.snapshotToken,declaredCanvasIds,receivedElements:value.receivedElements,totalElements:value.totalElements})}));}}}
|
||||
catch(error){previewFailed=true;try{options.onObserverError?.(error);}catch{}}
|
||||
};
|
||||
const handle=reader.acquire({accountId:account,workspaceId:workspace,projectId:null,canvasId:canvas,viewKey:'canonical-project',format:'canonical-model-v1',queryFingerprint:'full-project',source:'foundry',authEpoch,navigationGeneration:settings.navigationGeneration??0},{signal:settings.signal,onUpdate});
|
||||
|
||||
@@ -27,3 +27,5 @@ export {createModelPreview} from './model-preview.ts';
|
||||
export type {ModelPreview,LiveModelPreview} from './model-preview.ts';
|
||||
|
||||
export type {LoadingState} from './loading.ts';
|
||||
|
||||
export type {CanvasElementCounts} from './scope-counts.ts';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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';
|
||||
@@ -5,18 +6,26 @@ 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. */
|
||||
export type LoadingState={readonly version:1;readonly sourceToken:string;readonly declaredCanvasIds:readonly Id[];readonly receivedElements:number;readonly totalElements:number};
|
||||
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||value.version!==1)throw fault('loading_interpreter_unavailable');
|
||||
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]==='*');
|
||||
}
|
||||
export function loadingCommand(command:Command,document:Document,registry:Registry):Availability{
|
||||
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;
|
||||
@@ -24,11 +33,11 @@ export function loadingCommand(command:Command,document:Document,registry:Regist
|
||||
}
|
||||
/** 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):void{
|
||||
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==='geometry'&&element.structure==='node'||change.field.startsWith('data.')&&dataKeyAllowed(element.componentId,change.field.slice(5),registry)))throw fault('model_loading',[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{
|
||||
@@ -37,12 +46,12 @@ export function validateLoadingRuntime(runtime:RuntimeState,registry:Registry):v
|
||||
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);
|
||||
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.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');
|
||||
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. */
|
||||
@@ -54,6 +63,17 @@ 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(!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]):[]};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {validateCanvasElementCounts,type CanvasElementCounts} from './scope-counts.ts';
|
||||
import type { CanvasRecord, Document, ElementRecord, Id, Registry } from '../canvas-core/types.ts';
|
||||
import { ancestors, createDocument, validateDocument, validateElementRecord } from '../canvas-core/document.ts';
|
||||
import { copyReferenceIds } from '../canvas-core/copy-paths.ts';
|
||||
@@ -5,10 +6,11 @@ import { clone, equal, fault, freeze, readMap } from '../canvas-core/values.ts';
|
||||
import type { CanonicalReadUpdate } from './contracts.ts';
|
||||
|
||||
export type LiveModelPreview=ModelPreview&{readonly loading:import('./loading.ts').LoadingState;readonly versions:ReadonlyMap<Id,number>};
|
||||
export type ModelPreview = { readonly document:Document;readonly receivedElements:number;readonly totalElements:number;readonly waitingElements:number;readonly complete:false };
|
||||
export type ModelPreview = { readonly canvasElementCounts?:CanvasElementCounts; readonly document:Document;readonly receivedElements:number;readonly totalElements:number;readonly waitingElements:number;readonly complete:false };
|
||||
/** An ephemeral read projection. It owns neither a working document nor ACKs,
|
||||
* history, operations, file bytes or a durable recovery cut. */
|
||||
export function createModelPreview(registry:Registry){
|
||||
let canvasElementCounts:CanvasElementCounts|undefined;const ownerCounts=new Map<Id,number>();
|
||||
let header=false,finished=false,projectId='',expected=0,received=0;
|
||||
let tombstones:ReadonlyMap<Id,number>=new Map(),published:Document|undefined,snapshot:ModelPreview|undefined;
|
||||
const canvases=new Map<Id,CanvasRecord>(),records=new Map<Id,ElementRecord>();
|
||||
@@ -32,12 +34,13 @@ export function createModelPreview(registry:Registry){
|
||||
else if(!canvas.parent||typeof canvas.parent.entryElementId!=='string'||!canvas.parent.entryElementId||entries.has(canvas.parent.entryElementId))throw fault('invalid_canvas_entry');
|
||||
else entries.add(canvas.parent.entryElementId);
|
||||
}
|
||||
if(roots!==1)throw fault('invalid_roots');header=true;
|
||||
}else if(value.projectId!==projectId||value.totalElements!==expected||value.canvases.length||value.tombstones.size)throw fault('model_preview_changed');
|
||||
if(roots!==1)throw fault('invalid_roots');if(value.canvasElementCounts!==undefined){validateCanvasElementCounts(value.canvasElementCounts,[...canvases.keys()],expected);canvasElementCounts=freeze(clone(value.canvasElementCounts));}header=true;
|
||||
}else if(value.projectId!==projectId||value.totalElements!==expected||value.canvases.length||value.tombstones.size||value.canvasElementCounts!==undefined)throw fault('model_preview_changed');
|
||||
if(value.receivedElements!==received+value.elements.length||value.receivedElements>expected)throw fault('invalid_model_preview');
|
||||
// Validate the entire incoming batch before publishing any prefix from it.
|
||||
const incoming=new Set<Id>();
|
||||
for(const record of value.elements){if(records.has(record.id)||known.has(key('element',record.id))||incoming.has(record.id)||!canvases.has(record.ownerCanvasId))throw fault('invalid_model_preview');validateElementRecord(record,registry,tombstones);incoming.add(record.id);}
|
||||
for(const record of value.elements){const count=(ownerCounts.get(record.ownerCanvasId)??0)+1;if(canvasElementCounts&&count>canvasElementCounts[record.ownerCanvasId])throw fault('invalid_model_coverage');ownerCounts.set(record.ownerCanvasId,count);}
|
||||
for(const record of value.elements)records.set(record.id,freeze(clone(record)));received=value.receivedElements;
|
||||
// Greatest closed subset: removing an unavailable dependency also removes its
|
||||
// dependants. Fully received cycles survive and publish in one legal prefix.
|
||||
@@ -56,7 +59,7 @@ export function createModelPreview(registry:Registry){
|
||||
const nextCanvases=[...(published?.canvases.values()??[])],nextElements=[...(published?.elements.values()??[])],accepted:string[]=[];
|
||||
for(const [id,item]of candidates)if(!blocked.has(id)){if(item.canvas)nextCanvases.push(item.canvas);if(item.element)nextElements.push(item.element);accepted.push(id);}
|
||||
if(!published||accepted.length){const next=createDocument(projectId,nextCanvases,nextElements,tombstones);validateDocument(next,registry);published=next;for(const id of accepted){known.add(id);const item=candidates.get(id);if(item?.element)records.delete(item.element.id);}}
|
||||
snapshot=Object.freeze({document:published,receivedElements:received,totalElements:expected,waitingElements:received-published.elements.size,complete:false});return snapshot;
|
||||
snapshot=Object.freeze({...(canvasElementCounts?{canvasElementCounts}:{}),document:published,receivedElements:received,totalElements:expected,waitingElements:received-published.elements.size,complete:false});return snapshot;
|
||||
}
|
||||
return Object.freeze({update(value:CanonicalReadUpdate){try{return update(value);}catch(error){finished=true;throw error;}},getSnapshot:()=>snapshot,close(){finished=true;records.clear();canvases.clear();known.clear();published=undefined;snapshot=undefined;}});
|
||||
}
|
||||
|
||||
@@ -52,8 +52,8 @@ export function encodeRecoveryValue(input:unknown):Json {
|
||||
}
|
||||
|
||||
/** Only codec-owned, validated immutable images qualify for the storage copy fast path. */
|
||||
export function sealRecoveryImage(formatVersion:1|2|3|4|5|6|7,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage {
|
||||
if(![1,2,3,4,5,6,7].includes(formatVersion)||!Number.isSafeInteger(rootRevision)||rootRevision<0||!Number.isSafeInteger(localRevision)||localRevision<0||localRevision>rootRevision)throw fault('corrupt_recovery_image');
|
||||
export function sealRecoveryImage(formatVersion:1|2|3|4|5|6|7|8,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage {
|
||||
if(![1,2,3,4,5,6,7,8].includes(formatVersion)||!Number.isSafeInteger(rootRevision)||rootRevision<0||!Number.isSafeInteger(localRevision)||localRevision<0||localRevision>rootRevision)throw fault('corrupt_recovery_image');
|
||||
const image=Object.freeze({formatVersion,rootRevision,localRevision,payload:encodeRecoveryValue(payload)});sealed.add(image);return image;
|
||||
}
|
||||
export function isSealedRecoveryImage(image:RecoveryImage):boolean{return sealed.has(image);}
|
||||
|
||||
@@ -28,7 +28,7 @@ export function encodeRecovery(snapshot: EngineSnapshot): RecoveryImage {
|
||||
operations: [...snapshot.operations],
|
||||
runtime: { ...(snapshot.runtime.loading?{loading:snapshot.runtime.loading}:{}), ...(snapshot.runtime.orphanFiles?.size?{orphanFiles:[...snapshot.runtime.orphanFiles]}:{}), imports:[...(snapshot.runtime.imports??[])], recoveries:[...(snapshot.runtime.recoveries??[])], runs: [...snapshot.runtime.runs], resources: [...snapshot.runtime.resources], resourcePins: [...snapshot.runtime.resourcePins], acknowledged: documentValue(snapshot.runtime.acknowledged), versions: [...snapshot.runtime.versions], receipts: [...snapshot.runtime.receipts], conflicts: [...snapshot.runtime.conflicts], ledger: { ...snapshot.runtime.ledger, operations: [...snapshot.runtime.ledger.operations], requests: [...snapshot.runtime.ledger.requests] } },
|
||||
};
|
||||
return sealRecoveryImage(snapshot.runtime.loading?7:[...snapshot.operations.values()].some(intent=>intent.historyEffect)||[...snapshot.runtime.resources.values()].some(resource=>resource.sizing)?6:snapshot.runtime.orphanFiles?.size?5:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.retirement||job.source?.previousFiles?.length)?4:[...snapshot.runtime.resources.values()].some(resource=>resource.retirement)?3:snapshot.runtime.imports?.size ? 2 : 1,snapshot.rootRevision,snapshot.revision,payload);
|
||||
return sealRecoveryImage(snapshot.runtime.loading?(snapshot.runtime.loading.version===2?8:7):[...snapshot.operations.values()].some(intent=>intent.historyEffect)||[...snapshot.runtime.resources.values()].some(resource=>resource.sizing)?6:snapshot.runtime.orphanFiles?.size?5:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.retirement||job.source?.previousFiles?.length)?4:[...snapshot.runtime.resources.values()].some(resource=>resource.retirement)?3:snapshot.runtime.imports?.size ? 2 : 1,snapshot.rootRevision,snapshot.revision,payload);
|
||||
}
|
||||
type ObjectValue = Record<string, unknown>;
|
||||
function object(value: unknown): ObjectValue { if (!value || Array.isArray(value) || typeof value !== 'object') throw fault('corrupt_recovery_image'); return value as ObjectValue; }
|
||||
@@ -92,13 +92,13 @@ function readHistory(history:ObjectValue,operations:ReadonlyMap<Id,OperationInte
|
||||
return Object.freeze({undo:freeze(undo),redo:freeze(redo),entries});
|
||||
}
|
||||
export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSnapshot & { readonly rootRevision: number; readonly runtime: RuntimeState } {
|
||||
if (![1,2,3,4,5,6,7].includes(image.formatVersion)) throw fault('unsupported_recovery_version');
|
||||
if (![1,2,3,4,5,6,7,8].includes(image.formatVersion)) throw fault('unsupported_recovery_version');
|
||||
integer(image.rootRevision); integer(image.localRevision);
|
||||
if (image.localRevision > image.rootRevision || !validJson(image.payload)) throw fault('corrupt_recovery_image');
|
||||
// Own the parsed records; mutating a caller's RecoveryImage must not mutate live state.
|
||||
const raw = object(JSON.parse(JSON.stringify(image.payload))), document = readDocument(raw.document, registry), history = object(raw.history), runtime = object(raw.runtime), ledger = object(runtime.ledger);
|
||||
if(runtime.loading!==undefined&&image.formatVersion!==7)throw fault('unsupported_recovery_version');
|
||||
if(image.formatVersion===7&&runtime.loading===undefined)throw fault('invalid_loading_state');
|
||||
if(runtime.loading!==undefined&&image.formatVersion!==(object(runtime.loading).version===2?8:7))throw fault('unsupported_recovery_version');
|
||||
if(image.formatVersion>=7&&runtime.loading===undefined)throw fault('invalid_loading_state');
|
||||
const operations = table(raw.operations, intent),restoredHistory=readHistory(history,operations);
|
||||
if(image.formatVersion<6&&[...operations.values()].some(intent=>intent.historyEffect))throw fault('unsupported_recovery_version');
|
||||
for(const operation of operations.values())if(operation.historyTransition?.version===1&&(operation.historyTransition.rootRevision<1||operation.historyTransition.rootRevision>image.rootRevision))throw fault('corrupt_recovery_image');
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {Id} from '../canvas-core/types.ts';
|
||||
import {fault} from '../canvas-core/values.ts';
|
||||
|
||||
/** Snapshot-bound direct membership counts, including nodes and connections. */
|
||||
export type CanvasElementCounts=Readonly<Record<Id,number>>;
|
||||
export function validateCanvasElementCounts(value:unknown,canvasIds:readonly Id[],total:number):asserts value is CanvasElementCounts {
|
||||
if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_model_coverage');
|
||||
const counts=value as Record<Id,unknown>,keys=Object.keys(counts);let sum=0;
|
||||
if(keys.length!==canvasIds.length||canvasIds.some(id=>!Object.hasOwn(counts,id)))throw fault('invalid_model_coverage');
|
||||
for(const count of Object.values(counts)){if(typeof count!=='number'||!Number.isSafeInteger(count)||count<0)throw fault('invalid_model_coverage');sum+=count;if(!Number.isSafeInteger(sum))throw fault('invalid_model_coverage');}
|
||||
if(sum!==total)throw fault('invalid_model_coverage');
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { Json } from '../canvas-core/types.ts';
|
||||
|
||||
/** Portable durable-store contract. Browser database types belong to the adapter. */
|
||||
export type StorageScope = { readonly accountId: string; readonly workspaceId: string; readonly projectId: string };
|
||||
export type RecoveryImage = { readonly formatVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7; readonly rootRevision: number; readonly localRevision: number; readonly payload: Json };
|
||||
export type RecoveryImage = { readonly formatVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; readonly rootRevision: number; readonly localRevision: number; readonly payload: Json };
|
||||
export type DurableBatch = { readonly image: RecoveryImage; readonly deleteFiles?:readonly string[]; readonly files?: readonly { readonly id: string; readonly bytes: Blob }[] };
|
||||
export type StorageLeader = { readonly scope: StorageScope; readonly fence: number; readonly active: boolean; release(): Promise<void> };
|
||||
export type StoredFileInfo={readonly id:string;readonly byteLength:number;readonly mimeType:string};
|
||||
|
||||
@@ -30,7 +30,7 @@ function purgedFileIds(image:RecoveryImage):Set<string>{
|
||||
return result;
|
||||
}
|
||||
function validateImage(image: RecoveryImage) {
|
||||
if (![1,2,3,4,5,6,7].includes(image.formatVersion)) throw fault('unsupported_recovery_version');
|
||||
if (![1,2,3,4,5,6,7,8].includes(image.formatVersion)) throw fault('unsupported_recovery_version');
|
||||
if (!Number.isSafeInteger(image.rootRevision) || image.rootRevision < 0 || !Number.isSafeInteger(image.localRevision) || image.localRevision < 0 || image.localRevision > image.rootRevision || !validJson(image.payload)) throw fault('corrupt_recovery_image');
|
||||
}
|
||||
const requestValue = <T>(request: IDBRequest<T>) => new Promise<T>((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });
|
||||
@@ -160,8 +160,8 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?:
|
||||
if(!('image' in candidate)&&(candidate.baseRevision!==previous||candidate.baseChecksum!==saved?.checksum))throw fault('recovery_delta_base_mismatch');
|
||||
for(const file of files){if(!file.id||!(file.bytes instanceof Blob))throw fault('invalid_staged_file');if(await requestValue(transaction.objectStore('files').getKey([key,file.id])))throw fault('resource_identity_conflict',[file.id]);transaction.objectStore('files').put(file.bytes,[key,file.id]);}
|
||||
for(const id of deleteFiles)transaction.objectStore('files').delete([key,id]);
|
||||
// A complete cut must not depend on a v7 partial checkpoint in an older reader.
|
||||
if(previous===undefined||(base?.image.formatVersion===7&&image.formatVersion!==7)||image.rootRevision-(metadata.checkpointRevision??0)>=checkpointEvery){
|
||||
// A format transition must not depend on a newer partial checkpoint in an older reader.
|
||||
if(previous===undefined||((base?.image.formatVersion??0)>=7&&base?.image.formatVersion!==image.formatVersion)||image.rootRevision-(metadata.checkpointRevision??0)>=checkpointEvery){
|
||||
transaction.objectStore('checkpoints').put(stored,[key,image.rootRevision]);if(metadata.checkpointRevision!==undefined)transaction.objectStore('checkpoints').delete([key,metadata.checkpointRevision]);transaction.objectStore('journal').delete(IDBKeyRange.bound([key,0],[key,image.rootRevision]));metadata.checkpointRevision=image.rootRevision;
|
||||
}else transaction.objectStore('journal').put(candidate,[key,image.rootRevision]);
|
||||
transaction.objectStore('metadata').put({...metadata,throughRevision:image.rootRevision},key);return{durableRootRevision:image.rootRevision};
|
||||
|
||||
Reference in New Issue
Block a user