build(数据库): 改用 drizzle migration,加提交列表索引、清掉 Django 残留
Some checks failed
Deploy / deploy (push) Has been cancelled

一条线上的三件事:让 drizzle 的迁移机制真正可用 → 用它加索引 → 用它清掉
不再需要的 Django 表,最后接进部署和 CI。

## 1. 让 drizzle-kit generate 可用

原本以为不能用:只加一个索引,generate 却吐出一堆噪音,其中 5 条
`DROP SEQUENCE auth_*/django_*` 打到生产库上会直接搞坏旧后端。
逐个查下来全是 `pull` 出的基线自己不能 round-trip,都是可修的:

- **快照里的 Django 序列**:tablesFilter 只过滤表、不过滤它们的序列。
  已从 0000_snapshot.json 清掉。
- **bigint 上限精度**:pull 生成的 `maxValue: 9223372036854775807` 是 JS
  number 字面量,round-trip 成 ...776000,每次 generate 都多出 10 条
  ALTER COLUMN。改成字符串。
- **表达式索引的 opclass**:problem_tag_name_ci_unique 在快照里带
  opclass,drizzle 自己序列化不出来,导致每次 drop + recreate。已去掉。

改完 `generate` 是干净的 no-op。

两个改不掉、只能绕的写进了 CLAUDE.md:索引 `.desc()` 生成 SQL 时会被丢
(单列索引不写方向即可,Postgres 用 Index Scan Backward 服务 ORDER BY
DESC,实测同样 0.08ms);migrator 把所有语句包一个事务,
CREATE INDEX CONCURRENTLY 跑不了。

最容易吃亏的是 drizzle 没有 --fake-initial:对已有数据的库直接 migrate
会从 0000 跑起、撞表回滚,**而且 exit 1 但一个错误都不打印**。

## 2. 0001 提交列表索引

`WHERE contest_id IS NULL ORDER BY create_time DESC LIMIT n` 用不上现有的
contest_create_time_idx (contest_id, create_time DESC) —— Postgres 不把
`IS NULL` 当成能吃掉首列、从而继承第二列有序性的等值条件。把
enable_seqscan / enable_bitmapscan 全关掉逼它用也不肯,宁可走单列
contest_id 索引再全量排序。于是每翻一页都 Parallel Seq Scan 扫完整张表。

换成部分索引后谓词由索引自己保证,索引序就是查询要的排序序。生产快照
(12.3 万条提交)实测首页取 10 行:61.8ms / 读 18936 blocks →
0.22ms / 读 34 blocks。端到端 94ms → 6ms。

真正要命的不是单次 61ms,是每个请求都要把 169MB 的表刷一遍
shared_buffers —— 一节课几十个学生同时开提交列表,磁盘和缓存直接被打穿。

## 3. 0002 删掉 Django 残留

确认旧 Django 后端不再使用、也不再作为回滚路径。删前核实过:没有任何
OJ2 保留的表引用这 7 张,3 条外键全在它们内部(所以不用 CASCADE,真有
漏网的会报错而不是被悄悄级联掉);5 个序列都由各自的表 owned,随
DROP TABLE 一并消失;数据全是 Django 自身元数据。tablesFilter 随之移除。

**回滚路径就此作废** —— CLAUDE.md 开头和 runbook 的「回滚保证」「七、回滚」
都改了。这条迁移已在本机 dev 库和生产快照副本上跑通,**生产库尚未执行**。

## 4. migrate 接进部署与 CI

deploy.sh 在构建之后、起栈之前跑 `oj2-api migrate`,失败就中止部署(旧
容器原样还在跑)。CI 走的也是 deploy.sh,所以不用给 GitHub 配数据库凭据,
也不用把生产库对外开放。

迁移文件**不内嵌进二进制**,随镜像装在 /usr/local/share/oj2/migrations。
这样 drizzle 的 migrate() 能原样用 —— 靠 _journal.json 自动发现,新增迁移
不用改任何代码,和 Django 扫 migrations/ 是一回事。内嵌就得为每条迁移
手写一行 import,那是迟早会漏的账。(CLAUDE.md 里「单二进制不能读文件」
那条讲的是 node_modules 和 import.meta.dir 推路径,按显式绝对路径读一个
数据目录不在此列。)

三道闸门,都是写完测出来才补上的:

