fix(canvas): capture schema migration data before lossy serialization

This commit is contained in:
2026-11-11 18:27:51 +08:00
parent b34dc6089a
commit b05058a955
4 changed files with 96 additions and 6 deletions
+26 -5
View File
@@ -1,5 +1,5 @@
import type { Capability, CapabilityHandler, ComponentDefinition, Data, ElementRecord, Feature, Registry, ReadonlyValueSet } from './types.ts';
import { clone, fault, freeze, readMap, readSet, validJson } from './values.ts';
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');
@@ -44,15 +44,36 @@ export function createRegistry(definitions: readonly ComponentDefinition[]): Reg
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: Data = freeze(clone(element.data)), version = element.dataVersion;
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 = freeze(clone(migrate(data))); version++;
if (!validJson(data)) throw fault('invalid_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 });