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

81 lines
6.5 KiB
TypeScript

import type { Capability, CapabilityHandler, ComponentDefinition, Data, ElementRecord, Feature, Json, Registry, ReadonlyValueSet } from './types.ts';
import { clone, fault, freeze, readMap, readSet } from './values.ts';
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 } });
}
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');
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');
}
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 });
}
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>();
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));
}
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: (element: ElementRecord, capability: Capability) => resolve(element) === 'known' && !!capabilities.get(element.componentId)?.has(capability) });
}
/** Capture each field once; JSON.stringify would silently erase invalid values. */
function captureMigrationData(input:unknown,id:string):Data {
const invalid=():never=>{throw fault('invalid_migration',[id]);};
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();
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();
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;
}
export function migrateElement(element: ElementRecord, registry: Registry): ElementRecord {
const definition = registry.components.get(element.componentId);
if (!definition || element.dataVersion > definition.dataVersion) return element;
let data = captureMigrationData(element.data,element.id), version = element.dataVersion;
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 });
}