Files
Endoleap/src/lib/canvas-sync.ts
T

149 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 旧画布的完整快照保存与本地草稿恢复。一个实例串行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 = <T>(path: string, options?: RequestInit) => Promise<T>;
/** 保存状态通知供界面显示;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<string, unknown>)[key], (node.data as Record<string, unknown>)[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<void> | 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<void> {
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<Storage, "getItem">, 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));
}