Skip to main content

drizzle_cli/commands/
migrate.rs

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