Files
Endoleap/src/lib/canvas-migration.ts
T

185 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 旧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<Storage,'getItem'|'setItem'|'removeItem'>;
// 与服务端一致的范围/请求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<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');};
/** 迁移版本向量包含根且最多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<Id,number>;
}
/** 校验账户/工作区后产生稳定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<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);
}
/** 只阻塞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<T>(work:()=>Promise<T>):Promise<T>;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<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;
// 明确终结否定证据单独保存为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<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);
}
// 目标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<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.
// 此协议允许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<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,
/** 没有记录返回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<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.
}
// 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);
}),
});
}