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
+15
View File
@@ -37,6 +37,21 @@ The React adapter has an explicit entry and optional peer dependencies; portable
consumers do not load it. No registry publication, release support promise, server
deployment or full-goal completion is implied by this local package.
### Explicit component-data migration
`migrateElement(record, registry)` prepares a detached record; it does not write
the document or migrate storage on read. The ID, incarnation, component and owner
stay unchanged. Each version converter receives frozen detached data and must
return a JSON object. Missing converters, thrown reference mapping, invalid schema,
undefined/function/non-finite values and malformed object/array data reject rather
than being erased or coerced by JSON serialization. Field values are captured once,
then frozen, so a getter cannot return one value for validation and another for
serialization. Unknown components and future versions remain unchanged.
Use an editor transaction when several prepared migrations must publish together:
an error after a staged edit leaves document, history, operations and indexes at
their original Root. Progressive imports still use their declared per-record
failure boundaries; this helper does not promise whole-import rollback.
Experimental partial-model runtime: pass a validated prefix plus `loading` in
`EngineOptions`. Version 1 uses recovery format 7; version 2 carries snapshot-bound direct canvas
membership counts and uses format 8. Both keep the scheduler held. Version 3
+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 });
+47
View File
@@ -174,6 +174,53 @@ test('migration validates a staged copy and retains stable identity and ownershi
assert.equal(original.dataVersion, 1);
});
test('distinct features cannot install the same capability or overwrite an existing component',()=>{
const handler=()=>[],a=defineFeature('first',{'content.edit':handler}),b=defineFeature('second',{'content.edit':handler});
const definition=defineComponent({id:'plugin',dataVersion:1,structure:'node',validate:()=>true,features:[a]});
assert.throws(()=>createRegistry([definition,definition]),/duplicate_component/);
assert.throws(()=>createRegistry([{...definition,features:[a,b]}]),/duplicate_capability/);
const registry=createRegistry([definition]);assert.equal(registry.handlers.get('plugin').get('content.edit'),handler);
assert.equal(registry.components.set,undefined);assert.equal(registry.handlers.get('plugin').set,undefined);
});
test('failed schema or reference migrations retain the original data, version and identity without partial publication',()=>{
const original={...node('a'),data:{title:'Original',links:['b'],nested:{retain:true}}},saved=structuredClone(original);
for(const migrations of[
{},
{1:()=>{throw Error('reference_mapping_failed');}},
{1:data=>{data.links.push('unexpected');return{heading:'Changed'};}},
{1:()=>({heading:3})},
{1:()=>({heading:'Valid',invalid:undefined})},
{1:()=>({heading:'Valid',invalid:()=>42})},
{1:()=>({heading:'Valid',invalid:NaN})},
]){
const registry=createRegistry([defineComponent({id:'text',dataVersion:2,structure:'node',features:[editable()],validate:data=>typeof data.heading==='string',migrations})]);
const editor=setup([node('safe')]),before=editor.getSnapshot(),queries=editor.getQueries();let notifications=0;editor.subscribe(()=>notifications++);
const result=editor.transaction(tx=>{tx.dispatch({type:'element.data',target:{id:'safe',incarnation:1},patch:{title:'must roll back'}});migrateElement(original,registry);});
assert.equal(result.status,'rejected');assert.strictEqual(editor.getSnapshot(),before);assert.strictEqual(editor.getQueries(),queries);assert.equal(notifications,0);assert.deepEqual(original,saved);
}
const future={...original,dataVersion:99},registry=createRegistry(builtinComponents());assert.strictEqual(migrateElement(future,registry),future);assert.deepEqual(original,saved);
const multi=createRegistry([defineComponent({id:'text',dataVersion:3,structure:'node',features:[],validate:()=>true,migrations:{1:data=>({...data,mapped:'b-prime'}),2:()=>{throw Error('second_reference_mapping_failed');}}})]);
assert.throws(()=>migrateElement(original,multi),/second_reference_mapping_failed/);assert.deepEqual(original,saved);
for(const data of[null,[],{title:'Invalid',lost:undefined}])assert.throws(()=>migrateElement({...original,data},createRegistry(builtinComponents())),/invalid_migration/);
let reads=0;const captured=createRegistry([defineComponent({id:'text',dataVersion:2,structure:'node',features:[],validate:data=>data.heading==='Captured once',migrations:{1:()=>({get heading(){return ++reads===1?'Captured once':undefined;}})}})]);
const migrated=migrateElement(original,captured);assert.equal(reads,1);assert.equal(migrated.data.heading,'Captured once');assert.deepEqual(original,saved);assert.ok(Object.isFrozen(migrated.data));
});
test('reentrant mutation entry points reject without publishing document, history, runtime, indexes or operations',()=>{
for(const attempt of[
editor=>editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'nested'}}),
editor=>editor.transaction(tx=>tx.dispatch({type:'element.remove',target:{id:'a',incarnation:1}})),
editor=>editor.history.undo(),
editor=>editor.updateRuntime(()=>({published:true})),
editor=>editor.reconcile(editor.getSnapshot().document,{published:true},editor.getSnapshot().rootRevision),
]){
let reductions=0,notifications=0;const editor=setup(undefined,{runtime:{published:false},reduceRuntime(previous){reductions++;return previous;}}),before=editor.getSnapshot(),queries=editor.getQueries();editor.subscribe(()=>notifications++);
const result=editor.transaction(()=>{const nested=attempt(editor);assert.equal(nested.code,'reentrant_dispatch');assert.strictEqual(editor.getSnapshot(),before);});
assert.equal(result.status,'applied');assert.deepEqual(result.operationIds,[]);assert.strictEqual(editor.getSnapshot(),before);assert.strictEqual(editor.getQueries(),queries);assert.equal(reductions,0);assert.equal(notifications,0);assert.equal(before.operations.size,0);assert.equal(before.history.undo.length,0);
}
});
test('cross-child connection belongs to the common ancestor; cyclic canvas ownership rejects atomically', () => {
const elements = [node('f'), node('g'), node('a', 'child'), node('b', 'sibling')];
const canvases = [{ id: 'root', parent: null }, { id: 'child', parent: { canvasId: 'root', entryElementId: 'f' } }, { id: 'sibling', parent: { canvasId: 'root', entryElementId: 'g' } }];
+8 -1
View File
@@ -2,7 +2,7 @@ import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createRendererCatalog,builtinRendererDefinitions} from '../../src/lib/canvas-bindings/renderers.ts';
import {createReactRenderers} from '../../src/lib/canvas-bindings/react-renderers.ts';
import {createDocument,createRegistry,builtinComponents,defineComponent,movable} from '../../src/lib/canvas-core/index.ts';
import {createDocument,createRegistry,builtinComponents,defineComponent,movable,createEditor} from '../../src/lib/canvas-core/index.ts';
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
const descriptor={componentId:'plugin:meter',dataVersion:1,structure:'node',rendererId:'meter'};
const definition=defineComponent({id:'plugin:meter',dataVersion:1,structure:'node',validate:data=>typeof data.metric==='number',features:[movable()]}),registry=createRegistry([...builtinComponents(),definition]);
@@ -24,3 +24,10 @@ test('installed node and edge renderers receive original records and transforms
test('pre-engine preview renderers are explicit and captured independently from engine-bound renderers',()=>{
const render=()=>null,preview=()=>null,assembly=createReactRenderers([{...descriptor,render,preview}]);assert.equal(assembly.nodeTypes.meter.type,render);assert.equal(assembly.previewNodeTypes.meter.type,preview);assert.equal(assembly.definitions[0].preview,preview);assert.ok(Object.isFrozen(assembly.previewNodeTypes));assert.equal(createReactRenderers([{...descriptor,render}]).previewNodeTypes.meter,undefined);assert.throws(()=>createReactRenderers([{...descriptor,render,preview:0}]),/invalid_react_preview/);assert.throws(()=>createReactRenderers([{...descriptor,render,preview},{...descriptor,componentId:'other',render}]),/renderer_id_conflict/);
});
test('independent host renderers for one component share model data and capability behavior',()=>{
const editor=createEditor({registry,document:createDocument('project',[{id:'root',parent:null}],[node])}),one=createReactRenderers([{...descriptor,rendererId:'host-one',render:()=>null}],registry),two=createReactRenderers([{...descriptor,rendererId:'host-two',render:()=>null}],registry);
assert.equal(editor.dispatch({type:'element.geometry',target:{id:node.id,incarnation:node.incarnation},patch:{x:42}}).status,'applied');
const document=editor.getSnapshot().document,a=projectCanvas(document,'root',registry,new Set(),()=>undefined,null,one.catalog),b=projectCanvas(document,'root',registry,new Set(),()=>undefined,null,two.catalog);
assert.equal(a.nodes[0].type,'host-one');assert.equal(b.nodes[0].type,'host-two');assert.strictEqual(a.nodes[0].data.__headlessRecord,document.elements.get(node.id));assert.strictEqual(b.nodes[0].data.__headlessRecord,a.nodes[0].data.__headlessRecord);assert.equal(a.nodes[0].position.x,42);assert.deepEqual(b.nodes[0].position,a.nodes[0].position);assert.equal(editor.getSnapshot().history.undo.length,1);
});