El modelo: un JSON inmutable que solo se toca dentro de un cambio
El documento de Automerge es un valor inmutable con forma de objeto JSON que nunca se muta directamente: se pasa a una función de cambio que graba lo que haces y devuelve un documento nuevo.
El modelo de programación de Automerge es más raro de lo que su superficie sugiere, y esa rareza es deliberada. Aparentemente escribes código imperativo normal: asignas propiedades, insertas en arrays, incrementas contadores. En realidad no estás modificando nada, sino describiendo un conjunto de operaciones que la biblioteca graba mientras finges mutar, para después construir con ellas un documento nuevo y dejar el anterior intacto. El documento viejo sigue siendo válido, sigue siendo legible y sigue teniendo su propia identidad en la historia. Entender bien esta doble naturaleza —API imperativa por fuera, valor inmutable por dentro— es la diferencia entre usar la biblioteca con soltura y pelearse con ella durante semanas por errores que parecen aleatorios y no lo son. Esta lección desmonta el mecanismo, recorre los tipos que el modelo reconoce y fija las cuatro reglas cuya violación explica casi todos los fallos de los que empiezan.
- Explicar por qué el documento es un valor inmutable y qué implica eso para el estado de tu aplicación.
- Describir qué ocurre mecánicamente dentro de la función de cambio y por qué existe el intermediario.
- Distinguir los tipos del modelo de datos y elegir bien entre cadena colaborativa y cadena inmutable.
- Reconocer las cuatro violaciones habituales del contrato y el síntoma con el que se manifiesta cada una.
El documento es un valor, no un contenedor
Un documento de Automerge se comporta como un número o como una cadena congelada, no como un objeto que guardas y modificas. La operación central lo deja claro en su firma: recibe un documento y devuelve otro. El que entró sigue existiendo, con el contenido que tenía, y puedes seguir leyéndolo indefinidamente.
import * as A from "@automerge/automerge";
const v0 = A.from({ titulo: "", tareas: [], hechas: new A.Counter(0) });
const v1 = A.change(v0, (d) => {
A.splice(d, ["titulo"], 0, 0, "Compra");
d.tareas.push({ texto: "pan", lista: false });
});
// v0 y v1 son dos documentos distintos y ambos siguen siendo validos.
console.log(v0.titulo); // ""
console.log(v1.titulo); // "Compra"
La consecuencia arquitectónica es que el estado de tu aplicación no es «el documento», sino «qué versión del documento estoy mostrando ahora mismo». Encaja de forma natural con cualquier almacén reactivo de los que vimos en los niveles de arquitectura del cliente: la función de cambio produce un valor nuevo, lo publicas, y quien esté suscrito vuelve a renderizar. No hay invalidación manual ni observadores sobre propiedades, porque no hay ninguna propiedad que cambie bajo tus pies.
flowchart LR A[documento v0] --> B[funcion de cambio] B --> C[el intermediario graba operaciones] C --> D[documento v1] A --> E[v0 sigue siendo legible y valido] D --> F[v1 es el estado que publicas] style D fill:#a6e3a1,color:#11111b style E fill:#89b4fa,color:#11111b
Hay un matiz de rendimiento que conviene aclarar temprano porque genera desconfianza injustificada. Que se devuelva un documento nuevo no significa que se copie el contenido. La estructura comparte casi toda su representación interna con la anterior, igual que hace cualquier estructura persistente, así que el coste de un cambio es proporcional a lo que el cambio toca y no al tamaño del documento. Encadenar miles de cambios sobre un documento grande es una operación barata.
El malentendido habitual es leer inmutabilidad como una restricción que te impide cambiar cosas. Es lo contrario: te permite cambiarlas sin destruir lo anterior. Un documento no es un archivo que se sobrescribe sino una secuencia de valores donde cada uno sigue existiendo mientras alguien lo referencie. Cuando nadie referencia una versión antigua, el recolector de basura del lenguaje la libera igual que liberaría cualquier otro objeto; lo que no desaparece es el cambio correspondiente dentro de la historia del documento, que es una cosa distinta y vive en la representación interna.
Dentro del cambio: el intermediario que graba
La segunda mitad del modelo es lo que ocurre dentro de la función. El objeto que recibe como argumento no es tu documento: es un intermediario, un objeto que intercepta cada lectura y cada escritura que haces sobre él. Cuando asignas una propiedad, el intermediario no guarda un valor sino que anota una operación con su ruta, su valor y su identidad. Cuando lees una propiedad anidada, te devuelve otro intermediario para que la intercepción siga funcionando en profundidad.
Al terminar la función, la biblioteca toma todas las operaciones anotadas y las empaqueta en un único cambio con su hash, sus dependencias causales, su marca de tiempo y, si se lo indicas, un mensaje descriptivo. Ese empaquetado es el motivo por el que la función de cambio es también una unidad de atomicidad: todo lo que ocurre dentro entra junto en la historia o no entra nada.
// Un cambio puede llevar metadatos, igual que un commit.
const v2 = A.change(v1, { message: "marcar la primera tarea" }, (d) => {
d.tareas[0].lista = true;
d.hechas.increment(1);
});
// Fusionar dos documentos con historias divergentes.
const fusionado = A.merge(v2, otroDocumento);
// Serializar y recuperar: el binario contiene el valor y la historia.
const bytes = A.save(fusionado);
const recuperado = A.load(bytes);
Agrupar operaciones relacionadas en un solo cambio no es solo higiene: es lo que hace legible la historia más adelante. Un cambio por pulsación de tecla es correcto pero produce un historial que nadie puede leer; un cambio por acción del usuario, con su mensaje, produce un historial que se parece a un registro de actividad y habilita todo lo que veremos en la lección siguiente.
El error más frecuente de quien empieza es guardar el objeto que recibe la función, o algo extraído de él, para usarlo fuera. No funciona: fuera de la función ese intermediario ya no está conectado a nada, y las escrituras que hagas sobre él se pierden en silencio o lanzan un error, según el caso. El corolario es igual de importante y menos evidente: la función de cambio debe ser pura respecto del mundo exterior. Nada de peticiones de red, nada de leer un reloj, nada de generar valores aleatorios. Si el contenido de un cambio depende de algo no determinista, calcúlalo antes y pásalo ya resuelto.
Los tipos que el modelo reconoce
El modelo de datos es un JSON con la raíz siempre un mapa, más un puñado de tipos que JSON no tiene y que aquí hacen falta porque llevan semántica de fusión propia. Conocerlos importa mucho más de lo que parece, porque elegir el tipo equivocado no produce un error sino un comportamiento de fusión distinto del que esperabas.
Mapas y listas
El mapa es la raíz y el bloque de construcción; la lista es una secuencia cuyas inserciones y borrados concurrentes se reconcilian preservando la intención.
Texto colaborativo
Una cadena JavaScript normal representa texto que fusiona carácter a carácter y admite marcas de formato para texto enriquecido.
Cadena inmutable
La clase ImmutableString representa texto que no se fusiona: se reemplaza entero, que es lo que quieres para un identificador o un enumerado.
Contador
La clase Counter suma todos los incrementos concurrentes en lugar de quedarse con uno, y es el único tipo numérico que fusiona de forma acumulativa.
La distinción entre los dos tipos de cadena es la que más problemas causa y la que la versión 3 dejó por fin en su sitio: el caso por defecto pasó a ser el texto colaborativo, representado con la cadena nativa del lenguaje, y el texto no colaborativo quedó relegado a una clase explícita. La regla mental es simple y vale casi siempre: si dos personas pueden estar escribiendo en ese campo a la vez y quieres que ambas contribuciones sobrevivan, es texto colaborativo; si el campo es un valor que se elige, no que se escribe —un estado, una categoría, una clave— es una cadena inmutable.
Hay una implicación operativa que se deriva de ahí y que conviene tener presente al modelar. El texto colaborativo se modifica con la función de empalme indicando la ruta, la posición, cuántos caracteres se borran y qué se inserta; nunca reasignando la propiedad entera. Reasignarla funciona, pero equivale a borrar todo el contenido e insertar el nuevo, lo cual destruye la posibilidad de fusionar con una edición concurrente y además infla la historia sin necesidad. Es la diferencia entre editar un párrafo y tirarlo para escribir otro.
// Correcto: se describe la edicion, y fusiona con ediciones concurrentes.
const bien = A.change(doc, (d) => {
A.splice(d, ["cuerpo"], 12, 3, "nuevo");
});
// Incorrecto: equivale a borrar todo el texto e insertar otro distinto.
// Fusiona mal y ademas infla la historia sin necesidad.
const mal = A.change(doc, (d) => {
d.cuerpo = "texto entero reescrito";
});
// Cuando ya tienes el texto final y no las posiciones,
// esta funcion calcula la diferencia minima por ti.
const tambienBien = A.change(doc, (d) => {
A.updateText(d, ["cuerpo"], textoFinalDelEditor);
});
La última variante es la que suele hacer falta al integrar un editor de terceros, porque esos editores te entregan el texto resultante y no la operación que lo produjo. Calcular la diferencia mínima y aplicarla como empalmes preserva casi toda la capacidad de fusión, aunque no toda: si dos personas reordenan párrafos enteros a la vez, ninguna heurística de diferencias va a reconstruir la intención original. Cuando el editor sí te da las operaciones, pásalas directamente y no pases por aquí.
Los tipos restantes completan el cuadro sin sorpresas: enteros con y sin signo, coma flotante de doble precisión, booleanos, valores nulos, marcas de tiempo que se corresponden con el objeto de fecha nativo y secuencias de bytes. Este último merece una advertencia de dimensionamiento: técnicamente puedes guardar binario dentro del documento, pero cada byte que metas ahí se convierte en historia que viaja en cada sincronización y que nunca se podrá borrar. Los adjuntos pesados van fuera, referenciados por identificador.
Las cuatro reglas del contrato
Casi todos los fallos que aparecen al adoptar la biblioteca son violaciones de una de estas cuatro reglas, y todas se derivan de la inmutabilidad. Vale la pena memorizarlas junto con su síntoma, porque el síntoma rara vez apunta a la causa.
La primera: solo se escribe dentro de la función de cambio. Cualquier mutación sobre un documento fuera de ella lanza un error o se pierde, y el síntoma típico es un cambio que «no se guarda» sin que nada falle. La segunda: siempre hay que quedarse con el documento devuelto y descartar la referencia anterior como estado vivo. Si sigues renderizando desde la variable vieja, la interfaz se queda congelada aunque la historia avance, y es un fallo que puede tardar días en aparecer si la variable vieja se copió a algún sitio.
La tercera: las vistas históricas son de solo lectura. Un documento obtenido mirando un punto del pasado comparte memoria con el original, es baratísimo de crear precisamente por eso, y no se puede pasar a la función de cambio. La cuarta: no mezcles documentos de identidades distintas asumiendo que da igual cuál conservas. La operación de fusión toma dos documentos y produce uno con la unión de ambas historias, y aunque el resultado converge, el actor bajo el que quedan registrados los cambios posteriores depende del documento que uses como base.
Hay una quinta regla que no es del contrato sino de higiene, y que conviene adoptar desde el principio porque después cuesta reconvertir el código. Escribe siempre a través de una única función de tu aplicación que envuelva la operación de cambio, en lugar de invocarla dispersa por todos los componentes. Esa envoltura es el sitio natural donde poner el mensaje descriptivo, donde validar invariantes de dominio antes de grabar, donde publicar el documento nuevo en tu almacén reactivo y donde instrumentar la telemetría. Sin ella acabas con mensajes vacíos, con cambios de granularidad arbitraria y sin ningún punto donde comprobar que lo que se escribe cumple las reglas de tu dominio.
Que la fusión no falle no significa que la información sobre la discrepancia se pierda. Cuando dos escrituras concurrentes asignan valores distintos a la misma clave de un mapa, la biblioteca elige determinísticamente una para el valor visible pero conserva las demás, y puedes recuperarlas para mostrarlas. Ese es el mecanismo que necesitas si tu producto quiere avisar de que hubo una discrepancia en lugar de resolverla en silencio, y es sorprendentemente poco usado. Vale la pena decidir campo por campo si el silencio es aceptable o si el usuario debería enterarse.
Merece la pena entender por qué el modelo tuvo que ser este y no otro, porque la respuesta no es que a los autores les guste la programación funcional. Un documento colaborativo tiene que poder existir en varios estados simultáneamente y todos ellos legítimos: el que tú ves mientras escribes, el que tu compañera veía hace treinta segundos, el que el servidor tenía antes de recibir tu último lote, el que reconstruyes para mostrar una versión anterior, y el resultado de fusionar dos ramas que llevaban horas separadas. En un modelo mutable esos estados no se pueden representar a la vez, porque solo hay un objeto y solo puede tener un contenido; lo único que puedes hacer es serializar copias, y en cuanto haces eso pierdes la identidad de las operaciones y con ella la capacidad de fusionar. Un modelo donde cada versión es un valor distinto, con su hash y sus padres, convierte esa multiplicidad en algo trivial: todos los estados coexisten, todos son direccionables, y la fusión es una función de dos de ellos a un tercero. Fíjate en que esto es exactamente lo que hace un sistema de control de versiones, y en que la razón es la misma. Ahora la parte que de verdad cambia cómo trabajas: la inmutabilidad es lo que convierte la concurrencia en un problema de datos en lugar de un problema de tiempo. En un modelo mutable, dos escrituras simultáneas son una carrera, y las carreras se combaten con cerrojos, con orden de llegada, con un servidor que arbitre; toda esa maquinaria existe para imponer un orden temporal único sobre eventos que no lo tienen. En un modelo inmutable con identidad por operación, dos escrituras simultáneas son simplemente dos valores, y combinarlos es una función pura que puedes ejecutar cuando quieras, las veces que quieras, en cualquier máquina, con el mismo resultado. Desaparece la noción de «demasiado tarde». Un cambio que llega tres semanas después de haberse producido se integra igual de bien que uno que llega en el mismo milisegundo, porque el modelo no pregunta cuándo llegó sino de qué depende. Esa es la propiedad que hace posible el trabajo sin conexión, y no se puede obtener a base de reintentos ni de colas: hay que construirla en el modelo desde el principio. Cuando evalúes cualquier herramienta de sincronización, la pregunta que separa las serias de las demás es si un cambio muy retrasado es un caso normal o una excepción que hay que gestionar.
- Toma una entidad de tu aplicación y escribe su forma como documento, decidiendo tipo por tipo.
- Justifica para cada cadena si es texto colaborativo o cadena inmutable, y escribe el criterio que usaste.
- Sustituye cualquier campo numérico que se incremente desde varios sitios por un contador y explica la diferencia al fusionar.
- Implementa dos cambios, uno con mensaje y otro sin él, y compara qué información queda registrada.
- Provoca deliberadamente las cuatro violaciones del contrato y anota el síntoma exacto de cada una.
- Fusiona dos documentos editados en paralelo sobre el mismo campo de texto y comprueba si el resultado preserva ambas contribuciones.