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; 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=>{ if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_migration_record');return value as Record; }; const exact=(value:Record,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; } 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,scope:CanvasMigrationScope):CanvasMigrationJob|undefined{ const text=storage.getItem(canvasMigrationStorageKey(scope));if(text===null)return; let raw:Record; 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(work:()=>Promise):Promise;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{ current();let body:Record; 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(),targets=new Set(); 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{ 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|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){ 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)=>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); }), }); }