wandres.dev
YJS · el estándar de facto

Transacciones y observadores: agrupar y escuchar

Toda modificación en Yjs ocurre dentro de una transacción, y de esa transacción salen tanto el búfer que viaja por la red como los eventos que reconstruyen la interfaz.

⏱ 20 min

Las dos lecciones anteriores describieron datos en reposo: una raíz, unos tipos colgando de ella y unas reglas de convergencia. Esta lección describe el movimiento, y lo hace alrededor de una afirmación que la documentación oficial hace sin adornos: todo cambio sobre el documento compartido ocurre dentro de una transacción. No es una funcionalidad opcional que uno pueda usar para agrupar escrituras si le apetece, es el mecanismo por el que pasa absolutamente cualquier mutación, incluida la que escribes sin pensar en transacciones. Lo que decides al usar transact de forma explícita no es si hay transacción, sino cuántas hay, y de ese número dependen tres cosas muy visibles: cuántos búferes salen a la red, cuántas veces se redibuja tu interfaz y qué agrupación verá el usuario cuando pulse deshacer. Alrededor de la transacción se organiza además todo el sistema de observación, que tiene dos modalidades cuya diferencia se paga cara si se elige mal.

🎯 Al terminar esta lección sabrás
  • Entender la transacción como unidad indivisible de cambio, de emisión y de notificación.
  • Conocer los cuatro eventos de ciclo de vida del documento y el evento de actualización.
  • Distinguir observación superficial de observación profunda y saber cuál cuesta qué.
  • Usar el origen de la transacción para cerrar bucles de retroalimentación y filtrar lo propio de lo ajeno.

La transacción como unidad indivisible

doc.transact recibe una función y, opcionalmente, un valor arbitrario llamado origen. Todo lo que ocurra dentro de esa función se agrupa: los observadores se llaman una sola vez al final y el evento de actualización se emite una sola vez con un único búfer que contiene todos los cambios.

// Tres mutaciones, una sola notificacion y un solo buffer
doc.transact(() => {
  doc.getArray("tareas").push([{ id: "t1" }]);
  doc.getMap("meta").set("modificado", Date.now());
  doc.getText("cuerpo").insert(0, "nueva linea\n");
}, "edicion-usuario");

// Sin transaccion explicita: tres transacciones implicitas,
// tres rondas de observadores y tres buffers de red
doc.getArray("tareas").push([{ id: "t2" }]);
doc.getMap("meta").set("modificado", Date.now());

La recomendación de la documentación es literal: conviene empaquetar los cambios en una sola transacción para reducir la cantidad de llamadas a los eventos. Pero la razón de fondo va más allá del rendimiento y tiene que ver con la coherencia de lo que ve el resto del sistema. Una transacción es el único intervalo durante el cual el documento puede estar en un estado que tu invariante de aplicación considera inválido —una tarea añadida al array pero todavía sin su entrada en el mapa de metadatos— sin que nadie lo observe. Fuera de la transacción, ese estado intermedio es visible para los observadores locales y, peor aún, es visible para las réplicas remotas, que lo recibirán como un búfer independiente y lo aplicarán tal cual.

Las transacciones pueden anidarse, y la biblioteca lo contempla explícitamente: si un observador que se ejecuta dentro de una transacción provoca otra, no se abren dos ciclos completos. Por eso existen eventos distintos para la transacción individual y para el conjunto de todas las transacciones anidadas.

ℹ️
La transacción es también la unidad natural del deshacer

Y.UndoManager agrupa por transacción y fusiona las que ocurren dentro de un intervalo de captura configurable, de modo que teclear varias letras seguidas produce un solo paso de deshacer. Su constructor acepta trackedOrigins, un conjunto de orígenes que decide qué transacciones entran en la pila y cuáles se ignoran. Esa combinación —agrupar por transacción y filtrar por origen— es lo que permite que el deshacer de un usuario revierta solo sus propios cambios y no los de la persona con la que está colaborando, que es exactamente el comportamiento que la gente espera y que ninguna implementación ingenua consigue.

Los eventos que emite el documento

El documento expone cinco eventos, y conviene tenerlos claros porque se usan para cosas muy distintas. El principal es update, que entrega el búfer binario, el origen de la transacción y el propio documento. Los otros cuatro delimitan el ciclo de vida.

doc.on("update", (update, origin, doc) => {
  // update es un Uint8Array listo para el transporte o para el disco
  if (origin !== "remoto") enviar(update);
});

