docs(adr): 补齐 ADR-0007(数据存储选型与双形态一致规则)

单机版结构化数据用本地 SQLite、会话消息用 JSONL、附件用本地目录;服务端版全部 PostgreSQL。定下七条写法约定(整数毫秒时间、应用侧 ULID 主键、JSONB/TEXT、整数最小单位、禁用库特有逻辑、一套 schema 一个迁移工具、会话消息不落结构化库),并记录访问层选型与四项缺口。来源:任务表决策 D11 / A0.12。
This commit is contained in:
zh 2026-09-23 15:43:40 +08:00
parent e144429763
commit de8d04489b
2 changed files with 65 additions and 0 deletions

View File

@ -0,0 +1,64 @@
# ADR-0007:数据存储选型与双形态一致规则
- **状态**:已接受
- **日期**:2026-09-23
- **决策人**:技术负责人
- **相关**:任务表 A0(决策 D11);设计文档《实施设计》1.2、1.4
## 背景
平台有两种落地形态:单机版(Electron 桌面端 + 本地平台服务)与远端服务器版。库选什么,决定三件事:单机能不能零运维、远端能不能扛并发写入与防篡改、上层代码会不会因为库不同而分叉。
《实施设计》1.4 已经按「桌面用 SQLite 与 JSONL、服务端用 PostgreSQL」写过一版存储形态表,但没有决策记录,也没定两种库的写法一致性规则。
旧实现的教训很具体:它同时支持 SQLite 与 MySQL,方言差异靠 `Float().with_variant(DOUBLE, "mysql")` 这类逐处变体修补;时间字段既有 `String(32)` 存字符串的,也有 epoch 浮点的。差异不写成明文约定,就会在代码里散成几十处特判。
## 选项
| 选项 | 做法 | 代价 | 不选的理由 |
| --- | --- | --- | --- |
| A 单机 SQLite + 服务端 PostgreSQL | 一套方言无关 schema,两种引擎各一套驱动实现 | 要守住一组写法约定 | 选定 |
| B 两端都用 PostgreSQL | 单机版也装 PostgreSQL | 单机从「装一个应用」变成「运维一个数据库」 | 工厂现场的单机部署不接受 |
| C 两端都用 SQLite | 服务端也用 SQLite | 并发写、在线备份、复制、细粒度权限都受限 | 多用户共享场景撑不住 |
| D 单机 SQLite + 服务端 MySQL | 与旧实现一致,沿用经验 | 旧实现的 MySQL 是为对接 `jms_mes`,属外部约束 | 新平台没有这个包袱;PostgreSQL 在 JSONB、并发写与在线备份上更省事 |
## 结论
选 A。同时定下七条写法约定——它们是「一套 schema 跑两种库」的前提,不是风格偏好:
| # | 约定 | 原因 |
| --- | --- | --- |
| 1 | 一套方言无关 schema + 一个迁移工具 | 两边各写一套必然漂移,等联调才发现对不上 |
| 2 | 时间统一存整数毫秒 | 避开各库原生时间类型的时区语义差异;旧实现已有两种写法混用 |
| 3 | 主键由应用侧生成(ULID),不用自增 | SQLite 与 PostgreSQL 的自增与序列语义不同 |
| 4 | JSON 列统一按 JSON 处理:PostgreSQL 落 JSONB,SQLite 落 TEXT | 上层只认 JSON,不写库特有的查询与字面量 |
| 5 | 金额与工时用整数最小单位 | 避免小数类型在不同引擎上的精度与舍入差异 |
| 6 | 不用触发器、存储过程、数据库专属函数 | 逻辑留在代码里,两种引擎行为才一致 |
| 7 | 会话消息不落结构化库 | 单机走 JSONL、服务端走 PostgreSQL,两种实现同一个 `SessionStore` 接口,上层代码共用 |
访问层用 Drizzle ORM:纯 TypeScript,schema 与迁移一体,方言由 schema 定义区分。不选 Prisma——它的查询与迁移依赖额外二进制,对 Electron 打包与离线升级是负担。
存储落点:
| 数据 | 单机版 | 远端服务器版 |
| --- | --- | --- |
| 会话消息 | JSONL(`~/.aps/sessions/<项目>/…`) | PostgreSQL |
| 会话索引(列表、搜索、未读) | 本地 SQLite | PostgreSQL |
| 执行、审批、审计 | 本地 SQLite | PostgreSQL |
| 附件与产物 | 本地目录,只存引用 | 对象存储 + 元数据表 |
| 导出与交换 | JSONL | JSONL |
单机版的 SQLite 文件放在应用数据目录,与会话 JSONL 同级,备份即拷贝目录。
## 影响
- **对代码**:`server/core` 的持久层按七条约定写;schema 与迁移集中在 `server/core/storage`,本地与远端两套驱动实现同一个 `SessionStore` 接口
- **对任务**:A1.1 脚手架按此建存储目录与迁移配置;A3 数据层两套实现一起写,不先写一套再补;B2 拆分后 APS 侧的库由 APS 自己选,平台不干预
- **对文档**:《实施设计》1.4 的存储形态表以此为准,补上 SQLite 文件路径与主键、时间约定;改动随 A0.10 文档入库一起做,避免覆盖飞书文档正文
- **不这么做的后果**:两端 schema 各自演进,差异在联调时集中爆发;时间与主键的差异会散进业务代码,改一次要全量回归
## 遗留
- SQLite 驱动的最终确认:A1.1 脚手架时实测 `better-sqlite3` 与 Node 内置 `node:sqlite`,选不需原生重编译的那个;若 Drizzle 与 Node ≥ 22.19 或引擎子进程模型冲突,退到 Kysely + 手写迁移,代价是改一份 schema 定义
- 单机版的备份与导出入口:A6 界面设计时定,ADR-0003 的「备份与排查直接用文件」规则要求不依赖数据库工具
- APS 侧数据库选型:归 APS,B2 只要求两侧库可独立启动

View File

@ -44,5 +44,6 @@ ADR 写完不再修改。要改结论就**新写一份**,并在两份里互相
| [0004](./0004-desktop-and-local-capabilities.md) | 桌面端与本地能力边界 | 已接受 | 任务表 A0.5(决策 D4) | | [0004](./0004-desktop-and-local-capabilities.md) | 桌面端与本地能力边界 | 已接受 | 任务表 A0.5(决策 D4) |
| [0005](./0005-cutover-strategy.md) | 直接切换,不并行、不迁移 | 已接受 | 任务表 A0.9(决策 D7) | | [0005](./0005-cutover-strategy.md) | 直接切换,不并行、不迁移 | 已接受 | 任务表 A0.9(决策 D7) |
| [0006](./0006-engine-as-subprocess.md) | 引擎以长驻子进程运行 | 已接受 | 任务表 A3(决策 D10) | | [0006](./0006-engine-as-subprocess.md) | 引擎以长驻子进程运行 | 已接受 | 任务表 A3(决策 D10) |
| [0007](./0007-storage-and-consistency.md) | 数据存储选型与双形态一致规则 | 已接受 | 任务表 A0(决策 D11) |
编号在写之前先在本表登记,避免多人并行时撞号。 编号在写之前先在本表登记,避免多人并行时撞号。