/** * 旧snapshot到canonical模型的身份交接协议,发生在ProjectEngine建立前。 * 每账户/工作区保存一份冻结迁移请求;未知结果保留pending并查询原身份,明确未生效才可重新准备。 * 本地记录、浏览器互斥和服务端幂等/版本校验分别承担恢复、同端互斥和最终写入判定,不能互相替代。 */ 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'; /** 迁移记录按账户与工作区隔离;具体树范围由job.rootId及expected_versions约束。 */ export type CanvasMigrationScope = {readonly accountId:Id;readonly workspaceId:Id}; /** version是本地记录版本,body是冻结的远端协议v2请求字符串;pending表示结果未结清。 */ export type CanvasMigrationJob = CanvasMigrationScope & { readonly version:1;readonly rootId:Id;readonly id:Id;readonly body:string; readonly phase:'pending'|'rejected';readonly rejection?:Json; }; /** idle没有作业;applied/already-migrated可转新版;pending仍需锁住相关旧编辑;rejected有明确否定证据。 */ 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}; /** 最小本地KV端口,不保存凭证;写入/删除后回读确认记录确实可见。 */ type Store = Pick; // 与服务端一致的范围/请求UUID语法,避免恢复任意不受支持的身份。 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');}; /** 迁移版本向量包含根且最多256张canvas,版本必须为正安全整数并给+1结果留出空间。 */ 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; } /** 校验账户/工作区后产生稳定KV键,一工作区同一时刻只有一份身份迁移记录。 */ 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}`; } /** 解析冻结body并对照request_id/协议,返回后续阻塞范围与回执版本校验用的原始版本向量。 */ 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); } /** 只有匹配协议/请求且terminal not-applied的有效JSON回执,才允许认定这次迁移明确未生效。 */ 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; } /** 读取并严格验证本地记录、作用域、body及阶段证据;损坏/未知版本不当作“没有任务”丢弃。 */ 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); } /** 只阻塞pending覆盖的根/子canvas;rejected已有否定证据,不继续用未知结果状态锁编辑。 */ 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. */ /** 注入互斥锁、存储与服务端口,提供只读状态、恢复查询和新迁移准备。 * start必须先drain旧writer,再重新读取版本;resume从未确认的原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; /** 在协调流程标明的异步交接点重查账户与取消;注入 transport/drain/lock 也须遵守自身生命周期契约。 */ const current=()=>{if(!options.isCurrentIdentity())throw fault('identity_changed');options.signal?.throwIfAborted();}; /** 本实例读取也受当前身份护栏约束,原始readCanvasMigration仅负责数据格式。 */ const read=()=>{current();return readCanvasMigration(options.storage,options.scope);}; /** 迁移/回执查询始终按冻结root寻址,不按当前页面任意子分区重定向。 */ const path=(root:Id)=>`workspaces/${encodeURIComponent(options.scope.workspaceId)}/materials/${encodeURIComponent(root)}/canvas/model`; /** 写入并回读完全相同的编码,失败不继续发请求;这是本地恢复前置条件,不是服务端成功证明。 */ // 同步回读验证当前 Storage 可见值;不等于跨设备副本、数据库事务或磁盘刷写证明。 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'); } /** 解释已返回结果。肯定/否定回执都要核身份;无法验证时返回pending,保留原请求供后续查证。 */ 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; // 明确终结否定证据单独保存为rejected,用户下次start可刷新基线并生成新请求。 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); // applied必须对应冻结源版本,且包含可验证的新模型及完整的新旧元素身份映射。 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'); // 源身份用canvas+结构+旧ID三元组,防止不同旧分区里的同名元素被错误合并。 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); } // 目标ID唯一且结构匹配,映射数量必须覆盖迁移后每个元素,不能漏一部分就切换编辑器。 // 这里没有重新读旧快照逐项核对 sourceId;源清单真实性仍依赖服务端迁移协议和冻结版本。 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'};} // 只在整个成功回执通过后删除本地pending;删除也须回读确认,失败保留重试义务。 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'}; } /** GET查原请求或POST精确原body;网络异常不等于未执行,只返回pending继续保留冻结身份。 */ 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. // 此协议允许unknown后重发同一幂等请求,绝不因此改requestId/body或宣称先前零写入。 // 仅 query 分支转一次 POST;POST 仍未知则 accept 返回 pending,不在这里无限轮询。 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); } /** active防实例内重入,注入lock防跨实例/页面冲突;所有路径finally撤销实例忙标记。 */ 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, /** 没有记录返回idle;rejected只回报既有证据,pending查询/续发原请求。 */ 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); }), /** pending优先恢复;无未决任务才drain并重读源版本。已是新模型直接返回already-migrated。 */ 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. } // drain 的范围由宿主保证;本协调器等待它完成,不自行枚举/停止旧 writer。 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'); // 新请求必须区别于上次已否定身份;冻结版本和body先写本地记录,再触发第一次网络副作用。 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); }), }); }