wandres.dev
YJS · el estándar de facto

El modelo: el documento como raíz de todo

En Yjs el documento es la raíz de la que cuelgan los tipos compartidos con nombre, y es a la vez la unidad indivisible de sincronización, de permisos y de historia.

⏱ 18 min

Los niveles anteriores han recorrido la teoría de los tipos replicados sin coordinación, sus relojes, sus costes y sus algoritmos de secuencia. Toca ahora la biblioteca que ganó: Yjs es, con diferencia, la opción dominante en producción, del orden de novecientas mil descargas semanales y más de veinte mil estrellas en su repositorio, y por debajo de los editores basados en Tiptap y BlockNote, de los cuadernos colaborativos de Jupyter y de una lista larga de productos que no anuncian que la usan. Aprender Yjs no es aprender una interfaz de programación más: es aprender un modelo de datos concreto con decisiones tomadas, y la primera de esas decisiones —la que condiciona todas las demás— es que existe un objeto raíz llamado documento del que cuelga absolutamente todo, y que ese objeto es la unidad atómica de casi todo lo que vas a querer hacer después. Esta lección disecciona esa raíz antes de tocar un solo tipo de dato.

🎯 Al terminar esta lección sabrás
  • Entender qué es un documento en Yjs y por qué los tipos compartidos se definen y no se crean.
  • Reconocer que la unidad de sincronización, de persistencia y de autorización es el documento entero.
  • Interpretar el identificador de cliente y el vector de estado como las dos piezas que hacen posible el intercambio de diferencias.
  • Decidir con criterio cuánta cosa meter en un mismo documento, porque esa decisión es cara de revertir.

Una raíz y tipos con nombre que cuelgan de ella

Un documento de Yjs se construye con new Y.Doc() y por sí solo no contiene nada. Lo que contiene son tipos compartidos de primer nivel, y a esos tipos no se llega construyéndolos sino pidiéndolos por su nombre. La distinción es más importante de lo que parece y conviene fijarla desde el principio: los métodos que empiezan por get no consultan, definen.

import * as Y from "yjs";

const doc = new Y.Doc();

// Definir tipos de primer nivel. Idempotente: siempre el mismo objeto.
const titulo = doc.getText("titulo");
const bloques = doc.getArray("bloques");
const meta = doc.getMap("meta");
const cuerpo = doc.getXmlFragment("cuerpo");

// Forma general equivalente
const otro = doc.get("titulo", Y.Text); // otro === titulo

La documentación oficial es explícita al respecto: getText es equivalente a get con la clase correspondiente, y su descripción literal es definir un tipo compartido. Llamar dos veces con el mismo nombre devuelve el mismo objeto, y llamarlo en dos réplicas distintas devuelve dos objetos que el algoritmo considera el mismo tipo. Esa equivalencia por nombre es lo que hace que dos clientes que nunca se han visto puedan sincronizar sin negociar un esquema: el nombre de la cadena de texto es el esquema.

De ahí salen dos consecuencias prácticas inmediatas. La primera es que no hay ninguna operación de creación que pueda entrar en conflicto: dos réplicas que definen a la vez el mapa llamado meta no crean dos mapas, definen el mismo. La segunda es que el nombre de primer nivel forma parte del contrato de datos de la aplicación para siempre, con la misma dureza que el nombre de una columna en una base de datos relacional, y renombrarlo es una migración con todas sus letras.

Hay una tercera consecuencia, más sutil, que solo se aprecia al depurar. Definir un tipo no produce ninguna actualización: mientras nadie escriba dentro, el nombre existe localmente y no viaja a ninguna parte. Eso significa que dos réplicas pueden estar perfectamente sincronizadas y tener conjuntos distintos de nombres definidos, sin que eso sea un error ni un síntoma de nada. Lo que se sincroniza es contenido, y un tipo vacío no tiene contenido que sincronizar.

ℹ️
El tipo de un nombre de primer nivel se fija en el primer uso y no se negocia

Yjs no valida que el tipo con el que defines un nombre coincida con el que usó otra réplica. Si un cliente define notas como Y.Array y otro lo define como Y.Map, no hay ningún mecanismo que detecte la discrepancia en el momento de sincronizar; lo que ocurre es que cada uno interpreta la misma estructura interna con la vista equivocada y el resultado es basura silenciosa. La disciplina de esquema, por tanto, no la impone la biblioteca: la impones tú, y el sitio natural para hacerlo es un módulo único que exponga funciones de acceso y que sea el único autorizado a nombrar tipos de primer nivel.

El documento es la unidad de sincronización

La segunda decisión estructural es que los cambios no se emiten por tipo sino por documento. Cuando algo se modifica, el documento emite un evento update con un búfer binario, y ese búfer se aplica en otra réplica con Y.applyUpdate. No hay ningún canal por tipo, ninguna suscripción parcial y ninguna forma soportada de sincronizar solo el mapa de metadatos sin sincronizar el texto que vive al lado.

