drizzle

drizzle

熱門

LobeHub 的 Drizzle ORM schema 與查詢風格指南。適用於 pgTable schema、索引(indexes)、聯結(joins)、型別推導(inferred types)、db.select/db.query、schema 欄位、外鍵(foreign keys)、轉接表(junction tables)或 postgres 查詢模式。

8.1萬星標
1.6萬分支
更新於 2026/8/4
SKILL.md
唯讀
名稱
drizzle
描述

LobeHub 的 Drizzle ORM schema 與查詢風格指南。適用於 pgTable schema、索引(indexes)、聯結(joins)、型別推導(inferred types)、db.select/db.query、schema 欄位、外鍵(foreign keys)、轉接表(junction tables)或 postgres 查詢模式。

Drizzle ORM Schema 風格指南

要新增 Model 或 Repository? 請在同一個 PR 隨附對應的測試檔 —— 位於 packages/database/src/models/**src/repositories/** 底下的每個新檔案,都必須有對應的 __tests__/<name>.test.ts。關於 getTestDB() 的整合模式、使用者隔離測試(user-isolation tests)、BM25 describe.skipIf(!isServerDB) 防護檢查以及 schema 常見陷阱,請參閱 testing skill(.agents/skills/testing/references/db-model-test.md)。CI 的覆蓋率補丁門檻(coverage patch gate)無法可靠地捕捉到全新且未測試的檔案,因此這項責任落在你身上。

配置 (Configuration)

  • 設定檔:drizzle.config.ts
  • Schema 目錄:packages/database/src/schemas/
  • Migration 目錄:packages/database/migrations/
  • 方言 (Dialect):postgresql 並開啟 strict: true

輔助函式 (Helper Functions)

位置:packages/database/src/schemas/_helpers.ts

  • timestamptz(name):帶時區的時間戳記
  • createdAt(), updatedAt(), accessedAt():標準時間戳記欄位
  • timestamps:包含上述三者的物件,方便展開使用

命名規範 (Naming Conventions)

  • 資料表 (Tables):複數 snake_case(例如 userssession_groups
  • 欄位 (Columns):snake_case(例如 user_idcreated_at
  • 新資料表:在為新表命名前,請先檢查附近的既有資料表。保持已建立的名詞家族與字尾習慣。例如:如果使用者範圍(user-scoped)的資料表名為 user_xxx_logs,則工作區範圍(workspace-scoped)的對應表應命名為 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', { ... });

欄位定義 (Column Definitions)

主鍵 (Primary Keys)

請勿使用自增主鍵(serialbigserial、生成的 identity 欄位)。它們在跨資料庫遷移、還原和資料複製作業期間會產生序列狀態(sequence-state)問題。內部資料表請優先使用應用程式生成器(idGeneratorcreateNanoId)產生的文字 ID,或使用 uuid

當資料表通常自行管理 ID 生成時,請保留 $defaultFn(...)。呼叫端仍可傳入明確的 id;只有在 insert 省略該欄位時才會執行預設值。請不要只因為某個流程需要提供 request 作用域的 ID 就移除預設值。

// ✅ 良好:由應用程式生成的文字 ID;明確傳入的 insert 仍可覆蓋它。
id: text('id')
  .primaryKey()
  .$defaultFn(() => idGenerator('agents'))
  .notNull(),

// ❌ 不佳:序列狀態在資料庫遷移與還原時相當脆弱。
id: serial('id').primaryKey(),

前綴(ID prefixes)可讓實體型別易於辨識。內部資料表請使用 uuid

請勿在新建資料表上使用複合主鍵(composite primary keys)。為每個資料表提供單一欄位的代理主鍵(surrogate PK),並將業務上的唯一性要求交由 uniqueIndex 處理。PK 欄位不能為 null,因此當日後唯一性範圍擴充了可為 null 的維度時,複合 PK 就必須拆除並重建 —— 這正是 ai_providers / ai_models 改為工作區作用域時發生的情況(Migration 0110 將它們的複合 PK 替換為代理 _id 加上局部 unique index)。unique index 依然能作為 onConflictDoUpdate upsert 的判斷依據。

// ✅ 良好:代理 PK;唯一性範圍可以演進,無須重建 PK。
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)],
);

// ❌ 不佳:鎖定於這幾個特定欄位;日後新增可為 null 的範圍欄位
// (如 workspaceId、deviceId…)將迫使進行完整的 PK 重建遷移。
(t) => [primaryKey({ columns: [t.workspaceId, t.userId] })],

現有的複合 PK 屬於舊有遺留程式碼(legacy)—— 除非它們阻礙了範圍變更,否則請保持原樣;若需調整,請參照 Migration 0110 的方式進行遷移。

外鍵 (Foreign Keys)

userId: text('user_id')
  .references(() => users.id, { onDelete: 'cascade' })
  .notNull(),

時間戳記 (Timestamps)

...timestamps,  // 從 _helpers.ts 展開

可選值與 Undefined 值 (Optional and Undefined Values)

請勿為缺失的值人為引進哨兵字串(sentinel strings),例如 unknown,除非該領域(domain)本來就有此明確狀態且既存程式碼已一致使用。當值確實不存在時,請優先使用可為 null 的欄位、可選的 TypeScript 欄位,或獨立具體的狀態列舉(enum)。

// ✅ 良好:在最後階段寫入真實決策前保持不存在(absent)。
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。資料庫列舉要在維護安全的同時進行演進成本很高:新增成員需要資料庫遷移,刪除或重命名成員非常棘手,而且部署順序會變得更加脆弱。

對於產品/業務狀態,請使用 text()varchar(),並透過 $type<...>() 指定 TypeScript 型別。將這些僅用於 TS 的型別保留在 domain/shared 型別模組中,然後匯入到 schema。對於雲端 DB schema,這通常意味著 cloudDB/types.ts

請勿將既存的 DB enum 當作範本複製。請將它們視為舊有遺留或經過特別審查的特例。如果看似必須使用新的 pgEnum,請先停下來並說明為什麼該值集合實際上是不可變的(immutable),以及為什麼遷移成本是可以接受的。

欄位說明 (Field Descriptions)

對於光看名稱無法明白其意圖的欄位,請在 schema 欄位上記述 JSDoc。當它能澄清儲存的值或寫入該值的生命週期時機時,請附上具體範例。這對於外部 ID、生命週期狀態、去正規化快照(denormalized snapshots)、JSONB 信號,以及名稱可能代表 request 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>(),
});

// ❌ 不佳:註釋只是重複顯而易見的欄位名稱,未增加領域含義。
/** User email */
email: text('email'),

