Add quiescent canonical recovery sessions without rewriting requests
This commit is contained in:
@@ -140,3 +140,67 @@ test('a fresh canonical read replaces a clean recovered base, keeps history and
|
||||
assert.equal(reopened.engine.getSnapshot().document.elements.get('a').geometry.x, 400);
|
||||
await reopened.engine.close();
|
||||
});
|
||||
|
||||
test('recovery waits for admitted writes, reads a complete base, fences edits and publishes without changing history', async()=>{
|
||||
const storage=storageFixture(),port=persistenceFixture(),response=deferred(),realSubmit=port.submit,readGate=deferred();
|
||||
let reads=0;port.submit=async request=>{await response.promise;return realSubmit(request);};
|
||||
port.read=async()=>{reads++;await readGate.promise;return{document:applyChanges(original,[{kind:'field',target,field:'data.title',before:'a',after:'local',beforePresent:true,afterPresent:true},{kind:'field',target,field:'geometry',before:original.elements.get('a').geometry,after:{...original.elements.get('a').geometry,x:300}}]),versions:new Map([['canvas',3]])};};
|
||||
const{engine}=await open(storage,port);engine.editor.dispatch({type:'element.data',target,patch:{title:'local'}});await tick();
|
||||
const preparing=engine.prepareRecovery();await tick();assert.equal(reads,0);
|
||||
assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{title:'blocked'}}).code,'recovery_in_progress');
|
||||
assert.equal(engine.startRun({}).code,'recovery_in_progress');assert.equal(engine.refresh(original,new Map([['canvas',1]])).code,'recovery_in_progress');
|
||||
await assert.rejects(engine.prepareRecovery(),/recovery_in_progress/);
|
||||
response.resolve();await tick();assert.equal(reads,1);const history=engine.getSnapshot().history;
|
||||
readGate.resolve();const session=await preparing;assert.equal(session.conflict,undefined);assert.equal(session.canonical.document.elements.get('a').geometry.x,300);
|
||||
assert.equal(engine.getSnapshot().document.elements.get('a').geometry.x,0);assert.equal(engine.editor.history.undo().code,'recovery_in_progress');
|
||||
assert.equal((await session.apply()).status,'applied');assert.equal(engine.getSnapshot().document.elements.get('a').geometry.x,300);assert.equal(engine.getSnapshot().history,history);
|
||||
assert.equal(engine.getDurability().durableRootRevision,engine.getSnapshot().rootRevision);await assert.rejects(session.apply(),/recovery_session_closed/);
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
test('unknown receipts prevent a recovery read and preserve the frozen request identity',async()=>{
|
||||
const storage=storageFixture();let reads=0;const port=persistenceFixture({async submit(){return{outcome:'unresolved'};},async read(){reads++;return{document:original,versions:new Map([['canvas',1]])};}});
|
||||
const{engine}=await open(storage,port);engine.editor.dispatch({type:'element.data',target,patch:{title:'local'}});await engine.settle();
|
||||
const before=engine.getSnapshot(),request=[...before.runtime.ledger.requests.values()][0];
|
||||
await assert.rejects(engine.prepareRecovery(),/recovery_waiting_for_receipts/);assert.equal(reads,0);
|
||||
const after=engine.getSnapshot();assert.equal(after.document,before.document);assert.equal(after.history,before.history);assert.equal(after.runtime.ledger.requests.get(request.id).body,request.body);
|
||||
assert.equal(engine.editor.can({type:'element.data',target,patch:{title:'allowed'}}).enabled,true);await engine.close();
|
||||
});
|
||||
|
||||
test('recovery conflict previews preserve local data/history and cancel releases the edit fence',async()=>{
|
||||
const remote=applyChanges(original,[{kind:'field',target,field:'data.title',before:'a',after:'remote',beforePresent:true,afterPresent:true}]);
|
||||
const storage=storageFixture(),port=persistenceFixture({async submit(){return{outcome:'not-applied',error:'version_conflict'};},async read(){return{document:remote,versions:new Map([['canvas',2]])};}});
|
||||
const{engine}=await open(storage,port);engine.editor.dispatch({type:'element.data',target,patch:{title:'local'}});await engine.settle();
|
||||
const before=engine.getSnapshot(),session=await engine.prepareRecovery();assert.ok(session.conflict);assert.equal(session.pendingOperationIds.length,1);
|
||||
assert.equal((await session.apply()).status,'rejected');assert.equal(engine.getSnapshot(),before);assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'local');
|
||||
await session.cancel();await session.cancel();assert.equal(engine.editor.can({type:'element.data',target,patch:{title:'more'}}).enabled,true);await engine.close();
|
||||
});
|
||||
|
||||
test('failed recovery durability retains its fence and retries the same published root',async()=>{
|
||||
const storage=storageFixture(),port=persistenceFixture({async read(){return{document:original,versions:new Map([['canvas',1]])};}}),{engine}=await open(storage,port);
|
||||
const session=await engine.prepareRecovery();storage.broken=true;await assert.rejects(session.apply(),/quota/);const revision=engine.getSnapshot().rootRevision;
|
||||
assert.equal(engine.editor.can({type:'element.data',target,patch:{title:'blocked'}}).code,'recovery_in_progress');await assert.rejects(session.cancel(),/quota/);
|
||||
storage.broken=false;assert.equal((await session.apply()).status,'applied');assert.equal(engine.getSnapshot().rootRevision,revision);assert.equal(engine.getDurability().durableRootRevision,revision);
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
test('closing the engine aborts a recovery read and rejects a late canonical response',async()=>{
|
||||
const storage=storageFixture(),response=deferred();let signal;
|
||||
const port=persistenceFixture({async read(input){signal=input;return response.promise;}}),{engine}=await open(storage,port);
|
||||
const preparing=engine.prepareRecovery();await tick();assert.ok(signal);await engine.close();assert.equal(signal.aborted,true);
|
||||
response.resolve({document:original,versions:new Map([['canvas',1]])});await assert.rejects(preparing,/engine_closing/);assert.equal(storage.owner(),null);
|
||||
});
|
||||
|
||||
test('complete recovery reads reject same-version rewrites and lifecycle regression before creating a session',async()=>{
|
||||
const remote=applyChanges(original,[{kind:'field',target,field:'data.title',before:'a',after:'remote',beforePresent:true,afterPresent:true}]);
|
||||
const storage=storageFixture(),port=persistenceFixture({async read(){return{document:remote,versions:new Map([['canvas',1]])};}}),{engine}=await open(storage,port);
|
||||
await assert.rejects(engine.prepareRecovery(),/canonical_version_mismatch/);
|
||||
assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'a');
|
||||
assert.equal(engine.editor.can({type:'element.data',target,patch:{title:'allowed'}}).enabled,true);
|
||||
assert.equal(engine.refresh(remote,new Map([['canvas',1]])).code,'canonical_version_mismatch');
|
||||
const {validateCanonicalRead}=await import('../../src/lib/canvas-runtime/reconcile.ts');
|
||||
const withTombstone=createDocument(original.projectId,[...original.canvases.values()],[...original.elements.values()],new Map([['deleted',2]]));
|
||||
assert.throws(()=>validateCanonicalRead(withTombstone,new Map([['canvas',1]]),original,new Map([['canvas',2]]),registry),/stale_canonical_lifecycle/);
|
||||
assert.throws(()=>validateCanonicalRead(original,new Map([['canvas',1]]),withTombstone,new Map([['canvas',1]]),registry),/canonical_version_mismatch/);
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user