Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5803787910 | |||
| 84c50fe595 | |||
| f78618be2b | |||
| 18913efab7 |
+4
-1
@@ -27,9 +27,11 @@ export async function api<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
// 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<T>(
|
||||
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配置;不是密码或用户账户身份。 */
|
||||
|
||||
@@ -10,6 +10,7 @@ type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
|
||||
/** 分页取齐board后返回按ID去重的列表,后出现的同ID记录更新内容。
|
||||
* next_offset必须严格前进且安全,seen防重复游标,不能只拿首页就推断某canvas没有父分区。 */
|
||||
export async function loadBoardDirectory(request: Request, workspace: string, signal?: AbortSignal) {
|
||||
// Map 是本次读取的临时去重表,全部分页成功才返回;某页失败不发布已积累的半份目录。
|
||||
const boards = new Map<string, Board>();
|
||||
let offset = 0;
|
||||
const seen = new Set<number>();
|
||||
@@ -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 }] : [];
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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}:{}) };
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<typeof setTimeout> | 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中安装的中断处理器。 */
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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)});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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" : ""}`;
|
||||
}
|
||||
|
||||
+2
-1
@@ -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));
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export async function loadWorkspaceHome(
|
||||
request: Request,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
// 任一读取拒绝则整体拒绝,Promise.all 不会自动取消另一请求;共同 signal 由宿主管理。
|
||||
const [profile, result] = await Promise.all([
|
||||
request<Profile>("profile", { signal }),
|
||||
request<Workspace[] | { items: Workspace[] }>("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;
|
||||
|
||||
Reference in New Issue
Block a user