wandres.dev
CUOTAS Y DESALOJO · cuando el navegador te borra

Diseñar para el desalojo: y si mañana no están

La pregunta que define tu arquitectura local-first no es cuánto cabe sino qué ocurre si mañana los datos no están, y se responde con reconstruibilidad, exportación y un servidor que actúe como red de seguridad.

⏱ 18 min

Las cuatro lecciones anteriores han ido cercando un hecho que ya no admite discusión: el presupuesto es dinámico, el modo por defecto autoriza el borrado, la persistencia se solicita sin garantía y la estimación solo te da un aviso aproximado. Nada de eso se arregla con más código de almacenamiento. Se arregla con una decisión de arquitectura que conviene tomar el primer día y no el día del incidente, y que cabe en una sola pregunta formulada sobre cada dato que tu aplicación guarda: qué pasa si mañana esto no está. La respuesta a esa pregunta, aplicada dato por dato, no es una precaución añadida al diseño local-first: es el diseño local-first.

🎯 Al terminar esta lección sabrás
  • Adoptar la pregunta del desalojo como criterio de clasificación de todos los datos locales de la aplicación.
  • Separar lo reconstruible de lo irreemplazable y tratar cada categoría con una política distinta.
  • Implementar el arranque en frío como un camino normal y probado en lugar de como una condición de error.
  • Situar la exportación y el servidor en su papel correcto: red de seguridad, no fuente de verdad.

La pregunta que define la arquitectura

Formúlala sobre cualquier dato concreto —esta base de datos, esta caché, este borrador— y observa que solo admite tres respuestas, sin zona intermedia. La primera: no pasa nada, se vuelve a obtener. La segunda: se pierde algo molesto pero recuperable con esfuerzo. La tercera: se pierde trabajo del usuario que no existe en ningún otro sitio. Esa tercera respuesta no es un riesgo aceptable en ninguna aplicación seria, y sin embargo es la respuesta real, hoy, de una parte notable de las aplicaciones que se anuncian como local-first.

Observa que la pregunta no admite la respuesta que todo el mundo quiere dar, que es no va a pasar. Las cuatro lecciones previas han cerrado esa salida: el modo por defecto lo autoriza, la persistencia no está garantizada y la estimación no avisa de un desalojo. Formular la pregunta correctamente significa aceptar el antecedente y responder solo por el consecuente, exactamente igual que se hace con la caída de un servicio del que dependes.

Lo valioso de la pregunta es que es local a cada dato y por tanto operativa: no exige rediseñar el sistema entero de golpe, exige recorrer un inventario. Y produce una taxonomía inmediata, con una política asociada a cada rama, que es lo que hace de ella una herramienta y no un ejercicio de reflexión.

flowchart TD
D[un dato guardado localmente] --> P[se puede reconstruir desde otra fuente]
D --> L[solo existe en este dispositivo]
P --> C[tratalo como cache y borralo sin ceremonia]
L --> S[sincronizalo o hazlo exportable cuanto antes]
S --> R[reduce la ventana de trabajo no replicado]
style C fill:#a6e3a1,color:#11111b
style L fill:#f38ba8,color:#11111b
style R fill:#89b4fa,color:#11111b

Conviene aplicarla también a lo que no parece un dato. Las claves criptográficas que generaste en el dispositivo, el identificador con que este cliente se presenta ante los demás, el registro de qué cambios ya se enviaron, la posición desde la que reanudar una sincronización: nada de eso es contenido del usuario, pero todo vive en el mismo cubo desalojable y su desaparición puede ser más grave que la de un documento. Un cliente que pierde su identidad y su registro de envíos no arranca en frío de forma limpia, sino que puede volver a subir lo que ya estaba, duplicar entidades o presentarse como un dispositivo nuevo. Los metadatos merecen la pregunta tanto como los datos.

Clasificar por reconstruibilidad

