FDE·案例复盘
基础目录
德莉玛 monorepo 订购单流水线 会员制 CRM 订购单系统(单体) 玻璃图纸与拆单 定价引擎 P0~P3

复盘 24 · 德莉玛 monorepo 与一次拆与不拆

这一篇不是表扬稿。它是我自己项目的一次架构决策复盘:为什么全盘唯一一个 monorepo 是这样拆的、拆完真正带来了什么、哪一部分价值被我高估了、哪一项技术债到现在还没还清。

项目锚点

项目:C:\Users\Administrator\WorkBuddy\delimao-system,淋浴房产供销一体化系统的「路线B 生产级重构」。
全盘唯一一个 monorepo,npm workspaces(不是 pnpm、不是 turbo、不是 lerna),四个包、27 张表集中在 packages/db/src/schema.ts。

包目录负责什么硬约束
@delimao/serverapps/serverExpress + tRPC 接口可被启动对外服务
@delimao/webapps/webReact 页面可被 vite 启动
@delimao/dbpackages/dbschema 收敛 + migrate + seed只管存取,不写业务判断
@delimao/enginepackages/engine报价 / BOM / 玻璃图 / 主数据dependencies 为空,纯 TypeScript
一个必须先讲清的口径:33,176 和 33,178 差在哪
本库多处引用的 33,176 单来自 source='production_export' 这一批导出数据;全表 product_order 实测 33,178 单,多出的 2 单来自 source='structured'。
两个数都不是错的,差别在口径不在对错。凡是要拿这个数去算比例(96.6% / 95.2% / 29% / 0% 那一组),都必须在旁边写清楚是「抽样 400 单」还是「全表 33,178」——不写口径的比例,等于半个假数字。全表口径的逐字段覆盖见 28 篇第 09 节。

相关基础篇:15 · 读懂 monorepo(速查版命令表)、15 · monorepo 深入(exports / 软链 / 循环依赖)、16 · 数据库怎么存东西、16 · 数据库深入(JSON blob 崩库机制)。

01

拆之前:那套连体单体到底痛在哪

复盘的第一步不是夸 monorepo,是把拆之前的痛写准。写不准,后面的「值不值」就没法判断。

拆之前的代码形态是「一个目录里平铺:后端文件、前端文件、SQL 文件、规则文件都在一起」。它不是「单仓库 vs 多仓库」的问题,是同一个逻辑要被抄四遍的问题:

痛点当时的具体表现真实的后果
改一处要动四个文件改一条报价规则 → engine 改算法、db 改价格表、server 改接口、web 改页面。四个地方靠人肉同步漏改一处,线上不报错,只是算出来的数悄悄不对——这种 bug 最贵
接口靠文档对齐后端改字段,前端看的是一份 Markdown 接口说明文档一过期,前端就按旧字段取值,页面不崩,只是显示空白
前端拿不到后端类型想让 TypeScript 知道接口长什么样,得为一个类型去装一个 npm 包类型约束基本等于没有,靠review 和线上报错兜
状态值字符串满天飞订单状态在后端、前端下拉框、导出表格里各写一遍某处写成 'done'、某处写成 'DONE',查不出来
发版有排队四个模块各有一份构建产物,更新要一个个来中间态是「前端新的、后端旧的」,半套系统在线上跑
但这里有个我要诚实承认的点:上面五条里,真正靠 monorepo 能解决的是 1、2、3、4,而第 5 条(发版排队)靠 monorepo 是顺带解决的,不是主要动机。更关键的是:这五条里面,有三条本质是「多个人一起改会不会打架」的协作问题,只有两条是「一套代码怎么组织」的工程问题。
这个区分很重要——协作类痛点要靠人多才成立。我一个人的时候,靠纪律其实也能做到「改一处就全改」。这句话先记着,第 08 节会用。
02

为什么最后选中 monorepo,而不是另外四种做法

