feat(迁移): 0000 改成可执行,空库能自举

0000_crazy_gateway.sql 原本是 drizzle-kit pull 的产物,整份被 /* */ 包着、
可执行语句 0 条,所以任何新库的结构都只能先手工 psql 灌一遍 schema.sql 再打基线。
现在它的内容由 docs/specs/schema.sql(2026-08-07 的生产 pg_dump --schema-only)
机械转换而来:去掉 psql 专有指令 12 条、去掉 7 张 Django 遗留表及其索引外键 41 条,
保留 212 条语句、顺序不动。转换脚本入库在 docs/spikes/。

不含 Django 那 7 张表,是因为 meta/0000_snapshot.json 从来就没有它们(pull 当时
tablesFilter 滤掉了),不建它们才和快照一致;0002 那串 DROP ... IF EXISTS 在新库上
空转、在生产库上真删,两边跑同一串迁移落点相同。

改 0000 对生产库没有影响:migrator 只比 created_at、从不校验 hash,而生产库那行
baseline-0000-faked 早把它挡在门外了。

migrate.ts 配套:空库直接从 0000 建起(并自建 drizzle 记账表——原来这步由 drizzle 的
migrate() 顺手做掉);自举时不触发破坏性闸门,因为空库上没有数据可丢,拦下来只会逼
每个新环境都带一次 OJ2_ALLOW_DESTRUCTIVE,把这道闸训练成习惯动作。「有表但没基线」
仍然 exit 3。

验证:空库自举出来的结构,和「灌 schema.sql + 打基线 + 跑迁移」这条老路子跑出来的
结构,pg_dump --schema-only 逐字节一致(734 行,零差异)。

转换时踩到两个坑,都写进注释了:注释里不能出现 statement-breakpoint 的字面量
(readMigrationFiles 纯文本切分,会把注释从中间切开);过滤 Django 对象要看
public.X 形式的对象引用,不能扫语句字样——user 表有一列叫 auth_token。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 09:54:31 -06:00
parent 2e871cb5b0
commit bb4b5825c3
4 changed files with 1074 additions and 422 deletions

View File