- **破坏性迁移拦截**:DROP TABLE / DROP COLUMN / DROP SCHEMA /
  ALTER COLUMN ... TYPE / TRUNCATE 命中就退出 4,需要
  `OJ2_ALLOW_DESTRUCTIVE=1` 显式放行。DROP INDEX / DROP CONSTRAINT 不算,
  拦了只会让人习惯性带上放行开关。扫描前先剥注释,避免误报。
- **基线缺失**:退出 3 并直接打印该敲的 SQL。注意判的是
  `max(created_at) < 0` 而不是「表不存在」—— 表存在而为空(上次迁移失败
  留下的)同样是没基线。
- **迁移目录读不到**:这是最可能犯的错(Dockerfile 漏拷),原本是 drizzle
  的堆栈,现在直接说该检查哪一行。

另外发现 0000_crazy_gateway.sql 是 pull 的产物,**整份被 /* */ 包着,
可执行语句 0 条**,所以这个库根本不能靠迁移自举建表。原先写的「空库就从
0000 建」跑起来会炸在一个和真实原因毫不相干的 unterminated /* comment 上。
现在如实说明:结构只能来自 docs/specs/schema.sql 或生产 dump。

验证:镜像内编译(ARTIFACTS=build)出的真实镜像跑完五种场景 —— 拦截、
放行、幂等、无基线、漏拷目录,全部符合预期;dev 形态同样五种场景全过。
tsc / 路由遮蔽 / deploy.sh 语法 / generate no-op 都通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 07:56:30 -06:00
parent 2ee61756b8
commit 586c88f629
17 changed files with 8016 additions and 91 deletions

View File

@@ -0,0 +1 @@
CREATE INDEX "submission_public_create_time_idx" ON "submission" USING btree ("create_time" timestamptz_ops) WHERE "submission"."contest_id" is null;

View File

@@ -0,0 +1,20 @@
-- 删掉旧 Django 后端遗留的 7 张表。2026-08-26 确认旧后端不再使用、也不再作为回滚路径。
--
-- 执行前提:目标库上**没有任何 Django 进程还在跑**。旧后端一旦还活着,
-- 掉的是它的 session 表和 migration 记录,会直接打崩线上。
--
-- 已核实(在生产快照上):
-- * 没有任何 OJ2 保留的表引用这 7 张表,它们之间只有 3 条内部外键,删除是自洽的;
-- * 5 个相关序列auth_group_id_seq 等)都由各自的表 owned随 DROP TABLE 一并消失,
-- 不需要单独 DROP SEQUENCE
-- * 表里没有需要保留的数据auth_permission 136 行、django_content_type 34 行、
-- django_migrations 91 行、django_session 1 行,其余为空——全是 Django 自身的元数据。
--
-- 按外键依赖顺序删,不用 CASCADE这样万一将来真有别的东西引用了会直接报错而不是被悄悄级联掉。
DROP TABLE IF EXISTS auth_group_permissions;--> statement-breakpoint
DROP TABLE IF EXISTS auth_permission;--> statement-breakpoint
DROP TABLE IF EXISTS auth_group;--> statement-breakpoint
DROP TABLE IF EXISTS django_content_type;--> statement-breakpoint
DROP TABLE IF EXISTS django_dramatiq_task;--> statement-breakpoint
DROP TABLE IF EXISTS django_migrations;--> statement-breakpoint
DROP TABLE IF EXISTS django_session;

View File

@@ -2552,7 +2552,6 @@
"expression": "lower(name)",
"asc": true,
"nulls": "last",
"opclass": "text_ops",
"isExpression": true
}
],
@@ -3828,58 +3827,7 @@
},
"enums": {},
"schemas": {},
"sequences": {
"public.auth_group_id_seq": {
"name": "auth_group_id_seq",
"schema": "public",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"increment": "1",
"cycle": false,
"cache": "1"
},
"public.auth_group_permissions_id_seq": {
"name": "auth_group_permissions_id_seq",
"schema": "public",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"increment": "1",
"cycle": false,
"cache": "1"
},
"public.auth_permission_id_seq": {
"name": "auth_permission_id_seq",
"schema": "public",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"increment": "1",
"cycle": false,
"cache": "1"
},
"public.django_content_type_id_seq": {
"name": "django_content_type_id_seq",
"schema": "public",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"increment": "1",
"cycle": false,
"cache": "1"
},
"public.django_migrations_id_seq": {
"name": "django_migrations_id_seq",
"schema": "public",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"increment": "1",
"cycle": false,
"cache": "1"
}
},
"sequences": {},
"roles": {},
"policies": {},
"views": {},

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,20 @@
"when": 1786070652521,
"tag": "0000_crazy_gateway",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1787740189021,
"tag": "0001_add_submission_public_create_time_idx",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1787740469403,
"tag": "0002_drop_django_leftovers",
"breakpoints": true
}
]
}

142
apps/api/src/db/migrate.ts Normal file
View File

@@ -0,0 +1,142 @@
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 { 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)
}
// 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 是 `drizzle-kit pull` 的产物,
// 整个文件被块注释包着,一条可执行语句都没有。结构只能来自 docs/specs/schema.sql
// 或生产 dump然后手工把 0000 标记成已执行。
const lastApplied = applied === null ? -1 : Number(applied[0]?.last ?? -1)
if (lastApplied < 0) {
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)
}
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) => destructiveReasons(f.sql.join("\n")))
.filter((reasons) => reasons.length > 0)
if (blocked.length > 0 && process.env.OJ2_ALLOW_DESTRUCTIVE !== "1") {
console.error(
"待执行的迁移里有破坏性语句,已停下:\n" +
blocked.map((reasons) => ` · ${reasons.join(" / ")}`).join("\n") +
"\n\n这类改动不可逆不该在一次日常部署里顺手执行。" +
"\n确认已经做过备份之后用这个显式放行\n\n" +
" OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh\n",
)
process.exit(4)
}
console.log(`待执行 ${pending.length} 条迁移,开始。`)
await drizzleMigrate(drizzle(client), { migrationsFolder: migrationsDir })
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)
}

View File

@@ -1,20 +1,27 @@
// 本文件由 `drizzle-kit pull` 从本地库自动生成,drizzle.config.ts 的 tablesFilter
// (["!django_*", "!auth_*"]) 已生效34 张表中的 7 张 Django 框架表
// (auth_group、auth_group_permissions、auth_permission、django_content_type、
// django_dramatiq_task、django_migrations、django_session) 均未生成 pgTable 定义,
// 剩余 27 张业务表原样保留。
// 本文件由 `drizzle-kit pull` 从生产库自动生成,之后按下面几条手工维护。
//
// 手工剪枝tablesFilter 只过滤了 pgTable未过滤这些框架表的 id 序列,遗留了
// 5 个孤儿 pgSequence 导出authGroupIdSeq、authGroupPermissionsIdSeq
// authPermissionIdSeq、djangoContentTypeIdSeq、djangoMigrationsIdSeq——
// 它们不被任何剩余表引用,留着只会在未来 `drizzle-kit generate` 时生成多余的
// `CREATE SEQUENCE` 迁移,因此手工删除。
// 2026-08-26旧 Django 后端下线7 张框架表auth_group、auth_group_permissions、
// auth_permission、django_content_type、django_dramatiq_task、django_migrations
// django_session已由 0002_drop_django_leftovers 删除drizzle.config.ts 的
// tablesFilter 随之移除。库里现在就是这 27 张业务表。
//
// 手工修正(都是 `pull` 自己没法无损 round-trip 的地方,改回去会让 generate 产生假 diff
// 详见 CLAUDE.md「改 schema 走 drizzle migration」
// * bigint identity 的 maxValue 用字符串,不能写成 JS number 字面量(会丢精度)。
// * 索引不写 `.desc()`,生成 SQL 时方向会被丢掉。
//
// 关于 10 张表的 bigint idproblemset*、achievement、user_achievement、user_stat、
// user_badge、ai_analysis这是历史巧合不是设计——这些 app 的 0001_initial 生成时
// Django 还没设 DEFAULT_AUTO_FIELD用了 3.2+ 的默认 BigAutoField更早的表user、
// problem、contest、submission都是 int4。现存最大 id 一万出头,确实都用不上 bigint
// 但 2026-08-26 评估后决定**不改**:省 4 字节/行毫无意义ALTER TYPE 要重写整表并拿
// ACCESS EXCLUSIVE 锁,而且其中 6 处 id 被外键绑着得连坐。别再提这件事了。
import { pgTable, index, foreignKey, bigint, text, jsonb, timestamp, integer, boolean, serial, doublePrecision, varchar, unique, uniqueIndex } from "drizzle-orm/pg-core"
import { sql } from "drizzle-orm"
export const aiAnalysis = pgTable("ai_analysis", {
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "ai_analysis_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 9223372036854775807, cache: 1 }),
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "ai_analysis_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
provider: text().notNull(),
data: jsonb().notNull(),
systemPrompt: text("system_prompt").notNull(),
@@ -55,7 +62,7 @@ export const announcement = pgTable("announcement", {
export const achievement = pgTable("achievement", {
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "achievement_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 9223372036854775807, cache: 1 }),
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "achievement_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
name: text().notNull(),
description: text().notNull(),
icon: text().notNull(),
@@ -222,7 +229,7 @@ export const optionsSysoptions = pgTable("options_sysoptions", {
export const problemset = pgTable("problemset", {
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "problemset_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 9223372036854775807, cache: 1 }),
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "problemset_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
title: text().notNull(),
description: text().notNull(),
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
@@ -243,7 +250,7 @@ export const problemset = pgTable("problemset", {
export const problemsetProblem = pgTable("problemset_problem", {
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "problemset_problem_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 9223372036854775807, cache: 1 }),
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "problemset_problem_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
order: integer().notNull(),
isRequired: boolean("is_required").notNull(),
score: integer().notNull(),
@@ -269,7 +276,7 @@ export const problemsetProblem = pgTable("problemset_problem", {
export const problemsetProgress = pgTable("problemset_progress", {
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "problemset_progress_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 9223372036854775807, cache: 1 }),
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "problemset_progress_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
joinTime: timestamp("join_time", { withTimezone: true, mode: 'string' }).notNull(),
completeTime: timestamp("complete_time", { withTimezone: true, mode: 'string' }),
isCompleted: boolean("is_completed").notNull(),
@@ -299,7 +306,7 @@ export const problemsetProgress = pgTable("problemset_progress", {
export const problemsetSubmission = pgTable("problemset_submission", {
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "problemset_submission_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 9223372036854775807, cache: 1 }),
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "problemset_submission_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
problemId: integer("problem_id").notNull(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
problemsetId: bigint("problemset_id", { mode: "number" }).notNull(),
@@ -460,6 +467,17 @@ export const submission = pgTable("submission", {
ip: text(),
}, (table) => [
index("contest_create_time_idx").using("btree", table.contestId.asc().nullsLast().op("timestamptz_ops"), table.createTime.desc().nullsFirst().op("int4_ops")),
// 提交列表默认视图WHERE contest_id IS NULL ORDER BY create_time DESC专用。
// 上面的 contest_create_time_idx 看着能覆盖,但 Postgres 不把 `contest_id IS NULL`
// 当成能吃掉首列、从而继承第二列有序性的等值条件——把 seqscan/bitmapscan 全关掉逼它
// 也不肯用,只会走单列 contest_id 索引再全量排序。结果是每翻一页都 Parallel Seq Scan
// 扫完整张表 + 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 用
// 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")),
index("submission_contest_id_775716d5").using("btree", table.contestId.asc().nullsLast().op("int4_ops")),
index("submission_problem_id_76847b55").using("btree", table.problemId.asc().nullsLast().op("int4_ops")),
@@ -500,7 +518,7 @@ export const tutorial = pgTable("tutorial", {
export const userStat = pgTable("user_stat", {
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "user_stat_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 9223372036854775807, cache: 1 }),
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "user_stat_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
metrics: jsonb().default({}).notNull(),
updateTime: timestamp("update_time", { withTimezone: true, mode: 'string' }).notNull(),
userId: integer("user_id").notNull(),
@@ -515,7 +533,7 @@ export const userStat = pgTable("user_stat", {
export const userAchievement = pgTable("user_achievement", {
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "user_achievement_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 9223372036854775807, cache: 1 }),
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "user_achievement_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
unlockTime: timestamp("unlock_time", { withTimezone: true, mode: 'string' }).notNull(),
backfilled: boolean().default(false).notNull(),
notified: boolean().default(false).notNull(),
@@ -542,7 +560,7 @@ export const userAchievement = pgTable("user_achievement", {
export const userBadge = pgTable("user_badge", {
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "user_badge_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 9223372036854775807, cache: 1 }),
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "user_badge_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
earnedTime: timestamp("earned_time", { withTimezone: true, mode: 'string' }).notNull(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
badgeId: bigint("badge_id", { mode: "number" }).notNull(),
@@ -634,7 +652,7 @@ export const user = pgTable("user", {
export const problemsetBadge = pgTable("problemset_badge", {
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "problemset_badge_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 9223372036854775807, cache: 1 }),
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "problemset_badge_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
name: text().notNull(),
description: text().notNull(),
icon: text().notNull(),