Migration-Safety: Datenverlust bei App- und Server-Updates verhindern #99

Closed
opened 2026-05-17 18:44:15 +00:00 by jreinemann-euris · 1 comment
jreinemann-euris commented 2026-05-17 18:44:15 +00:00 (Migrated from github.com)

Ziel

Sicherstellen, dass App-Updates und Server-Updates niemals Benutzerdaten löschen. Aktuell gibt es auf beiden Seiten kritische Schwachstellen.


Ist-Zustand & Risiken

Android (Room)

🔴 Kritisch: fallbackToDestructiveMigration() aktiv

In DatabaseModule.kt ist der Room-Fallback aktiv:

Room.databaseBuilder(context, BollwerkDatabase::class.java, "bollwerk.db")
    .fallbackToDestructiveMigration()  // ← alle Tabellen werden gelöscht!
    .build()

Das bedeutet: Sobald Room eine Schemaversion erkennt, für die kein Migrationspfad definiert ist, löscht es die gesamte Datenbank und erstellt sie neu. Alle Inventar-Daten, Nachrichten und Einstellungen gehen verloren.

🔴 Keine @AutoMigration-Annotationen

Obwohl Room 2.6.1 @AutoMigration unterstützt, sind keine definierten Migrationspfade vorhanden. Alle bisherigen Schemaübergänge (v2→v3→v4→v5→v6) liefen über den Destruktiv-Fallback.

🔴 kcal_per_kgkcal_per_unit (v5→v6) bereits verlustbehaftet

Das Umbenennen dieser Spalte im letzten Release hat bei Usern mit v5-Datenbank alle Items-Daten gelöscht. Dies ist ein bereits eingetretener Schaden.

🟡 CASCADE DELETE ohne Soft-Delete

ItemEntity hat onDelete = CASCADE zu Categories und Locations. Löscht ein User eine Kategorie, verschwinden alle Items darin sofort und unwiederbringlich.


Server (PostgreSQL + Exposed)

Grundsätzlich sicher: SchemaUtils.createMissingTablesAndColumns()

Diese Methode ist rein additiv – sie erstellt fehlende Tabellen und Spalten, löscht aber keine bestehenden Daten.

🟡 Kein versioniertes Migrations-Tracking (kein Flyway/Liquibase)

Spalten-Umbenennen oder Typ-Änderungen können nicht mit SchemaUtils durchgeführt werden. Diese müssen als manuelle SQL-Migration in DatabaseFactory.kt geschrieben werden – ohne Versionierung besteht das Risiko, dass eine Migration doppelt oder gar nicht ausgeführt wird.

🟡 Veraltete user_id-Spalte (stille Migration)

Nach der Multi-Tenant-Migration bleibt die alte user_id-Spalte in mehreren Tabellen mit einem try/catch versteckt. Das ist technische Schuld und kann bei zukünftigen Migrationen zu Konflikten führen.


Lösung

Android

1. fallbackToDestructiveMigration() entfernen (sofort)

Room.databaseBuilder(context, BollwerkDatabase::class.java, "bollwerk.db")
    // fallbackToDestructiveMigration() ENTFERNT
    .build()

Ohne Fallback crasht die App, wenn kein Migrationspfad vorhanden ist – das ist gewollt, damit Datenverlust auffällt statt still passiert.

2. @AutoMigration für alle neuen Schemaänderungen nutzen

@Database(
    entities = [...],
    version = 7,
    exportSchema = true,
    autoMigrations = [
        AutoMigration(from = 6, to = 7)  // z.B. neue Spalte hinzufügen
    ]
)

@AutoMigration funktioniert für: neue Spalten (mit Default), neue Tabellen, Spalten löschen (@DeleteColumn), Tabellen löschen (@DeleteTable).

Für Spalten-Umbenennungen muss zusätzlich eine @RenameColumn-Spezifikation angegeben werden:

@AutoMigration(from = 6, to = 7, spec = RenameKcalColumnMigration::class)

@RenameColumn(tableName = "items", fromColumnName = "kcal_per_kg", toColumnName = "kcal_per_unit")
class RenameKcalColumnMigration : AutoMigrationSpec

3. Manuelle Migration als letzter Ausweg

Für komplexe Fälle (Daten transformieren, Tabellen zusammenführen):

val MIGRATION_6_7 = object : Migration(6, 7) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE items ADD COLUMN new_field TEXT DEFAULT '' NOT NULL")
    }
}
// In DatabaseModule:
.addMigrations(MIGRATION_6_7)

4. Migrations-Tests

Room bietet MigrationTestHelper für automatisierte Tests:

@Test
fun migrate6To7() {
    val db = helper.createDatabase(TEST_DB, 6)
    db.close()
    helper.runMigrationsAndValidate(TEST_DB, 7, true, MIGRATION_6_7)
}

