wandres.dev
YJS · el estándar de facto

Awareness: el canal aparte para lo efímero

Cursores, selecciones y presencia viajan por un protocolo propio que no forma parte del documento, no se persiste y caduca solo, y esa separación es una decisión de diseño correcta.

⏱ 19 min

Toda aplicación colaborativa acaba necesitando mostrar quién está conectado, dónde tiene el cursor cada cual y qué trozo de texto tiene seleccionado. La primera intuición de casi todo el mundo es guardar esa información en el propio documento compartido, porque ya existe un mecanismo que la haría llegar a todos los participantes y parece un desperdicio no usarlo. Esa intuición es un error, y es un error caro que se manifiesta semanas después en forma de historial ilegible, archivos que crecen sin motivo y cursores fantasma de personas que cerraron el portátil hace dos meses. Yjs lo resolvió creando un segundo canal completo, con su propio protocolo binario, sus propias reglas de caducidad y ninguna conexión con el tipo replicado del documento. Esta lección explica ese canal, sus operaciones verificadas, la pieza que hace posible expresar una posición que sobrevive a la edición ajena, y sobre todo por qué la separación es la decisión correcta y no una duplicación de esfuerzo.

🎯 Al terminar esta lección sabrás
  • Reconocer qué información es de estado efímero y por qué no debe entrar en el documento.
  • Manejar la interfaz de awareness, su mapa de estados por cliente y sus dos eventos.
  • Usar posiciones relativas para expresar un cursor que sobrevive a las ediciones concurrentes.
  • Argumentar por qué la separación entre lo persistente y lo efímero es correcta y no accidental.

Lo que nunca debe entrar en el documento

El criterio para decidir dónde va un dato tiene una formulación breve: si su valor deja de tener sentido en cuanto la persona que lo produjo se desconecta, no pertenece al documento. La posición de un cursor cumple ese criterio. El nombre y el color con el que se pinta a un participante durante una sesión también. Que alguien esté escribiendo en este instante, igualmente. Ninguno de esos datos es una afirmación sobre el contenido: son afirmaciones sobre el estado momentáneo de un observador.

La razón por la que meterlos en el documento sale mal es directa y se deduce de las lecciones anteriores. El documento es una estructura que solo crece: cada escritura deja huella, cada borrado deja una lápida, todo se persiste y todo se sincroniza con quien llegue. Un cursor que se mueve genera decenas de escrituras por segundo, y cada una de ellas quedaría registrada para siempre en un formato pensado para conservar la historia del texto. El archivo crecería sin límite con información que nadie va a querer leer nunca, el historial de versiones quedaría sepultado bajo movimientos de ratón, y el gestor de deshacer tendría que aprender a ignorar la mayor parte de lo que hay dentro.

⚠️
La prueba de fuego es preguntarse qué debe pasar al desconectarse

Si un dato debe desaparecer cuando su autor cierra la pestaña, es efímero y va por awareness. Si debe seguir ahí, es contenido y va en el documento. El caso interesante son los que están en medio y donde casi todo el mundo se equivoca la primera vez: un comentario anclado a un rango es contenido, pero el rango seleccionado mientras lo escribes es efímero; la lista de personas con acceso es contenido, pero la lista de personas conectadas ahora mismo es efímera; el hecho de que alguien esté escribiendo es efímero, pero la marca de última modificación es contenido. Separar bien ese medio es la mitad del trabajo de diseño de una aplicación colaborativa.

La interfaz de awareness

El protocolo vive en un paquete aparte, publicado como @y/protocols y conocido durante años como y-protocols, que contiene los tres formatos binarios del ecosistema: sincronización, awareness y autorización. La abstracción central es una clase que se construye a partir del documento pero que no lo modifica.

import * as awarenessProtocol from "@y/protocols/awareness";

const awareness = new awarenessProtocol.Awareness(doc);

