wandres.dev
EL CATÁLOGO DE BASES LOCALES · y cómo elegir

Envoltorios y almacenes reactivos: lo que añaden y su techo

Consultas vivas, tipado, migraciones y observabilidad son lo que de verdad compras al subir un escalón sobre la API cruda, y conviene saber cuál de las cuatro justifica la dependencia y dónde se acaba.

⏱ 18 min

Las dos primeras familias del catálogo comparten una propiedad que las hace comparables entre sí y difíciles de comparar con las otras dos: ninguna trae un motor de consultas nuevo. Un envoltorio dirige el motor del navegador; un almacén reactivo mantiene el estado en memoria y consulta ahí. Lo que ambos venden, por tanto, no es capacidad de cálculo sino algo más sutil y a menudo más valioso: la eliminación de cuatro déficits concretos que convierten la construcción de una interfaz sobre datos locales en un ejercicio de contabilidad manual. Merece la pena examinar esos cuatro déficits uno a uno, porque solo uno de ellos es realmente difícil de improvisar, y porque el punto donde esta capa intermedia se agota está definido con una precisión que sorprende a quien la descubre tarde.

🎯 Al terminar esta lección sabrás
  • Distinguir los cuatro servicios que aporta la capa intermedia y ordenarlos por dificultad real de construirlos uno mismo.
  • Entender el mecanismo interno de una consulta viva y por qué es la pieza que justifica la dependencia.
  • Ver qué cambia cuando el estado autorizado se muda a memoria y el disco pasa a ser un respaldo.
  • Localizar con precisión el techo de esta capa y reconocer las señales de que ya lo has tocado.

Las cuatro promesas de la capa intermedia

Los cuatro servicios que se compran en este escalón son ergonomía asíncrona, tipado del esquema, gestión de migraciones y observabilidad de los cambios. Están ordenados de menos a más difíciles de reproducir con código propio, y esa ordenación es el criterio con el que se decide si una dependencia compensa. Conviene sostener la ordenación con un argumento y no con una intuición: los tres primeros son transformaciones locales, en el sentido de que se pueden implementar mirando una sola llamada cada vez, mientras que el cuarto es global, porque exige saber qué relación tiene una escritura cualquiera con todas las lecturas activas del sistema. Esa diferencia de alcance es la que hace que el coste de construirlo uno mismo no crezca linealmente sino de golpe.

🔗

Ergonomía asíncrona

Promesas en lugar de manejadores de eventos, iteración de cursores como iterables asíncronos y errores que se propagan solos. Es real, es cómodo y es también lo más barato de escribir por tu cuenta: cuarenta líneas bien puestas cubren el caso completo. Nadie debería aceptar un modelo de datos ajeno a cambio únicamente de esto.

🏷️

Tipado del esquema

La relación entre el nombre de un almacén, la forma de sus registros y el tipo de su clave, declarada una vez y verificada por el compilador en cada acceso. Elimina una clase entera de errores silenciosos. También es replicable a mano, aunque el mantenimiento manual de esa correspondencia se degrada rápido cuando el esquema evoluciona.

🪜

Migraciones

Versiones declaradas de forma acumulativa y una función de transformación por escalón, ejecutadas en el momento correcto del ciclo de apertura. El valor no está en el mecanismo, que es sencillo, sino en la disciplina que impone: obliga a escribir la migración cuando cambias el esquema, y no seis meses después cuando alguien reaparece con datos viejos.

📡

Observabilidad

Saber qué consultas se han visto afectadas por una escritura concreta, reejecutar exactamente esas y propagar la conclusión a las demás pestañas del mismo origen. Es la única de las cuatro que no se improvisa, y por tanto la única que por sí sola justifica adoptar una dependencia con modelo propio.

💡
El orden de la lista es el orden de la decisión

