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