Cómo funciona el versionado de Room

Room guarda el número de versión de la base de datos en el archivo SQLite. Cuando la app arranca, compara la versión que espera el código con la versión que tiene el archivo en disco.

  • Si son iguales: abre la base normalmente.
  • Si la versión del código es mayor: busca una migración para recorrer el camino de la versión vieja a la nueva.
  • Si no encuentra la migración y no hay fallback configurado: lanza IllegalStateException y la app crashea.
@Database(
    entities = [ProductoEntity::class, PedidoEntity::class],
    version = 3,  // ← este número es lo que Room compara con el archivo en disco
    exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun productoDao(): ProductoDao
    abstract fun pedidoDao(): PedidoDao
}

Una vez publicada una versión, su número es permanenteSi publicaste la versión 2 en producción y después te arrepentís de un cambio, no podés "volver" al schema anterior sin una nueva migración. La versión solo sube. Nunca bajes el número de versión en una app publicada.

Cuándo se necesita una migración — y cuándo no

No todo cambio en una Entity requiere migración. Room detecta si el schema cambió comparando el hash del schema actual con el que tiene almacenado.

# Cambios que SÍ requieren migración (cambian el schema de SQLite):
# ✓ Agregar una columna a una tabla existente
# ✓ Eliminar una columna
# ✓ Cambiar el tipo de una columna
# ✓ Renombrar una columna o tabla
# ✓ Agregar o eliminar una tabla completa
# ✓ Agregar o eliminar un índice
# ✓ Cambiar constraints (NOT NULL, UNIQUE, DEFAULT)

# Cambios que NO requieren migración (no cambian el schema):
# ✗ Cambiar el nombre de la clase Entity en Kotlin (si @Entity(tableName) no cambia)
# ✗ Agregar métodos al DAO
# ✗ Cambiar queries SQL en el DAO
# ✗ Agregar nuevas consultas
# ✗ Cambiar la lógica en el repositorio

Room te avisa en tiempo de compilaciónSi cambiás el schema y olvidás incrementar el version, Room falla el build con un error claro: "Room cannot verify the data integrity. Looks like you've changed schema but forgot to update the version number." Es uno de los pocos casos donde el compilador te salva de un desastre en producción.

La clase Migration — la estructura básica

Una migración es una clase que extiende Migration y recibe la versión de origen y destino. Dentro de migrate() ejecutás el SQL necesario para transformar el schema:

val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // SQL que transforma el schema de la versión 1 a la 2
        database.execSQL("ALTER TABLE productos ADD COLUMN descripcion TEXT")
    }
}

// Registrar las migraciones al construir la base de datos:
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {

    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
        return Room.databaseBuilder(
            context,
            AppDatabase::class.java,
            "app_database"
        )
        .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
        .build()
    }
}

Las migraciones se aplican en cadena automáticamente. Si un usuario saltó de la versión 1 directo a la 4, Room aplica 1→2, 2→3 y 3→4 en secuencia.

El caso más común: agregar una columna

// Antes — versión 1:
@Entity(tableName = "productos")
data class ProductoEntity(
    @PrimaryKey val id: Int,
    val nombre: String,
    val precio: Double
)

// Después — versión 2:
@Entity(tableName = "productos")
data class ProductoEntity(
    @PrimaryKey val id: Int,
    val nombre: String,
    val precio: Double,
    val descripcion: String?,      // nueva columna nullable
    val stock: Int = 0             // nueva columna con default
)

// Migración 1 → 2:
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // Columna nullable — no necesita DEFAULT en SQLite
        database.execSQL(
            "ALTER TABLE productos ADD COLUMN descripcion TEXT"
        )
        // Columna con valor default — el DEFAULT va en el SQL, no en Kotlin
        database.execSQL(
            "ALTER TABLE productos ADD COLUMN stock INTEGER NOT NULL DEFAULT 0"
        )
    }
}

El DEFAULT del SQL y el default de Kotlin son independientesEl = 0 en Kotlin le dice a Room qué valor usar cuando vos no especificás el campo al crear un objeto. El DEFAULT 0 en el SQL le dice a SQLite qué valor poner en las filas existentes durante la migración. Necesitás los dos si querés consistencia completa.

