データアーキテクチャ
garden.ooo のデータは3つの層を持っている。ローカルのテキストファイルを SSOT(Single Source of Truth)として、ブラウザ内の DB、リモートの DB へと段階的にミラーしていく構造。
Local File (.md) ←→ Local DB (RxDB/IndexedDB) ←→ Remote DB (RxDB/MongoDB)
SSOT ブラウザ内 サーバー各層の役割
Local File(SSOT)
ローカルディスク上のテキストファイル群。現時点では Obsidian 互換の Markdown + YAML frontmatter を採用しているけれど、将来的には AST にパース可能な任意の軽量マークアップ言語を扱えるようにする想定(→ information-architecture.md のコンテンツパイプライン)。
File System Access API を介してブラウザからアクセスする。これがオリジナルで、クラウドはそのミラー。
Local DB
ブラウザ内の RxDB インスタンス(バックエンドは Dexie/IndexedDB)。
ページの検索、バックリンクの解決、リアクティブなクエリといった CMS としての機能を担っている。
Local File から読み込んだ内容をパースして、構造化されたドキュメントとして保持する。
Remote DB
サーバー上の RxDB インスタンス(バックエンドは MongoDB)。
Web 公開、モバイル端末との同期、複数人での共同編集を可能にする。
Local DB と HTTP pull/push で同期する。レプリケーション戦略の詳細は replication.md を参照。
content_id によるリネーム追跡
ファイル名(path)はいつでもリネームされうる。「編集とリネームを同時に行った」場合でも追跡できるように、各ページは content_id という不変の識別子を持っている。
- ULID で生成される(26文字の英数字)
- frontmatter に埋め込まれ、ファイルと一緒に移動する
- DB 上では
content.content_idから導かれるidが primary key として使われる
Local File ↔ Local DB の同期
Local DB ↔ Remote DB と同じく replicateRxCollection を使った
replication として実装している(utils/sync/replicateLocalFiles.ts)。
つまり db.pages コレクションには 2 本の replication が同時に走る:
Local File ⇆ Local DB ⇆ Remote DB
replicateRxCollection × 2replicationIdentifier でそれぞれ flag されるので、FS から pull した
doc は Remote へ push されるが FS には押し戻されない、逆も同様。
ping-pong は RxDB 側で標準的に防がれる。
Pull (FS → DB)
fs.list() でディレクトリを走査し、各ファイルを localPageMeta.lastSyncedHash
と突き合わせる。ハッシュが違うものだけを doc として emit する。
- 初回起動時は全ファイルが
pendingPathsキューに積まれ、batchSizeごとに
pull が呼ばれる - それ以降は
FileSystemObserverのイベントをpull.stream$に流して
RxDB を起こす - 消えたファイル:
localPageMeta.lastSeenPathで逆引きしてsoftDeleted: trueを emit - orphan (
content_idが frontmatter に無いファイル): pull 時に新しい id を
生成し、その場で FS に書き戻して から doc を emit する。echo-prevention
のために push handler が押し戻すことができないため
判定基準は frontmatter の updated でも mtime でもなく コンテンツの
ハッシュ(source_hash 相当)。mtime はクロスプラットフォームで信頼できない
(Dropbox/iCloud による書き換え、エディタによる保持、NTP 補正での巻き戻り等)し、updated はユーザが任意でスタンプする訪問者向けの値で同期判定には使えない。
Push (DB → FS)
DB の doc が変わると push handler が呼ばれる。assumedMasterState (RxDB が
記憶している前回の master 状態) の hash と現在の FS ファイルの hash を比較:
- 一致 → 書き込み or 削除を実行、
lastSyncedHashを更新 - 不一致 → FS の現状を conflict として返却。
conflictHandlerがrealMasterState(= FS) を採用 → ローカル doc が FS の内容で上書きされる
assumedMasterState が undefined(fresh replication 起動時など)の場合はlocalPageMeta.lastSyncedHash にフォールバックする。これがないと pull した
ばかりの同一内容ファイルが全部 conflict 扱いになる。
Echo 防止
「DB→FS 書き込みが FileSystemObserver から Local→DB として検知される」
ループは、localPageMeta.lastSyncedHash が唯一の echo フィルタとして
畳まれている:
| 発火源 | hash vs lastSyncedHash | 結果 |
|---|---|---|
| 自分の push 直後 | 一致(直前に更新済み) | pull 側で skip |
| Dropbox の同内容書き戻し | 一致 | skip |
| Dropbox 経由の新内容 | 不一致 | 真の変更として処理 |
| ユーザの編集 | 不一致 | 真の変更として処理 |
| FS Observer のバースト発火 | 一致(2回目以降) | 自動的に冪等 |
旧実装にあった「抑制セット」「保存前の content 比較」は不要になった
(ハッシュ比較に統合された)。
マルチタブ協調
waitForLeadership: true (デフォルト) と RxDBLeaderElectionPlugin により、
リーダータブ 1 つだけが FS replication を回す。他のタブは Local DB の
変更を BroadcastChannel 経由で受け取るだけ。複数タブで同時に FS ハンドルを
触り合うレースが構造的に消える。