Lo que uno se lleva de aquí para local-first
Separar un almacén inmutable de un puñado de punteros mutables y sincronizar por diferencia de grafos es el molde que Git dejó probado, y por eso su modelo es referencia obligada al diseñar sincronización.
Las cuatro lecciones anteriores han desmontado Git por dentro, y ahora toca la parte que justifica haberlo hecho en un recorrido que no trata de Git sino de aplicaciones que funcionan sin servidor. La pregunta no es cómo usar Git mejor, sino qué decisiones de su arquitectura son transferibles a un sistema local-first cualquiera y cuáles son específicas de versionar código escrito por personas. La respuesta se sostiene en tres piezas: la separación estricta entre un almacén inmutable direccionado por contenido y una capa mínima de punteros mutables, la sincronización planteada como diferencia entre dos grafos en lugar de como transferencia de ficheros, y una honestidad explícita sobre qué parte del problema queda fuera. Esta lección cierra el nivel poniendo esas tres piezas en forma de criterio de diseño, y nombrando con la misma claridad los lugares donde copiar el modelo de Git sin pensar produce sistemas peores que no copiarlo.
- Formular la separación entre capa inmutable y capa mutable como criterio evaluable de un diseño propio.
- Entender la sincronización como negociación de fronteras de un grafo y no como copia de datos.
- Reconocer las operaciones de Git que ya son, literalmente, comparaciones de grafos.
- Delimitar qué problemas de local-first el modelo de Git no resuelve y con qué se completan.
- Extraer una lista corta de preguntas que aplicar a cualquier sincronizador antes de escribirlo.
Dos capas con propiedades opuestas y deliberadas
La primera pieza es la que ya se ha demostrado y ahora conviene enunciar como principio. En un extremo hay un almacén donde cada dato se nombra por su contenido, donde escribir nunca destruye, donde la unión de dos réplicas es la unión de sus conjuntos y donde la verificación no necesita autoridad. En el otro extremo hay un conjunto muy pequeño de punteros que dicen cuál es el presente, donde escribir sí puede destruir, donde dos actores pueden querer cosas incompatibles y donde por tanto hacen falta bloqueo, atomicidad, actualización condicional y una bitácora para deshacer.
La virtud del diseño no está en tener las dos capas, que es casi inevitable, sino en la desproporción entre ellas. Un repositorio del núcleo de Linux tiene del orden de diez millones de objetos y unos pocos miles de referencias. Esa proporción se puede medir en cualquier repositorio y es el indicador más directo de si un diseño ha empujado bien el trabajo hacia donde no cuesta.
cd /tmp/almacen
git cat-file --batch-all-objects --batch-check='%(objectname)' | wc -l # capa inmutable
git for-each-ref --format='%(refname)' | wc -l # capa mutable
Aplicado a un sistema propio, el criterio se convierte en una pregunta que se puede responder antes de escribir una línea de código: cuántas cosas de mi modelo son inmutables y direccionables por su contenido, y cuántas son punteros que alguien puede querer mover a la vez que otro. Si la segunda lista crece con el número de campos editables, el diseño va a doler; si crece con el número de documentos o de dispositivos, es tratable.
Traducir esto a una aplicación cualquiera es más literal de lo que parece. Una operación del usuario, serializada de forma canónica y resumida criptográficamente, ya es un objeto inmutable con una dirección propia; si además incluye las direcciones de las operaciones que su autor había visto, ya es un nodo de un grafo causal idéntico en forma al que forman los commits. Con eso solo, el noventa por ciento del estado de la aplicación pasa a vivir en la capa que no requiere acuerdo, y lo que queda arriba es un puntero por documento que dice cuáles son sus puntas actuales.
Es una confusión frecuente y conviene despejarla pronto. Que el almacén sea inmutable no impide editar nada: editar produce objetos nuevos, y el puntero pasa a señalar el nuevo estado. Lo que la inmutabilidad prohíbe es que un dato ya publicado cambie bajo los pies de quien lo tiene. Esa prohibición es exactamente lo que permite cachear sin invalidar, replicar sin invalidar y verificar sin preguntar. La sensación de edición la produce la capa de punteros, que es diminuta y por eso barata de coordinar.
Sincronizar es comparar grafos, no comparar ficheros
La segunda pieza es la que más se malinterpreta al copiar el modelo. Cuando dos repositorios sincronizan no se comparan ficheros, ni se comparan marcas de tiempo, ni se envía un registro de cambios desde un punto acordado. Se comparan fronteras de un grafo: cada lado anuncia las direcciones de sus puntas, ambos deducen qué parte del grafo comparten y solo viaja lo que falta al otro lado. La deducción es posible porque, gracias al direccionamiento por contenido, saber que se tiene un objeto implica saber que se tiene todo lo que cuelga de él.
# Lo que el otro extremo anuncia: sus punteros y nada mas
git ls-remote /tmp/otro-repo
# Que objetos tengo yo que no alcanza ninguna de mis referencias conocidas
git rev-list --objects --not --all | head
# Cuanto se han separado dos lineas y donde divergieron
git rev-list --left-right --count main...origin/main
git merge-base main origin/main
Conviene contrastar esto con la alternativa que casi todo el mundo escribe primero, porque el contraste es la mitad del aprendizaje. Un sincronizador ingenuo pregunta qué ha cambiado desde una fecha, y esa pregunta arrastra tres problemas que no tienen arreglo dentro de ese planteamiento: exige relojes comparables entre dispositivos que no los tienen, obliga a guardar por cada par de réplicas cuánto le había mandado ya a la otra, y se rompe en cuanto una réplica se restaura desde una copia antigua, porque su idea de por dónde iba deja de ser cierta y nadie se entera. La negociación por frontera de grafo no sufre ninguno de los tres, porque el estado de la conversación no se guarda en ningún sitio: se deduce cada vez a partir de los datos mismos.
Esa negociación es explícita y se puede observar aislada, sin transferir datos, lo cual es un excelente ejercicio para entender que la parte cara del protocolo es acordar la frontera y no mover los bytes.
git fetch --negotiate-only --negotiation-tip=main /tmp/otro-repo
Y como el resultado de la negociación es un conjunto de objetos, la sincronización no necesita conexión: cabe entera en un fichero que se puede llevar en una memoria o enviar por correo, que es la prueba más contundente de que el modelo no depende de la red.
git bundle create /tmp/todo.bundle --all
git bundle verify /tmp/todo.bundle
git clone /tmp/todo.bundle copia
flowchart LR A[replica A anuncia sus puntas] --> N[negociacion de frontera] B[replica B anuncia sus puntas] --> N N --> C[base comun deducida] C --> P[paquete con lo que falta] P --> U[union de almacenes sin resolver conflictos] U --> R[decidir donde apuntan los punteros] style U fill:#a6e3a1,color:#11111b style R fill:#f9e2af,color:#11111b
Lo que Git no resuelve y tú sí tendrás que resolver
Copiar el modelo sin ver sus huecos produce sistemas frustrantes, así que conviene enumerarlos con la misma franqueza. Git no fusiona contenido automáticamente: cuando dos ramas tocan la misma zona, se detiene y pregunta, y esa respuesta es aceptable para código revisado por personas e inaceptable para una aplicación que debe converger sola en un teléfono. Git tampoco tiene granularidad fina: la unidad es el fichero y la revisión, no el campo ni el carácter. Su historia es completa por omisión, y aunque exista la clonación parcial, sigue siendo un sistema pensado para que cada réplica lo tenga casi todo. Y su capa mutable se resuelve por rechazo, no por convergencia.
Ninguno de estos huecos es un descuido. Git se diseñó para un caso muy concreto —muchas personas competentes editando texto fuente y revisando los resultados— y cada una de sus renuncias está alineada con ese caso. Detenerse ante un conflicto es correcto cuando hay alguien mirando; la unidad de fichero es correcta cuando el dato es un fichero; la historia completa es correcta cuando cabe. Copiar el modelo a un contexto donde ninguna de las tres premisas se cumple y luego culpar al modelo es un error de lectura, no del modelo.
# Historia completa por omision, y la alternativa parcial
git clone --filter=blob:none /tmp/otro-repo copia-ligera
# La capa mutable rechaza en lugar de converger
git push --force-with-lease origin main
Fusión de contenido
Git delega en la persona; una aplicación local-first necesita una regla determinista, y de ahí vienen los CRDT del recorrido.
Granularidad
La unidad de Git es el fichero completo; un editor colaborativo necesita operar por carácter o por campo.
Historia parcial
Un dispositivo pequeño rara vez puede sostener toda la historia, y el modelo hay que adaptarlo para eso.
Conflicto de punteros
Git rechaza y avisa; un sistema sin interfaz para preguntar necesita decidir de antemano quién gana.
La tercera tarjeta merece un comentario porque es la que más ha cambiado en los últimos años y la que más se parece al problema de un dispositivo modesto. La clonación parcial permite traer la topología sin traer los contenidos y pedir los blobs cuando de verdad se necesiten, lo cual es posible precisamente porque las direcciones son autónomas: se puede tener un árbol que menciona un blob que todavía no se ha descargado, y sigue siendo un árbol perfectamente válido y verificable. Un almacén donde la identidad dependiera de la presencia física del dato no podría ofrecer nada parecido.
Puestos así, los huecos dibujan con precisión el reparto de responsabilidades que este recorrido lleva construyendo desde hace varios niveles. Del modelo de Git se toma la capa de abajo: almacén inmutable direccionado por contenido, grafo de causalidad, sincronización por diferencia de fronteras, compactación local revisable. De los CRDT y del grafo de eventos se toma la capa de arriba: la regla determinista que decide qué significa el estado actual cuando dos punteros discrepan, sin necesidad de preguntarle a nadie. Ninguna de las dos mitades funciona sola, y el error habitual es tratar de resolverlo todo con una de ellas.
Existe la tentación de aprovechar la implementación en lugar del modelo, guardando el estado de la aplicación en un repositorio real. Suele acabar mal por motivos concretos y no ideológicos: el coste por escritura es alto porque cada cambio crea árboles nuevos hasta la raíz, la granularidad de fichero encaja fatal con datos estructurados, la fusión requiere intervención, el mantenimiento periódico bloquea, y la ventana de recuperación del registro de referencias es un comportamiento pensado para personas, no para procesos. Lo valioso es el diseño, y ese sí se reimplementa en unas pocas semanas sobre el almacén que ya tengas.
Las cuatro preguntas que conviene responder antes de escribir código
Todo lo anterior se puede comprimir en un cuestionario corto, y responderlo por escrito antes de empezar ahorra meses. La primera pregunta es dónde está el almacén inmutable: qué entidades del modelo se nombran por su contenido, con qué serialización canónica y con qué función de resumen. La segunda es cuántos punteros mutables hay y con qué crecen, porque ese número es el tamaño real del problema de coordinación. La tercera es cómo se negocia la frontera entre dos réplicas y qué información necesita cada lado para deducirla sin guardar estado por pareja. La cuarta es qué decide los empates cuando dos punteros discrepan, y aquí solo hay tres respuestas honestas: una regla determinista acordada de antemano, una pregunta a la persona, o la aceptación explícita de que se pierde una de las dos versiones.
La segunda pregunta admite además una comprobación empírica que no cuesta nada y que conviene hacer sobre el repositorio de cualquier proyecto conocido antes de fiarse de la propia intuición. La proporción entre ambas capas es el resumen numérico de todo este nivel, y verla con los ojos convence más que cualquier argumento.
# La desproporcion que hay que perseguir, medida en un repositorio real
git clone --bare https://github.com/git/git /tmp/git-espejo
cd /tmp/git-espejo
git cat-file --batch-all-objects --batch-check='%(objectname)' | wc -l
git for-each-ref --format='%(refname)' | wc -l
git count-objects -vH
Ninguna de las cuatro respuestas exige elegir todavía una biblioteca, un formato ni un protocolo, y sin embargo las cuatro juntas determinan casi todo lo que vendrá después. Un diseño que las tiene claras puede cambiar de motor de almacenamiento, de transporte y hasta de algoritmo de fusión sin rehacerse; un diseño que las dejó implícitas descubrirá cada una de ellas tarde, en forma de error de sincronización que solo se reproduce con dos dispositivos y mala conexión.
Si este nivel deja una sola idea, que sea esta, porque es la que convierte cuatro lecciones de fontanería en un criterio de diseño. Durante décadas se asumió que un sistema con varias réplicas necesitaba un árbitro, y la discusión era qué protocolo de acuerdo usar. Git puso el problema del revés y probó a escala planetaria que la pregunta correcta es otra: qué fracción de mi estado necesita autoridad para tener sentido. La respuesta, cuando el modelo de datos se diseña para ello, es asombrosamente pequeña. Un contenido nombrado por su resumen no necesita que nadie autorice su existencia, ni que nadie decida quién llegó primero, ni que nadie resuelva un empate, porque no hay empate posible: o es el mismo objeto o es otro. Un grafo de causalidad donde cada nodo referencia a sus antecesores por dirección no necesita relojes sincronizados, porque el orden que importa está inscrito en los propios datos. Y una vez que esas dos cosas están en su sitio, lo único que queda por acordar es el puñado de punteros que dicen dónde está el presente. Ahí está el desplazamiento conceptual que hay que llevarse: el trabajo de diseño no consiste en elegir un buen algoritmo de consenso, sino en reorganizar el modelo de datos para que casi nada necesite consenso. Los sistemas que duelen no duelen por haber elegido mal el algoritmo, duelen por haber dejado que la parte que requiere acuerdo crezca hasta ser la mayoría del sistema, normalmente por decisiones que parecían inocentes en su momento: identificadores asignados por un servidor, contadores compartidos, orden global implícito, campos que se sobrescriben en lugar de acumularse. Por eso Git es la referencia obligada al diseñar sincronización, y no por sus comandos ni por su interfaz, que son mejorables y llevan discutiéndose veinte años. Es la referencia porque es la demostración empírica más larga y más grande que existe de que la coordinación se puede acorralar hasta volverla marginal, y de que cuando se consigue, el sistema resultante funciona sin red, escala sin servidor y sobrevive a sus autores. Cualquier diseño local-first que se proponga en serio debería poder responder dónde está su almacén inmutable, cuántos punteros mutables tiene, cómo se negocia su frontera y qué decide los empates. Si esas cuatro respuestas existen y son cortas, el sistema tiene futuro. Si alguna falta, lo que falta acabará apareciendo como un servidor obligatorio.
- Enumera en tu sistema qué entidades pueden nombrarse por su contenido y cuáles no, y explica por qué en cada caso.
- Cuenta los punteros mutables de tu modelo y comprueba si crecen con los documentos o con los campos.
- Escribe el protocolo de negociación de frontera que usarías, y compáralo con lo que hace
git fetch --negotiate-only. - Mide en un repositorio real la proporción entre objetos y referencias y compárala con la de tu diseño.
- Define qué regla determinista decide los empates de tus punteros y qué usuario podría quedar descontento con ella.
- Prueba a sincronizar dos réplicas sin red usando
git bundley traslada esa restricción a tu propio sincronizador.