wandres.dev
AUTOMERGE · documentos e historia

La historia: causalidad registrada y qué habilita en un producto

Cada cambio guarda su hash, sus padres causales, su actor y su marca de tiempo, lo que convierte el documento en un grafo recorrible donde puedes situarte en cualquier punto del pasado y comparar dos versiones.

⏱ 20 min

Lo que distingue a Automerge de la mayoría de sus alternativas no es cómo fusiona, sino qué conserva después de fusionar. Otras bibliotecas de la familia tratan la historia como un residuo del algoritmo que conviene descartar en cuanto deja de hacer falta; aquí la historia es un producto de primera clase, con su propia API, y está pensada para que la consultes en tiempo de ejecución desde tu aplicación. Cada cambio lleva un hash criptográfico que lo identifica, la lista de hashes de los cambios de los que depende, el actor que lo produjo, una marca de tiempo y un mensaje opcional. Con eso, el documento deja de ser un estado y pasa a ser un grafo dirigido acíclico que puedes recorrer, del que puedes extraer una vista en cualquier punto y sobre el que puedes preguntar qué cambió entre dos versiones. Esta lección recorre esa API con precisión, y sobre todo delimita hasta dónde llega la respuesta a la pregunta que todo el mundo hace primero: quién hizo qué.

🎯 Al terminar esta lección sabrás
  • Describir qué metadatos lleva cada cambio y por qué las dependencias causales bastan para ordenar sin relojes.
  • Manejar las funciones que sitúan el documento en un punto del pasado y que comparan dos puntos.
  • Delimitar con exactitud qué significa el actor de un cambio y qué no garantiza.
  • Enumerar las funcionalidades de producto que esta información habilita sin infraestructura adicional.

Qué queda registrado en cada cambio

Un cambio no es un diff de texto ni una fotografía del estado: es un paquete de operaciones con identidad propia. Sus campos son pocos y todos cumplen una función. El hash lo identifica de forma única y se calcula sobre su contenido, de modo que dos réplicas que produzcan el mismo cambio producen el mismo hash y la deduplicación es automática. Las dependencias son la lista de hashes de los cambios que el autor ya conocía cuando lo produjo, y son lo que codifica la causalidad. El actor identifica la sesión de escritura que lo generó, la secuencia lo ordena dentro de esa sesión, y la marca de tiempo y el mensaje son metadatos informativos.

Las dependencias merecen atención porque son el mecanismo entero. Al declarar de qué cambios depende, cada cambio dice implícitamente «esto ocurrió después de aquello», sin necesidad de ningún reloj compartido. Dos cambios que no se alcanzan mutuamente recorriendo el grafo hacia atrás son concurrentes por definición, y esa definición es puramente estructural: no depende de en qué momento se escribieron ni de qué hora marcaba cada máquina. Todo lo que los niveles anteriores construyeron sobre relojes lógicos y vectores de versión está aquí resuelto de raíz, porque el grafo es el reloj.

Conviene apreciar lo que eso elimina. Un sistema que ordena por marca de tiempo hereda todos los problemas de los relojes: dispositivos desajustados, cambios de horario, usuarios que ajustan la hora a mano, y el caso patológico en que un reloj adelantado hace que una escritura futura gane para siempre contra todas las demás. Aquí la marca de tiempo existe pero es puramente informativa: sirve para enseñarla en una interfaz y no participa en ninguna decisión de fusión. Si mañana descubres que la mitad de tus dispositivos tenían la hora mal, tus documentos siguen siendo correctos.

flowchart LR
A[cambio raiz] --> B[cambio de ana]
A --> C[cambio de luis]
B --> D[fusion]
C --> D
D --> E[cabeza actual]
style C fill:#f9e2af,color:#11111b
style B fill:#f9e2af,color:#11111b
style E fill:#a6e3a1,color:#11111b

Las dos ramas amarillas del diagrama son concurrentes y lo son por estructura: ninguna aparece en el pasado de la otra. El nodo verde es la cabeza, y aquí conviene fijar el término porque es el que usa toda la API. Las cabezas de un documento son el conjunto de cambios que ningún otro cambio tiene como dependencia, es decir, las puntas del grafo. Normalmente hay una sola, pero justo después de recibir trabajo concurrente y antes de escribir encima puede haber varias, y ese conjunto de hashes es lo que identifica una versión del documento de forma exacta y portátil.

import * as A from "@automerge/automerge";

// Las cabezas identifican una version concreta. Guardalas si quieres volver.
const antes = A.getHeads(doc);

const despues = A.change(doc, { message: "renombrar el proyecto" }, (d) => {
  d.nombre = new A.ImmutableString("Proyecto Fenix");
});

// Estadisticas internas del documento: util para vigilar el crecimiento.
const s = A.stats(despues);
console.log(s.numChanges, s.numOps, s.numActors);

Recorrer el pasado y comparar dos puntos

Con las cabezas en la mano, las dos operaciones fundamentales sobre la historia son situarse y comparar. Situarse produce una vista inmutable del documento tal como estaba en ese punto; comparar produce la lista de modificaciones que llevan de un punto a otro.

