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};
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    ///
37    /// Everything not declared per-database falls back to built-in defaults.
38    /// Prefer [`Self::to_waypoint_config_inheriting`], which carries the
39    /// top-level `[safety]`, `[preflight]`, `[guards]`, `[reversals]`,
40    /// `[advisor]`, `[snapshots]`, `[lint]` and `[simulation]` sections over
41    /// instead of silently dropping them.
42    pub fn to_waypoint_config(&self) -> WaypointConfig {
43        self.to_waypoint_config_inheriting(&WaypointConfig::default())
44    }
45
46    /// Convert to a standalone `WaypointConfig`, inheriting the global
47    /// sections from `parent`.
48    ///
49    /// A `[[databases]]` entry only carries connection, migration, hook and
50    /// placeholder settings. Every other section is process-wide policy — a
51    /// `[safety] block_on_danger = true` must not stop applying just because
52    /// the run happens to be multi-database.
53    pub fn to_waypoint_config_inheriting(&self, parent: &WaypointConfig) -> WaypointConfig {
54        WaypointConfig {
55            database: self.database.clone(),
56            migrations: self.migrations.clone(),
57            hooks: self.hooks.clone(),
58            placeholders: self.placeholders.clone(),
59            // Inherited global policy.
60            lint: parent.lint.clone(),
61            snapshots: parent.snapshots.clone(),
62            preflight: parent.preflight.clone(),
63            guards: parent.guards.clone(),
64            reversals: parent.reversals.clone(),
65            safety: parent.safety.clone(),
66            advisor: parent.advisor.clone(),
67            simulation: parent.simulation.clone(),
68            // Never inherited: a nested multi-database list would recurse.
69            multi_database: None,
70        }
71    }
72}
73
74/// Multi-database orchestration entry point.
75pub struct MultiWaypoint {
76    /// List of all database configurations to orchestrate.
77    pub databases: Vec<NamedDatabaseConfig>,
78}
79
80/// Result from a multi-db operation on a single database.
81#[derive(Debug, Serialize)]
82pub struct DatabaseResult {
83    /// Logical name of the database.
84    pub name: String,
85    /// Whether the operation succeeded on this database.
86    pub success: bool,
87    /// Human-readable summary of the operation result.
88    pub message: String,
89}
90
91/// Aggregate result from a multi-db operation.
92#[derive(Debug, Serialize)]
93pub struct MultiResult {
94    /// Per-database operation results.
95    pub results: Vec<DatabaseResult>,
96    /// Whether every database operation succeeded.
97    pub all_succeeded: bool,
98}
99
100impl MultiWaypoint {
101    /// Determine execution order based on depends_on relationships (Kahn's algorithm).
102    ///
103    /// Databases that are ready at the same moment run in **declaration order**
104    /// — the order they appear in `[[databases]]` — which makes the result
105    /// deterministic.
106    ///
107    /// The ready set used to be seeded by iterating a `HashMap`, whose order is
108    /// randomly seeded per process, so three independent databases migrated in
109    /// a different order on almost every run. Any topological order is correct,
110    /// but a varying one makes `--fail-fast` leave a different subset migrated
111    /// each time, and makes staging and production disagree for no reason.
112    ///
113    /// Uses borrowed `&str` references internally to avoid cloning database names
114    /// during the topological sort; only clones into owned `String`s for the output.
115    pub fn execution_order(databases: &[NamedDatabaseConfig]) -> Result<Vec<String>> {
116        let all_names: HashSet<&str> = databases.iter().map(|d| d.name.as_str()).collect();
117        // Declaration order, used both to break ties and to keep the
118        // "available databases" error message stable.
119        let declared: Vec<&str> = databases.iter().map(|d| d.name.as_str()).collect();
120        let rank: HashMap<&str, usize> = declared
121            .iter()
122            .enumerate()
123            .map(|(i, &name)| (name, i))
124            .collect();
125
126        let mut in_degree: HashMap<&str, usize> = HashMap::new();
127        let mut reverse_edges: HashMap<&str, Vec<&str>> = HashMap::new();
128
129        for db in databases {
130            in_degree.entry(db.name.as_str()).or_insert(0);
131            for dep in &db.depends_on {
132                if !all_names.contains(dep.as_str()) {
133                    return Err(WaypointError::DatabaseNotFound {
134                        name: dep.clone(),
135                        available: declared.join(", "),
136                    });
137                }
138                *in_degree.entry(db.name.as_str()).or_insert(0) += 1;
139                reverse_edges
140                    .entry(dep.as_str())
141                    .or_default()
142                    .push(db.name.as_str());
143            }
144        }
145
146        let mut ready: std::collections::BTreeSet<(usize, &str)> = declared
147            .iter()
148            .filter(|name| in_degree.get(*name).copied().unwrap_or(0) == 0)
149            .map(|&name| (rank[name], name))
150            .collect();
151
152        let mut sorted = Vec::new();
153        while let Some(&(_, name)) = ready.iter().next() {
154            ready.remove(&(rank[name], name));
155            sorted.push(name.to_string());
156            if let Some(dependents) = reverse_edges.get(name) {
157                for &dep in dependents {
158                    let deg = in_degree
159                        .get_mut(dep)
160                        .expect("dependency not found in in_degree map");
161                    *deg -= 1;
162                    if *deg == 0 {
163                        ready.insert((rank[dep], dep));
164                    }
165                }
166            }
167        }
168
169        if sorted.len() != databases.len() {
170            // Declaration order again, so the reported cycle is the same on
171            // every run rather than a fresh permutation each time.
172            let in_cycle: Vec<&str> = declared
173                .iter()
174                .copied()
175                .filter(|name| in_degree.get(name).copied().unwrap_or(0) > 0)
176                .collect();
177            return Err(WaypointError::MultiDbDependencyCycle {
178                path: in_cycle.join(" -> "),
179            });
180        }
181
182        Ok(sorted)
183    }
184
185    /// Connect to all databases (or a filtered subset). The engine for each
186    /// database is auto-detected from the URL scheme — mixed PG/MySQL configs
187    /// are fully supported here.
188    ///
189    /// Uses built-in defaults for the global config sections. Prefer
190    /// [`Self::connect_inheriting`] so that top-level `[database]` transport
191    /// settings apply.
192    pub async fn connect(
193        databases: &[NamedDatabaseConfig],
194        filter: Option<&str>,
195    ) -> Result<HashMap<String, DbClient>> {
196        Self::connect_inheriting(databases, filter, &WaypointConfig::default()).await
197    }
198
199    /// Like [`Self::connect`], but inheriting global sections from `parent`.
200    pub async fn connect_inheriting(
201        databases: &[NamedDatabaseConfig],
202        filter: Option<&str>,
203        parent: &WaypointConfig,
204    ) -> Result<HashMap<String, DbClient>> {
205        let mut clients = HashMap::new();
206
207        for db in databases {
208            if let Some(name_filter) = filter
209                && db.name != name_filter
210            {
211                continue;
212            }
213
214            let config = db.to_waypoint_config_inheriting(parent);
215            let conn_string = config.connection_string()?;
216            let client = crate::db::connect_for_url(&conn_string, &config).await?;
217            clients.insert(db.name.clone(), client);
218        }
219
220        if let Some(name_filter) = filter
221            && !clients.contains_key(name_filter)
222        {
223            let available = databases
224                .iter()
225                .map(|d| d.name.clone())
226                .collect::<Vec<_>>()
227                .join(", ");
228            return Err(WaypointError::DatabaseNotFound {
229                name: name_filter.to_string(),
230                available,
231            });
232        }
233
234        Ok(clients)
235    }
236
237    /// Run migrate on all databases in dependency order.
238    pub async fn migrate(
239        databases: &[NamedDatabaseConfig],
240        clients: &HashMap<String, DbClient>,
241        order: &[String],
242        target_version: Option<&str>,
243        fail_fast: bool,
244    ) -> Result<MultiResult> {
245        Self::migrate_with_options(databases, clients, order, target_version, fail_fast, false)
246            .await
247    }
248
249    /// Run migrate on all databases in dependency order with the `force`
250    /// flag for overriding DANGER safety verdicts on PostgreSQL.
251    ///
252    /// Uses built-in defaults for the global config sections. Prefer
253    /// [`Self::migrate_inheriting`] so that top-level `[safety]`,
254    /// `[preflight]`, `[guards]` and `[reversals]` policy applies.
255    pub async fn migrate_with_options(
256        databases: &[NamedDatabaseConfig],
257        clients: &HashMap<String, DbClient>,
258        order: &[String],
259        target_version: Option<&str>,
260        fail_fast: bool,
261        force: bool,
262    ) -> Result<MultiResult> {
263        Self::migrate_inheriting(
264            databases,
265            clients,
266            order,
267            target_version,
268            fail_fast,
269            force,
270            &WaypointConfig::default(),
271        )
272        .await
273    }
274
275    /// Like [`Self::migrate_with_options`], but inheriting global config
276    /// sections from `parent`.
277    #[allow(clippy::too_many_arguments)]
278    pub async fn migrate_inheriting(
279        databases: &[NamedDatabaseConfig],
280        clients: &HashMap<String, DbClient>,
281        order: &[String],
282        target_version: Option<&str>,
283        fail_fast: bool,
284        force: bool,
285        parent: &WaypointConfig,
286    ) -> Result<MultiResult> {
287        let mut results = Vec::new();
288
289        for name in order {
290            let db = databases.iter().find(|d| &d.name == name);
291            let client = clients.get(name);
292
293            match (db, client) {
294                (Some(db), Some(client)) => {
295                    let config = db.to_waypoint_config_inheriting(parent);
296                    let outcome = dispatch_migrate(client, &config, target_version, force).await;
297                    match outcome {
298                        Ok(report) => {
299                            results.push(DatabaseResult {
300                                name: name.clone(),
301                                success: true,
302                                message: format!(
303                                    "Applied {} migration(s) ({}ms)",
304                                    report.migrations_applied, report.total_time_ms
305                                ),
306                            });
307                        }
308                        Err(e) => {
309                            results.push(DatabaseResult {
310                                name: name.clone(),
311                                success: false,
312                                message: format!("{}", e),
313                            });
314                            if fail_fast {
315                                break;
316                            }
317                        }
318                    }
319                }
320                _ => {
321                    results.push(DatabaseResult {
322                        name: name.clone(),
323                        success: false,
324                        message: "Database not connected".to_string(),
325                    });
326                    if fail_fast {
327                        break;
328                    }
329                }
330            }
331        }
332
333        let all_succeeded = results.iter().all(|r| r.success);
334        Ok(MultiResult {
335            results,
336            all_succeeded,
337        })
338    }
339
340    /// Run info on all databases in dependency order.
341    pub async fn info(
342        databases: &[NamedDatabaseConfig],
343        clients: &HashMap<String, DbClient>,
344        order: &[String],
345    ) -> Result<HashMap<String, Vec<crate::commands::info::MigrationInfo>>> {
346        Self::info_inheriting(databases, clients, order, &WaypointConfig::default()).await
347    }
348
349    /// Like [`Self::info`], but inheriting global config sections from `parent`.
350    pub async fn info_inheriting(
351        databases: &[NamedDatabaseConfig],
352        clients: &HashMap<String, DbClient>,
353        order: &[String],
354        parent: &WaypointConfig,
355    ) -> Result<HashMap<String, Vec<crate::commands::info::MigrationInfo>>> {
356        let mut all_info = HashMap::new();
357
358        for name in order {
359            let db = databases.iter().find(|d| &d.name == name);
360            let client = clients.get(name);
361
362            if let (Some(db), Some(client)) = (db, client) {
363                let config = db.to_waypoint_config_inheriting(parent);
364                let info = crate::commands::info::execute_db(client, &config).await?;
365                all_info.insert(name.clone(), info);
366            }
367        }
368
369        Ok(all_info)
370    }
371}
372
373/// Dispatch migrate to the appropriate engine-specific implementation.
374async fn dispatch_migrate(
375    client: &DbClient,
376    config: &WaypointConfig,
377    target_version: Option<&str>,
378    force: bool,
379) -> Result<crate::commands::migrate::MigrateReport> {
380    match client.dialect_kind() {
381        #[cfg(feature = "postgres")]
382        DialectKind::Postgres => {
383            crate::commands::migrate::execute_with_options(
384                client.as_postgres()?,
385                config,
386                target_version,
387                force,
388            )
389            .await
390        }
391        #[cfg(not(feature = "postgres"))]
392        DialectKind::Postgres => Err(WaypointError::ConfigError(
393            "PostgreSQL support is not compiled in (enable the `postgres` feature)".into(),
394        )),
395        #[cfg(feature = "mysql")]
396        DialectKind::Mysql => {
397            crate::commands::migrate::execute_mysql_with_options(
398                client,
399                config,
400                target_version,
401                force,
402            )
403            .await
404        }
405        #[cfg(not(feature = "mysql"))]
406        DialectKind::Mysql => Err(WaypointError::ConfigError(
407            "MySQL support is not compiled in (enable the `mysql` feature)".into(),
408        )),
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use crate::config::{DatabaseConfig, HooksConfig, MigrationSettings};
416
417    fn db(name: &str, depends_on: &[&str]) -> NamedDatabaseConfig {
418        NamedDatabaseConfig {
419            name: name.to_string(),
420            database: DatabaseConfig::default(),
421            migrations: MigrationSettings::default(),
422            hooks: HooksConfig::default(),
423            placeholders: std::collections::HashMap::new(),
424            depends_on: depends_on.iter().map(|s| s.to_string()).collect(),
425        }
426    }
427
428    #[test]
429    fn test_execution_order_is_deterministic_for_independent_databases() {
430        // The ready set used to be seeded by iterating a HashMap, so these
431        // migrated in a different order on almost every process.
432        let dbs = vec![
433            db("alpha", &[]),
434            db("bravo", &[]),
435            db("charlie", &[]),
436            db("delta", &[]),
437        ];
438        let order = MultiWaypoint::execution_order(&dbs).unwrap();
439        assert_eq!(
440            order,
441            vec!["alpha", "bravo", "charlie", "delta"],
442            "independent databases must run in declaration order"
443        );
444    }
445
446    #[test]
447    fn test_execution_order_respects_dependencies_then_declaration_order() {
448        // `app` depends on `auth`, so it runs after it despite being declared
449        // first; the two independent databases keep declaration order.
450        let dbs = vec![db("app", &["auth"]), db("reports", &[]), db("auth", &[])];
451        let order = MultiWaypoint::execution_order(&dbs).unwrap();
452        assert_eq!(order, vec!["reports", "auth", "app"]);
453    }
454
455    #[test]
456    fn test_execution_order_reports_a_missing_dependency_stably() {
457        let dbs = vec![db("app", &["nope"]), db("auth", &[]), db("reports", &[])];
458        let err = MultiWaypoint::execution_order(&dbs).unwrap_err();
459        let msg = err.to_string();
460        assert!(msg.contains("nope"), "{msg}");
461        // Declaration order, not hash order, so the message is reproducible.
462        assert!(msg.contains("app, auth, reports"), "{msg}");
463    }
464}