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    ///
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    /// Uses borrowed `&str` references internally to avoid cloning database names
104    /// during the topological sort; only clones into owned `String`s for the output.
105    pub fn execution_order(databases: &[NamedDatabaseConfig]) -> Result<Vec<String>> {
106        let all_names: HashSet<&str> = databases.iter().map(|d| d.name.as_str()).collect();
107
108        let mut in_degree: HashMap<&str, usize> = HashMap::new();
109        let mut reverse_edges: HashMap<&str, Vec<&str>> = HashMap::new();
110
111        for db in databases {
112            in_degree.entry(db.name.as_str()).or_insert(0);
113            for dep in &db.depends_on {
114                if !all_names.contains(dep.as_str()) {
115                    return Err(WaypointError::DatabaseNotFound {
116                        name: dep.clone(),
117                        available: all_names.iter().copied().collect::<Vec<_>>().join(", "),
118                    });
119                }
120                *in_degree.entry(db.name.as_str()).or_insert(0) += 1;
121                reverse_edges
122                    .entry(dep.as_str())
123                    .or_default()
124                    .push(db.name.as_str());
125            }
126        }
127
128        let mut queue: VecDeque<&str> = VecDeque::new();
129        for (&name, &deg) in &in_degree {
130            if deg == 0 {
131                queue.push_back(name);
132            }
133        }
134
135        let mut sorted = Vec::new();
136        while let Some(name) = queue.pop_front() {
137            sorted.push(name.to_string());
138            if let Some(dependents) = reverse_edges.get(name) {
139                for &dep in dependents {
140                    let deg = in_degree
141                        .get_mut(dep)
142                        .expect("dependency not found in in_degree map");
143                    *deg -= 1;
144                    if *deg == 0 {
145                        queue.push_back(dep);
146                    }
147                }
148            }
149        }
150
151        if sorted.len() != databases.len() {
152            let in_cycle: Vec<&str> = in_degree
153                .iter()
154                .filter(|(_, deg)| **deg > 0)
155                .map(|(&name, _)| name)
156                .collect();
157            return Err(WaypointError::MultiDbDependencyCycle {
158                path: in_cycle.join(" -> "),
159            });
160        }
161
162        Ok(sorted)
163    }
164
165    /// Connect to all databases (or a filtered subset). The engine for each
166    /// database is auto-detected from the URL scheme — mixed PG/MySQL configs
167    /// are fully supported here.
168    ///
169    /// Uses built-in defaults for the global config sections. Prefer
170    /// [`Self::connect_inheriting`] so that top-level `[database]` transport
171    /// settings apply.
172    pub async fn connect(
173        databases: &[NamedDatabaseConfig],
174        filter: Option<&str>,
175    ) -> Result<HashMap<String, DbClient>> {
176        Self::connect_inheriting(databases, filter, &WaypointConfig::default()).await
177    }
178
179    /// Like [`Self::connect`], but inheriting global sections from `parent`.
180    pub async fn connect_inheriting(
181        databases: &[NamedDatabaseConfig],
182        filter: Option<&str>,
183        parent: &WaypointConfig,
184    ) -> Result<HashMap<String, DbClient>> {
185        let mut clients = HashMap::new();
186
187        for db in databases {
188            if let Some(name_filter) = filter
189                && db.name != name_filter
190            {
191                continue;
192            }
193
194            let config = db.to_waypoint_config_inheriting(parent);
195            let conn_string = config.connection_string()?;
196            let client = crate::db::connect_for_url(&conn_string, &config).await?;
197            clients.insert(db.name.clone(), client);
198        }
199
200        if let Some(name_filter) = filter
201            && !clients.contains_key(name_filter)
202        {
203            let available = databases
204                .iter()
205                .map(|d| d.name.clone())
206                .collect::<Vec<_>>()
207                .join(", ");
208            return Err(WaypointError::DatabaseNotFound {
209                name: name_filter.to_string(),
210                available,
211            });
212        }
213
214        Ok(clients)
215    }
216
217    /// Run migrate on all databases in dependency order.
218    pub async fn migrate(
219        databases: &[NamedDatabaseConfig],
220        clients: &HashMap<String, DbClient>,
221        order: &[String],
222        target_version: Option<&str>,
223        fail_fast: bool,
224    ) -> Result<MultiResult> {
225        Self::migrate_with_options(databases, clients, order, target_version, fail_fast, false)
226            .await
227    }
228
229    /// Run migrate on all databases in dependency order with the `force`
230    /// flag for overriding DANGER safety verdicts on PostgreSQL.
231    ///
232    /// Uses built-in defaults for the global config sections. Prefer
233    /// [`Self::migrate_inheriting`] so that top-level `[safety]`,
234    /// `[preflight]`, `[guards]` and `[reversals]` policy applies.
235    pub async fn migrate_with_options(
236        databases: &[NamedDatabaseConfig],
237        clients: &HashMap<String, DbClient>,
238        order: &[String],
239        target_version: Option<&str>,
240        fail_fast: bool,
241        force: bool,
242    ) -> Result<MultiResult> {
243        Self::migrate_inheriting(
244            databases,
245            clients,
246            order,
247            target_version,
248            fail_fast,
249            force,
250            &WaypointConfig::default(),
251        )
252        .await
253    }
254
255    /// Like [`Self::migrate_with_options`], but inheriting global config
256    /// sections from `parent`.
257    #[allow(clippy::too_many_arguments)]
258    pub async fn migrate_inheriting(
259        databases: &[NamedDatabaseConfig],
260        clients: &HashMap<String, DbClient>,
261        order: &[String],
262        target_version: Option<&str>,
263        fail_fast: bool,
264        force: bool,
265        parent: &WaypointConfig,
266    ) -> Result<MultiResult> {
267        let mut results = Vec::new();
268
269        for name in order {
270            let db = databases.iter().find(|d| &d.name == name);
271            let client = clients.get(name);
272
273            match (db, client) {
274                (Some(db), Some(client)) => {
275                    let config = db.to_waypoint_config_inheriting(parent);
276                    let outcome = dispatch_migrate(client, &config, target_version, force).await;
277                    match outcome {
278                        Ok(report) => {
279                            results.push(DatabaseResult {
280                                name: name.clone(),
281                                success: true,
282                                message: format!(
283                                    "Applied {} migration(s) ({}ms)",
284                                    report.migrations_applied, report.total_time_ms
285                                ),
286                            });
287                        }
288                        Err(e) => {
289                            results.push(DatabaseResult {
290                                name: name.clone(),
291                                success: false,
292                                message: format!("{}", e),
293                            });
294                            if fail_fast {
295                                break;
296                            }
297                        }
298                    }
299                }
300                _ => {
301                    results.push(DatabaseResult {
302                        name: name.clone(),
303                        success: false,
304                        message: "Database not connected".to_string(),
305                    });
306                    if fail_fast {
307                        break;
308                    }
309                }
310            }
311        }
312
313        let all_succeeded = results.iter().all(|r| r.success);
314        Ok(MultiResult {
315            results,
316            all_succeeded,
317        })
318    }
319
320    /// Run info on all databases in dependency order.
321    pub async fn info(
322        databases: &[NamedDatabaseConfig],
323        clients: &HashMap<String, DbClient>,
324        order: &[String],
325    ) -> Result<HashMap<String, Vec<crate::commands::info::MigrationInfo>>> {
326        Self::info_inheriting(databases, clients, order, &WaypointConfig::default()).await
327    }
328
329    /// Like [`Self::info`], but inheriting global config sections from `parent`.
330    pub async fn info_inheriting(
331        databases: &[NamedDatabaseConfig],
332        clients: &HashMap<String, DbClient>,
333        order: &[String],
334        parent: &WaypointConfig,
335    ) -> Result<HashMap<String, Vec<crate::commands::info::MigrationInfo>>> {
336        let mut all_info = HashMap::new();
337
338        for name in order {
339            let db = databases.iter().find(|d| &d.name == name);
340            let client = clients.get(name);
341
342            if let (Some(db), Some(client)) = (db, client) {
343                let config = db.to_waypoint_config_inheriting(parent);
344                let info = crate::commands::info::execute_db(client, &config).await?;
345                all_info.insert(name.clone(), info);
346            }
347        }
348
349        Ok(all_info)
350    }
351}
352
353/// Dispatch migrate to the appropriate engine-specific implementation.
354async fn dispatch_migrate(
355    client: &DbClient,
356    config: &WaypointConfig,
357    target_version: Option<&str>,
358    force: bool,
359) -> Result<crate::commands::migrate::MigrateReport> {
360    match client.dialect_kind() {
361        #[cfg(feature = "postgres")]
362        DialectKind::Postgres => {
363            crate::commands::migrate::execute_with_options(
364                client.as_postgres()?,
365                config,
366                target_version,
367                force,
368            )
369            .await
370        }
371        #[cfg(not(feature = "postgres"))]
372        DialectKind::Postgres => Err(WaypointError::ConfigError(
373            "PostgreSQL support is not compiled in (enable the `postgres` feature)".into(),
374        )),
375        #[cfg(feature = "mysql")]
376        DialectKind::Mysql => {
377            crate::commands::migrate::execute_mysql_with_options(
378                client,
379                config,
380                target_version,
381                force,
382            )
383            .await
384        }
385        #[cfg(not(feature = "mysql"))]
386        DialectKind::Mysql => Err(WaypointError::ConfigError(
387            "MySQL support is not compiled in (enable the `mysql` feature)".into(),
388        )),
389    }
390}