Skip to main content

drizzle_cli/commands/
migrate.rs

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