Claude の多段レビュー(7観点の並列レビュー + 反証検証)で浮かんだ「マイグレーションが凍らない」まわりの観察を、勉強のためにまとめたものです。
綴Photo のモバイル版は、写真と「綴り」を端末ローカルの DB(Drift / SQLite)に保存している。アップグレード経路を実際に通すテスト(別記事)を入れたら、Drift(や同種の ORM)固有の罠が見えてきた。「過去に書いたマイグレーションは凍っている」という思い込みが、実は成り立たないという話だ。
コードで起きていたこと
マイグレーションの中で列を追加するとき、Drift では宣言的にこう書けます:
await m.addColumn(photos, photos.title); // 写真テーブルに title 列を足す直感的には「ここに書いた photos.title という列定義を1つ足す」だけに見える。
何が問題か。将来、写真テーブルに新しい列を足したとします。すると過去に書いた alterTable は、実行時に最新形(新しい列を含むテーブル)で作り直そうとします。古いバージョンから順に上げてくると、途中の alterTable 段階で既に最新形のテーブルができてしまい、その後ろの段にある「その新しい列を addColumn する」処理が duplicate column(列が既にある)で落ちる、という事故が起き得ます。「過去のマイグレーションは凍結されている」という思い込みが裏切られる形です。
実際このコードベースには、過去に同種の罠を踏んだ痕跡が残っていた。本来なら宣言的に addColumn で足せるはずの列を、わざわざ生 SQL で書いている箇所がある(v2→v3 の段):
// fileSize カラムは v7(v6→v7 の alterTable)で削除されるが、この段では追加が必要だったため raw SQL で対応
await customStatement('ALTER TABLE photos ADD COLUMN file_size INTEGER');この file_size 列は v7(v6→v7 の alterTable)で削除される列だ。最新のテーブル定義にはもう存在しない。だから m.addColumn(photos, photos.fileSize) と宣言的に書こうにも、最新スキーマにその列が無いので参照できない。「最新形を見る」性質のせいで、後で消す予定の列をマイグレーションで一時的に足すには、定義に頼らず生 SQL で直書きするしかなかった、というわけだ。コメントはその回避の理由を残したものだった。
もう一段、より直接的な実例がある。v6→v7 では alterTable でテーブルを再作成する処理が入っているが、この alterTable が参照する「今のテーブル定義」には、後になって v11→v12 で足した web_selected 列が含まれてしまう。つまり v5 以前から順番に上げてくると、v6→v7 の段で既に web_selected 入りのテーブルが作られてしまい、その後の v11→v12 で addColumn を呼ぼうとすると duplicate column で落ちる。これを防ぐために v11→v12 は列の存在を確認してから足す「冪等ガード」になっている:
// v11→v12: web_selected を追加するが、v6→v7 の alterTable で既に作られている可能性がある
final colCheck = await customSelect(
"SELECT 1 FROM pragma_table_info('photos') WHERE name = 'web_selected'",
).get();
if (colCheck.isEmpty) {
await m.addColumn(photos, photos.webSelected);
}この2つは症状が真逆だが、根は同じで、起点も同じ v6→v7 の alterTable だ。この段の本来の仕事は「fileSize を消す」ことだ。
「マイグレーションは凍っている」という前提で書くと、後から別の段が副作用を起こしたときにこのガードが必要になる理由に気づけません。アップグレード経路を順に踏むテストがあれば、この衝突は実行時に必ず表面化します。
なぜそうなりやすいか
「マイグレーションは過去の事実の記録だから、一度書いたら凍る」というメンタルモデルが自然すぎるからです。Git のコミットや会計の仕訳のように、過去の1段は確定して動かないものという前提で読まれやすいです。
でも Drift の宣言的 API はそう動きません。マイグレーション関数は「書いた時点」と「実行される時点」でスキーマ認識がズレます。しかもこのズレは、新規作成テストでは絶対に表面化しません。新規作成は最新形を一気に作るだけで、途中段を順に踏まないからです。アップグレード経路を実際に通すテストを入れて初めて、この種の事故を機械的に拾えるようになります。
declarative で書ける所・生 SQL が要る所
この「最新を見る」性質が分かると、宣言的 API(m.addColumn / m.createTable / m.alterTable)と生 SQL(customStatement)の使い分けが見えてきます。最新スキーマに「在る」ものは declarative でいけて、生 SQL は次の3つの逃げ場に絞られます。
- 最新スキーマに無い構造 — 一時的に足して後で消す列(
file_size)や、後で drop するテーブル。photos.fileSizeというシンボルが最新定義に無いので、そもそも宣言的に書けない。 - データの変形 — 行の統合・付け替え・移動(v8→v9 の重複解消)。スキーマ API は DDL 担当で、
UPDATE/DELETE/INSERT … SELECTのような DML は守備範囲外(単純な更新はクエリビルダでも書けるが、相関サブクエリは生 SQL が現実的)。 - Drift が表現できない SQL — 部分 UNIQUE インデックス(
CREATE UNIQUE INDEX … WHERE …)やIF NOT EXISTSの冪等性など。
効くのは、①の生 SQL が「仕方なく」ではなく正しい選択だという点です。生 SQL で書く=その段を最新スキーマから切り離して凍らせること。後で消える列を最新定義に縛りつけないための手段になります。declarative は便利だが常に最新を見る/生 SQL は冗長だが凍る——「この段は凍らせたい」ときに生 SQL を選びます。
しかもオール・オア・ナッシングではありません。さきほどの webSelected ガードは、存在確認は生 SQL(pragma_table_info)・実際の追加は declarative(m.addColumn)の混在だった。在る部分は宣言的に、表現できない部分だけ生 SQL に、と混ぜられます。
改善の方向
- 後で削除する列・最新定義に残らないものをマイグレーションで触るときは、生 SQL(
customStatement)で直書きして、最新のテーブル定義への参照を断つ。そして「なぜ宣言的に書かないのか」をコメントで残す(このコードベースはそうしていた) alterTableのようにテーブルを再作成する操作は特に慎重に。実行時の最新形で作り直されるので、後続の段との順序矛盾が起きやすい- そして何より、古いバージョンから実際に上げ切るテストを1本持つこと。順序の事故は「全段を順に踏む」ことでしか再現しない
学びポイント
- Drift の
addColumn/alterTableが参照するのは「書いた当時」ではなく「最新のテーブル定義」。マイグレーションは凍らない - 後で消す列をマイグレーションで足すような「最新定義に残らない操作」は、生 SQL で書いて参照を断つ
- 最新スキーマに「在る」ものは declarative、無いもの/データ変形/Drift が表現できない SQL は生 SQL。生 SQL はその段を最新から切り離して「凍らせる」道具でもある
- この種の順序事故は新規作成テストでは絶対に出ず、「気をつける」でも防げない。旧バージョンから上げ切るテストだけが機械的に拾える
おわりに
「過去に書いたマイグレーションは凍っている」——Git のコミットや会計の仕訳のような直感は、Drift では裏切られます。
厄介なのは、「気をつける」では防げない種類のバグだということです。後段で addColumn を書く瞬間、それは完全に正しく見える——遠く離れた v6→v7 の alterTable が未来の列を前倒しで作る、なんて手元からは見えない。しかも新規作成テストは緑のまま。「凍っている」前提が自然すぎて、疑うきっかけがありません。
だから効くのは記憶や注意力ではなく、古いバージョンから全段を順に通すテスト(別記事)です。これが duplicate column を実行時に機械的に叩き出します。気づいて初めて「ここはガードと newColumns が要る」と書けます。注意して書く→テスト、ではなく、テストが教えてくれる→正しく書ける、の順番です。
この記事は2026年6月時点の自分の理解で書いています。Drift / SQLite のマイグレーション API は変わることがあるので、最新の公式ドキュメントも合わせて参照してください。