131 lines
11 KiB
JavaScript
131 lines
11 KiB
JavaScript
import {test} from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
|
|
import {createCanvasMigration,readCanvasMigration,canvasMigrationStorageKey,migrationBlocksCanvas} from '../../src/lib/canvas-migration.ts';
|
|
|
|
const id=n=>`10000000-0000-4000-8000-${String(n).padStart(12,'0')}`,root=id(1),child=id(2),scope={accountId:id(3),workspaceId:id(4)},registry=createRegistry(builtinComponents());
|
|
function fixture(){
|
|
const records=new Map(),receipts=new Map(),bodies=new Map(),calls=[];
|
|
const f={records,receipts,calls,version:1,canonical:false,current:true,quota:false,loseResponse:false,failBeforeCommit:false,reject:false,alter:undefined};let sequence=100;
|
|
const storage={getItem:key=>records.get(key)??null,setItem(key,value){if(f.quota)throw Error('quota');records.set(key,value);},removeItem:key=>records.delete(key)};
|
|
const model=version=>({documentFormatVersion:1,projectId:root,complete:true,canvases:[{id:root,parent:null}],elements:[{id:id(20),incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',data:{title:'a',text:'body'},geometry:{x:0,y:0,width:240,height:72,rotation:0}}],versions:{[root]:version},tombstones:{},removedCanvasIds:[]});
|
|
const transport=async input=>{
|
|
calls.push(input);
|
|
if(input.method==='GET'&&input.path.endsWith('/model'))return f.canonical?{status:200,body:{model:model(f.version)}}:{status:409,body:{error:'canonical_migration_required',document_format_version:1,root_id:root,source_versions:{[root]:f.version}}};
|
|
if(input.method==='GET'){
|
|
const key=input.path.split('/').at(-1),known=receipts.get(key);
|
|
const response=known?{status:200,body:known.body}:{status:404,body:{status:'unknown',request_id:key,error:'operation_receipt_unknown'}};
|
|
return f.alter?f.alter(response,input):response;
|
|
}
|
|
assert.equal(JSON.parse(storage.getItem(canvasMigrationStorageKey(scope))).body,input.body,'request must be durable before POST');
|
|
assert.equal(input.responseFormat,'canvas-v2');
|
|
if(f.failBeforeCommit)throw Error('connection lost before commit');
|
|
const raw=JSON.parse(input.body);assert.equal(raw.request_id,input.requestId);
|
|
let response=receipts.get(input.requestId);
|
|
if(response){assert.equal(bodies.get(input.requestId),input.body,'retry changed frozen bytes');}
|
|
else{
|
|
response=f.reject?{status:412,body:{protocol_version:2,request_id:input.requestId,status:'not-applied',terminal:true,error:'migration_version_conflict'}}:{status:200,body:{protocol_version:2,request_id:input.requestId,status:'applied',source_versions:raw.expected_versions,identity_map:[{sourceCanvasId:root,structure:'node',sourceId:'a',elementId:id(20)}],model:model(f.version+1)}};
|
|
receipts.set(input.requestId,response);bodies.set(input.requestId,input.body);
|
|
if(!f.reject){f.version++;f.canonical=true;}
|
|
}
|
|
if(f.loseResponse)throw Error('connection lost after commit');
|
|
return f.alter?f.alter(structuredClone(response),input):response;
|
|
};
|
|
f.storage=storage;f.make=extra=>createCanvasMigration({scope,storage,registry,transport,isCurrentIdentity:()=>f.current,createId:()=>id(++sequence),lock:async work=>work(),...extra});
|
|
f.posts=()=>calls.filter(call=>call.method==='POST');return f;
|
|
}
|
|
|
|
test('migration drains first, refreshes source versions and persists exact request identity before dispatch',async()=>{
|
|
const f=fixture(),migration=f.make();let drained=false;
|
|
const result=await migration.start(root,async()=>{assert.equal(f.calls.length,0);drained=true;f.version=4;});
|
|
assert.equal(drained,true);assert.equal(result.status,'applied');assert.equal(result.rootId,root);
|
|
assert.deepEqual(JSON.parse(f.posts()[0].body).expected_versions,{[root]:4});assert.equal(migration.read(),undefined);
|
|
assert.equal(f.posts()[0].requestId,JSON.parse(f.posts()[0].body).request_id);
|
|
});
|
|
|
|
test('lost committed migration ACK is recovered by original query after reload without flushing or invoking twice',async()=>{
|
|
const f=fixture();f.loseResponse=true;const first=f.make();
|
|
assert.equal((await first.start(root,async()=>{})).status,'pending');const job=first.read();
|
|
assert.equal(migrationBlocksCanvas(job,root),true);assert.equal(migrationBlocksCanvas(job,child),false);
|
|
f.loseResponse=false;const recovered=f.make({createId(){assert.fail('recovery must not allocate a new key');}});
|
|
assert.equal((await recovered.start(root,async()=>assert.fail('old writers must remain frozen'))).status,'applied');
|
|
assert.equal(f.posts().length,1);assert.ok(f.calls.some(call=>call.path.endsWith('/operations/'+job.id)));assert.equal(recovered.read(),undefined);
|
|
});
|
|
|
|
test('a durable uncommitted request queries unknown then retries only its exact original body and key',async()=>{
|
|
const f=fixture();f.failBeforeCommit=true;const first=f.make();await first.start(root,async()=>{});
|
|
const original=first.read(),spaced={...original,body:' \n'+original.body+' '};f.storage.setItem(canvasMigrationStorageKey(scope),JSON.stringify(spaced));f.failBeforeCommit=false;
|
|
const recovered=f.make({createId(){assert.fail('must retain original key');}});
|
|
assert.equal((await recovered.resume()).status,'applied');
|
|
assert.equal(f.posts()[1].requestId,original.id);assert.equal(f.posts()[1].body,spaced.body);
|
|
assert.equal(f.calls.at(-2).method,'GET');assert.equal(f.calls.at(-2).body,undefined);
|
|
});
|
|
|
|
test('storage and legacy-drain failure prevent dispatch and do not silently discard recovery state',async()=>{
|
|
const f=fixture();f.quota=true;
|
|
await assert.rejects(()=>f.make().start(root,async()=>{}),/quota/);assert.equal(f.posts().length,0);
|
|
f.quota=false;f.calls.length=0;
|
|
await assert.rejects(()=>f.make().start(root,async()=>{throw Error('draft_recovery_required');}),/draft_recovery_required/);
|
|
assert.equal(f.calls.length,0);assert.equal(f.records.size,0);
|
|
});
|
|
|
|
test('only verified terminal rejection releases the editing gate; a new explicit preparation refreshes and allocates',async()=>{
|
|
const f=fixture();f.reject=true;const migration=f.make();const result=await migration.start(root,async()=>{});
|
|
assert.equal(result.status,'rejected');const rejected=migration.read();assert.equal(rejected.phase,'rejected');assert.equal(migrationBlocksCanvas(rejected,root),false);
|
|
const posts=f.posts().length;assert.equal((await migration.resume()).status,'rejected');assert.equal(f.posts().length,posts);
|
|
f.reject=false;f.version=7;
|
|
assert.equal((await migration.start(root,async()=>{})).status,'applied');assert.notEqual(f.posts()[1].requestId,rejected.id);assert.deepEqual(JSON.parse(f.posts()[1].body).expected_versions,{[root]:7});
|
|
});
|
|
|
|
test('bare rejection and malformed or foreign success retain the frozen request until the original query repairs it',async()=>{
|
|
for(const alter of [
|
|
response=>({status:403,body:{error:'forbidden'}}),
|
|
response=>({...response,body:{...response.body,request_id:id(999)}}),
|
|
response=>({...response,body:{...response.body,source_versions:{[root]:99}}}),
|
|
response=>({...response,body:{...response.body,identity_map:[]}}),
|
|
response=>({...response,body:{...response.body,identity_map:[{sourceCanvasId:child,structure:'node',sourceId:'a',elementId:id(20)}]}}),
|
|
response=>({...response,body:{...response.body,identity_map:[{sourceCanvasId:root,structure:'connection',sourceId:'a',elementId:id(20)}]}}),
|
|
]){
|
|
const f=fixture();f.alter=alter;const migration=f.make();assert.equal((await migration.start(root,async()=>{})).status,'pending');
|
|
const frozen=migration.read();assert.equal(frozen.phase,'pending');assert.equal(migrationBlocksCanvas(frozen,root),true);
|
|
f.alter=undefined;assert.equal((await f.make().resume()).status,'applied');assert.equal(f.posts().length,1);
|
|
}
|
|
});
|
|
|
|
test('future/corrupt migration material and another pending project cannot be overwritten',async()=>{
|
|
const f=fixture(),key=canvasMigrationStorageKey(scope);f.storage.setItem(key,JSON.stringify({version:2,payload:'retain'}));
|
|
const saved=f.storage.getItem(key);await assert.rejects(()=>f.make().start(root,async()=>assert.fail()),/migration_interpreter_unavailable/);assert.equal(f.storage.getItem(key),saved);assert.equal(f.calls.length,0);
|
|
f.storage.removeItem(key);f.failBeforeCommit=true;const migration=f.make();await migration.start(root,async()=>{});const pending=f.storage.getItem(key);
|
|
await assert.rejects(()=>migration.start(child,async()=>assert.fail()),/workspace_migration_pending/);assert.equal(f.storage.getItem(key),pending);
|
|
assert.equal(readCanvasMigration(f.storage,{...scope,accountId:id(99)}),undefined);
|
|
});
|
|
|
|
test('identity changes and aborted lifetimes cannot clear an old account request on a late success',async()=>{
|
|
const f=fixture();f.alter=response=>{f.current=false;return response;};
|
|
await assert.rejects(()=>f.make().start(root,async()=>{}),/identity_changed/);assert.equal(f.records.size,1);
|
|
f.current=true;f.alter=undefined;assert.equal((await f.make().resume()).status,'applied');assert.equal(f.posts().length,1);
|
|
const g=fixture(),controller=new AbortController();g.alter=response=>{controller.abort();return response;};
|
|
await assert.rejects(()=>g.make({signal:controller.signal}).start(root,async()=>{}),{name:'AbortError'});assert.equal(g.records.size,1);assert.equal(g.posts()[0].signal,controller.signal);
|
|
});
|
|
|
|
test('reentrant actions cannot create a second in-flight migration and already migrated projects do not POST',async()=>{
|
|
const f=fixture();let entered,release;const started=new Promise(resolve=>entered=resolve),gate=new Promise(resolve=>release=resolve);
|
|
const original=f.make();
|
|
const calls=f.calls;const migration=f.make({lock:async work=>{entered();await gate;return work();}});
|
|
const first=migration.start(root,async()=>{});await started;
|
|
await assert.rejects(()=>migration.resume(),/migration_busy/);assert.equal(calls.length,0);release();assert.equal((await first).status,'applied');
|
|
assert.equal((await original.start(root,async()=>{})).status,'already-migrated');assert.equal(f.posts().length,1);
|
|
});
|
|
|
|
test('failed result retirement remains recoverable and a storage port that drops writes cannot dispatch',async()=>{
|
|
const f=fixture(),storage={...f.storage,removeItem(){throw Error('storage unavailable');}};
|
|
await assert.rejects(()=>f.make({storage}).start(root,async()=>{}),/storage unavailable/);
|
|
assert.equal(f.records.size,1);assert.equal((await f.make().resume()).status,'applied');assert.equal(f.posts().length,1);
|
|
const g=fixture();await assert.rejects(()=>g.make({storage:{...g.storage,setItem(){}}}).start(root,async()=>{}),/migration_not_durable/);assert.equal(g.posts().length,0);
|
|
});
|
|
|
|
test('a valid empty migration has no fabricated identity entries',async()=>{
|
|
const f=fixture();f.alter=response=>({...response,body:{...response.body,identity_map:[],model:{...response.body.model,elements:[]}}});
|
|
assert.equal((await f.make().start(root,async()=>{})).status,'applied');assert.equal(f.records.size,0);
|
|
});
|