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

File diff suppressed because it is too large Load Diff

View File

@@ -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") +