docs(tests): explain engine recovery cuts conflict plans and lifecycle guards
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 显式冲突恢复测试:用户选择 keep-local 或 discard-local 后,基于已审阅的远程基线重建条件操作和撤销链。
|
||||
* 旧请求及拒绝证据保留在账本与恢复审计中;结果未知的操作不能靠用户选择直接退休,付费动作不能自动重启。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRegistry,builtinComponents,createDocument,applyChanges } from '../../src/lib/canvas-core/index.ts';
|
||||
@@ -5,19 +9,56 @@ import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts';
|
||||
import { decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts';
|
||||
import { createLedger } from '../../src/lib/canvas-runtime/ledger.ts';
|
||||
|
||||
// 共享类型定义和目标身份,保证冲突处理仍执行数据校验及 incarnation 校验。
|
||||
const registry=createRegistry(builtinComponents()),target={id:'a',incarnation:1};
|
||||
// 单元素基线同时包含文字和几何,便于区分局部字段重放与整对象覆盖。
|
||||
const element={...target,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:'canvas',data:{title:'a',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}};
|
||||
// 独立的初始项目文档;服务替身和新引擎都从此基线开始。
|
||||
const original=createDocument('project',[{id:'canvas',parent:null}],[element]);
|
||||
// 推进有限轮微任务,供恢复读取进入挂起点;不等待被故意阻塞的完整恢复会话。
|
||||
const tick=async()=>{for(let i=0;i<40;i++)await Promise.resolve();};
|
||||
// 默认拒绝写入并允许外部修改服务基线;存储可注入 quota,支持验证恢复计划先发布后落盘失败的重试。
|
||||
function fixture(){
|
||||
let server=original,version=1,image,leader;const calls=[];
|
||||
const f={reject:true,broken:false,calls,storage:{async claim(scope){if(leader)throw Error('occupied');return leader={scope,fence:1,active:true,async release(){leader=undefined;}};},async recover(){return image&&structuredClone(image);},async commit(owner,batch){if(f.broken)throw Error('quota');assert.equal(owner,leader);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},async readFile(){},close(){}},port:{compile(intent,requestId,snapshot){return{canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)])),payload:{intent,requestId}};},async submit(request){calls.push(request);if(f.reject)return{outcome:'not-applied',error:'version_conflict'};const{intent}=JSON.parse(request.body);assert.equal(request.expectedVersions.canvas,version);server=applyChanges(server,intent.changes);version++;return{outcome:'applied',canonical:{requestId:request.id}};},async query(){throw Error('unexpected query');},canonical(){return{document:server,versions:new Map([['canvas',version]])};},async read(){return{document:server,versions:new Map([['canvas',version]])};}},remote(patch){const before=server.elements.get('a');server=applyChanges(server,Object.entries(patch).map(([key,after])=>({kind:'field',target,field:`data.${key}`,before:before.data[key]??null,beforePresent:Object.hasOwn(before.data,key),after,afterPresent:true})));version++;},remoteGeometry(patch){const before=server.elements.get('a').geometry;server=applyChanges(server,[{kind:'field',target,field:'geometry',before,after:{...before,...patch}}]);version++;},setServer(document){server=document;version++;},image:()=>structuredClone(image),async open(){return openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document:original,versions:new Map([['canvas',1]]),storage:f.storage,persistence:f.port});}};
|
||||
const f={reject:true,broken:false,calls,storage:{
|
||||
// 拒绝并行 owner,释放后下一次 open 才能重新接管;这是内存互斥替身。
|
||||
async claim(scope){if(leader)throw Error('occupied');return leader={scope,fence:1,active:true,async release(){leader=undefined;}};},
|
||||
// 返回最近已提交镜像的副本,重开复用相同存储但不共享可变镜像对象。
|
||||
async recover(){return image&&structuredClone(image);},
|
||||
// 故障发生时不推进镜像;成功时校验存储 owner 并返回实际保存修订。
|
||||
async commit(owner,batch){if(f.broken)throw Error('quota');assert.equal(owner,leader);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},
|
||||
// 本组不读取本地文件;空方法仅补齐 StoragePort。
|
||||
async readFile(){},close(){}},port:{
|
||||
// 根据当前 ACK 版本编译条件请求,让恢复后的替代意图采用新基线。
|
||||
compile(intent,requestId,snapshot){return{canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)])),payload:{intent,requestId}};},
|
||||
// 默认给出明确 not-applied;放行后先检查版本再改服务状态。此替身不实现未知结果或请求去重。
|
||||
async submit(request){calls.push(request);if(f.reject)return{outcome:'not-applied',error:'version_conflict'};const{intent}=JSON.parse(request.body);assert.equal(request.expectedVersions.canvas,version);server=applyChanges(server,intent.changes);version++;return{outcome:'applied',canonical:{requestId:request.id}};},
|
||||
// 这里不测试未知结果查询;若恢复误走该路径,立即失败。
|
||||
async query(){throw Error('unexpected query');},
|
||||
// 为已知成功请求提供当前服务模型和版本。
|
||||
canonical(){return{document:server,versions:new Map([['canvas',version]])};},
|
||||
// 恢复会话读取最新完整服务基线,不读取客户端 working。
|
||||
async read(){return{document:server,versions:new Map([['canvas',version]])};}},
|
||||
// 外部写入逐字段保留存在性,模拟其他客户端推进远程版本。
|
||||
remote(patch){const before=server.elements.get('a');server=applyChanges(server,Object.entries(patch).map(([key,after])=>({kind:'field',target,field:`data.${key}`,before:before.data[key]??null,beforePresent:Object.hasOwn(before.data,key),after,afterPresent:true})));version++;},
|
||||
// 外部几何只改变指定轴,恢复时应保留本地未触碰的那些轴。
|
||||
remoteGeometry(patch){const before=server.elements.get('a').geometry;server=applyChanges(server,[{kind:'field',target,field:'geometry',before,after:{...before,...patch}}]);version++;},
|
||||
// 直接替换服务模型并推进版本,用来模拟目标删除等生命周期变化。
|
||||
setServer(document){server=document;version++;},
|
||||
// 提供恢复镜像副本,供审计篡改测试使用。
|
||||
image:()=>structuredClone(image),
|
||||
// 重新装配引擎但保留 fixture 的服务端、回执观测和存储状态。
|
||||
async open(){return openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document:original,versions:new Map([['canvas',1]]),storage:f.storage,persistence:f.port});}};
|
||||
return f;
|
||||
}
|
||||
// 只收集尚未解决的非上传操作,生成恢复选项时不把独立资源上传当作文档编辑。
|
||||
const pending=engine=>[...engine.getSnapshot().runtime.ledger.operations.values()].filter(record=>record.phase!=='resolved'&&record.intent.operationType!=='asset.upload');
|
||||
// 为每个待处理操作显式指定选择;某些用例覆盖个别选择以验证依赖闭包和动作限制。
|
||||
const choices=(engine,choice='keep-local')=>new Map(pending(engine).map(record=>[record.intent.id,choice]));
|
||||
// 通过真实命令入口修改标题,可传 groupId 检查输入历史分组在恢复后如何拆分或保留。
|
||||
const title=(engine,value,settings)=>engine.editor.dispatch({type:'element.data',target,patch:{title:value}},settings);
|
||||
|
||||
// keep-local 以远程字段前值创建新操作和新请求 key,保留旧拒绝;撤销回到审阅过的远程标题,未触碰文字不回退。
|
||||
test('keep-local freezes a new conditional intent/key, retains the old rejection, and undo restores the reviewed remote value',async()=>{
|
||||
const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.remote({title:'remote',text:'untouched remote'});
|
||||
const before=engine.getSnapshot(),old=[...before.runtime.ledger.requests.values()][0],session=await engine.prepareRecovery(),plan=session.plan(choices(engine));
|
||||
@@ -34,6 +75,7 @@ test('keep-local freezes a new conditional intent/key, retains the old rejection
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
// 丢弃中间一步后重连剩余操作依赖和 undo 链;旧历史及恢复前内容仍保存在审计备份。
|
||||
test('discarding a middle edit preserves the remaining undo chain and archives the old history',async()=>{
|
||||
const f=fixture(),engine=await f.open();title(engine,'one');await engine.settle();title(engine,'two');title(engine,'three');await engine.settle();f.remote({title:'remote'});
|
||||
const oldHistory=engine.getSnapshot().history,ids=pending(engine).map(record=>record.intent.id),session=await engine.prepareRecovery(),selected=new Map([[ids[0],'keep-local'],[ids[1],'discard-local'],[ids[2],'keep-local']]),plan=session.plan(selected);
|
||||
@@ -44,6 +86,7 @@ test('discarding a middle edit preserves the remaining undo chain and archives t
|
||||
assert.equal(oldHistory.undo.length,3);assert.equal(engine.getSnapshot().runtime.recoveries.get(plan.id).backup.document.elements[0].data.title,'three');await engine.close();
|
||||
});
|
||||
|
||||
// 同一输入组中的已确认前缀仍可撤销;若服务端改过同字段,则撤销基点改为审阅的远程值。
|
||||
test('typing groups preserve an acknowledged prefix unless the server changed that field',async()=>{
|
||||
for(const remote of [false,true]){
|
||||
const f=fixture(),engine=await f.open();f.reject=false;title(engine,'acknowledged',{groupId:'typing'});await engine.settle();f.reject=true;title(engine,'pending',{groupId:'typing'});await engine.settle();
|
||||
@@ -53,24 +96,28 @@ test('typing groups preserve an acknowledged prefix unless the server changed th
|
||||
}
|
||||
});
|
||||
|
||||
// 只丢弃输入组的 pending 后缀,不删除已确认前缀的撤销能力。
|
||||
test('discarding only a pending group suffix retains applied edits and other fields',async()=>{
|
||||
const f=fixture(),engine=await f.open();f.reject=false;title(engine,'acknowledged',{groupId:'typing'});await engine.settle();f.reject=true;title(engine,'pending',{groupId:'typing'});await engine.settle();
|
||||
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine,'discard-local'));assert.equal(plan.operationIds.length,0);assert.equal(plan.history.undo.length,1);
|
||||
await session.resolve(plan);f.reject=false;assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'a');await engine.close();
|
||||
});
|
||||
|
||||
// 局部改宽度时保留远程 x 和 rotation;撤销只恢复宽度,不回退远程几何。
|
||||
test('geometry recovery changes only locally touched axes and undo retains remote geometry',async()=>{
|
||||
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.geometry',target,patch:{width:200}});await engine.settle();f.remoteGeometry({x:300,rotation:45});
|
||||
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));assert.deepEqual(plan.document.elements.get('a').geometry,{x:300,y:0,width:200,height:100,rotation:45});
|
||||
f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.deepEqual(engine.getSnapshot().document.elements.get('a').geometry,{x:300,y:0,width:100,height:100,rotation:45});await engine.close();
|
||||
});
|
||||
|
||||
// 远程已删除的目标只能丢弃本地修改,keep-local 不得暗中复活旧 incarnation;恢复保留墓碑。
|
||||
test('a deleted target may be discarded but cannot be resurrected by keep-local',async()=>{
|
||||
const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.setServer(applyChanges(original,[{kind:'element',id:'a',before:element,after:null}]));
|
||||
const session=await engine.prepareRecovery();assert.throws(()=>session.plan(choices(engine)),/stale_incarnation/);const plan=session.plan(choices(engine,'discard-local'));
|
||||
assert.equal(plan.history.undo.length,0);await session.resolve(plan);assert.equal(engine.getSnapshot().document.elements.has('a'),false);assert.equal(engine.getSnapshot().document.tombstones.get('a'),1);await engine.close();
|
||||
});
|
||||
|
||||
// 恢复发布后 quota 失败仍保留同一个计划和发送屏障;不能复制计划对象替代原计划,持久化重试成功后只新增一次写入。
|
||||
test('recovery publication failure keeps one plan and blocks sends until the exact cut becomes durable',async()=>{
|
||||
const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.remote({title:'remote'});const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));
|
||||
f.reject=false;f.broken=true;await assert.rejects(session.resolve(plan),/quota/);const revision=engine.getSnapshot().rootRevision;assert.equal(f.calls.length,1);
|
||||
@@ -78,11 +125,13 @@ test('recovery publication failure keeps one plan and blocks sends until the exa
|
||||
f.broken=false;await session.resolve(plan);await engine.settle();assert.equal(f.calls.length,2);assert.ok(engine.getDurability().durableRootRevision>revision);assert.equal(engine.getSnapshot().runtime.recoveries.size,1);await engine.close();
|
||||
});
|
||||
|
||||
// 漏选任何待处理操作都会拒绝计划;撤销产生的 traversal 也能进入恢复规划,取消会话不提交该计划。
|
||||
test('incomplete choices reject while recorded user/traversal recovery can be planned',async()=>{
|
||||
const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();const session=await engine.prepareRecovery();assert.throws(()=>session.plan(new Map()),/recovery_choices_incomplete/);await session.cancel();
|
||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();const undoSession=await engine.prepareRecovery();assert.ok(undoSession.plan(choices(engine)).history.redo.length===1);await undoSession.cancel();await engine.close();
|
||||
});
|
||||
|
||||
// 只退休依赖链一部分会被拒绝;已 dispatch、结果未知的操作即使补全闭包仍不安全,失败必须保持账本原引用。
|
||||
test('retirement rejects unknown work and incomplete dependency closures without mutation',()=>{
|
||||
const ledger=createLedger(),intent=(id,dependencies=[])=>({id,operationType:'document.change',operationSchemaVersion:1,sequence:id==='a'?1:2,origin:'user',canvasIds:['canvas'],changes:[],dependencies});
|
||||
ledger.add(intent('a'),{});ledger.add(intent('b',['a']),{});const before=ledger.getSnapshot();assert.throws(()=>ledger.retire('recovery',[{operationId:'a',resolution:'discarded'}]),/recovery_dependency_closure/);assert.equal(ledger.getSnapshot(),before);
|
||||
@@ -90,6 +139,7 @@ test('retirement rejects unknown work and incomplete dependency closures without
|
||||
assert.throws(()=>ledger.retire('recovery',[{operationId:'a',resolution:'discarded'},{operationId:'b',resolution:'discarded'}]),/recovery_operation_not_safe/);assert.equal(ledger.getSnapshot(),frozen);
|
||||
});
|
||||
|
||||
// 旧 owner 的几何不能直接搬到新坐标系;pending run.start 必须显式丢弃并另发新命令,恢复不能自动重启付费动作。
|
||||
test('owner changes reject old geometry; pending paid actions require an explicit discard and never auto-restart',async()=>{
|
||||
const {planConflictRecovery}=await import('../../src/lib/canvas-runtime/conflicts.ts');
|
||||
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.geometry',target,patch:{width:200}});await engine.settle();
|
||||
@@ -100,6 +150,7 @@ test('owner changes reject old geometry; pending paid actions require an explici
|
||||
assert.equal(plan.ledger.operations.get('paid-start').resolution,'discarded');assert.ok(plan.operationIds.every(id=>plan.operations.get(id).operationType==='document.change'));await session.cancel();await engine.close();
|
||||
});
|
||||
|
||||
// 已发布计划即使脱离原会话也可完成持久化屏障;重开校验审计、资源 pin、替代操作链接和备份历史,任一篡改都拒绝。
|
||||
test('recovery audits and replacement links are validated when reopening and the owner can finish a detached durable barrier',async()=>{
|
||||
const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.remote({title:'remote'});const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));
|
||||
f.broken=true;await assert.rejects(session.resolve(plan),/quota/);f.broken=false;f.reject=false;await engine.cancelRecovery();await engine.settle();
|
||||
@@ -114,18 +165,21 @@ test('recovery audits and replacement links are validated when reopening and the
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
// 远程已满足本地目标值时以 satisfied 退休旧操作,不发送新请求或新增 undo,也不把原拒绝改写成服务端成功。
|
||||
test('a remote value already satisfying the local choice retires the intent without claiming a server write or adding undo',async()=>{
|
||||
const f=fixture(),engine=await f.open();title(engine,'same');await engine.settle();f.remote({title:'same'});const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));
|
||||
assert.equal(plan.operationIds.length,0);assert.equal(plan.history.undo.length,0);assert.equal(plan.audit.decisions[0].resolution,'satisfied');await session.resolve(plan);
|
||||
assert.equal(f.calls.length,1);assert.equal([...engine.getSnapshot().runtime.ledger.requests.values()][0].outcome,'not-applied');assert.equal(decodeRecovery(f.image(),registry).runtime.recoveries.size,1);await engine.close();
|
||||
});
|
||||
|
||||
// 混合输入组丢弃待处理标题后,已确认文字仍可撤销;撤销不得覆盖远程标题。
|
||||
test('discarding a field in a mixed typing group keeps the unrelated acknowledged field undoable',async()=>{
|
||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target,patch:{text:'acknowledged text'}},{groupId:'mixed'});await engine.settle();f.reject=true;title(engine,'pending title',{groupId:'mixed'});await engine.settle();f.remote({title:'remote title'});
|
||||
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine,'discard-local'));assert.equal(plan.history.undo.length,1);await session.resolve(plan);f.reject=false;
|
||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'remote title');assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'');await engine.close();
|
||||
});
|
||||
|
||||
// 取消挂起读取会 abort 且拒绝迟到结果;即使计划全是 discard-local,提交时仍须重新检查当前身份策略。
|
||||
test('discard-only resolution rechecks current identity and a cancelled read cannot later create a session',async()=>{
|
||||
const f=fixture();let allowed=true,release,signal;const engine=await f.open();title(engine,'local');await engine.settle();
|
||||
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine,'discard-local'));await session.cancel();
|
||||
@@ -136,6 +190,7 @@ test('discard-only resolution rechecks current identity and a cancelled read can
|
||||
});
|
||||
|
||||
|
||||
// 较早拒绝编辑的 keep/discard 不丢失后来本地抵消的创建删除历史;恢复保留本地墓碑,撤销用新 incarnation,不把抵消操作塞进请求。
|
||||
test('later locally elided history survives keep/discard of an earlier rejected edit and its recovery replay audit',async()=>{
|
||||
for(const keep of [false,true]){
|
||||
const f=fixture();let engine=await f.open();
|
||||
|
||||
Reference in New Issue
Block a user