Los tipos: texto, array, mapa y la familia XML
Yjs ofrece cuatro familias de tipos compartidos y bajo cada una hay un tipo replicado distinto, con operaciones y semántica de conflicto propias que conviene conocer antes de elegir.
Una vez establecido que el documento es la raíz, lo siguiente que hay que entender es qué se le puede colgar. Yjs expone cuatro familias de tipos compartidos —texto, array, mapa y un pequeño conjunto orientado a árboles de marcado— y la tentación natural del recién llegado es leerlas como si fueran las estructuras equivalentes del lenguaje anfitrión con la magia de la sincronización añadida por encima. Esa lectura es cómoda y es falsa en el punto que más importa: cada familia lleva debajo un tipo replicado distinto, con una regla de convergencia distinta y con un comportamiento distinto ante la edición concurrente. Elegir entre un array y un mapa para guardar una lista de tareas no es una elección de estilo, es una elección sobre qué ocurre exactamente cuando dos personas tocan la misma tarea a la vez. Esta lección recorre las cuatro familias por dentro, con las operaciones verificadas contra la interfaz publicada, y con el algoritmo que cada una implementa.
- Distinguir qué tipo replicado hay bajo cada familia y qué garantía concreta ofrece.
- Conocer las operaciones reales de cada tipo y el formato delta que comparten los tipos de secuencia.
- Predecir el resultado de una edición concurrente en un array frente al mismo caso en un mapa.
- Elegir la familia adecuada a partir de la semántica de conflicto que quieres, no de la forma de los datos.
El texto y el algoritmo YATA
Y.Text es el tipo optimizado para edición compartida de texto y el único que además permite asignar atributos de formato a rangos, que es lo que hace posible construir editores de texto enriquecido sobre él. Su interfaz es pequeña y deliberadamente indexada por posición, aunque por debajo no haya posiciones.
const texto = doc.getText("cuerpo");
texto.insert(0, "hola mundo");
texto.insert(0, "negrita", { bold: true }); // atributos de formato
texto.format(0, 7, { italic: true });
texto.delete(0, 5);
texto.length; // numero de caracteres visibles
texto.toString(); // cadena sin formato
texto.toDelta(); // representacion en formato delta de Quill
texto.applyDelta(delta);
El algoritmo que hay debajo es YATA, siglas de la propuesta original de Kevin Jahns sobre edición compartida entre pares en tipos de datos extensibles. Yjs implementa una versión modificada de ese algoritmo, y su corrección ha sido objeto de verificación formal reciente: el proyecto lean-yjs ha demostrado con el asistente de pruebas Lean propiedades de preservación y conmutatividad del algoritmo, y de paso ha revelado que el pseudocódigo del artículo original contenía errores que la implementación real no tiene.
Lo relevante para el uso diario es que YATA es un tipo de secuencia con identidad por elemento, con las mismas propiedades que estudiamos en niveles anteriores: cada carácter insertado recibe un identificador formado por cliente y contador, cada borrado deja una lápida, y el orden entre inserciones concurrentes se decide de forma determinista sin consultar a nadie. Yjs añade tres optimizaciones que la documentación describe con precisión y que explican por qué el coste en la práctica es tolerable: fusiona estructuras contiguas del mismo cliente en una sola, vacía el contenido de las estructuras borradas conservando solo su marca, y recoge lápidas convertidas en estructuras de recolección cuando el padre entero ha sido eliminado y el orden ya no importa.
Tanto Y.Text como los eventos que emite hablan el formato delta popularizado por el editor Quill: una lista de operaciones de retener, insertar y borrar que describe una transformación completa del documento. Ese formato es el punto de integración con prácticamente todos los editores del ecosistema, y aprenderlo es más rentable que aprender la interfaz de cualquier enlace concreto. Cuando toDelta te devuelve tramos con atributos, estás viendo la representación canónica del texto enriquecido en Yjs, y cuando applyDelta recibe una lista, está aplicando una transacción entera de golpe.
El array: la misma secuencia con contenido arbitrario
Y.Array es, según la propia documentación, un tipo similar a un array que soporta inserción y borrado eficientes en cualquier posición y que internamente usa una lista enlazada de arrays que se parte cuando hace falta. Es decir, es el mismo mecanismo de secuencia que el texto, con la diferencia de que cada elemento no es un carácter sino un valor.
const lista = doc.getArray("tareas");
lista.insert(0, [{ id: "a", hecho: false }]); // ojo: el contenido es un array
lista.push([1, 2, 3]);
lista.unshift(["primero"]);
lista.delete(1, 2);
lista.get(0);
lista.slice(0, 5);
lista.length;
lista.toArray(); // copia superficial
lista.toJSON(); // convierte tambien los tipos hijos
lista.map((v, i) => v);
El detalle de interfaz que más errores causa en los primeros días es que insert y push reciben un array de elementos, no un elemento: lista.insert(0, [1]) inserta el número uno en la posición cero, mientras que lista.insert(0, 1) no hace lo que parece. La razón es que la operación es un empalme, no una asignación, y esa forma es coherente con el hecho de que por debajo se está insertando un tramo en una secuencia.
Lo que hay que interiorizar es el comportamiento concurrente. Como es una secuencia con identidad, dos inserciones simultáneas en la misma posición conservan las dos, en un orden que todas las réplicas acuerdan. No hay pérdida. Pero eso mismo significa que un array no sirve para representar un conjunto ni para representar un valor único: si modelas un campo de estado como un array de un solo elemento, dos ediciones concurrentes te dejarán un array de dos.
flowchart TB S[secuencia con identidad y YATA] --> TX[Y Text con atributos de formato] S --> AR[Y Array con valores cualesquiera] S --> XF[Y XmlFragment y Y XmlElement] M[registro por clave] --> MP[Y Map] TX --> R1[dos inserciones concurrentes se conservan las dos] AR --> R1 MP --> R2[dos escrituras en la misma clave gana una sola] style S fill:#89b4fa,color:#11111b style M fill:#f9e2af,color:#11111b style R1 fill:#a6e3a1,color:#11111b style R2 fill:#f38ba8,color:#11111b
El mapa: un registro por clave sin fusión del valor
Y.Map es un tipo de mapa compartido con la interfaz que cabe esperar, y su semántica de conflicto es radicalmente distinta de la de los tres tipos de secuencia. Aquí no hay identidad por elemento ni acumulación: hay claves, y cada clave guarda un valor.
const meta = doc.getMap("meta");
meta.set("titulo", "Borrador");
meta.set("etiquetas", new Y.Array()); // anidar tipos si quieres fusion dentro
meta.get("titulo");
meta.has("titulo");
meta.delete("titulo");
meta.clear();
meta.size;
meta.toJSON();
for (const [clave, valor] of meta) { /* ... */ }
Ante dos escrituras concurrentes sobre la misma clave, el resultado es que sobrevive una de las dos, elegida de forma determinista e idéntica en todas las réplicas, y la otra desaparece de la vista. No hay fusión del valor, no hay excepción y no hay aviso. Es la semántica de última escritura gana que estudiamos en el bloque de conflictos, con la ventaja de que el desempate no depende de relojes de pared sino de la identidad de las estructuras, y por tanto no se rompe cuando los relojes de dos máquinas discrepan.
La consecuencia de diseño es la regla más útil de toda la lección y merece enunciarse sola: si el valor que guardas en una clave es un objeto plano de JavaScript, ese objeto es opaco para el algoritmo y se sustituye entero. Si quieres que dos personas puedan editar campos distintos del mismo objeto sin pisarse, el objeto tiene que ser un tipo compartido anidado, no un objeto plano.
Un tipo compartido solo puede estar integrado en un documento una vez. Si construyes un Y.Map y lo insertas en dos sitios, o si extraes uno de un sitio y lo colocas en otro, el resultado no es el que esperas y la biblioteca no siempre te lo va a decir con claridad. Para copiar hay que usar clone, que devuelve una instancia nueva apta para integrar. Y para mover contenido de un sitio a otro no hay atajo: hay que leer los valores, construir tipos nuevos y borrar los antiguos, aceptando que esa operación no es un movimiento a ojos del algoritmo sino un borrado más una inserción, con todo lo que eso implica ante concurrencia.
La familia XML y para qué existe realmente
La cuarta familia la componen Y.XmlFragment, Y.XmlElement y Y.XmlText. La documentación es franca sobre lo que son: Y.XmlElement representa un elemento con un nombre de nodo, atributos y una lista de hijos, pero no hace ningún esfuerzo por validar su contenido ni por ser realmente conforme a XML. Y.XmlFragment es un contenedor que guarda una secuencia de esos elementos.
const cuerpo = doc.getXmlFragment("cuerpo");
const parrafo = new Y.XmlElement("paragraph");
parrafo.setAttribute("align", "center");
parrafo.insert(0, [new Y.XmlText("texto del parrafo")]);
cuerpo.insert(0, [parrafo]);
cuerpo.firstChild;
cuerpo.toString(); // serializacion de todos los descendientes
cuerpo.createTreeWalker((t) => true); // recorrido iterable de los hijos
El nombre despista y conviene decir para qué se usan en la práctica: no para intercambiar XML, sino para representar árboles de documento de editores de texto enriquecido. Los enlaces con ProseMirror —y por tanto Tiptap, BlockNote y Milkdown, que lo usan por debajo— apoyan el árbol del editor sobre esta familia, porque la correspondencia entre un nodo con nombre, atributos e hijos y un nodo de un esquema de editor es directa. Si no estás integrando un editor de este tipo, casi con seguridad no necesitas estos tipos y una combinación de mapas y arrays anidados expresará mejor tu modelo.
Y.Text
Secuencia de caracteres con atributos de formato por rango. Úsalo cuando el usuario escribe prosa y esperas fusión carácter a carácter.
Y.Array
Secuencia de valores con inserción y borrado por posición. Úsalo para listas ordenadas donde dos añadidos concurrentes deben sobrevivir.
Y.Map
Registro por clave con una sola ganadora ante escrituras concurrentes. Úsalo para campos, ajustes y objetos con identidad propia.
Familia XML
Árbol con nombre de nodo, atributos e hijos. Úsalo solo si integras un editor que ya espera esta forma, como los basados en ProseMirror.
La pregunta correcta no es si tus datos parecen una lista o un objeto, sino qué quieres que ocurra cuando dos personas los toquen a la vez. Si quieres que ambas aportaciones sobrevivan, necesitas una secuencia. Si quieres que gane una sola de forma limpia, necesitas una clave de mapa. Modelar una lista de tareas como un Y.Array de mapas anidados y no como un Y.Map de identificadores es lo que decide si dos personas pueden reordenar y editar a la vez sin destruirse el trabajo, y esa decisión se toma antes de escribir código, no después de recibir el primer informe de fallo.
Aquí está la reinterpretación que cambia cómo se diseña con estas bibliotecas, y que va mucho más allá de Yjs. Estamos acostumbrados a que elegir entre una lista y un diccionario sea una decisión sobre acceso y coste: cómo se busca, cuánto tarda, cuánta memoria ocupa. En un sistema replicado sin coordinación esa decisión sigue existiendo, pero queda subordinada a otra mucho más consecuente que casi nunca se enuncia: cada tipo compartido es, antes que una estructura, una respuesta congelada a la pregunta de qué hacer cuando dos personas actúan a la vez sobre la misma cosa. Y.Array responde conservemos las dos y ordenémoslas de forma determinista. Y.Map responde que gane una y la otra desaparezca. Y.Text responde conservemos las dos con la mejor aproximación posible a la intención de cada autor. Son tres políticas distintas, y la biblioteca las ha empaquetado con nombres tomados del vocabulario de las estructuras de datos locales, lo que hace que se elijan por analogía de forma en lugar de por política. El nombre es una interfaz heredada; la semántica es lo que compraste. De ahí sale un método de diseño que conviene adoptar para el resto del track y que se puede aplicar a cualquier biblioteca de este tipo, sea Yjs, Automerge o Loro. En lugar de partir del modelo de datos y buscarle un tipo, parte de la lista de situaciones concurrentes que tu producto va a vivir —dos personas renombran, dos reordenan, una borra mientras otra edita dentro— y escribe para cada una cuál es el resultado que tu usuario consideraría correcto. Esa lista es una especificación de política de conflicto, y solo cuando la tienes escrita puedes elegir tipos, porque solo entonces sabes qué estás comprando. Hacerlo en este orden convierte una fuente inagotable de fallos sutiles y tardíos en una decisión explícita de la primera semana, y tiene además un efecto secundario valioso: obliga a que producto y diseño se pronuncien sobre casos que de otro modo los resolvería en silencio la estructura que el desarrollador eligió por costumbre.
- Lista diez situaciones concurrentes reales de tu producto y escribe el resultado que consideras correcto en cada una.
- Asigna a cada situación la familia de tipo que produce ese resultado y anota las que no encajan en ninguna.
- Construye un array de mapas anidados y comprueba qué pasa al reordenar y editar a la vez desde dos documentos.
- Repite el experimento guardando objetos planos en lugar de mapas anidados y compara lo que sobrevive.
- Serializa un fragmento XML con
toStringy compáralo con eltoJSONde la estructura equivalente en arrays. - Documenta en tu repositorio qué tipo usa cada nombre de primer nivel y por qué, con la política que justifica la elección.