@@ -143,16 +143,38 @@ OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh
``` ```
`DROP INDEX` / `DROP CONSTRAINT` 不算——它们不掉数据,拦了只会让人习惯性带上放行开关。 `DROP INDEX` / `DROP CONSTRAINT` 不算——它们不掉数据,拦了只会让人习惯性带上放行开关。
**空库自举时这道闸不生效**没有数据可丢0002 那串 `DROP ... IF EXISTS` 全是空转,
拦下来只会逼每个新环境都带一次放行开关,把它训练成习惯动作。
**0000 跑不了,库不能靠迁移自举。** `0000_crazy_gateway.sql``drizzle-kit pull` **空库能自举** `oj2-api migrate` 指向一个空库时直接从 `0000` 建起:
的产物,整份被 `/* */` 包着,可执行语句 0 条。所以任何新库的结构都只能来自
`docs/specs/schema.sql` 或生产 dump然后手工做基线。`oj2-api migrate` 会检测这两种 ```bash
情况并打印具体该做什么,不会让你撞上 drizzle 那个语焉不详的报错。 DATABASE_URL=postgres://... oj2-api migrate
# 空库,从 0000 开始自举。
# 待执行 3 条迁移,开始。
# ✓ 0000_crazy_gateway
# ✓ 0001_add_submission_public_create_time_idx
# ✓ 0002_drop_django_leftovers
```
`0000_crazy_gateway.sql` 原本是 `drizzle-kit pull` 的产物、整份被 `/* */` 包着、可执行
语句 0 条,所以以前新库只能先手工 `psql -f docs/specs/schema.sql`。现在它的内容由那份
生产 dump 机械转换而来(去掉 psql 专有指令、去掉 7 张 Django 遗留表及其索引外键,
其余原样保留)。**实测**:空库自举出来的结构,和「灌 schema.sql + 打基线 + 跑迁移」
这条老路子跑出来的结构,`pg_dump --schema-only` 逐字节一致734 行,零差异)。
改 0000 对生产库没有影响 —— migrator 只比 `created_at`、**从不校验 hash**
`pg-core/dialect.js` 里就一句 `Number(lastDbMigration.created_at) < migration.folderMillis`
而生产库那行 `baseline-0000-faked` 早把它挡在门外了。
⚠️ **0000 的注释里不要出现 statement-breakpoint 那个分隔标记的字面量。**
`readMigrationFiles` 是纯文本切分,不管它在不在注释里,照切不误 —— 注释被从中间切开,
后半截当成 SQL 发出去,报的是 `syntax error at or near "。"` 这种和真实原因毫不相干的错。
**给一个已经存在的库做基线**drizzle 没有 `--fake-initial``migrate` 见到空的 **给一个已经存在的库做基线**drizzle 没有 `--fake-initial``migrate` 见到空的
`__drizzle_migrations` 会从 `0000` 的完整建表跑起,撞上已存在的表就整个事务回滚 —— `__drizzle_migrations`、库里却已经有表,会拒绝执行并 exit 3裸跑 `drizzle-kit migrate`
**而且失败时 exit 1 一个错误都不打印**(只有 NOTICE实测过。所以对已有数据的库 的话则是从 `0000` 撞上已存在的表、整个事务回滚,**而且 exit 1 一个错误都不打印**)。
第一次跑之前,先手插一行把 `0000` 标记成已执行: 对已有数据的库第一次跑之前,先手插一行把 `0000` 标记成已执行:
```sql ```sql
CREATE SCHEMA IF NOT EXISTS drizzle; CREATE SCHEMA IF NOT EXISTS drizzle;

File diff suppressed because it is too large Load Diff

View File

