Saltar a contenido

MOB-MIGRATION-001: Migracion de Esquema de Base de Datos Local

Identificador: TECH-MOB-MIGRATION-001 Version: 1.0.0 Fecha: 2025-12-09 Autor: ArchitectureDrone (MTS-DRN-ARC-001) Refs: MOB-IOS-001, MOB-AND-001, DATA-MODEL-001 Estado: Aprobado (DV2-P2)


1. Introduccion

1.1. Proposito

Este documento especifica el sistema de migraciones de base de datos local para MedTime, cubriendo:

  • Estrategia de versionamiento de esquema
  • Migraciones seguras con datos cifrados
  • Rollback y recovery
  • Testing de migraciones

1.2. Contexto

IMPORTANTE - MIGRACIONES CON DATOS CIFRADOS:
+------------------------------------------------------------------+
|  Los datos PHI estan cifrados E2E en la DB local.                |
|  Las migraciones DEBEN:                                          |
|                                                                   |
|  1. NUNCA descifrar datos durante migracion                      |
|  2. Migrar estructura sin tocar contenido cifrado                |
|  3. Actualizar version de cifrado si cambia el esquema           |
|  4. Preservar integridad de blobs en todo momento                |
+------------------------------------------------------------------+

2. Arquitectura de Migraciones

2.1. Versionamiento de Esquema

flowchart LR
    subgraph Versions["Versiones de Esquema"]
        V1["v1: Initial<br/>Medicamentos, Horarios"]
        V2["v2: Recetas<br/>+prescriptions table"]
        V3["v3: Dependientes<br/>+dependents, +caregivers"]
        V4["v4: Analytics<br/>+dose_analytics"]
    end

    V1 -->|Migration 1->2| V2
    V2 -->|Migration 2->3| V3
    V3 -->|Migration 3->4| V4

    style V1 fill:#e8f5e9
    style V2 fill:#e3f2fd
    style V3 fill:#fff3e0
    style V4 fill:#fce4ec

2.2. Estructura de Migraciones

migrations/
├── ios/
│   ├── Migration_v1_to_v2.swift
│   ├── Migration_v2_to_v3.swift
│   └── Migration_v3_to_v4.swift
└── android/
    ├── Migration_1_2.kt
    ├── Migration_2_3.kt
    └── Migration_3_4.kt

3. Implementacion iOS (Realm)

3.1. Configuracion de Realm

// RealmConfiguration.swift
import RealmSwift

enum RealmConfiguration {
    static let currentSchemaVersion: UInt64 = 4

    static var configuration: Realm.Configuration {
        var config = Realm.Configuration()
        config.schemaVersion = currentSchemaVersion
        config.migrationBlock = migrationBlock

        // Encryption key from Keychain
        if let key = KeychainService.shared.getRealmEncryptionKey() {
            config.encryptionKey = key
        }

        return config
    }

    static let migrationBlock: MigrationBlock = { migration, oldSchemaVersion in
        // Migraciones incrementales
        if oldSchemaVersion < 2 {
            Migration_v1_to_v2.migrate(migration)
        }
        if oldSchemaVersion < 3 {
            Migration_v2_to_v3.migrate(migration)
        }
        if oldSchemaVersion < 4 {
            Migration_v3_to_v4.migrate(migration)
        }
    }
}

3.2. Ejemplo de Migracion (v1 -> v2)

// Migration_v1_to_v2.swift
import RealmSwift

/// Migracion v1 -> v2: Agregar soporte para recetas
struct Migration_v1_to_v2 {

    static func migrate(_ migration: Migration) {
        // 1. Agregar nueva tabla 'Prescription'
        // Realm maneja esto automaticamente si la clase existe

        // 2. Agregar campo 'prescription_id' a Medication
        migration.enumerateObjects(ofType: MedicationEntity.className()) { oldObject, newObject in
            // Nuevo campo opcional, inicializar en nil
            newObject?["prescription_id"] = nil
            newObject?["prescription_blob_id"] = nil
        }

        // 3. Actualizar metadata de version de esquema
        migration.enumerateObjects(ofType: SyncMetadataEntity.className()) { oldObject, newObject in
            newObject?["local_schema_version"] = 2
        }

        Logger.info("Migration v1->v2 completed: Added prescriptions support")
    }
}

