ウォークスルー
4. JSON ストア
brain とチャンクを .locallens/store.json に保存する — それだけを担う。
src/store.ts は JSON ファイル 1 つを読み書きする小さなクラスを公開します。
ローカルファイルシステムを書き込みモードで触る唯一のモジュールであり、
QVAC のことは意図的に何も知りません。
ファイルに置くもの
{
"brains": [Brain, …],
"chunks": {
"<brainId>": [TextChunk, …]
}
}ディスク上のスキーマはこれで全部です。embedding とモデルの重みは QVAC 独自のストレージに置かれます。
クラス
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 つのディスクを 触るモジュールです。