Certify loaded canvas scopes and publish completeness with runtime queries
This commit is contained in:
+21
-1
@@ -36,7 +36,8 @@ publication, release support promise, server deployment or full-goal completion
|
||||
implied by this local package.
|
||||
|
||||
Experimental partial-model runtime: pass a validated prefix plus `loading` in
|
||||
`EngineOptions`. The marker requires recovery format 7 and keeps the scheduler held.
|
||||
`EngineOptions`. Version 1 uses recovery format 7; version 2 carries snapshot-bound direct canvas
|
||||
membership counts and uses format 8. Both keep the scheduler held.
|
||||
Loaded non-reference data fields and node geometry can be edited and undone; creation,
|
||||
deletions, ownership/reference changes, uploads, imports and AI admission wait.
|
||||
`engine.loading.accept(document, versions, state)` extends the captured prefix without
|
||||
@@ -56,3 +57,22 @@ view's stream and asks the retained project owner to complete through a fresh re
|
||||
Reload recovers the durable prefix when that project is opened again. This is not a
|
||||
persistent HTTP cursor or a workspace-wide startup scan; partial-scope ACK/chunk
|
||||
interleaving and broader structural permissions remain unfinished.
|
||||
|
||||
Scope-aware queries are obtained from `engine.editor.getQueries()` and share the
|
||||
same publication boundary as the root. `lookupElement(id)` returns `present`,
|
||||
`absent` (complete project), or `unloaded`; a missing ID in a partial model is not
|
||||
proof of absence. `membersResult(canvasId)` adds a completeness flag for direct
|
||||
members; `childCanvasesResult` covers published topology, while `incidentResult`
|
||||
requires the complete project because another owner may contain an incoming edge.
|
||||
Legacy array-returning methods enumerate loaded records only. Hit-query completeness
|
||||
also includes scope coverage. Derived indexes and coverage views are not persisted.
|
||||
|
||||
An optional `canvasElementCounts` field in the version-1 stream header allows new
|
||||
clients to certify individual scopes. Missing metadata retains legacy conservative
|
||||
coverage. Counts include nodes and connections; they must cover exactly the declared
|
||||
active canvases, sum to the total, and never be exceeded by received records. A scope
|
||||
is ready only after its full count has entered the dependency-closed published prefix.
|
||||
Version-2 loading permits `connection.path` edits and history in a certified owner
|
||||
scope; endpoint, ownership, creation and deletion edits still wait. Neither scope
|
||||
completion nor receiving the last element replaces terminal/EOF validation or releases
|
||||
network scheduling. Partial-scope ACK/chunk interleaving remains pending.
|
||||
|
||||
@@ -64,6 +64,7 @@ export const errorText = (error: unknown) => {
|
||||
unsupported_model_stream: "当前画布加载格式暂不受支持,请更新后重试。",
|
||||
model_stream_budget: "画布数据量超过当前加载上限,请联系支持。",
|
||||
load_snapshot_changed: "加载期间画布快照发生变化,请重新加载。",
|
||||
invalid_model_coverage: "画布的加载范围信息不一致,请重新加载。",
|
||||
model_loading: "画布尚在加载,请等待加载完成后编辑。",
|
||||
identity_changed: "账户已变化,当前画布已停止同步,请重新打开。",
|
||||
engine_closing: "画布正在关闭,本地修改会保留用于恢复。",
|
||||
|
||||
@@ -36,5 +36,6 @@ export function hitProjectedCanvas(projection:Projection,queries:DocumentQueries
|
||||
for(const segment of segments){if(remaining<=0){possible??={hit:true,certainty:'possible',steps:0,distance:Infinity,error:Infinity,budget:true};break;}const result=hitCubic(segment,point,tolerance+width/2,{error:errorPx/zoom,maxDepth,maxSteps:remaining});remaining-=result.steps;if(result.hit){if(result.certainty==='confirmed'){confirmed=result;break;}possible=result;}}
|
||||
const result=confirmed??possible;if(result?.hit){if(!confirmed)diagnostics.push({target:edge.id,code:result.budget?'geometry_query_budget':'geometry_precision'});hits.push({target:{id:edge.id,incarnation:record.incarnation},kind:'connection',ownerCanvasId:record.ownerCanvasId,displayPath:path(record.ownerCanvasId),displayedEndpoints:{source:edge.source,target:edge.target},layer:layer(edge.id,'connection',edge.zIndex??0),certainty:confirmed?'confirmed':'possible',...(Number.isFinite(result.distance)?{distanceScene:result.distance}:{}),...(Number.isFinite(result.error)?{errorScene:result.error}:{})});}
|
||||
}catch(error){diagnostics.push({target:edge.id,code:(error as{code?:string}).code??'geometry_handler_failed'});}}
|
||||
if([...projection.transforms.keys()].some(id=>!queries.coverage.completeCanvasIds.includes(id)))diagnostics.push({code:'model_scope_incomplete'});
|
||||
hits.sort((a,b)=>b.layer-a.layer||(a.kind===b.kind?0:a.kind==='node'?-1:1)||(rank.get(b.target.id)??0)-(rank.get(a.target.id)??0));return freeze({projectId:document.projectId,revision:queries.revision,viewSessionId,pointScene:{...point},toleranceScene:tolerance,hits,complete:diagnostics.length===0,diagnostics});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Applied, Availability, Capability, Change, Command, Commit, CoreSn
|
||||
import { applyChanges, createDocument, invert, targetElement, validateDocument } from './document.ts';
|
||||
import { clone, equal, fault, freeze, readMap, reject, validJson } from './values.ts';
|
||||
|
||||
import {createDocumentQueries,advanceDocumentQueries,type QueryBudget} from './queries.ts';
|
||||
import {createDocumentQueries,advanceDocumentQueries,withQueryCoverage,type QueryCoverage,type QueryBudget} from './queries.ts';
|
||||
import { canvasTransform, convertGeometry } from './coordinates.ts';
|
||||
import { unitScale, withUnitScale } from './units.ts';
|
||||
import { removalChanges, repairConnectionOwners } from './structure.ts';
|
||||
@@ -13,6 +13,7 @@ import type { CreationSizeEffect } from './types.ts';
|
||||
|
||||
export type EditorOptions<Runtime = undefined> = {
|
||||
registry: Registry; document: Document; queryBudget?:QueryBudget; createId?: () => Id; historyLimit?: number;
|
||||
queryCoverage?: (snapshot:Snapshot<Runtime>)=>QueryCoverage;
|
||||
policy?: (command: Command, document: Document) => Availability;
|
||||
traversalPolicy?: (entry: HistoryEntry, direction: 'undo' | 'redo') => Availability;
|
||||
onListenerError?: (error: unknown) => void;
|
||||
@@ -36,7 +37,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
const document = createDocument(source.projectId, [...source.canvases.values()], [...source.elements.values()], source.tombstones);
|
||||
validateDocument(document, options.registry);
|
||||
let state: Snapshot<Runtime> = Object.freeze({ revision: options.recovery?.revision ?? 0, rootRevision: options.recovery?.rootRevision ?? 0, document, history: options.recovery?.history ?? Object.freeze({ undo: Object.freeze([]), redo: Object.freeze([]), entries: readMap(new Map<Id, HistoryEntry>()) }), operations: options.recovery?.operations ?? readMap(new Map<Id, OperationIntent>()), runtime: options.runtime as Runtime });
|
||||
let queries=createDocumentQueries(document,options.registry,state.revision,options.queryBudget);
|
||||
const bindCoverage=(snapshot:Snapshot<Runtime>,queries:ReturnType<typeof createDocumentQueries>)=>options.queryCoverage?withQueryCoverage(queries,options.queryCoverage(snapshot)):queries;
|
||||
let queries=bindCoverage(state,createDocumentQueries(document,options.registry,state.revision,options.queryBudget));
|
||||
const queriesByDocument=new WeakMap([[document,queries]]);
|
||||
let entered = false, disposed = false, sequence = 0;
|
||||
for (const intent of state.operations.values()) sequence = Math.max(sequence, intent.sequence);
|
||||
@@ -191,7 +193,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
const commit = freeze({ revision: next.revision, origin, changes, operationIds });
|
||||
const runtime = options.reduceRuntime ? options.reduceRuntime(state.runtime, next, commit) : state.runtime;
|
||||
if (isThenable(runtime)) throw fault('async_transaction');
|
||||
const nextState=prepareState(Object.freeze({ ...next, rootRevision: state.rootRevision + 1, runtime })),nextQueries=advanceDocumentQueries(queries,document,options.registry,next.revision,changes);
|
||||
const nextState=prepareState(Object.freeze({ ...next, rootRevision: state.rootRevision + 1, runtime })),nextQueries=bindCoverage(nextState,advanceDocumentQueries(queries,document,options.registry,next.revision,changes));
|
||||
state=nextState;queries=nextQueries;queriesByDocument.set(document,queries);
|
||||
if (outbound) sequence = nextSequence;
|
||||
emit(commit);
|
||||
@@ -297,7 +299,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
const runtime = reduce(state.runtime);
|
||||
if (isThenable(runtime)) throw fault('async_transaction');
|
||||
if (runtime !== state.runtime) {
|
||||
state = prepareState(Object.freeze({ ...state, rootRevision: state.rootRevision + 1, runtime }));
|
||||
const nextState=prepareState(Object.freeze({ ...state, rootRevision: state.rootRevision + 1, runtime })),nextQueries=bindCoverage(nextState,queries);
|
||||
state=nextState;queries=nextQueries;queriesByDocument.set(state.document,queries);
|
||||
emit(freeze({ revision: state.revision, origin: 'ack', changes: [], operationIds: [] }));
|
||||
}
|
||||
return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) });
|
||||
@@ -322,8 +325,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
for(const [id,entry]of history.entries)if(entry.id!==id||entry.operationIds.some(operationId=>!operations.has(operationId)))throw fault('invalid_history');
|
||||
}
|
||||
const changed = !documentsEqual(next, state.document);
|
||||
const nextState=prepareState(Object.freeze({ ...state, ...(replacement?{history:replacement.history,operations:replacement.operations}:{}), document: changed ? next : state.document, runtime, revision: state.revision + Number(changed), rootRevision: state.rootRevision + 1 })),nextQueries=changed?advanceDocumentQueries(queries,next,options.registry,state.revision+1):queries;
|
||||
state=nextState;queries=nextQueries;if(changed)queriesByDocument.set(next,queries);
|
||||
const nextState=prepareState(Object.freeze({ ...state, ...(replacement?{history:replacement.history,operations:replacement.operations}:{}), document: changed ? next : state.document, runtime, revision: state.revision + Number(changed), rootRevision: state.rootRevision + 1 })),nextQueries=bindCoverage(nextState,changed?advanceDocumentQueries(queries,next,options.registry,state.revision+1):queries);
|
||||
state=nextState;queries=nextQueries;queriesByDocument.set(state.document,queries);
|
||||
sequence=nextSequence;
|
||||
emit(freeze({ revision: state.revision, origin: 'ack', changes: [], operationIds: [] }));
|
||||
return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) });
|
||||
|
||||
@@ -15,6 +15,6 @@ export {createDuplicatePlanner,validateDuplicateCheckpoint} from './duplicate-ch
|
||||
export type {DuplicateCheckpoint} from './duplicate-checkpoint.ts';
|
||||
|
||||
export {createDocumentQueries,advanceDocumentQueries} from './queries.ts';
|
||||
export type {DocumentQueries,Bounds,QueryBudget} from './queries.ts';
|
||||
export type {DocumentQueries,Bounds,QueryBudget,QueryCoverage,ElementLookup,IdQueryResult} from './queries.ts';
|
||||
|
||||
export {rectangleGeometry,rectangleBounds,hitRectangle,bezierChainSegments} from './geometry.ts';
|
||||
|
||||
@@ -33,7 +33,15 @@ function order(previous:Map<Id,number>|undefined,ids:Iterable<Id>){const list=[.
|
||||
function protectSpatial(spatial:Spatial){return Object.freeze({cells:readMap(spatial.cells),entries:readMap(spatial.entries),oversized:spatial.oversized});}
|
||||
function intersect(a:Box,b:Box){return a.minX<=b.maxX&&a.maxX>=b.minX&&a.minY<=b.maxY&&a.maxY>=b.minY;}
|
||||
function finite(box:Box){return Object.values(box).every(Number.isFinite);}
|
||||
export type QueryCoverage={readonly projectComplete:boolean;readonly topologyComplete:boolean;readonly completeCanvasIds:readonly Id[]};
|
||||
export type ElementLookup={readonly status:'present';readonly element:ElementRecord}|{readonly status:'absent'|'unloaded'};
|
||||
export type IdQueryResult={readonly ids:readonly Id[];readonly complete:boolean};
|
||||
export type DocumentQueries={
|
||||
readonly coverage:QueryCoverage;
|
||||
lookupElement(id:Id):ElementLookup;
|
||||
membersResult(canvas:Id):IdQueryResult;
|
||||
childCanvasesResult(canvas:Id):IdQueryResult;
|
||||
incidentResult(element:Id):IdQueryResult;
|
||||
readonly document:Document;readonly registry:Registry;readonly revision:number;readonly budget:ResolvedBudget;
|
||||
readonly relations:{readonly ownerMembers:ReadonlyMap<Id,ReadonlyValueSet<Id>>;readonly canvasChildren:ReadonlyMap<Id,ReadonlyValueSet<Id>>;readonly childCanvasByEntry:ReadonlyMap<Id,Id>;readonly incidentEdges:ReadonlyMap<Id,ReadonlyValueSet<Id>>;readonly elementReferrers:ReadonlyMap<Id,ReadonlyValueSet<Id>>;readonly canvasReferrers:ReadonlyMap<Id,ReadonlyValueSet<Id>>;readonly opaqueReferences:ReadonlyValueSet<Id>;readonly referenceComplete:boolean};
|
||||
readonly spatial:ReadonlyMap<Id,{readonly cells:ReadonlyMap<Id,ReadonlyValueSet<Id>>;readonly entries:ReadonlyMap<Id,Box>;readonly oversized:ReadonlyValueSet<Id>}>;
|
||||
@@ -48,12 +56,34 @@ function expose(document:Document,revision:number,tables:Tables,previous?:Docume
|
||||
const table=tables.spatial.get(canvas);if(!table)return Object.freeze([]) as readonly Id[];const box={minX:rect.x,minY:rect.y,maxX:rect.x+rect.width,maxY:rect.y+rect.height},keys=cells(box,tables.budget.maxQueryCells,tables.budget.cellSize),ids=new Set<Id>(keys?table.oversized:table.entries.keys());if(keys)for(const key of keys)for(const id of table.cells.get(key)??[])ids.add(id);
|
||||
return sorted([...ids].filter(id=>{const entry=table.entries.get(id)!;return !finite(entry)||intersect(box,entry);}));
|
||||
}
|
||||
const view=Object.freeze({document,registry:tables.registry,revision,budget:tables.budget,relations:relation,spatial,
|
||||
const view:DocumentQueries=Object.freeze({
|
||||
coverage:Object.freeze({projectComplete:true,topologyComplete:true,completeCanvasIds:Object.freeze([...document.canvases.keys()])}),
|
||||
lookupElement:(id:Id):ElementLookup=>{const element=document.elements.get(id);return element?Object.freeze({status:'present',element}):Object.freeze({status:'absent'});},
|
||||
membersResult:(canvas:Id)=>Object.freeze({ids:view.members(canvas),complete:true}),
|
||||
childCanvasesResult:(canvas:Id)=>Object.freeze({ids:view.childCanvases(canvas),complete:true}),
|
||||
incidentResult:(element:Id)=>Object.freeze({ids:view.incident(element),complete:true}),
|
||||
document,registry:tables.registry,revision,budget:tables.budget,relations:relation,spatial,
|
||||
members:(canvas:Id)=>{if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);return sortedBucket(tables.members.get(canvas)??empty,tables.order);},childCanvases:(canvas:Id)=>{if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);return sortedBucket(tables.children.get(canvas)??empty,tables.canvasOrder);},incident:(element:Id)=>{if(!document.elements.has(element))throw fault('element_missing',[element]);return sortedBucket(tables.incident.get(element)??empty,tables.order);},orderedElements:(ids:Iterable<Id>)=>sorted(ids),orderedCanvases:(ids:Iterable<Id>)=>sorted(ids,tables.canvasOrder),candidateNodes:candidates,
|
||||
hitNodes(canvas:Id,point:Point,tolerance=0){if(![point.x,point.y,tolerance].every(Number.isFinite)||tolerance<0)throw fault('invalid_query_point');const hits:Id[]=[],unavailable:Id[]=[];for(const id of candidates(canvas,{x:point.x-tolerance,y:point.y-tolerance,width:2*tolerance,height:2*tolerance})){const record=document.elements.get(id)! as Extract<ElementRecord,{structure:'node'}>;try{if(!finite(tables.spatial.get(canvas)!.entries.get(id)!))throw fault('geometry_unavailable');const protocol=tables.registry.resolve(record)==='known'?tables.registry.components.get(record.componentId)?.geometry:undefined,result=protocol?.kind==='node'?protocol.hit({element:record,geometry:record.geometry,point,tolerance}):hitRectangle(record.geometry,point,tolerance);if(typeof result!=='boolean')throw fault('invalid_geometry_response');if(result)hits.push(id);}catch{unavailable.push(id);}}return Object.freeze({ids:Object.freeze(hits),complete:!unavailable.length,unavailable:Object.freeze(unavailable)});},
|
||||
hitNodeBounds(canvas:Id,point:Point,tolerance=0){if(![point.x,point.y,tolerance].every(Number.isFinite)||tolerance<0)throw fault('invalid_query_point');const ids=candidates(canvas,{x:point.x-tolerance,y:point.y-tolerance,width:2*tolerance,height:2*tolerance}),hits:Id[]=[];let complete=true;for(const id of ids){const g=(document.elements.get(id)! as Extract<ElementRecord,{structure:'node'}>).geometry,box=tables.spatial.get(canvas)!.entries.get(id)!;if(!finite(box)){complete=false;continue;}const angle=-g.rotation*Math.PI/180,c=Math.cos(angle),s=Math.sin(angle),dx=point.x-g.x-g.width/2,dy=point.y-g.y-g.height/2,x=dx*c-dy*s,y=dx*s+dy*c,ox=Math.max(0,Math.abs(x)-g.width/2),oy=Math.max(0,Math.abs(y)-g.height/2);if(Math.hypot(ox,oy)<=tolerance+Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),g.width,g.height))hits.push(id);}return Object.freeze({ids:Object.freeze(hits),complete});},
|
||||
});privateTables.set(view,tables);return view;
|
||||
}
|
||||
const queryBases=new WeakMap<DocumentQueries,DocumentQueries>();
|
||||
/** Bind completeness to the same publication as the model, without rebuilding indexes. */
|
||||
export function withQueryCoverage(queries:DocumentQueries,input:QueryCoverage):DocumentQueries {
|
||||
const base=queryBases.get(queries)??queries,ids=Object.freeze([...input.completeCanvasIds]);
|
||||
if(new Set(ids).size!==ids.length||ids.some(id=>!base.document.canvases.has(id))||input.projectComplete&&(!input.topologyComplete||ids.length!==base.document.canvases.size))throw fault('invalid_query_coverage');
|
||||
const coverage=Object.freeze({projectComplete:input.projectComplete,topologyComplete:input.topologyComplete,completeCanvasIds:ids}),complete=new Set(ids);
|
||||
const view:DocumentQueries=Object.freeze({...base,coverage,
|
||||
relations:Object.freeze({...base.relations,referenceComplete:base.relations.referenceComplete&&coverage.projectComplete}),
|
||||
lookupElement:(id:Id):ElementLookup=>{const element=base.document.elements.get(id);return element?Object.freeze({status:'present',element}):Object.freeze({status:coverage.projectComplete?'absent':'unloaded'});},
|
||||
membersResult:(canvas:Id)=>Object.freeze({ids:base.members(canvas),complete:complete.has(canvas)}),
|
||||
childCanvasesResult:(canvas:Id)=>Object.freeze({ids:base.childCanvases(canvas),complete:coverage.topologyComplete}),
|
||||
incidentResult:(element:Id)=>Object.freeze({ids:base.incident(element),complete:coverage.projectComplete}),
|
||||
hitNodes(canvas:Id,point:Point,tolerance=0){const result=base.hitNodes(canvas,point,tolerance);return Object.freeze({...result,complete:result.complete&&complete.has(canvas)});},
|
||||
hitNodeBounds(canvas:Id,point:Point,tolerance=0){const result=base.hitNodeBounds(canvas,point,tolerance);return Object.freeze({...result,complete:result.complete&&complete.has(canvas)});},
|
||||
});queryBases.set(view,base);const tables=privateTables.get(base);if(tables)privateTables.set(view,tables);return view;
|
||||
}
|
||||
/** Pure derived query snapshot. Never serialize these tables or publish them as a second document. */
|
||||
export function createDocumentQueries(document:Document,registry:Registry,revision=0,options:QueryBudget={}):DocumentQueries{return advanceDocumentQueries(undefined,document,registry,revision,undefined,options);}
|
||||
export function advanceDocumentQueries(previous:DocumentQueries|undefined,document:Document,registry:Registry,revision:number,changes?:readonly Change[],options:QueryBudget={}):DocumentQueries{
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {validateCanvasElementCounts,type CanvasElementCounts} from '../canvas-runtime/scope-counts.ts';
|
||||
import type { CanvasRecord, ElementRecord, Id, Registry } from '../canvas-core/types.ts';
|
||||
import { createDocument, validateDocument } from '../canvas-core/document.ts';
|
||||
import { fault, freeze, readMap, validJson } from '../canvas-core/values.ts';
|
||||
@@ -29,6 +30,7 @@ export async function* decodeModelStream(source:AsyncIterable<Uint8Array>,canvas
|
||||
let sequence=0,token='',projectId='',expected=0,ended=false;
|
||||
let terminal: {sequence:number;snapshotToken:string;projectId:string;versions:ReadonlyMap<Id,number>;complete:boolean;byteLength:number;coverage:string[];value:CanonicalReadUpdate}|undefined;
|
||||
let canvases:CanvasRecord[]=[],versions:ReadonlyMap<Id,number>=new Map(),tombstones:ReadonlyMap<Id,number>=new Map();
|
||||
let canvasElementCounts:CanvasElementCounts|undefined;const ownerCounts=new Map<Id,number>();
|
||||
const elements:ElementRecord[]=[],ids=new Set<Id>(),canvasIds=new Set<Id>();
|
||||
for await(const line of lines(source,signal)){
|
||||
const raw=object(line.raw);
|
||||
@@ -44,13 +46,14 @@ export async function* decodeModelStream(source:AsyncIterable<Uint8Array>,canvas
|
||||
if(!Array.isArray(raw.removedCanvasIds)||new Set(raw.removedCanvasIds).size!==raw.removedCanvasIds.length||raw.removedCanvasIds.some(id=>typeof id!=='string'||canvasIds.has(id)||!versionMap.has(id))||versionMap.size!==canvasIds.size+raw.removedCanvasIds.length)throw fault('invalid_model_stream');
|
||||
for(const [id,value]of Object.entries(object(raw.tombstones))){if(!id||!Number.isSafeInteger(value)||(value as number)<1)throw fault('invalid_model_stream');dead.set(id,value as number);}
|
||||
versions=readMap(versionMap);tombstones=readMap(dead);
|
||||
const value:CanonicalReadUpdate=freeze({status:'receiving',projectId,canvases,elements:[],tombstones,versions,receivedElements:0,totalElements:expected});
|
||||
if(Object.hasOwn(raw,'canvasElementCounts')){validateCanvasElementCounts(raw.canvasElementCounts,[...canvasIds],expected);canvasElementCounts=freeze({...raw.canvasElementCounts});}
|
||||
const value:CanonicalReadUpdate=freeze({status:'receiving',...(canvasElementCounts?{canvasElementCounts}:{}),projectId,canvases,elements:[],tombstones,versions,receivedElements:0,totalElements:expected});
|
||||
yield{sequence:0,snapshotToken:token,projectId,versions,complete:false,byteLength:line.byteLength,coverage:['model-header'],value};continue;
|
||||
}
|
||||
if(raw.snapshotToken!==token)throw fault('load_snapshot_changed');
|
||||
if(raw.kind==='elements'){
|
||||
if(!Array.isArray(raw.elements)||!raw.elements.length||raw.elements.length>64||elements.length+raw.elements.length>expected)throw fault('invalid_model_stream');
|
||||
for(const item of raw.elements){const element=object(item) as unknown as ElementRecord;if(typeof element.id!=='string'||!element.id||ids.has(element.id)||!canvasIds.has(element.ownerCanvasId))throw fault('invalid_model_stream');ids.add(element.id);elements.push(element);}
|
||||
for(const item of raw.elements){const element=object(item) as unknown as ElementRecord;if(typeof element.id!=='string'||!element.id||ids.has(element.id)||!canvasIds.has(element.ownerCanvasId))throw fault('invalid_model_stream');const count=(ownerCounts.get(element.ownerCanvasId)??0)+1;if(canvasElementCounts&&count>canvasElementCounts[element.ownerCanvasId])throw fault('invalid_model_coverage');ownerCounts.set(element.ownerCanvasId,count);ids.add(element.id);elements.push(element);}
|
||||
const value:CanonicalReadUpdate=freeze({status:'receiving',projectId,canvases:[],elements:raw.elements as ElementRecord[],tombstones:readMap(new Map()),versions,receivedElements:elements.length,totalElements:expected});
|
||||
yield{sequence:sequence-1,snapshotToken:token,projectId,versions,complete:false,byteLength:line.byteLength,coverage:raw.elements.map(item=>`element:${(item as ElementRecord).id}`),value};
|
||||
}else if(raw.kind==='complete'){
|
||||
|
||||
@@ -679,22 +679,23 @@ async function engineRecoveryCase(name, assert) {
|
||||
} finally { await engine?.close(); storage.close(); indexedDB.deleteDatabase(`${name}-engine`); }
|
||||
}
|
||||
|
||||
async function loadingRecoveryCase(name,assert){
|
||||
const root=crypto.randomUUID(),scope={accountId:crypto.randomUUID(),workspaceId:crypto.randomUUID(),projectId:root},registry=createRegistry(builtinComponents()),node=id=>({id,incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:'original'},geometry:{x:0,y:0,width:120,height:80,rotation:0}}),a=node('A'),b=node('B'),canvases=[{id:root,parent:null}],prefix=createDocument(root,canvases,[a]),full=createDocument(root,canvases,[a,b]),versions=new Map([[root,1]]),loading={version:1,sourceToken:'browser-captured-read',declaredCanvasIds:[root],receivedElements:1,totalElements:2};
|
||||
async function loadingRecoveryCase(name,assert,coverage=false){
|
||||
const format=coverage?8:7;name+=coverage?"-coverage":"-legacy";
|
||||
const root=crypto.randomUUID(),scope={accountId:crypto.randomUUID(),workspaceId:crypto.randomUUID(),projectId:root},registry=createRegistry(builtinComponents()),node=id=>({id,incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:'original'},geometry:{x:0,y:0,width:120,height:80,rotation:0}}),a=node('A'),b=node('B'),canvases=[{id:root,parent:null}],prefix=createDocument(root,canvases,[a]),full=createDocument(root,canvases,[a,b]),versions=new Map([[root,1]]),loading={...(coverage?{version:2,canvasElementCounts:{[root]:2}}:{version:1}),sourceToken:'browser-captured-read',declaredCanvasIds:[root],receivedElements:1,totalElements:2};
|
||||
const adapter=createBrowserStorage({name:`${name}-loading`,checkpointEvery:3});let ownerForTest;const storage={...adapter,async claim(scope){ownerForTest=await adapter.claim(scope);return ownerForTest;}};const calls=[],receipts=new Map();let server=full,version=1,engine;
|
||||
const model=(complete=true)=>({documentFormatVersion:1,projectId:root,complete,canvases,elements:[...server.elements.values()],tombstones:{},versions:{[root]:version},removedCanvasIds:[]});
|
||||
const transport=async input=>{if(input.method==='GET')return{status:200,body:{model:model()}};calls.push(input);const body=JSON.parse(input.body);assert(body.expected_versions[root]===version,'loading writer used a stale version');server=applyChanges(server,body.operations.flatMap(operation=>operation.changes));version++;const receipt={protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false)};receipts.set(input.requestId,receipt);return{status:200,body:receipt};};
|
||||
const persistence=createFoundryPersistence({registry,workspaceId:scope.workspaceId,rootId:root,transport}),open=()=>openProjectEngine({scope,registry,document:prefix,versions,loading,storage,persistence,createId:()=>crypto.randomUUID()});
|
||||
try{engine=await open();const target={id:'A',incarnation:1};assert(engine.editor.dispatch({type:'element.data',target,patch:{text:'local before tail'}}).status==='applied','loading field rejected');await engine.settle();assert(calls.length===0&&engine.getSnapshot().runtime.ledger.requests.size===0,'loading prefix froze or sent a request');await engine.close();
|
||||
const owner=await storage.claim(scope),image=await storage.recover(owner);assert(image.formatVersion===7,'loading state did not require recovery v7');decodeRecovery(image,registry);await owner.release();engine=await open();assert(engine.getSnapshot().document.elements.get('A').data.text==='local before tail','loading edit lost after IndexedDB reopen');assert(engine.editor.history.undo().status==='applied'&&engine.editor.history.redo().status==='applied','loading history did not reopen');await engine.settle();assert(calls.length===0,'history traversal escaped the loading hold');
|
||||
const owner=await storage.claim(scope),image=await storage.recover(owner);assert(image.formatVersion===format,'loading state did not require its versioned recovery envelope');decodeRecovery(image,registry);await owner.release();engine=await open();assert(engine.getSnapshot().document.elements.get('A').data.text==='local before tail','loading edit lost after IndexedDB reopen');assert(engine.editor.history.undo().status==='applied'&&engine.editor.history.redo().status==='applied','loading history did not reopen');await engine.settle();assert(calls.length===0,'history traversal escaped the loading hold');
|
||||
const before=engine.getDurability().durableRootRevision,nativePut=IDBObjectStore.prototype.put;let injected=false,failure;
|
||||
IDBObjectStore.prototype.put=function(value,key){const request=nativePut.call(this,value,key);if(!injected&&this.name==='checkpoints'&&value?.image?.formatVersion!==7){injected=true;throw Error('loading_full_cut_abort');}return request;};
|
||||
IDBObjectStore.prototype.put=function(value,key){const request=nativePut.call(this,value,key);if(!injected&&this.name==='checkpoints'&&value?.image?.formatVersion!==format){injected=true;throw Error('loading_full_cut_abort');}return request;};
|
||||
try{await engine.loading.complete(full,versions,{sourceToken:loading.sourceToken});}catch(error){failure=error.message;}finally{IDBObjectStore.prototype.put=nativePut;}
|
||||
assert(injected&&failure==='loading_full_cut_abort','completion did not reach forced checkpoint rollback');const partial=await adapter.recover(ownerForTest);assert(partial.rootRevision===before&&partial.formatVersion===7&&calls.length===0,'aborted full checkpoint released loading writes or lost the partial cut');
|
||||
assert(injected&&failure==='loading_full_cut_abort','completion did not reach forced checkpoint rollback');const partial=await adapter.recover(ownerForTest);assert(partial.rootRevision===before&&partial.formatVersion===format&&calls.length===0,'aborted full checkpoint released loading writes or lost the partial cut');
|
||||
await engine.persist();await engine.settle();
|
||||
assert(!engine.loading.getState()&&server.elements.get('A').data.text==='local before tail'&&engine.getSnapshot().document.elements.size===2,'full tail overwrote the local prefix');await engine.close();engine=await open();await engine.settle();assert(!engine.loading.getState()&&engine.getSnapshot().document.elements.size===2,'completed recovery reopened as partial');
|
||||
const database=await new Promise((resolve,reject)=>{const request=indexedDB.open(`${name}-loading`,2);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
|
||||
try{const transaction=database.transaction(['checkpoints','journal'],'readonly'),read=store=>new Promise((resolve,reject)=>{const request=transaction.objectStore(store).getAll();request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});const cuts=(await Promise.all([read('checkpoints'),read('journal')])).flat();assert(cuts.every(cut=>(cut.image?.formatVersion??cut.header?.formatVersion)<7),'completed storage still requires a v7 journal base');}finally{database.close();}
|
||||
try{const transaction=database.transaction(['checkpoints','journal'],'readonly'),read=store=>new Promise((resolve,reject)=>{const request=transaction.objectStore(store).getAll();request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});const cuts=(await Promise.all([read('checkpoints'),read('journal')])).flat();assert(cuts.every(cut=>(cut.image?.formatVersion??cut.header?.formatVersion)<7),'completed storage still requires a partial journal base');}finally{database.close();}
|
||||
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-loading`);}
|
||||
}
|
||||
|
||||
@@ -719,7 +720,7 @@ export async function run() {
|
||||
await storage.commit(leader, { image: image(1) });
|
||||
await rejects(() => storage.commit(leader, { image: { ...image(1), payload: null } }), 'storage_revision_conflict');
|
||||
await rejects(() => storage.commit(leader, { image: image(3) }), 'storage_revision_gap');
|
||||
await rejects(() => storage.commit(leader, { image: { ...image(2), formatVersion: 8 } }), 'unsupported_recovery_version');
|
||||
await rejects(() => storage.commit(leader, { image: { ...image(2), formatVersion: 9 } }), 'unsupported_recovery_version');
|
||||
results.push('same-cut retry is idempotent; conflicting, skipped and future-format cuts reject');
|
||||
|
||||
// The first put succeeds before a later duplicate resource aborts the whole transaction.
|
||||
@@ -770,6 +771,8 @@ export async function run() {
|
||||
results.push('staged file and image recover from real IndexedDB; lost upload ACK queries stable resource identity');
|
||||
await loadingRecoveryCase(name,assert);
|
||||
results.push('recovery v7 preserves loaded-field edits/history in IndexedDB; an injected full-checkpoint failure retains the partial cut and zero writes, then retry removes v7 journal dependencies before releasing requests');
|
||||
await loadingRecoveryCase(name,assert,true);
|
||||
results.push('recovery v8 preserves certified scope coverage and loading edits; full-checkpoint fault rolls back atomically, retry retires all partial journal dependencies before sending');
|
||||
await scopedNetworkLoaderCase(assert);
|
||||
results.push('scoped loader shares one real streamed fetch, exposes the first chunk before tail release, keeps the other subscriber alive after cancellation and preserves partial data after a malformed tail');
|
||||
await defaultImageSizeCase(name, assert);
|
||||
|
||||
@@ -117,3 +117,39 @@ test('Workspace reports a background source failure while preserving the pending
|
||||
const {createWorkspaceRuntime}=await import('../../src/lib/canvas-runtime/workspace.ts'),f=fixture(),reports=[];let current=true;const workspace=createWorkspaceRuntime({accountId:scope.accountId,workspaceId:scope.workspaceId,authEpoch:1,registry,storage:f.storage,isCurrentIdentity:()=>current,onError:error=>reports.push(error),adapters:()=>({persistence:{...f.persistence,async read(){throw Error('offline');}}})});
|
||||
try{const engine=await workspace.openPartial(prefix,versions,state);engine.editor.dispatch({type:'element.data',target,patch:{text:'held while offline'}});await engine.persist();await assert.rejects(workspace.resumeLoading(root),/offline/);assert.equal(reports.length,1);assert.ok(engine.loading.getState());assert.equal(f.calls.length,0);assert.equal(f.storage.image().formatVersion,7);current=false;await assert.rejects(workspace.resumeLoading(root),/identity_changed/);assert.equal(reports.length,1);}finally{await workspace.close();}
|
||||
});
|
||||
|
||||
test('certified scopes distinguish a complete child from an incomplete parent and publish coverage atomically',async()=>{
|
||||
const loading={...state,version:2,canvasElementCounts:{[root]:4,[child]:1}},f=fixture();let engine=await f.open({loading});
|
||||
try{
|
||||
const first=engine.editor.getQueries();assert.equal(first.lookupElement('B').status,'unloaded');assert.equal(first.lookupElement('A').status,'present');assert.equal(first.membersResult(root).complete,false);assert.equal(first.hitNodes(root,{x:50,y:40}).complete,false);
|
||||
const next=createDocument(root,canvases,[a,frame,c]);assert.equal(engine.loading.accept(next,versions,{...loading,receivedElements:3}).status,'applied');
|
||||
const queries=engine.editor.getQueries();assert.equal(queries.membersResult(child).complete,true);assert.deepEqual(queries.membersResult(child).ids,['C']);assert.equal(queries.membersResult(root).complete,false);assert.equal(queries.childCanvasesResult(root).complete,true);assert.equal(queries.incidentResult('C').complete,false);assert.equal(queries.relations.referenceComplete,false);assert.equal(queries.coverage.projectComplete,false);assert.equal(first.lookupElement('C').status,'unloaded');
|
||||
engine.editor.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{text:'child edit'}});await engine.persist();assert.equal(f.storage.image().formatVersion,8);assert.equal(f.calls.length,0);decodeRecovery(f.storage.image(),registry);
|
||||
const source=f.storage.image();for(const formatVersion of [1,2,3,4,5,6,7])assert.throws(()=>decodeRecovery({...source,formatVersion},registry),{code:'unsupported_recovery_version'});
|
||||
for(const mutate of [image=>delete image.payload.runtime.loading.canvasElementCounts,image=>image.payload.runtime.loading.version=1,image=>image.payload.runtime.loading.version=99,image=>image.payload.runtime.loading.canvasElementCounts[child]=0]){const image=structuredClone(source);mutate(image);assert.throws(()=>decodeRecovery(image,registry));}
|
||||
await engine.close();engine=await f.open({loading});assert.equal(engine.editor.getQueries().membersResult(child).complete,true);assert.equal(engine.getSnapshot().document.elements.get('C').data.text,'child edit');
|
||||
assert.equal(engine.loading.accept(full,versions,{...loading,receivedElements:5}).status,'applied');const before=engine.getSnapshot(),oldQueries=engine.editor.getQueries();assert.equal(oldQueries.lookupElement('missing').status,'unloaded');assert.equal(oldQueries.membersResult(root).complete,true);
|
||||
assert.equal((await engine.loading.complete(full,versions,{sourceToken:loading.sourceToken})).status,'applied');assert.equal(engine.getSnapshot().revision,before.revision,'coverage-only completion must not invent a document revision');assert.equal(engine.editor.getQueries().lookupElement('missing').status,'absent');assert.equal(engine.editor.getQueries().coverage.projectComplete,true);assert.equal(oldQueries.lookupElement('missing').status,'unloaded');await engine.settle();assert.equal(f.server().elements.get('C').data.text,'child edit');assert.equal(f.storage.image().formatVersion<7,true);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
test('legacy prefixes do not infer scope completion; a changed count manifest cannot replace the published root',async()=>{
|
||||
const f=fixture(),engine=await f.open();try{
|
||||
assert.equal(engine.loading.accept(full,versions,{...state,receivedElements:5}).status,'applied');assert.equal(engine.editor.getQueries().membersResult(child).complete,false);
|
||||
const before=engine.getSnapshot();assert.equal(engine.loading.accept(full,versions,{...state,version:2,canvasElementCounts:{[root]:4,[child]:1},receivedElements:5}).code,'loading_source_mismatch');assert.strictEqual(engine.getSnapshot(),before);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
test('zero-member scopes are complete only once their entry dependencies publish',async()=>{
|
||||
const f=fixture(),document=createDocument(root,canvases,[a,frame]),loading={...state,version:2,totalElements:2,receivedElements:2,canvasElementCounts:{[root]:2,[child]:0}},engine=await f.open({document,loading});
|
||||
try{assert.deepEqual(engine.editor.getQueries().membersResult(child),{ids:[],complete:true});assert.equal(engine.editor.getQueries().lookupElement('not-here').status,'unloaded');}finally{await engine.close();}
|
||||
});
|
||||
|
||||
test('v2 admits path edits in a certified connection owner while another scope is incomplete, preserving undo and v8 recovery',async()=>{
|
||||
const f=fixture(),loading={...state,version:2,receivedElements:5,totalElements:6,canvasElementCounts:{[root]:4,[child]:2}},engine=await f.open({document:full,loading}),path={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:50,y:25},in:{x:0,y:0},out:{x:0,y:0}}]};
|
||||
try{
|
||||
assert.equal(engine.editor.getQueries().membersResult(root).complete,true);assert.equal(engine.editor.getQueries().membersResult(child).complete,false);
|
||||
const command={type:'connection.path',target:{id:'E',incarnation:1},path};assert.equal(engine.editor.can(command).enabled,true);assert.equal(engine.editor.dispatch(command).status,'applied');assert.equal(engine.getSnapshot().document.elements.get('E').path.knots[0].position.x,50);
|
||||
assert.equal(engine.editor.history.undo().status,'applied');assert.equal(engine.getSnapshot().document.elements.get('E').path,null);assert.equal(engine.editor.history.redo().status,'applied');await engine.persist();assert.equal(f.calls.length,0);assert.equal(f.storage.image().formatVersion,8);assert.equal(decodeRecovery(f.storage.image(),registry).document.elements.get('E').path.knots[0].position.x,50);
|
||||
const before=engine.getSnapshot();assert.equal(engine.loading.accept(full,versions,{...loading,canvasElementCounts:{[root]:5,[child]:1}}).code,'loading_source_mismatch');assert.strictEqual(engine.getSnapshot(),before);
|
||||
}finally{await engine.close();}
|
||||
const other=fixture(),incomplete=await other.open({document:full,loading:{...loading,canvasElementCounts:{[root]:5,[child]:1}}});try{assert.equal(incomplete.editor.can({type:'connection.path',target:{id:'E',incarnation:1},path}).enabled,false);}finally{await incomplete.close();}
|
||||
});
|
||||
|
||||
@@ -42,3 +42,10 @@ test('owned preview records do not freeze or retain mutable caller payloads; fin
|
||||
test('published identities cannot be repeated in later chunks and invalid input closes the projection',()=>{
|
||||
const preview=createModelPreview(registry);preview.update({...head(2),canvases:[{id:root,parent:null}]});preview.update(delta([node('one')],1,2));assert.throws(()=>preview.update(delta([node('one')],2,2)));assert.throws(()=>preview.update(delta([node('two')],2,2)),{code:'model_preview_closed'});
|
||||
});
|
||||
|
||||
test('scope certificates count received records without treating dependency-blocked members as published',()=>{
|
||||
const preview=createModelPreview(registry);const counts={[root]:3,[child]:1};preview.update({...head(),canvasElementCounts:counts});counts[child]=0;
|
||||
const waiting=preview.update(delta([a,edge],2));assert.equal(waiting.document.canvases.has(child),false);assert.equal(waiting.canvasElementCounts[child],1);assert.equal(waiting.waitingElements,2);
|
||||
const shown=preview.update(delta([frame,b],4));assert.equal(shown.document.elements.size,4);assert.deepEqual(shown.canvasElementCounts,{[root]:3,[child]:1});
|
||||
const invalid=createModelPreview(registry),first=invalid.update({...head(1),canvasElementCounts:{[root]:0,[child]:1}});assert.throws(()=>invalid.update(delta([b],1,1)),{code:'invalid_model_coverage'});assert.strictEqual(invalid.getSnapshot(),first);
|
||||
});
|
||||
|
||||
@@ -54,3 +54,9 @@ test('stream transport fences changed identity and abort cancels a blocked body
|
||||
const reply=await transport({method:'GET',path:'model',responseFormat:'model-stream-v1',signal:abort.signal});const iterator=reply.stream[Symbol.asyncIterator]();const pending=iterator.next();await Promise.resolve();abort.abort();await assert.rejects(pending);assert.equal(cancelled,1);
|
||||
const other=await transport({method:'GET',path:'model',responseFormat:'model-stream-v1'});current=false;await assert.rejects(()=>other.stream[Symbol.asyncIterator]().next(),{code:'identity_changed'});assert.equal(cancelled,2);
|
||||
});
|
||||
|
||||
test('optional snapshot scope counts preserve legacy streams and reject invalid or exceeded coverage before publication',async()=>{
|
||||
const rows=records();rows[0].canvasElementCounts={[root]:1};const seen=await collect(decodeModelStream(source(rows),root,registry,signal()));assert.deepEqual(seen[0].value.canvasElementCounts,{[root]:1});assert.equal(Object.isFrozen(seen[0].value.canvasElementCounts),true);
|
||||
for(const manifest of [null,[],{}, {[root]:-1},{[root]:.5},{[root]:2},{[root]:1,[workspace]:0}]){const broken=records();broken[0].canvasElementCounts=manifest;await assert.rejects(()=>collect(decodeModelStream(source(broken),root,registry,signal())),{code:'invalid_model_coverage'});}
|
||||
const broken=records();broken[0].canvases.push({id:workspace,parent:{canvasId:root,entryElementId:'frame'}});broken[0].versions[workspace]=1;broken[0].canvasElementCounts={[root]:0,[workspace]:1};const iterator=decodeModelStream(source(broken),root,registry,signal())[Symbol.asyncIterator]();assert.equal((await iterator.next()).value.value.receivedElements,0);await assert.rejects(iterator.next(),{code:'invalid_model_coverage'});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,13 @@ try {
|
||||
const accepted=engine.loading.accept(document,versions,state);
|
||||
return accepted.status==='applied'?engine.loading.complete(document,versions,{sourceToken:state.sourceToken}):accepted;
|
||||
}
|
||||
export const certified:LoadingState={version:2,sourceToken:'consumer-read',declaredCanvasIds:['root'],receivedElements:0,totalElements:1,canvasElementCounts:{root:1}};
|
||||
export function inspectPartial(engine:Awaited<ReturnType<typeof openProjectEngine>>){
|
||||
const queries=engine.editor.getQueries(),lookup=queries.lookupElement('node');
|
||||
const id:string|undefined=lookup.status==='present'?lookup.element.id:undefined;
|
||||
const complete:boolean=queries.membersResult(engine.getSnapshot().document.projectId).complete;
|
||||
return {id,complete,projectComplete:queries.coverage.projectComplete};
|
||||
}
|
||||
export function open(scope:StorageScope,storage:StoragePort,transport:ServiceTransport){
|
||||
return openProjectEngine({scope,storage,registry,document:createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]),versions:new Map([[scope.projectId,1]]),persistence:createFoundryPersistence({registry,transport,rootId:scope.projectId,workspaceId:scope.workspaceId})});
|
||||
}
|
||||
|
||||
@@ -39,3 +39,10 @@ test('reverse element/canvas references remove obsolete subjects and mark malfor
|
||||
const custom=createRegistry([...builtinComponents(),{id:'refs',dataVersion:1,structure:'node',validate:()=>true,features:[],copy:{references:[{path:['targets'],many:true,target:'element',external:'preserve'},{path:['scopes'],many:true,target:'canvas',external:'preserve'}],omit:[]}}]),r={...node('R','P','refs'),data:{targets:['A','A'],scopes:['S']}},r2={...node('R2','P','refs'),data:{targets:['B'],scopes:['S']}},doc=createDocument('project',[...document.canvases.values()],[...document.elements.values(),r,r2]),q=createDocumentQueries(doc,custom);
|
||||
assert.deepEqual([...q.relations.canvasReferrers.get('S')],['R','R2']);assert.deepEqual([...q.relations.elementReferrers.get('A')],['F','R']);const change={kind:'field',target:{id:'R',incarnation:1},field:'data.scopes',before:['S'],after:['P']},next=applyChanges(doc,[change]),updated=advanceDocumentQueries(q,next,custom,1,[change]);assert.deepEqual([...updated.relations.canvasReferrers.get('S')],['R2']);assert.deepEqual([...updated.relations.canvasReferrers.get('P')],['R']);assert.equal(updated.relations.elementReferrers,q.relations.elementReferrers);const invalid={kind:'field',target:{id:'R',incarnation:1},field:'data.targets',before:['A','A'],after:'invalid'},bad=advanceDocumentQueries(updated,applyChanges(next,[invalid]),custom,2,[invalid]);assert.equal(bad.relations.referenceComplete,false);assert.deepEqual([...bad.relations.opaqueReferences],['R']);assert.equal(bad.relations.canvasReferrers.has('P'),false);assert.deepEqual([...bad.relations.elementReferrers.get('A')],['F']);
|
||||
});
|
||||
|
||||
test('coverage changes share the metadata publication boundary and invalid coverage cannot partially publish a root',()=>{
|
||||
const editor=createEditor({registry,document,runtime:{complete:false},queryCoverage:snapshot=>({projectComplete:snapshot.runtime.complete,topologyComplete:true,completeCanvasIds:snapshot.runtime.complete?['P','S']:[]})});
|
||||
const before=editor.getSnapshot(),queries=editor.getQueries();assert.equal(queries.lookupElement('unknown').status,'unloaded');assert.equal(queries.membersResult('P').complete,false);let observed;
|
||||
editor.subscribe((_commit,snapshot)=>observed={root:snapshot,queries:editor.getQueries()});assert.equal(editor.updateRuntime(()=>({complete:true})).status,'applied');assert.equal(observed.root,editor.getSnapshot());assert.equal(observed.queries.lookupElement('unknown').status,'absent');assert.equal(observed.root.revision,before.revision);assert.equal(queries.lookupElement('unknown').status,'unloaded');
|
||||
const invalid=createEditor({registry,document,runtime:{bad:false},queryCoverage:snapshot=>({projectComplete:false,topologyComplete:false,completeCanvasIds:snapshot.runtime.bad?['foreign']:[]})}),root=invalid.getSnapshot(),query=invalid.getQueries();assert.equal(invalid.updateRuntime(()=>({bad:true})).code,'invalid_query_coverage');assert.strictEqual(invalid.getSnapshot(),root);assert.strictEqual(invalid.getQueries(),query);
|
||||
});
|
||||
|
||||
@@ -24,5 +24,5 @@ test('cached subtree height cannot bypass the depth bound and omitted object fie
|
||||
const cycle={};cycle.self=cycle;assert.throws(()=>encodeRecoveryValue(cycle),/invalid_recovery_payload/);assert.throws(()=>encodeRecoveryValue({big:1n}));
|
||||
});
|
||||
test('only factory-owned images are recognized; immutable-looking impostors retain defensive-copy handling',()=>{
|
||||
const mutable={title:'before'},image=sealRecoveryImage(2,3,2,{mutable});assert.equal(isSealedRecoveryImage(image),true);assert.equal(isSealedRecoveryImage(structuredClone(image)),false);assert.equal(isSealedRecoveryImage(Object.freeze({...image})),false);mutable.title='after';assert.equal(image.payload.mutable.title,'before');assert.ok(Object.isFrozen(image.payload.mutable));assert.throws(()=>sealRecoveryImage(2,1,2,{}),/corrupt_recovery_image/);assert.equal(isSealedRecoveryImage(sealRecoveryImage(3,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(4,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(5,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(6,1,1,{})),true);assert.throws(()=>sealRecoveryImage(8,1,1,{}),/corrupt_recovery_image/);
|
||||
const mutable={title:'before'},image=sealRecoveryImage(2,3,2,{mutable});assert.equal(isSealedRecoveryImage(image),true);assert.equal(isSealedRecoveryImage(structuredClone(image)),false);assert.equal(isSealedRecoveryImage(Object.freeze({...image})),false);mutable.title='after';assert.equal(image.payload.mutable.title,'before');assert.ok(Object.isFrozen(image.payload.mutable));assert.throws(()=>sealRecoveryImage(2,1,2,{}),/corrupt_recovery_image/);assert.equal(isSealedRecoveryImage(sealRecoveryImage(3,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(4,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(5,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(6,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(7,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(8,1,1,{})),true);assert.throws(()=>sealRecoveryImage(9,1,1,{}),/corrupt_recovery_image/);
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ const upstream = createServer(async (request, response) => {
|
||||
const value=model(documents.get(read[1])),token='fixture-snapshot-'+calls.length;let sequence=0;
|
||||
response.writeHead(200,{'Content-Type':'application/vnd.foundry.canvas-model+ndjson;version=1'});
|
||||
const line=record=>response.write(JSON.stringify({streamVersion:1,snapshotToken:token,sequence:sequence++,...record})+'\n');
|
||||
line({kind:'header',documentFormatVersion:1,projectId:value.projectId,canvases:value.canvases,versions:value.versions,tombstones:value.tombstones,removedCanvasIds:value.removedCanvasIds,elementCount:value.elements.length});
|
||||
line({kind:'header',documentFormatVersion:1,projectId:value.projectId,canvases:value.canvases,versions:value.versions,tombstones:value.tombstones,removedCanvasIds:value.removedCanvasIds,elementCount:value.elements.length,canvasElementCounts:Object.fromEntries(value.canvases.map(canvas=>[canvas.id,value.elements.filter(element=>element.ownerCanvasId===canvas.id).length]))});
|
||||
if(read[1]===brokenStreamRoot){line({kind:'elements',elements:value.elements.slice(0,1)});response.end('{broken tail}\n');return;}
|
||||
let start=0;
|
||||
if(read[1]===abandonedRoot){line({kind:'elements',elements:value.elements.slice(0,1)});start=1;await abandonedGate;if(response.destroyed)return;}
|
||||
|
||||
Reference in New Issue
Block a user