個人開発2026年11月5日約5分
同期の ずれの 原因は 「並び順で 変わる 指紋」だった

ツモルで、スマホで入れた内容が別の端末を開くたびに古い中身へ戻る不具合がありました。原因は、データの中身が同じでも、キーの並び順が違うだけで「変わった」と判定してしまう仕組みでした。直すまでに2回、原因を読み違えています。その順に書きます。
ツモルの同期のしくみ
ツモルは ToDo・ワークアウト・学習を記録するアプリで、Web と iPhone で使えます。同期(2台の中身をそろえること)は「1日ぶんの記録」と「設定のかたまり」を単位にしています。全体を1つにすると、スマホと PC で同じ日に触ったときに片方の変更が丸ごと消えるからです。
各端末は、単位ごとに「最後に同期したときの中身の指紋」を控えています。指紋は、中身を短い文字列に縮めたものです(ハッシュとも言います)。同期のたびに今の中身の指紋を取り、控えと違えば「手元で変わった」とみなして送ります。向こうも変わっていれば、新しいほうを採ります。
症状:直したはずの今日が戻る
2026年9月30日、「スマホで入れた内容が反映されていない」ことに気づきました。同期そのものは動いていました。スマホで足した項目は、シミュレータ(Mac 上で iPhone アプリを動かす道具)を開き直すと届いていたからです。
それでも、スマホで朝から入れていた今日の ToDo が、サーバーに残っていない。スマホの設定画面には「たった今に保存しました」と出ていて、アカウントも同じでした。スマホは送れている。なのに別の端末が、それを押し戻していることになります。
最初に疑ったこと(1):前の日で直したもの
最初に見つけたのは、前の日の画面で直したものが今日に運ばれていない、という不具合でした。ツモルでは、終わらなかった ToDo を次の日へ持ち越します。持ち越しは日ごとに別のものとして作られます。完了だけは次の日へ運ぶようにしていましたが、名前・期限・区分・削除はその日にしか書いていませんでした。
これは本当にあった不具合なので直しました。直す前の姿で探して、あとの日の持ち越しぶんへ同じ変更を当てる形です。ただ、「もっといろんな操作をしている」のに戻る、という症状はこれでは説明できませんでした。
最初に疑ったこと(2):あとから開いた端末が勝つ
次に、同期の判断を読み直しました。その日を一度も同期したことの無い端末は、開いた瞬間に手元の前日から今日を自動で作ります。その今日が「いま作った=新しい」として、必ず勝っていました。テストでも再現できました。
つまり2台を使う日は、あとから開いた端末が、先に使っていた端末の今日を消しうる作りでした。これも直しました。一度も同期していない日は、向こうを土台にして突き合わせます。手元からは、その端末で人が足したものだけを加えます。自動で作ったものは加えません。
これで解決したと思いました。ところが、そのあとも戻りました。スマホの今日がサーバーに無いことを、スクリーンショットで改めて確かめることになります。
本当の原因:並び順で変わる指紋
指紋は JSON.stringify(データを文字列にする標準の関数)の結果から取っていました。この関数は、オブジェクトのキーを入っている順に書き出します。{a:1, b:2} と {b:2, a:1} は中身が同じでも、別の文字列になります。
一方、サーバー側のデータベース(Postgres の jsonb という型)は、保存するときにキーを並べ替えます。短い順、同じ長さなら字の順です。送ったあとに控える指紋は、サーバーから返ってきた並びで取っていました。手元の並びとは一致しません。
その結果、一度でも送ったものは、その端末で永遠に「変わった」と見えていました。開くたびに、その端末は古い写しを送り直します。「いま変わった」ので、勝ってしまいます。
実際の数字です。シミュレータでは、同期済み117件のうち17件がこの状態でした。14:00 と 14:07 に開いたとき、ちょうどその17件を送り直していました。スマホの今日が古いものへ戻り、スマホの次の同期で取り戻す。これを繰り返していました。調査のためにシミュレータを開くたびに、私自身が今日を押し戻していたことになります。そのたびにスマホが取り戻していたので、データは失われていません。
9月11日に「機器ごとにずれている」と感じたことがあり、それもおそらくこれでした。前の節の「あとから開いた端末が勝つ」も実在した不具合ですが、主因はこちらでした。
なぜテストで出なかったか
同期の往復を確かめるテストはありました。偽のサーバー(本物の代わりにテスト用に作った相手)に送って、受け取る流れです。ただ、その偽のサーバーは、受け取った並びのまま返していました。本物は並べ替えて返すのに、です。
本物との違いがちょうど不具合の原因だったので、テストは通り続けていました。
直し方:並べてから比べる
指紋は、キーを並べ替えてから取るようにしました。中身が同じなら、並びが違っても同じ指紋になります。
function stable(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stable);
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const key of Object.keys(value).sort()) {
const v = (value as Record<string, unknown>)[key];
if (v !== undefined) out[key] = stable(v);
}
return out;
}
return value;
}
オブジェクトの中まで降りて、キーを字の順に並べ直します。配列の順番はそのままです。ToDo の並びのように、配列の順番には意味があるからです。値が undefined のキーは落とします。これも JSON.stringify が書き出さないので、そろえておきます。
厄介だったのは、すでに古い形の指紋を控えている端末です。古い指紋は今の指紋と比べようがありません。そこで新しい指紋の頭に版(2~)を付け、古い形と見分けられるようにしました。古い形のものは次のように扱います。
- 向こうが最後の同期のあとに変わっていない:手元を送る。向こうにあるのは自分が最後に送ったものなので、誰のものも消さない。
- 向こうが変わっている:押し付けない。日は突き合わせ、設定は向こうを採る。
一度送るか受け取れば、新しい形で控え直すので、次からはふつうに戻ります。
偽のサーバーも、本物と同じくキーを並べ替えて返すようにしました。足したテスト4件は、直す前のコードで4件とも落ちることを確かめています。シミュレータを3回開いて、1回目で新しい指紋へ切り替わり、スマホの最新の今日を上書きせずに受け取ること、2回目と3回目は何も送らないことも確かめました。
同じ種類の不具合を防ぐために
今回のことから、次の3つを気をつけるようにしました。
1つ目は、「同じか」を比べる値は、表し方の違いに左右されない形にしてから取ることです。キーの並び、undefined の有無などです。データベースや通信を通ると、中身が同じでも形は変わりえます。
2つ目は、テスト用の偽物は、本物がデータを変えるところまで真似ることです。今回は「並べ替えて返す」という一点を真似ていなかったために、不具合がテストをすり抜けました。
3つ目は、「変わった」と判定されたものが勝つ作りでは、判定の誤りがそのままデータの上書きになる、と覚えておくことです。何も変えていないのに、開いただけで何かを送っている端末があれば、それは疑うきっかけになります。
まとめ
同期のずれの主因は、キーの並び順で値が変わる指紋でした。並べてから指紋を取り、古い指紋の端末を別扱いにして直しました。偽のサーバーも本物と同じく並べ替えるようにしています。