docs(canvas): explain editor entry migration gates and loading lifecycles
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
/**
|
||||
* 工作区画布目录的读取与展示树派生。完整分页包含子分区,随后用parent_board_id构建侧栏关系。
|
||||
* 显示树可保守容纳孤儿/循环记录,不据此修改服务端owner,也不替代文档模型中的canvas树校验。
|
||||
*/
|
||||
import { matchesRef, type Board } from "./workspace.ts";
|
||||
|
||||
/** 页面注入的通用JSON请求能力,传入signal供导航/卸载取消。 */
|
||||
type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
|
||||
/** Include children and finish pagination before building ownership paths. */
|
||||
/** 分页取齐board后返回按ID去重的列表,后出现的同ID记录更新内容。
|
||||
* next_offset必须严格前进且安全,seen防重复游标,不能只拿首页就推断某canvas没有父分区。 */
|
||||
export async function loadBoardDirectory(request: Request, workspace: string, signal?: AbortSignal) {
|
||||
const boards = new Map<string, Board>();
|
||||
let offset = 0;
|
||||
@@ -16,7 +23,10 @@ export async function loadBoardDirectory(request: Request, workspace: string, si
|
||||
offset = page.next_offset;
|
||||
}
|
||||
}
|
||||
/** 侧栏派生分支:保留原board记录引用,children是本次展示树容器。 */
|
||||
export type BoardBranch = { board: Board; children: BoardBranch[] };
|
||||
/** 每条记录沿父链检查循环;父缺失或成环时作为展示根,避免递归挂载成无限树。
|
||||
* 同层顺序沿用输入列表,不在此另做排序或写回parent_board_id。 */
|
||||
export function boardDirectory(boards: Board[]): BoardBranch[] {
|
||||
const entries = new Map(boards.map((board) => [board.id, { board, children: [] as BoardBranch[] }]));
|
||||
const roots: BoardBranch[] = [];
|
||||
@@ -33,12 +43,14 @@ export function boardDirectory(boards: Board[]): BoardBranch[] {
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
/** 按ID或slug找到当前项,包含自身向上收集祖先ID,重复ID即停,供侧栏自动展开。 */
|
||||
export function activeBoardAncestors(boards: Board[], ref?: string) {
|
||||
const result = new Set<string>();
|
||||
let item = boards.find((board) => matchesRef(board, ref));
|
||||
while (item && !result.has(item.id)) { result.add(item.id); item = boards.find((board) => board.id === item!.parent_board_id); }
|
||||
return result;
|
||||
}
|
||||
/** 忽略首尾空白并做本地小写匹配;父标题命中保留整子树,否则保留命中后代及其路径祖先。 */
|
||||
export function filterBoardDirectory(branches: BoardBranch[], query: string): BoardBranch[] {
|
||||
const term = query.trim().toLocaleLowerCase();
|
||||
if (!term) return branches;
|
||||
|
||||
@@ -1,30 +1,45 @@
|
||||
/**
|
||||
* 迁移协调器的浏览器装配层。旧编辑视图持共享Web Lock,迁移操作取同一键的独占锁,
|
||||
* 配合本地pending记录阻止已发出迁移却结果未知时继续旧格式写入。锁协调本浏览器,服务端版本/幂等校验另行负责。
|
||||
*/
|
||||
import type {Registry} from './canvas-core/types.ts';
|
||||
import {fault} from './canvas-core/values.ts';
|
||||
import {createFoundryTransport} from './canvas-service/foundry.ts';
|
||||
import {canvasMigrationStorageKey,createCanvasMigration,migrationBlocksCanvas,readCanvasMigration,type CanvasMigrationScope} from './canvas-migration.ts';
|
||||
|
||||
/** 页面提供身份、可迁移根和完成后的重开回调;rootId缺失不代表可任意推测迁移范围。 */
|
||||
export type LegacyMigrationContext={accountId:string;rootId?:string;registry:Registry;isCurrentIdentity():boolean;onApplied():void};
|
||||
/** 锁/Storage可注入测试,生产默认使用navigator.locks/localStorage;身份和signal都由宿主管理。 */
|
||||
type BrowserScope={scope:CanvasMigrationScope;isCurrentIdentity():boolean;signal:AbortSignal;locks?:LockManager;storage?:Storage};
|
||||
/** 同账户/工作区共享编辑锁键,与持久迁移记录同一作用域,跨页面形成读写互斥。 */
|
||||
const lockKey=(scope:CanvasMigrationScope)=>canvasMigrationStorageKey(scope)+':editing';
|
||||
/** 缺少Web Locks明确拒绝安全迁移装配,不用不可靠的计时器冒充独占锁。 */
|
||||
function ports(options:BrowserScope){
|
||||
const locks=options.locks??globalThis.navigator?.locks;
|
||||
if(!locks)throw fault('migration_lock_unavailable');
|
||||
return{locks,storage:options.storage??globalThis.localStorage};
|
||||
}
|
||||
/** 获取锁前后均检查身份/取消,旧页面不能借已等待的锁继续对新账户操作。 */
|
||||
const check=(options:BrowserScope)=>{if(!options.isCurrentIdentity())throw fault('identity_changed');options.signal.throwIfAborted();};
|
||||
|
||||
/** Held for the entire lifetime of an editable legacy view, not just each PATCH. */
|
||||
/** 为旧编辑视图整个生命周期借共享锁,release返回Promise确认锁请求已结束。
|
||||
* ifAvailable不排长队;迁移占用或本canvas有未确认迁移时立即拒绝开放旧编辑。 */
|
||||
export async function claimLegacyCanvasEditing(options:BrowserScope&{canvasId:string}){
|
||||
check(options);const{locks,storage}=ports(options);
|
||||
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;});
|
||||
const request=locks.request(lockKey(options.scope),{mode:'shared',ifAvailable:true},async lock=>{
|
||||
try{
|
||||
check(options);if(!lock)throw fault('migration_busy');
|
||||
if(migrationBlocksCanvas(readCanvasMigration(storage,options.scope),options.canvasId))throw fault('migration_response_unknown');
|
||||
check(options);
|
||||
// 回调等待released以保持共享锁;不能在第一次PATCH完成后就把编辑门解除。
|
||||
let unlock!:()=>void;const released=new Promise<void>(yes=>{unlock=yes;});
|
||||
/** 解开锁回调并等待平台释放;resolve可重复调用,已释放后的再次release仍等同一请求结束。 */
|
||||
const release=async()=>{unlock();await request;};
|
||||
/** 页面生命周期中止时释放锁,异步清理异常不制造未处理拒绝。 */
|
||||
const abort=()=>{void release().catch(()=>{});};options.signal.addEventListener('abort',abort,{once:true});
|
||||
resolve({release});
|
||||
try{await released;}finally{options.signal.removeEventListener('abort',abort);}
|
||||
@@ -33,11 +48,13 @@ export async function claimLegacyCanvasEditing(options:BrowserScope&{canvasId:st
|
||||
void request.catch(reject);return ready;
|
||||
}
|
||||
|
||||
/** 连接localStorage、专属账户transport和UUID生成器;真正迁移在同一工作区独占锁内执行。 */
|
||||
export function createBrowserCanvasMigration(options:BrowserScope&{registry:Registry}){
|
||||
check(options);const{locks,storage}=ports(options);
|
||||
return createCanvasMigration({scope:options.scope,registry:options.registry,storage,
|
||||
transport:createFoundryTransport({accountId:options.scope.accountId,isCurrentIdentity:options.isCurrentIdentity}),
|
||||
isCurrentIdentity:options.isCurrentIdentity,signal:options.signal,createId:()=>crypto.randomUUID(),
|
||||
// 任一共享旧编辑页仍开着时独占锁不可用;调用方先保存/释放自己的编辑租约,再进入迁移。
|
||||
lock:work=>locks.request(lockKey(options.scope),{mode:'exclusive',ifAvailable:true},async lock=>{
|
||||
check(options);if(!lock)throw fault('migration_busy');return work();
|
||||
}),
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
/**
|
||||
* 旧画布的图片上传辅助,与新版canvas-service/assets的冻结资源协议分开保留。
|
||||
* 同一次调用的重试沿用FormData和幂等键;这里只返回旧版素材记录,不维护可恢复上传账本。
|
||||
*/
|
||||
/** 由应用注入JSON/FormData请求端口,凭证与错误形状沿用宿主API封装。 */
|
||||
type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
|
||||
/** 旧上传接口的素材响应形状,原件规范URL与尺寸由返回前校验。 */
|
||||
export type CanvasAsset = { id: string; url: string; width: number; height: number; bytes: number; mime_type: string };
|
||||
/** 上传原文件大小上限20MiB,空文件同样拒绝。 */
|
||||
export const ASSET_LIMIT = 20 * 1024 * 1024;
|
||||
/** 仅网络TypeError或5xx且未取消时追加一次同键重试;其它错误直接交给调用方。
|
||||
* 调用方可提供key以跨尝试复用;默认key只在这次函数调用开始时生成,不是持久恢复记录。 */
|
||||
export async function uploadCanvasImage(request: Request, workspace: string, file: Blob, name: string, signal?: AbortSignal, key = crypto.randomUUID()): Promise<CanvasAsset> {
|
||||
if (!file.size || file.size > ASSET_LIMIT) throw new Error("asset_too_large");
|
||||
const form = new FormData(); form.append("image", file, name);
|
||||
/** 两次发送共用同一字节、文件名和key,避免丢响应后新建第二份素材。 */
|
||||
const send = () => request<CanvasAsset>(`workspaces/${encodeURIComponent(workspace)}/assets`, { method: "POST", headers: { "Idempotency-Key": key }, body: form, signal });
|
||||
let asset: CanvasAsset;
|
||||
try { asset = await send(); } catch (error) {
|
||||
if (signal?.aborted || !(error instanceof TypeError || typeof error === "object" && error && "status" in error && Number(error.status) >= 500)) throw error;
|
||||
asset = await send(); // The same bytes/key retry a possibly committed upload.
|
||||
}
|
||||
// 成功HTTP不等于合法素材,继续核对ID、规范源站/路径及正有限尺寸,不接受任意远端URL。
|
||||
if (!asset || typeof asset.url !== "string") throw new Error("invalid_asset_response");
|
||||
let url: URL;
|
||||
try { url = new URL(asset.url); } catch { throw new Error("invalid_asset_response"); }
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 页面打开画布的协议分流入口。先读取 canonical 模型,再决定使用新版 engine 或旧版 snapshot 编辑器,
|
||||
* 防止已迁移项目误走旧快照写入;迁移提示只说明当前可迁移,不能直接作为冻结迁移请求使用。
|
||||
*/
|
||||
import type {LiveModelPreview} from './canvas-runtime/model-preview.ts';
|
||||
import type { Profile } from './api.ts';
|
||||
import type { CanvasRequest } from './canvas-sync.ts';
|
||||
@@ -8,16 +12,21 @@ import type { Registry } from './canvas-core/types.ts';
|
||||
import { loadFoundryModel, type ModelLoad, type ServiceTransport } from './canvas-service/foundry.ts';
|
||||
|
||||
/** Read canonical first: a migrated project must never be opened through snapshot writers. */
|
||||
/** 并行解析画布引用与账户,再按实际工作区/画布ID读取模型。可注入共享readModel以取得渐进预览,
|
||||
* 或注入基础transport做完整读取;ready 返回headless,其它允许回退的情形才读取旧snapshot。 */
|
||||
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');
|
||||
if(!options.readModel&&!options.transport)throw new Error('model_loader_unavailable');
|
||||
let migration:Extract<ModelLoad,{status:'migration-required'}>|undefined;
|
||||
try {
|
||||
// 预览回调携带同次读取的board/profile,navigationGeneration由共享loader隔离导航结果。
|
||||
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写它。
|
||||
if (model.status === 'ready') return { mode: 'headless' as const, board, profile, model };
|
||||
// Existing projects keep their current editor until the user explicitly migrates them.
|
||||
// This read describes eligibility, not a frozen write request: the migration
|
||||
@@ -25,8 +34,10 @@ export async function loadCanvasEntry(options: { request: CanvasRequest; workspa
|
||||
migration=model;
|
||||
} catch (error) {
|
||||
options.signal?.throwIfAborted();
|
||||
// 仅明确not_found允许旧端点回退,身份/权限/协议/网络错误继续抛出,避免掩盖新模型故障。
|
||||
if ((error as { code?: string }).code !== 'not_found') throw error;
|
||||
}
|
||||
// 旧请求显式协商v2快照;调用页面仍负责对当前导航/身份校验,不在这里安装页面状态。
|
||||
const snapshot = decodeSnapshot(await options.request(path + '/canvas', { signal: options.signal, headers: { Accept: CANVAS_ACCEPT } }));
|
||||
return { mode: 'legacy' as const, board, profile, snapshot,...(migration?{migration}:{}) };
|
||||
}
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
/**
|
||||
* 公开图标资源的分片定位、格式校验与共享读取。manifest给出构建产物路径,模块级资源加载器
|
||||
* 合并同分片请求并缓存解析结果;它不保存画布节点状态或私人资源授权。
|
||||
*/
|
||||
import manifest from "./canvas-icon-assets.json" with { type: "json" };
|
||||
import { createResourceLoader } from "./resource-loader.ts";
|
||||
|
||||
/** React绘制可用的SVG基础形状及属性列表,不接收任意HTML/SVG字符串。 */
|
||||
export type IconPaths = [string, Record<string, string | number>][];
|
||||
/** Tabler统一分片,其它名字用稳定31进制散列落入96个Lucide分片;散列仅用于定位而非安全校验。 */
|
||||
export function iconAsset(name: string) {
|
||||
let hash = 0;
|
||||
for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
|
||||
const key = name.startsWith("Tabler") ? "tabler" : `lucide-${hash % 96}`;
|
||||
return `${manifest.root}/${manifest.assets[key as keyof typeof manifest.assets]}`;
|
||||
}
|
||||
// 限定基础图形标签,不提供script、foreignObject或任意节点构造能力。
|
||||
const allowedTags = new Set(["path", "circle", "ellipse", "rect", "line", "polyline", "polygon"]);
|
||||
// 属性名白名单配合值类型与外部引用模式检查,拒绝任意事件处理/URL内容。
|
||||
const allowedAttributes = new Set(["key", "d", "cx", "cy", "r", "rx", "ry", "x", "y", "x1", "x2", "y1", "y2", "width", "height", "points", "fill", "fillRule", "clipRule", "stroke", "strokeWidth", "strokeLinecap", "strokeLinejoin", "opacity", "transform"]);
|
||||
/** 校验图标名、非空且最多100项的路径数组、标签与属性形状,并拒绝url()/javascript:/data:值。
|
||||
* 此处是特定分片结构验证,不宣称具有全文件字节预算或任意SVG净化能力。 */
|
||||
export function validateIconShard(value: unknown): Record<string, IconPaths> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid icon data");
|
||||
for (const [id, paths] of Object.entries(value)) {
|
||||
@@ -23,12 +33,15 @@ export function validateIconShard(value: unknown): Record<string, IconPaths> {
|
||||
}
|
||||
return value as Record<string, IconPaths>;
|
||||
}
|
||||
/** 单例分片加载器使用HTTP强缓存,响应失败或校验失败不写入成功缓存。 */
|
||||
const resources = createResourceLoader(async (url, signal) => {
|
||||
const response = await fetch(url, { signal, cache: "force-cache" });
|
||||
if (!response.ok) throw new Error("图标加载失败");
|
||||
return validateIconShard(await response.json());
|
||||
});
|
||||
/** 缓存命中只取指定图标路径,不发请求;未加载或分片没有该名均返回undefined。 */
|
||||
export function peekIcon(name: string) { return resources.peek(iconAsset(name))?.[name]; }
|
||||
/** 加入分片共享读取,取消只撤销本调用者;分片就绪但缺指定图标时给出明确错误。 */
|
||||
export async function loadIcon(name: string, signal?: AbortSignal) {
|
||||
const paths = (await resources.request(iconAsset(name), signal))[name];
|
||||
if (!paths) throw new Error("未找到图标");
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
/**
|
||||
* 旧版画布的联合读取工具,并行取得目录元数据与v2快照。
|
||||
* 它不协商canonical新模型,迁移感知的页面入口应使用canvas-entry中的分流流程。
|
||||
*/
|
||||
import { CANVAS_ACCEPT } from "./canvas-v2.ts";
|
||||
import { decodeSnapshot, type CanvasSnapshot, type CanvasRequest } from "./canvas-sync.ts";
|
||||
import { matchesRef, type Board } from "./workspace.ts";
|
||||
|
||||
/** 逐段编码路由引用,校验board身份和工作区,再解码快照;AbortSignal传给两次请求。
|
||||
* 返回数据不直接更新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 } })]);
|
||||
|
||||
@@ -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})});
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
/**
|
||||
* 画布响应的流转发与超时分层:响应头截止、整个body时限、单次等待下一块的空闲时限分别管理。
|
||||
* 不把大body一次读入内存或缓存,返回保留状态/头的新Response;外部断开与内部超时通过组合signal中止上游。
|
||||
*/
|
||||
/** Separate the response-header deadline from a large snapshot's transfer.
|
||||
* A client disconnect still aborts the upstream; bodies are never cached. */
|
||||
/** 单次传输诊断:头耗时、头后首块等待、body/总耗时与字节/块数;记录结果不是协议ACK。 */
|
||||
export type CanvasTransferTrace = { started_at: string; finished_at: string; headers_ms: number; first_chunk_ms?: number; transfer_ms: number; total_ms: number; bytes: number; chunks: number; outcome: "complete" | "cancelled" | "error"; error?: string };
|
||||
/** 默认头15秒、body90秒、单次read30秒;request/report可注入。
|
||||
* report由调用方提供且应避免抛错;此处没有把报告异常完全隔离成独立旁路。 */
|
||||
export async function fetchCanvasResponse(url: string, options: RequestInit, limits = { headersMs: 15000, bodyMs: 90000, idleMs: 30000 }, request = fetch, report?: (trace: CanvasTransferTrace) => void) {
|
||||
const started = performance.now();
|
||||
const startedAt = new Date().toISOString();
|
||||
const abort = new AbortController();
|
||||
// 外部请求取消与内部超时任一生效都中止同一上游fetch,避免客户端已离开却继续传输。
|
||||
const signal = options.signal ? AbortSignal.any([options.signal, abort.signal]) : abort.signal;
|
||||
const timeout = () => abort.abort(new DOMException("Canvas transfer timed out", "TimeoutError"));
|
||||
// 初始计时器只约束拿响应头,取得Response后清掉,再给body设置独立总时限。
|
||||
let timer = setTimeout(timeout, limits.headersMs);
|
||||
let upstream: Response;
|
||||
try { upstream = await request(url, { ...options, signal }); }
|
||||
@@ -17,36 +26,45 @@ export async function fetchCanvasResponse(url: string, options: RequestInit, lim
|
||||
throw error;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
// 无body的响应直接透传,不建立reader,也不产生body完成轨迹。
|
||||
if (!upstream.body) return upstream;
|
||||
const headersAt = performance.now();
|
||||
const reader = upstream.body.getReader();
|
||||
let bytes = 0, chunks = 0, firstChunk: number | undefined;
|
||||
/** 从性能时钟计算耗时,四舍五入到0.1ms;首块耗时相对headersAt,不相对最初发起时刻。 */
|
||||
const record = (outcome: CanvasTransferTrace["outcome"], error?: unknown) => {
|
||||
const now = performance.now(), rounded = (value: number) => Math.round(value * 10) / 10;
|
||||
report?.({ started_at: startedAt, finished_at: new Date().toISOString(), headers_ms: rounded(headersAt - started), ...(firstChunk === undefined ? {} : { first_chunk_ms: rounded(firstChunk - headersAt) }), transfer_ms: rounded(now - headersAt), total_ms: rounded(now - started), bytes, chunks, outcome, ...(error instanceof Error ? { error: error.name } : {}) });
|
||||
};
|
||||
let idle: ReturnType<typeof setTimeout> | undefined;
|
||||
let finished = false;
|
||||
/** 标记结束并清除总/空闲计时与组合signal监听,后续迟到read不能再次enqueue或重复结束。 */
|
||||
const cleanup = () => { finished = true; clearTimeout(timer); clearTimeout(idle); signal.removeEventListener("abort", interrupted); };
|
||||
let interrupt: (reason: unknown) => void;
|
||||
/** 将组合signal的取消原因交给流start中安装的中断处理器。 */
|
||||
const interrupted = () => interrupt(signal.reason);
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
/** 流开始即启动body总时限,消费者背压暂停不暂停该总时限;中断使下游报错并尽力取消reader。 */
|
||||
start(controller) {
|
||||
interrupt = (reason) => { if (finished) return; cleanup(); record("error", reason); controller.error(reason); void reader.cancel(reason).catch(() => {}); };
|
||||
signal.addEventListener("abort", interrupted, { once: true });
|
||||
timer = setTimeout(timeout, limits.bodyMs);
|
||||
if (signal.aborted) interrupted();
|
||||
},
|
||||
/** 按下游需求读下一块,idle只覆盖本次reader.read等待;consumer不pull时不运行idle计时。 */
|
||||
async pull(controller) {
|
||||
if (finished) return;
|
||||
idle = setTimeout(timeout, limits.idleMs);
|
||||
try {
|
||||
const next = await reader.read(); clearTimeout(idle);
|
||||
// read等待期间可能已取消/超时;结果回来后先查finished,不向已关闭controller追加字节。
|
||||
if (finished) return;
|
||||
if (next.done) { cleanup(); record("complete"); controller.close(); } else { firstChunk ??= performance.now(); bytes += next.value.byteLength; chunks++; controller.enqueue(next.value); }
|
||||
} catch (error) { if (!finished) { cleanup(); record("error", error); controller.error(error); } }
|
||||
},
|
||||
/** 下游主动取消记录cancelled,并中止fetch和reader;已结束状态不重复记录轨迹。 */
|
||||
async cancel(reason) { if (!finished) { cleanup(); record("cancelled", reason); } abort.abort(reason); await reader.cancel(reason).catch(() => {}); },
|
||||
});
|
||||
// 透传HTTP状态和头,业务JSON/NDJSON校验由调用方执行,不在代理流中重新编码内容。
|
||||
return new Response(body, { status: upstream.status, statusText: upstream.statusText, headers: upstream.headers });
|
||||
}
|
||||
|
||||
@@ -1,38 +1,54 @@
|
||||
/** Shared, bounded queue; unused requests are removed or aborted. */
|
||||
/**
|
||||
* 按key合并重复读取的通用资源加载器,限制活跃并发与结果缓存数量,等待队列本身没有长度上限。
|
||||
* 多个调用者共享一次load但可独立取消;最后一个订阅离开才撤销底层请求。适用于可按key复用的资源,
|
||||
* 不保存业务操作/请求回执,也不替代runtime可靠队列。concurrency/cacheSize由调用方提供有效范围。
|
||||
*/
|
||||
/** 创建共享加载实例。默认并发6、最多缓存16个key,缓存按最近peek顺序淘汰。
|
||||
* undefined用作未命中标记,因此load返回undefined不会作为可复用命中。此工厂未运行时校验数值配置。 */
|
||||
export function createResourceLoader<T>(load: (key: string, signal: AbortSignal) => Promise<T>, concurrency = 6, cacheSize = 16) {
|
||||
// 每次request各有Promise与abort监听,取消一个调用者不能拒绝仍需要结果的其它调用者。
|
||||
type Subscriber = { resolve: (value: T) => void; reject: (error: unknown) => void; cleanup: () => void };
|
||||
// 一个key的一次执行;controller属于共享job,订阅者signal不直接当底层signal。
|
||||
type Job = { key: string; controller: AbortController; subscribers: Set<Subscriber> };
|
||||
// cache保存完成值,jobs保存当前可加入的尝试,queue只保存尚未占并发槽的job。
|
||||
const cache = new Map<string, T>();
|
||||
const jobs = new Map<string, Job>();
|
||||
const queue: Job[] = [];
|
||||
let active = 0;
|
||||
/** 命中后移到Map尾部,更新近用顺序;只读缓存,不启动网络。 */
|
||||
function peek(key: string) {
|
||||
const value = cache.get(key);
|
||||
if (value !== undefined) { cache.delete(key); cache.set(key, value); }
|
||||
return value;
|
||||
}
|
||||
/** 从队头取得有订阅的任务,占槽后异步load,最终归还槽并继续排队任务。
|
||||
* 在途任务即使被abort也要等Promise真正结束才释放并发名额。 */
|
||||
function pump() {
|
||||
while (active < concurrency && queue.length) {
|
||||
const job = queue.shift()!;
|
||||
if (!job.subscribers.size) continue;
|
||||
active++;
|
||||
// 发起前再查共享取消状态,队列任务可在进入微任务前失去全部订阅。
|
||||
Promise.resolve().then(() => {
|
||||
if (job.controller.signal.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
return load(job.key, job.controller.signal);
|
||||
}).then((value) => {
|
||||
if (!job.controller.signal.aborted) {
|
||||
cache.set(job.key, value);
|
||||
// 超出缓存容量淘汰最久未触及key,不影响仍在运行的其它job。
|
||||
while (cache.size > cacheSize) cache.delete(cache.keys().next().value!);
|
||||
for (const subscriber of job.subscribers) { subscriber.cleanup(); subscriber.resolve(value); }
|
||||
}
|
||||
}, (error) => {
|
||||
for (const subscriber of job.subscribers) { subscriber.cleanup(); subscriber.reject(error); }
|
||||
}).finally(() => {
|
||||
// 旧取消任务结束时,同key可能已有新job;按对象身份删除,不能误删替代任务。
|
||||
if (jobs.get(job.key) === job) jobs.delete(job.key);
|
||||
active--; pump();
|
||||
});
|
||||
}
|
||||
}
|
||||
/** 已取消立即拒绝;缓存命中立即返回;否则加入同key的在途/排队job,必要时新建一次执行。 */
|
||||
function request(key: string, signal?: AbortSignal): Promise<T> {
|
||||
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
||||
const cached = peek(key);
|
||||
@@ -41,6 +57,7 @@ export function createResourceLoader<T>(load: (key: string, signal: AbortSignal)
|
||||
if (!job) { job = { key, controller: new AbortController(), subscribers: new Set() }; jobs.set(key, job); queue.push(job); }
|
||||
const current = job;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
/** 只撤销本订阅并解除监听。最后一个离开时移除排队项/可加入索引,再abort底层执行。 */
|
||||
const abort = () => {
|
||||
current.subscribers.delete(subscriber); subscriber.cleanup();
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
|
||||
Reference in New Issue
Block a user