Recover user groups across nonprefix acknowledgments and discarded entry births

This commit is contained in:
2026-11-08 03:35:58 +08:00
parent 1b29b0138b
commit d99b6951cf
4 changed files with 65 additions and 19 deletions
+16
View File
@@ -5,6 +5,20 @@ 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 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;
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),'group recovery 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})};
try{
engine=await openProjectEngine(options);const first=engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{text:'pending first'}},{groupId:'shared'});await engine.settle();const old=[...engine.getSnapshot().runtime.ledger.requests.values()][0];reject=false;const confirmed=engine.editor.dispatch({type:'element.data',target:{id:'c',incarnation:1},patch:{text:'confirmed later'}},{groupId:'shared'});await engine.settle();await engine.close();engine=await openProjectEngine(options);
const session=await engine.prepareRecovery(),plan=session.plan(new Map([[first.operationIds[0],keep?'keep-local':'discard-local']]));await session.resolve(plan);await engine.settle();assert(calls.filter(call=>call.method==='POST'&&JSON.parse(call.body).operations.some(operation=>operation.changes.some(change=>change.kind==='field'&&change.target.id==='c'))).length===1,'confirmed group contribution was sent again');await engine.close();engine=await openProjectEngine(options);
const history=engine.getSnapshot().history;assert(history.undo.length===1&&history.entries.values().next().value.operationIds.includes(confirmed.operationIds[0]),'group lost its confirmed logical fact');assert(engine.getSnapshot().runtime.ledger.requests.get(old.id).body===old.body,'group replan changed rejected bytes');assert(document.elements.get('a').data.text===(keep?'pending first':''),'group prefix choice was ignored');assert(engine.editor.history.undo().status==='applied','group undo failed');await engine.settle();assert(document.elements.get('a').data.text===''&&document.elements.get('c').data.text==='','group undo did not restore both selected contributions');assert(engine.editor.history.redo().status==='applied','group redo failed');await engine.settle();assert(document.elements.get('a').data.text===(keep?'pending first':'')&&document.elements.get('c').data.text==='confirmed later','group redo lost selected content');assert(calls.filter(call=>call.method==='POST').length===(keep?5:4),'group recovery repeated a write');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
}
async function journalRecoveryCase(name,assert){
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)]),storage=createBrowserStorage({name:`${name}-journal-recovery`}),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)]))});
@@ -372,6 +386,8 @@ export async function run() {
results.push('history transition evidence survives redo invalidation and IndexedDB reopening, reconstructs prior stacks without document mutation and never enters service payloads');
await journalRecoveryCase(name,assert);
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');
return results;
} finally { await leader?.release(); other?.close(); storage.close(); indexedDB.deleteDatabase(name); }
}
+25 -5
View File
@@ -228,11 +228,13 @@ test('mixed structural history cannot silently delete remote additions or claim
});
test('a later confirmed group operation cannot be moved before an earlier rejected structural operation',async()=>{
const f=fixture(),submit=f.port.submit;f.port.submit=async request=>{const intent=JSON.parse(request.body).intent;f.reject=intent.changes.some(change=>change.kind==='element'&&change.id==='a');return submit(request);};
const engine=await f.open();engine.editor.dispatch({type:'element.remove',target:target('a')},{groupId:'out-of-order'});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'confirmed later'}},{groupId:'out-of-order'});await engine.settle();
assert.equal(f.server().elements.get('c').data.title,'confirmed later');assert.equal(pending(engine).length,1);
const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(choices(engine)),/recovery_history_nonprefix_ack/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close();
test('a later confirmed group operation retains its fact while a rejected structural predecessor is kept or discarded',async()=>{
for(const keep of [true,false]){
const f=fixture(),submit=f.port.submit;let deny=true;f.port.submit=async request=>{const intent=JSON.parse(request.body).intent;f.reject=deny&&intent.changes.some(change=>change.kind==='element'&&change.id==='a');return submit(request);};
const engine=await f.open(),remove=engine.editor.dispatch({type:'element.remove',target:target('a')},{groupId:'out-of-order'});await engine.settle();const confirmed=engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'confirmed later'}},{groupId:'out-of-order'});await engine.settle();
assert.equal(f.server().elements.get('c').data.title,'confirmed later');assert.equal(pending(engine).length,1);const session=await engine.prepareRecovery(),plan=session.plan(new Map([[remove.operationIds[0],keep?'keep-local':'discard-local']]));assert.equal(plan.history.undo.length,1);assert.ok(plan.history.entries.values().next().value.operationIds.includes(confirmed.operationIds[0]));deny=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.has('a'),!keep);assert.equal(f.server().elements.get('c').data.title,'confirmed later');assert.equal(f.calls.filter(call=>JSON.parse(call.body).intent.changes.some(change=>change.kind==='field'&&change.target.id==='c')).length,1);
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').incarnation,keep?2:1);assert.equal(f.server().elements.get('c').data.title,'c');assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.has('a'),!keep);assert.equal(f.server().elements.get('c').data.title,'confirmed later');await engine.close();
}
});
@@ -346,3 +348,21 @@ test('native event recovery refuses future, missing or unproved legacy transitio
test('history event replay uses the engine capacity when discarding undo retains an extra prior entry',async()=>{
const f=fixture(),engine=await f.open({historyLimit:2});f.reject=false;const first=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'first'}});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'second'}});await engine.settle();f.reject=true;const undo=engine.editor.history.undo();engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'branch'}});await engine.settle();const session=await engine.prepareRecovery(),selected=choices(engine);selected.set(undo.operationIds[0],'discard-local');const plan=session.plan(selected);assert.equal(plan.history.undo.length,2);assert.equal(plan.history.entries.has(first.historyEntryId),false);f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,'first');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('c').data.title,'c');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('b').data.title,'b');assert.equal(engine.editor.history.undo().code,'history_empty');await engine.close();
});
test('grouped user edits rebuild after discarding or satisfying the original entry birth',async()=>{
for(const firstChoice of ['keep-local','discard-local','satisfied'])for(const secondKeep of [true,false]){
const f=fixture(),engine=await f.open(),first=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'one'}},{groupId:'typing'});await engine.settle();const second=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'two'}},{groupId:'typing'});await engine.settle();if(firstChoice==='satisfied')f.remote([{kind:'field',target:target('a'),field:'data.title',before:'a',after:'one',beforePresent:true,afterPresent:true}]);
const session=await engine.prepareRecovery(),plan=session.plan(new Map([[first.operationIds[0],firstChoice==='discard-local'?'discard-local':'keep-local'],[second.operationIds[0],secondKeep?'keep-local':'discard-local']]));f.reject=false;await session.resolve(plan);await engine.settle();const retained=secondKeep||firstChoice==='keep-local';assert.equal(engine.getSnapshot().history.undo.length,retained?1:0);assert.equal(f.server().elements.get('a').data.title,secondKeep?'two':firstChoice==='discard-local'?'a':'one');
if(retained){assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,firstChoice==='satisfied'?'one':'a');}await engine.close();
}
});
test('discarding a group suffix removes only the acknowledged field contribution already overwritten remotely',async()=>{
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'ack',text:'keep history'}},{groupId:'typing'});await engine.settle();f.reject=true;const edit=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}},{groupId:'typing'});await engine.settle();f.remote([{kind:'field',target:target('a'),field:'data.title',before:'ack',after:'remote',beforePresent:true,afterPresent:true}]);
const session=await engine.prepareRecovery(),plan=session.plan(new Map([[edit.operationIds[0],'discard-local']]));await session.resolve(plan);f.reject=false;assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'remote');assert.equal(f.server().elements.get('a').data.text,'');await engine.close();
});
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();
});