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

「やめる」だけ元に戻していたので、「もう一度やる」が嘘をついた

データ保全エラーハンドリングUX綴Photo

対になる処理の片方だけを丁寧に作っていて、その丁寧さが対称になっていなかった、という記録です。

ここで言う「web」は、綴ったものを web でも見られるように開発中のアプリ(紡Book)のことです。

写真を web に公開する / 公開をやめる。この2つは対になっています。

「やめる」のほうは、少し前に丁寧に作り直してあった。ローカルの公開フラグ(この写真を web に出す/出さないの札)を下ろして、その場でサーバへ送る。送信に失敗したらローカルも公開中へ戻します。

この「戻す」形をなぜ選んだかは「分けたはずの作業が分かれていなかった — スライスの独立性は「実装できるか」では測れない」に書いた。

dart
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;                                           // 戻したうえで、失敗は握りつぶさず呼び出し元へ返す
}

理由もコメントに書いてある。要約すると「外れたのは端末の表示だけで、写真はサーバに残ったまま。引き上げたと思わせて実際は公開中、という嘘の状態表示になる」。

これは正しい。問題は、公開する側に同じ処理が無かったこと。

dart
await _db.photoDao.setWebSelected(photoId, true);
final memory = await _db.memoryDao.fetchPhotoWithAllMeanings(photoId);
final summary = await _syncAndApply([memory]);   // ← ここで落ちると true のまま
送信失敗の直後。札は端末 SQLite の Photos.webSelected に残る。サーバは変わっていない。やめる(戻す口がある)公開する(直し前)メモリ wasSelected押す前の値を控える保存しないメモリ戻し先を控えていない保存しない端末 SQLitePhotos.webSelected をいったん falsecatch で wasSelected へ戻す。閉じても残る端末 SQLitePhotos.webSelected は true のまま戻す口が無い。閉じても残るサーバ DB FavoriteMemory取り消しは届いていない公開中のまま。閉じても残るサーバ DB FavoriteMemory公開は届いていない一覧に無い。閉じても残る

壊れるのは「再公開」だけだった

面白いのは、この非対称がいつも問題になるわけではないところ。

初めて公開する写真は、サーバ側の ID をまだ持っていません。

壊れるのは、一度公開して、取り消して、もう一度公開するときです。この写真はサーバ側の ID を持っています。

突き合わせが触ってよいのは、端末 SQLite の Photos.serverId がある行だけ。初めて公開する端末 SQLitePhotos.serverId なし閉じても残るサーバ DBFavoriteMemory に無い閉じても残る端末 SQLitePhotos.webSelected は触らない次の同期で送り直す一度公開して、取り消して、もう一度公開する端末 SQLitePhotos.serverId あり閉じても残るサーバ DBFavoriteMemory に無い閉じても残るPhotos.webSelectedtrue を false に書く同じ SQLite。閉じても残る初めてのほうは放っておいて正しい。壊れるのは serverId がある行だけである。

送信だけが失敗する状態(圏外や一時的なエラー)でこれをやると、こうなります。

再公開の送信が失敗した直後。値は端末 SQLite とサーバ DB に分かれている。端末 SQLitePhotos.webSelected = true画面はここを読んで公開中と出す閉じても残る。表示は保存しないサーバ DB FavoriteMemoryこの ID は無い送れていないので当然閉じても残る突き合わせは交差。サーバを読み、SQLite へ書く。置き場所ではない。端末 SQLite Photos.webSelected を false に上書きする通知は「web で外れた1枚を下ろしました」。サーバの不在からの推測列は閉じても残る。文言は保存しない誰も web で外していない。画面は SQLite の札を読んでいただけである。

認証チェックは通ります。ログイン済みかの確認は端末の中だけで済むので、電波が無くてもここでは止まりません。通信が戻ってからアプリに復帰する / ホームや本棚へ入ると、自動の突き合わせがサーバへ聞きに行きます(復帰は前回から5秒、画面遷移は60秒あけば走ります)。

