wandres.dev
PERSISTENCIA ALTERNATIVA · Core Data, archivos, Keychain

Migraciones y versiones: cambiar sin perder datos

Anatomia de un cambio de esquema, esquemas versionados y etapas de migracion ligeras y personalizadas, el orden en que se aplican al saltar varias versiones, y como probar la migracion contra almacenes reales antes de publicar.

⏱ 19 min

El código de tu app se reemplaza entero en cada actualización; los datos de tus usuarios, jamás. Esa asimetría es la razón de que las migraciones sean el problema más ingrato de la persistencia: se ejecutan una sola vez por dispositivo, sobre datos que no puedes inspeccionar de antemano, sin posibilidad de deshacer y con el usuario esperando en la pantalla de arranque. Y sin embargo son perfectamente domesticables si se tratan como lo que son: código de producción que merece pruebas.

🎯 Al terminar esta lección sabrás
  • Clasificar un cambio de esquema como ligero, personalizado o estructural antes de escribirlo.
  • Declarar esquemas versionados y componer un plan con etapas ordenadas.
  • Anticipar el comportamiento al saltar varias versiones de golpe.
  • Construir un corpus de almacenes reales y probar la migración de forma automatizada.

Anatomía de un cambio de esquema

Un cambio es ligero cuando el motor puede deducir la correspondencia sin ambigüedad: añadir una propiedad con valor por defecto, añadir una opcional, añadir una entidad nueva, relajar una restricción. El almacén se reescribe automáticamente al abrirlo y tu código no interviene.

Un cambio es personalizado cuando la correspondencia existe pero requiere una decisión que el motor no puede tomar: renombrar una propiedad, cambiar el tipo de un atributo, derivar el valor de un campo nuevo a partir de los antiguos. Aquí declaras la intención — este campo de aquella versión es este campo de esta — o ejecutas código antes y después de la transformación.

Y un cambio es estructural cuando la propia forma del grafo cambia: partir una entidad en dos, fusionar dos en una, invertir la cardinalidad de una relación. Estos son los casos donde conviene bajar a la capa de Core Data y usar sus modelos de correspondencia, porque la transformación necesita ver el objeto de origen y el de destino a la vez.

🌱

Ligero

Propiedad con valor por defecto, opcional nueva, entidad nueva. Automático.

🔧

Personalizado

Renombrar, cambiar tipo, derivar valores. Declaras la correspondencia o escribes código.

🏛️

Estructural

Partir, fusionar, invertir relaciones. Capa inferior y modelo de correspondencia.

🧪

Verificable

Toda migración se prueba contra almacenes reales antes de publicarse. Sin excepción.

Esquemas versionados y etapas

La disciplina que hace manejable todo lo anterior es congelar cada versión del modelo como un tipo propio y no volver a tocarla nunca. Una versión publicada es historia: existe en dispositivos ajenos y modificarla invalida las etapas que parten de ella.

import SwiftData

enum EsquemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] { [Nota.self] }

    @Model final class Nota {
        var titulo: String = ""
        var cuerpo: String = ""
    }
}

enum EsquemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] { [Nota.self] }

    @Model final class Nota {
        var titulo: String = ""
        var cuerpo: String = ""
        var etiquetas: [String] = []
        var creada: Date = Date.now
    }
}

enum PlanDeMigracion: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [EsquemaV1.self, EsquemaV2.self]
    }

    static var stages: [MigrationStage] {
        [deV1aV2]
    }

    static let deV1aV2 = MigrationStage.custom(
        fromVersion: EsquemaV1.self,
        toVersion: EsquemaV2.self,
        willMigrate: nil,
        didMigrate: { contexto in
            let notas = try contexto.fetch(FetchDescriptor<EsquemaV2.Nota>())
            for nota in notas where nota.etiquetas.isEmpty {
                nota.etiquetas = ["sin clasificar"]
            }
            try contexto.save()
        }
    )
}

let contenedor = try ModelContainer(
    for: EsquemaV2.Nota.self,
    migrationPlan: PlanDeMigracion.self
)

El plan se aplica en cadena: un dispositivo que venía de la primera versión y salta directo a la cuarta ejecuta las tres etapas en orden, una tras otra, en la misma apertura. Eso significa que cada etapa debe funcionar sobre el resultado exacto de la anterior, y que no puedes asumir que el usuario venía de la versión inmediatamente previa. Los usuarios que no abren la app en un año son los que ejercitan los caminos que nadie probó.

