綴Photo の勉強ノートに戻る
技術ブログ2026.08.11

テストは通っていた。写真を1枚も入れていなかったから — アプリ更新時のデータ移行(マイグレーション)

FlutterdriftSQLiteマイグレーションテスト綴Photo

AIレビューで「マイグレーションを机上で読むんじゃなく、実際に走らせて確かめて」と頼んだら、既存のテストが何も測っていなかったところから2つ出てきた、という記録です。

アプリを更新すると、端末に入っているデータベースの形も一緒に変わる。列が増えたり、表が消えたり、別の表へ引っ越したりする。この「形を変える手順」がマイグレーションで、うちのアプリでは今12段ある(最新の形が13代目)。

そこにはテストが付いていた。v5 の形で作ったデータベースを、最新の v13 まで上げて、途中で落ちないことを確かめるというもの。ずっと緑だった(テストが全部通っている状態のこと。落ちると赤で出る)。旧バージョンの形のデータベースを手で組んで、user_version を旧値にしたファイルを開き直す、というこのテストの組み方自体は「マイグレーションが一度もテストされていなかった — 旧バージョンの DB を作って『通す』テストの書き方」に書いた。

器だけ通していた

テストの中身を開いたら、photos の表が空のままだった。

マイグレーションの仕事は2つある。器(表と列)の形を変えることと、中に入っているデータを新しい器へ運ぶことだ。

どちらも同じ SQLite ファイル。prefs にもメモリにも無い。空の土台photos 0行・meanings 0行skip_status 0行user_version = 5器は v13 まで上がる。行は無い落ちないことだけが見える閉じてもこのファイルに残る実データの土台photos 1行・meanings 1行skip_status 1行user_version = 5同じ段が行に当たる運ぶ処理が間違うと中身が壊れる閉じてもこのファイルに残る関所 マイグレーション。置き場所ではない。形を変え、行があれば運ぶ

そこで、写真と綴りを実際に載せた状態を作って、v5 起点と、途中で中断した形のデータベース(v2 と v6)から v13 へ上げ直してもらった。

2つ落ちた。

「また今度」の棚に西暦55840年が生えた

うちのアプリには「また今度にする」があって、後で見返せるように月ごとの棚に並ぶ。v5 起点で上げたあと、そこに 55840-11 という棚が1つできていた。

日時の単位がずれている。

dart
// v3→v4。この表は後で捨てられる(=この DEFAULT は連鎖の最中には発火しない)
await customStatement('''
  CREATE TABLE IF NOT EXISTS skip_status (
    ...
    -- strftime('%s','now') = 現在時刻を「秒」で返す。1000倍してミリ秒のつもり
    created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000),
    updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000)
  )
''');

strftime('%s','now') は現在時刻を秒で返す。それを 1000 倍しているので、入るのはミリ秒。JavaScript や Java の感覚だとこれが自然だ。

ところが drift(Dart のオブジェクトと SQL を橋渡しするライブラリ)は、DateTime を秒で読み書きする。実際に値を入れて確かめてもらったら、1700000000000 ミリ秒を渡すと DB には 1700000000 が入っていた。

つまり、ミリ秒で書かれた数字を drift が秒として読む。1000 倍ズレるので、2023年が55840年になる。

これが表に出るのは「また今度」を開いたときだけで、存在しない年の棚が1つ増えるだけだ。例外も警告も出ない。

ただ、この 55840-11 を実際に作っていたのは、実装ではなくテストの土台データのほうだった。実装側のミリ秒は、追いかけると誰の端末にも届かない。

55840年はテストが書いた行。実装は SQLite に書かない。prefs には無い。同じ SQLite ファイルskip_status に 1700000000000v5→v6 で passed_photos へコピーskip_status は DROPテストがミリ秒のまま手で書いた閉じてもこのファイルに残る実装のミリ秒 DEFAULT行を書かない端末の SQLite には無い置き場所ではないprefs にもメモリにも無い関所 drift の DateTime。置き場所ではない。INTEGER を秒として読む55840-11 は画面メモリ。保存しない
  • v5→v6 でミリ秒が入っているのは COALESCE の4番目だが、3番目の created_at は NOT NULL で宣言されている。4番目には永久に到達しない
  • 上の DEFAULT 句が効くのは、その表に行が挿入されたときだけ。この表は空のまま作られて2つ先の段で捨てられるので、連続して流すかぎり1行も入らない
  • そもそも v1〜v4 は一度も配布していない

**間違った単位を教えていたのは、確かめるための側だった。**つまりこれはバグではなく、書き方の地雷だ。実装側に実害がないと分かるまでの追い方(直したのにテストが赤にならず、値がそこまで届いているかを数えた)は「「直す前に赤にする」を守ったら、同じ日に2回、赤にできなかった」に書いた。

怖いのはこの1件そのものより、この書き方がファイル内に5箇所あって、テストの土台データにも同じ形で入っていることのほうだった。次に日時の列を足すとき、周りを真似すれば同じ間違いが入る。しかもそのときズレるのは、写真の撮影日かもしれない。

