Some checks failed
Deploy / deploy (push) Has been cancelled
原来只有 `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>
273 lines
12 KiB
TypeScript
273 lines
12 KiB
TypeScript
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)
|
||
})
|
||
}
|