awareness.setLocalStateField("user", { name: "Ada", color: "#f0a" });
awareness.setLocalStateField("cursor", { ancla: null, cabeza: null });

awareness.getLocalState(); // el estado local completo o null
awareness.setLocalState(null); // marcarse como desconectado

awareness.getStates(); // Map de clientID a estado, incluye el propio
awareness.clientID;    // espeja el clientID del documento

awareness.on("change", ({ added, updated, removed }, origin) => {
  repintarCursoresRemotos(awareness.getStates());
});

El modelo de datos es deliberadamente pobre y esa pobreza es una virtud: se trata de un Map de identificador de cliente a estado, donde cada cliente posee exactamente una entrada y solo puede escribir la suya. No hay concurrencia que resolver porque no hay escrituras concurrentes posibles sobre la misma entrada, y por eso el protocolo no necesita ningún tipo replicado por debajo. Es la simplificación que hace que todo el canal sea barato.

La caducidad es la segunda pieza esencial. Un cliente cuyo estado no se ha refrescado durante treinta segundos se descarta localmente, sin que nadie tenga que anunciar nada ni negociar con nadie. Ese temporizador es lo que hace que el sistema se limpie solo cuando alguien pierde la conexión de forma abrupta, que es la forma habitual de perderla.

Vale la pena detenerse en que la limpieza es local y no coordinada, porque explica un comportamiento que de otro modo parece un fallo. Cada participante decide por su cuenta a quién ha dejado de ver, de modo que durante unos segundos dos personas pueden tener listas de conectados ligeramente distintas. Eso no es una inconsistencia que haya que corregir: es la elección correcta para un dato cuya única función es informar a un humano de algo que va a cambiar enseguida de todas formas. Intentar que la lista de presentes converja con las mismas garantías que el texto sería pagar el precio de un consenso para resolver una pregunta que se responde sola en el siguiente latido.

// Las funciones de codificacion, que usan los proveedores por dentro
const buf = awarenessProtocol.encodeAwarenessUpdate(awareness, [awareness.clientID]);
awarenessProtocol.applyAwarenessUpdate(otroAwareness, buf, "remoto");
awarenessProtocol.removeAwarenessStates(awareness, [123, 456], "desconexion");
awarenessProtocol.modifyAwarenessUpdate(buf, (estado) => sanear(estado));
ℹ️
Dos eventos distintos: cambio de contenido y señal de vida

La clase emite change cuando cambia el contenido de algún estado, y emite update cada vez que llega una actualización aunque el estado sea idéntico al anterior. La distinción parece pedante y no lo es: para repintar la interfaz quieres change, porque te ahorra trabajo inútil, pero para decidir si alguien sigue vivo necesitas update, ya que el latido de un usuario quieto no cambia nada y sin embargo es la prueba de que sigue ahí. Elegir mal produce dos fallos característicos: parpadeo continuo de la interfaz, o participantes que desaparecen de la lista por el mero hecho de estar leyendo sin tocar nada.

Posiciones relativas: cómo se nombra un cursor

Queda un problema que awareness por sí solo no resuelve. Un cursor es una posición dentro del texto, y una posición expresada como número entero deja de significar lo mismo en cuanto otra persona inserta algo por delante. La documentación de Yjs lo dice sin rodeos: las posiciones por índice no son convenientes porque el rango de índices queda invalidado en cuanto un cambio remoto manipula el documento.

La solución es un tipo aparte, la posición relativa, que se fija a un elemento concreto del documento compartido y no se ve afectada por los cambios remotos. Es la misma idea de identidad estable que sostiene el algoritmo de secuencia, expuesta para que la aplicación pueda usarla.

// De indice a posicion relativa y vuelta
const rel = Y.createRelativePositionFromTypeIndex(texto, 12);
const abs = Y.createAbsolutePositionFromRelativePosition(rel, doc);
// abs es null si el elemento fue borrado; si no, trae type, index y assoc

