データアーキテクチャ
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 の同期
初期同期(Union Merge)
アプリ起動時に1回だけ走る。追加のみで削除はしない。
- ローカルの
.mdファイルをすべて読み込み →Map<content_id, {page, fileLastModified}> - DB の全ドキュメントを取得し、
softDeleted: trueを除外 →Map<content_id, {page, updatedAt}> - 両方に存在する場合:
- ソース同一 & パス同一 → スキップ
- ソース同一 & パス異なる → タイムスタンプが新しい方のパスを採用
- ソース異なる → タイムスタンプが新しい方を両側に反映
- ローカルのみに存在 → DB に upsert
- DB のみに存在 → ローカルに保存
softDeleted: true のドキュメントは初期同期の比較対象から外す。対応するローカルファイルがまだ残っていれば、そのローカルファイルを upsert して復活させる。
タイムスタンプは frontmatter の modified ではなく、システムタイムスタンプ(file.lastModified, PageDoc.updatedAt)を使っている。frontmatter の modified はユーザーが編集できるし、Obsidian みたいなツールは保存時に更新しないこともあるので、信頼できない。
継続同期: DB → Local
db.pages.$を監視する。
- INSERT:
softDeleted: falseの新規ページならファイルを保存 - UPDATE: パスが変わっていれば旧ファイルを削除してから新ファイルを保存。
softDeleted: trueになった更新はファイル削除として扱う - DELETE: 実運用ではほぼ使わない。RxDB ドキュメントの物理削除が起きた場合だけファイルを削除
継続同期: Local → DB
FileSystemObserver でファイルシステムの変更をバッチで検知する。
- appeared / modified / moved: ファイルを読み込み → DB に upsert。生存している
content_idを収集 - disappeared: パスから DB を検索 → 生存セットに含まれていなければ
removePage()を呼び、softDeleted: trueとして扱う
ループ防止
双方向同期では「DB→Local の書き込みが Local→DB の変更として検知される」無限ループが問題になる。3段階で防止している。
- コンテンツ比較(Level 1): ファイル書き込み時に内容が同一なら書き込みをスキップ → FileSystemObserver イベントが発火しない
- 抑制セット(Level 2): DB→Local 書き込み時にパスを登録。Observer はそのパスをスキップ
- 冪等な upsert(Level 3): 同一データの upsert は実質的な変更を生まない → Level 1 で止まる