3.3. Migracion con Cambio de Estructura de Blob

// Migration_v2_to_v3.swift
import RealmSwift

/// Migracion v2 -> v3: Agregar soporte para dependientes
/// IMPORTANTE: Esta migracion requiere re-cifrado de algunos blobs
struct Migration_v2_to_v3 {

    static func migrate(_ migration: Migration) {
        // 1. Agregar tablas nuevas (Realm automatico)

        // 2. Migrar estructura de UserProfile para soportar dependientes
        migration.enumerateObjects(ofType: UserProfileEntity.className()) { oldObject, newObject in
            // Agregar campo 'profile_type' (owner vs dependent)
            newObject?["profile_type"] = "owner"

            // El blob cifrado NO se modifica
            // Solo se agrega metadata
        }

        // 3. Marcar que se requiere re-sync para nuevas estructuras
        migration.enumerateObjects(ofType: SyncMetadataEntity.className()) { oldObject, newObject in
            newObject?["local_schema_version"] = 3
            newObject?["requires_full_resync"] = false  // No necesario en esta migracion
        }

        Logger.info("Migration v2->v3 completed: Added dependents support")
    }

    /// Si la migracion requiere actualizar el formato del blob cifrado,
    /// esto debe hacerse DESPUES de la migracion de esquema, cuando
    /// la app tiene acceso al CryptoManager
    static func postMigrationBlobUpdate(cryptoManager: CryptoManager) async throws {
        let realm = try await Realm()

        // Para v3, no se requiere actualizar blobs
        // Pero si fuera necesario:
        /*
        let entities = realm.objects(SomeEntity.self).filter("blob_version < 3")

        for entity in entities {
            // 1. Descifrar con formato antiguo
            let decrypted = try cryptoManager.decrypt(
                blob: entity.encrypted_blob,
                format: .v2
            )

            // 2. Re-cifrar con formato nuevo
            let newBlob = try cryptoManager.encrypt(
                data: decrypted,
                format: .v3
            )

            // 3. Actualizar
            try realm.write {
                entity.encrypted_blob = newBlob
                entity.blob_version = 3
            }
        }
        */
    }
}

3.4. Manejo de Errores de Migracion

// MigrationErrorHandler.swift
enum MigrationError: Error {
    case schemaTooOld(current: UInt64, minimum: UInt64)
    case schemaTooNew(current: UInt64, maximum: UInt64)
    case migrationFailed(from: UInt64, to: UInt64, underlying: Error)
    case dataCorruption(details: String)
}

class MigrationErrorHandler {

    static func handle(error: Error, context: MigrationContext) {
        switch error {
        case let migrationError as MigrationError:
            handleMigrationError(migrationError, context: context)
        default:
            handleUnknownError(error, context: context)
        }
    }

    private static func handleMigrationError(
        _ error: MigrationError,
        context: MigrationContext
    ) {
        switch error {
        case .schemaTooOld(let current, let minimum):
            // App muy vieja, requiere reinstalacion
            Logger.critical("Schema too old: \(current) < \(minimum)")
            presentReinstallAlert()

        case .schemaTooNew(let current, let maximum):
            // App muy nueva para estos datos (downgrade)
            Logger.critical("Schema too new: \(current) > \(maximum)")
            presentUpgradeAlert()

        case .migrationFailed(let from, let to, let underlying):
            // Migracion fallo, intentar rollback
            Logger.error("Migration \(from)->\(to) failed: \(underlying)")
            attemptRollback(to: from, context: context)

        case .dataCorruption(let details):
            // Datos corruptos, ofrecer recovery
            Logger.critical("Data corruption detected: \(details)")
            presentRecoveryOptions()
        }
    }

