Apps

🛢️ Cómo dejé de olvidar el nivel de aceite del coche

Hay decisiones de arquitectura que nacen de un capricho y decisiones que nacen de una manía. Esta app nació de las dos cosas: llevaba años apuntando el nivel de aceite del coche en una libreta, y un día me harté. Pero la versión inicial, con el formulario metido a martillazos en un objeto JavaScript y un array de registros en localStorage, empezó a fallarme en cuanto tuve más de seis meses de datos: las búsquedas se volvían lentas, las migraciones de esquema un drama, y cuando quise cruzar kilómetros con consumo ya era tarde. Necesitaba algo con consultas reales. No quería un backend. Ahí es donde aparece SQLite en WebAssembly.

El problema: estado en localStorage se rompe a medio plazo

La mayoría de PWA que ves por ahí guardan su estado en localStorage: una clave, un JSON, fuera. Para una app de notas o una checklist está bien, pero cuando tu modelo crece —usuarios, tareas, planificación semanal, histórico— empiezas a notar cosas:

  • Cada lectura parsea el JSON completo. Con 10 KB no se nota; con 500 KB sí.
  • No hay consultas: para buscar «todas las revisiones deAceite a más de 8.000 km» tienes que cargar todo, filtrar en memoria y reescribirlo entero.
  • Las migraciones son un infierno. Cambias un campo y tienes que escribir código defensivo que lea la versión vieja, transforme, y reescriba.

Yo necesitaba SQL. Pero la app es una PWA cliente: instalar Postgres en el móvil del usuario no era opción. La respuesta fue SQLite compilado a WebAssembly: cargo sql-wasm.js y sql-wasm.wasm en el navegador, ejecuto sentencias SQL reales contra un fichero .db que vive en IndexedDB, y desde el código solo veo «tengo una base de datos». El motor es el mismo que usarías en un servidor, pero el almacenamiento es del navegador.

Arquitectura: tres capas, ninguna capa de más

La app está estructurada como tres módulos muy ligeros que se cargan en orden desde index.html, sin bundler:

  • Capa de datos: un wrapper sobre sql.js que expone db.query(sql, params), db.exec(sql) y un sistema de migraciones versionadas. Las migraciones viven en una tabla schema_version y se aplican en orden al arrancar la app, igual que harías con Alembic o Knex.
  • Capa de UI: render directo en el DOM con plantillas leídas desde <template> HTML5. Sin React, sin Vue, sin build step. El JS lee la plantilla, clona el nodo, sustituye los marcadores y lo pega. Lo que en frameworks se llama «componentes» aquí son funciones que devuelven nodos.
  • Capa de estado: una sola instancia Store que mantiene la conexión SQLite en memoria, sincroniza a IndexedDB en cada escritura y expone un evento change al que la UI se suscribe para re-renderizar.

El resultado: cero dependencias npm en runtime. El package.json solo tiene lo necesario para el service worker y la generación del manifest; lo que el usuario ejecuta en el navegador es JS nuestro a mano, plano.

Diseñar la base de datos antes que la UI

Una de las cosas que más he cambiado en mi forma de trabajar es dejar de empezar por la UI. La primera versión de Control Aceite la diseñé visualmente: «pongo tres campos y un botón». La actual la diseñé por esquema de datos primero: ¿qué entidades tengo? registro_aceite, tipo_aceite, coche, intervalo_mantenimiento. ¿Qué relaciones? ¿Qué índices necesito para las consultas que sé que voy a hacer?

Cuando tienes eso claro, la UI casi se escribe sola: cada pantalla es una vista sobre una consulta SQL. Y cuando cambias un campo (añadí km_actual al registro), la migración versionada se aplica sola en cada cliente y no rompes a nadie.

El manejo de la IA en el flujo

No uso IA generativa para escribir código. Uso IA para tres cosas concretas y las tres me ahorran tiempo de verdad:

  1. Generación de migraciones: le digo a la IA «necesito añadir un campo filtro_aire_cambiado_en a registro_aceite, escríbeme la migración y actualiza las queries afectadas». Me genera el SQL correcto, las queries actualizadas y un diff que reviso. Nunca commiteo sin leerlo, pero me ahorra el 80% del trabajo mecánico.
  2. Diseño de esquema: cuando una app nueva empieza con una idea vaga («quiero controlar X»), le pido a la IA que me proponga 3 esquemas posibles con sus tradeoffs. Elijo el que mejor encaja con las consultas que sé que voy a hacer y rechazo el resto con criterio propio.
  3. Revisión de rendimiento: si una consulta va lenta con datos reales, le paso el EXPLAIN QUERY PLAN y le pido sugerencias de índice. Valido la sugerencia contra mis datos, aplico la que tiene sentido, descarto la que fuerza un índice que no compensa.

La IA no escribe el código por mí. La IA acelera mi flujo de trabajo quitando lo mecánico. La diferencia es enorme.

PWA: manifest, service worker y lo que aprendí

El manifest.json declara la app como instalable con display: standalone y orientation: portrait. El icono maskable 512×512 es lo único que pide Android moderno. El service worker hace cache-first para los assets estáticos y network-first para el WASM, porque si el sql-wasm.wasm falla al cargar, la app entera falla. La trampa clásica aquí es cachear el SW un año entero y luego no enterarte de que tienes un bug en producción — eso lo aprendí a base de tener varios sw.js mal desplegados.

El truco: el sw.js se sirve con Cache-Control: no-cache, no-store, must-revalidate en nginx, así cada deploy garantiza que el usuario recibe la versión nueva. Y bumpear CACHE_VERSION en cada deploy, sin excepciones.

Lo que aprendí y lo que cambiaría

Si volviera a empezar, no cambiaría la decisión de SQLite en WASM. Lo que sí cambiaría es la elección inicial de localStorage durante el MVP: si tu app va a tener histórico, datos relacionados y consultas, empieza con SQLite desde el día uno. Migrar después es perder funcionalidad durante semanas.

Lo segundo que cambiaría: tests E2E del esquema de datos desde el principio. Migrar de localStorage a SQLite sin tests fue un infierno silencioso que me costó una tarde entera. Hoy no empiezo una app con estado sin un test que verifique que las migraciones se aplican en orden y los datos sobreviven.

Si quieres ver cómo está montado, échale un ojo. Si tienes un problema parecido con datos en localStorage que se te están complicando, SQLite en WASM es una solución más sencilla de lo que parece.