Certify loaded canvas scopes and publish completeness with runtime queries

This commit is contained in:
2026-11-09 03:42:08 +08:00
parent ef08222915
commit cabf2ec667
26 changed files with 209 additions and 48 deletions
+9 -6
View File
@@ -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([]) });
+1 -1
View File
@@ -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';
+31 -1
View File
@@ -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{