diff --git a/CLAUDE.md b/CLAUDE.md index 05e0cc0..05ce2fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -176,11 +176,27 @@ schema,下面三处已经修过了,别让它们回潮): - **表达式索引的 opclass**:`problem_tag_name_ci_unique` 在快照里带 `opclass`,但 drizzle 自己序列化不出来,导致每次都 drop + recreate。已从快照里去掉。 -**还有两个改不掉的、写代码时要绕开的**: +**还有两个写代码时要绕开的**: -- **索引方向会被丢**:`.desc()` 在生成 SQL 时消失,但快照里记成 `asc: false`,两边对不上, - 下次 pull 就是假 diff。单列索引不写方向就行(Postgres 用 Index Scan Backward 服务 - `ORDER BY ... DESC`,代价一样)。**多列混合方向的索引别指望 generate**,得手写。 +- **`.op()` 会吞掉索引方向**:真正的根因不是 `.desc()`,是 opclass。drizzle-kit 的 + `CreatePgIndexConvertor` 里那个三元一旦走进 opclass 分支就回不到方向分支: + `${it.opclass ? ` ${it.opclass}` : it.asc ? "" : " DESC"}`。而 `drizzle-kit pull` + 给**每一列**都挂了 `.op(...)`,所以本仓库里"写了 `.desc()` 却生成不出 DESC"每次都会重演。 + + **要方向就别写 `.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 才炸出来。这是当初难定位的原因。 - **`CREATE INDEX CONCURRENTLY` 跑不了**:migrator 把所有语句包在一个事务里。大表加索引 要是不能接受锁写窗口,只能绕开 migration 手工执行。参考量级:12.3 万行的部分索引, 普通 `CREATE INDEX` 只锁 74ms,一般不用纠结。 diff --git a/apps/api/src/db/meta/0002_snapshot.json b/apps/api/src/db/meta/0002_snapshot.json index e6ce078..21664e2 100644 --- a/apps/api/src/db/meta/0002_snapshot.json +++ b/apps/api/src/db/meta/0002_snapshot.json @@ -489,22 +489,19 @@ "expression": "visible", "isExpression": false, "asc": true, - "nulls": "last", - "opclass": "bool_ops" + "nulls": "last" }, { "expression": "top", "isExpression": false, "asc": false, - "nulls": "first", - "opclass": "bool_ops" + "nulls": "first" }, { "expression": "create_time", "isExpression": false, "asc": false, - "nulls": "first", - "opclass": "bool_ops" + "nulls": "first" } ], "isUnique": false, @@ -2915,15 +2912,13 @@ "expression": "contest_id", "isExpression": false, "asc": true, - "nulls": "last", - "opclass": "timestamptz_ops" + "nulls": "last" }, { "expression": "create_time", "isExpression": false, "asc": false, - "nulls": "first", - "opclass": "int4_ops" + "nulls": "first" } ], "isUnique": false, diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 5610c83..315e99f 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -52,7 +52,9 @@ export const announcement = pgTable("announcement", { top: boolean().notNull(), }, (table) => [ 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({ columns: [table.createdById], foreignColumns: [user.id], @@ -466,7 +468,9 @@ export const submission = pgTable("submission", { username: text().notNull(), ip: text(), }, (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)专用。 // 上面的 contest_create_time_idx 看着能覆盖,但 Postgres 不把 `contest_id IS NULL` // 当成能吃掉首列、从而继承第二列有序性的等值条件——把 seqscan/bitmapscan 全关掉逼它 @@ -474,8 +478,8 @@ export const submission = pgTable("submission", { // 扫完整张表 + top-N 排序。改用部分索引后谓词由索引本身保证,排序序就是索引序。 // 生产快照(12.3 万条提交)实测:61.8ms / 18936 blocks → 0.22ms / 34 blocks。 // 这个索引不在 Django 的 migration 里,是 OJ2 单独加的,见 src/db/0001_naive_agent_zero.sql。 - // 不写 .desc():drizzle-kit 生成 SQL 时会把方向丢掉,写了会让快照(记 asc:false)和实际 - // 建出来的索引(ASC)对不上,下次 pull 就产生假 diff。单列索引无所谓方向,Postgres 用 + // 不写 .desc():这条带 .op(),而 .op() 会吞掉方向(见 CLAUDE.md)——写了只会让快照 + // (记 asc:false)和实际建出来的索引(ASC)对不上。单列索引本来也无所谓方向,Postgres 用 // 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("problem_user_idx").using("btree", table.problemId.asc().nullsLast().op("int4_ops"), table.userId.asc().nullsLast().op("int4_ops")),