@@ -77,26 +77,35 @@ export async function runMigrations() {
from drizzle.__drizzle_migrations from drizzle.__drizzle_migrations
`.catch(() => null) `.catch(() => null)
// 基线缺失。这个库没法靠迁移自举 —— 0000 是 `drizzle-kit pull` 的产物, // 没有基线记录,两种情况分开处理:空库直接从 0000 建起来,有表的库要人来确认。
// 整个文件被块注释包着,一条可执行语句都没有。结构只能来自 docs/specs/schema.sql
// 或生产 dump然后手工把 0000 标记成已执行。
const lastApplied = applied === null ? -1 : Number(applied[0]?.last ?? -1) const lastApplied = applied === null ? -1 : Number(applied[0]?.last ?? -1)
if (lastApplied < 0) { const bootstrapping = lastApplied < 0
if (bootstrapping) {
const rows = await client<{ count: number }[]>` const rows = await client<{ count: number }[]>`
select count(*)::int as count from information_schema.tables where table_schema = 'public' select count(*)::int as count from information_schema.tables where table_schema = 'public'
` `
const tableCount = rows[0]?.count ?? 0 const tableCount = rows[0]?.count ?? 0
console.error(
tableCount > 0 // 有表却没有基线记录 —— 这个库不是 OJ2 从 0000 建起来的(多半是从旧后端接管、
? `库里已经有 ${tableCount} 张表,但没有迁移基线记录。\n` + // 或者从生产 dump 恢复出来的。0000 是完整建表,直接跑必然撞上已存在的表,
"直接迁移会从 0000 跑起,而 0000 是 introspect 产物、整份被注释掉,跑不了。\n\n" + // 而且是整个事务回滚。这种情况只能由人确认之后手工打基线。
BASELINE_HOWTO if (tableCount > 0) {
: "这是个空库迁移没法自举建表0000 是 introspect 产物,整份被注释掉)。\n" + console.error(
"先把结构灌进去:\n\n" + `库里已经有 ${tableCount} 张表,但没有迁移基线记录。\n` +
" psql -d <库> -f docs/specs/schema.sql\n\n" + "直接迁移会从 0000 跑起,而 0000 是完整建表,撞上已存在的表会整条回滚。\n\n" +
BASELINE_HOWTO, BASELINE_HOWTO,
) )
process.exit(3) process.exit(3)
}
// 空库drizzle 的记账表还不存在,先建出来。原来这一步由 drizzle 的 migrate()
// 顺手做掉,换成自己的执行器之后得自己建。
console.log("空库,从 0000 开始自举。")
await client`create schema if not exists drizzle`
await client`
create table if not exists drizzle.__drizzle_migrations (
id serial primary key, hash text not null, created_at bigint)
`
} }
const pending = files.filter((f) => f.folderMillis > lastApplied) const pending = files.filter((f) => f.folderMillis > lastApplied)
@@ -124,7 +133,10 @@ export async function runMigrations() {
})) }))
.filter(({ reasons }) => reasons.length > 0) .filter(({ reasons }) => reasons.length > 0)
if (blocked.length > 0 && process.env.OJ2_ALLOW_DESTRUCTIVE !== "1") { // 自举时不拦空库上没有数据可丢0002 那串 DROP ... IF EXISTS 全是空转。
// 拦下来只会逼着每个新环境都带一次 OJ2_ALLOW_DESTRUCTIVE把这道闸训练成习惯动作 ——
// 那正是它想避免的事。
if (blocked.length > 0 && !bootstrapping && process.env.OJ2_ALLOW_DESTRUCTIVE !== "1") {
console.error( console.error(
"待执行的迁移里有破坏性语句,已停下:\n" + "待执行的迁移里有破坏性语句,已停下:\n" +
blocked.map(({ tag, reasons }) => ` · ${tag}${reasons.join(" / ")}`).join("\n") + blocked.map(({ tag, reasons }) => ` · ${tag}${reasons.join(" / ")}`).join("\n") +

View File

@@ -0,0 +1,105 @@
#!/usr/bin/env bun
// 把 docs/specs/schema.sql生产库的 pg_dump --schema-only转成可执行的基线迁移
// apps/api/src/db/0000_crazy_gateway.sql。
//
// 这是**一次性**的转换,产物已经入库。留着它是为了说清 0000 的出处、以及日后万一要
// 从一份新的生产 dump 重做基线时不用从头想一遍规则。日常改 schema 不要碰这里,
// 走 `bun run db:generate`。
//
// 跑法(仓库根):
// bun docs/spikes/build-baseline-migration.ts
//
// 产物是确定性的:同一份 schema.sql 跑出来的字节完全一样,改完可以直接 git diff 看。
import { readFileSync, writeFileSync } from "node:fs"
const SOURCE = "docs/specs/schema.sql"
const TARGET = "apps/api/src/db/0000_crazy_gateway.sql"
// drizzle 的语句分隔标记。故意不写成字面量常量之外的形式:`readMigrationFiles` 是纯文本
// 切分,这个串出现在哪里都会切,所以生成出来的文件的**注释里**绝不能带上它。
const BREAKPOINT = "--> statement-breakpoint"
// 判断一条语句是不是 Django 遗留物:只看它引用了哪些**对象**`public.X` 形式),
// 不看语句里有没有出现这些字样。
//
// 踩过的坑:一开始扫整条语句里的 `auth_` / `django_` 字样,结果把 `user` 表整个滤掉了 ——
// 它有一列叫 `auth_token`。列名不带 `public.` 前缀,按对象引用来判就不会误伤。
//
// 覆盖到的形式CREATE TABLE/SEQUENCE public.X、CREATE INDEX ... ON public.X、
// ALTER TABLE [ONLY] public.X、ALTER SEQUENCE public.X OWNED BY public.Y.id、
// 以及外键里的 REFERENCES public.Y —— 对象名全都跟在 `public.` 后面。
const OBJECT_REF = /public\."?([a-z_]+)"?/g
const DJANGO_PREFIX = ["auth_", "django_"]
function isDjango(stmt: string) {
return [...stmt.matchAll(OBJECT_REF)].some(([, name]) => DJANGO_PREFIX.some((p) => name.startsWith(p)))
}
const HEADER = `-- OJ2 的基线迁移:把一个空库建成新后端要的结构。
--
-- 这份文件**不是** \`drizzle-kit generate\` 的产物,也不该由它重新生成。原本这里是
-- \`drizzle-kit pull\` 吐出来的东西,整份被 /* */ 包着、一条可执行语句都没有,于是
-- 「空库没法靠迁移自举」——新环境、演练、别人接手,都得先手工 psql 灌一遍 schema.sql。
--
-- 现在的内容由 \`docs/specs/schema.sql\`(生产库 2026-08-07 的 pg_dump --schema-only
-- 机械转换而来:去掉 psql 专有指令(\\restrict / SET / set_config、去掉 7 张 Django
-- 遗留表及其索引与外键,其余原样保留、顺序不动,语句之间插上 drizzle 的分隔标记。
-- 转换脚本:\`docs/spikes/build-baseline-migration.ts\`
--
-- 注意:那个分隔标记是纯文本切分,\`readMigrationFiles\` 不管它出现在哪里 —— 写进注释里
-- 一样会把文件切开。所以本文件的注释里不要出现它的字面量(我踩过一次,报错是
-- 「syntax error at or near "。"」,因为注释被从中间切断了)。
--
-- 为什么不含 Django 那 7 张表:\`meta/0000_snapshot.json\` 从来就没有它们pull 当时用
-- tablesFilter 滤掉了所以不建它们才和快照一致。0002 那条 DROP 全带 IF EXISTS
-- 在新库上是空转,在生产库上才真删——两边跑同一串迁移,落点相同。
--
-- **改 schema 不要动这个文件**,走 \`bun run db:generate\` 生成新的迁移。
-- 生产库早已把 0000 标记成已执行migrator 只比 created_at、不校验 hash
-- 所以这份内容的任何改动都不会在生产库上重放。
`
const statements: string[] = []
let buffer: string[] = []
let droppedDjango = 0
let droppedNoise = 0
for (const line of readFileSync(SOURCE, "utf8").split("\n")) {
const trimmed = line.trim()
// 语句之外的行空行、注释、psql 元命令(\restrict直接跳过
// SET / set_config 是 pg_dump 给自己用的会话设置,迁移里不需要。
if (buffer.length === 0) {
if (trimmed === "" || trimmed.startsWith("--") || trimmed.startsWith("\\")) continue
if (trimmed.startsWith("SET ") || trimmed.startsWith("SELECT pg_catalog.set_config")) {
droppedNoise++
continue
}
}
buffer.push(line)
// 按行尾分号断句。schema.sql 里句中出现分号的只有注释行,而注释行进不到这儿。
if (!trimmed.endsWith(";")) continue
const stmt = buffer.join("\n").trim()
buffer = []
if (isDjango(stmt)) {
droppedDjango++
continue
}
if (/^ALTER TABLE .* OWNER TO /.test(stmt) || stmt.startsWith("COMMENT ON")) {
droppedNoise++
continue
}
statements.push(stmt)
}
if (buffer.length > 0) throw new Error(`有没闭合的语句:${buffer[0]}`)
const body = statements.map((s) => s.replace(/;+$/, "")).join(`;\n${BREAKPOINT}\n`)
writeFileSync(TARGET, `${HEADER}\n${body};\n`)
console.log(`保留 ${statements.length} 条语句 | 滤掉 Django 相关 ${droppedDjango} 条、噪音 ${droppedNoise}`)
console.log(`已写入 ${TARGET}`)