    private static func attemptRollback(
        to version: UInt64,
        context: MigrationContext
    ) {
        // Restaurar desde backup si disponible
        if let backupPath = context.backupPath {
            do {
                try FileManager.default.copyItem(
                    atPath: backupPath,
                    toPath: context.realmPath
                )
                Logger.info("Rollback successful to backup")
            } catch {
                Logger.error("Rollback failed: \(error)")
                presentRecoveryOptions()
            }
        }
    }
}

4. Implementacion Android (Room)

4.1. Configuracion de Room

// AppDatabase.kt
@Database(
    entities = [
        MedicationEntity::class,
        ScheduleEntity::class,
        DoseLogEntity::class,
        PrescriptionEntity::class,
        DependentEntity::class,
        CaregiverEntity::class,
        SyncMetadataEntity::class
    ],
    version = 4,
    exportSchema = true
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {

    abstract fun medicationDao(): MedicationDao
    abstract fun scheduleDao(): ScheduleDao
    abstract fun prescriptionDao(): PrescriptionDao
    // ... otros DAOs

    companion object {
        private const val DATABASE_NAME = "medtime_db"

        fun build(context: Context): AppDatabase {
            return Room.databaseBuilder(
                context,
                AppDatabase::class.java,
                DATABASE_NAME
            )
            .addMigrations(
                MIGRATION_1_2,
                MIGRATION_2_3,
                MIGRATION_3_4
            )
            .fallbackToDestructiveMigrationOnDowngrade()
            .build()
        }
    }
}

4.2. Ejemplo de Migracion (v1 -> v2)

// Migrations.kt

/**
 * Migracion v1 -> v2: Agregar soporte para recetas
 */
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // 1. Crear tabla de recetas
        database.execSQL("""
            CREATE TABLE IF NOT EXISTS prescriptions (
                id TEXT PRIMARY KEY NOT NULL,
                prescription_blob_id TEXT NOT NULL,
                prescription_type TEXT NOT NULL,
                issue_date INTEGER NOT NULL,
                expiry_date INTEGER,
                country_code TEXT NOT NULL,
                status TEXT NOT NULL DEFAULT 'ACTIVE',
                encrypted_blob BLOB NOT NULL,
                blob_version INTEGER NOT NULL DEFAULT 1,
                checksum TEXT NOT NULL,
                created_at INTEGER NOT NULL,
                updated_at INTEGER NOT NULL,
                sync_version INTEGER NOT NULL DEFAULT 0,
                is_synced INTEGER NOT NULL DEFAULT 0
            )
        """)

        // 2. Agregar columnas a medications
        database.execSQL("""
            ALTER TABLE medications
            ADD COLUMN prescription_id TEXT DEFAULT NULL
        """)

        database.execSQL("""
            ALTER TABLE medications
            ADD COLUMN prescription_blob_id TEXT DEFAULT NULL
        """)

        // 3. Crear indice
        database.execSQL("""
            CREATE INDEX IF NOT EXISTS index_medications_prescription_id
            ON medications(prescription_id)
        """)

        // 4. Actualizar metadata
        database.execSQL("""
            UPDATE sync_metadata
            SET local_schema_version = 2
            WHERE id = 'main'
        """)

        Timber.i("Migration 1->2 completed: Added prescriptions support")
    }
}

/**
 * Migracion v2 -> v3: Agregar soporte para dependientes
 */