// Para enviarla por el canal efimero
const binaria = Y.encodeRelativePosition(rel);
const recuperada = Y.decodeRelativePosition(binaria);
// tambien vale JSON.stringify sobre la posicion relativa

Hay un detalle de la interfaz que decide el comportamiento percibido y que casi nadie mira: el tercer argumento, la asociación. Por omisión la posición se asocia con el carácter que viene después del índice indicado; con un valor negativo se asocia con el que viene antes. Esa diferencia es la que determina si el cursor de alguien que escribe al final de una palabra se queda pegado a lo que él escribe o es empujado por lo que escribe el vecino. Es un detalle de un carácter que se percibe como calidad del producto.

El otro detalle que hay que gestionar es el caso degenerado: la conversión de vuelta devuelve un valor nulo si la posición ya no se puede referenciar o si el tipo al que apuntaba fue eliminado. Ocurre de verdad y con frecuencia, porque el ancla de un cursor remoto es exactamente el fragmento de texto que otra persona está a punto de borrar. Toda interfaz que pinte cursores ajenos necesita, por tanto, una respuesta explícita a qué dibujar cuando la posición se evapora; la respuesta razonable suele ser no dibujar nada y esperar al siguiente latido, y la respuesta desastrosa es asumir que el resultado nunca es nulo y arrastrar el fallo hasta el renderizado.

flowchart TB
U[usuario mueve el cursor] --> AW[estado local de awareness]
AW --> RP[posicion relativa codificada]
RP --> NET[protocolo de awareness sobre el mismo transporte]
NET --> REM[replica remota]
REM --> ABS[posicion absoluta recalculada al vuelo]
D[documento y su historia] -.no lo toca.-> AW
AW -.caduca a los treinta segundos.-> X[entrada descartada]
style AW fill:#f9e2af,color:#11111b
style D fill:#cba6f7,color:#11111b
style X fill:#f38ba8,color:#11111b

Por qué la separación es correcta

Reunidas las piezas, se puede evaluar la decisión de diseño en lugar de limitarse a describirla. La tesis es que el canal aparte no es una duplicación de infraestructura sino el reconocimiento de que estamos ante dos problemas con requisitos opuestos en todos los ejes que importan.

🅨

Durabilidad opuesta

El documento debe conservarlo todo para siempre. La presencia debe olvidarlo todo enseguida. Un mismo mecanismo no puede hacer ambas.

Frecuencia opuesta

El texto cambia a ritmo de tecleo. El cursor cambia a ritmo de ratón. Persistir lo segundo al coste de lo primero es inviable.

🧊

Consistencia opuesta

El contenido exige convergencia demostrable. La presencia tolera perder mensajes: el siguiente latido corrige cualquier desajuste.

🔒

Privacidad opuesta

Dónde miraba cada cual es dato sensible que nadie quiere archivado. Fuera del documento, no queda registrado en ningún sitio.

La cuarta tarjeta suele pasarse por alto y es la que tiene consecuencias legales. Un registro permanente de dónde ha estado el cursor de cada empleado dentro de cada documento es un rastro de comportamiento con implicaciones de protección de datos que nadie pretendía crear. Al vivir fuera del documento, ese rastro sencillamente no existe: se transmite, se muestra y se olvida.

Hay además un argumento de simplicidad que refuerza la decisión. Como cada cliente escribe solo su entrada, el canal efímero no necesita ningún algoritmo de resolución de conflictos, ni identidades por elemento, ni lápidas, ni vector de estado. Todo el aparato conceptual del nivel se vuelve innecesario en cuanto se acepta que la última palabra sobre dónde está mi cursor la tengo yo. Reutilizar el tipo replicado para esto habría sido pagar un mecanismo caro para un problema que no lo necesitaba.

