El documento JSON completo: mapas, listas y valores primitivos anidados
Un documento JSON convergente es un árbol con tres clases de nodo —mapa, lista y hoja— cada una con su estructura de fusión, y las librerías reales difieren menos en la teoría que en dónde colocan los bordes.
Con el mapa resuelto y la semántica de la baja decidida, ya están todas las piezas para montar la estructura que da nombre al nivel: un documento arbitrario, con la misma forma que el objeto que cualquier programador manipula a diario, pero cuya fusión está definida en cada punto y no requiere que nadie decida nada en tiempo de ejecución. La construcción es exactamente la que sugiere el teorema de clausura: un árbol donde cada nodo interno es un mapa o una lista convergente y cada hoja es un registro, de modo que la fusión del documento entero se obtiene descendiendo por el árbol y aplicando en cada nodo la política que le corresponde. Lo interesante no es que funcione, que era previsible, sino que la forma concreta del árbol impone decisiones que no se ven hasta que se lleva a producción, y que las librerías reales, pese a partir de la misma teoría, colocan los bordes en sitios distintos. Conocer esos bordes antes de escribir el esquema ahorra migraciones que en un sistema replicado sin autoridad central son especialmente caras.
- Construir el árbol convergente con sus tres clases de nodo y saber qué estructura gobierna cada una.
- Situar la formulación de referencia del tipo de datos y lo que aporta respecto a componer piezas sueltas.
- Comparar los modelos de las librerías reales en los puntos donde de verdad se separan.
- Entender qué es la vista materializada, cuánto cuesta y por qué no es el documento.
Tres clases de nodo y una raíz
El modelo canónico reconoce tres clases de nodo y ninguna más. Un nodo mapa asocia claves a hijos con la maquinaria de la lección anterior: presencia por etiquetas, semántica de eliminación observada y un hijo por cada clave viva. Un nodo lista mantiene una secuencia de hijos con identificadores de posición estables, de modo que las inserciones concurrentes se intercalan de forma determinista y las eliminaciones marcan elementos concretos sin desplazar a nadie; cómo se construyen esos identificadores es el asunto del nivel siguiente y aquí se usa como pieza dada. Una hoja contiene un valor primitivo y se gobierna con un registro, que según la librería conservará todos los valores concurrentes o aplicará una regla de desempate para devolver uno solo.
La raíz merece mención aparte porque es el único nodo que no puede eliminarse ni sustituirse. Suele ser un mapa fijo, y esa fijeza es lo que permite que todas las rutas tengan un origen común y estable en todas las réplicas. Un documento cuya raíz pudiera reemplazarse no tendría un punto de partida compartido para resolver rutas, y sin ese punto la identidad de los nodos deja de ser comparable entre réplicas.
Casi todas las implementaciones añaden en la práctica una cuarta clase que el modelo teórico no exige y que la experiencia impuso: el nodo de texto colaborativo. Un texto podría representarse como una hoja de cadena, y en muchos esquemas se hace así sin problema alguno; pero en cuanto dos personas escriben a la vez en el mismo párrafo, una hoja de cadena solo sabe elegir una de las dos versiones completas, mientras que un nodo de texto ordena inserciones y borrados carácter a carácter con la misma maquinaria de posiciones estables que una lista. La regla de modelado que se deduce es simple y vale la pena aplicarla desde el principio: si un campo se edita, es texto; si se rellena, es cadena. Un título que se sustituye entero puede ser hoja; un cuerpo que se escribe a lo largo del tiempo no debería serlo nunca.
{
"titulo": "hoja gobernada por un registro",
"etiquetas": ["nodo lista con posiciones estables", "cada elemento con identidad"],
"metadatos": {
"autor": "otra hoja",
"revisiones": "hoja numerica, y aqui empiezan los problemas del nivel siguiente"
}
}
Lo que ese fragmento no muestra, y es justamente lo que hay que tener en la cabeza, es que la representación interna no se parece en nada. Cada clave lleva su conjunto de etiquetas de presencia, cada elemento de la lista lleva su identificador de posición y su marca de eliminación, y cada hoja lleva la información causal que permite decidir qué escritura domina a cuál. El objeto legible es una proyección de esa estructura, no la estructura.
Esa distancia entre lo que se ve y lo que hay explica por qué el modelo se llama como JSON y no JSON. La notación sirve para describir la forma y para intercambiar datos con el exterior, pero el conjunto de valores representables no coincide: no hay un lugar natural para un contador, el texto colaborativo no es una cadena, los identificadores internos no tienen equivalente y la propia noción de que dos claves distintas puedan haber sido la misma clave en momentos diferentes carece de expresión. Tomar la notación como si fuera el modelo es el origen de la mayoría de las sorpresas que quedan por ver.
La ausencia de una cuarta clase de nodo tampoco es casual y conviene entenderla como una decisión, no como una omisión. El árbol no admite aristas que no sean de contención, es decir, no admite que un nodo tenga dos padres ni que apunte a otro nodo como parte de su estructura. Esa restricción es lo que garantiza que el documento sea recorrible desde la raíz sin ciclos y que borrar un nodo tenga un alcance bien definido. Todo lo que quiera parecerse a un grafo tendrá que construirse por encima, guardando identificadores como contenido, y con ello quedará fuera de las garantías del árbol. Es el tema de la última lección del nivel y ya conviene tenerlo presente aquí.
La construcción que acabamos de describir tiene una formulación académica de referencia en el artículo de Martin Kleppmann y Alastair R. Beresford titulado A Conflict-Free Replicated JSON Datatype, publicado en 2017. Conviene leerlo en su fuente original antes de tomar decisiones de esquema con vocación de permanencia, porque el nivel de precisión de un artículo revisado es de otro orden que el de cualquier resumen, incluido este. Lo que aquí se presenta es el modelo de árbol tal como aparece en las implementaciones del ecosistema, no una paráfrasis de sus resultados.
Las decisiones que el árbol impone
La primera decisión que el árbol impone, y la menos evidente, es que la identidad de un nodo no es su ruta. Dos réplicas que hablan del mismo elemento no pueden identificarlo diciendo que es el tercero de la lista que cuelga de cierta clave, porque las inserciones concurrentes cambian las posiciones y las reasignaciones de clave cambian el destino de las rutas. Todo nodo interno necesita un identificador propio y estable, generado en el momento de su creación, y las operaciones viajan dirigidas a ese identificador. La ruta legible existe solo para el programador.
La segunda es que asignar un valor compuesto no es una escritura sino una creación. Poner una lista nueva en una clave que ya contenía otra no sustituye el contenido: crea un nodo nuevo con identidad nueva y deja al anterior sin padre. Si otra réplica estaba escribiendo dentro del nodo anterior, esas escrituras siguen siendo válidas y siguen aplicándose a un nodo que ya no cuelga de ninguna parte. El resultado es un fragmento inalcanzable desde la raíz, que es la versión estructural del elemento mutilado que apareció en el mapa.
La tercera es que el tipo de un nodo forma parte de su identidad. Cambiar una clave de lista a texto no es una modificación sino la creación de un nodo de otra clase, y dos réplicas que hagan ese cambio de forma concurrente hacia clases distintas producen dos nodos concurrentes cuya única salida es una regla de desempate. Ninguna de las dos escrituras es incorrecta y ninguna se puede fusionar con la otra, porque no existe una operación que combine una lista con un texto.
Conviene añadir una cuarta consecuencia que afecta a las listas y que rompe una expectativa muy arraigada: el orden es determinista pero no es predecible. Dos inserciones concurrentes en la misma posición se colocan una detrás de otra según una regla de desempate que todas las réplicas aplican igual, así que el resultado es el mismo en todas partes; lo que no se puede prometer es que ese resultado coincida con lo que cualquiera de los dos autores esperaba, porque ninguno de los dos vio al otro. En una lista de la compra da igual. En una secuencia de pasos numerados donde el orden tiene significado, el elemento que aparece en un sitio inesperado es un error de contenido, y la salida no es mejorar el algoritmo sino sacar el orden significativo del árbol y expresarlo con un campo explícito que el usuario controle.
flowchart TD R[raiz mapa fija con identidad conocida] --> A[clave titulo] R --> B[clave etiquetas] R --> C[clave metadatos] A --> A1[hoja con registro] B --> B1[nodo lista con posiciones estables] B1 --> B2[elemento con identidad propia] B1 --> B3[elemento con identidad propia] C --> C1[nodo mapa anidado] C1 --> C2[hoja autor] C1 --> C3[hoja revisiones] X[nodo huerfano tras reasignar la clave] -.escrituras que siguen llegando.-> B1 style R fill:#cba6f7,color:#11111b style X fill:#f38ba8,color:#11111b
De esas tres decisiones se deduce una regla de modelado que conviene adoptar desde el primer esquema: evita reasignar nodos compuestos y prefiere vaciarlos. Sustituir una lista entera por otra crea un huérfano; borrar sus elementos uno a uno conserva la identidad del nodo y hace que las escrituras concurrentes lleguen a un sitio que sigue existiendo, donde al menos son visibles y se pueden gestionar. La versión fuerte de la misma regla es que la estructura del documento —qué claves compuestas existen y de qué clase son— debería fijarse en el momento de la creación y no cambiar con el uso, dejando la variabilidad para el contenido de las colecciones.
Cómo difieren las librerías reales
Las dos implementaciones más extendidas parten de la misma teoría y se separan en decisiones de interfaz que acaban siendo decisiones de modelo. Merece la pena mirarlas por lo que le exigen al programador, que es donde se nota la diferencia.
Documento como objeto
Un modelo donde el documento se parece a un objeto normal y las estructuras compuestas se crean asignando valores corrientes, con instantáneas inmutables en cada lectura.
Tipos compartidos explícitos
Un modelo donde cada nodo compuesto es una instancia de un tipo que hay que crear y enlazar de forma deliberada, lo que hace visible la creación y difícil el huérfano accidental.
El texto como nodo propio
Ambas familias tratan el texto colaborativo como una clase de nodo distinta de la hoja de cadena, porque editar una cadena carácter a carácter no es lo mismo que reemplazarla.
Conflictos visibles u ocultos
Una familia expone los valores concurrentes y obliga a decidir en cada lectura; la otra devuelve un único valor con desempate determinista y no interrumpe al programador.
La distinción entre crear un nodo de forma implícita y hacerlo de forma explícita parece cosmética y no lo es. Cuando asignar un objeto corriente a una clave basta para materializar un subárbol, el código queda breve y natural, y a cambio resulta trivial reasignar por accidente y fabricar huérfanos sin enterarse. Cuando el nodo hay que construirlo y enlazarlo con una llamada, el código es más verboso y a cambio la creación queda escrita en un sitio concreto, lo que hace que la reasignación sea un acto deliberado y revisable.
La segunda diferencia con consecuencias es el tratamiento del conflicto en las hojas. Devolver todos los valores concurrentes convierte cada lectura en una decisión y hace imposible ignorar el problema, al precio de complicar todo el código de presentación. Devolver uno solo con desempate determinista mantiene la interfaz sencilla y traslada el riesgo a un lugar donde nadie lo mira, que es exactamente el patrón de la pérdida silenciosa. Ninguna de las dos es gratis y la elección debería tomarse sabiendo cuál de los dos costes puede pagar tu equipo.
Queda una diferencia menos comentada y que aparece en cuanto el producto crece: el formato de almacenamiento y el modelo de sincronización. Una codificación binaria compacta con estructuras columnares y una sincronización por diferencias no es un detalle de rendimiento, porque determina si el documento cabe en el disco de un móvil y si la primera carga tarda medio segundo o medio minuto. Vale la pena medirlo con un documento del tamaño real que esperas, y no con el ejemplo de la documentación.
Y hay una diferencia que no aparece en ninguna comparativa porque no es técnica: la portabilidad del formato. Un documento convergente no es un objeto que se pueda leer con cualquier herramienta; es una estructura con identidades internas cuya interpretación depende de la implementación que lo escribió. Eso significa que la elección de librería no es reversible del mismo modo en que lo es la elección de un formato de serialización corriente: migrar exige leer todos los documentos con la librería antigua, materializar su vista y reconstruirlos con la nueva, con lo que se pierde la historia, se pierden las identidades y se rompe la posibilidad de fusionar con réplicas que aún no han migrado. En un producto local-first, donde hay copias en dispositivos que no controlas, esa operación puede ser sencillamente impracticable. Conviene tomar la decisión sabiendo que dura lo que dure el producto.
En una base de datos con autoridad central, añadir un campo es una migración que se ejecuta una vez. Aquí conviven simultáneamente versiones distintas de la aplicación sobre el mismo documento, y una versión antigua que no conoce un nodo nuevo tiene que preservarlo intacto al escribir, no descartarlo. Diseña desde el primer día para que el código ignore lo que no entiende sin borrarlo, y evita cualquier operación que reescriba el documento entero a partir de lo que la versión actual sabe leer.
La vista materializada no es el documento
Todo lo anterior obliga a separar con cuidado dos objetos que la interfaz de las librerías tiende a confundir. El documento es la estructura completa con su historia, sus identidades, sus lápidas y su información causal; es lo que se sincroniza, lo que se guarda y lo que permite fusionar. La vista materializada es la proyección legible que se obtiene aplicando todo lo conocido, y es lo que se pinta en pantalla y lo que se pasa a la lógica de la aplicación.
// La vista se recalcula descendiendo por el arbol y aplicando cada politica
function materializar(nodo) {
if (nodo.clase === "hoja") return nodo.registro.valorVisible();
if (nodo.clase === "lista") {
return nodo.elementos.filter((e) => !e.eliminado).map((e) => materializar(e.hijo));
}
const salida = {};
for (const [clave, hijo] of Object.entries(nodo.hijos)) {
if (nodo.presencia[clave]?.size > 0) salida[clave] = materializar(hijo);
}
return salida;
}
Confundir ambos objetos produce dos errores caros. El primero es de rendimiento: recalcular la vista completa en cada cambio es lineal en el tamaño del documento y, en un editor con cientos de cambios por minuto, convierte la aplicación en algo inservible. La salida es el mantenimiento incremental que ya apareció al hablar de invalidación: recalcular solo el subárbol afectado y avisar solo a los observadores de esa rama.
Conviene además decidir con antelación en qué forma se entrega la vista, porque la elección condiciona todo el código de presentación. Devolver una estructura inmutable nueva en cada cambio hace trivial comparar por identidad y encaja bien con las interfaces declarativas, al precio de reconstruir objetos que en su mayoría no han cambiado. Devolver un objeto vivo que se muta en el sitio evita esa reconstrucción y obliga a que la interfaz se suscriba a eventos en lugar de comparar. Ninguna de las dos es superior; lo que no funciona es mezclarlas, porque un código que a veces compara referencias y a veces recibe mutaciones deja de refrescar en casos que nadie sabe reproducir.
El segundo error es de semántica y es peor: guardar la vista materializada creyendo que se guarda el documento. Una vista no contiene identidades, ni lápidas, ni información causal, así que un documento reconstruido desde su vista es un documento nuevo que no sabe fusionarse con las réplicas existentes. Es un fallo que no se detecta en desarrollo, porque en desarrollo hay una sola réplica y la vista basta para que todo parezca correcto, y que se manifiesta el día del primer conflicto real en producción.
La forma más común de ese error no es guardar la vista de forma deliberada, sino hacerlo sin darse cuenta a través de un intermediario que la impone. Un almacén de estado que serializa lo que recibe, una capa de caché que guarda el resultado de una consulta, un componente que envía el objeto a un servidor y lo recupera después: todos ellos manipulan la proyección y devuelven algo que se le parece pero que ya no es el documento. La regla defensiva que conviene adoptar es que el documento no sale nunca de la capa que lo gobierna: hacia arriba solo circulan vistas, de solo lectura, y toda escritura vuelve como operación dirigida a una identidad, jamás como un objeto completo que reemplace lo que había.
Conviene entender por qué el ecosistema entero se ha organizado alrededor de esta forma concreta, porque la respuesta no es que sea la mejor estructura convergente posible. No lo es, y las lecciones anteriores ya dejaron ver por qué: un árbol de mapas, listas y hojas obliga a que la identidad viva en los nodos y no en las rutas, fabrica huérfanos en cuanto alguien reasigna una clave, se atraganta con los cambios de tipo y —como veremos en la última lección— ni siquiera acomoda cómodamente un número que se quiere sumar. Si el criterio fuera la elegancia algebraica, existen modelos claramente superiores. La razón de que este haya ganado es de otra naturaleza y es la misma que explica el éxito de casi toda infraestructura que se adopta de verdad: es la estructura convergente más potente que se puede ofrecer sin obligar a nadie a aprender un modelo de datos nuevo. Un programador que abre una de estas librerías por primera vez ve un objeto, le asigna claves, mete elementos en un array y todo se comporta como espera; la maquinaria de identidades, etiquetas y lápidas queda por debajo y solo asoma en los bordes. Ese acierto de adopción tiene una contrapartida que hay que mirar de frente, y es que la interfaz familiar miente por omisión: sugiere que asignar una clave es una escritura cuando es una creación, que el tercer elemento de una lista es una dirección cuando es una coincidencia momentánea, que leer una clave devuelve un valor cuando puede haber varios, y que guardar lo que se lee es guardar lo que hay cuando no lo es. Cada uno de los bordes afilados de este nivel es un punto donde esa mentira se rompe, y todos comparten la misma forma: aparecen cuando el programador razona con la semántica del objeto que ve en lugar de con la del árbol que hay debajo. De ahí la única disciplina que de verdad protege, y que vale más que memorizar cualquier tabla comparativa entre librerías: cada vez que escribas una línea que toca el documento, pregúntate qué operación estás emitiendo sobre el árbol —a qué identidad va dirigida, qué contexto causal lleva y qué ocurre si llega después de otra que aún no has visto— en lugar de qué valor estás poniendo. Quien sostiene esa traducción mentalmente es quien puede diseñar un esquema que aguante años; quien no la sostiene está programando contra una abstracción que funciona hasta el día en que dos personas trabajan a la vez.
- Modela tu documento principal como árbol de tres clases de nodo y anota, para cada nodo compuesto, si se crea una vez o se reasigna con el uso.
- Provoca deliberadamente un nodo huérfano reasignando una clave compuesta mientras otra réplica escribe dentro, y observa dónde acaban esas escrituras.
- Sustituye todas las reasignaciones de nodos compuestos por vaciados y comprueba que el escenario anterior deja de producir huérfanos.
- Escribe una prueba que cambie el tipo de una clave de forma concurrente hacia dos clases distintas y documenta la regla de desempate que aplica tu librería.
- Mide el coste de materializar la vista completa con un documento del tamaño real que esperas y compáralo con el de materializar solo el subárbol afectado.
- Verifica en tu código que ninguna ruta de persistencia guarda la vista materializada en lugar del documento, y añade una prueba que falle si alguien lo intenta.