Skip to main content

miden_node_db/migration/
build_script.rs

1use std::collections::HashSet;
2use std::ffi::OsStr;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail, ensure};
6use codegen::{Function, Scope};
7use fs_err as fs;
8
9use super::Migrator;
10
11impl Migrator {
12    /// Generates Rust source for a migrator from a migration directory.
13    ///
14    /// Writes to `output_file` relative to Cargo's `OUT_DIR`.
15    /// Use a different output filename for each database in the crate.
16    ///
17    /// Call this from a `build.rs`, then include the generated file in the crate:
18    ///
19    /// ```ignore
20    /// // build.rs
21    /// fn main() -> Result<(), Box<dyn std::error::Error>> {
22    ///     miden_node_db::migration::Migrator::generate("migrations", "db_migrator.rs")?;
23    ///     Ok(())
24    /// }
25    ///
26    /// // src/lib.rs
27    /// include!(concat!(env!("OUT_DIR"), "/db_migrator.rs"));
28    ///
29    /// #[cfg(test)]
30    /// mod tests {
31    ///     use miden_node_db::migration::SchemaHash;
32    ///
33    ///     const EXPECTED_SCHEMA_HASHES: [SchemaHash; 3] = [
34    ///         SchemaHash::from_hex(
35    ///             "1111111111111111111111111111111111111111111111111111111111111111",
36    ///         ),
37    ///         SchemaHash::from_hex(
38    ///             "2222222222222222222222222222222222222222222222222222222222222222",
39    ///         ),
40    ///         SchemaHash::from_hex(
41    ///             "3333333333333333333333333333333333333333333333333333333333333333",
42    ///         ),
43    ///     ];
44    ///
45    ///     #[test]
46    ///     fn migration_schema_hashes_are_stable() -> anyhow::Result<()> {
47    ///         let migrator = super::migrator()?;
48    ///
49    ///         assert_eq!(migrator.schema_hashes(), &EXPECTED_SCHEMA_HASHES);
50    ///         Ok(())
51    ///     }
52    /// }
53    /// ```
54    ///
55    /// The expected layout is:
56    ///
57    /// ```text
58    /// migrations/
59    ///   retired/
60    ///     001_legacy.sql
61    ///   002_initial.sql
62    ///   003_backfill.rs
63    ///   003_backfill/
64    ///     fixture.bin
65    /// ```
66    ///
67    /// Retired migrations are loaded from lexicographically sorted `.sql` files in `retired`;
68    /// the migration name is the file stem. Active migrations are loaded from lexicographically
69    /// sorted direct `.sql` and `.rs` files in the migration directory; the migration name is the
70    /// file stem. Rust migration files must expose a `pub fn migrate(...)` matching
71    /// [`super::CodeMigrationFn`]. Direct subdirectories other than `retired` are ignored by the
72    /// framework so callers can keep migration-specific support files next to a migration file.
73    ///
74    /// The `retired` directory contains SQL retained for fresh database initialization after the
75    /// corresponding active migrations no longer need to be supported. Relative migration paths are
76    /// resolved from the package manifest directory, i.e. the crate root.
77    pub fn generate(
78        migration_dir: impl AsRef<Path>,
79        output_file: impl AsRef<Path>,
80    ) -> Result<PathBuf> {
81        let migration_dir = migration_dir_path(migration_dir.as_ref());
82        build_rs::output::rerun_if_changed(&migration_dir);
83
84        let out_path = build_rs::input::out_dir().join(output_file);
85        let migrations = discover_migrations(&migration_dir)?;
86        fs::write(
87            &out_path,
88            render_migrator(&migrations.retired_migrations, &migrations.active_migrations)?,
89        )
90        .with_context(|| format!("failed to write generated migrator to {}", out_path.display()))?;
91        Ok(out_path)
92    }
93}
94
95fn migration_dir_path(migration_dir: &Path) -> PathBuf {
96    if migration_dir.is_absolute() {
97        migration_dir.to_path_buf()
98    } else {
99        build_rs::input::cargo_manifest_dir().join(migration_dir)
100    }
101}
102
103#[derive(Debug)]
104struct DiscoveredMigrations {
105    retired_migrations: Vec<SqlMigration>,
106    active_migrations: Vec<ActiveMigration>,
107}
108
109#[derive(Debug)]
110struct SqlMigration {
111    name: String,
112    path: PathBuf,
113}
114
115#[derive(Debug)]
116struct CodeMigration {
117    name: String,
118    module_ident: String,
119    path: PathBuf,
120}
121
122#[derive(Debug)]
123enum ActiveMigration {
124    Sql(SqlMigration),
125    Code(CodeMigration),
126}
127
128fn discover_migrations(migration_dir: &Path) -> Result<DiscoveredMigrations> {
129    ensure!(
130        migration_dir.is_dir(),
131        "migration path is not a directory: {}",
132        migration_dir.display()
133    );
134
135    let retired_migrations = discover_retired_migrations(migration_dir)?;
136    let active_migrations = discover_active_migrations(migration_dir)?;
137    ensure!(
138        !retired_migrations.is_empty() || !active_migrations.is_empty(),
139        "migration directory contains no migrations: {}",
140        migration_dir.display()
141    );
142
143    Ok(DiscoveredMigrations { retired_migrations, active_migrations })
144}
145
146fn discover_retired_migrations(migration_dir: &Path) -> Result<Vec<SqlMigration>> {
147    let retired_dir = migration_dir.join("retired");
148    if !retired_dir.exists() {
149        return Ok(Vec::new());
150    }
151
152    ensure!(
153        retired_dir.is_dir(),
154        "retired migration path is not a directory: {}",
155        retired_dir.display()
156    );
157
158    let mut seen_prefixes = HashSet::new();
159    let mut migrations = Vec::new();
160    for entry in read_dir_sorted(&retired_dir)? {
161        let path = entry.path();
162        ensure!(path.is_file(), "retired migration entry is not a file: {}", path.display());
163        ensure!(
164            path.extension() == Some(OsStr::new("sql")),
165            "retired migration file must use .sql extension: {}",
166            path.display()
167        );
168
169        let name = file_stem(&path)?;
170        let prefix = migration_prefix(&name, &path)?;
171        ensure!(
172            seen_prefixes.insert(prefix.to_owned()),
173            "duplicate retired migration prefix {prefix:?}"
174        );
175
176        migrations.push(SqlMigration { name, path: absolute_path(&path)? });
177    }
178
179    Ok(migrations)
180}
181
182fn discover_active_migrations(migration_dir: &Path) -> Result<Vec<ActiveMigration>> {
183    let mut seen_prefixes = HashSet::new();
184    let mut migrations = Vec::new();
185    for entry in read_dir_sorted(migration_dir)? {
186        let path = entry.path();
187        if path.is_dir() {
188            continue;
189        }
190
191        ensure!(path.is_file(), "active migration entry is not a file: {}", path.display());
192
193        let name = file_stem(&path)?;
194        let prefix = migration_prefix(&name, &path)?;
195        ensure!(
196            seen_prefixes.insert(prefix.to_owned()),
197            "duplicate active migration prefix {prefix:?}"
198        );
199
200        match path.extension().and_then(OsStr::to_str) {
201            Some("sql") => {
202                migrations
203                    .push(ActiveMigration::Sql(SqlMigration { name, path: absolute_path(&path)? }));
204            },
205            Some("rs") => {
206                let module_ident = module_ident(&name)?;
207
208                migrations.push(ActiveMigration::Code(CodeMigration {
209                    name,
210                    module_ident,
211                    path: absolute_path(&path)?,
212                }));
213            },
214            _ => {
215                bail!("active migration file must use .sql or .rs extension: {}", path.display());
216            },
217        }
218    }
219
220    Ok(migrations)
221}
222
223/// Renders the Rust source written by [`Migrator::generate`].
224///
225/// For one retired migration named `001_legacy`, one SQL migration named `002_initial`, and one
226/// Rust migration named `003_backfill`,
227/// the generated file has this shape:
228///
229/// ```ignore
230/// #[path = "/path/to/migrations/003_backfill.rs"]
231/// mod migration_003_backfill;
232///
233/// pub fn migrator() -> ::anyhow::Result<::miden_node_db::migration::Migrator> {
234///     ::miden_node_db::migration::Migrator::builder()?
235///         .push_retired("001_legacy", include_str!("/path/to/migrations/retired/001_legacy.sql"))?
236///         .push_sql("002_initial", include_str!("/path/to/migrations/002_initial.sql"))?
237///         .push_code("003_backfill", migration_003_backfill::migrate)?
238///         .build()
239/// }
240/// ```
241fn render_migrator(
242    retired_migrations: &[SqlMigration],
243    active_migrations: &[ActiveMigration],
244) -> Result<String> {
245    let mut scope = Scope::new();
246
247    for migration in active_migrations {
248        let ActiveMigration::Code(migration) = migration else {
249            continue;
250        };
251
252        let path = format!("{:?}", rust_path(&migration.path)?);
253        scope.raw(format!("#[path = {path}]\nmod {};", migration.module_ident));
254    }
255
256    let mut function = Function::new("migrator");
257    function.vis("pub");
258    function.ret("::anyhow::Result<::miden_node_db::migration::Migrator>");
259    function.line("::miden_node_db::migration::Migrator::builder()?");
260
261    for migration in retired_migrations {
262        let name = format!("{:?}", migration.name);
263        let path = format!("{:?}", rust_path(&migration.path)?);
264        function.line(format!("    .push_retired({name}, include_str!({path}))?"));
265    }
266
267    for migration in active_migrations {
268        match migration {
269            ActiveMigration::Sql(migration) => {
270                let name = format!("{:?}", migration.name);
271                let path = format!("{:?}", rust_path(&migration.path)?);
272                function.line(format!("    .push_sql({name}, include_str!({path}))?"));
273            },
274            ActiveMigration::Code(migration) => {
275                let name = format!("{:?}", migration.name);
276                function
277                    .line(format!("    .push_code({name}, {}::migrate)?", migration.module_ident));
278            },
279        }
280    }
281
282    function.line("    .build()");
283    scope.push_fn(function);
284
285    let mut source = scope.to_string();
286    source.push('\n');
287    Ok(source)
288}
289
290fn read_dir_sorted(dir: &Path) -> Result<Vec<fs::DirEntry>> {
291    let mut entries = fs::read_dir(dir)
292        .with_context(|| format!("failed to read migration directory {}", dir.display()))?
293        .collect::<std::result::Result<Vec<_>, _>>()
294        .with_context(|| {
295            format!("failed to read migration directory entry in {}", dir.display())
296        })?;
297    entries.sort_by_key(fs::DirEntry::file_name);
298    Ok(entries)
299}
300
301fn absolute_path(path: &Path) -> Result<PathBuf> {
302    fs::canonicalize(path)
303        .with_context(|| format!("failed to canonicalize migration path {}", path.display()))
304}
305
306fn file_stem(path: &Path) -> Result<String> {
307    path.file_stem().and_then(OsStr::to_str).map(str::to_owned).with_context(|| {
308        format!("migration file has invalid UTF-8 stem or no stem: {}", path.display())
309    })
310}
311
312fn migration_prefix<'a>(name: &'a str, path: &Path) -> Result<&'a str> {
313    let bytes = name.as_bytes();
314    ensure!(
315        bytes.len() > 4
316            && bytes[0].is_ascii_digit()
317            && bytes[1].is_ascii_digit()
318            && bytes[2].is_ascii_digit()
319            && bytes[3] == b'_'
320            && name[4..].chars().any(|ch| ch.is_ascii_alphanumeric()),
321        "migration file name must start with a three-digit prefix followed by an underscore, e.g. \
322         001_initial: {}",
323        path.display()
324    );
325
326    ensure!(
327        &name[..3] != "000",
328        "migration file prefix must start at 001: {}",
329        path.display()
330    );
331
332    Ok(&name[..3])
333}
334
335/// Converts a migration folder name into a Rust module identifier.
336///
337/// The generated identifier is prefixed with `migration_`, ASCII alphanumeric characters are
338/// lowercased, and every other character is replaced with `_`. For example,
339/// `001--Backfill-Accounts` becomes `migration_001__backfill_accounts`.
340fn module_ident(name: &str) -> Result<String> {
341    ensure!(
342        name.chars().any(|ch| ch.is_ascii_alphanumeric()),
343        "migration name {name:?} cannot be converted to a Rust module identifier"
344    );
345
346    let ident = name
347        .chars()
348        .map(|ch| {
349            if ch.is_ascii_alphanumeric() {
350                ch.to_ascii_lowercase()
351            } else {
352                '_'
353            }
354        })
355        .collect::<String>();
356
357    Ok(format!("migration_{ident}"))
358}
359
360fn rust_path(path: &Path) -> Result<&str> {
361    path.to_str()
362        .with_context(|| format!("migration path is not valid UTF-8: {}", path.display()))
363}
364
365#[cfg(test)]
366mod tests {
367    use std::env;
368    use std::process::Command;
369
370    use super::*;
371
372    #[test]
373    fn generates_multiple_migrators_without_overwriting() -> Result<()> {
374        const CHILD_PROCESS: &str = "MIDEN_DB_TEST_GENERATE_MULTIPLE";
375        if env::var_os(CHILD_PROCESS).is_some() {
376            let first = Migrator::generate("first", "first_migrator.rs")?;
377            let original = fs::read(&first)?;
378            let second = Migrator::generate("second", "second_migrator.rs")?;
379
380            let out_dir = build_rs::input::out_dir();
381            assert_eq!(first, out_dir.join("first_migrator.rs"));
382            assert_eq!(second, out_dir.join("second_migrator.rs"));
383            assert_eq!(fs::read(first)?, original);
384            assert_ne!(fs::read(second)?, original);
385            return Ok(());
386        }
387
388        let root = tempfile::tempdir()?;
389        for name in ["first", "second"] {
390            let dir = root.path().join(name);
391            fs::create_dir(&dir)?;
392            fs::write(dir.join("001_initial.sql"), format!("CREATE TABLE {name} (id INTEGER);"))?;
393        }
394        let out_dir = root.path().join("out");
395        fs::create_dir(&out_dir)?;
396
397        // A child process isolates Cargo's environment variables from the other tests.
398        let output = Command::new(env::current_exe()?)
399            .args(["generates_multiple_migrators_without_overwriting", "--nocapture"])
400            .env(CHILD_PROCESS, "1")
401            .env("CARGO_MANIFEST_DIR", root.path())
402            .env("OUT_DIR", &out_dir)
403            .output()?;
404        ensure!(
405            output.status.success(),
406            "generator test failed:\n{}\n{}",
407            String::from_utf8_lossy(&output.stdout),
408            String::from_utf8_lossy(&output.stderr)
409        );
410        ensure!(out_dir.join("first_migrator.rs").is_file(), "first migrator was not generated");
411        ensure!(
412            out_dir.join("second_migrator.rs").is_file(),
413            "second migrator was not generated"
414        );
415        Ok(())
416    }
417
418    #[test]
419    fn renders_migrations_in_lexicographic_order() -> Result<()> {
420        let root = unique_temp_dir("renders_migrations_in_lexicographic_order")?;
421        fs::create_dir_all(root.join("retired"))?;
422        fs::create_dir_all(root.join("003_backfill"))?;
423        fs::write(root.join("retired").join("001_legacy.sql"), "CREATE TABLE t (id INTEGER);")?;
424        fs::write(root.join("002_indexes.sql"), "CREATE INDEX idx ON t(id);")?;
425        fs::write(
426            root.join("003_backfill.rs"),
427            "pub fn migrate(_: &rusqlite::Transaction<'_>) -> anyhow::Result<()> { Ok(()) }",
428        )?;
429        fs::write(root.join("003_backfill").join("fixture.bin"), "supporting data")?;
430
431        let retired = discover_retired_migrations(&root)?;
432        let active = discover_active_migrations(&root)?;
433        let rendered = render_migrator(&retired, &active)?;
434
435        let legacy = rendered.find("\"001_legacy\"").expect("legacy migration is rendered");
436        let indexes = rendered.find("\"002_indexes\"").expect("index migration is rendered");
437        let backfill = rendered.find("\"003_backfill\"").expect("code migration is rendered");
438
439        assert!(legacy < indexes);
440        assert!(indexes < backfill);
441        assert!(rendered.contains("include_str!("));
442        assert!(rendered.contains(".push_retired("));
443        assert!(rendered.contains(".push_sql("));
444        assert!(rendered.contains(".push_code("));
445        assert!(!rendered.contains(".push_base("));
446        assert!(rendered.contains("migration_003_backfill::migrate"));
447        assert!(rendered.contains(".build()\n}\n"));
448        assert!(!rendered.contains("Ok(migrator)"));
449
450        fs::remove_dir_all(root)?;
451        Ok(())
452    }
453
454    #[test]
455    fn rejects_empty_migration_directory() -> Result<()> {
456        let root = unique_temp_dir("rejects_empty_migration_directory")?;
457
458        let err = discover_migrations(&root).expect_err("empty migration directory should fail");
459
460        assert!(err.to_string().contains("contains no migrations"));
461        fs::remove_dir_all(root)?;
462        Ok(())
463    }
464
465    #[test]
466    fn rejects_invalid_retired_migration_entries() -> Result<()> {
467        let root = unique_temp_dir("rejects_invalid_retired_migration_entries")?;
468        fs::create_dir_all(root.join("retired"))?;
469        fs::write(root.join("retired").join("001_init.txt"), "CREATE TABLE t (id INTEGER);")?;
470
471        let err =
472            discover_retired_migrations(&root).expect_err("invalid retired entry should fail");
473
474        assert!(err.to_string().contains("must use .sql extension"));
475        fs::remove_dir_all(root)?;
476        Ok(())
477    }
478
479    #[test]
480    fn rejects_invalid_active_migration_file_extension() -> Result<()> {
481        let root = unique_temp_dir("rejects_invalid_active_migration_file_extension")?;
482        fs::write(root.join("001_init.txt"), "CREATE TABLE t (id INTEGER);")?;
483
484        let err = discover_active_migrations(&root).expect_err("invalid entry should fail");
485
486        assert!(err.to_string().contains("must use .sql or .rs extension"));
487        fs::remove_dir_all(root)?;
488        Ok(())
489    }
490
491    #[test]
492    fn rejects_active_migrations_without_three_digit_prefix() -> Result<()> {
493        let root = unique_temp_dir("rejects_active_migrations_without_three_digit_prefix")?;
494        fs::write(root.join("1_init.sql"), "CREATE TABLE t (id INTEGER);")?;
495
496        let err = discover_active_migrations(&root).expect_err("invalid prefix should fail");
497
498        assert!(err.to_string().contains("three-digit prefix"));
499        fs::remove_dir_all(root)?;
500        Ok(())
501    }
502
503    #[test]
504    fn rejects_retired_migrations_without_three_digit_prefix() -> Result<()> {
505        let root = unique_temp_dir("rejects_retired_migrations_without_three_digit_prefix")?;
506        fs::create_dir_all(root.join("retired"))?;
507        fs::write(root.join("retired").join("init.sql"), "CREATE TABLE t (id INTEGER);")?;
508
509        let err = discover_retired_migrations(&root).expect_err("invalid prefix should fail");
510
511        assert!(err.to_string().contains("three-digit prefix"));
512        fs::remove_dir_all(root)?;
513        Ok(())
514    }
515
516    #[test]
517    fn rejects_duplicate_active_migration_prefixes() -> Result<()> {
518        let root = unique_temp_dir("rejects_duplicate_active_migration_prefixes")?;
519        fs::write(root.join("001_init.sql"), "CREATE TABLE t (id INTEGER);")?;
520        fs::write(root.join("001_indexes.sql"), "CREATE INDEX idx ON t(id);")?;
521
522        let err = discover_active_migrations(&root).expect_err("duplicate prefix should fail");
523
524        assert!(err.to_string().contains("duplicate active migration prefix"));
525        fs::remove_dir_all(root)?;
526        Ok(())
527    }
528
529    #[test]
530    fn rejects_duplicate_retired_migration_prefixes() -> Result<()> {
531        let root = unique_temp_dir("rejects_duplicate_retired_migration_prefixes")?;
532        fs::create_dir_all(root.join("retired"))?;
533        fs::write(root.join("retired").join("001_init.sql"), "CREATE TABLE t (id INTEGER);")?;
534        fs::write(root.join("retired").join("001_indexes.sql"), "CREATE INDEX idx ON t(id);")?;
535
536        let err = discover_retired_migrations(&root).expect_err("duplicate prefix should fail");
537
538        assert!(err.to_string().contains("duplicate retired migration prefix"));
539        fs::remove_dir_all(root)?;
540        Ok(())
541    }
542
543    #[test]
544    fn rejects_zero_migration_prefix() -> Result<()> {
545        let root = unique_temp_dir("rejects_zero_migration_prefix")?;
546        fs::write(root.join("000_init.sql"), "CREATE TABLE t (id INTEGER);")?;
547
548        let err = discover_active_migrations(&root).expect_err("zero prefix should fail");
549
550        assert!(err.to_string().contains("prefix must start at 001"));
551        fs::remove_dir_all(root)?;
552        Ok(())
553    }
554
555    #[test]
556    fn module_ident_preserves_repeated_separators() -> Result<()> {
557        assert_eq!(module_ident("001--backfill")?, "migration_001__backfill");
558        Ok(())
559    }
560
561    #[test]
562    fn migration_dir_path_resolves_relative_paths_from_manifest_dir() {
563        assert_eq!(
564            migration_dir_path(Path::new("migrations")),
565            build_rs::input::cargo_manifest_dir().join("migrations")
566        );
567
568        let absolute = env::temp_dir().join("miden-node-db-absolute-migrations");
569        assert_eq!(migration_dir_path(&absolute), absolute);
570    }
571
572    fn unique_temp_dir(name: &str) -> Result<PathBuf> {
573        let dir = env::temp_dir().join(format!("miden-node-db-{name}-{}", std::process::id()));
574        if dir.exists() {
575            fs::remove_dir_all(&dir)?;
576        }
577        fs::create_dir_all(&dir)?;
578        Ok(dir)
579    }
580}