Las cuatro estrategias y qué resuelve cada una
Versionar el documento y migrar al abrir, diseñar esquemas que no rompen, preservar los campos que no entiendes y tender un puente entre dos formas a la vez son cuatro capas complementarias.
Con el problema dimensionado y la propiedad que hay que garantizar ya enunciada como relación entre pares de versiones, toca revisar el catálogo de mecanismos disponibles. Son cuatro, y el primer aviso es que se presentan casi siempre como alternativas entre las que hay que elegir cuando en realidad son capas que se apilan y que resuelven partes distintas del problema. Versionar el documento y migrarlo al abrirlo resuelve los datos que ya están en disco. Diseñar el esquema para que no rompa nunca resuelve el futuro, si se adopta a tiempo. Preservar lo que no se entiende resuelve la dirección hacia adelante y es la única que impide la pérdida por viaje de ida y vuelta. Y el puente que traduce entre dos formas a la vez resuelve el caso en que dos versiones tienen que coexistir de verdad, escribiendo ambas, durante meses. Un sistema serio acaba usando las cuatro simultáneamente en niveles distintos de su pila, y saber cuál cubre qué hueco evita la frustración habitual de aplicar una y descubrir que el problema sigue ahí.
- Implementar una migración perezosa por documento y entender la exigencia de determinismo que impone.
- Formular el esquema como conjunto de identificadores permanentes y adoptar la depreciación en lugar del borrado.
- Diseñar la preservación de campos desconocidos y ver por qué no basta con guardarlos como un bloque opaco.
- Entender qué es una lente bidireccional, qué garantiza y en qué casos deja de ser invertible.
- Elegir la combinación de capas adecuada al tipo de cambio en lugar de buscar una estrategia única.
Versionar el documento y migrar al abrirlo
Es el mecanismo más conocido y el punto de partida razonable. Cada documento lleva escrito dentro el número de versión con el que fue producido, y el código mantiene una cadena de funciones de transformación que llevan de cada versión a la siguiente. Al abrir un documento se comprueba su número, se aplican en orden las transformaciones que falten y se trabaja ya con la forma actual. No hay ventana de mantenimiento, no hay proceso masivo y cada documento paga su coste la primera vez que alguien lo mira.
const migraciones = {
4: (d) => ({ ...d, prioridad: "normal", esquemaV: 5 }),
5: (d) => ({ ...d, etiquetas: d.etiquetas ?? [], esquemaV: 6 }),
};
function alAbrir(doc, objetivo) {
let actual = doc;
while (actual.esquemaV < objetivo) {
const paso = migraciones[actual.esquemaV];
if (!paso) throw new Error("falta un eslabon de la cadena");
actual = paso(actual);
}
return actual;
}
La cadena tiene tres exigencias que casi nunca se enuncian y que deciden si el mecanismo funciona o produce divergencia. La primera es que los eslabones son permanentes: no se pueden borrar los antiguos porque no sabes cuándo aparecerá un documento de la versión dos, y esa cadena crecerá indefinidamente durante toda la vida del producto. La segunda es que cada paso tiene que ser puro y determinista, sin reloj del sistema, sin identificadores aleatorios, sin orden dependiente del idioma del dispositivo y sin nada que dependa del entorno. La tercera se deriva de la segunda y es la que produce los peores accidentes: la misma migración se va a ejecutar de forma independiente en cada dispositivo, y si dos dispositivos producen resultados distintos, acabas de fabricar un conflicto sobre un dato que nadie había tocado.
Este es el fallo más frecuente del mecanismo y también el más difícil de atribuir cuando aparece. Una migración que rellena un campo nuevo con la fecha actual produce un valor distinto en cada dispositivo, y la capa de fusión ve dos escrituras concurrentes con valores diferentes sobre el mismo campo, exactamente igual que si dos personas lo hubieran editado a la vez. Lo mismo ocurre con un identificador generado al azar, con un orden que depende de la configuración regional, con cualquier recorrido de claves cuyo resultado no esté fijado y con cualquier valor derivado de la posición del dispositivo o de su zona horaria. La regla práctica es tajante y no admite excepciones cómodas: si una migración necesita un valor que no se puede deducir del propio documento, ese valor no puede generarse dentro de la migración. O se deriva de forma determinista de lo que ya hay en el documento, o el campo se deja explícitamente ausente y lo rellena la primera escritura real de una persona.
No romper nunca: el esquema como identificador permanente
La segunda estrategia no es un mecanismo sino una disciplina, y es con diferencia la más rentable porque es la única que actúa antes de que exista el problema. Consiste en tratar cada nombre de campo como un identificador permanente que, una vez publicado, no se renombra, no se reutiliza para otra cosa y no cambia de tipo jamás. Es exactamente la política que los formatos de serialización con números de campo llevan décadas imponiendo por construcción, y la razón por la que esos formatos sobreviven a décadas de evolución sin incidentes.
Cuando algo deja de ser útil no se borra: se depreca. Se deja de escribir, se sigue leyendo indefinidamente y se documenta que su nombre queda reservado para siempre. Cuando algo tiene que cambiar de forma o de significado, no se modifica: se añade al lado con un nombre nuevo y durante la ventana de compatibilidad se escriben los dos.
{
"id": "tarea-204",
"esquemaV": 7,
"duracion": 30,
"duracionSegundos": 1800,
"autor": "luis",
"creadoPor": { "tipo": "persona", "id": "luis" },
"_deprecados": ["duracion", "autor"]
}
Ese documento resulta redundante y algo feo, y esa fealdad es el precio explícito de que ninguna versión viva se rompa. Un cliente antiguo encuentra duracion y autor donde siempre estuvieron y funciona sin enterarse de nada. Un cliente moderno prefiere duracionSegundos y creadoPor. Cuando la telemetría confirme que ya no queda ninguna versión que lea los campos antiguos, se deja de escribirlos, aunque el código que sabe leerlos convenga conservarlo bastante más tiempo por si aparece un documento restaurado de una copia de seguridad.
Hay tres decisiones que cuestan una tarde al principio del proyecto y que resultan casi imposibles de introducir después, porque requieren cooperación de versiones que ya están publicadas. La primera es el campo de versión dentro del documento, que la lección anterior ya justificó. La segunda es un contenedor reservado para campos desconocidos, presente y vacío desde la versión uno, de modo que todas las versiones sepan que existe aunque ninguna lo use todavía. La tercera es una regla escrita y aplicada por revisión sobre qué se puede hacer con un nombre ya publicado. Ninguna de las tres aporta nada visible en la primera versión, y las tres son la diferencia entre un cambio de esquema rutinario y un incidente de pérdida de datos dos años más tarde.
Preservar lo que no entiendes
La tercera estrategia es la que resuelve la dirección hacia adelante y por tanto la que impide el borrado por viaje de ida y vuelta que describió la lección anterior. El principio es simple de enunciar: cuando el código encuentra en un documento algo que no reconoce, lo guarda tal cual, lo lleva consigo mientras trabaja y lo vuelve a emitir intacto al escribir. La versión antigua no entiende el campo nuevo, no lo muestra y no lo edita, pero tampoco lo destruye.
const conocidos = new Set(["id", "esquemaV", "titulo", "etiquetas"]);
function leer(bruto) {
const doc = { desconocidos: {} };
for (const [clave, valor] of Object.entries(bruto)) {
if (conocidos.has(clave)) doc[clave] = valor;
else doc.desconocidos[clave] = valor; // se conserva sin interpretar
}
return doc;
}
function escribir(doc) {
const { desconocidos, ...resto } = doc;
return { ...desconocidos, ...resto }; // lo desconocido vuelve al documento
}
La implementación de arriba es correcta para un documento tratado como un valor único, y es engañosamente insuficiente en cuanto hay concurrencia real. Si dos dispositivos con versiones distintas editan a la vez, y el contenedor de desconocidos se trata como un bloque opaco que se sobrescribe entero, la última escritura gana sobre todo el bloque y se pierden los cambios concurrentes que el otro dispositivo sí entendía. La preservación tiene que ser tan granular como el resto del documento: el contenedor no puede ser un valor, tiene que ser una estructura que se fusione clave a clave con las mismas reglas que aplica el modelo al resto de los campos.
flowchart TB A[version antigua lee el documento] --> B[separa lo conocido de lo desconocido] B --> C[edita solo lo que entiende] B --> D[conserva lo desconocido sin tocarlo] C --> E[escribe fusionando ambas partes] D --> E E --> F[el campo de la version nueva sobrevive al viaje] style D fill:#a6e3a1,color:#11111b style F fill:#a6e3a1,color:#11111b
El puente que habla las dos formas a la vez
La cuarta estrategia aparece cuando dos formas tienen que convivir escribiendo ambas durante un periodo largo, y no basta con que una tolere a la otra. La idea es escribir la relación entre dos versiones del esquema como una transformación bidireccional, con una dirección hacia adelante y otra hacia atrás, y componer esas transformaciones para llevar un documento entre dos versiones cualesquiera de la cadena. Es la propuesta que el proyecto Cambria formuló con precisión bajo el nombre de lentes, y su aportación conceptual es tratar la evolución del esquema como un objeto de primera clase, escrito, versionado y compuesto, en lugar de como un guion suelto.
const lente = {
adelante: (d) => ({ ...omitir(d, "autor"), creadoPor: { tipo: "persona", id: d.autor } }),
atras: (d) => ({ ...omitir(d, "creadoPor"), autor: d.creadoPor.id }),
};
// La propiedad que se persigue: ir y volver deja el documento equivalente
const ida = lente.adelante(viejo);
const vuelta = lente.atras(ida);
equivalente(viejo, vuelta); // deberia ser cierto para todo documento valido
Conviene ser honesto sobre los límites del enfoque, porque se presenta a veces como si resolviera el problema entero. La propiedad de ida y vuelta solo se cumple exactamente cuando la transformación no pierde información, y la mayoría de los cambios interesantes sí la pierden: pasar de un campo con tres valores posibles a uno con dos obliga a decidir qué se hace con el tercero al volver, y esa decisión no es reversible. Lo que ofrecen las lentes en esos casos no es una equivalencia perfecta sino una degradación declarada, escrita en un sitio, revisable y probada, en lugar de repartida por el código en forma de condicionales sobre la versión. Ese cambio de ubicación ya es una mejora considerable, y hay que tomarlo por lo que es y no por lo que no es.
Migración perezosa
Resuelve los datos en reposo. Exige eslabones permanentes, puros y deterministas, y no arregla nada del canal de sincronización.
Esquema que no rompe
Resuelve el futuro y solo funciona si se adopta antes de publicar. Es la estrategia más barata y la que más disciplina requiere.
Campos de reserva
Resuelve la dirección hacia adelante. Es la única que impide la pérdida por viaje de ida y vuelta, y debe fusionarse con granularidad fina.
Puente bidireccional
Resuelve la convivencia larga de dos formas activas. Da control explícito sobre lo que se pierde en cada dirección, no lo elimina.
La pregunta con la que casi todo el mundo llega a este punto es cuál de las cuatro elegir, y la pregunta está mal planteada de una forma que conviene desmontar porque conduce a decisiones caras. Cada estrategia actúa sobre un eje distinto y ninguna cubre los ejes de las demás. La migración perezosa actúa sobre el eje del tiempo dentro de un dispositivo: convierte lo que se escribió ayer en lo que se lee hoy. El esquema que no rompe actúa sobre el eje del futuro: es la única que reduce el número de cambios que van a necesitar cualquiera de las otras tres. La preservación de lo desconocido actúa sobre el eje del espacio entre dispositivos: es lo único que protege un dato al atravesar una versión que no lo comprende. Y el puente actúa sobre el eje de la convivencia activa, cuando dos formas no solo tienen que tolerarse sino escribirse a la vez durante meses. Poner una en el lugar de otra produce siempre el mismo desenlace: se implementa una migración perezosa impecable, se prueba con documentos antiguos, funciona, se publica, y semanas más tarde empiezan a llegar informes de campos que desaparecen, porque el problema nunca estuvo en los datos en reposo sino en el canal, y el canal necesitaba la tercera estrategia. El orden en que conviene adoptarlas es el inverso al de su popularidad. Primero la disciplina de no romper, que es gratis si se decide pronto y carísima si se decide tarde. Después la preservación de lo desconocido, que hay que tener publicada antes de necesitarla porque es la versión antigua quien la ejecuta y para entonces ya no la puedes cambiar. Después el versionado con migración perezosa, que se puede añadir en cualquier momento siempre que el campo de versión estuviera desde el principio. Y solo al final, cuando un cambio concreto lo justifique, el puente, que es el más costoso de construir y de mantener. Ahí está el desplazamiento que hay que llevarse de esta lección: la migración en local-first no es una operación que se ejecuta, es una propiedad que se sostiene, y sostenerla exige tener puestas de antemano piezas que no sirven para nada el día que se instalan y que resultan imposibles de instalar el día que hacen falta.
- Escribe la cadena de migración de tu formato y audítala buscando relojes, azar, orden dependiente del idioma o cualquier otra fuente de no determinismo.
- Ejecuta la misma migración en dos dispositivos con almacenes idénticos y compara los resultados byte a byte.
- Comprueba si tu versión publicada más antigua conserva los campos que no reconoce o los descarta al escribir.
- Toma un renombrado que quieras hacer y escríbelo como par de campos coexistentes con política de depreciación fechada.
- Implementa una lente bidireccional para ese mismo cambio y localiza el caso concreto donde la vuelta pierde información.
- Decide y anota, para cada uno de los cuatro ejes, qué estrategia lo cubre hoy en tu sistema y cuál queda descubierto.