Apariencia
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 siguienteLas 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 lasfailedapending(resetea intentos y error) y dispara el replay. Es el único disparador que tocafailed— 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()): siemprekeepOutbox: true. - Re-login del mismo usuario (
AuthRepository.signin()): compara el id entrante contra el último guardado (AppMetaclavelast_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
| Archivo | Contenido |
|---|---|
lib/shared/database/app_database.dart | Tabla OutboxEntries + columnas pendingOp/localOnly en las 6 tablas de campo; wipeAllData(keepOutbox:); last_user_id en AppMeta |
lib/core/sync/outbox_service.dart | Cola: enqueue (colapsa contra create pendiente o failed), replay (single-flight, clasificación de errores, maxAttempts), discard, requeueFailed, generateLocalId |
lib/core/sync/sync_service.dart | Orquesta replayOutbox() / retryFailedAndReplay() / discardEntry(); registro de callbacks de resync por entidad |
lib/core/sync/write_result.dart | WriteResult.synced / .queued |
lib/core/sync/submit_outcome.dart | Resultado de un submit de UI (error / éxito / queued) |
lib/core/network/connectivity_listener.dart | Dispara replayOutbox() al recuperar red |
lib/shared/widgets/pending_badge.dart | Chip "Pendiente" |
lib/shared/widgets/pending_changes_sheet.dart | Hoja "Cambios pendientes": Descartar (discardEntry) / Reintentar todo (retryFailedAndReplay) |
lib/app/providers.dart | wireOutboxResync (@visibleForTesting): conecta cada repo de campo al replay |
lib/features/auth/data/auth_repository.dart | signin/clearSession deciden keepOutbox según el último usuario |
lib/features/home/home_screen.dart | Confirma el signout si hay cambios sin enviar |
camaroneras_backend/src/common/interceptors/idempotency.interceptor.ts | Idempotencia best-effort vía Redis |
camaroneras_backend/src/common/redis/redis.service.ts | setNx (lock in-flight) |