From 2b4c18410b2c435d6864ba0edfb7ec7deba217c5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:49:07 +0200 Subject: [PATCH 01/14] fix(db): clean orphaned FK rows before v33 network rename migration Users whose wallets were deleted while system SQLite had FK enforcement OFF retained orphaned child rows in wallet_transactions (and potentially shielded tables). The v33 rename_network_dash_to_mainnet UPDATE triggers FK re-validation under bundled SQLite (SQLITE_DEFAULT_FOREIGN_KEYS=1), causing "FOREIGN KEY constraint failed". Add clean_orphaned_fk_rows() step that safely removes orphans before the rename, handling tables that may not yet exist. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/database/initialization.rs | 263 +++++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 42805033d..752f4c3cb 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -65,6 +65,7 @@ impl Database { self.create_shielded_tables(tx)?; self.create_shielded_wallet_meta_table(tx)?; self.add_nullifier_sync_timestamp_column(tx)?; + self.clean_orphaned_fk_rows(tx)?; self.rename_network_dash_to_mainnet(tx)?; self.add_wallet_transaction_status_column(tx)?; } @@ -939,6 +940,35 @@ impl Database { // Shielded table helpers (create_shielded_tables, create_shielded_wallet_meta_table, // add_nullifier_sync_timestamp_column) are implemented in database/shielded.rs. + /// Remove orphaned child rows whose parent wallet was deleted while FK + /// enforcement was off (system SQLite before bundled build). The UPDATE in + /// `rename_network_dash_to_mainnet` re-validates FKs and fails on these + /// orphans. All affected data is fully recoverable from the network via + /// resync, so deletion is safe. + fn clean_orphaned_fk_rows(&self, conn: &Connection) -> rusqlite::Result<()> { + let tables: &[(&str, &str)] = &[ + ("shielded_notes", "wallet_seed_hash"), + ("shielded_wallet_meta", "wallet_seed_hash"), + ("wallet_transactions", "seed_hash"), + ]; + for (table, fk_col) in tables { + let exists: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)", + [table], + |row| row.get(0), + )?; + if exists { + conn.execute( + &format!( + "DELETE FROM {table} WHERE {fk_col} NOT IN (SELECT seed_hash FROM wallet)" + ), + [], + )?; + } + } + Ok(()) + } + /// Migration 29: rename network value `"dash"` to `"mainnet"` in all tables. /// /// Upstream `dashcore` renamed `Network::Dash` to `Network::Mainnet`, @@ -1267,4 +1297,237 @@ mod test { let conn = db.conn.lock().unwrap(); assert_v33_schema(&conn); } + + #[test] + fn test_v33_migration_with_orphaned_fk_rows() { + let temp_dir = tempfile::tempdir().unwrap(); + let db_file_path = temp_dir.path().join("orphans.db"); + let db = super::Database::new(&db_file_path).unwrap(); + + // Build full schema at current version + db.create_tables().unwrap(); + db.set_default_version().unwrap(); + + let valid_seed_hash = vec![0xAAu8; 32]; + let orphan_seed_hash = vec![0xBBu8; 32]; + + { + let conn = db.conn.lock().unwrap(); + + // Insert a real wallet with the old network name + conn.execute( + "INSERT INTO wallet ( + seed_hash, encrypted_seed, salt, nonce, + master_ecdsa_bip44_account_0_epk, uses_password, network + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + valid_seed_hash, + vec![1u8; 16], + vec![2u8; 16], + vec![3u8; 12], + vec![4u8; 33], + 0, + "dash" + ], + ) + .unwrap(); + + // Disable FK enforcement to simulate legacy system SQLite + conn.execute_batch("PRAGMA foreign_keys = OFF").unwrap(); + + // Insert orphaned wallet_transactions row (seed_hash not in wallet table). + // Shielded table orphans are not needed: those tables get dropped to + // simulate v27, then recreated empty by the migration. + conn.execute( + "INSERT INTO wallet_transactions ( + seed_hash, txid, network, timestamp, net_amount, + is_ours, raw_transaction, status + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + orphan_seed_hash, + vec![0xCCu8; 32], + "dash", + 1000, + -50000, + 1, + vec![0u8; 100], + 0 + ], + ) + .unwrap(); + + // Insert valid wallet_transactions row for the real wallet + conn.execute( + "INSERT INTO wallet_transactions ( + seed_hash, txid, network, timestamp, net_amount, + is_ours, raw_transaction, status + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + valid_seed_hash, + vec![0xDDu8; 32], + "dash", + 2000, + 100000, + 1, + vec![1u8; 100], + 0 + ], + ) + .unwrap(); + + // Strip v28+ additions to simulate v27 state (same as test_v33_migration_from_v27) + // Remove shielded tables — they'll be recreated by migration + conn.execute("DROP TABLE IF EXISTS shielded_notes", []) + .unwrap(); + conn.execute("DROP TABLE IF EXISTS shielded_wallet_meta", []) + .unwrap(); + conn.execute("DROP TABLE IF EXISTS contact_private_info", []) + .unwrap(); + + // Recreate wallet without core_wallet_name + conn.execute_batch( + "CREATE TABLE wallet_old AS SELECT + seed_hash, encrypted_seed, salt, nonce, + master_ecdsa_bip44_account_0_epk, alias, is_main, + uses_password, password_hint, network, + confirmed_balance, unconfirmed_balance, total_balance, + last_platform_full_sync, last_platform_sync_checkpoint, + last_terminal_block + FROM wallet; + DROP TABLE wallet; + CREATE TABLE wallet ( + seed_hash BLOB NOT NULL PRIMARY KEY, + encrypted_seed BLOB NOT NULL, + salt BLOB NOT NULL, + nonce BLOB NOT NULL, + master_ecdsa_bip44_account_0_epk BLOB NOT NULL, + alias TEXT, + is_main INTEGER, + uses_password INTEGER NOT NULL, + password_hint TEXT, + network TEXT NOT NULL, + confirmed_balance INTEGER DEFAULT 0, + unconfirmed_balance INTEGER DEFAULT 0, + total_balance INTEGER DEFAULT 0, + last_platform_full_sync INTEGER DEFAULT 0, + last_platform_sync_checkpoint INTEGER DEFAULT 0, + last_terminal_block INTEGER DEFAULT 0 + ); + INSERT INTO wallet SELECT * FROM wallet_old; + DROP TABLE wallet_old;", + ) + .unwrap(); + + // Recreate wallet_transactions without status but WITH FK constraint, + // preserving orphaned rows (FK enforcement is still OFF). + conn.execute_batch( + "CREATE TABLE wallet_transactions_old AS SELECT + seed_hash, txid, network, timestamp, height, block_hash, + net_amount, fee, label, is_ours, raw_transaction + FROM wallet_transactions; + DROP TABLE wallet_transactions; + CREATE TABLE wallet_transactions ( + seed_hash BLOB NOT NULL, + txid BLOB NOT NULL, + network TEXT NOT NULL, + timestamp INTEGER NOT NULL, + height INTEGER, + block_hash BLOB, + net_amount INTEGER NOT NULL, + fee INTEGER, + label TEXT, + is_ours INTEGER NOT NULL, + raw_transaction BLOB NOT NULL, + PRIMARY KEY (seed_hash, txid, network), + FOREIGN KEY (seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE + ); + INSERT INTO wallet_transactions SELECT * FROM wallet_transactions_old; + DROP TABLE wallet_transactions_old;", + ) + .unwrap(); + + // Recreate single_key_wallet without core_wallet_name + conn.execute_batch( + "DROP TABLE IF EXISTS single_key_wallet; + CREATE TABLE single_key_wallet ( + key_hash BLOB NOT NULL PRIMARY KEY, + encrypted_private_key BLOB NOT NULL, + salt BLOB NOT NULL, + nonce BLOB NOT NULL, + public_key BLOB NOT NULL, + address TEXT NOT NULL, + alias TEXT, + uses_password INTEGER NOT NULL, + network TEXT NOT NULL, + confirmed_balance INTEGER DEFAULT 0, + unconfirmed_balance INTEGER DEFAULT 0, + total_balance INTEGER DEFAULT 0 + );", + ) + .unwrap(); + + // Re-enable FK enforcement + conn.execute_batch("PRAGMA foreign_keys = ON").unwrap(); + + // Set version to 27 + conn.execute("UPDATE settings SET database_version = 27 WHERE id = 1", []) + .unwrap(); + } + + assert_eq!(db.db_schema_version().unwrap(), 27); + + // Run migration — this would fail without clean_orphaned_fk_rows + let result = db.try_perform_migration(27, DEFAULT_DB_VERSION); + assert!( + result.is_ok(), + "migration with orphaned FK rows failed: {:?}", + result.err() + ); + + assert_eq!(db.db_schema_version().unwrap(), DEFAULT_DB_VERSION); + + let conn = db.conn.lock().unwrap(); + assert_v33_schema(&conn); + + // Orphaned wallet_transactions should be gone + let orphan_txs: i64 = conn + .query_row( + "SELECT COUNT(*) FROM wallet_transactions WHERE seed_hash = ?1", + params![orphan_seed_hash], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + orphan_txs, 0, + "orphaned wallet_transactions should be deleted" + ); + + // Shielded tables should exist but be empty (recreated fresh by migration; + // the cleanup handles them gracefully even when just-created) + assert_table_exists(&conn, "shielded_notes"); + assert_table_exists(&conn, "shielded_wallet_meta"); + + // Valid wallet_transactions should survive with network renamed to mainnet + let valid_txs: i64 = conn + .query_row( + "SELECT COUNT(*) FROM wallet_transactions WHERE seed_hash = ?1 AND network = 'mainnet'", + params![valid_seed_hash], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + valid_txs, 1, + "valid wallet_transactions should survive with network=mainnet" + ); + + // Wallet itself should have mainnet + let wallet_network: String = conn + .query_row( + "SELECT network FROM wallet WHERE seed_hash = ?1", + params![valid_seed_hash], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(wallet_network, "mainnet"); + } } From 2f77c0152b1c233cfeaa81ac65301cdf5b59ae86 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:56:30 +0200 Subject: [PATCH 02/14] feat(db): add non-fatal consistency checks on startup Run PRAGMA quick_check and PRAGMA foreign_key_check before migrations on every startup (skipped for first-time setup). Logs warnings for any b-tree corruption or FK violations but never blocks initialization. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/database/initialization.rs | 60 +++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 752f4c3cb..1805ce166 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -32,7 +32,8 @@ impl Database { self.create_tables()?; self.set_default_version()?; } else { - // If outdated, back up and either migrate or recreate the database. + self.run_consistency_checks(); + let current_version = self.db_schema_version()?; if current_version != DEFAULT_DB_VERSION { self.backup_db(db_file_path)?; @@ -1004,6 +1005,63 @@ impl Database { } Ok(()) } + + /// Run database consistency checks on startup. + /// Non-fatal: logs warnings for any issues found but does not fail. + fn run_consistency_checks(&self) { + let conn = self.conn.lock().unwrap(); + + // PRAGMA quick_check is a faster subset of integrity_check. + // It verifies b-tree structure without cross-checking indexes. + match conn.query_row("PRAGMA quick_check", [], |row| row.get::<_, String>(0)) { + Ok(ref result) if result == "ok" => { + tracing::debug!("Database quick_check passed"); + } + Ok(result) => { + tracing::warn!("Database quick_check found issues: {result}"); + } + Err(e) => { + tracing::warn!("Database quick_check failed to execute: {e}"); + } + } + + // PRAGMA foreign_key_check returns rows for each FK violation. + match conn.prepare("PRAGMA foreign_key_check") { + Ok(mut stmt) => { + match stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + )) + }) { + Ok(rows) => { + let violations: Vec<_> = rows.filter_map(|r| r.ok()).collect(); + if violations.is_empty() { + tracing::debug!("Database foreign_key_check passed — no violations"); + } else { + tracing::warn!( + "Database foreign_key_check found {} violation(s):", + violations.len() + ); + for (table, rowid, parent, fk_idx) in &violations { + tracing::warn!( + " FK violation: {table} rowid={rowid} -> {parent} (fk_index={fk_idx})" + ); + } + } + } + Err(e) => { + tracing::warn!("Database foreign_key_check query failed: {e}"); + } + } + } + Err(e) => { + tracing::warn!("Database foreign_key_check failed to prepare: {e}"); + } + } + } } #[cfg(test)] From 887c5da612486d0bf7c0490de5c5c8925bdfba6a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:24:27 +0200 Subject: [PATCH 03/14] fix(db): extend orphan cleanup to wallet_addresses and asset_lock_transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add wallet_addresses to FK orphan deletion (seed_hash → wallet). For asset_lock_transaction, apply the intended ON DELETE SET NULL behavior: nullify identity_id and identity_id_potentially_in_creation where the referenced identity no longer exists. Co-Authored-By: Claude Opus 4.6 --- src/database/initialization.rs | 145 ++++++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 2 deletions(-) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 1805ce166..47a58265e 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -947,12 +947,14 @@ impl Database { /// orphans. All affected data is fully recoverable from the network via /// resync, so deletion is safe. fn clean_orphaned_fk_rows(&self, conn: &Connection) -> rusqlite::Result<()> { - let tables: &[(&str, &str)] = &[ + // Tables with FK to wallet(seed_hash) — delete orphaned rows. + let wallet_children: &[(&str, &str)] = &[ ("shielded_notes", "wallet_seed_hash"), ("shielded_wallet_meta", "wallet_seed_hash"), ("wallet_transactions", "seed_hash"), + ("wallet_addresses", "seed_hash"), ]; - for (table, fk_col) in tables { + for (table, fk_col) in wallet_children { let exists: bool = conn.query_row( "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)", [table], @@ -967,6 +969,21 @@ impl Database { )?; } } + + // asset_lock_transaction has ON DELETE SET NULL for identity_id columns. + // Apply the intended SET NULL for orphaned identity references. + conn.execute( + "UPDATE asset_lock_transaction SET identity_id = NULL + WHERE identity_id IS NOT NULL AND identity_id NOT IN (SELECT id FROM identity)", + [], + )?; + conn.execute( + "UPDATE asset_lock_transaction SET identity_id_potentially_in_creation = NULL + WHERE identity_id_potentially_in_creation IS NOT NULL + AND identity_id_potentially_in_creation NOT IN (SELECT id FROM identity)", + [], + )?; + Ok(()) } @@ -1433,6 +1450,75 @@ mod test { ) .unwrap(); + // Insert orphaned wallet_addresses row + conn.execute( + "INSERT INTO wallet_addresses ( + seed_hash, address, derivation_path, balance, + path_reference, path_type + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![orphan_seed_hash, "yOrphanAddr1", "m/44'/1'/0'/0/0", 0, 0, 0], + ) + .unwrap(); + + // Insert valid wallet_addresses row + conn.execute( + "INSERT INTO wallet_addresses ( + seed_hash, address, derivation_path, balance, + path_reference, path_type + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + valid_seed_hash, + "yValidAddr1", + "m/44'/1'/0'/0/0", + 1000, + 0, + 0 + ], + ) + .unwrap(); + + // Insert a real identity for the valid wallet + let valid_identity_id = vec![0xEEu8; 32]; + let orphan_identity_id = vec![0xFFu8; 32]; + conn.execute( + "INSERT INTO identity (id, is_local, identity_type, alias, network) + VALUES (?1, 1, 'user', 'test', 'dash')", + params![valid_identity_id], + ) + .unwrap(); + + // Insert asset_lock_transaction referencing a deleted identity + conn.execute( + "INSERT INTO asset_lock_transaction ( + tx_id, transaction_data, amount, identity_id, wallet, network + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + vec![0xA1u8; 32], + vec![0u8; 50], + 100_000, + orphan_identity_id, + valid_seed_hash, + "dash" + ], + ) + .unwrap(); + + // Insert asset_lock_transaction referencing a valid identity + conn.execute( + "INSERT INTO asset_lock_transaction ( + tx_id, transaction_data, amount, identity_id, wallet, network + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + vec![0xA2u8; 32], + vec![1u8; 50], + 200_000, + valid_identity_id, + valid_seed_hash, + "dash" + ], + ) + .unwrap(); + // Strip v28+ additions to simulate v27 state (same as test_v33_migration_from_v27) // Remove shielded tables — they'll be recreated by migration conn.execute("DROP TABLE IF EXISTS shielded_notes", []) @@ -1587,5 +1673,60 @@ mod test { ) .unwrap(); assert_eq!(wallet_network, "mainnet"); + + // Orphaned wallet_addresses should be gone, valid ones survive + let orphan_addrs: i64 = conn + .query_row( + "SELECT COUNT(*) FROM wallet_addresses WHERE seed_hash = ?1", + params![orphan_seed_hash], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + orphan_addrs, 0, + "orphaned wallet_addresses should be deleted" + ); + + let valid_addrs: i64 = conn + .query_row( + "SELECT COUNT(*) FROM wallet_addresses WHERE seed_hash = ?1", + params![valid_seed_hash], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + valid_addrs, 1, + "valid wallet_addresses should survive migration" + ); + + // asset_lock_transaction with orphaned identity_id should be SET NULL + let valid_identity_id = vec![0xEEu8; 32]; + + let orphan_lock_identity: Option> = conn + .query_row( + "SELECT identity_id FROM asset_lock_transaction WHERE tx_id = ?1", + params![vec![0xA1u8; 32]], + |row| row.get(0), + ) + .unwrap(); + assert!( + orphan_lock_identity.is_none(), + "orphaned asset_lock identity_id should be NULL, got {:?}", + orphan_lock_identity + ); + + // asset_lock_transaction with valid identity_id should keep it + let valid_lock_identity: Option> = conn + .query_row( + "SELECT identity_id FROM asset_lock_transaction WHERE tx_id = ?1", + params![vec![0xA2u8; 32]], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + valid_lock_identity, + Some(valid_identity_id), + "valid asset_lock identity_id should be preserved" + ); } } From 9ea73a00ef96a5158531ad5e3704dd8243ba4698 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:58:04 +0200 Subject: [PATCH 04/14] fix(db): comprehensive FK orphan cleanup covering all parent-child relationships Extend clean_orphaned_fk_rows to cover every FK constraint in the schema: - wallet children: wallet_addresses, wallet_transactions, platform_address_balances, shielded_notes, shielded_wallet_meta, asset_lock_transaction, identity - identity children: top_up, scheduled_votes, identity_order, identity_token_balances, token_order - identity SET NULL: asset_lock_transaction.identity_id columns - token/contract/contested_name cascades Add table_exists helper and per-table logging of cleaned rows. Co-Authored-By: Claude Opus 4.6 --- src/database/initialization.rs | 134 +++++++++++++++++++++++++++------ 1 file changed, 110 insertions(+), 24 deletions(-) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 47a58265e..5c52f3389 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -947,46 +947,132 @@ impl Database { /// orphans. All affected data is fully recoverable from the network via /// resync, so deletion is safe. fn clean_orphaned_fk_rows(&self, conn: &Connection) -> rusqlite::Result<()> { - // Tables with FK to wallet(seed_hash) — delete orphaned rows. - let wallet_children: &[(&str, &str)] = &[ + // --- CASCADE children of wallet(seed_hash) --- + // Delete orphaned rows where parent wallet no longer exists. + let wallet_fk_delete: &[(&str, &str)] = &[ + ("wallet_addresses", "seed_hash"), + ("wallet_transactions", "seed_hash"), + ("platform_address_balances", "seed_hash"), ("shielded_notes", "wallet_seed_hash"), ("shielded_wallet_meta", "wallet_seed_hash"), - ("wallet_transactions", "seed_hash"), - ("wallet_addresses", "seed_hash"), + ("asset_lock_transaction", "wallet"), ]; - for (table, fk_col) in wallet_children { - let exists: bool = conn.query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)", - [table], - |row| row.get(0), - )?; - if exists { - conn.execute( + for (table, fk_col) in wallet_fk_delete { + if self.table_exists(conn, table)? { + let deleted = conn.execute( &format!( "DELETE FROM {table} WHERE {fk_col} NOT IN (SELECT seed_hash FROM wallet)" ), [], )?; + if deleted > 0 { + tracing::info!( + "Cleaned {deleted} orphaned row(s) from {table} (missing wallet)" + ); + } } } + // identity.wallet is nullable with ON DELETE CASCADE — delete orphaned + // identities whose wallet no longer exists (but skip NULL wallet). + if self.table_exists(conn, "identity")? { + let deleted = conn.execute( + "DELETE FROM identity WHERE wallet IS NOT NULL + AND wallet NOT IN (SELECT seed_hash FROM wallet)", + [], + )?; + if deleted > 0 { + tracing::info!("Cleaned {deleted} orphaned identity row(s) (missing wallet)"); + } + } + + // --- CASCADE children of identity(id) --- + let identity_fk_delete: &[(&str, &str)] = &[ + ("top_up", "identity_id"), + ("scheduled_votes", "identity_id"), + ("identity_order", "identity_id"), + ("identity_token_balances", "identity_id"), + ("token_order", "identity_id"), + ]; + for (table, fk_col) in identity_fk_delete { + if self.table_exists(conn, table)? { + let deleted = conn.execute( + &format!("DELETE FROM {table} WHERE {fk_col} NOT IN (SELECT id FROM identity)"), + [], + )?; + if deleted > 0 { + tracing::info!( + "Cleaned {deleted} orphaned row(s) from {table} (missing identity)" + ); + } + } + } + + // --- SET NULL children of identity(id) --- // asset_lock_transaction has ON DELETE SET NULL for identity_id columns. - // Apply the intended SET NULL for orphaned identity references. - conn.execute( - "UPDATE asset_lock_transaction SET identity_id = NULL - WHERE identity_id IS NOT NULL AND identity_id NOT IN (SELECT id FROM identity)", - [], - )?; - conn.execute( - "UPDATE asset_lock_transaction SET identity_id_potentially_in_creation = NULL - WHERE identity_id_potentially_in_creation IS NOT NULL - AND identity_id_potentially_in_creation NOT IN (SELECT id FROM identity)", - [], - )?; + if self.table_exists(conn, "asset_lock_transaction")? { + conn.execute( + "UPDATE asset_lock_transaction SET identity_id = NULL + WHERE identity_id IS NOT NULL + AND identity_id NOT IN (SELECT id FROM identity)", + [], + )?; + conn.execute( + "UPDATE asset_lock_transaction SET identity_id_potentially_in_creation = NULL + WHERE identity_id_potentially_in_creation IS NOT NULL + AND identity_id_potentially_in_creation NOT IN (SELECT id FROM identity)", + [], + )?; + } + + // --- CASCADE children of token(id) --- + if self.table_exists(conn, "identity_token_balances")? + && self.table_exists(conn, "token")? + { + conn.execute( + "DELETE FROM identity_token_balances + WHERE token_id NOT IN (SELECT id FROM token)", + [], + )?; + } + if self.table_exists(conn, "token_order")? && self.table_exists(conn, "token")? { + conn.execute( + "DELETE FROM token_order WHERE token_id NOT IN (SELECT id FROM token)", + [], + )?; + } + + // --- CASCADE children of contract --- + if self.table_exists(conn, "token")? && self.table_exists(conn, "contract")? { + conn.execute( + "DELETE FROM token WHERE (data_contract_id, network) + NOT IN (SELECT contract_id, network FROM contract)", + [], + )?; + } + + // --- CASCADE children of contested_name --- + if self.table_exists(conn, "contestant")? && self.table_exists(conn, "contested_name")? { + conn.execute( + "DELETE FROM contestant + WHERE (normalized_contested_name, network) + NOT IN (SELECT normalized_contested_name, network FROM contested_name)", + [], + )?; + } Ok(()) } + /// Check if a table exists in the database. + fn table_exists(&self, conn: &Connection, table: &str) -> rusqlite::Result { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)", + [table], + |row| row.get(0), + ) + } + /// Migration 29: rename network value `"dash"` to `"mainnet"` in all tables. /// /// Upstream `dashcore` renamed `Network::Dash` to `Network::Mainnet`, From d52ce39181faa09ba0a5c1ed0a2b6c18c2c6b41b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:03:09 +0200 Subject: [PATCH 05/14] fix(db): run orphan cleanup first in v33 migration Move clean_orphaned_fk_rows to the top of the v33 migration step, before any ALTER TABLE or CREATE TABLE operations that might trigger FK re-validation on orphaned rows. Co-Authored-By: Claude Opus 4.6 --- src/database/initialization.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 5c52f3389..0b9bc2346 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -61,12 +61,12 @@ impl Database { // Every sub-migration is idempotent (IF NOT EXISTS / column checks), // so this is safe to run on any DB that already applied some or all // of the individual steps. + self.clean_orphaned_fk_rows(tx)?; self.add_core_wallet_name_column(tx)?; self.init_contacts_tables(tx)?; self.create_shielded_tables(tx)?; self.create_shielded_wallet_meta_table(tx)?; self.add_nullifier_sync_timestamp_column(tx)?; - self.clean_orphaned_fk_rows(tx)?; self.rename_network_dash_to_mainnet(tx)?; self.add_wallet_transaction_status_column(tx)?; } From 7a73f09cf3684291134a77d5d4f41a7579dd2213 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:18:11 +0200 Subject: [PATCH 06/14] fix(db): move wallet column additions into v33 migration after orphan cleanup ensure_wallet_columns_exist() ran ALTER TABLE wallet_addresses before the migration system, triggering FK re-validation on orphaned rows before clean_orphaned_fk_rows had a chance to run. This was the actual cause of the user-reported migration failure. Move add_wallet_balance_columns (v16) and add_address_total_received_column (v17) into the v33 migration arm, after orphan cleanup. Both are idempotent (check column existence first) so safe to re-run. Remove the pre-migration ensure_wallet_columns_exist() call from initialize(). Co-Authored-By: Claude Opus 4.6 --- src/database/initialization.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 0b9bc2346..4adc00a1f 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -24,7 +24,12 @@ impl Database { if settings_exists { self.ensure_settings_columns_exist(&conn)?; } - self.ensure_wallet_columns_exist(&conn)?; + // NOTE: ensure_wallet_columns_exist() used to run here, but it + // ALTERs wallet_addresses which triggers FK re-validation on + // orphaned rows before our migration cleanup has a chance to run. + // Those column additions (v16 balance cols, v17 total_received) + // are now covered idempotently inside the v33 migration step, + // after clean_orphaned_fk_rows(). } // Check if this is the first time setup by looking for entries in the settings table. @@ -62,6 +67,12 @@ impl Database { // so this is safe to run on any DB that already applied some or all // of the individual steps. self.clean_orphaned_fk_rows(tx)?; + // Wallet column additions formerly in ensure_wallet_columns_exist(). + // Moved here so they run after orphan cleanup (ALTER TABLE on + // wallet_addresses triggers FK re-validation on orphaned rows). + // Idempotent — safe if columns already exist from v16/v17. + self.add_wallet_balance_columns(tx)?; + self.add_address_total_received_column(tx)?; self.add_core_wallet_name_column(tx)?; self.init_contacts_tables(tx)?; self.create_shielded_tables(tx)?; From a8db54685ae9921b8ff66f4ffc223dfa79897b50 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:22:10 +0200 Subject: [PATCH 07/14] fix(db): remove redundant wallet column additions from v33 migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v16/v17 migration steps already add these columns sequentially before v33 runs. The idempotent re-add in v33 was unnecessary — any database reaching v33 has already passed through v16 and v17. Co-Authored-By: Claude Opus 4.6 --- src/database/initialization.rs | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 4adc00a1f..bd00e9838 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -27,9 +27,9 @@ impl Database { // NOTE: ensure_wallet_columns_exist() used to run here, but it // ALTERs wallet_addresses which triggers FK re-validation on // orphaned rows before our migration cleanup has a chance to run. - // Those column additions (v16 balance cols, v17 total_received) - // are now covered idempotently inside the v33 migration step, - // after clean_orphaned_fk_rows(). + // Removed: those columns are added by their own migration steps + // (v16 balance cols, v17 total_received) which run sequentially + // before v33, so they always exist by the time v33 executes. } // Check if this is the first time setup by looking for entries in the settings table. @@ -67,12 +67,6 @@ impl Database { // so this is safe to run on any DB that already applied some or all // of the individual steps. self.clean_orphaned_fk_rows(tx)?; - // Wallet column additions formerly in ensure_wallet_columns_exist(). - // Moved here so they run after orphan cleanup (ALTER TABLE on - // wallet_addresses triggers FK re-validation on orphaned rows). - // Idempotent — safe if columns already exist from v16/v17. - self.add_wallet_balance_columns(tx)?; - self.add_address_total_received_column(tx)?; self.add_core_wallet_name_column(tx)?; self.init_contacts_tables(tx)?; self.create_shielded_tables(tx)?; From d5dcd7baff5e5a2131a6d0c5de18d4cbe97e8033 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:28:07 +0200 Subject: [PATCH 08/14] fix(db): handle missing network column in v0.9.0 scheduled_votes migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v0.9.0 release created scheduled_votes without a network column. The v6 migration (update_scheduled_votes_table) assumed it existed, causing migration failure for v0.9.0 users upgrading to v1.0. Fix: check if scheduled_votes_old has a network column before copying data. If missing, default to 'dash' (the only network at v0.9.0). Add test_migration_from_v090_to_current that creates the exact v0.9.0 schema at DB version 5, populates realistic data, and migrates all the way to current version — verifying data survives with correct network rename. Co-Authored-By: Claude Opus 4.6 --- src/database/initialization.rs | 295 ++++++++++++++++++++++++++++++++ src/database/scheduled_votes.rs | 25 ++- 2 files changed, 315 insertions(+), 5 deletions(-) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index bd00e9838..5cae9035f 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -1820,4 +1820,299 @@ mod test { "valid asset_lock identity_id should be preserved" ); } + + /// Test migration from v0.9.0 schema (DB version 5) all the way to current. + /// This is the exact schema shipped in the v0.9.0 release, with realistic + /// data including wallets, addresses, identities, and asset locks. + #[test] + fn test_migration_from_v090_to_current() { + let temp_dir = tempfile::tempdir().unwrap(); + let db_file_path = temp_dir.path().join("v090.db"); + let db = super::Database::new(&db_file_path).unwrap(); + + { + let conn = db.conn.lock().unwrap(); + + // Exact v0.9.0 schema — copied from git show v0.9.0:src/database/initialization.rs + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + password_check BLOB, + main_password_salt BLOB, + main_password_nonce BLOB, + network TEXT NOT NULL, + start_root_screen INTEGER NOT NULL, + custom_dash_qt_path TEXT, + overwrite_dash_conf INTEGER, + database_version INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS wallet ( + seed_hash BLOB NOT NULL PRIMARY KEY, + encrypted_seed BLOB NOT NULL, + salt BLOB NOT NULL, + nonce BLOB NOT NULL, + master_ecdsa_bip44_account_0_epk BLOB NOT NULL, + alias TEXT, + is_main INTEGER, + uses_password INTEGER NOT NULL, + password_hint TEXT, + network TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS wallet_addresses ( + seed_hash BLOB NOT NULL, + address TEXT NOT NULL, + derivation_path TEXT NOT NULL, + balance INTEGER, + path_reference INTEGER NOT NULL, + path_type INTEGER NOT NULL, + PRIMARY KEY (seed_hash, address), + FOREIGN KEY (seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_wallet_addresses_path_reference + ON wallet_addresses (path_reference); + CREATE INDEX IF NOT EXISTS idx_wallet_addresses_path_type + ON wallet_addresses (path_type); + + CREATE TABLE IF NOT EXISTS utxos ( + txid BLOB NOT NULL, + vout INTEGER NOT NULL, + address TEXT NOT NULL, + value INTEGER NOT NULL, + script_pubkey BLOB NOT NULL, + network TEXT NOT NULL, + PRIMARY KEY (txid, vout, network) + ); + + CREATE INDEX IF NOT EXISTS idx_utxos_address ON utxos (address); + CREATE INDEX IF NOT EXISTS idx_utxos_network ON utxos (network); + + CREATE TABLE IF NOT EXISTS asset_lock_transaction ( + tx_id BLOB PRIMARY KEY, + transaction_data BLOB NOT NULL, + amount INTEGER, + instant_lock_data BLOB, + chain_locked_height INTEGER, + identity_id BLOB, + identity_id_potentially_in_creation BLOB, + wallet BLOB NOT NULL, + network TEXT NOT NULL, + FOREIGN KEY (identity_id) REFERENCES identity(id) ON DELETE CASCADE, + FOREIGN KEY (identity_id_potentially_in_creation) REFERENCES identity(id), + FOREIGN KEY (wallet) REFERENCES wallet(seed_hash) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS identity ( + id BLOB PRIMARY KEY, + data BLOB, + is_in_creation INTEGER NOT NULL DEFAULT 0, + is_local INTEGER NOT NULL, + alias TEXT, + info TEXT, + wallet BLOB, + wallet_index INTEGER, + identity_type TEXT, + network TEXT NOT NULL, + CHECK ((wallet IS NOT NULL AND wallet_index IS NOT NULL) + OR (wallet IS NULL AND wallet_index IS NULL)), + FOREIGN KEY (wallet) REFERENCES wallet(seed_hash) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_identity_local_network_type + ON identity (is_local, network, identity_type); + + CREATE TABLE IF NOT EXISTS contested_name ( + normalized_contested_name TEXT NOT NULL, + locked_votes INTEGER, + abstain_votes INTEGER, + awarded_to BLOB, + end_time INTEGER, + locked INTEGER NOT NULL DEFAULT 0, + last_updated INTEGER, + network TEXT NOT NULL, + PRIMARY KEY (normalized_contested_name, network) + ); + + CREATE TABLE IF NOT EXISTS contestant ( + normalized_contested_name TEXT NOT NULL, + identity_id BLOB NOT NULL, + name TEXT, + votes INTEGER, + created_at INTEGER, + created_at_block_height INTEGER, + created_at_core_block_height INTEGER, + document_id BLOB, + network TEXT NOT NULL, + PRIMARY KEY (normalized_contested_name, identity_id, network), + FOREIGN KEY (normalized_contested_name, network) + REFERENCES contested_name(normalized_contested_name, network) + ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS contract ( + contract_id BLOB, + contract BLOB, + name TEXT, + network TEXT NOT NULL, + PRIMARY KEY (contract_id, network) + ); + + CREATE INDEX IF NOT EXISTS idx_name_network ON contract (name, network);", + ) + .unwrap(); + + // v0.9.0 also created these via separate functions + // proof_log (v2) + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS proof_log ( + proof_log_id INTEGER PRIMARY KEY AUTOINCREMENT, + proof_log BLOB NOT NULL, + proof_log_timestamp INTEGER NOT NULL + );", + ) + .unwrap(); + + // top_up (v4) + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS top_up ( + identity_id BLOB NOT NULL, + top_up_index INTEGER NOT NULL, + amount INTEGER NOT NULL, + PRIMARY KEY (identity_id, top_up_index), + FOREIGN KEY (identity_id) REFERENCES identity(id) ON DELETE CASCADE + );", + ) + .unwrap(); + + // scheduled_votes (v5) — v0.9.0 schema had NO network column + // and NO FK to identity. The v6 migration handles both. + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS scheduled_votes ( + identity_id BLOB NOT NULL, + contested_name TEXT NOT NULL, + vote_choice TEXT NOT NULL, + time INTEGER NOT NULL, + executed INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (identity_id, contested_name) + );", + ) + .unwrap(); + + // Insert settings at version 5 + conn.execute( + "INSERT INTO settings (id, network, start_root_screen, database_version) + VALUES (1, 'dash', 0, 5)", + [], + ) + .unwrap(); + + // Insert a wallet with some addresses and an identity + let seed_hash = vec![0xAAu8; 32]; + conn.execute( + "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, + master_ecdsa_bip44_account_0_epk, alias, is_main, uses_password, network) + VALUES (?1, ?2, ?3, ?4, ?5, 'test-wallet', 1, 0, 'dash')", + params![ + seed_hash, + vec![1u8; 64], + vec![2u8; 16], + vec![3u8; 12], + vec![4u8; 33] + ], + ) + .unwrap(); + + conn.execute( + "INSERT INTO wallet_addresses (seed_hash, address, derivation_path, + balance, path_reference, path_type) + VALUES (?1, 'yTestAddr1', 'm/44''/1''/0''/0/0', 50000, 0, 0)", + params![seed_hash], + ) + .unwrap(); + + let identity_id = vec![0xBBu8; 32]; + conn.execute( + "INSERT INTO identity (id, is_local, alias, wallet, wallet_index, + identity_type, network) + VALUES (?1, 1, 'my-identity', ?2, 0, 'user', 'dash')", + params![identity_id, seed_hash], + ) + .unwrap(); + + conn.execute( + "INSERT INTO asset_lock_transaction (tx_id, transaction_data, amount, + identity_id, wallet, network) + VALUES (?1, ?2, 100000, ?3, ?4, 'dash')", + params![vec![0xCCu8; 32], vec![0u8; 50], identity_id, seed_hash], + ) + .unwrap(); + + conn.execute( + "INSERT INTO contract (contract_id, contract, name, network) + VALUES (?1, ?2, 'dpns', 'dash')", + params![vec![0xDDu8; 32], vec![0u8; 100]], + ) + .unwrap(); + } + + assert_eq!(db.db_schema_version().unwrap(), 5); + + // Run full migration from v5 to current + let result = db.try_perform_migration(5, DEFAULT_DB_VERSION); + assert!( + result.is_ok(), + "migration from v0.9.0 (v5) to v{DEFAULT_DB_VERSION} failed: {:?}", + result.err() + ); + + assert_eq!(db.db_schema_version().unwrap(), DEFAULT_DB_VERSION); + + let conn = db.conn.lock().unwrap(); + assert_v33_schema(&conn); + + // Verify data survived migration + let wallet_network: String = conn + .query_row( + "SELECT network FROM wallet WHERE seed_hash = ?1", + params![vec![0xAAu8; 32]], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + wallet_network, "mainnet", + "wallet network should be renamed" + ); + + // wallet_addresses should have total_received column (added by v17) + assert_column_exists(&conn, "wallet_addresses", "total_received"); + + // wallet should have balance columns (added by v16) + assert_column_exists(&conn, "wallet", "confirmed_balance"); + assert_column_exists(&conn, "wallet", "total_balance"); + + // wallet should have core_wallet_name (added by v33) + assert_column_exists(&conn, "wallet", "core_wallet_name"); + + // Identity should survive with network renamed + let id_network: String = conn + .query_row( + "SELECT network FROM identity WHERE id = ?1", + params![vec![0xBBu8; 32]], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(id_network, "mainnet"); + + // Asset lock should survive with identity_id intact + let lock_identity: Option> = conn + .query_row( + "SELECT identity_id FROM asset_lock_transaction WHERE tx_id = ?1", + params![vec![0xCCu8; 32]], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(lock_identity, Some(vec![0xBBu8; 32])); + } } diff --git a/src/database/scheduled_votes.rs b/src/database/scheduled_votes.rs index 3bbfd0336..461709ead 100644 --- a/src/database/scheduled_votes.rs +++ b/src/database/scheduled_votes.rs @@ -79,13 +79,28 @@ impl Database { [], )?; - // Copy data from old to new table - conn.execute( - "INSERT INTO scheduled_votes (identity_id, contested_name, vote_choice, time, executed, network) - SELECT identity_id, contested_name, vote_choice, time, executed, network - FROM scheduled_votes_old", + // Copy data from old to new table. The v0.9.0 schema created + // scheduled_votes without a network column, so handle both cases. + let has_network: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('scheduled_votes_old') WHERE name='network'", [], + |row| row.get::<_, i32>(0).map(|count| count > 0), )?; + if has_network { + conn.execute( + "INSERT INTO scheduled_votes (identity_id, contested_name, vote_choice, time, executed, network) + SELECT identity_id, contested_name, vote_choice, time, executed, network + FROM scheduled_votes_old", + [], + )?; + } else { + conn.execute( + "INSERT INTO scheduled_votes (identity_id, contested_name, vote_choice, time, executed, network) + SELECT identity_id, contested_name, vote_choice, time, executed, 'dash' + FROM scheduled_votes_old", + [], + )?; + } // Drop the old table conn.execute("DROP TABLE scheduled_votes_old", [])?; From b810aafae57168dce9555aeebcdb88ec87802f6a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:33:24 +0200 Subject: [PATCH 09/14] chore(db): clean up historical comments in migration code Co-Authored-By: Claude Opus 4.6 --- src/database/initialization.rs | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 5cae9035f..0fd532f1c 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -24,12 +24,6 @@ impl Database { if settings_exists { self.ensure_settings_columns_exist(&conn)?; } - // NOTE: ensure_wallet_columns_exist() used to run here, but it - // ALTERs wallet_addresses which triggers FK re-validation on - // orphaned rows before our migration cleanup has a chance to run. - // Removed: those columns are added by their own migration steps - // (v16 balance cols, v17 total_received) which run sequentially - // before v33, so they always exist by the time v33 executes. } // Check if this is the first time setup by looking for entries in the settings table. @@ -946,14 +940,14 @@ impl Database { // Shielded table helpers (create_shielded_tables, create_shielded_wallet_meta_table, // add_nullifier_sync_timestamp_column) are implemented in database/shielded.rs. - /// Remove orphaned child rows whose parent wallet was deleted while FK - /// enforcement was off (system SQLite before bundled build). The UPDATE in - /// `rename_network_dash_to_mainnet` re-validates FKs and fails on these - /// orphans. All affected data is fully recoverable from the network via - /// resync, so deletion is safe. + /// Remove orphaned child rows left behind when parent rows were deleted + /// while FK enforcement was off (system SQLite before bundled build). + /// Bundled SQLite enables FK checks by default, so any subsequent UPDATE + /// on these rows triggers re-validation and fails. Covers all FK + /// relationships in the schema: wallet→children, identity→children, + /// token→children, contract→children, contested_name→children. fn clean_orphaned_fk_rows(&self, conn: &Connection) -> rusqlite::Result<()> { // --- CASCADE children of wallet(seed_hash) --- - // Delete orphaned rows where parent wallet no longer exists. let wallet_fk_delete: &[(&str, &str)] = &[ ("wallet_addresses", "seed_hash"), ("wallet_transactions", "seed_hash"), @@ -1014,7 +1008,6 @@ impl Database { } // --- SET NULL children of identity(id) --- - // asset_lock_transaction has ON DELETE SET NULL for identity_id columns. if self.table_exists(conn, "asset_lock_transaction")? { conn.execute( "UPDATE asset_lock_transaction SET identity_id = NULL @@ -1711,7 +1704,7 @@ mod test { assert_eq!(db.db_schema_version().unwrap(), 27); - // Run migration — this would fail without clean_orphaned_fk_rows + // Run migration with orphaned FK rows present let result = db.try_perform_migration(27, DEFAULT_DB_VERSION); assert!( result.is_ok(), From e6aebd5251a9f9a9a526606b13e3a987e4fb7ad8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:54:06 +0200 Subject: [PATCH 10/14] fix(db): defer FK checks during rename, improve consistency check logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CMT-1: Add PRAGMA defer_foreign_keys = ON before rename_network_dash_to_mainnet. Tables contestant and token have composite FKs that include network — updating parent tables first would temporarily break child FK references. CMT-2: PRAGMA quick_check can return multiple rows. Replace query_row with prepare + query_map to capture all issues, with bounded logging. CMT-3: Replace filter_map(|r| r.ok()) with explicit error handling in foreign_key_check. Row decode errors are now logged instead of silently dropped. Both checks cap output at 20 issues to avoid log spam. Co-Authored-By: Claude Opus 4.6 --- src/database/initialization.rs | 91 +++++++++++++++++++++++++++------- 1 file changed, 72 insertions(+), 19 deletions(-) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 0fd532f1c..4b8e35a37 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -66,6 +66,9 @@ impl Database { self.create_shielded_tables(tx)?; self.create_shielded_wallet_meta_table(tx)?; self.add_nullifier_sync_timestamp_column(tx)?; + // Defer FK checks so parent→child rename order doesn't matter + // (contestant and token have composite FKs that include network). + tx.execute_batch("PRAGMA defer_foreign_keys = ON")?; self.rename_network_dash_to_mainnet(tx)?; self.add_wallet_transaction_status_column(tx)?; } @@ -1110,23 +1113,44 @@ impl Database { /// Run database consistency checks on startup. /// Non-fatal: logs warnings for any issues found but does not fail. fn run_consistency_checks(&self) { + const MAX_ISSUES_TO_LOG: usize = 20; + let conn = self.conn.lock().unwrap(); - // PRAGMA quick_check is a faster subset of integrity_check. - // It verifies b-tree structure without cross-checking indexes. - match conn.query_row("PRAGMA quick_check", [], |row| row.get::<_, String>(0)) { - Ok(ref result) if result == "ok" => { - tracing::debug!("Database quick_check passed"); - } - Ok(result) => { - tracing::warn!("Database quick_check found issues: {result}"); - } + // PRAGMA quick_check can return multiple rows (one per issue). + match conn.prepare("PRAGMA quick_check") { + Ok(mut stmt) => match stmt + .query_map([], |row| row.get::<_, String>(0)) + .and_then(|rows| rows.collect::>>()) + { + Ok(results) if results.len() == 1 && results[0] == "ok" => { + tracing::debug!("Database quick_check passed"); + } + Ok(results) if results.is_empty() => { + tracing::warn!("Database quick_check returned no results"); + } + Ok(results) => { + tracing::warn!("Database quick_check found {} issue(s):", results.len()); + for issue in results.iter().take(MAX_ISSUES_TO_LOG) { + tracing::warn!(" {issue}"); + } + if results.len() > MAX_ISSUES_TO_LOG { + tracing::warn!( + " ... and {} more issue(s) not shown", + results.len() - MAX_ISSUES_TO_LOG + ); + } + } + Err(e) => { + tracing::warn!("Database quick_check failed: {e}"); + } + }, Err(e) => { - tracing::warn!("Database quick_check failed to execute: {e}"); + tracing::warn!("Database quick_check failed to prepare: {e}"); } } - // PRAGMA foreign_key_check returns rows for each FK violation. + // PRAGMA foreign_key_check returns one row per FK violation. match conn.prepare("PRAGMA foreign_key_check") { Ok(mut stmt) => { match stmt.query_map([], |row| { @@ -1138,17 +1162,46 @@ impl Database { )) }) { Ok(rows) => { - let violations: Vec<_> = rows.filter_map(|r| r.ok()).collect(); - if violations.is_empty() { + let mut violations = Vec::new(); + let mut row_errors = 0usize; + for row in rows { + match row { + Ok(v) => violations.push(v), + Err(e) => { + row_errors += 1; + if row_errors <= 3 { + tracing::warn!( + "Database foreign_key_check row decode error: {e}" + ); + } + } + } + } + if violations.is_empty() && row_errors == 0 { tracing::debug!("Database foreign_key_check passed — no violations"); } else { - tracing::warn!( - "Database foreign_key_check found {} violation(s):", - violations.len() - ); - for (table, rowid, parent, fk_idx) in &violations { + if !violations.is_empty() { + tracing::warn!( + "Database foreign_key_check found {} violation(s):", + violations.len() + ); + for (table, rowid, parent, fk_idx) in + violations.iter().take(MAX_ISSUES_TO_LOG) + { + tracing::warn!( + " FK violation: {table} rowid={rowid} -> {parent} (fk_index={fk_idx})" + ); + } + if violations.len() > MAX_ISSUES_TO_LOG { + tracing::warn!( + " ... and {} more violation(s) not shown", + violations.len() - MAX_ISSUES_TO_LOG + ); + } + } + if row_errors > 0 { tracing::warn!( - " FK violation: {table} rowid={rowid} -> {parent} (fk_index={fk_idx})" + "Database foreign_key_check had {row_errors} row decode error(s)" ); } } From a6c641766b02e74d73623f3554ac822ecfae4a58 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:25:31 +0200 Subject: [PATCH 11/14] fix(db): add debug logging to v33 migration steps for failure diagnosis Add per-step debug logging to the v33 migration arm, per-table logging to rename_network_dash_to_mainnet (with error-level on failure), and version context to try_perform_migration error messages. This helps pinpoint exactly which statement causes FK constraint failures. Co-Authored-By: Claude Opus 4.6 --- src/database/initialization.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 4b8e35a37..2f535387f 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -60,17 +60,27 @@ impl Database { // Every sub-migration is idempotent (IF NOT EXISTS / column checks), // so this is safe to run on any DB that already applied some or all // of the individual steps. + tracing::debug!("v33: cleaning orphaned FK rows"); self.clean_orphaned_fk_rows(tx)?; + tracing::debug!("v33: adding core_wallet_name column"); self.add_core_wallet_name_column(tx)?; + tracing::debug!("v33: creating contacts tables"); self.init_contacts_tables(tx)?; + tracing::debug!("v33: creating shielded tables"); self.create_shielded_tables(tx)?; + tracing::debug!("v33: creating shielded_wallet_meta table"); self.create_shielded_wallet_meta_table(tx)?; + tracing::debug!("v33: adding nullifier_sync_timestamp column"); self.add_nullifier_sync_timestamp_column(tx)?; // Defer FK checks so parent→child rename order doesn't matter // (contestant and token have composite FKs that include network). + tracing::debug!("v33: deferring FK checks for network rename"); tx.execute_batch("PRAGMA defer_foreign_keys = ON")?; + tracing::debug!("v33: renaming network dash -> mainnet"); self.rename_network_dash_to_mainnet(tx)?; + tracing::debug!("v33: adding wallet_transaction status column"); self.add_wallet_transaction_status_column(tx)?; + tracing::debug!("v33: migration complete"); } 27 => { self.add_network_indexes(tx)?; @@ -203,12 +213,13 @@ impl Database { .expect("Failed to lock database connection"); for version in (original_version + 1)..=to_version { + tracing::debug!("Applying migration v{version}"); let tx = conn.transaction().map_err(|e| e.to_string())?; self.apply_version_changes(version, &tx) - .map_err(|e| e.to_string())?; + .map_err(|e| format!("v{version} apply_version_changes: {e}"))?; self.update_database_version(version, &tx) - .map_err(|e| e.to_string())?; - tx.commit().map_err(|e| e.to_string())?; + .map_err(|e| format!("v{version} update_database_version: {e}"))?; + tx.commit().map_err(|e| format!("v{version} commit: {e}"))?; } Ok(true) } @@ -1102,10 +1113,15 @@ impl Database { "shielded_wallet_meta", ]; for table in tables { + tracing::debug!(" rename_network: updating {table}"); conn.execute( &format!("UPDATE {table} SET network = 'mainnet' WHERE network = 'dash'"), [], - )?; + ) + .map_err(|e| { + tracing::error!(" rename_network: FAILED on {table}: {e}"); + e + })?; } Ok(()) } From bd51806b4553e45110978bdd6ae1a56620c8876b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:46:05 +0200 Subject: [PATCH 12/14] refactor(db): replace stringly-typed migration errors with MigrationError Introduce a structured MigrationError type that carries table name, operation details, and the underlying rusqlite::Error. This replaces the previous String-based error path in try_perform_migration and gives exact context when a migration step fails. Also adds automatic PRAGMA foreign_key_check diagnostics when a SQLITE_CONSTRAINT_FOREIGNKEY error is detected during migration. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/database/initialization.rs | 565 +++++++++++++++++++++++++++------ 1 file changed, 462 insertions(+), 103 deletions(-) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 2f535387f..008247e8f 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -4,6 +4,22 @@ use rusqlite::{Connection, params}; use std::fs; use std::path::Path; +/// Error during database migration with structured context. +#[derive(Debug, thiserror::Error)] +#[error("migration failed on {}{}: {source}", + table.as_deref().unwrap_or("(unknown table)"), + if details.is_empty() { String::new() } else { format!(" ({})", details) } +)] +pub struct MigrationError { + /// Table being operated on when the error occurred, if known. + pub table: Option, + /// Human-readable description of the operation that failed. + pub details: String, + /// Underlying SQLite error. + #[source] + pub source: rusqlite::Error, +} + pub const DEFAULT_DB_VERSION: u16 = 33; pub const DEFAULT_NETWORK: &str = "mainnet"; @@ -49,7 +65,7 @@ impl Database { Ok(()) } - fn apply_version_changes(&self, version: u16, tx: &Connection) -> rusqlite::Result<()> { + fn apply_version_changes(&self, version: u16, tx: &Connection) -> Result<(), MigrationError> { match version { // Versions 28-32 were consolidated into v33 to resolve migration // numbering conflicts between the zk and v1.0-dev branches. @@ -60,114 +76,330 @@ impl Database { // Every sub-migration is idempotent (IF NOT EXISTS / column checks), // so this is safe to run on any DB that already applied some or all // of the individual steps. - tracing::debug!("v33: cleaning orphaned FK rows"); self.clean_orphaned_fk_rows(tx)?; - tracing::debug!("v33: adding core_wallet_name column"); - self.add_core_wallet_name_column(tx)?; - tracing::debug!("v33: creating contacts tables"); - self.init_contacts_tables(tx)?; - tracing::debug!("v33: creating shielded tables"); - self.create_shielded_tables(tx)?; - tracing::debug!("v33: creating shielded_wallet_meta table"); - self.create_shielded_wallet_meta_table(tx)?; - tracing::debug!("v33: adding nullifier_sync_timestamp column"); - self.add_nullifier_sync_timestamp_column(tx)?; - // Defer FK checks so parent→child rename order doesn't matter + self.add_core_wallet_name_column(tx) + .map_err(|e| MigrationError { + table: Some("wallet".into()), + details: "add core_wallet_name column".into(), + source: e, + })?; + self.init_contacts_tables(tx).map_err(|e| MigrationError { + table: Some("contact_private_info".into()), + details: "create contacts tables".into(), + source: e, + })?; + self.create_shielded_tables(tx) + .map_err(|e| MigrationError { + table: Some("shielded_notes".into()), + details: "create shielded tables".into(), + source: e, + })?; + self.create_shielded_wallet_meta_table(tx) + .map_err(|e| MigrationError { + table: Some("shielded_wallet_meta".into()), + details: "create shielded_wallet_meta table".into(), + source: e, + })?; + self.add_nullifier_sync_timestamp_column(tx) + .map_err(|e| MigrationError { + table: Some("shielded_wallet_meta".into()), + details: "add last_nullifier_sync_timestamp column".into(), + source: e, + })?; + // Defer FK checks so parent->child rename order doesn't matter // (contestant and token have composite FKs that include network). - tracing::debug!("v33: deferring FK checks for network rename"); - tx.execute_batch("PRAGMA defer_foreign_keys = ON")?; - tracing::debug!("v33: renaming network dash -> mainnet"); + tx.execute_batch("PRAGMA defer_foreign_keys = ON") + .map_err(|e| MigrationError { + table: None, + details: "defer FK checks for network rename".into(), + source: e, + })?; self.rename_network_dash_to_mainnet(tx)?; - tracing::debug!("v33: adding wallet_transaction status column"); - self.add_wallet_transaction_status_column(tx)?; - tracing::debug!("v33: migration complete"); + self.add_wallet_transaction_status_column(tx) + .map_err(|e| MigrationError { + table: Some("wallet_transactions".into()), + details: "add status column".into(), + source: e, + })?; } 27 => { - self.add_network_indexes(tx)?; + self.add_network_indexes(tx).map_err(|e| MigrationError { + table: None, + details: "add network indexes".into(), + source: e, + })?; } 26 => { - self.add_last_full_sync_balance_column(tx)?; + self.add_last_full_sync_balance_column(tx) + .map_err(|e| MigrationError { + table: Some("platform_address_balances".into()), + details: "add last_full_sync_balance column".into(), + source: e, + })?; } 25 => { - self.add_avatar_bytes_column(tx)?; + self.add_avatar_bytes_column(tx) + .map_err(|e| MigrationError { + table: Some("dashpay_profiles".into()), + details: "add avatar_bytes column".into(), + source: e, + })?; } 24 => { - self.add_selected_wallet_columns(tx)?; + self.add_selected_wallet_columns(tx) + .map_err(|e| MigrationError { + table: Some("settings".into()), + details: "add selected_wallet columns".into(), + source: e, + })?; } 23 => { - self.add_last_terminal_block_column(tx)?; + self.add_last_terminal_block_column(tx) + .map_err(|e| MigrationError { + table: Some("wallet".into()), + details: "add last_terminal_block column".into(), + source: e, + })?; } 22 => { - self.add_network_column_to_dashpay_contact_requests(tx)?; - self.add_network_column_to_dashpay_contacts(tx)?; + self.add_network_column_to_dashpay_contact_requests(tx) + .map_err(|e| MigrationError { + table: Some("dashpay_contact_requests".into()), + details: "add network column".into(), + source: e, + })?; + self.add_network_column_to_dashpay_contacts(tx) + .map_err(|e| MigrationError { + table: Some("dashpay_contacts".into()), + details: "add network column".into(), + source: e, + })?; } 21 => { - self.add_network_column_to_dashpay_profiles(tx)?; + self.add_network_column_to_dashpay_profiles(tx) + .map_err(|e| MigrationError { + table: Some("dashpay_profiles".into()), + details: "add network column".into(), + source: e, + })?; } 20 => { - self.add_platform_sync_columns(tx)?; + self.add_platform_sync_columns(tx) + .map_err(|e| MigrationError { + table: Some("wallet".into()), + details: "add platform sync columns".into(), + source: e, + })?; } 19 => { - self.initialize_platform_address_balances_table(tx)?; + self.initialize_platform_address_balances_table(tx) + .map_err(|e| MigrationError { + table: Some("platform_address_balances".into()), + details: "create table".into(), + source: e, + })?; } 18 => { - self.initialize_single_key_wallet_table(tx)?; + self.initialize_single_key_wallet_table(tx) + .map_err(|e| MigrationError { + table: Some("single_key_wallet".into()), + details: "create table".into(), + source: e, + })?; } 17 => { - self.add_address_total_received_column(tx)?; + self.add_address_total_received_column(tx) + .map_err(|e| MigrationError { + table: Some("wallet_addresses".into()), + details: "add total_received column".into(), + source: e, + })?; } 16 => { - self.add_wallet_balance_columns(tx)?; + self.add_wallet_balance_columns(tx) + .map_err(|e| MigrationError { + table: Some("wallet".into()), + details: "add balance columns".into(), + source: e, + })?; } 15 => { - self.add_core_backend_mode_column(tx)?; + self.add_core_backend_mode_column(tx) + .map_err(|e| MigrationError { + table: Some("settings".into()), + details: "add core_backend_mode column".into(), + source: e, + })?; } 14 => { - self.initialize_wallet_transactions_table(tx)?; + self.initialize_wallet_transactions_table(tx) + .map_err(|e| MigrationError { + table: Some("wallet_transactions".into()), + details: "create table".into(), + source: e, + })?; } 13 => { - // Add DashPay tables in version 12 - self.init_dashpay_tables_in_tx(tx)?; + self.init_dashpay_tables_in_tx(tx) + .map_err(|e| MigrationError { + table: Some("dashpay_profiles".into()), + details: "create DashPay tables".into(), + source: e, + })?; + } + 12 => { + self.add_disable_zmq_column(tx) + .map_err(|e| MigrationError { + table: Some("settings".into()), + details: "add disable_zmq column".into(), + source: e, + })?; + } + 11 => { + self.rename_identity_column_is_in_creation_to_status(tx) + .map_err(|e| MigrationError { + table: Some("identity".into()), + details: "rename is_in_creation to status".into(), + source: e, + })?; } - 12 => self.add_disable_zmq_column(tx)?, - 11 => self.rename_identity_column_is_in_creation_to_status(tx)?, 10 => { - self.add_theme_preference_column(tx)?; + self.add_theme_preference_column(tx) + .map_err(|e| MigrationError { + table: Some("settings".into()), + details: "add theme_preference column".into(), + source: e, + })?; } 9 => { - self.delete_all_identities_in_all_devnets_and_regtest(tx)?; - self.delete_all_local_tokens_in_all_devnets_and_regtest(tx)?; - self.remove_all_asset_locks_identity_id_for_all_devnets_and_regtest(tx)?; - self.remove_all_contracts_in_all_devnets_and_regtest(tx)?; - self.fix_identity_devnet_network_name(tx)?; + self.delete_all_identities_in_all_devnets_and_regtest(tx) + .map_err(|e| MigrationError { + table: Some("identity".into()), + details: "delete devnet/regtest identities".into(), + source: e, + })?; + self.delete_all_local_tokens_in_all_devnets_and_regtest(tx) + .map_err(|e| MigrationError { + table: Some("token".into()), + details: "delete devnet/regtest tokens".into(), + source: e, + })?; + self.remove_all_asset_locks_identity_id_for_all_devnets_and_regtest(tx) + .map_err(|e| MigrationError { + table: Some("asset_lock_transaction".into()), + details: "clear devnet/regtest asset lock identity IDs".into(), + source: e, + })?; + self.remove_all_contracts_in_all_devnets_and_regtest(tx) + .map_err(|e| MigrationError { + table: Some("contract".into()), + details: "delete devnet/regtest contracts".into(), + source: e, + })?; + self.fix_identity_devnet_network_name(tx) + .map_err(|e| MigrationError { + table: Some("identity".into()), + details: "fix devnet network name".into(), + source: e, + })?; } 8 => { - self.change_contract_name_to_alias(tx)?; + self.change_contract_name_to_alias(tx) + .map_err(|e| MigrationError { + table: Some("contract".into()), + details: "rename name to alias".into(), + source: e, + })?; } 7 => { - self.migrate_asset_lock_fk_to_set_null(tx)?; + self.migrate_asset_lock_fk_to_set_null(tx) + .map_err(|e| MigrationError { + table: Some("asset_lock_transaction".into()), + details: "migrate FK to SET NULL".into(), + source: e, + })?; } 6 => { - self.update_scheduled_votes_table(tx)?; - self.initialize_token_table(tx)?; - self.drop_identity_token_balances_table(tx)?; - self.initialize_identity_token_balances_table(tx)?; - tx.execute("DROP TABLE IF EXISTS identity_order", [])?; - self.initialize_identity_order_table(tx)?; - tx.execute("DROP TABLE IF EXISTS token_order", [])?; - self.initialize_token_order_table(tx)?; + self.update_scheduled_votes_table(tx) + .map_err(|e| MigrationError { + table: Some("scheduled_votes".into()), + details: "update table schema".into(), + source: e, + })?; + self.initialize_token_table(tx) + .map_err(|e| MigrationError { + table: Some("token".into()), + details: "create table".into(), + source: e, + })?; + self.drop_identity_token_balances_table(tx) + .map_err(|e| MigrationError { + table: Some("identity_token_balances".into()), + details: "drop table".into(), + source: e, + })?; + self.initialize_identity_token_balances_table(tx) + .map_err(|e| MigrationError { + table: Some("identity_token_balances".into()), + details: "create table".into(), + source: e, + })?; + tx.execute("DROP TABLE IF EXISTS identity_order", []) + .map_err(|e| MigrationError { + table: Some("identity_order".into()), + details: "drop table".into(), + source: e, + })?; + self.initialize_identity_order_table(tx) + .map_err(|e| MigrationError { + table: Some("identity_order".into()), + details: "create table".into(), + source: e, + })?; + tx.execute("DROP TABLE IF EXISTS token_order", []) + .map_err(|e| MigrationError { + table: Some("token_order".into()), + details: "drop table".into(), + source: e, + })?; + self.initialize_token_order_table(tx) + .map_err(|e| MigrationError { + table: Some("token_order".into()), + details: "create table".into(), + source: e, + })?; } 5 => { - self.initialize_scheduled_votes_table(tx)?; + self.initialize_scheduled_votes_table(tx) + .map_err(|e| MigrationError { + table: Some("scheduled_votes".into()), + details: "create table".into(), + source: e, + })?; } 4 => { - self.initialize_top_up_table(tx)?; + self.initialize_top_up_table(tx) + .map_err(|e| MigrationError { + table: Some("top_up".into()), + details: "create table".into(), + source: e, + })?; } 3 => { - self.add_custom_dash_qt_columns(tx)?; + self.add_custom_dash_qt_columns(tx) + .map_err(|e| MigrationError { + table: Some("settings".into()), + details: "add custom dash_qt columns".into(), + source: e, + })?; } 2 => { - self.initialize_proof_log_table(tx)?; + self.initialize_proof_log_table(tx) + .map_err(|e| MigrationError { + table: Some("proof_log".into()), + details: "create table".into(), + source: e, + })?; } _ => { tracing::warn!("No database changes for version {}", version); @@ -193,7 +425,7 @@ impl Database { &self, original_version: u16, to_version: u16, - ) -> Result { + ) -> Result { match original_version.cmp(&to_version) { std::cmp::Ordering::Equal => { tracing::trace!( @@ -202,10 +434,18 @@ impl Database { ); Ok(false) } - std::cmp::Ordering::Greater => Err(format!( - "Database schema version {} is too new, max supported version: {}. Please update dash-evo-tool.", - original_version, to_version - )), + std::cmp::Ordering::Greater => Err(MigrationError { + table: None, + details: format!( + "schema version {} is too new, max supported: {}", + original_version, to_version + ), + source: rusqlite::Error::InvalidParameterName(format!( + "Database schema version {} is too new, max supported version: {}. \ + Please update dash-evo-tool.", + original_version, to_version + )), + }), std::cmp::Ordering::Less => { let mut conn = self .conn @@ -214,12 +454,38 @@ impl Database { for version in (original_version + 1)..=to_version { tracing::debug!("Applying migration v{version}"); - let tx = conn.transaction().map_err(|e| e.to_string())?; - self.apply_version_changes(version, &tx) - .map_err(|e| format!("v{version} apply_version_changes: {e}"))?; - self.update_database_version(version, &tx) - .map_err(|e| format!("v{version} update_database_version: {e}"))?; - tx.commit().map_err(|e| format!("v{version} commit: {e}"))?; + let tx = conn.transaction().map_err(|e| MigrationError { + table: None, + details: format!("v{version}: begin transaction"), + source: e, + })?; + let result = self + .apply_version_changes(version, &tx) + .and_then(|()| { + self.update_database_version(version, &tx) + .map_err(|e| MigrationError { + table: Some("settings".into()), + details: format!("v{version}: update_database_version"), + source: e, + }) + }) + .and_then(|()| { + tx.commit().map_err(|e| MigrationError { + table: None, + details: format!("v{version}: commit"), + source: e, + }) + }); + + if let Err(ref migration_err) = result { + if let rusqlite::Error::SqliteFailure(err, _) = &migration_err.source + && err.extended_code == 787 + { + // SQLITE_CONSTRAINT_FOREIGNKEY + Self::log_fk_violations(&conn); + } + return result.map(|()| true); + } } Ok(true) } @@ -960,7 +1226,18 @@ impl Database { /// on these rows triggers re-validation and fails. Covers all FK /// relationships in the schema: wallet→children, identity→children, /// token→children, contract→children, contested_name→children. - fn clean_orphaned_fk_rows(&self, conn: &Connection) -> rusqlite::Result<()> { + fn clean_orphaned_fk_rows(&self, conn: &Connection) -> Result<(), MigrationError> { + // Helper to wrap rusqlite errors with table context for this function. + let wrap = |table: &str, details: &str| { + let table = table.to_string(); + let details = details.to_string(); + move |e: rusqlite::Error| MigrationError { + table: Some(table), + details, + source: e, + } + }; + // --- CASCADE children of wallet(seed_hash) --- let wallet_fk_delete: &[(&str, &str)] = &[ ("wallet_addresses", "seed_hash"), @@ -971,13 +1248,18 @@ impl Database { ("asset_lock_transaction", "wallet"), ]; for (table, fk_col) in wallet_fk_delete { - if self.table_exists(conn, table)? { - let deleted = conn.execute( - &format!( - "DELETE FROM {table} WHERE {fk_col} NOT IN (SELECT seed_hash FROM wallet)" - ), - [], - )?; + if self + .table_exists(conn, table) + .map_err(wrap(table, "check table existence"))? + { + let deleted = conn + .execute( + &format!( + "DELETE FROM {table} WHERE {fk_col} NOT IN (SELECT seed_hash FROM wallet)" + ), + [], + ) + .map_err(wrap(table, "delete orphaned wallet FK rows"))?; if deleted > 0 { tracing::info!( "Cleaned {deleted} orphaned row(s) from {table} (missing wallet)" @@ -988,12 +1270,17 @@ impl Database { // identity.wallet is nullable with ON DELETE CASCADE — delete orphaned // identities whose wallet no longer exists (but skip NULL wallet). - if self.table_exists(conn, "identity")? { - let deleted = conn.execute( - "DELETE FROM identity WHERE wallet IS NOT NULL + if self + .table_exists(conn, "identity") + .map_err(wrap("identity", "check table existence"))? + { + let deleted = conn + .execute( + "DELETE FROM identity WHERE wallet IS NOT NULL AND wallet NOT IN (SELECT seed_hash FROM wallet)", - [], - )?; + [], + ) + .map_err(wrap("identity", "delete orphaned identity rows"))?; if deleted > 0 { tracing::info!("Cleaned {deleted} orphaned identity row(s) (missing wallet)"); } @@ -1008,11 +1295,18 @@ impl Database { ("token_order", "identity_id"), ]; for (table, fk_col) in identity_fk_delete { - if self.table_exists(conn, table)? { - let deleted = conn.execute( - &format!("DELETE FROM {table} WHERE {fk_col} NOT IN (SELECT id FROM identity)"), - [], - )?; + if self + .table_exists(conn, table) + .map_err(wrap(table, "check table existence"))? + { + let deleted = conn + .execute( + &format!( + "DELETE FROM {table} WHERE {fk_col} NOT IN (SELECT id FROM identity)" + ), + [], + ) + .map_err(wrap(table, "delete orphaned identity FK rows"))?; if deleted > 0 { tracing::info!( "Cleaned {deleted} orphaned row(s) from {table} (missing identity)" @@ -1022,60 +1316,124 @@ impl Database { } // --- SET NULL children of identity(id) --- - if self.table_exists(conn, "asset_lock_transaction")? { + if self + .table_exists(conn, "asset_lock_transaction") + .map_err(wrap("asset_lock_transaction", "check table existence"))? + { conn.execute( "UPDATE asset_lock_transaction SET identity_id = NULL WHERE identity_id IS NOT NULL AND identity_id NOT IN (SELECT id FROM identity)", [], - )?; + ) + .map_err(wrap( + "asset_lock_transaction", + "nullify orphaned identity_id", + ))?; conn.execute( "UPDATE asset_lock_transaction SET identity_id_potentially_in_creation = NULL WHERE identity_id_potentially_in_creation IS NOT NULL AND identity_id_potentially_in_creation NOT IN (SELECT id FROM identity)", [], - )?; + ) + .map_err(wrap( + "asset_lock_transaction", + "nullify orphaned identity_id_potentially_in_creation", + ))?; } // --- CASCADE children of token(id) --- - if self.table_exists(conn, "identity_token_balances")? - && self.table_exists(conn, "token")? + if self + .table_exists(conn, "identity_token_balances") + .map_err(wrap("identity_token_balances", "check table existence"))? + && self + .table_exists(conn, "token") + .map_err(wrap("token", "check table existence"))? { conn.execute( "DELETE FROM identity_token_balances WHERE token_id NOT IN (SELECT id FROM token)", [], - )?; + ) + .map_err(wrap( + "identity_token_balances", + "delete orphaned token FK rows", + ))?; } - if self.table_exists(conn, "token_order")? && self.table_exists(conn, "token")? { + if self + .table_exists(conn, "token_order") + .map_err(wrap("token_order", "check table existence"))? + && self + .table_exists(conn, "token") + .map_err(wrap("token", "check table existence"))? + { conn.execute( "DELETE FROM token_order WHERE token_id NOT IN (SELECT id FROM token)", [], - )?; + ) + .map_err(wrap("token_order", "delete orphaned token FK rows"))?; } // --- CASCADE children of contract --- - if self.table_exists(conn, "token")? && self.table_exists(conn, "contract")? { + if self + .table_exists(conn, "token") + .map_err(wrap("token", "check table existence"))? + && self + .table_exists(conn, "contract") + .map_err(wrap("contract", "check table existence"))? + { conn.execute( "DELETE FROM token WHERE (data_contract_id, network) NOT IN (SELECT contract_id, network FROM contract)", [], - )?; + ) + .map_err(wrap("token", "delete orphaned contract FK rows"))?; } // --- CASCADE children of contested_name --- - if self.table_exists(conn, "contestant")? && self.table_exists(conn, "contested_name")? { + if self + .table_exists(conn, "contestant") + .map_err(wrap("contestant", "check table existence"))? + && self + .table_exists(conn, "contested_name") + .map_err(wrap("contested_name", "check table existence"))? + { conn.execute( "DELETE FROM contestant WHERE (normalized_contested_name, network) NOT IN (SELECT normalized_contested_name, network FROM contested_name)", [], - )?; + ) + .map_err(wrap("contestant", "delete orphaned contested_name FK rows"))?; } Ok(()) } + /// Log all FK violations to help diagnose SQLITE_CONSTRAINT_FOREIGNKEY errors. + fn log_fk_violations(conn: &Connection) { + tracing::error!( + "FK constraint failure detected — running PRAGMA foreign_key_check for diagnostics:" + ); + if let Ok(mut stmt) = conn.prepare("PRAGMA foreign_key_check") + && let Ok(rows) = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + )) + }) + { + for row in rows.flatten() { + let (table, rowid, parent, fk_idx) = row; + tracing::error!( + " FK violation: {table} rowid={rowid} -> {parent} (fk_index={fk_idx})" + ); + } + } + } + /// Check if a table exists in the database. fn table_exists(&self, conn: &Connection, table: &str) -> rusqlite::Result { conn.query_row( @@ -1090,7 +1448,7 @@ impl Database { /// Upstream `dashcore` renamed `Network::Dash` to `Network::Mainnet`, /// changing the `Display`/`FromStr` representation. This migration updates /// every table that stores the network as a string column. - fn rename_network_dash_to_mainnet(&self, conn: &Connection) -> rusqlite::Result<()> { + fn rename_network_dash_to_mainnet(&self, conn: &Connection) -> Result<(), MigrationError> { let tables = [ "settings", "wallet", @@ -1118,9 +1476,10 @@ impl Database { &format!("UPDATE {table} SET network = 'mainnet' WHERE network = 'dash'"), [], ) - .map_err(|e| { - tracing::error!(" rename_network: FAILED on {table}: {e}"); - e + .map_err(|e| MigrationError { + table: Some(table.to_string()), + details: "rename network dash -> mainnet".into(), + source: e, })?; } Ok(()) From 3209f82ca62ca5ebec830ddc7effafe1e2cfcd5e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:11:00 +0200 Subject: [PATCH 13/14] refactor(db): use MigrationResultExt trait for cleaner error wrapping Co-Authored-By: Claude Opus 4.6 (1M context) --- src/database/initialization.rs | 362 +++++++++------------------------ 1 file changed, 95 insertions(+), 267 deletions(-) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 008247e8f..44f447aba 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -20,6 +20,21 @@ pub struct MigrationError { pub source: rusqlite::Error, } +/// Extension trait for converting `rusqlite::Result` into `MigrationError` with table context. +trait MigrationResultExt { + fn migration_err(self, table: &str, details: &str) -> Result; +} + +impl MigrationResultExt for rusqlite::Result { + fn migration_err(self, table: &str, details: &str) -> Result { + self.map_err(|e| MigrationError { + table: Some(table.into()), + details: details.into(), + source: e, + }) + } +} + pub const DEFAULT_DB_VERSION: u16 = 33; pub const DEFAULT_NETWORK: &str = "mainnet"; @@ -78,34 +93,17 @@ impl Database { // of the individual steps. self.clean_orphaned_fk_rows(tx)?; self.add_core_wallet_name_column(tx) - .map_err(|e| MigrationError { - table: Some("wallet".into()), - details: "add core_wallet_name column".into(), - source: e, - })?; - self.init_contacts_tables(tx).map_err(|e| MigrationError { - table: Some("contact_private_info".into()), - details: "create contacts tables".into(), - source: e, - })?; + .migration_err("wallet", "add core_wallet_name column")?; + self.init_contacts_tables(tx) + .migration_err("contact_private_info", "create contacts tables")?; self.create_shielded_tables(tx) - .map_err(|e| MigrationError { - table: Some("shielded_notes".into()), - details: "create shielded tables".into(), - source: e, - })?; + .migration_err("shielded_notes", "create shielded tables")?; self.create_shielded_wallet_meta_table(tx) - .map_err(|e| MigrationError { - table: Some("shielded_wallet_meta".into()), - details: "create shielded_wallet_meta table".into(), - source: e, - })?; - self.add_nullifier_sync_timestamp_column(tx) - .map_err(|e| MigrationError { - table: Some("shielded_wallet_meta".into()), - details: "add last_nullifier_sync_timestamp column".into(), - source: e, - })?; + .migration_err("shielded_wallet_meta", "create shielded_wallet_meta table")?; + self.add_nullifier_sync_timestamp_column(tx).migration_err( + "shielded_wallet_meta", + "add last_nullifier_sync_timestamp column", + )?; // Defer FK checks so parent->child rename order doesn't matter // (contestant and token have composite FKs that include network). tx.execute_batch("PRAGMA defer_foreign_keys = ON") @@ -116,11 +114,7 @@ impl Database { })?; self.rename_network_dash_to_mainnet(tx)?; self.add_wallet_transaction_status_column(tx) - .map_err(|e| MigrationError { - table: Some("wallet_transactions".into()), - details: "add status column".into(), - source: e, - })?; + .migration_err("wallet_transactions", "add status column")?; } 27 => { self.add_network_indexes(tx).map_err(|e| MigrationError { @@ -130,276 +124,133 @@ impl Database { })?; } 26 => { - self.add_last_full_sync_balance_column(tx) - .map_err(|e| MigrationError { - table: Some("platform_address_balances".into()), - details: "add last_full_sync_balance column".into(), - source: e, - })?; + self.add_last_full_sync_balance_column(tx).migration_err( + "platform_address_balances", + "add last_full_sync_balance column", + )?; } 25 => { self.add_avatar_bytes_column(tx) - .map_err(|e| MigrationError { - table: Some("dashpay_profiles".into()), - details: "add avatar_bytes column".into(), - source: e, - })?; + .migration_err("dashpay_profiles", "add avatar_bytes column")?; } 24 => { self.add_selected_wallet_columns(tx) - .map_err(|e| MigrationError { - table: Some("settings".into()), - details: "add selected_wallet columns".into(), - source: e, - })?; + .migration_err("settings", "add selected_wallet columns")?; } 23 => { self.add_last_terminal_block_column(tx) - .map_err(|e| MigrationError { - table: Some("wallet".into()), - details: "add last_terminal_block column".into(), - source: e, - })?; + .migration_err("wallet", "add last_terminal_block column")?; } 22 => { self.add_network_column_to_dashpay_contact_requests(tx) - .map_err(|e| MigrationError { - table: Some("dashpay_contact_requests".into()), - details: "add network column".into(), - source: e, - })?; + .migration_err("dashpay_contact_requests", "add network column")?; self.add_network_column_to_dashpay_contacts(tx) - .map_err(|e| MigrationError { - table: Some("dashpay_contacts".into()), - details: "add network column".into(), - source: e, - })?; + .migration_err("dashpay_contacts", "add network column")?; } 21 => { self.add_network_column_to_dashpay_profiles(tx) - .map_err(|e| MigrationError { - table: Some("dashpay_profiles".into()), - details: "add network column".into(), - source: e, - })?; + .migration_err("dashpay_profiles", "add network column")?; } 20 => { self.add_platform_sync_columns(tx) - .map_err(|e| MigrationError { - table: Some("wallet".into()), - details: "add platform sync columns".into(), - source: e, - })?; + .migration_err("wallet", "add platform sync columns")?; } 19 => { self.initialize_platform_address_balances_table(tx) - .map_err(|e| MigrationError { - table: Some("platform_address_balances".into()), - details: "create table".into(), - source: e, - })?; + .migration_err("platform_address_balances", "create table")?; } 18 => { self.initialize_single_key_wallet_table(tx) - .map_err(|e| MigrationError { - table: Some("single_key_wallet".into()), - details: "create table".into(), - source: e, - })?; + .migration_err("single_key_wallet", "create table")?; } 17 => { self.add_address_total_received_column(tx) - .map_err(|e| MigrationError { - table: Some("wallet_addresses".into()), - details: "add total_received column".into(), - source: e, - })?; + .migration_err("wallet_addresses", "add total_received column")?; } 16 => { self.add_wallet_balance_columns(tx) - .map_err(|e| MigrationError { - table: Some("wallet".into()), - details: "add balance columns".into(), - source: e, - })?; + .migration_err("wallet", "add balance columns")?; } 15 => { self.add_core_backend_mode_column(tx) - .map_err(|e| MigrationError { - table: Some("settings".into()), - details: "add core_backend_mode column".into(), - source: e, - })?; + .migration_err("settings", "add core_backend_mode column")?; } 14 => { self.initialize_wallet_transactions_table(tx) - .map_err(|e| MigrationError { - table: Some("wallet_transactions".into()), - details: "create table".into(), - source: e, - })?; + .migration_err("wallet_transactions", "create table")?; } 13 => { self.init_dashpay_tables_in_tx(tx) - .map_err(|e| MigrationError { - table: Some("dashpay_profiles".into()), - details: "create DashPay tables".into(), - source: e, - })?; + .migration_err("dashpay_profiles", "create DashPay tables")?; } 12 => { self.add_disable_zmq_column(tx) - .map_err(|e| MigrationError { - table: Some("settings".into()), - details: "add disable_zmq column".into(), - source: e, - })?; + .migration_err("settings", "add disable_zmq column")?; } 11 => { self.rename_identity_column_is_in_creation_to_status(tx) - .map_err(|e| MigrationError { - table: Some("identity".into()), - details: "rename is_in_creation to status".into(), - source: e, - })?; + .migration_err("identity", "rename is_in_creation to status")?; } 10 => { self.add_theme_preference_column(tx) - .map_err(|e| MigrationError { - table: Some("settings".into()), - details: "add theme_preference column".into(), - source: e, - })?; + .migration_err("settings", "add theme_preference column")?; } 9 => { self.delete_all_identities_in_all_devnets_and_regtest(tx) - .map_err(|e| MigrationError { - table: Some("identity".into()), - details: "delete devnet/regtest identities".into(), - source: e, - })?; + .migration_err("identity", "delete devnet/regtest identities")?; self.delete_all_local_tokens_in_all_devnets_and_regtest(tx) - .map_err(|e| MigrationError { - table: Some("token".into()), - details: "delete devnet/regtest tokens".into(), - source: e, - })?; + .migration_err("token", "delete devnet/regtest tokens")?; self.remove_all_asset_locks_identity_id_for_all_devnets_and_regtest(tx) - .map_err(|e| MigrationError { - table: Some("asset_lock_transaction".into()), - details: "clear devnet/regtest asset lock identity IDs".into(), - source: e, - })?; + .migration_err( + "asset_lock_transaction", + "clear devnet/regtest asset lock identity IDs", + )?; self.remove_all_contracts_in_all_devnets_and_regtest(tx) - .map_err(|e| MigrationError { - table: Some("contract".into()), - details: "delete devnet/regtest contracts".into(), - source: e, - })?; + .migration_err("contract", "delete devnet/regtest contracts")?; self.fix_identity_devnet_network_name(tx) - .map_err(|e| MigrationError { - table: Some("identity".into()), - details: "fix devnet network name".into(), - source: e, - })?; + .migration_err("identity", "fix devnet network name")?; } 8 => { self.change_contract_name_to_alias(tx) - .map_err(|e| MigrationError { - table: Some("contract".into()), - details: "rename name to alias".into(), - source: e, - })?; + .migration_err("contract", "rename name to alias")?; } 7 => { self.migrate_asset_lock_fk_to_set_null(tx) - .map_err(|e| MigrationError { - table: Some("asset_lock_transaction".into()), - details: "migrate FK to SET NULL".into(), - source: e, - })?; + .migration_err("asset_lock_transaction", "migrate FK to SET NULL")?; } 6 => { self.update_scheduled_votes_table(tx) - .map_err(|e| MigrationError { - table: Some("scheduled_votes".into()), - details: "update table schema".into(), - source: e, - })?; + .migration_err("scheduled_votes", "update table schema")?; self.initialize_token_table(tx) - .map_err(|e| MigrationError { - table: Some("token".into()), - details: "create table".into(), - source: e, - })?; + .migration_err("token", "create table")?; self.drop_identity_token_balances_table(tx) - .map_err(|e| MigrationError { - table: Some("identity_token_balances".into()), - details: "drop table".into(), - source: e, - })?; + .migration_err("identity_token_balances", "drop table")?; self.initialize_identity_token_balances_table(tx) - .map_err(|e| MigrationError { - table: Some("identity_token_balances".into()), - details: "create table".into(), - source: e, - })?; + .migration_err("identity_token_balances", "create table")?; tx.execute("DROP TABLE IF EXISTS identity_order", []) - .map_err(|e| MigrationError { - table: Some("identity_order".into()), - details: "drop table".into(), - source: e, - })?; + .migration_err("identity_order", "drop table")?; self.initialize_identity_order_table(tx) - .map_err(|e| MigrationError { - table: Some("identity_order".into()), - details: "create table".into(), - source: e, - })?; + .migration_err("identity_order", "create table")?; tx.execute("DROP TABLE IF EXISTS token_order", []) - .map_err(|e| MigrationError { - table: Some("token_order".into()), - details: "drop table".into(), - source: e, - })?; + .migration_err("token_order", "drop table")?; self.initialize_token_order_table(tx) - .map_err(|e| MigrationError { - table: Some("token_order".into()), - details: "create table".into(), - source: e, - })?; + .migration_err("token_order", "create table")?; } 5 => { self.initialize_scheduled_votes_table(tx) - .map_err(|e| MigrationError { - table: Some("scheduled_votes".into()), - details: "create table".into(), - source: e, - })?; + .migration_err("scheduled_votes", "create table")?; } 4 => { self.initialize_top_up_table(tx) - .map_err(|e| MigrationError { - table: Some("top_up".into()), - details: "create table".into(), - source: e, - })?; + .migration_err("top_up", "create table")?; } 3 => { self.add_custom_dash_qt_columns(tx) - .map_err(|e| MigrationError { - table: Some("settings".into()), - details: "add custom dash_qt columns".into(), - source: e, - })?; + .migration_err("settings", "add custom dash_qt columns")?; } 2 => { self.initialize_proof_log_table(tx) - .map_err(|e| MigrationError { - table: Some("proof_log".into()), - details: "create table".into(), - source: e, - })?; + .migration_err("proof_log", "create table")?; } _ => { tracing::warn!("No database changes for version {}", version); @@ -462,12 +313,10 @@ impl Database { let result = self .apply_version_changes(version, &tx) .and_then(|()| { - self.update_database_version(version, &tx) - .map_err(|e| MigrationError { - table: Some("settings".into()), - details: format!("v{version}: update_database_version"), - source: e, - }) + self.update_database_version(version, &tx).migration_err( + "settings", + &format!("v{version}: update_database_version"), + ) }) .and_then(|()| { tx.commit().map_err(|e| MigrationError { @@ -1227,17 +1076,6 @@ impl Database { /// relationships in the schema: wallet→children, identity→children, /// token→children, contract→children, contested_name→children. fn clean_orphaned_fk_rows(&self, conn: &Connection) -> Result<(), MigrationError> { - // Helper to wrap rusqlite errors with table context for this function. - let wrap = |table: &str, details: &str| { - let table = table.to_string(); - let details = details.to_string(); - move |e: rusqlite::Error| MigrationError { - table: Some(table), - details, - source: e, - } - }; - // --- CASCADE children of wallet(seed_hash) --- let wallet_fk_delete: &[(&str, &str)] = &[ ("wallet_addresses", "seed_hash"), @@ -1250,7 +1088,7 @@ impl Database { for (table, fk_col) in wallet_fk_delete { if self .table_exists(conn, table) - .map_err(wrap(table, "check table existence"))? + .migration_err(table, "check table existence")? { let deleted = conn .execute( @@ -1259,7 +1097,7 @@ impl Database { ), [], ) - .map_err(wrap(table, "delete orphaned wallet FK rows"))?; + .migration_err(table, "delete orphaned wallet FK rows")?; if deleted > 0 { tracing::info!( "Cleaned {deleted} orphaned row(s) from {table} (missing wallet)" @@ -1272,7 +1110,7 @@ impl Database { // identities whose wallet no longer exists (but skip NULL wallet). if self .table_exists(conn, "identity") - .map_err(wrap("identity", "check table existence"))? + .migration_err("identity", "check table existence")? { let deleted = conn .execute( @@ -1280,7 +1118,7 @@ impl Database { AND wallet NOT IN (SELECT seed_hash FROM wallet)", [], ) - .map_err(wrap("identity", "delete orphaned identity rows"))?; + .migration_err("identity", "delete orphaned identity rows")?; if deleted > 0 { tracing::info!("Cleaned {deleted} orphaned identity row(s) (missing wallet)"); } @@ -1297,7 +1135,7 @@ impl Database { for (table, fk_col) in identity_fk_delete { if self .table_exists(conn, table) - .map_err(wrap(table, "check table existence"))? + .migration_err(table, "check table existence")? { let deleted = conn .execute( @@ -1306,7 +1144,7 @@ impl Database { ), [], ) - .map_err(wrap(table, "delete orphaned identity FK rows"))?; + .migration_err(table, "delete orphaned identity FK rows")?; if deleted > 0 { tracing::info!( "Cleaned {deleted} orphaned row(s) from {table} (missing identity)" @@ -1318,7 +1156,7 @@ impl Database { // --- SET NULL children of identity(id) --- if self .table_exists(conn, "asset_lock_transaction") - .map_err(wrap("asset_lock_transaction", "check table existence"))? + .migration_err("asset_lock_transaction", "check table existence")? { conn.execute( "UPDATE asset_lock_transaction SET identity_id = NULL @@ -1326,77 +1164,71 @@ impl Database { AND identity_id NOT IN (SELECT id FROM identity)", [], ) - .map_err(wrap( - "asset_lock_transaction", - "nullify orphaned identity_id", - ))?; + .migration_err("asset_lock_transaction", "nullify orphaned identity_id")?; conn.execute( "UPDATE asset_lock_transaction SET identity_id_potentially_in_creation = NULL WHERE identity_id_potentially_in_creation IS NOT NULL AND identity_id_potentially_in_creation NOT IN (SELECT id FROM identity)", [], ) - .map_err(wrap( + .migration_err( "asset_lock_transaction", "nullify orphaned identity_id_potentially_in_creation", - ))?; + )?; } // --- CASCADE children of token(id) --- if self .table_exists(conn, "identity_token_balances") - .map_err(wrap("identity_token_balances", "check table existence"))? + .migration_err("identity_token_balances", "check table existence")? && self .table_exists(conn, "token") - .map_err(wrap("token", "check table existence"))? + .migration_err("token", "check table existence")? { conn.execute( "DELETE FROM identity_token_balances WHERE token_id NOT IN (SELECT id FROM token)", [], ) - .map_err(wrap( - "identity_token_balances", - "delete orphaned token FK rows", - ))?; + .migration_err("identity_token_balances", "delete orphaned token FK rows")?; } if self .table_exists(conn, "token_order") - .map_err(wrap("token_order", "check table existence"))? + .migration_err("token_order", "check table existence")? && self .table_exists(conn, "token") - .map_err(wrap("token", "check table existence"))? + .migration_err("token", "check table existence")? { conn.execute( "DELETE FROM token_order WHERE token_id NOT IN (SELECT id FROM token)", [], ) - .map_err(wrap("token_order", "delete orphaned token FK rows"))?; + .migration_err("token_order", "delete orphaned token FK rows")?; } // --- CASCADE children of contract --- if self .table_exists(conn, "token") - .map_err(wrap("token", "check table existence"))? + .migration_err("token", "check table existence")? && self .table_exists(conn, "contract") - .map_err(wrap("contract", "check table existence"))? + .migration_err("contract", "check table existence")? { conn.execute( "DELETE FROM token WHERE (data_contract_id, network) NOT IN (SELECT contract_id, network FROM contract)", [], ) - .map_err(wrap("token", "delete orphaned contract FK rows"))?; + .migration_err("token", "delete orphaned contract FK rows")?; } // --- CASCADE children of contested_name --- if self .table_exists(conn, "contestant") - .map_err(wrap("contestant", "check table existence"))? + .migration_err("contestant", "check table existence")? && self .table_exists(conn, "contested_name") - .map_err(wrap("contested_name", "check table existence"))? + .migration_err("contested_name", "check table existence")? { conn.execute( "DELETE FROM contestant @@ -1404,7 +1236,7 @@ impl Database { NOT IN (SELECT normalized_contested_name, network FROM contested_name)", [], ) - .map_err(wrap("contestant", "delete orphaned contested_name FK rows"))?; + .migration_err("contestant", "delete orphaned contested_name FK rows")?; } Ok(()) @@ -1476,11 +1308,7 @@ impl Database { &format!("UPDATE {table} SET network = 'mainnet' WHERE network = 'dash'"), [], ) - .map_err(|e| MigrationError { - table: Some(table.to_string()), - details: "rename network dash -> mainnet".into(), - source: e, - })?; + .migration_err(table, "rename network dash -> mainnet")?; } Ok(()) } From 7a2ed21995d81f7dc493ed0552902e0fbcdf54ea Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:38:07 +0200 Subject: [PATCH 14/14] fix(db): fix schema-too-new error type and bound FK diagnostic logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CMT-2: Replace InvalidParameterName with InvalidQuery for the "schema version too new" error — semantically correct for a misuse condition rather than a parameter naming issue. CMT-3: Fix log_fk_violations to handle row decode errors (logged, capped at 3) and cap violation output at 50 entries. Early-return with explicit error messages on prepare/execute failures instead of silently dropping them. Co-Authored-By: Claude Opus 4.6 --- src/database/initialization.rs | 70 +++++++++++++++++++++++----------- 1 file changed, 48 insertions(+), 22 deletions(-) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 44f447aba..4f3e2d265 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -288,14 +288,10 @@ impl Database { std::cmp::Ordering::Greater => Err(MigrationError { table: None, details: format!( - "schema version {} is too new, max supported: {}", - original_version, to_version + "database is at version {original_version} but this build \ + only supports up to version {to_version} — please update dash-evo-tool" ), - source: rusqlite::Error::InvalidParameterName(format!( - "Database schema version {} is too new, max supported version: {}. \ - Please update dash-evo-tool.", - original_version, to_version - )), + source: rusqlite::Error::InvalidQuery, }), std::cmp::Ordering::Less => { let mut conn = self @@ -1244,26 +1240,56 @@ impl Database { /// Log all FK violations to help diagnose SQLITE_CONSTRAINT_FOREIGNKEY errors. fn log_fk_violations(conn: &Connection) { + const MAX_VIOLATIONS_TO_LOG: usize = 50; + tracing::error!( "FK constraint failure detected — running PRAGMA foreign_key_check for diagnostics:" ); - if let Ok(mut stmt) = conn.prepare("PRAGMA foreign_key_check") - && let Ok(rows) = stmt.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, i64>(1)?, - row.get::<_, String>(2)?, - row.get::<_, i64>(3)?, - )) - }) - { - for row in rows.flatten() { - let (table, rowid, parent, fk_idx) = row; - tracing::error!( - " FK violation: {table} rowid={rowid} -> {parent} (fk_index={fk_idx})" - ); + let Ok(mut stmt) = conn.prepare("PRAGMA foreign_key_check") else { + tracing::error!(" failed to prepare PRAGMA foreign_key_check"); + return; + }; + let Ok(rows) = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + )) + }) else { + tracing::error!(" failed to execute PRAGMA foreign_key_check"); + return; + }; + + let mut count = 0usize; + let mut errors = 0usize; + for row in rows { + match row { + Ok((table, rowid, parent, fk_idx)) => { + count += 1; + if count <= MAX_VIOLATIONS_TO_LOG { + tracing::error!( + " FK violation: {table} rowid={rowid} -> {parent} (fk_index={fk_idx})" + ); + } + } + Err(e) => { + errors += 1; + if errors <= 3 { + tracing::error!(" FK check row decode error: {e}"); + } + } } } + if count > MAX_VIOLATIONS_TO_LOG { + tracing::error!( + " ... and {} more violation(s) not shown", + count - MAX_VIOLATIONS_TO_LOG + ); + } + if count == 0 && errors == 0 { + tracing::error!(" no violations found (failure may be from deferred FK check)"); + } } /// Check if a table exists in the database.