「また今度」した写真の識別を1列から2列に分ける作業の記録。制約を1個外したら、5バージョン前のマイグレーションが静かに壊れていた。テストは全部緑だった。
綴Photo には「今は言葉が浮かばない写真を先送りする」機能があります。先送りした写真は DB に1行ずつ記録していて、その識別を assetId という1つのカラムだけで持っていました。今回はそれを2カラムに分ける作業をした。最後に思わぬ所が壊れていたのに気づいたので、その顛末を残す。
1列に2つの意味を持たせていた
先送り写真の識別カラムには、実は種類の違う2つの値が入っていました。
- スマホ版では、カメラロールの写真を指す ID
- PC版では、フォルダ内のファイルのパス
同じ assetId という1列に、ID とパスという別物が混ざっていました。呼び出す側は「今これは ID なのかパスなのか」を暗黙に覚えておく必要があります。写真本体を持つ別のテーブルはとっくに assetId / localPath の2列に分けてあったのに、先送りテーブルだけがこの古い1列のまま取り残されていました。
種類が違うものを1列に詰めるのは、いつか取り違えます。分けることにした。
分け方とスコープを決める
分け方は、写真本体テーブルと同じ assetId / localPath の2列に揃える形にした。新しい流儀(種別カラム+値カラム、みたいな)を持ち込むと、同じ「写真の識別」に2つのやり方が並んで、それはそれで不整合になります。既にある形に合わせるのが一番おさまりがいいです。
ついでに、写真本体テーブル側で見つけた別の小さい穴も一緒に塞ぐと決めた。詳細は省くが「新規インストールの端末にだけ、ある index が張られていない」という状態で、隣を触るついでなら低コストで揃えられる、という判断だった。
移行で踏んだ小さい罠 2つ
本題の前に、移行で引っかかった2つ。どちらもすぐ抜けられた。
1つ目。スマホの写真 ID は、iOS では …/L0/001 のようにスラッシュを含みます。だから「スラッシュがあればパス」みたいな文字列での見分けは効きません。幸い1台の端末はスマホかPCのどちらか一方なので、移行時はファイルの値を覗くのではなく、動いている OS で「この端末のデータは全部 ID/全部パス」と確定させた。OS 判定を直接書くとテストがホスト依存になるので、テストから差し替えられる入口だけ用意しておいた。
2つ目。Drift(使っている DB ライブラリ)でテーブルを作り直すとき、新しく足した localPath カラムを古いテーブルからコピーしようとして「そんなカラムは無い」で止まった。「これは新規カラムだから古い方からは持ってこない」と明示する指定が要る、というだけの話。
// 新規カラムは newColumns に挙げて「旧テーブルからコピーしない」と教える
await m.alterTable(TableMigration(
passedPhotos,
newColumns: [passedPhotos.localPath],
));本題 — 制約を1個外したら、遠くが壊れた
今回、先送りテーブルの assetId から UNIQUE 制約(重複を許さない指定)を外した。2列に分けるのに伴って、一意性の担保を別のやり方に移したからだ。完全にローカルな変更のつもりだった。先送りテーブルの定義をいじっただけ。
ところが。5バージョン前のマイグレーション(このテーブルを最初に作った所)が、こういう SQL を実行していた。
-- 5バージョン前。先送りテーブルへ初期データを移していた所
INSERT INTO passed_photos (...)
SELECT ... FROM skip_status
ON CONFLICT(asset_id) DO NOTHING -- ← asset_id に UNIQUE 制約があることが前提。制約がないとここでエラーON CONFLICT(asset_id) は、「asset_id がぶつかったら何もしない」という重複回避の書き方で、これは asset_id に対応する UNIQUE 制約が存在しないと成立しません。SQLite は制約が無いと「その ON CONFLICT に合う制約が無い」とエラーを投げます。
ここで効いてくるのが、さっきの Callout の話。DB を作り直すとき、Drift は「今のテーブル定義」から CREATE TABLE を組みます。つまり、5バージョン前のマイグレーションが走る瞬間でも、作られるテーブルは今回 UNIQUE を外した最新の定義になります。制約の無いテーブルに、制約を前提にした ON CONFLICT が走ります。
図にすると、下でやった変更が上の手順に回り込んでいる。
結果:古いバージョンで止まっていた端末がアップデートすると、起動時のマイグレーションでこの行が例外を吐いて、アプリが立ち上がらなくなります。しかも ON CONFLICT は対象データが0件でも、文を準備した時点で「合う制約が無い」と弾かれます。データの有無に関係なく、必ず落ちます。
そして — テストは全部緑だった。理由はあとで分かった。マイグレーションのテストは v8 から始めていて、問題の v5→v6 を一度も通っていなかった。緑は「テストが踏んだ範囲が緑」でしかなくて、踏んでいない古い経路は丸ごと死角だった。
テストのカバー範囲が「最近のバージョンから」になりやすいのには構造的な理由があります。マイグレーションを書いた時点では古い経路は全部通っていて、新しいバージョンを足すたびに「最新のテスト」を書き足していきます。すると自然に「今動かしているバージョン近辺のテスト」が厚くなり、初期バージョンの手順は「一度書いたから大丈夫」と暗黙に前提になっていきます。制約を外したような横断的な変更は、この「昔の経路は触っていない」という前提を静かに崩します。
これを見つけたのは、実装が一通り終わった後に、わざと粗探しさせる多視点のレビューに通したから。マイグレーション・制約・DBへの読み書き役(DAO)・呼び出し側を、それぞれ別の観点で「データが壊れる所はないか」と探させた。そのうちの一つが、この5バージョン前の ON CONFLICT を掘り当てた。
実装した当人は、意識が「今いじった先送りテーブルの周り」に張り付いていて、5バージョン前のマイグレーションが今回の変更の巻き添えになる、という線はそもそも視野に入りません。直し自体は、制約に依存しない書き方(重複を別の条件で弾く)に置き換えるだけ。
-- 制約に依存しない形へ。重複は WHERE NOT EXISTS で弾く
INSERT INTO passed_photos (...)
SELECT ... FROM skip_status ss
WHERE NOT EXISTS (
SELECT 1 FROM passed_photos pp WHERE pp.asset_id = ss.asset_id
)つまり ON CONFLICT が「入れてみて、ぶつかったら DB に握り潰してもらう」だったのに対して、NOT EXISTS は「入れる前に自分で見て、ぶつかる行はそもそも選ばない」。例外を捕まえるのをやめて、例外が起きない形にした、ということだ。
ただしどこでも同じ置き換えができるわけではありません。本来は ON CONFLICT の方が堅いです。あちらは「見る」と「入れる」が DB の中で1動作なので間に誰も割り込めませんが、NOT EXISTS は2動作なので、その隙に誰かが同じ行を入れると重複が通ります。今回それで問題にならなかったのは、条件が2つ揃っていたからだった。
- マイグレーション中は他に書き込む人がいません — DB を開いた瞬間に単一接続で走るので、割り込む隙が存在しません
- 移行元にも重複がありません —
skip_statusのasset_id側には UNIQUE が付いたままなので、元データに同じ ID は1件しかありません
置き換えが等価になったのは、マイグレーションという場所の性質に乗っかった結果で、アプリが動いている最中のコード(複数箇所から同時に書く)で同じことをやると、今度は競合でバグります。
直したあと、v5 から通すテストを足して、修正前のコードに戻すと確かにその ON CONFLICT で落ちることも見てから、直した形に戻した。「テストが本当にそのバグを踏んでいる」を確認してから直す、という順にした。
学びポイント
- 制約(UNIQUE 等)を外すのは、局所的に見えて遠くを静かに壊します。 その制約に寄りかかっていた離れたコードが、何も言わずに前提を失います。今回は5バージョン前のマイグレーションだった。
ON CONFLICTで全文検索すれば一発ですが、触った箇所の周辺しか探さないと、そもそも調べようとも思いません。 - テストが「最近のバージョンから」しか始まっていないと、古い経路は死角になります。 全部緑でも、それは踏んだ範囲の緑。今回マイグレーションのテストは v8 起点で、v5→v6 は誰も通っていなかった。
- 実装した本人は、直した箇所にアンカーされて遠くが見えなくなります。 文脈をリセットした別の目(今回は観点を分けた敵対的レビュー)を実装後に一回通すのは、この盲点を埋めるのに効いた。直す手は数行でも、見つける所が本体だった。
おわりに
やったこと自体は「1列を2列に分けて、移行手順を書いて、型で取り違えを防ぐ」で、地味な整理作業のはずだった。一番危なかったのは、その整理のために外した制約が、5バージョン前の自分(が書いたわけではないが、過去のコード)の前提を崩していた所。緑のテストは安心の証明にはならない、というのを具体的な形でもう一度確認した回だった。
この記事は2026年6月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。