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.37.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
505/// The commands that install confiture, one a line (#1602).
506///
507/// confiture ships on PyPI as `fraiseql-confiture`, which installs a `confiture` console
508/// script and needs Python 3.14 or later. `confiture` on crates.io is an unrelated crate and
509/// there is no Homebrew formula: neither installs the tool this command runs.
510pub(crate) const CONFITURE_INSTALL_COMMANDS: [&str; 2] = [
511 "uv tool install fraiseql-confiture",
512 "pipx install fraiseql-confiture",
513];
514
515fn print_install_instructions(formatter: &OutputFormatter) {
516 formatter.progress("confiture is not installed.");
517 formatter.progress("");
518 formatter.progress("Install it (Python 3.14 or later) with one of:");
519 for command in CONFITURE_INSTALL_COMMANDS {
520 formatter.progress(&format!(" {command}"));
521 }
522 formatter.progress("");
523 formatter.progress("Learn more: https://github.com/fraiseql/confiture");
524}