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
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 | jqshows you what's going on. - No schema migrations. New fields on
Brainare 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.