From 5803787910032c51fb9524187cee775284b03e46 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 16:01:25 +0800 Subject: [PATCH] docs(canvas): clarify entry and migration lifecycle --- src/lib/browser-canvas-migration.ts | 3 +++ src/lib/canvas-entry.ts | 3 +++ src/lib/canvas-loader.ts | 2 ++ src/lib/canvas-migration.ts | 6 +++++- src/lib/resource-loader.ts | 3 +++ 5 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/lib/browser-canvas-migration.ts b/src/lib/browser-canvas-migration.ts index 1037bcd..dcdf4d3 100644 --- a/src/lib/browser-canvas-migration.ts +++ b/src/lib/browser-canvas-migration.ts @@ -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,reject!:(error:unknown)=>void; // ready在锁授予且本地pending检查通过后返回;request则在release/abort后才结束,两者用途不同。 const ready=new Promise<{release():Promise}>((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(); }), }); diff --git a/src/lib/canvas-entry.ts b/src/lib/canvas-entry.ts index 33a6d20..45e6e90 100644 --- a/src/lib/canvas-entry.ts +++ b/src/lib/canvas-entry.ts @@ -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;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(path, { signal: options.signal }), options.request('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|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}:{}) }; } diff --git a/src/lib/canvas-loader.ts b/src/lib/canvas-loader.ts index 11f142d..61c45a2 100644 --- a/src/lib/canvas-loader.ts +++ b/src/lib/canvas-loader.ts @@ -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(path, { signal }), request(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) }; } diff --git a/src/lib/canvas-migration.ts b/src/lib/canvas-migration.ts index 9c6e87b..1b65592 100644 --- a/src/lib/canvas-migration.ts +++ b/src/lib/canvas-migration.ts @@ -85,13 +85,14 @@ export function createCanvasMigration(options:{ 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'); @@ -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}; diff --git a/src/lib/resource-loader.ts b/src/lib/resource-loader.ts index ee272a3..ac7bfdc 100644 --- a/src/lib/resource-loader.ts +++ b/src/lib/resource-loader.ts @@ -13,6 +13,7 @@ export function createResourceLoader(load: (key: string, signal: AbortSignal) // cache保存完成值,jobs保存当前可加入的尝试,queue只保存尚未占并发槽的job。 const cache = new Map(); const jobs = new Map(); + // FIFO 使用数组 shift/splice;不是按优先级排序的 heap,也不持久化等待项或失败重试。 const queue: Job[] = []; let active = 0; /** 命中后移到Map尾部,更新近用顺序;只读缓存,不启动网络。 */ @@ -34,6 +35,7 @@ export function createResourceLoader(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(load: (key: string, signal: AbortSignal) pump(); }); } + // 实例没有 dispose/clear:宿主通过订阅信号释放任务;缓存随实例不再被引用后交给 GC。 return { request, peek }; }