綴Photo の勉強ノートに戻る
開発記録2026.06.22

選んだ写真だけ web に上げる同期にした — 「選んだ意図」と「実際に届いたか」を分けて持つ

設計同期FlutterDrift綴Photo

モバイルで綴ったデータをサーバへ同期する管を作っている連載の続き。今回は「何を送るか」と「画面にどう状態を出すか」の設計を変えた記録です。

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

綴Photo は、モバイルアプリと、それを見せる web、その間の backend でできています。前まで、モバイルの同期は「まだサーバに送っていない綴りを全部送る」だった。今回それを「ユーザーが選んだ写真だけ送る」に変えた。なぜ変えたか、そのために状態をどう持ったかを整理しておく。

先にデータの置き場所を確認しておく。データベースは2つしかありません。端末の中と、サーバの中です。web は自分のデータベースを持たず、見せるときに毎回 backend へ聞きにいきます。

mobile(端末の中)Drift / SQLitePhotos — 写真1枚ぶんMeanings — 綴った言葉PassedPhotos — 見送りUserSettings ほかbackend(Docker の中)PostgreSQLPhoto — 上がってきた写真Meaning — その言葉TemplatePage / SharedPageFavoriteMemory(休眠)webNext.jsDB を持たない見せるたびにbackend に聞くwebSelectedだけ通すAPI綴る場所。全部入る(人に見せない日記も)選ばれたものだけ入る+web の作品の保存先見せる係持ち物はゼロ

この記事で足す webSelected は、左の Photos テーブルに1列足して、真ん中の矢印を細くする話になります。

「全部送る」だと、日記が丸ごと web に出てしまう

最初の実装は、未送信の綴りを片っ端からサーバへ送っていた。同期の管を通すだけならそれで動きます。でも、モバイルは「気持ちを綴る場所」で、その中には人に見せる前提じゃない日記みたいな綴りも混ざります。web 側はもともと「選んだ写真を並べる場所(curated=選り抜き)」として作っています。なのに同期が全部送ると、選り抜きのはずの web に、全部が流れ込んでしまいます。

なので方針を「選んだものだけが web に出る」にした。ユーザーが「これは載せる」と選んだ写真だけがサーバに上がり、選んでいないものは端末から一歩も出ません。同期=バックアップ、ではなく、同期=公開(選んで出す)に寄せた、という判断。

「選んだ」を1列にして、送る所で1行はじく

「選んだ」状態は、写真テーブルに boolean を1列足して持たせた。

dart
// 写真テーブル(drift)。「web に上げる、と選んだか」の意図を1列で持つ
BoolColumn get webSelected => boolean().withDefault(const Constant(false))();
// 既定 false = 既存の写真も新規も、選ぶまでは何も上がらない(プライバシー側に倒す)

送信データを組み立てる所で、選んでいない写真を最初に弾きます。実装は AI に書いてもらったが、「未選択は一切送らない」を最初の1行に置く、というのがこの変更の本体だ。

dart
for (final memory in memories) {
  final photo = memory.photo;
  if (!photo.webSelected) continue; // ← 未選択は丸ごとスキップ(curated の門)
  for (final meaning in memory.meanings) {
    if (meaning.serverId != null) continue; // 既に送ったものは送らない(差分送信)
    result.add({ /* ... */ 'is_favorite': true }); // 選んだ写真の綴り=お気に入りとして送る
  }
}

このループは二重になっていて、外側が写真、内側がその写真に綴った言葉です。写真1枚に言葉は何件でも付きます(1対多)。だから門も2つ要ります。外側の門(webSelected)は**写真単位で「そもそも外に出すか」を決め、内側の門(serverId)は綴り単位で「これはもう送ったか」**を見ます。あとから足した言葉は serverId が空のまま残るので、次の同期で拾われます。

if (!photo.webSelected) continue; の1行で、送られる集合が「全部」から「選んだ分」に変わります。コードの差は小さいけど、これは何が端末の外に出るかを決める変更で、プライバシーの建て付けそのものが動きます。だからこの挙動は「未選択は本当に送られない」をテストで固定してから入れた。

「選んだ意図」と「実際に届いたか」は別物として持つ

画面に「この写真、web に出てる?」を出したい。ここで詰まりやすいのが、状態を1個にまとめたくなること。でも、よく見ると別物の情報が2つあります。

  • 選んだ意図 … ユーザーが「載せる」と押したか(webSelected)。オフラインでも押せます。押した瞬間に立ちます。
  • 実際に届いたか … サーバに綴りが1件以上登録され(syncState='synced')、画像も上がった(remoteUrl が入った)か。

この2つはズレます。電波が無い所で「載せる」を押せば、意図は立っているけどまだ届いていません。だから1つのフラグにまとめず、意図は意図、着地は着地で別々に持った。

そのうえで、画面に出す3状態(未選択 / 送信中 / 公開中)は、この2層から計算で出します。表示用の状態を DB に別途保存しません。

dart
enum WebPublishState { notSelected, pending, published }
 
