LocalLens
Walkthrough

4. El store JSON

Persiste cerebros y chunks en .locallens/store.json — y nada más.

src/store.ts expone una clase pequeña que lee y escribe un solo archivo JSON. Es el único módulo que toca el filesystem local en modo escritura, y deliberadamente ignora QVAC por completo.

Qué vive en el archivo

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

Ese es todo el esquema en disco. Los embeddings y los pesos de modelos viven en el almacenamiento propio de QVAC.

La clase

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 qué JSON y no SQLite

Para una cuenta de cerebros en las docenas — el techo realista para esta app — una base de datos real no vale el overhead. JSON nos compra:

  • Backup trivial. Copia el archivo. Tienes un backup.
  • Inspección trivial. cat .locallens/store.json | jq te muestra qué pasa.
  • Sin migraciones de esquema. Campos nuevos en Brain son aditivos. Las entradas viejas siguen leyéndose bien.

El costo es la concurrencia. El store no es seguro bajo varios escritores. Eso encaja con el modelo de amenazas: un usuario, un proceso.

Por qué rootPath inyectado por constructor

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

El default aterriza en <cwd>/.locallens/, así que el uso del día a día Just Works. Los tests lo sobreescriben para escribir en un directorio temporal. Sin framework de DI — solo un parámetro con un default.

Lo que este módulo deliberadamente no posee

  • No llama a QVAC. Borrar un cerebro acá solo remueve la entrada JSON. La limpieza del workspace pasa en LocalLensApp.deleteBrain.
  • No valida nombres de cerebros ni formas de chunks. Eso le corresponde a la capa de workflow.
  • No maneja embeddings. Esos viven enteramente del lado de QVAC.

¿Por qué una clase con read/write privados?

read() y write() son privados porque los callers nunca deberían tocar la forma cruda. Pasan por los métodos con nombre, que actúan como una interfaz CRUD chica. Esa es la diferencia entre un módulo y un blob clave-valor — chica, pero importa.

Lo que sigue: adaptadores de archivos, el otro módulo que habla con el disco.

On this page