Conviene señalar, para cerrar el argumento, que la separación no impide que los dos canales se toquen donde tiene sentido que lo hagan. La posición relativa es precisamente el punto de contacto: es un valor que solo tiene significado con respecto al documento, y que sin embargo viaja por el canal efímero y nunca se guarda. Esa asimetría es el diseño en su forma más refinada, porque demuestra que separar no significa aislar. Lo efímero puede referirse a lo permanente, apoyarse en sus identidades y aprovechar sus garantías de orden, y aun así no ensuciarlo, siempre que la referencia viaje en un solo sentido y no deje rastro de vuelta.

💡
Un solo transporte, dos protocolos: eso es lo que hace un proveedor

Conviene deshacer una confusión frecuente antes de la última lección. Que awareness sea un canal lógicamente separado no significa que abra una conexión aparte: los proveedores multiplexan los dos protocolos sobre el mismo socket y por eso la separación no cuesta infraestructura adicional. Lo que está separado es el modelo de datos y las reglas, no el cable. Si escribes tu propio proveedor, tendrás que llevar los dos, y la parte de awareness es notablemente más simple porque no tiene que reconciliar nada.

Todo sistema que persiste algo necesita un lugar explícito donde no persistir nada

La lección general que deja este canal es una de las más transferibles de todo el track, y conviene enunciarla en abstracto porque su alcance es mucho mayor que los cursores. Cuando construimos un sistema alrededor de una garantía fuerte —durabilidad, convergencia, trazabilidad, auditoría— la garantía se vuelve gravitatoria: como el mecanismo está ahí y funciona, todo lo demás tiende a caer dentro, y cada cosa que cae dentro se lleva consigo un coste que nunca necesitó. Un registro de eventos que existía para reconstruir el estado de negocio acaba conteniendo latidos de salud; una base de datos transaccional acaba guardando sesiones; un repositorio de control de versiones acaba con artefactos generados dentro; un documento replicado acaba con las coordenadas del ratón. La deriva no ocurre por descuido sino porque el mecanismo bueno es el único disponible, y ante la ausencia de una alternativa legítima todo el mundo elige la que existe. La respuesta de Yjs no fue añadir excepciones al documento ni inventar tipos que no se persisten, que es lo que casi cualquier equipo habría hecho bajo presión de calendario, sino construir deliberadamente un segundo lugar de primera categoría, con su propio protocolo, sus propias reglas y su propia caducidad, cuyo contrato explícito es que nada de lo que entre ahí sobrevivirá. Eso convierte la decisión de dónde va cada dato en una elección visible entre dos opciones nombradas, en lugar de en una omisión silenciosa. La regla de diseño que se extrae es simple de enunciar y difícil de aplicar a tiempo: cuando construyas un sistema con una garantía cara, construye al mismo tiempo el canal explícito para lo que no la necesita, y hazlo antes de que nadie lo pida. Si esperas a que aparezca la primera necesidad de estado efímero, ya será tarde, porque para entonces habrá tres funcionalidades apoyadas en el mecanismo caro, el formato en disco estará contaminado y sacarlas de ahí exigirá una migración. El coste de tener dos canales desde el principio es una tarde de diseño; el de tener uno solo se paga durante toda la vida del producto, y lo paga sobre todo quien intente leer el historial dentro de dos años.

⚔️ Separa lo efímero de lo permanente en tu aplicación
  1. Haz el inventario de todo lo que tu interfaz muestra sobre otros usuarios y clasifícalo con la prueba de la desconexión.
  2. Implementa presencia con setLocalStateField y comprueba que getStates incluye tu propia entrada.
  3. Desconecta un cliente de forma abrupta y mide cuánto tarda en desaparecer de la lista de los demás.
  4. Suscríbete a change y a update por separado y anota en qué situaciones se dispara solo uno.
  5. Codifica un cursor con createRelativePositionFromTypeIndex y verifica que sobrevive a una inserción por delante.
  6. Prueba los dos valores de asociación y describe con tus palabras cómo se comporta el cursor con cada uno.