docs(canvas): explain editor entry migration gates and loading lifecycles

This commit is contained in:
2026-11-12 01:26:22 +08:00
parent 450fa3f719
commit f28a2a96bf
9 changed files with 142 additions and 1 deletions
+36
View File
@@ -1,43 +1,60 @@
/**
* 旧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>;
@@ -51,6 +68,7 @@ export function readCanvasMigration(storage:Pick<Storage,'getItem'>,scope:Canvas
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));
}
@@ -60,22 +78,30 @@ export function migrationBlocksCanvas(job:CanvasMigrationJob|undefined,canvasId:
* 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);
@@ -84,24 +110,29 @@ export function createCanvasMigration(options:{
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{
@@ -111,18 +142,22 @@ export function createCanvasMigration(options:{
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();
@@ -134,6 +169,7 @@ export function createCanvasMigration(options:{
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})});