JSONB 型別 (JSONB Types)

schema 欄位請避免使用 Record<string, unknown> 或類似的鬆散 JSONB 型別。即使大多數屬性都是可選的,也請定義具體的介面(interface)來描述預期的 JSON 形狀。這能讓呼叫端、遷移作業和審查查詢在同一資料契約(data contract)上保持一致。

interface UserSignupLogMetadata {
  payloadPath?: string;
  requestPath?: string;
}

metadata: jsonb('metadata').$type<UserSignupLogMetadata>(),
// ❌ 不佳:隱藏了資料契約,並導致下游存取失去型別保護。
metadata: jsonb('metadata').$type<Record<string, unknown>>(),

型別鬆散的 JSONB 欄位通常反映了更深層的問題:該欄位是被投機性地保留的(「為了未來的擴充」),實際上沒有任何程式碼寫入它。請不要為了假設性的未來需求而新增 metadata / extra JSONB 欄位 —— 只有當隨附有具體的寫入端發布時,欄位才有存在的價值。當 Code Review 發現此類欄位時,修復方式是刪除該欄位,而不是為不存在的資料憑空發明一個介面;等到真實需求到來時,再新增型別完善的欄位。

索引 (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;

範例模式 (Example Pattern)

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)],
);

常見模式 (Common Patterns)

轉接表 / 多對多關聯表 (Junction Tables)

上述的代理 PK 規則同樣適用於轉接表 —— 成對的唯一性應放置於 uniqueIndex 中,而非複合 PK(許多現有的轉接表仍使用複合 PK;那是遺留程式碼,並非規範範本):

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.* 關聯 APIfindManyfindFirstwith:)。

關聯 API 會生成包含 json_build_array 的複雜 LATERAL JOIN,這類查詢相當脆弱且難以偵錯。

查詢單列資料 (Select Single Row)

// ✅ 良好
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 with 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 with Aggregation)

// ✅ 良好:select + leftJoin + groupBy
const rows = await this.db
  .select({
    id: agentEvalDatasets

<!-- truncated for translation batch; full body continues in source -->