対になる処理の片方だけを丁寧に作っていて、その丁寧さが対称になっていなかった、という記録です。
ここで言う「web」は、綴ったものを web でも見られるように開発中のアプリ(紡Book)のことです。
写真を web に公開する / 公開をやめる。この2つは対になっています。
「やめる」のほうは、少し前に丁寧に作り直してあった。ローカルの公開フラグ(この写真を web に出す/出さないの札)を下ろして、その場でサーバへ送る。送信に失敗したらローカルも公開中へ戻します。
この「戻す」形をなぜ選んだかは「分けたはずの作業が分かれていなかった — スライスの独立性は「実装できるか」では測れない」に書いた。
final before = await _db.memoryDao.fetchPhotoWithAllMeanings(photoId);
final wasSelected = before.photo.webSelected; // 押す前の値を先に控える(これが戻し先)
await _db.photoDao.setWebSelected(photoId, false); // 札を下ろす(端末のDBに確定で書かれる)
try {
final memory = await _db.memoryDao.fetchPhotoWithAllMeanings(photoId);
return await _syncAndApply([memory]); // ← サーバへ送る。電波が無いとここで落ちる
} catch (_) { // 失敗した時だけ通る道
await _db.photoDao.setWebSelected(photoId, wasSelected); // ← 戻す
rethrow; // 戻したうえで、失敗は握りつぶさず呼び出し元へ返す
}理由もコメントに書いてある。要約すると「外れたのは端末の表示だけで、写真はサーバに残ったまま。引き上げたと思わせて実際は公開中、という嘘の状態表示になる」。
これは正しい。問題は、公開する側に同じ処理が無かったこと。
await _db.photoDao.setWebSelected(photoId, true);
final memory = await _db.memoryDao.fetchPhotoWithAllMeanings(photoId);
final summary = await _syncAndApply([memory]); // ← ここで落ちると true のまま壊れるのは「再公開」だけだった
面白いのは、この非対称がいつも問題になるわけではないところ。
初めて公開する写真は、サーバ側の ID をまだ持っていません。
壊れるのは、一度公開して、取り消して、もう一度公開するときです。この写真はサーバ側の ID を持っています。
送信だけが失敗する状態(圏外や一時的なエラー)でこれをやると、こうなります。
認証チェックは通ります。ログイン済みかの確認は端末の中だけで済むので、電波が無くてもここでは止まりません。通信が戻ってからアプリに復帰する / ホームや本棚へ入ると、自動の突き合わせがサーバへ聞きに行きます(復帰は前回から5秒、画面遷移は60秒あけば走ります)。
ユーザーは公開ボタンを押しただけです。それなのにアプリは、勝手に取り消したうえで原因を web のせいにして知らせてきます。
しかも押した直後に出るのは「送信に失敗しました。時間をおいてお試しください。」という一行だけで、そこに再試行のボタンはありません。ボタンの表示は「web に公開中」に変わっているので、もう一度押しても再送ではなく「公開をやめる」になります。
対処は2つ。ローカルを元へ戻す(巻き戻し)か、未送信として覚えておいて後で送る(再送キュー)か。
| やり方 | 失敗したあとどうなるか | 払うもの |
|---|---|---|
| 巻き戻す | 押す前の状態に戻る。嘘の表示は残らない | オフラインでは操作そのものが残らない。押し直すのはユーザー任せ |
| 未送信として覚えておく | 電波が戻った時に自動で送られる。操作が消えない | 「まだ送っていないつもり」が端末に溜まる |
このアプリは巻き戻しのほう。決め手は下の段の右で、突き合わせがサーバの答えで上書きしてよいのは、端末に未送信の操作が1件も残っていないからです。再送キューを足すと、その前提を自分から壊すことになります。
直したのは1つの構造と、1つの細部
構造は「やめる」側と同じ形に揃えただけ。
final before = await _db.memoryDao.fetchPhotoWithAllMeanings(photoId);
final wasSelected = before.photo.webSelected;
await _db.photoDao.setWebSelected(photoId, true);
try {
// ...送信...
} catch (_) {
await _db.photoDao.setWebSelected(photoId, wasSelected);
rethrow;
}細部のほうが判断だった。戻し先を false 固定にしなかったこと。
すでに公開中の写真をもう一度押した場合(重複操作や、UI が古い状態を持っていた場合)に false 固定で戻すと、送信に失敗したことを理由に公開を下ろすことになります。ユーザーは何も取り消していません。だから戻すのは「操作前の値」であって「初期値」ではありません。
巻き戻しを入れたことで、嘘の表示は消えた。代わりに、オフラインで押した公開は跡形もなく消えます。「送れなかった」をユーザーに返す口は無いままで、直したのは表示の嘘のほうだけ。
// expect(A, isTrue) は「A が true であること」を期待する、の意味
expect(photo.webSelected, isTrue, reason: 'false 固定でなく操作前へ戻す'); // true でなければ失敗し reason の文言が出るこのテストは、実装前に書いた時点では通ってしまった(戻す処理が無いので true のまま残るから、たまたま期待と一致します)。それでも残したのは、戻す処理を入れた後に「戻し先」を固定する役割があるからです。実際に修正を測っているのはもう1本のほうで、そちらは修正前に赤くなる(=テストが失敗する)ことを確認した。
実装前のテストが最初から緑なら、それは「もう直っている」ではなく「そのテストは今回のバグを見ていない」の合図でもあります。
「対になる処理」を数えるほうが早い
この非対称は、探そうと思って探したわけではない。AI レビューに指摘されて初めて見た。
でも後から考えると、見つけ方はあった。「やめる」を丁寧に作った時に、対になる「やる」を開いて同じ形になっているか見ます。片方に巻き戻しを足したなら、もう片方に要るかどうかを必ず問います。
このプロジェクトの判断ログには、前から似た原則が書いてある。
逆操作は正操作から導く — 戻す/消す処理の対象は、対になる「進める」処理の実装から導出する(人の列挙は必ず落ちる)。対の操作は隣接させ相互参照を書く
今回は逆向きだった。逆操作のほうを先に丁寧にして、正操作を置き去りにした。同じ話の裏返しで、丁寧にした側が基準になって、もう片方が取り残されます。守りが片側にしか無い状態は、守っていない側だけが静かに嘘をつきます。
学び
- 巻き戻しは、戻し先を決め打ちにした瞬間に「元に戻す」ではなく「別の状態へ動かす」処理に化けます
- 自動処理が原因を推測して文言にすると、間違った説明を堂々と出します。「web で外れました」は、サーバに無いという事実からの推測でしかなかった。推測を断定で書くと、ユーザーは調べようがありません
- 同じ操作でも「初めて」と「2回目」で通る道が違うことがあります。今回は自己回復するかどうかが分かれ目で、壊れていたのは2回目だけだった。手で試して「動いた」と思う時、たいてい試しているのは1回目です
この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。