当时摆在面前的有五条路。选型的价值不在于选了最先进的,在于知道自己为什么没选另外四个。

方案怎么做在你这个体量下的真实结果
① 继续单体
(一个目录平铺)
什么工具都不上,继续靠纪律成本最低。但痛点 1/2/4 一条都解决不了,且随着 27 张表加进来会持续恶化
② 四个独立仓库server / web / db / engine 各一个 git把痛点 1 从一个文件变成四次提交 + 四次发版,中间任何一次没跟上就是版本错乱。对一个维护者是最差选项
③ monorepo + npm workspaces一个 git,packages/* + apps/*,本地软链互相引用选中。一次提交四个包同时变,前端能直接 import 后端类型,枚举一处定义三处生效
④ monorepo + pnpm / turbo加一层任务编排工具四包 + 一个人,编排工具的收益接近 0,纯增加学习成本。npm 自带的 workspaces 就够了
⑤ 先别建系统拿 Excel + 脚本先把结论验证一遍我实际上先做了这一步——先跑数据体检,确认「数据不够」再去建引擎(这条链路的完整复盘见 复盘 25 · 订购单流水线)
选型结论:monorepo 在这里买的不是「先进架构」,买的是「类型与枚举的一处定义、多处处生效」。如果你的系统里前后端各写各的状态字符串、各写各的金额单位,那 monorepo 对你就是划算的;如果前后端本来只靠一个 JSON 接口传字符串,那 monorepo 基本白装。
03

四包边界怎么划:谁依赖谁,红线在哪

monorepo 的难点从来不是「建起来」,是划边界。划错了,四个包会迅速长成四坨互相咬住的意大利面。

依赖方向(箭头 = 「依赖」):

apps/web  ──(只 import type)──▶  apps/server
    │                                  │
    └──(import 枚举/类型)──▶ packages/db ◀── packages/engine
                                 ▲
                                 └── apps/server 依赖它

packages/engine  ★ 什么都不依赖(dependencies 为空)
packages/db      ★ 只依赖 drizzle / libsql 客户端,不依赖任何业务包
包负责什么明确不许干什么允许依赖谁
apps/serverHTTP 收口、鉴权、把请求翻译成 engine 调用不许把算价公式写进来(否则 engine 就白纯了);不许直接写 SQL 拼业务结果db、engine
apps/web页面、表单、表格不许自己重新实现一套状态枚举;不许绕过接口直接读库server(仅类型)、engine(类型)、db(枚举)
packages/db27 张表 schema、migrate、seed、枚举常量不许含业务判断(「订单算不算完成」这种判断一律不上来)只有 drizzle / libsql 客户端
packages/engine型号解析 → BOM → 校验 → 玻璃图 → 报价不许 import express、不许 import db——一旦 import 就成环且没法单测什么都不许依赖
红线 1(最重要):packages/engine 里出现任何 @delimao/server 或 @delimao/db 的 import,立刻停下来。它会同时击穿三件事:engine 跑不了单测、server 与 engine 成环、engine 彻底失去可复用性。
这条我用工具守,不靠口头纪律:packages/engine/package.json 里一个 dependencies 都没有(只有 devDependencies 的 tsx / typescript)。只要有人乱 import,npm install 会直接报缺包——问题在装依赖那一刻就暴露,而不是等运行时。
红线 2:依赖方向必须单向,任何两个包之间不许成环。发现方法:npx madge --circular apps/server/src 逐个包查。成环的运行时表现不是报错,是某个 import 进来的是 undefined,报错位置离真因十万八千里。
划边界时我唯一让步的一处:apps/web 的 devDependencies 里放了 @delimao/server。看起来违反直觉,其实它只 import type 一个 AppRouter——为拿接口清单而依赖,不是为调后端而依赖。放 devDependencies 是精确表达:编译期需要,运行期不需要。
04

27 张表按 9 层分区:这张图是给「人」看的

packages/db/src/schema.ts 里 27 张表不是随便堆的,是按业务生命周期分了 9 层。先说明白:9 层分区不是数据库约束,只是文件的组织顺序 + 人的索引。数据库不知道第 3 层比第 5 层重要。

第 1 层主数据厂商 / 材质 / 门店(dealer)基准档案
第 2 层商品SKU、型号(model)、系列(series)—— 3,799 个型号的词表住这层
第 3 层价格系列价、出厂价红线、BOM 模板单价
第 4 层订单order 主表 + order_line 明细 —— 历史 33,176 单落这层(口径:production_export 导出批次;全表 33,178 单,含 structured 2 单)
第 5 层拆单BOM 结果、标准件(std_parts)、bom_rule
第 6 层生产工单(WO)、工序流转
第 7 层图纸玻璃图纸(glass_dwg)、框线、开孔参数
第 8 层库存物流库存、出入库流水(inventory_movement)
第 9 层系统审计用户、权限、操作日志、迁移记录
分区关键意义谁写谁读
1–3 主数据 / 商品 / 价格决定「算不算得出价」。缺出厂价红线,4–9 层全是空转seed / 后台维护engine 优先,web 只展示
4 订单流水线的入口,也是唯一有真实历史规模的一层(33,176 单)server全线
5–7 拆单 / 生产 / 图纸决定「能不能出图」,engine 的主战场engineserver → web
8–9 库存 / 系统校验与留痕,出问题时的证据链server审计与对账
分区的最大用处不是「好看」,是排障时能定位。线上报「算不出价」,我第一反应不是去 engine 里翻公式,而是先问:订单有没有进第 4 层?型号有没有进第 2 层?价格有没有进第 3 层?三层数据齐了,问题才在算法;三层里有一层是空的,问题在数据源。这个顺序能省掉一半无效调试。
05

那 6 组枚举,其实是「业务语言」

很多人看 schema 只看表名和字段类型,把枚举当普通常量。在我这个项目里,枚举是全盘唯一一处「一处定义、三处生效」的东西,它是业务词汇的单一事实来源。

枚举取值数它其实在说什么典型取值
ORDER_STATUS8订单从录入到关闭的完整生命周期(具体取值见 schema.ts)录入态 / 校验中 / 已确认 / 已拆单 / 生产中 / 待安装 / 已完工 / 已关闭
PO_STATUS5采购单(PO)的流转阶段草稿 / 待审 / 已审 / 已下单 / 已到货
WO_STATUS4工单(WO)的阶段,比订单少一半——因为工单不做校验待排产 / 生产中 / 待检验 / 已完工
REQ_STATUS3需求单:最简的一层待处理 / 已处理 / 已关闭
MOVE_TYPES5出入库流水类型(进 / 出 / 调 / 报损 / 退货)入库 / 出库 / 调拨 / 报损 / 退贷
USER_ROLES3系统角色(管理员 / 门店操作员 / 只读)管理 / 操作 / 只读
为什么 8 / 5 / 4 / 3 这个数字本身有信息量:它们的差,就是业务的复杂度差。
订单 8 态是因为订单要穿过销售 → 拆单 → 生产 → 安装 → 售后五个角色,每个角色交接一次就得有一个状态;
采购单 5 态比订单少两级,因为它不穿过生产;
工单 4 态再少一级,因为它只管「做完没做完」;
需求单 3 态是最简形态。
看到 8/5/4/3 就知道该往哪层加状态:只有订单那条线值得认真加,其余三层加太多态没人会用。
一处定义
packages/db/src/schema.ts 定义 ORDER_STATUS
枚举常量与类型都在这里。这是全盘唯一一处订单状态的写法。
第 1 处生效
packages/engine 直接 import
engine 判断「这个订单能不能进拆单」用枚举常量比较,不写字符串字面量。
第 2 处生效
apps/server 校验接口入参
tRPC 的入参校验用 zod 挂枚举,非法状态接口层直接拒。
第 3 处生效
apps/web 的下拉框
页面下拉选项从同一个枚举生成,不是手写 8 个 option。
这就是 monorepo 给我的最直观一笔钱:后端拒绝的枚举值集合、前端能选的选项集合、engine 认得的合法值集合,不可能对不上——因为它们物理上是同一份定义。
拆成多仓库的话,这份枚举至少有三份:后端一份、前端一份、文档一份,然后就是经典的「前端多了个选项,后端直接 400」。我不打算把这张表讲成「monorepo 包治百病」,它只解决同一份定义多处消费这一类问题。
06

SQLite 够不够:以及 schema.ts 末尾那段「PostgreSQL 迁移」的真相

先清清楚楚说一句,免得后面全白讲:

全盘没有任何 MySQL / PostgreSQL 在跑。数据库全是 SQLite 家族;better-sqlite3 在全项目命中 26 个文件。

schema.ts 末尾确实有一段注释列着「将来迁到 PostgreSQL 时字段类型怎么映射」——integer 怎么变、real 怎么变、JSON 列怎么处理。那是预案,不是现状。讲这一段时我从不把它当成已有资产。

当时的部署形态(决定「够不够」的关键)

环节实际做法为什么
运行时Express + better-sqlite3进程内同步驱动,没有连接池开销
并发SQLite WAL 模式读写不互斥,读不阻塞写
进程管理pm2挂了自动拉起
发布rsync 推文件 + git tag + md5 校验整个库是一个文件,备份 = 拷文件,迁移服务器 = 拷文件
迁移drizzle-kit generate → migrategenerate 只生成文件,migrate 才写库,两步不能混

什么时候我会认为「必须换 PostgreSQL」

信号SQLite 会怎样要不要换
写并发超过一个进程(多台机器同时写同一份数据)文件锁打架,SQLITE_BUSY 频发换(想清楚要不要先 accepts 多机)
同一份数据要被多台机器读要么共享磁盘(单点),要么每机一份(不一致)换
需要 JSONB 内部索引、窗口函数、复杂分析型聚合能写但很难读、很难优化考虑换
数据量大到单机磁盘装不下 / 备份窗口过长拷文件这个「优点」直接变成缺点换
只是「担心以后不够用」——不换。这是过度工程。
我现在的判断:这个系统换 PG 的收益接近 0,因为部署永远是单台单机单进程,业务峰值撑不到 SQLite 的天花板。真要换,触发条件应该是「上了第二台机器」,而不是「怕以后不够」。
而 schema 末尾那段迁移注释的真实价值是:它让我在决策要不要换的时候,不用先花两周查「换 PG 要改多少字段」——答案是「看注释,半天」。预案的价值是加速决策,不是提前实施。
07

collections 那个 JSON blob 的债,为什么到现在还没还清

这是全盘唯一一处我承认「知道错了但还没修完」的地方。机制在 深入版 16 讲过,这里只复盘为什么没修。

现象机制(机制详见 16 深入版)
写坏一次整库读不出来blob 字段一改就是重新序列化整个对象 + 重写整行,横跨好几页。中途任何一页写失败,这行就被截断在半路;截断的 JSON 是非法 JSON,JSON.parse 直接抛异常,于是这个查询接口整个报错,同一次查询里其他完全正常的数据也一起取不到
没法按字段查索引用在「列」上,建不到 JSON 内部的键上。只能全表拉出来在应用层 parse 再 filter,连带分页失效、排序和分组统计做不了
并发只能串行「读出来 → 改一个字段 → 整个写回」不是原子操作,两个请求会丢更新;而且整行互斥,写入串成一列

没修的三个理由(第二个是我判断错的)

1

真因:修它的成本被我算高了

要动的是读写全路径 + 迁移期间的双写,一次性投入不小。但我当时只算了工作量,没算风险等级。

2

真的判断错了:我把「还债」排在「加功能」后面

排序逻辑是「先交付客户要的新功能,债下个迭代还」。这个排序是错的:这个债的画像不是「高频低损」,是「低频高损」——一年可能一次都不爆,一旦爆就是整库读不出来,属于全站级故障。低频高损的债,优先级应该高于中等收益的新功能,因为它不可预测,且恢复时间长。
这是我在这套代码上做过的最值得记住的一个判断错误:不是技术错误,是风险排序错误。

3

客观原因:改动面积确实大

那张表被多处读写,改结构要配合迁移脚本 + 应用双读双写 + 回滚预案三件事一起上,不能分两次做。

现在的最小止损(还没还本)

止损动作做了没有作用
迁移前 cp 主库 连 -wal 一起拷,并比对 md5✅ 已在流程里不校验 md5 等于没备份
只读接口外层加 try/catch,坏行只让这一行失败✅ 已加把「整功能挂」降级成「这一行显示不出来」,这是目前最有价值的一处改造
导出 CSV 留档(尤其删字段前)✅ 已进流程SQLite 没有 ALTER COLUMN,删列底层是「建新表→拷数据→删旧表→改名」
把 blob 拆成独立列❌ 还没做本债未偿
诚实收口:「我已经识别了技术债」不等于「我还了技术债」。我可以很清楚地讲清它的机制(见 16 深入版),但这篇复盘里我必须承认:它还在,而且它是我优先级排错造成的。下次做债的排序,我用一条标准:按「发生概率 × 恢复时长」排,不按「修起来要几个人天」排。
08

拆完之后,真实的收益和代价各是什么

这一节是整篇复盘里最想让人记住的部分:把红利和代价分开算,不要混成一句「用了 monorepo 效率更高」。

项是收益还是代价真实程度(我自己怎么评价)
类型联动(后端改接口,前端立刻编译报错)✅ 真收益最值钱的一笔。而且只有 monorepo 能做到——拆两仓库前端根本拿不到后端类型。代价几乎为零
枚举一处定义三处生效✅ 真收益非常实用,见第 05 节。这个是「业务词汇收敛」的红利
一次 build 全量(engine → db → server 串行)✅ 真收益部署时不用先想「该先编哪个包」。但收益比想象小:因为最后一个 packages 只有 4 个,人工排也算得清
engine 能单测✅ 超预期收益npm run engine:test 不起库、不起服务,几个真实型号几秒跑完。这直接决定了「敢不敢改引擎」
packages/db 的 schema 收敛(27 张表一个文件)✅ 全盘最值钱的一处见下
CI 变慢❌ 真代价四个包各跑一次 typecheck,本地改一行代码要等全量 tsc。四包规模下,增量构建其实更划算
本地要装全栈工具链❌ 真代价一次 npm install 要把后端、前端、构建工具全装齐;Windows 上还要处理 symlink 权限(EPERM);新人上手第一天就卡
「一个人维护时 monorepo 的价值被高估了」⚠️ 我的结论见下
关于「最值钱的是 packages/db 那层 schema 收敛」:哪怕这个仓库明天被拆成四个仓库,只要 schema.ts 还在一处、枚举还在一处、engine 还是纯 TS,这套架构的核心价值就还在。
反过来,如果只把代码搬进 monorepo,而 packages/db 仍然分散成 9 个文件、枚举仍然各写一份、engine 仍然 import express——那 monorepo 只是一个换了位置的文件夹。
所以真正的结论是:monorepo 是「把已经收敛的东西放到一起」的容器,不是「让东西自动收敛」的机制。
关于「一个人维护时被高估」:上面第 01 节留了个伏笔——五条痛点里三条是协作痛、两条是工程痛。协作痛要靠「有多个人同时改」才成立。我一个人的时候:
• 类型联动仍值钱(编译器给我兜底,不用记)
• 枚举一处定义仍值钱(防止我自己前后不一致)
• 但「免了四次发版」这条,在一个人身上几乎等于零——我本来就不会忘记自己刚发过哪一版
所以我给客户或面试的建议一直是同一句:一个人维护的小系统,先想清楚「要共享的是类型还是只是代码」。要共享类型 → monorepo 划算;只是想少建几个仓库 → 别装。
09

如果重来一次,我会怎么做(取舍清单)

不是「我早说了应该一开始就…」那种马后炮,是把当时做的决策逐个翻出来,标出我愿意改哪几条、哪几条不改。

#当时怎么做重来怎么改理由
1直接上四包全量 monorepo先只抽 packages/db(哪怕先只做 schema + 枚举一个包)收敛是最大价值,但它不需要 monorepo 才能成立。先做这一步,风险最小、见效最快
2engine 做成纯 TypeScript、依赖为空不改,保持这是唯一一条我觉得超出预期的决策:它换来的是「几秒跑完的单测」,是我后面敢改引擎的前提
3一上来就配全量 CI(四条构建 + 四条 typecheck)先不上 CI,等包超过 6 个再上四个包时全量构建纯属负担,增量构建更划算;多一层工具就多一份维护
4把 JSON blob 的债排在功能后面按「概率 × 恢复时长」排,高损债一律前置第 07 节那个判断错的直接后果
59 层分区只存在我脑子里 + 文件顺序落进表名前缀(mst_/ord_/wo_…)分区现在只能靠人记。靠人记的东西,第二个人来就会错位
6部署用 rsync + git tag + md5不改单文件数据库 + 单机的组合下,这个方案简单到没有理由换
7先跑数据体检再建引擎不改,这条我要强调的是「坚持」体检结论直接改掉了项目走向(数据不够就不硬算,详见 复盘 25)。这是这个项目最正确的顺序决策
最重要的一条是第 1 条和第 5 条:如果把「先抽 db」和「分区落表名」这两条做到位,就算有一天我把 monorepo 拆回多仓库,这个项目也不会垮。
架构的价值有两个层次:容器(monorepo)和约束(schema 收敛 + 依赖方向 + 纯引擎)。容器可以换,约束不能丢。我当时把太多精力放在容器上了,这点我认。
✓

自查清单

  • ✓我说得出 delimao-system 是全盘唯一一个 monorepo,用 npm workspaces 而非 pnpm/turbo,并知道为什么四包规模下不上更重的工具
  • ✓我能背出四包边界和依赖方向,并说出engine 不许 import server / db 的三重代价
  • ✓我知道这条红线是用工具守的(engine 的 dependencies 为空,乱 import 会 install 报错)
  • ✓我讲得清 27 张表 9 层分区是什么,并且知道分区是人的索引不是数据库约束
  • ✓我能把 6 组枚举(ORDER 8 / PO 5 / WO 4 / REQ 3 / MOVE 5 / USER 3)跟真实业务流转对上,并知道数字差就是业务复杂度差
  • ✓我承认 schema.ts 末尾那段 PostgreSQL 映射是预案不是现状,全盘零 MySQL
  • ✓我给出过更换 PG 的具体触发条件(多机写、多机读同一份、JSONB 索引需求、备份窗口),并且明确「只是怕以后不够」不换
  • ✓我承认 collections 的 JSON blob 债还没还,并且知道根因是我把风险排序排错了,不是工作量问题
  • ✓我能把收益和代价分开算:类型联动 / 枚举收敛 / engine 单测是真收益;CI 变慢 / 全栈工具链是真代价
  • ✓我能说出那句结论:monorepo 是「把已收敛的东西放在一起」的容器,不是「让东西自动收敛」的机制
案例复盘 25 订购单流水线:一条流水线怎么在「数据不够」时停下来 →
面试怎么答

题目:你做过的最有价值的一个架构决策是什么?

我会讲「先做数据体检,再决定要不要建引擎」,架构决策放在第二条讲——因为架构决策的说服力来自「你为什么这么做、代价是什么」,而数据体检那条有真数字兜底。(那条在 复盘 25。)

架构这条我讲四包边界。我的项目只有一个 monorepo,npm workspaces,四个包:apps/server(Express + tRPC)、apps/web(React)、packages/db(27 张表 schema + migrate/seed)、packages/engine(报价 / BOM / 玻璃图)。划分标准是「有没有独立启动方式」,不是命名习惯。

最值钱的设计是 engine 一个依赖都没有。它纯 TypeScript,不是省事,是自检机制:package.json 里没声明 dependencies,谁乱 import 了 express 或 db,npm install 当场报错,不用等运行时发现。换来的是 npm run engine:test 不起数据库、不起服务,几秒钟跑完几个真实型号的断言——这就是我敢反复改引擎的原因。代价我也认:engine 绝不能 import server,否则成环。

第二值钱的是 packages/db 那一层 schema 收敛。27 张表在一个 schema.ts 里,按 9 层分区(主数据 / 商品 / 价格 / 订单 / 拆单 / 生产 / 图纸 / 库存 / 系统)。分区不是数据库约束,是给我自己排障用的索引:线上说「算不出价」,我先问订单进没进第 4 层、型号进没进第 2 层、价格进没进第 3 层,三层都有问题才去翻算法。
枚举也是这一层的红利:ORDER_STATUS 8 态、PO_STATUS 5 态、WO_STATUS 4 态、REQ_STATUS 3 态、MOVE_TYPES 5 种、USER_ROLES 3 种。这六组枚举一处定义、engine / server / web 三处消费——后端拒绝的集合、前端下拉框选项、engine 认得的合法值,物理上是同一份,不可能对不上。拆两仓库的话就是三份,然后就是经典的「前端多了个选项,后端直接 400」。

数据库这块我必须诚实说一句:全盘没有 MySQL / PostgreSQL,都是 SQLite 家族,better-sqlite3 命中 26 个文件,最佳实践是 schema.ts 末尾那一段 PostgreSQL 迁移映射注释——那是预案不是现状,我从不把它当已有资产讲。现在换 PG 我不认为是好事:部署是单机单进程 pm2 + rsync,库就是一个文件,备份 = 拷文件。真要换的触发条件应是「上了第二台机器」,而不是「怕以后不够」,后者是过度工程。

还有一个我没还完的债,我主动讲。门店订购单系统的 collections 表把整个对象存成单行 JSON blob。我清楚它的机制:改一个字段要重写整行,写坏一次整行截断、JSON.parse 抛异常,同一个查询里其他正常数据也一起取不到;索引用不到 JSON 内部键,只能全表拉出来应用层 filter;读写不是原子,两个请求会丢更新,而且整行互斥、并发串行。
但它还没修,根因不是工作量,是我把风险排序排错了——我按「修要几个人天」排,把它排在功能后面。它其实是低频高损:一年可能一次都不爆,一旦爆就是全站读不出来,恢复时间长。现在的最小止损是只读接口 try/catch(把整功能挂降级成这一行显示不出来)+ 迁移必拷 -wal 并比对 md5。下次排债我用一条新标准:按「发生概率 × 恢复时长」排,不按工作量排。

最后说说我对 monorepo 的真实判断,这个比技术细节更能看出水平:收益里类型联动、枚举收敛、engine 能单测是真收益;CI 变慢(四个包各跑一次全量 tsc)、本地要装全栈工具链(Windows 还要处理 symlink 权限)是真代价。而且一个人维护的时候,monorepo 的价值是被高估的——「免了四次发版」这条在一个人身上几乎等于零,我本来就不会忘记自己刚发过哪版。
所以我的结论是:monorepo 是「把已经收敛的东西放到一起」的容器,不是「让东西自动收敛」的机制。如果重来,我会先只抽 packages/db——因为 schema 收敛是最大价值,但它不需要 monorepo 也能成立;CI 等包超过 6 个再上;9 层分区要落进表名前缀而不是靠人记。 容器可以换,约束不能丢。