Server

1. Flyway für versioniertes Migrations-Tracking einführen

Flyway ergänzt Exposed nahtlos. Migrationsskripte liegen als SQL-Dateien vor und werden nur einmal ausgeführt:

server/src/main/resources/db/migration/
  V1__initial_schema.sql
  V2__add_inventory_id.sql
  V3__cleanup_user_id_column.sql   ← veraltete Spalten aufräumen
  V4__add_public_key.sql           ← zukünftig für E2EE
// DatabaseFactory.kt
Flyway.configure()
    .dataSource(url, user, password)
    .locations("classpath:db/migration")
    .load()
    .migrate()

2. Übergangsweise: Manuelle Migration formalisieren

Bis Flyway eingeführt ist, jede manuelle Migration in DatabaseFactory.kt mit einer Guard-Condition versehen:

// Nur ausführen, wenn Spalte noch existiert
val columnExists = exec("SELECT column_name FROM information_schema.columns WHERE table_name='users' AND column_name='user_id'") { it.next() } == true
if (columnExists) {
    exec("ALTER TABLE users DROP COLUMN user_id")
}

Abhängigkeiten

  • Muss vor dem nächsten Release mit Schema-Änderung (z.B. E2EE #96) abgeschlossen sein

Akzeptanzkriterien

  • fallbackToDestructiveMigration() ist aus DatabaseModule.kt entfernt
  • Alle zukünftigen Schema-Änderungen nutzen @AutoMigration oder manuelle Migration-Klassen
  • Migrations-Test für den Pfad v6→v7 (erster neuer Pfad) ist implementiert
  • Server: Flyway ist integriert, bestehende Migrationen als SQL-Skripte dokumentiert
  • Veraltete user_id-Spalte auf dem Server ist bereinigt
  • App-Update von einer früheren Version auf die neue verliert keine Daten (manuell getestet)
## Ziel Sicherstellen, dass App-Updates und Server-Updates **niemals Benutzerdaten löschen**. Aktuell gibt es auf beiden Seiten kritische Schwachstellen. --- ## Ist-Zustand & Risiken ### Android (Room) **🔴 Kritisch: `fallbackToDestructiveMigration()` aktiv** In `DatabaseModule.kt` ist der Room-Fallback aktiv: ```kotlin Room.databaseBuilder(context, BollwerkDatabase::class.java, "bollwerk.db") .fallbackToDestructiveMigration() // ← alle Tabellen werden gelöscht! .build() ``` Das bedeutet: Sobald Room eine Schemaversion erkennt, für die kein Migrationspfad definiert ist, löscht es die gesamte Datenbank und erstellt sie neu. **Alle Inventar-Daten, Nachrichten und Einstellungen gehen verloren.** **🔴 Keine `@AutoMigration`-Annotationen** Obwohl Room 2.6.1 `@AutoMigration` unterstützt, sind keine definierten Migrationspfade vorhanden. Alle bisherigen Schemaübergänge (v2→v3→v4→v5→v6) liefen über den Destruktiv-Fallback. **🔴 `kcal_per_kg` → `kcal_per_unit` (v5→v6) bereits verlustbehaftet** Das Umbenennen dieser Spalte im letzten Release hat bei Usern mit v5-Datenbank alle Items-Daten gelöscht. Dies ist ein bereits eingetretener Schaden. **🟡 CASCADE DELETE ohne Soft-Delete** `ItemEntity` hat `onDelete = CASCADE` zu Categories und Locations. Löscht ein User eine Kategorie, verschwinden alle Items darin sofort und unwiederbringlich. --- ### Server (PostgreSQL + Exposed) **✅ Grundsätzlich sicher: `SchemaUtils.createMissingTablesAndColumns()`** Diese Methode ist rein additiv – sie erstellt fehlende Tabellen und Spalten, löscht aber keine bestehenden Daten. **🟡 Kein versioniertes Migrations-Tracking (kein Flyway/Liquibase)** Spalten-Umbenennen oder Typ-Änderungen können nicht mit `SchemaUtils` durchgeführt werden. Diese müssen als manuelle SQL-Migration in `DatabaseFactory.kt` geschrieben werden – ohne Versionierung besteht das Risiko, dass eine Migration doppelt oder gar nicht ausgeführt wird. **🟡 Veraltete `user_id`-Spalte (stille Migration)** Nach der Multi-Tenant-Migration bleibt die alte `user_id`-Spalte in mehreren Tabellen mit einem `try/catch` versteckt. Das ist technische Schuld und kann bei zukünftigen Migrationen zu Konflikten führen. --- ## Lösung ### Android **1. `fallbackToDestructiveMigration()` entfernen (sofort)** ```kotlin Room.databaseBuilder(context, BollwerkDatabase::class.java, "bollwerk.db") // fallbackToDestructiveMigration() ENTFERNT .build() ``` Ohne Fallback crasht die App, wenn kein Migrationspfad vorhanden ist – das ist **gewollt**, damit Datenverlust auffällt statt still passiert. **2. `@AutoMigration` für alle neuen Schemaänderungen nutzen** ```kotlin @Database( entities = [...], version = 7, exportSchema = true, autoMigrations = [ AutoMigration(from = 6, to = 7) // z.B. neue Spalte hinzufügen ] ) ``` `@AutoMigration` funktioniert für: neue Spalten (mit Default), neue Tabellen, Spalten löschen (`@DeleteColumn`), Tabellen löschen (`@DeleteTable`). Für Spalten-Umbenennungen muss zusätzlich eine `@RenameColumn`-Spezifikation angegeben werden: ```kotlin @AutoMigration(from = 6, to = 7, spec = RenameKcalColumnMigration::class) @RenameColumn(tableName = "items", fromColumnName = "kcal_per_kg", toColumnName = "kcal_per_unit") class RenameKcalColumnMigration : AutoMigrationSpec ``` **3. Manuelle Migration als letzter Ausweg** Für komplexe Fälle (Daten transformieren, Tabellen zusammenführen): ```kotlin val MIGRATION_6_7 = object : Migration(6, 7) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE items ADD COLUMN new_field TEXT DEFAULT '' NOT NULL") } } // In DatabaseModule: .addMigrations(MIGRATION_6_7) ``` **4. Migrations-Tests** Room bietet `MigrationTestHelper` für automatisierte Tests: ```kotlin @Test fun migrate6To7() { val db = helper.createDatabase(TEST_DB, 6) db.close() helper.runMigrationsAndValidate(TEST_DB, 7, true, MIGRATION_6_7) } ``` --- ### Server **1. Flyway für versioniertes Migrations-Tracking einführen** Flyway ergänzt Exposed nahtlos. Migrationsskripte liegen als SQL-Dateien vor und werden nur einmal ausgeführt: ``` server/src/main/resources/db/migration/ V1__initial_schema.sql V2__add_inventory_id.sql V3__cleanup_user_id_column.sql ← veraltete Spalten aufräumen V4__add_public_key.sql ← zukünftig für E2EE ``` ```kotlin // DatabaseFactory.kt Flyway.configure() .dataSource(url, user, password) .locations("classpath:db/migration") .load() .migrate() ``` **2. Übergangsweise: Manuelle Migration formalisieren** Bis Flyway eingeführt ist, jede manuelle Migration in `DatabaseFactory.kt` mit einer Guard-Condition versehen: ```kotlin // Nur ausführen, wenn Spalte noch existiert val columnExists = exec("SELECT column_name FROM information_schema.columns WHERE table_name='users' AND column_name='user_id'") { it.next() } == true if (columnExists) { exec("ALTER TABLE users DROP COLUMN user_id") } ``` --- ## Abhängigkeiten - Muss vor dem nächsten Release mit Schema-Änderung (z.B. E2EE #96) abgeschlossen sein ## Akzeptanzkriterien - [ ] `fallbackToDestructiveMigration()` ist aus `DatabaseModule.kt` entfernt - [ ] Alle zukünftigen Schema-Änderungen nutzen `@AutoMigration` oder manuelle `Migration`-Klassen - [ ] Migrations-Test für den Pfad v6→v7 (erster neuer Pfad) ist implementiert - [ ] Server: Flyway ist integriert, bestehende Migrationen als SQL-Skripte dokumentiert - [ ] Veraltete `user_id`-Spalte auf dem Server ist bereinigt - [ ] App-Update von einer früheren Version auf die neue verliert keine Daten (manuell getestet)
jreinemann-euris commented 2026-05-17 20:09:38 +00:00 (Migrated from github.com)

Alle Akzeptanzkriterien erfüllt – Issue wird geschlossen.

fallbackToDestructiveMigration() entfernt
@AutoMigration (v5→v6, v6→v7) aktiv
Migrations-Tests mit MigrationTestHelper vorhanden
Flyway im Server integriert
user_id-Spalte per Flyway V2 bereinigt
Kein Destruktiv-Fallback mehr

Alle Akzeptanzkriterien erfüllt – Issue wird geschlossen. ✅ fallbackToDestructiveMigration() entfernt ✅ @AutoMigration (v5→v6, v6→v7) aktiv ✅ Migrations-Tests mit MigrationTestHelper vorhanden ✅ Flyway im Server integriert ✅ user_id-Spalte per Flyway V2 bereinigt ✅ Kein Destruktiv-Fallback mehr
Sign in to join this conversation.
No description provided.