Skip to main content

fse_cli/
migrate.rs

1//! The `fse migrate` flow: parse the tables folder, diff against the
2//! snapshot, write a plain sqlx migration, then apply everything pending.
3//!
4//! The snapshot is updated at *generation* time: it records the schema the
5//! generated migrations produce, while the database's `_sqlx_migrations`
6//! table tracks what has been applied. So an aborted apply, or the
7//! edit-the-TODO-then-rerun flow, never generates the same migration twice —
8//! rerunning just applies what is pending.
9
10use std::fs;
11use std::io::Write;
12use std::path::{Path, PathBuf};
13
14use color_eyre::eyre::{Result, WrapErr, bail};
15use fse_schema::{Schema, diff_schemas, parse, snapshot};
16
17use crate::config::{self, OrmConfig};
18
19#[derive(Debug, Default)]
20pub struct MigrateOpts {
21    /// Print the pending diff without writing or applying anything.
22    pub dry_run: bool,
23    /// Skip the confirmation prompts (also required for non-interactive use).
24    pub assume_yes: bool,
25    /// Skip `cargo sqlx prepare` after applying.
26    pub no_prepare: bool,
27    /// Overrides the env var from fse.toml (used by tests).
28    pub database_url: Option<String>,
29}
30
31#[derive(Debug)]
32pub struct MigrateOutcome {
33    /// The migration file written this run, if the schema changed.
34    pub generated: Option<PathBuf>,
35    /// The generated SQL contains a TODO and was not applied.
36    pub needs_manual_edit: bool,
37}
38
39pub async fn run(root: &Path, opts: &MigrateOpts) -> Result<MigrateOutcome> {
40    let cfg = config::load(root)?;
41
42    // Module tables (shipped snapshots) resolve app foreign keys and merge
43    // into the schema, so their DDL lands in the app's own migrations.
44    let mut external = Vec::new();
45    let mut module_schemas = Vec::new();
46    for module in crate::modules::discover(root, &cfg)? {
47        let schema = crate::modules::load_schema(&module)?;
48        external.extend(schema.tables.iter().cloned());
49        module_schemas.push((module.name, schema));
50    }
51
52    let mut new_schema = parse_tables(root, &cfg, &external)?;
53    for (name, schema) in module_schemas {
54        new_schema.merge(schema, &name)?;
55    }
56    validate_required_columns(&new_schema, &cfg)?;
57
58    let snapshot_path = root.join(&cfg.snapshot_path);
59    let old_schema = if snapshot_path.exists() {
60        let raw = fs::read_to_string(&snapshot_path)
61            .wrap_err_with(|| format!("cannot read {}", snapshot_path.display()))?;
62        snapshot::schema_from_json(&raw)?
63    } else {
64        Schema::default()
65    };
66
67    let migrations_dir = root.join(&cfg.migrations_dir);
68    let mut outcome = MigrateOutcome {
69        generated: None,
70        needs_manual_edit: false,
71    };
72
73    if let Some(migration) = diff_schemas(&old_schema, &new_schema)? {
74        println!("Schema change: {}\n", migration.summary);
75        println!("{}", migration.sql);
76        if migration.destructive {
77            println!("!! this migration is destructive (data is dropped)\n");
78        }
79
80        if opts.dry_run {
81            println!("dry run: nothing written.");
82            return Ok(outcome);
83        }
84        if !opts.assume_yes && !confirm(migration.destructive)? {
85            println!("aborted: nothing written.");
86            return Ok(outcome);
87        }
88
89        fs::create_dir_all(&migrations_dir)
90            .wrap_err_with(|| format!("cannot create {}", migrations_dir.display()))?;
91        let file = migrations_dir.join(format!(
92            "{}_{}.sql",
93            next_version(&migrations_dir)?,
94            slug_or_default(&migration.filename_slug()),
95        ));
96        fs::write(&file, &migration.sql)
97            .wrap_err_with(|| format!("cannot write {}", file.display()))?;
98        write_snapshot(&snapshot_path, &new_schema)?;
99        println!("wrote {}", file.display());
100
101        outcome.needs_manual_edit = migration.needs_manual_edit;
102        outcome.generated = Some(file);
103        if migration.needs_manual_edit {
104            println!(
105                "\nThe migration contains a TODO (a new NOT NULL column needs a backfill).\n\
106                 Edit the file, then run `fse migrate` again to apply it."
107            );
108            return Ok(outcome);
109        }
110    } else {
111        // Keep the snapshot in existence even when nothing changed (first
112        // run of an app whose migrations already match its structs).
113        if !snapshot_path.exists() {
114            write_snapshot(&snapshot_path, &new_schema)?;
115        }
116        println!("schema up to date.");
117    }
118
119    if opts.dry_run {
120        return Ok(outcome);
121    }
122
123    apply_pending(root, &cfg, opts, &migrations_dir).await?;
124
125    if !opts.no_prepare {
126        crate::prepare::run(root, &cfg, opts.database_url.as_deref())?;
127    }
128    Ok(outcome)
129}
130
131fn parse_tables(
132    root: &Path,
133    cfg: &OrmConfig,
134    external: &[fse_schema::TableDef],
135) -> Result<Schema> {
136    let dir = root.join(&cfg.tables_dir);
137    if !dir.exists() {
138        bail!(
139            "tables folder {} does not exist (set orm.tables_dir in fse.toml)",
140            dir.display()
141        );
142    }
143    let mut sources = Vec::new();
144    for entry in fs::read_dir(&dir).wrap_err_with(|| dir.display().to_string())? {
145        let path = entry.wrap_err_with(|| dir.display().to_string())?.path();
146        if path.extension().is_some_and(|e| e == "rs") {
147            sources.push((
148                path.file_name().unwrap().to_string_lossy().into_owned(),
149                fs::read_to_string(&path).wrap_err_with(|| path.display().to_string())?,
150            ));
151        }
152    }
153    sources.sort();
154    if sources.is_empty() {
155        bail!("no .rs files in {}", dir.display());
156    }
157    Ok(parse::parse_sources_with_external(&sources, external)?)
158}
159
160/// The framework contract from fse.toml: every listed table must exist and
161/// carry the listed columns (e.g. what auth needs on `users`).
162fn validate_required_columns(schema: &Schema, cfg: &OrmConfig) -> Result<()> {
163    for (table_name, columns) in &cfg.required_columns {
164        let Some(table) = schema.table(table_name) else {
165            bail!(
166                "fse.toml requires a `{table_name}` table, but no #[derive(Table)] struct defines it"
167            );
168        };
169        for column in columns {
170            if table.column(column).is_none() {
171                bail!(
172                    "fse.toml requires column `{column}` on `{table_name}` — the framework depends on it; add it back to the struct"
173                );
174            }
175        }
176    }
177    Ok(())
178}
179
180fn write_snapshot(path: &Path, schema: &Schema) -> Result<()> {
181    if let Some(parent) = path.parent() {
182        fs::create_dir_all(parent)
183            .wrap_err_with(|| format!("cannot create {}", parent.display()))?;
184    }
185    fs::write(path, snapshot::schema_to_json(schema))
186        .wrap_err_with(|| format!("cannot write {}", path.display()))
187}
188
189/// sqlx migration version: current UTC timestamp, bumped past any version
190/// already in the folder (hand-written or generated seconds apart).
191fn next_version(migrations_dir: &Path) -> Result<u64> {
192    let mut version: u64 = chrono::Utc::now()
193        .format("%Y%m%d%H%M%S")
194        .to_string()
195        .parse()
196        .expect("timestamp is numeric");
197    let mut existing = Vec::new();
198    if migrations_dir.exists() {
199        for entry in
200            fs::read_dir(migrations_dir).wrap_err_with(|| migrations_dir.display().to_string())?
201        {
202            let name = entry
203                .wrap_err_with(|| migrations_dir.display().to_string())?
204                .file_name();
205            let name = name.to_string_lossy();
206            let digits: String = name.chars().take_while(char::is_ascii_digit).collect();
207            if let Ok(v) = digits.parse::<u64>() {
208                existing.push(v);
209            }
210        }
211    }
212    while existing.contains(&version) {
213        version += 1;
214    }
215    Ok(version)
216}
217
218fn slug_or_default(slug: &str) -> &str {
219    if slug.is_empty() { "schema" } else { slug }
220}
221
222fn confirm(destructive: bool) -> Result<bool> {
223    if destructive {
224        print!("apply this DESTRUCTIVE migration? type `yes` to continue: ");
225    } else {
226        print!("write and apply? [Y/n] ");
227    }
228    std::io::stdout().flush().ok();
229    let mut answer = String::new();
230    std::io::stdin()
231        .read_line(&mut answer)
232        .wrap_err("cannot read from stdin")?;
233    let answer = answer.trim().to_lowercase();
234    Ok(if destructive {
235        answer == "yes"
236    } else {
237        answer.is_empty() || answer == "y" || answer == "yes"
238    })
239}
240
241async fn apply_pending(
242    root: &Path,
243    cfg: &OrmConfig,
244    opts: &MigrateOpts,
245    migrations_dir: &Path,
246) -> Result<()> {
247    if !migrations_dir.exists() {
248        return Ok(());
249    }
250    let url = config::resolve_database_url(root, cfg, opts.database_url.as_deref())?;
251
252    // Migrations must run with foreign-key enforcement OFF: a table rebuild
253    // DROPs the old table, and with enforcement on that DROP fires child
254    // tables' ON DELETE actions — CASCADE silently wipes their rows, RESTRICT
255    // fails the migration. sqlx wraps every migration in a transaction, where
256    // `PRAGMA foreign_keys` is a silent no-op, so it has to be set on the
257    // connection itself. `pragma_foreign_key_check` below restores the safety
258    // net once everything has been applied.
259    let options = url
260        .parse::<sqlx::sqlite::SqliteConnectOptions>()
261        .wrap_err("invalid database url")?
262        .create_if_missing(true)
263        .foreign_keys(false);
264    let pool = sqlx::SqlitePool::connect_with(options)
265        .await
266        .wrap_err("cannot open database")?;
267
268    let migrator = sqlx::migrate::Migrator::new(migrations_dir.to_path_buf())
269        .await
270        .wrap_err("invalid migrations folder")?;
271    migrator.run(&pool).await.wrap_err("migration failed")?;
272
273    let violations: Vec<(String, Option<i64>, String)> =
274        sqlx::query_as("SELECT \"table\", rowid, parent FROM pragma_foreign_key_check")
275            .fetch_all(&pool)
276            .await
277            .wrap_err("foreign_key_check failed")?;
278    pool.close().await;
279    if !violations.is_empty() {
280        let examples: Vec<String> = violations
281            .iter()
282            .take(5)
283            .map(|(table, rowid, parent)| match rowid {
284                Some(rowid) => format!("{table} rowid {rowid} -> missing {parent} row"),
285                None => format!("{table} -> missing {parent} row"),
286            })
287            .collect();
288        bail!(
289            "migrations applied, but the database now has {} foreign key violation(s):\n  {}\n\
290             fix the offending rows (or the migration that orphaned them) and rerun.",
291            violations.len(),
292            examples.join("\n  "),
293        );
294    }
295    println!("database is up to date.");
296    Ok(())
297}