Compatibilidad en las dos direcciones
La versión vieja tiene que sobrevivir a datos escritos por la nueva y al revés, así que la compatibilidad deja de ser una propiedad de una versión y pasa a serlo de cada par que coexiste.
La lección anterior dejó el problema dimensionado y sin resolver: cuatro poblaciones de dispositivos escribiendo a la vez sobre un canal común, sin posibilidad de detenerlas ni de revertir nada. Esta lección aísla la propiedad concreta que hay que garantizar para que esa situación no destruya datos, y lo primero que hay que hacer es corregir el vocabulario, porque el término habitual induce a error. Casi todo el mundo dice compatibilidad hacia atrás y piensa en una sola dirección: que el código nuevo sepa leer lo viejo. Esa mitad es la fácil y es la única que hace falta en un servidor. La mitad difícil, la que decide si un sistema local-first pierde datos o no, es la contraria: que el código viejo, que ya no puedes tocar, sobreviva a datos escritos por una versión que no existía cuando se compiló. Y como ambas mitades tienen que cumplirse a la vez y sobre el mismo documento, la compatibilidad deja de ser una propiedad de una versión y pasa a ser una relación entre pares de versiones, con todo lo que eso implica para el diseño y para las pruebas.
- Separar con precisión las dos direcciones de compatibilidad y entender por qué el servidor solo necesita una.
- Identificar el viaje de ida y vuelta como el mecanismo concreto por el que un cliente antiguo destruye datos.
- Clasificar los cambios de esquema según sean seguros, seguros solo con condiciones, o directamente rupturistas.
- Reconocer que el cambio de significado con forma constante es el más peligroso de todos y por qué ningún validador lo detecta.
- Entender por qué una validación estricta, que en el servidor es una virtud, aquí es una fuente de pérdida de datos.
Dos direcciones que hay que garantizar a la vez
Conviene fijar los dos términos antes de usarlos, porque se confunden constantemente. Hay compatibilidad hacia atrás cuando el código nuevo lee correctamente datos escritos por el código viejo. Hay compatibilidad hacia adelante cuando el código viejo lee correctamente datos escritos por el código nuevo. La primera es un problema de lectura resuelto por quien escribe la versión nueva, con toda la información delante y todas las decisiones disponibles. La segunda es un problema resuelto por código que ya se escribió, que ya se publicó y que no sabía nada de lo que iba a venir, de modo que solo puede resolverse si la versión antigua se escribió desde el principio con una política explícita sobre lo que no entiende.
En un servidor la segunda dirección se necesita durante minutos, mientras el despliegue progresivo convive con instancias antiguas, y por eso se trata como un detalle operativo. En local-first se necesita durante toda la ventana de compatibilidad, que se mide en meses o en años, y sobre datos que además son los datos de trabajo de una persona real y no un registro reproducible desde otra fuente.
// Las dos direcciones, enunciadas como propiedades comprobables
const haciaAtras = (nuevo, datoViejo) => nuevo.leer(datoViejo) !== null;
const haciaAdelante = (viejo, datoNuevo) => viejo.leer(datoNuevo) !== null;
// Pero leer sin romperse no basta: lo que hay que garantizar es el ciclo
const sinPerdida = (viejo, datoNuevo) => {
const leido = viejo.leer(datoNuevo);
const reescrito = viejo.escribir(leido);
return equivalente(datoNuevo, reescrito); // esta es la propiedad de verdad
};
Esa tercera función es la que importa y la que casi nunca se prueba. Un cliente antiguo que no se rompe al leer sigue siendo perfectamente capaz de destruir información, y lo hace precisamente cuando funciona bien: lee, muestra, la persona edita otra cosa, y al guardar escribe únicamente los campos que su versión conoce.
El viaje de ida y vuelta que borra sin errores
Este es el mecanismo concreto de pérdida y merece verlo con números pequeños, porque es sorprendentemente fácil de reproducir y sorprendentemente difícil de detectar en producción. La versión seis añade un campo. Un documento pasa por un dispositivo con la versión cinco. Ese dispositivo lo lee, descarta lo que no reconoce, la persona cambia el título, y el documento vuelve al canal sin el campo nuevo. Para el resto de la red eso no es una omisión: es una escritura reciente y perfectamente válida que dice que el campo ya no está.
{
"estado_escrito_por_la_version_6": {
"id": "doc-91",
"esquemaV": 6,
"titulo": "Informe anual",
"etiquetas": ["contabilidad", "cierre"],
"revisadoPor": "ana@ejemplo.org"
},
"lo_que_devuelve_la_version_5_tras_editar_el_titulo": {
"id": "doc-91",
"esquemaV": 5,
"titulo": "Informe anual revisado",
"etiquetas": ["contabilidad", "cierre"]
}
}
El campo revisadoPor ha desaparecido y no hay ningún error en ningún registro. Nadie ha lanzado una excepción, ninguna validación ha fallado, la sincronización ha funcionado exactamente como se diseñó. La versión cinco hizo lo único que sabía hacer, que era escribir su propia forma completa. Y como su escritura es causalmente posterior, cualquier estrategia de resolución razonable la considerará la buena.
El patrón de investigación es tan repetido que conviene anticiparlo. Alguien informa de que un campo se le borra solo, de forma intermitente y sin patrón aparente. Se revisa la lógica de fusión, se revisan los relojes, se añaden trazas al canal y todo parece correcto, porque efectivamente lo es. La pista que resuelve el caso es siempre la misma y hay que buscarla explícitamente: comprobar si los documentos afectados pasaron por un dispositivo con una versión anterior del cliente entre la escritura del campo y su desaparición. Si la respuesta es que sí, no tienes un problema de sincronización, tienes un problema de compatibilidad hacia adelante, y ninguna cantidad de trabajo sobre la capa de fusión lo va a arreglar porque la capa de fusión está haciendo justo lo que le pediste.
flowchart LR N[version 6 escribe con el campo nuevo] --> C[canal de sincronizacion] C --> V[version 5 lee y descarta lo desconocido] V --> E[la persona edita el titulo] E --> W[version 5 escribe su forma completa] W --> C2[canal de sincronizacion] C2 --> R[el campo nuevo desaparece para todos] style V fill:#f9e2af,color:#11111b style R fill:#f38ba8,color:#11111b
Qué cambios son seguros y cuáles solo lo parecen
Con las dos direcciones y el ciclo completo como criterio, la clasificación de los cambios deja de ser una cuestión de opinión. Son seguros los cambios que un lector antiguo puede ignorar sin quedarse con un documento inválido y que un lector nuevo puede suplir sin inventarse nada. En la práctica esto reduce la lista de operaciones inofensivas a un conjunto muy corto: añadir un campo opcional con un valor por defecto bien definido, añadir un almacén o una colección nueva, añadir una variante a un conjunto cerrado siempre que los lectores antiguos tengan ya una regla para lo que no reconocen, y relajar una restricción de validación. Poco más.
Todo lo demás es ruptura, aunque el sistema de tipos no proteste. Renombrar es la trampa más común porque parece cosmético y no lo es: para un lector antiguo un renombrado es indistinguible de un borrado del campo viejo más la aparición de un campo desconocido, y en el viaje de vuelta lo tratará como tal. Cambiar el tipo de un campo obliga a decidir qué hace un lector que esperaba un número y recibe una cadena, y la respuesta honesta es que hará algo distinto en cada versión. Mover un campo dentro de un objeto anidado es un renombrado con más pasos. Convertir un campo opcional en obligatorio rompe hacia atrás porque los documentos antiguos no lo tienen. Y estrechar una validación rompe en las dos direcciones a la vez.
// Seguro: el lector antiguo lo ignora, el nuevo lo suple
agregar({ campo: "prioridad", opcional: true, defecto: "normal" });
// Ruptura disfrazada: para la version antigua esto es un borrado
renombrar({ de: "autor", a: "creadoPor" });
// Ruptura silenciosa: la forma no cambia, el significado si
cambiarUnidad({ campo: "duracion", antes: "minutos", ahora: "segundos" });
// Ruptura en las dos direcciones a la vez
estrechar({ campo: "titulo", antes: "cualquier cadena", ahora: "hasta 80 caracteres" });
Añadir opcional
Campo nuevo, opcional, con un valor por defecto que el lector nuevo puede aplicar sin consultar a nadie. Es la única operación realmente libre.
Añadir variante
Un valor nuevo en un conjunto cerrado solo es seguro si las versiones anteriores ya tenían una regla para lo desconocido. Si no la tenían, es ruptura.
Renombrar o mover
Parece cosmético y es equivalente a borrar más crear. Ningún lector antiguo puede deducir la correspondencia entre el nombre viejo y el nuevo.
Cambiar el significado
La forma se mantiene y ningún validador se queja, así que el error viaja intacto por toda la red hasta que alguien lo ve en pantalla.
Forma y significado son dos ejes, no uno
La cuarta tarjeta merece desarrollo propio porque es la categoría que produce los incidentes más caros y la que menos herramienta tiene. Todos los cambios anteriores alteran la forma del documento, y la forma es verificable: un esquema declarado, un validador y una prueba automatizada bastan para detectarlos. Existe una segunda dimensión, independiente de la primera, que es el significado atribuido a una forma que no cambia.
Un campo duracion que contenía minutos y ahora contiene segundos sigue siendo un número entero, sigue validando contra cualquier esquema declarado, sigue viajando por el canal sin incidencias y sigue mezclándose sin conflictos. Simplemente miente. Y miente en las dos direcciones a la vez: la versión nueva multiplica por sesenta lo que escribió la vieja y la vieja divide entre sesenta lo que escribió la nueva, sin que ninguna de las dos tenga forma de saberlo. Lo mismo ocurre con un campo de fecha que pasa de hora local a hora universal, con un identificador que cambia de esquema de generación manteniendo su tipo textual, con un porcentaje que pasa de escala cero a uno a escala cero a cien, o con un campo booleano cuya interpretación se invierte al renombrar el concepto que representa.
La única defensa real contra esta categoría no es técnica sino disciplinaria, y es breve de enunciar: cuando cambia el significado, cambia el nombre. Un campo duracionSegundos que convive durante toda la ventana de compatibilidad con el antiguo duracion cuesta unos bytes y convierte un fallo silencioso e indetectable en un cambio de forma perfectamente visible, que las herramientas sí saben manejar. Pagar ese precio es una de las decisiones con mejor relación entre coste y riesgo evitado de todo el diseño.
Hay un reflejo importado del servidor que aquí hace daño y conviene desactivarlo pronto. En una interfaz pública de red, rechazar todo lo que no se reconoce es una buena práctica de seguridad: reduce la superficie de ataque y evita interpretar entradas ambiguas. Aplicado al almacén local de una aplicación local-first, ese mismo reflejo se convierte en una negación de servicio contra tus propios usuarios, porque el emisor de lo desconocido no es un atacante, es una versión futura de tu propia aplicación. Un validador que rechaza campos no declarados deja inutilizable cualquier documento tocado por una versión más nueva, que es exactamente el escenario para el que existe la compatibilidad hacia adelante. La política correcta se enuncia en una línea y se decide una sola vez, al principio del proyecto: lo desconocido se conserva, no se rechaza ni se descarta. Esa frase es la que hace posible todo lo que viene en la lección siguiente.
Aquí está el cambio de marco que hay que hacer, y merece formularse con cuidado porque tiene consecuencias directas sobre cómo se prueba el sistema. La costumbre heredada del servidor invita a preguntar si la versión seis es compatible, en singular, como si compatible fuera un adjetivo que un artefacto posee o no posee por sí mismo. En local-first esa pregunta no tiene sentido porque no describe nada. La compatibilidad no está en la versión, está en la relación entre dos versiones, y por tanto lo que hay que evaluar no es una lista de versiones sino una matriz de pares. Si tienes seis versiones vivas, tienes treinta y seis pares de lectura y otros tantos de escritura, y cada celda es una afirmación distinta que puede ser cierta o falsa con independencia de las demás. La consecuencia inmediata es que el conjunto de tus pruebas está mal dimensionado por un factor cuadrático, porque casi con seguridad prueba una diagonal cuando el sistema vive en una superficie. La consecuencia más profunda llega al preguntarse qué es entonces el esquema real de tu aplicación, y la respuesta es incómoda: el esquema efectivo no es el que escribiste en la versión más reciente, sino la intersección de lo que todas las versiones vivas saben interpretar sin destruir nada. Ese esquema efectivo no está escrito en ningún fichero de tu repositorio, no aparece en ninguna definición de tipos y no lo conoce ningún miembro del equipo, pero es el que gobierna de verdad lo que le pasa a los datos de la gente. Retrocede cada vez que publicas algo y solo avanza cuando muere la última versión que lo sostenía hacia abajo, es decir, avanza por defunción y no por decisión. Entender esto reordena el trabajo por completo: la unidad de diseño deja de ser el esquema y pasa a ser la ventana de versiones, la unidad de prueba deja de ser el documento y pasa a ser el par emisor receptor, y la pregunta que abre cualquier propuesta de cambio deja de ser si el cambio es correcto y pasa a ser con qué versiones vivas sigue siendo correcto. Los sistemas que sobreviven muchos años a esta clase de presión no son los que migran mejor, sino los que han reducido deliberadamente el número de celdas de esa matriz que pueden estar en falso.
- Escribe la función de ida y vuelta para tu formato y compruébala con un documento escrito por la versión siguiente a la actual.
- Recorre los últimos diez cambios de esquema de tu proyecto y clasifícalos como seguros, condicionales o rupturistas según el criterio de esta lección.
- Busca en tu modelo de datos algún campo cuyo significado haya cambiado sin cambiar de nombre y calcula cuántos documentos pudo afectar.
- Comprueba qué hace hoy tu validador con un campo desconocido y decide, por escrito, cuál debe ser la política.
- Genera automáticamente la matriz de pares de versiones vivas y marca cuántas celdas tienes cubiertas por pruebas.
- Elige un campo que planees renombrar y estima el coste real de mantener los dos nombres durante toda la ventana de compatibilidad.