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