wandres.dev
PERSISTENCIA · guardar y rehidratar

Migraciones: versionar el estado guardado y al usuario que vuelve

El estado persistido tiene un esquema aunque nadie lo haya escrito, y ese esquema cambia cada vez que alguien añade un campo, renombra una clave o parte una rebanada en dos. Esta lección trata la disciplina que impide que ese cambio rompa a los usuarios con datos antiguos: numerar el esquema con `version`, escribir funciones de transformación encadenadas en `migrate`, y validar el resultado antes de dejarlo entrar en el store. Explica por qué las migraciones deben ser puras y sin red, por qué hay que probarlas contra capturas reales de estados viejos y no contra objetos inventados, cuándo conviene descartar lo guardado en lugar de repararlo, y cuánto tiempo hay que conservar los pasos antiguos para no romper al usuario que vuelve después de un año.

⏱ 19 min

Hay un momento en la vida de toda aplicación con persistencia en el que alguien renombra un campo del store, la prueba en su máquina, la ve funcionar perfectamente y despliega. La prueba funcionó porque su navegador tenía el estado guardado por la misma versión que acababa de escribir. Los usuarios, en cambio, abren la aplicación nueva con el estado que dejó la vieja, y en su store entra un objeto con la clave antigua, sin la nueva, que el código de hoy leerá como indefinida. Ese es el bug característico de este nivel: no se reproduce en desarrollo, no aparece en las pruebas, no lo detecta el compilador porque los tipos describen lo que el código produce y no lo que el almacén contiene, y llega como un informe confuso de un usuario que dice que algo dejó de funcionar sin saber explicar qué. La migración es la disciplina que convierte ese cambio silencioso en un cambio declarado.

🎯 Al terminar esta lección sabrás
  • Reconocer el estado persistido como un esquema versionado y tratar sus cambios como cambios de contrato.
  • Configurar version y migrate en las dos herramientas y encadenar los pasos correctamente.
  • Escribir migraciones puras, probadas contra capturas reales y con salida de emergencia.
  • Decidir cuándo migrar y cuándo descartar lo guardado, y cuánto conservar los pasos antiguos.

El estado guardado tiene un esquema, lo hayas escrito o no

Cada vez que serializas una rebanada del store al almacén, estás publicando un formato. No hay archivo de esquema, no hay declaración, pero el contrato existe con la misma fuerza que el de una base de datos: hay unas claves con unos nombres, unos tipos y unos rangos, y hay un código que asumirá todo eso al leerlo. La diferencia con una base de datos es que ahí nadie puede modificar la tabla sin escribir una migración, y aquí cualquier persona del equipo puede cambiar el esquema sin darse cuenta, simplemente editando el objeto inicial de una rebanada.

Los cambios que rompen son más variados de lo que parece, y el primer paso de la disciplina es saber reconocerlos. Renombrar una clave rompe. Cambiar el tipo de un identificador de número a texto rompe. Anidar un campo suelto dentro de un objeto rompe. Cambiar la unidad de un valor —de minutos a segundos, de porcentaje a fracción— es el peor de todos, porque no produce ningún error: produce datos plausibles y equivocados. Añadir un campo nuevo con valor por defecto es el único que suele sobrevivir, y solo si vive en el primer nivel y la fusión lo respeta.

✏️

Renombrar

La clave vieja llega y la nueva no existe. El síntoma es un valor indefinido donde el código declara uno válido.

🔢

Cambiar el tipo

Un identificador que pasa de número a texto rompe toda comparación estricta y todo índice construido con él.

🪆

Reestructurar

Aplanar o anidar cambia la ruta de acceso. La fusión superficial conserva la forma vieja y el código busca en la nueva.

📏

Cambiar la unidad

El cambio más peligroso porque no falla: el valor sigue siendo un número, solo que significa otra cosa. Ningún tipo lo detecta.

version y migrate: la mecánica

Ambas herramientas resuelven esto igual: un número de versión que viaja junto a los datos guardados y una función que recibe lo guardado y la versión con la que se guardó, y devuelve algo con la forma que el código de hoy espera. Si la versión guardada coincide con la actual, la función no se llama. Si es menor, se llama. Si el almacén no tiene versión porque lo escribió una época anterior a esta disciplina, se trata como la versión más baja posible.

// Zustand: migrate recibe lo guardado y la version con la que se escribio.
persist(inicializador, {
  name: 'preferencias',
  version: 3,
  migrate: (guardado, version) => {
    let s = guardado as Record<string, unknown>
    if (version < 1) s = { ...s, idioma: 'es' }                       // campo nuevo
    if (version < 2) s = { ...s, densidad: s.compacto ? 'alta' : 'media' }
    if (version < 3) s = { ...s, duracionMs: Number(s.duracionSeg) * 1000 }
    return s
  },
})
// redux-persist: los pasos se declaran por numero y la libreria los encadena.
import { createMigrate } from 'redux-persist'