// Vista del documento en un punto del pasado.
// Es una copia muy barata porque comparte memoria con el original.
const vistaAntigua = A.view(doc, antes);
console.log(vistaAntigua.nombre);

// Que cambio entre dos versiones: una lista de parches.
const parches = A.diff(doc, antes, A.getHeads(doc));

// Metadatos de todos los cambios posteriores a un punto.
const meta = A.getChangesMetaSince(doc, antes);
for (const c of meta) {
  console.log(c.hash, c.actor, c.time, c.message);
}

// Detalle completo de un cambio concreto, incluidas sus operaciones.
const detalle = A.inspectChange(doc, meta[0].hash);

Hay dos detalles de comportamiento que la documentación señala y que conviene no descubrir en producción. El primero: la vista es de solo lectura y no se puede pasar a la función de cambio, precisamente porque comparte memoria con el documento original en lugar de duplicarlo, que es lo que la hace tan barata. El segundo: si alguno de los hashes que pasas no está en el documento, situarse lanza un error mientras que comparar devuelve una lista vacía. Esa diferencia de trato importa cuando reconstruyes una versión a partir de cabezas guardadas hace tiempo en un dispositivo que puede no haber recibido todavía esos cambios.

💡
Guardar una versión es guardar un puñado de hashes, no una copia

Este es el patrón que más rendimiento ahorra y el que menos gente aplica. Para marcar un hito —una publicación, una revisión aprobada, el estado en el que se firmó algo— no hace falta duplicar el documento: basta con persistir el conjunto de cabezas, que son unas pocas cadenas de texto. La reconstrucción se hace después bajo demanda y es baratísima porque comparte memoria con el documento vivo. Un sistema de versiones etiquetadas para un producto entero cabe en una tabla de dos columnas.

⚠️
La función de historial de la serie 2 ya no existe

Quien venga de Automerge 2 buscará una función que devolvía el historial completo como una lista de estados y no la va a encontrar: desapareció en la versión 3, y con razón, porque materializar un estado por cada cambio es justo el patrón que hacía inviables los documentos grandes. La sustitución no es una función equivalente sino un conjunto de piezas más finas: pedir metadatos cuando solo necesitas metadatos, pedir una vista cuando necesitas un estado concreto, pedir una comparación cuando lo que quieres es la diferencia. Es más verboso y es órdenes de magnitud más barato. Ten en cuenta además que la función que descodifica un cambio individual está declarada como API semiestable: el equipo evita romperla, pero no se compromete a una versión mayor si tiene que hacerlo.

Quién hizo qué, y hasta dónde llega esa respuesta

Aquí es donde hay que ser preciso, porque es el punto donde más gente construye una funcionalidad sobre una garantía que no existe. Cada cambio lleva un actor, y es tentador leerlo como «el usuario que hizo esto». No lo es. El actor es un identificador de sesión de escritura, generado localmente, que por defecto es aleatorio y puede cambiar entre sesiones del mismo usuario. Puedes fijarlo tú al inicializar el documento, y mucha gente lo hace poniendo el identificador de usuario, pero eso solo traslada el problema: nada en el documento verifica que quien escribió ese cambio fuera realmente quien dice ser.

La distinción es la que existe entre atribución y procedencia. La atribución es una etiqueta que el propio autor se pone y que sirve perfectamente para colaborar entre gente que confía entre sí: mostrar colores por autor, agrupar cambios, escribir un registro de actividad. La procedencia es una afirmación verificable sobre quién produjo un cambio, resistente a que alguien mienta, y requiere firmas criptográficas que la biblioteca no aplica hoy. El equipo trabaja de forma visible en añadir procedencia de autoría al núcleo, ligada a su capa de control de acceso, porque es también el requisito que faltaba para poder revocar permisos de forma coherente. Mientras eso no esté cerrado, la regla es sencilla: la autoría del documento vale para la interfaz, no para decidir a quién se le permite qué.

🎨

Atribución visual

Colorear por autor, agrupar cambios por sesión y mostrar un registro de actividad, todo desde los metadatos del cambio.

🧭

Etiqueta, no identidad

El actor lo elige quien escribe, así que sirve para colaborar entre pares que confían y no para autorizar.

🕵️

Fuga de metadatos

Aunque cifres el contenido, el grafo revela cuántos actores hay, cuándo escribió cada uno y con qué ritmo.

🔏

Procedencia verificable

Es trabajo en curso y llega ligado al control de acceso, porque revocar permisos exige poder demostrar quién escribió qué.

Qué habilita todo esto en un producto

La utilidad práctica de tener la historia dentro del documento es que un conjunto de funcionalidades que normalmente exigen infraestructura dedicada pasan a ser consultas locales. El historial de versiones deja de necesitar una tabla de auditoría en el servidor: guardas las cabezas en el momento que te interese y reconstruyes la vista cuando haga falta. La comparación entre dos versiones deja de necesitar un servicio que almacene instantáneas y las diferencie: la produce la biblioteca a partir de dos conjuntos de hashes. El registro de actividad deja de necesitar eventos emitidos a un sistema aparte: está implícito en los metadatos de los cambios.

