LocalLens
ウォークスルー

4. JSON ストア

brain とチャンクを .locallens/store.json に保存する — それだけを担う。

src/store.ts は JSON ファイル 1 つを読み書きする小さなクラスを公開します。 ローカルファイルシステムを書き込みモードで触る唯一のモジュールであり、 QVAC のことは意図的に何も知りません。

ファイルに置くもの

{
  "brains": [Brain, ],
  "chunks": {
    "<brainId>": [TextChunk, ]
  }
}

ディスク上のスキーマはこれで全部です。embedding とモデルの重みは QVAC 独自のストレージに置かれます。

クラス

src/store.ts
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import type { Brain, TextChunk } from "./domain.ts";

type StoreData = {
  brains: Brain[];
  chunks: Record<string, TextChunk[]>;
};

export class LocalLensStore {
  private readonly dataPath: string;

  constructor(private readonly rootPath = path.join(process.cwd(), ".locallens")) {
    this.dataPath = path.join(rootPath, "store.json");
  }

  async listBrains(): Promise<Brain[]> {
    return (await this.read()).brains.sort((a, b) => a.name.localeCompare(b.name));
  }

  async getBrain(id: string): Promise<Brain | undefined> {
    return (await this.read()).brains.find((brain) => brain.id === id);
  }

  async saveBrain(brain: Brain): Promise<void> {
    const data = await this.read();
    data.brains = [...data.brains.filter((item) => item.id !== brain.id), brain];
    await this.write(data);
  }

  async deleteBrain(id: string): Promise<void> {
    const data = await this.read();
    data.brains = data.brains.filter((brain) => brain.id !== id);
    delete data.chunks[id];
    await this.write(data);
  }

  async saveChunks(brainId: string, chunks: TextChunk[]): Promise<void> {
    const data = await this.read();
    data.chunks[brainId] = chunks;
    await this.write(data);
  }

  private async read(): Promise<StoreData> {
    await mkdir(this.rootPath, { recursive: true });
    const raw = await readFile(this.dataPath, "utf8").catch(() => "");
    return raw ? (JSON.parse(raw) as StoreData) : { brains: [], chunks: {} };
  }

  private async write(data: StoreData): Promise<void> {
    await mkdir(this.rootPath, { recursive: true });
    await writeFile(this.dataPath, `${JSON.stringify(data, null, 2)}\n`);
  }
}

SQLite ではなく JSON にする理由

このアプリの現実的な上限である brain 数十個程度であれば、本物の データベースを引き入れるオーバーヘッドに見合いません。JSON で得られるのは こんなものです。

  • バックアップが簡単。 ファイルをコピーすればバックアップ完了。
  • 覗き見が簡単。 cat .locallens/store.json | jq で中身が分かります。
  • スキーママイグレーションがいらない。 Brain への新しいフィールドは 追加方向。古いエントリも問題なく読み込めます。

コストは並行性です。複数ライターには安全ではありません。これは想定する 脅威モデル(ユーザー 1 人、プロセス 1 つ)と一致しています。

コンストラクタで rootPath を注入する理由

constructor(private readonly rootPath = path.join(process.cwd(), ".locallens")) {

デフォルトは <cwd>/.locallens/ に落ちるので、日常の利用では何もしなくて 動きます。テストは temp ディレクトリに書くようにこれをオーバーライドします。 DI フレームワークではなく、ただのデフォルト値付きパラメータです。

このモジュールが意図的に持たないもの

  • QVAC を呼びません。ここで brain を削除しても、消えるのは JSON エントリ だけです。ワークスペースのクリーンアップは LocalLensApp.deleteBrain で行われます。
  • brain 名やチャンクの形状を検証しません。それはワークフロー層の仕事です。
  • embedding は管理しません。それらは完全に QVAC 側に置かれます。

なぜ read/write を private にしたクラスなのか?

read()write() を private にしてあるのは、呼び出し元が生の形に 触れてはいけないからです。すべて名前付きのメソッド経由になり、これらが 小さな CRUD インターフェースとして機能します。これがモジュールと キーバリューな塊との違いで、小さくも重要な差です。

次はファイルアダプタ、もう 1 つのディスクを 触るモジュールです。

On this page