Files
OJ2/apps/api/src/db/migrate.ts
yuetsh ed56a209ea
Some checks failed
Deploy / deploy (push) Has been cancelled
chore(格式): Prettier 统一到全仓,后端和契约一次性格式化
原来只有 `apps/web` 在 Prettier 下(配置在 `apps/web/.prettierrc.toml`、脚本在
web 的 package.json),后端和契约从来没格式化过 —— 手写在 100 列上下,`db/schema.ts`
还是 drizzle-kit pull 留下的 tab 缩进。两套口径分叉久了,跨端改一处就得记着「这边
什么风格」。

- 配置搬到根目录 `.prettierrc.toml`,内容不变(`semi=false`,其余全默认,
  printWidth 80 —— 和前端已有的格式一致,不另立一套宽度);
- 脚本统一成根目录 `bun run fmt`,覆盖 `apps/*/src`、`apps/web/tests` 和两个构建
  配置;web 自己那份 `fmt` 和重复的 prettier 依赖删掉;
- `.prettierignore` 挡掉两类不该碰的:drizzle-kit 生成的 `src/db/meta/` 结构快照
  (它是 db:generate 的比对输入,只该由 drizzle-kit 写)、unplugin 每次 dev 都会
  重写的 `auto-imports.d.ts` / `components.d.ts`;
- 全量跑了一遍。纯格式,无行为改动:api typecheck / check:routes / check:ast、
  前端 type-check 全过,起 api 打了接口确认正常。前端这 39 个文件的小改动是
  prettier 版本漂移(类型断言的换行口径变了),不是新配置带来的。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:27:34 -06:00