flowchart LR
A[Almacen en version 1] --> B[Etapa 1 a 2]
B --> C[Etapa 2 a 3]
C --> D[Etapa 3 a 4]
D --> E[Almacen en version 4]
F[Almacen en version 3] --> D
style E fill:#a6e3a1,color:#11111b
⚠️
La etapa posterior se ejecuta con el modelo nuevo

El bloque que corre después de la transformación consulta con los tipos de la versión de destino, no la de origen. Si necesitas leer un valor que desaparece en la versión nueva, tienes que capturarlo en el bloque previo y llevarlo contigo. Escribir esa lectura en el bloque equivocado produce el fallo más común de todos: una migración que se completa sin errores y deja los datos vacíos.

Probar la migración antes de publicar

Una migración sin pruebas es una apuesta sobre datos ajenos. La práctica que la convierte en ingeniería es mantener un corpus: un directorio con almacenes de cada versión publicada, generados con datos representativos — conjuntos vacíos, conjuntos grandes, valores límite, caracteres no latinos, relaciones incompletas — y versionados junto al código.

import Testing

@Test func migracionDesdeV1ConservaLosDatos() throws {
    let origen = Bundle.module.url(forResource: "corpus-v1", withExtension: "store")!
    let trabajo = URL.temporaryDirectory.appending(path: "prueba-\(UUID()).store")
    try FileManager.default.copyItem(at: origen, to: trabajo)

    let contenedor = try ModelContainer(
        for: EsquemaV2.Nota.self,
        migrationPlan: PlanDeMigracion.self,
        configurations: ModelConfiguration(url: trabajo)
    )
    let contexto = ModelContext(contenedor)
    let notas = try contexto.fetch(FetchDescriptor<EsquemaV2.Nota>())

    #expect(notas.count == 250)
    #expect(notas.allSatisfy { !$0.etiquetas.isEmpty })
    #expect(notas.allSatisfy { !$0.titulo.isEmpty })
}

Cada prueba trabaja sobre una copia, nunca sobre el original del corpus, porque una migración es destructiva por definición. Y las aserciones deben cubrir tres cosas distintas: que el conteo se conserva, que los campos derivados tienen el valor esperado, y que ningún dato previo se ha perdido por el camino. Un conteo correcto con campos vacíos es un fallo silencioso.

Los datos del usuario son el único estado que no controlas

Hay una diferencia de naturaleza entre desplegar código y desplegar una migración que conviene interiorizar antes de necesitarla. Un error en el código se corrige publicando la siguiente versión: el estado defectuoso desaparece porque el código se reemplaza entero. Un error en una migración es irreversible en el sentido fuerte del término, porque ya transformó los datos, la información original ya no existe y la siguiente versión de tu app no puede reconstruir lo que nadie guardó. Esta propiedad coloca a las migraciones en la misma categoría que las operaciones de un sistema distribuido sobre almacenamiento primario, y el conjunto de prácticas que las hace seguras es el mismo: idempotencia siempre que sea posible, una copia del almacén antes de transformar cuando el volumen lo permita, despliegue escalonado a un porcentaje pequeño de usuarios antes de abrir el grifo, y telemetría que reporte el resultado de la migración para que un fallo se detecte en horas y no en las reseñas de la tienda. Nada de esto es exagerado para una app que guarda meses de trabajo de alguien. La medida de madurez de un equipo iOS no es la elegancia de su capa de datos: es si puede responder, con evidencia, qué le pasará al almacén del usuario que lleva dos años sin actualizar cuando por fin pulse el botón.

⚔️ Construye tu red de seguridad de migraciones
  1. Congela la versión actual de tu modelo como un esquema versionado y no vuelvas a modificarlo.
  2. Introduce un cambio personalizado — un renombrado — y escribe la etapa correspondiente.
  3. Genera almacenes de corpus con cero, mil y cien mil registros, y guárdalos junto a las pruebas.
  4. Escribe la prueba automatizada que migra sobre una copia y verifica conteo, contenido y campos derivados.
  5. Simula un salto de dos versiones y confirma que ambas etapas se ejecutan en orden sobre el mismo almacén.