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
}

迁移规则:

  1. 读取旧 snapshot 后,在创建 DocumentStore 之前迁移。
  2. 迁移保持纯函数与幂等,保留原始备份或可回滚 checkpoint。
  3. 未知 kind 不要静默删除;可以只读显示或标为 unsupported。
  4. 大版本迁移先在克隆数据库验证,再替换 canonical state。
  5. 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 退出时:

  1. 停止新的输入和 Agent 请求;
  2. 等待或取消当前持久化;
  3. 保存同 revision 的 document + history checkpoint;
  4. kit.destroy() 回收 renderer/working set/index;
  5. 释放资源 Pool;
  6. await persistence.close() 关闭 IndexedDB(如果使用该实现)。

Document 可恢复不代表 renderer、decoded cache 或跨进程撤销日志自动恢复。恢复验收应分别测试 文档、history、媒体工作集和 renderer 重建。