4. O JSON store
Persistir brains e chunks em .locallens/store.json — e nada mais.
src/store.ts expõe uma classe minúscula que lê e escreve um arquivo
JSON. É o único módulo que toca o sistema de arquivos local em modo
write, e ignora o QVAC completamente de propósito.
O que vive no arquivo
{
"brains": [Brain, …],
"chunks": {
"<brainId>": [TextChunk, …]
}
}Esse é o schema em disco inteiro. Embeddings e pesos de modelo vivem no storage do próprio QVAC.
A classe
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`);
}
}Por que JSON, não SQLite
Para uma contagem de brains nas dúzias — o teto realista para este app — um banco de verdade não vale o overhead. JSON nos dá:
- Backup trivial. Copia o arquivo. Você tem um backup.
- Inspeção trivial.
cat .locallens/store.json | jqte mostra o que está rolando. - Sem migrations de schema. Campos novos no
Brainsão aditivos. Entradas velhas continuam lendo bem.
O custo é concorrência. O store não é safe sob múltiplos writers. Isso bate com o modelo de ameaça: um usuário, um processo.
Por que rootPath injetado no construtor
constructor(private readonly rootPath = path.join(process.cwd(), ".locallens")) {O default cai em <cwd>/.locallens/, então uso do dia a dia simplesmente
funciona. Testes sobrescrevem para escrever em diretório temp. Sem framework de
DI — só um parâmetro com default.
O que esse módulo deliberadamente não possui
- Não chama o QVAC. Apagar um brain aqui só remove a
entrada JSON. O cleanup do workspace acontece em
LocalLensApp.deleteBrain. - Não valida nomes de brain nem formato de chunk. Isso pertence à camada de workflow.
- Não gerencia embeddings. Esses vivem inteiramente do lado QVAC.
Por que uma classe com read/write privados?
read() e write() são privados porque chamadores nunca devem
tocar o formato cru. Eles passam pelos métodos nomeados, que agem
como uma interface CRUD minúscula. Essa é a diferença entre um módulo
e um blob key-value — pequena, mas importa.
A seguir: adaptadores de arquivo, o outro módulo que conversa com o disco.