diff --git a/crates/aionui-app/src/bootstrap/environment.rs b/crates/aionui-app/src/bootstrap/environment.rs index 43d6b5c9b..c9817e3e7 100644 --- a/crates/aionui-app/src/bootstrap/environment.rs +++ b/crates/aionui-app/src/bootstrap/environment.rs @@ -50,6 +50,7 @@ pub fn init_environment(cli: &Cli, merged_path: &str) -> Result Result Result { - init_database_staged(path).await.map_err(DatabaseInitError::into_source) + init_database_with_options(path, DatabaseInitOptions::default()) + .await + .map_err(DatabaseInitError::into_source) +} + +pub async fn init_database_with_options( + path: &Path, + options: DatabaseInitOptions, +) -> Result { + init_database_staged_with_options(path, options).await } pub async fn init_database_staged(path: &Path) -> Result { + init_database_staged_with_options(path, DatabaseInitOptions::default()).await +} + +pub async fn init_database_staged_with_options( + path: &Path, + options: DatabaseInitOptions, +) -> Result { if let Some(parent) = path.parent() && !parent.as_os_str().is_empty() { @@ -111,10 +133,25 @@ pub async fn init_database_staged(path: &Path) -> Result Ok(db), - Err(e) if path.exists() && should_attempt_recovery(e.source()) => { - warn!("Database initialization failed, attempting recovery: {e}"); + Err(e) if path.exists() && options.recover_corrupted_database && should_attempt_recovery(e.source()) => { + warn!( + code = "BOOTSTRAP_DATABASE_CORRUPTION_REBUILD_AUTHORIZED", + stage = e.stage(), + "Authorized corrupted database backup and rebuild" + ); recover_and_retry(path, e.into_source()).await } + Err(e) if path.exists() && is_recoverable_migration_corruption(e.source()) => { + warn!( + code = "BOOTSTRAP_DATABASE_CORRUPTION_REQUIRES_USER_CONFIRMATION", + stage = e.stage(), + "Database corruption-like migration failure requires user confirmation before rebuild" + ); + Err(DatabaseInitError::new( + RECOVERABLE_DATABASE_CORRUPTION_STAGE, + e.into_source(), + )) + } Err(e) => Err(e), } } @@ -638,12 +675,21 @@ async fn recover_and_retry(path: &Path, original_error: DbError) -> Result bool { match err { - DbError::Migration(_) => false, + DbError::Migration(sqlx::migrate::MigrateError::VersionMismatch(_)) => false, + DbError::Migration(_) => is_corruption_like_error(err), DbError::NotFound(_) | DbError::Conflict(_) => false, DbError::Query(_) | DbError::Init(_) => is_corruption_like_error(err), } } +fn is_recoverable_migration_corruption(err: &DbError) -> bool { + match err { + DbError::Migration(sqlx::migrate::MigrateError::VersionMismatch(_)) => false, + DbError::Migration(_) => is_corruption_like_error(err), + _ => false, + } +} + fn is_corruption_like_error(err: &DbError) -> bool { let message = err.to_string().to_ascii_lowercase(); @@ -692,6 +738,28 @@ mod tests { ); } + #[test] + fn recovery_allows_corruption_like_migration_errors_when_authorized() { + let err = DbError::Migration(sqlx::migrate::MigrateError::ExecuteMigration( + sqlx::Error::Protocol("database disk image is malformed".into()), + 13, + )); + + assert!(should_attempt_recovery(&err)); + assert!(is_recoverable_migration_corruption(&err)); + } + + #[test] + fn recovery_skips_non_corruption_migration_errors() { + let err = DbError::Migration(sqlx::migrate::MigrateError::ExecuteMigration( + sqlx::Error::Protocol("UNIQUE constraint failed: tasks.id".into()), + 13, + )); + + assert!(!should_attempt_recovery(&err)); + assert!(!is_recoverable_migration_corruption(&err)); + } + #[tokio::test] async fn migration_preserves_fk_references() { let db = init_database_memory().await.unwrap(); diff --git a/crates/aionui-db/src/lib.rs b/crates/aionui-db/src/lib.rs index ade58ad9e..ec9dfa740 100644 --- a/crates/aionui-db/src/lib.rs +++ b/crates/aionui-db/src/lib.rs @@ -13,7 +13,8 @@ pub use agent_binding::{ runtime_backend_for_agent, }; pub use database::{ - Database, DatabaseInitError, init_database, init_database_memory, init_database_staged, maybe_copy_legacy_database, + Database, DatabaseInitError, DatabaseInitOptions, init_database, init_database_memory, init_database_staged, + init_database_staged_with_options, init_database_with_options, maybe_copy_legacy_database, }; pub use error::DbError; pub use models::{ diff --git a/crates/aionui-db/tests/db_lifecycle.rs b/crates/aionui-db/tests/db_lifecycle.rs index a2198327d..9decce6dd 100644 --- a/crates/aionui-db/tests/db_lifecycle.rs +++ b/crates/aionui-db/tests/db_lifecycle.rs @@ -1,4 +1,6 @@ -use aionui_db::{init_database, init_database_memory, maybe_copy_legacy_database}; +use aionui_db::{ + DatabaseInitOptions, init_database, init_database_memory, init_database_with_options, maybe_copy_legacy_database, +}; use sqlx::Row; // -- T1.1 Initialization -- @@ -231,7 +233,14 @@ async fn corruption_recovery_creates_backup() { // Write invalid content to simulate corruption std::fs::write(&path, b"not a valid sqlite database").unwrap(); - let db = init_database(&path).await.unwrap(); + let db = init_database_with_options( + &path, + DatabaseInitOptions { + recover_corrupted_database: true, + }, + ) + .await + .unwrap(); // Recovered database should work let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") @@ -250,6 +259,24 @@ async fn corruption_recovery_creates_backup() { db.close().await; } +#[tokio::test] +async fn corruption_without_recovery_authorization_preserves_original_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.db"); + let original = b"not a valid sqlite database"; + std::fs::write(&path, original).unwrap(); + + let result = init_database(&path).await; + + assert!(result.is_err(), "unconfirmed startup must not rebuild corrupted DB"); + assert_eq!(std::fs::read(&path).unwrap(), original); + let has_backup = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .any(|e| e.file_name().to_string_lossy().contains("backup")); + assert!(!has_backup, "unconfirmed startup must not create a backup file"); +} + // -- Directory creation -- #[tokio::test]