181 lines
14 KiB
TypeScript
181 lines
14 KiB
TypeScript
/**
|
||
* 旧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;
|
||
/** 每个异步边界重查账户与取消状态,避免迟到结果删除另一身份正在使用的记录。 */
|
||
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`;
|
||
/** 写入并回读完全相同的编码,失败不继续发请求;这是本地恢复前置条件,不是服务端成功证明。 */
|
||
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唯一且结构匹配,映射数量必须覆盖迁移后每个元素,不能漏一部分就切换编辑器。
|
||
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或宣称先前零写入。
|
||
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.
|
||
}
|
||
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);
|
||
}),
|
||
});
|
||
}
|