レプリケーションのマージ戦略

このドキュメントは data-architecture.md の「Local DB ↔ Remote DB」同期の詳細。

概要

各ブラウザはローカルの RxDB (IndexedDB) を持ち、HTTP pull/push を介して
中央の RxDB (MongoDB) サーバと同期する。
さしあたり共同同時編集は想定せず、単一ユーザが一度に一つのデバイスで操作する前提。

戦略: Server Wins

push 時に conflict が検出された場合、サーバの状態を正とする。
クライアントはサーバ状態を受け入れ、ローカルの差分を破棄する。
RxDB の conflictHandler.resolve も常に realMasterState を返す。

Pull: seq による単調カーソル

サーバが accept した書き込みごとに、サーバ側の seq ローカルドキュメント
(db.pages.local('seq')) を incrementalModify で原子的に +1 し、
新しい seq を doc にスタンプする。

  • Pull cursor: {seq: number} の単一値
  • Pull selector: {seq: {$gt: checkpoint.seq}}、sort: [{seq: 'asc'}]
  • wire 形: クライアント schema には seq が無いので、サーバはレスポンス組み立て時に
    各 doc から seq を destructure で除去してから返す

seq は サーバ側のスキーマだけ に存在する。pageSchema.ts で
import.meta.server 分岐により schema を fork し、サーバ schema は
seq を required + indexed、クライアント schema は seq を持たない
(additionalProperties: false で混入を弾く)。

カウンタ世代とフルリシンク・ガード

seq カウンタは pages と同じ Mongo データベース内のローカルドキュメントなので、
スキーマ変更規約(migration なしで wipe & rebuild)で 一緒に消えて 0 付近から
再スタートする
。一方、warm クライアントの checkpoint は wipe を生き延びる
(seq はサーバ専用 schema なので、クライアント側の schema hash が変わらず
DB6 auto-wipe が発火しない)。旧世代の checkpoint は新カウンタより数値的に
先にいるため、seq > checkpoint が永遠にマッチしない ——
pull は空、push は正常という「聾だが喋る」片方向状態に陥る
(2026-08 に実際に発生: checkpoint 50792 vs 再生成カウンタ 25119)。

対策として pull ハンドラは checkpoint がカウンタ現在値より先にいたら
死んだ世代の checkpoint とみなし、null に落としてフルリシンクを返す
。
矛盾はサーバ単体で検出できるので、取り残されたクライアントの回復は自動。

変更通知: いつ pull するか

pull は ポーリングしない。RxDB の live replication はストリームを与えない限り
自発的には再 pull せず、以前は reSync() のキック(online 復帰・タブの
visible 復帰・ページ遷移時の priority pull・push 直後)だけが pull の契機だった。
つまり 1 ページを開いたまま動かない admin には、別デバイスや MCP の編集が
降ってこなかった。

