drizzle

drizzle

热门

LobeHub Drizzle ORM 的 Schema 与查询风格规范。适用于 pgTable 表结构、索引、Join 连接、类型推导、db.select/db.query、Schema 字段、外键、中间表(Junction tables)以及 Postgres 查询模式等场景。

8.1万Star
1.6万Fork
更新于 2026/8/4
SKILL.md
只读
名称
drizzle
描述

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() 的集成模式、用户隔离测试、BM25 describe.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(例如 userssession_groups
  • 列名(Columns):snake_case(例如 user_idcreated_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)

切勿使用自增主键(如 serialbigserial、生成身份列)。它们在跨数据库迁移、恢复和数据复制作业中会导致序列状态(sequence-state)问题。推荐使用应用层生成器(idGeneratorcreateNanoId)生成的文本 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(例如 findManyfindFirstwith:)。

关系型 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