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)、BM25describe.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(例如
users、session_groups) - 欄位 (Columns):snake_case(例如
user_id、created_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)
請勿使用自增主鍵(serial、bigserial、生成的 identity 欄位)。它們在跨資料庫遷移、還原和資料複製作業期間會產生序列狀態(sequence-state)問題。內部資料表請優先使用應用程式生成器(idGenerator、createNanoId)產生的文字 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.* 關聯 API(findMany、findFirst、with:)。
關聯 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 -->