WebPublishState webPublishState(Photo photo) {
  if (!photo.webSelected) return WebPublishState.notSelected;            // 選んでない
  final landed = photo.syncState == 'synced' && photo.remoteUrl != null; // 「着地した」= 綴りが1件以上届き、画像も上がった
  return landed ? WebPublishState.published : WebPublishState.pending;    // 届いた=公開中 / まだ=送信中
}

ひとつ注意がいります。syncState は写真の列ですが、表しているのは「綴りが1件以上サーバに届いたか」で、全部の綴りが届いたか、ではありません。だから公開中と表示されたあとに言葉をもう1つ足すと、その言葉が未同期でも表示は公開中のまま動きません。これは意図してそうしている。写真単位の表示に、綴り単位の進捗を持ち込まないことにした、という線引きだ。

「選ぶ=送る」のボタンも、この計算した状態を見て見た目を変えるだけにした。状態を二重に管理しなくて済みます。

残っていた"写真側の片肺"を閉じた

同じ同期の連載で「保険が片肺だ」と書いて、前回その片肺を片方だけ閉じた。閉じたのは綴り(言葉)側——サーバが採番した綴りのIDを端末に書き戻す経路だ。写真そのもののサーバIDの方は、まだ端末に書き戻せていないまま残していた。今回はそのもう片方=写真側を閉じた。

写真の方が残っていたのは、当時同期の応答に写真のサーバIDが入っていなかったから。前回も「応答に無いから書きようがない、サーバが返す中身を広げる作業とセットで別の機会に」と但し書きを残していた。

今回その"別の機会"を通した。まずサーバの応答に、綴りに紐づく写真の photo_id を含めるように広げ、そのうえで端末がそれを拾って写真の serverId 列に書き戻す。実装は AI に書かせて、自分は「サーバの ID を端末に持ち帰る経路が要る(=応答を広げる所とセット)」と気づいた所を判断した。

dart
// 同期応答の photo_id を、その写真の serverId として書き戻す
for (final e in photoServerIdByPhotoId.entries) {
  await (update(photos)..where((p) => p.id.equals(e.key)))
      .write(PhotosCompanion(serverId: Value(e.value)));
}

これで「端末側の写真」と「サーバ側の写真」が、ようやく ID で繋がった。前回開けたままだった保険が、ここで一つ閉じた。

また同じ穴の隣を踏んだ(マイグレーション)

列を1本足すだけ、のはずが、また古いマイグレーションで引っかかった。前に「5バージョン前のマイグレーションが静かに壊れていた」話を書いたが、その隣だ。

列を足したことで、ずっと前のバージョンから順に上がってくる端末では、途中のある手順がテーブルを作り直すときに、新しい列が自分の追加手順より前にできてしまう経路があった。その状態で素直に「列を追加」を実行すると「もうあるよ」で落ちます。なので追加の前に「もう在る?」を確認してから足す、という冪等な形にした。この経路の条件分岐は、まだ自分の中で完全には整理しきれていなくて、AI に道筋を整理してもらいながら直した。ただ「これは前と同じ系統のバグだ」という直感だけは、前回踏んだおかげで先に立っていた。

dart
// 古いバージョンからの通し移行では、この時点で既に列がある経路がある。
// 在る場合は足さない(冪等)。実バージョン v11 からの素直な上げでは無いので足す。
// photos テーブルに web_selected 列が在るかを問い合わせる SQL
final exists = await customSelect(
  "SELECT 1 FROM pragma_table_info('photos') WHERE name = 'web_selected'",
).get();
if (exists.isEmpty) {
  await m.addColumn(photos, photos.webSelected);
}

過去の手順の足元が変わるというのは前回と同じ構図だった。詳しくは前の記事(識別の列を2本に分けた回)に書いた。

学びポイント

  • 「全部送る」を「選んだ分だけ」にするのは、boolean を1列足すのと、送る所で1行はじくだけで書けます。 でもそれは見た目以上に「何が端末の外に出るか」を変えます。コードの小ささに対して、テストで固定すべき重さは大きいです。
  • "選んだ意図" と "実際に届いたか" は別の情報。1つのフラグにまとめません。 別々に持って、画面の状態はそこから計算で出すと、状態の二重管理(とその食い違い)が消えます。
  • 前に踏んだバグの隣は、また踏みやすいです。 列を1本足すような変更でも、最新定義から作り直すマイグレーションでは過去の手順の前提が動きます。同じ仕組み(最新定義でテーブルを作り直す)の上で列を増やす変更は、すべて同じ種類の穴が生まれます。一度踏んだ系統は、次の変更でも同じ確認が要ります。

おわりに

やったことは「選ぶための列を1本足して、送る所で1行はじいて、表示は計算で出す」で、派手な機能じゃない。でも「同期=全部バックアップ」から「同期=選んで公開」へ、何を端末の外に出すかの方針が変わった回だった。まだ本物のログインは載っていなくて、今は開発用の入口の中だけで動く段階。次はそこ(ログイン)を本物にして、選んで公開のループを実際に使える所まで持っていく。


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