La clasificación útil no distingue por importancia percibida ni por tamaño, sino por origen: de dónde volvería este dato si desapareciera. Los datos derivados —índices de búsqueda, miniaturas, resultados de un cálculo, vistas materializadas— se reconstruyen a partir de otros datos locales y su pérdida cuesta tiempo de CPU. Los datos replicados —lo que ya viajó al servidor o a otro dispositivo— se recuperan por la red y su pérdida cuesta ancho de banda. Y los datos no replicados, el trabajo que el usuario acaba de hacer y que aún no ha salido de esta máquina, no se recuperan de ningún sitio: su pérdida es definitiva.

♻️

Derivado

Índices, miniaturas y cachés de cálculo. Se regeneran solos a partir de las fuentes locales. Son los primeros candidatos a purgarse bajo presión.

🌐

Replicado

Lo que ya está en el servidor o en otro dispositivo. Su pérdida local es una descarga, no un drama. Marca claramente qué está en este estado.

🔥

No replicado

El trabajo reciente que aún no salió de aquí. Es la única categoría irrecuperable y la que debe gobernar tus decisiones de diseño.

La ventana de riesgo

El tiempo que un dato pasa en la tercera categoría es la métrica que de verdad importa. Todo el trabajo consiste en acortarla.

La clasificación deja de ser un ejercicio conceptual en cuanto se materializa en el propio esquema de datos. Basta con que cada registro lleve la marca de su estado de replicación y con que exista una consulta capaz de responder, en cualquier instante, qué hay ahora mismo en la categoría irrecuperable. Esa consulta es la que alimenta el indicador de la interfaz, la que decide si conviene insistir con la exportación y la que un día te dirá, con números, cuánto trabajo ajeno estabas arriesgando.

// La categoria peligrosa es una consulta, no una intuicion
const pendientes = await db
  .transaction("documentos")
  .objectStore("documentos")
  .index("porEstado")
  .getAll("no_replicado");

indicador.actualizar({
  enRiesgo: pendientes.length,
  masAntiguo: pendientes[0]?.creadoEn ?? null,
});

De aquí sale la métrica que conviene poner por delante de cualquier otra en una aplicación local-first: la ventana de trabajo no replicado, es decir, cuánto tiempo transcurre entre que el usuario crea algo y que ese algo existe en un segundo lugar. Si esa ventana dura horas porque sincronizas al cerrar la sesión, tu exposición al desalojo es de horas de trabajo ajeno. Si dura segundos, el desalojo pasa de catástrofe a molestia. Ninguna otra optimización de almacenamiento tiene un retorno comparable.

La categoría de lo derivado admite un matiz que conviene no aplanar: reconstruible no siempre significa barato. Un índice de búsqueda de texto completo sobre miles de documentos puede tardar minutos en regenerarse, y aunque su pérdida no sea definitiva sí puede arruinar el primer arranque tras el desalojo. La política correcta para esos casos no es protegerlos —siguen siendo los primeros que deben caer bajo presión— sino hacer que su reconstrucción sea incremental y en segundo plano, de modo que la aplicación esté utilizable mientras se rehace. Lo derivado se purga sin ceremonia; lo que se cuida es la experiencia de volver a tenerlo.

💡
Separa lo prescindible en su propio recipiente

Una decisión estructural pequeña rinde muchísimo: no mezcles las tres categorías en la misma base de datos. Guarda lo derivado en almacenes o incluso en bases separadas, con nombres que digan lo que son, de modo que purgarlo bajo presión sea una operación de una línea y sin riesgo. Cuando índices, miniaturas y documentos del usuario conviven en el mismo almacén, cualquier limpieza se convierte en cirugía y acabas no haciéndola. La separación física por reconstruibilidad es la que convierte una política escrita en un procedimiento ejecutable.

El arranque en frío es un camino normal

Recuerda la propiedad que la segunda lección destacó: el desalojo vacía el cubo del origen entero, no una parte. Eso significa que tu aplicación nunca se encontrará con una base de datos corrupta a medias, sino con una base de datos vacía, exactamente igual que en la primera visita de un usuario nuevo. Bien mirado, es la mejor noticia de todo el nivel, porque el camino que necesitas ya existe en tu código: es el del primer arranque. La cuestión es si lo has probado alguna vez desde un estado que no fuera el de una instalación limpia.

