aps-agent/docs/adr/0007-storage-and-consistenc...

65 lines
4.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 只要求两侧库可独立启动