From ad03ec2b98834d3022e0e7b6efc37f8f8c9fe8bb Mon Sep 17 00:00:00 2001 From: raheemjnr Date: Thu, 30 Apr 2026 14:04:58 +0100 Subject: [PATCH 1/3] test(db): walking-migration regression guard + Room schema export (#142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-existing MigrationTest used Room.inMemoryDatabaseBuilder which creates the schema directly from the current entity declarations and never exercises Migration.migrate code paths. So when v1.5.1's entity declarations drifted from what the migrations produced, the in-memory test passed but real upgraders crashed at launch (#141, #143). Adds: 1. Room schema export (exportSchema = true + ksp arg pointing at app/schemas/). Generates a versioned JSON dump per @Database version. v9 committed in this PR. Future schema drift becomes visible in PR diffs. 2. WalkingMigrationTest: bootstraps a SQLite file at v8 with a known historical shape, opens it via Room.databaseBuilder, and lets Room run MIGRATION_8_9 + validate the result against entity declarations. Two test methods cover the divergent v8 shapes that existed in the wild after v1.5.1: - Path A (upgrade-from-v1.5.0): walletId DEFAULT '' + partial idx_tx_pending already on disk. - Path B (fresh-install-on-v1.5.1): neither. Both must converge to v9 successfully. 3. Regression-guard test that registers a no-op MIGRATION_8_9 (what the first cut of #143 shipped) and asserts path B fails with the exact 'Migration didn't properly handle' exception that surfaced in production. Permanent in-codebase guard against the test losing its detection power. Note on scope: the JVM/Robolectric harness here covers the v8→v9 upgrade path because that is where the historical fork lives. Walking from v1 forward is harder without an exported schema for v1 and is tracked in #144 Phase 2 (instrumented MigrationTestHelper). --- android/app/build.gradle.kts | 8 + .../9.json | 643 ++++++++++++++++++ .../pocketnode/data/database/AppDatabase.kt | 6 +- .../data/database/WalkingMigrationTest.kt | 370 ++++++++++ 4 files changed, 1026 insertions(+), 1 deletion(-) create mode 100644 android/app/schemas/com.rjnr.pocketnode.data.database.AppDatabase/9.json create mode 100644 android/app/src/test/java/com/rjnr/pocketnode/data/database/WalkingMigrationTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index bb2b532..073129a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -24,6 +24,14 @@ android { ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") } + + // Room schema export: dump every database version's schema to JSON + // so reviewers can see schema drift in PR diffs and so the migration + // tests can validate against an explicit reference. The schemas live + // under app/schemas//.json. (#142) + ksp { + arg("room.schemaLocation", "$projectDir/schemas") + } } testOptions { diff --git a/android/app/schemas/com.rjnr.pocketnode.data.database.AppDatabase/9.json b/android/app/schemas/com.rjnr.pocketnode.data.database.AppDatabase/9.json new file mode 100644 index 0000000..497e37c --- /dev/null +++ b/android/app/schemas/com.rjnr.pocketnode.data.database.AppDatabase/9.json @@ -0,0 +1,643 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "5c3585c27257d8b0a1a3119c4e50e1ac", + "entities": [ + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`txHash` TEXT NOT NULL, `blockNumber` TEXT NOT NULL, `blockHash` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `balanceChange` TEXT NOT NULL, `direction` TEXT NOT NULL, `fee` TEXT NOT NULL, `confirmations` INTEGER NOT NULL, `blockTimestampHex` TEXT, `network` TEXT NOT NULL, `status` TEXT NOT NULL, `isLocal` INTEGER NOT NULL, `cachedAt` INTEGER NOT NULL, `walletId` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`txHash`))", + "fields": [ + { + "fieldPath": "txHash", + "columnName": "txHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockNumber", + "columnName": "blockNumber", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockHash", + "columnName": "blockHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balanceChange", + "columnName": "balanceChange", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "confirmations", + "columnName": "confirmations", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockTimestampHex", + "columnName": "blockTimestampHex", + "affinity": "TEXT" + }, + { + "fieldPath": "network", + "columnName": "network", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cachedAt", + "columnName": "cachedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "txHash" + ] + }, + "indices": [ + { + "name": "idx_tx_wallet_network_time", + "unique": false, + "columnNames": [ + "walletId", + "network", + "timestamp" + ], + "orders": [ + "ASC", + "ASC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_tx_wallet_network_time` ON `${TABLE_NAME}` (`walletId` ASC, `network` ASC, `timestamp` DESC)" + }, + { + "name": "idx_tx_pending", + "unique": false, + "columnNames": [ + "walletId", + "network", + "timestamp" + ], + "orders": [ + "ASC", + "ASC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_tx_pending` ON `${TABLE_NAME}` (`walletId` ASC, `network` ASC, `timestamp` DESC)" + } + ] + }, + { + "tableName": "balance_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` TEXT NOT NULL DEFAULT '', `network` TEXT NOT NULL, `address` TEXT NOT NULL, `capacity` TEXT NOT NULL, `capacityCkb` TEXT NOT NULL, `blockNumber` TEXT NOT NULL, `cachedAt` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `network`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "network", + "columnName": "network", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "capacity", + "columnName": "capacity", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "capacityCkb", + "columnName": "capacityCkb", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockNumber", + "columnName": "blockNumber", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAt", + "columnName": "cachedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "network" + ] + } + }, + { + "tableName": "header_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`blockHash` TEXT NOT NULL, `number` TEXT NOT NULL, `epoch` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `dao` TEXT NOT NULL, `network` TEXT NOT NULL, `cachedAt` INTEGER NOT NULL, PRIMARY KEY(`blockHash`))", + "fields": [ + { + "fieldPath": "blockHash", + "columnName": "blockHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epoch", + "columnName": "epoch", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dao", + "columnName": "dao", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "network", + "columnName": "network", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAt", + "columnName": "cachedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "blockHash" + ] + }, + "indices": [ + { + "name": "idx_header_network_number", + "unique": false, + "columnNames": [ + "network", + "number" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_header_network_number` ON `${TABLE_NAME}` (`network`, `number`)" + } + ] + }, + { + "tableName": "dao_cells", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`txHash` TEXT NOT NULL, `index` TEXT NOT NULL, `capacity` INTEGER NOT NULL, `status` TEXT NOT NULL, `depositBlockNumber` INTEGER NOT NULL, `depositBlockHash` TEXT NOT NULL, `depositEpochHex` TEXT, `withdrawBlockNumber` INTEGER, `withdrawBlockHash` TEXT, `withdrawEpochHex` TEXT, `compensation` INTEGER NOT NULL, `unlockEpochHex` TEXT, `depositTimestamp` INTEGER NOT NULL, `network` TEXT NOT NULL, `lastUpdatedAt` INTEGER NOT NULL, `walletId` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`txHash`, `index`))", + "fields": [ + { + "fieldPath": "txHash", + "columnName": "txHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "capacity", + "columnName": "capacity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "depositBlockNumber", + "columnName": "depositBlockNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "depositBlockHash", + "columnName": "depositBlockHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "depositEpochHex", + "columnName": "depositEpochHex", + "affinity": "TEXT" + }, + { + "fieldPath": "withdrawBlockNumber", + "columnName": "withdrawBlockNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "withdrawBlockHash", + "columnName": "withdrawBlockHash", + "affinity": "TEXT" + }, + { + "fieldPath": "withdrawEpochHex", + "columnName": "withdrawEpochHex", + "affinity": "TEXT" + }, + { + "fieldPath": "compensation", + "columnName": "compensation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unlockEpochHex", + "columnName": "unlockEpochHex", + "affinity": "TEXT" + }, + { + "fieldPath": "depositTimestamp", + "columnName": "depositTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "network", + "columnName": "network", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdatedAt", + "columnName": "lastUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "txHash", + "index" + ] + }, + "indices": [ + { + "name": "idx_dao_wallet_network", + "unique": false, + "columnNames": [ + "walletId", + "network" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_dao_wallet_network` ON `${TABLE_NAME}` (`walletId`, `network`)" + } + ] + }, + { + "tableName": "wallets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `derivationPath` TEXT, `parentWalletId` TEXT, `accountIndex` INTEGER NOT NULL, `mainnetAddress` TEXT NOT NULL, `testnetAddress` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastActiveAt` INTEGER NOT NULL, `colorIndex` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT" + }, + { + "fieldPath": "parentWalletId", + "columnName": "parentWalletId", + "affinity": "TEXT" + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mainnetAddress", + "columnName": "mainnetAddress", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "testnetAddress", + "columnName": "testnetAddress", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastActiveAt", + "columnName": "lastActiveAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "colorIndex", + "columnName": "colorIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + } + }, + { + "tableName": "key_material", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` TEXT NOT NULL, `encryptedPrivateKey` BLOB NOT NULL, `encryptedMnemonic` BLOB, `iv` BLOB NOT NULL, `walletType` TEXT NOT NULL, `mnemonicBackedUp` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedPrivateKey", + "columnName": "encryptedPrivateKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptedMnemonic", + "columnName": "encryptedMnemonic", + "affinity": "BLOB" + }, + { + "fieldPath": "iv", + "columnName": "iv", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletType", + "columnName": "walletType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mnemonicBackedUp", + "columnName": "mnemonicBackedUp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + } + }, + { + "tableName": "sync_progress", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` TEXT NOT NULL, `network` TEXT NOT NULL, `lightStartBlockNumber` INTEGER NOT NULL, `localSavedBlockNumber` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `network`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "network", + "columnName": "network", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightStartBlockNumber", + "columnName": "lightStartBlockNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSavedBlockNumber", + "columnName": "localSavedBlockNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "network" + ] + } + }, + { + "tableName": "pending_broadcasts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`txHash` TEXT NOT NULL, `walletId` TEXT NOT NULL, `network` TEXT NOT NULL, `signedTxJson` TEXT NOT NULL, `reservedInputs` TEXT NOT NULL, `state` TEXT NOT NULL, `submittedAtTipBlock` INTEGER NOT NULL, `nullCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastCheckedAt` INTEGER NOT NULL, PRIMARY KEY(`txHash`))", + "fields": [ + { + "fieldPath": "txHash", + "columnName": "txHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "network", + "columnName": "network", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signedTxJson", + "columnName": "signedTxJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reservedInputs", + "columnName": "reservedInputs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "submittedAtTipBlock", + "columnName": "submittedAtTipBlock", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nullCount", + "columnName": "nullCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckedAt", + "columnName": "lastCheckedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "txHash" + ] + }, + "indices": [ + { + "name": "idx_pb_wallet_net_state", + "unique": false, + "columnNames": [ + "walletId", + "network", + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_pb_wallet_net_state` ON `${TABLE_NAME}` (`walletId`, `network`, `state`)" + } + ] + } + ], + "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, '5c3585c27257d8b0a1a3119c4e50e1ac')" + ] + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/rjnr/pocketnode/data/database/AppDatabase.kt b/android/app/src/main/java/com/rjnr/pocketnode/data/database/AppDatabase.kt index 8fd84ea..073d95d 100644 --- a/android/app/src/main/java/com/rjnr/pocketnode/data/database/AppDatabase.kt +++ b/android/app/src/main/java/com/rjnr/pocketnode/data/database/AppDatabase.kt @@ -37,7 +37,11 @@ import com.rjnr.pocketnode.data.database.entity.WalletEntity // so MIGRATION_8_9 is a no-op. Version bump alone is needed to refresh // Room's stored identity hash after the entity declarations changed. (#90 / #141) version = 9, - exportSchema = false + // Schema export ON (#142). Generates app/schemas//9.json on every + // build. Diff visible in PR review when entity declarations or migration + // SQL drifts. The walking-migration test under src/test/ validates that + // the entity declarations agree with what the migrations produce on disk. + exportSchema = true ) abstract class AppDatabase : RoomDatabase() { abstract fun transactionDao(): TransactionDao diff --git a/android/app/src/test/java/com/rjnr/pocketnode/data/database/WalkingMigrationTest.kt b/android/app/src/test/java/com/rjnr/pocketnode/data/database/WalkingMigrationTest.kt new file mode 100644 index 0000000..c4df9c2 --- /dev/null +++ b/android/app/src/test/java/com/rjnr/pocketnode/data/database/WalkingMigrationTest.kt @@ -0,0 +1,370 @@ +package com.rjnr.pocketnode.data.database + +import android.content.Context +import androidx.room.Room +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.room.migration.Migration +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.core.app.ApplicationProvider +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +/** + * Walking-migration regression guard for the v1.5.1 / v1.5.2 hotfix shapes (#141, #143). + * + * The existing [MigrationTest] uses [Room.inMemoryDatabaseBuilder] which + * builds the schema directly from the current entity declarations and + * never exercises [Migration.migrate] code paths. So when the v1.5.1 + * entity declarations drifted from what the migrations produced on disk, + * the in-memory test passed but real upgraders crashed at launch. + * + * This test class drives Room down the actual upgrade path: it creates a + * SQLite file with a known historical shape, then opens it via + * [Room.databaseBuilder] with the migrations registered. Room runs + * `onUpgrade` (which executes the migrations) and then validates the + * resulting schema against the entity declarations. If any drift exists + * between migration output and entity declarations, validation throws + * [IllegalStateException] and the test fails. + * + * Two divergent v8 shapes existed in the wild after v1.5.1: + * + * - **Path A (upgrade from v1.5.0):** ran [MIGRATION_2_3] (which adds + * walletId with `DEFAULT ''`) and [MIGRATION_5_6] (which creates a + * partial `idx_tx_pending`). Both present. + * - **Path B (fresh install on v1.5.1):** Room created v8 directly + * from v1.5.1's broken entity declarations. Neither present. + * + * Both paths must converge to v9 successfully. The dedicated tests below + * cover each. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class WalkingMigrationTest { + + private val ctx: Context get() = ApplicationProvider.getApplicationContext() + private val dbName = "walking-migration-test.db" + private var openedRoomDb: AppDatabase? = null + + @After + fun teardown() { + openedRoomDb?.close() + openedRoomDb = null + ctx.deleteDatabase(dbName) + } + + /** + * v1.5.0 upgrader: migrations 1→8 produced this exact shape. + * - `transactions.walletId TEXT NOT NULL DEFAULT ''` + * - `idx_tx_pending` exists (partial, with `WHERE status = 'PENDING'`) + * - `balance_cache` and `dao_cells` walletId also `DEFAULT ''` + * MIGRATION_8_9 should be idempotent here. + */ + @Test + fun `path A v8 walks to v9 successfully`() { + bootstrapV8(pathA = true) + openViaRoomAndValidate() + } + + /** + * v1.5.1 fresh installer: Room created v8 schema directly from + * v1.5.1's entity declarations, which lacked both the partial index + * and the `DEFAULT ''` annotations. MIGRATION_8_9 must add the + * missing pieces. + */ + @Test + fun `path B v8 walks to v9 successfully`() { + bootstrapV8(pathA = false) + openViaRoomAndValidate() + } + + /** + * Regression guard for the v1.5.1 → v1.5.2 hotfix (#143). + * + * If `MIGRATION_8_9` is reverted to a no-op (which is what the first + * cut of the hotfix shipped — sufficient for path A but not path B), + * a fresh-install-on-v1.5.1 user crashes on launch. This test + * confirms the failure surfaces as an `IllegalStateException` from + * Room's schema validation. If this test ever stops failing under a + * no-op migration, it means schema validation is no longer catching + * the drift, and we have lost our regression guard. + */ + @Test + fun `path B fails loudly when MIGRATION_8_9 is a no-op`() { + val noOpMigration8To9 = object : Migration(8, 9) { + override fun migrate(db: SupportSQLiteDatabase) { + // No-op — what the first cut of #143 shipped. Path A users + // were fine because their on-disk shape already matched the + // entity declarations, but path B users had neither + // idx_tx_pending nor walletId DEFAULT '' and crashed. + } + } + bootstrapV8(pathA = false) + try { + val db = Room.databaseBuilder(ctx, AppDatabase::class.java, dbName) + .addMigrations( + MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, + MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, noOpMigration8To9, + ) + .build() + openedRoomDb = db + db.openHelper.writableDatabase + fail("Expected schema validation to fail with a no-op MIGRATION_8_9") + } catch (e: IllegalStateException) { + assertTrue( + "Expected the failure to mention table-info mismatch, got: ${e.message}", + e.message?.contains("Migration didn't properly handle") == true, + ) + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Opens the on-disk DB via Room with all migrations registered. Room + * runs the migration sequence to bring the stored version up to the + * current `@Database` version, then validates the schema against the + * entity declarations. Fetching the writable database forces the + * `onUpgrade` + validation path. + */ + private fun openViaRoomAndValidate() { + val db = Room.databaseBuilder(ctx, AppDatabase::class.java, dbName) + .addMigrations( + MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, + MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, + ) + .build() + openedRoomDb = db + // Force open. Validation runs eagerly during the first connection, + // so any TableInfo mismatch surfaces here. + db.openHelper.writableDatabase + } + + /** + * Creates a SQLite file at version 8 with either the post-upgrade + * shape (path A) or the fresh-install-on-v1.5.1 shape (path B). The + * helper uses Android's [SupportSQLiteOpenHelper] and reports + * version 8 so when Room subsequently opens it at version 9, + * `onUpgrade` fires and runs MIGRATION_8_9. + */ + private fun bootstrapV8(pathA: Boolean) { + val factory = FrameworkSQLiteOpenHelperFactory() + val callback = object : SupportSQLiteOpenHelper.Callback(8) { + override fun onCreate(db: SupportSQLiteDatabase) { + createV8Tables(db, pathA) + // Room writes its own bookkeeping table on first open. Mirror + // that here so Room's identity-hash check on the subsequent + // open doesn't complain about a missing room_master_table. + db.execSQL("CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)") + db.execSQL("INSERT OR REPLACE INTO room_master_table VALUES(42, 'bootstrap-v8')") + } + override fun onUpgrade(db: SupportSQLiteDatabase, oldV: Int, newV: Int) = Unit + } + val config = SupportSQLiteOpenHelper.Configuration.builder(ctx) + .name(dbName) + .callback(callback) + .build() + val helper = factory.create(config) + helper.writableDatabase.close() + helper.close() + } + + /** + * Run-out of the v8 schema. `pathA = true` matches what migrations + * 1→8 produced for an upgrader from v1.5.0. `pathA = false` matches + * what Room created from v1.5.1's broken entity declarations on a + * fresh install. + * + * Differences between the two: + * - `transactions.walletId`: DEFAULT '' (path A) vs no default (path B) + * - `idx_tx_pending`: present partial (path A) vs absent (path B) + * - `balance_cache.walletId`: DEFAULT '' (path A) vs no default (path B) + * - `dao_cells.walletId`: DEFAULT '' (path A) vs no default (path B) + */ + private fun createV8Tables(db: SupportSQLiteDatabase, pathA: Boolean) { + val walletIdDefault = if (pathA) " DEFAULT ''" else "" + + // transactions + db.execSQL( + """ + CREATE TABLE `transactions` ( + `txHash` TEXT NOT NULL, + `blockNumber` TEXT NOT NULL, + `blockHash` TEXT NOT NULL, + `timestamp` INTEGER NOT NULL, + `balanceChange` TEXT NOT NULL, + `direction` TEXT NOT NULL, + `fee` TEXT NOT NULL, + `confirmations` INTEGER NOT NULL, + `blockTimestampHex` TEXT, + `network` TEXT NOT NULL, + `status` TEXT NOT NULL, + `isLocal` INTEGER NOT NULL, + `cachedAt` INTEGER NOT NULL, + `walletId` TEXT NOT NULL$walletIdDefault, + PRIMARY KEY(`txHash`) + ) + """.trimIndent() + ) + db.execSQL( + "CREATE INDEX `idx_tx_wallet_network_time` " + + "ON `transactions` (`walletId`, `network`, `timestamp` DESC)" + ) + if (pathA) { + // Partial index, exactly as MIGRATION_5_6 created it. + db.execSQL( + "CREATE INDEX `idx_tx_pending` " + + "ON `transactions` (`walletId`, `network`, `timestamp` DESC) " + + "WHERE `status` = 'PENDING'" + ) + } + + // balance_cache + db.execSQL( + """ + CREATE TABLE `balance_cache` ( + `walletId` TEXT NOT NULL$walletIdDefault, + `network` TEXT NOT NULL, + `address` TEXT NOT NULL, + `capacity` TEXT NOT NULL, + `capacityCkb` TEXT NOT NULL, + `blockNumber` TEXT NOT NULL, + `cachedAt` INTEGER NOT NULL, + PRIMARY KEY(`walletId`, `network`) + ) + """.trimIndent() + ) + + // dao_cells + db.execSQL( + """ + CREATE TABLE `dao_cells` ( + `txHash` TEXT NOT NULL, + `index` TEXT NOT NULL, + `capacity` INTEGER NOT NULL, + `status` TEXT NOT NULL, + `depositBlockNumber` INTEGER NOT NULL, + `depositBlockHash` TEXT NOT NULL, + `depositEpochHex` TEXT, + `withdrawBlockNumber` INTEGER, + `withdrawBlockHash` TEXT, + `withdrawEpochHex` TEXT, + `compensation` INTEGER NOT NULL, + `unlockEpochHex` TEXT, + `depositTimestamp` INTEGER NOT NULL, + `network` TEXT NOT NULL, + `lastUpdatedAt` INTEGER NOT NULL, + `walletId` TEXT NOT NULL$walletIdDefault, + PRIMARY KEY(`txHash`, `index`) + ) + """.trimIndent() + ) + db.execSQL( + "CREATE INDEX `idx_dao_wallet_network` " + + "ON `dao_cells` (`walletId`, `network`)" + ) + + // header_cache. Entity declares PK on blockHash only (not composite) + // and an index on (network, number). + db.execSQL( + """ + CREATE TABLE `header_cache` ( + `blockHash` TEXT NOT NULL, + `number` TEXT NOT NULL, + `epoch` TEXT NOT NULL, + `timestamp` TEXT NOT NULL, + `dao` TEXT NOT NULL, + `network` TEXT NOT NULL, + `cachedAt` INTEGER NOT NULL, + PRIMARY KEY(`blockHash`) + ) + """.trimIndent() + ) + db.execSQL( + "CREATE INDEX `idx_header_network_number` " + + "ON `header_cache` (`network`, `number`)" + ) + + // wallets (M3, MIGRATION_2_3). + db.execSQL( + """ + CREATE TABLE `wallets` ( + `walletId` TEXT NOT NULL, + `name` TEXT NOT NULL, + `type` TEXT NOT NULL, + `derivationPath` TEXT, + `parentWalletId` TEXT, + `accountIndex` INTEGER NOT NULL, + `mainnetAddress` TEXT NOT NULL, + `testnetAddress` TEXT NOT NULL, + `isActive` INTEGER NOT NULL, + `createdAt` INTEGER NOT NULL, + `lastActiveAt` INTEGER NOT NULL, + `colorIndex` INTEGER NOT NULL, + PRIMARY KEY(`walletId`) + ) + """.trimIndent() + ) + + // key_material (MIGRATION_4_5). + db.execSQL( + """ + CREATE TABLE `key_material` ( + `walletId` TEXT NOT NULL, + `encryptedPrivateKey` BLOB NOT NULL, + `encryptedMnemonic` BLOB, + `iv` BLOB NOT NULL, + `walletType` TEXT NOT NULL, + `mnemonicBackedUp` INTEGER NOT NULL DEFAULT 0, + `updatedAt` INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(`walletId`) + ) + """.trimIndent() + ) + + // sync_progress (MIGRATION_6_7). + db.execSQL( + """ + CREATE TABLE `sync_progress` ( + `walletId` TEXT NOT NULL, + `network` TEXT NOT NULL, + `lightStartBlockNumber` INTEGER NOT NULL, + `localSavedBlockNumber` INTEGER NOT NULL, + `updatedAt` INTEGER NOT NULL, + PRIMARY KEY(`walletId`, `network`) + ) + """.trimIndent() + ) + + // pending_broadcasts (MIGRATION_7_8). + db.execSQL( + """ + CREATE TABLE `pending_broadcasts` ( + `txHash` TEXT NOT NULL, + `walletId` TEXT NOT NULL, + `network` TEXT NOT NULL, + `signedTxJson` TEXT NOT NULL, + `reservedInputs` TEXT NOT NULL, + `state` TEXT NOT NULL, + `submittedAtTipBlock` INTEGER NOT NULL, + `nullCount` INTEGER NOT NULL, + `createdAt` INTEGER NOT NULL, + `lastCheckedAt` INTEGER NOT NULL, + PRIMARY KEY(`txHash`) + ) + """.trimIndent() + ) + db.execSQL( + "CREATE INDEX `idx_pb_wallet_net_state` " + + "ON `pending_broadcasts` (`walletId`, `network`, `state`)" + ) + } +} From e1b7df02b212f51ce8324c172b045db59c000754 Mon Sep 17 00:00:00 2001 From: raheemjnr Date: Thu, 30 Apr 2026 14:49:18 +0100 Subject: [PATCH 2/3] fix(ci): don't commit Room schema JSONs (kotlinx-serialization conflict) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's clean build hit AbstractMethodError in Room's SchemaBundle.$serializer because Room 2.8.4's bundled kotlinx-serialization-core is binary-incompatible with the project's kotlinx-serialization-json:1.8.0 on a fresh classpath. Local builds didn't repro because of cached classloaders. When a schema JSON exists at the target version, Room's processor deserializes it to diff against the new export. With no JSON present, Room only writes a new one, no deserialize path, no crash. Workaround: don't commit the JSONs. They're still generated on every build (locally and in CI), they just don't get persisted. The walking-migration test doesn't depend on them — it validates against entity declarations directly. Trade-off lost: schema diffs aren't visible in PR review. We get them back when we either upgrade Room to a version compiled against kotlinx-serialization 1.8.x, or downgrade kotlinx-serialization-json to 1.7.x. Tracked alongside #142. --- .gitignore | 8 + .../9.json | 643 ------------------ 2 files changed, 8 insertions(+), 643 deletions(-) delete mode 100644 android/app/schemas/com.rjnr.pocketnode.data.database.AppDatabase/9.json diff --git a/.gitignore b/.gitignore index 72953f4..0fc6a3f 100644 --- a/.gitignore +++ b/.gitignore @@ -206,3 +206,11 @@ CLAUDE.md *.orig .vercel .superpowers/ + +# Room schema dumps. Generated by KSP on every build via +# room.schemaLocation. Not committed because Room 2.8.4's bundled +# kotlinx-serialization-core conflicts with the project's 1.8.0 +# kotlinx-serialization-json on a clean CI classpath: when an existing +# schema JSON is present, Room's processor tries to deserialize it for +# diff comparison and crashes with AbstractMethodError. (#142) +android/app/schemas/ diff --git a/android/app/schemas/com.rjnr.pocketnode.data.database.AppDatabase/9.json b/android/app/schemas/com.rjnr.pocketnode.data.database.AppDatabase/9.json deleted file mode 100644 index 497e37c..0000000 --- a/android/app/schemas/com.rjnr.pocketnode.data.database.AppDatabase/9.json +++ /dev/null @@ -1,643 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 9, - "identityHash": "5c3585c27257d8b0a1a3119c4e50e1ac", - "entities": [ - { - "tableName": "transactions", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`txHash` TEXT NOT NULL, `blockNumber` TEXT NOT NULL, `blockHash` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `balanceChange` TEXT NOT NULL, `direction` TEXT NOT NULL, `fee` TEXT NOT NULL, `confirmations` INTEGER NOT NULL, `blockTimestampHex` TEXT, `network` TEXT NOT NULL, `status` TEXT NOT NULL, `isLocal` INTEGER NOT NULL, `cachedAt` INTEGER NOT NULL, `walletId` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`txHash`))", - "fields": [ - { - "fieldPath": "txHash", - "columnName": "txHash", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "blockNumber", - "columnName": "blockNumber", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "blockHash", - "columnName": "blockHash", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "balanceChange", - "columnName": "balanceChange", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "direction", - "columnName": "direction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "fee", - "columnName": "fee", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "confirmations", - "columnName": "confirmations", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "blockTimestampHex", - "columnName": "blockTimestampHex", - "affinity": "TEXT" - }, - { - "fieldPath": "network", - "columnName": "network", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "isLocal", - "columnName": "isLocal", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "cachedAt", - "columnName": "cachedAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "walletId", - "columnName": "walletId", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "''" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "txHash" - ] - }, - "indices": [ - { - "name": "idx_tx_wallet_network_time", - "unique": false, - "columnNames": [ - "walletId", - "network", - "timestamp" - ], - "orders": [ - "ASC", - "ASC", - "DESC" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `idx_tx_wallet_network_time` ON `${TABLE_NAME}` (`walletId` ASC, `network` ASC, `timestamp` DESC)" - }, - { - "name": "idx_tx_pending", - "unique": false, - "columnNames": [ - "walletId", - "network", - "timestamp" - ], - "orders": [ - "ASC", - "ASC", - "DESC" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `idx_tx_pending` ON `${TABLE_NAME}` (`walletId` ASC, `network` ASC, `timestamp` DESC)" - } - ] - }, - { - "tableName": "balance_cache", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` TEXT NOT NULL DEFAULT '', `network` TEXT NOT NULL, `address` TEXT NOT NULL, `capacity` TEXT NOT NULL, `capacityCkb` TEXT NOT NULL, `blockNumber` TEXT NOT NULL, `cachedAt` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `network`))", - "fields": [ - { - "fieldPath": "walletId", - "columnName": "walletId", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "''" - }, - { - "fieldPath": "network", - "columnName": "network", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "address", - "columnName": "address", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "capacity", - "columnName": "capacity", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "capacityCkb", - "columnName": "capacityCkb", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "blockNumber", - "columnName": "blockNumber", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "cachedAt", - "columnName": "cachedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "walletId", - "network" - ] - } - }, - { - "tableName": "header_cache", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`blockHash` TEXT NOT NULL, `number` TEXT NOT NULL, `epoch` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `dao` TEXT NOT NULL, `network` TEXT NOT NULL, `cachedAt` INTEGER NOT NULL, PRIMARY KEY(`blockHash`))", - "fields": [ - { - "fieldPath": "blockHash", - "columnName": "blockHash", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "number", - "columnName": "number", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "epoch", - "columnName": "epoch", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "dao", - "columnName": "dao", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "network", - "columnName": "network", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "cachedAt", - "columnName": "cachedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "blockHash" - ] - }, - "indices": [ - { - "name": "idx_header_network_number", - "unique": false, - "columnNames": [ - "network", - "number" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `idx_header_network_number` ON `${TABLE_NAME}` (`network`, `number`)" - } - ] - }, - { - "tableName": "dao_cells", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`txHash` TEXT NOT NULL, `index` TEXT NOT NULL, `capacity` INTEGER NOT NULL, `status` TEXT NOT NULL, `depositBlockNumber` INTEGER NOT NULL, `depositBlockHash` TEXT NOT NULL, `depositEpochHex` TEXT, `withdrawBlockNumber` INTEGER, `withdrawBlockHash` TEXT, `withdrawEpochHex` TEXT, `compensation` INTEGER NOT NULL, `unlockEpochHex` TEXT, `depositTimestamp` INTEGER NOT NULL, `network` TEXT NOT NULL, `lastUpdatedAt` INTEGER NOT NULL, `walletId` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`txHash`, `index`))", - "fields": [ - { - "fieldPath": "txHash", - "columnName": "txHash", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "index", - "columnName": "index", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "capacity", - "columnName": "capacity", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "depositBlockNumber", - "columnName": "depositBlockNumber", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "depositBlockHash", - "columnName": "depositBlockHash", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "depositEpochHex", - "columnName": "depositEpochHex", - "affinity": "TEXT" - }, - { - "fieldPath": "withdrawBlockNumber", - "columnName": "withdrawBlockNumber", - "affinity": "INTEGER" - }, - { - "fieldPath": "withdrawBlockHash", - "columnName": "withdrawBlockHash", - "affinity": "TEXT" - }, - { - "fieldPath": "withdrawEpochHex", - "columnName": "withdrawEpochHex", - "affinity": "TEXT" - }, - { - "fieldPath": "compensation", - "columnName": "compensation", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "unlockEpochHex", - "columnName": "unlockEpochHex", - "affinity": "TEXT" - }, - { - "fieldPath": "depositTimestamp", - "columnName": "depositTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "network", - "columnName": "network", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastUpdatedAt", - "columnName": "lastUpdatedAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "walletId", - "columnName": "walletId", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "''" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "txHash", - "index" - ] - }, - "indices": [ - { - "name": "idx_dao_wallet_network", - "unique": false, - "columnNames": [ - "walletId", - "network" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `idx_dao_wallet_network` ON `${TABLE_NAME}` (`walletId`, `network`)" - } - ] - }, - { - "tableName": "wallets", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `derivationPath` TEXT, `parentWalletId` TEXT, `accountIndex` INTEGER NOT NULL, `mainnetAddress` TEXT NOT NULL, `testnetAddress` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastActiveAt` INTEGER NOT NULL, `colorIndex` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", - "fields": [ - { - "fieldPath": "walletId", - "columnName": "walletId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "derivationPath", - "columnName": "derivationPath", - "affinity": "TEXT" - }, - { - "fieldPath": "parentWalletId", - "columnName": "parentWalletId", - "affinity": "TEXT" - }, - { - "fieldPath": "accountIndex", - "columnName": "accountIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "mainnetAddress", - "columnName": "mainnetAddress", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "testnetAddress", - "columnName": "testnetAddress", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "isActive", - "columnName": "isActive", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastActiveAt", - "columnName": "lastActiveAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "colorIndex", - "columnName": "colorIndex", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "walletId" - ] - } - }, - { - "tableName": "key_material", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` TEXT NOT NULL, `encryptedPrivateKey` BLOB NOT NULL, `encryptedMnemonic` BLOB, `iv` BLOB NOT NULL, `walletType` TEXT NOT NULL, `mnemonicBackedUp` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", - "fields": [ - { - "fieldPath": "walletId", - "columnName": "walletId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "encryptedPrivateKey", - "columnName": "encryptedPrivateKey", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "encryptedMnemonic", - "columnName": "encryptedMnemonic", - "affinity": "BLOB" - }, - { - "fieldPath": "iv", - "columnName": "iv", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "walletType", - "columnName": "walletType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mnemonicBackedUp", - "columnName": "mnemonicBackedUp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "walletId" - ] - } - }, - { - "tableName": "sync_progress", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` TEXT NOT NULL, `network` TEXT NOT NULL, `lightStartBlockNumber` INTEGER NOT NULL, `localSavedBlockNumber` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `network`))", - "fields": [ - { - "fieldPath": "walletId", - "columnName": "walletId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "network", - "columnName": "network", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lightStartBlockNumber", - "columnName": "lightStartBlockNumber", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "localSavedBlockNumber", - "columnName": "localSavedBlockNumber", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "walletId", - "network" - ] - } - }, - { - "tableName": "pending_broadcasts", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`txHash` TEXT NOT NULL, `walletId` TEXT NOT NULL, `network` TEXT NOT NULL, `signedTxJson` TEXT NOT NULL, `reservedInputs` TEXT NOT NULL, `state` TEXT NOT NULL, `submittedAtTipBlock` INTEGER NOT NULL, `nullCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastCheckedAt` INTEGER NOT NULL, PRIMARY KEY(`txHash`))", - "fields": [ - { - "fieldPath": "txHash", - "columnName": "txHash", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "walletId", - "columnName": "walletId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "network", - "columnName": "network", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "signedTxJson", - "columnName": "signedTxJson", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "reservedInputs", - "columnName": "reservedInputs", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "state", - "columnName": "state", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "submittedAtTipBlock", - "columnName": "submittedAtTipBlock", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nullCount", - "columnName": "nullCount", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastCheckedAt", - "columnName": "lastCheckedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "txHash" - ] - }, - "indices": [ - { - "name": "idx_pb_wallet_net_state", - "unique": false, - "columnNames": [ - "walletId", - "network", - "state" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `idx_pb_wallet_net_state` ON `${TABLE_NAME}` (`walletId`, `network`, `state`)" - } - ] - } - ], - "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, '5c3585c27257d8b0a1a3119c4e50e1ac')" - ] - } -} \ No newline at end of file From 8fe3c28fa2f1ef699a55cba93e9b30e12fded111 Mon Sep 17 00:00:00 2001 From: raheemjnr Date: Thu, 30 Apr 2026 14:56:28 +0100 Subject: [PATCH 3/3] fix(ci): turn Room exportSchema off; track re-enable in #149 Same AbstractMethodError surfaced even after .gitignore'ing the JSONs: Room's deserialize path runs on every KSP cycle, not only when an existing schema file is present. The dep conflict between Room 2.8.4 and kotlinx-serialization 1.8.0 needs a real fix before exportSchema can be on. Reverts the @Database exportSchema flag and the room.schemaLocation KSP arg. The walking-migration test under src/test/ still validates schema-vs-entity drift via Room's onMigrate path, so #142's regression-guard value is unaffected. Re-enabling tracked in #149. --- android/app/build.gradle.kts | 13 ++++++------- .../rjnr/pocketnode/data/database/AppDatabase.kt | 14 +++++++++----- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 073129a..20449c1 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -25,13 +25,12 @@ android { abiFilters += listOf("arm64-v8a", "armeabi-v7a") } - // Room schema export: dump every database version's schema to JSON - // so reviewers can see schema drift in PR diffs and so the migration - // tests can validate against an explicit reference. The schemas live - // under app/schemas//.json. (#142) - ksp { - arg("room.schemaLocation", "$projectDir/schemas") - } + // ksp { arg("room.schemaLocation", "$projectDir/schemas") } is + // deliberately omitted, because turning on Room schema export + // crashes KSP with an AbstractMethodError between Room 2.8.4's + // bundled kotlinx-serialization-core and the project's + // kotlinx-serialization-json:1.8.0 (tracked in #149). Re-enable + // once the dep conflict is resolved. } testOptions { diff --git a/android/app/src/main/java/com/rjnr/pocketnode/data/database/AppDatabase.kt b/android/app/src/main/java/com/rjnr/pocketnode/data/database/AppDatabase.kt index 073d95d..d8eacbe 100644 --- a/android/app/src/main/java/com/rjnr/pocketnode/data/database/AppDatabase.kt +++ b/android/app/src/main/java/com/rjnr/pocketnode/data/database/AppDatabase.kt @@ -37,11 +37,15 @@ import com.rjnr.pocketnode.data.database.entity.WalletEntity // so MIGRATION_8_9 is a no-op. Version bump alone is needed to refresh // Room's stored identity hash after the entity declarations changed. (#90 / #141) version = 9, - // Schema export ON (#142). Generates app/schemas//9.json on every - // build. Diff visible in PR review when entity declarations or migration - // SQL drifts. The walking-migration test under src/test/ validates that - // the entity declarations agree with what the migrations produce on disk. - exportSchema = true + // Schema export deliberately OFF until the Room 2.8.4 / kotlinx-serialization + // 1.8.0 binary incompatibility is resolved (tracked in #149). Enabling it + // crashes KSP with AbstractMethodError in Room's bundled + // SchemaBundle$$serializer on a clean CI classpath. + // + // The walking-migration test under src/test/ does the schema-validation + // work that exportSchema was meant to enable, so #142's regression-guard + // value is preserved. We just lose the JSON diff in PR review. + exportSchema = false ) abstract class AppDatabase : RoomDatabase() { abstract fun transactionDao(): TransactionDao