db-migrations について
Drizzle のスキーマ規約、マイグレーション生成、リリース方法をまとめたデータベース変更向けガイドです。オンラインインデックス作成やデータ補完、開発中のマイグレーション整理を扱います。
主な機能
- 01拡張する値にはテキスト型を使用
- 02変更ごとにリリース方法を選択
- 03オンラインでインデックスを作成
- 04データ補完を独立スクリプトで実行
- 05開発中のマイグレーションを統合
- 06冪等なマイグレーション SQL を生成
活用シーン
- 01Drizzle スキーマの追加や変更
- 02インデックスのリリース方法の検討
- 03過去データの補完計画
- 04ブランチ間のマイグレーション競合の解消
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ lobehub/ lobehub --skill db-migrationsインストール後、Claude Code で/db-migrationsを入力して読み込まれたか確認します。
詳細
データベース移行ガイド
スキーマの規約
マイグレーションを生成する前に、以下を適用してください。これらは SQL だけでなく、スキーマファイルの内容も変えます。
-
拡大し続けるドメインには pg enum(および固定値の集合)を使わないでください。 値の種類が増え続ける列(リソースの種類、ステータス、プロバイダーなど)には、
.$type<UnionType>()で型付けした通常のtext列を使ってください。pgEnumでは新しいリテラルを追加するたびにALTER TYPE ... ADD VALUEマイグレーションが必要です。また、Drizzle のtext('col', { enum: [...] })オプションでも、値のリストがスキーマファイルに固定されます。.$type<>()を使えば、新しい値の追加は型だけの変更となり、マイグレーションは一切不要です。// ✅ Good — type lives in @lobechat/types, column stays plain text resourceType: text('resource_type').$type<TransferResourceType>().notNull(), // ❌ Bad — pg enum, needs ALTER TYPE per new value resourceType: resourceTypeEnum('resource_type').notNull(), // ❌ Avoid — value list hardwired into the schema file resourceType: text('resource_type', { enum: TRANSFER_RESOURCE_TYPES }).notNull(), -
ドメイン定数をスキーマファイルに置かないでください。
packages/database/src/schemas/以下の新規または変更対象のスキーマファイルでは、共有するドメインのリテラル配列、ユニオン型、オプションインターフェースを@lobechat/types(ドメインごとに 1 つのモジュールを作成し、そのindex.tsから再エクスポート)に置きます。スキーマ(.$type<>())と利用側(z.enum(...)を使うルーター、サービス、UI)は、いずれもそこからインポートしてください。このルールの対象はドメイン定数のみです。テーブルオブジェクト、推論された行型、Drizzle のリレーションオブジェクト、zod の挿入・選択スキーマ(insertAgentSchemaなど)はスキーマファイルの役割であり、そのまま置いてください。すでにこのような定数をエクスポートしている既存のスキーマファイル(例:resourcePermission.ts)は従来どおりで構いません。一括ではなく、次にファイルを変更するときに適宜移行してください。 -
プレフィックス付き ID は、個別に参照される行にのみ使ってください。
idGeneratorのプレフィックス(task_、cmt_、brf_)が役立つのは、URL のセグメント、API 引数、ツール呼び出しのペイロード、サポートチケットなど、人が読む場所に ID が現れる場合です。親単位で一括取得されるだけの子テーブルでは、task_dependencies/task_documents/task_topicsのように、通常の UUID を使ってください。プレフィックスはidGenerator.tsに登録項目を増やすだけで、何の利点もありません。// ✅ Addressed individually — deleteComment(commentId), /task/:id id: text('id') .primaryKey() .$defaultFn(() => idGenerator('taskComments')) .notNull(), // ✅ Only ever read in bulk for one parent id: uuid('id').defaultRandom().primaryKey().notNull(), -
列の順序:識別情報 → スコープ → テーブル固有の列 →
visibility→ タイムスタンプ。 まずidと親の FK を置き、次に所有権の列(user_id、workspace_id)、続いてこのテーブル固有の列、次にvisibility、最後に...timestamps/createdAt()の展開を置いてください。こうすれば、見慣れないテーブルを確認するときも、同じフィールドが常に同じ位置にあります。visibilityを末尾に置くのは意図的な配置であり、歴史的な偶然ではありません。現在この列を持つ 22 個のテーブルのうち 17 個では、末尾から 3 列以内に置かれており、idの直後に置いているテーブルはありません。新しいテーブルでこれを先頭に持ってくると、唯一の例外になります。配置を変更する価値がある場合は、テーブルごとではなくリポジトリ全体で変更し、このルールも更新してください。
ロールアウト戦略を選ぶ
マイグレーションを生成または編集する前に、すべてのデータベース変更を次の 3 つのロールアウト経路のいずれかに分類してください。
実際の Dev データベースでロールアウトの前提を検証する
「このマイグレーションは遅いかもしれない」「これらのトリガーをインストールするとデプロイがブロックされる可能性がある」といった仮定上の主張をもとに、ロールアウト方法を決めないでください。スキーマ変更に手動の本番手順、インストールの延期、または専用のバックフィルが必要だと判断する前に、プロジェクトの実際の Dev データベースで該当する操作をテストしてください。
- プロジェクトで承認されているデータベースアクセスツールを使って、まずデータベースの対象を分類してください。シークレットを含む
.envファイルを直接読み取らないでください。 - 実際の操作、または一時的な名前で同一の定義のプローブインデックスを作成する、あるいはロールバックするトランザクション内で一時的なトリガーをインストールするなど、最も近い安全な同等操作を測定してください。
- テストした SQL または操作、代表的な行数とテーブルサイズ、所要時間、およびクリーンアップの検証結果を記録してください。
- プローブは取り消し可能なものにし、測定後はすべての一時的なデータベースオブジェクトを削除してください。
- Dev での単一の測定結果は、観測した Dev の規模に関する証拠として扱い、本番環境での挙動の証明とはみなさないでください。本番環境における規模、負荷、キャッシュ状態、ロック競合の重要な違いを明示し、本番環境についての結論は推論であると記してください。
ロールアウトの判断には、リポジトリのデプロイに関する事実とこれらの測定結果の両方を反映してください。測定していないパフォーマンス上の懸念だけを理由に、運用テーブル、遅延有効化の経路、または手動リリース手順を追加しないでください。
1. 通常の Drizzle マイグレーション
小規模なテーブルの作成や NULL 許容列の追加など、デプロイ中に安全に実行できるスキーマ変更には、通常の Drizzle ワークフローを使用してください。
- Drizzle スキーマを更新します。
bun run db:generateを実行します。- 以下の手順に従って、生成された成果物を確認し、必要に応じて強化します。
手動のマイグレーションコマンドをリリース手順として記載する前に、対象リポジトリのビルドおよびデプロイスクリプトを確認してください。デプロイパイプラインですでにマイグレーションが自動的に適用される場合、重複する手動実行を必須にしないでください。
2. オンラインでのインデックス作成
通常の方法でインデックスを作成すると、大規模なテーブルや頻繁にアクセスされるテーブルでは書き込みやクエリがブロックされる可能性があり、実行時間の長いステートメントによってデプロイが停止することもあります。その場合は、次の手順に従ってください。
-
アプリケーションをデプロイする前に、対象データベースの SQL エディターで
CONCURRENTLYを使ってインデックス作成を手動で実行します。CREATE INDEX CONCURRENTLY IF NOT EXISTS "table_column_idx" ON "table" USING btree ("column"); -
冪等性のある、
CONCURRENTLYを使わないバージョンを Drizzle マイグレーションに残します。CREATE INDEX IF NOT EXISTS "table_column_idx" ON "table" USING btree ("column");
この手動のオンライン操作により、本番トラフィックのブロックを回避できます。その後、デプロイでマイグレーションが実行されると、IF NOT EXISTS によってステートメントは何も行わず、新規またはセルフホスト可能なデータベースも通常のマイグレーションの再実行によって最終的に同じ状態になります。CREATE INDEX CONCURRENTLY をトランザクション内に配置しないでください。
3. データのバックフィル
バックフィルと過去データの整合性調整は、Drizzle マイグレーション内ではなく、専用の冪等性のあるスクリプトとして実行する必要があります。スキーマ変更は Drizzle に残し、デプロイをブロックしないよう、行単位またはバッチ単位のデータ処理を別のスクリプトに移してください。
互換性に基づいて、スクリプトをアプリケーションのデプロイ前とデプロイ後のどちらに実行するかを決めてください。
- 新しいコードまたは新しい制約によって既存の行にただちに値を設定する必要がある場合は、デプロイ前に実行します。
- アプリケーションが古い形式と新しい形式のどちらの行も安全に処理でき、バックフィルを段階的に完了できる場合は、デプロイ後に実行します。
バックフィルスクリプトは、再開可能で、再試行しても安全であり、上限を設けたバッチで処理でき、状況を観測可能なものにしてください。任意のクリーンアップや即時の整合性調整は、リリースを妨げる必須要件ではなく、任意の作業として扱ってください。
開発段階でのスキーマ変更
機能開発中は、スキーマが頻繁に変わります。マイグレーションがまだリリースされていない段階でスキーマが変わった場合、新しいスキーマの形に合わせるために既存のマイグレーション SQL を手作業で編集しないでください。このブランチで追加したドラフトのマイグレーション成果物(SQL ファイル、対応するスナップショット、対応するジャーナルエントリー)を削除し、ジェネレーターを再実行して、以下の通常のマイグレビュー手順をもう一度行ってください。
たとえば、このブランチのドラフトマイグレーションが NNNN_add_verify_tables の場合(実際のブランチ番号に置き換えてください。リリース済みのマイグレーション番号は使用しないでください):
# 1. Delete the draft SQL and its snapshot
rm packages/database/migrations/NNNN_add_verify_tables.sql
rm packages/database/migrations/meta/NNNN_snapshot.json
# 2. Remove the matching NNNN entry from the journal's "entries" array
# packages/database/migrations/meta/_journal.json
# 3. Regenerate from the current schema
bun run db:generate
これにより、生成された SQL、スナップショット、ジャーナルが実際のスキーマと一致した状態に保たれます。手作業で SQL を編集するのは、レビュー時の堅牢化(冪等な句、カスタム拡張 SQL、適切なファイル名やタグの更新など)に限ります。
リリース前に、機能ブランチで複数の開発専用マイグレーションが追加された場合は、可能であれば 1 つのマイグレーションに統合してください。本番環境で中間段階のドラフトスキーマをすべて適用する必要はなく、マイグレーションが少ないほどデプロイ時のリスクも低減します。
たとえば、このブランチで AAAA、BBBB、CCCC の 3 つのドラフトを追加した場合は、3 つすべてを削除して、マイグレーションを 1 つ再生成します:
# 1. Delete every draft SQL and snapshot this branch added
rm packages/database/migrations/{AAAA,BBBB,CCCC}_*.sql
rm packages/database/migrations/meta/{AAAA,BBBB,CCCC}_snapshot.json
# 2. Remove those entries from the journal's "entries" array
# packages/database/migrations/meta/_journal.json
# 3. Regenerate one migration covering the full schema delta
bun run db:generate
同じブランチの以前の開発専用バージョンとの互換性を、マイグレーションに持たせないでください。マイグレーションがまだリリースされていない間は、維持すべき本番環境の履歴はありません。ローカル環境や開発環境のデータベースは、最も簡単な SQL で直接修正してください(ドラフトテーブルの削除、カラム名の変更、ドラフト行の削除など)。その後、現在のスキーマからブランチのマイグレーションを再生成してください。
たとえば、このブランチの以前のドラフトで signup_attempt_id を作成し、その後 user_signup_log_id に名前を変更した場合、マイグレーションに互換性のための ALTER ... RENAME を追加しないでください。開発用データベースを直接修正してから、再生成します:
# Fix the dev DB to match the new schema (simplest SQL wins)
set -a && source .env && set +a && bun -e '
import pg from "pg";
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
await client.query("ALTER TABLE user_signup_logs DROP COLUMN signup_attempt_id");
await client.end();
'
# Regenerate so the migration reflects only the final shape
bun run db:generate
マイグレーションが本番環境または対象のデフォルトブランチに反映された後は、変更不可として扱い、書き換えるのではなく後続のマイグレーションを追加してください。
リベースの競合
マイグレーションファイルでリベースの競合が発生した場合は、上流ブランチ/デフォルトブランチのマイグレーションを残し、現在の機能ブランチで追加したマイグレーションをすべて削除してください。リベースを完了してから、リベース後のスキーマに基づいてこのブランチのマイグレーションを再生成してください。これにより、独立した 2 つのスナップショットをマージしたり、ジャーナルエントリーを手作業でつなぎ合わせたりすることを避けられます。
ステップ 1:マイグレーションを生成する
bun run db:generate
次のファイルが生成されます:
packages/database/migrations/0046_meaningless_file_name.sql
次のファイルが更新されます:
packages/database/migrations/meta/_journal.jsondocs/development/database-schema.dbml
カスタムマイグレーション(例:CREATE EXTENSION)
Drizzle のスキーマ変更を伴わないマイグレーション(PostgreSQL 拡張機能の有効化など)には、--custom フラグを使用します:
bunx drizzle-kit generate --custom --name=enable_pg_search
空の SQL ファイルが生成され、_journal.json とスナップショットが適切に更新されます。その後、生成された SQL ファイルを編集して、カスタム SQL を追加します:
-- Custom SQL migration file, put your code below! --
CREATE EXTENSION IF NOT EXISTS pg_search;
マイグレーションファイルを手作業で作成したり、_journal.json を編集したりしないでください。ジャーナルエントリーとスナップショットを正しく生成するため、必ず drizzle-kit generate を使用してください。
ステップ 2:マイグレーション SQL のファイル名を最適化する
自動生成されたファイル名を、内容が分かる名前に変更します:
0046_meaningless_file_name.sql → 0046_user_add_avatar_column.sql
ステップ 3:冪等な句を使用する(防御的プログラミング)
マイグレーションを冪等(再実行しても安全)にするため、必ず防御的な句を使用してください:
CREATE TABLE
-- ✅ Good
CREATE TABLE IF NOT EXISTS "agent_eval_runs" (
"id" text PRIMARY KEY NOT NULL,
"name" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
-- ❌ Bad
CREATE TABLE "agent_eval_runs" (...);
ALTER TABLE - カラム
-- ✅ Good
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "avatar" text;
ALTER TABLE "posts" DROP COLUMN IF EXISTS "deprecated_field";
-- ❌ Bad
ALTER TABLE "users" ADD COLUMN "avatar" text;
ALTER TABLE - 外部キー制約
PostgreSQL には ADD CONSTRAINT IF NOT EXISTS がありません。DROP IF EXISTS と ADD を使用してください:
-- ✅ Good: Drop first, then add (idempotent)
ALTER TABLE "agent_eval_datasets" DROP CONSTRAINT IF EXISTS "agent_eval_datasets_user_id_users_id_fk";
ALTER TABLE "agent_eval_datasets" ADD CONSTRAINT "agent_eval_datasets_user_id_users_id_fk"
FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
-- ❌ Bad: Will fail if constraint already exists
ALTER TABLE "agent_eval_datasets" ADD CONSTRAINT "agent_eval_datasets_user_id_users_id_fk"
FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
DROP TABLE / INDEX
-- ✅ Good
DROP TABLE IF EXISTS "old_table";
CREATE INDEX IF NOT EXISTS "users_email_idx" ON "users" ("email");
CREATE UNIQUE INDEX IF NOT EXISTS "users_email_unique" ON "users" USING btree ("email");
-- ❌ Bad
DROP TABLE "old_table";
CREATE INDEX "users_email_idx" ON "users" ("email");
ステップ 4:ジャーナルのタグを更新する
ステップ 2 でマイグレーション SQL ファイルの名前を変更した後、packages/database/migrations/meta/_journal.json の tag フィールドを新しいファイル名(.sql 拡張子を除く)に合わせて更新してください。