同じ段をもう一度流すと、データベースが二度と開かない

もう1つは、user_version = 6 の状態で photos.title の列がすでに在るデータベースを開いたら、duplicate column name: title で起動そのものが止まった。バックアップのない一点物なので、その端末に溜まった綴りはそこで終わる。

メモも列も行も同じ SQLite ファイル。prefs にもメモリにも無い。同じ SQLite ファイル中断したあと(ファイルに残る)user_version = 6photos.title は既にあるphotos 1行・meanings に中断前の綴り関所 起動時。置き場所ではない。メモだけを読む列の有無は見ない。6 なら v6→v7 から流す次の起動ADD COLUMN title → duplicate column name開けない。行は残るが取り出せない以後どの起動でも同じ所で落ちる

該当箇所はこれ。

dart
Future<void> _migrateV6ToV7(Migrator m) async {
  await m.addColumn(photos, photos.title);   // 存在確認なしで ALTER TABLE ADD COLUMN を打つ = 2回目は落ちる
  ...

Migrator.addColumn は素直に ALTER TABLE ... ADD COLUMN を発行する。SQLite はすでに在る列を足そうとするとエラーを返すので、同じ段が2回流れると落ちる。

「2回流れることなんてあるのか」と言われると、ある。段の途中で電源が落ちたり、書き込みに失敗したりして、列は足されたが記録上のバージョンは上がっていない状態が作れる。次の起動でそこからやり直そうとして、ぶつかる。

同じファイルの後ろのほうでは、ちゃんと防いであった。

dart
Future<void> _migrateV11ToV12(Migrator m) async {
  // 列があるか先に聞いてから足す
  final colCheck = await customSelect(
    // pragma_table_info = その表の列一覧。web_selected という名前の列を1行だけ探す
    "SELECT 1 FROM pragma_table_info('photos') WHERE name = 'web_selected'",
  ).get();
  if (colCheck.isEmpty) {   // 0行 = まだ無い。このときだけ足す
    await m.addColumn(photos, photos.webSelected);
  }
}

m.addColumn を呼んでいる箇所は4つあって、守られていたのは後ろの2つだけだった。生の SQL で ALTER TABLE ... ADD COLUMN を書いている箇所を足すと、無防備な側は3つになる。

同じ duplicate column name は別の経路でも出る。過去の段が最新のテーブル定義を見にいくせいで、後の段で足すはずの列が前倒しで生えてしまうほうで、そちらは「Drift のマイグレーションは「凍らない」— addColumn / alterTable が見るのは最新のテーブル定義」に書いた。

同じファイルの先頭に、両方書いてあった

いちばん効いたのはここだ。_runMigrations の上に、こういうコメントが置いてある。

dart
/// 新しい段を足す時のチェックリスト:
/// - CREATE TABLE / CREATE INDEX は `IF NOT EXISTS` を付ける
/// - ALTER TABLE ADD COLUMN は pragma_table_info で存在確認してから
///   (`Migrator.addColumn` は素の ADD COLUMN =再生で duplicate column name になる)
/// - UPDATE は「まだ変換していない行」だけに当たる WHERE を付ける
/// - DROP は `IF EXISTS`。DROP した表を後段が参照しない

2つめの落とし穴は、この文章が名指しで禁じているものだった。しかも「再生すると duplicate column name になる」という結果まで書いてある。それでも守られていない箇所が残っている。

ただ、履歴を見ると誰も破ってはいなかった。**このチェックリストが書かれたのは、該当する箇所がぜんぶ出来たあとだ。**無防備な段は5か月前、守られている段はその3か月後、注意書きはさらにその後に足されている。書いた時点で「同じ理由が当てはまる箇所は他にいくつあるか」を数えていないので、古いほうはそのまま残った。

日時の単位のほうは、このチェックリストに書かれていない。だから書き足せばいい、とは思わなかった。項目を足しても効くのはこれから書く段だけで、注意書きは前へは進むが、後ろへは戻らない。

やったのは、置き場所を変えるほうだった。列を足す処理を存在確認つきのヘルパー1本に寄せて、そこを通らないと列を足せない形にした(素の呼び出しが混ざっていないかを機械で見るテストも一緒に置いた)。生の SQL から * 1000 を落とし、テストの土台データも同じ単位に直した。土台には写真と綴りを実際に載せた。

土台に実データを載せたのが本体だと思っている。実際、直したコードを一度外して流し直したら duplicate column name で赤になった。空の土台では最後まで緑だったものだ。

学び

今回のテストは「v5 から v13 へ上げても落ちない」を確かに測っていて、その主張は正しかった。ただ、確かめたかったのは「入っているデータが無傷で v13 に着く」のほうで、そこは1文字も測っていない。名前も緑も嘘はついていなくて、測っている範囲が違っただけだった。緑を見た時点で、それが何を測っている緑なのかを一度言葉にしていれば、この差には気づけた。

この記事は2026年8月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。