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

同期を冪等にする② — サーバのIDを端末に書き戻して片肺を閉じる

同期設計Flutter綴Photo

モバイルで綴ったデータをサーバへ同期する管を作っている連載の続き。前回「片肺だ」と書いた保険を、片方だけ閉じた回の記録です。前回(端末が作るID(clientMemoryId)とサーバが採番するID(memory_id)の2本を持たせた話)はこちら。

前にIDを2本持たせた話を書いたとき、最後に「同期はまだ片肺だ」と残した。サーバ側は同じ綴りを2回受け取っても1個しか作らない(冪等)ようになっているが、端末側がその結果を覚えていません。サーバが採番した memory_id を受け取っても捨てていて、端末DBの serverId 列は null、syncState は pending のまま。だから毎回ぜんぶ送り直していた。

今回はその端末側を閉じた。「どこで工程を分けるか」「どこに置くか」みたいな線引きは自分で引いている。

書き戻すって、何を書くのか

同期は「送る → サーバが綴りごとの結果を返す」往復になっています。返ってくるのは綴りごとに accepted(新規に作った)/ duplicated(もう持ってた)/ failed と、確定した memory_id。

書き戻すのは2つ。

綴りの serverId と写真の syncState は端末 SQLite。対応表はメモリ。保存しない。サーバ DB綴りごとの memory_idスナックバー以前は件数だけ。保存しない関所 純関数対応表はメモリ。保存しない端末 SQLite Meanings綴り行。clientMemoryId はずっとあるserverId null → 99閉じても残る端末 SQLite Photos写真行。syncState pending → syncedserverId は空のまま(応答に無い)閉じても残る
dart
// サーバの結果から {端末のID: サーバのID} を作る純関数。
// accepted と duplicated だけ拾う(どちらもサーバに在ると確定したもの)
Map<String, int> serverMemoryIdsByClientId(SyncResultSummary summary) { ... }

前回書いた「端末を新しくしても照合して繋ぎ直せる」保険が、ようやく端末側からも効きます。

あえて「記録するだけ」にした

ここで一つ線を引いた。送信のしかたは一切変えなかった。

今回書くのは端末 SQLite。送るリストはメモリで、まだ全件。端末 SQLite に記録するMeanings.serverIdPhotos.syncState送り方は変えない閉じても残るリスクの無い追加だけ送るリストメモリ。保存しないまだ全件のままサーバの冪等で二重にならない記録を見て減らすのは別の回閉じたら消える

「もう同期済みだから送らない」は、判定を一個でも間違えると綴りが永久に送られない取りこぼしになります。

「記録する」と「送信を減らす」を別のスライス(一度に入れる変更のかたまり)に割った。前者はリスクの無い追加だけ。後者は送信挙動を変えるので、別の回でテストを厚くして入れる。同じコミットに混ぜると、もし取りこぼしが出たとき原因がどっちか切り分けられなくなります。

写真そのもののサーバIDも、無いものを別の値から捏造して埋めるより、空のまま残すほうがいいです。

新しい置き場所を作らず、既存に足した

書き戻す処理をどこに置くかで、もう一つ判断した。

素直に考えると「同期の書き戻し専用の置き場所(DAO)」を新しく作る。役割がはっきりするし後で育てやすいです。でも端末DB(drift)は、新しいDAOを足すとコードの自動生成をかけ直す必要があります。

今回足したいのはメソッド1つ。それだけのために生成を回したくなかった。見たら、既存の参照用DAOが写真と綴りの両方のテーブルにすでにアクセスできた。ここにメソッドを1つ足すだけなら生成は要りません(手書きのメソッドを増やすのは生成の対象じゃない)。役割の純度より、回す手間の少なさを取った。

dart
// 既存のDAOに足したメソッド。↓の transaction でまとめて書き戻す
Future<void> applySyncResults(Map<String, int> serverIdByClientMemoryId) async {
  await transaction(() async {
    // 綴りに serverId を書き、その親写真を synced にする
    // ...
  });
}

もう一つ、この足したメソッドにはサーバ応答の型を渡さず、ただの「文字列→数値」のマップを渡すようにした。DB側のコードがサーバ通信側の型を知ると、互いを参照し合う形になりかけます。間に「マップを作る純関数」を挟んで、DBはマップだけ受け取ります。こうすると DB 側はサーバの都合を知らないままでいられます。

呼ぶ側は、同期したあとに2行足すだけになった。

dart
final synced = serverMemoryIdsByClientId(summary);
await db.memoryDao.applySyncResults(synced);

追記:その「別のスライス」も入れた

上で「送信を減らすのは別の回でやる」と書いた、その別の回をすぐ続けて入れた。送るデータを作るところで、すでに serverId を持つ(=同期済みの)綴りを除外するだけ。これで毎回ぜんぶ送らず、まだ送っていない綴りだけが飛びます。

ここでもう一つ判断した。「同期済みを送らない」を写真単位でやると事故ることに気づいた。同期済みの写真に後から綴りを足すと、こうなります。

送るかは端末 SQLite の Meanings.serverId。Photos.syncState は写真行で、送りの判定には使わない。1枚の写真 Photos.syncState = synced(写真行)綴りA serverId あり / 綴りB serverId 無し(後から足した・失敗した)写真単位で飛ばす写真ごと落とす綴りBも落ちる新しい綴りが永久に送られないsyncState は写真行の印綴り単位で飛ばすA は送らないB は送るserverId が無い綴りだけ飛ぶ判定は綴り行に残る
dart
for (final meaning in memory.meanings) {
  // serverId が付いている=同期済み。送らずに飛ばす(写真単位ではなく綴り単位で判定)
  if (meaning.serverId != null) continue;
  // ...
}

片肺で残っているのは写真そのもののサーバIDだけになった。これは同期の応答に今は入っていないので、サーバが返す中身を広げる作業とセットで、別の機会に。


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