关于 drizzle
面向使用 PostgreSQL 的代码库开发者的 Drizzle ORM 模式与查询风格指南,规定模式设计、迁移、类型安全和数据库查询规范。
主要功能
- 01PostgreSQL 模式约定
- 02ID、主键与索引模式
- 03JSONB 类型与字段文档规范
- 04Drizzle 查询构建器指南
- 05数据库模型测试要求
适用场景
- 01添加数据库表或模型
- 02设计主键与唯一性约束
- 03编写类型安全的 Drizzle 查询
- 04审查数据库模式迁移
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ lobehub/ lobehub --skill drizzle安装后在 Claude Code 中输入/drizzle确认已加载。
详细介绍
Drizzle ORM 模式风格指南
要添加模型或仓库? 请在同一个 PR 中一并提交对应的测试文件——
packages/database/src/models/**或src/repositories/**下的每个新文件,都需要有对应的__tests__/<name>.test.ts。有关getTestDB()集成模式、用户隔离测试、BM25describe.skipIf(!isServerDB)守卫以及模式注意事项,请参阅 testing 技能(.agents/skills/testing/references/db-model-test.md)。CI 的覆盖率补丁门禁无法可靠地发现全新且未经测试的文件,因此这项工作由你负责。
配置
- 配置:
drizzle.config.ts - 模式:
packages/database/src/schemas/ - 迁移:
packages/database/migrations/ - 方言:
postgresql,且strict: true
辅助函数
位置:packages/database/src/schemas/_helpers.ts
timestamptz(name):带时区的时间戳createdAt()、updatedAt()、accessedAt():标准时间戳列timestamps:包含以上三项的对象,便于展开
命名约定
- 表:复数蛇形命名(
users、session_groups) - 列:蛇形命名(
user_id、created_at) - 新表:命名新表前,请先检查附近已有的表。保留既有的名词系列和后缀。例如,如果用户范围的表名为
user_xxx_logs,则工作区范围的对应表应为workspace_xxx_logs,而不是workspace_xxx_records或其他新造的同义词。
// ✅ Good: follows the existing user/workspace table family (names are
// illustrative — swap `widget` for the real noun).
export const userWidgetLogs = pgTable('user_widget_logs', { ... });
export const workspaceWidgetLogs = pgTable('workspace_widget_logs', { ... });
// ❌ Bad: introduces a new suffix for the same concept.
export const workspaceWidgetRecords = pgTable('workspace_widget_records', { ... });
列定义
主键
不要使用自增主键(serial、bigserial、生成的 identity 列)。它们会在跨数据库迁移、还原和数据复制任务中造成序列状态问题。优先使用应用生成器(idGenerator、createNanoId)生成的文本 ID,或在内部表中使用 uuid。
当表通常自行生成 ID 时,请保留 $defaultFn(...)。调用方仍可显式传入 id;只有插入时未提供该值,才会运行默认函数。不要仅仅因为某个流程需要提供请求范围的 ID,就移除默认函数。
// ✅ Good: app-generated text ID; explicit inserts can still override it.
id: text('id')
.primaryKey()
.$defaultFn(() => idGenerator('agents'))
.notNull(),
// ❌ Bad: sequence state is fragile across DB migrations and restores.
id: serial('id').primaryKey(),
ID 前缀有助于区分实体类型。内部表请使用 uuid。
新表不要使用复合主键。为每张表设置单列代理主键,并通过 uniqueIndex 保留业务唯一性。主键列不能为 nullable,因此如果唯一性范围之后增加了一个 nullable 维度,就必须拆除并重建复合主键——ai_providers / ai_models 按工作区划分范围时就发生了这种情况(迁移 0110–0111 将它们的复合主键替换为代理 _id,并添加了部分唯一索引)。唯一索引仍可作为 onConflictDoUpdate upsert 的仲裁依据。
// ✅ Good: surrogate PK; uniqueness scope can evolve without a PK rebuild.
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)],
);
// ❌ Bad: locked to exactly these columns; adding a nullable scope column
// (workspaceId, deviceId, …) later forces a full PK rebuild migration.
(t) => [primaryKey({ columns: [t.workspaceId, t.userId] })],
现有复合主键属于遗留设计——除非它们妨碍范围变更,否则不要改动;如需迁移,请采用 0110–0111 的方式。
外键
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
时间戳
...timestamps, // Spread from _helpers.ts
可选值和 undefined 值
不要为缺失值引入人为的哨兵字符串,例如 unknown,除非该领域已经明确定义了这种状态,且现有代码始终如一地使用它。优先使用 nullable 列、可选的 TypeScript 字段,或者在值确实缺失时使用单独且具体的状态枚举。
// ✅ Good: absent until a custom-execution attempt actually runs
// (packages/database/src/schemas/agentIntervention.ts).
export const AGENT_INTERVENTION_CUSTOM_EXECUTION_STATES = ['pending', 'executing', 'completed'] as const;
export type AgentInterventionCustomExecutionState =
(typeof AGENT_INTERVENTION_CUSTOM_EXECUTION_STATES)[number];
customExecutionState: text('custom_execution_state').$type<AgentInterventionCustomExecutionState>(),
// ❌ Bad: invents a new state that callers now need to handle everywhere.
export type AgentInterventionCustomExecutionState = 'pending' | 'executing' | 'completed' | 'unknown';
customExecutionState: text('custom_execution_state')
.$type<AgentInterventionCustomExecutionState>()
.notNull()
.default('unknown');
数据库枚举
默认不要使用 PostgreSQL/Drizzle pgEnum。数据库枚举难以安全演进:添加成员需要迁移,删除或重命名成员也很麻烦,而且部署顺序会更脆弱。
对于产品或业务状态,使用 text() 或 varchar(),并通过 $type<...>() 指定 TypeScript 值类型。将这些仅用于 TS 的值类型放在领域/共享类型模块中,然后导入模式。在云 DB 模式中,这通常指 cloudDB/types.ts。
不要将现有 DB 枚举照搬为范例。应将其视为遗留设计或经过明确审查的例外。如果认为有必要新增 pgEnum,请先停下来说明为什么该值集合实际上不可变,以及为什么迁移成本可以接受。
字段说明
对于仅凭名称无法明确含义的列,请在 schema 字段上添加 JSDoc。若能说明存储值或写入该值的生命周期节点,请附上具体示例。这一点对外部 ID、生命周期状态、反规范化快照、JSONB 信号,以及名称可能指请求 ID 或持久化行 ID 的字段尤为重要。
// ✅ Good: explain the table's business object first, then only document
// non-obvious lifecycle fields
// (packages/database/src/schemas/agentIntervention.ts).
/**
* Private resolution/outbox records. Unlike `agent_interventions`, this table
* may retain user-edited arguments so a claimed decision can be delivered
* reliably after the app disconnects. It is never a notification payload.
*/
export const agentInterventionResolutions = pgTable('agent_intervention_resolutions', {
/** Owner of the durable operation, distinct from the resolving actor. */
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
/** Private executor result; never selected into Review or notification DTOs. */
customExecutionResult: jsonb('custom_execution_result').$type<AgentInterventionCustomExecutionResult>(),
/** Monotonic lease attempt, starting at zero while pending. */
customExecutionAttempt: integer('custom_execution_attempt'),
});
// ❌ Bad: comments restate obvious column names without adding domain meaning.
/** User email */
email: text('email'),
JSONB 类型
避免为 schema 列使用 Record<string, unknown> 或类似宽泛的 JSONB 类型。请定义描述预期 JSON 形状的具体接口,即使其中大多数属性都是可选的。这样可以确保调用方、迁移和审查查询遵循相同的数据契约。
// packages/types/src/agent/agentIntervention.ts +
// packages/database/src/schemas/agentIntervention.ts
interface AgentInterventionCustomExecutionResult {
content: string;
pluginState: Record<string, unknown>;
}
customExecutionResult: jsonb('custom_execution_result').$type<AgentInterventionCustomExecutionResult>(),
// ❌ Bad: hides the contract and makes downstream access untyped.
customExecutionResult: jsonb('custom_execution_result').$type<Record<string, unknown>>(),
JSONB 列的类型定义宽泛,往往意味着存在更深层的问题:该列是推测性地预留的(“供未来扩展”),但实际上没有任何代码会写入它。不要为假设的未来需求添加 metadata / extra JSONB 列——只有在具体写入逻辑随之上线时,列才有存在的价值。审查时若发现此类列,应删除该列,而不是为不存在的数据虚构接口;等实际需求出现后,再添加类型定义完善的列。
索引
// Return array (object style deprecated)
(t) => [uniqueIndex('client_id_user_id_unique').on(t.clientId, t.userId)],
类型推断
export const insertAgentSchema = createInsertSchema(agents);
export type NewAgent = typeof agents.$inferInsert;
export type AgentItem = typeof agents.$inferSelect;
示例模式
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)],
);
常见模式
联结表(多对多)
上述代理主键规则同样适用于联结表——成对唯一性应由 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,
),
],
);
查询风格
始终使用 db.select() 构建器 API。切勿使用 db.query.* 关系 API(findMany、findFirst、with:)。
关系 API 会生成复杂的横向联接,其中包含 json_build_array,既脆弱又难以调试。
查询单行
// ✅ Good
const [result] = await this.db.select().from(agents).where(eq(agents.id, id)).limit(1);
return result;
// ❌ Bad: relational API
return this.db.query.agents.findFirst({
where: eq(agents.id, id),
});
使用 JOIN 查询
// ✅ Good: explicit 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));
// ❌ Bad: relational API with `with:`
return this.db.query.agentEvalRunTopics.findMany({
where: eq(agentEvalRunTopics.runId, runId),
with: { testCase: true, topic: true },
});
使用聚合查询
// ✅ Good: select + leftJoin + groupBy
const rows = await this.db
.select({
id: agentEvalDatasets.id,
name: agentEvalDatasets.name,
testCaseCount: count(agentEvalTestCases.id).as('testCaseCount'),
})
.from(agentEvalDatasets)
.leftJoin(agentEvalTestCases, eq(agentEvalDatasets.id, agentEvalTestCases.datasetId))
.groupBy(agentEvalDatasets.id);
原始 SQL 和高级查询
只要使用 select、insert().select()、update().from()、联接、CTE 和 groupBy 能清晰地表达查询,就优先使用 Drizzle 构建器;这样表和列的引用会与 schema 保持关联,变更也会以 TypeScript 错误的形式显现。在构建器中,对于缺少辅助方法的功能(JSON 路径、类型转换、聚合、CASE、NOW()),可以使用表达式级别的 sql<T>。行锁是子句,而非表达式——请使用 .for('update'),不要使用原始 FOR UPDATE。
仅当空值处理属于必要的数据库语义(可为空的 JSONB 追加/合并、“保留第一个非空值”)时,才使用 COALESCE。不要在普通 upsert 标量字段中到处使用 COALESCE(excluded.col, current.col),只为避免构造更新对象——只根据已定义的值构建 set,并将剩余 SQL 封装在命名辅助函数中(appendJsonbArray、mergeJsonbObject、keepFirstValue),让方法体现业务意图,而非 SQL 细节。
// ✅ Scalars included only when present; SQL hidden behind a named helper.
// (`userWidgetLogs` is illustrative — swap for the real table.)
const updateValues = compactUndefined({
email: record.email ?? undefined,
ip: record.ip ?? undefined,
});
await db.insert(userWidgetLogs).values(values).onConflictDoUpdate({
set: { ...updateValues, events: appendJsonbArray(userWidgetLogs.events, event), updatedAt: now },
target: userWidgetLogs.id,
});
// ❌ Every scalar becomes SQL plumbing.
set: {
email: sql`COALESCE(excluded.email, ${userWidgetLogs.email})`,
ip: sql`COALESCE(excluded.ip, ${userWidgetLogs.ip})`,
}
重构原始 SQL 时:
- 在对延迟敏感的路径上保留查询形状。如果原始 SQL 只需一次往返,就不要仅为移除
execute而将其拆分成多个按深度查询。 - 对 Drizzle 能表达的多步骤单次往返写入,使用
$with(...)+insert().select()/update().from()。 - 不要依赖
execute<MyRow>(sql...)来保证安全——它只能为行提供类型,无法确保所选列与 schema 变更保持同步。 - 如果只有 Drizzle 无法表达的 PostgreSQL 功能可行,就保留原始 SQL 并收紧其实现:在插值中引用 schema、明确用户范围、使用精简的行接口,并添加回归测试。
递归 CTE 是应保留原始 SQL 的典型场景——没有简洁的 WITH RECURSIVE 构建器,改写还会增加按深度查询的往返:
interface TaskTreeRow {
id: string;
parent_task_id: string | null;
}
// execute<T> acceptable: no clean WITH RECURSIVE builder. Keep schema refs in the
// interpolations and scope every leg to the user.
const { rows } = await db.execute<TaskTreeRow>(sql`
WITH RECURSIVE task_tree AS (
SELECT ${tasks.id}, ${tasks.parentTaskId}
FROM ${tasks}
WHERE ${tasks.id} = ${rootTaskId} AND ${tasks.createdByUserId} = ${userId}
UNION ALL
SELECT ${tasks.id}, ${tasks.parentTaskId}
FROM ${tasks}
JOIN task_tree ON ${tasks.parentTaskId} = task_tree.id
WHERE ${tasks.createdByUserId} = ${userId}
)
SELECT * FROM task_tree
`);
一对多(分开查询)
需要同时获取父记录及其子记录时,请使用两次查询,而不是关系 API 的 with::
// ✅ Good: two simple queries
const [dataset] = await this.db
.select()
.from(agentEvalDatasets)
.where(eq(agentEvalDatasets.id, id))
.limit(1);
if (!dataset) return undefined;
const testCases = await this.db
.select()
.from(agentEvalTestCases)
.where(eq(agentEvalTestCases.datasetId, id))
.orderBy(asc(agentEvalTestCases.sortOrder));
return { ...dataset, testCases };
数据库迁移
详细的迁移指南请参阅 db-migrations 技能。