Skip to content

Sync offline (mobile) — escrituras encoladas

Ver ADR-0021 (patrón general), ADR-0054 (sync de lectura), ADR-0055 (esta cola de escrituras) y ADR-0056 (política de reintentos y preservación entre sesiones) para el detalle de las decisiones.

Alcance

Cubre los 6 módulos de registro de campo: parámetros de agua, alimentación (feedings), muestreos (samplings + populations), eventos, y raleos (harvests). El CRUD administrativo (sectores, piscinas, equipo, ciclos, siembras, llenados, transferencias, overrides de guía de alimentación) sigue exigiendo red — sin cambios.

Flujo de una escritura

Técnico registra un dato (ej. parámetro de agua)
  → Repo intenta el POST/PATCH/DELETE online
     → Éxito → sync de lectura de esa entidad → WriteResult.synced
     → DioException de red (sin conexión)
        → Guarda la fila local con pendingOp = create/update/delete
        → Encola en OutboxEntries (Drift): entity, method, path, bodyJson,
          idempotencyKey propio, localRowId, parentId
        → WriteResult.queued
  → UI: snackbar "Guardado — se enviará al recuperar conexión."
        chip "Pendiente" en la fila (mientras pendingOp != null)

Un delete sobre una fila local: (un create que nunca sincronizó) no llama a la red: cancela el create pendiente en el outbox y borra la fila local directo — nunca existió en el servidor. Un update sobre una fila local: tampoco llama a la red — colapsa contra el create pendiente (se fusiona el body) en vez de encolarse aparte, sin importar si ese create sigue pending o ya quedó failed (ver ADR-0056).

Replay

Disparadores: reconexión (connectivity_listener, después del silentRefresh), arranque autenticado con red, y pull-to-refresh manual en cada pantalla de campo. replay() es single-flight: si ya hay una pasada en curso, un segundo disparo la comparte en vez de arrancar otra sobre el mismo snapshot de la cola (ADR-0056).

SyncService.replayOutbox()
  → OutboxService.replay(): procesa entradas `pending` en orden FIFO (createdAt)
     → request con header `Idempotency-Key: <clave de la entrada>`
        → 2xx → borra la entrada del outbox
                → callback registrado (registerResync en providers.dart):
                   borra la fila placeholder local + re-sincroniza la entidad
                   (trae el id real del servidor y los derivados calculados)
        → red / 401 / 409 / 429 / 5xx → CORTA el replay (el resto queda pending)
             · 401 no consume un intento (es la sesión, no el dato)
             · el resto suma un intento; al llegar a `maxAttempts` (5) pasa a `failed`
        → error permanente (400/403/404/422) → marca la entrada `failed` de una,
          sigue con la siguiente

Las entradas failed se ven en la hoja "Cambios pendientes" (ícono de nube en el AppBar del Home, con contador) con dos acciones:

  • Descartar: borra la entrada del outbox y, vía el mismo callback de resync que usa un replay exitoso, limpia también la fila placeholder local (no queda huérfana).
  • Reintentar todo (SyncService.retryFailedAndReplay): devuelve todas las failed a pending (resetea intentos y error) y dispara el replay. Es el único disparador que toca failed — el replay automático (reconexión, boot, pull-to-refresh) nunca las revive por su cuenta, para no reintentar en loop un dato realmente inválido.

Preservación entre sesiones

AppDatabase.wipeAllData({bool keepOutbox = false}) borra el cache de dominio en signout/signin — con keepOutbox: true preserva la cola (pending + failed) y las filas de feature que la respaldan (pendingOp IS NOT NULL). Se usa en dos casos donde el dueño de las escrituras no cambió (ver ADR-0056):

  • Sesión expirada (AuthRepository.clearSession()): siempre keepOutbox: true.
  • Re-login del mismo usuario (AuthRepository.signin()): compara el id entrante contra el último guardado (AppMeta clave last_user_id); solo si coincide preserva la cola. Ante cualquier duda (usuario distinto, o sin id) hace el wipe completo — el aislamiento entre usuarios en el mismo dispositivo sigue pesando más.

Un signout explícito sigue borrando todo, pero la UI avisa antes si hay cambios sin enviar (pendientes + fallidas).

Idempotencia (backend)

Header Idempotency-Key opcional en POST/PATCH/PUT/DELETE. El backend (IdempotencyInterceptor) cachea la respuesta 2xx en Redis (TTL 24h, keyed por organización + usuario + clave) y devuelve la misma respuesta ante un reintento con la misma clave. Sin el header, o si Redis no responde, el request se procesa normalmente (best-effort — ver ADR-0055). Un 409 significa "ya hay una operación con esa clave en curso" — el cliente debe reintentar más tarde, no es un error permanente.

Archivos clave

ArchivoContenido
lib/shared/database/app_database.dartTabla OutboxEntries + columnas pendingOp/localOnly en las 6 tablas de campo; wipeAllData(keepOutbox:); last_user_id en AppMeta
lib/core/sync/outbox_service.dartCola: enqueue (colapsa contra create pendiente o failed), replay (single-flight, clasificación de errores, maxAttempts), discard, requeueFailed, generateLocalId
lib/core/sync/sync_service.dartOrquesta replayOutbox() / retryFailedAndReplay() / discardEntry(); registro de callbacks de resync por entidad
lib/core/sync/write_result.dartWriteResult.synced / .queued
lib/core/sync/submit_outcome.dartResultado de un submit de UI (error / éxito / queued)
lib/core/network/connectivity_listener.dartDispara replayOutbox() al recuperar red
lib/shared/widgets/pending_badge.dartChip "Pendiente"
lib/shared/widgets/pending_changes_sheet.dartHoja "Cambios pendientes": Descartar (discardEntry) / Reintentar todo (retryFailedAndReplay)
lib/app/providers.dartwireOutboxResync (@visibleForTesting): conecta cada repo de campo al replay
lib/features/auth/data/auth_repository.dartsignin/clearSession deciden keepOutbox según el último usuario
lib/features/home/home_screen.dartConfirma el signout si hay cambios sin enviar
camaroneras_backend/src/common/interceptors/idempotency.interceptor.tsIdempotencia best-effort vía Redis
camaroneras_backend/src/common/redis/redis.service.tssetNx (lock in-flight)