Si el déficit que te está costando tiempo esta semana es el primero, compra lo más fino que exista y sigue. Si es el cuarto, deja de mirar el peso del paquete: estás comprando la única pieza cara. La mayoría de las discusiones sobre qué librería adoptar se resuelven en dos minutos si se empieza por nombrar en voz alta cuál de los cuatro déficits duele, en lugar de comparar listas de funciones que casi siempre coinciden.

La consulta viva por dentro

Conviene abrir la caja, porque entender cómo funciona una consulta viva es lo que permite predecir cuándo se va a comportar bien y cuándo va a reejecutar de más. La documentación de Dexie describe el mecanismo con bastante detalle: durante la ejecución de la función consultora se registra cada llamada a la API, anotando qué índice se consulta y con qué rango; esas anotaciones se guardan en una estructura pensada para detectar colisiones de rangos; y cuando una transacción de escritura confirma con éxito, las claves y rangos mutados se cotejan contra las consultas vivas activas usando esa misma estructura. Las mutaciones que añaden comprueban si el objeto nuevo cae dentro de algún rango observado; las que actualizan comprueban si un valor entra o sale de un rango en el que antes no estaba o sí estaba; y las que borran despiertan a las consultas cuyo resultado contenía esa clave primaria. Los rangos mutados se difunden además a otros contextos de navegación para despertar consultas vivas en otras pestañas o workers.

// Un unico patron: describir el resultado, no suscribirse a eventos.
const observable = liveQuery(() =>
  db.amigos.where("edad").between(50, 75).toArray()
);

const suscripcion = observable.subscribe({
  next: (resultado) => pintar(resultado),
  error: (e) => registrar(e),
});

// Y la regla que casi nadie lee: dentro del consultor solo
// se esperan promesas de la propia libreria.
const hash = liveQuery(async () => {
  const amigos = await db.amigos.toArray();
  const bytes = new TextEncoder().encode(JSON.stringify(amigos));
  return await Dexie.waitFor(crypto.subtle.digest("SHA-1", bytes));
});

De esa descripción se deducen dos propiedades que conviene tener presentes. La primera es la garantía asimétrica: el sistema puede producir falsos positivos, es decir, reejecutar una consulta que en realidad no cambió, pero nunca falsos negativos. Esa asimetría es exactamente la correcta para una interfaz, porque el coste de un repintado de más es un poco de trabajo desperdiciado mientras que el coste de uno de menos es una pantalla que miente. La segunda es que la finura del seguimiento depende de que todas las lecturas pasen por la librería: la propia documentación advierte de que no se deben llamar directamente API asíncronas ajenas dentro del consultor, y ofrece un envoltorio explícito para los casos en que hace falta. Un await sobre algo que la librería no reconoce rompe el registro de dependencias, y lo rompe en silencio.

Queda una consecuencia operativa que se descubre tarde y conviene anticipar: una consulta viva no es gratis, y su coste no está donde uno lo busca. No está en el cotejo de rangos, que es barato por diseño, sino en la reejecución del consultor, que vuelve a leer de la base y a construir el resultado entero cada vez que se dispara. Una vista que observa una colección grande y se reejecuta con cada escritura de un proceso de fondo puede consumir más tiempo repintando de lo que consume la aplicación trabajando. La disciplina que evita eso es la misma que en cualquier sistema reactivo: observar lo más estrecho posible, proyectar en la consulta solo los campos que la vista pinta, y separar las escrituras de alta frecuencia en colecciones que nadie observa directamente.

Cuando el disco pasa a ser un respaldo

El almacén reactivo lleva la misma idea un paso más allá y cambia el sitio donde vive la verdad. En lugar de observar un disco, mantiene el estado en memoria con una estructura propia y persiste de forma explícita. TinyBase organiza sus datos en tablas compuestas de filas y celdas, junto a un espacio separado de valores sueltos, y construye encima oyentes, transacciones, esquemas, consultas, índices, relaciones, métricas y puntos de control; sus guías tratan la persistencia y la sincronización como capas separadas del núcleo, y sus plantillas de arranque ofrecen persistir sobre SQLite o sobre PGlite y sincronizar contra un servidor remoto. Es decir: la reactividad es el producto, y el almacenamiento es un adaptador.

