07 · SQLite、事件日志与读取投影
40 轮迁移背后的数据模型、写时投影、恢复策略和历史演化信号。
SQLite 承担的是权威存储
T3 Code 的 Server 本地运行,SQLite 很适合它的约束:单执行环境、低运维、事务强、可随 userdata 携带。
但“本地数据库”不代表数据模型简单。本快照包含从 001 到 040 的迁移,覆盖事件溯源、关系投影、Provider 运行态、
认证、checkpoint diff 和持续增加的 thread 产品字段。
三种数据,不应混为一张表
事件日志
001_OrchestrationEvents.ts 建立追加式领域日志,sequence 提供全局顺序。事件 payload 保留发生了什么,供启动恢复、
调试因果与客户端增量同步使用。
事件并不是任意日志行。它必须是可 decode 的领域 Schema,并且 projector 要能在旧历史上持续工作。删除或改变旧事件语义 比给普通表改字段更危险。
Command receipts
002_OrchestrationCommandReceipts.ts 保存 idempotency key 对应的 accepted/rejected 结果。它与事件同事务提交,
让网络重试重放“结果”而不是重做“动作”。receipt 的生命周期要覆盖客户端可能重试的窗口,且 rejection 同样需要稳定。
关系投影
005_Projections.ts 建立 projects、threads、messages、activities、sessions、turns、pending approvals 等读模型表,
后续迁移逐步加入附件、activity sequence、runtime mode、interaction mode、proposed plan、archived、settled、snoozed、pinned、favicon 等。
这些表不是第二个真相源,而是事件的可查询产品形态。写路径在同一事务中同步推进它们,从而允许 RPC 快照做索引查询和分页, 不必每次 replay 全部事件。
迁移序列透露出的产品演化
迁移名本身是一份压缩历史:
| 范围 | 主题 | 透露的演化 |
|---|---|---|
| 001–005 | 事件、回执、diff、runtime、投影 | 先建立可恢复核心 |
| 006–012 | session/thread runtime 与 interaction mode | 多运行模式进入稳定模型 |
| 013–015 | proposed plan 与 turn 来源 | 计划从临时 UI 变成领域对象 |
| 016、026 | model selection canonicalization | Provider 模型配置需要兼容清洗 |
| 017–019 | archive 与查询索引 | 数据量增长后优化列表 |
| 020–022、031–032 | access、client metadata、scope、DPoP | 远程访问安全逐步加固 |
| 023–030 | shell summary、清理与排序索引 | 大列表/历史线程性能与数据卫生 |
| 033–040 | settled、snoozed、pin、env mode、favicon | 控制面产品能力成熟 |
不要把这张表当发布时间线;快照没有 Git 历史。它只能证明 Schema 的演化顺序和当前兼容负担。
Persisted projection pipeline
编排引擎先用事件推进候选内存 model,再调用 persistence projection pipeline 更新关系表。两者使用同一事件语义但面向不同读者:
- 内存 model 服务当前进程的快速决策与 PubSub;
- SQLite projection 服务分页、重启恢复和大型集合查询。
最重要的不变量是:二者必须在已提交 sequence 上一致。若只更新其一,下一条 command 可能基于和客户端不同的世界做决定。
为什么不只存最终 Thread JSON
只存一个大 JSON 会让简单原型更快,但在 T3 Code 的需求下有明显问题:
- 并发命令失败时难以解释部分状态;
- 无法用 sequence 做可靠增量订阅;
- 幂等重试无法证明此前做了什么;
- 大线程每次更新都重写整个 blob;
- 索引 archive/pin/settled/turn 时间会很困难;
- Provider 失败后的意图与结果无法分开追踪。
事件 + 投影付出更多 Schema 和迁移成本,换来可解释因果与产品查询能力。
大内容为什么单独存 blob
Checkpoint diff 可能很大,若内嵌在每个 thread snapshot 或 event payload 中,会污染热路径。003_CheckpointDiffBlobs.ts
把它放进独立存储,通过引用按需读取。Git driver 对 diff 还有约 10 MB 上限,避免极端仓库把 Server 内存和网络占满。
类似策略也适用于附件:事件记录事实与 metadata,大 payload 通过专用路径读取。
索引是一种产品契约
后期迁移大量增加 archivedAt、thread detail ordering、shell archive、turn keyset、pin order 等索引。
这说明列表不再适合 offset + 全表排序。Keyset pagination 需要稳定的复合排序键,Server 和客户端都要把 cursor 当不透明边界。
索引设计不是“DBA 事后优化”:它由 UI 的排序、过滤和翻页语义决定。改变 pinned/snoozed 排序时,必须同时审查 query 与索引。
启动恢复
重启时,Server 需要恢复:
- 数据库 Schema 到当前版本;
- 持久化投影或由事件重建的 read model;
- Provider instance 配置;
- thread ↔ provider session runtime binding;
- 未结算 turn、pending approval 与 checkpoint 状态。
“恢复状态”不代表外部 Provider 一定仍活着。runtime binding 是尝试 resume 的证据,adapter 的 hasSession/readSession
才判断实际进程/SDK 状态;失败后需通过新事件结算,不可静默假装 session running。
Schema 演化原则
- 历史事件优先保持可读;必要时在 decode/projector 做兼容,而非原地改旧 payload。
- 关系投影可以迁移/回填,因为它可由事实重建,但迁移仍必须原子、可测试。
- canonicalization 迁移应幂等,并对未知 Provider 值保守处理。
- 新索引先匹配真实 query shape,避免为字段名盲目建索引。
- 认证和权限迁移需要 fail closed;数据缺失不能默认提升权限。
源码锚点
可迁移经验
事件日志负责“为什么”,关系投影负责“怎样高效展示”,runtime 表负责“如何重新接上外部系统”。三者可以在一个 SQLite 中, 但不要把语义混成一张万能表。对本地优先应用,SQLite 足以承载严谨事件模型;规模小不应成为放弃一致性的理由。