El coste de cambiar después: qué se aísla y qué no
Migrar de una librería CRDT a otra es caro porque el formato binario, la historia y la identidad de las operaciones no se traducen; conviene saber de antemano qué se puede esconder tras una interfaz propia y qué no.
Toda decisión difícil se vuelve más llevadera si uno se convence de que podrá deshacerla, y la frase que cierra la mayoría de estas discusiones es que siempre se puede cambiar de librería más adelante. Con un CRDT esa frase es más falsa que con casi cualquier otra dependencia, y conviene entender exactamente por qué antes de apoyarse en ella. No es que migrar sea trabajoso: es que buena parte de lo que hay que migrar no admite traducción, porque no son datos sino identidades y relaciones causales que solo significan algo dentro del sistema que las generó. Esta lección cierra el nivel separando con precisión lo que sí se puede esconder tras una interfaz propia de lo que no se puede esconder por mucho esfuerzo que se invierta, y proponiendo la única estrategia de salida que funciona de verdad, que no consiste en poder cambiar sino en poder salir.
- Entender por qué el formato, la historia y la identidad de las operaciones no se traducen entre librerías.
- Delimitar qué parte del acoplamiento sí se aísla detrás de una interfaz propia de documento.
- Reconocer las cuatro fugas que atraviesan cualquier abstracción por bien diseñada que esté.
- Diseñar una estrategia de salida basada en exportación semántica en lugar de en portabilidad ilusoria.
Por qué la migración no es una traducción
Migrar entre dos bases de datos relacionales es trabajoso pero conceptualmente trivial: las filas significan lo mismo en las dos, y lo que cambia es la sintaxis y el dialecto. Migrar entre dos librerías CRDT no tiene esa propiedad, y la razón está en qué se guarda realmente. Un documento no contiene el texto: contiene un conjunto de operaciones identificadas, con una relación causal entre ellas y con identificadores que ordenan de forma determinista las inserciones rivales. Esos identificadores son locales al esquema de la librería y no tienen equivalente en otra.
De ahí sale la primera consecuencia dura. El estado actual sí se traduce, porque es contenido: puedes leer el texto, el árbol o el mapa y reconstruirlos en la otra librería. Lo que no se traduce es la historia, porque su unidad no es un valor sino una operación con identidad, y esa identidad se pierde en la conversión. Una migración produce por tanto documentos nuevos que empiezan su historia desde cero, y todo lo que dependía de conocer el pasado deja de funcionar para lo anterior a la migración.
Conviene ver por qué esa pérdida es estructural y no un defecto de las herramientas de conversión. La historia de un documento no es una lista de estados: es un grafo de operaciones donde cada una referencia a las que su autor veía cuando la produjo. Traducir ese grafo exigiría inventar, en el sistema de destino, identidades equivalentes a las del origen, con la misma relación causal entre ellas y con el mismo criterio de desempate para las inserciones rivales. Como cada librería define esos tres elementos a su manera, el resultado no sería el mismo grafo expresado de otra forma, sino un grafo distinto que casualmente produce el mismo estado final. Y un grafo distinto ya no responde las preguntas por las que guardabas el pasado.
La segunda consecuencia es más incómoda todavía. Los dispositivos no migran a la vez. Un usuario que no abre la aplicación en cuatro meses vuelve con documentos en el formato viejo y con operaciones que su copia local generó después del corte, de modo que durante toda la ventana de migración conviven dos sistemas que no pueden fusionar entre sí. Esa ventana no es un detalle de despliegue: es un periodo con lógica dual, dos formatos persistidos y un procedimiento para lo que llegue tarde. Es la parte cara y es la que nunca se presupuesta.
Si tu producto tiene documentos con varias personas conectadas a la vez, el cambio de librería no puede ocurrir mientras la sesión está viva, porque los dos protocolos no se entienden y no hay estado intermedio que ambos acepten. Hace falta un punto de corte por documento: cerrar la sesión, convertir el estado, reabrir contra el sistema nuevo y rechazar operaciones tardías del viejo. Multiplica eso por el número de documentos activos y por la imposibilidad de coordinar a usuarios que no controlas, y tendrás la medida real de la operación. Es exactamente el motivo por el que estas migraciones se aplazan hasta que dejan de ser posibles.
Lo que sí se aísla tras una interfaz propia
Que la migración sea cara no significa que el acoplamiento sea uniforme. Hay una parte grande del contacto entre tu aplicación y la librería que sí se puede concentrar en una capa fina, y hacerlo tiene sentido aunque nunca llegues a cambiar, porque el beneficio principal no es la portabilidad sino la claridad del modelo.
// Tu vocabulario, no el de la libreria: verbos del dominio y estado plano
export function crearDocumento(id) { /* ... */ }
export function aplicar(doc, accion) { /* accion es del dominio, no de la libreria */ }
export function leer(doc) { /* devuelve estado plano listo para la vista */ }
export function serializar(doc) { /* bytes para persistir */ }
export function fusionar(doc, bytesRemotos) { /* ... */ }
export function suscribir(doc, callback) { /* notifica cambios a la vista */ }
// Ninguna otra parte de la aplicacion importa la libreria concreta.
// Los tipos que cruzan esta frontera son tuyos y no de la dependencia.
Lo que esa frontera captura bien son cuatro cosas. Los verbos del dominio, de modo que la aplicación diga que añade un elemento a una lista y no que manipula una estructura concreta de la librería. La forma de lectura, devolviendo estado plano que las vistas consumen sin conocer la representación interna. La persistencia, concentrando en un punto la serialización y su versionado. Y la suscripción a cambios, que es donde cada librería tiene su propio vocabulario de eventos y donde el acoplamiento se dispersa por toda la aplicación si no se contiene.
flowchart TB UI[vistas y logica de aplicacion] --> API[tu interfaz de documento] API --> LIB[libreria CRDT concreta] LIB --> FMT[formato binario y grafo de historia] FMT --> DISK[bytes en dispositivos que no controlas] API -.fuga.-> P[rendimiento y granularidad] API -.fuga.-> S[semantica de fusion] API -.fuga.-> H[modelo de historia] API -.fuga.-> R[protocolo de red] style API fill:#a6e3a1,color:#11111b style FMT fill:#f38ba8,color:#11111b style DISK fill:#f38ba8,color:#11111b
Merece la pena insistir en el beneficio que llega aunque no haya migración. Una aplicación que habla con la librería desde cuarenta sitios distintos no tiene un modelo de datos: tiene una dependencia esparcida. Concentrar el contacto obliga a nombrar las operaciones del dominio, hace comprobable el conjunto de acciones posibles y convierte las pruebas del modelo en pruebas que no arrancan la librería entera. Eso se cobra desde el primer mes, y la eventual portabilidad es una propina.
Las cuatro fugas que ninguna interfaz contiene
La prueba de que la frontera está bien puesta es mecánica y conviene automatizarla: ningún archivo fuera de ese módulo puede importar la librería, y ningún tipo definido por la librería puede aparecer en las firmas que cruzan la frontera. Ambas condiciones se comprueban con una regla de análisis estático, y ambas se degradan sin ruido en cuanto alguien tiene prisa. Sin esa comprobación, la interfaz existe en el diagrama de arquitectura y no en el código, que es la situación más común y la más engañosa.
La primera fuga es de rendimiento y granularidad. Cada librería tiene un tamaño de operación en el que es eficiente, y tu aplicación acaba adaptándose a él: agrupa escrituras, evita ciertos patrones, elige entre una estructura anidada o plana según lo que le salga barato. Esas decisiones viven fuera de la interfaz, repartidas por la aplicación, y son invisibles hasta que cambias la librería y descubres que la forma de tu modelo estaba dictada por la anterior.
Esta fuga tiene una firma reconocible en el código y merece la pena aprender a verla, porque es la única de las cuatro que se puede al menos documentar.
// Una adaptacion a la granularidad de la libreria, escrita fuera de la frontera
function guardarParrafo(doc, texto) {
// Agrupamos en una sola transaccion porque escribir caracter a caracter
// genera mas operaciones de las que esta libreria maneja con holgura.
enUnaSolaTransaccion(doc, () => escribir(doc, texto));
}
// El comentario justifica una propiedad de la dependencia, pero el codigo
// vive en la aplicacion. Al cambiar de libreria nadie sabra por que existe,
// y se conservara por miedo o se borrara sin medir. Ambas son malas.
La segunda es de semántica de fusión. Tu producto hace promesas al usuario sobre lo que pasa cuando dos personas editan a la vez, y esas promesas son la semántica concreta de la librería asomando por la interfaz de usuario. No se pueden encapsular porque no son una implementación: son comportamiento observable, documentado, aprendido por los usuarios y a veces recogido en la ayuda del producto.
La tercera es el modelo de historia, y aquí la asimetría entre las tres opciones es estructural y no de grado. Loro y Automerge guardan el grafo completo del historial; Yjs no. Una interfaz que expone comparación entre versiones, restauración o autoría por fragmento solo puede implementarse sobre las primeras, y una interfaz que no las expone deja sin usar una capacidad por la que estás pagando en cada byte sincronizado. Elegir qué expone tu frontera es, de hecho, elegir a qué subconjunto de librerías te limitas.
La cuarta es el protocolo de red. La sincronización no es un detalle interno: define qué se transmite, cuándo, con qué resumen de estado y con qué formato en el cable. Cambiar de librería cambia el protocolo, y por tanto cambia también el servidor de sincronización, los adaptadores y cualquier integración de terceros que hable ese protocolo. Aquí es donde la diferencia de ecosistema entre 920 mil, 85 mil y 12 mil descargas semanales deja de ser una cifra de popularidad y se convierte en la cantidad de piezas ajenas que dejarían de encajar.
Se aísla: verbos y lectura
Acciones del dominio, estado plano para las vistas, un único punto de serialización y una suscripción a cambios con vocabulario propio.
Fuga: granularidad
La forma de tu modelo acaba dictada por el tamaño de operación en el que la librería es eficiente, y esa adaptación vive fuera de la frontera.
Fuga: promesas al usuario
Lo que pasa al editar a la vez es comportamiento observable que el usuario aprende. No es implementación y no se encapsula.
Fuga: historia y red
Guardar o no el grafo decide qué puede exponer tu interfaz, y el protocolo arrastra servidor, adaptadores e integraciones de terceros.
La estrategia de salida que sí funciona
La conclusión práctica es que el objetivo correcto no es poder cambiar de librería, que es caro y a menudo ilusorio, sino poder salir de ella conservando lo que importa. Son objetivos distintos y el segundo es alcanzable con una fracción del esfuerzo. Salir significa que en cualquier momento puedes producir, a partir de tus documentos, una representación semántica completa del estado actual en un formato que tú controlas y que no depende de ninguna librería: el texto, el árbol, los campos, las relaciones y los metadatos que tu producto considere parte del dato.
Escribe el exportador semántico el primer mes y ejecútalo en cada versión sobre un conjunto de documentos representativos, comprobando que el resultado se puede volver a importar y produce un estado equivalente. Ese guion cuesta poco, detecta regresiones de modelo antes de que lleguen a producción, sirve como formato de copia de seguridad, resuelve la exportación que tus usuarios pedirán tarde o temprano y, el día que haya que migrar, ya está probado con datos reales. Es la inversión con mejor relación entre esfuerzo y seguro de todo el nivel, y su valor no depende de que la migración llegue a ocurrir.
El exportador tiene una propiedad que lo distingue de una copia de seguridad y que es la razón de su valor: se comprueba con una ida y vuelta, de modo que su corrección es verificable de forma automática en cada versión.
// Exportacion semantica: tu vocabulario, sin rastro de la libreria
export function exportar(doc) {
return {
formato: 1, // version de TU formato, no de la suya
contenido: leerTextoPlano(doc),
campos: leerCamposDelDominio(doc),
relaciones: leerRelaciones(doc),
historia: HISTORIA_ES_DATO ? leerHistoriaEnTusTerminos(doc) : null,
};
}
// La comprobacion que convierte el exportador en un seguro de verdad
const ida = exportar(doc);
const vuelta = exportar(importar(ida));
assert(equivalentes(ida, vuelta)); // si falla, has perdido significado
Sobre la historia hay que tomar una decisión explícita y por adelantado, no descubrirla el día de la migración. Si el pasado es funcionalidad de tu producto, entonces forma parte de lo que hay que poder exportar y necesitas un formato propio para él: quién cambió qué, cuándo y en qué versión, expresado en tus términos y no en identificadores internos de la librería. Si el pasado no es funcionalidad, decláralo por escrito y acepta que una migración lo pierde. Lo que no es defendible es dejarlo sin decidir, porque entonces la decisión la toma el formato binario y la toma en el peor momento.
La razón última por la que estas migraciones son caras no es técnica sino de propiedad, y verla ordena todo el nivel hacia atrás. Una dependencia normal vive en tu servidor: si te equivocas, la cambias, redespliegas y nadie se entera, porque los datos estaban en un formato que tú controlabas y la dependencia solo los manipulaba. Una librería CRDT no funciona así, porque su formato es el almacenamiento, y ese almacenamiento está repartido en dispositivos que no controlas, con usuarios que abren la aplicación cuando quieren y que pueden pasar meses sin hacerlo. Elegir Yjs, Automerge o Loro no es elegir un módulo: es decidir en qué formato binario quedará escrito el trabajo de tus usuarios en sus propios discos, con la historia que ese formato decida guardar y con la identidad de operación que ese formato invente. Por eso el primer eje del nivel era el tamaño en disco y no la velocidad, por eso la fila estructural de la tabla —quién guarda el grafo completo y quién no— pesaba más que cualquier cifra de benchmark, y por eso la forma de tus datos mandaba sobre todos los ejes medibles. Todo apuntaba al mismo sitio: la elección de librería es una decisión de formato, y las decisiones de formato son las únicas de una arquitectura que no puedes revertir unilateralmente, porque revertirlas exige la cooperación de gente que no sabe que existe el problema. De ahí la postura que conviene adoptar y que es a la vez menos ansiosa y más rigurosa que buscar la opción perfecta. No intentes preservar la libertad de cambiar de librería, porque esa libertad no existe y perseguirla produce abstracciones que ocultan justo lo que necesitabas ver. Preserva en su lugar dos cosas concretas: la capacidad de expresar tus datos en un formato tuyo en cualquier momento, y una decisión escrita sobre si la historia forma parte de esos datos. Con esas dos piezas, equivocarte de librería es un proyecto acotado y desagradable. Sin ellas, equivocarte de librería es una condena, y ninguna comparación de benchmarks que hicieras al principio te habrá servido de nada.
- Concentra en un solo módulo todo el contacto con la librería y comprueba que ningún otro archivo de la aplicación la importa directamente.
- Escribe el exportador semántico completo y un importador que reconstruya un estado equivalente, y verifica la ida y vuelta con documentos reales.
- Añade ese guion a tu integración continua para que se ejecute en cada versión sobre un conjunto representativo de documentos.
- Decide por escrito si la historia forma parte de tus datos y, si lo es, define el formato propio en el que la exportarías.
- Busca en tu código las adaptaciones a la granularidad de la librería y anótalas, porque son la fuga que menos se ve y la que más duele.
- Redacta el plan de la ventana de migración: punto de corte por documento, convivencia de dos formatos y qué hacer con las operaciones que lleguen tarde.