CRDTs desde primeros principios
Antes de examinar Yjs y Automerge, es conveniente comprender los mecanismos subyacentes, ya que la mayoría de los tutoriales de CRDTs omiten la teoría y el comportamiento de fusión resulta difícil de razonar.
Un CRDT garantiza que si dos réplicas recibieron el mismo set de operaciones (en cualquier orden), van a converger al mismo estado. Hay dos familias:
CRDTs basados en estado (CvRDTs): Las réplicas se mandan su estado completo entre sí y mergean usando una función que es conmutativa, asociativa e idempotente. Considerar en un G-Counter:
// G-Counter: cada nodo mantiene su propio contador
// Basado en estado, mergeás tomando el max del count de cada nodo
class GCounter {
constructor(nodeId) {
this.nodeId = nodeId;
this.counts = {}; // { nodeId: count }
}
increment() {
this.counts[this.nodeId] = (this.counts[this.nodeId] || 0) + 1;
}
value() {
return Object.values(this.counts).reduce((sum, c) => sum + c, 0);
}
merge(other) {
// Tomá el max para cada nodo, esta es la magia
// Conmutativa: merge(A,B) = merge(B,A)
// Asociativa: merge(merge(A,B),C) = merge(A,merge(B,C))
// Idempotente: merge(A,A) = A
const merged = new GCounter(this.nodeId);
const allNodes = new Set([
...Object.keys(this.counts),
...Object.keys(other.counts)
]);
for (const node of allNodes) {
merged.counts[node] = Math.max(
this.counts[node] || 0,
other.counts[node] || 0
);
}
return merged;
}
}
CRDTs basados en operaciones (CmRDTs): Las réplicas se mandan operaciones entre sí. Las operaciones tienen que ser conmutativas (se pueden aplicar en cualquier orden). Esto es lo que Yjs y Automerge realmente usan, porque mandar "insertar carácter 'a' en posición 3" es mucho más barato que mandar el documento entero.
La parte complicada con CRDTs op-based para secuencias (como texto) es definir qué significa "posición 3" cuando dos usuarios están tipeando simultáneamente. Acá es donde la cosa resulta particularmente relevante.
Yjs: el rey del rendimiento
Yjs usa un algoritmo YATA (Yet Another Transformation Approach) modificado para sus tipos de secuencia. Cada carácter obtiene un ID único basado en el client ID y un reloj lógico:
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
// Crear un documento Yjs
const ydoc = new Y.Doc();
// Tipos compartidos, estos son los CRDTs
const ytext = ydoc.getText('editor');
const ymap = ydoc.getMap('metadata');
const yarray = ydoc.getArray('comments');
// Conectar con otros peers
const provider = new WebsocketProvider(
'wss://sync.example.com',
'document-abc-123',
ydoc
);
// Observar cambios de cualquier fuente (local o remota)
ytext.observe((event) => {
event.delta.forEach((op) => {
if (op.insert) console.log('Insertado:', op.insert);
if (op.delete) console.log('Borrado:', op.delete, 'chars');
if (op.retain) console.log('Retenido:', op.retain, 'chars');
});
});
// Hacer cambios dentro de una transacción
ydoc.transact(() => {
ytext.insert(0, 'Hola, ');
ytext.insert(6, 'mundo!');
ymap.set('lastEditor', 'lucio');
ymap.set('updatedAt', Date.now());
});
Lo que hace rápido a Yjs es su representación interna. En vez de almacenar cada carácter como un nodo separado en una lista enlazada (que es lo que el paper de YATA describe), Yjs usa una técnica llamada "struct optimization", caracteres consecutivos del mismo usuario se guardan como un solo struct:
// En vez de:
[{id: {client:1, clock:0}, content: 'H'},
{id: {client:1, clock:1}, content: 'o'},
{id: {client:1, clock:2}, content: 'l'},
{id: {client:1, clock:3}, content: 'a'}]
// Yjs guarda:
[{id: {client:1, clock:0}, content: 'Hola', length: 4}]
Esta sola optimización hace que Yjs sea órdenes de magnitud más rápido que implementaciones CRDT naive. Kevin Jahns (el autor de Yjs) publicó benchmarks mostrando Yjs procesando 260,000 ediciones en 0.5 segundos. Yo verifiqué números similares en mis propios tests.
Construyendo un editor colaborativo real con Yjs
Acá va un ejemplo más completo mostrando cómo cablear Yjs en un editor de producción:
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { IndexeddbPersistence } from 'y-indexeddb';
import { Awareness } from 'y-protocols/awareness';
const ydoc = new Y.Doc();
// Capa 1: Persistencia local, funciona offline
const localProvider = new IndexeddbPersistence('doc-abc-123', ydoc);
localProvider.on('synced', () => {
console.log('Cargado desde IndexedDB local');
});
// Capa 2: Sync por red, cuando hay conexión
const wsProvider = new WebsocketProvider(
'wss://sync.example.com',
'doc-abc-123',
ydoc,
{ connect: navigator.onLine }
);
// Manejar online/offline
window.addEventListener('online', () => wsProvider.connect());
window.addEventListener('offline', () => wsProvider.disconnect());
// Capa 3: Awareness, datos de presencia efímeros
const awareness = wsProvider.awareness;
awareness.setLocalStateField('user', {
name: 'Lucio',
color: '#d97757',
cursor: null,
});
// Escuchar la presencia de otros usuarios
awareness.on('change', () => {
const states = awareness.getStates();
states.forEach((state, clientId) => {
if (clientId !== ydoc.clientID) {
renderRemoteCursor(state.user);
}
});
});
// Undo/Redo con semánticas CRDT correctas
const undoManager = new Y.UndoManager(ydoc.getText('editor'), {
trackedOrigins: new Set([null]), // Solo trackear cambios locales
captureTimeout: 500, // Agrupar ediciones rápidas
});
La arquitectura de tres capas (IndexedDB + WebSocket + Awareness) es el patrón en el que convergí después de probar una docena de approaches diferentes. IndexedDB te da carga instantánea y soporte offline. WebSocket te da sync en tiempo real. Awareness te da presencia sin contaminar el historial del documento.
Automerge 2.0: el campeón de datos estructurados
Mientras Yjs domina en edición de texto, Automerge 2.0 trae una filosofía diferente. Trata al documento como un árbol tipo JSON con historial de cambios completo, cada modificación se trackea, y es posible "viajar en el tiempo" por la evolución del documento.
import * as Automerge from '@automerge/automerge';
// Crear un documento
let doc = Automerge.init();
// Hacer cambios
doc = Automerge.change(doc, 'Agregar tarea inicial', (d) => {
d.tasks = [];
d.tasks.push({
id: 'task-1',
title: 'Implementar sync CRDT',
status: 'in_progress',
assignee: 'lucio',
subtasks: [
{ title: 'Investigar Yjs vs Automerge', done: true },
{ title: 'Construir prototipo', done: false },
],
});
});
// Simular ediciones concurrentes de dos peers
let doc1 = Automerge.clone(doc);
let doc2 = Automerge.clone(doc);
// Peer 1: marcar tarea como terminada
doc1 = Automerge.change(doc1, 'Completar tarea', (d) => {
d.tasks[0].status = 'done';
});
// Peer 2: agregar una subtarea (concurrentemente)
doc2 = Automerge.change(doc2, 'Agregar subtarea', (d) => {
d.tasks[0].subtasks.push({
title: 'Escribir blog post sobre CRDTs',
done: false,
});
});
// Merge, ambos cambios se preservan, sin conflictos
let merged = Automerge.merge(doc1, doc2);
console.log(merged.tasks[0].status); // 'done'
console.log(merged.tasks[0].subtasks.length); // 3
La característica más destacada de Automerge 2.0 es su formato binario. El almacenamiento interno usa un encoding columnar que es increíblemente compacto:
// Guardar en formato binario
const binary = Automerge.save(doc);
console.log(binary.length); // ~200 bytes para este documento
// vs JSON
const json = JSON.stringify(Automerge.toJS(doc));
console.log(json.length); // ~350 bytes
// La diferencia se hace dramática con historial
// Un documento con 10,000 cambios:
// Binario: ~50KB (guarda historial completo)
// Snapshot JSON: ~5KB (sin historial)
// JSON con historial: ~2MB+
La realidad de la resolución de conflictos
Acá hay algo que me costó internalizar: los CRDTs no eliminan conflictos, hacen que la resolución de conflictos sea determinista y automática. Pero "automática" no siempre significa "correcta desde la perspectiva del usuario."
Considerá dos usuarios editando el mismo ítem de una lista simultáneamente:
// Usuario A: renombrar tarea
doc_A = Automerge.change(doc_A, (d) => {
d.tasks[0].title = 'Construir motor de sync CRDT';
});
// Usuario B: también renombra la misma tarea
doc_B = Automerge.change(doc_B, (d) => {
d.tasks[0].title = 'Implementar sync real-time';
});
// Después del merge: uno gana basado en ordenamiento de actor ID
let merged = Automerge.merge(doc_A, doc_B);
// Detectar conflictos en Automerge
const conflicts = Automerge.getConflicts(merged.tasks[0], 'title');
if (conflicts) {
console.log('Valores en conflicto:', Object.values(conflicts));
}
El enfoque recomendado es utilizar CRDTs para la capa de sync e implementar estrategias de merge específicas del dominio encima. Para un tablero kanban, movimientos concurrentes a distintas columnas probablemente deberían resultar en que gane el movimiento más reciente. Para un documento colaborativo, las ediciones concurrentes al mismo párrafo deberían intercalarse. Para una lista de compras, las adiciones concurrentes deberían aparecer ambas.
Patrones de arquitectura para apps local-first
Después de construir varias aplicaciones local-first, convergí en esta arquitectura:
┌─────────────────────────────────────────────┐
│ Cliente │
│ ┌─────────────┐ ┌──────────────────────┐ │
│ │ Capa UI │ │ Documento CRDT │ │
│ │ (React) │◄─┤ (Yjs / Automerge) │ │
│ └─────────────┘ └──────┬───────────────┘ │
│ │ │
│ ┌───────────────────────┼────────────────┐ │
│ │ Capa de Persistencia │ │ │
│ │ ┌─────────┐ ┌──────┴──────┐ │ │
│ │ │ IndexedDB│ │ Motor Sync │ │ │
│ │ │ (offline)│ │ (WebSocket) │ │ │
│ │ └─────────┘ └─────────────┘ │ │
│ └────────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ Servidor de Sync (opcional, stateless) │
│ - Retransmite updates entre peers │
│ - Guarda snapshots para peers nuevos │
│ - Auth/control de acceso │
│ - NO resuelve conflictos │
└─────────────────────────────────────────────┘
El servidor de sync es deliberadamente fino. No entiende el formato del documento, solo retransmite mensajes binarios entre peers y guarda el último snapshot para bootstrapear clientes nuevos.
// Servidor de sync mínimo con y-websocket
const WebSocket = require('ws');
const { setupWSConnection } = require('y-websocket/bin/utils');
const wss = new WebSocket.Server({ port: 1234 });
wss.on('connection', (ws, req) => {
const docName = req.url.slice(1);
setupWSConnection(ws, req, {
docName,
gc: true,
});
});
Ese es el servidor entero. Quince líneas. La complejidad vive en la librería CRDT, que corre en el cliente.
Reality check de rendimiento
Déjenme ser honesto con los trade-offs. Los CRDTs no son gratis:
Memoria: Un documento Yjs con 100,000 caracteres de texto e historial completo usa ~2MB de memoria. El string plano equivalente son 100KB. Ese overhead de 20x es el costo de trackear cada operación para resolución de conflictos.
Tamaño de documento: Los documentos de Automerge crecen con el historial. Un documento que fue muy editado durante meses puede llegar a 10-50MB en formato binario. Necesitás estrategias de compactación.
Sync inicial: Cuando un peer nuevo se une, necesita el documento completo. Para documentos grandes, esto puede tardar segundos.
A pesar de estos trade-offs, para aplicaciones donde la colaboración en tiempo real y el soporte offline importan, los CRDTs son el único enfoque que no te obliga a construir un sistema de resolución de conflictos custom desde cero. Y créanme, intenté construir ese es sistemas custom. Los autores de las librerías CRDT pensaron en edge cases que ni te imaginás.
El movimiento local-first todavía está en etapa temprana, pero las herramientas finalmente están listas para producción. Elegir Yjs para edición colaborativa de texto en tiempo real, Automerge para datos estructurados con historial rico, y construí tu capa de sync lo más fina posible. Cuanto menos sepa tu servidor sobre tus datos, más resiliente se vuelve tu arquitectura.