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