wandres.dev
MOTORES DE SYNC I · la taxonomía

Qué es exactamente un motor de sincronización

Un motor de sincronización es la capa que mantiene una copia local al día con una remota y propaga las escrituras, y su definición útil se reconoce tanto por lo que deja fuera como por lo que asume.

⏱ 22 min

La expresión motor de sincronización se ha vuelto tan corriente en los últimos años que ha perdido casi todo su filo: se aplica por igual a una biblioteca de doscientas líneas que reintenta peticiones fallidas y a un sistema distribuido con su propio lenguaje de consultas y su propio protocolo de reanudación. Antes de comparar productos hace falta una definición que sirva para descartar, y la que sostiene este nivel entero es deliberadamente estrecha: un motor de sincronización es la capa que mantiene una copia local al día con una copia remota y propaga hacia ella las escrituras que ocurren en local. Ni una responsabilidad más. Lo verdaderamente informativo de esa frase no es lo que promete sino lo que se niega a asumir, porque cada obligación que un motor no cubre no desaparece: reaparece íntegra, y normalmente tarde, dentro del código de tu aplicación. Esta lección fija la definición, deriva de ella las cuatro obligaciones que sí son irrenunciables, nombra sin adornos lo que queda fuera y explica por qué construir una taxonomía debe preceder a mirar un solo producto.

🎯 Al terminar esta lección sabrás
  • Fijar una definición de motor de sincronización que sirva para descartar candidatos, no solo para describirlos.
  • Derivar las cuatro obligaciones irrenunciables y reconocer cuál de ellas es la técnicamente difícil.
  • Identificar las cuatro fronteras donde un motor devuelve el problema a la aplicación sin anunciarlo.
  • Entender por qué una taxonomía de tres ejes envejece mejor que cualquier catálogo de herramientas.

La definición que discrimina

Una definición útil tiene que permitir decir que no. La nuestra tiene dos cláusulas y ambas son necesarias: mantener una copia local al día respecto de una remota, y propagar hacia esa remota las escrituras locales. Una caché con revalidación cumple la primera y no la segunda, porque no tiene noción de escritura propia. Una cola de peticiones con reintentos cumple la segunda y no la primera, porque nada le informa de los cambios que hicieron otros. Ninguna de las dos es un motor de sincronización, y no por escrúpulo terminológico: es que cada una deja al programador el problema que la otra resuelve, y es exactamente en la costura entre ambos donde aparecen los errores que motivan todo este track.

De la primera cláusula se deduce una inversión en el camino de lectura que conviene enunciar despacio. La aplicación lee siempre de la copia local, nunca condicionalmente. Esa es la diferencia real entre una copia y una caché, y no es de grado sino de tipo: una caché se consulta y puede fallar, y el fallo tiene una rama de código propia —el indicador de carga, el estado de error, el reintento, el esqueleto gris—. Una copia local no falla; puede estar atrasada, pero responde. Al desaparecer la rama del fallo desaparecen con ella el estado de carga y su cortejo de estados intermedios, y esa desaparición es la mitad del valor que la gente percibe en estos sistemas sin saber nombrarla.

De la segunda cláusula se deduce la inversión simétrica en el camino de escritura. La mutación se aplica primero a la copia local y la función retorna, de modo que toda escritura tiene dos vidas: una local, inmediata y provisional, y otra remota, posterior y definitiva. Todo lo que este nivel discute vive en el intervalo entre ambas: quién decide si la segunda confirma a la primera, con qué unidad se compara, y qué parte del universo remoto tiene que estar presente en local para que la primera pudiera calcularse siquiera.

flowchart LR
APP[aplicacion] -->|lee siempre| LOC[copia local]
APP -->|escribe primero| LOC
LOC --> UP[canal ascendente]
UP -->|mutacion| REM[copia remota]
REM --> DOWN[canal descendente]
DOWN -->|cambios y confirmaciones| LOC
style LOC fill:#a6e3a1,color:#11111b
style REM fill:#89b4fa,color:#11111b
style APP fill:#cba6f7,color:#11111b

Las cuatro obligaciones irrenunciables

La definición se traduce en cuatro obligaciones, y merece la pena enunciarlas como contrato porque cualquier candidato que incumpla una está devolviéndote trabajo. La primera es una lectura local sin red, con la latencia y la disponibilidad del almacenamiento del cliente y no las de un centro de datos. La segunda es una escritura local optimista con cola durable, donde lo durable importa más que lo optimista: una mutación aceptada por la interfaz y perdida al cerrar la pestaña es peor que un fallo visible, porque el usuario ya ha seguido adelante creyendo lo contrario.

