1use 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 pub dry_run: bool,
23 pub assume_yes: bool,
25 pub no_prepare: bool,
27 pub database_url: Option<String>,
29}
30
31#[derive(Debug)]
32pub struct MigrateOutcome {
33 pub generated: Option<PathBuf>,
35 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 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 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
160fn 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
189fn 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 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}