LocalLens

4. The JSON store

Persist brains and chunks to .locallens/store.json — and nothing more.

src/store.ts exposes a tiny class that reads and writes one JSON file. It's the only module that touches the local filesystem in write mode, and it deliberately ignores QVAC entirely.

What lives in the file

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

That's the whole on-disk schema. Embeddings and model weights live in QVAC's own storage.

The class

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`);
  }
}

Why JSON, not SQLite

For a brain count in the dozens — the realistic ceiling for this app — a real database isn't worth the overhead. JSON buys us:

  • Trivial backup. Copy the file. You have a backup.
  • Trivial inspection. cat .locallens/store.json | jq shows you what's going on.
  • No schema migrations. New fields on Brain are additive. Old entries still read fine.

The cost is concurrency. The store isn't safe under multiple writers. That matches the threat model: one user, one process.

Why constructor-injected rootPath

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

The default lands at <cwd>/.locallens/, so day-to-day usage Just Works. Tests override it to write into a temp directory. No DI framework — just a parameter with a default.

What this module deliberately does not own

  • It doesn't call QVAC. Deleting a brain here only removes the JSON entry. The workspace cleanup happens in LocalLensApp.deleteBrain.
  • It doesn't validate brain names or chunk shapes. That belongs to the workflow layer.
  • It doesn't manage embeddings. Those live entirely on the QVAC side.

Why a class with private read/write?

read() and write() are private because callers should never touch the raw shape. They go through the named methods, which act as a tiny CRUD interface. That's the difference between a module and a key-value blob — small, but it matters.

Next: file adapters, the other module that talks to disk.

On this page