doc.on("beforeTransaction", (transaccion, doc) => { /* ... */ });
doc.on("afterTransaction", (transaccion, doc) => { /* ... */ });
doc.on("beforeAllTransactions", (doc) => { /* ... */ });
doc.on("afterAllTransactions", (doc, transacciones) => { /* ... */ });

La diferencia entre los pares de eventos es la anidación. beforeTransaction y afterTransaction se emiten una vez por cada transacción, incluidas las anidadas; beforeAllTransactions se emite antes de la primera y afterAllTransactions una sola vez al terminar la última, recibiendo el array de todas las que se ejecutaron. Ese último es el sitio correcto para trabajo caro que debe hacerse una sola vez por ráfaga, como recalcular un índice derivado o programar un guardado.

flowchart TB
A[llamada a transact] --> B[beforeAllTransactions si es la primera]
B --> C[beforeTransaction]
C --> D[mutaciones sobre los tipos compartidos]
D --> E[observadores superficiales y profundos]
E --> F[evento update con el buffer binario]
F --> G[afterTransaction]
G --> H[afterAllTransactions con el array de transacciones]
style D fill:#89b4fa,color:#11111b
style F fill:#f9e2af,color:#11111b
style H fill:#a6e3a1,color:#11111b

Existe además la variante updateV2 para el formato de actualización de segunda versión, que la documentación describe como notablemente mejor en compresión pero que todavía no usan todos los proveedores. Si escribes tu propio transporte puedes adoptarlo desde el principio; si usas uno existente, comprueba antes cuál emite, porque los dos formatos no son intercambiables y hay funciones explícitas de conversión entre ellos precisamente porque hace falta convertirlos.

Observación superficial frente a profunda

Cada tipo compartido ofrece cuatro métodos de observación, y su firma revela la diferencia esencial. observe recibe un único evento y se dispara cuando ese tipo concreto se modifica. observeDeep recibe un array de eventos y se dispara cuando se modifica ese tipo o cualquiera de sus descendientes, entregando todos los eventos generados por él o por sus hijos.

const tareas = doc.getArray("tareas");

// Superficial: solo cambios en la propia lista
tareas.observe((evento, transaccion) => {
  evento.delta;   // retener, insertar, borrar
  evento.target;  // el tipo que cambio
});

// Profundo: cambios en la lista o dentro de cualquier tarea anidada
tareas.observeDeep((eventos, transaccion) => {
  for (const evento of eventos) {
    evento.path;         // ruta desde el observado hasta el que cambio
    evento.currentTarget; // el tipo donde se registro el observador
    evento.changes;      // added, deleted, delta y keys
  }
});

El objeto de evento es más rico de lo que sugiere el uso habitual y merece la pena conocerlo entero. target es el tipo que cambió y currentTarget es aquel sobre el que se registró la escucha, distinción que solo importa en observación profunda. path devuelve la ruta desde uno hasta el otro como un array de índices y claves, de modo que se puede reconstruir la posición exacta del cambio sin comparar objetos. keys da, para los tipos con claves, un mapa de la clave a la acción realizada y su valor anterior. delta da la representación en el formato de operaciones para los tipos de secuencia. Y changes reúne todo eso junto con los conjuntos de estructuras añadidas y borradas.

⚠️
Las propiedades calculadas del evento solo son seguras dentro del manejador

El código fuente de Yjs es explícito en este punto y lanza un error si se incumple: changes, delta y keys son propiedades calculadas que solo pueden computarse con seguridad durante la llamada al evento. Calcularlas después, cuando ya han ocurrido otros cambios, produce resultados incorrectos de forma silenciosa. Si necesitas procesar los cambios más tarde, la forma correcta es leer delta o changes dentro del manejador y guardar el resultado; lo que nunca debes guardar es el objeto de transacción, porque su contenido deja de tener sentido en cuanto la transacción se cierra.

🅨

observe

Un evento, un tipo. Barato y preciso. Es lo que quieres para enlazar un componente concreto a un tipo concreto.

🌊

observeDeep

Un array de eventos por transacción, con la ruta de cada uno. Cómodo para árboles, caro si el árbol es grande y cambia mucho.

🧭

path

Convierte un evento profundo en una coordenada, lo que permite invalidar solo la parte de la interfaz afectada.

🧹

unobserve y unobserveDeep

Sin desregistrar, cada montaje de componente añade una escucha más y el coste crece en silencio hasta hacerse visible.

El origen y los bucles de retroalimentación

