Los proveedores: separar el CRDT del transporte
Yjs no sabe nada de redes ni de discos: los proveedores conectan el documento con un transporte o un almacén, y persistir una versión exige guardar un vector de estado más un conjunto de borrados.
Llegamos al cierre del nivel con la pieza que explica por qué este ecosistema es tan grande: Yjs, la biblioteca, no sabe absolutamente nada de redes, de servidores, de bases de datos ni de discos. Lo único que hace es emitir búferes binarios cuando algo cambia y aceptar búferes binarios cuando alguien se los da. Todo lo que ocurre entre esos dos extremos —abrir un socket, reconectar tras una caída, guardar en el navegador, autenticar a un usuario, replicar en un servidor— vive en módulos independientes llamados proveedores, que la documentación define como el punto de partida perfecto para una aplicación colaborativa porque se encargan de la comunicación entre clientes, de la gestión de la información de presencia y del almacenamiento para uso sin conexión. Esta lección disecciona esa frontera, recorre el catálogo real de proveedores, muestra el protocolo de sincronización que todos implementan y termina con un detalle de almacenamiento que decide si tu producto puede ofrecer historial de versiones o no.
- Entender qué contrato cumple un proveedor y por qué el núcleo no depende de ninguno.
- Distinguir proveedores de conexión de proveedores de persistencia y saber que se combinan.
- Seguir el protocolo de sincronización en dos pasos y reconocer el viaje adicional que introduce.
- Calcular qué hay que guardar de verdad para persistir una versión y qué condición previa exige.
Qué es exactamente un proveedor
Un proveedor no es una interfaz formal que la biblioteca imponga: es un patrón. Un módulo que recibe un documento, se suscribe a su evento de actualización, lleva esos búferes a algún sitio, y aplica de vuelta con Y.applyUpdate lo que llega desde ese sitio. La mayoría gestiona además el canal de awareness sobre el mismo transporte y expone alguna forma de saber cuándo ha terminado la sincronización inicial.
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import { IndexeddbPersistence } from "y-indexeddb";
const doc = new Y.Doc();
// Persistencia local: el documento esta disponible al instante
const local = new IndexeddbPersistence("mi-documento", doc);
local.whenSynced.then(() => console.log("cargado desde indexeddb"));
// Red: solo hay que sincronizar las diferencias
const red = new WebsocketProvider("wss://ejemplo.dev", "mi-documento", doc);
Ese ejemplo contiene la recomendación central de la documentación oficial y conviene subrayarla porque casi todo el mundo empieza usando solo la mitad: en la mayoría de los casos quieres un proveedor de red combinado con un proveedor de persistencia. El de persistencia hace que el documento esté disponible de inmediato al abrir la aplicación, sin esperar a la red, y reduce lo que hay que sincronizar a las diferencias. Sin él tienes una aplicación colaborativa; con él tienes una aplicación local primero, que es de lo que trata este track entero.
El mismo formato binario está implementado en otros lenguajes mediante ports compatibles: y-crdt en Rust con enlaces para Python, Ruby, Swift, Kotlin, C y WebAssembly, entre otros, además de y-octo, otra implementación en Rust. Un servidor escrito en Python con pycrdt puede fusionar actualizaciones producidas por un navegador con JavaScript, porque lo que comparten no es el código sino el formato de las actualizaciones. Esa compatibilidad a nivel de bytes es lo que permite tener lógica de servidor que entiende el documento en lugar de limitarse a reenviar búferes opacos.
El catálogo real
Conviene tener una idea del mapa antes de elegir, porque la lista es larga y las opciones no son intercambiables. Se divide en dos categorías que la documentación mantiene separadas y que responden a preguntas distintas.
y-websocket
El de referencia para el modelo cliente-servidor. Existen backends alternativos compatibles: y-redis, y-sweet, yrs-warp, ypy-websocket y Hocuspocus.
y-webrtc
Propagación entre pares con servidores de señalización, con cifrado opcional del canal de señalización mediante un secreto compartido.
y-indexeddb
Persistencia en el navegador. El documento está disponible al instante y solo se sincronizan diferencias por la red.
Servidor con almacén
y-postgresql, y-mongodb-provider, y-op-sqlite para React Native, y-sweet contra S3 o sistema de archivos, y opciones gestionadas como Liveblocks o PartyKit.
Hay además una familia menos citada y muy interesante para lo que persigue este track, que consiste en usar como transporte una infraestructura que ya existe por otros motivos. Hay proveedores sobre Matrix, sobre el protocolo AT que sostiene Bluesky, sobre libp2p con difusión por GossipSub, sobre ElectricSQL, sobre objetos durables de Cloudflare y sobre nostr. Su interés no es la novedad: es que trasladan al transporte problemas que de otro modo tendrías que resolver tú, como la autenticación, la federación o el cifrado extremo a extremo.
La pregunta que discrimina de verdad entre las opciones no es qué protocolo usan sino dónde se decide quién puede leer y escribir. En un proveedor entre pares no hay ningún sitio natural para esa decisión y acabas cifrando el contenido o aceptando que quien tenga la clave de la sala tiene acceso completo. En uno cliente-servidor la decisión vive en el servidor, que puede rechazar la conexión y para eso existe el tercer protocolo del paquete, el de autorización, con su mensaje de permiso denegado. Decide esto antes que nada, porque cambiar de topología después arrastra el modelo de seguridad entero.
El protocolo de sincronización en dos pasos
Todos los proveedores de red implementan el mismo protocolo, publicado como módulo independiente, y entenderlo es lo que permite depurar cuando algo no sincroniza. El intercambio tiene dos mensajes con nombres poco imaginativos y muy descriptivos.
import * as syncProtocol from "@y/protocols/sync";
import * as encoding from "lib0/encoding";
import * as decoding from "lib0/decoding";
// Saludo inicial: envio mi vector de estado
const enc = encoding.createEncoder();
syncProtocol.writeSyncStep1(enc, doc);
enviar(encoding.toUint8Array(enc));
// Al recibir cualquier mensaje del protocolo
const dec = decoding.createDecoder(buffer);
const respuesta = encoding.createEncoder();
syncProtocol.readSyncMessage(dec, respuesta, doc, origen);
if (encoding.length(respuesta) > 0) enviar(encoding.toUint8Array(respuesta));
// Cambios posteriores, ya en regimen estacionario
doc.on("update", (update) => {
const e = encoding.createEncoder();
syncProtocol.writeUpdate(e, update);
enviar(encoding.toUint8Array(e));
});
El primer paso lleva el vector de estado de quien saluda. El segundo lleva todas las actualizaciones que al otro le faltan, calculadas contra ese vector. Como ambos extremos ejecutan el mismo procedimiento en sentidos opuestos, al terminar los dos tienen todo. La documentación es explícita sobre el precio: sincronizar usando el vector de estado exige un viaje adicional, pero ahorra mucho ancho de banda, y esa es exactamente la disyuntiva que uno querría poder elegir.
sequenceDiagram participant A as cliente A participant B as servidor B A->>B: syncStep1 con el vector de estado de A B->>A: syncStep2 con lo que le falta a A B->>A: syncStep1 con el vector de estado de B A->>B: syncStep2 con lo que le falta a B Note over A,B: a partir de aqui solo mensajes update A->>B: update tras cada transaccion local B->>A: update propagado desde otros clientes
Que el protocolo esté publicado como paquete separado y documentado es lo que hace viable escribir un proveedor propio, y hay más razones para hacerlo de las que parece: integrarlo con un sistema de autenticación existente, multiplexar varios documentos sobre una conexión, añadir compresión, o sencillamente adaptarlo a un transporte que ya tienes. El trabajo real no está en el protocolo, que son unas decenas de líneas, sino en la reconexión, el respaldo exponencial y la gestión del estado de conexión.
El detalle de almacenamiento que decide si hay historial
Queda el asunto más específico de la lección y el que más equipos descubren tarde. Persistir el estado actual de un documento es trivial: Y.encodeStateAsUpdate produce un búfer que lo contiene todo. Persistir una versión concreta a la que se pueda volver es otra cosa, y aquí Yjs se separa de otras bibliotecas de una forma que hay que conocer antes de prometer un historial de versiones en un producto.
En Loro y en Automerge, el grafo completo de la historia forma parte de lo que se guarda, de modo que una versión pasada es una coordenada dentro de algo que ya está en disco. Yjs no guarda ese grafo: guarda una estructura optimizada para el estado presente. Por eso, para poder reconstruir cómo estaba el documento en un instante pasado, hace falta almacenamiento adicional, y lo que hay que almacenar por cada versión está definido con precisión en el código fuente. Una instantánea es exactamente dos cosas: un vector de estado y un conjunto de borrados.
const doc = new Y.Doc({ gc: false }); // condicion previa imprescindible
const version = Y.snapshot(doc); // { sv: vector de estado, ds: borrados }
const guardable = Y.encodeSnapshot(version);
// guardable es lo que hay que persistir POR CADA version que quieras conservar
const recuperada = Y.decodeSnapshot(guardable);
const docPasado = Y.createDocFromSnapshot(doc, recuperada);
Y.equalSnapshots(version, recuperada); // comparacion barata entre versiones
Las dos piezas responden a preguntas distintas y las dos son necesarias. El vector de estado dice hasta qué contador de cada cliente había llegado el documento, es decir, qué existía. El conjunto de borrados dice cuáles de esas estructuras estaban marcadas como eliminadas en ese momento, es decir, qué se veía. Sin el primero no se sabe qué contenido incluir; sin el segundo no se sabe cuál de ese contenido estaba visible, porque el borrado no es un contador creciente y no se puede deducir del vector.
El código de createDocFromSnapshot lanza un error explícito si el documento de origen tiene la recolección activada, y la razón es que no se debe intentar restaurar un documento recolectado porque parte del contenido restaurado tendría su contenido ya eliminado. Esto significa que la decisión de ofrecer historial no se puede tomar más adelante: hay que construir el documento con gc desactivado desde el primer día, aceptando que las lápidas conservarán su contenido para siempre y que el documento ocupará bastante más. Un documento que lleva dos años recolectando no puede volver atrás, y ninguna instantánea guardada sobre él servirá.
Conviene por tanto hacer la cuenta antes de prometer nada. Con una instantánea por sesión de edición y varias sesiones diarias, el número de vectores de estado guardados crece deprisa, y cada uno de ellos tiene tamaño proporcional al número de clientes que han escrito alguna vez en el documento, que como vimos en la primera lección no es el número de personas sino el de instancias. A eso se suma que el propio documento pesa más por no recolectar. La conclusión práctica no es que el historial sea inviable, sino que es una funcionalidad con un coste de almacenamiento que hay que dimensionar, y que su política de retención —cuántas versiones, cada cuánto, durante cuánto tiempo— es una decisión de producto que conviene tomar antes de escribir código y no después de recibir la factura.
Cerramos el nivel con la observación que da sentido a los cinco temas juntos, y que conviene formular como criterio general de evaluación técnica. Yjs es la opción dominante por razones excelentes y muy poco misteriosas: es rápida, está probada en producción a gran escala, tiene el ecosistema de integraciones más completo, existe en media docena de lenguajes con compatibilidad binaria y su algoritmo cuenta ya con verificación formal parcial de sus propiedades. Nada de eso está en discusión. Pero el recorrido de este nivel debería haber dejado claro que al adoptarla no has adoptado una implementación, has adoptado un conjunto de respuestas a preguntas que quizá no sabías que se estaban respondiendo: que la unidad de todo es el documento entero, que el conflicto en un mapa lo gana uno solo, que lo efímero vive fuera, que lo que se guarda es el estado y no el grafo de la historia. Esa última respuesta es la que se hace visible tarde, y es la que ilustra el punto general con más claridad. Automerge y Loro guardan la historia completa y pagan por ello en tamaño y en velocidad de carga; Yjs guarda el presente y paga por ello exigiéndote almacenamiento adicional y una decisión irreversible sobre la recolección de basura si algún día quieres mirar atrás. Ninguna de las dos posturas es incorrecta: son inversiones distintas, con rendimientos distintos, hechas por equipos que pensaban en usos distintos. Lo incorrecto es adoptar una sin saber cuál has elegido. De aquí sale un criterio que vale para cualquier decisión de infraestructura y que casi nunca aparece en las comparativas: al evaluar una tecnología de datos, no compares funcionalidades ni rendimiento, compara qué considera cada una digno de ser persistido y qué considera derivable o desechable. Esa distinción es la más profunda que toma un sistema de almacenamiento, es la que no se puede cambiar después sin migrar, y es la que determina qué funcionalidades de producto te resultarán naturales dentro de tres años y cuáles te resultarán imposibles. El rendimiento se mejora con trabajo; el catálogo de integraciones crece solo; el modelo de lo que merece ser guardado es la única parte que no se puede refactorizar, porque no vive en tu código sino en los archivos de tus usuarios.
- Combina un proveedor de persistencia local con uno de red y mide el tiempo hasta primera pintura con y sin el primero.
- Corta la red durante una edición larga y comprueba qué llega al reconectar y en cuántos mensajes.
- Implementa el saludo del protocolo a mano con
writeSyncStep1yreadSyncMessagesobre un canal de prueba. - Crea un documento con recolección desactivada, guarda diez instantáneas y mide cuánto ocupan en total.
- Restaura una versión pasada con
createDocFromSnapshoty compara su texto con el que esperabas. - Escribe la política de retención de versiones de tu producto y estima su coste anual de almacenamiento.