From dadfd0e41d026bd4d81b2a44c6d2a1811cb4d7dd Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 12:39:22 +0800 Subject: [PATCH] docs(core): clarify query completeness and public type guarantees --- src/lib/canvas-core/index.ts | 4 +++- src/lib/canvas-core/queries.ts | 40 +++++++++++++++++++++++----------- src/lib/canvas-core/types.ts | 34 +++++++++++++++++++---------- 3 files changed, 53 insertions(+), 25 deletions(-) diff --git a/src/lib/canvas-core/index.ts b/src/lib/canvas-core/index.ts index e2f7f4e..c302d8b 100644 --- a/src/lib/canvas-core/index.ts +++ b/src/lib/canvas-core/index.ts @@ -21,8 +21,10 @@ export type {DuplicatePlan,DuplicateOptions} from './duplicate.ts'; export {createDuplicatePlanner,validateDuplicateCheckpoint} from './duplicate-checkpoint.ts'; export type {DuplicateCheckpoint} from './duplicate-checkpoint.ts'; -// 查询按不可变文档快照绑定;更新 API 用于沿发布变化推进关系与空间索引。 +// 查询按不可变文档快照绑定;此处公开建表/增量推进与查询类型。 +// withQueryCoverage 是宿主加载链直接使用的模块 API,没有在这个根入口重导出。 export {createDocumentQueries,advanceDocumentQueries} from './queries.ts'; export type {DocumentQueries,Bounds,QueryBudget,QueryCoverage,ElementLookup,IdQueryResult} from './queries.ts'; +// 公开共享几何函数;预算曲线细分 hitCubic 和 history 执行规划器等仍从内部模块使用,不在此整包重导出。 export {rectangleGeometry,rectangleBounds,hitRectangle,bezierChainSegments} from './geometry.ts'; diff --git a/src/lib/canvas-core/queries.ts b/src/lib/canvas-core/queries.ts index 5335df9..4218234 100644 --- a/src/lib/canvas-core/queries.ts +++ b/src/lib/canvas-core/queries.ts @@ -3,6 +3,7 @@ * 关系表描述 owner、子 canvas、关联边与声明引用;空间表按 owner canvas 分网格存节点包围盒。 * 更新采用 touched bucket 的 copy-on-write,未变索引保持身份,供局部订阅复用。 * 查询完整性与命中成功分开:没查到不一定不存在,加载不完整/协议失败必须显式报告。 + * 工厂不执行 validateDocument;输入文档须已校验且不可变,局部加载的完整性由宿主绑定 coverage。 */ import {rectangleBounds,hitRectangle} from './geometry.ts'; import type {Change,Document,ElementRecord,Geometry,Id,Point,ReadonlyValueSet,Registry} from './types.ts'; @@ -18,7 +19,8 @@ type Relation=Map>; type Spatial={cells:Relation;entries:Map;oversized:ReadonlyValueSet}; /** 单元素声明引用的正向集合;complete=false 时不能据此证明它没有其它引用。 */ type References={elements:ReadonlySet;canvases:ReadonlySet;complete:boolean}; -/** cellSize 控制格子边长,两项上限限制单对象/单查询覆盖的格子数,避免坐标范围拖垮枚举。 */ +/** cellSize 控制格子边长,两项上限限制单对象/单查询覆盖的格子数,避免坐标范围拖垮枚举。 + * 这是格子枚举预算,不限制结果 ID 数或扫描耗时;超限查询可能转而扫描此 owner 全部节点。 */ export type QueryBudget={readonly cellSize?:number;readonly maxElementCells?:number;readonly maxQueryCells?:number}; /** 已补默认值并验证后的预算,后续同一索引链不能静默换配置。 */ type ResolvedBudget=Required; @@ -49,7 +51,8 @@ function cells(box:Box,limit:number,cellSize:number):string[]|undefined{ 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。 */ +/** 只解析组件 copy.references 的声明路径;未知 schema/解析失败标记不完整,不猜 payload 中的 ID。 + * complete 仅说明这些声明可解析,不验证被引用对象存在、external 策略或隐藏在未声明字段中的引用。 */ 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}; @@ -72,7 +75,8 @@ export type ElementLookup={readonly status:'present';readonly element:ElementRec export type IdQueryResult={readonly ids:readonly Id[];readonly complete:boolean}; /** 与一个确切 document/registry/revision 绑定的公共查询。 * members/child/incident 提供稳定排序;Result 版本另外携带加载完整性。 - * candidateNodes 是空间粗筛,hitNodeBounds 用矩形精判,hitNodes 使用组件 hit 协议。 */ + * candidateNodes 是空间粗筛,hitNodeBounds 用矩形精判,hitNodes 使用组件 hit 协议。 + * 查询的是指定 owner 的直接节点,不自动投影整棵子树、折叠重定向或检查编辑权限。 */ export type DocumentQueries={ readonly coverage:QueryCoverage; lookupElement(id:Id):ElementLookup; @@ -101,7 +105,8 @@ function expose(document:Document,revision:number,tables:Tables,previous?:Docume // 关系子表按身份复用;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。 */ + /** 从网格收集候选并做 AABB 过滤;超大查询扫描全部 entries,普通查询同时加入 oversized。 + * 空间表只含 node;曲线命中由其它适配路径完成,返回顺序按模型插入 rank,不是渲染命中优先级。 */ 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 保留为候选,后续精判负责报告不完整。 @@ -118,9 +123,11 @@ function expose(document:Document,revision:number,tables:Tables,previous?:Docume 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。 */ + /** 点周围容差框粗筛,再调用自定义 hit;无 node 协议或未知组件时回退到矩形,不自动列入 unavailable。 + * 自定义 hit 抛错/返回非 boolean,或缓存 Box 非有限时才列 unavailable;命中不等于 registry 支持编辑。 */ 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。 */ + /** 对候选按旋转矩形距离精判;缓存 Box 无效使 complete=false,不当作可靠 miss。 + * 即使本次只需矩形,前面自定义 bounds 失败产生的 NaN Box 也会让该项跳过,不绕过共享粗筛失败。 */ 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; } @@ -128,10 +135,12 @@ function expose(document:Document,revision:number,tables:Tables,previous?:Docume const queryBases=new WeakMap(); // 相同 namespace 来源只捕获一次只读副本,防止宿主后来修改集合污染已发布证据。 const namespaceCopies=new WeakMap>(); -/** 捕获外部 ID 集合的成员,缓存仅按原对象身份复用;输入合同应视为不可变值。 */ +/** 捕获外部 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/矛盾完整声明拒绝。 */ +/** 不重建索引,只绑定调用方提供的加载证据;检查重复/未知完整 canvas 以及 projectComplete 的显式矛盾。 + * 不读取服务端 manifest,也不推导声明是否真实;基础 candidateNodes/关系读取仍只返回现有记录。 */ 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'); @@ -139,7 +148,8 @@ export function withQueryCoverage(queries:DocumentQueries,input:QueryCoverage):D 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。 */ + /** 已有记录优先 present;projectComplete 可直接判缺失为 absent。 + * 部分项目须有 elementIds:命名空间排除该 ID,或该 ID 已加载且有墓碑时才判 absent,其余 unloaded。 */ 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)}), @@ -152,13 +162,16 @@ export function withQueryCoverage(queries:DocumentQueries,input:QueryCoverage):D });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 算法,避免维护两套建表规则。 */ +/** 从零构造纯派生索引,复用 advance 建表;默认把输入当完整文档,不自动检测部分加载。 + * 部分模型调用方必须随后 withQueryCoverage 绑定真实证据,否则默认 complete=true 不代表读取已完成。 */ 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。 */ + * 必须与同一项目/registry/预算配合,revision 不可倒退,同 revision 不接受另一份 document。 + * changes 是可信提示,不核对是否漏列真实差异;提供不完整/错误提示可能留下旧索引,无清单时才全表比较。 */ 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(); @@ -177,7 +190,7 @@ export function advanceDocumentQueries(previous:DocumentQueries|undefined,docume 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 集合,提示调用方不能把空引用结果当作完整证明。 + // 无法解释引用的活元素进入 opaque 集合;opaqueReferences 描述引用可解释性,不表示整元素已失去几何/显示能力。 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));} @@ -187,6 +200,7 @@ export function advanceDocumentQueries(previous:DocumentQueries|undefined,docume 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 的视图。 + // 关系桶 finish 后装配;精确 changes 未报告增删时复用旧 rank,否则按文档迭代顺序重算/比较。 + // expose 返回基础完整 coverage,不继承旧查询的加载声明;宿主应在同次发布重新绑定当前 coverage。 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-core/types.ts b/src/lib/canvas-core/types.ts index edc12ff..c26e665 100644 --- a/src/lib/canvas-core/types.ts +++ b/src/lib/canvas-core/types.ts @@ -17,7 +17,8 @@ export type Point = { readonly x: number; readonly y: number }; export type Geometry = Point & { readonly width: number; readonly height: number; readonly rotation: number }; /** 拥有树的一条记录;根的 parent 为 null,子 canvas 指向父 canvas 及父内的入口元素。 */ export type CanvasRecord = { readonly id: Id; readonly parent: null | { readonly canvasId: Id; readonly entryElementId: Id } }; -/** 元素 ID 加生命周期 incarnation;删除后恢复同 ID 时换代,旧回调不能命中新对象。 */ +/** 元素 ID 加生命周期 incarnation;删除后恢复同 ID 时换代,由 targetElement 等运行时检查拦截旧目标。 + * 类型本身不验证代次数值,也不是权限凭证。 */ export type Target = { readonly id: Id; readonly incarnation: number }; /** 连线指向的节点及可选端口;端点的 owner 不必等于连线 owner。 */ export type Endpoint = { readonly elementId: Id; readonly portId?: string }; @@ -28,11 +29,13 @@ export type ElementRecord = ElementBase & ( | { readonly structure: 'node'; readonly geometry: Geometry } | { readonly structure: 'connection'; readonly source: Endpoint; readonly target: Endpoint; readonly path: Json } ); -/** 项目规范化事实表;父子/引用关系通过 ID 连接,tombstones 保留已死代次下限。 */ +/** 项目规范化事实表;父子/引用关系通过 ID 连接,tombstones 保存每个 ID 已死亡的最大代次。 + * Map 在运行时使用,持久化要经 codec;readonly 类型不等于 JSON 可直接序列化或输入已校验。 */ export type Document = { readonly projectId: Id; readonly canvases: ReadonlyMap; readonly elements: ReadonlyMap; readonly tombstones: ReadonlyMap }; /** 可修改字段白名单;data. 后缀表示一个顶层键,整份 geometry 是一个条件字段。 */ export type Field = `data.${string}` | 'geometry' | 'ownerCanvasId' | 'source' | 'target' | 'path'; -/** 字段条件变化:before/after 与存在性一起比较,使缺失和显式 null 不混淆。 */ +/** 字段条件变化;data 顶层键结合 beforePresent/afterPresent 区分缺失与显式 null,缺省存在性为 true。 + * 非 data 字段按整体 before/after 比较,不用这两个标记表达信封字段缺失。 */ export type FieldChange = { readonly kind: 'field'; readonly target: Target; readonly field: Field; readonly before: Json; readonly after: Json; readonly beforePresent?: boolean; readonly afterPresent?: boolean }; /** 统一变化格式;元素/canvas 的 null 表示不存在,可表达创建、删除、替换及反向修改。 */ export type Change = FieldChange @@ -59,13 +62,15 @@ export type Rejected = { readonly status: 'rejected'; readonly code: string; rea export type Applied = { readonly status: 'applied'; readonly revision: number; readonly operationIds: readonly Id[]; readonly historyEntryId?: Id }; /** 公共命令结果的判别联合,调用方必须按 status 处理失败。 */ export type Result = Applied | Rejected; -/** 一个逻辑撤销单元,包含正向变化和关联操作 ID;不把每个 pointermove 当一项。 */ +/** 一个逻辑撤销单元,保存正向 changes 和关联操作 ID;一次 entry 可按 groupId 合并多次提交。 + * undo 时计算逆变化,恢复代次后可修订这些正向 changes;它不是永远不变的原请求 body。 */ export type HistoryEntry = { readonly id: Id; readonly label: string; readonly changes: readonly Change[]; readonly operationIds: readonly Id[]; readonly groupId?: Id }; /** undo/redo 两个 ID 栈(尾部为栈顶),entries 保存记录,栈项与 ledger 事实分开。 */ export type HistoryState = { readonly undo: readonly Id[]; readonly redo: readonly Id[]; readonly entries: ReadonlyMap }; /** 图片默认尺寸解析对原创建历史的修正计划;绑定目标代次与创建 entry,避免多造用户步骤。 */ export type CreationSizeEffect = { readonly version: 1; readonly kind: 'creation-size'; readonly entryId: Id; readonly target: Target; readonly size: { readonly width: number; readonly height: number } }; -/** 所有意图共用的身份、单调序列、参与 canvas、changes、依赖及可选历史修正证据。 */ +/** 所有意图共用的身份、单调序列、参与 canvas、changes、依赖及可选历史修正证据。 + * operationSchemaVersion 是意图解释版本,historyTransition/historyEffect 是本地历史证据,不是服务端请求版本。 */ type IntentBase = { readonly historyEffect?:CreationSizeEffect; readonly historyTransition?:HistoryTransition; readonly id: Id; readonly operationSchemaVersion: 1; readonly sequence: number; readonly origin: Origin; readonly canvasIds: readonly Id[]; readonly changes: readonly Change[]; readonly dependencies: readonly Id[] }; /** 生成任务的稳定输入;目标代次、模型/提示词/参数随逻辑操作保存,凭证不入文档。 */ export type RunInput = { readonly target: Target; readonly boardId: Id; readonly modelId: string; readonly prompt: string; readonly parameters: Data; readonly propagate: boolean }; @@ -84,24 +89,31 @@ export type CoreSnapshot = { readonly revision: number; readonly document: Docum export type Snapshot = CoreSnapshot & { readonly rootRevision: number; readonly runtime: Runtime }; /** 命令或能力预检查结果;enabled=false 给出原因,不以按钮是否灰掉代替真正提交校验。 */ export type Availability = { readonly enabled: true } | { readonly enabled: false; readonly code: string; readonly targets: readonly Id[] }; -/** 只暴露读能力的集合合同,forEach 也只接触只读视图。 */ +/** 类型合同仅列读方法;真正隐藏 add/delete 和保护 forEach 第三个参数要由 readSet 等工厂实现。 + * TypeScript 结构兼容并不阻止外部把原生 Set 当作这个接口传入。 */ export type ReadonlyValueSet = Pick, 'size' | 'has' | 'entries' | 'keys' | 'values' | typeof Symbol.iterator> & { forEach(callback: (value: T, value2: T, set: ReadonlyValueSet) => void): void }; /** 编辑行为的稳定标识,组件通过安装 feature 获得对应 handler。 */ export type Capability = 'geometry.move' | 'geometry.resize' | 'geometry.rotate' | 'content.edit' | 'connection.edit'; -/** 读取当前元素和输入,返回字段变化;不能越权写其它元素,也不在这里发布或访问网络。 */ +/** 读取当前元素和输入,返回字段变化;约定只规划而不发布/访问网络。 + * editor 再检查输出目标/字段;函数类型不自动证明实现纯净,也不能回滚 handler 自己的外部副作用。 */ export type CapabilityHandler = (context: { readonly element: ElementRecord; readonly input: Readonly> }) => readonly FieldChange[]; /** 可复用行为集合;同一组件安装重复 capability 时 registry 明确拒绝。 */ export type Feature = { readonly id: string; readonly handlers: Readonly>> }; /** Path segments address own fields; * iterates array entries. many marks a terminal ID array. */ -/** 声明 data 内的 ID 路径;* 遍历数组,many 表示终端 ID 数组,external 决定外部引用保留/拒绝。 */ +/** 声明 data 内的 ID 路径;* 遍历数组,many 表示终端 ID 数组。 + * external 是深复制的外部引用策略;其它规划器可复用路径,但不必沿用复制的 preserve/reject 语义。 */ export type CopyReference = { readonly path: readonly string[]; readonly target: 'element' | 'canvas'; readonly many?: boolean; readonly external: 'preserve' | 'reject' }; /** 深复制时只重写声明引用并删 omit 字段;不能扫描任意相似字符串猜测引用。 */ export type CopySchema = { readonly references: readonly CopyReference[]; readonly omit: readonly (readonly string[])[] }; /** 节点几何协议的计算输入;geometry 可是预览值,不要求等于记录的 committed 几何。 */ export type NodeGeometryContext={readonly element:Extract;readonly geometry:Geometry}; -/** 版本化的纯几何扩展:节点 bounds/hit,边 strokeWidth/segments;不依赖 renderer 实例。 */ +/** 版本化的纯几何扩展约定:节点 bounds/hit,边 strokeWidth/segments;不依赖 renderer 实例。 + * node dataDependencies 缺省时数据变化保守重算,空数组表示不依赖 data;role 提示 bounds 的用途。 + * connection 输入已含展示锚点/变换,displayPath 可为 undefined;返回值仍由查询边界验证,类型不代替校验。 */ export type GeometryProtocol=({readonly kind:'node';readonly version:1;readonly dataDependencies?:readonly string[];bounds(context:NodeGeometryContext&{readonly role:'logical'|'hit'|'paint'}):import('./geometry.ts').Rect;hit(context:NodeGeometryContext&{readonly point:Point;readonly tolerance:number}):boolean}|{readonly kind:'connection';readonly version:1;strokeWidth(context:{readonly element:Extract;readonly transform:{readonly x:number;readonly y:number;readonly scale:number;readonly rotation:number}}):number;segments(context:{readonly element:Extract;readonly source:import('./geometry.ts').Connector;readonly target:import('./geometry.ts').Connector;readonly displayPath:Json|undefined;readonly transform:{readonly x:number;readonly y:number;readonly scale:number;readonly rotation:number}}):readonly import('./geometry.ts').Cubic[]}); -/** 一种组件的定义:精确版本/schema、feature、迁移、资源字段、几何及复制声明,可服务多个实例。 */ +/** 一种组件的定义,可服务多个实例:dataVersion/validate、features、逐级 migrations、顶层 resourceFields、geometry 和 copy。 + * 未提供 copy 不表示可以猜引用复制;resourceFields 只声明资源扫描入口,不在定义里保存文件字节或用户凭证。 */ export type ComponentDefinition = { readonly id: string; readonly dataVersion: number; readonly structure: ElementRecord['structure']; readonly features: readonly Feature[]; readonly validate: (data: Data) => boolean; readonly migrations?: Readonly Data>>; readonly resourceFields?: readonly string[]; readonly geometry?:GeometryProtocol; readonly copy?: CopySchema }; -/** 只读定义与编译后能力表;resolve 区分未知组件和不支持版本,supports 只允许精确已知版本。 */ +/** 只读定义与编译后能力表;resolve 只按组件 ID/精确数据版本分类,不执行 data schema。 + * supports 还要求 capability 已安装,仍不代替 policy、命令规划或最终文档校验。 */ export type Registry = { readonly components: ReadonlyMap; readonly capabilities: ReadonlyMap>; readonly handlers: ReadonlyMap>; readonly resolve: (element: ElementRecord) => 'known' | 'unknown-component' | 'unsupported-version'; readonly supports: (element: ElementRecord, capability: Capability) => boolean };