diff --git a/src/lib/canvas-core/data-containers.ts b/src/lib/canvas-core/data-containers.ts index 1f04cbc..3f6cb57 100644 --- a/src/lib/canvas-core/data-containers.ts +++ b/src/lib/canvas-core/data-containers.ts @@ -4,12 +4,14 @@ */ // Private shape provenance: values may still be mutable and require capture. // Always copy; external Object.freeze is never accepted as ownership evidence. +// WeakSet 以新容器身份为键,不按内容相等判断;容器不再被外部引用时,标记不会单独阻止回收。 const owned = new WeakSet(); /** 消费 iterable 得到新数组并浅冻结;不会冻结数组中的业务值。 */ export function dataArray(values: Iterable): readonly T[] { const result = Object.freeze(Array.from(values)); owned.add(result); return result; } -/** 浅复制对象的可枚举自有属性再冻结外壳;不是任意对象的深复制器。 */ +/** 浅复制对象的可枚举自有属性再冻结外壳;不是任意对象的深复制器。 + * 对象展开会读取 getter 并保留嵌套值引用,也不保留原对象原型或属性描述符。 */ export function dataRecord(value: T): Readonly { const result = Object.freeze({...value}); owned.add(result); return result; } diff --git a/src/lib/canvas-core/document.ts b/src/lib/canvas-core/document.ts index c4d19b6..73514eb 100644 --- a/src/lib/canvas-core/document.ts +++ b/src/lib/canvas-core/document.ts @@ -16,7 +16,8 @@ function documentEnvelope(document:Document):Document{const value=Object.freeze( export const isFactoryDocument=(document:Document)=>factoryDocuments.has(document); /** 捕获输入记录、拒绝同一表内重复 ID,并脱离调用者的可变数据。 - * 不在此处验证整棵树或所有跨记录引用;完整发布前仍需 validateDocument。 */ + * 不在此处验证整棵树、组件 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'); @@ -61,14 +62,19 @@ const staticElementProofs=new WeakSet(); /** 校验单条记录的基础形状、墓碑代次和已知组件数据。 * 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); } @@ -101,6 +107,7 @@ function validateElementInDocument(document:Document,element:ElementRecord,regis 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'); @@ -111,7 +118,8 @@ function* validateDocumentChecks(document:Document,registry:Registry,componentDa 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); @@ -120,21 +128,24 @@ export function validateDocument(document:Document,registry:Registry):void{ if(roots!==1)throw fault('invalid_roots'); for(const element of document.elements.values())validateElementInDocument(document,element,registry,true); } -/** 一批有序修改及其允许的 canvas 上下文;回放不能跨越已冻结的操作范围。 */ +/** 一批有序修改及可选 canvas 上下文;当前执行器用它检查 field 目标的 owner。 + * 这不是覆盖所有 change 种类的授权器;整元素/树修改还需调用方规划、权限及发布前校验。 */ export type ReplayChangeBatch = {readonly changes:readonly Change[];readonly canvasIds?:readonly Id[]}; -/** 在私有表副本中应用一批变化;默认校验 before 条件,失败不会修改输入文档。 */ +/** 在已捕获的不可变文档上,以私有表副本应用变化;默认校验 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 可跳过,但仍检查目标的 scope。 */ +/** 顺序回放多批变化,已经满足的 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,输入快照保持不变。 + // 复制表成员而复用未改记录;失败不返回私有 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){ @@ -158,7 +169,8 @@ function applyChangeBatches(document:Document,batches:Iterable,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); diff --git a/src/lib/canvas-core/element-data.ts b/src/lib/canvas-core/element-data.ts index 24c628d..4c0b628 100644 --- a/src/lib/canvas-core/element-data.ts +++ b/src/lib/canvas-core/element-data.ts @@ -8,7 +8,8 @@ import {detachJsonData} from './json-data.ts'; // Provenance comes from detaching JSON data, never from an external freeze or // document envelope. This does not assert record validity or registry support. const owned=new WeakSet(); -/** 已由本模块捕获的记录直接复用;外部记录重新复制冻结,再标记新记录身份。 */ +/** 已由本模块捕获的记录直接复用;外部记录重新按 JSON 语义复制冻结,再标记新记录身份。 + * 此处不调用组件 schema;捕获成功与记录合法是两项独立事实。 */ export function detachElementData(record:ElementRecord):ElementRecord{ if(record&&typeof record==='object'&&owned.has(record))return record; const value=detachJsonData(record); @@ -21,7 +22,8 @@ export const hasOwnedElementData=(record:ElementRecord)=>!!record&&typeof record // Framework-owned validators depend only on detached immutable data. Custom // validators remain live, including their method receiver and external context. const builtinValidators=new WeakSet(),dataProofs=new WeakMap(); -/** 标记框架内置、仅依赖输入的纯校验器;不要用它包装依赖外部可变状态的校验器。 */ +/** 标记框架内置、仅依赖输入的纯校验器;不要用它包装依赖外部可变状态的校验器。 + * WeakSet 只记录这项声明,不能自动证明函数无副作用或不读取闭包状态。 */ export function builtinDataValidator(validate:T):T{builtinValidators.add(validate);return validate;} /** 检查组件 data,只有“owned 记录 + 内置 validator”组合才允许复用成功结果。 * 失败不缓存;更换 validator 后缓存也不命中;Reflect.apply 保留定义对象作为 this。 */ @@ -29,6 +31,7 @@ export function componentDataValid(record:ElementRecord,definition:ComponentDefi // record 身份和具体 validator 函数同时匹配,才能跳过一次已成功的 schema 检查。 const validate=definition.validate,reusable=hasOwnedElementData(record)&&builtinValidators.has(validate); if(reusable&&dataProofs.get(record)===validate)return true; + // 校验器抛错会直接传播给上层;这里只缓存正常返回且通过的结果,不吞错或缓存失败。 const valid=Reflect.apply(validate,definition,[record.data]); if(valid&&reusable)dataProofs.set(record,validate); return valid; diff --git a/src/lib/canvas-core/json-data.ts b/src/lib/canvas-core/json-data.ts index 1706b1c..f991dfb 100644 --- a/src/lib/canvas-core/json-data.ts +++ b/src/lib/canvas-core/json-data.ts @@ -7,8 +7,9 @@ import {clone,freeze} from './values.ts'; // Private provenance for a fresh, recursively frozen JSON clone. A caller can // request a detached copy, but cannot grant this fact to an existing object. const detached=new WeakSet(); -/** JSON 复制后递归冻结;对象结果记录到 WeakSet,原始值直接返回。 - * clone 会按 JSON 规则处理数据,所以不可用它代替迁移输入的严格捕获校验。 */ +/** JSON 复制后递归冻结;仅对象结果记录到 WeakSet。 + * 输入先经过 JSON 序列化语义:toJSON/getter 可能执行,稀疏槽/数组中的非法 JSON 值可能转为 null。 + * 循环或 BigInt 等仍会抛错;它不保留任意输入原貌,不能替代迁移输入的严格捕获校验。 */ export function detachJsonData(input:T):T { const value=freeze(clone(input)); if(value&&typeof value==='object')detached.add(value); diff --git a/src/lib/canvas-core/registry.ts b/src/lib/canvas-core/registry.ts index 7152797..990b4dc 100644 --- a/src/lib/canvas-core/registry.ts +++ b/src/lib/canvas-core/registry.ts @@ -2,6 +2,7 @@ * 组件定义的装配与显式数据迁移。Registry 保存可复用的定义/handler,不保存元素实例或 view 状态。 * 装配时把 feature 合成为 componentId→capability→handler 查找表,冲突即拒绝,避免依赖安装顺序。 * 迁移只生成候选记录,不写 editor;调用方负责把结果作为一次合法事务发布。 + * 冻结的是定义配置容器,handler 函数及其闭包仍沿用;容器只读不证明外部上下文不会变化。 */ import type { Capability, CapabilityHandler, ComponentDefinition, Data, ElementRecord, Feature, Json, Registry, ReadonlyValueSet } from './types.ts'; import { clone, fault, freeze, readMap, readSet } from './values.ts'; @@ -11,7 +12,8 @@ export function defineFeature(id: string, handlers: Partial typeof value !== 'function')) throw fault('invalid_feature'); return freeze({ id, handlers: { ...handlers } }); } -/** 校验组件定义与复制/几何协议,捕获其配置容器,防止装配后被外部改写。 */ +/** 校验组件定义与复制/几何协议,并复制声明过的配置容器后冻结。 + * validate、几何方法、feature handler 和 migration 函数本身保持原引用,不复制函数闭包。 */ export function defineComponent(definition: ComponentDefinition): ComponentDefinition { if (!definition.id || !Number.isSafeInteger(definition.dataVersion) || definition.dataVersion < 1 || typeof definition.validate !== 'function') throw fault('invalid_component'); // 复制路径访问自有字段,禁止原型污染键;数组遍历由 * 路径段表达。 @@ -47,7 +49,8 @@ export function createRegistry(definitions: readonly ComponentDefinition[]): Reg } components.set(definition.id, definition); capabilities.set(definition.id, readSet(found.keys())); handlers.set(definition.id, readMap(found)); } - // 只识别已安装的精确数据版本;可保留未知记录,但不能假设它可被当前 handler 编辑。 + // 这里只按 componentId+dataVersion 分派,不执行 data schema 或结构校验;known 不是整条记录合法的证明。 + // 可保留未知记录,但不能假设它可被当前 handler 编辑。 const resolve: Registry['resolve'] = element => { const definition = components.get(element.componentId); if (!definition) return 'unknown-component'; @@ -59,10 +62,10 @@ export function createRegistry(definitions: readonly ComponentDefinition[]): Reg supports: (element: ElementRecord, capability: Capability) => resolve(element) === 'known' && !!capabilities.get(element.componentId)?.has(capability) }); } /** Capture each field once; JSON.stringify would silently erase invalid values. */ -/** 迁移边界的严格 JSON 捕获:每个字段只读取一次,捕获后递归冻结。 +/** 迁移边界的 JSON 捕获:遍历的自有可枚举字符串字段取值一次,捕获后递归冻结。 * 不先 JSON.stringify,因为 undefined、函数、非有限数会被丢弃/改值,掩盖非法迁移输出。 */ function captureMigrationData(input:unknown,id:string):Data { - // 统一带原元素 ID 抛出 invalid_migration,便于调用方保留原记录并报告失败对象。 + // 本函数发现形状非法时带原元素 ID 抛 invalid_migration;getter/转换器自身抛出的异常不在这里包装。 const invalid=():never=>{throw fault('invalid_migration',[id]);}; // 递归接收 JSON 原子、稠密数组和普通对象;深度上限防止循环/异常深输入无限递归。 const capture=(value:unknown,depth=0):Json=>{ @@ -78,6 +81,7 @@ function captureMigrationData(input:unknown,id:string):Data { const result:Json[]=[];for(let index=0;index; return freeze(Object.fromEntries(Object.keys(source).map(key=>[key,capture(source[key],depth+1)]))); }; @@ -88,9 +92,11 @@ function captureMigrationData(input:unknown,id:string):Data { /** 按 migrations[旧版本] 逐级转换到当前版本,每步重新捕获,最后通过目标 schema。 * 未知组件/未来版本原样保留;缺 converter 或任一步失败抛错,不返回半迁移记录。 - * ID、incarnation、owner 与其它信封字段不变,转换器只负责 data。 */ + * ID、incarnation、owner 与其它信封字段值不变,转换器只负责 data。 + * 其余信封对象按引用沿用,返回时 freeze 可能冻结这些共享嵌套值;调用方应传入已捕获的记录。 */ export function migrateElement(element: ElementRecord, registry: Registry): ElementRecord { const definition = registry.components.get(element.componentId); + // 未安装/未来版本保持原引用且不捕获;即使版本已是当前版本,下方仍捕获 data 并校验 schema。 if (!definition || element.dataVersion > definition.dataVersion) return element; let data = captureMigrationData(element.data,element.id), version = element.dataVersion; // 一个 converter 仅前进一个版本,不把“有版本号”当成自动跨版本兼容承诺。 diff --git a/src/lib/canvas-core/values.ts b/src/lib/canvas-core/values.ts index b3f5226..c4af3d1 100644 --- a/src/lib/canvas-core/values.ts +++ b/src/lib/canvas-core/values.ts @@ -5,11 +5,13 @@ */ import type { Id, Json, ReadonlyValueSet } from './types.ts'; -/** 构造携带稳定错误码和相关对象 ID 的错误,供命令层返回可解释的拒绝结果。 */ +/** 构造携带稳定错误码和相关对象 ID 的错误,供命令层返回可解释的拒绝结果。 + * targets 沿用传入数组引用;这个辅助函数本身不捕获或冻结错误明细。 */ export function fault(code: string, targets: readonly Id[] = []): Error & { code: string; targets: readonly Id[] } { return Object.assign(new Error(code), { code, targets }); } -/** 将捕获的异常归一化为只读 rejected 结果;无领域错误码时使用 invalid_command。 */ +/** 将捕获的异常归一化为浅冻结的 rejected 外壳;无领域错误码时使用 invalid_command。 + * 已有 targets 数组仍沿用原引用,不应把外壳冻结解释为整个错误结果深度不可变。 */ export function reject(error: unknown) { const value = error as { code?: string; targets?: readonly string[] }; return Object.freeze({ status: 'rejected' as const, code: value?.code || 'invalid_command', targets: value?.targets || [] }); @@ -24,13 +26,15 @@ export function freeze(value: T): T { return value; } /** JSON 往返复制已验证的数据,切断共享引用;undefined 原样返回。 - * 此处不是校验器:JSON 会丢弃某些非法字段,因此外部输入必须在复制前验证。 */ + * 此处不是校验器:JSON 会执行 toJSON、读取可枚举字段并丢弃/改写不支持的值。 + * 不保留原型、描述符或共享子对象身份;需要严格输入语义时须在调用边界捕获。 */ export function clone(value: T): T { if (value === undefined) return value; return JSON.parse(JSON.stringify(value)) as T; } -/** 按可枚举自有键递归比较 JSON 风格的值;数组与对象不能互相视为相等。 - * 不处理任意循环对象,也不把 Map/Set 等实例当成业务值来比较。 */ +/** 先以 Object.is 判断,再按可枚举自有字符串键递归比较 JSON 风格的值;数组与对象不能互相视为相等。 + * 不检查 Symbol/不可枚举字段,数组长度本身也不在 Object.keys 中;稀疏数组不适合作为此比较器的输入。 + * 不处理任意循环对象,也不提供 Map/Set 成员比较语义。 */ export function equal(a: unknown, b: unknown): boolean { if (Object.is(a, b)) return true; if (!a || !b || typeof a !== 'object' || typeof b !== 'object' || Array.isArray(a) !== Array.isArray(b)) return false; @@ -46,6 +50,7 @@ export function isOwnedMapView(value:object){return ownedMapViews.has(value);} /** 复制表成员并仅暴露读取能力,外部既拿不到原 Map,也拿不到 set/delete。 * value 保留原引用,深度不可变性由生产这些 value 的模块负责。 */ export function readMap(input: ReadonlyMap): ReadonlyMap { + // 从此以后 input 的增删与本视图无关;但 K/V 对象仍共享引用,键/值本身不是在此深复制的。 const map = new Map(input); const view: ReadonlyMap = { get size() { return map.size; }, get: key => map.get(key), has: key => map.has(key), @@ -58,6 +63,7 @@ export function readMap(input: ReadonlyMap): ReadonlyMap { } /** 捕获一份成员集合并隐藏 add/delete;回调同样只接触只读视图。 */ export function readSet(input: Iterable): ReadonlyValueSet { + // 成员表独立于原 iterable,成员若为对象则仍保留原身份;隐藏可变方法并不冻结对象成员。 const set = new Set(input); const view: ReadonlyValueSet = { get size() { return set.size; }, has: key => set.has(key), entries: () => set.entries(), keys: () => set.keys(), values: () => set.values(), @@ -66,7 +72,9 @@ export function readSet(input: Iterable): ReadonlyValueSet { return Object.freeze(view); } /** 递归检查 JSON 值域:排除 undefined/函数/非有限数,最多允许 64 层。 - * 这里只检查值形状,不验证组件 schema,也不是所有原型/属性描述符的严格捕获器。 */ + * 对象要求 Object.prototype 原型,但只遍历 Object.values;Symbol/不可枚举属性不参与。 + * 数组用 every 跳过空洞,也不检查额外属性;它不是稠密数组或所有属性描述符的严格捕获器。 + * 校验读取可能触发 getter;这里不证明组件 schema、输入稳定性或数据所有权。 */ export function validJson(value: unknown, depth = 0): value is Json { if (depth > 64) return false; if (value === null || typeof value === 'string' || typeof value === 'boolean') return true;