Apariencia
0055. Mobile: cola de escrituras offline (outbox) con idempotencia best-effort en backend
Estado
Aceptada
Contexto
ADR-0021 dejó establecido el patrón offline-read + online-write: la lectura sale de Drift, pero toda escritura exigía red y fallaba con "Sin conexión. Necesitas internet para esta operación." (pools_notifier.dart). Era la deuda sync offline del backlog: darle al técnico de campo la capacidad de registrar datos sin señal.
sync-offline-hardening (2026-07-26, ver ADR-0054) resolvió la mitad del problema — sync de lectura endurecida (hard replace en vez de upsert puro) — pero dejó explícitamente pendiente la cola de escrituras. Esta tarea (sync-offline-escrituras) cierra esa mitad.
Decisión
Alcance: solo registros de campo
La cola offline cubre únicamente los 6 módulos de registro diario: parámetros de agua, alimentación (feedings), muestreos (samplings + populations), eventos, y raleos (harvests). No cubre CRUD administrativo (sectores, piscinas, equipo, ciclos, siembras, llenados, transferencias) — son operaciones de oficina que en la práctica se hacen con conexión, y meterlas obligaría a remapear ids locales de padres hacia hijos (crear un ciclo offline generaría dependencias en cascada) sin aportar valor real de campo.
Outbox en Drift, no una cola en memoria
Tabla OutboxEntries (schemaVersion 10 → 11): una fila por operación (create / update / delete) con entity, method, path, bodyJson, idempotencyKey, localRowId, parentId, attempts, status (pending / failed). Sobrevive a que la app se cierre entre el registro offline y la reconexión — un Provider en memoria no.
Cada una de las 6 tablas de campo gana pendingOp (nullable: create/update/delete) y localOnly (bool). La UI muestra un chip "Pendiente" mientras pendingOp != null (alternativa descartada: no mostrar el dato hasta sincronizar — el técnico no vería su propio registro y podría duplicarlo a mano).
Un create offline usa un id local, no un id real
OutboxService.generateLocalId() genera local:<timestamp>-<random>. Un update/delete posterior sobre esa misma fila, mientras el create sigue pending, colapsa contra la entrada create en vez de encolarse aparte (OutboxService.enqueue): un update no puede apuntar a un recurso que el backend todavía no conoce. Un delete sobre una fila local: nunca sincronizada simplemente cancela el create pendiente y borra la fila — no hay nada que borrar en el servidor.
Replay: FIFO, corta ante red, no ante error de negocio
OutboxService.replay() procesa pending en orden createdAt. Ante un error de red, 401 (sesión inválida) o 409 (operación en curso del lado del servidor) corta el replay entero — las siguientes entradas quedan pending para el próximo disparo, no se reordenan ni se saltean. Ante un error permanente (400/403/404/422) marca esa entrada failed y sigue con las siguientes — un dato inválido puntual no debe bloquear el resto de la cola.
Disparadores: reconexión (connectivity_listener, después del silentRefresh — necesita el token fresco), arranque autenticado con red, y pull-to-refresh manual en cada pantalla.
El hard replace de ADR-0054 tenía que aprenderse a convivir con esto
Riesgo #1 identificado en la planificación: los replace*ForPool/ForCycle (delete + insertAllOnConflictUpdate) de ADR-0054 habrían borrado una fila con una escritura pendiente en cuanto corriera la siguiente sync de lectura — el técnico registra offline, entra a la pantalla, la sync corre, su dato desaparece. Los 6 replace* ahora excluyen pendingOp IS NOT NULL del delete, con test de regresión Drift in-memory por cada uno.
Idempotencia: header Idempotency-Key + Redis, best-effort
Un reintento del replay (timeout, corte de red a mitad de respuesta) no debe duplicar el registro en el backend. Cada entrada del outbox lleva su propio idempotencyKey, reenviado igual en cada intento vía el header Idempotency-Key. El backend (IdempotencyInterceptor, camaroneras_backend/src/common/interceptors/) cachea la respuesta 2xx en Redis (RedisService.setNx/get/set, TTL 24h) keyed por org+usuario+clave, y devuelve la misma respuesta ante un reintento.
Es best-effort, no una garantía dura: sigue la filosofía ya establecida de RedisService ("Redis es una optimización, NUNCA un punto de falla"). Si Redis está caído, el request se procesa igual — sin protección de idempotencia. Se aceptó conscientemente en vez de la alternativa (clientId único por tabla, persistente en Postgres) porque:
- No exige migrar 6 tablas con una columna + constraint nueva.
- No depende de que cada service conozca y valide el
clientId. - El caso real que hay que cubrir es "el timeout del técnico en el campo", no un ataque malicioso — Redis, aunque no es HA en este stack, tiene una disponibilidad de sobra para eso.
Si en producción aparecen duplicados reales por caídas de Redis, la salida es migrar a clientId único por tabla — pero no se implementa preventivamente.
Last-write-wins con updatedAt del servidor
El backlog pedía explícitamente "last-write-wins con timestamp del servidor" (no del dispositivo: el reloj de un teléfono en campo no es confiable). Los 6 modelos de campo en Prisma solo tenían createdAt — se agregó updatedAt DateTime @updatedAt a los 6 (WaterParam, Event, Harvest, Feeding, Sampling, Population), con backfill de las filas existentes a now() en la migración. Expuesto en las 6 respuestas HTTP.
El outbox se borra en logout (no sobrevive a un cambio de usuario)
AppDatabase.wipeAllData() ya borra todo el cache de dominio en cada signout, para que un usuario B no herede datos de A en el mismo dispositivo (varios técnicos suelen compartir tablets). Se evaluó preservar el outbox entre sesiones para no perder un registro sin enviar por un logout accidental, pero eso filtraría escrituras pendientes de A hacia la sesión de B — prioridad para la invariante de aislamiento existente sobre no perder un registro puntual. wipeAllData() incluye outboxEntries en su transacción de borrado.
SyncService.initialHydration() sigue sin hidratar catálogos
No es un olvido: sigue siendo el comportamiento definido en ADR-0021 (cada feature sincroniza su propia pantalla al abrirla). Lo único que cambia en el arranque es que ahora también dispara replayOutbox().
Consecuencias
- El técnico puede registrar parámetros de agua, alimentación, muestreos, poblaciones, eventos y raleos sin señal; el dato aparece con chip "Pendiente" y se envía solo al recuperar conexión.
- Un replay interrumpido a la mitad no pierde ni duplica nada: lo no aplicado sigue
pending. - Con Redis caído, la app sigue funcionando (offline y online) — la protección de duplicados se degrada, no la disponibilidad.
- CRUD administrativo (sectores, piscinas, ciclos, etc.) sigue exigiendo red — sin cambios.
- Cubierto con tests:
OutboxService(FIFO, colapso create+update, corte de red, error permanente), los 6 repos (escritura sin red encola, preserva pendientes tras sync),IdempotencyInterceptor(unit + e2e de doble POST con la misma clave).