English | 简体中文
持久化的目标是重启后恢复用户文档及与它严格匹配的撤销历史。它不负责恢复已经解码的图片、 GPU 纹理、Konva Group、HTMLVideoElement、Blob URL 或正在播放的时间点。
1. 哪些状态应该保存
| 状态 | 是否保存 | 原因 |
|---|---|---|
| Document revision + nodes | 是 | 用户内容的唯一真相 |
| CommandHistory snapshot | 可选,但必须与 revision 匹配 | 跨会话 Undo/Redo |
| Asset manifest + encoded blob | 可选 | 离线资源与变体索引 |
| Camera / selection / UI panels | 由宿主按产品需要保存 | 属于 workspace preference,不是核心 Document |
| Spatial index | 否 | 可由 nodes 重建 |
| WorkingSet / pendingExit | 否 | 可由 camera 重新计算 |
| Renderer objects / decoded cache | 否 | 进程与设备相关,必须重建 |
2. 三个端口
DocumentPersistence
interface DocumentPersistence<Node extends CanvasNode> {
load(documentId: string): Promise<DocumentSnapshot<Node> | undefined>
save(documentId: string, snapshot: DocumentSnapshot<Node>): Promise<void>
append(documentId: string, commit: DocumentCommit<Node>): Promise<void>
}
适合只保存文档 checkpoint,并把 commit 追加到宿主 journal/服务端事件流。
CanvasWorkspacePersistence
interface CanvasWorkspacePersistence<Node extends CanvasNode> {
loadWorkspace(documentId: string): Promise<{
document: DocumentSnapshot<Node>
history?: CommandHistorySnapshot<Node>
} | undefined>
saveWorkspace(
documentId: string,
document: DocumentSnapshot<Node>,
history: CommandHistorySnapshot<Node>,
): Promise<void>
}
适合跨会话撤销。保存时要求 document.revision === history.documentRevision,否则抛错,
不会制造表面可加载、实际不可撤销的组合。
AssetStore
独立保存 AssetManifest 与 Blob。节点只引用稳定 assetId,运行时再选择具体 LOD variant。
详见缓存与媒体资源。
3. IndexedDB 实现
import { IndexedDbPersistence } from '@jason-huang/infinite-canvas-agent/indexeddb'
const persistence = new IndexedDbPersistence<MyNode>({
databaseName: 'my-canvas',
version: 2,
})
默认数据库名 infinite-canvas-agent,版本 2,包含以下 object stores:
| Store | Key | Value |
|---|---|---|
documents |
documentId | DocumentSnapshot |
histories |
documentId | CommandHistorySnapshot |
journals |
auto increment | { documentId, commit } |
asset-manifests |
assetId | AssetManifest |
asset-blobs |
variant key | Blob |
saveWorkspace 在一个 readwrite transaction 中写 documents 和 histories,并清理 journal,
所以浏览器不会看到只更新了一半的 workspace。当前 v2 实现的 journal 是数据库级 store;
checkpoint 时会整体 clear(),因此不要把同一个数据库实例当作多个文档的独立、永久事件日志。
需要多文档 durable journal 的产品,应实现自己的按 documentId 索引与 compaction 策略。
4. 恢复流程
必须先加载 durable state,再创建 Runtime:
const saved = await persistence.loadWorkspace('board-42')
const kit = createCanvasRuntime<MyNode>({
renderer,
initialSnapshot: saved?.document,
initialHistory: saved?.history,
})
kit.refresh(camera, viewport)
initialHistory 只能与同 revision 的 Document 一起恢复。恢复失败时不要忽略异常并强行塞入
历史栈;安全降级是只恢复文档并创建空 CommandBus,同时记录可诊断事件。
如果你传入现有 document,不能同时传 initialSnapshot。这可以避免两个真相来源互相覆盖。
5. 保存流程
命令提交成功后,可以先 append journal,再按策略做 checkpoint:
const commit = kit.commands.execute(plan)
await persistence.append(documentId, commit)
if (shouldCheckpoint()) {
await persistence.saveWorkspace(
documentId,
kit.document.snapshot(),
kit.commands.exportHistory(),
)
}
生产实现还需明确 durable 成功与 UI 成功的关系:
- 强一致:持久化成功后再向用户确认;延迟更高。
- 乐观 UI:先提交内存,再异步保存;失败时必须展示“未保存”状态并重试。
- 服务端协同:本地 action id 与服务端幂等键一致,冲突后重新拉取 canonical document。
绝不能让一个延迟返回的旧 snapshot 覆盖更高 revision 的 durable state。写入端应比较 revision, 或在服务端使用 CAS/ETag/数据库 transaction。
6. 自定义服务端适配器
class HttpDocumentPersistence implements DocumentPersistence<MyNode> {
async load(documentId: string) {
const response = await fetch(`/api/canvas/${encodeURIComponent(documentId)}`)
if (response.status === 404) return undefined
if (!response.ok) throw new Error(`load failed: ${response.status}`)
return response.json() as Promise<DocumentSnapshot<MyNode>>
}
async save(documentId: string, snapshot: DocumentSnapshot<MyNode>) {
const response = await fetch(`/api/canvas/${encodeURIComponent(documentId)}`, {
method: 'PUT',
headers: {
'content-type': 'application/json',
'if-match': String(snapshot.revision - 1),
},
body: JSON.stringify(snapshot),
})
if (response.status === 409 || response.status === 412) {
throw new Error('durable revision conflict')
}
if (!response.ok) throw new Error(`save failed: ${response.status}`)
}
async append(documentId: string, commit: DocumentCommit<MyNode>) {
const response = await fetch(`/api/canvas/${encodeURIComponent(documentId)}/commits`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'idempotency-key': commit.transactionId,
},
body: JSON.stringify(commit),
})
if (!response.ok) throw new Error(`append failed: ${response.status}`)
}
}
示例中的 HTTP 协议只是宿主设计参考,不是包内置协议。服务端应重新验证 schema、权限、 revision 和 action id,不能因为浏览器已经校验过就信任输入。
7. Schema 演进
节点 payload 属于宿主,版本也应由宿主负责。推荐:
type MyPayload = {
schemaVersion: 2
title: string
color: string
}
迁移规则:
- 读取旧 snapshot 后,在创建 DocumentStore 之前迁移。
- 迁移保持纯函数与幂等,保留原始备份或可回滚 checkpoint。
- 未知
kind不要静默删除;可以只读显示或标为 unsupported。 - 大版本迁移先在克隆数据库验证,再替换 canonical state。
- IndexedDB
version只负责 object store 结构,不代替节点 payload 迁移。
8. Quota 与失败处理
IndexedDB 可能因为无痕模式、磁盘、权限或 quota 失败。宿主至少要区分:
- open/upgrade 失败;
- transaction abort;
- quota exceeded;
- blob 写入失败但 Document 已写入;
- workspace history revision 不匹配。
若 asset 写入失败,Document 不应引用一个永远不存在的 asset;采用“先 durable asset,再提交 节点”或提供明确的 pending/uploading 状态。若 Document 保存失败,保留内存状态并向用户 展示未同步,不要回退到旧缓存覆盖当前内容。
9. 关闭顺序
页面或 workspace 退出时:
- 停止新的输入和 Agent 请求;
- 等待或取消当前持久化;
- 保存同 revision 的 document + history checkpoint;
kit.destroy()回收 renderer/working set/index;- 释放资源 Pool;
await persistence.close()关闭 IndexedDB(如果使用该实现)。
Document 可恢复不代表 renderer、decoded cache 或跨进程撤销日志自动恢复。恢复验收应分别测试 文档、history、媒体工作集和 renderer 重建。