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