const doc1 = new Y.Doc();
const doc2 = new Y.Doc();

doc1.on("update", (update) => Y.applyUpdate(doc2, update));
doc2.on("update", (update) => Y.applyUpdate(doc1, update));

doc1.getArray("bloques").insert(0, ["hola"]);
doc2.getArray("bloques").get(0); // "hola"

Los búferes de actualización son conmutativos e idempotentes: se pueden aplicar en cualquier orden y varias veces sin cambiar el resultado. Esa propiedad es la que permite que el transporte sea tonto —puede duplicar mensajes, reordenarlos y reenviar los que dude— y es la razón de que escribir un proveedor propio sea sorprendentemente asequible, cosa que veremos en la última lección de este nivel.

flowchart TB
D[Y Doc raiz] --> T1[getText titulo]
D --> T2[getArray bloques]
D --> T3[getMap meta]
D --> T4[getXmlFragment cuerpo]
D --> U[evento update con buffer binario]
U --> N[transporte cualquiera]
N --> A[applyUpdate en la replica remota]
style D fill:#cba6f7,color:#11111b
style U fill:#f9e2af,color:#11111b
style A fill:#a6e3a1,color:#11111b

La misma atomicidad se hereda hacia arriba en toda la pila. Un proveedor de persistencia guarda documentos enteros; un servidor autoriza el acceso a documentos enteros; el historial de versiones, cuando se implementa, se toma sobre el documento entero. Cualquier requisito de producto que suene a que este usuario puede ver los comentarios pero no el texto se traduce inevitablemente en dos documentos distintos, nunca en dos tipos dentro del mismo.

Conviene además fijarse en lo que el búfer de actualización no contiene, porque la ausencia es informativa. No lleva una marca de tiempo de pared, no lleva la identidad del usuario, no lleva un número de secuencia global y no lleva ninguna referencia al canal por el que viaja. Todo eso son cosas que la aplicación puede querer y que tendrá que transportar por su cuenta, fuera del formato. La consecuencia es que el búfer es autosuficiente en el único sentido que le importa al algoritmo —contiene lo necesario para integrarse en cualquier réplica— y deliberadamente pobre en todo lo demás, que es lo que le permite ser tan pequeño y tan indiferente al transporte.

El identificador de cliente y el vector de estado

Para entender por qué la sincronización puede ser incremental hace falta mirar la contabilidad interna. Cada documento tiene un clientID numérico, aleatorio, generado en el momento de instanciarlo y de solo lectura. Cada estructura que ese documento crea recibe un identificador formado por ese cliente y un contador creciente, es decir, una marca de Lamport. El vector de estado es el mapa que asocia a cada cliente conocido el siguiente contador que se espera de él.

const sv2 = Y.encodeStateVector(doc2);        // que tiene ya doc2
const diff = Y.encodeStateAsUpdate(doc1, sv2); // solo lo que le falta
Y.applyUpdate(doc2, diff);

// Tambien se puede calcular sin cargar el documento en memoria
const sv = Y.encodeStateVectorFromUpdate(estadoBinario);
const parcial = Y.diffUpdate(otroEstadoBinario, sv);
const fusionado = Y.mergeUpdates([estadoBinario, parcial]);

La documentación es cuidadosa con un matiz que conviene no perder: el vector de estado se parece a un vector de versión de los que vimos en niveles anteriores, pero Yjs lo usa únicamente para describir el estado local y calcular lo que le falta al otro, no para rastrear causalidad. No es un reloj vectorial en el sentido pleno del término, y no responde a la pregunta de si dos operaciones fueron concurrentes; responde a la pregunta mucho más modesta de qué estructuras no has visto todavía.

Esa modestia deliberada es la que abre una capacidad muy útil en el servidor y que conviene conocer desde el principio: como el vector de estado se puede calcular directamente sobre un búfer binario, es posible sincronizar dos clientes y computar diferencias sin llegar a construir el documento en memoria. Un servidor que solo hace de intermediario puede así almacenar búferes, calcular lo que le falta a cada cliente y fusionar actualizaciones sin instanciar nada, con un consumo de memoria que no depende del tamaño del documento. Es la diferencia entre un servidor que escala con el número de conexiones y uno que escala con el volumen de datos, y está a una decisión de distancia.

⚠️
Un identificador de cliente por instancia, no por usuario ni por dispositivo

El clientID se genera de nuevo en cada new Y.Doc(). Dos pestañas del mismo navegador son dos clientes; recargar la página produce un cliente nuevo; una sesión larga con reconexiones puede generar decenas. Esto importa porque el vector de estado crece con el número de clientes que han escrito alguna vez en el documento, no con el número de personas. En aplicaciones de vida larga ese vector se convierte en una entrada de coste real, y es el motivo de que convenga reutilizar la misma instancia de documento durante toda la vida de la pestaña en lugar de construir una por vista.

Cuánto meter en un mismo documento

