Files
OJ2/docs/database.md
T
xuyueandClaude Opus 5.5 4be569d978
Deploy / deploy (push) Canceled after 0s
chore(文档): CLAUDE.md 瘦身,后端专属的坑挪进 apps/api/CLAUDE.md,并改正「推 origin 不部署」的说法
- 单二进制、runtime.ts、SQL 自 spawn、路由遮蔽、出参 satisfies、外键与迁移原样搬到
  apps/api/CLAUDE.md,只在动后端文件时加载;根文件留一行指路
- Chrome 105 的 polyfill 审计细节挪到 apps/web/CLAUDE.md
- 删掉 ls 就能看到的仓库结构表,以及 docs/database.md、docker/judge/README.md 已有的重复步骤
- origin 会镜像到 github,平时推 origin 通常也就部署了(CLAUDE.md 与 deploy.md 原来写的是不触发)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:18:24 +08:00

136 lines
7.4 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.
# 数据库与迁移
`apps/api/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 才炸出来。这是当初难定位的原因。