Codex と Claude のレビューで浮かんだ「DB マイグレーション」まわりの観察を、勉強のためにまとめたものです。
綴Photo のモバイル版は、写真と「綴り(気持ち + テキスト)」を端末ローカルの DB に保存しています。レビューで「重複防止のインデックスが、新規インストールのユーザーには作られていない」という指摘が出た。これは「アプリを使い続けている人」と「今日入れたばかりの人」で DB の堅牢性が違う、という非対称を生む厄介なバグだった。
この記事で扱う観察ポイント
- ローカル DB の「スキーマを最初に作る処理」と「バージョンを上げる処理」が別々であること
- 重複防止のインデックスが片方にしか書かれていなかったこと
- なぜ「新規ユーザーだけ制約が弱い」という非対称が生まれるのか
UNIQUE インデックスがマイグレーションにしか無かった
コードで起きていたこと
写真の重複保存を防ぐために、「同じ写真 ID は1行だけ」という UNIQUE インデックスを作っていた。ただしこのインデックスは「特定の条件のときだけ効かせる」部分インデックス(NULL や空文字は除外する形)だったので、ライブラリの宣言的な書き方では表現しづらく、生の SQL で作っていた。
その生 SQL を書いた場所が、マイグレーション(onUpgrade)の中だけだった。
@override
MigrationStrategy get migration => MigrationStrategy(
// 新規インストールはこっち。テーブルは作るが、生 SQL のインデックスは作られない
onCreate: (m) async => m.createAll(),
// アップデートはこっち。v8→v9 の中で生 SQL のインデックスを作っている
onUpgrade: (m, from, to) async {
// ... 途中略 ...
// CREATE UNIQUE INDEX idx_photos_asset_id ON ... WHERE ...
},
);なぜそうなりやすいか
インデックスを追加したくなるのは、たいてい「アプリを運用していて重複に気づいたとき」です。その瞬間、頭の中は「既存ユーザーの DB をどう直すか(=マイグレーション)」でいっぱいになります。マイグレーションにインデックス作成を書いて、テストして、動いた。これで「やった」と思ってしまいます。
「今いるユーザーを直す」ことに気を取られて、「これから来るユーザー」が抜け落ちます。これは onCreate と onUpgrade を二重にメンテしなければならないという、この設計固有の落とし穴です。
改善の方向
「新規でもアップデートでも、必ず同じインデックスが存在する」状態を保証します。やり方はいくつかあります。
// 案: beforeOpen で毎回 idempotent に保証する(IF NOT EXISTS)
beforeOpen: (details) async {
await customStatement(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_photos_asset_id "
"ON photos(asset_id) WHERE asset_id IS NOT NULL AND asset_id != ''",
);
// 他のインデックスも同様
},または、インデックス作成を共通関数に切り出して、onCreate(createAll() の直後)と onUpgrade の両方から呼びます。IF NOT EXISTS を付けておけば、既にある人にも無害。
学びポイント
- 「既存ユーザーをどう直すか」に気を取られると「新規ユーザー」が抜けます。
IF NOT EXISTSで毎回保証する設計にすると非対称が消えます - テストで「新規 DB(onCreate 経由)にインデックスが存在する」を1本入れると、この穴を機械的に防げます
このテーマの括り
これは「同じことを2箇所に書かなければならない設計」が生む典型的なバグです。データの整合性は「全ユーザーが同じ形の DB を持っている」前提で成り立つので、この非対称はエラーを出さないまま効いてきます。
おわりに
マイグレーションを書いてアップデートで動作確認すると、その経路は直ります。ただし検証していないもう一方の経路(新規インストール)には同じ穴が残ったままになります。onCreate と onUpgrade のように入口が2つある処理は、両方の経路でテストしないと片方の穴に気づけません。
この記事は2026年5月時点の自分の理解で書いています。Drift / SQLite のマイグレーション API は変わることがあるので、最新の公式ドキュメントも合わせて参照してください。