docs(core): clarify copy checkpoint references and dissolve planning

This commit is contained in:
2026-11-12 12:28:44 +08:00
parent aac3813ce4
commit 22119358f5
4 changed files with 41 additions and 18 deletions
+5 -3
View File
@@ -1,6 +1,7 @@
/**
* 组件声明式引用路径的遍历器,供复制/解散等纯规划复用。
* 只访问自有字段,不用字符串相似度猜 ID;这里 yield 可写 slot,调用者必须先复制数据再改。
* 普通路径段只访问自有字段,不用字符串相似度猜 ID;这里 yield 可写 slot,调用者必须先复制数据再改。
* path 应先经组件定义校验:本遍历器不重新禁止空路径或原型敏感键,数组 * 按下标位置枚举。
*/
import type { Json } from './types.ts';
import { fault } from './values.ts';
@@ -14,7 +15,7 @@ export function* copySlots(data: Json, path: readonly string[]): Generator<{pare
const {value, depth} = work.pop()!, key = path[depth];
if (key === '*') {
if (!Array.isArray(value)) throw fault('invalid_copy_reference');
// 终端 * 直接枚举数组项位置;非终端 * 继续匹配下一路径段。
// 终端 * 直接枚举 0..length-1,不另用 hasOwn 检查空洞;非终端 * 继续匹配下一路径段。
if (depth === path.length - 1) { for (let i = 0; i < value.length; i++) yield {parent: value as unknown as Record<string, Json>, key: String(i)}; }
else for (let i = value.length - 1; i >= 0; i--) work.push({value: value[i], depth: depth + 1});
} else if (value && typeof value === 'object' && Object.hasOwn(value, key)) {
@@ -23,7 +24,8 @@ export function* copySlots(data: Json, path: readonly string[]): Generator<{pare
}
}
}
/** 在 slot 上读取引用 ID;null 代表无引用,many 要求数组,每个 ID 必须非空字符串。 */
/** 在 slot 上读取引用 ID;整个 slot 为 null 表示无引用,many 要求数组,每个 ID 必须非空字符串。
* many 数组里的 null 不等于无引用,会拒绝;many 与路径中的 * 是独立的两层展开规则。 */
export function* copyReferenceIds(data: Json, ref: {path: readonly string[]; many?: boolean}): Generator<string> {
for (const {parent, key} of copySlots(data, ref.path)) {
const value = parent[key]; if (value === null) continue;
+8 -4
View File
@@ -12,7 +12,8 @@ import {repairConnectionOwners} from './structure.ts';
import {clone,equal,fault,freeze} from './values.ts';
/** Remove an owning canvas, retaining its entry and promoting only direct members. */
/** 验证 canvas/入口生命周期,规划成员和引用转换;根不能解散,任何不支持路径整体拒绝。 */
/** 验证 canvas/入口生命周期并规划成员/引用转换;根不能解散,必需转换遇到不支持能力或格式时抛错。
* 源应是有效不可变文档;本函数不执行最终 validateDocument,也不调用宿主 policy 或直接发布。 */
export function planDissolveCanvas(document:Document,registry:Registry,canvasId:Id,entryTarget:Target):{readonly changes:readonly Change[];readonly commands:readonly Command[]}{
const canvas=document.canvases.get(canvasId);if(!canvas)throw fault('canvas_not_loaded',[canvasId]);
if(!canvas.parent)throw fault('cannot_dissolve_root',[canvasId]);
@@ -23,7 +24,7 @@ export function planDissolveCanvas(document:Document,registry:Registry,canvasId:
for(const element of document.elements.values())if(element.ownerCanvasId===canvasId&&element.structure==='node'){
if(!registry.supports(element,'geometry.move'))throw fault('unsupported_capability',[element.id,'geometry.move']);
const target={id:element.id,incarnation:element.incarnation},geometry=convertGeometry(element.geometry,from,to),presentation=withUnitScale(element.data.presentation,unitScale(element.data.presentation)*from.scale/to.scale);
// 即使最终用 changes 原子提交,也要向 editor 暴露逐成员 policy 检查,防止入口权限越权。
// commands 仅给 editor 逐成员做 policy 检查,不在此执行 element.move handler;几何转换直接由上面的坐标数学生成。
commands.push({type:'element.move',target,canvasId:parent});
changes.push({kind:'field',target,field:'ownerCanvasId',before:canvasId,after:parent});
if(!equal(geometry,element.geometry))changes.push({kind:'field',target,field:'geometry',before:element.geometry,after:geometry});
@@ -35,7 +36,8 @@ export function planDissolveCanvas(document:Document,registry:Registry,canvasId:
// 在私有候选文档上计算边的新公共上级,并把曲线从旧 owner 空间一并转换。
const promoted=applyChanges(document,changes);changes.push(...repairConnectionOwners(document,promoted));
// Only declared canvas references have meaning here. Opaque payloads are retained.
// 只解释已知组件明确声明的 canvas 引用;opaque payload 和普通字面 ID 文本保留。
// 扫描整个 promoted 文档中已知组件声明的 canvas 引用,因此子树外的引用也可能需要修正。
// opaque payload 与未声明的普通字面 ID 保留;引用路径复用 copy schema,但这里不执行复制的 external 策略。
for(const element of promoted.elements.values()){
if(registry.resolve(element)!=='known')continue;
const references=registry.components.get(element.componentId)?.copy?.references.filter(reference=>reference.target==='canvas')??[];
@@ -48,12 +50,14 @@ export function planDissolveCanvas(document:Document,registry:Registry,canvasId:
const map=(value:Json):Json=>{if(typeof value!=='string'||!value)throw fault('invalid_copy_reference',[element.id]);return value===canvasId?parent:value;};
slot.parent[slot.key]=reference.many?(value as Json[]).map(map):map(value);
}
// 仅产生实际改变的顶层 data 字段,并要求 content.edit 能力/宿主 policy 通过。
// 仅产生实际改变的顶层 data 字段;此处检查 content.edit 已安装,宿主 policy 由返回 commands 交给 editor 重查。
// 不调用 content.edit handler 生成变化,最终 schema/关系检查仍由事务发布路径执行。
for(const key of Object.keys(data))if(!equal(data[key],element.data[key])){
if(!registry.supports(element,'content.edit'))throw fault('unsupported_capability',[element.id,'content.edit']);
changes.push({kind:'field',target:{id:element.id,incarnation:element.incarnation},field:`data.${key}`,before:element.data[key],after:data[key]});
commands.push({type:'element.data',target:{id:element.id,incarnation:element.incarnation},patch:{[key]:data[key]}});
}
}
// 保留原入口元素与边 ID;只移除 canvas 记录,不用 removalChanges 的子树删除语义。
return freeze({changes,commands});
}
+13 -6
View File
@@ -2,6 +2,7 @@
* 可暂停/恢复的深复制准备状态机。检查点保存 ID、阶段、游标、DFS stack 和稳定映射,
* 不保存元素 payload 的第二份副本;输出记录是由源与映射确定性重建的缓存。
* 每次 step 只推进有限语义工作,但持久源校验、分片调度、checkpoint 保存和渐进加入由 runtime 负责。
* 构造器仍同步展开源表并校验保存状态,step 也没有按字节/毫秒强制切断单记录转换。
*/
import {detachJsonData} from './json-data.ts';
import type { CanvasRecord, Change, Document, ElementRecord, Id, Point, Registry, Target } from './types.ts';
@@ -22,7 +23,8 @@ type Immutable<T>=T extends object?{readonly [K in keyof T]:Immutable<T[K]>}:T;
export type DuplicateCheckpoint=Immutable<MutableCheckpoint>;
/** 形状辅助检查:数组成员必须是非空字符串;是否存在/重复由后续检查负责。 */
const ids=(value:unknown):value is Id[]=>Array.isArray(value)&&value.every(id=>typeof id==='string'&&!!id);
/** 校验外部检查点格式、阶段/计数/去重与前缀边界;与实际源的一致性在工厂中进一步核验。 */
/** 检查 DTO 中明确列出的格式、阶段/计数、去重与前缀边界;不是按源重新推导整个已保存执行轨迹。
* 与实际源的部分成员/映射一致性在工厂核验;可信持久记录与 token 来源由 runtime 边界保证。 */
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');
@@ -33,7 +35,8 @@ export function validateDuplicateCheckpoint(value:DuplicateCheckpoint):void{
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。 */
* 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 开始。
@@ -56,7 +59,8 @@ export function createDuplicatePlanner(document:Document,registry:Registry,selec
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<Id,ElementRecord>(),outputCanvases=new Map<Id,CanvasRecord>(),output=duplicateOverlay(document,outputElements,outputCanvases),changes:Change[]=[];
// 源校验每次重开重新执行;topologyOnly 仅用于明确的结构源,最终输出仍执行完整数据校验。
// 源校验每次重开重新执行;topologyOnly 跳过源的组件 payload schema。
// 本解释器继续到 result 时校验源+副本完整输出;若 runtime 在 ready 接管逐记录生成,则由那条消费路径完成后续校验。
let treeCache=0,edgeCache=0,sourceChecks:Generator<void>|undefined=(binding.topologyOnly?validateDocumentTopologySteps:validateDocumentSteps)(document,registry),outputChecks:Generator<void>|undefined,validated=false;
/** 切换解释阶段并重置/指定该阶段 cursor,不改稳定 ID 或输出前缀。 */
const phase=(next:DuplicateCheckpoint['phase'],cursor=0)=>{state.phase=next;state.cursor=cursor;};
@@ -69,7 +73,8 @@ export function createDuplicatePlanner(document:Document,registry:Registry,selec
/** 只为尚未分配的源项取新 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,范围/映射固定,可以交给运行时逐记录转换,不要求完整计划已生成。 */
/** 源检查迭代已结束、阶段刚到 emit 且 emitted=0 时可接管范围/映射;不是完整计划已生成或已加入画布。
* topologyOnly 下“源检查结束”仍只证明拓扑路径通过,不证明所有组件 payload schema。 */
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});},
@@ -98,7 +103,8 @@ export function createDuplicatePlanner(document:Document,registry:Registry,selec
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,恢复可重用。
// 与同步规划器相同:显式选边、复制 canvas 的边及指向复制节点的入边;只有源端被复制不足以自动带上出边。
// 随后 element/canvas 分配都把新 ID 写回 state,恢复重用已有分配,不为同一前缀再造身份。
case 'edges':if(state.cursor<sourceElements.length){const record=sourceElements[state.cursor++];if(record.structure==='connection'&&(selected.has(record.id)||canvases.has(record.ownerCanvasId)||copied.has(record.target.elementId))){state.edges.push(record.id);copied.add(record.id);state.copied.push(record.id);}}else phase('allocate-elements');break;
case 'allocate-elements':if(state.cursor<state.copied.length){const id=state.copied[state.cursor++],value=allocate(id,'elements');elementIds.set(id,value);state.elementIds.push([id,value]);}else phase('allocate-canvases');break;
case 'allocate-canvases':if(state.cursor<state.canvases.length){const id=state.canvases[state.cursor++],value=allocate(id,'canvases');canvasIds.set(id,value);state.canvasIds.push([id,value]);}else phase('mapping');break;
@@ -110,7 +116,8 @@ export function createDuplicatePlanner(document:Document,registry:Registry,selec
// 每次状态机工作/阶段切换单调计数;step 的 wall time 和 yield 时机由宿主软预算决定。
state.steps++;return false;
},
/** 仅 done 且本轮最终校验成功后返回完整冻结计划;不能把未完成缓存当成可提交计划。 */
/** 仅 done 且本轮最终校验成功后返回完整冻结计划;不能把未完成缓存当成可提交计划。
* 返回的是准备 changes,不是 editor 提交/持久化/服务端确认结果;调用 result 也不会发网络请求。 */
result():DuplicatePlan{if(!validated||state.phase!=='done')throw fault('copy_plan_not_ready');return freeze({version:1,roots:state.roots.map(id=>elementIds.get(id)!),elementIds:Object.fromEntries(elementIds),canvasIds:Object.fromEntries(canvasIds),changes:[...changes]});},
});
}
+15 -5
View File
@@ -2,6 +2,7 @@
* 完整快照上的深复制纯规划:先确定范围和稳定 ID 映射,再生成节点/canvas,最后生成边。
* 同时选分区入口和其内部节点时只复制一次;原上级 C→F 保留,新增边可连接原 C 与新 F′。
* 这里只返回计划,不改 editor、不上传图片、不启动生成;runtime 负责调度、持久源和逐批加入。
* 源须在规划期间保持不可变;资源 ID 沿 data/copy 声明处理,不在这里复制文件字节或创建资源绑定。
*/
import type { CanvasRecord, Change, Data, Document, ElementRecord, Id, Json, Point, Registry, Target } from './types.ts';
import { copySlots } from './copy-paths.ts';
@@ -20,6 +21,7 @@ export type DuplicateOptions = { readonly createId: () => Id; readonly worldOffs
export function remapCopyData(element: ElementRecord, registry: Registry, elements: ReadonlyMap<Id, Id>, canvases: ReadonlyMap<Id, Id>, 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]);
// data 完整 JSON 复制后再改声明路径;未声明为引用的字符串(即使看起来像 ID)原样保留。
const data = clone(element.data) as Record<string, Json>;
for (const ref of schema.references) {
for (const slot of copySlots(data, ref.path)) {
@@ -37,13 +39,15 @@ export function remapCopyData(element: ElementRecord, registry: Registry, elemen
slot.parent[slot.key] = ref.many ? (value as Json[]).map(mapped) : mapped(value);
}
}
// omit 删除对象字段,拒绝删除数组下标造成稀疏数据;生成任务状态通过声明省略,不复制成新任务。
// omit 只删除声明的对象字段,数组下标不能直接删成空洞;是否清除任务状态取决于该组件 copy.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、不落盘;耗尽后才返回完整已校验计划。 */
/** 分步迭代器:yield 给调用者调度机会,本身不 await、不落盘;耗尽后才返回完整已校验计划。
* yield 点不是字节/耗时硬上限,单条记录的 clone、引用转换或祖先遍历仍可能较重。 */
export function* planDuplicateSteps(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): Generator<void, DuplicatePlan> {
yield* validateDocumentSteps(document, registry);
const offset = options.worldOffset ?? { x: 32, y: 32 };
@@ -78,6 +82,7 @@ export function* planDuplicateSteps(document: Document, registry: Registry, sele
}
}
// 边另外扫描:显式选中、属于复制 canvas、或指向复制节点的入边均纳入,源端可仍指向原上级节点。
// 仅 source 被复制、target 在外部的出边,不因 source 在范围内就自动纳入;显式选中等其它条件仍可纳入。
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 互撞。
@@ -124,6 +129,7 @@ export function* planDuplicateSteps(document: Document, registry: Registry, sele
/** 共享确定性转换器:canvas 改 parent/entry 映射,节点改身份/owner/data 与根级偏移。
* 同步及 checkpoint planner 都使用这里,避免两套复制产品规则。 */
export function duplicateTreeChange(document:Document,registry:Registry,kind:'element'|'canvas',record:ElementRecord|CanvasRecord,elementIds:ReadonlyMap<Id,Id>,canvasIds:ReadonlyMap<Id,Id>,copiedCanvases:ReadonlySet<Id>,offset:Point):Exclude<Change,{kind:'field'}>{
// 调用方须提供已固定且覆盖范围的映射;此转换器不自行分配缺失 ID,canvas 分支依赖非空 parent、只供子 canvas 使用。
if (kind === 'canvas') {
const canvas=record as CanvasRecord,parent=canvas.parent!;
const after = { ...clone(canvas), id: canvasIds.get(canvas.id)!, parent: { ...parent, canvasId: canvasIds.get(parent.canvasId) ?? parent.canvasId, entryElementId: elementIds.get(parent.entryElementId)! } };
@@ -140,9 +146,11 @@ export function duplicateTreeChange(document:Document,registry:Registry,kind:'el
return { kind: 'element', id: after.id, before: null, after };
}
}
/** 映射边端点(外部端点沿用原 ID),按新端点算共同 owner,转换曲线后创建新 ID/incarnation=1。 */
/** 映射边端点(外部端点沿用原 ID),按新端点算共同 owner,转换曲线后创建新 ID/incarnation=1。
* 端点结构与组件定义的 copy.references 是两套路径:前者在此映射,后者交给 remapCopyData。 */
export function duplicateEdgeChange(document:Document,registry:Registry,edge:ElementRecord,elementIds:ReadonlyMap<Id,Id>,canvasIds:ReadonlyMap<Id,Id>,nodes:Document):Extract<Change,{kind:'element'}>{
if (edge.structure !== 'connection') throw fault('invalid_copy_endpoint');
// 与 convertPath 的同变换早返回不同,这里即使 owner 空间不变,也先要求复制支持该曲线格式。
if (!connectionPathValid(edge.path)) throw fault('unsupported_copy_path', [edge.id]);
const source = { ...edge.source, elementId: elementIds.get(edge.source.elementId) ?? edge.source.elementId }, target = { ...edge.target, elementId: elementIds.get(edge.target.elementId) ?? edge.target.elementId };
const owner = commonCanvas(nodes, nodes.elements.get(source.elementId)!.ownerCanvasId, nodes.elements.get(target.elementId)!.ownerCanvasId), transform = canvasTransform(nodes, owner);
@@ -155,7 +163,8 @@ export function duplicateEdgeChange(document:Document,registry:Registry,edge:Ele
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 按链中序号在两端位移间插值;一端仍为原节点时,不把整条线简单平移。
// knot 按序号 t=(index+1)/(count+1) 在线两端世界位移间插值,不按弧长或几何距离加权。
// 这里只移动 knot.position,in/out 等偏移向量已在前面的空间转换中处理,不再次加位移。
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<string, Json>, 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 }) }; }) };
}
@@ -167,7 +176,8 @@ export function duplicateOverlay(document:Document,elements:ReadonlyMap<Id,Eleme
/** Internal overlay avoids an O(document size) clone between planning phases. Additions own unique IDs. */
/** 只读接口覆盖层:新增 ID 保证唯一,先查 additions,迭代则 base 后 additions。
* additions 在规划过程中仍会增长,故这个 view 只供内部计算,不是固定成员的发布快照。 */
* additions 在规划过程中仍会增长,故这个 view 只供内部计算,不是固定成员的发布快照。
* 本函数不验证 base/additions 的 ID 不相交;规划器分配时保证此约定,否则 size/迭代会出现重复。 */
function overlay<K,V>(base: ReadonlyMap<K,V>, additions: ReadonlyMap<K,V>): ReadonlyMap<K,V> {
const view: ReadonlyMap<K,V> = {
// get/has 与所有迭代器读取同一组当前表;forEach 的第三参数仍是这个只读接口。