Skip to main content

waypoint_core/commands/
migrate.rs

1//! Apply pending migrations to the database.
2//!
3//! This module owns the engine-agnostic public types ([`MigrateReport`],
4//! [`MigrateDetail`]) and a handful of shared helpers used by both
5//! engine-specific implementations. The actual `execute*` entry points
6//! live in [`crate::engines::postgres::migrate`] and
7//! [`crate::engines::mysql::migrate`] and are re-exported here so that
8//! downstream callers (and the library `Waypoint` façade) can keep using
9//! the historical paths under `crate::commands::migrate::*`.
10
11use std::collections::{HashMap, HashSet};
12
13use serde::Serialize;
14
15use crate::directive::MigrationDirectives;
16use crate::error::{Result, WaypointError};
17use crate::migration::{MigrationVersion, ResolvedMigration};
18
19// ── Re-exports of the engine-specific entry points ──────────────────────────
20//
21// `multi.rs` and `lib.rs` reference these paths today. Keeping the names
22// where they used to live preserves the public API and back-compat.
23
24#[allow(deprecated)]
25#[cfg(feature = "mysql")]
26pub use crate::engines::mysql::migrate::execute as execute_mysql;
27#[cfg(feature = "mysql")]
28pub use crate::engines::mysql::migrate::execute_with_options as execute_mysql_with_options;
29#[allow(deprecated)]
30#[cfg(feature = "postgres")]
31pub use crate::engines::postgres::migrate::execute;
32#[cfg(feature = "postgres")]
33pub use crate::engines::postgres::migrate::execute_with_options;
34
35// ── Engine-agnostic public types ────────────────────────────────────────────
36
37/// Report returned after a migrate operation.
38#[derive(Debug, Serialize)]
39pub struct MigrateReport {
40    /// Number of migrations that were applied in this run.
41    pub migrations_applied: usize,
42    /// Total execution time of all migrations in milliseconds.
43    pub total_time_ms: i32,
44    /// Per-migration details for each applied migration.
45    pub details: Vec<MigrateDetail>,
46    /// Number of lifecycle hooks that were executed.
47    pub hooks_executed: usize,
48    /// Total execution time of all hooks in milliseconds.
49    pub hooks_time_ms: i32,
50    /// Migrations that were pending but skipped by a `require` guard under
51    /// `guards.on_require_fail = "skip"`.
52    ///
53    /// Previously a skip left no trace in the report at all — only an `INFO`
54    /// log line, which both `--json` and `--quiet` suppress. A pipeline reading
55    /// `migrations_applied` had no way to tell a deliberate skip from there
56    /// being nothing to do.
57    #[serde(default)]
58    pub skipped: Vec<SkippedMigration>,
59}
60
61/// A pending migration that a `require` guard skipped.
62#[derive(Debug, Serialize)]
63pub struct SkippedMigration {
64    /// Version string, or `None` for a repeatable migration.
65    pub version: Option<String>,
66    /// Filename of the migration that was skipped.
67    pub script: String,
68    /// The `require` expression that was not satisfied.
69    pub expression: String,
70}
71
72/// Details of a single applied migration within a migrate run.
73#[derive(Debug, Serialize)]
74pub struct MigrateDetail {
75    /// Version string, or None for repeatable migrations.
76    pub version: Option<String>,
77    /// Human-readable description from the migration filename.
78    pub description: String,
79    /// Filename of the migration script.
80    pub script: String,
81    /// Execution time of this migration in milliseconds.
82    pub execution_time_ms: i32,
83}
84
85// ── Shared helpers used by both engine paths ────────────────────────────────
86
87/// Result of evaluating require-guard preconditions for a single migration.
88pub(crate) enum GuardAction {
89    /// All preconditions passed; proceed with the migration.
90    Continue,
91    /// A precondition failed with on_require_fail=Skip; skip this migration.
92    ///
93    /// Carries the expression that failed so the run can *report* the skip.
94    /// It used to carry nothing, and the only trace was an `INFO` log line —
95    /// which `--json` and `--quiet` both suppress.
96    Skip(String),
97    /// A precondition failed fatally; abort with the given error.
98    Error(WaypointError),
99}
100
101/// Turn one `require` guard evaluation into a [`GuardAction`].
102///
103/// The two engine paths differ only in *how* they evaluate the expression
104/// (`guard::evaluate` over `&Client` vs `guard::evaluate_db` over `&DbClient`).
105/// Everything after that — the on-fail policy, the logging, the error shape —
106/// is identical, and lives here so the engines cannot drift apart.
107///
108/// `outcome` is the parse-then-evaluate result: `Ok(bool)` for a guard that
109/// evaluated, `Err` for a parse or evaluation failure.
110pub(crate) fn classify_require(
111    outcome: Result<bool>,
112    expr_str: &str,
113    script: &str,
114    on_require_fail: &crate::guard::OnRequireFail,
115) -> GuardAction {
116    match outcome {
117        Ok(true) => GuardAction::Continue,
118        Ok(false) => match on_require_fail {
119            crate::guard::OnRequireFail::Skip => {
120                log::info!(
121                    "Guard require failed, skipping migration; script={}, expr={}",
122                    script,
123                    expr_str
124                );
125                GuardAction::Skip(expr_str.to_string())
126            }
127            crate::guard::OnRequireFail::Warn => {
128                log::warn!(
129                    "Guard require failed (continuing); script={}, expr={}",
130                    script,
131                    expr_str
132                );
133                GuardAction::Continue
134            }
135            crate::guard::OnRequireFail::Error => GuardAction::Error(WaypointError::GuardFailed {
136                kind: "require".to_string(),
137                script: script.to_string(),
138                expression: expr_str.to_string(),
139            }),
140        },
141        Err(e) => {
142            log::warn!(
143                "Guard evaluation error; script={}, expr={}, error={}",
144                script,
145                expr_str,
146                e
147            );
148            GuardAction::Error(WaypointError::GuardFailed {
149                kind: "require".to_string(),
150                script: script.to_string(),
151                expression: format!("{} ({})", expr_str, describe_guard_error(&e)),
152            })
153        }
154    }
155}
156
157/// Turn one `ensure` guard evaluation into a result.
158pub(crate) fn classify_ensure(outcome: Result<bool>, expr_str: &str, script: &str) -> Result<()> {
159    match outcome {
160        Ok(true) => Ok(()),
161        Ok(false) => Err(WaypointError::GuardFailed {
162            kind: "ensure".to_string(),
163            script: script.to_string(),
164            expression: expr_str.to_string(),
165        }),
166        Err(e) => Err(WaypointError::GuardFailed {
167            kind: "ensure".to_string(),
168            script: script.to_string(),
169            expression: format!("{} ({})", expr_str, describe_guard_error(&e)),
170        }),
171    }
172}
173
174/// Build the error for a guard expression that failed to parse.
175///
176/// Kept next to [`classify_require`] / [`classify_ensure`] so all three
177/// produce the same `GuardFailed` shape.
178pub(crate) fn guard_parse_error(
179    kind: &str,
180    script: &str,
181    expr_str: &str,
182    e: &WaypointError,
183) -> WaypointError {
184    WaypointError::GuardFailed {
185        kind: kind.to_string(),
186        script: script.to_string(),
187        expression: format!("{} (parse error: {})", expr_str, e),
188    }
189}
190
191/// Format an evaluation failure for inclusion in a `GuardFailed` expression.
192fn describe_guard_error(e: &WaypointError) -> String {
193    format!("evaluation error: {}", e)
194}
195
196/// Inputs that decide which migrations are still pending.
197///
198/// Shared by both engine paths so that PostgreSQL and MySQL cannot drift apart
199/// on baseline/target/out-of-order/environment semantics.
200pub(crate) struct PendingCriteria<'a> {
201    /// Versions currently applied (respecting undo).
202    pub effective_versions: &'a HashSet<String>,
203    /// Baseline version from history, if any — anything at or below is skipped.
204    pub baseline_version: Option<&'a MigrationVersion>,
205    /// Upper bound requested by the caller, if any.
206    pub target: Option<&'a MigrationVersion>,
207    /// Highest effectively-applied version, for the out-of-order check.
208    pub highest_applied: Option<&'a MigrationVersion>,
209    /// Applied repeatable script name -> recorded checksum.
210    pub applied_scripts: &'a HashMap<String, Option<i32>>,
211    /// Environment name to filter `-- waypoint:env` directives against.
212    pub current_env: Option<&'a str>,
213    /// Whether applying a version below `highest_applied` is permitted.
214    pub out_of_order: bool,
215    /// Whether `-- waypoint:depends` directives decide apply order.
216    ///
217    /// When false, pending migrations run in ascending version order. When
218    /// true, they run in a topological order derived from the dependency
219    /// graph, which still degrades to version order when no migration declares
220    /// a dependency.
221    pub dependency_ordering: bool,
222}
223
224/// The pending work for one migrate run.
225#[derive(Debug)]
226pub(crate) struct PendingSelection<'a> {
227    /// Pending versioned migrations, in ascending version order.
228    pub versioned: Vec<&'a ResolvedMigration>,
229    /// Pending repeatable migrations (new, or checksum changed).
230    pub repeatables: Vec<&'a ResolvedMigration>,
231}
232
233/// Select the migrations that still need to run.
234///
235/// Returns [`WaypointError::OutOfOrder`] when a pending version sorts below the
236/// highest applied one and `out_of_order` is disabled. Erroring — rather than
237/// silently skipping — is deliberate: a skipped migration that the report
238/// counts as a clean run is the worst possible outcome.
239pub(crate) fn select_pending<'a>(
240    resolved: &'a [ResolvedMigration],
241    criteria: &PendingCriteria<'_>,
242) -> Result<PendingSelection<'a>> {
243    let mut versioned: Vec<&ResolvedMigration> = Vec::new();
244
245    for migration in resolved.iter().filter(|m| m.is_versioned()) {
246        if !should_run_in_environment(&migration.directives, criteria.current_env) {
247            continue;
248        }
249        // `is_versioned()` guarantees a version is present.
250        let version = match migration.version() {
251            Some(v) => v,
252            None => continue,
253        };
254
255        if criteria.effective_versions.contains(&version.raw) {
256            continue;
257        }
258        if let Some(baseline) = criteria.baseline_version
259            && version <= baseline
260        {
261            log::debug!("Skipping {} (below baseline)", migration.script);
262            continue;
263        }
264        if let Some(target) = criteria.target
265            && version > target
266        {
267            log::debug!("Skipping {} (above target {})", migration.script, target);
268            continue;
269        }
270        if !criteria.out_of_order
271            && let Some(highest) = criteria.highest_applied
272            && version < highest
273        {
274            return Err(WaypointError::OutOfOrder {
275                version: version.raw.clone(),
276                highest: highest.raw.clone(),
277            });
278        }
279
280        versioned.push(migration);
281    }
282
283    if criteria.dependency_ordering {
284        order_by_dependencies(resolved, &mut versioned)?;
285    } else {
286        versioned.sort_by(|a, b| a.version().cmp(&b.version()));
287    }
288
289    let repeatables: Vec<&ResolvedMigration> = resolved
290        .iter()
291        .filter(|m| !m.is_versioned() && !m.is_undo())
292        .filter(|m| should_run_in_environment(&m.directives, criteria.current_env))
293        .filter(|m| match criteria.applied_scripts.get(&m.script) {
294            None => true,
295            Some(applied) => *applied != Some(m.checksum),
296        })
297        .collect();
298
299    Ok(PendingSelection {
300        versioned,
301        repeatables,
302    })
303}
304
305/// Reorder `pending` into a topological order honouring `-- waypoint:depends`.
306///
307/// The graph is built over *all* resolved migrations, not just the pending
308/// ones, so a `depends` on an already-applied version still resolves instead of
309/// reporting a missing dependency. Pending migrations are then emitted in the
310/// order the sort produced.
311fn order_by_dependencies(
312    resolved: &[ResolvedMigration],
313    pending: &mut Vec<&ResolvedMigration>,
314) -> Result<()> {
315    let all: Vec<&ResolvedMigration> = resolved.iter().collect();
316    // `implicit_chain = true` keeps plain version order for any migration that
317    // declares no dependencies, so turning this on is behaviour-preserving for
318    // projects that never use the directive.
319    let graph = crate::dependency::DependencyGraph::build(&all, true)?;
320    let order = graph.topological_sort()?;
321
322    let rank: HashMap<&str, usize> = order
323        .iter()
324        .enumerate()
325        .map(|(i, v)| (v.as_str(), i))
326        .collect();
327
328    pending.sort_by_key(|m| {
329        m.version()
330            .and_then(|v| rank.get(v.raw.as_str()).copied())
331            // A migration absent from the graph sorts last rather than
332            // panicking; it cannot happen for versioned migrations, which the
333            // graph always contains.
334            .unwrap_or(usize::MAX)
335    });
336    Ok(())
337}
338
339/// Check if a migration should run in the current environment.
340///
341/// Returns true if:
342/// - The migration has no env directives (runs everywhere)
343/// - No environment is configured (runs everything)
344/// - The migration's env list includes the current environment
345pub(crate) fn should_run_in_environment(
346    directives: &MigrationDirectives,
347    current_env: Option<&str>,
348) -> bool {
349    if directives.env.is_empty() {
350        return true;
351    }
352    let env = match current_env {
353        Some(e) => e,
354        None => return true,
355    };
356    directives.env.iter().any(|e| e.eq_ignore_ascii_case(env))
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    #[test]
364    fn test_should_run_in_environment_no_directives() {
365        let directives = MigrationDirectives::default();
366        assert!(should_run_in_environment(&directives, Some("production")));
367        assert!(should_run_in_environment(&directives, None));
368    }
369
370    #[test]
371    fn test_should_run_in_environment_matches() {
372        let directives = MigrationDirectives {
373            env: vec!["production".to_string(), "staging".to_string()],
374            ..Default::default()
375        };
376        assert!(should_run_in_environment(&directives, Some("production")));
377        assert!(should_run_in_environment(&directives, Some("staging")));
378        assert!(!should_run_in_environment(&directives, Some("dev")));
379    }
380
381    #[test]
382    fn test_should_run_in_environment_case_insensitive() {
383        let directives = MigrationDirectives {
384            env: vec!["PROD".to_string()],
385            ..Default::default()
386        };
387        assert!(should_run_in_environment(&directives, Some("prod")));
388        assert!(should_run_in_environment(&directives, Some("PROD")));
389        assert!(should_run_in_environment(&directives, Some("Prod")));
390        assert!(!should_run_in_environment(&directives, Some("dev")));
391    }
392
393    #[test]
394    fn test_should_run_in_environment_no_env_configured() {
395        let directives = MigrationDirectives {
396            env: vec!["prod".to_string()],
397            ..Default::default()
398        };
399        assert!(should_run_in_environment(&directives, None));
400    }
401
402    use crate::migration::{MigrationKind, MigrationVersion};
403
404    fn mig(name: &str, depends: &[&str]) -> ResolvedMigration {
405        let (kind, description) = crate::migration::parse_migration_filename(name).unwrap();
406        ResolvedMigration {
407            kind,
408            description,
409            script: name.to_string(),
410            checksum: 1,
411            sql: String::new(),
412            directives: MigrationDirectives {
413                depends: depends.iter().map(|s| s.to_string()).collect(),
414                ..Default::default()
415            },
416        }
417    }
418
419    fn criteria<'a>(
420        applied: &'a HashSet<String>,
421        scripts: &'a HashMap<String, Option<i32>>,
422        highest: Option<&'a MigrationVersion>,
423    ) -> PendingCriteria<'a> {
424        PendingCriteria {
425            effective_versions: applied,
426            baseline_version: None,
427            target: None,
428            highest_applied: highest,
429            applied_scripts: scripts,
430            current_env: None,
431            out_of_order: false,
432            dependency_ordering: false,
433        }
434    }
435
436    #[test]
437    fn select_pending_orders_by_version() {
438        let migs = vec![mig("V10__Ten.sql", &[]), mig("V2__Two.sql", &[])];
439        let applied = HashSet::new();
440        let scripts = HashMap::new();
441        let out = select_pending(&migs, &criteria(&applied, &scripts, None)).unwrap();
442        let order: Vec<&str> = out.versioned.iter().map(|m| m.script.as_str()).collect();
443        assert_eq!(order, vec!["V2__Two.sql", "V10__Ten.sql"]);
444    }
445
446    #[test]
447    fn select_pending_errors_on_out_of_order() {
448        // V1 pending while V5 is already applied, with out_of_order disabled:
449        // must be an error, never a silent skip.
450        let migs = vec![mig("V1__One.sql", &[])];
451        let mut applied = HashSet::new();
452        applied.insert("5".to_string());
453        let scripts = HashMap::new();
454        let highest = MigrationVersion::parse("5").unwrap();
455        let err = select_pending(&migs, &criteria(&applied, &scripts, Some(&highest))).unwrap_err();
456        assert!(
457            matches!(err, WaypointError::OutOfOrder { .. }),
458            "expected OutOfOrder, got {err:?}"
459        );
460    }
461
462    #[test]
463    fn select_pending_allows_out_of_order_when_enabled() {
464        let migs = vec![mig("V1__One.sql", &[])];
465        let mut applied = HashSet::new();
466        applied.insert("5".to_string());
467        let scripts = HashMap::new();
468        let highest = MigrationVersion::parse("5").unwrap();
469        let mut c = criteria(&applied, &scripts, Some(&highest));
470        c.out_of_order = true;
471        let out = select_pending(&migs, &c).unwrap();
472        assert_eq!(out.versioned.len(), 1);
473    }
474
475    #[test]
476    fn select_pending_honours_depends_directive() {
477        // V2 declares a dependency on V3, so it must run *after* it even though
478        // its version sorts lower.
479        let migs = vec![
480            mig("V1__One.sql", &[]),
481            mig("V2__Two.sql", &["3"]),
482            mig("V3__Three.sql", &[]),
483        ];
484        let applied = HashSet::new();
485        let scripts = HashMap::new();
486        let mut c = criteria(&applied, &scripts, None);
487        c.dependency_ordering = true;
488        let out = select_pending(&migs, &c).unwrap();
489        let order: Vec<&str> = out.versioned.iter().map(|m| m.script.as_str()).collect();
490        assert_eq!(
491            order,
492            vec!["V1__One.sql", "V3__Three.sql", "V2__Two.sql"],
493            "V2 depends on V3 so it must follow it"
494        );
495    }
496
497    #[test]
498    fn select_pending_dependency_ordering_is_version_order_without_directives() {
499        let migs = vec![
500            mig("V1__One.sql", &[]),
501            mig("V2__Two.sql", &[]),
502            mig("V3__Three.sql", &[]),
503        ];
504        let applied = HashSet::new();
505        let scripts = HashMap::new();
506        let mut c = criteria(&applied, &scripts, None);
507        c.dependency_ordering = true;
508        let out = select_pending(&migs, &c).unwrap();
509        let order: Vec<&str> = out.versioned.iter().map(|m| m.script.as_str()).collect();
510        assert_eq!(
511            order,
512            vec!["V1__One.sql", "V2__Two.sql", "V3__Three.sql"],
513            "no directives means dependency ordering degrades to version order"
514        );
515    }
516
517    #[test]
518    fn select_pending_repeatable_reruns_on_checksum_change() {
519        let mut r = mig("V1__One.sql", &[]);
520        r.kind = MigrationKind::Repeatable;
521        r.script = "R__View.sql".to_string();
522        r.checksum = 99;
523        let migs = vec![r];
524        let applied = HashSet::new();
525
526        let mut scripts = HashMap::new();
527        scripts.insert("R__View.sql".to_string(), Some(99));
528        let out = select_pending(&migs, &criteria(&applied, &scripts, None)).unwrap();
529        assert!(
530            out.repeatables.is_empty(),
531            "unchanged checksum must not re-run"
532        );
533
534        let mut scripts = HashMap::new();
535        scripts.insert("R__View.sql".to_string(), Some(1));
536        let out = select_pending(&migs, &criteria(&applied, &scripts, None)).unwrap();
537        assert_eq!(out.repeatables.len(), 1, "changed checksum must re-run");
538    }
539}