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

111 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 组件定义的装配与显式数据迁移。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';
/** 捕获一份行为表并冻结:ID 非空、至少一个 handler,且每个已提供 handler 都是函数。 */
export function defineFeature(id: string, handlers: Partial<Record<Capability, CapabilityHandler>>): Feature {
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');
// 复制路径访问自有字段,禁止原型污染键;数组遍历由 * 路径段表达。
const pathValid = (path: readonly string[]) => Array.isArray(path) && path.length > 0 && path.every(key => typeof key === 'string' && !!key && !['__proto__','prototype','constructor'].includes(key));
if (definition.copy) {
const references = definition.copy.references, omitted = definition.copy.omit;
if (!Array.isArray(references) || !Array.isArray(omitted) || omitted.some(path => !pathValid(path)) || references.some(ref => !pathValid(ref.path) || !['element','canvas'].includes(ref.target) || !['preserve','reject'].includes(ref.external) || ref.many !== undefined && typeof ref.many !== 'boolean')) throw fault('invalid_copy_schema');
// reference 和 omit 不可指向重叠路径;通配符也参与前缀冲突判断,避免复制顺序改变结果。
const paths: readonly (readonly string[])[] = [...references.map(ref => ref.path), ...omitted];
for (let i = 0; i < paths.length; i++) for (let j = 0; j < i; j++) if (paths[i].slice(0, Math.min(paths[i].length, paths[j].length)).every((key, index) => (key === paths[j][index] || key === '*' || paths[j][index] === '*'))) throw fault('conflicting_copy_paths');
}
// 节点提供 bounds/hit,边提供 segments/strokeWidth;协议种类必须与组件结构一致。
const geometry=definition.geometry;if(geometry&&(geometry.version!==1||geometry.kind!==definition.structure||geometry.kind==='node'&&(typeof geometry.bounds!=='function'||typeof geometry.hit!=='function'||geometry.dataDependencies!==undefined&&(!Array.isArray(geometry.dataDependencies)||geometry.dataDependencies.some(key=>typeof key!=='string'||!key)))||geometry.kind==='connection'&&(typeof geometry.segments!=='function'||typeof geometry.strokeWidth!=='function')))throw fault('invalid_geometry_protocol');
return freeze({ ...definition, geometry:geometry?{...geometry,...(geometry.kind==='node'&&geometry.dataDependencies?{dataDependencies:[...geometry.dataDependencies]}:{})}:undefined, copy: definition.copy ? clone(definition.copy) : undefined, features: definition.features.map(feature => defineFeature(feature.id, feature.handlers)), migrations: definition.migrations ? { ...definition.migrations } : undefined, resourceFields: definition.resourceFields ? [...definition.resourceFields] : undefined });
}
/** 一次性建立只读 registry;组件 ID、同组件 feature ID 和 capability 都要求唯一。 */
export function createRegistry(definitions: readonly ComponentDefinition[]): Registry {
const components = new Map<string, ComponentDefinition>(), capabilities = new Map<string, ReadonlyValueSet<Capability>>(), handlers = new Map<string, ReadonlyMap<Capability, CapabilityHandler>>();
for (const input of definitions) {
const definition = defineComponent(input);
if (components.has(definition.id)) throw fault('duplicate_component', [definition.id]);
const found = new Map<Capability, CapabilityHandler>(), features = new Set<string>();
// 即使两个 feature 的 ID 不同,也不能偷偷覆盖同一个 capability 的 handler。
for (const feature of definition.features) {
if (features.has(feature.id)) throw fault('duplicate_feature', [feature.id]);
features.add(feature.id);
for (const [key, handler] of Object.entries(feature.handlers)) {
const capability = key as Capability;
if (!handler) throw fault('invalid_feature');
if (found.has(capability)) throw fault('duplicate_capability', [definition.id, capability]);
found.set(capability, handler);
}
}
components.set(definition.id, definition); capabilities.set(definition.id, readSet(found.keys())); handlers.set(definition.id, readMap(found));
}
// 这里只按 componentId+dataVersion 分派,不执行 data schema 或结构校验;known 不是整条记录合法的证明。
// 可保留未知记录,但不能假设它可被当前 handler 编辑。
const resolve: Registry['resolve'] = element => {
const definition = components.get(element.componentId);
if (!definition) return 'unknown-component';
if (definition.dataVersion !== element.dataVersion) return 'unsupported-version';
return 'known';
};
return Object.freeze({ components: readMap(components), capabilities: readMap(capabilities), handlers: readMap(handlers), resolve,
// supports 同时要求版本受支持及行为已安装,而不是仅看 componentId 是否存在。
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.stringify,因为 undefined、函数、非有限数会被丢弃/改值,掩盖非法迁移输出。 */
function captureMigrationData(input:unknown,id:string):Data {
// 本函数发现形状非法时带原元素 ID 抛 invalid_migration;getter/转换器自身抛出的异常不在这里包装。
const invalid=():never=>{throw fault('invalid_migration',[id]);};
// 递归接收 JSON 原子、稠密数组和普通对象;深度上限防止循环/异常深输入无限递归。
const capture=(value:unknown,depth=0):Json=>{
if(depth>64)return invalid();
if(value===null||typeof value==='string'||typeof value==='boolean')return value;
if(typeof value==='number')return Number.isFinite(value)?value:invalid();
if(!value||typeof value!=='object')return invalid();
// JSON 无法表达可枚举 Symbol 键;明确拒绝,不能静默漏掉迁移数据。
if(Object.getOwnPropertySymbols(value).some(key=>Object.prototype.propertyIsEnumerable.call(value,key)))return invalid();
// 同时要求自有连续下标与键数一致,排除空洞及附加属性。
if(Array.isArray(value)){
if(Object.keys(value).length!==value.length)return invalid();
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)])));
};
const result=capture(input);
if(!result||typeof result!=='object'||Array.isArray(result))return invalid();
return result;
}
/** 按 migrations[旧版本] 逐级转换到当前版本,每步重新捕获,最后通过目标 schema。
* 未知组件/未来版本原样保留;缺 converter 或任一步失败抛错,不返回半迁移记录。
* 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 仅前进一个版本,不把“有版本号”当成自动跨版本兼容承诺。
while (version < definition.dataVersion) {
const migrate = definition.migrations?.[version];
if (!migrate) throw fault('missing_migration', [element.id]);
data = captureMigrationData(migrate(data),element.id); version++;
}
if (!definition.validate(data)) throw fault('invalid_component_data', [element.id]);
return freeze({ ...element, dataVersion: version, data });
}