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.
Topología
Sección titulada «Topología»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
companyIdextraí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.
Replicación bidireccional
Sección titulada «Replicación bidireccional»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).
Bootstrap inicial
Sección titulada «Bootstrap inicial»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 conbulkDocs({ new_edits: false })para respetar el_revy la cadena_revisionsque 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.
Ciclo de live replication
Sección titulada «Ciclo de live replication»-
active: el replicador está transmitiendo documentos. -
change: llegó (o se envió) un lote; se notifica aonChangesy se emite telemetríasync.state='active'condocs_read/docs_written. -
paused: hay dos casos muy distintos.- Pausa sana: el long-poll está abierto y el servidor no tiene cambios. El
replicador espera; volverá a
activeapenas llegue algo. - Pausa con error: el
pausedtrae unerror(socket caído, proxy colgado). El SDK marca el estado de conexión comoOFFLINE.
- Pausa sana: el long-poll está abierto y el servidor no tiene cambios. El
replicador espera; volverá a
-
error: fallo de la replicación; el estado pasa aOFFLINEyretryreintenta 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.
SyncWatchdog: detección de sync congelado
Sección titulada «SyncWatchdog: detección de sync congelado»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:
-
Offline: sin red, las escrituras se persisten en la PouchDB local. El push queda en
paused/errory el estado de conexión esOFFLINE. La app sigue operativa. -
Vuelve la red: el
NetworkMonitordetectaonliney los replicadoresliveconretryreanudan automáticamente la conexión long-poll. -
Push: el filtro de propias escrituras empuja al remoto los documentos creados offline por este dispositivo.
-
Pull: trae los cambios que otros dispositivos generaron mientras tanto. PouchDB reanuda desde su checkpoint persistido (
_local/<replication_id>), no desde cero. -
Convergencia: ambas direcciones vuelven a
paused(idle sano) y el watchdog limpia cualquier marca destalled.
Resolución de conflictos
Sección titulada «Resolución de conflictos»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:resolvedoconflict:unresolvedpor elManagerEventBus.
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 heredado
Sección titulada «SyncManager heredado»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.
Páginas relacionadas
Sección titulada «Páginas relacionadas»- Arquitectura — visión general offline-first.
- Brokers del SDK —
SyncManager,NetworkMonitory demás. - Plataforma e infraestructura — proxy de CouchDB y ruteo.