docs(core): clarify capture validation and replay guarantees

This commit is contained in:
2026-11-12 12:17:04 +08:00
parent cda4db5ec8
commit db3eb8ec5d
6 changed files with 56 additions and 23 deletions
+3 -1
View File
@@ -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<object>();
/** 消费 iterable 得到新数组并浅冻结;不会冻结数组中的业务值。 */
export function dataArray<T>(values: Iterable<T>): readonly T[] {
const result = Object.freeze(Array.from(values)); owned.add(result); return result;
}
/** 浅复制对象的可枚举自有属性再冻结外壳;不是任意对象的深复制器。 */
/** 浅复制对象的可枚举自有属性再冻结外壳;不是任意对象的深复制器。
* 对象展开会读取 getter 并保留嵌套值引用,也不保留原对象原型或属性描述符。 */
export function dataRecord<T extends object>(value: T): Readonly<T> {
const result = Object.freeze({...value}); owned.add(result); return result;
}
+20 -7
View File
@@ -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<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');
@@ -61,14 +62,19 @@ const staticElementProofs=new WeakSet<object>();
/** 校验单条记录的基础形状、墓碑代次和已知组件数据。
* 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);
}
@@ -101,6 +107,7 @@ function validateElementInDocument(document:Document,element:ElementRecord,regis
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');
@@ -111,7 +118,8 @@ function* validateDocumentChecks(document:Document,registry:Registry,componentDa
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);
@@ -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<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,输入快照保持不变。
// 复制表成员而复用未改记录;失败不返回私有 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<ReplayChangeBatch
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);
// 整元素修改必须匹配 before;删除同时保留墓碑,使旧 incarnation 永远不能复活。
// 整元素修改在 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) {
@@ -184,6 +196,7 @@ function applyChangeBatches(document:Document,batches:Iterable<ReplayChangeBatch
}
/** 检测回放目标效果是否已经存在;字段目标必须仍是同一个 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);
+5 -2
View File
@@ -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<object>();
/** 已由本模块捕获的记录直接复用;外部记录重新复制冻结,再标记新记录身份。 */
/** 已由本模块捕获的记录直接复用;外部记录重新按 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<Function>(),dataProofs=new WeakMap<object,ComponentDefinition['validate']>();
/** 标记框架内置、仅依赖输入的纯校验器;不要用它包装依赖外部可变状态的校验器。 */
/** 标记框架内置、仅依赖输入的纯校验器;不要用它包装依赖外部可变状态的校验器。
* WeakSet 只记录这项声明,不能自动证明函数无副作用或不读取闭包状态。 */
export function builtinDataValidator<T extends ComponentDefinition['validate']>(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;
+3 -2
View File
@@ -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<object>();
/** JSON 复制后递归冻结;对象结果记录到 WeakSet,原始值直接返回。
* clone 会按 JSON 规则处理数据,所以不可用它代替迁移输入的严格捕获校验。 */
/** JSON 复制后递归冻结;仅对象结果记录到 WeakSet。
* 输入先经过 JSON 序列化语义:toJSON/getter 可能执行,稀疏槽/数组中的非法 JSON 值可能转为 null。
* 循环或 BigInt 等仍会抛错;它不保留任意输入原貌,不能替代迁移输入的严格捕获校验。 */
export function detachJsonData<T>(input:T):T {
const value=freeze(clone(input));
if(value&&typeof value==='object')detached.add(value);
+11 -5
View File
@@ -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<Record<Capability, C
if (!id || !Object.values(handlers).length || Object.values(handlers).some(value => 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<value.length;index++){if(!Object.hasOwn(value,index))return invalid();result.push(capture(value[index],depth+1));}return freeze(result);
}
if(Object.getPrototypeOf(value)!==Object.prototype)return invalid();
// Object.keys 不遍历不可枚举字段;getter 在这里读取,结果复制为普通数据属性,不保留原描述符。
const source=value as Record<string,unknown>;
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 仅前进一个版本,不把“有版本号”当成自动跨版本兼容承诺。
+14 -6
View File
@@ -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<T>(value: T): T {
return value;
}
/** JSON 往返复制已验证的数据,切断共享引用;undefined 原样返回。
* 此处不是校验器:JSON 会丢弃某些非法字段,因此外部输入必须在复制前验证。 */
* 此处不是校验器:JSON 会执行 toJSON、读取可枚举字段并丢弃/改写不支持的值。
* 不保留原型、描述符或共享子对象身份;需要严格输入语义时须在调用边界捕获。 */
export function clone<T>(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<K, V>(input: ReadonlyMap<K, V>): ReadonlyMap<K, V> {
// 从此以后 input 的增删与本视图无关;但 K/V 对象仍共享引用,键/值本身不是在此深复制的。
const map = new Map(input);
const view: ReadonlyMap<K, V> = {
get size() { return map.size; }, get: key => map.get(key), has: key => map.has(key),
@@ -58,6 +63,7 @@ export function readMap<K, V>(input: ReadonlyMap<K, V>): ReadonlyMap<K, V> {
}
/** 捕获一份成员集合并隐藏 add/delete;回调同样只接触只读视图。 */
export function readSet<T>(input: Iterable<T>): ReadonlyValueSet<T> {
// 成员表独立于原 iterable,成员若为对象则仍保留原身份;隐藏可变方法并不冻结对象成员。
const set = new Set(input);
const view: ReadonlyValueSet<T> = {
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<T>(input: Iterable<T>): ReadonlyValueSet<T> {
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;