Como la atomicidad es total, la pregunta de diseño más consecuente de todo el nivel es dónde poner la frontera del documento. No hay una respuesta universal, pero sí hay ejes que hacen la decisión discutible en lugar de arbitraria.

🅨

Eje de autorización

Si dos partes de los datos pueden tener destinatarios distintos, van en documentos distintos. No hay forma de filtrar dentro de uno.

📦

Eje de carga

Todo el documento se carga entero para poder leer cualquier parte. Un documento por proyecto se abre despacio; uno por nota, rápido.

🔗

Eje de atomicidad

Lo que deba fusionarse de forma coherente tiene que compartir documento, porque solo dentro de uno hay una noción común de orden.

🌱

Subdocumentos

Yjs permite anidar documentos dentro de tipos compartidos, lo que da carga perezosa a costa de perder atomicidad entre ellos.

El patrón que mejor envejece en aplicaciones reales es el de un documento por unidad que el usuario reconoce como una cosa —una nota, un tablero, un archivo— más un documento índice separado que solo contiene metadatos ligeros y que se sincroniza siempre. El índice permite listar sin cargar, y cada unidad se carga cuando alguien la abre. Es la misma arquitectura que tiene un sistema de archivos, y funciona por las mismas razones.

Ese patrón tiene un coste que conviene aceptar con los ojos abiertos: la coherencia entre el índice y las unidades deja de estar garantizada. Si alguien renombra una nota sin conexión mientras otro la borra desde otro dispositivo, el índice y el contenido pueden quedar diciendo cosas distintas, y ningún algoritmo lo va a reconciliar porque viven en documentos separados y no comparten noción de orden. La reconciliación pasa a ser responsabilidad de tu código, normalmente en forma de una rutina que se ejecuta al cargar y que trata el índice como una vista reconstruible en lugar de como una fuente de verdad. Es trabajo real, y es el precio exacto de haber elegido una granularidad más fina que el átomo del sistema.

💡
Escribe hoy el procedimiento de partir un documento en dos

Cambiar la frontera después es la migración más cara de este ecosistema, porque no basta con mover datos: hay que decidir qué pasa con la historia, y la historia de un documento no se puede repartir entre dos. Antes de escribir la primera línea de producto, escribe en un párrafo cómo dividirías el documento si mañana hiciera falta, y comprueba si tu respuesta implica perder el historial. Si lo implica, aún estás a tiempo de elegir una frontera más fina.

Elegir la unidad de replicación es elegir la unidad de todo lo demás

Merece la pena detenerse en lo que acaba de ocurrir, porque es un patrón que reaparece en cualquier sistema distribuido y que casi nadie reconoce a tiempo. Yjs no ha tomado una decisión de comodidad al hacer del documento la unidad de sincronización: ha tomado una decisión que arrastra consigo la granularidad de la persistencia, la de la autorización, la del historial, la de la carga y la del rendimiento percibido, y lo ha hecho de una sola vez, en la primera línea de la interfaz. Cuando escribes new Y.Doc() no estás construyendo un contenedor de datos, estás declarando el átomo de tu sistema, y todo lo que quieras hacer más fino que ese átomo tendrás que hacerlo fuera del modelo, con tu propio código, y sin ninguna de las garantías que la biblioteca te da dentro. Esta forma de encadenamiento no es exclusiva de Yjs. Es exactamente lo mismo que ocurre con el agregado en un diseño dirigido por el dominio, con la partición en un sistema de mensajería, con la fila en una base de datos replicada y con el archivo en un sistema de control de versiones: en todos ellos, la unidad sobre la que el sistema garantiza consistencia acaba siendo también la unidad de todo lo que el sistema no puede dejar de hacer de forma atómica. La consecuencia metodológica es directa y vale para muchas decisiones fuera de este track. Cuando adoptes cualquier tecnología de replicación, la pregunta que hay que hacerse primero no es qué operaciones ofrece ni qué rendimiento tiene, sino cuál es su átomo, y si ese átomo coincide con el átomo de tu dominio. Si coinciden, casi todo lo demás encajará solo. Si no coinciden, vas a pasar el resto del proyecto construyendo capas que simulan una granularidad que el sistema no tiene, y cada una de esas capas será un sitio donde las garantías se pierden en silencio. Reconocer el desajuste el primer día cuesta una tarde de análisis; reconocerlo el segundo año cuesta una reescritura.

⚔️ Dibuja la frontera de tus documentos
  1. Enumera todas las entidades de tu aplicación y marca cuáles pueden tener destinatarios distintos entre sí.
  2. Para cada entidad, estima cuántos kilobytes de historia acumulará en dos años de uso real.
  3. Escribe el mapa de nombres de primer nivel que usarías, con su tipo, y guárdalo como contrato.
  4. Construye dos documentos en memoria y sincronízalos a mano con encodeStateVector y encodeStateAsUpdate.
  5. Imprime el clientID tras cinco recargas y comprueba cuánto crece el vector de estado.
  6. Escribe el procedimiento de partir tu documento principal en dos y anota qué se perdería al hacerlo.