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:
36
CLAUDE.md
36
CLAUDE.md
@@ -143,16 +143,38 @@ OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh
|
||||
```
|
||||
|
||||
`DROP INDEX` / `DROP CONSTRAINT` 不算——它们不掉数据,拦了只会让人习惯性带上放行开关。
|
||||
**空库自举时这道闸不生效**:没有数据可丢,0002 那串 `DROP ... IF EXISTS` 全是空转,
|
||||
拦下来只会逼每个新环境都带一次放行开关,把它训练成习惯动作。
|
||||
|
||||
**0000 跑不了,库不能靠迁移自举。** `0000_crazy_gateway.sql` 是 `drizzle-kit pull`
|
||||
的产物,整份被 `/* */` 包着,可执行语句 0 条。所以任何新库的结构都只能来自
|
||||
`docs/specs/schema.sql` 或生产 dump,然后手工做基线。`oj2-api migrate` 会检测这两种
|
||||
情况并打印具体该做什么,不会让你撞上 drizzle 那个语焉不详的报错。
|
||||
**空库能自举了。** `oj2-api migrate` 指向一个空库时直接从 `0000` 建起:
|
||||
|
||||
```bash
|
||||
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_migrations` 会从 `0000` 的完整建表跑起,撞上已存在的表就整个事务回滚 ——
|
||||
**而且失败时 exit 1 但一个错误都不打印**(只有 NOTICE,实测过)。所以对已有数据的库
|
||||
第一次跑之前,先手插一行把 `0000` 标记成已执行:
|
||||
`__drizzle_migrations`、库里却已经有表,会拒绝执行并 exit 3(裸跑 `drizzle-kit migrate`
|
||||
的话则是从 `0000` 撞上已存在的表、整个事务回滚,**而且 exit 1 却一个错误都不打印**)。
|
||||
对已有数据的库第一次跑之前,先手插一行把 `0000` 标记成已执行:
|
||||
|
||||
```sql
|
||||
CREATE SCHEMA IF NOT EXISTS drizzle;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -77,26 +77,35 @@ export async function runMigrations() {
|
||||
from drizzle.__drizzle_migrations
|
||||
`.catch(() => null)
|
||||
|
||||
// 基线缺失。这个库没法靠迁移自举 —— 0000 是 `drizzle-kit pull` 的产物,
|
||||
// 整个文件被块注释包着,一条可执行语句都没有。结构只能来自 docs/specs/schema.sql
|
||||
// 或生产 dump,然后手工把 0000 标记成已执行。
|
||||
// 没有基线记录,两种情况分开处理:空库直接从 0000 建起来,有表的库要人来确认。
|
||||
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 }[]>`
|
||||
select count(*)::int as count from information_schema.tables where table_schema = 'public'
|
||||
`
|
||||
const tableCount = rows[0]?.count ?? 0
|
||||
console.error(
|
||||
tableCount > 0
|
||||
? `库里已经有 ${tableCount} 张表,但没有迁移基线记录。\n` +
|
||||
"直接迁移会从 0000 跑起,而 0000 是 introspect 产物、整份被注释掉,跑不了。\n\n" +
|
||||
BASELINE_HOWTO
|
||||
: "这是个空库,迁移没法自举建表(0000 是 introspect 产物,整份被注释掉)。\n" +
|
||||
"先把结构灌进去:\n\n" +
|
||||
" psql -d <库> -f docs/specs/schema.sql\n\n" +
|
||||
BASELINE_HOWTO,
|
||||
)
|
||||
process.exit(3)
|
||||
|
||||
// 有表却没有基线记录 —— 这个库不是 OJ2 从 0000 建起来的(多半是从旧后端接管、
|
||||
// 或者从生产 dump 恢复出来的)。0000 是完整建表,直接跑必然撞上已存在的表,
|
||||
// 而且是整个事务回滚。这种情况只能由人确认之后手工打基线。
|
||||
if (tableCount > 0) {
|
||||
console.error(
|
||||
`库里已经有 ${tableCount} 张表,但没有迁移基线记录。\n` +
|
||||
"直接迁移会从 0000 跑起,而 0000 是完整建表,撞上已存在的表会整条回滚。\n\n" +
|
||||
BASELINE_HOWTO,
|
||||
)
|
||||
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)
|
||||
@@ -124,7 +133,10 @@ export async function runMigrations() {
|
||||
}))
|
||||
.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(
|
||||
"待执行的迁移里有破坏性语句,已停下:\n" +
|
||||
blocked.map(({ tag, reasons }) => ` · ${tag}:${reasons.join(" / ")}`).join("\n") +
|
||||
|
||||
105
docs/spikes/build-baseline-migration.ts
Normal file
105
docs/spikes/build-baseline-migration.ts
Normal 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}`)
|
||||
Reference in New Issue
Block a user