docs: 文档整理,CLAUDE.md 瘦身一半,删掉重写期已完成的 22 份阶段产物

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>
This commit is contained in:
2026-09-16 08:03:26 -06:00
parent 3559ae4d6f
commit a8408c0bb5
34 changed files with 593 additions and 7570 deletions

135
docs/database.md Normal file
View File

@@ -0,0 +1,135 @@
# 数据库与迁移
`CLAUDE.md` 里只留了日常要记住的那几条,这里是细节:迁移执行器为什么是自己的、
空库怎么自举、给已有库打基线、以及 drizzle-kit 的几个坑。
## 迁移执行器是自己的,不是 drizzle 那个
`db/migrate.ts` 不调用 drizzle 的 `migrate()`,自己按 `meta/_journal.json` 逐条执行。
换掉它是因为 `pg-core/dialect.js` 里那个实现有两条硬伤:
1. **所有待执行的迁移共用一个事务**,第 3 条失败会把第 1、2 条一起回滚。现在是**一条一个
事务**,语义和 Django `migrate` 一致,失败时也说得清库停在哪儿。
2. 正因为全在事务里,`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`
在迁移文件**第一行**写上标记,这条迁移就走裸执行(简单查询协议,不包事务):
```sql
-- 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` 不算 —— 它们不
掉数据,拦了只会让人习惯性带上放行开关。
1. 服务器上手工部署:`OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh`
2. CI`.github/workflows/deploy.yml`**必须先手工触发**并在 `workflow_dispatch` 上勾
`allow_destructive`。push 触发拿不到这个 input值恒为空 —— 也就是说**自动部署永远
不会执行破坏性迁移**,只会停在闸门上把工作流判红。这是有意的:那种改动得有人先确认备份。
3. 先单跑迁移把结构推到位,再 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` 标记成已执行:
```sql
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 才炸出来。这是当初难定位的原因。