Baku Hashimoto

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

このドキュメントは 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 として返却

isPageIdenticalsoftDeleted + 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}) より扱いやすい
Some Rights Reserved. (cc) 2026 Baku Hashimoto
This site is generated by Garden.ooo