予定も、お得も、ひとつのカレンダーに

個人開発しているカレンダーアプリで、あるユーザーの予定が丸ごと消えた。原因を追って全コードを点検したところ、データを失う可能性のある箇所が8つ見つかった。そして驚いたことに、8つすべてが同じ一つの誤解から来ていた。

誤解はこれだ ― 「Firestoreの .get() は通信に失敗したら例外を投げる」。投げない。オフラインではローカルキャッシュで静かに答える。そしてキャッシュにその文書がなければ exists == false が返る。エラーではなく「存在しない」として返ってくる。

症状:応答がないことを「削除された」と読んでいた

コード側のコメントは意図を正確に書いていた。「.get() 成功かつ !exists の場合のみ削除する(ネットワークエラーは throw されるので catch でスキップ)」。ところが実装がその通りに動いたことは一度もなかった。throw されないからだ。

結果として、オフラインや一時的な通信不良のときに、アプリは「サーバーにデータがない = 削除された」と判断していた。そして幽霊ルームの掃除、バックアップの上書き、ルーム一覧の初期化、お知らせキャッシュの削除を実行した。ユーザーは何もしていないのに、自分のデータが消えた。

疑ったこと(全部外れだった)

最初はセキュリティルールを疑った。次に削除処理のトリガー条件、そしてユーザー自身の操作ミスを疑った。どれも違った。

分かりにくかった理由は、このバグが「通信が不安定なときだけ」出るからだ。開発中は常にWi-Fiがある。手元では一度も再現しなかった。

本当の原因:.get() の既定値は serverAndCache

DocumentReference.get() の既定の Source は serverAndCache だ。サーバーに届かなければローカルキャッシュで答える。これは表示用途ではむしろありがたい挙動で、だからこそ既定値になっている。

問題は、その戻り値を「判定」に使ったときだ。キャッシュに該当文書がなければ exists == false が返る。例外ではない。呼び出し側からは「サーバーが確かに削除を答えた」場合と区別がつかない。

ストリームも同じだ。stream.first はキャッシュのスナップショットを先に返す。snapshot.metadata.isFromCache が true かどうかを見ないと、サーバーの答えかキャッシュの答えか分からない。

つまり「応答がない」と「存在しない」を区別する手段が、既定の書き方には入っていない。

直し方:判定に使う読み取りだけ Source.server にする

書き方はこうなる。await ref.get(const GetOptions(source: Source.server)); ― オフラインならこれは例外を投げるので、catch で「何もしない」を選べる。判定を保留できるようになる。

ストリームの場合は await stream.firstWhere((s) => !s.metadata.isFromCache).timeout(const Duration(seconds: 5)); のように、サーバースナップショットだけを判定に使う。タイムアウトしたら判定を保留する。

重要なのは全部を Source.server にしないことだ。表示用の読み取りはそのままでいい。キャッシュが効くほうがユーザー体験は良い。切り替えるのは「その結果で何かを削除または上書きする」箇所だけだ。

catch の中で権限エラーとネットワークエラーを分ける

Source.server にすると例外が飛んでくるが、その例外を一括で「失敗」として扱うと別の問題が起きる。permission-denied はサーバーが確かに答えた確定信号(退出させられた、削除された)なので、片付けてよい。それ以外(オフラインなど)は判定不能なので、何もしてはいけない。

この区別を入れなかったとき、「退出させられたユーザーのデータが永久に移行されない」という別の不具合を作ってしまった。エラーの種類で分岐する必要がある。

もう一つ。Source.server に変えたあと、呼び出し側が戻り値で何をしているかを必ず確認したほうがいい。確認に失敗したときに処理を止めてしまう呼び出し側があると、オフラインで機能そのものが使えなくなる。片付けはしない、しかし処理は進める ― という選択が必要な場所がある。

同じ系統の罠

snapshot.docs.isEmpty を「サーバーに0件」と読んではいけない。キャッシュミスでも0件になる。その0件をローカルキャッシュに保存すると、元のデータが消える。私のアプリではお知らせがその経路で消えた。

存在しない文書を読むと、セキュリティルール側で resource == null となり permission-denied が返る。つまり「削除されたルーム」と「自分がメンバーではないルーム」が同じエラーで届く。エラーメッセージだけでは区別できない。

まとめ

判定基準は一行でまとめられる ― 読み取った結果で何かを消すか上書きするなら Source.server を使う。表示のためだけなら既定のままでよい。この一線を引くだけで、私の場合は8箇所のデータ喪失リスクが消えた。そして根本的な教訓はもう少し手前にある。「通信エラーなら例外が飛んでくるはず」という前提は、コメントに書いた時点では正しく見える。しかし実際にオフラインで動かしてみるまで、その前提が成り立っているかは分からない。開発中に正常であることは、正常という意味ではなかった。

Haily(ヘイリー)は、毎日の予定も、クーポンやチケットの期限も、ひとつのカレンダーで見渡せるアプリです。

📱 App Store でダウンロード / 🤖 Google Play で入手

#Firestore#Flutter#オフライン対応#個人開発#バグ修正
← 他の記事を見る