LobeHub Drizzle ORM 的 Schema 与查询风格规范。适用于 pgTable 表结构、索引、Join 连接、类型推导、db.select/db.query、Schema 字段、外键、中间表(Junction tables)以及 Postgres 查询模式等场景。
Drizzle ORM Schema 代码风格指南
要添加 Model 或 Repository? 请在同一个 PR 中随附同级测试文件——
packages/database/src/models/**或src/repositories/**下的每个新文件都必须有对应的__tests__/<name>.test.ts。参考 testing skill(.agents/skills/testing/references/db-model-test.md)了解getTestDB()的集成模式、用户隔离测试、BM25describe.skipIf(!isServerDB)守护条件以及 Schema 的踩坑点。CI 的 Coverage 补丁门禁无法稳定拦截未经测试的新文件,所以这全靠你自己把关。
配置
- 配置文件:
drizzle.config.ts - Schema 目录:
packages/database/src/schemas/ - Migration 目录:
packages/database/migrations/ - 方言(Dialect):
postgresql(开启strict: true)
辅助函数
位置:packages/database/src/schemas/_helpers.ts
timestamptz(name):带时区的时间戳createdAt(),updatedAt(),accessedAt():标准时间戳列timestamps:包含上述三者的对象,方便展开引入
命名规范
- 表名(Tables):复数 snake_case(例如
users、session_groups) - 列名(Columns):snake_case(例如
user_id、created_at) - 新表命名:在给新表命名之前,先看看周边已有的表。保持既定的名词词组和后缀一致。例如,如果用户作用域的表名为
user_xxx_logs,那么对应的工作区作用域表名应为workspace_xxx_logs,而不是workspace_xxx_records或其他新的同义词。
// ✅ 好:遵循现有的 user/workspace 表命名体系。
export const userSignupLogs = pgTable('user_signup_logs', { ... });
export const workspaceSignupLogs = pgTable('workspace_signup_logs', { ... });
// ❌ 差:为同一个概念引入了新的后缀。
export const workspaceSignupRecords = pgTable('workspace_signup_records', { ... });
列定义
主键(Primary Keys)
切勿使用自增主键(如 serial、bigserial、生成身份列)。它们在跨数据库迁移、恢复和数据复制作业中会导致序列状态(sequence-state)问题。推荐使用应用层生成器(idGenerator、createNanoId)生成的文本 ID,内部表建议使用 uuid。
当表默认由自己管理 ID 生成逻辑时,请保留 $defaultFn(...)。调用方仍然可以显式传递 id;默认值仅在 insert 缺省该字段时触发。不要只因为某个流程需要传入 request-scoped ID 就把默认值删掉。
// ✅ 好:应用层生成的文本 ID;显式 insert 仍然可以覆盖它。
id: text('id')
.primaryKey()
.$defaultFn(() => idGenerator('agents'))
.notNull(),
// ❌ 差:在数据库迁移和恢复过程中,序列状态非常脆弱。
id: serial('id').primaryKey(),
ID 前缀能让实体类型更易区分。对于内部表,直接使用 uuid。
新表不要使用复合主键(Composite primary keys)。每张表都给一个单列代理主键(surrogate PK),并将业务唯一性约束放到 uniqueIndex 中。主键列不能为 null,因此当未来唯一性作用域增加了一个可空维度时,复合主键就必须拆掉重构——这正是 ai_providers / ai_models 切换为工作区作用域时发生的事情(Migration 0110 用代理主键 _id 加部分唯一索引替换了原本的复合主键)。唯一索引依然可以完美作为 onConflictDoUpdate upsert 操作的仲裁依据。
// ✅ 好:代理主键;唯一性作用域后续扩展时无需重构主键。
export const workspaceUserSettings = pgTable(
'workspace_user_settings',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
workspaceId: text('workspace_id').references(() => workspaces.id, { onDelete: 'cascade' }).notNull(),
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
...timestamps,
},
(t) => [uniqueIndex('workspace_user_settings_workspace_id_user_id_unique').on(t.workspaceId, t.userId)],
);
// ❌ 差:死死绑定这几列;后续一旦添加可空的作用域列(如 workspaceId、deviceId 等),就必须做 Migration 来彻底重构主键。
(t) => [primaryKey({ columns: [t.workspaceId, t.userId] })],
已有的复合主键属于历史遗留代码——除非它们阻碍了作用域变更,否则不要动它们;若需变更,按 0110 的方式进行迁移。
外键(Foreign Keys)
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
时间戳(Timestamps)
...timestamps, // 从 _helpers.ts 中展开
可选值与未定义值(Optional and Undefined Values)
除非领域模型中本身就存在该明确状态且已有代码在一致使用,否则不要为缺失值人为引入哨兵字符串(sentinel strings),比如 unknown。当值确实不存在时,优先使用可空列(nullable columns)、TypeScript 可选字段(optional fields)或单独的具象状态枚举。
// ✅ 好:在最终阶段写入真实决策前保持缺失状态。
export type UserSignupLogFinalDecision = 'allow' | 'block' | 'error';
finalDecision: varchar('final_decision', { length: 32 }).$type<UserSignupLogFinalDecision>(),
// ❌ 差:凭空发明一个新状态,导致所有调用方处处都要额外处理它。
export type UserSignupLogFinalDecision = 'allow' | 'block' | 'error' | 'unknown';
finalDecision: varchar('final_decision', { length: 32 })
.$type<UserSignupLogFinalDecision>()
.notNull()
.default('unknown');
数据库枚举(Database Enums)
默认不要使用 PostgreSQL/Drizzle 的 pgEnum。数据库枚举的演进成本非常高且难以安全维护:新增枚举值需要跑 Migration,删除或重命名枚举值很繁琐,而且部署顺序会变得更脆弱。
对于产品/业务状态,建议使用 text() 或 varchar(),并通过 $type<...>() 标注 TypeScript 值类型。将这些纯 TS 的值类型统一定义在领域/共享类型模块中,再导入到 Schema 中。在云端数据库 Schema 中,通常意味着放在 cloudDB/types.ts。
不要直接把已有的 DB 枚举当成参考范式,请视其为历史遗留代码或经过专门评审的特例。如果觉得有必要使用新的 pgEnum,先停下来,给出充分理由说明为什么该值集合是绝对不可变的,以及为什么可以接受其 Migration 成本。
字段说明文档(Field Descriptions)
对于仅看列名无法一目了然其含义的字段,请在 Schema 字段上添加 JSDoc。如果能补充具体示例来澄清存储值或写入时机,务必附上。这对于外部 ID、生命周期状态、去范式化快照(denormalized snapshots)、JSONB 信号,以及列名既可能代表请求 ID 又可能代表持久化行 ID 的字段尤为重要。
// ✅ 好:先说明表对应的业务对象,再仅针对非显而易见的生命周期或风控字段编写注释。
/**
* 用户注册日志 - 每个注册流程对应一行,记录 Auth Provider 创建用户前后的阶段级风控决策。
*/
export const userSignupLogs = pgTable('user_signup_logs', {
/** 最终注册结果原因,例如 user_created、llm_block 或 guard_error */
finalReason: text('final_reason'),
/** 根据各阶段决策导出的聚合风险等级,例如 block -> high */
riskLevel: varchar('risk_level', { length: 16 }).$type<UserSignupLogRiskLevel>(),
/** 按注册评审阶段分组的有序阶段决策与元数据 */
stageResults: jsonb('stage_results').$type<UserSignupLogStageResults>(),
});
// ❌ 差:注释只是机械重复明摆着的列名,没有补充任何领域含义。
/** 用户邮箱 */
email: text('email'),
JSONB 类型
Schema 列中应避免使用 Record<string, unknown> 或类似的宽松 JSONB 类型。必须定义具体的 interface 来描述预期的 JSON 结构,即使大多数属性都是可选的。这能保证调用方、Migration 和评审查询始终基于同一数据契约。
interface UserSignupLogMetadata {
payloadPath?: string;
requestPath?: string;
}
metadata: jsonb('metadata').$type<UserSignupLogMetadata>(),
// ❌ 差:掩盖了数据契约,导致下游访问完全失去类型约束。
metadata: jsonb('metadata').$type<Record<string, unknown>>(),
类型宽泛的 JSONB 列通常意味着更深层的问题:该列是投机性保留的(“为了以后扩展”),但实际上根本没有代码去写入它。切勿为了虚无缥缈的未来需求添加 metadata / extra 等 JSONB 列——只有当配合的具体写入代码一起发布时,该列才有存在的价值。如果 Code Review 发现了这种列,正确的做法是直接删除该列,而不是为不存在的数据凭空捏造一个 interface;等真实需求落地时,再添加一个带有精准类型的列。
索引(Indexes)
// 返回数组格式(已弃用对象风格)
(t) => [uniqueIndex('client_id_user_id_unique').on(t.clientId, t.userId)],
类型推导(Type Inference)
export const insertAgentSchema = createInsertSchema(agents);
export type NewAgent = typeof agents.$inferInsert;
export type AgentItem = typeof agents.$inferSelect;
典型 Schema 示例
export const agents = pgTable(
'agents',
{
id: text('id')
.primaryKey()
.$defaultFn(() => idGenerator('agents'))
.notNull(),
slug: varchar('slug', { length: 100 })
.$defaultFn(() => randomSlug(4))
.unique(),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
clientId: text('client_id'),
chatConfig: jsonb('chat_config').$type<LobeAgentChatConfig>(),
...timestamps,
},
(t) => [uniqueIndex('client_id_user_id_unique').on(t.clientId, t.userId)],
);
常见模式
中间表 / 关联表(Junction Tables / Many-to-Many)
上述代理主键规则同样适用于中间表——组合唯一性应放置在 uniqueIndex 中,而不是使用复合主键(注意:虽然许多现存中间表仍在使用复合主键,但那是历史遗留,不是推荐范式):
export const agentsKnowledgeBases = pgTable(
'agents_knowledge_bases',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
agentId: text('agent_id')
.references(() => agents.id, { onDelete: 'cascade' })
.notNull(),
knowledgeBaseId: text('knowledge_base_id')
.references(() => knowledgeBases.id, { onDelete: 'cascade' })
.notNull(),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
enabled: boolean('enabled').default(true),
...timestamps,
},
(t) => [
uniqueIndex('agents_knowledge_bases_agent_id_knowledge_base_id_unique').on(
t.agentId,
t.knowledgeBaseId,
),
],
);
查询风格(Query Style)
一律使用 db.select() 构建器 API。切勿使用 db.query.* 关系型 API(例如 findMany、findFirst、with:)。
关系型 API 会生成带 json_build_array 的复杂 Lateral Join,既脆弱又极难排查调试。
查询单行
// ✅ 好
const [result] = await this.db.select().from(agents).where(eq(agents.id, id)).limit(1);
return result;
// ❌ 差:使用了关系型 API
return this.db.query.agents.findFirst({
where: eq(agents.id, id),
});
带 JOIN 的查询
// ✅ 好:显式 select + leftJoin
const rows = await this.db
.select({
runId: agentEvalRunTopics.runId,
score: agentEvalRunTopics.score,
testCase: agentEvalTestCases,
topic: topics,
})
.from(agentEvalRunTopics)
.leftJoin(agentEvalTestCases, eq(agentEvalRunTopics.testCaseId, agentEvalTestCases.id))
.leftJoin(topics, eq(agentEvalRunTopics.topicId, topics.id))
.where(eq(agentEvalRunTopics.runId, runId))
.orderBy(asc(agentEvalRunTopics.createdAt));
// ❌ 差:使用带 `with:` 的关系型 API
return this.db.query.agentEvalRunTopics.findMany({
where: eq(agentEvalRunTopics.runId, runId),
with: { testCase: true, topic: true },
});
带聚合的查询
// ✅ 好:select + leftJoin + groupBy
const rows = await this.db
.select({
id: agentEvalDatasets




