写真日記アプリ(綴Photo)モバイル版で「別アカウントで入り直したときの黙って 0 件」を塞ぐ設計を詰めていて、初手で出した対策が副作用を持っていたので、実装する前に案を組み直した。その記録です。
前の記事で、綴Photo は「ログアウトしても端末のDBは消さない(消す経路を増やしたくないから)」と決めた。じゃあ同じ端末で 別のアカウント で入ろうとした人はどう扱うか、が残っていた。今日そこを詰めた。最初は「ローカルの同期マーカーだけリセットして続行させる」という案に落ちていたが、副作用に気づいてやめた。ここではその一点だけを書く。
その前に、前提を1つだけ置いておく。綴Photo は local-first——綴り(写真に添えた文章)はまず端末の中のデータベースに保存されて、ログイン無しでも全機能が成立する(ログインを全画面の壁にしない)。サーバへ送るのは、いま作っている Web アプリ(紡Book)と連携させたいから。この記事もその連携まわりの機能の話になる。
ここで効いてくるのは、一度同期すると同じ綴りが端末とサーバの2箇所にあるという点。だから最初から最後まで、端末側とサーバ側で何が起きるかを並べて追うことになる。今日踏んだ副作用も、端末側だけを見ていると最後まで見えない位置にあった。
この記事で扱う観察ポイント
- 「同期マーカーだけリセットして続行させる」がなぜ自然な初手に見えるか
- リセットが backend 側で綴りを二重に立てる仕組み
- 「そもそも別アカウントで入らせない」に寄せると、リセットの副作用が消えること
初手で出した「マーカーだけリセット」案
黙って 0 件になる正体は、こうだった。A さんで一度同期した綴りは、端末DBに「届け済み」の印(serverId)が付いている。ログアウトして B さんで入り直しても、この印は端末に残る。次の同期で送信対象を選ぶロジックが「印の付いてない綴りだけ」だから、B さんとして同期を押しても 全部除外されて 0 件になる。ユーザーからは「押しても反応がない」に見える。
初手の案はシンプルだった。ログアウト時に 同期マーカーだけリセット(serverId を null に戻す)すれば、次に誰かが入って同期を押したとき「印の付いてない綴り=全部」を送るから、少なくとも 0 件にはならない。綴り本体(本文・写真参照)には触らないから、前の記事で決めた「消す経路を増やさない」も守れる——そう思っていた。
リセットが呼ぶ副作用: 綴りが二重に立つ
副作用に気づいたのは、その案を出した直後、その挙動を頭の中で backend まで追ったときだ。
「マーカーだけリセット」は、端末側の困りごと(0 件)は解くが、backend 側にゴミを増やす方向の副作用を持っていた。写真に紐付いた綴り、しかも家族写真かもしれないものが、意図しない別ユーザーの下にコピーされていく——というのは、思い出アプリで一番やりたくない挙動だ。
「そもそも別アカウントで入らせない」に切り替える
副作用に気づいた時点で、案を組み直した。この端末は誰の領域と紐づいているかを覚えておいて、別ユーザーで入ろうとしたら login そのものを止めるほうにした。
コードは AI に組んでもらった。不一致のときは「この端末は A さんの記憶を持っています。別アカウントで入り直すには、設定からデータ削除してください」の文言を返す。B さんが本当にこの端末を使いたいなら、設定からデータ削除で明示的に紐づけを外してもらう(この削除導線は今日の実装には含めていない。まだ別の作業回しとして寝かせてある)。
// 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月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。