Stage transaction creations without copying full tables per command

This commit is contained in:
2026-11-10 01:30:47 +08:00
parent 198c7f9863
commit fb997f23a8
4 changed files with 66 additions and 1 deletions
+11
View File
@@ -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.
+30
View File
@@ -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<object,{base:ReadonlyMap<Id,unknown>;added:ReadonlyMap<Id,unknown>}>(),drafts=new WeakSet<Document>();
function append<T>(input:ReadonlyMap<Id,T>,id:Id,value:T):ReadonlyMap<Id,T>{
if(input.has(id))throw fault('duplicate_identity',[id]);
let previous=overlays.get(input) as {base:ReadonlyMap<Id,T>;added:ReadonlyMap<Id,T>}|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<Id,T>=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<Id,undefined,unknown>{for(const [key]of entries())yield key;return undefined;},*values():Generator<T,undefined,unknown>{for(const [,item]of entries())yield item;return undefined;},[Symbol.iterator]:entries,
forEach(callback:(value:T,key:Id,map:ReadonlyMap<Id,T>)=>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<Change,{kind:'canvas'|'element'}>):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;
+3 -1
View File
@@ -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<Runtime = undefined>(options: EditorOptions<Runtime
changes = output;
}
}
const next = applyChanges(document, changes);
const next = command.type==='element.create'||command.type==='canvas.create'?stageCreation(document,changes[0] as Extract<Change,{kind:'canvas'|'element'}>):applyChanges(document, changes);
return { document: next, changes: freeze(clone(changes)) };
}
@@ -221,6 +222,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
active = false;
if (isThenable(result)) { Promise.resolve(result).catch(() => {}); 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;
+22
View File
@@ -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);
});