81 lines
6.5 KiB
TypeScript
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 });
|
|
}
|