val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // 1. Crear tabla de dependientes
        database.execSQL("""
            CREATE TABLE IF NOT EXISTS dependents (
                id TEXT PRIMARY KEY NOT NULL,
                owner_id TEXT NOT NULL,
                display_name TEXT NOT NULL,
                relationship TEXT NOT NULL,
                encrypted_profile_blob BLOB NOT NULL,
                blob_version INTEGER NOT NULL DEFAULT 1,
                created_at INTEGER NOT NULL,
                updated_at INTEGER NOT NULL,
                sync_version INTEGER NOT NULL DEFAULT 0,
                FOREIGN KEY(owner_id) REFERENCES user_profiles(id) ON DELETE CASCADE
            )
        """)

        // 2. Crear tabla de cuidadores
        database.execSQL("""
            CREATE TABLE IF NOT EXISTS caregivers (
                id TEXT PRIMARY KEY NOT NULL,
                dependent_id TEXT NOT NULL,
                caregiver_user_id TEXT NOT NULL,
                permission_level TEXT NOT NULL,
                encrypted_permissions_blob BLOB,
                granted_at INTEGER NOT NULL,
                expires_at INTEGER,
                is_active INTEGER NOT NULL DEFAULT 1,
                FOREIGN KEY(dependent_id) REFERENCES dependents(id) ON DELETE CASCADE
            )
        """)

        // 3. Agregar profile_type a user_profiles
        database.execSQL("""
            ALTER TABLE user_profiles
            ADD COLUMN profile_type TEXT NOT NULL DEFAULT 'owner'
        """)

        // 4. Indices
        database.execSQL("""
            CREATE INDEX IF NOT EXISTS index_dependents_owner_id
            ON dependents(owner_id)
        """)

        database.execSQL("""
            CREATE INDEX IF NOT EXISTS index_caregivers_dependent_id
            ON caregivers(dependent_id)
        """)

        Timber.i("Migration 2->3 completed: Added dependents support")
    }
}

4.3. Migracion con Actualizacion de Blob Format

/**
 * Migracion v3 -> v4: Analytics + actualizacion de formato de blob
 */
val MIGRATION_3_4 = object : Migration(3, 4) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // 1. Crear tabla de analytics
        database.execSQL("""
            CREATE TABLE IF NOT EXISTS dose_analytics (
                id TEXT PRIMARY KEY NOT NULL,
                medication_id TEXT NOT NULL,
                period_start INTEGER NOT NULL,
                period_end INTEGER NOT NULL,
                total_doses INTEGER NOT NULL DEFAULT 0,
                taken_doses INTEGER NOT NULL DEFAULT 0,
                missed_doses INTEGER NOT NULL DEFAULT 0,
                late_doses INTEGER NOT NULL DEFAULT 0,
                adherence_rate REAL NOT NULL DEFAULT 0.0,
                created_at INTEGER NOT NULL,
                FOREIGN KEY(medication_id) REFERENCES medications(id) ON DELETE CASCADE
            )
        """)

        // 2. Agregar blob_version a todas las tablas que lo necesiten
        // (para tracking de formato de cifrado)
        val tablesNeedingBlobVersion = listOf(
            "medications",
            "schedules",
            "dose_logs"
        )

        for (table in tablesNeedingBlobVersion) {
            try {
                database.execSQL("""
                    ALTER TABLE $table
                    ADD COLUMN blob_version INTEGER NOT NULL DEFAULT 1
                """)
            } catch (e: Exception) {
                // Columna ya existe, ignorar
                Timber.d("Column blob_version already exists in $table")
            }
        }

        // 3. Marcar que se requiere actualizacion de blobs post-migracion
        database.execSQL("""
            UPDATE sync_metadata
            SET local_schema_version = 4,
                requires_blob_update = 1,
                target_blob_version = 2
            WHERE id = 'main'
        """)

        Timber.i("Migration 3->4 completed: Added analytics, marked for blob update")
    }
}

/**
 * Post-migracion: Actualizar formato de blobs cifrados
 * Se ejecuta DESPUES de la migracion de esquema, cuando CryptoManager esta disponible
 */
