离线优先同步:事务性 Outbox(桌面端)
父节点:key-designs
这是桌面端架构中最难啡的一块。Web 端是在线优先(纯 HTTP 委托 + 只读的 wa-sqlite 缓存,没有 outbox 机制);桌面端对已上线的 6 个领域(书籍/书签/笔记/词汇/偏好设置/复习)是真正意义上的离线优先,靠一层 Hybrid*Repository 包装器实现。
底层:经由 IPC 的原生 SQLite。 桌面端在 Electron 主进程里用的是 better-sqlite3(main/sqlite-handler.ts,WAL 模式 + busy_timeout=30000),而不是 wa-sqlite——渲染进程这一侧的门面(ElectronSqliteHost)把所有操作串成一条单一的 Promise 链,并把 transaction(fn) 映射到一套 IPC 的 txBegin/txExec/txCommit/txRollback 协议上。因为 better-sqlite3 本身是同步的,而渲染进程的事务回调要跨越异步的 IPC 往返,它没法直接用 db.transaction(fn);取而代之的是一座事务 ID 桥:先发出 BEGIN,拿到一个 id,后续调用都挂在这个 id 下执行。一条 LOAD_TOKEN 水位线加上 resetPendingTransactions(),一起拆掉了"重载留下事务孤儿"这个死锁(也就是那个"reader 卡在 Loading book…"的 bug)。
权威来源 = 云端权威,清空重灌。 读取走的是"先 HTTP、后回填本地":seedLocal 会先执行 DELETE … WHERE id NOT LIKE 'temp_%',再在每次拉取列表时重新插入服务器返回的行(这样跨设备的服务器端删除就不会滞留);尚未同步的 temp_% 行则会保留下来。整个 FULL_SCHEMA_SQL 在每次启动时都会重新应用一遍。代码里的注释写道:"云端才是事实来源;本地缓存会在下一次会话时重新填充。"
写入路径(以 vocabulary.create 为例追踪):
- 优先在线——先尝试
vocabularyApi.create;成功后只需把这一行原样镜像到本地(这样联网时就不会出现临时 id 与服务器 id 之间的落差)。 - 离线兜底——铸造一个
temp_${uuid},并在同一个事务里既插入领域数据行、又追加 outbox 条目(insertEntry+Outbox.appendInTx)。同一事务的原子性正是关键:如果崩溃发生在这两步之间,就会留下一条永远同步不上去的本地行,让这台设备永久失步。 - 触发一次 drain(排空)。
- Drain(
OutboxProcessor)——按最旧优先的顺序、合并处理、跳过尝试次数超过MAX_ATTEMPTS=5的条目;成功则markDone(DELETE),失败则markFailed(attempts 加一、记下 last_error)。触发时机:online事件、每次写入之后,以及启动时延迟 2 秒触发一次(不设周期性定时器,以保护首屏渲染速度)。 - 对账(Reconcile)——
create处理器重放那次 POST 请求,然后执行UPDATE … SET id=server_id WHERE id=tempId:临时行被原地改写为服务器身份。
冲突模型 = 最后写入者获胜 + 服务器端幂等,客户端不做合并(Outbox 里的说明是:"不做去重,不做压缩,不保证乱序排空……由服务器对重复的 (bookId, page, label) 保证幂等")。SRS 不在客户端跑——它归服务器所有(vocabulary-loop);SRS 相关的列被有意地从本地镜像里排除。
老实说这块还很糙(代码里也坦承这一点):每一行 hybrid 数据都用字面量 'local' 作为用户(没有按用户隔离——靠 clearHybridLocalState() 在会话切换时清空数据来掩盖跨用户泄漏的问题),而 library 这一块目前只走 HTTP(配额、本地迁移功能都被推迟了)。真正承重、并且有 E2E 测试覆盖的是:同事务的 outbox 原子性、清空重灌式读取、事务 ID 桥,以及临时 id 的对账重写。