/** * 规范化文档与条件变化:canvases/elements/tombstones 是按 ID 查找的只读表。 * 这里负责记录/树/连线校验、私有副本上的修改、条件回放与反向变化;不拥有 history、 * 网络或公开订阅。applyChanges 只生成候选文档,最终合法性与统一发布由调用方完成。 */ import {detachElementData,hasOwnedElementData,componentDataValid} from './element-data.ts'; import type { CanvasRecord, Change, Document, ElementRecord, Geometry, Id, Registry, Target } from './types.ts'; import { clone, equal, fault, freeze, readMap, validJson } from './values.ts'; // This owns only the envelope and fixed table membership, not validation or // immutability of values inherited by applyChanges from an external document. const factoryDocuments=new WeakSet(); /** 冻结最外层文档并标记工厂来源;该标记只证明 envelope 来源,不代替数据校验。 */ function documentEnvelope(document:Document):Document{const value=Object.freeze(document);factoryDocuments.add(value);return value;} /** 查询工厂来源,供调用方选择可信结构路径;不能据此信任所有外来字段。 */ export const isFactoryDocument=(document:Document)=>factoryDocuments.has(document); /** 捕获输入记录、拒绝同一表内重复 ID,并脱离调用者的可变数据。 * 不在此处验证整棵树、组件 schema 或所有跨记录引用;完整发布前仍需 validateDocument。 * canvas 总是复制冻结,element 可复用元素捕获模块已持有的记录;墓碑复制表成员,元素固有证明只省去重复形状遍历。 */ export function createDocument(projectId: Id, canvases: readonly CanvasRecord[], elements: readonly ElementRecord[] = [], tombstones: ReadonlyMap = new Map()): Document { for (const element of elements) { const id = element.id; if (!staticElementProofs.has(element)&&!validJson(element)) throw fault('invalid_element', [id]); } for (const canvas of canvases) if (!validJson(canvas)) throw fault('invalid_canvas'); if (!projectId || new Set(canvases.map(item => item.id)).size !== canvases.length || new Set(elements.map(item => item.id)).size !== elements.length) throw fault('duplicate_identity'); return documentEnvelope({ projectId, canvases: readMap(new Map(canvases.map(item => [item.id, freeze(clone(item))]))), elements: readMap(new Map(elements.map(item => [item.id, detachElementData(item)]))), tombstones: readMap(tombstones) }); } /** 节点几何必须有限、宽高为正且旋转在 [0,360);不把无效值静默修正。 */ export function geometryValid(value: Geometry): boolean { return !!value && typeof value === 'object' && [value.x, value.y, value.width, value.height, value.rotation].every(Number.isFinite) && value.width > 0 && value.height > 0 && value.rotation >= 0 && value.rotation < 360; } /** 通过 ID+incarnation 定位活元素,阻止旧手势/异步回调命中删除后重建的同 ID 对象。 */ export function targetElement(document: Document, target: Target): ElementRecord { const element = document.elements.get(target.id); if (!element) throw fault('element_missing', [target.id]); if (element.incarnation !== target.incarnation) throw fault('stale_incarnation', [target.id]); return element; } /** 从当前 canvas 向根收集路径(包含自身);Set 检测循环,缺失父项明确报告未加载。 */ export function ancestors(document: Document, canvasId: Id): Id[] { const result: Id[] = [], visited = new Set(); let next: Id | undefined = canvasId; while (next) { if (visited.has(next)) throw fault('canvas_cycle', [next]); visited.add(next); result.push(next); const canvas: CanvasRecord | undefined = document.canvases.get(next); if (!canvas) throw fault('canvas_not_loaded', [next]); next = canvas.parent?.canvasId; } return result; } /** 找最近共同上级:先收集左路径,再从右侧逐级向上找首次交点。用于确定连线 owner。 */ export function commonCanvas(document: Document, left: Id, right: Id): Id { const parents = new Set(ancestors(document, left)); const result = ancestors(document, right).find(id => parents.has(id)); if (!result) throw fault('no_common_canvas', [left, right]); return result; } // Only intrinsic shape is reusable; document- and registry-dependent checks stay live. const staticElementProofs=new WeakSet(); /** Validate one record before its graph dependencies have arrived. This does not * prove owner membership, endpoint existence or connection common ownership. */ /** 校验单条记录的基础形状、墓碑代次和已知组件数据。 * staticElementProofs 仅缓存已捕获记录的固有形状;墓碑和 registry 相关检查每次仍执行。 */ export function validateElementRecord(element: ElementRecord, registry: Registry, tombstones: ReadonlyMap = new Map(), componentData = true): void { // 静态证明仅随不可变记录身份复用,不能因浅冻结或一个文档 envelope 标记就跳过校验。 const proven=!!element&&staticElementProofs.has(element); if (!proven&&(!element || !validJson(element) || typeof element.id !== 'string' || !element.id || typeof element.ownerCanvasId !== 'string' || !element.ownerCanvasId || typeof element.componentId !== 'string' || !element.componentId || !['node','connection'].includes(element.structure) || !Number.isSafeInteger(element.incarnation) || element.incarnation < 1 || !Number.isSafeInteger(element.dataVersion) || element.dataVersion < 1 || !element.data || typeof element.data !== 'object' || Array.isArray(element.data) || !validJson(element.data))) throw fault('invalid_element', [element?.id]); // 墓碑属于当前文档上下文,即使同一记录以前通过过形状校验,也要重新核对已死亡代次。 const dead=tombstones.get(element.id);if(dead!==undefined&&element.incarnation<=dead)throw fault('stale_incarnation',[element.id]); // 只有精确支持的组件版本进入 schema 检查;未知组件/不支持版本仍须通过通用形状约束,不在这里解释其 payload。 const definition=registry.components.get(element.componentId); if(componentData&®istry.resolve(element)==='known'&&(!definition||definition.structure!==element.structure||!componentDataValid(element,definition)))throw fault('invalid_component_data',[element.id]); // 命中固有证明只能略过下方几何/端点形状遍历,不能略过上方墓碑与本次启用的 schema 检查。 if(proven)return; if(element.structure==='node'){if(!geometryValid(element.geometry))throw fault('invalid_geometry',[element.id]);} else if(!element.source||!element.target||typeof element.source.elementId!=='string'||!element.source.elementId||typeof element.target.elementId!=='string'||!element.target.elementId||[element.source,element.target].some(endpoint=>endpoint.portId!==undefined&&(typeof endpoint.portId!=='string'||!endpoint.portId))||!validJson(element.path))throw fault('invalid_endpoint',[element.id]); // 拓扑规划可 componentData=false;此时仍可记固有证明,但以后启用 schema 的调用依然会重查 data。 if(hasOwnedElementData(element))staticElementProofs.add(element); } /** 墓碑保存已死亡的最大 incarnation,必须是非空 ID 对应的正安全整数。 */ function validateTombstone(id:Id,incarnation:number){ if(!id||!Number.isSafeInteger(incarnation)||incarnation<1)throw fault('invalid_tombstone',[id]); } /** 校验父链与入口:入口是父 canvas 的节点,且不能被多个子 canvas 共用。 * 返回 1 表示根,供整文档校验要求恰好一个根。 */ function validateCanvasInDocument(document:Document,canvas:CanvasRecord,entries:Set):number{ if(typeof canvas.id!=='string'||!canvas.id||!Object.hasOwn(canvas,'parent')||canvas.parent!==null&&(!canvas.parent||typeof canvas.parent.canvasId!=='string'||typeof canvas.parent.entryElementId!=='string'))throw fault('invalid_canvas'); ancestors(document,canvas.id); if(!canvas.parent)return 1; const entry=document.elements.get(canvas.parent.entryElementId); if(!entry||entry.structure!=='node'||entry.ownerCanvasId!==canvas.parent.canvasId||entries.has(entry.id))throw fault('invalid_canvas_entry',[canvas.id]); entries.add(entry.id);return 0; } /** 在单记录校验之上检查 owner、真实端点及最近共同上级;只允许节点作为边端点。 */ function validateElementInDocument(document:Document,element:ElementRecord,registry:Registry,componentData:boolean){ validateElementRecord(element,registry,document.tombstones,componentData); if(!document.canvases.has(element.ownerCanvasId))throw fault('invalid_element',[element.id]); if(element.structure==='connection'){ const source=document.elements.get(element.source.elementId),target=document.elements.get(element.target.elementId); if(!source||!target||source.structure!=='node'||target.structure!=='node'||!validJson(element.path))throw fault('invalid_endpoint',[element.id]); if(element.ownerCanvasId!==commonCanvas(document,source.ownerCanvasId,target.ownerCanvasId))throw fault('invalid_connection_owner',[element.id]); } } /** Cooperative and synchronous paths use the same per-record checks and order. */ /** 每项检查前 yield,让异步调度调用方可以分片;迭代器本身不等待或调度。 */ function* validateDocumentChecks(document:Document,registry:Registry,componentData:boolean):Generator{ if(!document.canvases.size)throw fault('missing_root'); for(const[id,incarnation]of document.tombstones){yield;validateTombstone(id,incarnation);} // 一次遍历累计入口占用和根数;必须继续到最后,才能得到根数及所有元素的完整结论。 const entries=new Set();let roots=0; for(const canvas of document.canvases.values()){yield;roots+=validateCanvasInDocument(document,canvas,entries);} if(roots!==1)throw fault('invalid_roots'); for(const element of document.elements.values()){yield;validateElementInDocument(document,element,registry,componentData);} } /** Structural indexes contain no component payload; use only for source planning, never editor publication. */ /** 仅结构检查的分片迭代器:跳过组件 payload schema,供源结构规划使用。 */ export function* validateDocumentTopologySteps(document:Document,registry:Registry):Generator{yield* validateDocumentChecks(document,registry,false);} /** 含组件 payload 的完整分片校验;必须迭代到结束才证明整份文档通过。 */ export function* validateDocumentSteps(document:Document,registry:Registry):Generator{yield* validateDocumentChecks(document,registry,true);} /** 同步走完整文档检查:要求一个根,检查拥有关系、入口、节点端点及连线共同 owner。 * 已支持的精确组件版本检查 data schema;未知/不支持版本按通用记录规则保留,不冒称已理解其 payload。 */ export function validateDocument(document:Document,registry:Registry):void{ if(!document.canvases.size)throw fault('missing_root'); for(const[id,incarnation]of document.tombstones)validateTombstone(id,incarnation); const entries=new Set();let roots=0; for(const canvas of document.canvases.values())roots+=validateCanvasInDocument(document,canvas,entries); if(roots!==1)throw fault('invalid_roots'); for(const element of document.elements.values())validateElementInDocument(document,element,registry,true); } /** 一批有序修改及可选 canvas 上下文;当前执行器用它检查 field 目标的 owner。 * 这不是覆盖所有 change 种类的授权器;整元素/树修改还需调用方规划、权限及发布前校验。 */ export type ReplayChangeBatch = {readonly changes:readonly Change[];readonly canvasIds?:readonly Id[]}; /** 在已捕获的不可变文档上,以私有表副本应用变化;默认校验 before 条件。 * 未改记录沿用原引用,不是任意外部 Document 的深隔离入口;修改记录的 freeze 也可能触及共享嵌套值。 */ export function applyChanges(document: Document, changes: readonly Change[], conditional = true): Document { return applyChangeBatches(document,[{changes}],conditional,false); } /** Ordered conditional replay against one private draft; no intermediate document escapes. */ /** 顺序回放多批变化,已经满足的 after 可跳过;field 变化在跳过前仍检查当前 owner 范围。 * 批次未提供 canvasIds 或提供空数组时,这一局部 owner 范围检查不启用。 */ export function replayChanges(document:Document,batches:Iterable):Document { return applyChangeBatches(document,batches,true,true); } /** 三类变化共用的执行器:field 改字段、element 增删元素、canvas 改树记录。 * 仅在全部循环成功后返回冻结 envelope,不泄漏半成品;此处不做整图合法性校验。 */ function applyChangeBatches(document:Document,batches:Iterable,conditional:boolean,replay:boolean):Document { // 复制表成员而复用未改记录;失败不返回私有 Map。对有效不可变输入,不会发布或改写原快照。 const elements = new Map(document.elements), canvases = new Map(document.canvases), tombstones = new Map(document.tombstones); for (const batch of batches) for (const change of batch.changes) { if(replay){ // Check scope even when the desired field value is already present. if(change.kind==='field'){const owner=elements.get(change.target.id)?.ownerCanvasId;if(owner&&batch.canvasIds?.length&&!batch.canvasIds.includes(owner))throw fault('operation_context_changed',[change.target.id]);} if(changeSatisfied(elements,canvases,change))continue; } // 字段级条件检查同时比较值与“字段是否存在”,区分缺失和显式 null。 if (change.kind === 'field') { if (!validJson(change.after)) throw fault('invalid_change'); if (!change.field.startsWith('data.') && !['geometry', 'ownerCanvasId', 'source', 'target', 'path'].includes(change.field)) throw fault('invalid_field'); const element = elements.get(change.target.id); if (!element || element.incarnation !== change.target.incarnation) throw fault('stale_incarnation', [change.target.id]); // data. 后面的部分是一个顶层数据键,不在这里按点路径递归写嵌套对象。 const dataKey = change.field.startsWith('data.') ? change.field.slice(5) : null; const prior = dataKey !== null ? element.data[dataKey] ?? null : (element as unknown as Record)[change.field]; if (conditional && (dataKey !== null && Object.hasOwn(element.data, dataKey) !== (change.beforePresent ?? true) || !equal(prior, change.before))) throw fault('field_conflict', [element.id]); if (dataKey !== null) { if (!dataKey || ['__proto__', 'prototype', 'constructor'].includes(dataKey)) throw fault('invalid_field'); const data = { ...element.data }; if (change.afterPresent === false) delete data[dataKey]; else data[dataKey] = clone(change.after); elements.set(element.id, freeze({ ...element, data })); } else elements.set(element.id, freeze({ ...element, [change.field]: clone(change.after) }) as ElementRecord); // 整元素修改在 conditional=true 时匹配 before;删除只记录已死亡的最大代次。 // 此执行器不直接以墓碑拒绝后续插入,发布前 validateElementRecord/validateDocument 才拦截旧代次。 } else if (change.kind === 'element') { if (conditional && !equal(elements.get(change.id) || null, change.before)) throw fault('element_conflict', [change.id]); if (change.after) { if (!validJson(change.after)) throw fault('invalid_element', [change.id]); if (change.after.id !== change.id) throw fault('identity_mismatch', [change.id]); elements.set(change.id, detachElementData(change.after)); } else { const old = elements.get(change.id); if (old) tombstones.set(change.id, Math.max(tombstones.get(change.id) || 0, old.incarnation)); elements.delete(change.id); } // canvas 删除只移除该记录;子树/连线清理由 structure 规划器显式生成,避免隐藏副作用。 } else if (change.kind === 'canvas') { if (conditional && !equal(canvases.get(change.id) || null, change.before)) throw fault('canvas_conflict', [change.id]); if (change.after) { if (!validJson(change.after)) throw fault('invalid_canvas', [change.id]); if (change.after.id !== change.id) throw fault('identity_mismatch', [change.id]); canvases.set(change.id, freeze(clone(change.after))); } else canvases.delete(change.id); } else throw fault('invalid_change'); } return documentEnvelope({ projectId: document.projectId, elements: readMap(elements), canvases: readMap(canvases), tombstones: readMap(tombstones) }); } /** 检测回放目标效果是否已经存在;字段目标必须仍是同一个 incarnation 才可视为满足。 */ function changeSatisfied(elements:ReadonlyMap,canvases:ReadonlyMap,change:Change):boolean { // canvas/element 使用整记录 after 比较;删除后的缺失等同于 after:null,重复删除可跳过。 if(change.kind==='canvas')return equal(canvases.get(change.id)??null,change.after); if(change.kind==='element')return equal(elements.get(change.id)??null,change.after); const element=elements.get(change.target.id); if(!element||element.incarnation!==change.target.incarnation)return false; if(change.field.startsWith('data.')){const key=change.field.slice(5);return Object.hasOwn(element.data,key)===(change.afterPresent??true)&&equal(element.data[key]??null,change.after);} return equal((element as unknown as Record)[change.field],change.after); } /** 倒序并交换 before/after,生成撤销变化;字段存在性标记也一起交换。 * 如先创建再改名,撤销必须先撤改名再撤创建,因此不能只交换值而保持原顺序。 */ export function invert(changes: readonly Change[]): readonly Change[] { return changes.slice().reverse().map(change => { if(change.kind==='field'){const{beforePresent,afterPresent,...record}=change;return freeze({...record,before:change.after,after:change.before,...(afterPresent===undefined?{}:{beforePresent:afterPresent}),...(beforePresent===undefined?{}:{afterPresent:beforePresent})});} return freeze({...change,before:change.after,after:change.before}) as Change; }); }