綴Photo の勉強ノートに戻る
プロダクト設計2026.07.03

別アカウントで入るとき、ローカルをリセットすると綴りがサーバで二重に立つ — 対策の副作用まで踏んでから決める

設計判断同期綴Photo

写真日記アプリ(綴Photo)モバイル版で「別アカウントで入り直したときの黙って 0 件」を塞ぐ設計を詰めていて、初手で出した対策が副作用を持っていたので、実装する前に案を組み直した。その記録です。

前の記事で、綴Photo は「ログアウトしても端末のDBは消さない(消す経路を増やしたくないから)」と決めた。じゃあ同じ端末で 別のアカウント で入ろうとした人はどう扱うか、が残っていた。今日そこを詰めた。最初は「ローカルの同期マーカーだけリセットして続行させる」という案に落ちていたが、副作用に気づいてやめた。ここではその一点だけを書く。

その前に、前提を1つだけ置いておく。綴Photo は local-first——綴り(写真に添えた文章)はまず端末の中のデータベースに保存されて、ログイン無しでも全機能が成立する(ログインを全画面の壁にしない)。サーバへ送るのは、いま作っている Web アプリ(紡Book)と連携させたいから。この記事もその連携まわりの機能の話になる。

ここで効いてくるのは、一度同期すると同じ綴りが端末とサーバの2箇所にあるという点。だから最初から最後まで、端末側とサーバ側で何が起きるかを並べて追うことになる。今日踏んだ副作用も、端末側だけを見ていると最後まで見えない位置にあった。

この記事で扱う観察ポイント

  • 「同期マーカーだけリセットして続行させる」がなぜ自然な初手に見えるか
  • リセットが backend 側で綴りを二重に立てる仕組み
  • 「そもそも別アカウントで入らせない」に寄せると、リセットの副作用が消えること
同期すると同じ綴りが2箇所に残る。印は端末の列で、サーバの表ではない。端末 SQLitemeaningsbody と client_memory_idserverId=届け済みの印Photos.syncState も同じファイル閉じても残る同期PostgreSQLphotos_meaningowner=A の行一意は owner + client_memory_idユーザーが違えば別行閉じても残るログアウトではトークンだけ消す。SQLite の印も PostgreSQL の行も残る。prefs には無い。

初手で出した「マーカーだけリセット」案

黙って 0 件になる正体は、こうだった。A さんで一度同期した綴りは、端末DBに「届け済み」の印(serverId)が付いている。ログアウトして B さんで入り直しても、この印は端末に残る。次の同期で送信対象を選ぶロジックが「印の付いてない綴りだけ」だから、B さんとして同期を押しても 全部除外されて 0 件になる。ユーザーからは「押しても反応がない」に見える。

初手の案はシンプルだった。ログアウト時に 同期マーカーだけリセット(serverId を null に戻す)すれば、次に誰かが入って同期を押したとき「印の付いてない綴り=全部」を送るから、少なくとも 0 件にはならない。綴り本体(本文・写真参照)には触らないから、前の記事で決めた「消す経路を増やさない」も守れる——そう思っていた。

リセットが呼ぶ副作用: 綴りが二重に立つ

副作用に気づいたのは、その案を出した直後、その挙動を頭の中で backend まで追ったときだ。

リセットして B で送ったあと。本文は端末に1件。サーバは2行。端末 SQLitemeanings 1件body は残すserverId を空に戻すclient_memory_id 同じ印が無いので再送対象閉じても残る触らないPostgreSQL photos_meaningowner=A の行が残る閉じても残る。リセットでは消えないB で再送PostgreSQL photos_meaningowner=B の行が新規に立つ同じ client_memory_id一意が owner ごとなので止まらない本文は端末 SQLite に1件。サーバは A と B に複製される。

「マーカーだけリセット」は、端末側の困りごと(0 件)は解くが、backend 側にゴミを増やす方向の副作用を持っていた。写真に紐付いた綴り、しかも家族写真かもしれないものが、意図しない別ユーザーの下にコピーされていく——というのは、思い出アプリで一番やりたくない挙動だ。

「そもそも別アカウントで入らせない」に切り替える

副作用に気づいた時点で、案を組み直した。この端末は誰の領域と紐づいているかを覚えておいて、別ユーザーで入ろうとしたら login そのものを止めるほうにした。

前に使った人の名前は金庫に残し、ログアウトでは消さない。端末の金庫Keychain / KeystorelastUsername=Alogout では消さない閉じても残る比べる(交差)今回の username置き場所ではない別の人なら止めるトークンは破棄金庫に残さない同じ人・初回は通すSQLite には書かない端末 SQLite meanings綴り本体は触らない閉じても残るPostgreSQL photos_meaningA の行は触らない閉じても残る。複製しない

コードは AI に組んでもらった。不一致のときは「この端末は A さんの記憶を持っています。別アカウントで入り直すには、設定からデータ削除してください」の文言を返す。B さんが本当にこの端末を使いたいなら、設定からデータ削除で明示的に紐づけを外してもらう(この削除導線は今日の実装には含めていない。まだ別の作業回しとして寝かせてある)。

dart
// login フローの共通後段(要点だけ)
Future<void> applyLoginResult(AuthTokens tokens) async {
  await _store.save(tokens);
  final username = tokens.username ?? await _api.fetchMe(tokens.accessToken); // ?? は「左が空なら右を使う」。username が来なければ backend に問い合わせる
 
  final last = await _store.readLastUsername(); // 前にこの端末を使った人の名前(覚えてある)
  if (last != null && last != username) { // 覚えてる人が居て、今の人と違う=別アカウント
    // 別の人だった。受け取ったトークンをその場で破棄して弾く。
    await _store.clear();
    state = AuthState(
      status: AuthStatus.unauthenticated,
      alternateAccountRejected: true,
      errorMessage: 'この端末は $last さんの記憶を持っています。…',
    );
    return;
  }
  // 初回 or 同じ人。
  await _store.writeLastUsername(username);
  state = AuthState(status: AuthStatus.authenticated, username: username);
}

学びポイント

  • 端末側の困りごとだけを見て決めた対策は、backend まで追うと副作用を持つことがある。 「ローカルをリセットして再送すれば直る」は、backend で誰の領域にどう立つかまで追わないと、二重化を静かに増やす。
  • 一点物のデータを扱うアプリでは、「増える」もリスクとして数える。 データ喪失(消える)ばかり気にしていたが、「意図しない相手に複製される」も同じ重さで守るべきだった。前の記事の「壊れて守る」に対する「気づいて守る」の続きで、今回は「気づいた時点で止める」に着地した。
  • 副作用は初手を実装する前に、頭の中で最後まで追い切ってから決める。 今回はコードを書く前に案を並べ直したので、書き直しの手戻りはゼロだった。ここは AI との作業のリズムでいうと、「実装のお願い」より一段手前で立ち止まる価値がある部分だと思う。

このテーマの括り

前の記事で「消す経路を増やさない」を選んだ延長で、今回は「複製する経路を増やさない」を選んだ。守る対象を「消えない」だけでなく「意図しない相手に増えない」まで広げると、初手で自然に見える対策の半分は候補から落ちる。落とすほうが実装は減るし、レビューで指摘される角も減る。

おわりに

「別アカウントで入ろうとしたら止める」は、UX としては強めの制約に見える。でもこのアプリはもともと「一人の人が、自分の綴りを、この端末で綴じていく」ためのアプリなので、その1対1が壊れる場面をアプリ側で受け止めなくていい、と考え直した。切り替えたい人には「データ削除して仕切り直す」を明示で用意する予定で、今日はそこまで作り込んでいない。

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