drizzle について
PostgreSQL を使うコードベースの開発者向けに、Drizzle ORM のスキーマとクエリの規約を示すガイドです。スキーマ設計、マイグレーション、型安全性、データベースクエリの方針をまとめています。
主な機能
- 01PostgreSQL スキーマ規約
- 02ID・キー・インデックスのパターン
- 03型付き JSONB とフィールド説明
- 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()の統合パターン、ユーザー分離テスト、BM25 のdescribe.skipIf(!isServerDB)ガード、スキーマに関する注意点については、testing スキル(.agents/skills/testing/references/db-model-test.md)を参照してください。CI のカバレッジパッチゲートでは、新しく追加された未テストのファイルを確実に検出できないため、テストの追加はあなたの責任です。
設定
- 設定:
drizzle.config.ts - スキーマ:
packages/database/src/schemas/ - マイグレーション:
packages/database/migrations/ - ダイアレクト:
strict: trueのpostgresql
ヘルパー関数
場所:packages/database/src/schemas/_helpers.ts
timestamptz(name):タイムゾーン付きタイムスタンプcreatedAt()、updatedAt()、accessedAt():標準のタイムスタンプ列timestamps:簡単に展開できる、3 つすべてを含むオブジェクト
命名規則
- テーブル:複数形のスネークケース(
users、session_groups) - 列:スネークケース(
user_id、created_at) - 新しいテーブル:新しいテーブル名を決める前に、近くにある既存のテーブルを確認してください。確立された名詞の系統と接尾辞を維持します。たとえば、ユーザー単位のテーブルが
user_xxx_logsなら、ワークスペース単位のテーブルもworkspace_xxx_recordsなどの別の同義語ではなく、workspace_xxx_logsにします。
// ✅ 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、生成される ID 列)は使用しないでください。データベース間のマイグレーション、復元、データコピーのジョブでシーケンス状態の問題が発生します。アプリケーションのジェネレーター(idGenerator、createNanoId)が生成するテキスト ID、または内部テーブルでは uuid を使用してください。
通常そのテーブルで ID を生成する場合は、$defaultFn(...) を維持してください。呼び出し側は引き続き明示的な id を渡せます。デフォルト値が実行されるのは、挿入時に id を省略した場合のみです。リクエスト単位の ID を渡す必要があるフローが 1 つあるからといって、デフォルト値を削除しないでください。
// ✅ 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 で保持してください。主キー列は NULL にできないため、一意性の範囲に後から NULL 許容の次元が追加されると、複合主キーを作り直す必要があります。実際に ai_providers/ai_models をワークスペース単位にした際、複合主キーを代理 _id と部分一意インデックスに置き換えました(マイグレーション 0110–0111)。onConflictDoUpdate によるアップサートでは、一意インデックスを競合判定の基準として引き続き使用できます。
// ✅ 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
オプション値と未定義値
値がない場合に unknown などの人為的なセンチネル文字列を導入しないでください。ただし、そのドメインに明示的にその状態が存在し、既存のコードで一貫して使用されている場合は除きます。値が実際に存在しない場合は、NULL 許容の列、オプションの 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 の値型を指定してください。TypeScript のみで使う値型はドメイン/共有型モジュールに置き、スキーマにインポートしてください。クラウド DB スキーマの場合は、通常 cloudDB/types.ts が該当します。
既存のデータベース列挙型を手本として複製しないでください。レガシー、または明示的にレビューされた例外として扱ってください。新たに pgEnum が必要だと思われる場合は、値の集合が実質的に不変である理由と、マイグレーションのコストを許容できる理由を明確にしてから進めてください。
フィールドの説明
名前だけでは意味が明らかでない列には、スキーマのフィールドに 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 の型
スキーマの列では、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)],
);
よく使うパターン
中間テーブル(多対多)
上記のサロゲート PK のルールは中間テーブルにも適用されます。ペアの一意性には複合 PK ではなく uniqueIndex を使用してください(既存の中間テーブルには複合 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,
),
],
);
クエリのスタイル
常に 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()、JOIN、CTE、groupBy でクエリが明確に書ける場合は、Drizzle ビルダーを優先してください。テーブルや列への参照がスキーマに結び付くため、変更が TypeScript のエラーとして表面化します。ビルダー内で、ヘルパーのない機能(JSON パス、キャスト、集計、CASE、NOW())に式レベルの sql<T> を使うのは問題ありません。行ロックは式ではなく句なので、生の FOR UPDATE ではなく .for('update') を使用してください。
COALESCE は、NULL の処理が必須の DB セマンティクスに含まれる場合(NULL 許容の JSONB への追加/マージ、「最初の非 NULL 値を維持する」場合)に限って使用してください。更新オブジェクトを避けるためだけに、通常の 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 が 1 回の往復で済んでいる場合、
executeを取り除くためだけに、深さごとの複数クエリに分割しないでください。 - Drizzle で表現できる複数段階の書き込みを 1 回の往復で行う場合は、
$with(...)とinsert().select()/update().from()を使用してください。 - 安全性を
execute<MyRow>(sql...)に頼らないでください。行の型は付与されますが、選択した列はスキーマの変更と同期されません。 - Drizzle で表現できない PostgreSQL の機能だけが必要な場合は、生 SQL を維持して、補間内のスキーマ参照、明示的なユーザースコープ、限定的な行インターフェース、回帰テストを使い、厳密にしてください。
再帰 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
`);
1 対多(個別のクエリ)
親レコードとその子レコードが必要な場合は、リレーショナルの with: を使う代わりに、2 つのクエリを使用してください。
// ✅ 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 スキルを参照してください。