Files
Endoleap/src/lib/canvas-core/document.ts
T

215 lines
18 KiB
TypeScript

/**
* 规范化文档与条件变化: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<object>();
/** 冻结最外层文档并标记工厂来源;该标记只证明 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<Id, number> = 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<Id>();
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<object>();
/** 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<Id,number> = 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&&registry.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<Id>):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<void,void>{
if(!document.canvases.size)throw fault('missing_root');
for(const[id,incarnation]of document.tombstones){yield;validateTombstone(id,incarnation);}
// 一次遍历累计入口占用和根数;必须继续到最后,才能得到根数及所有元素的完整结论。
const entries=new Set<Id>();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<void,void>{yield* validateDocumentChecks(document,registry,false);}
/** 含组件 payload 的完整分片校验;必须迭代到结束才证明整份文档通过。 */
export function* validateDocumentSteps(document:Document,registry:Registry):Generator<void,void>{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<Id>();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<ReplayChangeBatch>):Document {
return applyChangeBatches(document,batches,true,true);
}
/** 三类变化共用的执行器:field 改字段、element 增删元素、canvas 改树记录。
* 仅在全部循环成功后返回冻结 envelope,不泄漏半成品;此处不做整图合法性校验。 */
function applyChangeBatches(document:Document,batches:Iterable<ReplayChangeBatch>,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<string, unknown>)[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<Id,ElementRecord>,canvases:ReadonlyMap<Id,CanvasRecord>,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<string,unknown>)[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;
});
}