SQLite dentro del navegador con sql.js e IndexedDB, sin perder documentos
CrasterDoc no tiene cuentas ni una base de datos de usuarios: los documentos de cada persona viven en su navegador. En lugar de guardar un montón de JSON suelto en localStorage, el archivero usa una base SQLite compilada a WebAssembly (sql.js) y la persiste en IndexedDB. Este artículo cuenta por qué y cómo, con código de src/lib/bd.ts.
Por qué SQLite y no localStorage
Un archivero tiene carpetas anidadas, etiquetas, búsqueda y enlaces entre documentos con la sintaxis [[código]]. Eso son consultas: «los documentos de esta carpeta», «a quién enlaza este documento», «cuáles tienen esta etiqueta». Con SQL son una línea; con JSON en localStorage son bucles y un límite de cupo pequeño. Las tablas son pocas y claras:
CREATE TABLE IF NOT EXISTS documentos (
id TEXT PRIMARY KEY,
carpeta_id TEXT NOT NULL,
json TEXT NOT NULL,
codigo TEXT,
titulo TEXT,
modulo TEXT,
etiquetas TEXT,
actualizado_en TEXT
);
CREATE TABLE IF NOT EXISTS enlaces (
origen_id TEXT NOT NULL,
destino TEXT NOT NULL,
PRIMARY KEY (origen_id, destino)
);
El documento completo se guarda como JSON en una columna, y las columnas sueltas (codigo, titulo, etiquetas) existen para buscar y ordenar sin deserializar todo.
Persistir: exportar la base entera a IndexedDB
sql.js trabaja en memoria. Para que sobreviva a cerrar la pestaña, cada escritura exporta la base completa como un Uint8Array y la guarda en IndexedDB:
async function persistir(): Promise<void> {
const e = estado()
e.persistiendo = e.persistiendo.then(async () => {
if (!e.db) return
await escribirBytes(e.db.export())
})
return e.persistiendo
}
La clave está en e.persistiendo.then(...): las escrituras se encadenan en una promesa, así que dos guardados seguidos nunca compiten por IndexedDB ni se pisan al revés. No es la solución más sofisticada, y exportar la base completa en cada guardado solo es razonable porque la base de una persona es pequeña. Es una decisión consciente: simple, fácil de razonar y suficiente para este tamaño de datos.
El estado cuelga de globalThis (por la recarga en caliente)
Este fue un error real. Con la recarga en caliente de Next, un módulo se vuelve a evaluar. Si la base abierta vivía en una variable del módulo, tras recargar la variable volvía a null mientras la pantalla seguía creyendo que el archivero estaba listo, con el resultado «La base SQLite aún no está lista» y una recarga completa en bucle. La solución es guardar el estado en globalThis:
const ESTADO_GLOBAL = '__crasterdocBd'
function estado(): EstadoBd {
const g = globalThis as unknown as Record<string, EstadoBd | undefined>
return (g[ESTADO_GLOBAL] ??= { db: null, persistiendo: Promise.resolve() })
}
El ??= crea el estado la primera vez y lo reutiliza después, aunque el módulo se reevalúe. Es un patrón general para singletons en desarrollo con HMR.
Un documento corrupto no se pierde: se aparta
Aquí está la decisión de producto que más me importa. Al abrir la base, cada fila de documentos se valida. Si el JSON está dañado, o no tiene la forma esperada, el documento no se borra ni se ignora: se mueve a una tabla recuperados con su texto original y el motivo.
function aislarInvalidosSync() {
const { recuperados } = clasificarFilas(filasDocumentosSync())
for (const r of recuperados) {
sql().run('INSERT OR REPLACE INTO recuperados (id, json, motivo, titulo) VALUES (?, ?, ?, ?)', [
r.id, r.json, r.motivo, r.titulo,
])
sql().run('DELETE FROM documentos WHERE id = ?', [r.id])
sql().run('DELETE FROM enlaces WHERE origen_id = ?', [r.id])
}
}
La pantalla del archivero avisa y deja descargar el documento tal como estaba guardado. Incluso intenta rescatar un título legible de las versiones para que la persona reconozca qué archivo es. La regla es simple: si algo no se puede abrir, el programa se lo dice al usuario; nunca decide por él que se pierda.
Borrar tampoco es definitivo
Eliminar un documento lo mueve a la papelera durante 30 días, y se puede restaurar. Internamente cada baja registra su fecha y la lista de bajas se fusiona sin duplicados (fusionarBajas). Al importar un archivero, el usuario elige «Combinar» (se suma lo del archivo, y si un documento está en los dos lados gana el modificado más recientemente) o «Reemplazar todo» (con confirmación).
Migración sin romper a nadie
La primera versión guardaba todo en localStorage. Al iniciar la base, si está vacía, iniciarBd lee el almacén antiguo, copia carpetas, documentos, marcas y recuperados a SQLite, persiste, y solo entonces borra la clave antigua. Si algo falla antes, los datos viejos siguen ahí.
Lo que me llevo
- SQLite en el navegador es viable para datos personales de tamaño modesto, y SQL simplifica mucho un archivero con carpetas, etiquetas y enlaces.
- Serializa las escrituras: una cadena de promesas es suficiente y evita carreras.
- Los singletons de desarrollo necesitan globalThis si usas HMR.
- Diseña para no perder datos: apartar y avisar es mejor que descartar. Es la parte que la gente nota solo cuando falta.
Cada persona guarda sus documentos en su navegador: localhost y la versión publicada no comparten archivero, y por eso el producto ofrece copia local y respaldo opcional en Drive. Puedes probarlo en crasterdoc.morelodev.com, ver la ficha del producto o leer Cómo se construyó.