レプリケーションのマージ戦略
このドキュメントは 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、
MCPwrite_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}) より扱いやすい