OdoKeep ya está en el App Store. Descárgalo gratis.

Todos los artículos

Sincronización sin conexión

Proteger los cambios sin conexión en React Native con MMKV y Supabase

Cómo una cola en MMKV gestiona borrados durante el envío, respuestas perdidas y cambios obsoletos en React Native con Supabase.

Actualizado el

En este artículo

Un registro que reaparece después de borrarlo es una buena forma de perder la confianza de quien lleva un historial.

Era uno de los fallos que tuve que resolver en OdoKeep, mi aplicación para el seguimiento de vehículos. Alguien podía crear un registro, borrarlo mientras la inserción seguía en curso y volver a verlo en la siguiente sincronización. El borrado local funcionaba. La inserción en el servidor también. Lo que fallaba era la explicación que la cola daba de lo ocurrido.

OdoKeep usa React Native y Expo, MMKV para la persistencia local y Supabase para la copia del servidor. Los repositorios escriben localmente y encolan la operación. Las pantallas pueden usar esos datos mientras el gestor de sincronización se ocupa de la red.

Así es posible guardar un repostaje sin conexión. Pero la cola tiene que representar algo más que una lista de peticiones pendientes.

Una creación en cola no es una creación en curso

La primera distinción útil fue separar una operación aún no enviada de otra enviada cuya respuesta todavía no había llegado.

En una sesión normal sin conexión:

Crear el registro local
Encolar INSERT
Borrar el registro local
Cancelar el INSERT pendiente

No hay motivo para contactar con el servidor. El registro nunca existió allí. Reducir la creación y el borrado a ninguna operación es la optimización correcta.

Ahora añadamos una conexión lenta entre el segundo paso y el tercero:

Crear el registro local
Enviar INSERT
Borrar el registro local
Cancelar el INSERT pendiente
El servidor termina INSERT
La siguiente descarga devuelve el registro

Quitar una operación de un array no puede retirar una petición ya enviada. La cola seguía llamando «pendiente» a la creación, y el borrado interpretaba esa palabra como prueba de que el servidor no la había visto.

La solución es un conjunto de identificadores de operaciones en curso. En lib/sync-queue.ts, la búsqueda de una creación que pueda cancelarse incluye esta condición:

const pendingCreate = pending.find(
  (op) => op.type === "create" && !this.inFlight.has(op.id),
);

Si la creación ya salió, borrar el registro debe encolar un borrado en el servidor. Ya no es válido cancelar ambas operaciones localmente.

El conjunto se mantiene deliberadamente en memoria. Tras reiniciar, el proceso no conserva ninguna petición activa que rastrear. Persistir inFlight haría que el siguiente arranque creyera que una operación sigue enviándose, sin nadie que pudiera limpiar el estado. La cola duradera sobrevive; la descripción de las peticiones activas de este proceso, no.

También evita que una segunda ejecución envíe la misma operación mientras la primera espera respuesta. La sincronización por reconexión y la activada al actualizar la pantalla no deben enviar la misma inserción simultáneamente. El gestor protege la ejecución, y la cola excluye por su cuenta las operaciones en curso de la lista disponible. Incluso una petición del usuario que omite la espera entre reintentos respeta esa exclusión.

Recuperar una inserción cuando se pierde la respuesta

El servidor puede guardar una fila y el teléfono perder su respuesta.

En el siguiente intento, una inserción normal puede fallar porque el identificador ya existe. Considerarlo un rechazo permanente desharía un registro local que el servidor sí guardó.

En la creación habitual, el gestor pide a PostgREST que ignore un identificador duplicado en vez de sobrescribirlo. Si no devuelve ninguna fila, vuelve a leer ese identificador. Una fila accesible confirma que la inserción llegó, y el dispositivo la adopta. Si falla esa comprobación, la operación sigue siendo reintentable. Una colisión que no puede leerse no se acepta como éxito.

Esto no garantiza una entrega exactamente una vez. Permite recuperar pruebas de una inserción sin sustituir el contenido de una fila existente.

Actualizar la versión que realmente editaste

Compartir un vehículo introdujo otro problema. Dos personas pueden editar el mismo registro a partir de la misma versión del servidor mientras una o ambas están sin conexión.

