/** * 流式 canonical 捕获的范围证据校验:每个 canvas 的直属成员数量、全部活元素 ID→owner 和拥有树。 * 这些头部证据用于判断某 owner 是否完整,不能替代尾部 payload/schema/端点的完整文档校验。 */ import type {Id} from '../canvas-core/types.ts'; import {fault} from '../canvas-core/values.ts'; /** Snapshot-bound direct membership counts, including nodes and connections. */ /** 当前捕获快照的直属数量,包含节点和边;不是递归子树大小或 DOM 已挂载数量。 */ export type CanvasElementCounts=Readonly>; /** 检查自有可枚举键数量及 canvasIds 的每个键存在,计数为非负安全整数,总和安全且等于 total。 * canvasIds 须由调用方保证唯一;此函数不单独去重,重复声明会破坏“键数量相同即覆盖相同”的前提。 * 只验证声明内部一致,不读取真实元素、认证服务或证明后续 payload 已齐;输入也不在此捕获/冻结。 */ export function validateCanvasElementCounts(value:unknown,canvasIds:readonly Id[],total:number):asserts value is CanvasElementCounts { if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_model_coverage'); const counts=value as Record,keys=Object.keys(counts);let sum=0; if(keys.length!==canvasIds.length||canvasIds.some(id=>!Object.hasOwn(counts,id)))throw fault('invalid_model_coverage'); for(const count of Object.values(counts)){if(typeof count!=='number'||!Number.isSafeInteger(count)||count<0)throw fault('invalid_model_coverage');sum+=count;if(!Number.isSafeInteger(sum))throw fault('invalid_model_coverage');} if(sum!==total)throw fault('invalid_model_coverage'); } /** All active IDs in the capture, including records not yet received. */ /** 完整活 ID 命名空间,即使部分记录尚未传到客户端,也预先知道其直接 owner。 */ export type ElementOwners=Readonly>; /** 要求声明恰有 total 个自有可枚举活 ID,每个非空 ID 的 owner 是 counts 中已有键,再反算数量。 * counts 应先经计数校验;此处不独立验证其值域,也不与已读 document 比较 ID/owner 是否对应。 * 只做同步声明一致性检查,不捕获/冻结输入;实际 payload 到达后还需匹配这份源快照身份表。 */ export function validateElementOwners(value:unknown,counts:CanvasElementCounts,total:number):asserts value is ElementOwners { if(!value||typeof value!=='object'||Array.isArray(value)||Object.keys(value).length!==total)throw fault('invalid_model_identity'); // 无原型计数表避免特殊 owner 键污染计数;空 canvas 由缺省 0 与 counts 比较。 const actual:Record=Object.create(null); for(const [id,owner]of Object.entries(value)){if(!id||typeof owner!=='string'||!Object.hasOwn(counts,owner))throw fault('invalid_model_identity');actual[owner]=(actual[owner]??0)+1;} if(Object.keys(counts).some(id=>(actual[id]??0)!==counts[id]))throw fault('invalid_model_identity'); } /** Full captured topology, independent of which entry payloads have arrived. */ /** 用源 canvas 表和已验证 owners 声明检查唯一根=projectId、父存在、入口唯一/归属与无环。 * 不是完整 CanvasRecord/payload schema 校验;未收到入口时不能证明其为 node、组件可用或实际拥有关系一致。 * 每个子 canvas 重新走父链,深树会反复访问共享祖先;同步检查无预算/取消,应由捕获规模约束成本。 * 不捕获输入或标记文档已完整;加载器另需核对已收到树/元素与此声明,再建立 owner 的覆盖状态。 */ export function validateCanvasTopology(canvases:readonly import('../canvas-core/types.ts').CanvasRecord[],owners:ElementOwners,projectId:Id):void { if(!Array.isArray(canvases)||!canvases.length)throw fault('invalid_model_topology'); const table=new Map(canvases.map(canvas=>[canvas?.id,canvas]));if(table.size!==canvases.length||!table.has(projectId))throw fault('invalid_model_topology'); // 每个入口最多绑定一个子 canvas;逐条父链用 seen 检测环,根 ID 必须等于 projectId。 let roots=0;const entries=new Set(); for(const canvas of canvases){if(!canvas||typeof canvas.id!=='string'||!canvas.id||!Object.hasOwn(canvas,'parent'))throw fault('invalid_model_topology');if(canvas.parent===null){roots++;if(canvas.id!==projectId)throw fault('invalid_model_topology');continue;} const parent=canvas.parent;if(!parent||!table.has(parent.canvasId)||typeof parent.entryElementId!=='string'||!parent.entryElementId||entries.has(parent.entryElementId)||owners[parent.entryElementId]!==parent.canvasId)throw fault('invalid_model_topology');entries.add(parent.entryElementId); const seen=new Set();let current=canvas;while(current.parent){if(seen.has(current.id))throw fault('invalid_model_topology');seen.add(current.id);const next=table.get(current.parent.canvasId);if(!next)throw fault('invalid_model_topology');current=next;} } if(roots!==1)throw fault('invalid_model_topology'); }