fix(canvas): capture schema migration data before lossy serialization
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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' } }];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user