docs(core): clarify query completeness and public type guarantees

This commit is contained in:
2026-11-12 12:39:22 +08:00
parent 5fd8312ef5
commit dadfd0e41d
3 changed files with 53 additions and 25 deletions
+3 -1
View File
@@ -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';
+27 -13
View File
@@ -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<Id,ReadonlyValueSet<Id>>;
type Spatial={cells:Relation;entries:Map<Id,Box>;oversized:ReadonlyValueSet<Id>};
/** 单元素声明引用的正向集合;complete=false 时不能据此证明它没有其它引用。 */
type References={elements:ReadonlySet<Id>;canvases:ReadonlySet<Id>;complete:boolean};
/** cellSize 控制格子边长,两项上限限制单对象/单查询覆盖的格子数,避免坐标范围拖垮枚举。 */
/** cellSize 控制格子边长,两项上限限制单对象/单查询覆盖的格子数,避免坐标范围拖垮枚举。
* 这是格子枚举预算,不限制结果 ID 数或扫描耗时;超限查询可能转而扫描此 owner 全部节点。 */
export type QueryBudget={readonly cellSize?:number;readonly maxElementCells?:number;readonly maxQueryCells?:number};
/** 已补默认值并验证后的预算,后续同一索引链不能静默换配置。 */
type ResolvedBudget=Required<QueryBudget>;
@@ -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<Id>(),canvases=new Set<Id>(),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<Id>)=>sorted(ids),orderedCanvases:(ids:Iterable<Id>)=>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<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)});},
/** 对候选按旋转矩形距离精判;无效几何使 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<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;
}
@@ -128,10 +135,12 @@ function expose(document:Document,revision:number,tables:Tables,previous?:Docume
const queryBases=new WeakMap<DocumentQueries,DocumentQueries>();
// 相同 namespace 来源只捕获一次只读副本,防止宿主后来修改集合污染已发布证据。
const namespaceCopies=new WeakMap<object,ReadonlyValueSet<Id>>();
/** 捕获外部 ID 集合的成员,缓存仅按原对象身份复用;输入合同应视为不可变值。 */
/** 捕获外部 ID 集合的成员,缓存仅按原对象身份复用;输入合同应视为不可变值。
* 同一来源对象后续被原地改动,再绑定也仍取第一次副本;新的证据必须传新的集合对象。 */
function ownedNamespace(ids:ReadonlyValueSet<Id>){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||revision<previous.revision||revision===previous.revision&&document!==previous.document))throw fault('invalid_query_revision');
const old=previous?privateTables.get(previous):undefined;if(previous&&!old)throw fault('invalid_query_snapshot');if(old&&old.registry!==registry)throw fault('query_registry_changed');if(old&&Object.keys(options).length&&!equal(old.budget,budget(options)))throw fault('query_budget_changed');if(previous?.document===document&&previous.revision===revision)return previous;
// 非空 options 是按默认值补齐后的完整预算声明,不是叠加旧预算的 patch;须与旧配置相等。
// 精确 changes 限定扫描范围;ACK 等没有差异列表的路径保守比较新旧记录。
const configuration=old?.budget??budget(options),oldDoc=previous?.document,ids=changes?new Set(changes.filter(change=>change.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<Id,Id>(),forward=old?.forward??new Map<Id,References>(),opaque=old?.opaque??empty,spatial=old?.spatial??new Map<Id,Spatial>();
@@ -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&&registry.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);
}
+23 -11
View File
@@ -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<Id, CanvasRecord>; readonly elements: ReadonlyMap<Id, ElementRecord>; readonly tombstones: ReadonlyMap<Id, number> };
/** 可修改字段白名单;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<Id, HistoryEntry> };
/** 图片默认尺寸解析对原创建历史的修正计划;绑定目标代次与创建 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<Runtime = unknown> = 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<T> = Pick<ReadonlySet<T>, 'size' | 'has' | 'entries' | 'keys' | 'values' | typeof Symbol.iterator> & { forEach(callback: (value: T, value2: T, set: ReadonlyValueSet<T>) => 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<Record<string, unknown>> }) => readonly FieldChange[];
/** 可复用行为集合;同一组件安装重复 capability 时 registry 明确拒绝。 */
export type Feature = { readonly id: string; readonly handlers: Readonly<Partial<Record<Capability, CapabilityHandler>>> };
/** 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<ElementRecord,{structure:'node'}>;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<ElementRecord,{structure:'connection'}>;readonly transform:{readonly x:number;readonly y:number;readonly scale:number;readonly rotation:number}}):number;segments(context:{readonly element:Extract<ElementRecord,{structure:'connection'}>;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<Record<number, (data: Data) => 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<string, ComponentDefinition>; readonly capabilities: ReadonlyMap<string, ReadonlyValueSet<Capability>>; readonly handlers: ReadonlyMap<string, ReadonlyMap<Capability, CapabilityHandler>>; readonly resolve: (element: ElementRecord) => 'known' | 'unknown-component' | 'unsupported-version'; readonly supports: (element: ElementRecord, capability: Capability) => boolean };