El parámetro de origen es la pieza que convierte un sistema de observación en una arquitectura utilizable. Se pasa como segundo argumento de transact, queda disponible en transaction.origin y llega al manejador de update como segundo parámetro. También lo acepta Y.applyUpdate como tercer argumento, y ahí está su uso más importante.

// Al aplicar lo que llega de la red, marcamos el origen
doc.on("update", (update, origin) => {
  if (origin === conexion) return; // no reenviar lo que acaba de llegar
  conexion.enviar(update);
});

conexion.alRecibir((update) => Y.applyUpdate(doc, update, conexion));

Sin ese filtro, la aplicación reenvía a la red cada cambio que recibe de la red. Como los búferes son idempotentes el sistema no se corrompe, pero el tráfico crece de forma cuadrática con el número de participantes y el problema se manifiesta en producción, no en desarrollo con dos pestañas. El mismo mecanismo resuelve el otro bucle clásico, el de la interfaz: un enlace de editor debe ignorar los eventos cuyo origen es él mismo, porque de lo contrario aplica sobre el editor un cambio que el editor acaba de producir.

💡
Un vocabulario de orígenes es documentación ejecutable

Define desde el principio un pequeño conjunto de valores de origen con nombre —la instancia de la conexión, la del gestor de deshacer, una constante para la migración inicial, otra para los cambios generados por el sistema— y úsalos siempre. Ese vocabulario aparece luego en los filtros de red, en trackedOrigins del gestor de deshacer y en tus registros de depuración, y convierte preguntas del tipo por qué se ha vuelto a redibujar esto en una consulta trivial. Es de las decisiones más baratas de tomar el primer día y de las más caras de retrofitar el año siguiente.

El origen es lo único que rompe la simetría de un sistema replicado, y por eso decide su arquitectura

Conviene mirar de frente lo que hace ese parámetro aparentemente menor, porque contiene una lección general sobre sistemas distribuidos que se aplica muy lejos de Yjs. Un tipo replicado sin coordinación está construido sobre una simetría deliberada y muy poderosa: una operación es una operación, venga de donde venga, y el algoritmo se niega por diseño a distinguir la local de la remota, porque justo esa indiferencia es lo que garantiza la convergencia sin necesidad de un árbitro. Es una propiedad matemática excelente y es, a la vez, insuficiente para construir un producto, porque casi todo lo que una aplicación necesita hacer con un cambio depende precisamente de su procedencia. ¿Hay que reenviarlo a la red? Solo si es local. ¿Entra en la pila de deshacer? Solo si lo hizo esta persona. ¿Hay que mover el cursor del editor? Solo si no lo movió el editor. ¿Hay que mostrar un indicador de guardado? Solo si el cambio nació aquí. El algoritmo es correcto porque ignora el origen; el producto es utilizable porque lo recuerda. Yjs resuelve esa tensión de la forma más limpia posible: no contamina el modelo de datos con la procedencia, no la persiste, no la envía por la red y no la usa jamás para resolver conflictos; la deja vivir únicamente en el ámbito de la transacción, como metadato local y efímero que existe mientras dura la notificación y desaparece después. Reconocer ese patrón tiene valor mucho más allá de esta biblioteca, porque la misma forma aparece una y otra vez: el núcleo que garantiza la corrección debe ser ciego a las distinciones que la capa de aplicación necesita hacer, y la solución no es enriquecer el núcleo sino añadir un canal lateral, local y no persistido que lleve esa información. Cuando veas que estás a punto de meter en tu modelo de datos un campo que dice quién hizo esto o desde dónde vino, párate y pregúntate si lo que necesitas es un dato del documento o un dato de la transacción. Casi siempre es lo segundo, y confundirlos contamina el formato en disco con información que solo tenía sentido durante un instante. La lección siguiente muestra la misma idea llevada a su forma más ambiciosa, con un canal entero dedicado a lo efímero.

⚔️ Instrumenta el ciclo de vida de tus transacciones
  1. Registra los cinco eventos del documento en consola y observa el orden exacto en una edición real.
  2. Compara el número de búferes emitidos al hacer diez cambios sueltos frente a los mismos diez en un transact.
  3. Mide el tamaño total en bytes de ambos casos y explica la diferencia.
  4. Sustituye un observeDeep de tu aplicación por observadores superficiales y mide el cambio en tiempo de redibujado.
  5. Define un vocabulario de orígenes con nombre y filtra el reenvío a la red usando solo ese vocabulario.
  6. Provoca a propósito un bucle de reenvío entre dos documentos y comprueba que la idempotencia lo esconde.