Skip to main content

fraiseql_cli/commands/
migrate.rs

1//! `fraiseql migrate` - Database migration wrapper
2//!
3//! Wraps confiture for database migrations: every verb is one `confiture migrate <verb> …`
4//! invocation, assembled by `ConfitureCommand` from the option table its doc comment
5//! holds. Confiture must be installed and on `PATH`; `run` says so and stops when it is not.
6//!
7//! The call shape follows confiture's CLI reference, `docs/reference/cli.md` in
8//! <https://github.com/fraiseql/confiture> at tag `v1.29.0`, section "`confiture migrate`".
9//! That is the confiture the `integration (postgres)` leg installs from
10//! `tools/confiture-requirements.txt` and runs `tests/migrate_against_confiture.rs` against;
11//! a bump of that pin is where a change to the table gets checked (#1376).
12
13use std::{path::Path, process::Command};
14
15use anyhow::{Context, Result};
16use fraiseql_db::postgres::PostgresTlsConfig;
17use tracing::info;
18
19use crate::{config::DatabaseRuntimeConfig, output::OutputFormatter};
20
21/// Migration subcommand
22#[derive(Debug, Clone)]
23#[non_exhaustive]
24pub enum MigrateAction {
25    /// Apply pending migrations
26    Up {
27        /// Database connection URL
28        database_url: String,
29        /// Migration directory
30        dir:          String,
31    },
32    /// Roll back migrations
33    Down {
34        /// Database connection URL
35        database_url: String,
36        /// Migration directory
37        dir:          String,
38        /// Number of steps to roll back
39        steps:        u32,
40    },
41    /// Show migration status
42    Status {
43        /// Database connection URL
44        database_url: String,
45        /// Migration directory
46        dir:          String,
47    },
48    /// Create a new migration file
49    Create {
50        /// Migration name
51        name: String,
52        /// Migration directory
53        dir:  String,
54    },
55    /// Generate a new migration from schema diff
56    Generate {
57        /// Migration name
58        name: String,
59        /// Migration directory
60        dir:  String,
61    },
62    /// Validate migration files for naming, idempotency, and drift
63    Validate {
64        /// Migration directory
65        dir: String,
66    },
67    /// Pre-deploy safety check on pending migrations
68    Preflight {
69        /// Migration directory
70        dir: String,
71    },
72}
73
74/// Run the migrate command
75///
76/// # Errors
77///
78/// Returns an error if `confiture` is not installed, or if the underlying
79/// `confiture` subprocess fails (non-zero exit status or spawn failure).
80pub fn run(action: &MigrateAction, formatter: &OutputFormatter) -> Result<()> {
81    require_confiture(formatter)?;
82
83    let (command, done, failed) = match action {
84        MigrateAction::Up { database_url, dir } => {
85            info!("Running migrations up from {dir}");
86            formatter.progress(&format!("Applying migrations from {dir}..."));
87            (
88                ConfitureCommand::new(ConfitureVerb::Up, dir, formatter).database_url(database_url),
89                "Migrations applied successfully.".to_string(),
90                "Migration failed. Check the output above for details.",
91            )
92        },
93        MigrateAction::Down {
94            database_url,
95            dir,
96            steps,
97        } => {
98            info!("Rolling back {steps} migration(s) from {dir}");
99            formatter.progress(&format!("Rolling back {steps} migration(s)..."));
100            (
101                ConfitureCommand::new(ConfitureVerb::Down, dir, formatter)
102                    .database_url(database_url)
103                    .steps(*steps),
104                "Rollback completed successfully.".to_string(),
105                "Rollback failed. Check the output above for details.",
106            )
107        },
108        MigrateAction::Status { database_url, dir } => {
109            info!("Checking migration status for {dir}");
110            (
111                ConfitureCommand::new(ConfitureVerb::Status, dir, formatter)
112                    .database_url(database_url),
113                String::new(),
114                "Failed to get migration status.",
115            )
116        },
117        MigrateAction::Create { name, dir } => {
118            info!("Creating migration: {name} in {dir}");
119            ensure_migrations_dir(dir)?;
120            (
121                ConfitureCommand::new(ConfitureVerb::Generate, dir, formatter).name(name),
122                format!("Migration created in {dir}/"),
123                "Failed to create migration.",
124            )
125        },
126        MigrateAction::Generate { name, dir } => {
127            info!("Generating migration: {name} in {dir}");
128            ensure_migrations_dir(dir)?;
129            formatter.progress(&format!("Generating migration '{name}' in {dir}..."));
130            (
131                ConfitureCommand::new(ConfitureVerb::Generate, dir, formatter).name(name),
132                format!("Migration generated in {dir}/"),
133                "Failed to generate migration.",
134            )
135        },
136        MigrateAction::Validate { dir } => {
137            info!("Validating migrations in {dir}");
138            (
139                ConfitureCommand::new(ConfitureVerb::Validate, dir, formatter),
140                String::new(),
141                "Migration validation failed. Check the output above for details.",
142            )
143        },
144        MigrateAction::Preflight { dir } => {
145            info!("Running preflight checks for {dir}");
146            formatter.progress(&format!("Running preflight checks on {dir}..."));
147            (
148                ConfitureCommand::new(ConfitureVerb::Preflight, dir, formatter),
149                "Preflight checks passed.".to_string(),
150                "Preflight checks failed. Check the output above for details.",
151            )
152        },
153    };
154
155    if command.succeeds()? {
156        if !done.is_empty() {
157            formatter.progress(&done);
158        }
159        Ok(())
160    } else {
161        anyhow::bail!("{failed}")
162    }
163}
164
165/// Fail, with install instructions, when `confiture` is not on `PATH`.
166///
167/// Checked before anything connects: a missing confiture is the error a user can act on
168/// first, whatever the database turns out to be.
169///
170/// # Errors
171///
172/// Returns an error when `confiture --version` cannot be run.
173pub fn require_confiture(formatter: &OutputFormatter) -> Result<()> {
174    if is_confiture_installed() {
175        return Ok(());
176    }
177    print_install_instructions(formatter);
178    anyhow::bail!("confiture is not installed. See instructions above.")
179}
180
181/// Refuse a PostgreSQL server below FraiseQL's floor before confiture touches it.
182///
183/// `up`, `down` and `status` hand the URL to confiture, which would run migrations against
184/// any server; every other connection FraiseQL opens asks the version first (#1452). This
185/// opens the same checked connection the catalogue commands use, with the TLS settings the
186/// URL was resolved with, and drops it.
187///
188/// # Errors
189///
190/// Returns the floor refusal for a server below PostgreSQL 18, or the connection error.
191pub async fn refuse_unsupported_server(database: &ResolvedDatabase) -> Result<()> {
192    crate::connection::postgres_adapter(&database.url, "migration", &database.tls)
193        .await
194        .map(drop)
195}
196
197/// The `confiture migrate` verbs this wrapper invokes.
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub(crate) enum ConfitureVerb {
200    /// `confiture migrate up`
201    Up,
202    /// `confiture migrate down`
203    Down,
204    /// `confiture migrate status`
205    Status,
206    /// `confiture migrate generate` — the verb that writes a new migration file
207    Generate,
208    /// `confiture migrate validate`
209    Validate,
210    /// `confiture migrate preflight`
211    Preflight,
212}
213
214impl ConfitureVerb {
215    /// The verb as confiture spells it.
216    pub(crate) const fn as_str(self) -> &'static str {
217        match self {
218            Self::Up => "up",
219            Self::Down => "down",
220            Self::Status => "status",
221            Self::Generate => "generate",
222            Self::Validate => "validate",
223            Self::Preflight => "preflight",
224        }
225    }
226
227    /// Whether the verb has a `--no-config` option: `up`, `down`, `status` and `preflight`
228    /// do, `generate` and `validate` do not (`confiture migrate <verb> --help`, 1.29.0).
229    pub(crate) const fn accepts_no_config(self) -> bool {
230        matches!(self, Self::Up | Self::Down | Self::Status | Self::Preflight)
231    }
232}
233
234/// One `confiture migrate <verb>` invocation.
235///
236/// Every option this wrapper passes is a row of this table, and every row was checked
237/// against `confiture migrate <verb> --help`:
238///
239/// | argv                          | verbs                      | source                           |
240/// |-------------------------------|----------------------------|----------------------------------|
241/// | `--migrations-dir <dir>`      | all six                    | the resolved migration directory |
242/// | `<name>` (positional)         | `generate`                 | the migration name               |
243/// | `--steps <n>`                 | `down`                     | how many migrations to roll back |
244/// | `--no-config`                 | `up`, `down`, `status`     | accompanies the DSN (below)      |
245/// | `--format json`               | all six                    | the CLI's global `--json`        |
246///
247/// No verb takes a schema directory (`validate` has `--ddl-dir`/`--schema`; the rest have
248/// no schema path at all), so none is passed.
249///
250/// The DSN never travels on argv, where `ps aux` and `/proc/<pid>/cmdline` would show it.
251/// It is exported as `CONFITURE_DATABASE_URL`, confiture's canonical variable, together
252/// with `--no-config`. Under that flag confiture's connection ladder (its
253/// `docs/reference/cli.md`, §"Connection source and precedence") reads the environment as
254/// the sole DSN source and skips config-file discovery, so the DSN [`resolve_database_url`]
255/// settled on is the one confiture connects with; no `confiture.yaml` or
256/// `db/environments/*.yaml` in the working directory can shadow it. The same ladder is why
257/// the ambient `DATABASE_URL` is never used for the handoff: confiture ignores it for
258/// `status` (every migration "unknown (no config)", exit 0) and refuses it for `up` and
259/// `down` (`CONFIG_010`). `preflight` also has `--no-config`, but the wrapper hands it no
260/// DSN, so it runs under confiture's own discovery.
261#[derive(Clone)]
262pub(crate) struct ConfitureCommand {
263    verb:           ConfitureVerb,
264    migrations_dir: String,
265    name:           Option<String>,
266    steps:          Option<u32>,
267    json:           bool,
268    database_url:   Option<String>,
269}
270
271impl ConfitureCommand {
272    /// A `confiture migrate <verb> --migrations-dir <dir>` call, `--format json` under `--json`.
273    pub(crate) fn new(
274        verb: ConfitureVerb,
275        migrations_dir: &str,
276        formatter: &OutputFormatter,
277    ) -> Self {
278        Self {
279            verb,
280            migrations_dir: migrations_dir.to_string(),
281            name: None,
282            steps: None,
283            json: formatter.is_json(),
284            database_url: None,
285        }
286    }
287
288    /// The positional migration name (`generate`).
289    pub(crate) fn name(mut self, name: &str) -> Self {
290        self.name = Some(name.to_string());
291        self
292    }
293
294    /// `--steps <n>` (`down`).
295    pub(crate) const fn steps(mut self, steps: u32) -> Self {
296        self.steps = Some(steps);
297        self
298    }
299
300    /// The DSN confiture connects with: exported as `CONFITURE_DATABASE_URL`, with
301    /// `--no-config` on argv. Only a verb that has `--no-config` can be handed one.
302    pub(crate) fn database_url(mut self, url: &str) -> Self {
303        debug_assert!(
304            self.verb.accepts_no_config(),
305            "confiture migrate {} has no --no-config, so it cannot take a DSN this way",
306            self.verb.as_str()
307        );
308        self.database_url = Some(url.to_string());
309        self
310    }
311
312    /// The arguments after the program name, in the order confiture receives them.
313    pub(crate) fn argv(&self) -> Vec<String> {
314        let mut argv = vec!["migrate".to_string(), self.verb.as_str().to_string()];
315        if let Some(name) = &self.name {
316            argv.push(name.clone());
317        }
318        argv.push("--migrations-dir".to_string());
319        argv.push(self.migrations_dir.clone());
320        if let Some(steps) = self.steps {
321            argv.push("--steps".to_string());
322            argv.push(steps.to_string());
323        }
324        if self.database_url.is_some() {
325            argv.push("--no-config".to_string());
326        }
327        if self.json {
328            argv.push("--format".to_string());
329            argv.push("json".to_string());
330        }
331        argv
332    }
333
334    /// The process to spawn: `confiture` with [`Self::argv`], and `CONFITURE_DATABASE_URL`
335    /// set when a DSN was handed over. Nothing else in the environment is touched.
336    pub(crate) fn command(&self) -> Command {
337        let mut command = Command::new("confiture");
338        command.args(self.argv());
339        if let Some(url) = &self.database_url {
340            command.env("CONFITURE_DATABASE_URL", url);
341        }
342        command
343    }
344
345    /// Runs confiture with stdio inherited and reports whether it exited 0.
346    fn succeeds(&self) -> Result<bool> {
347        let status = self.command().status().context("Failed to execute confiture")?;
348        Ok(status.success())
349    }
350}
351
352impl std::fmt::Debug for ConfitureCommand {
353    /// The DSN is a credential: it is shown as present or absent, never spelled out.
354    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
355        f.debug_struct("ConfitureCommand")
356            .field("verb", &self.verb)
357            .field("migrations_dir", &self.migrations_dir)
358            .field("name", &self.name)
359            .field("steps", &self.steps)
360            .field("json", &self.json)
361            .field("database_url", &self.database_url.as_ref().map(|_| "<redacted>"))
362            .finish()
363    }
364}
365
366fn ensure_migrations_dir(dir: &str) -> Result<()> {
367    std::fs::create_dir_all(dir).context(format!("Failed to create migration directory: {dir}"))
368}
369
370/// The long help of `fraiseql migrate`. It lives beside the resolver whose order it states
371/// and the builder whose handoff it describes, so a change to either is a change here; a
372/// unit test holds the text to the resolver's order.
373pub(crate) const MIGRATE_LONG_ABOUT: &str = "Run database migrations\n\n\
374    Wraps confiture: every verb is one `confiture migrate <verb>` call. The database URL is \
375    resolved in this order: the --database flag, then [database].url in fraiseql.toml, then \
376    the DATABASE_URL environment variable. The result reaches confiture as \
377    CONFITURE_DATABASE_URL with --no-config, so confiture's own config files never override \
378    it.";
379
380/// Resolves the database URL for the commands that connect: the explicit `--database`
381/// flag, else `[database].url` in `fraiseql.toml`, else the `DATABASE_URL` environment
382/// variable.
383///
384/// `fraiseql.toml` is placed above the environment because it is the project's own file,
385/// while `DATABASE_URL` is whatever the shell happens to hold; an explicit flag beats both.
386/// This is fraiseql's ladder, not confiture's. Confiture has one of its own — its
387/// `docs/reference/cli.md`, §"Connection source and precedence" — in which a present config
388/// file beats an ambient `DATABASE_URL` and a mutating verb refuses an ambient variable
389/// outright. The wrapper does not let the two ladders compete: the URL this function
390/// settles on is handed to confiture under `--no-config`, which makes that URL the sole
391/// source (`ConfitureCommand`, below).
392///
393/// # Errors
394///
395/// Returns an error if `fraiseql.toml` exists but cannot be read or parsed, or if no
396/// source provides a URL.
397pub fn resolve_database_url(explicit: Option<&str>) -> Result<String> {
398    resolve_database(explicit).map(|db| db.url)
399}
400
401/// A database URL and the transport security to connect to it with.
402#[derive(Debug, Clone)]
403pub struct ResolvedDatabase {
404    /// The connection string.
405    pub url: String,
406    /// `[database] ssl_mode` when the URL came from `fraiseql.toml`; otherwise unset,
407    /// which leaves the URL's own `sslmode` in charge.
408    pub tls: PostgresTlsConfig,
409}
410
411/// Resolve the database URL like [`resolve_database_url`], together with its TLS
412/// settings (#1429).
413///
414/// A URL taken from `fraiseql.toml`'s `[database]` section brings that section's
415/// `ssl_mode` with it: the two are one setting written in two keys. A URL from the flag
416/// or `DATABASE_URL` carries its own `?sslmode=`.
417///
418/// # Errors
419///
420/// Returns an error if `fraiseql.toml` exists but cannot be read or parsed, if its
421/// `ssl_mode` names a mode the connector cannot honour, or if no database URL can be
422/// found from any source (flag, TOML, or `DATABASE_URL`).
423pub fn resolve_database(explicit: Option<&str>) -> Result<ResolvedDatabase> {
424    resolve_configured_database(explicit)?.ok_or_else(|| {
425        anyhow::anyhow!(
426            "No database URL provided. Use --database, set [database].url in fraiseql.toml, \
427             or set DATABASE_URL environment variable."
428        )
429    })
430}
431
432/// [`resolve_database`] for a command whose database is optional.
433///
434/// "No source names a database" is `Ok(None)` rather than an error. A configuration that
435/// names one badly — an unparseable `fraiseql.toml`, an unknown `ssl_mode` — is still an
436/// error.
437///
438/// # Errors
439///
440/// Returns an error if `fraiseql.toml` exists but cannot be read or parsed, or if its
441/// `ssl_mode` names a mode the connector cannot honour.
442pub fn resolve_configured_database(explicit: Option<&str>) -> Result<Option<ResolvedDatabase>> {
443    if let Some(url) = explicit {
444        return Ok(Some(ResolvedDatabase {
445            url: url.to_string(),
446            tls: PostgresTlsConfig::default(),
447        }));
448    }
449
450    // Try loading from fraiseql.toml
451    let toml_path = Path::new("fraiseql.toml");
452    if toml_path.exists() {
453        let content = std::fs::read_to_string(toml_path).context("Failed to read fraiseql.toml")?;
454        let parsed: toml::Value =
455            toml::from_str(&content).context("Failed to parse fraiseql.toml")?;
456
457        let database = DatabaseRuntimeConfig::from_document(&parsed)
458            .context("Failed to parse [database] in fraiseql.toml")?;
459        if let Some(url) = &database.url {
460            info!("Using database URL from fraiseql.toml");
461            return Ok(Some(ResolvedDatabase {
462                url: url.clone(),
463                tls: database.postgres_tls()?,
464            }));
465        }
466    }
467    if let Ok(url) = std::env::var("DATABASE_URL") {
468        info!("Using DATABASE_URL environment variable");
469        return Ok(Some(ResolvedDatabase {
470            url,
471            tls: PostgresTlsConfig::default(),
472        }));
473    }
474
475    Ok(None)
476}
477
478/// Resolve the migration directory: use explicit flag, or auto-discover
479pub fn resolve_migration_dir(explicit: Option<&str>) -> String {
480    if let Some(dir) = explicit {
481        return dir.to_string();
482    }
483
484    // Auto-discover common directory names
485    for candidate in &["db/0_schema", "db/migrations", "migrations"] {
486        if Path::new(candidate).is_dir() {
487            info!("Auto-discovered migration directory: {candidate}");
488            return (*candidate).to_string();
489        }
490    }
491
492    // Default
493    "db/0_schema".to_string()
494}
495
496fn is_confiture_installed() -> bool {
497    Command::new("confiture")
498        .arg("--version")
499        .stdout(std::process::Stdio::null())
500        .stderr(std::process::Stdio::null())
501        .status()
502        .is_ok_and(|s| s.success())
503}
504
505fn print_install_instructions(formatter: &OutputFormatter) {
506    formatter.progress("confiture is not installed.");
507    formatter.progress("");
508    formatter.progress("Install it with one of:");
509    formatter.progress("  cargo install confiture          # From crates.io");
510    formatter.progress("  brew install confiture            # macOS (if available)");
511    formatter.progress("");
512    formatter.progress("Learn more: https://github.com/fraiseql/confiture");
513}