const migraciones = {
  1: (s) => ({ ...s, idioma: 'es' }),
  2: (s) => ({ ...s, densidad: s.compacto ? 'alta' : 'media' }),
  3: (s) => ({ ...s, duracionMs: Number(s.duracionSeg) * 1000 }),
}

const config = { key: 'raiz', version: 3, storage, migrate: createMigrate(migraciones) }

La forma encadenada es la única correcta y merece una explicación, porque la alternativa tentadora —un solo bloque condicional que convierte cualquier versión vieja directamente a la actual— parece más corta y es insostenible. Con la cadena, cada paso solo necesita conocer dos formas consecutivas, la anterior y la siguiente, y por tanto se escribe una vez y no vuelve a tocarse nunca. Con el bloque único, cada versión nueva obliga a revisar todas las ramas anteriores, y el número de combinaciones que hay que tener en la cabeza crece hasta que alguien se rinde y borra el soporte de las versiones antiguas.

flowchart LR
V0[guardado version 0] --> P1[paso 1 anade idioma]
P1 --> P2[paso 2 traduce compacto a densidad]
P2 --> P3[paso 3 convierte segundos a milisegundos]
P3 --> OK[forma actual validada]
V2[guardado version 2] --> P3
OK --> ST[entra en el store]
style OK fill:#a6e3a1,color:#11111b

Migraciones que no mienten

Una migración es una función que se ejecutará en el arranque de la aplicación, en el navegador de un usuario, sin nadie mirando y sin posibilidad de reintento manual. Esa situación impone tres restricciones que no son estilísticas. La primera es que debe ser pura y síncrona: nada de red, nada de peticiones al servidor para completar campos que faltan, nada de leer otras partes del almacén. Si la información necesaria para reparar el estado no está en el propio estado guardado, la respuesta correcta no es ir a buscarla, es descartar el dato y dejar que el flujo normal de la aplicación lo recupere.

La segunda es que debe ser total: tiene que producir un resultado válido para cualquier entrada, incluidas las que no esperabas, porque el almacén de un usuario puede contener basura escrita por una extensión, una escritura interrumpida a medias o el resultado de una migración anterior que falló. La tercera es que debe ser idempotente en la práctica: si por cualquier motivo un paso se ejecuta dos veces sobre el mismo dato, el resultado no debe empeorar. Duplicar una conversión de unidades es el ejemplo canónico de lo que ocurre cuando no se respeta.

⚠️
Validar después de migrar, y descartar sin remordimiento

La migración transforma, pero no garantiza. El único punto en el que puedes afirmar que lo guardado cumple el contrato es después de pasarlo por una validación de esquema real, con los tipos y los rangos comprobados en tiempo de ejecución. Si la validación falla, la decisión correcta casi siempre es descartar lo guardado y arrancar con el estado inicial: el usuario pierde sus preferencias, lo cual es molesto y reversible, en lugar de entrar en una aplicación con estado corrupto, lo cual es confuso e irreversible. Un estado persistido corrupto puede además volver a escribirse corrupto en el siguiente cambio, y entonces el problema ya no se cura solo.

// La migracion propone y el validador dispone.
const Esquema = z.object({
  idioma: z.enum(['es', 'en']),
  densidad: z.enum(['alta', 'media', 'baja']),
  duracionMs: z.number().int().nonnegative(),
})

const migrarSeguro = (guardado: unknown, version: number) => {
  try {
    const migrado = migrar(guardado, version)
    return Esquema.parse(migrado)         // lanza si la forma no cumple
  } catch {
    return undefined                      // descartar y usar el estado inicial
  }
}

El usuario que vuelve después de un año

Toda esta disciplina existe por una persona concreta: la que instaló la aplicación, la usó una temporada, la dejó y vuelve mucho después. Su navegador guarda un estado de la versión cuatro y tu código va por la once. Si conservaste los siete pasos intermedios, su estado se repara y no nota nada. Si alguien los borró porque parecían código muerto, su estado no se puede interpretar y, con suerte, se descarta.

Su caso tiene además una particularidad que rompe la intuición de quien nunca la ha vivido: los saltos de versión no son de uno en uno. La cadena tiene que soportar entradas en cualquier versión histórica, incluida la ausencia de versión, y tiene que producir un resultado válido para todas ellas. Por eso los pasos se escriben con condiciones de menor que y no de igualdad: una comparación por igualdad solo repara al usuario que estaba exactamente una versión atrás, que es precisamente el único que no necesitaba reparación.

ℹ️
Una versión por rebanada evita invalidaciones innecesarias