async function arrancar() {
  const db = await abrirBase();
  const hayDatos = await tieneAlgo(db);

  if (!hayDatos) {
    // Indistinguible de una primera visita: no es un error, es un estado
    await rehidratarDesdeElServidor();   // idempotente y reanudable
    await ofrecerImportarDesdeFichero(); // por si no hay sesion ni red
  }
  return db;
}

Que ese camino exista en el código no significa que esté probado. En la práctica se ejercita una sola vez por desarrollador, el día que abrió el proyecto, y a partir de ahí toda la actividad ocurre sobre una base ya poblada; cualquier regresión que lo rompa —una consulta que asume un registro de configuración, una vista que da por hecho un índice ya construido, una migración que espera una versión previa— pasa desapercibida durante meses. Convertirlo en un caso de prueba automatizado que arranca desde el vacío es de las medidas más baratas y de mayor rendimiento de todo este nivel.

Dos condiciones hacen que este camino funcione de verdad. La primera es que la rehidratación sea idempotente: ejecutarla dos veces no debe duplicar nada, porque el usuario cerrará la pestaña a mitad y volverá. La segunda es que sea reanudable por partes, para que una descarga interrumpida no obligue a empezar de cero. Ambas son propiedades que se diseñan al principio y se retrofitan con mucho dolor.

⚠️
Un cubo vacío no es un usuario nuevo, y el mensaje importa

Aunque técnicamente el estado sea idéntico al de una primera visita, la experiencia no lo es en absoluto: quien lleva medio año usando tu aplicación y se encuentra la pantalla de bienvenida no piensa que le han desalojado los datos, piensa que tu aplicación los ha perdido. Y la distinción es imposible de hacer con certeza desde el código, porque cualquier marca que hubieras dejado vivía en el mismo cubo vaciado. Lo que sí puedes hacer es no tratar el vacío como una celebración: si existe una sesión iniciada, un token en otra capa o cualquier indicio externo de que ya te conocían, sustituye el mensaje de bienvenida por uno que reconozca la situación y ofrezca restaurar. La honestidad aquí conserva más confianza que el disimulo.

Exportación y servidor: la red de seguridad

Merece la pena señalar que una exportación bien hecha resuelve mucho más que el desalojo. Es la respuesta al usuario que quiere llevarse sus datos a otra herramienta, al que necesita un archivo antes de dejar de pagar, al equipo que debe migrar un esquema roto y al soporte técnico que pide una reproducción del estado para diagnosticar un fallo. Pocas piezas de una aplicación local-first tienen una relación tan favorable entre lo poco que cuestan y la cantidad de problemas distintos que cierran.

Quedan las dos salidas que sacan los datos del cubo desalojable, y conviene entender que resuelven problemas distintos y no se sustituyen entre sí. La exportación produce un fichero bajo el control del usuario, fuera del navegador, legible sin tu aplicación si el formato es honesto. Es la encarnación técnica del ideal de propiedad: no depende de tu infraestructura, no caduca con tu empresa y funciona sin red. Su debilidad es evidente: requiere una acción humana, y las acciones humanas no ocurren.

async function exportar() {
  const todo = await volcarTodoElEstado(); // formato estable y documentado
  const blob = new Blob([JSON.stringify(todo, null, 2)], {
    type: "application/json",
  });
  descargar(blob, `respaldo-${new Date().toISOString().slice(0, 10)}.json`);
}

Una exportación solo vale lo que valga su reimportación, y esa es la parte que casi nadie prueba. El fichero debe contener el estado completo y no una vista amputada de lo que era cómodo serializar, debe llevar una versión de esquema para que el código futuro sepa interpretarlo, y debe existir un camino real que lo devuelva a una instalación limpia dejándola en el estado exacto de origen. Mientras esa ida y vuelta no esté en tu batería de pruebas, lo que tienes no es una red de seguridad sino un botón que genera un fichero cuya utilidad nadie ha verificado.

El servidor, en cambio, replica sin intervención y por eso acorta la ventana de riesgo de forma continua. Su papel en una arquitectura local-first no es el que tenía en la arquitectura que estamos abandonando: no es la fuente de verdad ni el árbitro de los conflictos, sino una réplica más entre iguales que resulta estar siempre encendida y tener una dirección estable. Esa función —punto de encuentro y copia disponible— es precisamente lo que hace de él una red de seguridad frente al desalojo sin devolverle la autoridad que le quitamos.

