Skip to main content

drizzle_cli/commands/
migrate.rs

1//! Migrate command implementation
2//!
3//! Runs pending migrations against the database.
4
5use crate::config::{Config, Driver};
6use crate::error::CliError;
7use crate::output;
8
9#[derive(clap::Args, Debug, Clone, Copy, Default)]
10pub struct MigrateOptions {
11    /// Check migration integrity without applying changes; exits non-zero on
12    /// any finding
13    ///
14    /// Beyond the pending summary, this compares the tracking table against
15    /// the local migration files: hash drift (an applied migration whose file
16    /// was edited afterwards), applied rows with no local migration,
17    /// duplicate tracking rows, and interrupted (dirty) migrations.
18    #[arg(long)]
19    pub verify: bool,
20
21    /// Show what `migrate` would do without applying changes
22    ///
23    /// Prints the applied/pending summary and pending tags. Integrity
24    /// findings are shown as warnings but do not fail the command — use
25    /// `--verify` for a hard integrity gate.
26    #[arg(long, visible_alias = "dry-run")]
27    pub plan: bool,
28
29    /// Run the `--verify` integrity checks first, then apply only if they pass
30    #[arg(long)]
31    pub safe: bool,
32
33    /// Reconcile a migration that was interrupted mid-apply, then continue
34    ///
35    /// Statements of the interrupted migration are classified against the live
36    /// schema: CREATE TABLE / CREATE [UNIQUE] INDEX / CREATE VIEW /
37    /// CREATE TYPE ... AS ENUM statements whose object already exists with a
38    /// matching definition are skipped, the rest are executed. Anything that
39    /// cannot be proven either way aborts with a manual-resolution list.
40    #[arg(long)]
41    pub repair: bool,
42}
43
44/// Run the migrate command.
45///
46/// # Errors
47///
48/// Returns [`CliError`] if mutually exclusive flags are combined, the database
49/// or credentials cannot be resolved, connecting to the database fails, or
50/// applying migrations fails.
51pub fn run(config: &Config, db_name: Option<&str>, opts: MigrateOptions) -> Result<(), CliError> {
52    validate_mutex_opts(opts)?;
53
54    let db = config.database(db_name)?;
55
56    crate::commands::harness::print_db_header(config, db_name);
57
58    println!("{}", output::heading(migrate_heading(opts)));
59    println!();
60
61    let out_dir = db.migrations_dir();
62
63    // Check if migrations directory exists
64    if !out_dir.exists() {
65        println!("  {}", output::warning("No migrations directory found."));
66        println!("  Run 'drizzle generate' to create your first migration.");
67        return Ok(());
68    }
69
70    // Codegen-only drivers (e.g. durable-sqlite) have no remote endpoint for the
71    // CLI to reach — migrations execute inside the DO runtime. Short-circuit
72    // with a pointed message instead of the generic "no credentials" fallback.
73    if matches!(db.driver, Some(Driver::DurableSqlite)) {
74        print_durable_sqlite_notice(out_dir);
75        return Ok(());
76    }
77
78    // Get credentials
79    let credentials = db.credentials()?;
80
81    let Some(credentials) = credentials else {
82        print_missing_credentials_help();
83        return Ok(());
84    };
85
86    let plan = if opts.verify || opts.plan || opts.safe {
87        Some(crate::db::plan_migrations(
88            &credentials,
89            db.dialect,
90            out_dir,
91            db.migrations_table(),
92            db.migrations_schema(),
93        )?)
94    } else {
95        None
96    };
97
98    if let Some(plan) = &plan
99        && handle_plan_short_circuit(plan, opts)?
100    {
101        return Ok(());
102    }
103
104    // Run migrations
105    let result = crate::db::run_migrations(
106        &credentials,
107        db.dialect,
108        out_dir,
109        db.migrations_table(),
110        db.migrations_schema(),
111        opts.repair,
112    )?;
113
114    print_migration_result(&result, opts.safe);
115    Ok(())
116}
117
118fn validate_mutex_opts(opts: MigrateOptions) -> Result<(), CliError> {
119    if opts.safe && opts.verify {
120        return Err(CliError::Other(
121            "--safe can't be combined with --verify".to_string(),
122        ));
123    }
124    if opts.safe && opts.plan {
125        return Err(CliError::Other(
126            "--safe can't be combined with --plan".to_string(),
127        ));
128    }
129    // --repair applies statements; the read-only modes would silently ignore it.
130    if opts.repair && opts.verify {
131        return Err(CliError::Other(
132            "--repair can't be combined with --verify".to_string(),
133        ));
134    }
135    if opts.repair && opts.plan {
136        return Err(CliError::Other(
137            "--repair can't be combined with --plan".to_string(),
138        ));
139    }
140    Ok(())
141}
142
143const fn migrate_heading(opts: MigrateOptions) -> &'static str {
144    if opts.verify {
145        "Verifying migrations..."
146    } else if opts.plan {
147        "Planning migrations..."
148    } else if opts.safe {
149        "Running safe migration flow..."
150    } else if opts.repair {
151        "Repairing and running migrations..."
152    } else {
153        "Running migrations..."
154    }
155}
156
157fn print_durable_sqlite_notice(out_dir: &std::path::Path) {
158    println!(
159        "{}",
160        output::warning("Durable Objects SQLite runs inside the Workers runtime.")
161    );
162    println!();
163    println!("  The CLI can't apply migrations to a DO from outside.");
164    println!(
165        "  Apply them at `DurableObject` init time by importing `{}/migrations.js`",
166        out_dir.display()
167    );
168    println!("  and running each statement against `state.storage().sql()`.");
169    println!();
170    println!(
171        "  (This command only generates the SQL + JS bundle — run `drizzle generate` for that.)"
172    );
173}
174
175fn print_missing_credentials_help() {
176    println!("{}", output::warning("No database credentials configured."));
177    println!();
178    println!("Add credentials to your drizzle.config.toml:");
179    println!();
180    println!("  {}", output::muted("[dbCredentials]"));
181    println!("  {}", output::muted("url = \"./dev.db\""));
182    println!();
183    println!("Or use an environment variable:");
184    println!();
185    println!("  {}", output::muted("[dbCredentials]"));
186    println!("  {}", output::muted("url = { env = \"DATABASE_URL\" }"));
187}
188
189/// Print plan summary and return `Ok(true)` if the caller should return early.
190///
191/// Integrity findings are warnings under `--plan`, and failures under
192/// `--verify` and `--safe`.
193fn handle_plan_short_circuit(
194    plan: &crate::db::MigrationPlan,
195    opts: MigrateOptions,
196) -> Result<bool, CliError> {
197    println!(
198        "  {} {}",
199        output::label("Applied migrations:"),
200        plan.applied_count
201    );
202    println!(
203        "  {} {} ({} statement(s))",
204        output::label("Pending migrations:"),
205        plan.pending_count,
206        plan.pending_statements
207    );
208
209    if !plan.pending_migrations.is_empty() {
210        println!("  {}", output::label("Pending tags:"));
211        for tag in &plan.pending_migrations {
212            println!("    {} {}", output::label("->"), tag);
213        }
214    }
215
216    if !plan.findings.is_empty() {
217        println!();
218        println!("  {}", output::label("Integrity findings:"));
219        for finding in &plan.findings {
220            println!("    {} {}", output::warning("!"), finding);
221        }
222    }
223    println!();
224
225    if opts.verify || opts.safe {
226        if !plan.findings.is_empty() {
227            let mode = if opts.verify {
228                "verification"
229            } else {
230                "--safe"
231            };
232            return Err(CliError::MigrationError(format!(
233                "{} failed with {} integrity finding(s):\n  - {}",
234                mode,
235                plan.findings.len(),
236                plan.findings.join("\n  - ")
237            )));
238        }
239        if opts.verify {
240            println!(
241                "{}",
242                output::success(&format!(
243                    "Migration verification passed: {} applied migration(s) match their \
244                     local files; no drift, no interrupted rows.",
245                    plan.applied_count
246                ))
247            );
248            return Ok(true);
249        }
250    }
251
252    if opts.plan {
253        println!("{}", output::success("Migration plan complete."));
254        return Ok(true);
255    }
256
257    if opts.safe && plan.pending_count == 0 {
258        println!("  {}", output::success("No pending migrations."));
259        println!();
260        println!("{}", output::success("Safe migration complete!"));
261        return Ok(true);
262    }
263
264    Ok(false)
265}
266
267fn print_migration_result(result: &crate::db::MigrationResult, safe: bool) {
268    if !result.repaired_migrations.is_empty() {
269        println!(
270            "  {} {} interrupted migration(s):",
271            output::success("Repaired"),
272            result.repaired_migrations.len()
273        );
274        for tag in &result.repaired_migrations {
275            println!("    {} {}", output::label("->"), tag);
276        }
277        println!();
278    }
279
280    if result.applied_count == 0 {
281        println!("  {}", output::success("No pending migrations."));
282    } else {
283        println!(
284            "  {} {} migration(s):",
285            output::success("Applied"),
286            result.applied_count
287        );
288        for hash in &result.applied_migrations {
289            println!("    {} {}", output::label("->"), hash);
290        }
291    }
292
293    println!();
294    if safe {
295        println!("{}", output::success("Safe migration complete!"));
296    } else {
297        println!("{}", output::success("Migrations complete!"));
298    }
299}