Hay dos funcionalidades más que suelen pasar desapercibidas y que son las que de verdad justifican el modelo en ciertos dominios. La primera es el trabajo por ramas: como una versión es un conjunto de hashes y la fusión es una función pura, puedes dejar que alguien trabaje sobre una versión fija durante días y luego integrar, con la garantía de que la integración no puede fallar. Eso es lo que permite construir revisión de cambios sobre datos que no son código. La segunda es la reproducibilidad: ante un informe de error puedes reconstruir exactamente el estado que el usuario tenía al ocurrir, sin depender de que alguien hubiera pensado en registrarlo.

// Un historial de versiones completo cabe en muy poco codigo.
function historial(doc, desde) {
  return A.getChangesMetaSince(doc, desde).map((c) => ({
    id: c.hash,
    autor: c.actor,
    cuando: new Date(c.time),
    resumen: c.message ?? "sin descripcion",
  }));
}

// Y restaurar consiste en leer una vista antigua
// y volver a escribir su contenido como un cambio nuevo,
// nunca en borrar cambios del pasado.
function restaurar(doc, cabezasAntiguas) {
  const antiguo = A.view(doc, cabezasAntiguas);
  return A.change(doc, { message: "restaurar version anterior" }, (d) => {
    A.updateText(d, ["cuerpo"], antiguo.cuerpo);
  });
}

La segunda función merece un comentario porque encierra el patrón correcto para deshacer en esta arquitectura. Restaurar no es retroceder: es avanzar hacia un estado que se parece a uno anterior. El cambio que restaura se añade a la historia como cualquier otro, con su autor y su mensaje, y por eso se puede a su vez deshacer, sincronizar y fusionar sin sorpresas. Intentar implementar el deshacer eliminando cambios del grafo es el error que rompe la convergencia, porque cualquier réplica que todavía los tenga los volverá a introducir en la siguiente sincronización.

Guardar la historia convierte el olvido en un problema de diseño, y ahí es donde esta arquitectura pasa factura

Todo lo anterior describe lo que ganas, y es mucho, pero el nivel se quedaría corto si no enunciara con la misma claridad la contrapartida, porque es estructural y no se arregla con una actualización. Un documento que registra causalmente cada operación es, por construcción, un sistema que no puede olvidar. Y no puede en un sentido fuerte: no es que sea caro borrar, es que borrar rompe la propiedad que sostiene todo lo demás. Si eliminas un cambio del grafo, cualquier réplica que ya lo hubiera integrado deja de poder verificar sus dependencias, y cualquier réplica que no lo tuviera y lo reciba de un tercero lo volverá a introducir, porque la fusión es idempotente y no distingue entre un cambio nuevo y uno resucitado. La consecuencia inmediata y muy poco discutida es jurídica antes que técnica: el derecho de supresión y una estructura solo-añadir son fundamentalmente incompatibles, y si tu producto trata datos personales vas a tener que resolverlo en el diseño y no en el código. Las salidas reales son tres y las tres tienen coste. Puedes no meter nunca datos personales dentro del documento y guardar solo referencias, lo cual funciona pero reintroduce un almacén externo con su propia sincronización. Puedes cifrar por elemento y borrar la clave, que es criptográficamente sólido pero convierte la gestión de claves en un sistema entero. O puedes reescribir la historia periódicamente publicando un documento nuevo sin lo suprimido, que es lo que en la práctica hace casi todo el mundo, y que te obliga a coordinar a todas las réplicas para que abandonen el documento antiguo, es decir, exactamente el tipo de coordinación global que habías adoptado esta arquitectura para evitar. Y hay una segunda cara aún más sutil: aunque cifres el contenido, el grafo de cambios es en sí mismo un canal de información. Revela cuántos actores participan, con qué frecuencia escribe cada uno, a qué horas trabajan, cuándo dejó alguien de escribir y cuánto tiempo estuvo desconectado. Ese perfil temporal sobrevive al cifrado porque es la estructura, no el contenido, y en algunos dominios es más sensible que los propios datos. La lección transferible es la que deberías aplicar en la próxima decisión de arquitectura: la retención por defecto es una decisión de producto disfrazada de detalle técnico, y una tecnología que la fija en el infinito te está tomando esa decisión sin consultarte. No es una razón para descartarla, pero sí para elegirla con los ojos abiertos y con la política de datos escrita antes de la primera línea de código.

⚔️ Construye un historial de versiones sobre un documento real
  1. Guarda las cabezas antes y después de una serie de cambios y comprueba que identifican versiones distintas.
  2. Reconstruye una vista de hace diez cambios y mide cuánto tarda frente a cargar el documento entero.
  3. Genera la comparación entre dos versiones y clasifica los parches por tipo de operación.
  4. Extrae los metadatos de los cambios de la última hora y agrúpalos por actor para producir un registro de actividad.
  5. Fija el actor al identificador de tu usuario y razona por escrito qué sigue sin estar garantizado.
  6. Escribe la política de retención de tu producto y comprueba si es realizable sobre una estructura que no olvida.