Renombrar una tabla o columna

SQLite no soporta ALTER TABLE RENAME COLUMN en versiones viejas (antes de SQLite 3.25.0, Android API 29). El patrón correcto para renombrar es crear la tabla nueva, copiar los datos y eliminar la vieja:

// Renombrar la tabla "productos" a "items" — versión 2 → 3
val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // 1. Crear la tabla nueva con el nombre correcto
        database.execSQL("""
            CREATE TABLE IF NOT EXISTS items (
                id INTEGER NOT NULL PRIMARY KEY,
                nombre TEXT NOT NULL,
                precio REAL NOT NULL,
                descripcion TEXT,
                stock INTEGER NOT NULL DEFAULT 0
            )
        """.trimIndent())

        // 2. Copiar todos los datos
        database.execSQL("""
            INSERT INTO items (id, nombre, precio, descripcion, stock)
            SELECT id, nombre, precio, descripcion, stock FROM productos
        """.trimIndent())

        // 3. Eliminar la tabla vieja
        database.execSQL("DROP TABLE productos")
    }
}

// Renombrar una columna en API 29+ (SQLite 3.25+):
val MIGRATION_3_4 = object : Migration(3, 4) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL(
            "ALTER TABLE items RENAME COLUMN nombre TO titulo"
        )
    }
}

// Para compatibilidad con API < 29, usar el patrón
// crear-copiar-eliminar también para columnas.

El caso difícil: columna NOT NULL sin valor default

SQLite no permite agregar una columna NOT NULL sin un valor DEFAULT a una tabla que ya tiene filas. Si necesitás esto — por ejemplo, una columna obligatoria que no tiene un valor genérico sensato — tenés que usar el patrón completo:

// Queremos agregar: val categoria: String (NOT NULL, sin default)
// No podemos hacer simplemente:
// ALTER TABLE productos ADD COLUMN categoria TEXT NOT NULL  ← falla si hay filas

val MIGRATION_4_5 = object : Migration(4, 5) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // 1. Crear tabla temporal con el schema nuevo
        database.execSQL("""
            CREATE TABLE productos_new (
                id INTEGER NOT NULL PRIMARY KEY,
                nombre TEXT NOT NULL,
                precio REAL NOT NULL,
                categoria TEXT NOT NULL DEFAULT 'sin_categoria'
            )
        """.trimIndent())

        // 2. Copiar datos — las filas existentes reciben el DEFAULT
        database.execSQL("""
            INSERT INTO productos_new (id, nombre, precio, categoria)
            SELECT id, nombre, precio, 'sin_categoria' FROM productos
        """.trimIndent())

        // 3. Eliminar la tabla vieja
        database.execSQL("DROP TABLE productos")

        // 4. Renombrar la nueva
        database.execSQL("ALTER TABLE productos_new RENAME TO productos")
    }
}

Verificá que el SQL coincide exactamente con lo que Room esperaEl schema que describís en el SQL de la migración debe coincidir byte a byte con lo que Room generaría para tu Entity. Si hay una diferencia — un espacio, un DEFAULT que Room no incluye, un índice que olvidaste — Room fallará la verificación del schema post-migración. La forma de estar seguro es exportar el schema (siguiente sección) y comparar.

Migraciones encadenadas — usuarios que se saltan versiones

Un usuario puede no haber actualizado la app en meses. Si tenés la versión 5 en producción y ese usuario tenía la versión 1, Room aplica 1→2, 2→3, 3→4, 4→5 en secuencia. Tenés que tener todas las migraciones disponibles:

Room.databaseBuilder(context, AppDatabase::class.java, "app_database")
    .addMigrations(
        MIGRATION_1_2,
        MIGRATION_2_3,
        MIGRATION_3_4,
        MIGRATION_4_5
    )
    .build()

// Si querés agregar un "atajo" para saltos grandes
// (ej: usuarios muy viejos en versión 1 van directo a versión 5):
val MIGRATION_1_5 = object : Migration(1, 5) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // Schema completo de la versión 5, aplicado de una vez
        // Más eficiente que aplicar 4 migraciones en cadena
        // Útil cuando las versiones intermedias son muy viejas
    }
}

