run-migration について
オフラインファーストアプリの Drift/SQLite 向けに、前進のみのスキーマ移行手順を定めるスキルです。移行前スナップショット、追記のみの移行ステップ、データ内容を検証するテストで、更新時のローカルデータを保護します。
主な機能
- 01移行前のデータベーススナップショット
- 02追記のみの前進型マイグレーション
- 03バージョン管理されたスキーマスナップショット
- 04全アップグレード経路のテスト
- 05データ・整合性・復元の検証
活用シーン
- 01Drift のテーブルや列を安全に追加
- 02アプリの複数バージョンを飛ばす更新のテスト
- 03データ保持とスナップショット復元の検証
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill run-migrationインストール後、Claude Code で/run-migrationを入力して読み込まれたか確認します。
詳細
マイグレーションの実行(前方のみの Drift スキーママイグレーション)
以下の厳密な順序の手順に従って、ローカルの Drift/SQLite データベースにスキーマ変更を適用します。オフラインファーストのアプリにはサーバーも再同期の手段もないため、デバイス上の DB が唯一の信頼できる情報源です。列を削除または破損するマイグレーションを行うと、ほかに保存されていない手入力のレコードが永久に失われます。テレメトリーがなければ、誰もその問題を報告せず、ユーザーはただアプリをアンインストールします。これは手動で、自由度が低く、人が実行するワークフローです。手順を順番どおりに実行してください。独自の判断で変更、並べ替え、スキップしないでください。
絶対に守るべきルール
- マイグレーション前のスナップショットを最初に取得します。データベースを開く前に行ってください。
AppDatabaseを開くコンポジション境界で実行し、onUpgradeの中では決して実行しないでください。ライブハンドルに対してPRAGMA wal_checkpoint(TRUNCATE)を実行して、-walの内容をメインファイルに反映します。その後、ファイルを開いているものが何もない状態で、DB ファイルと-wal/-shmのサイドカーファイルをコピーします。データベースを開くとマイグレーションが開始されます。ライブ接続中にバイト列をコピーしたり、上書きしたりしないでください。SQLite で可能な唯一の「ロールバック」はこのスナップショットです。失敗した場合は必ず復元してください。 - 前方のみです。ダウンマイグレーションは絶対に作成しないでください。 SQLite に真のダウンマイグレーションはないため、あるかのように扱ってはいけません。「ロールバック」とはスナップショットを復元することだけを意味します。
- 手順は追記のみです。リリース済みの手順は絶対に編集しないでください。 リリース済みの手順を編集すると、バージョンを飛ばしたすべてのユーザーのデータが破損します。過去の手順のバグを修正する場合は、新しい手順を追加して
schemaVersionを増やしてください。履歴をさかのぼって編集してはいけません。 - リリースするスキーマ変更ごとに
schemaVersionを必ず 1 だけ増やし、対応するfromNToM分岐を追加します。スキーマを変更してもバージョンを増やさない場合、ユーザーのデバイスではマイグレーションがまったく実行されません。CI ゲートで検出しなければなりません。 - バージョン管理されたスキーマスナップショットをコミットしてください。
drift_dev make-migrations(またはschema dump)を実行し、エクスポートしたスナップショットをdrift_schemas/にコミットして、schema_versions.dartもコミットします。差分をレビュー可能な形にし、テストスイートで稼働中のスキーマとの差分を確認します。 - データベースを開く処理は、接続の完全な外側で try/復元処理でラップします。 データベースを開く際に例外が発生した場合、マイグレーションは失敗しており、その接続は使用できません。接続を閉じ、オープン前に取得したスナップショットファイルを復元してから、例外を再送出します。ファイル操作はすべて、DB を開いているものが何もない状態で行ってください。
onUpgradeの中からライブファイルを上書きしてはいけません。 - スキーマ形状チェックの成功は必要ですが、それだけでは不十分です。
migrateAndValidateが比較するのはCREATE文のみで、行の読み取りは行いません。テーブルを完全に再構築しても、行を 1 件もコピーしないマイグレーションはチェックを通過して緑になります。成果物はコンテンツテストであり、スキーマ形状テストではありません。 - テストスイート全体が成功するまで、マイグレーションをマージしてはいけません。 すべての
from → to経路(複数バージョンをまたぐ場合も含む)、新しいペアごとのコンテンツテスト、PRAGMA integrity_check == ok、結果が空のPRAGMA foreign_key_check、そしてスナップショットが復元されることを確認する、マイグレーション途中で例外を強制するテストを含めてください。 - 新しい列には正規化された値のみを保存してください(金額には ISO-4217 の指数に基づく最小通貨単位の整数、数量には SI の整数、時点には UTC のエポックミリ秒、暦日にはローカルのシリアル日整数)。浮動小数点の金額、表示用文字列、ローカライズされた数字を格納する列は追加しないでください。
順序付きワークフロー
データベースを所有するパッケージからコマンドを実行します(単一パッケージのアプリではアプリのルートから実行します。末尾の複数パッケージに関する注記を参照してください)。
- 変更を設計します。データレイヤーの規約(
persistence-driftを参照)に従い、テーブルクラスを編集します。 - データベースクラスの
schemaVersionを1つ上げます。 - スナップショットとステップのスタブを生成し、生成されたスナップショットをコミットします。
dart run drift_dev make-migrationsmake-migrationsが動作すると確認されていない固定バージョンでは、代わりに3つのコマンドを明示的に実行します(下記の「スナップショットコマンド」を参照してください)。 - 追加専用のフォワードステップを記述します。新しい
fromNToMブランチを1つ追加します。既存のブランチは変更しないでください。このバージョンで追加するオブジェクトだけを作成し、後のバージョンで追加される列は参照しないでください。 - コード生成を再実行して、生成されたデータベースに新しいバージョンを反映します(
run-codegenスキルを呼び出します)。dart run build_runner build --delete-conflicting-outputs - マイグレーションテストスイートを実行します(「検証」を参照してください)。すべてのパス、コンテンツテスト、両方のPRAGMA、強制例外発生時の復元がすべて成功する必要があります。
- 解析、フォーマット、コミットを行います。テーブルの変更、更新した
schemaVersion、新しいステップ、コミット済みのスナップショット、テストをまとめて1つの変更としてコミットします。
マイグレーションガード
スナップショット/復元はコンポジション境界に置き、開いている接続の完全に外側で実行します。onUpgradeはステップの実行と外部キーのチェックだけを行います。この正確な構成を維持してください。
// ── composition boundary (app_database_opener.dart) ─────────────────────────
// Snapshot BEFORE the open, restore AFTER a failed open — no connection is ever
// live while the bytes are copied. Opening is what triggers onUpgrade.
Future<AppDatabase> openMigratedDatabase(File dbFile) async {
// 1. Checkpoint any prior handle, then copy dbFile + its -wal/-shm sidecars.
final snapshot = await _snapshotDbFiles(dbFile);
final db = AppDatabase(NativeDatabase(dbFile));
try {
// 2. Force the migration to run NOW, while the file fallback still stands.
await db.customStatement('PRAGMA user_version;');
return db;
} catch (_) {
// 3. The failed connection is dead — close it, THEN restore with nothing open.
await db.close();
await _restoreDbFiles(snapshot);
rethrow;
}
}
// ── app_database.dart ───────────────────────────────────────────────────────
@override
int get schemaVersion => 3; // bump by exactly ONE per shipped schema change
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) => m.createAll(),
onUpgrade: (m, from, to) async {
// FKs OFF here — BEFORE the migration's own transaction opens, because
// the pragma is ignored once a transaction is running.
await customStatement('PRAGMA foreign_keys = OFF;');
// Forward-only, append-only stepwise migration in one transaction.
await transaction(() => _steps(m, from, to));
// Prove the body left no dangling references — in production, not tests.
final orphans = await customSelect('PRAGMA foreign_key_check').get();
if (orphans.isNotEmpty) {
throw StateError('foreign_key_check found ${orphans.length} orphans');
}
},
// Pragmas are per-connection, NOT persisted — re-assert on every open.
beforeOpen: (details) async {
await customStatement('PRAGMA foreign_keys = ON;');
},
);
final _steps = stepByStep(
from1To2: (m, schema) async { /* shipped — DO NOT EDIT */ },
from2To3: (m, schema) async {
// Create EXACTLY the objects this version adds.
await m.addColumn(schema.notes, schema.notes.archivedAtUtc);
},
);
スナップショットコマンド(明示的な形式)
make-migrationsを使わない場合は、schemaVersionを上げるたびに3つすべてを再実行し、結果をコミットしてください。
# 1. Export the schema for the CURRENT schemaVersion.
dart run drift_dev schema dump lib/data/app_database.dart drift_schemas/
# 2. Era-correct classes. --data-classes --companions is LOAD-BEARING: it emits
# DatabaseAtV1 / DatabaseAtV2 with era-correct data classes and companions,
# the only mechanism that lets a test write rows in the v1 world and read
# them back in the v2 world. Without it only shape tests are possible.
dart run drift_dev schema generate --data-classes --companions \
drift_schemas/ test/drift/generated/
# 3. Step-by-step helpers — each callback gets a schema frozen at its version.
dart run drift_dev schema steps drift_schemas/ lib/data/schema_versions.dart
検証(ブロッキング — コミット前に必ず成功させること)
テストスイートでは、以下のすべてを証明する必要があります。
スキップを含む、すべてのfrom→toパス。 更新を無視していたユーザーは、v1 → v3へ直接アップグレードします。これは誰も実行したことのないコードパスです。ネストしたループですべての組み合わせを網羅すれば、今後バージョンを上げてもスキップパスを忘れることはありません。
group('schema shape', () {
for (var from = 1; from < kLatestSchemaVersion; from++) {
for (var to = from + 1; to <= kLatestSchemaVersion; to++) {
test('v$from -> v$to', () async {
final connection = await verifier.startAt(from);
final db = AppDatabase(connection);
await verifier.migrateAndValidate(db, to); // shape only — necessary, not sufficient
await db.close();
});
}
}
});
コンテンツテスト — 重要なのはこれです。 時代に合ったクラスを使ってv(n)で行を書き込み、実際のマイグレーションを実行し、v(n+1)で読み戻して、行が残っていて、正しい状態で、元の場所にあることをアサートします。columnTransformerや手書きのINSERT ... SELECTによって値が壊れる箇所に、アポストロフィ、非ASCII文字、全角ダッシュ、絵文字、引用符、バックスラッシュ、空白文字だけの値を含む意地悪なフィクスチャを使ってください。'test1'はどんな処理でもそのまま残り、何も証明しません。すべてのexpectにreason:を指定してください。クラッシュレポートが届かないとき、失敗メッセージがデバッグ作業のすべてになります。
test('v2 -> v3 preserves every note, byte for byte', () async {
final schema = await verifier.schemaAt(2);
// Write real data with v2-era classes. newConnection() over the SAME bytes is
// what lets different-era database objects see one database.
final old = v2.DatabaseAtV2(schema.newConnection());
await old.into(old.notes).insert(
v2.NotesCompanion.insert(title: 'Blank-ish', body: const Value(' ')),
);
await old.close();
// Run the REAL migration, then prove integrity — FKs are off during migration
// and SQLite reports a dangling reference to nobody.
final db = AppDatabase(schema.newConnection());
await verifier.migrateAndValidate(db, 3);
expect(await db.customSelect('PRAGMA integrity_check').get(),
[predicate((QueryRow r) => r.data.values.first == 'ok')]);
expect(await db.customSelect('PRAGMA foreign_key_check').get(), isEmpty,
reason: 'migration left dangling references — a lost record');
await db.close();
// Read back with v3-era classes and assert CONTENT, not just shape.
final now = v3.DatabaseAtV3(schema.newConnection());
final rows = await now.select(now.notes).get();
expect(rows, hasLength(1), reason: 'a row disappeared during migration');
expect(rows.single.body, ' ', reason: 'whitespace-only body must survive intact');
await now.close();
});
マイグレーション途中で強制的に例外を発生させ、スナップショットが復元されること。 ステップが途中で例外を投げるようにし、境界オープナーを通してデータベースを開いてマイグレーションを実行します。オープン処理が例外を再送出することと、オープン前のスナップショットが復元されることをアサートしてください。つまり、以前のスキーマと行がバイト単位で同一の状態に戻ることを確認します。復元パスは最後の防衛線であり、本番環境では、まさにそのときまで一度も実行されないものです。
アナライザーが問題なしと判定しただけで、マイグレーションを完了扱いにしてはいけません。コンテンツテスト、両方のPRAGMA、全パスのループ、スナップショット復元テストがすべて成功して初めて安全です。
ひそかに失敗するマイグレーションの仕組み
PRAGMA foreign_keysはトランザクションが開始されると無視されます。ステップを囲むtransaction()の前、onUpgradeの先頭で外部キーをOFFにし、接続ごとの設定でファイルには保存されないため、必ずbeforeOpenで再び有効にしてください。- テスト内だけでなく、マイグレーション本体の実行後に
PRAGMA foreign_key_checkを実行してください。 flutter testは通常のDart VMで実行されるため、sqlite3_flutter_libsは何もしません。macOSではシステムのlibsqlite3にフォールバックするため(実際にバージョン差異が生じるリスクがあります)、Linuxでは実行前にlibsqlite3-devをインストールする必要があります。
アンチパターン
- リリース済みのステップを編集する — 既存のインストール環境の履歴を書き換えてしまいます。バージョンを飛ばして更新するユーザーが、編集後のステップを古いデータに対して実行し、データを失います。
- ダウンマイグレーションを書く — SQLite にはありません。唯一のロールバック方法は、スナップショットを復元することです。
schemaVersionを増やさずにスキーマを変更する — ユーザーのデバイスではマイグレーションが実行されず、スキーマとコードが気づかないうちに食い違います。migrateAndValidateだけで完了とみなす — これは行を一切読み込まない形状比較です。何もコピーしないマイグレーションでも、問題なく通過します。INSERT ... SELECT/columnTransformerを'test1'のフィクスチャだけで検証する — 文字化けはアポストロフィ、絵文字、バックスラッシュで発生します。- 古いステップで未追加の列を
select/updateする — 書き込みクエリは、そのステップのバージョンで固定されたスキーマに対してのみ実行してください。 - プラグマがファイルに永続化されると思い込む —
foreign_keys、synchronous、暗号化キーはすべて接続ごとに設定されます。接続を開くたびに再設定してください。 - リリースビルドから
eraseDatabaseOnSchemaChangeに到達できる状態にする — これは DEBUG 専用の便宜機能です。リリース版に含めると、バージョンを上げるたびに実際のユーザーデータが消去されます。 - すべての組み合わせを対象にしたループではなく、マイグレーションテストを大きなテスト1つとして実行する — 次のバージョン更新で忘れられるのは、こうしたスキップ経路です。
完了の定義
-
schemaVersionを正確に1つ増やし、対応するfromNToMステップを追記のみで追加する。リリース済みのステップは一切編集せず、ダウンマイグレーションも追加しない。 -
make-migrations(または3つのschemaコマンドを個別に)再実行し、コミットするスナップショットとschema_versions.dartを同じ変更に含める。 - データベースを開く前にマイグレーション前のスナップショットを取得する。オープン処理を try/復元して再スローする処理で囲み、ファイル操作はすべて接続が開いていない状態で行う。
-
--delete-conflicting-outputsを指定してコード生成を再実行する。 - 形状テストのループで、
kLatestSchemaVersionまでのすべての組み合わせを対象にする。 - 新しい組み合わせのコンテンツテストを追加し、厄介なフィクスチャを使う。すべての
expectにreason:を指定し、リストのインデックスではなくキーごとにアサーションする。 - マイグレーション後に
PRAGMA integrity_check == okとなり、PRAGMA foreign_key_checkの結果が空であることを確認する。マイグレーションのトランザクション開始前に FK を無効にし、beforeOpenで再度有効にする。 - マイグレーションの途中で強制的に例外を発生させた場合にスナップショットが復元され、以前のスキーマと行が維持される。
-
eraseDatabaseOnSchemaChangeが DEBUG 専用であり、リリース時には到達不能である。 - アナライザーとフォーマッターのチェックが通り、テーブル変更、バージョン更新、ステップ、スナップショット、テストをまとめてコミットする。
複数パッケージの場合(ワークスペース)
Drift が Dart pub ワークスペース内のデータパッケージに限定されている場合、すべてのコマンドをそのパッケージから実行します(make-migrations はワークスペースのルートからでも実行できます)。drift_schemas/ と schema_versions.dart はそのパッケージ内に置き、マイグレーションスイートもそこで実行します。単一パッケージのアプリでは、これらはすべてアプリのルートに置きます。こうした手順にワークスペースは必要ありません。
関連スキル
- スキーマ変更で遵守すべきデータレイヤーの規約(STRICT テーブル、スキーマレベルの CHECK/FK、WAL/プラグマ、DAO トランザクション)については、
persistence-driftを参照してください。 - 手順5で実行する決定論的な
build_runnerパスについては、run-codegenを参照してください。 - このワークフローを補完する、データを失わないための保証(トランザクション、自動保存の下書き、ソフト削除/Undo)については、
error-handling-typed-resultsを参照してください。 - コンテンツテストと形状テストが従う、実際のインメモリ DB を使ったテストの規律については、
testing-strategyを参照してください。 - スキーマが変更されたのにスナップショットがコミットされていない場合にビルドを失敗させる、スキーマの最新性チェックについては、
ci-pipeline-and-gatesを参照してください。
References
- Drift — Migrations: https://drift.simonbinder.eu/Migrations/
- Drift — Migration test API (
SchemaVerifier,make-migrations): https://drift.simonbinder.eu/Migrations/tests/ - Drift —
stepByStep: https://drift.simonbinder.eu/Migrations/step_by_step/ - SQLite —
PRAGMA integrity_check/foreign_key_check: https://sqlite.org/pragma.html - SQLite — Write-Ahead Logging: https://sqlite.org/wal.html