Esa lectura del servidor como réplica, y no como autoridad, es también la que enlaza este nivel con el resto del track. Cuando la copia local y la remota son dos réplicas de igual rango, reconciliarlas al volver de un arranque en frío no es un caso especial de recuperación sino la misma operación de convergencia que se ejecuta continuamente entre dispositivos. Por eso la maquinaria que los próximos niveles construyen —relojes lógicos, causalidad y estructuras que convergen sin árbitro— no es un lujo teórico: es precisamente lo que hace que rehidratar tras un desalojo sea seguro en lugar de una fuente de duplicados.

📝
Sin cuenta y sin red también hay respuestas

Una objeción legítima es que muchas aplicaciones local-first no quieren obligar a registrarse ni depender de un servidor. La objeción es buena y no invalida el argumento: cambia la forma de la red de seguridad, no su necesidad. Sin cuenta, el peso recae en la exportación, y entonces conviene hacerla insistente y barata —un recordatorio periódico, un respaldo automático a un directorio elegido por el usuario, un formato que se entienda sin tu código—. La regla que no cambia es que debe existir algún segundo lugar donde el trabajo del usuario resida. Un único lugar, y encima desalojable, no es una arquitectura: es una apuesta.

La reconstruibilidad no es un plan de contingencia: es la definición del sistema

El giro que cierra este nivel consiste en dejar de ver la pérdida de datos como un accidente contra el que uno se protege y empezar a verla como una operación del sistema que el diseño debe soportar sin degradarse, igual que soporta un cierre de pestaña o un corte de red. Cuando se hace ese giro, todas las piezas anteriores dejan de ser precauciones y se convierten en propiedades estructurales: purgar lo derivado bajo presión no es limpieza de emergencia sino gestión ordinaria del presupuesto; el arranque en frío no es un caso extremo sino uno de los dos estados iniciales posibles, tan normal como el otro; el servidor no es un seguro contratado por miedo sino la segunda réplica que la topología ya exigía. Y hay un dividendo que casi nadie anticipa y que justifica el esfuerzo por sí solo: una aplicación que sabe reconstruirse desde cero es exactamente la misma aplicación que sabe incorporar un dispositivo nuevo, migrar un esquema roto, recuperarse de una base corrupta y ofrecer al usuario la exportación completa que el ideal de propiedad reclama. Cuatro problemas distintos, una sola capacidad, y ninguno de ellos resuelto con almacenamiento sino con arquitectura. Fíjate por último en la simetría con la que arrancó el track: la promesa local-first era liberar al usuario de un servidor que podía apagarse llevándose sus datos, y el precio honesto de esa liberación es reconocer que el dispositivo también puede vaciarse y que la única defensa real nunca fue el lugar donde guardas, sino el número de lugares donde existe lo mismo. Diseñar para el desalojo es, en el fondo, aceptar que la permanencia no es una propiedad de un almacén: es una propiedad de la redundancia.

⚔️ Somete tu aplicación a la prueba del desalojo
  1. Inventaría todos los datos que tu aplicación guarda localmente y clasifícalos en derivados, replicados y no replicados, sin dejar ninguno sin etiqueta.
  2. Mide la ventana de trabajo no replicado: cronometra cuánto tarda algo que el usuario acaba de crear en existir en un segundo lugar.
  3. Reorganiza el almacenamiento para que lo derivado viva separado y pueda purgarse con una sola operación segura.
  4. Borra todo el almacenamiento del sitio con la sesión iniciada y recarga: comprueba si la aplicación se reconstruye, si el mensaje que muestra es honesto y si la rehidratación resiste una interrupción a mitad.
  5. Implementa la exportación completa a un fichero con formato documentado y verifica que puedes reimportarla en una instalación limpia hasta el estado exacto anterior.
  6. Escribe en una línea la respuesta de tu aplicación a la pregunta del nivel, y si esa línea menciona la esperanza de que el navegador no desaloje, vuelve al punto uno.