From a6092e61cdbb38fe5e652b0d0c9b1cb165d4ffa4 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Tue, 10 Nov 2026 16:07:02 +0800 Subject: [PATCH] Share core-owned immutable elements and validation facts --- headless/README.md | 17 +++++ src/lib/canvas-core/creation-draft.ts | 3 +- src/lib/canvas-core/document.ts | 77 +++++++++++++--------- src/lib/canvas-core/editor.ts | 2 + src/lib/canvas-core/element-data.ts | 25 +++++++ src/lib/canvas-core/features.ts | 5 +- tests/headless/element-validation.test.mjs | 34 ++++++++++ 7 files changed, 128 insertions(+), 35 deletions(-) create mode 100644 src/lib/canvas-core/element-data.ts create mode 100644 tests/headless/element-validation.test.mjs diff --git a/headless/README.md b/headless/README.md index 7201459..f9cc400 100644 --- a/headless/README.md +++ b/headless/README.md @@ -855,3 +855,20 @@ step. The existing recursive codec still checks nested mutability and depth, so mutable values and external Maps are re-read. This is an encoding cache, not a second state table; recovery JSON order, formats and journal checks remain the same. Replacing a table gets a new entry view and leaves prior images unchanged. + +Static element validation proofs apply only to records detached and deeply frozen +by the core's element-data factory. Document construction, element replacement +and staged creations share this private provenance. A separate WeakSet records a +successful intrinsic shape check. Registry support, tombstones, owner membership and connection topology remain +live checks. Custom schemas are always called with their original receiver. +Only framework-owned data-only validators can reuse a successful result for the +same detached record and current validator function; their private proof keeps +one validator identity per record. +External or shallow-frozen records never gain a proof. Field updates without +factory provenance use the complete path. Document equality rejects different +table sizes before allocating entry arrays; equal-sized tables keep the existing +ordered comparison and no-op rules. + +Synchronous document validation loops directly over the same per-record checks +used by the cooperative iterator. The iterator retains its original yield points; +the atomic publication path does not pay a generator handoff per unchanged record. diff --git a/src/lib/canvas-core/creation-draft.ts b/src/lib/canvas-core/creation-draft.ts index bd8ac4c..8a42f49 100644 --- a/src/lib/canvas-core/creation-draft.ts +++ b/src/lib/canvas-core/creation-draft.ts @@ -1,3 +1,4 @@ +import {detachElementData} from './element-data.ts'; import type {CanvasRecord,Change,Document,ElementRecord,Id} from './types.ts'; import {applyChanges} from './document.ts'; import {clone,fault,freeze,readMap,validJson} from './values.ts'; @@ -23,7 +24,7 @@ export function stageCreation(document:Document,change:ExtractfactoryDocuments.has(document); export function createDocument(projectId: Id, canvases: readonly CanvasRecord[], elements: readonly ElementRecord[] = [], tombstones: ReadonlyMap = new Map()): Document { - for (const element of elements) { const id = element.id; if (!validJson(element)) throw fault('invalid_element', [id]); } + for (const element of elements) { const id = element.id; if (!staticElementProofs.has(element)&&!validJson(element)) throw fault('invalid_element', [id]); } for (const canvas of canvases) if (!validJson(canvas)) throw fault('invalid_canvas'); if (!projectId || new Set(canvases.map(item => item.id)).size !== canvases.length || new Set(elements.map(item => item.id)).size !== elements.length) throw fault('duplicate_identity'); - return documentEnvelope({ projectId, canvases: readMap(new Map(canvases.map(item => [item.id, freeze(clone(item))]))), elements: readMap(new Map(elements.map(item => [item.id, freeze(clone(item))]))), tombstones: readMap(tombstones) }); + return documentEnvelope({ projectId, canvases: readMap(new Map(canvases.map(item => [item.id, freeze(clone(item))]))), elements: readMap(new Map(elements.map(item => [item.id, detachElementData(item)]))), tombstones: readMap(tombstones) }); } export function geometryValid(value: Geometry): boolean { return !!value && typeof value === 'object' && [value.x, value.y, value.width, value.height, value.rotation].every(Number.isFinite) && value.width > 0 && value.height > 0 && value.rotation >= 0 && value.rotation < 360; @@ -40,49 +41,61 @@ export function commonCanvas(document: Document, left: Id, right: Id): Id { if (!result) throw fault('no_common_canvas', [left, right]); return result; } +// Only intrinsic shape is reusable; document- and registry-dependent checks stay live. +const staticElementProofs=new WeakSet(); /** Validate one record before its graph dependencies have arrived. This does not * prove owner membership, endpoint existence or connection common ownership. */ export function validateElementRecord(element: ElementRecord, registry: Registry, tombstones: ReadonlyMap = new Map(), componentData = true): void { - if (!element || !validJson(element) || typeof element.id !== 'string' || !element.id || typeof element.ownerCanvasId !== 'string' || !element.ownerCanvasId || typeof element.componentId !== 'string' || !element.componentId || !['node','connection'].includes(element.structure) || !Number.isSafeInteger(element.incarnation) || element.incarnation < 1 || !Number.isSafeInteger(element.dataVersion) || element.dataVersion < 1 || !element.data || typeof element.data !== 'object' || Array.isArray(element.data) || !validJson(element.data)) throw fault('invalid_element', [element?.id]); + const proven=!!element&&staticElementProofs.has(element); + if (!proven&&(!element || !validJson(element) || typeof element.id !== 'string' || !element.id || typeof element.ownerCanvasId !== 'string' || !element.ownerCanvasId || typeof element.componentId !== 'string' || !element.componentId || !['node','connection'].includes(element.structure) || !Number.isSafeInteger(element.incarnation) || element.incarnation < 1 || !Number.isSafeInteger(element.dataVersion) || element.dataVersion < 1 || !element.data || typeof element.data !== 'object' || Array.isArray(element.data) || !validJson(element.data))) throw fault('invalid_element', [element?.id]); const dead=tombstones.get(element.id);if(dead!==undefined&&element.incarnation<=dead)throw fault('stale_incarnation',[element.id]); const definition=registry.components.get(element.componentId); - if(componentData&®istry.resolve(element)==='known'&&(!definition||definition.structure!==element.structure||!definition.validate(element.data)))throw fault('invalid_component_data',[element.id]); + if(componentData&®istry.resolve(element)==='known'&&(!definition||definition.structure!==element.structure||!componentDataValid(element,definition)))throw fault('invalid_component_data',[element.id]); + if(proven)return; if(element.structure==='node'){if(!geometryValid(element.geometry))throw fault('invalid_geometry',[element.id]);} else if(!element.source||!element.target||typeof element.source.elementId!=='string'||!element.source.elementId||typeof element.target.elementId!=='string'||!element.target.elementId||[element.source,element.target].some(endpoint=>endpoint.portId!==undefined&&(typeof endpoint.portId!=='string'||!endpoint.portId))||!validJson(element.path))throw fault('invalid_endpoint',[element.id]); + if(hasOwnedElementData(element))staticElementProofs.add(element); } -/** Cooperative validation over an immutable document; synchronous callers drain the same checks. */ -function* validateDocumentChecks(document: Document, registry: Registry, componentData:boolean): Generator { - if (!document.canvases.size) throw fault('missing_root'); - for (const [id, incarnation] of document.tombstones) { yield; if (!id || !Number.isSafeInteger(incarnation) || incarnation < 1) throw fault('invalid_tombstone', [id]); } - const entries = new Set(); - let roots = 0; - for (const canvas of document.canvases.values()) { - yield; - if (typeof canvas.id !== 'string' || !canvas.id || !Object.hasOwn(canvas,'parent') || canvas.parent !== null && (!canvas.parent || typeof canvas.parent.canvasId !== 'string' || typeof canvas.parent.entryElementId !== 'string')) throw fault('invalid_canvas'); - ancestors(document, canvas.id); - if (!canvas.parent) { roots++; continue; } - const entry = document.elements.get(canvas.parent.entryElementId); - if (!entry || entry.structure !== 'node' || entry.ownerCanvasId !== canvas.parent.canvasId || entries.has(entry.id)) throw fault('invalid_canvas_entry', [canvas.id]); - entries.add(entry.id); - } - if (roots !== 1) throw fault('invalid_roots'); - for (const element of document.elements.values()) { - yield; - validateElementRecord(element, registry, document.tombstones, componentData); - if (!document.canvases.has(element.ownerCanvasId)) throw fault('invalid_element', [element.id]); - if (element.structure === 'connection') { - const source = document.elements.get(element.source.elementId), target = document.elements.get(element.target.elementId); - if (!source || !target || source.structure !== 'node' || target.structure !== 'node' || !validJson(element.path)) throw fault('invalid_endpoint', [element.id]); - if (commonCanvas(document, source.ownerCanvasId, target.ownerCanvasId) !== element.ownerCanvasId) throw fault('invalid_connection_owner', [element.id]); - } +function validateTombstone(id:Id,incarnation:number){ + if(!id||!Number.isSafeInteger(incarnation)||incarnation<1)throw fault('invalid_tombstone',[id]); +} +function validateCanvasInDocument(document:Document,canvas:CanvasRecord,entries:Set):number{ + if(typeof canvas.id!=='string'||!canvas.id||!Object.hasOwn(canvas,'parent')||canvas.parent!==null&&(!canvas.parent||typeof canvas.parent.canvasId!=='string'||typeof canvas.parent.entryElementId!=='string'))throw fault('invalid_canvas'); + ancestors(document,canvas.id); + if(!canvas.parent)return 1; + const entry=document.elements.get(canvas.parent.entryElementId); + if(!entry||entry.structure!=='node'||entry.ownerCanvasId!==canvas.parent.canvasId||entries.has(entry.id))throw fault('invalid_canvas_entry',[canvas.id]); + entries.add(entry.id);return 0; +} +function validateElementInDocument(document:Document,element:ElementRecord,registry:Registry,componentData:boolean){ + validateElementRecord(element,registry,document.tombstones,componentData); + if(!document.canvases.has(element.ownerCanvasId))throw fault('invalid_element',[element.id]); + if(element.structure==='connection'){ + const source=document.elements.get(element.source.elementId),target=document.elements.get(element.target.elementId); + if(!source||!target||source.structure!=='node'||target.structure!=='node'||!validJson(element.path))throw fault('invalid_endpoint',[element.id]); + if(element.ownerCanvasId!==commonCanvas(document,source.ownerCanvasId,target.ownerCanvasId))throw fault('invalid_connection_owner',[element.id]); } } +/** Cooperative and synchronous paths use the same per-record checks and order. */ +function* validateDocumentChecks(document:Document,registry:Registry,componentData:boolean):Generator{ + if(!document.canvases.size)throw fault('missing_root'); + for(const[id,incarnation]of document.tombstones){yield;validateTombstone(id,incarnation);} + const entries=new Set();let roots=0; + for(const canvas of document.canvases.values()){yield;roots+=validateCanvasInDocument(document,canvas,entries);} + if(roots!==1)throw fault('invalid_roots'); + for(const element of document.elements.values()){yield;validateElementInDocument(document,element,registry,componentData);} +} /** Structural indexes contain no component payload; use only for source planning, never editor publication. */ export function* validateDocumentTopologySteps(document:Document,registry:Registry):Generator{yield* validateDocumentChecks(document,registry,false);} export function* validateDocumentSteps(document:Document,registry:Registry):Generator{yield* validateDocumentChecks(document,registry,true);} -export function validateDocument(document: Document, registry: Registry): void { - for (const _ of validateDocumentSteps(document, registry)) { /* Drain identical validation without scheduling. */ } +export function validateDocument(document:Document,registry:Registry):void{ + if(!document.canvases.size)throw fault('missing_root'); + for(const[id,incarnation]of document.tombstones)validateTombstone(id,incarnation); + const entries=new Set();let roots=0; + for(const canvas of document.canvases.values())roots+=validateCanvasInDocument(document,canvas,entries); + if(roots!==1)throw fault('invalid_roots'); + for(const element of document.elements.values())validateElementInDocument(document,element,registry,true); } export function applyChanges(document: Document, changes: readonly Change[], conditional = true): Document { const elements = new Map(document.elements), canvases = new Map(document.canvases), tombstones = new Map(document.tombstones); @@ -106,7 +119,7 @@ export function applyChanges(document: Document, changes: readonly Change[], con if (change.after) { if (!validJson(change.after)) throw fault('invalid_element', [change.id]); if (change.after.id !== change.id) throw fault('identity_mismatch', [change.id]); - elements.set(change.id, freeze(clone(change.after))); + elements.set(change.id, detachElementData(change.after)); } else { const old = elements.get(change.id); if (old) tombstones.set(change.id, Math.max(tombstones.get(change.id) || 0, old.incarnation)); diff --git a/src/lib/canvas-core/editor.ts b/src/lib/canvas-core/editor.ts index 223a856..4cbfca5 100644 --- a/src/lib/canvas-core/editor.ts +++ b/src/lib/canvas-core/editor.ts @@ -340,5 +340,7 @@ export function createEditor(options: EditorOptions(); +export function detachElementData(record:ElementRecord):ElementRecord{ + if(record&&typeof record==='object'&&owned.has(record))return record; + const value=freeze(clone(record)); + if(value&&typeof value==='object')owned.add(value); + return value; +} +export const hasOwnedElementData=(record:ElementRecord)=>!!record&&typeof record==='object'&&owned.has(record); + +// Framework-owned validators depend only on detached immutable data. Custom +// validators remain live, including their method receiver and external context. +const builtinValidators=new WeakSet(),dataProofs=new WeakMap(); +export function builtinDataValidator(validate:T):T{builtinValidators.add(validate);return validate;} +export function componentDataValid(record:ElementRecord,definition:ComponentDefinition):boolean{ + const validate=definition.validate,reusable=hasOwnedElementData(record)&&builtinValidators.has(validate); + if(reusable&&dataProofs.get(record)===validate)return true; + const valid=Reflect.apply(validate,definition,[record.data]); + if(valid&&reusable)dataProofs.set(record,validate); + return valid; +} diff --git a/src/lib/canvas-core/features.ts b/src/lib/canvas-core/features.ts index 9dc1ff0..d3cb0ff 100644 --- a/src/lib/canvas-core/features.ts +++ b/src/lib/canvas-core/features.ts @@ -1,3 +1,4 @@ +import {builtinDataValidator} from './element-data.ts'; import {rectangleGeometry,bezierChainSegments,type BezierChain} from './geometry.ts'; import type { CapabilityHandler, ComponentDefinition, Data, FieldChange, Geometry, Json } from './types.ts'; import { defineComponent, defineFeature } from './registry.ts'; @@ -43,14 +44,14 @@ export const editable = () => defineFeature('content.edit', { }); }, }); -const textData = (data: Data) => presentationUnitsValid(data.presentation) && validJson(data) && typeof data.title === 'string' && typeof data.text === 'string'; +const textData = builtinDataValidator((data: Data) => presentationUnitsValid(data.presentation) && validJson(data) && typeof data.title === 'string' && typeof data.text === 'string'); export function builtinComponents(): readonly ComponentDefinition[] { return ['text', 'image', 'script', 'plain-text', 'plain-image', 'icon', 'frame'].map(id => defineComponent({ id, dataVersion: 1, structure: 'node', validate: textData, features: [movable(), resizable(), ...(id === 'frame' ? [] : [rotatable()]), editable()], resourceFields: ['image', 'plain-image'].includes(id) ? ['assetId','asset'] : [], geometry:rectangleGeometry,copy: { references: id === 'frame' ? [{path:['ports','*','nodeId'],target:'element',external:'reject'}] : [], omit: [['taskId'], ['runId'], ['progress'], ['runStatus']] }, - })).concat([defineComponent({ id: 'connection', dataVersion: 1, structure: 'connection', validate: data => validJson(data), features: [editable(), connectionEditable()], resourceFields: [],geometry:{kind:'connection',version:1,strokeWidth:({element})=>{const appearance=element.data.appearance;return (appearance&&typeof appearance==='object'&&!Array.isArray(appearance)?appearance.width:undefined) as number||1.5;},segments:context=>{if(context.displayPath===undefined||!connectionPathValid(context.displayPath))throw fault('unsupported_connection_geometry');return bezierChainSegments(context.source,context.target,context.displayPath as unknown as BezierChain|null);}}, copy: { references: [], omit: [] } })]); + })).concat([defineComponent({ id: 'connection', dataVersion: 1, structure: 'connection', validate: builtinDataValidator((data:Data) => validJson(data)), features: [editable(), connectionEditable()], resourceFields: [],geometry:{kind:'connection',version:1,strokeWidth:({element})=>{const appearance=element.data.appearance;return (appearance&&typeof appearance==='object'&&!Array.isArray(appearance)?appearance.width:undefined) as number||1.5;},segments:context=>{if(context.displayPath===undefined||!connectionPathValid(context.displayPath))throw fault('unsupported_connection_geometry');return bezierChainSegments(context.source,context.target,context.displayPath as unknown as BezierChain|null);}}, copy: { references: [], omit: [] } })]); } export function connectionPathValid(path: Json): boolean { diff --git a/tests/headless/element-validation.test.mjs b/tests/headless/element-validation.test.mjs new file mode 100644 index 0000000..d2618bc --- /dev/null +++ b/tests/headless/element-validation.test.mjs @@ -0,0 +1,34 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createDocument,validateElementRecord,validateDocument,validateDocumentSteps,createRegistry,defineComponent,builtinComponents,createEditor} from '../../src/lib/canvas-core/index.ts'; +const node=(id='a')=>({id,incarnation:1,ownerCanvasId:'root',componentId:'probe',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{text:'hello'}}); +function setup(){let allowed=true,calls=0;const component=defineComponent({id:'probe',dataVersion:1,structure:'node',features:[],validate:data=>{calls++;return allowed&&typeof data.text==='string';}}),registry=createRegistry([...builtinComponents(),component]),document=createDocument('p',[{id:'root',parent:null}],[node()]);return{registry,document,allow:value=>{allowed=value;},calls:()=>calls};} +test('owned record proofs skip repeated static traversal while schema and tombstones remain live checks',()=>{ + const f=setup(),record=f.document.elements.get('a');validateElementRecord(record,f.registry);const count=f.calls(),values=Object.values;try{Object.values=value=>{if(value===record||value===record.data)throw Error('owned static data enumerated again');return values(value);};validateElementRecord(record,f.registry);}finally{Object.values=values;}assert.equal(f.calls(),count+1);f.allow(false);assert.throws(()=>validateElementRecord(record,f.registry),/invalid_component_data/);f.allow(true);assert.throws(()=>validateElementRecord(record,f.registry,new Map([['a',1]])),/stale_incarnation/); +}); +test('static record proofs never certify owner membership or connection endpoints',()=>{ + const f=setup(),b={...node('b'),geometry:{...node().geometry,x:200}},edge={id:'edge',incarnation:1,ownerCanvasId:'root',componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'a'},target:{elementId:'b'},path:null},document=createDocument('p',[{id:'root',parent:null}],[node(),b,edge]);validateDocument(document,f.registry);assert.throws(()=>validateDocument({...document,canvases:new Map([['other',{id:'other',parent:null}]])},f.registry),/invalid_element/);assert.throws(()=>validateDocument({...document,elements:new Map([...document.elements].filter(([id])=>id!=='b'))},f.registry),/invalid_endpoint/); +}); +test('external shallow-frozen records and getters are revalidated and never frozen on behalf of callers',()=>{ + const f=setup(),raw=Object.freeze(node());validateElementRecord(raw,f.registry);raw.geometry.width=0;assert.throws(()=>validateElementRecord(raw,f.registry),/invalid_geometry/);assert.equal(Object.isFrozen(raw.geometry),false);let geometry={...node().geometry};const getter=Object.freeze({...node(),get geometry(){return geometry;}});validateElementRecord(getter,f.registry);geometry={...geometry,width:-1};assert.throws(()=>validateElementRecord(getter,f.registry),/invalid_geometry/); +}); +test('editor creations detach caller data; topology-only validation cannot bypass later schema checks',()=>{ + const f=setup();let id=0;const editor=createEditor({document:f.document,registry:f.registry,createId:()=>`op-${++id}`}),incoming=node('new');assert.equal(editor.dispatch({type:'element.create',element:incoming}).status,'applied');incoming.geometry.width=-1;incoming.data.text=undefined;const record=editor.getSnapshot().document.elements.get('new');assert.equal(record.geometry.width,100);assert.equal(record.data.text,'hello');const fresh=createDocument('p',[{id:'root',parent:null}],[node('topology')]).elements.get('topology');f.allow(false);const calls=f.calls();validateElementRecord(fresh,f.registry,new Map(),false);assert.equal(f.calls(),calls);assert.throws(()=>validateElementRecord(fresh,f.registry),/invalid_component_data/);editor.dispose(); +}); + +test('synchronous and cooperative validation preserve check order and the same rejection results',()=>{ + const f=setup(),base=f.document,edge={id:'edge',incarnation:1,ownerCanvasId:'root',componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'a'},target:{elementId:'missing'},path:null},cases=[base,{...base,canvases:new Map()}, {...base,tombstones:new Map([['a',0]])},{...base,tombstones:new Map([['a',1]])},{...base,canvases:new Map([['root',{id:'root',parent:null}],['other',{id:'other',parent:null}]])},createDocument('p',[{id:'root',parent:null}],[node(),edge]),createDocument('p',[{id:'root',parent:null}],[{...node(),geometry:{...node().geometry,width:0}}])]; + const outcome=callback=>{try{callback();return'valid';}catch(error){return{code:error.code,targets:error.targets};}}; + for(const document of cases)assert.deepEqual(outcome(()=>validateDocument(document,f.registry)),outcome(()=>{for(const _ of validateDocumentSteps(document,f.registry)){};})); + const iterator=validateDocumentSteps(base,f.registry);assert.deepEqual(iterator.next(),{done:false,value:undefined});assert.deepEqual(iterator.next(),{done:false,value:undefined});assert.equal(iterator.next().done,true); +}); + +test('only framework data-only validators can reuse a data proof; wrapped same-ID custom schemas stay live with their receiver',()=>{ + const registry=createRegistry(builtinComponents()),input={...node(),componentId:'text',data:{title:'A',text:''}},record=createDocument('p',[{id:'root',parent:null}],[input]).elements.get('a');validateElementRecord(record,registry);const values=Object.values;try{Object.values=value=>{if(value===record.data)throw Error('framework data schema was repeated');return values(value);};validateElementRecord(record,registry);}finally{Object.values=values;} + let allowed=true,calls=0;const custom=defineComponent({id:'text',dataVersion:1,structure:'node',features:[],validate:function(data){calls++;assert.equal(this.id,'text');return allowed&&typeof data.title==='string';}}),other=createRegistry([custom]);validateElementRecord(record,other);allowed=false;assert.throws(()=>validateElementRecord(record,other),/invalid_component_data/);assert.equal(calls,2);assert.throws(()=>validateElementRecord(record,registry,new Map([['a',1]])),/stale_incarnation/); + const malformed=createDocument('p',[{id:'root',parent:null}],[{...input,data:{title:7,text:''}}]).elements.get('a');assert.throws(()=>validateElementRecord(malformed,registry),/invalid_component_data/);assert.throws(()=>validateElementRecord(malformed,registry),/invalid_component_data/); +}); + +test('core-owned immutable element data survives document re-enveloping without aliasing caller-owned records',()=>{ + const f=setup(),record=f.document.elements.get('a');validateElementRecord(record,f.registry);const next=createDocument('p',[...f.document.canvases.values()],[record]);assert.strictEqual(next.elements.get('a'),record);const external=node('external'),copy=createDocument('p',[{id:'root',parent:null}],[external]);assert.notStrictEqual(copy.elements.get('external'),external);external.data.text='later';assert.equal(copy.elements.get('external').data.text,'hello');const lookalike=Object.freeze({...record});assert.notStrictEqual(createDocument('p',[{id:'root',parent:null}],[lookalike]).elements.get('a'),lookalike);f.allow(false);assert.throws(()=>validateDocument(next,f.registry),/invalid_component_data/); +});