Room.databaseBuilder(...)
    .addMigrations(
        MIGRATION_1_2,
        MIGRATION_2_3,
        MIGRATION_3_4,
        MIGRATION_4_5,
        MIGRATION_1_5  // Room elige el camino más corto disponible
    )
    .build()

Migración destructiva — cuándo aceptar perder los datos

A veces los datos en la base local son simplemente caché — si se pierden, la app los vuelve a descargar del servidor. En esos casos, una migración destructiva (borrar y recrear la base) es completamente válida y mucho más simple que mantener migraciones SQL.

// Opción 1: fallbackToDestructiveMigration — si no hay migración para esta versión,
// borrar y recrear la base completa
Room.databaseBuilder(context, AppDatabase::class.java, "app_database")
    .fallbackToDestructiveMigration()
    .build()

// Opción 2: fallbackToDestructiveMigrationFrom — solo para versiones específicas
// "Si el usuario viene de la versión 1 o 2, borrá todo. Para el resto, usá migraciones."
Room.databaseBuilder(context, AppDatabase::class.java, "app_database")
    .fallbackToDestructiveMigrationFrom(1, 2)
    .addMigrations(MIGRATION_3_4, MIGRATION_4_5)
    .build()

// Opción 3: fallbackToDestructiveMigrationOnDowngrade — solo en downgrades
// (cuando el usuario instala una versión más vieja de la app)
Room.databaseBuilder(context, AppDatabase::class.java, "app_database")
    .fallbackToDestructiveMigrationOnDowngrade()
    .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
    .build()

No uses fallbackToDestructiveMigration si los datos son del usuarioSi la base tiene datos que el usuario escribió — notas, historial, configuración — y no hay copia en un servidor, una migración destructiva borra datos que no se pueden recuperar. Para esos casos, las migraciones SQL son obligatorias por más trabajo que impliquen.

Schema export — el archivo que salva las migraciones

Room puede exportar el schema de cada versión a un archivo JSON. Este archivo te muestra exactamente qué SQL espera Room para cada tabla, columna e índice. Es la referencia que tenés que usar para escribir las migraciones correctamente.

// Habilitar el export del schema:
@Database(
    entities = [ProductoEntity::class],
    version = 3,
    exportSchema = true  // ← esto
)
abstract class AppDatabase : RoomDatabase()

// Configurar la carpeta de destino en build.gradle:
android {
    defaultConfig {
        // Los schemas se guardan en app/schemas/
        ksp {
            arg("room.schemaLocation", "$projectDir/schemas")
        }
    }
}
# Después del build, encontrás los schemas en:
app/schemas/
└── ar.pensa.miapp.AppDatabase/
    ├── 1.json    ← schema de la versión 1
    ├── 2.json    ← schema de la versión 2
    └── 3.json    ← schema de la versión 3

# El JSON de la versión 2 se ve así (simplificado):
# {
#   "formatVersion": 1,
#   "database": {
#     "version": 2,
#     "entities": [{
#       "tableName": "productos",
#       "createSql": "CREATE TABLE IF NOT EXISTS `productos` (
#                     `id` INTEGER NOT NULL,
#                     `nombre` TEXT NOT NULL,
#                     `precio` REAL NOT NULL,
#                     `descripcion` TEXT,
#                     `stock` INTEGER NOT NULL DEFAULT 0,
#                     PRIMARY KEY(`id`))",
#       "fields": [...]
#     }]
#   }
# }

# El "createSql" es EXACTAMENTE el SQL que tenés que replicar
# en la migración cuando usás el patrón crear-copiar-eliminar

Commiteá los schemas al repositorioLos archivos JSON de schema deben estar en el control de versiones. Así podés ver exactamente qué cambió entre versiones, y los tests de migración los usan para verificar que la migración es correcta.

Testing de migraciones — antes de que lleguen a producción

El test de migración más valioso es el que verifica que la base en la versión vieja puede llegar a la versión nueva sin perder datos. Room incluye soporte de testing específico para esto:

// build.gradle (app)
androidTestImplementation("androidx.room:room-testing:2.6.1")

// Test de migración:
@RunWith(AndroidJUnit4::class)
class MigracionesTest {