class BlobFormatUpdater @Inject constructor(
    private val database: AppDatabase,
    private val cryptoManager: CryptoManager
) {

    suspend fun updateBlobsIfNeeded() = withContext(Dispatchers.IO) {
        val metadata = database.syncMetadataDao().get("main") ?: return@withContext

        if (!metadata.requiresBlobUpdate) return@withContext

        val targetVersion = metadata.targetBlobVersion

        Timber.i("Starting blob format update to version $targetVersion")

        // Actualizar medications
        updateMedicationBlobs(targetVersion)

        // Actualizar schedules
        updateScheduleBlobs(targetVersion)

        // Actualizar dose_logs
        updateDoseLogBlobs(targetVersion)

        // Marcar como completado
        database.syncMetadataDao().update(
            metadata.copy(requiresBlobUpdate = false)
        )

        Timber.i("Blob format update completed")
    }

    private suspend fun updateMedicationBlobs(targetVersion: Int) {
        val outdatedMeds = database.medicationDao()
            .getWithBlobVersionLessThan(targetVersion)

        for (med in outdatedMeds) {
            try {
                // 1. Descifrar con formato antiguo
                val decrypted = cryptoManager.decrypt(
                    blob = med.encryptedBlob,
                    format = BlobFormat.fromVersion(med.blobVersion)
                )

                // 2. Re-cifrar con formato nuevo
                val newBlob = cryptoManager.encrypt(
                    data = decrypted,
                    format = BlobFormat.fromVersion(targetVersion)
                )

                // 3. Actualizar en DB
                database.medicationDao().updateBlob(
                    id = med.id,
                    encryptedBlob = newBlob,
                    blobVersion = targetVersion
                )

            } catch (e: Exception) {
                Timber.e(e, "Failed to update blob for medication ${med.id}")
                // Continuar con otros, marcar este para retry
            }
        }
    }
}

5. Backup Pre-Migracion

5.1. iOS

// MigrationBackupService.swift
class MigrationBackupService {

    private let fileManager = FileManager.default

    /// Crea backup antes de migracion
    func createPreMigrationBackup(fromVersion: UInt64) throws -> URL {
        let realmURL = Realm.Configuration.defaultConfiguration.fileURL!
        let backupName = "realm_backup_v\(fromVersion)_\(Date().timeIntervalSince1970).realm"
        let backupURL = getBackupDirectory().appendingPathComponent(backupName)

        try fileManager.copyItem(at: realmURL, to: backupURL)

        // Copiar archivos auxiliares (.lock, .note, etc.)
        for suffix in [".lock", ".note", ".management"] {
            let sourceAux = realmURL.appendingPathExtension(suffix)
            if fileManager.fileExists(atPath: sourceAux.path) {
                let destAux = backupURL.appendingPathExtension(suffix)
                try? fileManager.copyItem(at: sourceAux, to: destAux)
            }
        }

        Logger.info("Created pre-migration backup at \(backupURL)")
        return backupURL
    }

    /// Restaura desde backup
    func restoreFromBackup(_ backupURL: URL) throws {
        let realmURL = Realm.Configuration.defaultConfiguration.fileURL!

        // Cerrar Realm actual
        // (en produccion, esto requiere coordinacion con toda la app)

        // Reemplazar archivos
        try fileManager.removeItem(at: realmURL)
        try fileManager.copyItem(at: backupURL, to: realmURL)

        Logger.info("Restored from backup \(backupURL)")
    }

    /// Limpia backups antiguos (mantiene ultimos 3)
    func cleanOldBackups() {
        let backupDir = getBackupDirectory()
        guard let files = try? fileManager.contentsOfDirectory(
            at: backupDir,
            includingPropertiesForKeys: [.creationDateKey]
        ) else { return }

        let realmBackups = files
            .filter { $0.pathExtension == "realm" }
            .sorted { (url1, url2) -> Bool in
                let date1 = try? url1.resourceValues(forKeys: [.creationDateKey]).creationDate
                let date2 = try? url2.resourceValues(forKeys: [.creationDateKey]).creationDate
                return (date1 ?? .distantPast) > (date2 ?? .distantPast)
            }

        // Eliminar todos excepto los 3 mas recientes
        for backup in realmBackups.dropFirst(3) {
            try? fileManager.removeItem(at: backup)
        }
    }

    private func getBackupDirectory() -> URL {
        let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
        let backupDir = appSupport.appendingPathComponent("realm_backups")
        try? fileManager.createDirectory(at: backupDir, withIntermediateDirectories: true)
        return backupDir
    }
}

5.2. Android

