From fb997f23a8249ff4ea833ff8bc098431721705ff Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Tue, 10 Nov 2026 01:30:47 +0800 Subject: [PATCH] Stage transaction creations without copying full tables per command --- headless/README.md | 11 ++++++++++ src/lib/canvas-core/creation-draft.ts | 30 ++++++++++++++++++++++++++ src/lib/canvas-core/editor.ts | 4 +++- tests/headless/creation-draft.test.mjs | 22 +++++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 src/lib/canvas-core/creation-draft.ts create mode 100644 tests/headless/creation-draft.test.mjs diff --git a/headless/README.md b/headless/README.md index f46ac73..c8e9380 100644 --- a/headless/README.md +++ b/headless/README.md @@ -797,3 +797,14 @@ permits reuse. `stats().captured` counts actual dependency captures. Projection retention also keeps the existing retained row when the incoming node is the same object in the same lifetime. Both caches remain derived view-owned state; the full membership scan and paint-grid reconciliation remain present. + +Editor create commands now stage transaction-local append views instead of +copying all document tables for every element/canvas. Each view has fixed +membership, so a policy or caller retaining an earlier `getDocument()` prefix +cannot observe later additions. Ordered policy checks, ID/tombstone admission, +record cloning and final document validation remain unchanged. At most 64 new +entries are copied in a staging window before folding to a new read-only base. +The transaction materializes an owned document once before publication; a +non-create command can materialize earlier through the normal change path. +These views are unpublished preparation, not a second document store or durable +queue. Rollback drops them without publishing history, ledger or runtime state. diff --git a/src/lib/canvas-core/creation-draft.ts b/src/lib/canvas-core/creation-draft.ts new file mode 100644 index 0000000..bd8ac4c --- /dev/null +++ b/src/lib/canvas-core/creation-draft.ts @@ -0,0 +1,30 @@ +import type {CanvasRecord,Change,Document,ElementRecord,Id} from './types.ts'; +import {applyChanges} from './document.ts'; +import {clone,fault,freeze,readMap,validJson} from './values.ts'; + +/** Transaction-local append views. Each retained view has fixed membership; + * at most 64 additions are copied before folding into a new read-only base. */ +const overlays=new WeakMap;added:ReadonlyMap}>(),drafts=new WeakSet(); +function append(input:ReadonlyMap,id:Id,value:T):ReadonlyMap{ + if(input.has(id))throw fault('duplicate_identity',[id]); + let previous=overlays.get(input) as {base:ReadonlyMap;added:ReadonlyMap}|undefined; + if(previous&&previous.added.size>=64)previous=undefined; + const base=previous?.base??(overlays.has(input)?readMap(input):input),added=new Map(previous?.added); + added.set(id,value); + const entries=function*():Generator<[Id,T],undefined,unknown>{yield* base;yield* added;return undefined;}; + const view:ReadonlyMap=Object.freeze({size:base.size+added.size,get:(key:Id)=>added.has(key)?added.get(key):base.get(key),has:(key:Id)=>added.has(key)||base.has(key),entries, + *keys():Generator{for(const [key]of entries())yield key;return undefined;},*values():Generator{for(const [,item]of entries())yield item;return undefined;},[Symbol.iterator]:entries, + forEach(callback:(value:T,key:Id,map:ReadonlyMap)=>void,thisArg?:unknown){for(const[key,item]of entries())callback.call(thisArg,item,key,view);}, + }); + overlays.set(view,{base,added});return view; +} +/** Internal editor path: input tables already have fixed membership. */ +export function stageCreation(document:Document,change:Extract):Document{ + 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 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. */ +export const materializeCreations=(document:Document)=>drafts.has(document)?applyChanges(document,[]):document; diff --git a/src/lib/canvas-core/editor.ts b/src/lib/canvas-core/editor.ts index 685705e..223a856 100644 --- a/src/lib/canvas-core/editor.ts +++ b/src/lib/canvas-core/editor.ts @@ -1,3 +1,4 @@ +import {stageCreation,materializeCreations} from './creation-draft.ts'; import type { Applied, Availability, Capability, Change, Command, Commit, CoreSnapshot, Document, ElementRecord, Geometry, HistoryEntry, HistoryState, Id, OperationIntent, Origin, Registry, Result, Snapshot } from './types.ts'; import { applyChanges, createDocument, invert, targetElement, validateDocument } from './document.ts'; import { clone, equal, fault, freeze, readMap, reject, validJson } from './values.ts'; @@ -117,7 +118,7 @@ export function createEditor(options: EditorOptions):applyChanges(document, changes); return { document: next, changes: freeze(clone(changes)) }; } @@ -221,6 +222,7 @@ export function createEditor(options: EditorOptions {}); throw fault('async_transaction'); } if (failed) throw failed; + document=materializeCreations(document); validateDocument(document, options.registry); if (!changes.length || documentsEqual(document, state.document)) return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) }); let history = state.history, historyEntryId: Id | undefined; diff --git a/tests/headless/creation-draft.test.mjs b/tests/headless/creation-draft.test.mjs new file mode 100644 index 0000000..8758b36 --- /dev/null +++ b/tests/headless/creation-draft.test.mjs @@ -0,0 +1,22 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createDocument,createEditor,createRegistry,builtinComponents,isFactoryDocument} from '../../src/lib/canvas-core/index.ts'; +import {stageCreation,materializeCreations} from '../../src/lib/canvas-core/creation-draft.ts'; +const node=id=>({id,incarnation:1,ownerCanvasId:'root',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:id,text:''}}),document=()=>createDocument('p',[{id:'root',parent:null}],Array.from({length:1000},(_,i)=>node('old-'+i))),registry=createRegistry(builtinComponents()); +test('creation drafts avoid reenumerating the base per item, preserve held prefixes and fold bounded append windows in order',()=>{ + const base=document();let reads=0;const elements={...base.elements,[Symbol.iterator](){reads++;return base.elements[Symbol.iterator]();}},source={...base,elements};let draft=source;const held=[]; + for(let i=0;i<130;i++){draft=stageCreation(draft,{kind:'element',id:'new-'+i,before:null,after:node('new-'+i)});if([0,63,64].includes(i))held.push(draft);if(i===31)assert.equal(reads,0);} + assert.ok(reads<=1);assert.deepEqual(held.map(d=>d.elements.size),[1001,1064,1065]);assert.equal(held[0].elements.has('new-1'),false);assert.equal(draft.elements.get('old-10').id,'old-10');assert.equal(draft.elements.get('new-129').id,'new-129');assert.equal(draft.elements.set,undefined);const ids=[...draft.elements.keys()];assert.deepEqual(ids.slice(-130),Array.from({length:130},(_,i)=>'new-'+i));assert.equal([...draft.elements.values()].length,1130);const visits=[];draft.elements.forEach(function(value,key,map){assert.equal(this.tag,true);assert.strictEqual(map,draft.elements);visits.push(key);},{tag:true});assert.deepEqual(visits,ids);const materialized=materializeCreations(draft);assert.ok(isFactoryDocument(materialized));assert.equal(materialized.elements.size,1130);assert.equal(base.elements.size,1000);assert.throws(()=>stageCreation(draft,{kind:'element',id:'new-0',before:null,after:node('new-0')}),/duplicate_identity/); +}); +test('ordered command policies see stable prior prefixes; one owned publication has one history unit and round-trips undo/redo',()=>{ + const held=[],seen=[];let n=0;const editor=createEditor({document:document(),registry,createId:()=>`id-${++n}`,policy(command,doc){held.push(doc);seen.push([command.type,doc.elements.size,doc.canvases.size]);return{enabled:true};}});let publications=0;editor.subscribe(()=>publications++);const before=editor.getSnapshot();const result=editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('a')});const prefix=tx.getDocument();tx.dispatch({type:'element.create',element:node('b')});assert.equal(prefix.elements.has('b'),false);tx.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'edited'}});tx.dispatch({type:'element.create',element:node('c')});});assert.equal(result.status,'applied');assert.deepEqual(seen.map(row=>row[1]),[1000,1001,1002,1002]);assert.deepEqual(held.map(d=>d.elements.size),[1000,1001,1002,1002]);assert.equal(publications,1);const after=editor.getSnapshot();assert.ok(isFactoryDocument(after.document));assert.equal(after.history.undo.length,1);assert.equal(after.operations.size,1);assert.equal(after.document.elements.get('a').data.title,'edited');assert.equal(before.document.elements.size,1000);assert.equal(editor.history.undo().status,'applied');assert.equal(editor.getSnapshot().document.elements.size,1000);assert.equal(editor.history.redo().status,'applied');assert.equal(editor.getSnapshot().document.elements.get('a').data.title,'edited'); +}); +test('policy, schema, duplicate and runtime failures never publish staged creations',()=>{ + for(const failure of ['policy','schema','duplicate','runtime']){let n=0;const editor=createEditor({document:document(),registry,createId:()=>`id-${++n}`,policy:(command)=>failure==='policy'&&command.type==='element.create'&&command.element.id==='b'?{enabled:false,code:'denied',targets:['b']}:{enabled:true},reduceRuntime(){if(failure==='runtime')throw Error('runtime_failed');}}),before=editor.getSnapshot();const result=editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('a')});tx.dispatch({type:'element.create',element:failure==='duplicate'?node('a'):failure==='schema'?{...node('b'),geometry:{...node('b').geometry,width:-1}}:node('b')});});assert.equal(result.status,'rejected',failure);assert.strictEqual(editor.getSnapshot(),before);assert.equal(editor.getSnapshot().history.undo.length,0);assert.equal(editor.getSnapshot().operations.size,0);} +}); +test('canvas and element creation drafts compose a tree while held canvas prefixes and base snapshots remain fixed',()=>{ + const editor=createEditor({document:document(),registry});let first,second;const result=editor.transaction(tx=>{tx.dispatch({type:'element.create',element:{...node('entry'),componentId:'frame'}});first=tx.getDocument();tx.dispatch({type:'canvas.create',canvas:{id:'child',parent:{canvasId:'root',entryElementId:'entry'}}});second=tx.getDocument();tx.dispatch({type:'element.create',element:{...node('nested'),ownerCanvasId:'child'}});assert.equal(first.canvases.has('child'),false);assert.equal(second.elements.has('nested'),false);assert.equal(tx.getDocument().canvases.get('child').parent.entryElementId,'entry');});assert.equal(result.status,'applied');assert.ok(isFactoryDocument(editor.getSnapshot().document));assert.equal(editor.getSnapshot().document.elements.get('nested').ownerCanvasId,'child');assert.equal(editor.history.undo().status,'applied');assert.equal(editor.getSnapshot().document.canvases.has('child'),false);assert.equal(editor.getSnapshot().document.elements.has('entry'),false);assert.equal(editor.history.redo().status,'applied');assert.equal(editor.getSnapshot().document.canvases.get('child').parent.entryElementId,'entry'); +}); +test('editor creation staging does not enumerate an existing thousand-element table for each command',()=>{ + const editor=createEditor({document:document(),registry});const iterator=Map.prototype[Symbol.iterator];let admissions=0;const result=editor.transaction(tx=>{try{Map.prototype[Symbol.iterator]=function(){if(this.size>=1000)throw Error('full table copied during creation staging');return iterator.call(this);};for(let i=0;i<32;i++){assert.equal(tx.dispatch({type:'element.create',element:node('batch-'+i)}).enabled,true);admissions++;}}finally{Map.prototype[Symbol.iterator]=iterator;}});assert.equal(result.status,'applied');assert.equal(admissions,32);assert.equal(editor.getSnapshot().document.elements.size,1032);assert.equal(editor.getSnapshot().history.undo.length,1); +});