Share core-owned immutable elements and validation facts

This commit is contained in:
2026-11-10 16:07:02 +08:00
parent c8e5f6ac49
commit a6092e61cd
7 changed files with 128 additions and 35 deletions
+17
View File
@@ -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.
+2 -1
View File
@@ -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:Extract<Change,{kind:'can
if(change.before!==null||!change.after)throw fault('invalid_creation_draft');
if(!validJson(change.after))throw fault(change.kind==='canvas'?'invalid_canvas':'invalid_element',[change.id]);
if(change.after.id!==change.id)throw fault('identity_mismatch',[change.id]);
const value=freeze(clone(change.after));
const value=change.kind==='element'?detachElementData(change.after as ElementRecord):freeze(clone(change.after));
const next=Object.freeze({...document,...(change.kind==='canvas'?{canvases:append(document.canvases,change.id,value as CanvasRecord)}:{elements:append(document.elements,change.id,value as ElementRecord)})});drafts.add(next);return next;
}
/** Only published documents use the core's owned table envelopes. */
+45 -32
View File
@@ -1,3 +1,4 @@
import {detachElementData,hasOwnedElementData,componentDataValid} from './element-data.ts';
import type { CanvasRecord, Change, Document, ElementRecord, Geometry, Id, Registry, Target } from './types.ts';
import { clone, equal, fault, freeze, readMap, validJson } from './values.ts';
@@ -8,10 +9,10 @@ function documentEnvelope(document:Document):Document{const value=Object.freeze(
export const isFactoryDocument=(document:Document)=>factoryDocuments.has(document);
export function createDocument(projectId: Id, canvases: readonly CanvasRecord[], elements: readonly ElementRecord[] = [], tombstones: ReadonlyMap<Id, number> = 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<object>();
/** 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<Id,number> = 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&&registry.resolve(element)==='known'&&(!definition||definition.structure!==element.structure||!definition.validate(element.data)))throw fault('invalid_component_data',[element.id]);
if(componentData&&registry.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<void, void> {
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<Id>();
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<Id>):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<void,void>{
if(!document.canvases.size)throw fault('missing_root');
for(const[id,incarnation]of document.tombstones){yield;validateTombstone(id,incarnation);}
const entries=new Set<Id>();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<void,void>{yield* validateDocumentChecks(document,registry,false);}
export function* validateDocumentSteps(document:Document,registry:Registry):Generator<void,void>{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<Id>();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));
+2
View File
@@ -340,5 +340,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
});
}
function documentsEqual(a: Document, b: Document): boolean {
if(a===b)return true;
if(a.canvases.size!==b.canvases.size||a.elements.size!==b.elements.size||a.tombstones.size!==b.tombstones.size)return false;
return equal([...a.canvases], [...b.canvases]) && equal([...a.elements], [...b.elements]) && equal([...a.tombstones], [...b.tombstones]);
}
+25
View File
@@ -0,0 +1,25 @@
import type {ElementRecord,ComponentDefinition} from './types.ts';
import {clone,freeze} from './values.ts';
// Provenance comes from detaching JSON data, never from an external freeze or
// document envelope. This does not assert record validity or registry support.
const owned=new WeakSet<object>();
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<Function>(),dataProofs=new WeakMap<object,ComponentDefinition['validate']>();
export function builtinDataValidator<T extends ComponentDefinition['validate']>(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;
}
+3 -2
View File
@@ -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 {
@@ -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/);
});