diff --git a/src/lib/canvas-core/duplicate-checkpoint.ts b/src/lib/canvas-core/duplicate-checkpoint.ts index 8e60ed0..226b42f 100644 --- a/src/lib/canvas-core/duplicate-checkpoint.ts +++ b/src/lib/canvas-core/duplicate-checkpoint.ts @@ -1,75 +1,116 @@ +/** + * 可暂停/恢复的深复制准备状态机。检查点保存 ID、阶段、游标、DFS stack 和稳定映射, + * 不保存元素 payload 的第二份副本;输出记录是由源与映射确定性重建的缓存。 + * 每次 step 只推进有限语义工作,但持久源校验、分片调度、checkpoint 保存和渐进加入由 runtime 负责。 + */ import {detachJsonData} from './json-data.ts'; import type { CanvasRecord, Change, Document, ElementRecord, Id, Point, Registry, Target } from './types.ts'; import { ancestors, targetElement, validateDocumentSteps, validateDocumentTopologySteps } from './document.ts'; import { duplicateTreeChange, duplicateEdgeChange, duplicateOverlay, type DuplicateOptions, type DuplicatePlan } from './duplicate.ts'; import { clone, equal, fault, freeze, validJson } from './values.ts'; +/** 准备流程:选区→索引→根→DFS范围→边→ID分配→映射检查→记录生成→最终校验。 */ const phases=['selection','canvases','members','roots','seed','dfs','edges','allocate-elements','allocate-canvases','mapping','emit','emit-edges','validate','done'] as const; +/** DFS/order 项只存 kind+源 ID,恢复时从绑定的源文档取记录,避免双份数据漂移。 */ type Task={kind:'element'|'canvas';id:Id}; /** Internal preparation state: IDs/cursors only. Emitted records are a deterministic derived cache. */ +/** 运行中可变 DTO;version=1 是本检查点解释器版本,不是 ImportJob/文档/wire 版本。 + * emitted/emittedEdges 是已生成前缀长度,不能当作已加入或已同步元素数量。 */ type MutableCheckpoint={version:1;projectId:Id;sourceToken:string;schemaToken:string;selection:Target[];offset:Point;mapping?:DuplicateOptions['mapping'];phase:typeof phases[number];cursor:number;steps:number;selected:Id[];children:[Id,Id][];members:[Id,Id[]][];roots:Id[];copied:Id[];canvases:Id[];order:Task[];work:Task[];expanding?:{canvas:Id;index:number};edges:Id[];elementIds:[Id,Id][];canvasIds:[Id,Id][];emitted:number;emittedEdges:number}; +/** 对检查点结构递归加 readonly;真正外发时 checkpoint() 仍要复制并冻结。 */ type Immutable=T extends object?{readonly [K in keyof T]:Immutable}:T; +/** 对外只读检查点合同,可编码为 JSON,由 runtime 与源/任务事实一同持久化。 */ export type DuplicateCheckpoint=Immutable; +/** 形状辅助检查:数组成员必须是非空字符串;是否存在/重复由后续检查负责。 */ const ids=(value:unknown):value is Id[]=>Array.isArray(value)&&value.every(id=>typeof id==='string'&&!!id); +/** 校验外部检查点格式、阶段/计数/去重与前缀边界;与实际源的一致性在工厂中进一步核验。 */ export function validateDuplicateCheckpoint(value:DuplicateCheckpoint):void{ if(!value||!validJson(value)||value.version!==1)throw fault('copy_checkpoint_interpreter_unavailable'); if(![value.projectId,value.sourceToken,value.schemaToken].every(id=>typeof id==='string'&&!!id)||!phases.includes(value.phase)||!Number.isSafeInteger(value.cursor)||value.cursor<(value.phase==='seed'?-1:0)||![value.steps,value.emitted,value.emittedEdges].every(n=>Number.isSafeInteger(n)&&n>=0)||!Array.isArray(value.selection)||!value.selection.length||value.selection.some(t=>!t||typeof t.id!=='string'||!t.id||!Number.isSafeInteger(t.incarnation)||t.incarnation<1)||!value.offset||![value.offset.x,value.offset.y].every(Number.isFinite))throw fault('invalid_copy_checkpoint'); for(const list of [value.selected,value.roots,value.copied,value.canvases,value.edges])if(!ids(list)||new Set(list).size!==list.length)throw fault('invalid_copy_checkpoint'); for(const list of [value.order,value.work])if(!Array.isArray(list)||list.some(t=>!t||!['element','canvas'].includes(t.kind)||typeof t.id!=='string'||!t.id))throw fault('invalid_copy_checkpoint'); + // 元组映射的源键唯一,任务 stack 可有待去重项;不能错误地对所有数组一律去重。 for(const list of [value.children,value.elementIds,value.canvasIds])if(!Array.isArray(list)||list.some(pair=>!ids(pair)||pair.length!==2)||new Set(list.map(pair=>pair[0])).size!==list.length)throw fault('invalid_copy_checkpoint'); if(!Array.isArray(value.members)||value.members.some(pair=>!Array.isArray(pair)||pair.length!==2||typeof pair[0]!=='string'||!pair[0]||!ids(pair[1]))||new Set(value.members.map(pair=>pair[0])).size!==value.members.length||value.emitted>value.order.length||value.emittedEdges>value.edges.length||value.emittedEdges>0&&value.emitted!==value.order.length||value.expanding&&(typeof value.expanding.canvas!=='string'||!value.expanding.canvas||!Number.isSafeInteger(value.expanding.index)||value.expanding.index< -1))throw fault('invalid_copy_checkpoint'); } +/** 创建或恢复解释器;绑定 project/sourceToken/schemaToken/选区/偏移/预留映射,任一不符拒绝续跑。 + * token 的真实源摘要/定义签名由调用方提供并验证,这里不读取 Blob 或计算 SHA。 */ export function createDuplicatePlanner(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint;topologyOnly?:boolean}){ const offset=options.worldOffset??{x:32,y:32},idFactory=options.createId,suppliedMapping=options.mapping?freeze(clone(options.mapping)):undefined; + // 从检查点复制出私有可变 state,旧 checkpoint 保持不变;新任务从 selection 开始。 const state:MutableCheckpoint=binding.checkpoint?clone(binding.checkpoint) as MutableCheckpoint:{version:1,projectId:document.projectId,sourceToken:binding.sourceToken,schemaToken:binding.schemaToken,selection:clone(selection) as Target[],offset:clone(offset),...(suppliedMapping?{mapping:suppliedMapping}:{}),phase:'selection',cursor:0,steps:0,selected:[],children:[],members:[],roots:[],copied:[],canvases:[],order:[],work:[],edges:[],elementIds:[],canvasIds:[],emitted:0,emittedEdges:0}; validateDuplicateCheckpoint(state); if(state.projectId!==document.projectId||state.sourceToken!==binding.sourceToken||state.schemaToken!==binding.schemaToken||!equal(state.selection,selection)||!equal(state.offset,offset)||!equal(state.mapping,suppliedMapping))throw fault('copy_checkpoint_binding_mismatch'); + // 从序列化数组重建 Map/Set 查找结构;源有序数组也重建,但不重分配已保存的新 ID。 const sourceElements=[...document.elements.values()],sourceCanvases=[...document.canvases.values()],selected=new Set(state.selected),copied=new Set(state.copied),canvases=new Set(state.canvases),children=new Map(state.children),members=new Map(state.members),elementIds=new Map(state.elementIds),canvasIds=new Map(state.canvasIds),used=new Set([...document.elements.keys(),...document.canvases.keys(),...document.tombstones.keys()]); + // 逐项核对源成员、父入口、owner、映射范围与新 ID 冲突,不能只相信 DTO 形状。 for(const id of [...state.selected,...state.roots,...state.copied,...state.edges])if(!document.elements.has(id))throw fault('invalid_copy_checkpoint'); for(const id of state.canvases)if(!document.canvases.has(id))throw fault('invalid_copy_checkpoint'); for(const [entry,id]of children)if(document.canvases.get(id)?.parent?.entryElementId!==entry)throw fault('invalid_copy_checkpoint'); for(const [owner,list]of members)for(const id of list)if(document.elements.get(id)?.ownerCanvasId!==owner)throw fault('invalid_copy_checkpoint'); for(const [list,scope]of [[state.elementIds,copied],[state.canvasIds,canvases]] as const)for(const [id,mapped]of list){if(!scope.has(id)||used.has(mapped))throw fault('invalid_copy_checkpoint');used.add(mapped);} for(const task of [...state.order,...state.work])if(!(task.kind==='element'?document.elements:document.canvases).has(task.id))throw fault('invalid_copy_checkpoint'); + // 进入 emit 前必须已分配整个范围的映射,转换引用时不能边输出边猜后续 ID。 if(phases.indexOf(state.phase)>=phases.indexOf('emit')&&(elementIds.size!==copied.size||canvasIds.size!==canvases.size))throw fault('invalid_copy_checkpoint'); + // 按当前阶段的真实源长度检查 cursor;seed 特殊地允许 -1 表示压栈结束。 const limits:Partial>={selection:state.selection.length,canvases:sourceCanvases.length,members:sourceElements.length,roots:state.selected.length,seed:state.roots.length,edges:sourceElements.length,'allocate-elements':state.copied.length,'allocate-canvases':state.canvases.length};if(limits[state.phase]!==undefined&&state.cursor>limits[state.phase]!)throw fault('invalid_copy_checkpoint'); const supplied=suppliedMapping?[...Object.keys(suppliedMapping.elements).map(id=>({id,kind:'element' as const})),...Object.keys(suppliedMapping.canvases).map(id=>({id,kind:'canvas' as const}))]:[]; + // 这些 Map 是派生输出缓存,overlay 内部可读其增长,不作为第二份发布文档。 const outputElements=new Map(),outputCanvases=new Map(),output=duplicateOverlay(document,outputElements,outputCanvases),changes:Change[]=[]; + // 源校验每次重开重新执行;topologyOnly 仅用于明确的结构源,最终输出仍执行完整数据校验。 let treeCache=0,edgeCache=0,sourceChecks:Generator|undefined=(binding.topologyOnly?validateDocumentTopologySteps:validateDocumentSteps)(document,registry),outputChecks:Generator|undefined,validated=false; + /** 切换解释阶段并重置/指定该阶段 cursor,不改稳定 ID 或输出前缀。 */ const phase=(next:DuplicateCheckpoint['phase'],cursor=0)=>{state.phase=next;state.cursor=cursor;}; + /** 冻结生成记录,追加 changes 并登记到临时输出表,使后续边可读取新端点/owner。 */ function add(change:Exclude){freeze(change);changes.push(change);if(change.kind==='element')outputElements.set(change.id,change.after!);else outputCanvases.set(change.id,change.after!);} + /** 由 order 的源 ID 调用共享节点/canvas 转换器,恢复时同样可重建已输出前缀。 */ function tree(index:number){const task=state.order[index];add(duplicateTreeChange(document,registry,task.kind,(task.kind==='element'?document.elements:document.canvases).get(task.id)!,elementIds,canvasIds,canvases,state.offset));} + /** 仅在节点/canvas 输出后转换边,保证新端点和其所属树已可供查询。 */ function edge(index:number){add(duplicateEdgeChange(document,registry,document.elements.get(state.edges[index])!,elementIds,canvasIds,output));} + /** 只为尚未分配的源项取新 ID;优先宿主预留值,used 同时覆盖源、墓碑与全部新值。 */ function allocate(id:Id,kind:'elements'|'canvases'){const map=suppliedMapping?.[kind],value=map&&Object.hasOwn(map,id)?map[id]:idFactory();if(typeof value!=='string'||!value||used.has(value))throw fault('duplicate_identity',[value]);used.add(value);return value;} return Object.freeze({ + /** 源已校验且刚到 emit,范围/映射固定,可以交给运行时逐记录转换,不要求完整计划已生成。 */ ready():boolean{return !sourceChecks&&state.phase==='emit'&&state.emitted===0;}, + /** 返回阶段、语义 steps 和已分配数;不冒称解析/加入/网络已完成。 */ progress(){return Object.freeze({phase:state.phase,steps:state.steps,allocated:state.elementIds.length+state.canvasIds.length});}, + /** 脱离当前可变 state 并冻结;保存和可靠落盘不在这个方法内。 */ checkpoint():DuplicateCheckpoint{return detachJsonData(state);}, + /** 推进一次解释工作;false 表示仍需继续,只有最终输出校验结束才返回 true。 */ step():boolean{ + // 恢复的源校验和派生缓存重建不增加语义 steps,不能据 steps 声称重开完全无重算。 if(sourceChecks){if(sourceChecks.next().done)sourceChecks=undefined;return false;} // Reconstruct only the derived output cache. Index/DFS/allocation work stays at its saved cursor. if(treeCache{const parent=document.canvases.get(canvas)!.parent;return parent&&selected.has(parent.entryElementId);}))state.roots.push(id);}else phase('seed',state.roots.length-1);break; case 'seed':if(state.cursor>=0){const id=state.roots[state.cursor--];if(document.elements.get(id)!.structure==='node')state.work.push({kind:'element',id});}else phase('dfs');break; + // DFS 的“大分区兄弟压栈”本身也可暂停:expanding 保存未压完的 index,不一口气塞完整列表。 case 'dfs':{ if(state.expanding){const list=members.get(state.expanding.canvas)??[];if(state.expanding.index>=list.length)throw fault('invalid_copy_checkpoint');if(state.expanding.index<0)delete state.expanding;else{const id=list[state.expanding.index--];if(document.elements.get(id)!.structure==='node')state.work.push({kind:'element',id});}break;} const task=state.work.pop();if(!task){phase('edges');break;} if(task.kind==='canvas'){if(canvases.has(task.id))break;canvases.add(task.id);state.canvases.push(task.id);state.order.push(task);state.expanding={canvas:task.id,index:(members.get(task.id)?.length??0)-1};} else{if(copied.has(task.id))break;copied.add(task.id);state.copied.push(task.id);state.order.push(task);const child=children.get(task.id);if(child)state.work.push({kind:'canvas',id:child});}break; } + // 扫描上级入边与范围内边;随后 element/canvas 分配都把新 ID 写回 state,恢复可重用。 case 'edges':if(state.cursorelementIds.get(id)!),elementIds:Object.fromEntries(elementIds),canvasIds:Object.fromEntries(canvasIds),changes:[...changes]});}, }); } diff --git a/src/lib/canvas-core/duplicate.ts b/src/lib/canvas-core/duplicate.ts index 9a7da93..8ee9a03 100644 --- a/src/lib/canvas-core/duplicate.ts +++ b/src/lib/canvas-core/duplicate.ts @@ -1,3 +1,8 @@ +/** + * 完整快照上的深复制纯规划:先确定范围和稳定 ID 映射,再生成节点/canvas,最后生成边。 + * 同时选分区入口和其内部节点时只复制一次;原上级 C→F 保留,新增边可连接原 C 与新 F′。 + * 这里只返回计划,不改 editor、不上传图片、不启动生成;runtime 负责调度、持久源和逐批加入。 + */ import type { CanvasRecord, Change, Data, Document, ElementRecord, Id, Json, Point, Registry, Target } from './types.ts'; import { copySlots } from './copy-paths.ts'; import { ancestors, commonCanvas, targetElement, validateDocumentSteps } from './document.ts'; @@ -5,9 +10,13 @@ import { connectionPathValid } from './features.ts'; import { canvasTransform, convertPath, toLocal, toWorld } from './coordinates.ts'; import { clone, fault, freeze } from './values.ts'; +/** 完整计划:新根 ID、旧→新元素/canvas 映射和创建 changes;version 是计划格式版本。 */ export type DuplicatePlan = { readonly version: 1; readonly roots: readonly Id[]; readonly elementIds: Readonly>; readonly canvasIds: Readonly>; readonly changes: readonly Change[] }; +/** 宿主提供 ID 工厂、世界空间偏移及可选预留映射;所有新 ID 必须与源/墓碑/彼此不冲突。 */ export type DuplicateOptions = { readonly createId: () => Id; readonly worldOffset?: Point; readonly mapping?: { readonly elements: Readonly>; readonly canvases: Readonly> } }; +/** 复制 data 并按 schema 重写声明引用、删 omit 字段;普通字符串与资源 ID 不隐式重映射。 + * 未知组件/缺复制 schema 明确拒绝;外部引用只有 preserve 且源文档确实存在时才保留。 */ export function remapCopyData(element: ElementRecord, registry: Registry, elements: ReadonlyMap, canvases: ReadonlyMap, document: Document): Data { const definition = registry.components.get(element.componentId), schema = definition?.copy; if (registry.resolve(element) !== 'known' || !schema) throw fault('copy_schema_unavailable', [element.id]); @@ -15,6 +24,7 @@ export function remapCopyData(element: ElementRecord, registry: Registry, elemen for (const ref of schema.references) { for (const slot of copySlots(data, ref.path)) { if (slot.parent[slot.key] === null) continue; + /** 单个引用按 element/canvas 映射查新 ID,找不到时执行该路径的 external 策略。 */ const mapped = (value: Json): Json => { if (typeof value !== 'string' || !value) throw fault('invalid_copy_reference', [element.id]); const map = ref.target === 'element' ? elements : canvases, known = ref.target === 'element' ? document.elements : document.canvases; @@ -27,24 +37,30 @@ export function remapCopyData(element: ElementRecord, registry: Registry, elemen slot.parent[slot.key] = ref.many ? (value as Json[]).map(mapped) : mapped(value); } } + // omit 删除对象字段,拒绝删除数组下标造成稀疏数据;生成任务状态通过声明省略,不复制成新任务。 for (const path of schema.omit) for (const slot of copySlots(data, path)) { if (Array.isArray(slot.parent)) throw fault('invalid_copy_omit', [element.id]); delete slot.parent[slot.key]; } return data; } /** Pure, complete-snapshot planning. Runtime owns time slicing, durable source/mapping and partial progress. */ +/** 分步迭代器:yield 给调用者调度机会,本身不 await、不落盘;耗尽后才返回完整已校验计划。 */ export function* planDuplicateSteps(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): Generator { yield* validateDocumentSteps(document, registry); const offset = options.worldOffset ?? { x: 32, y: 32 }; if (![offset.x, offset.y].every(Number.isFinite)) throw fault('invalid_copy_offset'); + // 选区先以 ID+incarnation 定位,再用 Set 去重;不能让过期选中对象参与新一次复制。 const selected = new Set(); for (const target of selection) { yield; selected.add(targetElement(document, target).id); } if (!selected.size) throw fault('empty_copy_selection'); + // 建入口→子 canvas、owner→成员临时索引,后续 DFS 不反复扫描全表。 const childByEntry = new Map(), members = new Map(); for (const canvas of document.canvases.values()) { yield; if (canvas.parent) childByEntry.set(canvas.parent.entryElementId, canvas); } for (const element of document.elements.values()) { yield; const list = members.get(element.ownerCanvasId) ?? []; list.push(element); members.set(element.ownerCanvasId, list); } + // 去掉已被选中祖先入口包含的子项,保留选区顺序,避免 F 与内部 A 同选时生成两份 A。 const roots: Id[] = []; for (const id of selected) { yield; if (!ancestors(document, document.elements.get(id)!.ownerCanvasId).some(canvas => { const parent = document.canvases.get(canvas)!.parent; return parent && selected.has(parent.entryElementId); })) roots.push(id); } const copied = new Set(), copiedCanvases = new Set(), order: ({ kind: 'element'; record: ElementRecord } | { kind: 'canvas'; record: CanvasRecord })[] = []; + // 显式 DFS stack 反向压入兄弟,弹出顺序与源顺序一致;两种 Set 分别去重元素/canvas。 const work: typeof order = []; for (let index = roots.length - 1; index >= 0; index--) { yield; const record = document.elements.get(roots[index])!; if (record.structure === 'node') work.push({kind:'element',record}); } while (work.length) { @@ -61,10 +77,13 @@ export function* planDuplicateSteps(document: Document, registry: Registry, sele const child = childByEntry.get(item.record.id); if (child) work.push({ kind: 'canvas', record: child }); } } + // 边另外扫描:显式选中、属于复制 canvas、或指向复制节点的入边均纳入,源端可仍指向原上级节点。 const edges: ElementRecord[] = []; for (const element of document.elements.values()) { yield; if (element.structure === 'connection' && (selected.has(element.id) || copiedCanvases.has(element.ownerCanvasId) || copied.has(element.target.elementId))) { edges.push(element); copied.add(element.id); } } + // ID 分配前先保留源元素/canvas/墓碑全部 ID;同一 used 集合还防止两种新 ID 互撞。 const elementIds = new Map(), canvasIds = new Map(), used = new Set(); for (const map of [document.elements, document.canvases, document.tombstones]) for (const id of map.keys()) { yield; used.add(id); } + /** 优先使用宿主预留映射,否则调用工厂;无效/重复 ID 拒绝,不偷偷改映射后继续。 */ const allocate = (source: Id, kind: 'elements' | 'canvases') => { const supplied = options.mapping?.[kind]; const id = supplied && Object.hasOwn(supplied, source) ? supplied[source] : options.createId(); @@ -73,12 +92,15 @@ export function* planDuplicateSteps(document: Document, registry: Registry, sele }; for (const id of copied) { yield; elementIds.set(id, allocate(id, 'elements')); } for (const id of copiedCanvases) { yield; canvasIds.set(id, allocate(id, 'canvases')); } + // 映射不允许夹带复制范围之外的源键,防止恢复时带错任务的旧分配记录。 if (options.mapping) for (const [mapping, scope] of [[options.mapping.elements,copied],[options.mapping.canvases,copiedCanvases]] as const) for (const id in mapping) { yield; if (Object.hasOwn(mapping,id) && !scope.has(id)) throw fault('copy_mapping_scope_mismatch'); } + // 映射已完整固定才转换 payload,引用可以向前指向稍后生成的记录。 const changes: Change[] = []; for (const item of order) { yield; changes.push(duplicateTreeChange(document,registry,item.kind,item.record,elementIds,canvasIds,copiedCanvases,offset)); } + // 以源+新增表的临时 overlay 给边解算新端点/共同 owner,避免中间反复复制整份文档。 const nodeElements = new Map(), nodeCanvases = new Map(); for (const change of changes) { yield; if (change.kind === 'element' && change.after) nodeElements.set(change.id, change.after); else if (change.kind === 'canvas' && change.after) nodeCanvases.set(change.id, change.after); } const nodes: Document = { ...document, elements: overlay(document.elements, nodeElements), canvases: overlay(document.canvases, nodeCanvases) }; @@ -87,7 +109,9 @@ export function* planDuplicateSteps(document: Document, registry: Registry, sele const change=duplicateEdgeChange(document,registry,edge,elementIds,canvasIds,nodes); changes.push(change);nodeElements.set(change.id,change.after!); } + // 最终校验包含原文档与所有副本,确认 parent/entry/owner/端点/组件数据整体一致。 yield* validateDocumentSteps(nodes, registry); + // 输出普通对象使用 defineProperty 写自有键,避免特殊 ID 键触发原型 setter;最后冻结输出。 const elementMapping: Record = {}, canvasMapping: Record = {}, copiedRoots: Id[] = []; for (const [id, value] of elementIds) { yield; Object.defineProperty(elementMapping,id,{value,enumerable:true}); } for (const [id, value] of canvasIds) { yield; Object.defineProperty(canvasMapping,id,{value,enumerable:true}); } @@ -97,6 +121,8 @@ export function* planDuplicateSteps(document: Document, registry: Registry, sele } /** Shared deterministic emitters for synchronous and checkpointed planners. */ +/** 共享确定性转换器:canvas 改 parent/entry 映射,节点改身份/owner/data 与根级偏移。 + * 同步及 checkpoint planner 都使用这里,避免两套复制产品规则。 */ export function duplicateTreeChange(document:Document,registry:Registry,kind:'element'|'canvas',record:ElementRecord|CanvasRecord,elementIds:ReadonlyMap,canvasIds:ReadonlyMap,copiedCanvases:ReadonlySet,offset:Point):Exclude{ if (kind === 'canvas') { const canvas=record as CanvasRecord,parent=canvas.parent!; @@ -105,6 +131,7 @@ export function duplicateTreeChange(document:Document,registry:Registry,kind:'el } else { const old = record as ElementRecord; if (old.structure !== 'node') throw fault('invalid_copy_node'); let geometry = clone(old.geometry); + // 仅未被复制 owner 覆盖的根级节点应用世界偏移;内部节点保持局部位置,随新分区整体移动。 if (!copiedCanvases.has(old.ownerCanvasId)) { const transform = canvasTransform(document, old.ownerCanvasId), zero = toLocal(transform, { x: 0, y: 0 }), shift = toLocal(transform, offset); geometry = { ...geometry, x: geometry.x + shift.x - zero.x, y: geometry.y + shift.y - zero.y }; @@ -113,6 +140,7 @@ export function duplicateTreeChange(document:Document,registry:Registry,kind:'el return { kind: 'element', id: after.id, before: null, after }; } } +/** 映射边端点(外部端点沿用原 ID),按新端点算共同 owner,转换曲线后创建新 ID/incarnation=1。 */ export function duplicateEdgeChange(document:Document,registry:Registry,edge:ElementRecord,elementIds:ReadonlyMap,canvasIds:ReadonlyMap,nodes:Document):Extract{ if (edge.structure !== 'connection') throw fault('invalid_copy_endpoint'); if (!connectionPathValid(edge.path)) throw fault('unsupported_copy_path', [edge.id]); @@ -120,23 +148,29 @@ export function duplicateEdgeChange(document:Document,registry:Registry,edge:Ele const owner = commonCanvas(nodes, nodes.elements.get(source.elementId)!.ownerCanvasId, nodes.elements.get(target.elementId)!.ownerCanvasId), transform = canvasTransform(nodes, owner); let path = convertPath(edge.path, canvasTransform(document, edge.ownerCanvasId), transform); if (path && typeof path === 'object' && !Array.isArray(path) && Array.isArray(path.knots)) { + /** 对比原/新端点节点中心的世界位移,供曲线 knot 平滑分配跟随偏移。 */ const displacement = (beforeId: Id, afterId: Id) => { const a = document.elements.get(beforeId)!, b = nodes.elements.get(afterId)!; if (a.structure !== 'node' || b.structure !== 'node') throw fault('invalid_copy_endpoint'); const from = toWorld(canvasTransform(document, a.ownerCanvasId), { x: a.geometry.x + a.geometry.width / 2, y: a.geometry.y + a.geometry.height / 2 }), to = toWorld(canvasTransform(nodes, b.ownerCanvasId), { x: b.geometry.x + b.geometry.width / 2, y: b.geometry.y + b.geometry.height / 2 }); return { x: to.x - from.x, y: to.y - from.y }; }; + // knot 按链中序号在两端位移间插值;一端仍为原节点时,不把整条线简单平移。 const from = displacement(edge.source.elementId, source.elementId), to = displacement(edge.target.elementId, target.elementId), knots = path.knots; path = { ...path, knots: knots.map((raw, index) => { const knot = raw as Record, point = knot.position as { x: number; y: number }, world = toWorld(transform, point), t = (index + 1) / (knots.length + 1); return { ...knot, position: toLocal(transform, { x: world.x + from.x * (1 - t) + to.x * t, y: world.y + from.y * (1 - t) + to.y * t }) }; }) }; } const after: ElementRecord = { ...clone(edge), id: elementIds.get(edge.id)!, incarnation: 1, ownerCanvasId: owner, source, target, path, data: remapCopyData(edge, registry, elementIds, canvasIds, document) }; return { kind: 'element', id: after.id, before: null, after }; } +/** 为规划器组合源与新增表,返回临时查询文档;不是可公开发布的独立持久文档。 */ export function duplicateOverlay(document:Document,elements:ReadonlyMap,canvases:ReadonlyMap):Document{return {...document,elements:overlay(document.elements,elements),canvases:overlay(document.canvases,canvases)};} /** Internal overlay avoids an O(document size) clone between planning phases. Additions own unique IDs. */ +/** 只读接口覆盖层:新增 ID 保证唯一,先查 additions,迭代则 base 后 additions。 + * additions 在规划过程中仍会增长,故这个 view 只供内部计算,不是固定成员的发布快照。 */ function overlay(base: ReadonlyMap, additions: ReadonlyMap): ReadonlyMap { const view: ReadonlyMap = { + // get/has 与所有迭代器读取同一组当前表;forEach 的第三参数仍是这个只读接口。 get size() { return base.size + additions.size; }, get: key => additions.has(key) ? additions.get(key) : base.get(key), has: key => additions.has(key) || base.has(key), *entries(): Generator<[K,V],undefined> { yield* base; yield* additions; return undefined; }, @@ -149,6 +183,7 @@ function overlay(base: ReadonlyMap, additions: ReadonlyMap): Read } /** Synchronous API retained for small snapshots and callers that own scheduling. */ +/** 小数据同步便捷 API:连续耗尽分步迭代器,不自行让出主线程或持久化游标。 */ export function planDuplicate(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): DuplicatePlan { const steps = planDuplicateSteps(document,registry,selection,options); while (true) { const step = steps.next(); if (step.done) return step.value; } diff --git a/src/lib/canvas-core/queries.ts b/src/lib/canvas-core/queries.ts index 9615fa6..5335df9 100644 --- a/src/lib/canvas-core/queries.ts +++ b/src/lib/canvas-core/queries.ts @@ -1,41 +1,78 @@ +/** + * 文档的派生关系/空间查询快照:不保存另一份业务文档,不序列化这些索引。 + * 关系表描述 owner、子 canvas、关联边与声明引用;空间表按 owner canvas 分网格存节点包围盒。 + * 更新采用 touched bucket 的 copy-on-write,未变索引保持身份,供局部订阅复用。 + * 查询完整性与命中成功分开:没查到不一定不存在,加载不完整/协议失败必须显式报告。 + */ import {rectangleBounds,hitRectangle} from './geometry.ts'; import type {Change,Document,ElementRecord,Geometry,Id,Point,ReadonlyValueSet,Registry} from './types.ts'; import {copyReferenceIds} from './copy-paths.ts'; import {equal,fault,readMap,readSet} from './values.ts'; +/** owner canvas 空间内的轴对齐查询矩形;不是屏幕坐标,不能直接拿鼠标 clientX/Y 查询。 */ export type Bounds={readonly x:number;readonly y:number;readonly width:number;readonly height:number}; +/** 网格内部以四个边界保存包围盒,便于闭区间相交测试。 */ type Box={readonly minX:number;readonly minY:number;readonly maxX:number;readonly maxY:number}; +/** 一对多关系:键 ID→只读成员集合;更新时只复制被触及的集合。 */ type Relation=Map>; +/** 单个 owner 的空间表:格子→节点、节点→盒,以及无法合理分格的大对象集合。 */ type Spatial={cells:Relation;entries:Map;oversized:ReadonlyValueSet}; +/** 单元素声明引用的正向集合;complete=false 时不能据此证明它没有其它引用。 */ type References={elements:ReadonlySet;canvases:ReadonlySet;complete:boolean}; +/** cellSize 控制格子边长,两项上限限制单对象/单查询覆盖的格子数,避免坐标范围拖垮枚举。 */ export type QueryBudget={readonly cellSize?:number;readonly maxElementCells?:number;readonly maxQueryCells?:number}; +/** 已补默认值并验证后的预算,后续同一索引链不能静默换配置。 */ type ResolvedBudget=Required; +/** 私有索引状态;forward 用于计算引用差异,order/canvasOrder 维护文档稳定插入顺序。 */ type Tables={registry:Registry;budget:ResolvedBudget;members:Relation;children:Relation;childByEntry:Map;incident:Relation;elementRefs:Relation;canvasRefs:Relation;forward:Map;opaque:ReadonlyValueSet;spatial:Map;order:Map;canvasOrder:Map}; +/** 默认采用 512 单位网格;超限时保守扫描/oversized 回退,而不是丢掉候选项。 */ const defaultBudget:ResolvedBudget=Object.freeze({cellSize:512,maxElementCells:256,maxQueryCells:4096}); +/** 校验正 cellSize 与安全整数格子上限;冻结配置,保证同一快照查询规则稳定。 */ function budget(input:QueryBudget):ResolvedBudget{const result={...defaultBudget,...input};if(!Number.isFinite(result.cellSize)||result.cellSize<=0||![result.maxElementCells,result.maxQueryCells].every(value=>Number.isSafeInteger(value)&&value>=1&&value<=1000000))throw fault('invalid_query_budget');return Object.freeze(result);} +// 公共 view 仅暴露安全读接口;WeakMap 保存内部实现,不阻止旧查询快照被回收。 const privateTables=new WeakMap(); +/** 共享空只读集合,避免没有成员的关系查询反复分配容器。 */ const empty=readSet([]); +// 桶身份 + rank 表身份共同作为排序缓存键,任一变化才重新排序。 const orderedBuckets=new WeakMap>(); +/** 按文档顺序排序一个关系桶并缓存;只读桶和 rank 表在更新中保持不可变身份。 */ function sortedBucket(bucket:ReadonlyValueSet,ranks:Map){let versions=orderedBuckets.get(bucket);if(!versions){versions=new WeakMap();orderedBuckets.set(bucket,versions);}let ids=versions.get(ranks);if(!ids){ids=Object.freeze([...bucket].sort((a,b)=>ranks.get(a)!-ranks.get(b)!));versions.set(ranks,ids);}return ids;} /** Copy only touched buckets. Final buckets expose no native Set mutation methods. */ +/** copy-on-write 关系编辑器:change 首次触及时复制桶,finish 只替换实际有差异的桶。 + * 若增删相互抵消,finish 复用原表;最终用 readSet 隐藏可变 Set 方法。 */ function editRelation(base:Relation){const changed=new Map>();return{change(key:Id,id:Id,add:boolean){const current=changed.get(key)??base.get(key);if(!!current?.has(id)===add)return;let bucket=changed.get(key);if(!bucket){bucket=new Set(current??[]);changed.set(key,bucket);}if(add)bucket.add(id);else bucket.delete(id);},finish(){let next=base;for(const[key,ids]of changed){const before=base.get(key);if((before?.size??0)===ids.size&&[...ids].every(id=>before!.has(id)))continue;if(next===base)next=new Map(base);if(ids.size)next.set(key,readSet(ids));else next.delete(key);}return next;}};} +/** 合并默认矩形 logical 盒和组件 hit 盒并加浮点余量,避免粗筛漏掉自定义命中区域。 + * 协议抛错/非法几何返回 NaN 标记,后续保守保留候选并报告 unavailable,不伪称未命中。 */ function nodeBox(record:Extract,registry:Registry):Box{try{const protocol=registry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined,logical=rectangleBounds(record.geometry),hit=protocol?.kind==='node'?protocol.bounds({element:record,geometry:record.geometry,role:'hit'}):logical;if(![hit.x,hit.y,hit.width,hit.height].every(Number.isFinite)||hit.width<0||hit.height<0)throw fault('invalid_geometry_bounds');const bounds={x:Math.min(logical.x,hit.x),y:Math.min(logical.y,hit.y),width:Math.max(logical.x+logical.width,hit.x+hit.width)-Math.min(logical.x,hit.x),height:Math.max(logical.y+logical.height,hit.y+hit.height)-Math.min(logical.y,hit.y)};if(![bounds.x,bounds.y,bounds.width,bounds.height].every(Number.isFinite)||bounds.width<0||bounds.height<0)throw fault('invalid_geometry_bounds');const epsilon=Number.EPSILON*32*Math.max(1,Math.abs(bounds.x),Math.abs(bounds.y),bounds.width,bounds.height);return Object.freeze({minX:bounds.x-epsilon,minY:bounds.y-epsilon,maxX:bounds.x+bounds.width+epsilon,maxY:bounds.y+bounds.height+epsilon});}catch{return Object.freeze({minX:NaN,minY:NaN,maxX:NaN,maxY:NaN});}} +/** 将有限包围盒映射为格子键;计数不安全或超预算时返回 undefined,交给回退路径处理。 */ function cells(box:Box,limit:number,cellSize:number):string[]|undefined{ const x0=Math.floor(box.minX/cellSize),x1=Math.floor(box.maxX/cellSize),y0=Math.floor(box.minY/cellSize),y1=Math.floor(box.maxY/cellSize),count=(x1-x0+1)*(y1-y0+1); if(![x0,x1,y0,y1,count].every(Number.isSafeInteger)||count<1||count>limit)return; const keys:string[]=[];for(let x=x0;x<=x1;x++)for(let y=y0;y<=y1;y++)keys.push(`${x},${y}`);return keys; } +/** 只解析组件明确声明的复制引用;未知 schema/解析失败标记不完整,不能猜 payload 中的 ID。 */ function refs(record:ElementRecord,registry:Registry):References{ const elements=new Set(),canvases=new Set(),schema=registry.components.get(record.componentId)?.copy; if(registry.resolve(record)!=='known'||!schema)return{elements,canvases,complete:false}; try{for(const ref of schema.references)for(const id of copyReferenceIds(record.data,ref))(ref.target==='element'?elements:canvases).add(id);return{elements,canvases,complete:true};}catch{return{elements:new Set(),canvases:new Set(),complete:false};} } +/** 根据表的迭代顺序建立 rank;顺序完全相同时复用旧 Map,避免排序缓存全部失效。 */ function order(previous:Map|undefined,ids:Iterable){const list=[...ids];if(previous&&previous.size===list.length&&list.every((id,index)=>previous.get(id)===index))return previous;return new Map(list.map((id,index)=>[id,index]));} +/** 将单 owner 的内部 Map 转成安全只读视图,元素 Box 与 oversized 集合复用不可变值。 */ 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;} +/** 检测四个边界是否全部有限;NaN 哨兵表示几何协议不可用。 */ function finite(box:Box){return Object.values(box).every(Number.isFinite);} +/** 加载证据:elementIds 是已知完整 ID 命名空间,loadedElementIds 是实际处理过的源 ID。 + * projectComplete/topologyComplete 与逐 canvas 完整范围分别声明,不能由当前元素数量推断。 */ export type QueryCoverage={readonly elementIds?:ReadonlyValueSet;readonly loadedElementIds?:ReadonlyValueSet;readonly projectComplete:boolean;readonly topologyComplete:boolean;readonly completeCanvasIds:readonly Id[]}; +/** present/absent/unloaded 三态,防止把尚未读到的对象当成已删除。 */ export type ElementLookup={readonly status:'present';readonly element:ElementRecord}|{readonly status:'absent'|'unloaded'}; +/** 返回当前已知 ID 及是否完整;ids=[] 且 complete=false 不能证明空集合。 */ export type IdQueryResult={readonly ids:readonly Id[];readonly complete:boolean}; +/** 与一个确切 document/registry/revision 绑定的公共查询。 + * members/child/incident 提供稳定排序;Result 版本另外携带加载完整性。 + * candidateNodes 是空间粗筛,hitNodeBounds 用矩形精判,hitNodes 使用组件 hit 协议。 */ export type DocumentQueries={ readonly coverage:QueryCoverage; lookupElement(id:Id):ElementLookup; @@ -49,6 +86,7 @@ export type DocumentQueries={ }; // No mapping callback captures the previous query: retained methods must not // accidentally keep its context alive, including after consumer bundling. +/** 仅为变化 owner 重建公开空间视图;整表未变直接复用,不让新方法闭包保留旧查询上下文。 */ function spatialViews(tables:Tables,previous?:DocumentQueries){ const old=previous?privateTables.get(previous)!:undefined; if(previous&&tables.spatial===old!.spatial)return previous.spatial; @@ -56,66 +94,99 @@ function spatialViews(tables:Tables,previous?:DocumentQueries){ for(const[id,value]of tables.spatial)result.set(id,previous&&old!.spatial.get(id)===value?previous.spatial.get(id)!:protectSpatial(value)); return readMap(result); } +/** 将私有索引装配成只读查询 API;默认完整文档,部分加载通过 withQueryCoverage 再绑定证据。 */ function expose(document:Document,revision:number,tables:Tables,previous?:DocumentQueries):DocumentQueries{ + /** 按当前 rank 过滤/排序 ID;避免返回已经不在相应文档表中的项。 */ const sorted=(ids:Iterable,ranks=tables.order)=>Object.freeze([...ids].filter(id=>ranks.has(id)).sort((a,b)=>ranks.get(a)!-ranks.get(b)!)); + // 关系子表按身份复用;referenceComplete 只有全部记录引用声明均可解释时才为真。 const relation=Object.freeze({ownerMembers:previous&&tables.members===privateTables.get(previous)!.members?previous.relations.ownerMembers:readMap(tables.members),canvasChildren:previous&&tables.children===privateTables.get(previous)!.children?previous.relations.canvasChildren:readMap(tables.children),childCanvasByEntry:previous&&tables.childByEntry===privateTables.get(previous)!.childByEntry?previous.relations.childCanvasByEntry:readMap(tables.childByEntry),incidentEdges:previous&&tables.incident===privateTables.get(previous)!.incident?previous.relations.incidentEdges:readMap(tables.incident),elementReferrers:previous&&tables.elementRefs===privateTables.get(previous)!.elementRefs?previous.relations.elementReferrers:readMap(tables.elementRefs),canvasReferrers:previous&&tables.canvasRefs===privateTables.get(previous)!.canvasRefs?previous.relations.canvasReferrers:readMap(tables.canvasRefs),opaqueReferences:tables.opaque,referenceComplete:tables.opaque.size===0}); const spatial=spatialViews(tables,previous); + /** 从网格收集候选并做 AABB 过滤;超大查询扫描全部 entries,普通查询同时加入 oversized。 */ function candidates(canvas:Id,rect:Bounds){ if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);if(![rect.x,rect.y,rect.width,rect.height].every(Number.isFinite)||rect.width<0||rect.height<0)throw fault('invalid_query_bounds'); + // Set 合并多个格子的重复 ID;无效 Box 保留为候选,后续精判负责报告不完整。 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(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:DocumentQueries=Object.freeze({ coverage:Object.freeze({projectComplete:true,topologyComplete:true,completeCanvasIds:Object.freeze([...document.canvases.keys()])}), + /** 完整源下查不到即 absent;部分源的三态规则由 coverage 包装替换。 */ 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, + // 关系查询首先检查目标表项存在,再按元素/画布各自 rank 排序;不是遍历请求 queue。 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)=>sorted(ids),orderedCanvases:(ids:Iterable)=>sorted(ids,tables.canvasOrder),candidateNodes:candidates, + /** 点周围容差框粗筛,再调用自定义 hit(无协议则矩形回退);抛错项进入 unavailable。 */ 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;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)});}, + /** 对候选按旋转矩形距离精判;无效几何使 complete=false,不当作可靠 miss。 */ 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).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(); +// 相同 namespace 来源只捕获一次只读副本,防止宿主后来修改集合污染已发布证据。 const namespaceCopies=new WeakMap>(); +/** 捕获外部 ID 集合的成员,缓存仅按原对象身份复用;输入合同应视为不可变值。 */ function ownedNamespace(ids:ReadonlyValueSet){let value=namespaceCopies.get(ids);if(!value){value=readSet(ids);namespaceCopies.set(ids,value);}return value;} /** Bind completeness to the same publication as the model, without rebuilding indexes. */ +/** 不重建索引,仅替换同次发布中的加载证据与完整性方法;未知 canvas/矛盾完整声明拒绝。 */ 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'); + // 命名空间与 loaded IDs 一起捕获;它们可证明某 ID 不存在或已处理删除,其余缺失保留 unloaded。 const coverage=Object.freeze({...(input.elementIds?{elementIds:ownedNamespace(input.elementIds)}:{}),...(input.loadedElementIds?{loadedElementIds:ownedNamespace(input.loadedElementIds)}:{}),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}), + /** 已有记录优先 present;完整项目、命名空间排除、或已加载且有墓碑时才能判 absent。 */ lookupElement:(id:Id):ElementLookup=>{const element=base.document.elements.get(id);return element?Object.freeze({status:'present',element}):Object.freeze({status:coverage.projectComplete||coverage.elementIds&&(!coverage.elementIds.has(id)||coverage.loadedElementIds?.has(id)&&base.document.tombstones.has(id))?'absent':'unloaded'});}, + // 成员完整性按 owner,子树按 topology,关联边按整个项目,三者不能互相替代。 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}), + /** 几何可用还不够,当前 owner 源也完整时,命中结果才可声明 complete。 */ hitNodes(canvas:Id,point:Point,tolerance=0){const result=base.hitNodes(canvas,point,tolerance);return Object.freeze({...result,complete:result.complete&&complete.has(canvas)});}, + /** 将基础矩形命中的数值完整性与 owner 的加载完整性求交。 */ 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. */ +/** 从零构造纯派生索引,复用同一 advance 算法,避免维护两套建表规则。 */ export function createDocumentQueries(document:Document,registry:Registry,revision=0,options:QueryBudget={}):DocumentQueries{return advanceDocumentQueries(undefined,document,registry,revision,undefined,options);} +/** 在当前发布前准备下一份索引;changes 精确列出受影响 ID,否则按新旧表并集检查。 + * 必须与同一项目/registry/预算配合,revision 不可倒退,同 revision 不接受另一份 document。 */ export function advanceDocumentQueries(previous:DocumentQueries|undefined,document:Document,registry:Registry,revision:number,changes?:readonly Change[],options:QueryBudget={}):DocumentQueries{ if(!Number.isSafeInteger(revision)||revision<0||previous&&(previous.document.projectId!==document.projectId||revisionchange.kind!=='canvas').map(change=>change.kind==='field'?change.target.id:change.id)):new Set([...(oldDoc?.elements.keys()??[]),...document.elements.keys()]),canvasIds=changes?new Set(changes.filter(change=>change.kind==='canvas').map(change=>change.id)):new Set([...(oldDoc?.canvases.keys()??[]),...document.canvases.keys()]); const members=editRelation(old?.members??new Map()),children=editRelation(old?.children??new Map()),incident=editRelation(old?.incident??new Map()),elementRefs=editRelation(old?.elementRefs??new Map()),canvasRefs=editRelation(old?.canvasRefs??new Map());let childByEntry=old?.childByEntry??new Map(),forward=old?.forward??new Map(),opaque=old?.opaque??empty,spatial=old?.spatial??new Map(); + // 整记录替换不能信任字段提示;普通 data 修改可据顶层键判断是否影响声明引用/几何。 const replaced=new Set(),dataFields=new Map>();if(changes)for(const change of changes){if(change.kind==='element')replaced.add(change.id);else if(change.kind==='field'&&change.field.startsWith('data.')){let fields=dataFields.get(change.target.id);if(!fields){fields=new Set();dataFields.set(change.target.id,fields);}fields.add(change.field.slice(5));}} + // 先按 canvas 聚集空间修改,再统一编辑其格子桶,避免每条变化复制整份网格。 const spatialChanges=new Map>(); + /** 记录某 owner 内节点的最终 Box,null 表示移除;同一轮重复记录由最后值覆盖。 */ const spatialChange=(canvas:Id,id:Id,box:Box|null)=>{let list=spatialChanges.get(canvas);if(!list){list=new Map();spatialChanges.set(canvas,list);}list.set(id,box);}; + // 每个实际变化元素分别推进:owner成员→关联边→引用/opaque→几何;无关数据不触及空间表。 for(const id of ids){const before=oldDoc?.elements.get(id),after=document.elements.get(id);if(before===after)continue; if(before?.ownerCanvasId!==after?.ownerCanvasId){if(before)members.change(before.ownerCanvasId,id,false);if(after)members.change(after.ownerCanvasId,id,true);} + // 端点用 Set 去重(自环只占一个桶成员),只对真正新增/删除的端点关系写差异。 const beforeEndpoints=before?.structure==='connection'?new Set([before.source.elementId,before.target.elementId]):new Set(),afterEndpoints=after?.structure==='connection'?new Set([after.source.elementId,after.target.elementId]):new Set();for(const endpoint of beforeEndpoints)if(!afterEndpoints.has(endpoint))incident.change(endpoint,id,false);for(const endpoint of afterEndpoints)if(!beforeEndpoints.has(endpoint))incident.change(endpoint,id,true); + // 引用声明未涉及的顶层字段变化可复用 oldRefs,避免改 title 也递归扫描所有引用。 const oldRefs=forward.get(id),schema=after?registry.components.get(after.componentId)?.copy:undefined,referenceFieldsUnchanged=!!changes&&!replaced.has(id)&&!!schema&&schema.references.every(ref=>!dataFields.get(id)?.has(ref.path[0])),nextRefs=after?(before&&oldRefs&&before.componentId===after.componentId&&before.dataVersion===after.dataVersion&&(referenceFieldsUnchanged||equal(before.data,after.data))?oldRefs:refs(after,registry)):undefined; if(oldRefs!==nextRefs)for(const kind of ['elements','canvases'] as const){const table=kind==='elements'?elementRefs:canvasRefs;for(const target of oldRefs?.[kind]??[])if(!nextRefs?.[kind].has(target))table.change(target,id,false);for(const target of nextRefs?.[kind]??[])if(!oldRefs?.[kind].has(target))table.change(target,id,true);} if(oldRefs!==nextRefs&&(!!oldRefs?.complete!==!!nextRefs?.complete||!equal([...(oldRefs?.elements??[])],[...(nextRefs?.elements??[])])||!equal([...(oldRefs?.canvases??[])],[...(nextRefs?.canvases??[])])||!oldRefs&&nextRefs||oldRefs&&!nextRefs)){if(forward===old?.forward)forward=new Map(forward);if(nextRefs)forward.set(id,nextRefs);else forward.delete(id);} + // 无法解释引用的活元素进入 opaque 集合,提示调用方不能把空引用结果当作完整证明。 const shouldOpaque=!!after&&!nextRefs?.complete;if(opaque.has(id)!==shouldOpaque){const next=new Set(opaque);if(shouldOpaque)next.add(id);else next.delete(id);opaque=readSet(next);} + // 自定义几何未声明 dataDependencies 时保守重建;明确空数组则普通 data 编辑不重算 Box。 const was=before?.structure==='node'?before:undefined,now=after?.structure==='node'?after:undefined,protocol=now&®istry.resolve(now)==='known'?registry.components.get(now.componentId)?.geometry:undefined,shapeDataChanged=protocol?.kind==='node'&&!equal(before?.data,after?.data)&&(protocol.dataDependencies===undefined||protocol.dataDependencies.some(key=>!equal(before?.data[key],after?.data[key])));if(was?.ownerCanvasId!==now?.ownerCanvasId||!equal(was?.geometry,now?.geometry)||before?.componentId!==after?.componentId||before?.dataVersion!==after?.dataVersion||shapeDataChanged){if(was)spatialChange(was.ownerCanvasId,id,null);if(now)spatialChange(now.ownerCanvasId,id,nodeBox(now,registry));} } + // 父/入口关系先统一移除旧绑定,再统一添加新绑定,避免同批交换入口时误删刚写的新绑定。 for(const id of canvasIds){const before=oldDoc?.canvases.get(id)?.parent,after=document.canvases.get(id)?.parent;if(equal(before,after))continue;if(before){children.change(before.canvasId,id,false);if(childByEntry===old?.childByEntry)childByEntry=new Map(childByEntry);childByEntry.delete(before.entryElementId);}} for(const id of canvasIds){const before=oldDoc?.canvases.get(id)?.parent,after=document.canvases.get(id)?.parent;if(equal(before,after)||!after)continue;children.change(after.canvasId,id,true);if(childByEntry===old?.childByEntry)childByEntry=new Map(childByEntry);childByEntry.set(after.entryElementId,id);} + // 删除旧格子占用、再插新 Box;超限/无效 Box 放 oversized,不能因预算或错误漏出候选。 if(spatialChanges.size){spatial=new Map(spatial);for(const[canvas,updates]of spatialChanges){const base=spatial.get(canvas),entries=new Map(base?.entries),grid=editRelation(base?.cells??new Map()),large=new Set(base?.oversized??[]);for(const[id,box]of updates){const before=entries.get(id);if(before)for(const key of cells(before,configuration.maxElementCells,configuration.cellSize)??[])grid.change(key,id,false);large.delete(id);if(box){entries.set(id,box);const keys=cells(box,configuration.maxElementCells,configuration.cellSize);if(keys)for(const key of keys)grid.change(key,id,true);else large.add(id);}else entries.delete(id);}if(entries.size)spatial.set(canvas,{entries,cells:grid.finish(),oversized:readSet(large)});else spatial.delete(canvas);}} + // 关系桶 finish 后装配;只在增删记录时重算全局插入顺序,最后返回与文档同 revision 的视图。 const tables:Tables={registry,budget:configuration,members:members.finish(),children:children.finish(),childByEntry,incident:incident.finish(),elementRefs:elementRefs.finish(),canvasRefs:canvasRefs.finish(),forward,opaque,spatial,order:old&&changes&&!changes.some(change=>change.kind==='element'&&(!change.before||!change.after))?old.order:order(old?.order,document.elements.keys()),canvasOrder:old&&changes&&!changes.some(change=>change.kind==='canvas'&&(!change.before||!change.after))?old.canvasOrder:order(old?.canvasOrder,document.canvases.keys())};return expose(document,revision,tables,previous); } diff --git a/src/lib/canvas-runtime/contracts.ts b/src/lib/canvas-runtime/contracts.ts index 07ce29e..e1df584 100644 --- a/src/lib/canvas-runtime/contracts.ts +++ b/src/lib/canvas-runtime/contracts.ts @@ -1,3 +1,8 @@ +/** + * core 与 runtime/service 的共享合同:RuntimeState 与文档/历史/操作一起由一个 Root 发布。 + * ledger 是调度事实源,acknowledged+versions 是已确认服务基线,working 文档仍位于 EngineSnapshot.document。 + * 外部异步 read/submit/query 只能形成提案,由 engine/reconciler 校验后统一发布,不直接覆盖 working。 + */ import type {OrphanFile} from './orphan-files.ts'; import type { ImportJob } from './imports.ts'; import type { CanvasRecord, ElementRecord, Document, Id, Json, OperationIntent, Snapshot } from '../canvas-core/types.ts'; @@ -7,42 +12,72 @@ import type { RunRecord } from './runs.ts'; import type { ResourceRecord } from './resources.ts'; import type { RecoveryAudit } from './conflicts.ts'; +/** 持久运行事实集合;view/selection/gesture 和渲染缓存不放入该结构。 */ export type RuntimeState = { + /** 项目资源与共享 AssetDepot 的授权/身份绑定,非第二份图片字节。 */ readonly assetBindings?:ReadonlyMap; + /** 分段读取过程中保留的 canonical 捕获提案,与已有 ACK 基线分开协调。 */ readonly loadingRead?:CanonicalProposal; + /** 当前流式加载覆盖范围/进度与发送门禁事实。 */ readonly loading?:import('./loading.ts').LoadingState; + /** 孤儿文件盘点/保留事实,不因当前 DOM 未展示就立即删除。 */ readonly orphanFiles?:ReadonlyMap; + /** 导入与复制 job 的可恢复进度,包含源身份/游标/稳定映射,任务不归 view 所有。 */ readonly imports?: ReadonlyMap; + /** 唯一逻辑操作/冻结请求/结果事实;queue 和到期 heap 由它派生调度。 */ readonly ledger: LedgerSnapshot; + /** 已受理/控制/产生输出的生成任务记录,与文档元素和外部任务 ID 分开。 */ readonly runs: ReadonlyMap; + /** 最新已确认模型基线;后续本地编辑叠加在 working,不能用旧 ACK 直接覆盖。 */ readonly acknowledged: Document; + /** 每个参与 canvas 的服务端版本,用于条件提交;不是 rootRevision 或组件 dataVersion。 */ readonly versions: ReadonlyMap; /** Raw recovery inputs, or adapter-owned summaries for fully reconciled document requests. */ + /** 原始恢复输入或已完全协调请求的精简回执,保留外部执行证据。 */ readonly receipts: ReadonlyMap; + /** 待处理冲突原因,不能从请求队列为空推断冲突已消失。 */ readonly conflicts: ReadonlyMap; + /** 文件/远端资产的逻辑资源状态,不把临时 blob URL 当可恢复身份。 */ readonly resources: ReadonlyMap; + /** 显式保留 owner→资源 ID 列表(如 recovery);working/history/ledger 的反向 pin 表由扫描派生。 */ readonly resourcePins: ReadonlyMap; + /** 用户已审阅的恢复/采纳证据,用于后续重放与审计,避免再次猜测冲突取舍。 */ readonly recoveries: ReadonlyMap; }; +/** core Snapshot 的 runtime 具象化,单次订阅可同时看到模型和对应运行事实。 */ export type EngineSnapshot = Snapshot; +/** 经过服务 codec 校验的文档与版本向量候选;只有协调完成后才能成为新基线。 */ export type CanonicalProposal = { readonly document: Document; readonly versions: ReadonlyMap }; /** Receiving records are read deltas, not a validated Document or an ACK base. */ +/** receiving 是尚未完成的读取增量,数量/owner 证据不表示整份 payload 已通过校验。 */ export type CanonicalReadUpdate = CanonicalReadResult | { readonly status:'receiving'; readonly canvasElementCounts?:import('./scope-counts.ts').CanvasElementCounts;readonly elementOwners?:import('./scope-counts.ts').ElementOwners; readonly projectId:Id; readonly canvases:readonly CanvasRecord[]; readonly elements:readonly ElementRecord[]; readonly tombstones:ReadonlyMap; readonly versions:ReadonlyMap; readonly receivedElements:number; readonly totalElements:number }; +/** ready 提供新模型;migration-required 描述显式迁移资格,不在读取时偷偷执行迁移。 */ export type CanonicalReadResult = ({ readonly status: 'ready';readonly sourceToken?:string } & CanonicalProposal) | { readonly status: 'migration-required'; readonly rootId: Id; readonly sourceVersions: ReadonlyMap }; /** A validated historical execution receipt; its old model is never published over a fresh read. */ +/** 验证历史 applied 回执的执行证据;assertCanonical 比对 fresh read,不发布旧回执里的旧模型。 */ export type AppliedModelProof = { readonly versions:ReadonlyMap; assertCanonical(proposal:CanonicalProposal):void }; +/** 增量服务端口:compile/canonical 是纯编解码,submit/query/read 是可取消外部 I/O。 */ export type PersistencePort = { /** Complete canonical read for a quiescent recovery session; never a legacy fallback. */ + /** 恢复协调所需的完整 canonical read;不能拿 legacy fallback 当目标协议基线。 */ read?: (signal: AbortSignal) => Promise; + /** 从原请求与回执提取历史执行证明,供 receipt 过期/旧模型协调使用。 */ verifyApplied?: (request:FrozenRequest,receipt:Json) => AppliedModelProof; + /** 判断是否有原冻结协议解释器,不支持时保留原 body/key 并阻塞,不能重编码换身份。 */ supportsRequest?: (request: FrozenRequest) => boolean; + /** 将稳定意图与当前基线编成待冻结请求;网络发送前仍需满足持久化屏障。 */ compile(intent: OperationIntent, requestId: Id, snapshot: EngineSnapshot): CompiledRequest; /** Compile an ordered mutable batch; logical IDs and original intents remain independent. */ + /** 可把有序未冻结意图合并为一份 wire 请求,原 logical IDs 与意图事实仍独立保留。 */ compileBatch?: (intents: readonly OperationIntent[], requestId: Id, snapshot: EngineSnapshot) => CompiledRequest; + /** 发送原 FrozenRequest,保持已冻结字节/key;中断或丢响应不等于服务端没执行。 */ submit(request: FrozenRequest, signal: AbortSignal): Promise; + /** 查询原请求的执行状态;unknown/404 的含义由协议结果处理,不能擅自当 not-applied。 */ query(request: FrozenRequest, signal: AbortSignal): Promise; /** Optional pure codec for a joint resolved publication. Rejection/failure retains the raw recovery input. */ + /** 可选精简回执 codec,仅在联合已协调发布时采用;失败应保留原恢复输入。 */ compactResolvedReceipt?: (request:FrozenRequest,receipt:Json,applied:CanonicalProposal)=>Json; /** Pure codec, merging only participants into the latest base, never an old working snapshot. */ + /** 将回执参与者合并进最新已确认基线,不能拿旧 working 快照替换新本地编辑。 */ canonical(request: FrozenRequest, status: OperationStatus, snapshot: EngineSnapshot): CanonicalProposal; }; diff --git a/src/lib/canvas-runtime/network.ts b/src/lib/canvas-runtime/network.ts index cfd4ee5..3980cd7 100644 --- a/src/lib/canvas-runtime/network.ts +++ b/src/lib/canvas-runtime/network.ts @@ -1,24 +1,35 @@ +/** + * 跨文档写入、上传和 run 读取共享的并发闸门。 + * 等待项按 FIFO 排队,AbortSignal 取消等待;运行中的网络调用仍由 callback 自己响应 signal。 + * 闸门只控制容量,不保存 operation/request 身份,也不能代替 ledger 的依赖和结果协调。 + */ import { fault } from '../canvas-core/values.ts'; import { createDeque } from './structures.ts'; /** Shared bounded transport capacity for writes, uploads and run reads. */ +/** 建立正整数容量的 gate,beforeRun 在实际执行 callback 前检查宿主身份等条件。 */ export function createNetworkGate(limit = 2, beforeRun?:()=>void) { if(!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_concurrency'); let active = 0; const waiting = createDeque<{ signal: AbortSignal; grant(): void; cancelled: boolean }>(); + /** 有空位直接占用,否则等待 grant;已取消的等待项标记保留,释放时惰性跳过。 */ async function enter(signal: AbortSignal) { signal.throwIfAborted(); if (active < limit) { active++; return; } await new Promise((resolve,reject) => { + // grant 解除 abort 监听再唤醒;被唤醒后 run 还会再次检查 signal,避免取消竞态执行请求。 const item = { signal, cancelled: false, grant() { signal.removeEventListener('abort',abort);resolve(); } }; + /** 取消只拒绝此等待者,不占用/释放活跃名额;队列节点由 release 跳过。 */ const abort = () => { item.cancelled=true;signal.removeEventListener('abort',abort);reject(signal.reason); }; signal.addEventListener('abort',abort,{once:true});waiting.push(item); }); } + /** 优先把原名额直接交给下一有效等待者;无人等待时才 active--,避免超发容量。 */ function release() { let item=waiting.pop();while(item?.cancelled)item=waiting.pop(); if(item)item.grant();else active--; } + /** 统一入口:enter 后即使 signal/beforeRun/callback 抛错,finally 也会归还名额。 */ return Object.freeze({ async run(signal: AbortSignal, callback: () => Promise): Promise { await enter(signal); try { signal.throwIfAborted();beforeRun?.();return await callback(); } finally { release(); } diff --git a/src/lib/canvas-runtime/scope-counts.ts b/src/lib/canvas-runtime/scope-counts.ts index 52314b1..a163c98 100644 --- a/src/lib/canvas-runtime/scope-counts.ts +++ b/src/lib/canvas-runtime/scope-counts.ts @@ -1,8 +1,14 @@ +/** + * 流式 canonical 捕获的范围证据校验:每个 canvas 的直属成员数量、全部活元素 ID→owner 和拥有树。 + * 这些头部证据用于判断某 owner 是否完整,不能替代尾部 payload/schema/端点的完整文档校验。 + */ import type {Id} from '../canvas-core/types.ts'; import {fault} from '../canvas-core/values.ts'; /** Snapshot-bound direct membership counts, including nodes and connections. */ +/** 当前捕获快照的直属数量,包含节点和边;不是递归子树大小或 DOM 已挂载数量。 */ export type CanvasElementCounts=Readonly>; +/** 要求 counts 精确覆盖 canvasIds、每项非负安全整数,且总和等于声明 total。 */ 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,keys=Object.keys(counts);let sum=0; @@ -12,18 +18,24 @@ export function validateCanvasElementCounts(value:unknown,canvasIds:readonly Id[ } /** All active IDs in the capture, including records not yet received. */ +/** 完整活 ID 命名空间,即使部分记录尚未传到客户端,也预先知道其直接 owner。 */ export type ElementOwners=Readonly>; +/** 核验每个活 ID 的 owner 合法,并按 owner 反算数量与 counts 一致,不靠已读前缀猜完整性。 */ export function validateElementOwners(value:unknown,counts:CanvasElementCounts,total:number):asserts value is ElementOwners { if(!value||typeof value!=='object'||Array.isArray(value)||Object.keys(value).length!==total)throw fault('invalid_model_identity'); + // 无原型计数表避免特殊 owner 键污染计数;空 canvas 由缺省 0 与 counts 比较。 const actual:Record=Object.create(null); for(const [id,owner]of Object.entries(value)){if(!id||typeof owner!=='string'||!Object.hasOwn(counts,owner))throw fault('invalid_model_identity');actual[owner]=(actual[owner]??0)+1;} if(Object.keys(counts).some(id=>(actual[id]??0)!==counts[id]))throw fault('invalid_model_identity'); } /** Full captured topology, independent of which entry payloads have arrived. */ +/** 用完整 topology+owner 证明验证单根、父存在、入口唯一/归属正确和无环。 + * 此时入口记录可能尚未收到,入口是否确为 node 仍需 payload 到达后的文档校验。 */ export function validateCanvasTopology(canvases:readonly import('../canvas-core/types.ts').CanvasRecord[],owners:ElementOwners,projectId:Id):void { if(!Array.isArray(canvases)||!canvases.length)throw fault('invalid_model_topology'); const table=new Map(canvases.map(canvas=>[canvas?.id,canvas]));if(table.size!==canvases.length||!table.has(projectId))throw fault('invalid_model_topology'); + // 每个入口最多绑定一个子 canvas;逐条父链用 seen 检测环,根 ID 必须等于 projectId。 let roots=0;const entries=new Set(); for(const canvas of canvases){if(!canvas||typeof canvas.id!=='string'||!canvas.id||!Object.hasOwn(canvas,'parent'))throw fault('invalid_model_topology');if(canvas.parent===null){roots++;if(canvas.id!==projectId)throw fault('invalid_model_topology');continue;} const parent=canvas.parent;if(!parent||!table.has(parent.canvasId)||typeof parent.entryElementId!=='string'||!parent.entryElementId||entries.has(parent.entryElementId)||owners[parent.entryElementId]!==parent.canvasId)throw fault('invalid_model_topology');entries.add(parent.entryElementId); diff --git a/src/lib/canvas-runtime/storage-port.ts b/src/lib/canvas-runtime/storage-port.ts index 927c907..0429cd9 100644 --- a/src/lib/canvas-runtime/storage-port.ts +++ b/src/lib/canvas-runtime/storage-port.ts @@ -1,19 +1,39 @@ +/** + * 便携持久化端口:定义 scope、leader/fence、恢复图像与原子 DurableBatch。 + * 这里不导入 IndexedDB/Web Locks 实现,Node/测试宿主可以提供其它 adapter。 + * 成功 commit 才能确认 durable,内存发布或排队成功不能替代它。 + */ import type { Json } from '../canvas-core/types.ts'; /** Portable durable-store contract. Browser database types belong to the adapter. */ +/** 账号/工作区/项目共同隔离持久事实;同 projectId 不得串用其它账号的存储。 */ export type StorageScope = { readonly accountId: string; readonly workspaceId: string; readonly projectId: string }; +/** 可恢复单 Root 图像;formatVersion 是解释器版本,rootRevision 与文档 localRevision 分开。 */ export type RecoveryImage = { readonly formatVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17; readonly rootRevision: number; readonly localRevision: number; readonly payload: Json }; +/** 同一提交携带恢复图像、文件写入/删除及可选共享 depot cut,adapter 保证合同中的原子性。 */ export type DurableBatch = { readonly image: RecoveryImage; readonly deleteFiles?:readonly string[]; readonly depot?:import('./asset-depot.ts').DepotCut; readonly files?: readonly { readonly id: string; readonly bytes: Blob }[] }; +/** 排他写者句柄;fence 区分代次,active 检查加实际存储校验阻止旧 writer 在新 leader 后继续写。 */ export type StorageLeader = { readonly scope: StorageScope; readonly fence: number; readonly active: boolean; release(): Promise }; +/** 文件盘点只返回元信息,不为扫描加载全部 Blob 字节。 */ export type StoredFileInfo={readonly id:string;readonly byteLength:number;readonly mimeType:string}; +/** 分页盘点绑定 rootRevision,nextAfter 是后续页游标,不能跨变更拼接成完整清理依据。 */ export type FileInventoryPage={readonly files:readonly StoredFileInfo[];readonly rootRevision:number;readonly nextAfter?:string}; +/** 宿主实现的存储能力;可选字段必须显式探测,缺能力不能假装已完成扫描或原子删除。 */ export type StoragePort = { + /** 可选工作区共享资源仓库,文件/引用保留通过 depot 合同处理。 */ readonly assetDepot?:import('./asset-depot.ts').AssetDepotPort; + /** 在 expectedRootRevision 下按稳定游标读取文件页,revision 不匹配应拒绝。 */ scanFiles?(leader:StorageLeader,input:{readonly expectedRootRevision:number;readonly after?:string;readonly limit:number}):Promise; + /** atomic-v1 明确声明支持与 Root 一起提交文件删除,不能用普通异步 delete 冒充。 */ readonly fileDeletion?: 'atomic-v1'; + /** 取得作用域 leader,已有有效写者时拒绝,而不是悄悄允许两个 writer。 */ claim(scope: StorageScope): Promise; + /** 在有效 leader 下恢复最后可靠图像;null 表示没有已提交快照。 */ recover(leader: StorageLeader): Promise; + /** 原子提交 DurableBatch 并返回真实完成的 rootRevision,不能提前在请求入队时 resolve。 */ commit(leader: StorageLeader, batch: DurableBatch): Promise<{ readonly durableRootRevision: number }>; + /** 读取指定文件的 Blob,缺失用 undefined;不从文档资源引用猜字节已经存在。 */ readFile(leader: StorageLeader, id: string): Promise; + /** 关闭 adapter 的本地句柄,leader 的异步释放仍通过其 release 合同完成。 */ close(): void; }; diff --git a/src/lib/canvas-runtime/structures.ts b/src/lib/canvas-runtime/structures.ts index 3ddd5ef..a2705c9 100644 --- a/src/lib/canvas-runtime/structures.ts +++ b/src/lib/canvas-runtime/structures.ts @@ -1,31 +1,51 @@ +/** + * runtime 使用的基础调度容器,不包含任务状态事实:FIFO 存可执行顺序,min-heap 存优先项。 + * 两者都是内存结构,ledger/checkpoint 才负责可恢复事实;容器本身不去重、不校验依赖或执行网络。 + */ /** FIFO with amortized O(1) removal, without Array.shift on each item. */ +/** 当前接口是先进先出队列:push 入尾、pop 出头,并非 history 的后进先出 stack。 + * head 游标避免每次 Array.shift 搬动所有元素,周期压缩使出队摊销 O(1)。 */ export function createDeque() { + // 已出队区间保留到下一次压缩/clear;有效长度为 items.length-head。 let items: T[] = [], head = 0; return { + /** 有效未消费数量,不包含 head 前已取出的槽位。 */ get size() { return items.length - head; }, + /** 追加队尾,不处理重复值;queued Set 等去重策略由上层维护。 */ push(value: T) { items.push(value); }, + /** 查看最早未消费项,不移动 head。 */ peek(): T | undefined { return items[head]; }, + /** 取出队头;空队列返回 undefined,已消费量较大且过半时压缩数组。 */ pop(): T | undefined { if (head === items.length) return undefined; const value = items[head++]; if (head > 1024 && head * 2 >= items.length) { items = items.slice(head); head = 0; } return value; }, + /** 同时丢弃底层数组和游标,释放旧任务引用。 */ clear() { items = []; head = 0; }, + /** 返回有效区间的数组副本,外部改副本不会更改队列成员。 */ values() { return items.slice(head); }, }; } +/** 数组实现二叉最小堆,compare<0 表示左项优先;peek O(1),push/pop O(log n)。 + * 相等优先级不保证插入稳定性,上层若需要 FIFO tie-break 必须把序列写进 comparator。 */ export function createMinHeap(compare: (left: T, right: T) => number) { + // 父下标 (i-1)>>1,左右孩子 2i+1/2i+2;堆序只要求父不大于孩子,不是全数组排序。 const items: T[] = []; return { + /** 当前堆条目数,包含上层可能尚未清理的过期条目。 */ get size() { return items.length; }, + /** 查看最优项,不移除;任务是否过期仍由调度器校验。 */ peek(): T | undefined { return items[0]; }, + /** 新值从末端向上筛选,把较大的祖先下移到空位,最后写入新值。 */ push(value: T) { let index = items.length; items.push(value); while (index > 0) { const parent = (index - 1) >> 1; if (compare(items[parent], value) <= 0) break; items[index] = items[parent]; index = parent; } items[index] = value; }, pop(): T | undefined { + // 出堆:取根并移走末项,再将末项沿较小孩子向下筛选,恢复父≤孩子的不变量。 if (!items.length) return undefined; const first = items[0], last = items.pop()!; if (items.length) { @@ -40,6 +60,7 @@ export function createMinHeap(compare: (left: T, right: T) => number) { } return first; }, + /** 清空全部条目;不会替任务执行取消或修改 ledger 状态。 */ clear() { items.length = 0; }, }; }