flowchart LR
A[Escritura en el almacen] --> B[Transaccion en memoria]
B --> C[Oyentes afectados]
C --> D[Interfaz repintada]
B --> E[Persistidor]
E --> F[Disco o base subyacente]
F -.-> G[Recuperacion al arrancar]
style B fill:#cba6f7,color:#11111b
style D fill:#a6e3a1,color:#11111b
style F fill:#89b4fa,color:#11111b

La forma del código lo refleja de inmediato: no hay promesas en la lectura, la escritura es una llamada y la suscripción se declara sobre una coordenada del modelo en lugar de sobre una consulta.

// Leer es mirar: no hay await, no hay estado de carga.
const titulo = store.getCell("docs", "d17", "titulo");

// Escribir dispara oyentes de forma sincrona dentro de la transaccion.
store.transaction(() => {
  store.setCell("docs", "d17", "titulo", "Nuevo titulo");
  store.setValue("ultimaEdicion", Date.now());
});

// La observacion se declara sobre una coordenada del modelo.
store.addCellListener("docs", "d17", "titulo", (_s, _t, _r, _c, nuevo) => {
  pintarTitulo(nuevo);
});

// Y la persistencia es una capa aparte, conectada explicitamente.
await persistidor.startAutoSave();

La consecuencia arquitectónica es limpia y hay que aceptarla entera. La lectura nunca es asíncrona porque nunca sale de la memoria, y eso simplifica una cantidad enorme de código de interfaz: desaparecen los estados de carga, las condiciones de carrera entre dos lecturas y la gestión de resultados obsoletos. A cambio, el conjunto de trabajo completo tiene que caber en memoria, y no en la memoria de tu máquina de desarrollo sino en la del dispositivo más modesto de tu base de usuarios. Ese techo se fija el primer día del proyecto y no lo mueve ninguna optimización posterior, porque no es un problema de eficiencia sino de dónde has decidido que viva el estado.

📝
La persistencia como adaptador tiene una virtud olvidada

Separar el almacén de su persistidor no es solo higiene de diseño: es la propiedad que permite cambiar de sustrato sin reescribir la aplicación. Empezar guardando en el almacenamiento más simple del navegador y mudarse después a IndexedDB, a una base en WebAssembly o a un servidor remoto se convierte en cambiar un adaptador en lugar de reescribir cada consulta. Cuando evalúes una herramienta de esta familia, mira primero si esa frontera existe de verdad en su arquitectura o si el almacenamiento está entretejido con el modelo, porque de ahí depende tu capacidad de rectificar más adelante.

Dónde está el techo

El techo de esta capa se reconoce por tres síntomas que aparecen siempre en el mismo orden. El primero es el filtrado en memoria involuntario: escribes una consulta que la sintaxis acepta y que por debajo recorre miles de registros porque no existe índice que la sostenga. El detalle venenoso es que la sintaxis no lo distingue, de modo que dos expresiones que se parecen muchísimo pueden diferir en tres órdenes de magnitud de coste sin que nada en el código lo indique. El segundo es el índice compuesto que crece: cada consulta nueva pide un índice nuevo, las escrituras se encarecen porque hay que mantenerlos todos, y aun así siempre queda alguna pregunta que no se puede expresar, porque el número de combinaciones de campos crece mucho más deprisa que tu paciencia para declararlas.

El tercero es el definitivo, y conviene reconocerlo escrito porque casi siempre entra en el repositorio disfrazado de función auxiliar inofensiva.

