Replay confirmed history traversals without repeating applied content
This commit is contained in:
@@ -47,7 +47,7 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
|
||||
entries.set(id,freeze({...metadata,changes:[...prefix,...changes],operationIds:[...(prior?.operationIds??[]),operationId]}));if(!appending)undo.push(id);while(undo.length>limit)entries.delete(undo.shift()!);history=state(undo,[],entries);retired.delete(id);if(pending)touched.add(id);
|
||||
}
|
||||
function advance(intent?:OperationIntent){
|
||||
while(next<events.length&&events[next].record.intent.id!==intent?.id){const event=events[next],record=event.record;if(pendingIds.has(record.intent.id))throw fault('recovery_history_event_order',[record.intent.id]);if(record.resolution!=='applied'||record.intent.origin!=='user')throw fault('recovery_history_confirmed_traversal_replay_required',[record.intent.id]);addUser(record.intent,record.intent.changes,record.intent.id,false);next++;}
|
||||
while(next<events.length&&events[next].record.intent.id!==intent?.id){const event=events[next],record=event.record;if(pendingIds.has(record.intent.id))throw fault('recovery_history_event_order',[record.intent.id]);if(record.resolution!=='applied')throw fault('recovery_history_event_unresolved',[record.intent.id]);if(record.intent.origin==='user')addUser(record.intent,record.intent.changes,record.intent.id,false);else if(['undo','redo'].includes(record.intent.origin))addConfirmedTraversal(record.intent);else throw fault('recovery_history_event_origin_unsupported',[record.intent.id]);next++;}
|
||||
}
|
||||
function incarnationRemap(before:HistoryEntry,after:HistoryEntry){
|
||||
const maps=new Map<Id,Map<number,number>>();
|
||||
@@ -55,6 +55,15 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
|
||||
before.changes.forEach((a,index)=>{const b=after.changes[index];if(a.kind==='field'&&b?.kind==='field')pair(a.target,b.target);else if(a.kind==='element'&&b?.kind==='element'){pair(a.before,b.before);pair(a.after,b.after);}});
|
||||
return(change:Change):Change=>{const record=<T extends {id:Id;incarnation:number}>(value:T|null)=>value?{...value,incarnation:maps.get(value.id)?.get(value.incarnation)??value.incarnation}:null;if(change.kind==='field')return freeze({...change,target:record(change.target)!});if(change.kind==='element')return freeze({...change,before:record(change.before),after:record(change.after)});return change;};
|
||||
}
|
||||
function addConfirmedTraversal(intent:OperationIntent){
|
||||
const event=eventFor(intent),id=event.transition.entryId,undo=[...history.undo],redo=[...history.redo],source=intent.origin==='undo'?undo:redo,destination=intent.origin==='undo'?redo:undo;
|
||||
if(!id||source.at(-1)!==id)throw fault('recovery_history_traversal_order',id?[id]:[]);
|
||||
const entry=history.entries.get(id)!,before=event.before!.entries.get(id),after=event.after!.entries.get(id),changes=intent.origin==='undo'?invert(intent.changes):intent.changes;
|
||||
// An applied action is a fact. Its history may move only if the reviewed prefix still denotes that exact forward effect.
|
||||
if(!before||!after||!equal(entry.changes,before.changes)||!equal(changes,after.changes))throw fault('recovery_history_confirmed_traversal_conflict',[id]);
|
||||
const entries=new Map(history.entries),map=incarnationRemap(before,after);for(const[otherId,other]of entries)if(otherId!==id)entries.set(otherId,freeze({...other,changes:other.changes.map(map)}));
|
||||
entries.set(id,freeze({...entry,changes,operationIds:[...entry.operationIds,intent.id]}));source.pop();destination.push(id);history=state(undo,redo,entries);
|
||||
}
|
||||
return{
|
||||
project(document:Document,intent:OperationIntent,context:IntentContext,keep:boolean){
|
||||
if(!intent.changes.length)return{document,changes:[] as readonly Change[]};advance(intent);const event=eventFor(intent),id=event.transition.entryId;remap=undefined;
|
||||
|
||||
@@ -5,6 +5,25 @@ import { createFoundryRuns } from '../../src/lib/canvas-service/runs.ts';
|
||||
import { createFoundryAssets } from '../../src/lib/canvas-service/assets.ts';
|
||||
import { createFoundryPersistence } from '../../src/lib/canvas-service/foundry.ts';
|
||||
|
||||
async function confirmedTraversalCase(name,assert){
|
||||
for(const direction of ['undo','redo']){
|
||||
const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:id,text:''}}),original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'entry'}}],[node('a'),{...node('entry'),componentId:'frame'},node('c',child)]),database=`${name}-confirmed-${direction}`,storage=createBrowserStorage({name:database}),calls=[];let document=original,versions=new Map([[root,1],[child,1]]),reject=false,engine;
|
||||
const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:[],canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))});
|
||||
const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);if(reject)return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};for(const[id,version]of Object.entries(body.expected_versions))assert(version===versions.get(id),'confirmed traversal lost its version condition');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,versions.get(id)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false,Object.keys(body.expected_versions))}};};
|
||||
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
|
||||
const edit=async(id,text)=>{const result=engine.editor.dispatch({type:'element.data',target:{id,incarnation:document.elements.get(id).incarnation},patch:{text}});assert(result.status==='applied','fixture edit failed');await engine.settle();};
|
||||
try{
|
||||
engine=await openProjectEngine(options);await edit('a','edited a');
|
||||
if(direction==='undo'){engine.editor.dispatch({type:'element.remove',target:{id:'a',incarnation:1}});await engine.settle();await edit('c','edited c');}
|
||||
else{engine.editor.dispatch({type:'element.create',element:node('new',child)});await engine.settle();await edit('new','edited new');for(let i=0;i<3;i++){engine.editor.history.undo();await engine.settle();}}
|
||||
reject=true;const first=engine.editor.history[direction]();await engine.settle();const old=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.operationIds.includes(first.operationIds[0]));reject=false;const confirmed=engine.editor.history[direction]();await engine.settle();const applied=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.operationIds.includes(confirmed.operationIds[0]));await engine.close();engine=await openProjectEngine(options);
|
||||
const session=await engine.prepareRecovery();let denied=false;try{session.plan(new Map([[first.operationIds[0],'discard-local']]));}catch(error){denied=error.code==='recovery_history_traversal_order';}assert(denied,'confirmed traversal silently skipped a discarded predecessor');const plan=session.plan(new Map([[first.operationIds[0],'keep-local']]));await session.resolve(plan);await engine.settle();await engine.close();engine=await openProjectEngine(options);assert(engine.getSnapshot().runtime.ledger.requests.get(old.id).body===old.body&&engine.getSnapshot().runtime.ledger.requests.get(applied.id).body===applied.body,'history reconstruction rewrote original request facts');assert(calls.filter(call=>call.method==='POST'&&call.requestId===applied.id).length===1,'confirmed traversal was submitted again');
|
||||
if(direction==='undo'){assert(document.elements.get('a').incarnation===2&&document.elements.get('c').data.text==='','confirmed undo restoration was lost');assert(engine.editor.history.undo().status==='applied','older remapped history was stale');await engine.settle();assert(document.elements.get('a').data.text==='','older history inverse used the wrong lifecycle');assert(engine.editor.history.redo().status==='applied','older history redo failed');await engine.settle();assert(calls.filter(call=>call.method==='POST').length===8,'confirmed undo recovery duplicated a write');}
|
||||
else{assert(document.elements.get('new').incarnation===2,'confirmed redo incarnation was lost');assert(engine.editor.history.redo().status==='applied','remaining redo entry was not remapped');await engine.settle();for(let i=0;i<3;i++){assert(engine.editor.history.undo().status==='applied','post-recovery undo failed');await engine.settle();}for(let i=0;i<3;i++){assert(engine.editor.history.redo().status==='applied','post-recovery redo failed');await engine.settle();}assert(document.elements.get('new').incarnation===3&&document.elements.get('new').data.text==='edited new','later traversal reused an old lifecycle');assert(calls.filter(call=>call.method==='POST').length===16,'confirmed redo recovery duplicated a write');}
|
||||
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
|
||||
}
|
||||
}
|
||||
|
||||
async function userJournalRecoveryCase(name,assert){
|
||||
for(const keep of [true,false]){
|
||||
const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:id,text:''}}),original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'entry'}}],[node('a'),{...node('entry'),componentId:'frame'},node('c',child)]),database=`${name}-user-journal-${keep}`,storage=createBrowserStorage({name:database}),calls=[];let document=original,versions=new Map([[root,1],[child,1]]),reject=true,engine;
|
||||
@@ -388,6 +407,8 @@ export async function run() {
|
||||
results.push('native history events recover a rejected undo after a later confirmed branch cleared redo; IndexedDB reopening retains the branch identity and sends its content once');
|
||||
await userJournalRecoveryCase(name,assert);
|
||||
results.push('pure user group recovery preserves a later acknowledged contribution across keep/discard, IndexedDB reopening and atomic undo/redo without retransmission');
|
||||
await confirmedTraversalCase(name,assert);
|
||||
results.push('later confirmed undo/redo restorations replay history only, preserve request bytes through reopening and remap older and remaining history lifecycles');
|
||||
return results;
|
||||
} finally { await leader?.release(); other?.close(); storage.close(); indexedDB.deleteDatabase(name); }
|
||||
}
|
||||
|
||||
@@ -366,3 +366,27 @@ test('discarding a group suffix removes only the acknowledged field contribution
|
||||
test('a historical geometry prefix cannot silently use coordinates from an externally changed owner',async()=>{
|
||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.geometry',target:target('a'),patch:{width:200}},{groupId:'geometry'});await engine.settle();f.reject=true;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}},{groupId:'geometry'});await engine.settle();f.remote([{kind:'field',target:target('a'),field:'ownerCanvasId',before:'root',after:'child'}]);const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(choices(engine)),/recovery_history_prefix_conflict/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close();
|
||||
});
|
||||
|
||||
|
||||
function proveFixtureReceipt(f){f.port.verifyApplied=(request,receipt)=>{assert.equal(receipt.id,request.id);const intent=JSON.parse(request.body).intent,versions=new Map(request.canvasIds.map(id=>[id,request.expectedVersions[id]+1]));return{versions,assertCanonical(proposal){for(const[id,version]of versions)assert.ok(proposal.versions.get(id)>=version);for(const change of intent.changes){if(change.kind==='element'&&change.after)assert.deepEqual(proposal.document.elements.get(change.id),change.after);else if(change.kind==='field'){const record=proposal.document.elements.get(change.target.id),field=change.field.startsWith('data.')?change.field.slice(5):undefined;assert.deepEqual(field===undefined?record[change.field]:record.data[field]??null,change.after);}}}};};}
|
||||
|
||||
test('a later confirmed undo or redo changes only history during recovery and cannot skip a discarded earlier traversal',async()=>{
|
||||
for(const direction of ['undo','redo']){
|
||||
const f=fixture();proveFixtureReceipt(f);const engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited a'}});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'edited c'}});await engine.settle();
|
||||
if(direction==='redo'){engine.editor.history.undo();await engine.settle();engine.editor.history.undo();await engine.settle();}
|
||||
f.reject=true;const first=engine.editor.history[direction]();await engine.settle();f.reject=false;const confirmed=engine.editor.history[direction]();await engine.settle();const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(new Map([[first.operationIds[0],'discard-local']])),/recovery_history_traversal_order/);assert.equal(engine.getSnapshot(),before);
|
||||
const plan=session.plan(new Map([[first.operationIds[0],'keep-local']]));await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,direction==='undo'?'a':'edited a');assert.equal(f.server().elements.get('c').data.title,direction==='undo'?'c':'edited c');assert.equal(engine.getSnapshot().history[direction==='undo'?'redo':'undo'].length,2);const targetId=direction==='undo'?'a':'c';assert.equal(f.calls.filter(call=>JSON.parse(call.body).intent.id===confirmed.operationIds[0]).length,1);assert.equal(engine.getSnapshot().runtime.ledger.operations.get(confirmed.operationIds[0]).resolution,'applied');
|
||||
assert.ok([...engine.getSnapshot().history.entries.values()].some(entry=>entry.operationIds.includes(confirmed.operationIds[0])));for(let i=0;i<2;i++){assert.equal(engine.editor.history[direction==='undo'?'redo':'undo']().status,'applied');await engine.settle();}assert.equal(f.server().elements.get(targetId).data.title,direction==='undo'?'edited a':'c');await engine.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('a later confirmed undo restoration remaps older history without changing old operation incarnations',async()=>{
|
||||
const f=fixture();proveFixtureReceipt(f);const engine=await f.open();f.reject=false;const old=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();engine.editor.dispatch({type:'element.remove',target:target('a')});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'edited c'}});await engine.settle();f.reject=true;const pendingUndo=engine.editor.history.undo();await engine.settle();f.reject=false;const confirmed=engine.editor.history.undo();await engine.settle();assert.equal(f.server().elements.get('a').incarnation,2);
|
||||
const session=await engine.prepareRecovery(),plan=session.plan(new Map([[pendingUndo.operationIds[0],'keep-local']]));await session.resolve(plan);await engine.settle();assert.equal(engine.getSnapshot().history.undo.length,1);assert.equal(engine.getSnapshot().operations.get(old.operationIds[0]).changes[0].target.incarnation,1);assert.equal(f.calls.filter(call=>JSON.parse(call.body).intent.id===confirmed.operationIds[0]).length,1);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');assert.equal(f.server().elements.get('a').incarnation,2);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'edited');await engine.close();
|
||||
});
|
||||
|
||||
test('a later confirmed redo restoration remaps a remaining redo entry into its applied lifecycle',async()=>{
|
||||
const f=fixture();proveFixtureReceipt(f);const engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited a'}});await engine.settle();engine.editor.dispatch({type:'element.create',element:node('new','child')});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('new'),patch:{title:'edited new'}});await engine.settle();for(let i=0;i<3;i++){engine.editor.history.undo();await engine.settle();}
|
||||
f.reject=true;const first=engine.editor.history.redo();await engine.settle();f.reject=false;const confirmed=engine.editor.history.redo();await engine.settle();assert.equal(f.server().elements.get('new').incarnation,2);const session=await engine.prepareRecovery(),plan=session.plan(new Map([[first.operationIds[0],'keep-local']]));await session.resolve(plan);await engine.settle();assert.equal(f.calls.filter(call=>JSON.parse(call.body).intent.id===confirmed.operationIds[0]).length,1);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('new').data.title,'edited new');
|
||||
for(let i=0;i<3;i++){assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();}for(let i=0;i<3;i++){assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();}assert.equal(f.server().elements.get('new').incarnation,3);assert.equal(f.server().elements.get('new').data.title,'edited new');await engine.close();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user