garden.oooのデータアーキテクチャ
garden.ooo のデータは3つの層を持っている。ローカルのテキストファイルを SSOT(Single Source of Truth)として、ブラウザ内の DB、リモートの DB へと段階的にミラーしていく構造。
各層の役割
Local File(SSOT)
ローカルディスク上のテキストファイル群。現時点では Obsidian 互換の Markdown + YAML frontmatter を採用しているけれど、将来的には AST にパース可能な任意の軽量マークアップ言語を扱えるようにする想定(→ 情報設計 のコンテンツパイプライン)。
File System Access API を介してブラウザからアクセスする。これがオリジナルで、クラウドはそのミラー。
Local DB
ブラウザ内の RxDB インスタンス(バックエンドは Dexie/IndexedDB)。
ページの検索、バックリンクの解決、リアクティブなクエリといった CMS としての機能を担っている。Local File から読み込んだ内容をパースして、構造化されたドキュメントとして保持する。
Remote DB
サーバー上の RxDB インスタンス(バックエンドは MongoDB)。
Web 公開、モバイル端末との同期、複数人での共同編集を可能にする。Local DB と HTTP pull/push で同期する。レプリケーション戦略の詳細は レプリケーション を参照。
content_id によるリネーム追跡
ファイル名(path)はいつでもリネームされうる。「編集とリネームを同時に行った」場合でも追跡できるように、各ページは content_id という不変の識別子を持っている。
- ULID で生成され
- frontmatter に埋め込まれ、ファイルと一緒に移動する
- DB 上では
content_idが primary key として使われる
Local File ↔ Local DB の同期
初期同期
アプリ起動時に1回だけ走る。
- ローカルの
.mdファイルをすべて読み込み →Map<content_id, {page, fileLastModified}> - DB の全ドキュメントを取得 →
Map<content_id, {page, dbLwt: _meta.lwt}> - 両方に存在する場合:
- 同一 → スキップ
- 異なる → タイムスタンプが新しい方のパスを採用
- ローカルのみに存在
- → その更新日時よりも新しいtombstoneがLocal DBに存在
- する → ローカルを削除
- しない → DB に upsert
- → その更新日時よりも新しいtombstoneがLocal DBに存在
- DB のみに存在 → ローカルに保存
タイムスタンプは frontmatter の modified ではなく、システムタイムスタンプ(file.lastModified, _meta.lwt)を使っている。frontmatter の modified はユーザーが編集できるし、Obsidian みたいなツールは保存時に更新しないこともあるので、信頼できない。
ただしこのアルゴリズムは、5. の場合にローカルの削除を追跡できない。明示的に削除されたのか、まだ新しいページがローカルに同期されてないのか区別がつかないから。ただし、DB側が、最後に同期した際のローカルファイルのハッシュを持ってたりすると (存在していない場合はnull) 、アプリが走ってないときに明示的に (他アプリなどで) 削除されたなどが検知できる可能性がある。
継続同期: DB → Local
db.pages.$を監視する。
- INSERT: 新規ファイルを保存
- UPDATE: パスが変わっていれば旧ファイルを削除してから新ファイルを保存。そうでなければ上書き
- DELETE: ファイルを削除
継続同期: Local → DB
FileSystemObserver でファイルシステムの変更をバッチで検知する。
- appeared / modified / moved: ファイルを読み込み → DB に upsert。生存している
content_idを収集 - disappeared: パスから DB を検索 → 生存セットに含まれていなければ DB から削除
ループ防止
双方向同期では「DB→Local の書き込みが Local→DB の変更として検知される」無限ループが問題になる。3段階で防止している。
- コンテンツ比較(Level 1): ファイル書き込み時に内容が同一なら書き込みをスキップ → FileSystemObserver イベントが発火しない
- 抑制セット(Level 2): DB→Local 書き込み時にパスを登録。Observer はそのパスをスキップ
- 冪等な upsert(Level 3): 同一データの upsert は実質的な変更を生まない → Level 1 で止まる