docs(canvas): clarify entry and migration lifecycle

This commit is contained in:
2026-11-12 16:01:25 +08:00
parent 84c50fe595
commit 5803787910
5 changed files with 16 additions and 1 deletions
+3
View File
@@ -12,6 +12,7 @@ export type LegacyMigrationContext={accountId:string;rootId?:string;registry:Reg
/** 锁/Storage可注入测试,生产默认使用navigator.locks/localStorage;身份和signal都由宿主管理。 */
type BrowserScope={scope:CanvasMigrationScope;isCurrentIdentity():boolean;signal:AbortSignal;locks?:LockManager;storage?:Storage};
/** 同账户/工作区共享编辑锁键,与持久迁移记录同一作用域,跨页面形成读写互斥。 */
// 键不含 rootId:独占迁移锁会暂时排斥同账户/工作区所有共享旧编辑租约,而非仅目标树。
const lockKey=(scope:CanvasMigrationScope)=>canvasMigrationStorageKey(scope)+':editing';
/** 缺少Web Locks明确拒绝安全迁移装配,不用不可靠的计时器冒充独占锁。 */
function ports(options:BrowserScope){
@@ -30,6 +31,7 @@ export async function claimLegacyCanvasEditing(options:BrowserScope&{canvasId:st
let resolve!:(value:{release():Promise<void>})=>void,reject!:(error:unknown)=>void;
// ready在锁授予且本地pending检查通过后返回;request则在release/abort后才结束,两者用途不同。
const ready=new Promise<{release():Promise<void>}>((yes,no)=>{resolve=yes;reject=no;});
// signal 未传给 locks.request;取消在回调授予时检查,持锁后通过 abort 监听释放。
const request=locks.request(lockKey(options.scope),{mode:'shared',ifAvailable:true},async lock=>{
try{
check(options);if(!lock)throw fault('migration_busy');
@@ -56,6 +58,7 @@ export function createBrowserCanvasMigration(options:BrowserScope&{registry:Regi
isCurrentIdentity:options.isCurrentIdentity,signal:options.signal,createId:()=>crypto.randomUUID(),
// 任一共享旧编辑页仍开着时独占锁不可用;调用方先保存/释放自己的编辑租约,再进入迁移。
lock:work=>locks.request(lockKey(options.scope),{mode:'exclusive',ifAvailable:true},async lock=>{
// work 的 Promise 整体保持独占租约;拒绝同样交还锁,pending 记录不会随锁释放而自动删除。
check(options);if(!lock)throw fault('migration_busy');return work();
}),
});
+3
View File
@@ -17,6 +17,7 @@ import { loadFoundryModel, type ModelLoad, type ServiceTransport } from './canva
export async function loadCanvasEntry(options: { request: CanvasRequest; workspaceRef: string; boardRef: string; registry: Registry; transport?(accountId: string): ServiceTransport; readModel?(input:{accountId:string;workspaceId:string;canvasId:string;signal?:AbortSignal;navigationGeneration:number;onPreview?:(preview:LiveModelPreview)=>void}):Promise<ModelLoad>;onPreview?:(preview:LiveModelPreview,board:Board,profile:Profile)=>void;signal?: AbortSignal;navigationGeneration?:number }) {
const path = `workspaces/${encodeURIComponent(options.workspaceRef)}/materials/${encodeURIComponent(options.boardRef)}`;
// slug/路由ref先解析成真实ID,画布kind、引用和工作区/账户信息必须匹配再选择服务作用域。
// 两个读取共享取消信号,但任一拒绝不会自动取消另一个;这里不分配页面状态或编辑租约。
const [board, profile] = await Promise.all([options.request<Board>(path, { signal: options.signal }), options.request<Profile>('profile', { signal: options.signal })]);
options.signal?.throwIfAborted();
if (board.kind !== 'board' || !matchesRef(board, options.boardRef) || !board.workspace_id || !profile.id) throw new Error('invalid_reference');
@@ -24,6 +25,7 @@ export async function loadCanvasEntry(options: { request: CanvasRequest; workspa
let migration:Extract<ModelLoad,{status:'migration-required'}>|undefined;
try {
// 预览回调携带同次读取的board/profile,navigationGeneration由共享loader隔离导航结果。
// 注入的 readModel 优先于 transport;入口本身不去重请求,也不保存或撤销已发出的预览。
const model = options.readModel?await options.readModel({accountId:profile.id,workspaceId:board.workspace_id,canvasId:board.id,signal:options.signal,navigationGeneration:options.navigationGeneration??0,...(options.onPreview?{onPreview:(preview:LiveModelPreview)=>options.onPreview!(preview,board,profile)}:{})}):await loadFoundryModel(options.transport!(profile.id), board.workspace_id, board.id, options.registry, options.signal);
options.signal?.throwIfAborted();
// 新模型一旦ready立即走新版,不能因旧数据端点仍存在就继续让旧writer写它。
@@ -39,5 +41,6 @@ export async function loadCanvasEntry(options: { request: CanvasRequest; workspa
}
// 旧请求显式协商v2快照;调用页面仍负责对当前导航/身份校验,不在这里安装页面状态。
const snapshot = decodeSnapshot(await options.request(path + '/canvas', { signal: options.signal, headers: { Accept: CANVAS_ACCEPT } }));
// 此回退路径在 await 后没有再次 throwIfAborted;宿主仍须核对导航/身份后才能采用结果。
return { mode: 'legacy' as const, board, profile, snapshot,...(migration?{migration}:{}) };
}
+2
View File
@@ -10,7 +10,9 @@ import { matchesRef, type Board } from "./workspace.ts";
* 返回数据不直接更新UI,导航是否仍有效由调用方控制。 */
export async function loadBoardCanvas(request: CanvasRequest, workspaceRef: string, boardRef: string, signal?: AbortSignal) {
const path = `workspaces/${encodeURIComponent(workspaceRef)}/materials/${encodeURIComponent(boardRef)}`;
// 两次请求同时启动;没有结果发布,也不会因一个失败主动取消另一个。
const [board, snapshot] = await Promise.all([request<Board>(path, { signal }), request<CanvasSnapshot>(path + "/canvas", { signal, headers: { Accept: CANVAS_ACCEPT } })]);
if (board.kind !== "board" || !matchesRef(board, boardRef) || typeof board.workspace_id !== "string" || !board.workspace_id) throw new Error("invalid_reference");
// decodeSnapshot 验证旧协议快照;此函数不另做 await 后取消检查或 canonical 迁移状态探测。
return { board, snapshot: decodeSnapshot(snapshot) };
}
+5 -1
View File
@@ -85,13 +85,14 @@ export function createCanvasMigration(options:{
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');
@@ -123,6 +124,7 @@ export function createCanvasMigration(options:{
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;删除也须回读确认,失败保留重试义务。
@@ -143,6 +145,7 @@ export function createCanvasMigration(options:{
// 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);
}
@@ -165,6 +168,7 @@ export function createCanvasMigration(options:{
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};
+3
View File
@@ -13,6 +13,7 @@ export function createResourceLoader<T>(load: (key: string, signal: AbortSignal)
// cache保存完成值,jobs保存当前可加入的尝试,queue只保存尚未占并发槽的job。
const cache = new Map<string, T>();
const jobs = new Map<string, Job>();
// FIFO 使用数组 shift/splice;不是按优先级排序的 heap,也不持久化等待项或失败重试。
const queue: Job[] = [];
let active = 0;
/** 命中后移到Map尾部,更新近用顺序;只读缓存,不启动网络。 */
@@ -34,6 +35,7 @@ export function createResourceLoader<T>(load: (key: string, signal: AbortSignal)
return load(job.key, job.controller.signal);
}).then((value) => {
if (!job.controller.signal.aborted) {
// 缓存直接借用 load 的结果引用;订阅者共享同一对象,调用方须保证可安全共享/只读。
cache.set(job.key, value);
// 超出缓存容量淘汰最久未触及key,不影响仍在运行的其它job。
while (cache.size > cacheSize) cache.delete(cache.keys().next().value!);
@@ -74,5 +76,6 @@ export function createResourceLoader<T>(load: (key: string, signal: AbortSignal)
pump();
});
}
// 实例没有 dispose/clear:宿主通过订阅信号释放任务;缓存随实例不再被引用后交给 GC。
return { request, peek };
}