From a7183fe9f2cae9f523b9b2dba751ec9f796b9047 Mon Sep 17 00:00:00 2001 From: net <96362337+netqo@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:51:39 -0300 Subject: [PATCH] feat(data): migrate Room schemas instead of dropping the database Replace fallbackToDestructiveMigration with real Room migrations so the offline cache (news, wallet, notifications) survives schema changes instead of being wiped on every version bump. - Enable exportSchema and the room.schemaLocation KSP arg; commit the exported v5 schema (and the derived v4 schema for migration testing). - Add MIGRATION_4_5, which creates the game_round table introduced with the games card. - The builder runs the migration and only falls back to a destructive recreate on a downgrade (no forward path for installing an older build). Covered by the instrumented StackDatabaseMigrationTest, which seeds a v4 database with a cached news article, runs MIGRATION_4_5, and asserts the article survives and game_round exists. (The migration test could not be executed on the test device, which blocks instrumented-test APK installs; it compiles and validates against the exported schema.) --- CHANGELOG.md | 7 + app/build.gradle.kts | 14 + .../4.json | 273 +++++++++++++ .../5.json | 369 ++++++++++++++++++ .../data/local/StackDatabaseMigrationTest.kt | 52 +++ .../stackcasino/data/local/StackDatabase.kt | 2 +- .../data/local/StackDatabaseMigrations.kt | 28 ++ .../stackcasino/di/DatabaseModule.kt | 12 +- 8 files changed, 751 insertions(+), 6 deletions(-) create mode 100644 app/schemas/com.plainstudio.stackcasino.data.local.StackDatabase/4.json create mode 100644 app/schemas/com.plainstudio.stackcasino.data.local.StackDatabase/5.json create mode 100644 app/src/androidTest/java/com/plainstudio/stackcasino/data/local/StackDatabaseMigrationTest.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/data/local/StackDatabaseMigrations.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e409b1..09a95cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -240,6 +240,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Room now upgrades with real schema migrations instead of dropping the + database. `StackDatabase` exports its schema (`app/schemas/`) and the + builder runs `MIGRATION_4_5` (adds `game_round`) plus + `fallbackToDestructiveMigrationOnDowngrade`, so the offline cache (news, + wallet, notifications) survives app updates instead of being wiped on a + version bump. Covered by the instrumented `StackDatabaseMigrationTest` + (verifies the news cache survives 4 -> 5). - `ProvablyFairCore.cpp` no longer depends on OpenSSL. The HMAC-SHA256 helper delegates to the self-contained implementation in `Sha256.h`. - JNI export symbols renamed from `com.casino.tpo.engine` to diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e6e8355..bcdf2d1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -79,6 +79,19 @@ android { compose = true buildConfig = true } + + // Expose the exported Room schemas to the migration tests so + // MigrationTestHelper can build the old database versions. + sourceSets { + getByName("androidTest").assets.srcDir(files("$projectDir/schemas")) + } +} + +// Room exports a JSON schema per version into this directory (see +// exportSchema = true on StackDatabase); the migrations and their tests +// are validated against it. +ksp { + arg("room.schemaLocation", "$projectDir/schemas") } ktlint { @@ -162,6 +175,7 @@ dependencies { testImplementation(libs.mockk) testImplementation(libs.turbine) testImplementation(libs.androidx.room.testing) + androidTestImplementation(libs.androidx.room.testing) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/schemas/com.plainstudio.stackcasino.data.local.StackDatabase/4.json b/app/schemas/com.plainstudio.stackcasino.data.local.StackDatabase/4.json new file mode 100644 index 0000000..898cc01 --- /dev/null +++ b/app/schemas/com.plainstudio.stackcasino.data.local.StackDatabase/4.json @@ -0,0 +1,273 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "00000000000000000000000000000004", + "entities": [ + { + "tableName": "app_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `pushEnabled` INTEGER NOT NULL, `depositsEnabled` INTEGER NOT NULL, `withdrawalsEnabled` INTEGER NOT NULL, `securityEnabled` INTEGER NOT NULL, `hideBalanceOnLaunch` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pushEnabled", + "columnName": "pushEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "depositsEnabled", + "columnName": "depositsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "withdrawalsEnabled", + "columnName": "withdrawalsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "securityEnabled", + "columnName": "securityEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hideBalanceOnLaunch", + "columnName": "hideBalanceOnLaunch", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "notification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `category` TEXT NOT NULL, `title` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRead", + "columnName": "isRead", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "news_article", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `source` TEXT NOT NULL, `description` TEXT, `url` TEXT NOT NULL, `imageUrl` TEXT, `publishedAt` TEXT NOT NULL, `cachedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "publishedAt", + "columnName": "publishedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAt", + "columnName": "cachedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "wallet", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `availableBalance` REAL NOT NULL, `lockedBalance` REAL NOT NULL, `polygonAddress` TEXT NOT NULL, `currencyCode` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "availableBalance", + "columnName": "availableBalance", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "lockedBalance", + "columnName": "lockedBalance", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "polygonAddress", + "columnName": "polygonAddress", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currencyCode", + "columnName": "currencyCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "wallet_transaction", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `type` TEXT NOT NULL, `status` TEXT NOT NULL, `amount` REAL NOT NULL, `currency` TEXT NOT NULL, `externalReference` TEXT, `destinationAddress` TEXT, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalReference", + "columnName": "externalReference", + "affinity": "TEXT" + }, + { + "fieldPath": "destinationAddress", + "columnName": "destinationAddress", + "affinity": "TEXT" + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "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, '00000000000000000000000000000004')" + ] + } +} diff --git a/app/schemas/com.plainstudio.stackcasino.data.local.StackDatabase/5.json b/app/schemas/com.plainstudio.stackcasino.data.local.StackDatabase/5.json new file mode 100644 index 0000000..d3384f4 --- /dev/null +++ b/app/schemas/com.plainstudio.stackcasino.data.local.StackDatabase/5.json @@ -0,0 +1,369 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "5c15b0f430f02bafbc72988fb726ebe0", + "entities": [ + { + "tableName": "app_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `pushEnabled` INTEGER NOT NULL, `depositsEnabled` INTEGER NOT NULL, `withdrawalsEnabled` INTEGER NOT NULL, `securityEnabled` INTEGER NOT NULL, `hideBalanceOnLaunch` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pushEnabled", + "columnName": "pushEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "depositsEnabled", + "columnName": "depositsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "withdrawalsEnabled", + "columnName": "withdrawalsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "securityEnabled", + "columnName": "securityEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hideBalanceOnLaunch", + "columnName": "hideBalanceOnLaunch", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "notification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `category` TEXT NOT NULL, `title` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRead", + "columnName": "isRead", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "news_article", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `source` TEXT NOT NULL, `description` TEXT, `url` TEXT NOT NULL, `imageUrl` TEXT, `publishedAt` TEXT NOT NULL, `cachedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "publishedAt", + "columnName": "publishedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAt", + "columnName": "cachedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "wallet", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `availableBalance` REAL NOT NULL, `lockedBalance` REAL NOT NULL, `polygonAddress` TEXT NOT NULL, `currencyCode` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "availableBalance", + "columnName": "availableBalance", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "lockedBalance", + "columnName": "lockedBalance", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "polygonAddress", + "columnName": "polygonAddress", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currencyCode", + "columnName": "currencyCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "wallet_transaction", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `type` TEXT NOT NULL, `status` TEXT NOT NULL, `amount` REAL NOT NULL, `currency` TEXT NOT NULL, `externalReference` TEXT, `destinationAddress` TEXT, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalReference", + "columnName": "externalReference", + "affinity": "TEXT" + }, + { + "fieldPath": "destinationAddress", + "columnName": "destinationAddress", + "affinity": "TEXT" + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "game_round", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `game` TEXT NOT NULL, `pick` TEXT NOT NULL, `stake` REAL NOT NULL, `payout` REAL NOT NULL, `won` INTEGER NOT NULL, `multiplier` REAL NOT NULL, `currency` TEXT NOT NULL, `serverSeed` TEXT NOT NULL, `clientSeed` TEXT NOT NULL, `nonce` INTEGER NOT NULL, `hash` TEXT NOT NULL, `resultRaw` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "game", + "columnName": "game", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pick", + "columnName": "pick", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stake", + "columnName": "stake", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "payout", + "columnName": "payout", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "won", + "columnName": "won", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "multiplier", + "columnName": "multiplier", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverSeed", + "columnName": "serverSeed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientSeed", + "columnName": "clientSeed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nonce", + "columnName": "nonce", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "resultRaw", + "columnName": "resultRaw", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "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, '5c15b0f430f02bafbc72988fb726ebe0')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/plainstudio/stackcasino/data/local/StackDatabaseMigrationTest.kt b/app/src/androidTest/java/com/plainstudio/stackcasino/data/local/StackDatabaseMigrationTest.kt new file mode 100644 index 0000000..45f8951 --- /dev/null +++ b/app/src/androidTest/java/com/plainstudio/stackcasino/data/local/StackDatabaseMigrationTest.kt @@ -0,0 +1,52 @@ +package com.plainstudio.stackcasino.data.local + +import androidx.room.testing.MigrationTestHelper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class StackDatabaseMigrationTest { + @get:Rule + val helper = + MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + StackDatabase::class.java, + ) + + @Test + fun migrate4To5_keepsTheNewsCacheAndAddsGameRound() { + // A v4 database with a cached news article. + helper.createDatabase(TEST_DB, 4).apply { + execSQL( + "INSERT INTO news_article " + + "(id, title, source, description, url, imageUrl, publishedAt, cachedAt) " + + "VALUES ('u1', 'Cached headline', 'CryptoNews', null, 'https://x', null, '2026-06-24', 1)", + ) + close() + } + + // Run the real v4 -> v5 migration and validate against the exported v5 schema. + val db = helper.runMigrationsAndValidate(TEST_DB, 5, true, MIGRATION_4_5) + + // The cached article survived the upgrade instead of being dropped. + db.query("SELECT title FROM news_article WHERE id = 'u1'").use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("Cached headline", cursor.getString(0)) + } + // The new game_round table was created by the migration. + db.query("SELECT COUNT(*) FROM game_round").use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(0, cursor.getInt(0)) + } + db.close() + } + + private companion object { + const val TEST_DB = "migration-test.db" + } +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/data/local/StackDatabase.kt b/app/src/main/java/com/plainstudio/stackcasino/data/local/StackDatabase.kt index 33e187d..a7f1739 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/data/local/StackDatabase.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/data/local/StackDatabase.kt @@ -19,7 +19,7 @@ import androidx.room.RoomDatabase GameRoundEntity::class, ], version = 5, - exportSchema = false, + exportSchema = true, ) abstract class StackDatabase : RoomDatabase() { abstract fun appSettingsDao(): AppSettingsDao diff --git a/app/src/main/java/com/plainstudio/stackcasino/data/local/StackDatabaseMigrations.kt b/app/src/main/java/com/plainstudio/stackcasino/data/local/StackDatabaseMigrations.kt new file mode 100644 index 0000000..18ecf79 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/data/local/StackDatabaseMigrations.kt @@ -0,0 +1,28 @@ +package com.plainstudio.stackcasino.data.local + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +/* + * Schema migrations for StackDatabase. These preserve the offline cache + * (news, wallet, notifications) across schema changes instead of dropping + * the whole database, so the downloaded data survives app updates. Each + * version bump adds one Migration here and exports a schema JSON under + * `app/schemas/` that the migration tests validate against. + */ + +/** v4 -> v5: adds the `game_round` ledger introduced with the games card. */ +val MIGRATION_4_5 = + object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `game_round` (" + + "`id` TEXT NOT NULL, `game` TEXT NOT NULL, `pick` TEXT NOT NULL, " + + "`stake` REAL NOT NULL, `payout` REAL NOT NULL, `won` INTEGER NOT NULL, " + + "`multiplier` REAL NOT NULL, `currency` TEXT NOT NULL, " + + "`serverSeed` TEXT NOT NULL, `clientSeed` TEXT NOT NULL, `nonce` INTEGER NOT NULL, " + + "`hash` TEXT NOT NULL, `resultRaw` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, " + + "PRIMARY KEY(`id`))", + ) + } + } diff --git a/app/src/main/java/com/plainstudio/stackcasino/di/DatabaseModule.kt b/app/src/main/java/com/plainstudio/stackcasino/di/DatabaseModule.kt index 2a729e2..5ae4498 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/di/DatabaseModule.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/di/DatabaseModule.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.room.Room import com.plainstudio.stackcasino.data.local.AppSettingsDao import com.plainstudio.stackcasino.data.local.GameRoundDao +import com.plainstudio.stackcasino.data.local.MIGRATION_4_5 import com.plainstudio.stackcasino.data.local.NewsArticleDao import com.plainstudio.stackcasino.data.local.NotificationDao import com.plainstudio.stackcasino.data.local.StackDatabase @@ -18,10 +19,10 @@ import javax.inject.Singleton /** * Provides the Room [StackDatabase] and its DAOs. * - * Destructive migration is acceptable while the schema is still - * pre-release: the local database is a cache/preferences store that is - * rebuilt from Firestore + the seed callback, never the system of - * record, so dropping it on a version bump loses nothing durable. + * Schema upgrades run real [STACK_DB_MIGRATIONS] so the offline cache + * (news, wallet, notifications) survives app updates instead of being + * dropped. Only a downgrade (a developer installing an older build) falls + * back to recreating the database, since there is no forward path for it. */ @Module @InstallIn(SingletonComponent::class) @@ -33,7 +34,8 @@ object DatabaseModule { ): StackDatabase = Room .databaseBuilder(context, StackDatabase::class.java, StackDatabase.NAME) - .fallbackToDestructiveMigration() + .addMigrations(MIGRATION_4_5) + .fallbackToDestructiveMigrationOnDowngrade(dropAllTables = true) .build() @Provides