ユーザーは公開ボタンを押しただけです。それなのにアプリは、勝手に取り消したうえで原因を web のせいにして知らせてきます。

しかも押した直後に出るのは「送信に失敗しました。時間をおいてお試しください。」という一行だけで、そこに再試行のボタンはありません。ボタンの表示は「web に公開中」に変わっているので、もう一度押しても再送ではなく「公開をやめる」になります。

対処は2つ。ローカルを元へ戻す(巻き戻し)か、未送信として覚えておいて後で送る(再送キュー)か。

やり方 失敗したあとどうなるか 払うもの
巻き戻す 押す前の状態に戻る。嘘の表示は残らない オフラインでは操作そのものが残らない。押し直すのはユーザー任せ
未送信として覚えておく 電波が戻った時に自動で送られる。操作が消えない 「まだ送っていないつもり」が端末に溜まる

このアプリは巻き戻しのほう。決め手は下の段の右で、突き合わせがサーバの答えで上書きしてよいのは、端末に未送信の操作が1件も残っていないからです。再送キューを足すと、その前提を自分から壊すことになります。

直したのは1つの構造と、1つの細部

構造は「やめる」側と同じ形に揃えただけ。

dart
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 固定で戻すと、送信に失敗したことを理由に公開を下ろすことになります。ユーザーは何も取り消していません。だから戻すのは「操作前の値」であって「初期値」ではありません。

巻き戻しを入れたことで、嘘の表示は消えた。代わりに、オフラインで押した公開は跡形もなく消えます。「送れなかった」をユーザーに返す口は無いままで、直したのは表示の嘘のほうだけ。

dart
// expect(A, isTrue) は「A が true であること」を期待する、の意味
expect(photo.webSelected, isTrue, reason: 'false 固定でなく操作前へ戻す');  // true でなければ失敗し reason の文言が出る

このテストは、実装前に書いた時点では通ってしまった(戻す処理が無いので true のまま残るから、たまたま期待と一致します)。それでも残したのは、戻す処理を入れた後に「戻し先」を固定する役割があるからです。実際に修正を測っているのはもう1本のほうで、そちらは修正前に赤くなる(=テストが失敗する)ことを確認した。

実装前のテストが最初から緑なら、それは「もう直っている」ではなく「そのテストは今回のバグを見ていない」の合図でもあります。

「対になる処理」を数えるほうが早い

この非対称は、探そうと思って探したわけではない。AI レビューに指摘されて初めて見た。

でも後から考えると、見つけ方はあった。「やめる」を丁寧に作った時に、対になる「やる」を開いて同じ形になっているか見ます。片方に巻き戻しを足したなら、もう片方に要るかどうかを必ず問います。

このプロジェクトの判断ログには、前から似た原則が書いてある。

逆操作は正操作から導く — 戻す/消す処理の対象は、対になる「進める」処理の実装から導出する(人の列挙は必ず落ちる)。対の操作は隣接させ相互参照を書く

今回は逆向きだった。逆操作のほうを先に丁寧にして、正操作を置き去りにした。同じ話の裏返しで、丁寧にした側が基準になって、もう片方が取り残されます。守りが片側にしか無い状態は、守っていない側だけが静かに嘘をつきます。

学び

  • 巻き戻しは、戻し先を決め打ちにした瞬間に「元に戻す」ではなく「別の状態へ動かす」処理に化けます
  • 自動処理が原因を推測して文言にすると、間違った説明を堂々と出します。「web で外れました」は、サーバに無いという事実からの推測でしかなかった。推測を断定で書くと、ユーザーは調べようがありません
  • 同じ操作でも「初めて」と「2回目」で通る道が違うことがあります。今回は自己回復するかどうかが分かれ目で、壊れていたのは2回目だけだった。手で試して「動いた」と思う時、たいてい試しているのは1回目です

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