CLAUDE.md 从 490 行降到 228 行:只留日常要当场记住的约束,展开拆成五份专题 文档 —— docs/deploy.md(部署与备份恢复)、database.md(迁移执行器、基线、 drizzle-kit 的坑)、timezone.md(时区口径与那次成就订正)、contract.md (出参不 parse 的四次故障)、ast-rules.md(AST 规则与 C++ 的调用形态)。 删掉的是阶段 0–5 那批一次性产物:4 份实施计划、10 份评审/核验/修复报告、 endpoint-inventory.md(110 端点是 2026-08 的快照,现在 363 条路由)、 docs/spikes/ 的 spike 与提取脚本(结论早已落进代码)。phase5 切换手册删之前 先把仍然有效的部分提炼进 docs/deploy.md:拓扑、deploy.sh、部署后验证清单、 NPM 那两个不能关的开关、pg_dumpall 恢复的两个坑、镜像体积;演练报告与回滚 两节随旧栈下线一并作废。 两份设计文档保留,补上状态行说明它们是「当初为什么这么定」而不是现状。 apps/web/CLAUDE.md 顺手订正过期内容:PUBLIC_OJ_URL / PUBLIC_WS_URL 两个变量 早已不存在(baseURL 写死 /api,dev 走 vite proxy、线上由 Caddy 同源伺服), store 与 composable 清单补齐到与目录一致。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7.4 KiB
数据库与迁移
CLAUDE.md 里只留了日常要记住的那几条,这里是细节:迁移执行器为什么是自己的、
空库怎么自举、给已有库打基线、以及 drizzle-kit 的几个坑。
迁移执行器是自己的,不是 drizzle 那个
db/migrate.ts 不调用 drizzle 的 migrate(),自己按 meta/_journal.json 逐条执行。
换掉它是因为 pg-core/dialect.js 里那个实现有两条硬伤:
- 所有待执行的迁移共用一个事务,第 3 条失败会把第 1、2 条一起回滚。现在是一条一个
事务,语义和 Django
migrate一致,失败时也说得清库停在哪儿。 - 正因为全在事务里,
CREATE INDEX CONCURRENTLY一律跑不了,没有开关。
记账行的写法和 drizzle 完全一致(hash = 整个文件的 sha256,created_at = journal 的
when),而 migrator 只比 created_at、不校验 hash,所以两套执行器可以互换,
不会看不懂对方写的记录。
bun run db:migrate 走的就是这个执行器(bun src/main.ts migrate),和线上
oj2-api migrate 完全同一条代码路径。drizzle-kit migrate 只在 db:generate
的对面存在,别再去调它 —— 对着已打基线的库裸跑会从 0000 撞上已存在的表、整个事务
回滚,而且 exit 1 却一个错误都不打印。
退出码:2 = 配置/文件问题,3 = 基线不对,4 = 撞上破坏性迁移,5 = 某条迁移执行失败。
CREATE INDEX CONCURRENTLY
在迁移文件第一行写上标记,这条迁移就走裸执行(简单查询协议,不包事务):
-- oj2:no-transaction
CREATE INDEX CONCURRENTLY "xxx_idx" ON "submission" USING btree ("language");
代价是没有回滚:中途失败时前面的语句已经生效,而且 CONCURRENTLY 失败会在库里留下
一个 INVALID 索引,要先 DROP INDEX 再重来
(select indexrelid::regclass from pg_index where not indisvalid 能找出来)。
所以这种迁移一个文件只放一条语句。
要不要用是另一回事:参考量级是 12.3 万行的部分索引,普通 CREATE INDEX 只锁 74ms,
一般不用纠结,CONCURRENTLY 留给真扛不住锁写窗口的场合。
破坏性迁移的三条放行路
含 DROP TABLE / DROP COLUMN / DROP SCHEMA / ALTER COLUMN ... TYPE / TRUNCATE
的迁移会让部署停在迁移这步并退出 4。DROP INDEX / DROP CONSTRAINT 不算 —— 它们不
掉数据,拦了只会让人习惯性带上放行开关。
- 服务器上手工部署:
OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh。 - CI(
.github/workflows/deploy.yml):必须先手工触发并在workflow_dispatch上勾allow_destructive。push 触发拿不到这个 input,值恒为空 —— 也就是说自动部署永远 不会执行破坏性迁移,只会停在闸门上把工作流判红。这是有意的:那种改动得有人先确认备份。 - 先单跑迁移把结构推到位,再 push 代码:迁移一旦记进
drizzle.__drizzle_migrations就不会再跑,后续自动部署里它已不是 pending,自然不触发闸门。多环境共库时 (机房 + 服务器)推荐这条。
空库自举时这道闸不生效:没有数据可丢,0002 那串 DROP ... IF EXISTS 全是空转,
拦下来只会逼每个新环境都带一次放行开关,把它训练成习惯动作。
空库能自举
oj2-api migrate(或本机 bun run db:migrate)指向一个空库时直接从 0000 建起:
空库,从 0000 开始自举。
待执行 16 条迁移,开始。
✓ 0000_crazy_gateway
✓ 0001_add_submission_public_create_time_idx
…
✓ 0015_submission_filter_indexes
0000_crazy_gateway.sql 原本是 drizzle-kit pull 的产物、整份被 /* */ 包着、可执行
语句 0 条,所以以前新库只能先手工灌一份 schema dump。现在它的内容由生产 dump 机械转换
而来(去掉 psql 专有指令、去掉 7 张 Django 遗留表及其索引外键,其余原样保留)。
实测(2026-09-16 复测,16 条迁移):空库自举出来的结构,和「灌 schema dump + 打基线
- 跑迁移」这条老路子跑出来的结构,
pg_dump --schema-only --no-owner --no-privileges逐行一致,1981 行零差异。本机开发库(bun run db:up)走的就是自举这条路。
改 0000 对生产库没有影响 —— migrator 只比 created_at、从不校验 hash,
而生产库那行 baseline-0000-faked 早把它挡在门外了。
⚠️ 0000 的注释里不要出现 statement-breakpoint 那个分隔标记的字面量。
readMigrationFiles 是纯文本切分,不管它在不在注释里,照切不误 —— 注释被从中间切开,
后半截当成 SQL 发出去,报的是 syntax error at or near "。" 这种和真实原因毫不相干的错。
给一个已经存在的库做基线
drizzle 没有 --fake-initial。migrate 见到空的 __drizzle_migrations、库里却已经有表,
会拒绝执行并 exit 3。对已有数据的库第一次跑之前,先手插一行把 0000 标记成已执行:
CREATE SCHEMA IF NOT EXISTS drizzle;
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
id SERIAL PRIMARY KEY, hash text NOT NULL, created_at bigint);
INSERT INTO drizzle.__drizzle_migrations (hash, created_at)
VALUES ('baseline-0000-faked', 1786070652521); -- = meta/_journal.json 里 0000 的 when
migrator 只比 created_at,不校验 hash,所以 hash 随便填。
drizzle-kit 的坑
meta/0000_snapshot.json 是 pull 出来的,没法无损还原 Django 建的 schema。
下面两处已经修过了,别让它们回潮:
- bigint 上限精度:
pull生成的maxValue: 9223372036854775807是 JS number 字面量, round-trip 成...776000,每次 generate 都会多出 10 条ALTER COLUMN ... SET MAXVALUE。 已改成字符串。 - 表达式索引的 opclass:
problem_tag_name_ci_unique在快照里带opclass,但 drizzle 自己序列化不出来,导致每次都 drop + recreate。已从快照里去掉。
(第三处「快照里的 Django 序列」已随 0002_drop_django_leftovers 删表一并解决,
tablesFilter 也移除了。)
还有一个写代码时要绕开的 —— .op() 会吞掉索引方向。 根因不是 .desc(),是 opclass:
drizzle-kit 的 CreatePgIndexConvertor 里那个三元一旦走进 opclass 分支就回不到方向分支
(${it.opclass ? ${it.opclass} : it.asc ? "" : " DESC"}),而 drizzle-kit pull
给每一列都挂了 .op(...),所以本仓库里「写了 .desc() 却生成不出 DESC」每次都会重演。
要方向就别写 .op()。 不写没有任何代价 —— int4_ops / timestamptz_ops 本来就是
这些类型的默认 opclass。实测(drizzle-kit 0.31.10,探针索引跑过 generate):
| schema.ts | 生成的 SQL |
|---|---|
.desc().nullsFirst().op("timestamptz_ops") |
"create_time" timestamptz_ops ← 方向丢了 |
.desc().nullsFirst() |
"create_time" DESC NULLS FIRST ✅ |
.desc() |
"create_time" DESC NULLS LAST ✅ |
所以多列混合方向的索引可以正常 generate,不必手写。
假 diff 的机制也要理解对:带 .op() 时快照记的是 asc: false,SQL 建出来却是 ASC,
分歧在快照和真实库之间,不在快照和 schema.ts 之间 —— 所以再跑 generate 是干净的,
要等到下次 pull 才炸出来。这是当初难定位的原因。