La tercera es un canal ascendente que aprende el destino de cada escritura. No basta con enviar: el motor tiene que saber si la mutación fue aceptada, rechazada o transformada, y tiene que poder decírselo a la aplicación. Un motor que envía y olvida no es un motor, es un registro de auditoría con pretensiones. La cuarta es un canal descendente reanudable, y esta es la que separa a los proyectos serios de los ejercicios: el cliente debe poder decir por dónde iba y recibir solo lo que le falta, porque volver a descargarlo todo tras cada desconexión convierte cualquier despliegue real en una tormenta de tráfico.

// El contrato minimo. Todo lo demas es implementacion o producto.
const motor = {
  leer: (consulta) => copiaLocal.ejecutar(consulta),      // 1: sin red, nunca falla por red
  escribir: (mutacion) => {                                // 2: optimista y durable
    copiaLocal.aplicar(mutacion);
    return cola.encolarDurable(mutacion);
  },
  enviar: async () => {                                    // 3: aprende el destino
    for (const m of await cola.pendientes()) {
      const veredicto = await red.enviar(m);               // aceptada, rechazada o transformada
      await cola.resolver(m.id, veredicto);
    }
  },
  recibir: async (punto) => {                              // 4: reanudable desde un punto
    const { cambios, siguiente } = await red.desde(punto);
    copiaLocal.integrar(cambios);
    return siguiente;
  },
};

La cuarta obligación explica una regularidad que de otro modo parecería casualidad: casi todos estos sistemas se acoplan al registro de replicación de una base de datos existente en lugar de inventarse uno. ElectricSQL y PowerSync leen la corriente de cambios que Postgres ya produce para su propia replicación, y PowerSync hace lo equivalente con MySQL y con MongoDB. La razón es que un canal descendente reanudable necesita una historia ordenada, durable y con posiciones citables, que es precisamente lo que un registro de replicación es. LiveStore invierte el planteamiento y llega al mismo sitio por el otro lado: convierte el registro determinista de mutaciones en el artefacto primario y trata el estado de SQLite como una proyección derivada de él.

ℹ️
El punto de reanudación es el dato más delicado del sistema

Ese cursor, marca de agua o número de secuencia que el cliente guarda para decir por dónde iba es, en tamaño, el dato más pequeño del motor y, en consecuencias, el más peligroso. Si se adelanta, el cliente se pierde cambios de forma silenciosa y su copia diverge sin que nada lo señale. Si se atrasa, recibe cosas que ya tenía, y solo sobrevives si la integración es idempotente. Si se corrompe o queda huérfano tras un cambio de esquema, la única salida honesta es descartar la copia local y volver a bajarlo todo. Trátalo con el mismo cuidado que dedicarías a una clave primaria, escríbelo en la misma transacción que los datos que confirma y no lo actualices nunca antes de haber integrado con éxito.

Lo que deja fuera y quién acaba pagándolo

🗄️

El almacenamiento local

El motor supone que existe un lugar donde persistir. Elegirlo, medir su coste y sobrevivir a sus cuotas fue el trabajo de los bloques anteriores y sigue siendo tuyo.

🔀

La semántica de la fusión

Qué significa que dos escrituras se solapen no lo decide el motor sino tu modelo. El motor transporta y aplica; el significado lo pusiste tú al elegir las estructuras.

🔌

El transporte y la sesión

Reconexión, espera exponencial, renovación de credenciales, expiración de tokens a mitad de una corriente abierta. Casi ningún motor cubre esto entero.

🧭

El producto

Qué se le enseña al usuario mientras algo está pendiente, qué pasa cuando el servidor rechaza, y cómo migra el esquema de una copia local que lleva meses apagada.

De las cuatro omisiones, la que más caro sale casi siempre es la última, y dentro de ella la migración del esquema del cliente. En una arquitectura convencional el esquema vive en un solo sitio y una migración es un evento con principio y final. Aquí el esquema está replicado en cada dispositivo que alguna vez abrió tu aplicación, algunos de los cuales llevan meses sin encenderse y llegarán a la red con una copia local escrita por una versión de tu código que ya nadie recuerda. Ningún motor de los que existen resuelve eso por ti; en el mejor de los casos te da un número de versión y un gancho donde colgar tu propia lógica de conversión.

