feat(canvas): persist and reconcile explicit identity migration requests

This commit is contained in:
2026-11-11 17:10:14 +08:00
parent f1c3b63a32
commit 26da326c18
3 changed files with 283 additions and 3 deletions
+144
View File
@@ -0,0 +1,144 @@
import type {Id, Json, Registry} from './canvas-core/types.ts';
import {equal, fault, freeze, validJson} from './canvas-core/values.ts';
import {loadFoundryModel, type ServiceResponse, type ServiceTransport} from './canvas-service/foundry.ts';
export type CanvasMigrationScope = {readonly accountId:Id;readonly workspaceId:Id};
export type CanvasMigrationJob = CanvasMigrationScope & {
readonly version:1;readonly rootId:Id;readonly id:Id;readonly body:string;
readonly phase:'pending'|'rejected';readonly rejection?:Json;
};
export type CanvasMigrationResult =
| {readonly status:'idle'}
| {readonly status:'applied'|'already-migrated';readonly rootId:Id}
| {readonly status:'pending';readonly job:CanvasMigrationJob;readonly error:string}
| {readonly status:'rejected';readonly job:CanvasMigrationJob;readonly error:string};
type Store = Pick<Storage,'getItem'|'setItem'|'removeItem'>;
const uuid=/^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i;
const object=(value:unknown):Record<string,unknown>=>{
if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_migration_record');return value as Record<string,unknown>;
};
const exact=(value:Record<string,unknown>,keys:readonly string[])=>{if(Object.keys(value).some(key=>!keys.includes(key)))throw fault('migration_interpreter_unavailable');};
function vector(value:unknown,root:Id){
const versions=object(value),ids=Object.keys(versions);
if(!ids.length||ids.length>256||!Object.hasOwn(versions,root)||ids.some(id=>!uuid.test(id)||typeof versions[id]!=='number'||!Number.isSafeInteger(versions[id])||(versions[id] as number)<1||(versions[id] as number)>=Number.MAX_SAFE_INTEGER))throw fault('invalid_migration_record');
return versions as Record<Id,number>;
}
export function canvasMigrationStorageKey(scope:CanvasMigrationScope){
if(!uuid.test(scope.accountId)||!uuid.test(scope.workspaceId))throw fault('invalid_migration_scope');
// Only one identity migration per workspace. Unrelated projects remain editable.
return `endoleap_canvas_migration_v1:${scope.accountId}:${scope.workspaceId}`;
}
function payload(job:CanvasMigrationJob){
const raw=object(JSON.parse(job.body));exact(raw,['protocol_version','request_id','expected_versions']);
if(raw.protocol_version!==2||raw.request_id!==job.id)throw fault('invalid_migration_record');
return vector(raw.expected_versions,job.rootId);
}
function negative(job:CanvasMigrationJob,value:unknown){
const body=object(value);
if(!validJson(body)||body.protocol_version!==2||body.request_id!==job.id||body.status!=='not-applied'||body.terminal!==true||typeof body.error!=='string'||!body.error)throw fault('invalid_migration_receipt');
return body.error;
}
export function readCanvasMigration(storage:Pick<Storage,'getItem'>,scope:CanvasMigrationScope):CanvasMigrationJob|undefined{
const text=storage.getItem(canvasMigrationStorageKey(scope));if(text===null)return;
let raw:Record<string,unknown>;
try{raw=object(JSON.parse(text));}catch{throw fault('invalid_migration_record');}
if(raw.version!==1)throw fault('migration_interpreter_unavailable');
exact(raw,['version','accountId','workspaceId','rootId','id','body','phase','rejection']);
if(raw.accountId!==scope.accountId||raw.workspaceId!==scope.workspaceId||typeof raw.rootId!=='string'||!uuid.test(raw.rootId)||typeof raw.id!=='string'||!uuid.test(raw.id)||typeof raw.body!=='string'||!['pending','rejected'].includes(raw.phase as string))throw fault('invalid_migration_record');
const job=raw as unknown as CanvasMigrationJob;
try{payload(job);}catch{throw fault('invalid_migration_record');}
if(job.phase==='rejected')negative(job,job.rejection);
else if(Object.hasOwn(raw,'rejection'))throw fault('invalid_migration_record');
return freeze(job);
}
export function migrationBlocksCanvas(job:CanvasMigrationJob|undefined,canvasId:Id){
return !!job&&job.phase==='pending'&&(job.rootId===canvasId||Object.hasOwn(payload(job),canvasId));
}
/** Application identity handoff, before a canonical ProjectEngine exists.
* The host holds its legacy editing gate while pending, including across reload.
* lock() must exclude other migrations for this account/workspace (Web Locks in
* the browser). Storage contains no credentials and never rewrites a frozen body.
*/
export function createCanvasMigration(options:{
scope:CanvasMigrationScope;storage:Store;transport:ServiceTransport;registry:Registry;
isCurrentIdentity():boolean;createId():Id;lock<T>(work:()=>Promise<T>):Promise<T>;signal?:AbortSignal;
}){
const key=canvasMigrationStorageKey(options.scope);let active=false;
const current=()=>{if(!options.isCurrentIdentity())throw fault('identity_changed');options.signal?.throwIfAborted();};
const read=()=>{current();return readCanvasMigration(options.storage,options.scope);};
const path=(root:Id)=>`workspaces/${encodeURIComponent(options.scope.workspaceId)}/materials/${encodeURIComponent(root)}/canvas/model`;
function save(job:CanvasMigrationJob){
current();const encoded=JSON.stringify(job);options.storage.setItem(key,encoded);
if(options.storage.getItem(key)!==encoded)throw fault('migration_not_durable');
}
async function accept(job:CanvasMigrationJob,response:ServiceResponse):Promise<CanvasMigrationResult>{
current();let body:Record<string,unknown>;
try{body=object(response.body);}catch{return{status:'pending',job,error:'invalid_migration_receipt'};}
const matching=body.protocol_version===2&&body.request_id===job.id;
if(matching&&body.status==='not-applied'&&body.terminal===true&&response.status>=200&&response.status<500){
let error:string;try{error=negative(job,body);}catch{return{status:'pending',job,error:'invalid_migration_receipt'};}
const rejected=freeze({...job,phase:'rejected' as const,rejection:JSON.parse(JSON.stringify(body)) as Json});save(rejected);
return{status:'rejected',job:rejected,error};
}
if(matching&&body.status==='applied'&&response.status>=200&&response.status<300){
try{
const expected=payload(job);
if(!equal(body.source_versions,expected)||!Array.isArray(body.identity_map))throw fault('invalid_migration_receipt');
const loaded=await loadFoundryModel(async()=>({status:200,body:{model:body.model}}),options.scope.workspaceId,job.rootId,options.registry);
current();if(loaded.status!=='ready'||loaded.document.projectId!==job.rootId)throw fault('invalid_migration_receipt');
for(const[id,version]of Object.entries(expected))if(loaded.versions.get(id)!==version+1)throw fault('invalid_migration_receipt');
const sources=new Set<string>(),targets=new Set<Id>();
for(const value of body.identity_map){
const mapping=object(value),source=JSON.stringify([mapping.sourceCanvasId,mapping.structure,mapping.sourceId]),target=mapping.elementId;
if(typeof mapping.sourceCanvasId!=='string'||!Object.hasOwn(expected,mapping.sourceCanvasId)||!['node','connection'].includes(mapping.structure as string)||typeof mapping.sourceId!=='string'||!mapping.sourceId||typeof target!=='string'||!uuid.test(target)||loaded.document.elements.get(target)?.structure!==mapping.structure||sources.has(source)||targets.has(target))throw fault('invalid_migration_receipt');
sources.add(source);targets.add(target);
}
if(targets.size!==loaded.document.elements.size)throw fault('invalid_migration_receipt');
}catch(error){current();return{status:'pending',job,error:(error as {code?:string}).code??'invalid_migration_receipt'};}
current();options.storage.removeItem(key);
if(options.storage.getItem(key)!==null)throw fault('migration_not_durable');
return{status:'applied',rootId:job.rootId};
}
return{status:'pending',job,error:typeof body.error==='string'?body.error:'migration_response_unknown'};
}
async function send(job:CanvasMigrationJob,query:boolean):Promise<CanvasMigrationResult>{
current();let response:ServiceResponse;
try{
response=await options.transport({responseFormat:'canvas-v2',method:query?'GET':'POST',path:path(job.rootId)+(query?`/operations/${encodeURIComponent(job.id)}`:'/migrate'),signal:options.signal,...(query?{}:{requestId:job.id,body:job.body})});
}catch{current();return{status:'pending',job,error:'migration_response_unknown'};}
current();
const body=response.body as Record<string,unknown>|null;
// The current canonical receipt endpoint omits protocol_version on unknown.
// This is permission to retry the exact idempotent migration, never zero-write proof.
if(query&&response.status===404&&(body?.protocol_version===undefined||body.protocol_version===2)&&body?.request_id===job.id&&body.status==='unknown')return send(job,false);
return accept(job,response);
}
async function exclusive(work:()=>Promise<CanvasMigrationResult>){
if(active)throw fault('migration_busy');active=true;
try{return await options.lock(async()=>{current();return work();});}finally{active=false;}
}
return Object.freeze({read,
resume:()=>exclusive(async()=>{
const job=read();if(!job)return{status:'idle'};
return job.phase==='rejected'?{status:'rejected',job,error:negative(job,job.rejection)}:send(job,true);
}),
start:(rootId:Id,drain:()=>Promise<void>)=>exclusive(async()=>{
if(!uuid.test(rootId))throw fault('invalid_migration_scope');
const previous=read();
if(previous?.phase==='pending'){
if(previous.rootId!==rootId)throw fault('workspace_migration_pending');
return send(previous,true); // Never flush old writers after an unknown identity change.
}
await drain();current();
const source=await loadFoundryModel(options.transport,options.scope.workspaceId,rootId,options.registry,options.signal);current();
if(source.status==='ready')return{status:'already-migrated',rootId:source.document.projectId};
if(source.rootId!==rootId)throw fault('migration_scope_changed');
const expected=vector(Object.fromEntries(source.sourceVersions),rootId),id=options.createId();
if(!uuid.test(id)||id===previous?.id)throw fault('migration_identity_reused');
const job=freeze({...options.scope,version:1 as const,rootId,id,phase:'pending' as const,body:JSON.stringify({protocol_version:2,request_id:id,expected_versions:expected})});
save(job); // Exact identity/body is durable before the first side effect.
return send(job,false);
}),
});
}
+9 -3
View File
@@ -9,6 +9,7 @@ import {createModelPreview} from '../../src/lib/canvas-runtime/model-preview.ts'
import {createFoundryTransport,createFoundryPersistence,loadFoundryModel} from '../../src/lib/canvas-service/foundry.ts';
import {createFoundryAssets} from '../../src/lib/canvas-service/assets.ts';
import {createFoundryRuns} from '../../src/lib/canvas-service/runs.ts';
import {createCanvasMigration} from '../../src/lib/canvas-migration.ts';
import {verifyFoundryAI} from './foundry-ai.integration.mjs';
import {decodeModelStream} from '../../src/lib/canvas-service/model-stream.ts';
const {FOUNDRY_HTTP_URL:url,FOUNDRY_TEST_PROJECT:project,FOUNDRY_TEST_ACCOUNT:accountId,FOUNDRY_TEST_WORKSPACE:workspaceId,FOUNDRY_TEST_ROOT:rootId,FOUNDRY_TEST_TOKEN:token}=process.env;
@@ -20,8 +21,13 @@ const rawTransport=createFoundryTransport({basePath,accountId,isCurrentIdentity:
const transport=async input=>{const call={method:input.method,path:input.path,requestId:input.requestId,body:input.body};calls.push(call);const result=await rawTransport(input);if(input.path.includes('/canvas/ai/'))call.response={httpStatus:result.status,...Object.fromEntries(['protocol_version','request_id','operation_id','action','admission','status','error'].filter(key=>result.body?.[key]!==undefined).map(key=>[key,result.body[key]]))};if(loseNextAIAdmission&&input.method==='POST'&&input.path.endsWith('/ai/runs')&&result.status===202){loseNextAIAdmission=false;throw Error('injected response loss after AI admission');}if(loseNextAIAction&&input.method==='POST'&&/\/(cancel|apply)$/.test(input.path)&&result.status===200){loseNextAIAction=false;throw Error('injected response loss after AI action');}if(loseNextAssetAck&&input.method==='POST'&&input.path.endsWith('/assets')&&result.status>=200&&result.status<300){loseNextAssetAck=false;throw Error('injected upload response loss');}if(loseNextAck&&input.method==='POST'&&result.status===200){loseNextAck=false;throw Error('injected response loss after server commit');}return result;};
const path=`workspaces/${workspaceId}/materials/${rootId}/canvas`;
const migration=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(migration.status,'migration-required');
const migrationId=randomUUID(),migrationBody=JSON.stringify({protocol_version:2,request_id:migrationId,expected_versions:Object.fromEntries(migration.sourceVersions)});
const migrated=await transport({method:'POST',path:path+'/model/migrate',body:migrationBody,requestId:migrationId});assert.equal(migrated.status,200);assert.equal(migrated.body.status,'applied');
const migrationRecords=new Map(),migrationStorage={getItem:key=>migrationRecords.get(key)??null,setItem:(key,value)=>migrationRecords.set(key,value),removeItem:key=>migrationRecords.delete(key)};
const migrationOptions={scope:{accountId,workspaceId},registry,transport,storage:migrationStorage,isCurrentIdentity:()=>true,createId:randomUUID,lock:async work=>work()};
loseNextAck=true;const admission=createCanvasMigration(migrationOptions);assert.equal((await admission.start(rootId,async()=>{})).status,'pending');assert.equal(loseNextAck,false);
const frozenMigration=admission.read(),migrationId=frozenMigration.id,migrationBody=frozenMigration.body,migrationPosts=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/migrate')).length;
assert.equal((await createCanvasMigration({...migrationOptions,createId:()=>assert.fail('recovery allocated a new migration identity')}).resume()).status,'applied');
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/migrate')).length,migrationPosts);assert.equal(migrationRecords.size,0);
const migrated=await transport({method:'GET',path:path+'/model/operations/'+migrationId});assert.equal(migrated.status,200);assert.equal(migrated.body.status,'applied');
const retried=await transport({method:'POST',path:path+'/model/migrate',body:migrationBody,requestId:migrationId});assert.deepEqual(retried.body.identity_map,migrated.body.identity_map);
let initial=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(initial.status,'ready');
const sourceFrame=[...initial.document.elements.values()].find(element=>element.componentId==='frame'),sourceMember=[...initial.document.elements.values()].find(element=>element.structure==='node'&&element.ownerCanvasId!==rootId);assert.ok(sourceFrame&&sourceMember);
@@ -90,5 +96,5 @@ try{
await engine.close();adoptionOnly=false;aiReport=await verifyFoundryAI({open:()=>openProjectEngine({...targetOptions,runs:createFoundryRuns({workspaceId,rootId:targetRoot,registry,transport})}),transport,rootId:targetRoot,imageId:adoptedId,assets,calls,tick,readDisplay:async url=>{const response=await authenticatedFetch(url);assert.equal(response.status,200);return Buffer.from(await response.arrayBuffer());},advance:()=>{now+=10_000;},loseAdmission:()=>{loseNextAIAdmission=true;},loseAction:()=>{loseNextAIAction=true;}});
}
assert.ok(errors.every(error=>String(error).includes('injected response loss')),'unexpected engine error: '+errors.map(String).join(','));
console.log(JSON.stringify({status:'ok',...aiReport,migrationRetry:true,realHttp:true,lostAckRecovered:true,atomicPartition:true,deepCopyUpperEdge:true,nativePartialReport:true,loadingImportOldCapture:true,unloadedTailDuringCopy:true,scopedImageLostAck:true,copiedPendingImageSharesUpload:true,authorizedAssetMetadata:true,originalAssetBytes:true,crossProjectAdoption:true,adoptionReopenedBytes:true,requests:calls.length}));
console.log(JSON.stringify({status:'ok',...aiReport,migrationCoordinator:true,migrationLostAckRecovered:true,migrationRetry:true,realHttp:true,lostAckRecovered:true,atomicPartition:true,deepCopyUpperEdge:true,nativePartialReport:true,loadingImportOldCapture:true,unloadedTailDuringCopy:true,scopedImageLostAck:true,copiedPendingImageSharesUpload:true,authorizedAssetMetadata:true,originalAssetBytes:true,crossProjectAdoption:true,adoptionReopenedBytes:true,requests:calls.length}));
}finally{await engine.close();}
+130
View File
@@ -0,0 +1,130 @@
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);
});