// MigrationBackupService.kt
class MigrationBackupService @Inject constructor(
    @ApplicationContext private val context: Context
) {

    private val backupDir: File by lazy {
        File(context.filesDir, "db_backups").apply { mkdirs() }
    }

    /**
     * Crea backup antes de migracion
     */
    fun createPreMigrationBackup(fromVersion: Int): File {
        val dbFile = context.getDatabasePath("medtime_db")
        val backupName = "medtime_backup_v${fromVersion}_${System.currentTimeMillis()}.db"
        val backupFile = File(backupDir, backupName)

        dbFile.copyTo(backupFile, overwrite = true)

        // Copiar archivos auxiliares (-shm, -wal)
        listOf("-shm", "-wal").forEach { suffix ->
            val auxFile = File(dbFile.path + suffix)
            if (auxFile.exists()) {
                auxFile.copyTo(File(backupFile.path + suffix), overwrite = true)
            }
        }

        Timber.i("Created pre-migration backup at ${backupFile.path}")
        return backupFile
    }

    /**
     * Restaura desde backup
     */
    fun restoreFromBackup(backupFile: File) {
        val dbFile = context.getDatabasePath("medtime_db")

        // Cerrar DB actual (requiere coordinacion)

        // Reemplazar archivos
        backupFile.copyTo(dbFile, overwrite = true)

        listOf("-shm", "-wal").forEach { suffix ->
            val auxBackup = File(backupFile.path + suffix)
            val auxDb = File(dbFile.path + suffix)
            if (auxBackup.exists()) {
                auxBackup.copyTo(auxDb, overwrite = true)
            } else {
                auxDb.delete()
            }
        }

        Timber.i("Restored from backup ${backupFile.path}")
    }

    /**
     * Limpia backups antiguos (mantiene ultimos 3)
     */
    fun cleanOldBackups() {
        val backups = backupDir.listFiles { file ->
            file.extension == "db"
        }?.sortedByDescending { it.lastModified() } ?: return

        backups.drop(3).forEach { it.delete() }
    }
}

6. Testing de Migraciones

6.1. iOS (XCTest)

// MigrationTests.swift
import XCTest
import RealmSwift
@testable import MedTime

class MigrationTests: XCTestCase {

    var testRealmURL: URL!

    override func setUp() {
        super.setUp()
        testRealmURL = FileManager.default.temporaryDirectory
            .appendingPathComponent(UUID().uuidString + ".realm")
    }

    override func tearDown() {
        try? FileManager.default.removeItem(at: testRealmURL)
        super.tearDown()
    }

    func test_migration_v1_to_v2_addsPrescriptionsSupport() throws {
        // Given: DB v1 con datos
        let v1Config = createV1Configuration()
        let v1Realm = try Realm(configuration: v1Config)

        try v1Realm.write {
            let med = MedicationEntityV1()
            med.id = "med-001"
            med.name = "Encrypted blob here"
            v1Realm.add(med)
        }

        // When: Migrar a v2
        let v2Config = Realm.Configuration(
            fileURL: testRealmURL,
            schemaVersion: 2,
            migrationBlock: RealmConfiguration.migrationBlock
        )
        let v2Realm = try Realm(configuration: v2Config)

        // Then: Nuevos campos existen
        let migratedMed = v2Realm.object(ofType: MedicationEntity.self, forPrimaryKey: "med-001")
        XCTAssertNotNil(migratedMed)
        XCTAssertNil(migratedMed?.prescription_id)  // Campo nuevo, nil por defecto

        // Y tabla de prescriptions existe
        XCTAssertTrue(v2Realm.schema.objectSchema.contains { $0.className == "PrescriptionEntity" })
    }

    func test_migration_preservesEncryptedBlobs() throws {
        // Given: DB con blob cifrado
        let originalBlob = Data("encrypted-data-here".utf8)

        let v1Config = createV1Configuration()
        let v1Realm = try Realm(configuration: v1Config)

        try v1Realm.write {
            let med = MedicationEntityV1()
            med.id = "med-001"
            med.encrypted_blob = originalBlob
            v1Realm.add(med)
        }

        // When: Migrar todas las versiones
        let latestConfig = Realm.Configuration(
            fileURL: testRealmURL,
            schemaVersion: RealmConfiguration.currentSchemaVersion,
            migrationBlock: RealmConfiguration.migrationBlock
        )
        let latestRealm = try Realm(configuration: latestConfig)

        // Then: Blob intacto
        let migratedMed = latestRealm.object(ofType: MedicationEntity.self, forPrimaryKey: "med-001")
        XCTAssertEqual(migratedMed?.encrypted_blob, originalBlob)
    }

