/** * 旧画布的完整快照保存与本地草稿恢复。一个实例串行PATCH一张board,pending只保留最近一次待保存快照, * If-Match限制覆盖基线;失败停住,等待显式retry/adopt。它没有新版ledger的冻结请求身份/回执恢复保证。 * 序列化会去除上传未完成节点及其边,并重建允许字段,避免把React Flow测量/选择状态发给旧接口。 */ import { CANVAS_ACCEPT } from "./canvas-v2.ts"; import { decodeCanvas, type CanvasDocument } from "./canvas.ts"; /** 服务端快照版本与旧格式文档;version是写入并发基线,不是schema_version。 */ export type CanvasSnapshot = { version: number; document: CanvasDocument }; /** 宿主注入的通用HTTP请求端口,具体凭证、异常和取消由API封装负责。 */ export type CanvasRequest = (path: string, options?: RequestInit) => Promise; /** 保存状态通知供界面显示;saved只代表本writer内容保存判断,不代表所有任务或资源均已完成。 */ export type SyncState = { status: "saved" | "saving" | "error"; version: number; error?: string }; /** 编码工作区/board引用,生成旧snapshot读写端点。 */ export function canvasPath(workspace: string, board: string) { return `workspaces/${encodeURIComponent(workspace)}/materials/${encodeURIComponent(board)}/canvas`; } /** 去除pendingUpload节点及连到它们的边,再经过decodeCanvas规范化;显式只输出节点持久字段。 * pending图片仍可在本地显示,但不会以未完成资源引用覆盖服务端文档。 */ export function canvasPayload(document: CanvasDocument) { const pending = new Set(document.nodes.filter((node) => node.pendingUpload).map((node) => node.id)); const clean = decodeCanvas(JSON.stringify({ ...document, nodes: document.nodes.filter((node) => !pending.has(node.id)), edges: document.edges.filter((edge) => !pending.has(edge.source) && !pending.has(edge.target)) })); return { ...clean, nodes: clean.nodes.map(({ id, type, position, width, height, data }) => ({ id, type, position, ...(width !== undefined ? { width, height } : {}), data })) }; } /** 对规范payload对象键排序后序列化,用于内容相等判断;includeViewport=false忽略纯视口差异。 * 它不是密码学摘要,数组顺序仍有意义。 */ export function canvasFingerprint(document: CanvasDocument, includeViewport = true) { const { viewport, ...content } = canvasPayload(document); return JSON.stringify(includeViewport ? { ...content, viewport } : content, (_key, value) => value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) : value); } /** 开发诊断用差异摘要,按节点字段报告增改删,边整体比较;不作为发送到服务端的增量协议。 */ export function canvasChanges(before: CanvasDocument, after: CanvasDocument) { const a = canvasPayload(before), b = canvasPayload(after); /** 此诊断按JSON表示比较,正式脏判断另用稳定对象键的fingerprint。 */ const changed = (left: unknown, right: unknown) => JSON.stringify(left) !== JSON.stringify(right); return { viewport: changed(a.viewport, b.viewport), nodes: b.nodes.flatMap((node) => { const prior = a.nodes.find((item) => item.id === node.id); if (!prior) return [{ id: node.id, fields: ["added"] }]; const fields = ["position", "width", "height"].filter((key) => changed(prior[key as keyof typeof prior], node[key as keyof typeof node])); for (const key of new Set([...Object.keys(prior.data), ...Object.keys(node.data)])) if (changed((prior.data as Record)[key], (node.data as Record)[key])) fields.push("data." + key); return fields.length ? [{ id: node.id, fields }] : []; }), removed_nodes: a.nodes.filter((node) => !b.nodes.some((item) => item.id === node.id)).map((node) => node.id), edges: changed(a.edges, b.edges), }; } /** 校验服务器正整数版本并重解码文档,不能仅凭类型断言信任网络快照。 */ export function decodeSnapshot(value: CanvasSnapshot): CanvasSnapshot { if (!Number.isSafeInteger(value?.version) || value.version < 1) throw new Error("invalid_canvas_snapshot"); return { version: value.version, document: decodeCanvas(JSON.stringify(value.document)) }; } /** One writer per board. Coalesce pending snapshots; stop on any rejected write. */ /** 单board旧写者。document/saved是最近确认基线,pending是可被后续enqueue替换的等待槽, * running最多一个drain。不要将这里的快照合并理解成保留每项操作因果证据的可靠队列。 */ export class CanvasSync { // version用于下一次If-Match;saved含viewport,savedContent专供忽略viewport的needsSave。 version: number; saved: string; private savedContent: string; error = ""; document: CanvasDocument; // 在途请求不被新enqueue替换;只合并尚未发送的后续快照,失败时保留更新的pending优先。 private pending: CanvasDocument | null = null; private running: Promise | null = null; private request: CanvasRequest; private path: string; private notify: (state: SyncState) => void; /** 接受已解码初始快照,建立两种指纹与状态回调;构造时不启动保存。 */ constructor(request: CanvasRequest, path: string, initial: CanvasSnapshot, notify: (state: SyncState) => void) { this.request = request; this.path = path; this.version = initial.version; this.saved = canvasFingerprint(initial.document); this.savedContent = canvasFingerprint(initial.document, false); this.notify = notify; this.document = initial.document; } /** 只有持久内容变化才需PATCH,单纯平移/缩放视口不主动写;内容保存时payload仍携当前viewport。 */ needsSave(document: CanvasDocument) { return canvasFingerprint(document, false) !== this.savedContent; } /** 替换等待槽,有错误时只保留内容而不自动发送;无错误且未运行时启动一个微任务drain。 */ enqueue(document: CanvasDocument): Promise { this.pending = document; if (this.error) return Promise.resolve(); if (!this.running) this.running = Promise.resolve().then(() => this.drain()).finally(() => { this.running = null; if (this.pending && !this.error) void this.enqueue(this.pending); }); return this.running; } /** 清除错误并重试当前pending快照;不查询旧回执,也不更换版本,基线冲突仍由服务端拒绝。 */ retry() { this.error = ""; return this.pending ? this.enqueue(this.pending) : Promise.resolve(); } /** 等调用时的running结束;不是持续追踪未来enqueue或全项目同步完成的屏障。 */ async settled() { await this.running; } /** 仅在没有在途写时显式接纳另一个快照,清空pending/error并重置版本/指纹。 */ adopt(snapshot: CanvasSnapshot) { if (this.running) throw new Error("canvas_busy"); this.pending = null; this.error = ""; this.version = snapshot.version; this.document = snapshot.document; this.saved = canvasFingerprint(snapshot.document); this.savedContent = canvasFingerprint(snapshot.document, false); this.notify({ status: "saved", version: this.version }); } /** 串行消费等待快照,跳过无持久内容变化者;按当前version条件PATCH,回执必须恰好+1。 * 任意失败都停止,保留后来更近的pending;此处不自动合并远端冲突或推断请求是否已经生效。 */ private async drain() { while (this.pending) { const document = this.pending; this.pending = null; this.notify({ status: "saving", version: this.version }); try { const fingerprint = canvasFingerprint(document); if (!this.needsSave(document)) { this.notify({ status: "saved", version: this.version }); continue; } if (process.env.NODE_ENV === "development") console.info("[canvas-dirty]", JSON.stringify({ resource: this.path, ...canvasChanges(this.document, document) })); // 仍发送整文档,限制5MiB;上方canvasChanges只是日志,不会把请求变成字段补丁。 const body = JSON.stringify({ document: canvasPayload(document) }); if (new TextEncoder().encode(body).length > 5 * 1024 * 1024) throw new Error("canvas_too_large"); const result = await this.request<{ version: number }>(this.path, { method: "PATCH", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${this.version}"` }, body }); if (!Number.isSafeInteger(result?.version) || result.version !== this.version + 1) throw new Error("invalid_canvas_snapshot"); // 只有通过版本回执后推进保存基线;在途期间新enqueue留在pending供下一轮处理。 this.version = result.version; this.saved = fingerprint; this.savedContent = canvasFingerprint(document, false); this.document = canvasPayload(document); this.notify({ status: "saved", version: this.version }); } catch (error) { // 已有更新快照时保留它,否则把失败快照放回;错误不伪造成功版本。 this.pending ||= document; this.error = error instanceof Error ? error.message : "request_failed"; this.notify({ status: "error", version: this.version, error: this.error }); return; } } } } /** 优先读取带dirty标记的新草稿记录,无记录才兼容旧键;忽略只改viewport或与远端相同的内容。 * 格式错误继续抛出,不将损坏草稿当作空白文档自动覆盖。 */ export function readLocalRecovery(storage: Pick, key: string, snapshot?: CanvasSnapshot): CanvasDocument | null { const record = storage.getItem(key + ":recovery"); if (record) { const value = JSON.parse(record); const doc = decodeCanvas(JSON.stringify(value.document)); if (!snapshot || (value.dirty && canvasFingerprint(doc, false) !== canvasFingerprint(snapshot.document, false))) return doc; return null; } const legacy = storage.getItem(key); if (!legacy) return null; const doc = decodeCanvas(legacy); return !snapshot || canvasFingerprint(doc, false) !== canvasFingerprint(snapshot.document, false) ? doc : null; } /** 恢复旧草稿前检查schema不倒退,且分区入口/端口关系与远端一致。 * 只是必要的局部条件,不证明整棵跨board树可原子恢复。 */ export function canRestoreCanvasDraft(draft: CanvasDocument, remote: CanvasDocument) { if (draft.schema_version < remote.schema_version) return false; const relations = (document: CanvasDocument) => document.nodes.filter((node) => node.data.subgraph).map((node) => ({ id: node.id, subgraph: node.data.subgraph, ports: node.data.ports || [] })).sort((a, b) => a.id.localeCompare(b.id)); return JSON.stringify(relations(draft)) === JSON.stringify(relations(remote)); }