Skip to main content

waypoint_core/commands/
undo.rs

1//! Undo applied migrations by executing U{version}__*.sql files,
2//! or auto-generated reversal SQL stored in the history table.
3
4use std::collections::HashMap;
5
6use serde::Serialize;
7
8#[cfg(feature = "postgres")]
9use tokio_postgres::Client;
10
11use crate::config::WaypointConfig;
12#[cfg(feature = "postgres")]
13use crate::db;
14use crate::db::DbClient;
15use crate::dialect::DialectKind;
16use crate::error::{Result, WaypointError};
17use crate::history;
18use crate::migration::{MigrationVersion, ResolvedMigration, scan_migrations};
19use crate::placeholder::{build_placeholders, replace_placeholders};
20
21/// How many / which versions to undo.
22///
23/// `Last` and `Count` walk the currently-applied versions in descending
24/// *version* order, which is not necessarily installation order — with
25/// `out_of_order` enabled a lower version can be installed later. Undo is
26/// defined on version order so that it is the inverse of migrate.
27#[derive(Debug, Clone)]
28pub enum UndoTarget {
29    /// Undo the highest currently-applied version.
30    Last,
31    /// Undo all migrations above this version (the target version itself stays applied).
32    Version(MigrationVersion),
33    /// Undo the N highest currently-applied versions, highest first.
34    Count(usize),
35}
36
37/// Report returned after an undo operation.
38#[derive(Debug, Serialize)]
39pub struct UndoReport {
40    /// Number of migrations that were undone.
41    pub migrations_undone: usize,
42    /// Total execution time of all undo operations in milliseconds.
43    pub total_time_ms: i32,
44    /// Per-migration details for each undone migration.
45    pub details: Vec<UndoDetail>,
46}
47
48/// Details of a single undone migration.
49#[derive(Debug, Serialize)]
50pub struct UndoDetail {
51    /// Version string of the migration that was undone.
52    pub version: String,
53    /// Human-readable description from the undo migration filename.
54    pub description: String,
55    /// Filename of the undo migration script that was executed.
56    pub script: String,
57    /// Execution time of the undo operation in milliseconds.
58    pub execution_time_ms: i32,
59    /// Whether the undo used auto-generated reversal SQL.
60    pub auto_reversal: bool,
61}
62
63/// Execute undo SQL within an atomic transaction (BEGIN/execute/history-insert/COMMIT).
64///
65/// On SQL execution failure, the transaction is rolled back and a best-effort
66/// failure record is inserted into the history table. Returns the execution
67/// time in milliseconds on success.
68#[cfg(feature = "postgres")]
69#[allow(clippy::too_many_arguments)]
70async fn execute_undo_sql(
71    client: &Client,
72    schema: &str,
73    table: &str,
74    version: &str,
75    description: &str,
76    script: &str,
77    checksum: Option<i32>,
78    installed_by: &str,
79    sql: &str,
80) -> Result<i32> {
81    let start = std::time::Instant::now();
82    client.batch_execute("BEGIN").await?;
83
84    match client.batch_execute(sql).await {
85        Ok(()) => {
86            let exec_time = start.elapsed().as_millis() as i32;
87            match history::insert_applied_migration(
88                client,
89                schema,
90                table,
91                Some(version),
92                description,
93                "UNDO_SQL",
94                script,
95                checksum,
96                installed_by,
97                exec_time,
98                true,
99            )
100            .await
101            {
102                Ok(()) => {
103                    client.batch_execute("COMMIT").await?;
104                    Ok(exec_time)
105                }
106                Err(e) => {
107                    if let Err(rb) = client.batch_execute("ROLLBACK").await {
108                        log::error!("Failed to rollback undo transaction: {}", rb);
109                    }
110                    Err(e)
111                }
112            }
113        }
114        Err(e) => {
115            if let Err(rollback_err) = client.batch_execute("ROLLBACK").await {
116                log::error!("Failed to rollback undo transaction: {}", rollback_err);
117            }
118
119            // Record failure — best-effort outside the rolled-back transaction
120            if let Err(record_err) = history::insert_applied_migration(
121                client,
122                schema,
123                table,
124                Some(version),
125                description,
126                "UNDO_SQL",
127                script,
128                checksum,
129                installed_by,
130                0,
131                false,
132            )
133            .await
134            {
135                log::warn!(
136                    "Failed to record undo failure; script={}, error={}",
137                    script,
138                    record_err
139                );
140            }
141
142            let reason = crate::error::format_db_error(&e);
143            Err(WaypointError::UndoFailed {
144                script: script.to_string(),
145                reason,
146            })
147        }
148    }
149}
150
151/// Execute the undo command (PostgreSQL legacy entry).
152#[cfg(feature = "postgres")]
153pub async fn execute(
154    client: &Client,
155    config: &WaypointConfig,
156    target: UndoTarget,
157) -> Result<UndoReport> {
158    let schema = &config.migrations.schema;
159    let table = &config.migrations.table;
160
161    // Acquire advisory lock
162    db::acquire_advisory_lock(client, schema, table).await?;
163
164    let result = run_undo(client, config, target).await;
165
166    // Always release the advisory lock
167    if let Err(e) = db::release_advisory_lock(client, schema, table).await {
168        log::error!("Failed to release advisory lock: {}", e);
169    }
170
171    match &result {
172        Ok(report) => {
173            log::info!(
174                "Undo completed; migrations_undone={}, total_time_ms={}",
175                report.migrations_undone,
176                report.total_time_ms
177            );
178        }
179        Err(e) => {
180            log::error!("Undo failed: {}", e);
181        }
182    }
183
184    result
185}
186
187#[cfg(feature = "postgres")]
188async fn run_undo(
189    client: &Client,
190    config: &WaypointConfig,
191    target: UndoTarget,
192) -> Result<UndoReport> {
193    let schema = &config.migrations.schema;
194    let table = &config.migrations.table;
195
196    // Create history table if not exists
197    history::create_history_table(client, schema, table).await?;
198
199    // Scan migration files — build map of undo files by version
200    let resolved = scan_migrations(&config.migrations.locations)?;
201    let undo_by_version: HashMap<String, &ResolvedMigration> = resolved
202        .iter()
203        .filter(|m| m.is_undo())
204        .filter_map(|m| m.version().map(|v| (v.raw.clone(), m)))
205        .collect();
206
207    // Get applied history and compute effective set
208    let applied = history::get_applied_migrations(client, schema, table).await?;
209    let effective = history::effective_applied_versions(&applied);
210
211    // Build list of currently-applied versioned migrations, sorted descending by version
212    let mut applied_versions: Vec<MigrationVersion> = effective
213        .iter()
214        .filter_map(|v| MigrationVersion::parse(v).ok())
215        .collect();
216    applied_versions.sort();
217    applied_versions.reverse(); // newest first
218
219    // Determine which versions to undo
220    let versions_to_undo: Vec<MigrationVersion> = match target {
221        UndoTarget::Last => applied_versions.into_iter().take(1).collect(),
222        UndoTarget::Count(n) => applied_versions.into_iter().take(n).collect(),
223        UndoTarget::Version(ref target_ver) => applied_versions
224            .into_iter()
225            .filter(|v| v > target_ver)
226            .collect(),
227    };
228
229    // Get database user info for placeholders
230    let db_user = db::get_current_user(client)
231        .await
232        .unwrap_or_else(|_| "unknown".to_string());
233    let db_name = db::get_current_database(client)
234        .await
235        .unwrap_or_else(|_| "unknown".to_string());
236    let installed_by = config
237        .migrations
238        .installed_by
239        .as_deref()
240        .unwrap_or(&db_user);
241
242    let mut report = UndoReport {
243        migrations_undone: 0,
244        total_time_ms: 0,
245        details: Vec::new(),
246    };
247
248    // Execute undo for each version (newest first)
249    for version in &versions_to_undo {
250        // Try manual U file first, then fall back to auto-generated reversal
251        if let Some(undo_migration) = undo_by_version.get(&version.raw) {
252            // Manual undo file takes precedence
253            log::info!(
254                "Undoing migration (manual); migration={}, schema={}",
255                undo_migration.script,
256                schema
257            );
258
259            let placeholders = build_placeholders(
260                &config.placeholders,
261                schema,
262                &db_user,
263                &db_name,
264                &undo_migration.script,
265            );
266            let sql = replace_placeholders(&undo_migration.sql, &placeholders)?;
267
268            let exec_time = execute_undo_sql(
269                client,
270                schema,
271                table,
272                &version.raw,
273                &undo_migration.description,
274                &undo_migration.script,
275                Some(undo_migration.checksum),
276                installed_by,
277                &sql,
278            )
279            .await?;
280
281            report.migrations_undone += 1;
282            report.total_time_ms += exec_time;
283            report.details.push(UndoDetail {
284                version: version.raw.clone(),
285                description: undo_migration.description.clone(),
286                script: undo_migration.script.clone(),
287                execution_time_ms: exec_time,
288                auto_reversal: false,
289            });
290        } else if config.reversals.enabled {
291            // Fall back to auto-generated reversal SQL from history table
292            match crate::reversal::get_reversal(client, schema, table, &version.raw).await? {
293                Some(reversal_sql) => {
294                    let script = format!("auto-reversal:V{}", version.raw);
295                    log::info!(
296                        "Undoing migration (auto-reversal); version={}, schema={}",
297                        version.raw,
298                        schema
299                    );
300
301                    let exec_time = execute_undo_sql(
302                        client,
303                        schema,
304                        table,
305                        &version.raw,
306                        "Auto-generated reversal",
307                        &script,
308                        None,
309                        installed_by,
310                        &reversal_sql,
311                    )
312                    .await?;
313
314                    report.migrations_undone += 1;
315                    report.total_time_ms += exec_time;
316                    report.details.push(UndoDetail {
317                        version: version.raw.clone(),
318                        description: "Auto-generated reversal".to_string(),
319                        script,
320                        execution_time_ms: exec_time,
321                        auto_reversal: true,
322                    });
323                }
324                None => {
325                    return Err(WaypointError::UndoMissing {
326                        version: version.raw.clone(),
327                    });
328                }
329            }
330        } else {
331            return Err(WaypointError::UndoMissing {
332                version: version.raw.clone(),
333            });
334        }
335    }
336
337    Ok(report)
338}
339
340// ── Dialect-aware entry + MySQL path ─────────────────────────────────────────
341//
342// Both engines resolve an undo the same way: a manual `U{version}__*.sql` file
343// takes precedence, falling back to the auto-generated reversal SQL stored in
344// the history table when `[reversals] enabled` is set.
345
346/// Execute the undo command (dialect-aware entry).
347pub async fn execute_db(
348    client: &DbClient,
349    config: &WaypointConfig,
350    target: UndoTarget,
351) -> Result<UndoReport> {
352    match client.dialect_kind() {
353        #[cfg(feature = "postgres")]
354        DialectKind::Postgres => execute(client.as_postgres()?, config, target).await,
355        #[cfg(not(feature = "postgres"))]
356        DialectKind::Postgres => Err(WaypointError::ConfigError(
357            "PostgreSQL support is not compiled in (enable the `postgres` feature)".into(),
358        )),
359        #[cfg(feature = "mysql")]
360        DialectKind::Mysql => execute_mysql(client, config, target).await,
361        #[cfg(not(feature = "mysql"))]
362        DialectKind::Mysql => Err(WaypointError::ConfigError(
363            "MySQL support is not compiled in (enable the `mysql` feature)".into(),
364        )),
365    }
366}
367
368#[cfg(feature = "mysql")]
369async fn execute_mysql(
370    client: &DbClient,
371    config: &WaypointConfig,
372    target: UndoTarget,
373) -> Result<UndoReport> {
374    let schema = client.resolve_schema(&config.migrations.schema).await?;
375    let table = &config.migrations.table;
376
377    client.acquire_lock(&schema, table).await?;
378
379    let result = run_undo_mysql(client, config, target).await;
380
381    if let Err(e) = client.release_lock(&schema, table).await {
382        log::error!("Failed to release advisory lock: {}", e);
383    }
384
385    match &result {
386        Ok(report) => {
387            log::info!(
388                "Undo completed (mysql); migrations_undone={}, total_time_ms={}",
389                report.migrations_undone,
390                report.total_time_ms
391            );
392        }
393        Err(e) => {
394            log::error!("Undo failed (mysql): {}", e);
395        }
396    }
397
398    result
399}
400
401#[cfg(feature = "mysql")]
402async fn run_undo_mysql(
403    client: &DbClient,
404    config: &WaypointConfig,
405    target: UndoTarget,
406) -> Result<UndoReport> {
407    let schema = client.resolve_schema(&config.migrations.schema).await?;
408    let schema = schema.as_str();
409    let table = &config.migrations.table;
410
411    history::create_history_table_db(client, schema, table).await?;
412
413    let resolved = scan_migrations(&config.migrations.locations)?;
414    let undo_by_version: HashMap<String, &ResolvedMigration> = resolved
415        .iter()
416        .filter(|m| m.is_undo())
417        .filter_map(|m| m.version().map(|v| (v.raw.clone(), m)))
418        .collect();
419
420    let applied = history::get_applied_migrations_db(client, schema, table).await?;
421    let effective = history::effective_applied_versions(&applied);
422
423    let mut applied_versions: Vec<MigrationVersion> = effective
424        .iter()
425        .filter_map(|v| MigrationVersion::parse(v).ok())
426        .collect();
427    applied_versions.sort();
428    applied_versions.reverse();
429
430    let versions_to_undo: Vec<MigrationVersion> = match target {
431        UndoTarget::Last => applied_versions.into_iter().take(1).collect(),
432        UndoTarget::Count(n) => applied_versions.into_iter().take(n).collect(),
433        UndoTarget::Version(ref target_ver) => applied_versions
434            .into_iter()
435            .filter(|v| v > target_ver)
436            .collect(),
437    };
438
439    let db_user = client
440        .current_user()
441        .await
442        .unwrap_or_else(|_| "unknown".into());
443    let db_name = client
444        .current_database()
445        .await
446        .unwrap_or_else(|_| "unknown".into());
447    let installed_by = config
448        .migrations
449        .installed_by
450        .as_deref()
451        .unwrap_or(&db_user)
452        .to_string();
453
454    let mut report = UndoReport {
455        migrations_undone: 0,
456        total_time_ms: 0,
457        details: Vec::new(),
458    };
459
460    for version in &versions_to_undo {
461        let (sql, script, description, checksum, auto_reversal) = match undo_by_version
462            .get(&version.raw)
463        {
464            Some(m) => {
465                // Manual U file: highest precedence.
466                let placeholders =
467                    build_placeholders(&config.placeholders, schema, &db_user, &db_name, &m.script);
468                let sql = replace_placeholders(&m.sql, &placeholders)?;
469                log::info!(
470                    "Undoing migration (manual); migration={}, schema={}",
471                    m.script,
472                    schema
473                );
474                (
475                    sql,
476                    m.script.clone(),
477                    m.description.clone(),
478                    Some(m.checksum),
479                    false,
480                )
481            }
482            None if config.reversals.enabled => {
483                // Fall back to auto-generated reversal SQL stored in history.
484                match crate::reversal::get_reversal_db(client, schema, table, &version.raw).await? {
485                    Some(reversal_sql) => {
486                        let script = format!("auto-reversal:V{}", version.raw);
487                        log::info!(
488                            "Undoing migration (auto-reversal); version={}, schema={}",
489                            version.raw,
490                            schema
491                        );
492                        (
493                            reversal_sql,
494                            script,
495                            "Auto-generated reversal".to_string(),
496                            None,
497                            true,
498                        )
499                    }
500                    None => {
501                        return Err(WaypointError::UndoMissing {
502                            version: version.raw.clone(),
503                        });
504                    }
505                }
506            }
507            None => {
508                return Err(WaypointError::UndoMissing {
509                    version: version.raw.clone(),
510                });
511            }
512        };
513
514        let start = std::time::Instant::now();
515        let exec_result = client.execute_raw(&sql).await;
516        let exec_time = start.elapsed().as_millis() as i32;
517
518        match exec_result {
519            Ok(_) => {
520                history::insert_applied_migration_db(
521                    client,
522                    schema,
523                    table,
524                    Some(&version.raw),
525                    &description,
526                    "UNDO_SQL",
527                    &script,
528                    checksum,
529                    &installed_by,
530                    exec_time,
531                    true,
532                )
533                .await?;
534
535                report.migrations_undone += 1;
536                report.total_time_ms += exec_time;
537                report.details.push(UndoDetail {
538                    version: version.raw.clone(),
539                    description: description.clone(),
540                    script: script.clone(),
541                    execution_time_ms: exec_time,
542                    auto_reversal,
543                });
544            }
545            Err(e) => {
546                // MySQL DDL auto-commits so the schema may be in a partially-
547                // undone state; record the failure and surface a clear error.
548                if let Err(record_err) = history::insert_applied_migration_db(
549                    client,
550                    schema,
551                    table,
552                    Some(&version.raw),
553                    &description,
554                    "UNDO_SQL",
555                    &script,
556                    checksum,
557                    &installed_by,
558                    exec_time,
559                    false,
560                )
561                .await
562                {
563                    log::warn!(
564                        "Failed to record undo failure; script={}, error={}",
565                        script,
566                        record_err
567                    );
568                }
569                return Err(WaypointError::UndoFailed {
570                    script: script.clone(),
571                    reason: e.to_string(),
572                });
573            }
574        }
575    }
576
577    Ok(report)
578}