diff --git a/headless/README.md b/headless/README.md index cb593c6..d336f6d 100644 --- a/headless/README.md +++ b/headless/README.md @@ -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 diff --git a/src/lib/canvas-core/registry.ts b/src/lib/canvas-core/registry.ts index 72e0a2e..eb8c476 100644 --- a/src/lib/canvas-core/registry.ts +++ b/src/lib/canvas-core/registry.ts @@ -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>): 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; + 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 }); diff --git a/tests/headless/core.test.mjs b/tests/headless/core.test.mjs index 2e305e3..2b59c3f 100644 --- a/tests/headless/core.test.mjs +++ b/tests/headless/core.test.mjs @@ -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' } }]; diff --git a/tests/headless/renderers.test.mjs b/tests/headless/renderers.test.mjs index 61994ca..1904139 100644 --- a/tests/headless/renderers.test.mjs +++ b/tests/headless/renderers.test.mjs @@ -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); +});