Deploy / deploy (push) Canceled after 0s
- 部署:JUDGE_STATE_DIR 不再强制设;删掉搬库脚本和只为搬库存在的自检、提醒 - 语言:Python3 / Python2 别名表删掉,normalizeLanguage 改名 parseLanguage - 旧链接:后台题单详情的跳转、用户列表 -last_login 的转换 - 旧页面:布置作业必须带 language(null = 没选);不再清旧版出题页草稿 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1405 lines
60 KiB
TypeScript
1405 lines
60 KiB
TypeScript
// 本文件由 `drizzle-kit pull` 从生产库自动生成,之后按下面几条手工维护。
|
||
//
|
||
// 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 张业务表。
|
||
//
|
||
// 2026-09-02:又清了一批只有 Django 时代写过、OJ2 一次都没读过的列 ——
|
||
// 0008 删比赛 IP 白名单与 submission.ip(判题机的 judge_server.ip 保留,那是运维数据),
|
||
// 0009 删 user 的 auth_token / open_api / open_api_appkey / session_keys 和
|
||
// user_profile 的 blog / github / school / major / language。**删的判据是「全仓零读取」**,
|
||
// 不是「看着没用」:raw_password 同样刺眼却是在用的(老师要能查学生密码),别一起清掉。
|
||
//
|
||
// 手工修正(都是 `pull` 自己没法无损 round-trip 的地方,改回去会让 generate 产生假 diff,
|
||
// 详见 CLAUDE.md「改 schema 走 drizzle migration」):
|
||
// * bigint identity 的 maxValue 用字符串,不能写成 JS number 字面量(会丢精度)。
|
||
// * 索引不写 `.desc()`,生成 SQL 时方向会被丢掉。
|
||
// * `.$type<...>()` 的收窄(见下)—— `pull` 只会吐出 text/integer/jsonb,重新 pull
|
||
// 会把这些断言全抹掉,之后出参的 `satisfies` 会当场编译不过(这是好事,别拿
|
||
// `as` 糊过去,把断言补回来)。
|
||
//
|
||
// 2026-09-10:出参不再 `xxxSchema.parse()` 而是 `satisfies`(原来 136 处),
|
||
// 收窄的责任因此挪到了列上:枚举型的列和几个形状确定的 JSONB 都挂了 `.$type<>()`。
|
||
// `$type` 只是 TS 层的断言、不产生任何 SQL,所以它成立与否得靠数据说话 ——
|
||
// 下面每一处都拿根目录那份生产备份逐列核过(12.4 万条提交的 result 全在 -2..6,10、
|
||
// 961 道题的 languages 全是合法数组、10050 条榜单条目形状全对,无一例外)。
|
||
// **再给别的列加 $type 之前,照样先核一遍全量数据。** 兑现这些断言的是写入侧的
|
||
// `safeParse`,不是读出侧。前因后果见 CLAUDE.md「出参不 `parse`,用 `satisfies`」。
|
||
//
|
||
// 关于 10 张表的 bigint id(problemset*、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 type {
|
||
AchievementOperator,
|
||
AchievementRarity,
|
||
AdminType,
|
||
AstRules,
|
||
BadgeConditionType,
|
||
ContestSubmissionInfo,
|
||
ExerciseType,
|
||
FlowchartStatus,
|
||
HintDiagnosis,
|
||
JudgeStatus,
|
||
LessonLanguage,
|
||
ProblemChallenges,
|
||
ProblemDifficulty,
|
||
ProblemLanguage,
|
||
ProblemPermission,
|
||
ReactionKey,
|
||
SqlConfig,
|
||
SqlDisplay,
|
||
TagCategory,
|
||
TutorialType,
|
||
} from '@oj2/contract'
|
||
import {
|
||
pgTable,
|
||
index,
|
||
foreignKey,
|
||
primaryKey,
|
||
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,
|
||
}),
|
||
provider: text().notNull(),
|
||
data: jsonb().notNull(),
|
||
systemPrompt: text('system_prompt').notNull(),
|
||
userPrompt: text('user_prompt').notNull(),
|
||
analysis: text().notNull(),
|
||
createTime: timestamp('create_time', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
userId: integer('user_id').notNull(),
|
||
model: text().notNull(),
|
||
isPinned: boolean('is_pinned').notNull(),
|
||
},
|
||
(table) => [
|
||
index('ai_analysis_user_id_3aa23011').using('btree', table.userId.asc().nullsLast().op('int4_ops')),
|
||
foreignKey({
|
||
columns: [table.userId],
|
||
foreignColumns: [user.id],
|
||
name: 'ai_analysis_user_id_3aa23011_fk_user_id',
|
||
}),
|
||
],
|
||
)
|
||
|
||
export const announcement = pgTable(
|
||
'announcement',
|
||
{
|
||
id: serial().primaryKey().notNull(),
|
||
title: text().notNull(),
|
||
content: text().notNull(),
|
||
createTime: timestamp('create_time', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
lastUpdateTime: timestamp('last_update_time', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
visible: boolean().notNull(),
|
||
createdById: integer('created_by_id').notNull(),
|
||
tag: text().notNull(),
|
||
top: boolean().notNull(),
|
||
},
|
||
(table) => [
|
||
index('announcement_created_by_id_359ccf50').using('btree', table.createdById.asc().nullsLast().op('int4_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],
|
||
name: 'announcement_created_by_id_359ccf50_fk_user_id',
|
||
}),
|
||
],
|
||
)
|
||
|
||
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,
|
||
}),
|
||
name: text().notNull(),
|
||
description: text().notNull(),
|
||
icon: text().notNull(),
|
||
rarity: text().notNull().$type<AchievementRarity>(),
|
||
hidden: boolean().default(false).notNull(),
|
||
metric: text().notNull(),
|
||
operator: text().notNull().$type<AchievementOperator>(),
|
||
threshold: integer().notNull(),
|
||
visible: boolean().default(true).notNull(),
|
||
unlockCount: integer('unlock_count').default(0).notNull(),
|
||
order: integer().default(0).notNull(),
|
||
createTime: timestamp('create_time', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
})
|
||
|
||
export const contest = pgTable(
|
||
'contest',
|
||
{
|
||
id: serial().primaryKey().notNull(),
|
||
title: text().notNull(),
|
||
description: text().notNull(),
|
||
password: text(),
|
||
startTime: timestamp('start_time', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
endTime: timestamp('end_time', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
createTime: timestamp('create_time', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
lastUpdateTime: timestamp('last_update_time', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
visible: boolean().notNull(),
|
||
createdById: integer('created_by_id').notNull(),
|
||
tag: text().notNull(),
|
||
},
|
||
(table) => [
|
||
index('contest_created_by_id_a763ca7e').using('btree', table.createdById.asc().nullsLast().op('int4_ops')),
|
||
foreignKey({
|
||
columns: [table.createdById],
|
||
foreignColumns: [user.id],
|
||
name: 'contest_created_by_id_a763ca7e_fk_user_id',
|
||
}),
|
||
],
|
||
)
|
||
|
||
export const flowchartSubmission = pgTable(
|
||
'flowchart_submission',
|
||
{
|
||
id: text().primaryKey().notNull(),
|
||
mermaidCode: text('mermaid_code').notNull(),
|
||
flowchartData: jsonb('flowchart_data').notNull(),
|
||
status: integer().notNull().$type<FlowchartStatus>(),
|
||
createTime: timestamp('create_time', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
aiScore: doublePrecision('ai_score'),
|
||
aiGrade: varchar('ai_grade', { length: 10 }),
|
||
aiFeedback: text('ai_feedback'),
|
||
aiSuggestions: text('ai_suggestions'),
|
||
aiCriteriaDetails: jsonb('ai_criteria_details').notNull(),
|
||
aiProvider: varchar('ai_provider', { length: 50 }).notNull(),
|
||
aiModel: varchar('ai_model', { length: 50 }).notNull(),
|
||
processingTime: doublePrecision('processing_time'),
|
||
evaluationTime: timestamp('evaluation_time', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}),
|
||
problemId: integer('problem_id').notNull(),
|
||
userId: integer('user_id').notNull(),
|
||
},
|
||
(table) => [
|
||
index('flowchart_problem_time_idx').using('btree', table.problemId.asc().nullsLast().op('int4_ops'), table.createTime.asc().nullsLast().op('int4_ops')),
|
||
index('flowchart_status_idx').using('btree', table.status.asc().nullsLast().op('int4_ops')),
|
||
// 流程图列表分页。原来是 hash join 全表再 top-N 排序(4.5ms / 551 buffers),
|
||
// 走这条之后 0.19ms / 47。绝对值不大,但索引只要 64kB,而这张表每行带一大坨
|
||
// jsonb,行数涨上去是线性恶化的。ASC 反向扫,理由同 submission 那几条。
|
||
index('flowchart_create_time_idx').using('btree', table.createTime.asc().nullsLast()),
|
||
index('flowchart_user_time_idx').using('btree', table.userId.asc().nullsLast().op('int4_ops'), table.createTime.asc().nullsLast().op('int4_ops')),
|
||
foreignKey({
|
||
columns: [table.problemId],
|
||
foreignColumns: [problem.id],
|
||
name: 'flowchart_submission_problem_id_8551edbf_fk_problem_id',
|
||
}).onDelete('cascade'),
|
||
foreignKey({
|
||
columns: [table.userId],
|
||
foreignColumns: [user.id],
|
||
name: 'flowchart_submission_user_id_225c83e8_fk_user_id',
|
||
}),
|
||
],
|
||
)
|
||
|
||
export const judgeServer = pgTable('judge_server', {
|
||
id: serial().primaryKey().notNull(),
|
||
hostname: text().notNull(),
|
||
ip: text(),
|
||
judgerVersion: text('judger_version').notNull(),
|
||
cpuCore: integer('cpu_core').notNull(),
|
||
memoryUsage: doublePrecision('memory_usage').notNull(),
|
||
cpuUsage: doublePrecision('cpu_usage').notNull(),
|
||
lastHeartbeat: timestamp('last_heartbeat', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
createTime: timestamp('create_time', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
taskNumber: integer('task_number').notNull(),
|
||
serviceUrl: text('service_url'),
|
||
isDisabled: boolean('is_disabled').notNull(),
|
||
})
|
||
|
||
export const exercise = pgTable(
|
||
'exercise',
|
||
{
|
||
id: integer().primaryKey().generatedByDefaultAsIdentity({
|
||
name: 'exercise_id_seq',
|
||
startWith: 1,
|
||
increment: 1,
|
||
minValue: 1,
|
||
maxValue: 2147483647,
|
||
cache: 1,
|
||
}),
|
||
type: varchar({ length: 16 }).notNull().$type<ExerciseType>(),
|
||
data: jsonb().notNull(),
|
||
order: integer().notNull(),
|
||
createdAt: timestamp('created_at', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
tutorialId: integer('tutorial_id').notNull(),
|
||
},
|
||
(table) => [
|
||
index('exercise_tutorial_id_6fd04055').using('btree', table.tutorialId.asc().nullsLast().op('int4_ops')),
|
||
foreignKey({
|
||
columns: [table.tutorialId],
|
||
foreignColumns: [tutorial.id],
|
||
name: 'exercise_tutorial_id_6fd04055_fk_tutorial_id',
|
||
}).onDelete('cascade'),
|
||
],
|
||
)
|
||
|
||
export const optionsSysoptions = pgTable(
|
||
'options_sysoptions',
|
||
{
|
||
id: serial().primaryKey().notNull(),
|
||
key: text().notNull(),
|
||
value: jsonb().notNull(),
|
||
},
|
||
(table) => [unique('options_sysoptions_key_key').on(table.key)],
|
||
)
|
||
|
||
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,
|
||
}),
|
||
title: text().notNull(),
|
||
description: text().notNull(),
|
||
createTime: timestamp('create_time', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
lastUpdateTime: timestamp('last_update_time', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
visible: boolean().notNull(),
|
||
// difficulty / status 两列 0027 起代码不再读写(线上全是 Easy / active,「状态 + 可见」
|
||
// 合成了「公开」),等两个站点都换上新代码再删列
|
||
createdById: integer('created_by_id').notNull(),
|
||
/**
|
||
* 布置期:assignedAt 是这一轮布置开始的时刻,assignedUntil 是布置到哪天(东八区当天结束)。
|
||
* 布置期内,题单里这几道题学生在 assignedAt 之前交的代码先藏起来(防抄),见
|
||
* services/problemset.ts 的 problemSetLockCutoffs。不拦做题,过期照样做、照样记进度。
|
||
* 两个都为空 = 没布置。
|
||
*
|
||
* 列名还是 end_time(以前叫「截止时间」,名不副实):机房那套和服务器共用这个库、
|
||
* 不一定同时升级,改列名会让没升级的那边一查题单就报错,所以只改代码里的名字
|
||
*/
|
||
assignedUntil: timestamp('end_time', { withTimezone: true, mode: 'string' }),
|
||
assignedAt: timestamp('assigned_at', { withTimezone: true, mode: 'string' }),
|
||
},
|
||
(table) => [
|
||
index('problemset_created_by_id_01b5197f').using('btree', table.createdById.asc().nullsLast().op('int4_ops')),
|
||
foreignKey({
|
||
columns: [table.createdById],
|
||
foreignColumns: [user.id],
|
||
name: 'problemset_created_by_id_01b5197f_fk_user_id',
|
||
}),
|
||
],
|
||
)
|
||
|
||
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,
|
||
}),
|
||
order: integer().notNull(),
|
||
isRequired: boolean('is_required').notNull(),
|
||
// score / hint 两列 0027 起不再读写(分数拿掉了,提示一条都没人填过),待删
|
||
problemId: integer('problem_id').notNull(),
|
||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||
problemsetId: bigint('problemset_id', { mode: 'number' }).notNull(),
|
||
},
|
||
(table) => [
|
||
index('problemset_problem_problem_id_fff2d686').using('btree', table.problemId.asc().nullsLast().op('int4_ops')),
|
||
foreignKey({
|
||
columns: [table.problemId],
|
||
foreignColumns: [problem.id],
|
||
name: 'problemset_problem_problem_id_fff2d686_fk_problem_id',
|
||
}).onDelete('cascade'),
|
||
foreignKey({
|
||
columns: [table.problemsetId],
|
||
foreignColumns: [problemset.id],
|
||
name: 'problemset_problem_problemset_id_350d17fb_fk_problemset_id',
|
||
}).onDelete('cascade'),
|
||
unique('unique_problemset_problem').on(table.problemId, table.problemsetId),
|
||
],
|
||
)
|
||
|
||
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,
|
||
}),
|
||
joinTime: timestamp('join_time', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
completeTime: timestamp('complete_time', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}),
|
||
isCompleted: boolean('is_completed').notNull(),
|
||
// progress_percentage / total_score 两列 0027 起不再读写(百分比随用随算,分数拿掉了),待删
|
||
completedProblemsCount: integer('completed_problems_count').notNull(),
|
||
totalProblemsCount: integer('total_problems_count').notNull(),
|
||
progressDetail: jsonb('progress_detail').notNull(),
|
||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||
problemsetId: bigint('problemset_id', { mode: 'number' }).notNull(),
|
||
userId: integer('user_id').notNull(),
|
||
},
|
||
(table) => [
|
||
index('problemset_progress_user_id_c8041a80').using('btree', table.userId.asc().nullsLast().op('int4_ops')),
|
||
foreignKey({
|
||
columns: [table.problemsetId],
|
||
foreignColumns: [problemset.id],
|
||
name: 'problemset_progress_problemset_id_20a9632e_fk_problemset_id',
|
||
}).onDelete('cascade'),
|
||
foreignKey({
|
||
columns: [table.userId],
|
||
foreignColumns: [user.id],
|
||
name: 'problemset_progress_user_id_c8041a80_fk_user_id',
|
||
}),
|
||
unique('unique_problemset_progress_user').on(table.problemsetId, table.userId),
|
||
],
|
||
)
|
||
|
||
// problemset_submission 表 0027 起不再读写(每次做对都往里写一行,从来没有地方读它),待删
|
||
|
||
export const reaction = pgTable(
|
||
'reaction',
|
||
{
|
||
id: integer().primaryKey().generatedByDefaultAsIdentity({
|
||
name: 'reaction_id_seq',
|
||
startWith: 1,
|
||
increment: 1,
|
||
minValue: 1,
|
||
maxValue: 2147483647,
|
||
cache: 1,
|
||
}),
|
||
type: varchar({ length: 20 }).notNull().$type<ReactionKey>(),
|
||
createTime: timestamp('create_time', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
problemId: integer('problem_id').notNull(),
|
||
userId: integer('user_id').notNull(),
|
||
},
|
||
(table) => [
|
||
index('reaction_problem_type_idx').using('btree', table.problemId.asc().nullsLast().op('int4_ops'), table.type.asc().nullsLast().op('int4_ops')),
|
||
index('reaction_user_id_cfa7f469').using('btree', table.userId.asc().nullsLast().op('int4_ops')),
|
||
foreignKey({
|
||
columns: [table.problemId],
|
||
foreignColumns: [problem.id],
|
||
name: 'reaction_problem_id_a7f3b9f3_fk_problem_id',
|
||
}).onDelete('cascade'),
|
||
foreignKey({
|
||
columns: [table.userId],
|
||
foreignColumns: [user.id],
|
||
name: 'reaction_user_id_cfa7f469_fk_user_id',
|
||
}),
|
||
unique('reaction_problem_user_unique').on(table.problemId, table.userId),
|
||
],
|
||
)
|
||
|
||
export const problem = pgTable(
|
||
'problem',
|
||
{
|
||
id: serial().primaryKey().notNull(),
|
||
title: text().notNull(),
|
||
description: text().notNull(),
|
||
inputDescription: text('input_description').notNull(),
|
||
outputDescription: text('output_description').notNull(),
|
||
samples: jsonb().notNull(),
|
||
testCaseId: text('test_case_id').notNull(),
|
||
testCaseScore: jsonb('test_case_score').notNull(),
|
||
hint: text(),
|
||
languages: jsonb().notNull().$type<ProblemLanguage[]>(),
|
||
template: jsonb().notNull().$type<Record<string, string>>(),
|
||
createTime: timestamp('create_time', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
lastUpdateTime: timestamp('last_update_time', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}),
|
||
timeLimit: integer('time_limit').notNull(),
|
||
memoryLimit: integer('memory_limit').notNull(),
|
||
visible: boolean().default(true).notNull(),
|
||
difficulty: text().notNull().$type<ProblemDifficulty>(),
|
||
source: text(),
|
||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||
submissionNumber: bigint('submission_number', { mode: 'number' }).default(0).notNull(),
|
||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||
acceptedNumber: bigint('accepted_number', { mode: 'number' }).default(0).notNull(),
|
||
createdById: integer('created_by_id').notNull(),
|
||
displayId: text('_id').notNull(),
|
||
statisticInfo: jsonb('statistic_info').default({}).notNull(),
|
||
contestId: integer('contest_id'),
|
||
isPublic: boolean('is_public').default(false).notNull(),
|
||
/**
|
||
* 已停用。「提交互相可见」的两个开关(这个是题目级,submission.shared 是单条级)
|
||
* 连同判定分支一起删掉了:生产库 956 道题里只有 2 道打开过,还都是比赛题
|
||
* (比赛未结束时那条分支根本走不到),出题页也从来没给过开关。
|
||
* 列保留不删:删列是破坏性迁移,而留着不写不读没有任何代价。
|
||
*/
|
||
shareSubmission: boolean('share_submission').default(false).notNull(),
|
||
prompt: text(),
|
||
answers: jsonb(),
|
||
allowFlowchart: boolean('allow_flowchart').default(false).notNull(),
|
||
flowchartData: jsonb('flowchart_data').default({}).notNull(),
|
||
flowchartHint: text('flowchart_hint'),
|
||
mermaidCode: text('mermaid_code'),
|
||
showFlowchart: boolean('show_flowchart').default(false).notNull(),
|
||
astRules: jsonb('ast_rules').$type<AstRules>(),
|
||
/** 做对之后的挑战,按语言分组。写入侧在 admin/problem.ts 校验,见契约 challengeSchema */
|
||
challenges: jsonb().$type<ProblemChallenges>(),
|
||
sqlConfig: jsonb('sql_config').$type<SqlConfig>(),
|
||
sqlDisplay: jsonb('sql_display').$type<SqlDisplay>(),
|
||
},
|
||
(table) => [
|
||
index('problem_contest_visible_idx').using('btree', table.contestId.asc().nullsLast().op('bool_ops'), table.visible.asc().nullsLast().op('int4_ops')),
|
||
index('problem_created_by_id_cb362143').using('btree', table.createdById.asc().nullsLast().op('int4_ops')),
|
||
index('problem_visible_idx').using('btree', table.visible.asc().nullsLast().op('bool_ops')),
|
||
foreignKey({
|
||
columns: [table.contestId],
|
||
foreignColumns: [contest.id],
|
||
name: 'problem_contest_id_328e013a_fk_contest_id',
|
||
}),
|
||
foreignKey({
|
||
columns: [table.createdById],
|
||
foreignColumns: [user.id],
|
||
name: 'problem_created_by_id_cb362143_fk_user_id',
|
||
}),
|
||
unique('unique_problem_id_contest').on(table.displayId, table.contestId),
|
||
],
|
||
)
|
||
|
||
export const problemTags = pgTable(
|
||
'problem_tags',
|
||
{
|
||
id: serial().primaryKey().notNull(),
|
||
problemId: integer('problem_id').notNull(),
|
||
problemtagId: integer('problemtag_id').notNull(),
|
||
},
|
||
(table) => [
|
||
index('problem_tags_problemtag_id_72d20571').using('btree', table.problemtagId.asc().nullsLast().op('int4_ops')),
|
||
foreignKey({
|
||
columns: [table.problemId],
|
||
foreignColumns: [problem.id],
|
||
name: 'problem_tags_problem_id_866ecb8d_fk_problem_id',
|
||
}).onDelete('cascade'),
|
||
foreignKey({
|
||
columns: [table.problemtagId],
|
||
foreignColumns: [problemTag.id],
|
||
name: 'problem_tags_problemtag_id_72d20571_fk_problem_tag_id',
|
||
}).onDelete('cascade'),
|
||
unique('problem_tags_problem_id_problemtag_id_318459d1_uniq').on(table.problemId, table.problemtagId),
|
||
],
|
||
)
|
||
|
||
export const problemTag = pgTable(
|
||
'problem_tag',
|
||
{
|
||
id: serial().primaryKey().notNull(),
|
||
name: text().notNull(),
|
||
category: text().notNull().default('knowledge').$type<TagCategory>(),
|
||
// 分类有没有人确认过。出题时顺手新建的标签一律先落在「知识点」,这一列为 false,
|
||
// 后台标签管理把它们排在最前面等老师归类;存量标签由 0022 统一标成 true
|
||
categoryConfirmed: boolean('category_confirmed').notNull().default(false),
|
||
},
|
||
() => [uniqueIndex('problem_tag_name_ci_unique').using('btree', sql`lower(name)`)],
|
||
)
|
||
|
||
export const submission = pgTable(
|
||
'submission',
|
||
{
|
||
id: text().primaryKey().notNull(),
|
||
contestId: integer('contest_id'),
|
||
problemId: integer('problem_id').notNull(),
|
||
createTime: timestamp('create_time', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
userId: integer('user_id').notNull(),
|
||
code: text().notNull(),
|
||
result: integer().default(6).notNull().$type<JudgeStatus>(),
|
||
info: jsonb().default({}).notNull(),
|
||
language: text().notNull().$type<ProblemLanguage>(),
|
||
/**
|
||
* 已停用,见 problem.share_submission 的说明。历史上 12.3 万条提交里有 40 条
|
||
* 为真(2022 年 39 条、2023 年 1 条),入口在更早的那版前端上,ojnext 和 OJ2
|
||
* 都没有把它搬过来。现在没有任何代码读写它,行里的历史值原样留着。
|
||
*/
|
||
shared: boolean().default(false).notNull(),
|
||
statisticInfo: jsonb('statistic_info').default({}).notNull(),
|
||
username: text().notNull(),
|
||
// 来源题单:学生从题单入口(/problemset/:id/problem/:pid)提交时记下来,
|
||
// 提交列表据此标出「这条来自题单」。**只是来源标记**,题单进度、奖章一概不看它,
|
||
// 那些由判完之后的 recordSolvedProblem 按「已加入且含这道题的所有题单」记账。
|
||
// 老数据里只有迁移 0007 从(已废弃的)problemset_submission 回填的首次 AC 有值。
|
||
problemsetId: bigint('problemset_id', { mode: 'number' }),
|
||
},
|
||
(table) => [
|
||
// 同上,不写 .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, id 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。
|
||
//
|
||
// 带上 id 是为了让排序成为**全序**,深翻页的游标转换(routes/submission.ts 的
|
||
// paginateSubmissionRows)才精确。create_time 由 `new Date().toISOString()` 生成,
|
||
// 只有毫秒精度,同毫秒的两条提交靠 create_time 分不出先后:游标用 `<=` 回查时,
|
||
// 上一页的末行会重新出现在下一页页首。加上 id 之后两步用的是同一个全序,不会错位。
|
||
// 索引从 2.3MB 涨到 6.9MB,快照实测第一步 5.7ms → 8.9ms,换精确值得。
|
||
//
|
||
// 两列都建成默认的 ASC NULLS LAST,靠 Index Only Scan **Backward** 服务
|
||
// `ORDER BY create_time DESC, id DESC`。别照着 ORDER BY 写成 .desc():Postgres 里
|
||
// `ORDER BY x DESC` 默认是 NULLS FIRST,而 `CREATE INDEX ... (x DESC)` 默认是
|
||
// NULLS LAST,两边 nulls 位置对不上,规划器就当这条索引出不了序——实测建成
|
||
// DESC NULLS LAST 之后深翻页退化成 external merge sort(5.2MB 落盘),比不建还糟。
|
||
// 两列同为 ASC 时整条索引反着扫就是精确的反序,所以反而是能用的那一种。
|
||
// 这两列都 NOT NULL,nulls 位置在语义上无所谓,纯粹是规划器的匹配规则。
|
||
index('submission_public_create_time_id_idx')
|
||
.using('btree', table.createTime.asc().nullsLast(), table.id.asc().nullsLast())
|
||
.where(sql`${table.contestId} is null`),
|
||
/**
|
||
* Django 给每个外键都自动建了一个单列索引,`db_index=True` 的还会多一个
|
||
* `_like`(text_pattern_ops)。0012 把其中 21 个删了 —— 它们的列都是某个
|
||
* 复合索引的**最左前缀**,规划器本来就走那一个,多出来的只是每次写入要多维护
|
||
* 一棵树。这张表上删的三个是 contest_id / problem_id / user_id,分别被下面
|
||
* 的 contest_create_time_idx、problem_user_idx、user_create_time_idx 覆盖。
|
||
*
|
||
* 加新索引时先看一眼有没有现成的复合索引已经以它打头,别把这批又建回来。
|
||
*/
|
||
index('problem_user_idx').using('btree', table.problemId.asc().nullsLast().op('int4_ops'), table.userId.asc().nullsLast().op('int4_ops')),
|
||
/**
|
||
* `submission_result_37e2f67a` 是 Django 建的单列索引,**别当成被下面
|
||
* submission_result_time_idx 覆盖了就删**:那个是 `WHERE contest_id IS NULL`
|
||
* 的部分索引,管不了「全库含比赛按 result 统计」那类查询(实测删掉之后
|
||
* `count(*) where result in (6,7)` 从走索引掉回 75ms 全表扫)。856kB,留着。
|
||
*/
|
||
index('submission_result_37e2f67a').using('btree', table.result.asc().nullsLast().op('int4_ops')),
|
||
index('user_create_time_idx').using('btree', table.userId.asc().nullsLast().op('int4_ops'), table.createTime.asc().nullsLast().op('timestamptz_ops')),
|
||
/**
|
||
* 提交列表的「语言」和「结果」两个下拉筛选。原来这两列上要么没索引、要么只有
|
||
* 不带 `contest_id IS NULL` 的单列索引,翻页那条靠 submission_public_create_time_id_idx
|
||
* 边扫边滤还能对付,**count 那条只能全表扫**(快照实测固定 75~82ms / 18448 buffers,
|
||
* 筛什么值都一样)。加完:语言 count 80ms → 11ms(Python,占 8 成)/ 1.6ms(C),
|
||
* 结果 count 75ms → 2.0ms。
|
||
*
|
||
* 更要命的是冷门语言的**翻页**:JavaScript 只有 3 条、全是很早以前的,分页索引得从
|
||
* 最新一路倒扫到底才凑够一页,43ms 全表扫;走这条索引是 0.02ms。(这个实测当年用的
|
||
* 是 Python2,那 3 条 2026-09 已被 0019 迁移并进 Python,换了个同样冷门的值举例。)
|
||
*
|
||
* 两列都 ASC NULLS LAST,理由同上面 submission_public_create_time_id_idx ——
|
||
* 靠 Index Scan **Backward** 出 `ORDER BY create_time DESC`。这里再实测了一遍:
|
||
* 写成 DESC NULLS LAST 规划器直接不认这条索引,回落到分页索引带 Filter。
|
||
*/
|
||
index('submission_language_time_idx')
|
||
.using('btree', table.language.asc().nullsLast(), table.createTime.asc().nullsLast())
|
||
.where(sql`${table.contestId} is null`),
|
||
index('submission_result_time_idx')
|
||
.using('btree', table.result.asc().nullsLast(), table.createTime.asc().nullsLast())
|
||
.where(sql`${table.contestId} is null`),
|
||
/**
|
||
* 提交列表的「题号」筛选。路由先把题号解析成 problem.id(见 routes/submission.ts 的
|
||
* problemFilter),这条索引才用得上:等值定位到一道题,剩下两列正好是翻页的全序,
|
||
* 深翻页的游标也照走。列方向同上面几条,全 ASC 靠 Backward 扫。
|
||
*
|
||
* problem_user_idx 以 problem_id 打头,但不带时间:一道题最近几个月没人交的话,
|
||
* 规划器照样选分页索引倒扫、边扫边滤。快照实测 1047(6 月之后没人交)17ms 倒扫
|
||
* 2 万行、3017(最后一次在 2024 年)59ms 倒扫 8.2 万行、不存在的题号扫完全表。
|
||
*/
|
||
index('submission_public_problem_time_idx')
|
||
.using('btree', table.problemId.asc().nullsLast(), table.createTime.asc().nullsLast(), table.id.asc().nullsLast())
|
||
.where(sql`${table.contestId} is null`),
|
||
/**
|
||
* 提交列表的「用户名」筛选是 `ilike '%x%'`,btree 帮不上,只有 trigram 能索引中缀匹配。
|
||
* 扩展在迁移 0015 里装(官方 postgres 镜像自带 contrib,pg_trgm 是 trusted 扩展)。
|
||
* 3MB。模式不足 3 个字符时 trigram 抽不出东西,照旧全表扫——那种前缀匹配大半张表,
|
||
* 扫表本来就是对的计划。
|
||
*/
|
||
index('submission_public_username_trgm_idx')
|
||
.using('gin', table.username.op('gin_trgm_ops'))
|
||
.where(sql`${table.contestId} is null`),
|
||
/**
|
||
* 覆盖索引,专门给「在全部公开提交上做聚合」那几个接口用:教师统计不填班级、
|
||
* 活跃榜、题目 AC 趋势。它们慢的**不是聚合本身,是为了读这四个小列把 145MB 的堆
|
||
* 翻一遍** —— `code` 和 `info` 占了这张表的绝大部分体积,聚合一列都用不上。
|
||
*
|
||
* 有了它这些查询走 Index Only Scan,只读 6MB。快照实测:
|
||
* 教师统计全站 186ms → 49ms(还消掉了 4.2MB 的落盘排序)、活跃榜 108ms → 18ms、
|
||
* AC 趋势 120ms → 41ms,buffers 一律从 18000+ 掉到 2000 以内。
|
||
*
|
||
* 列序按 user_id 打头:三个查询里两个按人分组,能省掉排序。加列要谨慎 ——
|
||
* 多一列就多一份 10 万行的拷贝,而它的价值全在「窄」上。
|
||
*/
|
||
index('submission_public_metrics_idx')
|
||
.using('btree', table.userId.asc().nullsLast(), table.problemId.asc().nullsLast(), table.result.asc().nullsLast(), table.createTime.asc().nullsLast())
|
||
.where(sql`${table.contestId} is null`),
|
||
/**
|
||
* 题目列表每一页的「做对 / 做过人数」(`services/problem-progress.ts` 的
|
||
* problemUserCounts):按 problem_id 取一页 30 道题、数 distinct user_id。
|
||
* 上面那条以 user_id 打头,按题号查只能把整棵索引 11 万行扫一遍再滤,12–18ms 而且
|
||
* 随提交数线性涨;这条以 problem_id 打头,快照实测 12ms → 3.6ms,「最多人做对」排序
|
||
* 的全表聚合 85ms → 16ms。
|
||
*
|
||
* problem_user_idx 也以 problem_id 打头,但它不是部分索引、也不带 result,规划器
|
||
* 不选它(还得回表看 contest_id 和 result)。
|
||
*/
|
||
index('submission_public_problem_user_idx')
|
||
.using('btree', table.problemId.asc().nullsLast(), table.userId.asc().nullsLast(), table.result.asc().nullsLast())
|
||
.where(sql`${table.contestId} is null`),
|
||
foreignKey({
|
||
columns: [table.contestId],
|
||
foreignColumns: [contest.id],
|
||
name: 'submission_contest_id_775716d5_fk_contest_id',
|
||
}),
|
||
foreignKey({
|
||
columns: [table.problemId],
|
||
foreignColumns: [problem.id],
|
||
name: 'submission_problem_id_76847b55_fk_problem_id',
|
||
}),
|
||
// 部分索引:绝大多数提交不来自题单,全列索引等于给 12 万行白建一遍。
|
||
// 谓词是 IS NOT NULL,`problemset_id = $1` 蕴含非空,所以删题单时的外键检查
|
||
// 也能用上它——不然那条检查要顺序扫全表。
|
||
index('submission_problemset_id_idx')
|
||
.using('btree', table.problemsetId.asc().nullsLast())
|
||
.where(sql`${table.problemsetId} is not null`),
|
||
// 删掉题单不该带走提交:置空来源标记就行,提交本身照旧存在。
|
||
foreignKey({
|
||
columns: [table.problemsetId],
|
||
foreignColumns: [problemset.id],
|
||
name: 'submission_problemset_id_fk_problemset_id',
|
||
}).onDelete('set null'),
|
||
],
|
||
)
|
||
|
||
/**
|
||
* 提交时附带的编辑过程信号,和 submission 一对一。字段含义见契约的
|
||
* `submissionTraceSchema`,这里只记表本身的取舍:
|
||
*
|
||
* - **没有行 ≠ 可疑。** 2026-09 之前的全部历史提交、刷新过页面的、老版本前端交的
|
||
* 都没有 trace,用它的地方一律把「缺失」当「无数据」。
|
||
* - 类型化的列而不是一个 jsonb:「可信 AC」和学情热力图要在 SQL 里按这些值筛、聚合。
|
||
* - `since_prev_ms` 是唯一由**服务端**算的一列(距同一用户同一道题上一次提交),
|
||
* 客户端伪造不了;这道题的第一次提交为 null。
|
||
* - CASCADE 挂在 submission 上、不挂 user:它是提交的附属,提交没了它没有意义,
|
||
* 人是谁顺着 submission 就能查到。同表的 problemset_submission 也是这一档。
|
||
*/
|
||
export const submissionTrace = pgTable(
|
||
'submission_trace',
|
||
{
|
||
submissionId: text('submission_id').primaryKey().notNull(),
|
||
activeMs: integer('active_ms').notNull(),
|
||
sinceOpenMs: integer('since_open_ms').notNull(),
|
||
typedChars: integer('typed_chars').notNull(),
|
||
pastedChars: integer('pasted_chars').notNull(),
|
||
pasteCount: integer('paste_count').notNull(),
|
||
maxPaste: integer('max_paste').notNull(),
|
||
deletedChars: integer('deleted_chars').notNull(),
|
||
blurCount: integer('blur_count').notNull(),
|
||
initialLen: integer('initial_len').notNull(),
|
||
collab: boolean().notNull(),
|
||
// bigint:int4 的毫秒数只够 24.8 天,隔一个假期回来重交就溢出了
|
||
sincePrevMs: bigint('since_prev_ms', { mode: 'number' }),
|
||
},
|
||
(table) => [
|
||
foreignKey({
|
||
columns: [table.submissionId],
|
||
foreignColumns: [submission.id],
|
||
name: 'submission_trace_submission_id_fk_submission_id',
|
||
}).onDelete('cascade'),
|
||
],
|
||
)
|
||
|
||
/**
|
||
* 每一次「让 AI 分析我的代码」(POST /ai/hint)的留痕,连同学生的评价。
|
||
*
|
||
* 在此之前提示一条都没落库,用了多少次、在哪些题上用、用完有没有做出来都无从知道;
|
||
* 之后要做的提示分级、错误归因都得拿这张表做对照。**生成失败的也记**(content 为空、
|
||
* error 有值),失败率是 AI 功能悄悄变差时最先动的那个数。
|
||
*
|
||
* - 不存 prompt 原文:学生代码在 submission 里、题面在 problem 里,重复存一遍没有意义。
|
||
* 存的是 `prompt_version` —— 改 system / prompt 的拼法时在 routes/ai.ts 里加一,
|
||
* 事后才分得清哪批提示是按哪版生成的。
|
||
* - 人和题顺着 submission 查(submission_id 上有索引)。和 submission_trace 一样
|
||
* CASCADE 挂在提交上:提交没了,这条提示也就没有上下文了。
|
||
* - 同一条提交可以有多条:刷新页面之后按钮会重新出现。
|
||
*/
|
||
export const aiHint = pgTable(
|
||
'ai_hint',
|
||
{
|
||
id: bigint({ mode: 'number' }).primaryKey().generatedByDefaultAsIdentity({
|
||
name: 'ai_hint_id_seq',
|
||
startWith: 1,
|
||
increment: 1,
|
||
minValue: 1,
|
||
maxValue: '9223372036854775807',
|
||
cache: 1,
|
||
}),
|
||
submissionId: text('submission_id').notNull(),
|
||
model: text().notNull(),
|
||
promptVersion: integer('prompt_version').notNull(),
|
||
// 生成失败时为空串,失败原因在 error
|
||
content: text().notNull(),
|
||
error: text(),
|
||
// 从收到请求到生成结束(或失败)的毫秒数,两段式时含诊断那一段
|
||
durationMs: integer('duration_ms').notNull(),
|
||
/**
|
||
* 两段式第一段的诊断结果(见 services/hint-diagnosis.ts)。**只存 safeParse 过的**,
|
||
* 所以 `$type` 成立 —— 闸在写入侧。没开两段式、编译失败(不诊断)、诊断失败时为 null。
|
||
* 同一条提交再要提示时复用这里的结果,不再调一次模型。
|
||
*/
|
||
diagnosis: jsonb().$type<HintDiagnosis>(),
|
||
// 诊断失败的原因(超时、回的不是 JSON、校验不过)。这时第二段退回单段式的 prompt
|
||
diagnosisError: text('diagnosis_error'),
|
||
/**
|
||
* 这条提示给的是哪一级(契约的 `HINT_LEVELS`,AI 时代 OJ 设计 2c)。
|
||
* -1 = 编译失败那一档,**不在阶梯上**;null = 2c 上线前那批不分级的提示。
|
||
* 查阶梯的 SQL 一律 `level >= 0`,两者都被摘掉。
|
||
*/
|
||
level: integer(),
|
||
/**
|
||
* 一共生成了几次(1 = 一次就过,2 = 重生成过)。**整段生成、过滤后才推**(边流式边
|
||
* 过滤做不到:发现违规时内容已经在学生屏幕上了),违规就重生成一次,只重一次。
|
||
*
|
||
* **不是「发出去的是第几次生成」** —— `filter_blocked` 为真时两次都作废了,发的是
|
||
* 兜底话术。7.5 的「输出过滤触发率」= `filter_attempt = 2` ÷ `filter_attempt` 非空。
|
||
*/
|
||
filterAttempt: integer('filter_attempt'),
|
||
/**
|
||
* 两次都违规,发的是写死的兜底话术(content 是那句话,不是模型的输出)。
|
||
*
|
||
* **这种行的 `error` 仍是 null** —— 生成本身没失败,是内容被拦了。所以算「生成失败率」
|
||
* 看 `error`,算「学生有没有真拿到提示」要另外扣掉 `filter_blocked` 为真的这批。
|
||
*/
|
||
filterBlocked: boolean('filter_blocked'),
|
||
// 被拦下的原因,两次都被拦时是两条(用 ; 连)。null = 一次都没触发过过滤
|
||
filterReason: text('filter_reason'),
|
||
// 学生的评价:null = 没评
|
||
helpful: boolean(),
|
||
feedbackTime: timestamp('feedback_time', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}),
|
||
createTime: timestamp('create_time', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
},
|
||
(table) => [
|
||
index('ai_hint_submission_id_idx').on(table.submissionId),
|
||
foreignKey({
|
||
columns: [table.submissionId],
|
||
foreignColumns: [submission.id],
|
||
name: 'ai_hint_submission_id_fk_submission_id',
|
||
}).onDelete('cascade'),
|
||
],
|
||
)
|
||
|
||
export const tutorial = pgTable(
|
||
'tutorial',
|
||
{
|
||
id: integer().primaryKey().generatedByDefaultAsIdentity({
|
||
name: 'tutorial_id_seq',
|
||
startWith: 1,
|
||
increment: 1,
|
||
minValue: 1,
|
||
maxValue: 2147483647,
|
||
cache: 1,
|
||
}),
|
||
title: varchar({ length: 128 }).notNull(),
|
||
content: text().notNull(),
|
||
createdAt: timestamp('created_at', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
updatedAt: timestamp('updated_at', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
isPublic: boolean('is_public').notNull(),
|
||
order: integer().notNull(),
|
||
createdById: integer('created_by_id').notNull(),
|
||
code: text(),
|
||
type: varchar({ length: 10 }).notNull().$type<TutorialType>(),
|
||
// 所属项目(「项目三 · 智慧农业·蜜橘园」),自学目录按它分组。0031 从正文第一段的
|
||
// 「**所属项目:…**」回填;发布脚本不传它,后端照样从正文里抽,见 admin/tutorial.ts
|
||
project: varchar({ length: 64 }).default('').notNull(),
|
||
},
|
||
(table) => [
|
||
index('tutorial_created_by_id_07973cab').using('btree', table.createdById.asc().nullsLast().op('int4_ops')),
|
||
foreignKey({
|
||
columns: [table.createdById],
|
||
foreignColumns: [user.id],
|
||
name: 'tutorial_created_by_id_07973cab_fk_user_id',
|
||
}),
|
||
],
|
||
)
|
||
|
||
/**
|
||
* 一课挂的「去做几道题」:老师在后台手挑的题目,按 `order` 排。
|
||
*
|
||
* 两头都 CASCADE:教程删了这串题就没意义了;题目删了,教程少挂一道题也不是学生的
|
||
* 做题证据(证据在 submission 那边,它对 problem 是拦住的)。
|
||
*/
|
||
export const tutorialProblem = pgTable(
|
||
'tutorial_problem',
|
||
{
|
||
tutorialId: integer('tutorial_id').notNull(),
|
||
problemId: integer('problem_id').notNull(),
|
||
order: integer().notNull(),
|
||
},
|
||
(table) => [
|
||
primaryKey({
|
||
columns: [table.tutorialId, table.problemId],
|
||
name: 'tutorial_problem_pkey',
|
||
}),
|
||
foreignKey({
|
||
columns: [table.tutorialId],
|
||
foreignColumns: [tutorial.id],
|
||
name: 'tutorial_problem_tutorial_id_fk_tutorial_id',
|
||
}).onDelete('cascade'),
|
||
foreignKey({
|
||
columns: [table.problemId],
|
||
foreignColumns: [problem.id],
|
||
name: 'tutorial_problem_problem_id_fk_problem_id',
|
||
}).onDelete('cascade'),
|
||
],
|
||
)
|
||
|
||
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,
|
||
}),
|
||
metrics: jsonb().default({}).notNull(),
|
||
updateTime: timestamp('update_time', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
userId: integer('user_id').notNull(),
|
||
},
|
||
(table) => [
|
||
foreignKey({
|
||
columns: [table.userId],
|
||
foreignColumns: [user.id],
|
||
name: 'user_stat_user_id_73337fc0_fk_user_id',
|
||
}).onDelete('cascade'),
|
||
unique('user_stat_user_id_key').on(table.userId),
|
||
],
|
||
)
|
||
|
||
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,
|
||
}),
|
||
unlockTime: timestamp('unlock_time', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
backfilled: boolean().default(false).notNull(),
|
||
notified: boolean().default(false).notNull(),
|
||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||
achievementId: bigint('achievement_id', { mode: 'number' }).notNull(),
|
||
userId: integer('user_id').notNull(),
|
||
},
|
||
(table) => [
|
||
index('user_achievement_achievement_id_29db600d').using('btree', table.achievementId.asc().nullsLast().op('int8_ops')),
|
||
index('user_achv_notified_idx').using('btree', table.userId.asc().nullsLast().op('int4_ops'), table.notified.asc().nullsLast().op('bool_ops')),
|
||
index('user_achv_time_idx').using('btree', table.userId.asc().nullsLast().op('int4_ops'), table.unlockTime.desc().nullsFirst().op('timestamptz_ops')),
|
||
foreignKey({
|
||
columns: [table.achievementId],
|
||
foreignColumns: [achievement.id],
|
||
name: 'user_achievement_achievement_id_29db600d_fk_achievement_id',
|
||
}).onDelete('cascade'),
|
||
foreignKey({
|
||
columns: [table.userId],
|
||
foreignColumns: [user.id],
|
||
name: 'user_achievement_user_id_b8ec7d6a_fk_user_id',
|
||
}),
|
||
unique('unique_user_achievement').on(table.achievementId, table.userId),
|
||
],
|
||
)
|
||
|
||
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,
|
||
}),
|
||
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(),
|
||
userId: integer('user_id').notNull(),
|
||
},
|
||
(table) => [
|
||
index('user_badge_badge_id_92a983e9').using('btree', table.badgeId.asc().nullsLast().op('int8_ops')),
|
||
foreignKey({
|
||
columns: [table.badgeId],
|
||
foreignColumns: [problemsetBadge.id],
|
||
name: 'user_badge_badge_id_92a983e9_fk_problemset_badge_id',
|
||
}).onDelete('cascade'),
|
||
foreignKey({
|
||
columns: [table.userId],
|
||
foreignColumns: [user.id],
|
||
name: 'user_badge_user_id_a286d718_fk_user_id',
|
||
}),
|
||
unique('unique_user_badge').on(table.badgeId, table.userId),
|
||
],
|
||
)
|
||
|
||
export const userProfile = pgTable(
|
||
'user_profile',
|
||
{
|
||
id: serial().primaryKey().notNull(),
|
||
acmProblemsStatus: jsonb('acm_problems_status').default({}).notNull().$type<Record<string, unknown>>(),
|
||
avatar: text().notNull(),
|
||
mood: text(),
|
||
acceptedNumber: integer('accepted_number').default(0).notNull(),
|
||
submissionNumber: integer('submission_number').default(0).notNull(),
|
||
userId: integer('user_id').notNull(),
|
||
realName: text('real_name'),
|
||
},
|
||
(table) => [
|
||
foreignKey({
|
||
columns: [table.userId],
|
||
foreignColumns: [user.id],
|
||
name: 'user_profile_user_id_8fdce8e2_fk_user_id',
|
||
}).onDelete('cascade'),
|
||
unique('user_profile_user_id_key').on(table.userId),
|
||
],
|
||
)
|
||
|
||
export const acmContestRank = pgTable(
|
||
'acm_contest_rank',
|
||
{
|
||
id: serial().primaryKey().notNull(),
|
||
submissionNumber: integer('submission_number').default(0).notNull(),
|
||
acceptedNumber: integer('accepted_number').default(0).notNull(),
|
||
totalTime: integer('total_time').default(0).notNull(),
|
||
submissionInfo: jsonb('submission_info').default({}).notNull().$type<Record<string, ContestSubmissionInfo>>(),
|
||
contestId: integer('contest_id').notNull(),
|
||
userId: integer('user_id').notNull(),
|
||
},
|
||
(table) => [
|
||
index('acm_rank_contest_user_idx').using('btree', table.contestId.asc().nullsLast().op('int4_ops'), table.userId.asc().nullsLast().op('int4_ops')),
|
||
index('acm_rank_order_idx').using(
|
||
'btree',
|
||
table.contestId.asc().nullsLast().op('int4_ops'),
|
||
table.acceptedNumber.asc().nullsLast().op('int4_ops'),
|
||
table.totalTime.asc().nullsLast().op('int4_ops'),
|
||
),
|
||
foreignKey({
|
||
columns: [table.contestId],
|
||
foreignColumns: [contest.id],
|
||
name: 'acm_contest_rank_contest_id_21030ccd_fk_contest_id',
|
||
}),
|
||
foreignKey({
|
||
columns: [table.userId],
|
||
foreignColumns: [user.id],
|
||
name: 'acm_contest_rank_user_id_40391ab2_fk_user_id',
|
||
}),
|
||
unique('unique_acm_rank_user_contest').on(table.contestId, table.userId),
|
||
],
|
||
)
|
||
|
||
export const user = pgTable(
|
||
'user',
|
||
{
|
||
id: serial().primaryKey().notNull(),
|
||
password: varchar({ length: 128 }).notNull(),
|
||
lastLogin: timestamp('last_login', { withTimezone: true, mode: 'string' }),
|
||
username: text().notNull(),
|
||
email: text(),
|
||
createTime: timestamp('create_time', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}),
|
||
// $type 只是 TS 层的收窄,不产生任何 SQL —— 让 eq(schema.user.adminType, "...")
|
||
// 里的角色名也受类型检查。运行时的兜底仍在 auth/session.ts 的 toAdminType。
|
||
adminType: text('admin_type').notNull().$type<AdminType>(),
|
||
isDisabled: boolean('is_disabled').default(false).notNull(),
|
||
problemPermission: text('problem_permission').notNull().$type<ProblemPermission>(),
|
||
rawPassword: varchar('raw_password', { length: 20 }),
|
||
className: text('class_name'),
|
||
/**
|
||
* 老师把这个学生「不计入排名」的时刻,null = 正常上榜。怀疑抄代码时用,随时能恢复
|
||
* (见 routes/ranking.ts)。只管排名页和首页周榜,比赛排名不受它影响
|
||
*/
|
||
rankHiddenAt: timestamp('rank_hidden_at', { withTimezone: true, mode: 'string' }),
|
||
/**
|
||
* 签进 access token 的版本号。改密码、重置密码、禁用账号时加一,这个人所有设备上
|
||
* 签出去的 token 立刻作废(见 auth/session.ts 的 revokeUserSessions)。
|
||
*
|
||
* 放在库里而不是 Redis:机房和服务器共用这个库、各有各的 Redis,
|
||
* 在一边改了密码,另一边登着的也得一起下线。
|
||
*/
|
||
tokenVersion: integer('token_version').default(0).notNull(),
|
||
},
|
||
(table) => [
|
||
unique('user_username_key').on(table.username),
|
||
// 「近两年登录过的活跃人数」—— problems/:id/beat-count 每次打开题目详情都要算一遍,
|
||
// 而这张表原来只有主键和 username 两个索引,那句统计是全表扫。
|
||
index('user_active_idx').using('btree', table.isDisabled.asc().nullsLast(), table.lastLogin.desc().nullsFirst()),
|
||
// 按班级 / 按年级(class_name like '241%')取学生:班级榜、班级对比、AI 学情的
|
||
// 排名 scope 都走它,见 routes/classroom.ts 的 loadClassUsers。
|
||
index('user_class_name_idx').using('btree', table.className.asc().nullsLast()),
|
||
],
|
||
)
|
||
|
||
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,
|
||
}),
|
||
name: text().notNull(),
|
||
description: text().notNull(),
|
||
icon: text().notNull(),
|
||
conditionType: text('condition_type').notNull().$type<BadgeConditionType>(),
|
||
conditionValue: integer('condition_value').notNull(),
|
||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||
problemsetId: bigint('problemset_id', { mode: 'number' }).notNull(),
|
||
},
|
||
(table) => [
|
||
index('problemset_badge_problemset_id_6cb6c74f').using('btree', table.problemsetId.asc().nullsLast().op('int8_ops')),
|
||
foreignKey({
|
||
columns: [table.problemsetId],
|
||
foreignColumns: [problemset.id],
|
||
name: 'problemset_badge_problemset_id_6cb6c74f_fk_problemset_id',
|
||
}).onDelete('cascade'),
|
||
],
|
||
)
|
||
|
||
/**
|
||
* 自学模块的留痕:一个学生 × 一课一行。
|
||
*
|
||
* OJ2 自己建的表,不是 Django 遗留,所以没有代理主键 —— 写入路径只有一条 upsert,
|
||
* 冲突目标就是 (user_id, tutorial_id),再挂个 id 序列没有任何用处。
|
||
*
|
||
* **只记「读到哪一课 + 停留多久」。** 练一练的作答另有一张表(下面的 `exerciseAttempt`),
|
||
* 一个学生一道练习一行;「学完」由两张表合起来判(契约的 `isLessonDone`)。
|
||
*
|
||
* 外键这里**用了库级 CASCADE**,和 Django 建的那些 NO ACTION 外键不同:删教程、删用户
|
||
* 都不必再记得回来手工清一遍子表(后台删教程的事务里就没清它,靠的就是这里)。
|
||
*/
|
||
export const tutorialProgress = pgTable(
|
||
'tutorial_progress',
|
||
{
|
||
userId: integer('user_id').notNull(),
|
||
tutorialId: integer('tutorial_id').notNull(),
|
||
// 打开次数。只有「进入这一课」才 +1,后续补时长的心跳不动它
|
||
viewCount: integer('view_count').default(0).notNull(),
|
||
// 累计停留秒数。前端只在页面可见、且人没挂机时计时,见 useLearnTrace.ts
|
||
totalSeconds: integer('total_seconds').default(0).notNull(),
|
||
firstViewedAt: timestamp('first_viewed_at', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
lastViewedAt: timestamp('last_viewed_at', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
},
|
||
(table) => [
|
||
primaryKey({
|
||
columns: [table.userId, table.tutorialId],
|
||
name: 'tutorial_progress_pkey',
|
||
}),
|
||
// 按课汇总(「这一课全班多少人读过」)要扫这一列,主键的前缀索引帮不上忙
|
||
index('tutorial_progress_tutorial_id_idx').on(table.tutorialId),
|
||
foreignKey({
|
||
columns: [table.userId],
|
||
foreignColumns: [user.id],
|
||
name: 'tutorial_progress_user_id_fk_user_id',
|
||
}).onDelete('cascade'),
|
||
foreignKey({
|
||
columns: [table.tutorialId],
|
||
foreignColumns: [tutorial.id],
|
||
name: 'tutorial_progress_tutorial_id_fk_tutorial_id',
|
||
}).onDelete('cascade'),
|
||
],
|
||
)
|
||
|
||
/**
|
||
* 练一练的留痕:一个学生 × 一道练习一行。
|
||
*
|
||
* 存的是**聚合**,不是流水:试了几次、错了几次、做没做对、第几次做对的、
|
||
* 最后一次做错时填的什么。一道题一个学生一行,全校封顶就是「学生数 × 练习数」,
|
||
* 而流水会随着学生反复点「提交」无限长 —— 而且多存的那些行回答不了任何新问题:
|
||
* 「他第 3 次和第 5 次都选了 B」对老师没有意义,「他试了 7 次才对」有。
|
||
*
|
||
* `lastWrongAnswer` 存的是**前端拼好的一句人话**(「选了 A、C」「第 2 空填了 xy」),
|
||
* 不是原始作答结构:七种题型的作答形状各不相同,存结构就得在后台按题型各写一套
|
||
* 渲染,而老师要看的只是「他错在哪」。前端本来就知道怎么把自己的作答说成人话。
|
||
*/
|
||
export const exerciseAttempt = pgTable(
|
||
'exercise_attempt',
|
||
{
|
||
userId: integer('user_id').notNull(),
|
||
exerciseId: integer('exercise_id').notNull(),
|
||
// 提交次数。同一份答案连点两次只算一次,见 ExerciseWidget.vue 的去重
|
||
attempts: integer().default(0).notNull(),
|
||
wrongAttempts: integer('wrong_attempts').default(0).notNull(),
|
||
solved: boolean().default(false).notNull(),
|
||
// 第一次做对时累计试了几次。做对之后就不再变 —— 后面再点提交不该把它改大
|
||
attemptsToSolve: integer('attempts_to_solve'),
|
||
lastWrongAnswer: text('last_wrong_answer'),
|
||
firstAttemptAt: timestamp('first_attempt_at', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
lastAttemptAt: timestamp('last_attempt_at', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
solvedAt: timestamp('solved_at', { withTimezone: true, mode: 'string' }),
|
||
},
|
||
(table) => [
|
||
primaryKey({
|
||
columns: [table.userId, table.exerciseId],
|
||
name: 'exercise_attempt_pkey',
|
||
}),
|
||
// 按题汇总(「这道题全班多少人做对」)要扫这一列
|
||
index('exercise_attempt_exercise_id_idx').on(table.exerciseId),
|
||
foreignKey({
|
||
columns: [table.userId],
|
||
foreignColumns: [user.id],
|
||
name: 'exercise_attempt_user_id_fk_user_id',
|
||
}).onDelete('cascade'),
|
||
foreignKey({
|
||
columns: [table.exerciseId],
|
||
foreignColumns: [exercise.id],
|
||
name: 'exercise_attempt_exercise_id_fk_exercise_id',
|
||
}).onDelete('cascade'),
|
||
],
|
||
)
|
||
|
||
/**
|
||
* 老师给某个班「这节课」布置的题(课堂看板里输入的题号),一个班一天一行。
|
||
*
|
||
* 学生首页的「班里在做」优先读它,没有才退回从提交记录推断(routes/classroom.ts)。
|
||
* 推断在上课头几分钟是空的 —— 还没人交 —— 而这正是没听清题号的学生最需要它的时候。
|
||
*
|
||
* - `day` 是**东八区日历日**文本(`calendarDay()` 写的 `YYYY-MM-DD`),不用 date 列:
|
||
* 「哪一天」的口径只在 time.ts 一处定,不交给驱动和会话时区去解释。
|
||
* - `problem_ids` 按老师输入的顺序存 problem.id。**不挂外键**(jsonb 挂不了),
|
||
* 读的时候按 visible 过滤,题目被删、被藏的自然不出现;写的时候校验过都存在。
|
||
* - `created_by` CASCADE:备课记录不是「学生做过什么」的证据,老师账号没了它也没有意义
|
||
* (外键分档见 CLAUDE.md「数据库」一节)。
|
||
*/
|
||
export const classLesson = pgTable(
|
||
'class_lesson',
|
||
{
|
||
id: integer().primaryKey().generatedByDefaultAsIdentity({
|
||
name: 'class_lesson_id_seq',
|
||
startWith: 1,
|
||
increment: 1,
|
||
minValue: 1,
|
||
maxValue: 2147483647,
|
||
cache: 1,
|
||
}),
|
||
className: text('class_name').notNull(),
|
||
day: text().notNull(),
|
||
problemIds: jsonb('problem_ids').notNull().$type<number[]>(),
|
||
/**
|
||
* 这份作业用什么语言做(Python / C / C++ / SQL),老师布置时选。可空:加这一列之前布置的
|
||
* 没有它。写入侧在 PUT /classroom/lesson 按 lessonLanguageSchema 校验过
|
||
*/
|
||
language: text().$type<LessonLanguage>(),
|
||
/** 这天第一次布置的老师,别的老师「改」也不换(和 createdAt 一样)。看板上写「HH:mm 谁 布置」 */
|
||
createdBy: integer('created_by').notNull(),
|
||
/**
|
||
* 这天第一次布置的时刻,之后「改」只动 updatedAt。清掉(删行)再布置算重新开始。
|
||
* 加这一列之前布置的用 updated_at 回填(0034)
|
||
*/
|
||
createdAt: timestamp('created_at', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
})
|
||
.notNull()
|
||
.defaultNow(),
|
||
updatedAt: timestamp('updated_at', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
},
|
||
(table) => [
|
||
unique('class_lesson_class_day_unique').on(table.className, table.day),
|
||
index('class_lesson_created_by_idx').on(table.createdBy),
|
||
foreignKey({
|
||
columns: [table.createdBy],
|
||
foreignColumns: [user.id],
|
||
name: 'class_lesson_created_by_fk_user_id',
|
||
}).onDelete('cascade'),
|
||
],
|
||
)
|
||
|
||
/**
|
||
* 谁做到了哪条挑战(problem.challenges 里的 id)。一人一条挑战一行,记第一次做到的那份提交。
|
||
*
|
||
* 两条路写进来:判题时做对的提交顺手判一遍(judge/run.ts),以及新加一条挑战时拿历史上
|
||
* 做对的代码补一遍(services/challenge.ts)—— 后者的 created_at 也是那份提交的时刻,
|
||
* 所以「本班第几个、全站第几个做到」按它排,补的和新做的一个口径。
|
||
*
|
||
* 外键:提交、题目没了,从它算出来的这一行也就没意义了,CASCADE;
|
||
* user 跟着 submission 走(submission.user_id 是 NO ACTION,删不掉有提交的用户)。
|
||
*/
|
||
export const challengeSuccess = pgTable(
|
||
'challenge_success',
|
||
{
|
||
id: integer().primaryKey().generatedByDefaultAsIdentity({
|
||
name: 'challenge_success_id_seq',
|
||
startWith: 1,
|
||
increment: 1,
|
||
minValue: 1,
|
||
maxValue: 2147483647,
|
||
cache: 1,
|
||
}),
|
||
userId: integer('user_id').notNull(),
|
||
problemId: integer('problem_id').notNull(),
|
||
challengeId: text('challenge_id').notNull(),
|
||
submissionId: text('submission_id').notNull(),
|
||
/** 做到的那份提交的时刻(不是写这一行的时刻) */
|
||
createdAt: timestamp('created_at', {
|
||
withTimezone: true,
|
||
mode: 'string',
|
||
}).notNull(),
|
||
},
|
||
(table) => [
|
||
unique('challenge_success_user_challenge_unique').on(table.userId, table.problemId, table.challengeId),
|
||
index('challenge_success_problem_idx').on(table.problemId, table.challengeId, table.createdAt),
|
||
foreignKey({
|
||
columns: [table.userId],
|
||
foreignColumns: [user.id],
|
||
name: 'challenge_success_user_id_fk_user_id',
|
||
}),
|
||
foreignKey({
|
||
columns: [table.problemId],
|
||
foreignColumns: [problem.id],
|
||
name: 'challenge_success_problem_id_fk_problem_id',
|
||
}).onDelete('cascade'),
|
||
foreignKey({
|
||
columns: [table.submissionId],
|
||
foreignColumns: [submission.id],
|
||
name: 'challenge_success_submission_id_fk_submission_id',
|
||
}).onDelete('cascade'),
|
||
],
|
||
)
|