diff --git a/.changepacks/changepack_log_kx6-sb-ClLUo-70CrBAMA.json b/.changepacks/changepack_log_kx6-sb-ClLUo-70CrBAMA.json new file mode 100644 index 0000000..a3d07c0 --- /dev/null +++ b/.changepacks/changepack_log_kx6-sb-ClLUo-70CrBAMA.json @@ -0,0 +1 @@ +{"changes":{"crates/vespertide-naming/Cargo.toml":"Patch","crates/vespertide-planner/Cargo.toml":"Patch","crates/vespertide-query/Cargo.toml":"Patch","crates/vespertide-config/Cargo.toml":"Patch","crates/vespertide-core/Cargo.toml":"Patch","crates/vespertide-exporter/Cargo.toml":"Patch","crates/vespertide-cli/Cargo.toml":"Patch","crates/vespertide/Cargo.toml":"Patch","crates/vespertide-loader/Cargo.toml":"Patch","crates/vespertide-macro/Cargo.toml":"Patch"},"note":"Add migration id","date":"2026-02-10T15:23:54.389369700Z"} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index a17687e..9b41aa8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3283,6 +3283,7 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" dependencies = [ + "getrandom 0.3.4", "js-sys", "serde_core", "wasm-bindgen", @@ -3342,7 +3343,7 @@ dependencies = [ [[package]] name = "vespertide" -version = "0.1.43" +version = "0.1.44" dependencies = [ "vespertide-core", "vespertide-macro", @@ -3350,7 +3351,7 @@ dependencies = [ [[package]] name = "vespertide-cli" -version = "0.1.43" +version = "0.1.44" dependencies = [ "anyhow", "assert_cmd", @@ -3368,6 +3369,7 @@ dependencies = [ "serial_test", "tempfile", "tokio", + "uuid", "vespertide-config", "vespertide-core", "vespertide-exporter", @@ -3378,7 +3380,7 @@ dependencies = [ [[package]] name = "vespertide-config" -version = "0.1.43" +version = "0.1.44" dependencies = [ "clap", "schemars", @@ -3388,7 +3390,7 @@ dependencies = [ [[package]] name = "vespertide-core" -version = "0.1.43" +version = "0.1.44" dependencies = [ "rstest", "schemars", @@ -3400,7 +3402,7 @@ dependencies = [ [[package]] name = "vespertide-exporter" -version = "0.1.43" +version = "0.1.44" dependencies = [ "insta", "rstest", @@ -3412,7 +3414,7 @@ dependencies = [ [[package]] name = "vespertide-loader" -version = "0.1.43" +version = "0.1.44" dependencies = [ "anyhow", "rstest", @@ -3427,7 +3429,7 @@ dependencies = [ [[package]] name = "vespertide-macro" -version = "0.1.43" +version = "0.1.44" dependencies = [ "proc-macro2", "quote", @@ -3444,11 +3446,11 @@ dependencies = [ [[package]] name = "vespertide-naming" -version = "0.1.43" +version = "0.1.44" [[package]] name = "vespertide-planner" -version = "0.1.43" +version = "0.1.44" dependencies = [ "insta", "rstest", @@ -3459,7 +3461,7 @@ dependencies = [ [[package]] name = "vespertide-query" -version = "0.1.43" +version = "0.1.44" dependencies = [ "insta", "rstest", diff --git a/crates/vespertide-cli/Cargo.toml b/crates/vespertide-cli/Cargo.toml index bb91d9b..b367d21 100644 --- a/crates/vespertide-cli/Cargo.toml +++ b/crates/vespertide-cli/Cargo.toml @@ -15,6 +15,7 @@ clap = { version = "4", features = ["derive"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } colored = "3" dialoguer = "0.12" +uuid = { version = "1", features = ["v4"] } serde_json = "1" serde_yaml = "0.9" schemars = "1.2" diff --git a/crates/vespertide-cli/src/commands/log.rs b/crates/vespertide-cli/src/commands/log.rs index f935940..35f1d8e 100644 --- a/crates/vespertide-cli/src/commands/log.rs +++ b/crates/vespertide-cli/src/commands/log.rs @@ -136,6 +136,7 @@ mod tests { fn write_migration(cfg: &VespertideConfig) { fs::create_dir_all(cfg.migrations_dir()).unwrap(); let plan = MigrationPlan { + id: String::new(), comment: Some("init".into()), created_at: Some("2024-01-01T00:00:00Z".into()), version: 1, @@ -248,6 +249,7 @@ mod tests { // Create a migration with ModifyColumnType for SQLite, which generates multiple SQL statements let plan = MigrationPlan { + id: String::new(), comment: Some("modify column type".into()), created_at: Some("2024-01-01T00:00:00Z".into()), version: 1, diff --git a/crates/vespertide-cli/src/commands/revision.rs b/crates/vespertide-cli/src/commands/revision.rs index 6804e68..954d41d 100644 --- a/crates/vespertide-cli/src/commands/revision.rs +++ b/crates/vespertide-cli/src/commands/revision.rs @@ -303,6 +303,7 @@ pub async fn cmd_revision(message: String, fill_with_args: Vec) -> Resul prompt_enum_value, )?; + plan.id = uuid::Uuid::new_v4().to_string(); plan.comment = Some(message); if plan.created_at.is_none() { // Record creation time in RFC3339 (UTC). @@ -521,6 +522,7 @@ mod tests { fn check_non_nullable_fk_add_column_fails() { use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; let plan = MigrationPlan { + id: String::new(), comment: None, created_at: None, version: 2, @@ -567,6 +569,7 @@ mod tests { fn check_nullable_fk_add_column_ok() { use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; let plan = MigrationPlan { + id: String::new(), comment: None, created_at: None, version: 2, @@ -607,6 +610,7 @@ mod tests { // Regular non-nullable column without FK should NOT be blocked use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; let plan = MigrationPlan { + id: String::new(), comment: None, created_at: None, version: 2, @@ -671,6 +675,7 @@ mod tests { use vespertide_core::MigrationPlan; let mut plan = MigrationPlan { + id: String::new(), comment: None, created_at: None, version: 1, @@ -712,6 +717,7 @@ mod tests { use vespertide_core::MigrationPlan; let mut plan = MigrationPlan { + id: String::new(), comment: None, created_at: None, version: 1, @@ -744,6 +750,7 @@ mod tests { use vespertide_core::MigrationPlan; let mut plan = MigrationPlan { + id: String::new(), comment: None, created_at: None, version: 1, @@ -786,6 +793,7 @@ mod tests { use vespertide_core::MigrationPlan; let mut plan = MigrationPlan { + id: String::new(), comment: None, created_at: None, version: 1, @@ -828,6 +836,7 @@ mod tests { use vespertide_core::MigrationPlan; let mut plan = MigrationPlan { + id: String::new(), comment: None, created_at: None, version: 1, @@ -888,6 +897,7 @@ mod tests { use vespertide_core::MigrationPlan; let mut plan = MigrationPlan { + id: String::new(), comment: None, created_at: None, version: 1, @@ -1149,6 +1159,7 @@ mod tests { use vespertide_core::MigrationPlan; let mut plan = MigrationPlan { + id: String::new(), comment: None, created_at: None, version: 1, @@ -1206,6 +1217,7 @@ mod tests { // Plan with no missing fill_with values (nullable column) let mut plan = MigrationPlan { + id: String::new(), comment: None, created_at: None, version: 1, @@ -1249,6 +1261,7 @@ mod tests { use vespertide_core::MigrationPlan; let mut plan = MigrationPlan { + id: String::new(), comment: None, created_at: None, version: 1, @@ -1299,6 +1312,7 @@ mod tests { use vespertide_core::MigrationPlan; let mut plan = MigrationPlan { + id: String::new(), comment: None, created_at: None, version: 1, diff --git a/crates/vespertide-cli/src/commands/sql.rs b/crates/vespertide-cli/src/commands/sql.rs index 1889ec6..b40538a 100644 --- a/crates/vespertide-cli/src/commands/sql.rs +++ b/crates/vespertide-cli/src/commands/sql.rs @@ -227,6 +227,7 @@ mod tests { write_model("users"); let plan = MigrationPlan { + id: String::new(), comment: None, created_at: None, version: 1, @@ -267,6 +268,7 @@ mod tests { write_model("users"); let plan = MigrationPlan { + id: String::new(), comment: None, created_at: None, version: 1, @@ -307,6 +309,7 @@ mod tests { write_model("users"); let plan = MigrationPlan { + id: String::new(), comment: None, created_at: None, version: 1, @@ -341,6 +344,7 @@ mod tests { #[serial] async fn emit_sql_prints_created_at_and_comment_postgres() { let plan = MigrationPlan { + id: String::new(), comment: Some("with comment".into()), created_at: Some("2024-01-02T00:00:00Z".into()), version: 1, @@ -357,6 +361,7 @@ mod tests { #[serial] async fn emit_sql_prints_created_at_and_comment_mysql() { let plan = MigrationPlan { + id: String::new(), comment: Some("with comment".into()), created_at: Some("2024-01-02T00:00:00Z".into()), version: 1, @@ -373,6 +378,7 @@ mod tests { #[serial] async fn emit_sql_prints_created_at_and_comment_sqlite() { let plan = MigrationPlan { + id: String::new(), comment: Some("with comment".into()), created_at: Some("2024-01-02T00:00:00Z".into()), version: 1, @@ -389,6 +395,7 @@ mod tests { #[serial] async fn emit_sql_multiple_queries() { let plan = MigrationPlan { + id: String::new(), comment: None, created_at: None, version: 1, @@ -434,6 +441,7 @@ mod tests { // Create a migration that adds a NOT NULL column in SQLite, which generates multiple queries let plan = MigrationPlan { + id: String::new(), comment: None, created_at: None, version: 1, diff --git a/crates/vespertide-cli/src/commands/status.rs b/crates/vespertide-cli/src/commands/status.rs index 627771b..6fc69b6 100644 --- a/crates/vespertide-cli/src/commands/status.rs +++ b/crates/vespertide-cli/src/commands/status.rs @@ -225,6 +225,7 @@ mod tests { fn write_migration(cfg: &VespertideConfig) { fs::create_dir_all(cfg.migrations_dir()).unwrap(); let plan = MigrationPlan { + id: String::new(), comment: Some("init".into()), created_at: Some("2024-01-01T00:00:00Z".into()), version: 1, diff --git a/crates/vespertide-cli/src/utils.rs b/crates/vespertide-cli/src/utils.rs index e78d361..fbc4a48 100644 --- a/crates/vespertide-cli/src/utils.rs +++ b/crates/vespertide-cli/src/utils.rs @@ -226,12 +226,14 @@ mod tests { fs::create_dir_all("migrations").unwrap(); let plan1 = MigrationPlan { + id: String::new(), comment: Some("first".into()), created_at: None, version: 2, actions: vec![], }; let plan0 = MigrationPlan { + id: String::new(), comment: Some("zero".into()), created_at: None, version: 1, diff --git a/crates/vespertide-core/src/action.rs b/crates/vespertide-core/src/action.rs index f8c9e97..0afdebd 100644 --- a/crates/vespertide-core/src/action.rs +++ b/crates/vespertide-core/src/action.rs @@ -6,6 +6,10 @@ use std::fmt; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub struct MigrationPlan { + /// Unique identifier for this migration (UUID format). + /// Defaults to empty string for backward compatibility with old migration files. + #[serde(default)] + pub id: String, pub comment: Option, #[serde(default)] pub created_at: Option, @@ -810,6 +814,7 @@ mod tests { #[test] fn test_migration_plan_with_prefix() { let plan = MigrationPlan { + id: String::new(), comment: Some("test".into()), created_at: None, version: 1, diff --git a/crates/vespertide-core/src/migration.rs b/crates/vespertide-core/src/migration.rs index f91c111..ad551f2 100644 --- a/crates/vespertide-core/src/migration.rs +++ b/crates/vespertide-core/src/migration.rs @@ -9,4 +9,12 @@ pub enum MigrationError { NotImplemented, #[error("database error: {0}")] DatabaseError(String), + #[error( + "migration id mismatch for version {version}: expected '{expected}', found '{found}' in database" + )] + IdMismatch { + version: u32, + expected: String, + found: String, + }, } diff --git a/crates/vespertide-macro/src/lib.rs b/crates/vespertide-macro/src/lib.rs index e85b5b7..8c596ad 100644 --- a/crates/vespertide-macro/src/lib.rs +++ b/crates/vespertide-macro/src/lib.rs @@ -60,6 +60,7 @@ pub(crate) fn build_migration_block( verbose: bool, ) -> Result { let version = migration.version; + let migration_id = &migration.id; // Use the current baseline schema (from all previous migrations) let queries = build_plan_queries(migration, baseline_schema).map_err(|e| { @@ -148,10 +149,22 @@ pub(crate) fn build_migration_block( quote! { if __version < #version { + // Validate migration id against database if version already tracked + if let Some(db_id) = __version_ids.get(&#version) { + let expected_id: &str = #migration_id; + if !expected_id.is_empty() && !db_id.is_empty() && db_id != expected_id { + return Err(::vespertide::MigrationError::IdMismatch { + version: #version, + expected: expected_id.to_string(), + found: db_id.clone(), + }); + } + } + eprintln!("[vespertide] Applying migration {} ({})", #version_str, #comment_str); #(#action_blocks)* - let insert_sql = format!("INSERT INTO {q}{}{q} (version) VALUES ({})", __version_table, #version); + let insert_sql = format!("INSERT INTO {q}{}{q} (version, id) VALUES ({}, '{}')", __version_table, #version, #migration_id); let stmt = sea_orm::Statement::from_string(backend, insert_sql); __txn.execute_raw(stmt).await.map_err(|e| { ::vespertide::MigrationError::DatabaseError(format!("Failed to insert version: {}", e)) @@ -180,6 +193,18 @@ pub(crate) fn build_migration_block( quote! { if __version < #version { + // Validate migration id against database if version already tracked + if let Some(db_id) = __version_ids.get(&#version) { + let expected_id: &str = #migration_id; + if !expected_id.is_empty() && !db_id.is_empty() && db_id != expected_id { + return Err(::vespertide::MigrationError::IdMismatch { + version: #version, + expected: expected_id.to_string(), + found: db_id.clone(), + }); + } + } + let sqls: &[&str] = match backend { sea_orm::DatabaseBackend::Postgres => &[#(#pg_sqls),*], sea_orm::DatabaseBackend::MySql => &[#(#mysql_sqls),*], @@ -196,7 +221,7 @@ pub(crate) fn build_migration_block( } } - let insert_sql = format!("INSERT INTO {q}{}{q} (version) VALUES ({})", __version_table, #version); + let insert_sql = format!("INSERT INTO {q}{}{q} (version, id) VALUES ({}, '{}')", __version_table, #version, #migration_id); let stmt = sea_orm::Statement::from_string(backend, insert_sql); __txn.execute_raw(stmt).await.map_err(|e| { ::vespertide::MigrationError::DatabaseError(format!("Failed to insert version: {}", e)) @@ -232,7 +257,7 @@ fn generate_migration_code( // Create version table if it does not exist (outside transaction) let create_table_sql = format!( - "CREATE TABLE IF NOT EXISTS {q}{}{q} (version INTEGER PRIMARY KEY, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)", + "CREATE TABLE IF NOT EXISTS {q}{}{q} (version INTEGER PRIMARY KEY, id TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)", __version_table ); let stmt = sea_orm::Statement::from_string(backend, create_table_sql); @@ -240,6 +265,16 @@ fn generate_migration_code( ::vespertide::MigrationError::DatabaseError(format!("Failed to create version table: {}", e)) })?; + // Add id column for existing tables that don't have it yet (backward compatibility). + // We use a try-and-ignore approach: if the column already exists, the ALTER will fail + // and we simply ignore the error. + let alter_sql = format!( + "ALTER TABLE {q}{}{q} ADD COLUMN id TEXT DEFAULT ''", + __version_table + ); + let stmt = sea_orm::Statement::from_string(backend, alter_sql); + let _ = __pool.execute_raw(stmt).await; + // Single transaction for the entire migration process. // This prevents race conditions when multiple connections exist // (e.g. SQLite with max_connections > 1). @@ -258,6 +293,21 @@ fn generate_migration_code( .and_then(|row| row.try_get::("", "version").ok()) .unwrap_or(0) as u32; + // Load all existing (version, id) pairs for id mismatch validation + let select_ids_sql = format!("SELECT version, id FROM {q}{}{q}", __version_table); + let stmt = sea_orm::Statement::from_string(backend, select_ids_sql); + let id_rows = __txn.query_all_raw(stmt).await.map_err(|e| { + ::vespertide::MigrationError::DatabaseError(format!("Failed to read version ids: {}", e)) + })?; + + let mut __version_ids = std::collections::HashMap::::new(); + for row in &id_rows { + if let Ok(v) = row.try_get::("", "version") { + let id = row.try_get::("", "id").unwrap_or_default(); + __version_ids.insert(v as u32, id); + } + } + #verbose_current_version // Execute each migration block within the same transaction @@ -475,6 +525,7 @@ mod tests { #[test] fn test_build_migration_block_create_table() { let migration = MigrationPlan { + id: String::new(), version: 1, comment: None, created_at: None, @@ -505,6 +556,7 @@ mod tests { fn test_build_migration_block_add_column() { // First create the table let create_migration = MigrationPlan { + id: String::new(), version: 1, comment: None, created_at: None, @@ -520,6 +572,7 @@ mod tests { // Now add a column let add_column_migration = MigrationPlan { + id: String::new(), version: 2, comment: None, created_at: None, @@ -553,6 +606,7 @@ mod tests { #[test] fn test_build_migration_block_multiple_actions() { let migration = MigrationPlan { + id: String::new(), version: 1, comment: None, created_at: None, @@ -584,6 +638,7 @@ mod tests { // Create a simple migration block let migration = MigrationPlan { + id: String::new(), version: 1, comment: None, created_at: None, @@ -628,6 +683,7 @@ mod tests { let mut baseline = Vec::new(); let migration1 = MigrationPlan { + id: String::new(), version: 1, comment: None, created_at: None, @@ -640,6 +696,7 @@ mod tests { let block1 = build_migration_block(&migration1, &mut baseline, false).unwrap(); let migration2 = MigrationPlan { + id: String::new(), version: 2, comment: None, created_at: None, @@ -662,6 +719,7 @@ mod tests { #[test] fn test_build_migration_block_generates_all_backends() { let migration = MigrationPlan { + id: String::new(), version: 1, comment: None, created_at: None, @@ -688,6 +746,7 @@ mod tests { fn test_build_migration_block_with_delete_table() { // First create the table let create_migration = MigrationPlan { + id: String::new(), version: 1, comment: None, created_at: None, @@ -704,6 +763,7 @@ mod tests { // Now delete it let delete_migration = MigrationPlan { + id: String::new(), version: 2, comment: None, created_at: None, @@ -724,6 +784,7 @@ mod tests { #[test] fn test_build_migration_block_with_index() { let migration = MigrationPlan { + id: String::new(), version: 1, comment: None, created_at: None, @@ -761,6 +822,7 @@ mod tests { fn test_build_migration_block_error_nonexistent_table() { // Try to add column to a table that doesn't exist - should fail let migration = MigrationPlan { + id: String::new(), version: 1, comment: None, created_at: None, @@ -867,6 +929,7 @@ mod tests { #[test] fn test_build_migration_block_verbose_create_table() { let migration = MigrationPlan { + id: String::new(), version: 1, comment: Some("initial setup".into()), created_at: None, @@ -892,6 +955,7 @@ mod tests { #[test] fn test_build_migration_block_verbose_multiple_actions() { let migration = MigrationPlan { + id: String::new(), version: 1, comment: None, created_at: None, @@ -924,6 +988,7 @@ mod tests { fn test_build_migration_block_verbose_add_column() { // Create table first let create = MigrationPlan { + id: String::new(), version: 1, comment: None, created_at: None, @@ -938,6 +1003,7 @@ mod tests { // Add column in verbose mode let add_col = MigrationPlan { + id: String::new(), version: 2, comment: Some("add email".into()), created_at: None, @@ -971,6 +1037,7 @@ mod tests { let version_table = "test_versions"; let migration = MigrationPlan { + id: String::new(), version: 1, comment: None, created_at: None, diff --git a/crates/vespertide-planner/src/diff.rs b/crates/vespertide-planner/src/diff.rs index d5bfc70..d5d578a 100644 --- a/crates/vespertide-planner/src/diff.rs +++ b/crates/vespertide-planner/src/diff.rs @@ -612,6 +612,7 @@ pub fn diff_schemas(from: &[TableDef], to: &[TableDef]) -> Result