Skip to content

🔄 ESTRATEGIAS DE SINCRONIZACIÓN DE DATOS (OFFLINE-FIRST)

🎯 OBJETIVO

Garantizar la continuidad operativa de aplicaciones web, móviles y de escritorio sin conexión a internet, manteniendo la coherencia de datos y resolviendo conflictos de sincronización sin pérdida de información del usuario.


🎯 REGLAS INQUEBRANTABLES

[REQUIRED] SYNC-001: NUNCA perder datos del usuario por un conflicto de sincronización. Si dos escrituras entran en conflicto y no se pueden fusionar automáticamente, el estado anterior DEBE preservarse en un borrador de conflicto.

Por qué: en un flujo offline-first el usuario edita sin red y confía en que su trabajo se guardará; si un conflicto de sincronización se resuelve descartando una de las dos escrituras sin avisar, el usuario pierde trabajo sin enterarse hasta que ya es tarde para recuperarlo.

[REQUIRED] SYNC-002: Todo registro offline DEBE incluir metadatos de sincronización: client_updated_at, server_updated_at, sync_status (pending, syncing, synced, conflict, failed).

Por qué: sin metadatos de sincronización no hay forma de saber qué escritura es más reciente, cuál ya se envió y cuál sigue pendiente — son los datos que la resolución de conflictos y el reintento necesitan para funcionar, no un adorno del esquema.

[REQUIRED] SYNC-003: Sincronización Incremental por defecto. NUNCA descargar el dataset completo; solicitar únicamente registros modificados después del último last_synced_at.

Por qué: descargar el dataset completo en cada sincronización desperdicia ancho de banda y batería en proporción al tamaño total de los datos, no al tamaño de lo que realmente cambió — y en móvil esa diferencia se paga en datos del usuario.


📊 1. MATRIZ DE ESTRATEGIAS DE RESOLUCIÓN DE CONFLICTOS

EstrategiaAlgoritmoCaso de UsoComplejidad
Last Write Wins (LWW)Gana el timestamp más recienteDatos simples (perfil, configuraciones)🟢 Baja
CRDTs (State-based / Operation-based)Fusión matemática sin conflictosEditores colaborativos, contadores, listas🟡 Media
Manual User ResolutionPresenta UI de conflicto al usuarioFormularios complejos, contratos, documentos🔴 Alta

💻 2. COLA OFFLINE EN EL CLIENTE (IndexedDB + Dexie.js)

typescript
// src/lib/db.ts - Base de datos IndexedDB local para el cliente
import Dexie, { Table } from 'dexie'

export interface OfflineMutation {
  id?: number
  client_id: string
  action: 'CREATE' | 'UPDATE' | 'DELETE'
  entity: string
  payload: Record<string, unknown>
  status: 'pending' | 'syncing' | 'failed'
  created_at: number
}

class AppOfflineDatabase extends Dexie {
  mutations!: Table<OfflineMutation>

  constructor() {
    super('AppOfflineDatabase')
    this.version(1).stores({
      mutations: '++id, client_id, action, entity, status, created_at'
    })
  }
}

export const offlineDb = new AppOfflineDatabase()

💻 3. HOOK EN REACT: useSync CON REACT QUERY Y DEXIE

tsx
// src/hooks/useSync.ts
import { useEffect, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { offlineDb } from '@/lib/db'

export function useSync() {
  const queryClient = useQueryClient()
  const [isSyncing, setIsSyncing] = useState(false)
  const [pendingCount, setPendingCount] = useState(0)

  useEffect(() => {
    // Actualizar contador de mutaciones pendientes
    const interval = setInterval(async () => {
      const count = await offlineDb.mutations.where('status').equals('pending').count()
      setPendingCount(count)
    }, 1000)

    return () => clearInterval(interval)
  }, [])

  const triggerSync = async () => {
    if (isSyncing || !navigator.onLine) return
    setIsSyncing(true)

    try {
      const pendingMutations = await offlineDb.mutations
        .where('status')
        .equals('pending')
        .toArray()

      for (const mutation of pendingMutations) {
        // Marcar como en proceso
        await offlineDb.mutations.update(mutation.id!, { status: 'syncing' })

        try {
          const res = await fetch('/api/sync', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(mutation)
          })

          if (res.ok) {
            // Eliminar de la cola al confirmar sincronización
            await offlineDb.mutations.delete(mutation.id!)
          } else {
            await offlineDb.mutations.update(mutation.id!, { status: 'failed' })
          }
        } catch (err) {
          await offlineDb.mutations.update(mutation.id!, { status: 'pending' })
          break // Si se cae la red, pausar la sincronización
        }
      }

      // Invalidar caché de React Query para refrescar UI
      await queryClient.invalidateQueries()
    } finally {
      setIsSyncing(false)
    }
  }

  // Escuchar eventos de reconexión de red
  useEffect(() => {
    window.addEventListener('online', triggerSync)
    return () => window.removeEventListener('online', triggerSync)
  }, [])

  return { triggerSync, isSyncing, pendingCount }
}

⚡ 4. WORKER HANDLER: SINCRONIZACIÓN INCREMENTAL

typescript
// POST /api/sync
export async function handleSyncEndpoint(request: Request, env: Env) {
  const userId = request.headers.get('x-user-id')!
  const { client_id, action, entity, payload, client_updated_at } = await request.json()

  // 1. Obtener estado actual en el servidor
  const { data: serverRecord } = await supabase
    .from(entity)
    .select('id, updated_at')
    .eq('id', payload.id)
    .single()

  // 2. Conflict Resolution (Last Write Wins)
  if (serverRecord) {
    const serverTime = new Date(serverRecord.updated_at).getTime()
    const clientTime = new Date(client_updated_at).getTime()

    if (serverTime > clientTime) {
      // Conflicto: El servidor tiene una versión más reciente
      return fail('SYNC_CONFLICT', 'El registro en el servidor fue modificado recientemente.', 409)
    }
  }

  // 3. Aplicar mutación
  const { data, error } = await supabase
    .from(entity)
    .upsert({ ...payload, user_id: userId, updated_at: new Date().toISOString() })

  if (error) return fail('SYNC_ERROR', error.message, 500)

  return ok({ synced: true, data })
}

📋 CHECKLIST DE SINCRONIZACIÓN

  • [ ] Registro local en IndexedDB con Dexie.js para mutaciones offline.
  • [ ] Listener de reconexión window.addEventListener('online', triggerSync).
  • [ ] Indicador de estado visual en la UI (pending, syncing, synced, conflict).
  • [ ] Resolución de conflictos LWW o modal de intervención manual ante HTTP 409.
  • [ ] Invalidation de React Query tras sincronizar exitosamente.

171 documentos indexados · generado desde INDEX.json