    private func createV1Configuration() -> Realm.Configuration {
        // Crear configuracion con esquema v1
        var config = Realm.Configuration()
        config.fileURL = testRealmURL
        config.schemaVersion = 1
        config.objectTypes = [MedicationEntityV1.self, ScheduleEntityV1.self]
        return config
    }
}

6.2. Android (JUnit + Room Testing)

// MigrationTest.kt
@RunWith(AndroidJUnit4::class)
class MigrationTest {

    @get:Rule
    val helper: MigrationTestHelper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        AppDatabase::class.java
    )

    @Test
    fun migrate_1_to_2_addsPrescriptionsSupport() {
        // Given: DB v1 con datos
        helper.createDatabase("test_db", 1).apply {
            execSQL("""
                INSERT INTO medications (id, encrypted_blob, checksum)
                VALUES ('med-001', X'deadbeef', 'checksum123')
            """)
            close()
        }

        // When: Migrar a v2
        val db = helper.runMigrationsAndValidate("test_db", 2, true, MIGRATION_1_2)

        // Then: Tabla de prescriptions existe
        val cursor = db.query("SELECT * FROM prescriptions")
        assertThat(cursor.columnCount).isGreaterThan(0)
        cursor.close()

        // Y columna prescription_id agregada a medications
        val medCursor = db.query("SELECT prescription_id FROM medications WHERE id = 'med-001'")
        assertThat(medCursor.moveToFirst()).isTrue()
        assertThat(medCursor.isNull(0)).isTrue()  // NULL por defecto
        medCursor.close()
    }

    @Test
    fun migrate_preservesEncryptedBlobs() {
        // Given: Blob cifrado original
        val originalBlob = byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte())

        helper.createDatabase("test_db", 1).apply {
            execSQL(
                "INSERT INTO medications (id, encrypted_blob, checksum) VALUES (?, ?, ?)",
                arrayOf("med-001", originalBlob, "checksum123")
            )
            close()
        }

        // When: Migrar a ultima version
        val db = helper.runMigrationsAndValidate(
            "test_db",
            4,
            true,
            MIGRATION_1_2,
            MIGRATION_2_3,
            MIGRATION_3_4
        )

        // Then: Blob intacto
        val cursor = db.query("SELECT encrypted_blob FROM medications WHERE id = 'med-001'")
        cursor.moveToFirst()
        val migratedBlob = cursor.getBlob(0)
        cursor.close()

        assertThat(migratedBlob).isEqualTo(originalBlob)
    }

    @Test
    fun allMigrations_runWithoutError() {
        // Given: DB v1
        helper.createDatabase("test_db", 1).close()

        // When/Then: Todas las migraciones ejecutan sin error
        helper.runMigrationsAndValidate(
            "test_db",
            4,
            true,
            MIGRATION_1_2,
            MIGRATION_2_3,
            MIGRATION_3_4
        )
    }
}

7. Version Matrix

7.1. Compatibilidad de Versiones

App Version Schema Version Blob Format Min Supported Schema
1.0.0 1 v1 1
1.1.0 2 v1 1
1.2.0 3 v1 1
2.0.0 4 v2 2
2.1.0 5 v2 2

7.2. Politica de Soporte

  • Minimo soportado: 2 versiones de esquema anteriores
  • Migracion destructiva: Si esquema < minimo soportado
  • Downgrade: No soportado (app version anterior no puede leer schema nuevo)

8. Referencias

  • MOB-IOS-001: iOS Architecture
  • MOB-AND-001: Android Architecture
  • DATA-MODEL-001: Data Model Specification
  • 04-seguridad-cliente.md: Client Security