Ir al contenido

Sincronización

El SDK es offline-first: cada dispositivo trabaja contra una base local PouchDB y sincroniza con CouchDB cuando hay red. La clase Database (src/database/Database.ts) orquesta la replicación; el SyncWatchdog vigila que no se quede congelada; y el NetworkMonitor reporta el estado de la conexión. Esta página describe ese flujo real, tal como vive en el código del SDK.

La base local replica contra un CouchDB remoto expuesto por tables-socket. El cliente nunca llega a CouchDB directo: todo pasa por el proxy con autenticación HMAC. Ver plataforma e infraestructura para el detalle del ruteo.

  • PouchDB local: IndexedDB (web) o SQLite (app), por compañía. El nombre de la base local incorpora el companyId extraído del token JWT.
  • CouchDB remoto: una base física por compañía. El aislamiento multi-tenant se hace a nivel de nombre de base en la URL de CouchDB, no por filtro de documentos.

Cada fetch hacia el remoto inyecta el Authorization: Bearer <token> y, cuando existen, los headers X-Auth-CouchDB-* guardados en localStorage.

La replicación es live y se separa en dos handlers independientes:

  • Pull (replicate.from): trae cambios del remoto a la base local.
  • Push (replicate.to): envía los cambios locales al remoto.

Ambos se configuran con live: true, retry activo, heartbeat: 10000 y timeout: 30000. El heartbeat de 10s mantiene viva la conexión long-poll a través de Cloudflare (límite 100s) y nginx (límite 60s).

Antes de la primera replicación, bootstrapIfNeeded() descarga un snapshot de datos estáticos desde tables-socket (GET /bootstrap/<companyId>), usando un SHA cacheado:

  • SHA coincide → 304, sin transferencia.
  • SHA distinto o primera vez → 200, documentos insertados con bulkDocs({ new_edits: false }) para respetar el _rev y la cadena _revisions que envía el servidor.

El bootstrap deja un update_seq en _local/bootstrapped (useAsResumeSeq: true) que el primer pull consume una sola vez para arrancar desde ese punto en vez de since: 0, evitando que CouchDB tenga que reenviar toda la historia.

  1. active: el replicador está transmitiendo documentos.

  2. change: llegó (o se envió) un lote; se notifica a onChanges y se emite telemetría sync.state='active' con docs_read / docs_written.

  3. paused: hay dos casos muy distintos.

    • Pausa sana: el long-poll está abierto y el servidor no tiene cambios. El replicador espera; volverá a active apenas llegue algo.
    • Pausa con error: el paused trae un error (socket caído, proxy colgado). El SDK marca el estado de conexión como OFFLINE.
  4. error: fallo de la replicación; el estado pasa a OFFLINE y retry reintenta de forma transparente.

El primer paused del pull marca el fin del sync inicial: se emite syncComplete.next(true) para que la UI muestre los datos ya disponibles.

El problema clásico —“Android dejó de sincronizar y volvió, no sabemos por qué”— ocurre porque paused cubre tanto el idle sano como un socket muerto que retry: true sigue reintentando en silencio: sin evento de error, sin log, el replicador simplemente queda congelado.

El SyncWatchdog (src/core/brokers/SyncWatchdog.ts) resuelve esto. La clase Database le notifica el latido del replicador desde los handlers existentes:

  • notePaused('pull' | 'push') desde .on('paused')
  • noteActive(...) desde .on('active')
  • noteChange(...) desde .on('change')
  • noteError(...) desde .on('error')

Cada checkIntervalMs (30s por defecto) el watchdog evalúa cada dirección. Si una quedó paused de forma continua por más de stalledAfterMs (3 minutos) sin cambios en esa ventana, emite una vez sync.state='stalled' con sync.paused_for_seconds y sync.last_change_age_seconds. Cuando vuelve active o change, limpia la marca y emite un evento active anotado con cuánto duró el gap.

La UI puede suscribirse vía database.syncWatchdog.observe() para mostrar indicadores tipo “Sync pausado hace 3 min”.

NetworkMonitor y el flujo offline → online

Sección titulada «NetworkMonitor y el flujo offline → online»

El NetworkMonitor (src/core/brokers/NetworkMonitor.ts) monitorea la conectividad haciendo ping periódico a <apiUrl>/ping (cada 10s por defecto, timeout 5s) y escuchando los eventos online / offline del navegador. Clasifica el estado según la latencia:

  • online: ping correcto.
  • degraded: el servidor respondió pero lento (latencia alta) o con error.
  • offline: el navegador reporta offline o el ping falló.

Solo notifica a sus listeners cuando el estado cambia.

El recorrido offline → online completo:

  1. Offline: sin red, las escrituras se persisten en la PouchDB local. El push queda en paused/error y el estado de conexión es OFFLINE. La app sigue operativa.

  2. Vuelve la red: el NetworkMonitor detecta online y los replicadores live con retry reanudan automáticamente la conexión long-poll.

  3. Push: el filtro de propias escrituras empuja al remoto los documentos creados offline por este dispositivo.

  4. Pull: trae los cambios que otros dispositivos generaron mientras tanto. PouchDB reanuda desde su checkpoint persistido (_local/<replication_id>), no desde cero.

  5. Convergencia: ambas direcciones vuelven a paused (idle sano) y el watchdog limpia cualquier marca de stalled.

Cuando dos dispositivos editan el mismo documento offline, CouchDB termina con revisiones en conflicto. En el camino de replicación activo, el SDK no resuelve en el cliente: deja el ganador determinístico de CouchDB (ver el caution de arriba).

El SDK sí incluye un pipeline de merge a nivel de campo (defaultConflictPipeline, src/utils/conflict-resolver.ts), usado por el SyncManager heredado. Su estrategia es:

  • Si solo un lado modificó un campo, gana ese valor.
  • Si ambos modificaron el mismo campo, gana el más reciente por timestamp.
  • Emite eventos conflict:resolved o conflict:unresolved por el ManagerEventBus.

Como defensa adicional, ciertos campos de UI/operacionales se eliminan de algunos tipos de documento antes de persistir (sanitizeDocByType): por ejemplo, el documento table es inmutable y su estado operacional se deriva de órdenes/reservas activas, no se persiste en el doc. Esto reduce la principal fuente de conflictos entre dispositivos del mismo tenant operando la misma mesa.

SyncManager (src/core/brokers/SyncManager.ts) implementa un esquema distinto: junta los documentos con metadata.syncStatus === 'pending', los envía a POST <apiUrl>/sync con un Bearer token, procesa la respuesta del servidor, resuelve conflictos con el defaultConflictPipeline y marca los documentos como synced. Soporta auto-sync por intervalo (startAutoSync, 5 min por defecto), gateado por NetworkMonitor.

Este camino no es el de la replicación PouchDB↔CouchDB descrita arriba: convive como mecanismo separado. La sincronización offline-first del producto se apoya en la replicación nativa de Database.