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