Compare commits
4 Commits
1f9f33aa3b
...
1d9686b929
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d9686b929 | |||
| bb4b5825c3 | |||
| 2e871cb5b0 | |||
| eabb61189c |
93
CLAUDE.md
93
CLAUDE.md
@@ -143,16 +143,38 @@ OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh
|
|||||||
```
|
```
|
||||||
|
|
||||||
`DROP INDEX` / `DROP CONSTRAINT` 不算——它们不掉数据,拦了只会让人习惯性带上放行开关。
|
`DROP INDEX` / `DROP CONSTRAINT` 不算——它们不掉数据,拦了只会让人习惯性带上放行开关。
|
||||||
|
**空库自举时这道闸不生效**:没有数据可丢,0002 那串 `DROP ... IF EXISTS` 全是空转,
|
||||||
|
拦下来只会逼每个新环境都带一次放行开关,把它训练成习惯动作。
|
||||||
|
|
||||||
**0000 跑不了,库不能靠迁移自举。** `0000_crazy_gateway.sql` 是 `drizzle-kit pull`
|
**空库能自举了。** `oj2-api migrate` 指向一个空库时直接从 `0000` 建起:
|
||||||
的产物,整份被 `/* */` 包着,可执行语句 0 条。所以任何新库的结构都只能来自
|
|
||||||
`docs/specs/schema.sql` 或生产 dump,然后手工做基线。`oj2-api migrate` 会检测这两种
|
```bash
|
||||||
情况并打印具体该做什么,不会让你撞上 drizzle 那个语焉不详的报错。
|
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 没有 `--fake-initial`,`migrate` 见到空的
|
||||||
`__drizzle_migrations` 会从 `0000` 的完整建表跑起,撞上已存在的表就整个事务回滚 ——
|
`__drizzle_migrations`、库里却已经有表,会拒绝执行并 exit 3(裸跑 `drizzle-kit migrate`
|
||||||
**而且失败时 exit 1 但一个错误都不打印**(只有 NOTICE,实测过)。所以对已有数据的库
|
的话则是从 `0000` 撞上已存在的表、整个事务回滚,**而且 exit 1 却一个错误都不打印**)。
|
||||||
第一次跑之前,先手插一行把 `0000` 标记成已执行:
|
对已有数据的库第一次跑之前,先手插一行把 `0000` 标记成已执行:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE SCHEMA IF NOT EXISTS drizzle;
|
CREATE SCHEMA IF NOT EXISTS drizzle;
|
||||||
@@ -176,14 +198,57 @@ schema,下面三处已经修过了,别让它们回潮):
|
|||||||
- **表达式索引的 opclass**:`problem_tag_name_ci_unique` 在快照里带 `opclass`,但 drizzle
|
- **表达式索引的 opclass**:`problem_tag_name_ci_unique` 在快照里带 `opclass`,但 drizzle
|
||||||
自己序列化不出来,导致每次都 drop + recreate。已从快照里去掉。
|
自己序列化不出来,导致每次都 drop + recreate。已从快照里去掉。
|
||||||
|
|
||||||
**还有两个改不掉的、写代码时要绕开的**:
|
**还有一个写代码时要绕开的**:
|
||||||
|
|
||||||
- **索引方向会被丢**:`.desc()` 在生成 SQL 时消失,但快照里记成 `asc: false`,两边对不上,
|
- **`.op()` 会吞掉索引方向**:真正的根因不是 `.desc()`,是 opclass。drizzle-kit 的
|
||||||
下次 pull 就是假 diff。单列索引不写方向就行(Postgres 用 Index Scan Backward 服务
|
`CreatePgIndexConvertor` 里那个三元一旦走进 opclass 分支就回不到方向分支:
|
||||||
`ORDER BY ... DESC`,代价一样)。**多列混合方向的索引别指望 generate**,得手写。
|
`${it.opclass ? ` ${it.opclass}` : it.asc ? "" : " DESC"}`。而 `drizzle-kit pull`
|
||||||
- **`CREATE INDEX CONCURRENTLY` 跑不了**:migrator 把所有语句包在一个事务里。大表加索引
|
给**每一列**都挂了 `.op(...)`,所以本仓库里"写了 `.desc()` 却生成不出 DESC"每次都会重演。
|
||||||
要是不能接受锁写窗口,只能绕开 migration 手工执行。参考量级:12.3 万行的部分索引,
|
|
||||||
普通 `CREATE INDEX` 只锁 74ms,一般不用纠结。
|
**要方向就别写 `.op()`。** 不写没有任何代价——`int4_ops` / `timestamptz_ops` 本来就是
|
||||||
|
这些类型的默认 opclass,写了等于没写。实测(drizzle-kit 0.31.10,探针索引跑过 generate):
|
||||||
|
|
||||||
|
| schema.ts | 生成的 SQL |
|
||||||
|
|---|---|
|
||||||
|
| `.desc().nullsFirst().op("timestamptz_ops")` | `"create_time" timestamptz_ops` ← 方向丢了 |
|
||||||
|
| `.desc().nullsFirst()` | `"create_time" DESC NULLS FIRST` ✅ |
|
||||||
|
| `.desc()` | `"create_time" DESC NULLS LAST` ✅ |
|
||||||
|
|
||||||
|
所以**多列混合方向的索引可以正常 generate**,不必手写。
|
||||||
|
|
||||||
|
假 diff 的机制也要理解对:带 `.op()` 时快照记的是 `asc: false`,SQL 建出来却是 ASC,
|
||||||
|
**分歧在快照和真实库之间**,不在快照和 schema.ts 之间——所以再跑 generate 是干净的,
|
||||||
|
要等到下次 pull 才炸出来。这是当初难定位的原因。
|
||||||
|
|
||||||
|
### 迁移执行器是自己的,不是 drizzle 那个
|
||||||
|
|
||||||
|
`db/migrate.ts` 不调用 drizzle 的 `migrate()`,自己按 journal 逐条执行。换掉它是因为
|
||||||
|
`pg-core/dialect.js` 里那个实现有两条硬伤:
|
||||||
|
|
||||||
|
1. **所有待执行的迁移共用一个事务**,第 3 条失败会把第 1、2 条一起回滚。现在是**一条一个
|
||||||
|
事务**,语义和 Django `migrate` 一致,失败时也说得清库停在哪儿。
|
||||||
|
2. 正因为全在事务里,`CREATE INDEX CONCURRENTLY` 一律跑不了,没有开关。
|
||||||
|
|
||||||
|
记账行的写法和 drizzle 完全一致(`hash` = 整个文件的 sha256,`created_at` = journal 的
|
||||||
|
`when`),而 migrator 只比 `created_at`、不校验 hash,所以两套执行器可以互换,不会看不懂
|
||||||
|
对方写的记录。
|
||||||
|
|
||||||
|
**`CREATE INDEX CONCURRENTLY` 现在能跑了。** 在迁移文件**第一行**写上标记:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- oj2:no-transaction
|
||||||
|
CREATE INDEX CONCURRENTLY "xxx_idx" ON "submission" USING btree ("language");
|
||||||
|
```
|
||||||
|
|
||||||
|
这条迁移就走裸执行(简单查询协议,不包事务)。代价是**没有回滚**:中途失败时前面的语句
|
||||||
|
已经生效,而且 CONCURRENTLY 失败会在库里留下一个 INVALID 索引,要先
|
||||||
|
`DROP INDEX` 再重来(`select indexrelid::regclass from pg_index where not indisvalid`
|
||||||
|
能找出来)。所以**这种迁移一个文件只放一条语句**。
|
||||||
|
|
||||||
|
要不要用是另一回事:参考量级是 12.3 万行的部分索引,普通 `CREATE INDEX` 只锁 74ms,
|
||||||
|
一般不用纠结,CONCURRENTLY 留给真扛不住锁写窗口的场合。
|
||||||
|
|
||||||
|
退出码:2 = 配置/文件问题,3 = 基线不对,4 = 撞上破坏性迁移,5 = 某条迁移执行失败。
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -489,22 +489,19 @@
|
|||||||
"expression": "visible",
|
"expression": "visible",
|
||||||
"isExpression": false,
|
"isExpression": false,
|
||||||
"asc": true,
|
"asc": true,
|
||||||
"nulls": "last",
|
"nulls": "last"
|
||||||
"opclass": "bool_ops"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"expression": "top",
|
"expression": "top",
|
||||||
"isExpression": false,
|
"isExpression": false,
|
||||||
"asc": false,
|
"asc": false,
|
||||||
"nulls": "first",
|
"nulls": "first"
|
||||||
"opclass": "bool_ops"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"expression": "create_time",
|
"expression": "create_time",
|
||||||
"isExpression": false,
|
"isExpression": false,
|
||||||
"asc": false,
|
"asc": false,
|
||||||
"nulls": "first",
|
"nulls": "first"
|
||||||
"opclass": "bool_ops"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"isUnique": false,
|
"isUnique": false,
|
||||||
@@ -2915,15 +2912,13 @@
|
|||||||
"expression": "contest_id",
|
"expression": "contest_id",
|
||||||
"isExpression": false,
|
"isExpression": false,
|
||||||
"asc": true,
|
"asc": true,
|
||||||
"nulls": "last",
|
"nulls": "last"
|
||||||
"opclass": "timestamptz_ops"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"expression": "create_time",
|
"expression": "create_time",
|
||||||
"isExpression": false,
|
"isExpression": false,
|
||||||
"asc": false,
|
"asc": false,
|
||||||
"nulls": "first",
|
"nulls": "first"
|
||||||
"opclass": "int4_ops"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"isUnique": false,
|
"isUnique": false,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { readFileSync } from "node:fs"
|
||||||
|
|
||||||
import { readMigrationFiles } from "drizzle-orm/migrator"
|
import { readMigrationFiles } from "drizzle-orm/migrator"
|
||||||
import { drizzle } from "drizzle-orm/postgres-js"
|
|
||||||
import { migrate as drizzleMigrate } from "drizzle-orm/postgres-js/migrator"
|
|
||||||
import postgres from "postgres"
|
import postgres from "postgres"
|
||||||
|
|
||||||
import { migrationsDir } from "../runtime"
|
import { migrationsDir } from "../runtime"
|
||||||
@@ -61,6 +61,10 @@ export async function runMigrations() {
|
|||||||
process.exit(2)
|
process.exit(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readMigrationFiles 只返回 { sql, hash, folderMillis },不给文件名。日志和报错里
|
||||||
|
// 说「0002_drop_django_leftovers」比说「1787740469403」有用得多,所以自己读一遍 journal。
|
||||||
|
const tags = readMigrationTags()
|
||||||
|
|
||||||
// max: 1 —— advisory lock 是会话级的,多连接会让锁挂在另一条连接上,等于没锁
|
// max: 1 —— advisory lock 是会话级的,多连接会让锁挂在另一条连接上,等于没锁
|
||||||
const client = postgres(url, { max: 1, onnotice: () => {} })
|
const client = postgres(url, { max: 1, onnotice: () => {} })
|
||||||
|
|
||||||
@@ -73,26 +77,35 @@ export async function runMigrations() {
|
|||||||
from drizzle.__drizzle_migrations
|
from drizzle.__drizzle_migrations
|
||||||
`.catch(() => null)
|
`.catch(() => null)
|
||||||
|
|
||||||
// 基线缺失。这个库没法靠迁移自举 —— 0000 是 `drizzle-kit pull` 的产物,
|
// 没有基线记录,两种情况分开处理:空库直接从 0000 建起来,有表的库要人来确认。
|
||||||
// 整个文件被块注释包着,一条可执行语句都没有。结构只能来自 docs/specs/schema.sql
|
|
||||||
// 或生产 dump,然后手工把 0000 标记成已执行。
|
|
||||||
const lastApplied = applied === null ? -1 : Number(applied[0]?.last ?? -1)
|
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 }[]>`
|
const rows = await client<{ count: number }[]>`
|
||||||
select count(*)::int as count from information_schema.tables where table_schema = 'public'
|
select count(*)::int as count from information_schema.tables where table_schema = 'public'
|
||||||
`
|
`
|
||||||
const tableCount = rows[0]?.count ?? 0
|
const tableCount = rows[0]?.count ?? 0
|
||||||
console.error(
|
|
||||||
tableCount > 0
|
// 有表却没有基线记录 —— 这个库不是 OJ2 从 0000 建起来的(多半是从旧后端接管、
|
||||||
? `库里已经有 ${tableCount} 张表,但没有迁移基线记录。\n` +
|
// 或者从生产 dump 恢复出来的)。0000 是完整建表,直接跑必然撞上已存在的表,
|
||||||
"直接迁移会从 0000 跑起,而 0000 是 introspect 产物、整份被注释掉,跑不了。\n\n" +
|
// 而且是整个事务回滚。这种情况只能由人确认之后手工打基线。
|
||||||
BASELINE_HOWTO
|
if (tableCount > 0) {
|
||||||
: "这是个空库,迁移没法自举建表(0000 是 introspect 产物,整份被注释掉)。\n" +
|
console.error(
|
||||||
"先把结构灌进去:\n\n" +
|
`库里已经有 ${tableCount} 张表,但没有迁移基线记录。\n` +
|
||||||
" psql -d <库> -f docs/specs/schema.sql\n\n" +
|
"直接迁移会从 0000 跑起,而 0000 是完整建表,撞上已存在的表会整条回滚。\n\n" +
|
||||||
BASELINE_HOWTO,
|
BASELINE_HOWTO,
|
||||||
)
|
)
|
||||||
process.exit(3)
|
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)
|
const pending = files.filter((f) => f.folderMillis > lastApplied)
|
||||||
@@ -114,13 +127,19 @@ export async function runMigrations() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const blocked = pending
|
const blocked = pending
|
||||||
.map((f) => destructiveReasons(f.sql.join("\n")))
|
.map((f) => ({
|
||||||
.filter((reasons) => reasons.length > 0)
|
tag: tags.get(f.folderMillis) ?? String(f.folderMillis),
|
||||||
|
reasons: destructiveReasons(f.sql.join("\n")),
|
||||||
|
}))
|
||||||
|
.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(
|
console.error(
|
||||||
"待执行的迁移里有破坏性语句,已停下:\n" +
|
"待执行的迁移里有破坏性语句,已停下:\n" +
|
||||||
blocked.map((reasons) => ` · ${reasons.join(" / ")}`).join("\n") +
|
blocked.map(({ tag, reasons }) => ` · ${tag}:${reasons.join(" / ")}`).join("\n") +
|
||||||
"\n\n这类改动不可逆,不该在一次日常部署里顺手执行。" +
|
"\n\n这类改动不可逆,不该在一次日常部署里顺手执行。" +
|
||||||
"\n确认已经做过备份之后,用这个显式放行:\n\n" +
|
"\n确认已经做过备份之后,用这个显式放行:\n\n" +
|
||||||
" OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh\n",
|
" OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh\n",
|
||||||
@@ -129,7 +148,30 @@ export async function runMigrations() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`待执行 ${pending.length} 条迁移,开始。`)
|
console.log(`待执行 ${pending.length} 条迁移,开始。`)
|
||||||
await drizzleMigrate(drizzle(client), { migrationsFolder: migrationsDir })
|
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("迁移完成。")
|
console.log("迁移完成。")
|
||||||
} finally {
|
} finally {
|
||||||
await client.end()
|
await client.end()
|
||||||
@@ -140,3 +182,77 @@ function destructiveReasons(sql: string) {
|
|||||||
const bare = stripComments(sql)
|
const bare = stripComments(sql)
|
||||||
return DESTRUCTIVE_PATTERNS.filter(([re]) => re.test(bare)).map(([, label]) => label)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ export const announcement = pgTable("announcement", {
|
|||||||
top: boolean().notNull(),
|
top: boolean().notNull(),
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
index("announcement_created_by_id_359ccf50").using("btree", table.createdById.asc().nullsLast().op("int4_ops")),
|
index("announcement_created_by_id_359ccf50").using("btree", table.createdById.asc().nullsLast().op("int4_ops")),
|
||||||
index("announcement_list_idx").using("btree", table.visible.asc().nullsLast().op("bool_ops"), table.top.desc().nullsFirst().op("bool_ops"), table.createTime.desc().nullsFirst().op("bool_ops")),
|
// 不写 .op():opclass 会吞掉方向(见 CLAUDE.md)。生产库是 (visible, top DESC, create_time DESC),
|
||||||
|
// 写了 .op() 的话 generate 出来的是全 ASC,schema.ts 就和真实库对不上了。
|
||||||
|
index("announcement_list_idx").using("btree", table.visible.asc().nullsLast(), table.top.desc().nullsFirst(), table.createTime.desc().nullsFirst()),
|
||||||
foreignKey({
|
foreignKey({
|
||||||
columns: [table.createdById],
|
columns: [table.createdById],
|
||||||
foreignColumns: [user.id],
|
foreignColumns: [user.id],
|
||||||
@@ -466,7 +468,9 @@ export const submission = pgTable("submission", {
|
|||||||
username: text().notNull(),
|
username: text().notNull(),
|
||||||
ip: text(),
|
ip: text(),
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
index("contest_create_time_idx").using("btree", table.contestId.asc().nullsLast().op("timestamptz_ops"), table.createTime.desc().nullsFirst().op("int4_ops")),
|
// 同上,不写 .op()。原先 pull 出来的 opclass 还串了位(contest_id 标成 timestamptz_ops、
|
||||||
|
// create_time 标成 int4_ops),那条 SQL 真拿去执行 Postgres 会直接拒绝。
|
||||||
|
index("contest_create_time_idx").using("btree", table.contestId.asc().nullsLast(), table.createTime.desc().nullsFirst()),
|
||||||
// 提交列表默认视图(WHERE contest_id IS NULL ORDER BY create_time DESC)专用。
|
// 提交列表默认视图(WHERE contest_id IS NULL ORDER BY create_time DESC)专用。
|
||||||
// 上面的 contest_create_time_idx 看着能覆盖,但 Postgres 不把 `contest_id IS NULL`
|
// 上面的 contest_create_time_idx 看着能覆盖,但 Postgres 不把 `contest_id IS NULL`
|
||||||
// 当成能吃掉首列、从而继承第二列有序性的等值条件——把 seqscan/bitmapscan 全关掉逼它
|
// 当成能吃掉首列、从而继承第二列有序性的等值条件——把 seqscan/bitmapscan 全关掉逼它
|
||||||
@@ -474,8 +478,8 @@ export const submission = pgTable("submission", {
|
|||||||
// 扫完整张表 + top-N 排序。改用部分索引后谓词由索引本身保证,排序序就是索引序。
|
// 扫完整张表 + top-N 排序。改用部分索引后谓词由索引本身保证,排序序就是索引序。
|
||||||
// 生产快照(12.3 万条提交)实测:61.8ms / 18936 blocks → 0.22ms / 34 blocks。
|
// 生产快照(12.3 万条提交)实测:61.8ms / 18936 blocks → 0.22ms / 34 blocks。
|
||||||
// 这个索引不在 Django 的 migration 里,是 OJ2 单独加的,见 src/db/0001_naive_agent_zero.sql。
|
// 这个索引不在 Django 的 migration 里,是 OJ2 单独加的,见 src/db/0001_naive_agent_zero.sql。
|
||||||
// 不写 .desc():drizzle-kit 生成 SQL 时会把方向丢掉,写了会让快照(记 asc:false)和实际
|
// 不写 .desc():这条带 .op(),而 .op() 会吞掉方向(见 CLAUDE.md)——写了只会让快照
|
||||||
// 建出来的索引(ASC)对不上,下次 pull 就产生假 diff。单列索引无所谓方向,Postgres 用
|
// (记 asc:false)和实际建出来的索引(ASC)对不上。单列索引本来也无所谓方向,Postgres 用
|
||||||
// Index Scan Backward 服务 ORDER BY ... DESC,实测同样是 0.08ms。
|
// Index Scan Backward 服务 ORDER BY ... DESC,实测同样是 0.08ms。
|
||||||
index("submission_public_create_time_idx").using("btree", table.createTime.op("timestamptz_ops")).where(sql`${table.contestId} is null`),
|
index("submission_public_create_time_idx").using("btree", table.createTime.op("timestamptz_ops")).where(sql`${table.contestId} is null`),
|
||||||
index("problem_user_idx").using("btree", table.problemId.asc().nullsLast().op("int4_ops"), table.userId.asc().nullsLast().op("int4_ops")),
|
index("problem_user_idx").using("btree", table.problemId.asc().nullsLast().op("int4_ops"), table.userId.asc().nullsLast().op("int4_ops")),
|
||||||
|
|||||||
46
apps/web/src/admin/user/components/Password.vue
Normal file
46
apps/web/src/admin/user/components/Password.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import TextCopy from "shared/components/TextCopy.vue"
|
||||||
|
import { USER_TYPE } from "utils/constants"
|
||||||
|
import type { User } from "utils/types"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User
|
||||||
|
revealed: boolean
|
||||||
|
}
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
defineEmits<{
|
||||||
|
(e: "reveal", value: number): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 只有**管理员账号**的密码默认打码。
|
||||||
|
*
|
||||||
|
* 学生的密码照旧直接显示 —— 老师查学生密码是日常动作(明文列 `raw_password` 就是为它
|
||||||
|
* 保留的),挡一道只是添乱。要防的是管理员密码在投屏、或者旁人路过时被看到。
|
||||||
|
*
|
||||||
|
* `rawPassword` 为空时不打码:给一个点了什么也不显示的按钮没有意义,直接走原来的
|
||||||
|
* TextCopy(渲染成空)。
|
||||||
|
*/
|
||||||
|
const masked = computed(
|
||||||
|
() =>
|
||||||
|
!props.revealed &&
|
||||||
|
props.user.adminType !== USER_TYPE.REGULAR_USER &&
|
||||||
|
!!props.user.rawPassword,
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<n-flex v-if="masked" align="center" :size="6" :wrap="false">
|
||||||
|
<span class="dots">••••••</span>
|
||||||
|
<n-button size="tiny" secondary @click="$emit('reveal', props.user.id)">
|
||||||
|
显示
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
<TextCopy v-else>{{ props.user.rawPassword }}</TextCopy>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
/* 不加 nowrap 的话六个点会在窄列里折行,把整行撑高 */
|
||||||
|
.dots {
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "../api"
|
} from "../api"
|
||||||
import Actions from "./components/Actions.vue"
|
import Actions from "./components/Actions.vue"
|
||||||
import Name from "./components/Name.vue"
|
import Name from "./components/Name.vue"
|
||||||
|
import Password from "./components/Password.vue"
|
||||||
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
|
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
|
||||||
import { useRouteQuery } from "@vueuse/router"
|
import { useRouteQuery } from "@vueuse/router"
|
||||||
import TextCopy from "shared/components/TextCopy.vue"
|
import TextCopy from "shared/components/TextCopy.vue"
|
||||||
@@ -49,6 +50,9 @@ const sortOptions = [
|
|||||||
]
|
]
|
||||||
const [create, toggleCreate] = useToggle(false)
|
const [create, toggleCreate] = useToggle(false)
|
||||||
const password = ref("")
|
const password = ref("")
|
||||||
|
// 已经点开的管理员密码,按 user id 记。listUsers() 里清空,所以翻页/搜索/换筛选之后
|
||||||
|
// 一律回到打码状态 —— 不做定时自动隐藏,也不记进 localStorage。
|
||||||
|
const revealedPasswords = ref(new Set<number>())
|
||||||
const userIDs = ref<DataTableRowKey[]>([])
|
const userIDs = ref<DataTableRowKey[]>([])
|
||||||
|
|
||||||
const rowKey = (row: User) => row.id
|
const rowKey = (row: User) => row.id
|
||||||
@@ -65,8 +69,13 @@ const columns: DataTableColumn<User>[] = [
|
|||||||
{
|
{
|
||||||
title: "密码",
|
title: "密码",
|
||||||
key: "raw_password",
|
key: "raw_password",
|
||||||
width: 100,
|
width: 150,
|
||||||
render: (row) => h(TextCopy, () => row.rawPassword),
|
render: (row) =>
|
||||||
|
h(Password, {
|
||||||
|
user: row,
|
||||||
|
revealed: revealedPasswords.value.has(row.id),
|
||||||
|
onReveal: (id: number) => revealedPasswords.value.add(id),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "创建时间",
|
title: "创建时间",
|
||||||
@@ -131,6 +140,7 @@ async function listUsers() {
|
|||||||
)
|
)
|
||||||
total.value = res.total
|
total.value = res.total
|
||||||
users.value = res.results
|
users.value = res.results
|
||||||
|
revealedPasswords.value.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseUsers(rowKeys: DataTableRowKey[]) {
|
function chooseUsers(rowKeys: DataTableRowKey[]) {
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export function debounce<T extends (...args: any[]) => any>(
|
|||||||
|
|
||||||
export function getUserRole(role: User["adminType"]): {
|
export function getUserRole(role: User["adminType"]): {
|
||||||
type: "default" | "info" | "warning" | "error"
|
type: "default" | "info" | "warning" | "error"
|
||||||
label: "普通" | "学生管理员" | "教师管理员" | "超管"
|
label: "普通" | "生管" | "师管" | "超管"
|
||||||
} {
|
} {
|
||||||
const roleMap = {
|
const roleMap = {
|
||||||
[USER_TYPE.REGULAR_USER]: {
|
[USER_TYPE.REGULAR_USER]: {
|
||||||
@@ -150,11 +150,11 @@ export function getUserRole(role: User["adminType"]): {
|
|||||||
},
|
},
|
||||||
[USER_TYPE.STUDENT_ADMIN]: {
|
[USER_TYPE.STUDENT_ADMIN]: {
|
||||||
type: "info" as const,
|
type: "info" as const,
|
||||||
label: "学生管理员" as const,
|
label: "生管" as const,
|
||||||
},
|
},
|
||||||
[USER_TYPE.TEACHER_ADMIN]: {
|
[USER_TYPE.TEACHER_ADMIN]: {
|
||||||
type: "warning" as const,
|
type: "warning" as const,
|
||||||
label: "教师管理员" as const,
|
label: "师管" as const,
|
||||||
},
|
},
|
||||||
[USER_TYPE.SUPER_ADMIN]: {
|
[USER_TYPE.SUPER_ADMIN]: {
|
||||||
type: "error" as const,
|
type: "error" as const,
|
||||||
|
|||||||
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