Cuando toda la aplicación comparte un único número, cualquier cambio de forma en cualquier rincón obliga a subirlo, y con él se ejecutan migraciones sobre rebanadas que no habían cambiado. Dar a cada dominio persistido su propia clave y su propia versión limita el radio del cambio: si las preferencias evolucionan siete veces y el carrito ninguna, el carrito nunca pasa por una migración. El coste es más entradas en el almacén y más configuraciones que mantener, que es un precio pequeño comparado con el de un número global que nadie se atreve a tocar.

Ese es el criterio para decidir cuánto tiempo se conservan los pasos antiguos, y la respuesta honesta es que más de lo que resulta cómodo. Un paso de migración es una decena de líneas que no se vuelven a tocar y cuya única obligación es seguir existiendo. Borrarlo ahorra una cantidad de mantenimiento indistinguible de cero y arriesga la experiencia de una cola de usuarios que no aparece en ninguna métrica porque, precisamente, no estaban usando el producto. Cuando llegue el momento de recortar, la retirada correcta no es eliminar el paso sino sustituirlo por un descarte explícito de las versiones anteriores a un umbral, para que el caso quede documentado y el estado se limpie en vez de interpretarse mal.

// Retirada documentada: por debajo del umbral no se interpreta, se descarta.
const VERSION_MINIMA_SOPORTADA = 5

const migrar = (guardado: unknown, version: number) => {
  if (version < VERSION_MINIMA_SOPORTADA) return undefined   // arranque limpio
  let s = guardado as Record<string, unknown>
  if (version < 6) s = { ...s, moneda: 'EUR' }
  if (version < 7) s = { ...s, importeCent: Math.round(Number(s.importe) * 100) }
  return s
}
💡
Prueba contra capturas reales, no contra objetos inventados

La prueba de una migración escrita a partir de lo que tú crees que había guardado no prueba nada, porque reproduce tu recuerdo y no el almacén. La técnica que funciona es capturar el contenido literal del almacén en cada versión que se despliega —una cadena de texto por versión, guardada como fixture en el repositorio junto a las migraciones— y ejecutar la cadena completa sobre cada una en la batería de pruebas. Cuesta un minuto por despliegue y convierte las migraciones en el único componente de la persistencia que sabes que funciona.

Versionar el estado guardado es admitir que tu programa tiene usuarios en el pasado, y que el pasado no se puede desplegar

La resistencia que casi todos los equipos oponen a escribir migraciones no viene de la dificultad técnica, que es mínima, sino de un supuesto tácito sobre la naturaleza del despliegue en la web: la idea de que publicar una versión nueva sustituye a la anterior en todas partes, de modo que solo existe un programa a la vez y el pasado desaparece con la caché. Ese supuesto es lo que hace que el desarrollo web se sienta más ligero que el de aplicaciones instaladas, y es falso en el instante exacto en que escribes el primer byte en el almacén del navegador. A partir de ahí tu aplicación se parte en dos artefactos con ciclos de vida independientes: el código, que sí se sustituye entero en cada carga, y los datos, que persisten distribuidos en miles de dispositivos, versionados de forma heterogénea, fuera de tu alcance y sin ninguna posibilidad de que ejecutes nada sobre ellos hasta que su dueño decida volver. Eso es exactamente la situación de un sistema distribuido con clientes de versión mixta, y explica por qué todas las reglas de esta lección son reformulaciones de reglas conocidas en ese dominio: la compatibilidad hacia atrás no es cortesía sino requisito, la evolución del esquema debe ser explícita y numerada, las transformaciones deben ser puras porque no hay quien las supervise, y siempre debe existir una vía de descarte porque ningún esquema previsto cubre todos los estados que el mundo real produce. La consecuencia práctica es un cambio de criterio al revisar código: cuando alguien renombra un campo de una rebanada persistida, no está renombrando una variable, está desplegando un cambio de esquema sobre una base de datos que no puede consultar, y esa acción merece la misma pregunta que merecería en cualquier otro sistema con datos ajenos: qué pasa con los que ya estaban.

⚔️ Rompe a un usuario del pasado y repáralo
  1. Captura el contenido literal del almacén de tu aplicación tal como está hoy y guárdalo como fixture con su número de versión.
  2. Haz un cambio de esquema de cada una de las cuatro clases: renombrar, cambiar tipo, reestructurar y cambiar unidad. Despliega mentalmente y anota el síntoma de cada uno.
  3. Escribe la cadena de migraciones que repara los cuatro casos, un paso por cambio, y comprueba que ningún paso conoce más de dos formas consecutivas.
  4. Añade validación de esquema después de migrar y provoca un fallo escribiendo basura a mano en el almacén. Verifica que se descarta en lugar de entrar.
  5. Escribe una prueba que ejecute la cadena completa sobre cada fixture guardado y afirme la forma final. Que falle si alguien borra un paso.
  6. Decide y documenta tu política de retirada: a partir de qué versión se descarta en lugar de migrar, y por qué ese umbral y no otro.