Skip to main content

Offline-first

Lo más delicado de BullStock. El transportista trabaja en domicilios sin señal: una mutation que se pierde es una entrega que no quedó registrada.

Si vas a tocar el submit de un remito, la firma, un escaneo o cualquier mutation, leé esta página entera.

Las tres piezas

ArchivoRol
services/networkMonitor.tsEstado de conectividad + checkNetworkStatus()
services/offlineQueue.tsPersiste las mutations pendientes en SQLite
services/syncService.tsReproduce la cola cuando vuelve la conexión

La UI lo muestra con components/shared/feedback/networkBanner.tsx (banner inferior; no renderiza nada si hay red). Para depurar en el dispositivo: components/shared/debug/viewDatabase.tsx.

El flujo de una mutation

Si hay pendientes, se encola sin intentar

Es a propósito, no una optimización perdida: evita que una acción reciente llegue al backend antes que las que ya estaban esperando y desordene el estado del remito. No la "mejores" poniendo un if (isOnline) send() adelante.

El éxito sintético

Cuando encola, la mutation no falla: devuelve

{ success: true, savedOffline: true, message: 'Guardado localmente, se enviará cuando haya conexión' }
Todo call-site tiene que mirar savedOffline

Si no lo mirás, la pantalla le dice al operario "entregado" cuando en realidad quedó en cola. Mostrá "guardado, se enviará" en ese caso.

Deduplicación de taps

Hay un Map de mutations en vuelo por type + id. Un segundo tap sobre el mismo botón reutiliza la promise del primero en vez de disparar otra request. Es transparente para el caller: si el primer tap tiene éxito, el segundo "ve" ese mismo éxito.

La tabla

offline_queue (services/database/schemas/offlineQueue.ts):

Columna
type, endpoint, method, payload, headersLa request a reproducir
retries, status (default pending), error_message, last_attempt_atControl del replay
created_at, updated_at, synced_atAuditoría

Índices por status, created_at y type.

FormData

El payload se serializa a { _isFormData: true, _formDataFields: [...] } para poder guardarlo en SQLite; los File/{uri} se reducen a { name, type, uri }. El syncService lo reconstruye al reproducir.

Por eso el archivo local tiene que seguir existiendo: el sweep de archivos huérfanos que corre al iniciar la base preserva los referenciados por offline_queue.

API de la cola

hasPendingItemsInQueue() // ¿hay algo esperando?
isDuplicateInQueue(data) // evita encolar dos veces lo mismo
addToOfflineQueue(data) // → id | null
updateLastQueuedItemPayload(...) // corrige el último encolado
purgeCompletedOfflineQueueIfIdle() // limpia lo ya sincronizado
isNetworkError(error) // el discriminador que usa el hook

startSyncService() / stopSyncService() / getSyncService()
getOfflineQueueStats() / getOfflineQueueFailedItem()

La base local

bullstock.db, con un espejo de lo que el transportista necesita sin señal: hoja de ruta, remitos y sus líneas, pacientes, productos, elementos, stock del vehículo y firmas pendientes.

  • services/database/init.tsinitDatabase(): conecta, corre createTables() (que incluye migraciones de columnas) y limpia archivos huérfanos.
  • Un archivo de schema por entidad en services/database/schemas/. Para agregar una columna, la migración va dentro del propio createXTable(): no hay sistema de migraciones aparte.
  • Repositorios de dominio en lib/: roadmapRepository, signatureRepository, fileRepository, deliveryNoteUtils.

supply_for_roadmap

Es el stock del vehículo proyectado para la hoja de ruta: de ahí salen los selectores de qué se puede entregar o retirar. Es la tabla que más problemas dio.

  • Se resincroniza desde el backend (hook useResyncVehicleStock, disparado al enfocar Home/Mapa y por un botón "Recalcular").
  • Esa resincronización tiene un mutex de módulo y un guard contra respuestas vacías o no-JSON: sin eso, una respuesta rara borraba el stock local del vehículo.

Errores que ya nos costaron caro

Un SessionExpiredError NO se encola

El replay también necesita token: encolarlo solo posterga el fallo y le hace creer al operario que quedó guardado. Tampoco se marca la tarea como finalizada localmente.

La cola sin purga degrada la app

Sin purgeCompletedOfflineQueueIfIdle() la app se ponía progresivamente lenta después de días de uso. No la saques del ciclo.

No abras transacciones de expo-sqlite alrededor de lógica de negocio

Ya hubo un problema serio con eso. La concurrencia se resuelve con un mutex a nivel de módulo, no con BEGIN.

SQLite guarda NULL de verdad: cualquier concatenación de campos de texto tiene que ser null-safe. Ver el caso de productLabel en Visión general.

Documentación complementaria

En el repo mobile: docs/OFFLINE_ARCHITECTURE.md, docs/REACT_QUERY_OFFLINE.md, docs/SQLITE_USAGE.md y docs/supplyForRoadmap.AGENTS.md.