Skip to main content

waypoint_core/
multi.rs

1//! Multi-database orchestration.
2//!
3//! Allows managing migrations across multiple named databases with dependency
4//! ordering between them. Supports mixed-engine deployments — one config can
5//! mix `postgres://` and `mysql://` databases; the engine is auto-detected per
6//! database from the URL scheme.
7
8use std::collections::{HashMap, HashSet, VecDeque};
9
10use serde::Serialize;
11
12use crate::config::{DatabaseConfig, HooksConfig, MigrationSettings, WaypointConfig};
13use crate::db::DbClient;
14use crate::dialect::DialectKind;
15use crate::error::{Result, WaypointError};
16
17/// Configuration for a single named database within a multi-db setup.
18#[derive(Debug, Clone)]
19pub struct NamedDatabaseConfig {
20    /// Unique logical name identifying this database.
21    pub name: String,
22    /// Database connection configuration.
23    pub database: DatabaseConfig,
24    /// Migration settings for this database.
25    pub migrations: MigrationSettings,
26    /// Hook configuration for this database.
27    pub hooks: HooksConfig,
28    /// Placeholder key-value pairs for SQL template substitution.
29    pub placeholders: HashMap<String, String>,
30    /// Names of other databases that must be migrated before this one.
31    pub depends_on: Vec<String>,
32}
33
34impl NamedDatabaseConfig {
35    /// Convert to a standalone WaypointConfig for running commands.
36    pub fn to_waypoint_config(&self) -> WaypointConfig {
37        WaypointConfig {
38            database: self.database.clone(),
39            migrations: self.migrations.clone(),
40            hooks: self.hooks.clone(),
41            placeholders: self.placeholders.clone(),
42            ..WaypointConfig::default()
43        }
44    }
45}
46
47/// Multi-database orchestration entry point.
48pub struct MultiWaypoint {
49    /// List of all database configurations to orchestrate.
50    pub databases: Vec<NamedDatabaseConfig>,
51}
52
53/// Result from a multi-db operation on a single database.
54#[derive(Debug, Serialize)]
55pub struct DatabaseResult {
56    /// Logical name of the database.
57    pub name: String,
58    /// Whether the operation succeeded on this database.
59    pub success: bool,
60    /// Human-readable summary of the operation result.
61    pub message: String,
62}
63
64/// Aggregate result from a multi-db operation.
65#[derive(Debug, Serialize)]
66pub struct MultiResult {
67    /// Per-database operation results.
68    pub results: Vec<DatabaseResult>,
69    /// Whether every database operation succeeded.
70    pub all_succeeded: bool,
71}
72
73impl MultiWaypoint {
74    /// Determine execution order based on depends_on relationships (Kahn's algorithm).
75    ///
76    /// Uses borrowed `&str` references internally to avoid cloning database names
77    /// during the topological sort; only clones into owned `String`s for the output.
78    pub fn execution_order(databases: &[NamedDatabaseConfig]) -> Result<Vec<String>> {
79        let all_names: HashSet<&str> = databases.iter().map(|d| d.name.as_str()).collect();
80
81        let mut in_degree: HashMap<&str, usize> = HashMap::new();
82        let mut reverse_edges: HashMap<&str, Vec<&str>> = HashMap::new();
83
84        for db in databases {
85            in_degree.entry(db.name.as_str()).or_insert(0);
86            for dep in &db.depends_on {
87                if !all_names.contains(dep.as_str()) {
88                    return Err(WaypointError::DatabaseNotFound {
89                        name: dep.clone(),
90                        available: all_names.iter().copied().collect::<Vec<_>>().join(", "),
91                    });
92                }
93                *in_degree.entry(db.name.as_str()).or_insert(0) += 1;
94                reverse_edges
95                    .entry(dep.as_str())
96                    .or_default()
97                    .push(db.name.as_str());
98            }
99        }
100
101        let mut queue: VecDeque<&str> = VecDeque::new();
102        for (&name, &deg) in &in_degree {
103            if deg == 0 {
104                queue.push_back(name);
105            }
106        }
107
108        let mut sorted = Vec::new();
109        while let Some(name) = queue.pop_front() {
110            sorted.push(name.to_string());
111            if let Some(dependents) = reverse_edges.get(name) {
112                for &dep in dependents {
113                    let deg = in_degree
114                        .get_mut(dep)
115                        .expect("dependency not found in in_degree map");
116                    *deg -= 1;
117                    if *deg == 0 {
118                        queue.push_back(dep);
119                    }
120                }
121            }
122        }
123
124        if sorted.len() != databases.len() {
125            let in_cycle: Vec<&str> = in_degree
126                .iter()
127                .filter(|(_, deg)| **deg > 0)
128                .map(|(&name, _)| name)
129                .collect();
130            return Err(WaypointError::MultiDbDependencyCycle {
131                path: in_cycle.join(" -> "),
132            });
133        }
134
135        Ok(sorted)
136    }
137
138    /// Connect to all databases (or a filtered subset). The engine for each
139    /// database is auto-detected from the URL scheme — mixed PG/MySQL configs
140    /// are fully supported here.
141    pub async fn connect(
142        databases: &[NamedDatabaseConfig],
143        filter: Option<&str>,
144    ) -> Result<HashMap<String, DbClient>> {
145        let mut clients = HashMap::new();
146
147        for db in databases {
148            if let Some(name_filter) = filter {
149                if db.name != name_filter {
150                    continue;
151                }
152            }
153
154            let config = db.to_waypoint_config();
155            let conn_string = config.connection_string()?;
156            let client = connect_one(&conn_string, &config).await?;
157            clients.insert(db.name.clone(), client);
158        }
159
160        if let Some(name_filter) = filter {
161            if !clients.contains_key(name_filter) {
162                let available = databases
163                    .iter()
164                    .map(|d| d.name.clone())
165                    .collect::<Vec<_>>()
166                    .join(", ");
167                return Err(WaypointError::DatabaseNotFound {
168                    name: name_filter.to_string(),
169                    available,
170                });
171            }
172        }
173
174        Ok(clients)
175    }
176
177    /// Run migrate on all databases in dependency order.
178    pub async fn migrate(
179        databases: &[NamedDatabaseConfig],
180        clients: &HashMap<String, DbClient>,
181        order: &[String],
182        target_version: Option<&str>,
183        fail_fast: bool,
184    ) -> Result<MultiResult> {
185        Self::migrate_with_options(databases, clients, order, target_version, fail_fast, false)
186            .await
187    }
188
189    /// Run migrate on all databases in dependency order with the `force`
190    /// flag for overriding DANGER safety verdicts on PostgreSQL.
191    pub async fn migrate_with_options(
192        databases: &[NamedDatabaseConfig],
193        clients: &HashMap<String, DbClient>,
194        order: &[String],
195        target_version: Option<&str>,
196        fail_fast: bool,
197        force: bool,
198    ) -> Result<MultiResult> {
199        let mut results = Vec::new();
200
201        for name in order {
202            let db = databases.iter().find(|d| &d.name == name);
203            let client = clients.get(name);
204
205            match (db, client) {
206                (Some(db), Some(client)) => {
207                    let config = db.to_waypoint_config();
208                    let outcome = dispatch_migrate(client, &config, target_version, force).await;
209                    match outcome {
210                        Ok(report) => {
211                            results.push(DatabaseResult {
212                                name: name.clone(),
213                                success: true,
214                                message: format!(
215                                    "Applied {} migration(s) ({}ms)",
216                                    report.migrations_applied, report.total_time_ms
217                                ),
218                            });
219                        }
220                        Err(e) => {
221                            results.push(DatabaseResult {
222                                name: name.clone(),
223                                success: false,
224                                message: format!("{}", e),
225                            });
226                            if fail_fast {
227                                break;
228                            }
229                        }
230                    }
231                }
232                _ => {
233                    results.push(DatabaseResult {
234                        name: name.clone(),
235                        success: false,
236                        message: "Database not connected".to_string(),
237                    });
238                    if fail_fast {
239                        break;
240                    }
241                }
242            }
243        }
244
245        let all_succeeded = results.iter().all(|r| r.success);
246        Ok(MultiResult {
247            results,
248            all_succeeded,
249        })
250    }
251
252    /// Run info on all databases in dependency order.
253    pub async fn info(
254        databases: &[NamedDatabaseConfig],
255        clients: &HashMap<String, DbClient>,
256        order: &[String],
257    ) -> Result<HashMap<String, Vec<crate::commands::info::MigrationInfo>>> {
258        let mut all_info = HashMap::new();
259
260        for name in order {
261            let db = databases.iter().find(|d| &d.name == name);
262            let client = clients.get(name);
263
264            if let (Some(db), Some(client)) = (db, client) {
265                let config = db.to_waypoint_config();
266                let info = crate::commands::info::execute_db(client, &config).await?;
267                all_info.insert(name.clone(), info);
268            }
269        }
270
271        Ok(all_info)
272    }
273}
274
275/// Connect to one named database, auto-detecting the engine from the URL.
276async fn connect_one(
277    conn_string: &str,
278    #[cfg_attr(not(feature = "postgres"), allow(unused_variables))] config: &WaypointConfig,
279) -> Result<DbClient> {
280    let kind = DialectKind::from_url(conn_string).unwrap_or(DialectKind::Postgres);
281    match kind {
282        #[cfg(feature = "postgres")]
283        DialectKind::Postgres => {
284            let client = crate::db::connect_with_full_config(
285                conn_string,
286                &config.database.ssl_mode,
287                config.database.connect_retries,
288                config.database.connect_timeout_secs,
289                config.database.statement_timeout_secs,
290                config.database.keepalive_secs,
291            )
292            .await?;
293            Ok(DbClient::with_postgres(client))
294        }
295        #[cfg(not(feature = "postgres"))]
296        DialectKind::Postgres => Err(WaypointError::ConfigError(
297            "PostgreSQL support is not compiled in (enable the `postgres` feature)".into(),
298        )),
299        #[cfg(feature = "mysql")]
300        DialectKind::Mysql => {
301            let pool = mysql_async::Pool::from_url(conn_string)
302                .map_err(|e| WaypointError::ConfigError(format!("Invalid MySQL URL: {}", e)))?;
303            Ok(DbClient::with_mysql(pool))
304        }
305        #[cfg(not(feature = "mysql"))]
306        DialectKind::Mysql => Err(WaypointError::ConfigError(
307            "MySQL support is not compiled in (enable the `mysql` feature)".into(),
308        )),
309    }
310}
311
312/// Dispatch migrate to the appropriate engine-specific implementation.
313async fn dispatch_migrate(
314    client: &DbClient,
315    config: &WaypointConfig,
316    target_version: Option<&str>,
317    force: bool,
318) -> Result<crate::commands::migrate::MigrateReport> {
319    match client.dialect_kind() {
320        #[cfg(feature = "postgres")]
321        DialectKind::Postgres => {
322            crate::commands::migrate::execute_with_options(
323                client.as_postgres()?,
324                config,
325                target_version,
326                force,
327            )
328            .await
329        }
330        #[cfg(not(feature = "postgres"))]
331        DialectKind::Postgres => Err(WaypointError::ConfigError(
332            "PostgreSQL support is not compiled in (enable the `postgres` feature)".into(),
333        )),
334        #[cfg(feature = "mysql")]
335        DialectKind::Mysql => {
336            crate::commands::migrate::execute_mysql_with_options(
337                client,
338                config,
339                target_version,
340                force,
341            )
342            .await
343        }
344        #[cfg(not(feature = "mysql"))]
345        DialectKind::Mysql => Err(WaypointError::ConfigError(
346            "MySQL support is not compiled in (enable the `mysql` feature)".into(),
347        )),
348    }
349}