La segunda omisión más cara es la semántica del estado pendiente en la interfaz, que suele confundirse con un problema de diseño visual cuando es un problema de modelo. Una escritura pendiente no es un booleano: puede estar encolada, en vuelo, aceptada, rechazada, o transformada por el servidor de un modo que altera lo que el usuario ve. Si tu capa de vista solo distingue entre hay datos y no hay datos, el motor no puede ayudarte, y el resultado típico es una interfaz que enseña con total aplomo un valor que el servidor ya rechazó hace treinta segundos.

Sería más entretenido empezar por los productos, y sería un error. Este ecosistema se mueve tan deprisa que un catálogo memorizado caduca antes de resultar útil, y hay una prueba reciente y contundente: ElectricSQL nació exigiendo estructuras convergentes en el cliente y en 2024 abandonó ese planteamiento para convertirse en una capa que hace llegar tablas de Postgres al cliente sin imponerle esa condición. Quien había memorizado la ficha del producto tuvo que tirarla; quien había entendido en qué eje se movía vio el desplazamiento y supo hacia dónde.

Los tres ejes que ocupan el resto del nivel no salen de comparar herramientas sino de las restricciones del problema, y por eso no caducan. La autoridad pregunta quién tiene la última palabra sobre una mutación. La unidad pregunta qué objeto viaja y se compara: la fila de una base relacional o el documento con identidad propia. El alcance pregunta qué subconjunto del universo remoto baja al cliente. Cualquier motor que exista hoy o se anuncie mañana ocupa una posición en los tres, la declare o no, y conocer su posición predice sus virtudes y sus patologías mucho mejor que su documentación.

Ya tienes un motor de sincronización: la pregunta es si tiene nombre

La forma habitual de plantear esta decisión —vamos a evaluar si adoptamos un motor de sincronización— contiene un error de premisa que conviene desmontar antes de seguir, porque cambia por completo cómo se lee el resto del nivel. Toda aplicación con cliente ya tiene un motor de sincronización. Lo que ocurre es que está escrito mal, disperso y sin nombre: cada efecto que vuelve a pedir datos al enfocar la ventana es su canal descendente, cada actualización optimista dentro de un gancho de mutación es su escritura local, cada clave de invalidación de caché es su política de integración, cada reintento con espera creciente es su canal ascendente, y cada bandera que dice que algo se está guardando es su modelo de estado pendiente. La pregunta nunca fue si tienes uno, sino si es un componente con un contrato o cien fragmentos sin ninguno. Y aquí llega lo que de verdad importa: los tres ejes de este nivel no son categorías académicas, son exactamente las tres decisiones que esos fragmentos toman por su cuenta y sin ponerse de acuerdo. Sobre la autoridad, unos fragmentos creen a la respuesta del servidor y otros mantienen el valor local hasta que alguien recargue. Sobre la unidad, unos invalidan por entidad, otros por lista y otros por pantalla entera. Sobre el alcance, unos piden por página, otros piden todo al arrancar y otros piden bajo demanda al abrir un menú. La incoherencia entre fragmentos no es un defecto de estilo que se arregle con una revisión de código: es la causa mecánica de la clase de error más frustrante de depurar, esa en la que dos pantallas de la misma aplicación muestran valores distintos del mismo dato y ambas creen tener razón, porque literalmente la tienen —cada una aplicó una respuesta distinta a las tres preguntas—. Adoptar un motor de sincronización no es, por tanto, una decisión de rendimiento ni de moda arquitectónica. Es la decisión de dar una sola respuesta explícita a tres preguntas que tu código ya está respondiendo cien veces y de cien maneras. Ese es el valor real, y explica por qué equipos que migran a estos sistemas informan de que borraron más código del que escribieron: no ganaron una capacidad nueva, eliminaron una incoherencia que llevaban años pagando en errores intermitentes.

⚔️ Descubre el motor que ya escribiste
  1. Recorre tu aplicación y haz el inventario de todos los sitios donde se vuelve a pedir un dato al servidor: enfoque de ventana, reconexión, intervalos, navegación, invalidaciones tras mutar.
  2. Marca cuáles de esos sitios sobreviven a un cierre de pestaña con la escritura aún sin enviar, y anota qué pasa con las que no.
  3. Localiza dos pantallas que muestren el mismo dato y comprueba, con la red desconectada, si pueden discrepar. Si pueden, escribe por qué.
  4. Escribe en una línea qué responde hoy tu código a cada uno de los tres ejes, y señala los fragmentos que responden distinto.
  5. Enumera cuál de las cuatro obligaciones del contrato mínimo incumple tu situación actual y estima cuánto código tuyo existe solo para compensarla.
  6. Documenta qué ocurriría si un dispositivo con tu copia local se encendiera hoy tras seis meses apagado y tres versiones de esquema por detrás.