diff --git a/headless/README.md b/headless/README.md index cd0da58..f6b4614 100644 --- a/headless/README.md +++ b/headless/README.md @@ -897,3 +897,14 @@ The application's initial loading fit targets the current canvas's direct node members, including partition entries. Expanded descendant cards remain rendered and pannable but cannot move their parent entry outside the initial viewport. The manual fit control keeps its existing whole-scene behavior. + +Pending-operation reconciliation replays ordered change batches in one private +core draft. Each change still sees the results of earlier changes: field presence, +incarnation, before-value conditions and current owner scope are checked in that +order. Already-satisfied changes are skipped, but field scope is checked first. +Only the final document envelope escapes; a late conflict discards the draft and +leaves the input ACK and ledger unchanged. The runtime retains sequence sorting, +operation/history-effect admission and final registry/topology validation. This +removes a full table copy for every pending change without adding intermediate +Roots, history units or network writes. `applyChanges` uses the same conditional +change application path and retains its existing strict/default behavior. diff --git a/src/lib/canvas-core/document.ts b/src/lib/canvas-core/document.ts index 11552c0..38e0973 100644 --- a/src/lib/canvas-core/document.ts +++ b/src/lib/canvas-core/document.ts @@ -97,9 +97,22 @@ export function validateDocument(document:Document,registry:Registry):void{ if(roots!==1)throw fault('invalid_roots'); for(const element of document.elements.values())validateElementInDocument(document,element,registry,true); } +export type ReplayChangeBatch = {readonly changes:readonly Change[];readonly canvasIds?:readonly Id[]}; export function applyChanges(document: Document, changes: readonly Change[], conditional = true): Document { + return applyChangeBatches(document,[{changes}],conditional,false); +} +/** Ordered conditional replay against one private draft; no intermediate document escapes. */ +export function replayChanges(document:Document,batches:Iterable):Document { + return applyChangeBatches(document,batches,true,true); +} +function applyChangeBatches(document:Document,batches:Iterable,conditional:boolean,replay:boolean):Document { const elements = new Map(document.elements), canvases = new Map(document.canvases), tombstones = new Map(document.tombstones); - for (const change of changes) { + for (const batch of batches) for (const change of batch.changes) { + if(replay){ + // Check scope even when the desired field value is already present. + if(change.kind==='field'){const owner=elements.get(change.target.id)?.ownerCanvasId;if(owner&&batch.canvasIds?.length&&!batch.canvasIds.includes(owner))throw fault('operation_context_changed',[change.target.id]);} + if(changeSatisfied(elements,canvases,change))continue; + } if (change.kind === 'field') { if (!validJson(change.after)) throw fault('invalid_change'); if (!change.field.startsWith('data.') && !['geometry', 'ownerCanvasId', 'source', 'target', 'path'].includes(change.field)) throw fault('invalid_field'); @@ -136,6 +149,14 @@ export function applyChanges(document: Document, changes: readonly Change[], con } return documentEnvelope({ projectId: document.projectId, elements: readMap(elements), canvases: readMap(canvases), tombstones: readMap(tombstones) }); } +function changeSatisfied(elements:ReadonlyMap,canvases:ReadonlyMap,change:Change):boolean { + if(change.kind==='canvas')return equal(canvases.get(change.id)??null,change.after); + if(change.kind==='element')return equal(elements.get(change.id)??null,change.after); + const element=elements.get(change.target.id); + if(!element||element.incarnation!==change.target.incarnation)return false; + if(change.field.startsWith('data.')){const key=change.field.slice(5);return Object.hasOwn(element.data,key)===(change.afterPresent??true)&&equal(element.data[key]??null,change.after);} + return equal((element as unknown as Record)[change.field],change.after); +} export function invert(changes: readonly Change[]): readonly Change[] { return changes.slice().reverse().map(change => { if(change.kind==='field'){const{beforePresent,afterPresent,...record}=change;return freeze({...record,before:change.after,after:change.before,...(afterPresent===undefined?{}:{beforePresent:afterPresent}),...(beforePresent===undefined?{}:{afterPresent:beforePresent})});} diff --git a/src/lib/canvas-runtime/reconcile.ts b/src/lib/canvas-runtime/reconcile.ts index cd69144..dfb7feb 100644 --- a/src/lib/canvas-runtime/reconcile.ts +++ b/src/lib/canvas-runtime/reconcile.ts @@ -1,7 +1,7 @@ import { withLocalLifecycles } from './elision.ts'; import { validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts'; -import type { Change, Document, Id, OperationIntent, Registry } from '../canvas-core/types.ts'; -import { applyChanges, validateDocument } from '../canvas-core/document.ts'; +import type { Document, Id, OperationIntent, Registry } from '../canvas-core/types.ts'; +import { replayChanges, validateDocument } from '../canvas-core/document.ts'; import { equal, fault } from '../canvas-core/values.ts'; import type { LedgerSnapshot } from './ledger.ts'; @@ -53,27 +53,14 @@ export function operationCanvases(intent: OperationIntent, document: Document, a /** Replay once, by logical sequence. A receipt can already contain a desired field value. */ export function replayPending(base: Document, ledger: LedgerSnapshot, registry: Registry): Document { - let document = withLocalLifecycles(base, ledger); - for (const record of [...ledger.operations.values()].sort((a, b) => a.intent.sequence - b.intent.sequence)) { - if (record.phase === 'resolved' || !['document.change','run.apply'].includes(record.intent.operationType)) continue; - if (record.intent.historyEffect) validateCreationSizeEffect(record.intent.historyEffect); - for (const change of record.intent.changes) { - if(change.kind==='field'){const owner=document.elements.get(change.target.id)?.ownerCanvasId;if(owner&&record.intent.canvasIds?.length&&!record.intent.canvasIds.includes(owner))throw fault('operation_context_changed',[change.target.id]);} - if (satisfied(document, change)) continue; - document = applyChanges(document, [change]); + function* pending(){ + for(const record of [...ledger.operations.values()].sort((a,b)=>a.intent.sequence-b.intent.sequence)){ + if(record.phase==='resolved'||!['document.change','run.apply'].includes(record.intent.operationType))continue; + if(record.intent.historyEffect)validateCreationSizeEffect(record.intent.historyEffect); + yield {changes:record.intent.changes,canvasIds:record.intent.canvasIds}; } } - validateDocument(document, registry); + const document=replayChanges(withLocalLifecycles(base,ledger),pending()); + validateDocument(document,registry); return document; } -function satisfied(document: Document, change: Change) { - if (change.kind === 'canvas') return equal(document.canvases.get(change.id) ?? null, change.after); - if (change.kind === 'element') return equal(document.elements.get(change.id) ?? null, change.after); - const element = document.elements.get(change.target.id); - if (!element || element.incarnation !== change.target.incarnation) return false; - if (change.field.startsWith('data.')) { - const key = change.field.slice(5); - return Object.hasOwn(element.data, key) === (change.afterPresent ?? true) && equal(element.data[key] ?? null, change.after); - } - return equal((element as unknown as Record)[change.field], change.after); -} diff --git a/tests/headless/reconcile-replay.test.mjs b/tests/headless/reconcile-replay.test.mjs new file mode 100644 index 0000000..db80e29 --- /dev/null +++ b/tests/headless/reconcile-replay.test.mjs @@ -0,0 +1,34 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createDocument,createRegistry,builtinComponents,applyChanges,replayChanges} from '../../src/lib/canvas-core/index.ts'; +import {createLedger} from '../../src/lib/canvas-runtime/ledger.ts'; +import {replayPending} from '../../src/lib/canvas-runtime/reconcile.ts'; +const registry=createRegistry(builtinComponents()); +const node=(id='a',ownerCanvasId='canvas',incarnation=1)=>({id,ownerCanvasId,incarnation,componentId:'text',dataVersion:1,structure:'node',data:{title:'original',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}}); +const base=()=>createDocument('project',[{id:'canvas',parent:null}],[node()]); +const title=(before,after)=>({kind:'field',target:{id:'a',incarnation:1},field:'data.title',before,after}); +function ledgerFor(groups){const ledger=createLedger();groups.forEach((changes,index)=>{const presence={};for(const change of changes)if(change.kind!=='canvas')presence[change.kind==='field'?change.target.id:change.id]=change.kind==='element'&&change.before===null?'absent':'exists';ledger.add({id:`op-${index}`,sequence:index+1,origin:'user',operationType:'document.change',operationSchemaVersion:1,canvasIds:['canvas'],dependencies:[],changes},presence);});return ledger.getSnapshot();} + +test('pending replay preserves sequential field baselines and skips a prefix already present in ACK',()=>{ + const original=base(),ledger=ledgerFor([[title('original','first'),title('first','second')],[title('second','third')]]); + for(const acknowledged of [original,applyChanges(original,[title('original','first')])]){const result=replayPending(acknowledged,ledger,registry);assert.equal(result.elements.get('a').data.title,'third');assert.equal(acknowledged.elements.get('a').data.title,acknowledged===original?'original':'first');} +}); +test('replay distinguishes missing and null fields while applying and skipping repeated deletes',()=>{ + const original=base(),insert={kind:'field',target:{id:'a',incarnation:1},field:'data.optional',before:null,after:null,beforePresent:false,afterPresent:true},remove={...insert,beforePresent:true,afterPresent:false}; + const added=replayPending(original,ledgerFor([[insert]]),registry);assert.ok(Object.hasOwn(added.elements.get('a').data,'optional'));assert.equal(added.elements.get('a').data.optional,null); + const removed=replayPending(original,ledgerFor([[insert],[remove],[remove]]),registry);assert.equal(Object.hasOwn(removed.elements.get('a').data,'optional'),false); +}); +test('a draft replays owner transitions before later scope checks and rejects stale identity even for equal values',()=>{ + const frame={...node('frame'),componentId:'frame'},canvases=[{id:'canvas',parent:null},{id:'child',parent:{canvasId:'canvas',entryElementId:'frame'}}],original=createDocument('project',canvases,[frame,node()]); + const move={kind:'field',target:{id:'a',incarnation:1},field:'ownerCanvasId',before:'canvas',after:'child'}; + const result=replayChanges(original,[{changes:[move],canvasIds:['canvas','child']},{changes:[title('original','moved')],canvasIds:['child']}]);assert.equal(result.elements.get('a').ownerCanvasId,'child');assert.equal(result.elements.get('a').data.title,'moved'); + const relocated=createDocument('project',canvases,[frame,node('a','child')]);assert.throws(()=>replayPending(relocated,ledgerFor([[title('original','original')]]),registry),/operation_context_changed/); + const restored=createDocument('project',[canvases[0]],[node('a','canvas',2)]);assert.throws(()=>replayPending(restored,ledgerFor([[title('original','original')]]),registry),/stale_incarnation/); +}); +test('late conflicts discard the entire private replay draft and preserve the ACK and ledger',()=>{ + const original=base(),ledger=ledgerFor([[title('original','first')],[title('wrong','last')]]),before=JSON.stringify([...ledger.operations]);assert.throws(()=>replayPending(original,ledger,registry),/field_conflict/);assert.equal(original.elements.get('a').data.title,'original');assert.equal(JSON.stringify([...ledger.operations]),before);assert.equal(replayPending(original,ledgerFor([[title('original','first')],[title('first','last')]]),registry).elements.get('a').data.title,'last'); +}); +test('large replay preserves create/delete/restore lifecycles and conditional failures across operation boundaries',()=>{ + const original=base(),created=Array.from({length:256},(_,i)=>node(`new-${i}`)),create=created.map(after=>({kind:'element',id:after.id,before:null,after})),deleted=created[50],restored={...deleted,incarnation:2,data:{...deleted.data,title:'restored'}},groups=[create,[{kind:'element',id:deleted.id,before:deleted,after:null}],[{kind:'element',id:deleted.id,before:null,after:restored}]]; + const result=replayPending(original,ledgerFor(groups),registry);let reference=original;for(const changes of groups)reference=applyChanges(reference,changes);assert.deepEqual([...result.elements],[...reference.elements]);assert.deepEqual([...result.tombstones],[...reference.tombstones]);assert.equal(result.tombstones.get(deleted.id),1);assert.equal(original.elements.size,1); +});