// El sintoma que cierra el diagnostico: una union escrita a mano.
async function docsConAutor(desde) {
  const docs = await db.docs.where("actualizado").above(desde).toArray();
  const ids = [...new Set(docs.map((d) => d.autorId))];
  const autores = await db.autores.bulkGet(ids);          // segunda vuelta
  const porId = new Map(autores.filter(Boolean).map((a) => [a.id, a]));
  return docs
    .map((d) => ({ ...d, autor: porId.get(d.autorId) }))
    .filter((d) => d.autor && d.autor.activo)              // filtro en memoria
    .sort((a, b) => b.comentarios - a.comentarios);        // orden en memoria
}

Esa función no está mal escrita: está haciendo, a mano y sin plan de ejecución, exactamente lo que un planificador haría solo y mejor. Ha elegido un orden de acceso, ha materializado un resultado intermedio completo en memoria, ha resuelto la reunión con una tabla de dispersión y ha ordenado al final. Son las decisiones correctas, tomadas por la persona equivocada, sin estadísticas y sin posibilidad de cambiarlas cuando cambien los datos. Cuando aparece la segunda función de esta forma, el diagnóstico está cerrado.

La capa intermedia compra tiempo de desarrollo, no capacidad de consulta

Hay una confusión persistente que conviene deshacer con cuidado porque sobrevive a la experiencia y reaparece en cada proyecto: la de creer que subir un escalón en esta familia amplía lo que la base puede hacer. No lo amplía. Un envoltorio no añade ni una sola operación que el motor de abajo no supiera hacer; lo único que hace es que expresarlas sea barato para ti, y ese ahorro es real y a menudo enorme, pero es un ahorro de tiempo de programador y no de capacidad del sistema. La prueba es incómoda y vale la pena hacerla una vez en la vida: implementa el mismo caso con la API cruda y con la capa más gruesa que encuentres, mide ambas con el mismo volumen, y comprueba que los tiempos son equivalentes, porque tenían que serlo. Un almacén reactivo sí cambia algo estructural, pero tampoco es capacidad de consulta: es la ubicación del estado, y esa mudanza a memoria compra latencia cero de lectura a cambio de un techo de volumen que se fija el primer día. La consecuencia práctica es que existe una frontera nítida y no negociable entre lo que esta capa resuelve y lo que no, y la frontera se llama consulta relacional arbitraria. Mientras tus preguntas se puedan expresar como rangos sobre claves conocidas de antemano, esta familia es la respuesta correcta y adoptar algo más pesado es puro coste sin contrapartida. En el momento en que tus preguntas dejen de conocerse de antemano —porque el usuario las compone, porque el dominio tiene entidades relacionadas de verdad, porque necesitas agregar sobre filtros combinados— ninguna cantidad de índices compuestos ni de consultas vivas te va a salvar, y lo que necesitas es un planificador, que es exactamente lo que compra la familia siguiente. Reconocer ese momento a tiempo es la habilidad completa de este nivel, porque el coste de reconocerlo tarde no es cambiar de librería: es descubrir que llevas meses escribiendo a mano, mal y sin saberlo, la mitad de un motor de bases de datos.

⚔️ Encuentra tu techo antes de que te encuentre él
  1. Enumera las diez consultas que tu aplicación ejecuta con más frecuencia y marca cuáles se pueden expresar como un rango sobre un índice declarado.
  2. Para las que no, escribe qué estás haciendo hoy en su lugar y cuántos registros recorres en memoria para resolverlas.
  3. Instrumenta una consulta viva y cuenta cuántas veces se reejecuta en una sesión real de uso; identifica los falsos positivos y su causa.
  4. Introduce a propósito un await sobre una promesa ajena dentro de un consultor y observa exactamente cómo se rompe el seguimiento de dependencias.
  5. Estima el tamaño en memoria de tu conjunto de trabajo completo y decide, con ese número delante, si un almacén en memoria es viable para tu caso.
  6. Busca en tu código el primer bucle que hace una unión a mano y anota la fecha: es el día en que tocaste el techo.