Skip to main content

waypoint_core/commands/
simulate.rs

1//! Migration simulation: run pending migrations in a throwaway schema
2//! to prove they will succeed before applying to the real schema.
3
4use serde::Serialize;
5use tokio_postgres::Client;
6
7use crate::config::WaypointConfig;
8use crate::db::quote_ident;
9use crate::error::{Result, WaypointError};
10use crate::history;
11use crate::migration::scan_migrations;
12use crate::placeholder::{build_placeholders, replace_placeholders};
13use crate::schema;
14
15/// Report from a migration simulation.
16#[derive(Debug, Clone, Serialize)]
17pub struct SimulationReport {
18    /// Whether all pending migrations passed simulation.
19    pub passed: bool,
20    /// Number of migrations simulated.
21    pub migrations_simulated: usize,
22    /// Name of the temporary schema used.
23    pub temp_schema: String,
24    /// Errors encountered during simulation.
25    pub errors: Vec<SimulationError>,
26}
27
28/// An error encountered during simulation.
29#[derive(Debug, Clone, Serialize)]
30pub struct SimulationError {
31    /// The migration script that failed.
32    pub script: String,
33    /// Error message.
34    pub error: String,
35}
36
37/// Execute migration simulation in a throwaway schema.
38pub async fn execute(client: &Client, config: &WaypointConfig) -> Result<SimulationReport> {
39    let schema_name = &config.migrations.schema;
40    let table = &config.migrations.table;
41
42    // Create history table if needed (for querying applied state)
43    history::create_history_table(client, schema_name, table).await?;
44
45    // Generate a unique temp schema name
46    let temp_schema = format!(
47        "waypoint_sim_{}",
48        std::time::SystemTime::now()
49            .duration_since(std::time::UNIX_EPOCH)
50            .unwrap_or_default()
51            .as_millis()
52    );
53
54    let result = run_simulation(client, config, &temp_schema).await;
55
56    // Always clean up the temp schema (retry once on failure)
57    let drop_sql = format!(
58        "DROP SCHEMA IF EXISTS {} CASCADE",
59        quote_ident(&temp_schema)
60    );
61    if let Err(e) = client.batch_execute(&drop_sql).await {
62        log::warn!(
63            "First attempt to drop simulation schema {} failed, retrying: {}",
64            temp_schema,
65            e
66        );
67        if let Err(e2) = client.batch_execute(&drop_sql).await {
68            log::error!(
69                "Failed to drop simulation schema {} after retry: {}",
70                temp_schema,
71                e2
72            );
73        }
74    }
75
76    result
77}
78
79async fn run_simulation(
80    client: &Client,
81    config: &WaypointConfig,
82    temp_schema: &str,
83) -> Result<SimulationReport> {
84    let schema_name = &config.migrations.schema;
85    let table = &config.migrations.table;
86
87    // Create the temp schema
88    let create_sql = format!("CREATE SCHEMA {}", quote_ident(temp_schema));
89    client
90        .batch_execute(&create_sql)
91        .await
92        .map_err(|e| WaypointError::SimulationFailed {
93            reason: format!("Failed to create simulation schema: {}", e),
94        })?;
95
96    // Replicate current schema structure into temp schema
97    let snapshot = schema::introspect(client, schema_name).await?;
98    let ddl = schema::to_ddl(&snapshot);
99
100    if !ddl.is_empty() {
101        // Set search_path to temp schema for DDL execution
102        let set_path = format!("SET search_path TO {}", quote_ident(temp_schema));
103        client
104            .batch_execute(&set_path)
105            .await
106            .map_err(|e| WaypointError::SimulationFailed {
107                reason: format!("Failed to set search_path: {}", e),
108            })?;
109
110        // Execute DDL to replicate structure (ignore errors for complex objects)
111        if let Err(e) = client.batch_execute(&ddl).await {
112            log::debug!("Partial schema replication in simulation: {}", e);
113        }
114    }
115
116    // Set search_path to temp schema
117    let set_path = format!("SET search_path TO {}", quote_ident(temp_schema));
118    client
119        .batch_execute(&set_path)
120        .await
121        .map_err(|e| WaypointError::SimulationFailed {
122            reason: format!("Failed to set search_path: {}", e),
123        })?;
124
125    // Get pending migrations
126    let resolved = scan_migrations(&config.migrations.locations)?;
127    let applied = history::get_applied_migrations(client, schema_name, table).await?;
128    let effective = history::effective_applied_versions(&applied);
129
130    let db_user = crate::db::get_current_user(client)
131        .await
132        .unwrap_or_else(|_| "unknown".to_string());
133    let db_name = crate::db::get_current_database(client)
134        .await
135        .unwrap_or_else(|_| "unknown".to_string());
136
137    let mut errors = Vec::new();
138    let mut simulated = 0;
139
140    for migration in &resolved {
141        if migration.is_undo() {
142            continue;
143        }
144        if let Some(version) = migration.version() {
145            if effective.contains(&version.raw) {
146                continue; // Already applied
147            }
148        }
149
150        let placeholders = build_placeholders(
151            &config.placeholders,
152            temp_schema,
153            &db_user,
154            &db_name,
155            &migration.script,
156        );
157        let sql = match replace_placeholders(&migration.sql, &placeholders) {
158            Ok(s) => s,
159            Err(e) => {
160                errors.push(SimulationError {
161                    script: migration.script.clone(),
162                    error: e.to_string(),
163                });
164                continue;
165            }
166        };
167
168        match client.batch_execute(&sql).await {
169            Ok(_) => {
170                simulated += 1;
171            }
172            Err(e) => {
173                errors.push(SimulationError {
174                    script: migration.script.clone(),
175                    error: crate::error::format_db_error(&e),
176                });
177            }
178        }
179    }
180
181    // Restore search_path
182    let restore_path = format!("SET search_path TO {}", quote_ident(schema_name));
183    if let Err(e) = client.batch_execute(&restore_path).await {
184        log::warn!("Failed to restore search_path: {}", e);
185    }
186
187    Ok(SimulationReport {
188        passed: errors.is_empty(),
189        migrations_simulated: simulated,
190        temp_schema: temp_schema.to_string(),
191        errors,
192    })
193}