Una actualización filtrada solo por identificador permite que la última sincronización borre los cambios anteriores. Ambas peticiones pueden tener éxito sin avisar a nadie de que su edición se perdió.

Por eso las actualizaciones en cola incluyen baseUpdatedAt: la marca de tiempo del servidor conocida antes de la edición local. Esta es la condición esencial, simplificada desde lib/sync-manager.ts:

const write = client
  .from(tableName)
  .update(operation.data)
  .eq("id", operation.data.id);

if (operation.baseUpdatedAt) {
  write.eq("updated_at", operation.baseUpdatedAt);
}

const result = await write.select(returningColumns);

La actualización solo puede aplicarse mientras la fila conserve la versión sobre la que se editó. La API de actualización de Supabase permite devolver las filas afectadas con .select(), de modo que el cliente puede observar una coincidencia vacía.

Capturar la base tiene un detalle importante. Después de la primera edición local, la fila del dispositivo puede contener una marca de tiempo generada localmente. Una segunda edición no debe sustituir la base de la operación por ese valor: el servidor nunca lo emitió. La combinación de operaciones conserva la base original y fusiona los cambios del contenido.

Una fila recién creada es otra excepción. Hasta que el servidor confirme su creación, su marca local no sirve como precondición del servidor. Por eso la cola distingue entre «todavía hay una creación registrada» y «todavía puede cancelarse».

Una actualización vacía también exige interpretación. Otro autor puede haber cambiado la fila. Puede haberse borrado. En un vehículo compartido, quizá se revocó el acceso o cambió el rol del usuario.

La rama de conflicto comprueba si existe una fila accesible con una marca del servidor más reciente. Solo esa prueba produce el aviso de escritura obsoleta. La operación pasa por la ruta de rechazo permanente, se informa al usuario y el dispositivo se reconcilia con el servidor. Reintentar el contenido rechazado sin su precondición reproduciría la sobrescritura que intentábamos evitar.

La política actual es sencilla: ante un conflicto confirmado, prevalece la copia del servidor. No hay fusión por campos ni editor de conflictos. Una operación de una versión antigua sin base tampoco puede recibir esta protección retroactivamente. Son límites que conviene explicar.

Descargar con una marca del servidor

La descarga requiere el mismo cuidado. Un teléfono puede llevar el reloj dos minutos adelantado. Si guarda su hora actual como límite de la descarga incremental, pedirá omitir cambios hasta un punto situado en el futuro del servidor.

lib/delta-watermark.ts avanza ese límite a partir de las marcas de las filas recibidas. Una respuesta vacía conserva el valor anterior. La marca elegida mantiene su cadena original para la siguiente comparación exclusiva, sin pasar por el serializador de fechas del teléfono.

El reloj del teléfono sirve para calcular esperas entre reintentos. No puede certificar qué cambios del servidor ha observado.

Reintentos y pruebas de regresión

Los fallos también deben distinguir «no» de «ahora no». La cola registra por separado los intentos fallidos totales y los que consumen su límite de reintentos. Los errores de transporte y los fallos temporales conocidos generan una espera exponencial acotada sin gastar el presupuesto de fallos permanentes. Una interrupción breve no debería eliminar un vehículo creado localmente y todos sus registros pendientes.

Las pruebas cubren estas decisiones: cancelar una creación no enviada, conservar un borrado tras una creación en curso, excluir trabajo de una segunda ejecución, recuperar una respuesta perdida, conservar la base original y distinguir conflictos de falta de acceso. El servidor y el almacenamiento se simulan. Se verifican las decisiones del cliente, no las políticas desplegadas ni una red móvil real.

Nada de esto cambió el formulario de combustible. Cambió la evidencia necesaria para considerar una escritura cancelada, confirmada o rechazada definitivamente.

Si estás construyendo una cola sin conexión, merece la pena probar pronto crear y después borrar. Mantén abierta la respuesta de inserción, borra la fila local e inspecciona la cola. Aparece una suposición que una prueba normal en modo avión nunca alcanza.

Construyo OdoKeep para conservar el historial de un vehículo sin exigir conexión en cada entrada. Estas son algunas de las decisiones menos visibles que lo hacen posible. La aplicación está en la App Store.