feat: Migration-Safety – Room v7, AutoMigration, Flyway, kein fallbackToDestructiveMigration (#99)

- fallbackToDestructiveMigration() aus DatabaseModule entfernt
- BollwerkDatabase auf Version 7 gebumpt
- AutoMigration(from=5, to=6) und (from=6, to=7) definiert
- MigrationTestHelper-Test migrate6To7_preservesData implementiert
- 7.json Schema-Export generiert
- Server: Flyway 9.22.3 integriert (baselineOnMigrate=true)
- V1__initial_schema.sql + V2__cleanup_user_id.sql angelegt
- Skill android-db-migration erstellt
- versionCode 5 / versionName 1.4
This commit is contained in:
Jens Reinemann 2026-05-17 21:14:11 +02:00
parent 3d7c01cef5
commit 045a4b7674
11 changed files with 689 additions and 10 deletions

View file

@ -0,0 +1,219 @@
---
name: android-db-migration
description: >
Room-Datenbankmigration in der Bollwerk-App: Schema-Version erhöhen, @AutoMigration hinzufügen,
manuelle Migration schreiben, MigrationTestHelper-Tests erstellen, fallbackToDestructiveMigration
entfernen/vermeiden. Nutze diesen Skill immer wenn es um Room-Schema-Änderungen, DB-Versionen,
Migrationen, Spalten umbenennen/löschen, neue Tabellen oder Migrationstests geht.
Trigger-Phrasen: "migration", "DB-Version", "Schema", "Room", "AutoMigration", "fallback",
"Datenverlust", "Spalte hinzufügen/umbenennen", "BollwerkDatabase", "DatabaseModule".
---
# Skill: Android DB-Migration (Room)
Vollständige Anleitung zur sicheren Room-Migration in der Bollwerk-App vom Schema-Bump über @AutoMigration bis zum MigrationTestHelper-Test.
---
## Projektkontext
| Datei | Zweck |
| --------------------------------------------------------------- | ------------------------------------------------------------------------ |
| `app/src/main/java/de/bollwerk/app/data/db/BollwerkDatabase.kt` | `@Database`-Annotation, Versionsnummer, `autoMigrations`-Liste |
| `app/src/main/java/de/bollwerk/app/data/db/Migrations.kt` | Manuelle `Migration(X, Y)`-Objekte (nur bei AutoMigration-Grenzen nötig) |
| `app/src/main/java/de/bollwerk/app/di/DatabaseModule.kt` | Room Builder enthält **kein** `fallbackToDestructiveMigration()` |
| `app/schemas/de.bollwerk.app.data.db.BollwerkDatabase/` | Exportierte Schema-JSON-Dateien (auto-generiert via KSP) |
| `app/src/androidTest/.../BollwerkDatabaseMigrationTest.kt` | `MigrationTestHelper`-Tests |
**KSP-Konfiguration** (`app/build.gradle.kts`):
```kotlin
ksp { arg("room.schemaLocation", "$projectDir/schemas") }
```
```kotlin
sourceSets { getByName("androidTest").assets.srcDirs("$projectDir/schemas") }
```
→ Schema-JSONs werden bei jedem Build automatisch aktualisiert und sind als Assets für Migrationstests verfügbar.
---
## Goldene Regeln
1. **Niemals** `fallbackToDestructiveMigration()` verwenden die App crasht bewusst, wenn kein Pfad definiert ist (Datenverlust bleibt sichtbar).
2. **Jede** Versionserhöhung braucht einen `@AutoMigration`- oder manuellen `Migration`-Eintrag.
3. **Immer** nach dem Schema-Bump bauen, damit die neue `N.json` generiert wird, bevor Tests geschrieben werden.
4. Für Spalten-Umbenennungen **immer** `@RenameColumn`-Spec angeben (AutoMigration erkennt Rename nicht automatisch).
---
## Checkliste: Schema-Änderung
```
[ ] 1. Entity-Datei anpassen (Spalte/Tabelle hinzufügen, umbenennen, löschen)
[ ] 2. BollwerkDatabase.kt: version auf N+1 erhöhen
[ ] 3. BollwerkDatabase.kt: autoMigrations um AutoMigration(from=N, to=N+1) ergänzen
→ Bei Rename: spec = MeineRenameSpec::class angeben
→ Bei komplexer Migration: manuelles Migration-Objekt in Migrations.kt
[ ] 4. Build ausführen → N+1.json wird generiert
[ ] 5. MigrationTestHelper-Test in BollwerkDatabaseMigrationTest.kt schreiben
[ ] 6. Build + Tests grün
```
---
## BollwerkDatabase.kt Muster
```kotlin
@Database(
entities = [CategoryEntity::class, LocationEntity::class, ItemEntity::class,
SettingsEntity::class, PendingSyncOpEntity::class, MessageEntity::class],
version = 8, // ← erhöhen
exportSchema = true,
autoMigrations = [
AutoMigration(from = 5, to = 6), // No-op (gleiche Schemas)
AutoMigration(from = 6, to = 7), // No-op (Infrastruktur-Baseline)
AutoMigration(from = 7, to = 8), // z.B. neue Spalte
// AutoMigration(from = 8, to = 9, spec = MeineSpec::class) ← bei Rename
]
)
@TypeConverters(LocalDateConverter::class)
internal abstract class BollwerkDatabase : RoomDatabase() { ... }
```
---
## AutoMigration-Fälle
### Fall 1: Neue Spalte mit Default
```kotlin
// Entity: neue Spalte mit @ColumnInfo(defaultValue = "")
@ColumnInfo(name = "barcode", defaultValue = "") val barcode: String = ""
// @Database:
AutoMigration(from = 7, to = 8)
// Kein Spec nötig Room erkennt ADD COLUMN automatisch
```
### Fall 2: Spalte löschen
```kotlin
// Spec-Klasse anlegen (in Migrations.kt oder eigenem File):
@DeleteColumn(tableName = "items", columnName = "legacy_field")
class Migration7To8Spec : AutoMigrationSpec
// @Database:
AutoMigration(from = 7, to = 8, spec = Migration7To8Spec::class)
```
### Fall 3: Spalte umbenennen
```kotlin
@RenameColumn(tableName = "items", fromColumnName = "kcal_per_kg", toColumnName = "kcal_per_unit")
class Migration7To8Spec : AutoMigrationSpec
// @Database:
AutoMigration(from = 7, to = 8, spec = Migration7To8Spec::class)
```
### Fall 4: Komplexe Migration (Daten transformieren)
Wenn AutoMigration nicht reicht (z. B. Daten kopieren, Tabellen zusammenführen):
```kotlin
// In Migrations.kt:
internal val MIGRATION_7_8 = object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE items ADD COLUMN quantity_unit TEXT NOT NULL DEFAULT 'Stück'")
db.execSQL("UPDATE items SET quantity_unit = unit WHERE unit IS NOT NULL")
}
}
// In DatabaseModule.kt:
Room.databaseBuilder(context, BollwerkDatabase::class.java, "bollwerk.db")
.addCallback(DefaultDataCallback)
.addMigrations(MIGRATION_7_8)
.build()
```
---
## MigrationTestHelper-Test
```kotlin
@get:Rule
val migrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
BollwerkDatabase::class.java
)
@Test
fun migrate7To8_preservesData() {
// 1. DB im alten Zustand anlegen und Testdaten einfügen
migrationTestHelper.createDatabase(dbName, 7).use { db ->
db.execSQL("INSERT INTO categories (name) VALUES ('Lebensmittel')")
db.execSQL(
"INSERT INTO items (id, name, category_id, quantity, unit, unit_price, " +
"kcal_per_unit, expiry_date, location_id, notes, last_updated) " +
"VALUES ('item-1', 'Wasser', 1, 10.0, 'Liter', 0.5, NULL, NULL, 1, '', 0)"
)
}
// 2. Migration durchführen und Schema validieren
migrationTestHelper.runMigrationsAndValidate(dbName, 8, true)
.use { db ->
// 3. Datenpersistenz prüfen
db.query("SELECT id, name FROM items").use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("item-1", cursor.getString(0))
}
}
}
```
**Wichtig:** Die Schema-JSON-Dateien aus `app/schemas/` müssen als Assets im androidTest-Quellsatz liegen (bereits konfiguriert). Der `MigrationTestHelper` liest sie automatisch.
---
## Häufige Fehler & Lösungen
| Fehler | Ursache | Lösung |
| --------------------------------------------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------- |
| `IllegalStateException: A migration from X to Y was required but not found` | Version erhöht ohne AutoMigration-Eintrag | `@AutoMigration(from=X, to=Y)` in `@Database` eintragen |
| `Cannot find implementation for AutoMigration` | Build noch nicht nach Schema-Änderung ausgeführt | `./gradlew assembleDebug` ausführen, `Y.json` muss existieren |
| `Expected X migrations, found Y` | `addMigrations()` fehlt für manuelle Migration | In `DatabaseModule.kt` `.addMigrations(MIGRATION_X_Y)` eintragen |
| `MigrationTestHelper: No schema file found for version X` | Schema-JSON für Version X existiert nicht in `app/schemas/` | Build mit Version X zuerst ausführen, JSON committen |
| Rename-Spalte → Datenverlust | AutoMigration interpretiert Rename als DROP+ADD | `@RenameColumn`-Spec zwingend angeben |
---
## Aktuelle DB-Version
Aktuelle Produktionsversion: **7** (`BollwerkDatabase.kt`, `version = 7`)
Migrations-Pfade vorhanden:
- v5 → v6: No-op (identische Schemas, `@AutoMigration`)
- v6 → v7: No-op (Infrastruktur-Baseline, `@AutoMigration`)
Nächste Version für neue Schema-Änderung: **v8**
---
## Hinweis: Server-Migration (Flyway)
Für Schema-Änderungen auf dem **Server** (PostgreSQL + Exposed):
- SQL-Skripte unter `server/src/main/resources/db/migration/`
- Namenskonvention: `V{N}__{beschreibung}.sql`
- Flyway ist integriert (`baselineOnMigrate = true`) läuft beim Server-Start automatisch
- Bestehende Datenbanken werden auf V1 gebaselined; neue Skripte werden einmalig ausgeführt
```sql
-- Beispiel: server/src/main/resources/db/migration/V3__add_barcode_column.sql
ALTER TABLE items ADD COLUMN IF NOT EXISTS barcode VARCHAR(100);
```
`IF NOT EXISTS` / `IF EXISTS` in SQL verwenden, um Idempotenz zu gewährleisten.

View file

@ -15,8 +15,8 @@ android {
applicationId = "de.bollwerk.app"
minSdk = 26
targetSdk = 35
versionCode = 4
versionName = "1.3"
versionCode = 5
versionName = "1.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("boolean", "FEATURE_CAMERA_ENABLED", "false")

View file

@ -0,0 +1,314 @@
{
"formatVersion": 1,
"database": {
"version": 7,
"identityHash": "94ca0ddef5eb5333c781b3f97eff9c85",
"entities": [
{
"tableName": "categories",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "locations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "items",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `category_id` INTEGER NOT NULL, `quantity` REAL NOT NULL, `unit` TEXT NOT NULL, `unit_price` REAL NOT NULL, `kcal_per_unit` INTEGER, `expiry_date` TEXT, `location_id` INTEGER NOT NULL, `notes` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`category_id`) REFERENCES `categories`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`location_id`) REFERENCES `locations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "categoryId",
"columnName": "category_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "quantity",
"columnName": "quantity",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "unit",
"columnName": "unit",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unitPrice",
"columnName": "unit_price",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "kcalPerUnit",
"columnName": "kcal_per_unit",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "expiryDate",
"columnName": "expiry_date",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "locationId",
"columnName": "location_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notes",
"columnName": "notes",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastUpdated",
"columnName": "last_updated",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_items_category_id",
"unique": false,
"columnNames": [
"category_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_items_category_id` ON `${TABLE_NAME}` (`category_id`)"
},
{
"name": "index_items_location_id",
"unique": false,
"columnNames": [
"location_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_items_location_id` ON `${TABLE_NAME}` (`location_id`)"
}
],
"foreignKeys": [
{
"table": "categories",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"category_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "locations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"location_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "pending_sync_ops",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `item_id` TEXT NOT NULL, `operation` TEXT NOT NULL, `payload` TEXT NOT NULL, `created_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "item_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "operation",
"columnName": "operation",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "payload",
"columnName": "payload",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `sender_id` TEXT NOT NULL, `sender_username` TEXT NOT NULL, `receiver_id` TEXT NOT NULL, `body` TEXT NOT NULL, `sent_at` INTEGER NOT NULL, `is_pending` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "senderId",
"columnName": "sender_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "senderUsername",
"columnName": "sender_username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "receiverId",
"columnName": "receiver_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sentAt",
"columnName": "sent_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isPending",
"columnName": "is_pending",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '94ca0ddef5eb5333c781b3f97eff9c85')"
]
}
}

View file

@ -1,6 +1,7 @@
package de.bollwerk.app.data.db
import androidx.room.Room
import androidx.room.testing.MigrationTestHelper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import de.bollwerk.app.data.db.entity.CategoryEntity
@ -11,6 +12,7 @@ import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@ -27,6 +29,12 @@ internal class BollwerkDatabaseMigrationTest {
private val dbName = "bollwerk-migration-test.db"
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
@get:Rule
val migrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
BollwerkDatabase::class.java
)
@Before
fun setUp() {
context.deleteDatabase(dbName)
@ -37,6 +45,36 @@ internal class BollwerkDatabaseMigrationTest {
context.deleteDatabase(dbName)
}
@Test
fun migrate6To7_preservesAllData() {
migrationTestHelper.createDatabase(dbName, 6).use { db ->
db.execSQL("INSERT INTO categories (name) VALUES ('Lebensmittel')")
db.execSQL("INSERT INTO locations (name) VALUES ('Keller')")
db.execSQL(
"INSERT INTO items (id, name, category_id, quantity, unit, unit_price, " +
"kcal_per_unit, expiry_date, location_id, notes, last_updated) " +
"VALUES ('item-1', 'Wasser', 1, 10.0, 'Liter', 0.5, NULL, NULL, 1, '', 0)"
)
}
migrationTestHelper.runMigrationsAndValidate(dbName, 7, true)
.use { db ->
db.query("SELECT id, name FROM items").use { cursor ->
assertTrue("items Tabelle muss Datensätze haben", cursor.moveToFirst())
assertEquals("item-1", cursor.getString(0))
assertEquals("Wasser", cursor.getString(1))
}
db.query("SELECT name FROM categories").use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("Lebensmittel", cursor.getString(0))
}
db.query("SELECT name FROM locations").use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("Keller", cursor.getString(0))
}
}
}
@Test
fun freshInstall_allTablesExist() {
val db = Room.inMemoryDatabaseBuilder(context, BollwerkDatabase::class.java)
@ -79,3 +117,4 @@ internal class BollwerkDatabaseMigrationTest {
}
}
}

View file

@ -1,5 +1,6 @@
package de.bollwerk.app.data.db
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@ -18,8 +19,12 @@ import de.bollwerk.app.data.db.entity.SettingsEntity
@Database(
entities = [CategoryEntity::class, LocationEntity::class, ItemEntity::class, SettingsEntity::class, PendingSyncOpEntity::class, MessageEntity::class],
version = 6,
exportSchema = true
version = 7,
exportSchema = true,
autoMigrations = [
AutoMigration(from = 5, to = 6),
AutoMigration(from = 6, to = 7)
]
)
@TypeConverters(LocalDateConverter::class)
internal abstract class BollwerkDatabase : RoomDatabase() {

View file

@ -29,7 +29,6 @@ internal object DatabaseModule {
fun provideDatabase(@ApplicationContext context: Context): BollwerkDatabase =
Room.databaseBuilder(context, BollwerkDatabase::class.java, "bollwerk.db")
.addCallback(DefaultDataCallback)
.fallbackToDestructiveMigration()
.build()
private object DefaultDataCallback : RoomDatabase.Callback() {

View file

@ -24,6 +24,7 @@ h2 = "2.3.232"
postgresql = "42.7.4"
hikaricp = "6.2.1"
jbcrypt = "0.4"
flyway = "9.22.3"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -54,6 +55,7 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-
androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
flyway-core = { group = "org.flywaydb", name = "flyway-core", version.ref = "flyway" }
ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" }
ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty", version.ref = "ktor" }
ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" }

View file

@ -44,6 +44,7 @@ dependencies {
implementation(libs.exposed.jdbc)
implementation(libs.postgresql)
implementation(libs.hikaricp)
implementation(libs.flyway.core)
testImplementation(libs.h2.database)
testImplementation(libs.ktor.server.test.host)

View file

@ -2,6 +2,7 @@ package de.bollwerk.server.db
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import org.flywaydb.core.Flyway
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.insert
@ -37,7 +38,9 @@ internal object DatabaseFactory {
transactionIsolation = "TRANSACTION_REPEATABLE_READ"
validate()
}
Database.connect(HikariDataSource(config))
val dataSource = HikariDataSource(config)
runFlyway(dataSource, jdbcUrl, dbUser, dbPassword)
Database.connect(dataSource)
} else {
Database.connect(jdbcUrl, driver)
}
@ -49,9 +52,20 @@ internal object DatabaseFactory {
seedAdmin(adminPassword)
}
private fun runFlyway(dataSource: HikariDataSource, jdbcUrl: String, user: String, password: String) {
Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration")
.baselineOnMigrate(true)
.baselineVersion("1")
.load()
.migrate()
}
/**
* Migration: For each existing user without an inventoryId, create an inventory
* and migrate their data from the old user_id column to the new inventory_id column.
* The user_id column itself is cleaned up by Flyway V2__cleanup_user_id.sql.
*/
private fun migrateUserInventories() {
transaction {
@ -68,16 +82,15 @@ internal object DatabaseFactory {
Users.update({ Users.id eq userId }) {
it[Users.inventoryId] = inventoryId
}
// Migrate existing data from old user_id column to new inventory_id column.
// The exec() calls are wrapped in try/catch because the user_id column may not
// exist in fresh databases (only in upgraded databases from older schema).
// Migrate existing data: set inventory_id where still null for this user.
// Wrapped in try/catch because user_id column may already be dropped by Flyway V2.
try {
exec("UPDATE items SET inventory_id = '$inventoryId' WHERE user_id = '$userId' AND inventory_id IS NULL")
exec("UPDATE categories SET inventory_id = '$inventoryId' WHERE user_id = '$userId' AND inventory_id IS NULL")
exec("UPDATE locations SET inventory_id = '$inventoryId' WHERE user_id = '$userId' AND inventory_id IS NULL")
exec("UPDATE settings SET inventory_id = '$inventoryId' WHERE user_id = '$userId' AND inventory_id IS NULL")
} catch (_: Exception) {
// user_id column doesn't exist fresh database, nothing to migrate
// user_id column already dropped by V2 migration or doesn't exist on fresh DB
}
}
}

View file

@ -0,0 +1,79 @@
-- V1: Dokumentiert das initiale Datenbankschema (Baseline für bestehende Installationen).
-- Dieses Skript wird auf neuen Datenbanken ausgeführt. Bestehende Datenbanken werden
-- über Flyway baseline abgedeckt und führen dieses Skript NICHT aus.
CREATE TABLE IF NOT EXISTS inventories (
id VARCHAR(36) NOT NULL,
name VARCHAR(255) NOT NULL DEFAULT '',
created_at BIGINT NOT NULL,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(36) NOT NULL,
username VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at BIGINT NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
inventory_id VARCHAR(36),
PRIMARY KEY (id),
UNIQUE (username)
);
CREATE TABLE IF NOT EXISTS categories (
pk SERIAL,
id INTEGER NOT NULL,
name VARCHAR(255) NOT NULL,
inventory_id VARCHAR(36),
PRIMARY KEY (pk)
);
CREATE TABLE IF NOT EXISTS locations (
pk SERIAL,
id INTEGER NOT NULL,
name VARCHAR(255) NOT NULL,
inventory_id VARCHAR(36),
PRIMARY KEY (pk)
);
CREATE TABLE IF NOT EXISTS items (
id VARCHAR(36) NOT NULL,
name VARCHAR(255) NOT NULL,
category_id INTEGER NOT NULL,
quantity DOUBLE PRECISION NOT NULL,
unit VARCHAR(50) NOT NULL,
unit_price DOUBLE PRECISION NOT NULL,
kcal_per_unit INTEGER,
expiry_date VARCHAR(10),
location_id INTEGER NOT NULL,
notes TEXT NOT NULL,
last_updated BIGINT NOT NULL,
inventory_id VARCHAR(36),
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS settings (
id SERIAL,
key VARCHAR(255) NOT NULL,
value TEXT NOT NULL,
inventory_id VARCHAR(36),
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS messages (
id VARCHAR(36) NOT NULL,
sender_id VARCHAR(36) NOT NULL,
receiver_id VARCHAR(36) NOT NULL,
body TEXT NOT NULL,
sent_at BIGINT NOT NULL,
delivered_at BIGINT,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS deleted_items (
id SERIAL,
item_id VARCHAR(36) NOT NULL,
inventory_id VARCHAR(36) NOT NULL,
deleted_at BIGINT NOT NULL,
PRIMARY KEY (id)
);

View file

@ -0,0 +1,8 @@
-- V2: Bereinigt veraltete user_id-Spalten aus der Multi-Tenant-Migration.
-- Alle Daten wurden bereits über migrateUserInventories() nach inventory_id migriert.
-- IF EXISTS verhindert Fehler bei Neuinstallationen, bei denen user_id nie existierte.
ALTER TABLE items DROP COLUMN IF EXISTS user_id;
ALTER TABLE categories DROP COLUMN IF EXISTS user_id;
ALTER TABLE locations DROP COLUMN IF EXISTS user_id;
ALTER TABLE settings DROP COLUMN IF EXISTS user_id;