Replay pending changes in one private document draft
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<ReplayChangeBatch>):Document {
|
||||
return applyChangeBatches(document,batches,true,true);
|
||||
}
|
||||
function applyChangeBatches(document:Document,batches:Iterable<ReplayChangeBatch>,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<Id,ElementRecord>,canvases:ReadonlyMap<Id,CanvasRecord>,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<string,unknown>)[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})});}
|
||||
|
||||
@@ -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<string, unknown>)[change.field], change.after);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user