Skip to main content

spawn_db/commands/migration/
mod.rs

1mod adopt;
2mod apply;
3mod build;
4mod new;
5mod pin;
6mod status;
7
8pub use adopt::AdoptMigration;
9pub use apply::ApplyMigration;
10pub use build::BuildMigration;
11pub use new::NewMigration;
12pub use pin::PinMigration;
13pub use status::MigrationStatus;
14
15pub const DEFAULT_NAMESPACE: &str = "default";
16
17use crate::config::Config;
18use crate::engine::{MigrationDbInfo, MigrationHistoryStatus};
19use crate::store::list_migration_fs_status;
20use anyhow::Result;
21use dialoguer::Confirm;
22use std::collections::{HashMap, HashSet};
23
24/// Combined status of a migration from both filesystem and database
25#[derive(Debug, Clone)]
26pub struct MigrationStatusRow {
27    pub migration_name: String,
28    pub exists_in_filesystem: bool,
29    pub is_pinned: bool,
30    pub exists_in_db: bool,
31    pub last_status: Option<MigrationHistoryStatus>,
32    pub last_activity: Option<String>,
33    pub checksum: Option<String>,
34}
35
36/// Get the combined status of all migrations from both filesystem and database.
37/// If namespace is None, returns migrations from all namespaces.
38/// This is shared logic that can be used by multiple commands (status, apply_all, etc.)
39pub async fn get_combined_migration_status(
40    config: &Config,
41    namespace: Option<&str>,
42) -> Result<Vec<MigrationStatusRow>> {
43    let engine = config.new_engine().await?;
44
45    // Get filesystem status
46    let fs_status = list_migration_fs_status(config.operator(), &config.pather(), None).await?;
47
48    // Get all migrations from database with their latest history entry
49    let db_migrations_list = engine.get_migrations_from_db(namespace).await?;
50
51    // Convert to a map for easier lookup
52    let db_migrations: HashMap<String, MigrationDbInfo> = db_migrations_list
53        .into_iter()
54        .map(|info| (info.migration_name.clone(), info))
55        .collect();
56
57    // Combine both sources
58    let all_migration_names: HashSet<String> = fs_status
59        .keys()
60        .chain(db_migrations.keys())
61        .cloned()
62        .collect();
63
64    let mut results: Vec<MigrationStatusRow> = all_migration_names
65        .into_iter()
66        .map(|name| {
67            let fs = fs_status.get(&name);
68            let db_info = db_migrations.get(&name);
69
70            MigrationStatusRow {
71                migration_name: name.clone(),
72                exists_in_filesystem: fs.map_or(false, |s| s.has_up_sql),
73                is_pinned: fs.map_or(false, |s| s.has_lock_toml),
74                exists_in_db: db_info.is_some(),
75                last_status: db_info.and_then(|info| info.last_status),
76                last_activity: db_info.and_then(|info| info.last_activity.clone()),
77                checksum: db_info.and_then(|info| info.checksum.clone()),
78            }
79        })
80        .collect();
81
82    // Sort by migration name for consistent output
83    results.sort_by(|a, b| a.migration_name.cmp(&b.migration_name));
84
85    Ok(results)
86}
87
88/// Get pending migrations (no status, exists on filesystem) and prompt the user
89/// to confirm. Returns `Ok(Some(migrations))` if confirmed, `Ok(None)` if
90/// aborted or empty.
91pub async fn get_pending_and_confirm(
92    config: &Config,
93    action: &str,
94    yes: bool,
95) -> Result<Option<Vec<String>>> {
96    let status_rows = get_combined_migration_status(config, Some(DEFAULT_NAMESPACE)).await?;
97
98    let pending: Vec<String> = status_rows
99        .into_iter()
100        .filter(|row| row.last_status.is_none() && row.exists_in_filesystem)
101        .map(|row| row.migration_name)
102        .collect();
103
104    if pending.is_empty() {
105        println!("No pending migrations to {}.", action);
106        return Ok(None);
107    }
108
109    let db_config = config.db_config()?;
110    let target = config.database.as_deref().unwrap_or("unknown");
111    let env = &db_config.environment;
112
113    println!();
114    println!("TARGET: {}", target);
115    if env.starts_with("prod") {
116        println!("ENVIRONMENT: {} \u{26a0}\u{fe0f}", env);
117    } else {
118        println!("ENVIRONMENT: {}", env);
119    }
120    println!();
121    println!(
122        "The following {} migration{} will be {}:",
123        pending.len(),
124        if pending.len() == 1 { "" } else { "s" },
125        if action == "apply" {
126            "applied"
127        } else {
128            "adopted"
129        },
130    );
131    for (i, name) in pending.iter().enumerate() {
132        println!("  {}. {}", i + 1, name);
133    }
134    println!();
135
136    if !yes {
137        let prompt = format!("Do you want to {} these migrations?", action);
138        let confirmed = Confirm::new()
139            .with_prompt(prompt)
140            .default(false)
141            .interact()?;
142
143        if !confirmed {
144            println!("Aborted.");
145            return Ok(None);
146        }
147    }
148
149    println!();
150    Ok(Some(pending))
151}