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
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 | jqte muestra qué pasa. - Sin migraciones de esquema. Campos nuevos en
Brainson 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.