#AI#TypeScript#DevOps

IndexedDB 离线 AI 草稿:迁移、多标签页协调与崩溃恢复

离线草稿写进 IndexedDB 不等于可靠保存。本文用 versionchange 协议、原子结构升级、分段回填、事务性 outbox 与幂等恢复处理多标签页、崩溃和存储驱逐。

本文要点

  • 主题聚焦:AI、TypeScript、DevOps
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年4月16日·更新于 2026年7月20日

离线 AI 应用常把 prompt、草稿和待同步任务写进 IndexedDB,但“写入成功”只证明一个事务提交过。另一个旧标签页可能阻塞升级,浏览器可能在崩溃后重投任务,站点数据也可能被用户清除或被存储策略驱逐。Local-first AI 应用 说明本地优先的产品价值,本篇关注数据库生命周期和可证明的恢复契约。

让版本升级成为标签页协议

打开数据库时显式传入整数版本。版本变大触发 upgradeneeded,其中的 versionchange 事务是创建对象仓库、索引和小规模确定性转换的唯一结构变更边界。处理器根据 event.oldVersion 逐级执行迁移,不根据“某个 store 看起来存在”猜当前状态。任一步请求失败就 abort();事务未提交时,版本号与结构一起保留在旧状态。

升级前,其他连接会收到 versionchange。每个页面都应立即停止新写入、关闭连接并提示刷新;升级请求同时监听 blocked,记录阻塞时长和目标版本。BroadcastChannel 可以提前通知其他标签页排水,却不能替代 IndexedDB 自身的 versionchange 正确性。超时后展示明确的“关闭旧页面”状态,不能悄悄退回旧 schema 继续写。

ts
const request = indexedDB.open("ai-workspace", 7);

request.onupgradeneeded = (event) => {
  const db = request.result;
  const tx = request.transaction!;
  if (event.oldVersion < 7) {
    db.createObjectStore("outbox", { keyPath: "operationId" });
    const meta = db.objectStoreNames.contains("meta")
      ? tx.objectStore("meta")
      : db.createObjectStore("meta", { keyPath: "key" });
    meta.put({ key: "backfill", phase: "pending" });
  }
};
request.onblocked = () => reportBlockedUpgrade(7);

不要在 upgrade handler 中等待网络,也不要混入普通 Promise 后假定事务仍活跃;IndexedDB 事务会按请求队列和事件循环规则自动提交。结构升级的思路可与 数据库迁移管理 对照,但浏览器没有运维人员替用户关闭全部连接。

大回填采用可恢复阶段,而非超长升级

几百条记录可在 versionchange 事务内转换,几十万条 embedding 或会话则不应把页面锁在一次超长升级里。结构事务只增加新 store、索引和迁移元数据;页面打开后,以有限条数执行多个普通 readwrite 事务。每个事务同时写入目标记录与持久化 cursor,崩溃后从已提交 cursor 继续。转换必须由稳定输入产生稳定主键,重复一段不会制造副本。

回填期间读路径先查新格式、缺失时读旧格式并转换;写路径按 migration phase 双写,或者写入能由新旧版本共同理解的兼容 envelope。迁移状态只允许 pending -> copying -> verifying -> complete 单向前进。抽样数量、主键集合和内容 digest 验证通过后才停止旧格式写入;旧 store 至少保留一个应用回滚窗口,后续独立版本再删除。这里的原子性是“每批数据与 cursor 同一事务”,不能把跨多个事务的回填宣传成全库原子切换。

草稿与 outbox 必须一起提交

保存草稿和创建 AI/同步任务应在同一个 readwrite 事务完成。outbox 使用客户端生成且重试不变的 operationId,保存 payload digest、schemaVersion、createdAt、attempt、状态和服务端幂等键。事务失败时两者都不可见;事务提交后,即使页面立刻崩溃,恢复 worker 仍能找到工作。

任务领取使用带 fencing token 的 lease,而不是永久 running 标志。短事务读取 pending 或过期任务,把单调 leaseGeneration 加一,并写入随机 ownerId、新 generation 与 leaseExpiresAt;处理器把这组 token 保存在本次执行上下文,再发起网络调用。

确认结果必须开启新 readwrite 事务并重新读取任务,只有状态仍为 leased、ownerId、leaseGeneration 和 payload digest 全部匹配时才能 CAS 写成终态。若 A 的 lease 过期后 B 已领取,A 的迟到响应只能丢弃或写入独立、有界的对账记录,绝不能覆盖 B。崩溃仍可能发生在服务端成功之后、客户端确认之前,因此恢复语义只能是至少一次尝试;服务端必须按 operationId 去重,但远端去重不能替代本地 fencing。多标签页竞争可借鉴 乐观锁与悲观锁,不能依靠内存布尔值互斥。

把驱逐和回滚当作正常事件

navigator.storage.persist() 只是持久存储请求,结果可能为 false;persisted() 和 estimate() 用于观测,不构成不丢数据承诺。捕获 QuotaExceededError 后先停止接收大模型资产和派生缓存,保护未同步草稿与 outbox,并提供导出或同步。原始草稿若只存在浏览器,就必须向用户明确这一事实;服务端副本、加密导出或其他设备才是灾难恢复方案。渐进式 Web 应用 中的缓存也不能替代用户数据备份。

数据库版本只能前进。回滚旧 bundle 时,旧代码可能遇到更高版本并触发 VersionError,或打开当前版本却误解新记录。发布 manifest 要声明应用可读写的 schema 范围;不兼容时进入只读/导出界面,绝不能用 deleteDatabase() 伪装回滚。先发布能双读的旧版本,再升级数据库;观察期结束后才移除旧 decoder 和旧 store。

测试至少用两个真实页面制造 blocked upgrade,分别在结构升级、批次写入、网络成功和确认写回处终止页面;还要让 A 的 lease 过期、B 以更高 generation 完成,再放回 A 的迟到响应,断言旧 token 无法改变任务状态。继续注入配额耗尽、重复领取、旧 bundle 打开新库和站点数据被清除。门禁检查无半条草稿、无永久 running、重复 operation 只有一次服务端效果,并能从最近提交 cursor 继续。这样 IndexedDB 才是可恢复的本地事实层,而不是一份碰巧还在的缓存。

延伸阅读

Related

相关推荐