From f78618be2b2d6d72049d033ef4abdf6144feb00e Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 15:54:01 +0800 Subject: [PATCH 1/3] docs(service): clarify Foundry protocol validation boundaries --- src/lib/canvas-service/foundry.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/lib/canvas-service/foundry.ts b/src/lib/canvas-service/foundry.ts index c9eb650..77084da 100644 --- a/src/lib/canvas-service/foundry.ts +++ b/src/lib/canvas-service/foundry.ts @@ -38,7 +38,8 @@ const pathFor = (workspace: Id, canvas: Id) => `workspaces/${encodeURIComponent( /** Credentials stay in the host/session. Every write additionally binds the expected account. */ /** 默认将请求发送到同源代理,凭证留在宿主会话,不塞进请求描述或恢复镜像。 - * 发出前与收到后均检查当前身份,带 X-Expected-Account 供服务端复查账户;跨身份响应不能交给旧调用者。 */ + * 发出前与收到后均检查当前身份,带 X-Expected-Account 供服务端复查账户;跨身份响应不能交给旧调用者。 + * fetch 在创建时选定;其余 options 在请求时读取,宿主应维持此适配器的账户/路径绑定稳定。 */ export function createFoundryTransport(options: { accountId: Id; isCurrentIdentity(): boolean; fetch?: typeof fetch; basePath?: string }): ServiceTransport { if (!uuid.test(options.accountId)) throw fault('invalid_account_identity'); const requestFetch = options.fetch ?? globalThis.fetch; @@ -51,6 +52,7 @@ export function createFoundryTransport(options: { accountId: Id; isCurrentIdenti } }); if (!options.isCurrentIdentity()) { await response.body?.cancel(); throw fault('identity_changed'); } // 成功的资源字节读取同时约束 Content-Length 提示和实际累计20MiB,不能只信响应头。 + // 分块仍全部保留至 Blob 构造;这是载荷预算,不是总堆内存上限,也不验证摘要或图片解码。 if(input.responseFormat==='asset-bytes-v1'&&response.status===200){ const maximum=20*1024*1024,length=response.headers.get('content-length'); if(length!==null&&(!/^\d+$/.test(length)||Number(length)>maximum)){await response.body?.cancel();throw fault('asset_byte_budget_exceeded');} @@ -70,7 +72,8 @@ export function createFoundryTransport(options: { accountId: Id; isCurrentIdenti if(!response.body)throw fault('incomplete_model_stream'); const body=response.body; /** 惰性读取字节,每个 await 两侧复查身份;迭代结束、抛错或消费者提前退出都归还 reader 锁。 - * NDJSON 内容和字节预算由 model-stream 解释器执行,此处只提供有生命周期边界的字节源。 */ + * NDJSON 内容和字节预算由 model-stream 解释器执行,此处只提供有生命周期边界的字节源。 + * reader 在首次迭代时才取得;从不消费此生成器时,不会进入这里的 finally 清理。 */ async function* stream(){ const reader=body.getReader(),abort=()=>{void reader.cancel().catch(()=>{});};input.signal?.addEventListener('abort',abort,{once:true}); try{while(true){input.signal?.throwIfAborted();if(!options.isCurrentIdentity())throw fault('identity_changed');const next=await reader.read();input.signal?.throwIfAborted();if(!options.isCurrentIdentity())throw fault('identity_changed');if(next.done)break;yield next.value;}} @@ -147,6 +150,7 @@ function statusFor(request: FrozenRequest, response: ServiceResponse, query: boo return freeze({ outcome: 'applied', canonical: JSON.parse(JSON.stringify(body)) as Json }); } if (matching && body.status === 'accepted' && response.status >= 200 && response.status < 300) return { outcome: 'accepted', retryAfterMs: response.retryAfterMs }; + // 终结未生效分支只要求协议/请求匹配和 terminal;不复用 applied 的 HTTP 与操作覆盖校验。 if (matching && body.status === 'not-applied' && body.terminal === true) return { outcome: 'not-applied', error: error ?? 'not_applied' }; // 查询不到回执不等于从未执行,继续使用原请求身份查证;普通4xx未提供终结证据时阻塞。 if (query && response.status === 404 && body.status === 'unknown' && body.request_id === request.id) return { outcome: 'unresolved', error: 'operation_receipt_unknown', retryAfterMs: response.retryAfterMs }; @@ -154,7 +158,8 @@ function statusFor(request: FrozenRequest, response: ServiceResponse, query: boo return { outcome: 'unresolved', error: error ?? 'transport_unknown', retryAfterMs: response.retryAfterMs }; } /** 发送/查询/解释前交叉核对冻结元数据与请求体:协议、request_id、预期版本、画布集合和操作覆盖一致。 - * 不在这里重新编译 payload,重试必须沿用已经保存的精确 body。 */ + * 不在这里重新编译 payload,重试必须沿用已经保存的精确 body。 + * 这是冻结描述的一致性检查,不重新执行每项 change 的 schema、条件或依赖验证。 */ function validateFrozen(request: FrozenRequest) { let body: Record; try { body = record(JSON.parse(request.body)); } catch { throw fault('invalid_frozen_request'); } @@ -207,15 +212,17 @@ export function createFoundryPersistence(options: { workspaceId: Id; rootId: Id; } // The body is the durable coverage mapping. Never replace logical IDs with a synthetic batch ID. // projectBatch 基于 ACK 与 ledger 合并条件字段,同时保留逻辑覆盖;JSON复制使描述脱离调用方引用。 + // 返回值尚不是持久冻结的 body 字符串;保存请求、调度和重试由 runtime 完成。 const payload = JSON.parse(JSON.stringify({ protocol_version: 2, request_id: requestId, expected_versions: expectedVersions, operations: projectBatch(intents, snapshot.runtime.acknowledged, snapshot.runtime.ledger, canvasIds) })) as Json; if (!validJson(payload)) throw fault('invalid_operation_payload'); return freeze({ protocolVersion: 2, canvasIds, expectedVersions, payload }); } return Object.freeze({ - /** 此适配器仅解释远端协议v2,恢复的其它版本不能猜测着继续发送。 */ + /** 协议版本能力门,只声明可解释 v2;不是对冻结 body 完整合法性的认证。 */ supportsRequest: (request: FrozenRequest) => request.protocolVersion === 2, /** 仅在调用方已协调成功且请求resolved/applied后尝试摘要。 - * 检查参与版本与已采用结果一致,未知字段/未知组件/额外格式信息一律保留原始回执。 */ + * 检查参与版本与已采用结果一致,未知字段/未知组件/额外格式信息一律保留原始回执。 + * 此处信任 applied 已经协调采用,不再次逐元素比较 applied.document;摘要不能独立重建文档。 */ ...(options.resolvedReceiptPolicy==='summary'?{compactResolvedReceipt(request:FrozenRequest,receipt:Json,applied:import('../canvas-runtime/contracts.ts').CanonicalProposal):Json{ if(request.kind!=='document.change'||request.protocolVersion!==2||request.phase!=='resolved'||request.outcome!=='applied')return receipt; validateFrozen(request);const body=record(receipt); @@ -257,6 +264,7 @@ export function createFoundryPersistence(options: { workspaceId: Id; rootId: Id; query: (request: FrozenRequest, signal: AbortSignal) => execute(request, true, signal), /** 从原始回执提取参与者模型,合成新的 ACK 候选;不接受本地已压缩摘要代替原模型。 */ canonical(request: FrozenRequest, status: OperationStatus, snapshot: EngineSnapshot) { + // 按已确认的 applied 结果调用;此处核对身份并组装模型候选,不代替结果解释或版本推进检查。 assertRawReceipt(status.canonical);const body = record(status.canonical); if (body.request_id !== request.id || body.protocol_version !== 2) throw fault('receipt_identity_mismatch'); return mergeFoundryModel(body.model, request.canvasIds, snapshot, options.registry, options.rootId); @@ -280,6 +288,7 @@ export function verifyFoundryModelProof(value:unknown,participants:readonly Id[] if(!equal(canvases.get(canvas),proposal.document.canvases.get(canvas)))throw fault('canonical_version_mismatch',[canvas]); unchanged.add(canvas); } + // 更高版本跳过逐字段相等检查,允许后续合法修改;同版本(包括删除记录)必须保持既有内容。 // 对仍处于同版本的 canvas 逐元素比较,既不能缺元素也不能多元素;墓碑只能单调推进。 const expected=new Map(chunk.elements.filter(element=>unchanged.has(element.ownerCanvasId)).map(element=>[element.id,element])); let count=0;for(const element of proposal.document.elements.values())if(unchanged.has(element.ownerCanvasId)){count++;if(!equal(expected.get(element.id),element))throw fault('canonical_version_mismatch',[element.ownerCanvasId]);} @@ -294,7 +303,9 @@ export function verifyFoundryModelProof(value:unknown,participants:readonly Id[] export function mergeFoundryModel(value: unknown, participants: readonly Id[], snapshot: EngineSnapshot, registry: Registry, rootId: Id) { const chunk = modelChunk(value); if (chunk.projectId !== rootId || chunk.complete || chunk.versions.size !== participants.length || participants.some(id => !chunk.versions.has(id))) throw fault('receipt_coverage_mismatch'); + // 必须从 ACK 构造,不把 working 中尚未确认的本地内容冒充服务端已接纳内容。 const previous = snapshot.runtime.acknowledged, canvases = new Map(previous.canvases), elements = new Map(previous.elements), tombstones = new Map(previous.tombstones), serverVersions = new Map(snapshot.runtime.versions); + // 先全部删除参与 canvas;removed 参与者不在返回列表内,因此保留版本但不重新放回画布记录。 for (const canvas of participants) canvases.delete(canvas); for (const canvas of chunk.canvases) canvases.set(canvas.id, canvas); // 先移除旧参与者成员,再加入回执成员;与非参与者现有ID碰撞则拒绝,不能跨范围覆盖。 @@ -326,6 +337,7 @@ export function mergeFoundryRunContext(value: unknown, snapshot: EngineSnapshot, for(const canvas of chunk.canvases)if(replace.has(canvas.id))canvases.set(canvas.id,canvas); for(const [id,element] of elements)if(replace.has(element.ownerCanvasId))elements.delete(id); for(const element of chunk.elements)if(replace.has(element.ownerCanvasId)){if(elements.has(element.id))throw fault('canonical_scope_mismatch',[element.id]);elements.set(element.id,element);} + // 墓碑取全上下文的逐 ID 最大值,即使对应 canvas 因版本较旧未替换,也不能让生命周期倒退。 for(const [id,generation] of chunk.tombstones)tombstones.set(id,Math.max(tombstones.get(id)??0,generation)); const document=createDocument(rootId,[...canvases.values()],[...elements.values()],tombstones);validateDocument(document,registry); return Object.freeze({document,versions:readMap(serverVersions)}); From 84c50fe59550350331f9bbbd825b17bb2e4cee14 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 15:57:33 +0800 Subject: [PATCH 2/3] docs(app): explain request and directory lifecycle boundaries --- src/lib/api.ts | 5 ++++- src/lib/board-directory.ts | 3 +++ src/lib/canvas-response.ts | 4 +++- src/lib/notifications.ts | 3 ++- src/lib/session-cookie.ts | 5 +++-- src/lib/utils.ts | 3 ++- src/lib/workspace.ts | 2 ++ 7 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index 66e3189..3e7c5c9 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -27,9 +27,11 @@ export async function api( path: string, options: RequestInit = {}, ): Promise { + // fetch 本身拒绝(网络失败/发出前取消)时原异常直接传播;下面的 ApiError 包装只覆盖响应读取与 HTTP 错误。 const res = await fetch("/api/foundry/" + path, { ...options, - // FormData让浏览器生成boundary,其它请求默认JSON;调用方headers可覆盖默认Content-Type。 + // FormData让浏览器生成boundary,其它请求默认JSON;这里只展开 headers 的自有可枚举字段。 + // 普通对象可覆盖同名默认字段;Headers 实例/二元组数组不会在这里被规范化成对象键值。 headers: { ...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }), ...options.headers }, }); let body: unknown; @@ -49,6 +51,7 @@ export async function api( const code = typeof payload?.error === "object" ? payload.error?.code : payload?.error; throw new ApiError(typeof code === "string" ? code : "request_failed", res.status, payload?.details, res.headers.get("retry-after")); } + // 204 的 null 同样通过类型断言返回;调用者选 T 时须包含真实的空响应约定。 return body as T; } /** 公开项目标识,来自构建时NEXT_PUBLIC配置;不是密码或用户账户身份。 */ diff --git a/src/lib/board-directory.ts b/src/lib/board-directory.ts index 08b28d1..d2b71bb 100644 --- a/src/lib/board-directory.ts +++ b/src/lib/board-directory.ts @@ -10,6 +10,7 @@ type Request = (path: string, options?: RequestInit) => Promise; /** 分页取齐board后返回按ID去重的列表,后出现的同ID记录更新内容。 * next_offset必须严格前进且安全,seen防重复游标,不能只拿首页就推断某canvas没有父分区。 */ export async function loadBoardDirectory(request: Request, workspace: string, signal?: AbortSignal) { + // Map 是本次读取的临时去重表,全部分页成功才返回;某页失败不发布已积累的半份目录。 const boards = new Map(); let offset = 0; const seen = new Set(); @@ -28,6 +29,7 @@ export type BoardBranch = { board: Board; children: BoardBranch[] }; /** 每条记录沿父链检查循环;父缺失或成环时作为展示根,避免递归挂载成无限树。 * 同层顺序沿用输入列表,不在此另做排序或写回parent_board_id。 */ export function boardDirectory(boards: Board[]): BoardBranch[] { + // 重复 ID 采用最后一条数据、沿用首次插入顺序;分支节点是新对象,board 本身仍借用输入引用。 const entries = new Map(boards.map((board) => [board.id, { board, children: [] as BoardBranch[] }])); const roots: BoardBranch[] = []; for (const entry of entries.values()) { @@ -56,6 +58,7 @@ export function filterBoardDirectory(branches: BoardBranch[], query: string): Bo if (!term) return branches; return branches.flatMap((branch) => { if (branch.board.title.toLocaleLowerCase().includes(term)) return [branch]; + // 只重建需要裁剪 children 的祖先容器;直接命中的分支复用原对象,不深复制整棵树。 const children = filterBoardDirectory(branch.children, term); return children.length ? [{ ...branch, children }] : []; }); diff --git a/src/lib/canvas-response.ts b/src/lib/canvas-response.ts index a85bb09..b0fcc0b 100644 --- a/src/lib/canvas-response.ts +++ b/src/lib/canvas-response.ts @@ -14,6 +14,7 @@ export async function fetchCanvasResponse(url: string, options: RequestInit, lim const abort = new AbortController(); // 外部请求取消与内部超时任一生效都中止同一上游fetch,避免客户端已离开却继续传输。 const signal = options.signal ? AbortSignal.any([options.signal, abort.signal]) : abort.signal; + /** 三种时限共用内部取消源;只发送取消信号,实际唤醒等待依赖 request/reader 遵守取消契约。 */ const timeout = () => abort.abort(new DOMException("Canvas transfer timed out", "TimeoutError")); // 初始计时器只约束拿响应头,取得Response后清掉,再给body设置独立总时限。 let timer = setTimeout(timeout, limits.headersMs); @@ -38,7 +39,8 @@ export async function fetchCanvasResponse(url: string, options: RequestInit, lim }; let idle: ReturnType | undefined; let finished = false; - /** 标记结束并清除总/空闲计时与组合signal监听,后续迟到read不能再次enqueue或重复结束。 */ + /** 标记结束并清除总/空闲计时与组合signal监听,后续迟到read不能再次enqueue或重复结束。 + * 这里只清理调度资源,不 releaseLock;reader 的取消在中断/下游 cancel 路径另行执行。 */ const cleanup = () => { finished = true; clearTimeout(timer); clearTimeout(idle); signal.removeEventListener("abort", interrupted); }; let interrupt: (reason: unknown) => void; /** 将组合signal的取消原因交给流start中安装的中断处理器。 */ diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 9530b52..7670949 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -7,7 +7,8 @@ import { toast } from "sonner"; /** Application adapter. Domain/editor packages must not import Sonner. */ -/** 稳定id用于更新/关闭同一通知,level决定显示样式,description是可选补充文案。 */ +/** 稳定id用于更新/关闭同一通知,level决定显示样式,description是可选补充文案。 + * level 的联合类型是调用约束,运行时不另行验证;调用方必须提供通知库支持的级别。 */ export type Notification = { id: string; title: string; description?: string; level?: "info" | "success" | "warning" | "error" }; /** 按指定level调用Sonner,默认info;相同id的合并/更新行为交给通知库。 */ export function notify({ id, title, description, level = "info" }: Notification) { diff --git a/src/lib/session-cookie.ts b/src/lib/session-cookie.ts index d6f20a8..8a7a2dc 100644 --- a/src/lib/session-cookie.ts +++ b/src/lib/session-cookie.ts @@ -2,7 +2,7 @@ * 服务端会话Cookie的纯字符串与寿命辅助,不读取或写入浏览器Cookie。 * 调用方决定何时采用上游过期提示、是否Secure,并把返回字符串放进响应Set-Cookie。 */ -/** 本应用默认空闲期上限30天,单位秒。 */ +/** 会话 Cookie 的默认寿命上限30天,单位秒;是否滑动续期由调用方重新设置 Cookie 决定。 */ export const SESSION_IDLE_SECONDS = 30 * 24 * 60 * 60; /** 上游向代理传递剩余会话寿命的响应头名,不是Cookie名。 */ export const SESSION_EXPIRY_HEADER = "X-Foundry-Session-Expires-In"; @@ -12,7 +12,8 @@ export function sessionMaxAge(value: unknown, fallback = SESSION_IDLE_SECONDS) { const seconds = typeof value === "number" ? value : typeof value === "string" && /^\d+$/.test(value) ? Number(value) : NaN; return Number.isSafeInteger(seconds) && seconds >= 0 ? Math.min(seconds, SESSION_IDLE_SECONDS) : fallback; } -/** URL编码会话值,固定HttpOnly/SameSite=Lax/根路径并规范Max-Age;secure参数为true才添加Secure。 */ +/** URL编码会话值,固定HttpOnly/SameSite=Lax/根路径并规范Max-Age;secure参数为true才添加Secure。 + * maxAge 非法时使用默认30天,不自动推断为登出;明确清除须传0。此处不设置 Domain 或 Expires。 */ export function sessionCookie(value: string, maxAge: number, secure: boolean) { return `foundry_session=${encodeURIComponent(value)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${sessionMaxAge(maxAge)}${secure ? "; Secure" : ""}`; } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index b8a3697..03e7598 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -3,7 +3,8 @@ */ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; -/** clsx负责布尔条件和嵌套类名,twMerge按其规则处理同类覆盖;不解析业务状态或生成CSS。 */ +/** clsx负责布尔条件和嵌套类名,twMerge按其规则处理同类覆盖;不解析业务状态或生成CSS。 + * 后项优先只适用于 twMerge 能识别的冲突组,不能据此保证任意自定义 CSS 都按参数顺序覆盖。 */ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } diff --git a/src/lib/workspace.ts b/src/lib/workspace.ts index 2980c86..f0372a1 100644 --- a/src/lib/workspace.ts +++ b/src/lib/workspace.ts @@ -37,6 +37,7 @@ export async function loadWorkspaceHome( request: Request, signal?: AbortSignal, ) { + // 任一读取拒绝则整体拒绝,Promise.all 不会自动取消另一请求;共同 signal 由宿主管理。 const [profile, result] = await Promise.all([ request("profile", { signal }), request("workspaces", { signal }), @@ -62,6 +63,7 @@ export async function createBoard( body: JSON.stringify({ name: "个人空间" }), }); id = workspace.id; + // 回调在创建画布前同步执行;回调抛错也会阻止后续 POST,但已创建的空间仍存在。 onWorkspaceCreated(workspace); } const name = title.trim() || DEFAULT_BOARD_TITLE; From 5803787910032c51fb9524187cee775284b03e46 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 16:01:25 +0800 Subject: [PATCH 3/3] 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 }; }