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

@@ -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}`)