    // MigrationTestHelper abre la base en la versión vieja
    // y aplica la migración para verificar el resultado
    @get:Rule
    val helper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        AppDatabase::class.java
    )

    @Test
    fun migration_1_a_2_agrega_columnas() {
        // 1. Crear la base en la versión 1 con datos reales
        helper.createDatabase("test_db", 1).use { db ->
            // Insertar datos de prueba en el schema de la versión 1
            db.execSQL(
                "INSERT INTO productos (id, nombre, precio) VALUES (1, 'Producto A', 99.9)"
            )
        }

        // 2. Aplicar la migración 1→2 y verificar que el schema es correcto
        val db = helper.runMigrationsAndValidate(
            "test_db",
            2,
            true,  // validateDroppedTables
            MIGRATION_1_2
        )

        // 3. Verificar que los datos originales siguen ahí
        val cursor = db.query("SELECT * FROM productos WHERE id = 1")
        assertThat(cursor.moveToFirst()).isTrue()

        // 4. Verificar que las nuevas columnas existen con los valores correctos
        val indexDescripcion = cursor.getColumnIndex("descripcion")
        val indexStock = cursor.getColumnIndex("stock")
        assertThat(indexDescripcion).isNotEqualTo(-1)
        assertThat(indexStock).isNotEqualTo(-1)
        assertThat(cursor.isNull(indexDescripcion)).isTrue()  // nullable → null
        assertThat(cursor.getInt(indexStock)).isEqualTo(0)    // default → 0

        cursor.close()
        db.close()
    }

    @Test
    fun migration_completa_1_a_5_preserva_datos() {
        // Testear el camino completo para usuarios con versiones muy viejas
        helper.createDatabase("test_db", 1).use { db ->
            db.execSQL("INSERT INTO productos (id, nombre, precio) VALUES (42, 'Test', 10.0)")
        }

        val db = helper.runMigrationsAndValidate(
            "test_db", 5, true,
            MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5
        )

        // El ítem con id=42 debe seguir existiendo después de 4 migraciones
        val cursor = db.query("SELECT id FROM productos WHERE id = 42")
        assertThat(cursor.count).isEqualTo(1)
        cursor.close()
        db.close()
    }
}

Estos tests son instrumentados — corren en dispositivo o emuladorMigrationTestHelper necesita un contexto Android real para crear el archivo SQLite. Tienen que estar en androidTest/, no en test/, y se corren con ./gradlew connectedAndroidTest. Son más lentos que los tests unitarios pero son los únicos que verifican la migración real.

Checklist antes de publicar un cambio de schema

# ── ANTES DE ESCRIBIR LA MIGRACIÓN ──────────────────────────
# ✓ exportSchema = true en @Database
# ✓ room.schemaLocation configurado en build.gradle
# ✓ Schema de la versión anterior commiteado en el repo
#   (lo necesitás para escribir la migración y para los tests)

# ── AL ESCRIBIR LA MIGRACIÓN ─────────────────────────────────
# ✓ Incrementar version en @Database
# ✓ Crear el objeto Migration(oldVersion, newVersion)
# ✓ Verificar el SQL contra el schema JSON exportado
#   (el createSql del JSON es exactamente lo que Room espera)
# ✓ Para renombrar: usar el patrón crear-copiar-eliminar
# ✓ Para columnas NOT NULL sin default: ídem

# ── AL REGISTRAR LA MIGRACIÓN ────────────────────────────────
# ✓ Agregar la migración a .addMigrations() en el DatabaseModule
# ✓ Mantener TODAS las migraciones anteriores (nunca eliminar)
# ✓ Considerar migración de atajo para versiones muy viejas

# ── TESTING ──────────────────────────────────────────────────
# ✓ Test que crea la base en la versión antigua con datos
# ✓ Test que aplica la migración y valida el schema
# ✓ Test que verifica que los datos originales siguen presentes
# ✓ Test del camino completo para usuarios que se saltaron versiones
# ✓ Correr los tests en un emulador con API mínima de la app

# ── ANTES DE PUBLICAR ────────────────────────────────────────
# ✓ Probar manualmente: instalar la versión anterior → actualizar → verificar datos
# ✓ Verificar en el track interno de Play Store antes de ir a producción
# ✓ Commiteado el nuevo schema JSON en el repo