273 lines
12 KiB
TypeScript
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.
import { readFileSync } from "node:fs"
import { readMigrationFiles } from "drizzle-orm/migrator"
import postgres from "postgres"
import { migrationsDir } from "../runtime"
/**
* 会造成不可逆数据丢失的语句。DROP INDEX / DROP CONSTRAINT 不在内 —— 它们不掉数据,
* 拦下来只会让日常部署平白多一道人工确认。
*
* `ALTER COLUMN ... TYPE` 算进来是因为它要重写整表、拿 ACCESS EXCLUSIVE 锁,
* 而且窄化类型时会报错或截断。
*/
const DESTRUCTIVE_PATTERNS: Array<[RegExp, string]> = [
[/\bdrop\s+table\b/i, "DROP TABLE"],
[/\bdrop\s+schema\b/i, "DROP SCHEMA"],
[/\balter\s+table\s+.+\s+drop\s+column\b/is, "DROP COLUMN"],
[/\balter\s+column\s+.+\s+type\b/is, "ALTER COLUMN ... TYPE"],
[/\btruncate\b/i, "TRUNCATE"],
]
/** 去掉 `--` 行注释和 `/* *\/` 块注释,免得注释里提到 drop table 就误判 */
function stripComments(sql: string) {
return sql.replace(/\/\*[\s\S]*?\*\//g, "").replace(/--[^\n]*/g, "")
}
const BASELINE_HOWTO = `先建基线表并把 0000 标记成已执行(相当于 Django 的 --fake-initial
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);
详见 CLAUDE.md「改 schema 走 drizzle migration」。`
export async function runMigrations() {
const url = process.env.DATABASE_URL
if (!url) {
console.error("没有 DATABASE_URL不知道该迁移哪个库")
process.exit(2)
}
// readMigrationFiles 找不到 meta/_journal.json 会直接抛,堆栈指向 drizzle 内部,
// 看不出真实原因。而这恰恰是最可能发生的失误:镜像里漏拷迁移目录。
let files: ReturnType<typeof readMigrationFiles>
try {
files = readMigrationFiles({ migrationsFolder: migrationsDir })
} catch {
console.error(
`读不到迁移目录:${migrationsDir}\n` +
"编译产物不内嵌迁移文件,它们随镜像装在固定路径下。\n" +
"检查 docker/Dockerfile 里那条 `COPY apps/api/src/db/ ...`" +
"或用 OJ2_MIGRATIONS_DIR 显式指定。",
)
process.exit(2)
}
if (files.length === 0) {
console.error(
`${migrationsDir} 下没找到任何迁移。镜像里的迁移目录是不是漏拷了?`,
)
process.exit(2)
}
// readMigrationFiles 只返回 { sql, hash, folderMillis },不给文件名。日志和报错里
// 说「0002_drop_django_leftovers」比说「1787740469403」有用得多所以自己读一遍 journal。
const tags = readMigrationTags()
// max: 1 —— advisory lock 是会话级的,多连接会让锁挂在另一条连接上,等于没锁
const client = postgres(url, { max: 1, onnotice: () => {} })
try {
// 防止两次部署撞在一起同时迁移。key 是随手取的常量,只要全项目一致就行
await client`select pg_advisory_lock(4478215096)`
const applied = await client<{ last: string }[]>`
select coalesce(max(created_at), -1)::text as last
from drizzle.__drizzle_migrations
`.catch(() => null)
// 没有基线记录,两种情况分开处理:空库直接从 0000 建起来,有表的库要人来确认。
const lastApplied = applied === null ? -1 : Number(applied[0]?.last ?? -1)
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
// 有表却没有基线记录 —— 这个库不是 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)
if (pending.length === 0) {
console.log("没有待执行的迁移。")
return
}
// 兜底:真要跑到一条「没有可执行语句」的迁移,说明基线状态不对
// (多半是 0000 被算进了 pending。这种情况下 drizzle 会把整份注释当 SQL 发过去,
// 报一个和真实原因毫不相干的 "unterminated /* comment"。宁可自己先说清楚。
if (pending.some((f) => stripComments(f.sql.join("\n")).trim() === "")) {
console.error(
"待执行的迁移里有一条不含任何可执行语句(多半是 introspect 出来的 0000。\n" +
"基线记录不对,检查 drizzle.__drizzle_migrations。\n\n" +
BASELINE_HOWTO,
)
process.exit(3)
}
const blocked = pending
.map((f) => ({
tag: tags.get(f.folderMillis) ?? String(f.folderMillis),
reasons: destructiveReasons(f.sql.join("\n")),
}))
.filter(({ reasons }) => reasons.length > 0)
// 自举时不拦空库上没有数据可丢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") +
"\n\n这类改动不可逆不该在一次日常部署里顺手执行。" +
"\n确认已经做过备份之后用这个显式放行\n\n" +
" OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh\n",
)
process.exit(4)
}
console.log(`待执行 ${pending.length} 条迁移,开始。`)
let done = 0
for (const file of pending) {
const tag = tags.get(file.folderMillis) ?? String(file.folderMillis)
try {
await applyMigration(client, file, tag)
} catch (error) {
// 裸抛的话看到的是 postgres.js 内部的堆栈,真正的原因(那一行 PostgresError
// 被埋在中间。这里只留有用的部分。
const detail = error instanceof Error ? error.message : String(error)
const naked = NO_TRANSACTION_MARKER.test(file.sql[0] ?? "")
console.error(
`\n迁移 ${tag} 失败:${detail}\n\n` +
(naked
? "这条迁移标了 oj2:no-transaction**没有事务保护** —— 失败点之前的语句已经生效。\n" +
"如果失败的是 CREATE INDEX CONCURRENTLY库里多半留下了一个 INVALID 索引,\n" +
"先 `DROP INDEX <名字>` 再重来(`select indexrelid::regclass from pg_index where not indisvalid` 能找出来)。\n"
: "这条迁移已整体回滚,库里没有留下它的任何改动。\n") +
`本次已经成功执行的 ${done} 条不会被回滚 —— 每条迁移各自一个事务。`,
)
process.exit(5)
}
done++
console.log(`${tag}`)
}
console.log("迁移完成。")
} finally {
await client.end()
}
}
function destructiveReasons(sql: string) {
const bare = stripComments(sql)
return DESTRUCTIVE_PATTERNS.filter(([re]) => re.test(bare)).map(
([, label]) => label,
)
}
/**
* 从 `meta/_journal.json` 读出 `when → tag` 的对应。`readMigrationFiles` 不返回文件名,
* 但日志和报错里说得出「0002_drop_django_leftovers」比说「1787740469403」有用得多。
*
* 读不到就返回空表 —— 到这一步 `readMigrationFiles` 已经成功读过同一个文件了,
* 真读不到也只是日志退化成时间戳,不该因此中止一次迁移。
*/
function readMigrationTags(): Map<number, string> {
try {
const journal = JSON.parse(
readFileSync(`${migrationsDir}/meta/_journal.json`, "utf8"),
) as {
entries?: Array<{ when: number; tag: string }>
}
return new Map((journal.entries ?? []).map((e) => [e.when, e.tag]))
} catch {
return new Map()
}
}
/**
* 写在迁移文件**开头**的这行标记,表示这条迁移不能包在事务里跑。
*
* 唯一的用途是 `CREATE INDEX CONCURRENTLY` —— Postgres 明确禁止它出现在事务块里,
* 而大表加索引又常常不能接受 `CREATE INDEX` 那段锁写窗口。
*
* 代价要清楚:**没有回滚**。中途失败时前面的语句已经生效,而且 CONCURRENTLY 失败还会
* 在库里留下一个 INVALID 索引,得手工 `DROP INDEX` 之后重来。所以这种迁移**一个文件
* 只放一条语句**,别图省事把几条塞一起。
*/
const NO_TRANSACTION_MARKER = /^[ \t]*--[ \t]*oj2:no-transaction\b/m
/**
* 执行一条迁移。
*
* 这里没有用 drizzle 自带的 `migrate()`,原因有两条,都在 `pg-core/dialect.js` 里摆着:
*
* 1. 它把**所有**待执行的迁移塞进同一个 `session.transaction()`。于是第 3 条失败会把
* 第 1、2 条一起回滚 —— 和 Django `migrate` 的逐条提交语义不一样,排查时也更难判断
* 库到底停在哪儿。这里改成一条一个事务。
* 2. 正因为全都在事务里,`CREATE INDEX CONCURRENTLY` 一律跑不了,没有任何开关。
*
* 记账行(`drizzle.__drizzle_migrations`)的写法和 drizzle 保持一致:`hash` 是整个文件的
* sha256`created_at` 是 journal 里的 `when`。migrator 只比 `created_at`、不校验 hash
* 所以两套执行器可以互换着用,不会互相看不懂对方写的记录。
*/
async function applyMigration(
client: postgres.Sql,
migration: ReturnType<typeof readMigrationFiles>[number],
tag: string,
) {
// 只留有可执行内容的段。`readMigrationFiles` 按 `--> statement-breakpoint` 切开后
// 保留原文,所以纯注释段(比如 0002 开头那一大段说明)会自成一段。
const statements = migration.sql.filter(
(stmt) => stripComments(stmt).trim() !== "",
)
if (statements.length === 0) {
// 上游已经拦过一次(那条兜底检查),走到这里说明拦漏了,宁可响一声也别静默跳过
throw new Error(`${tag} 没有任何可执行语句`)
}
const record = (exec: postgres.Sql | postgres.TransactionSql) =>
exec`insert into drizzle.__drizzle_migrations ("hash", "created_at")
values (${migration.hash}, ${migration.folderMillis})`
if (NO_TRANSACTION_MARKER.test(migration.sql[0] ?? "")) {
// 走简单查询协议扩展协议会把语句包进一个隐式事务块CONCURRENTLY 照样被拒。
for (const stmt of statements) await client.unsafe(stmt).simple()
await record(client)
return
}
await client.begin(async (tx) => {
for (const stmt of statements) await tx.unsafe(stmt)
await record(tx)
})
}