今はサーバの変更通知を pull.stream$ に流している
(utils/rxdb/replication.ts, utils/pageEvents/)。

  • サーバ: GET /.garden/api/pages/events が server-sent events で、accept した
    書き込み 1 件につき page イベントを 1 つ流す。中身は見出しだけ
    (seq, content_id, slug, source_hash, softDeleted) で本文は載せない。
    発生源はサーバ側 RxDB の db.pages.$(全書き込み経路 — replication push、
    MCP write_page、write API、削除、view count の fold — が通る)。
    SSE の id: は seq そのもので、再接続時の Last-Event-ID(または
    自前で閉じて開き直すときの ?after=)より後の書き込みを再送するので、
    切断中の取りこぼしは無い(再送上限 200 件)。
  • admin クライアント: イベントを受けたら slug を priority pull に積み、500ms
    デバウンスして 'RESYNC' を emit する。あとは通常の checkpoint pull なので、
    warm な vault なら変わった doc だけが返り、conflict 処理やエコー抑止は従来の
    経路がそのまま効く。自分が push した source_hash が戻ってきたイベントは
    60 秒間は自分のエコーとみなして無視する(conflict は master 側の hash で
    届くので無視されない)。接続は replication の購読と同じ寿命 — leader タブで
    start したときに張り、cancel で閉じる。
  • 閲覧者(非 admin): replication を持たないので、画面上のページの
    content_id だけを ?content_id= で購読し、通知が来たら page API で再取得して
    seedCache に流す(vault.refreshFromServer)。間引きは、表示から 3 秒後に
    接続・2 秒デバウンス・visible なタブでのみ適用(hidden 中は復帰時に 1 回)・
    5 分 hidden が続いたら切断して復帰時に ?after= で再開。public / garden /
    unlisted のページだけ通知し、private 化や削除は通知しない(private になった
    ことを知らせない)。
    手元の slug と source_hash が通知と同じなら本文は変わっていない(view count の
    fold など admin 専用フィールドだけの書き込み)ので再取得しない
    (announcesChangeFrom)。閲覧者自身の view beacon が約 1 分後に fold されて
    通知として戻ってくるため、これが無いと全閲覧者が 1 回余分に再取得する。
  • 編集中の衝突: 編集中のページに通知経由で別版が降りてきても、エディタの
    テキストは置き換えない。既存の「別のセッションがこのページを更新しました」
    カード(取り込む / 自分の版で保存)がリッチ編集・ソース編集の両方で出る。

nginx 側の設定変更は不要(h3 が X-Accel-Buffering: no を付ける。本番で
curl -N により ready が即座に届くことを確認済み)。
プロセス終了時 (pm2 reload) は Nitro の close hook で全ストリームを閉じる。

Conflict 検出 (Push)

各 push 行には assumedMasterState(クライアントが想定するサーバの現在状態)が
付与される。サーバはこれを実際の状態と比較する:

  • ID 一致 + isPageIdentical(assumed, current) → 書き込みを受理 → 新しい seq を
    発番してスタンプ → upsert
  • いずれかが不一致 → 書き込みを拒否し、サーバの実状態を conflict として返却

isPageIdentical は softDeleted + content.path + content.source_hash の
3 フィールド比較のみ。source_hash(markdown 全文の ohash フィンガープリント)が
content の正規 identity なので、これだけで content equality が決まる。

クライアントの conflict handler はサーバ状態を採用して resolve する。

なお conflictHandler は collection 単位で 1 つしか持てない仕様だが、
同じハンドラを Remote DB / FS の両 replication で共用できる。各 replication
の視点で master = upstream が勝つ、という「master wins」だけ書いておけば、
Remote replication からは Server Wins、FS replication からは FS Wins として
自然に意味が通るため。詳しくは data-architecture.md の
「Local File ↔ Local DB」セクションを参照。

削除

ページの soft delete は PageDoc.softDeleted: true を立てて upsert するだけ。
RxDB native の _deleted ではなく通常フィールドなので、replication は
他のフィールド変更と同じ経路で削除フラグを伝播する。tombstone 専用の
コレクションは持たない。

クライアント側のクエリは selector: {softDeleted: false} で生存ページだけを
表示する。

なぜ単純 seq か

CouchDB スタイルの rev hash や HLC は採らない。理由:

  • 単一ユーザ + Server Wins — 因果的順序や rev tree のリッチさが要らない
  • オフライン書き込みの正しさ — クライアントが offline 中に作成した doc が
    オンライン復帰時に push されたとき、サーバが新しい seq をスタンプするので、
    他デバイスの pull 検査で必ず拾える。クライアント時計ベースの cursor だと、
    古い時計値で書かれた doc を他デバイスの checkpoint が追い越して取りこぼす
    可能性があった(旧 indexedAt 方式の問題)
  • 構造的単純さ — 単一の整数 cursor で済む。複合 keyset
    ({id, indexedAt}) より扱いやすい

関連項目