LocalLens
Walkthrough

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

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

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 | jq te mostra o que está rolando.
  • Sem migrations de schema. Campos novos no Brain sã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.

On this page