Skip to main content

miden_node_db/migration/
migrator.rs

1use std::path::Path;
2
3use anyhow::{Context, Result, bail, ensure};
4use rusqlite::{Connection, OpenFlags};
5
6use super::entry::{Migration, MigrationEntry, SqlMigration, apply_migration_and_verify_schema};
7use super::{MigratorBuilder, SchemaHash, SchemaHashes, schema};
8
9/// Bootstraps, migrates, and verifies versioned SQLite schemas.
10///
11/// A migrator is built from two ordered migration sets: retired SQL migrations followed by active
12/// migrations. Retired migrations are pure SQL snapshots of older active migrations whose schema we
13/// retain, but whose upgrade path we no longer want to support. Because that old migration path is
14/// intentionally unsupported, retired migrations are only applied when creating a new database whose
15/// `PRAGMA user_version` is zero. Existing databases are never allowed to run only part of the
16/// retired SQL set; once a database has a non-zero version, it must already be at or beyond the end
17/// of the retired migrations.
18///
19/// Active migrations run after the retired SQL set and can be pure SQL or Rust functions. Create or
20/// open a database with [`Self::create_database`] or [`Self::open_database`], then apply missing
21/// migrations through a target version with [`MigrationDatabase::migrate_to`]. Each migration runs
22/// in its own transaction and commits only after the resulting schema hash matches the hash
23/// computed by the builder.
24///
25/// Construct a migrator with [`Migrator::builder`] by pushing retired migrations first and active
26/// migrations second, or call [`Migrator::generate`] from a `build.rs` to generate that builder
27/// chain from a migration directory. Callers should snapshot [`Migrator::schema_hashes`] in tests
28/// so accidental schema changes are caught, especially when replacing active migrations with
29/// equivalent retired SQL.
30#[derive(Debug)]
31pub struct Migrator {
32    retired_migrations: Vec<SqlMigration>,
33    active_migrations: Vec<Migration>,
34    expected_schema_hashes: Vec<SchemaHash>,
35}
36
37impl Migrator {
38    /// Creates an empty migrator used as the builder's backing storage.
39    ///
40    /// Empty migrators are not exposed to callers; [`MigratorBuilder::build`] validates that at
41    /// least one migration was pushed before returning a migrator.
42    pub(super) fn empty() -> Self {
43        Self {
44            retired_migrations: Vec::new(),
45            active_migrations: Vec::new(),
46            expected_schema_hashes: Vec::new(),
47        }
48    }
49
50    /// Creates a migration builder backed by an in-memory SQLite database.
51    pub fn builder() -> Result<MigratorBuilder> {
52        MigratorBuilder::new()
53    }
54
55    /// Returns the version number that will be assigned to the next migration.
56    ///
57    /// Versions are one-based and follow insertion order across retired migrations first and then
58    /// active migrations.
59    pub(super) fn next_version(&self) -> usize {
60        self.expected_schema_hashes.len() + 1
61    }
62
63    /// Adds a retired SQL migration and the schema hash expected after it runs.
64    ///
65    /// This is used by [`MigratorBuilder`] after it has already applied the migration to its
66    /// in-memory reference database. The caller must ensure `schema_hash` is the hash of that
67    /// reference database after this migration. Retired migrations must be added before any active
68    /// migration.
69    pub(super) fn push_retired_unchecked(
70        &mut self,
71        migration: SqlMigration,
72        schema_hash: SchemaHash,
73    ) {
74        assert!(
75            self.active_migrations.is_empty(),
76            "cannot add retired migration after active migrations have started"
77        );
78        self.retired_migrations.push(migration);
79        self.expected_schema_hashes.push(schema_hash);
80    }
81
82    /// Adds an active migration and the schema hash expected after it runs.
83    ///
84    /// This is used by [`MigratorBuilder`] after it has already applied the migration to its
85    /// in-memory reference database. The caller must ensure `schema_hash` is the hash of that
86    /// reference database after this migration.
87    pub(super) fn push_active_unchecked(&mut self, migration: Migration, schema_hash: SchemaHash) {
88        self.active_migrations.push(migration);
89        self.expected_schema_hashes.push(schema_hash);
90    }
91
92    /// Validates invariants that must hold before a migrator can be returned to callers.
93    ///
94    /// A migrator must contain at least one migration and must have exactly one expected schema
95    /// hash for each migration.
96    pub(super) fn validate(&self) -> Result<()> {
97        let migration_count = self.retired_migrations.len() + self.active_migrations.len();
98        ensure!(
99            !self.expected_schema_hashes.is_empty(),
100            "cannot build migrator without migrations"
101        );
102        ensure!(
103            self.expected_schema_hashes.len() == migration_count,
104            "migrator schema hash count {} must match migration count {migration_count}",
105            self.expected_schema_hashes.len()
106        );
107        Ok(())
108    }
109
110    /// Returns the schema hashes expected after each migration.
111    ///
112    /// Callers can use these hashes in tests when retiring active migrations into SQL: the
113    /// replacement SQL should produce the same hash at the same migration index.
114    pub fn schema_hashes(&self) -> SchemaHashes<'_> {
115        SchemaHashes(&self.expected_schema_hashes)
116    }
117
118    /// Returns the latest schema version supported by this migrator.
119    pub fn latest_version(&self) -> usize {
120        self.expected_schema_hashes.len()
121    }
122
123    /// Creates an empty database and returns a handle for applying migrations.
124    ///
125    /// The database file must not already exist. No migrations run until
126    /// [`MigrationDatabase::migrate_to`] is called.
127    pub fn create_database(
128        &self,
129        database_filepath: impl AsRef<Path>,
130    ) -> Result<MigrationDatabase<'_>> {
131        let database_filepath = database_filepath.as_ref();
132        ensure!(
133            !fs_err::exists(database_filepath).with_context(|| {
134                format!("failed to check database path {}", database_filepath.display())
135            })?,
136            "database already exists: {}",
137            database_filepath.display()
138        );
139
140        let conn = Connection::open_with_flags(
141            database_filepath,
142            OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE,
143        )
144        .with_context(|| format!("failed to create database {}", database_filepath.display()))?;
145
146        Ok(MigrationDatabase { migrator: self, conn })
147    }
148
149    /// Opens an existing bootstrapped database and returns a handle for applying migrations.
150    ///
151    /// The database file must already exist and have a non-zero schema version. The schema is
152    /// verified when [`MigrationDatabase::migrate_to`] is called.
153    pub fn open_database(
154        &self,
155        database_filepath: impl AsRef<Path>,
156    ) -> Result<MigrationDatabase<'_>> {
157        let database_filepath = existing_database_path(database_filepath.as_ref())?;
158        let conn =
159            Connection::open_with_flags(database_filepath, OpenFlags::SQLITE_OPEN_READ_WRITE)
160                .with_context(|| {
161                    format!("failed to open database {}", database_filepath.display())
162                })?;
163        let current_version =
164            schema::get_version(&conn).context("failed to read database version")?;
165        ensure!(current_version > 0, "database has not been bootstrapped; run bootstrap first");
166
167        Ok(MigrationDatabase { migrator: self, conn })
168    }
169
170    /// Verifies that the database at `database_filepath` is already at the latest schema version.
171    ///
172    /// This does not create the database file and does not apply any missing migrations. Callers
173    /// should use this on normal startup paths which must reject databases that have not been
174    /// explicitly bootstrapped or migrated.
175    ///
176    /// This checks SQLite's `PRAGMA user_version` and verifies that the current schema hash matches
177    /// the expected hash for that version.
178    pub fn verify_latest_schema(&self, database_filepath: impl AsRef<Path>) -> Result<()> {
179        let database_filepath = existing_database_path(database_filepath.as_ref())?;
180        let conn =
181            Connection::open_with_flags(database_filepath, OpenFlags::SQLITE_OPEN_READ_WRITE)
182                .with_context(|| {
183                    format!("failed to open existing database {}", database_filepath.display())
184                })?;
185
186        self.verify_latest_connection_schema(&conn)
187    }
188
189    fn validate_target_version(&self, target_version: usize) -> Result<()> {
190        let latest_version = self.expected_schema_hashes.len();
191        ensure!(
192            (1..=latest_version).contains(&target_version),
193            "target version {target_version} must be between 1 and {latest_version}"
194        );
195        ensure!(
196            target_version >= self.retired_migrations.len(),
197            "target version {target_version} is inside the retired migration range"
198        );
199        Ok(())
200    }
201
202    fn apply_missing_migrations(
203        &self,
204        conn: &mut Connection,
205        current_version: usize,
206        target_version: usize,
207    ) -> Result<()> {
208        let retired_versions = self.retired_migrations.len();
209
210        let mut applied_version = current_version;
211        if applied_version == 0 {
212            for (idx, migration) in self.retired_migrations.iter().enumerate() {
213                let version = idx + 1;
214                self.apply_migration(conn, version, migration)?;
215                applied_version = version;
216            }
217        }
218
219        let active_start = applied_version.saturating_sub(retired_versions);
220        for (idx, migration) in self
221            .active_migrations
222            .iter()
223            .enumerate()
224            .take(target_version - retired_versions)
225            .skip(active_start)
226        {
227            let version = retired_versions + idx + 1;
228            self.apply_migration(conn, version, migration)?;
229        }
230
231        Ok(())
232    }
233
234    fn verify_latest_connection_schema(&self, conn: &Connection) -> Result<()> {
235        let current_version = self.version_check(conn)?;
236        let total_versions = self.expected_schema_hashes.len();
237
238        ensure!(
239            current_version == total_versions,
240            "database version {current_version} is older than migrator version {total_versions}; \
241             run the migrate command first"
242        );
243
244        Ok(())
245    }
246
247    /// Reads and validates the database version before any missing migrations are applied.
248    ///
249    /// This rejects databases newer than the migrator, databases inside the retired migration
250    /// range, and databases whose current schema hash does not match the expected hash for their
251    /// current version.
252    fn version_check(&self, conn: &Connection) -> Result<usize> {
253        let current_version =
254            schema::get_version(conn).context("failed to read database version")?;
255        let total_versions = self.expected_schema_hashes.len();
256
257        ensure!(
258            current_version <= total_versions,
259            "database version {current_version} is newer than migrator version {total_versions}"
260        );
261
262        let retired_versions = self.retired_migrations.len();
263        if current_version > 0 && current_version < retired_versions {
264            let name = self.migration_name(current_version).unwrap_or("<unknown>");
265            bail!(
266                "database version {current_version} \"{name}\" is inside the retired migration \
267                 range; retired migrations can only initialize new databases"
268            );
269        }
270
271        if current_version > 0 {
272            self.verify_current_schema(conn, current_version)?;
273        }
274
275        Ok(current_version)
276    }
277
278    /// Applies one migration transaction and verifies its resulting schema hash.
279    fn apply_migration(
280        &self,
281        conn: &mut Connection,
282        version: usize,
283        migration: &impl MigrationEntry,
284    ) -> Result<()> {
285        let name = migration.name();
286        let expected = self.expected_schema_hashes[version - 1];
287        apply_migration_and_verify_schema(conn, version, migration, expected)
288            .with_context(|| format!("failed to apply migration {version} \"{name}\""))
289    }
290
291    /// Verifies that an existing database still matches the schema hash for its `user_version`.
292    fn verify_current_schema(&self, conn: &Connection, version: usize) -> Result<()> {
293        let name = self.migration_name(version).unwrap_or("<unknown>");
294        let expected = self.expected_schema_hashes[version - 1];
295        let actual = SchemaHash::new(conn).with_context(|| {
296            format!("failed to compute schema hash at database version {version} \"{name}\"")
297        })?;
298
299        ensure!(
300            actual == expected,
301            "schema hash mismatch at database version {version} \"{name}\": expected {expected}, \
302             got {actual}"
303        );
304        Ok(())
305    }
306
307    /// Returns the migration name for a one-based migration version.
308    fn migration_name(&self, version: usize) -> Option<&'static str> {
309        if version == 0 {
310            return None;
311        }
312
313        if version <= self.retired_migrations.len() {
314            return Some(self.retired_migrations[version - 1].name());
315        }
316
317        self.active_migrations
318            .get(version - self.retired_migrations.len() - 1)
319            .map(MigrationEntry::name)
320    }
321}
322
323/// A database connection on which a [`Migrator`] can apply pending migrations.
324///
325/// Obtain a handle with [`Migrator::create_database`] for a new database or
326/// [`Migrator::open_database`] for an existing bootstrapped database.
327#[derive(Debug)]
328pub struct MigrationDatabase<'a> {
329    migrator: &'a Migrator,
330    conn: Connection,
331}
332
333impl MigrationDatabase<'_> {
334    /// Applies missing migrations through `target_version`, inclusive.
335    ///
336    /// Versions are one-based. The range starts after the database's current version, whose schema
337    /// is verified even if it is already at the target. Targets below the current version, inside
338    /// the retired migration range, or beyond the latest version are rejected. Each migration runs
339    /// in its own transaction and commits only after its schema hash is verified.
340    pub fn migrate_to(&mut self, target_version: usize) -> Result<()> {
341        self.migrator.validate_target_version(target_version)?;
342        let current_version = self.migrator.version_check(&self.conn)?;
343        ensure!(
344            target_version >= current_version,
345            "target version {target_version} is older than database version {current_version}"
346        );
347        self.migrator
348            .apply_missing_migrations(&mut self.conn, current_version, target_version)
349    }
350}
351
352fn existing_database_path(database_filepath: &Path) -> Result<&Path> {
353    let metadata = fs_err::metadata(database_filepath)
354        .with_context(|| format!("failed to read database {}", database_filepath.display()))?;
355    ensure!(
356        metadata.is_file(),
357        "database path is not a file: {}",
358        database_filepath.display()
359    );
360    Ok(database_filepath)
361}
362
363#[cfg(test)]
364mod tests {
365    use std::path::{Path, PathBuf};
366
367    use anyhow::Result;
368    use rusqlite::{Connection, Transaction};
369
370    use super::super::{Migrator, schema};
371
372    fn add_items_index(tx: &Transaction<'_>) -> Result<()> {
373        tx.execute_batch("CREATE INDEX idx_items_value ON items(value);")?;
374        Ok(())
375    }
376
377    fn add_item_height(tx: &Transaction<'_>) -> Result<()> {
378        tx.execute_batch("ALTER TABLE items ADD COLUMN height INTEGER;")?;
379        Ok(())
380    }
381
382    fn create_extra_table_when_items_exist(tx: &Transaction<'_>) -> Result<()> {
383        let item_count: i64 = tx.query_row("SELECT COUNT(*) FROM items", [], |row| row.get(0))?;
384        if item_count > 0 {
385            tx.execute_batch("CREATE TABLE unexpected (id INTEGER PRIMARY KEY);")?;
386        }
387        Ok(())
388    }
389
390    fn create_items_table(tx: &Transaction<'_>) -> Result<()> {
391        tx.execute_batch("CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT);")?;
392        Ok(())
393    }
394
395    fn object_exists(conn: &Connection, name: &str) -> Result<bool> {
396        let exists = conn.query_row(
397            "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name = ?1)",
398            [name],
399            |row| row.get::<_, bool>(0),
400        )?;
401        Ok(exists)
402    }
403
404    struct TestDatabase {
405        path: PathBuf,
406    }
407
408    impl TestDatabase {
409        fn new(name: &str) -> Self {
410            let path = std::env::temp_dir()
411                .join(format!("miden-node-db-migrator-{name}-{}.sqlite3", std::process::id()));
412            let db = Self { path };
413            db.remove_files();
414            db
415        }
416
417        fn path(&self) -> &Path {
418            &self.path
419        }
420
421        fn open(&self) -> Result<Connection> {
422            Connection::open(&self.path).map_err(Into::into)
423        }
424
425        fn remove_files(&self) {
426            let _ = fs_err::remove_file(&self.path);
427            let _ = fs_err::remove_file(self.path.with_extension("sqlite3-wal"));
428            let _ = fs_err::remove_file(self.path.with_extension("sqlite3-shm"));
429        }
430    }
431
432    impl Drop for TestDatabase {
433        fn drop(&mut self) {
434            self.remove_files();
435        }
436    }
437
438    #[test]
439    fn applies_only_migrations_through_target_version() -> Result<()> {
440        let migrator = Migrator::builder()?
441            .push_retired(
442                "create items",
443                "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT);",
444            )?
445            .push_code("index item values", add_items_index)?
446            .push_code("add item height", add_item_height)?
447            .build()?;
448        let db = TestDatabase::new("applies_only_migrations_through_target_version");
449
450        migrator.create_database(db.path())?.migrate_to(1)?;
451        let conn = db.open()?;
452        assert_eq!(schema::get_version(&conn)?, 1);
453        assert!(!object_exists(&conn, "idx_items_value")?);
454        conn.execute("INSERT INTO items (id, value) VALUES (1, 'preserved')", [])?;
455
456        let mut database = migrator.open_database(db.path())?;
457        assert_eq!(schema::get_version(&conn)?, 1);
458        assert!(!object_exists(&conn, "idx_items_value")?);
459        database.migrate_to(2)?;
460        database.migrate_to(2)?;
461        assert_eq!(schema::get_version(&conn)?, 2);
462        assert!(object_exists(&conn, "idx_items_value")?);
463        assert!(conn.prepare("SELECT height FROM items").is_err());
464        assert_eq!(
465            conn.query_row("SELECT value FROM items WHERE id = 1", [], |row| row
466                .get::<_, String>(0))?,
467            "preserved"
468        );
469
470        migrator.open_database(db.path())?.migrate_to(migrator.latest_version())?;
471        assert_eq!(schema::get_version(&conn)?, 3);
472        conn.execute("UPDATE items SET height = 10 WHERE id = 1", [])?;
473        migrator.verify_latest_schema(db.path())?;
474        Ok(())
475    }
476
477    #[test]
478    fn rejects_invalid_targets_without_changing_database() -> Result<()> {
479        let migrator = Migrator::builder()?
480            .push_retired(
481                "create items",
482                "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT);",
483            )?
484            .push_retired("create notes", "CREATE TABLE notes (id INTEGER PRIMARY KEY);")?
485            .push_code("index item values", add_items_index)?
486            .push_code("add item height", add_item_height)?
487            .build()?;
488        let db = TestDatabase::new("rejects_invalid_targets_without_changing_database");
489
490        let mut database = migrator.create_database(db.path())?;
491        let conn = db.open()?;
492        assert_eq!(schema::get_version(&conn)?, 0);
493        assert!(!object_exists(&conn, "items")?);
494        for target in [0, 1, 5] {
495            assert!(database.migrate_to(target).is_err());
496            assert_eq!(schema::get_version(&conn)?, 0);
497            assert!(!object_exists(&conn, "items")?);
498        }
499
500        database.migrate_to(3)?;
501        for target in [0, 1, 2, 5] {
502            assert!(migrator.open_database(db.path())?.migrate_to(target).is_err());
503            assert_eq!(schema::get_version(&conn)?, 3);
504            assert!(object_exists(&conn, "idx_items_value")?);
505            assert!(conn.prepare("SELECT height FROM items").is_err());
506        }
507        Ok(())
508    }
509
510    #[test]
511    fn bounded_migration_rejects_schema_drift_even_at_target() -> Result<()> {
512        let migrator = Migrator::builder()?
513            .push_code("create items", create_items_table)?
514            .push_code("index item values", add_items_index)?
515            .build()?;
516        let db = TestDatabase::new("bounded_migration_rejects_schema_drift_even_at_target");
517        migrator.create_database(db.path())?.migrate_to(1)?;
518        let conn = db.open()?;
519        conn.execute_batch("CREATE TABLE unexpected (id INTEGER PRIMARY KEY);")?;
520
521        for target in [1, 2] {
522            let err = migrator
523                .open_database(db.path())?
524                .migrate_to(target)
525                .expect_err("schema drift should fail");
526            assert!(err.to_string().contains("schema hash mismatch"));
527            assert_eq!(schema::get_version(&conn)?, 1);
528            assert!(!object_exists(&conn, "idx_items_value")?);
529        }
530        Ok(())
531    }
532
533    #[test]
534    fn bootstraps_new_database_through_retired_and_code() -> Result<()> {
535        let migrator = Migrator::builder()?
536            .push_retired(
537                "create items",
538                "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT);",
539            )?
540            .push_code("add item height", add_item_height)?
541            .build()?;
542
543        let db = TestDatabase::new("bootstraps_new_database_through_retired_and_code");
544        migrator.create_database(db.path())?.migrate_to(migrator.latest_version())?;
545
546        let conn = db.open()?;
547        assert_eq!(schema::get_version(&conn)?, 2);
548        conn.execute("INSERT INTO items (id, value, height) VALUES (1, 'a', 10)", [])?;
549        Ok(())
550    }
551
552    #[test]
553    fn bootstraps_new_database_with_code_only_migration() -> Result<()> {
554        let migrator =
555            Migrator::builder()?.push_code("create items", create_items_table)?.build()?;
556
557        let db = TestDatabase::new("bootstraps_new_database_with_code_only_migration");
558        migrator.create_database(db.path())?.migrate_to(migrator.latest_version())?;
559
560        let conn = db.open()?;
561        assert_eq!(schema::get_version(&conn)?, 1);
562        conn.execute("INSERT INTO items (id, value) VALUES (1, 'a')", [])?;
563        Ok(())
564    }
565
566    #[test]
567    fn bootstraps_new_database_with_sql_only_migration() -> Result<()> {
568        let migrator = Migrator::builder()?
569            .push_sql("create items", "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT);")?
570            .build()?;
571
572        let db = TestDatabase::new("bootstraps_new_database_with_sql_only_migration");
573        migrator.create_database(db.path())?.migrate_to(migrator.latest_version())?;
574
575        let conn = db.open()?;
576        assert_eq!(schema::get_version(&conn)?, 1);
577        conn.execute("INSERT INTO items (id, value) VALUES (1, 'a')", [])?;
578        Ok(())
579    }
580
581    #[test]
582    fn applies_missing_code_migrations_to_existing_database() -> Result<()> {
583        let migrator = Migrator::builder()?
584            .push_retired(
585                "create items",
586                "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT);",
587            )?
588            .push_code("index item values", add_items_index)?
589            .build()?;
590
591        let db = TestDatabase::new("applies_missing_code_migrations_to_existing_database");
592        {
593            let conn = db.open()?;
594            conn.execute_batch(
595                "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT);
596                 PRAGMA user_version = 1;",
597            )?;
598        }
599
600        migrator.open_database(db.path())?.migrate_to(migrator.latest_version())?;
601
602        let conn = db.open()?;
603        assert_eq!(schema::get_version(&conn)?, 2);
604        assert!(object_exists(&conn, "idx_items_value")?);
605        Ok(())
606    }
607
608    #[test]
609    fn create_database_rejects_existing_database_file() -> Result<()> {
610        let migrator = Migrator::builder()?
611            .push_sql("create items", "CREATE TABLE items (id INTEGER PRIMARY KEY);")?
612            .build()?;
613
614        let db = TestDatabase::new("create_database_rejects_existing_database_file");
615        {
616            let _conn = db.open()?;
617        }
618
619        let err = migrator.create_database(db.path()).expect_err("existing database should fail");
620        assert!(err.to_string().contains("database already exists"));
621        Ok(())
622    }
623
624    #[test]
625    fn open_database_rejects_missing_database() -> Result<()> {
626        let migrator = Migrator::builder()?
627            .push_sql("create items", "CREATE TABLE items (id INTEGER PRIMARY KEY);")?
628            .build()?;
629
630        let db = TestDatabase::new("migrate_rejects_missing_database");
631
632        let err = migrator.open_database(db.path()).expect_err("missing database should fail");
633        assert!(err.to_string().contains("failed to read database"));
634        assert!(!db.path().exists());
635        Ok(())
636    }
637
638    #[test]
639    fn open_database_rejects_unbootstrapped_database() -> Result<()> {
640        let migrator = Migrator::builder()?
641            .push_sql("create items", "CREATE TABLE items (id INTEGER PRIMARY KEY);")?
642            .build()?;
643
644        let db = TestDatabase::new("migrate_rejects_unbootstrapped_database");
645        {
646            let _conn = db.open()?;
647        }
648
649        let err = migrator
650            .open_database(db.path())
651            .expect_err("unbootstrapped database should fail");
652        assert!(err.to_string().contains("database has not been bootstrapped"));
653        Ok(())
654    }
655
656    #[test]
657    fn rejects_existing_database_inside_retired_migration_range() -> Result<()> {
658        let migrator = Migrator::builder()?
659            .push_retired("create items", "CREATE TABLE items (id INTEGER PRIMARY KEY);")?
660            .push_retired("create notes", "CREATE TABLE notes (id INTEGER PRIMARY KEY);")?
661            .build()?;
662
663        let db = TestDatabase::new("rejects_existing_database_inside_retired_migration_range");
664        {
665            let conn = db.open()?;
666            conn.execute_batch(
667                "CREATE TABLE items (id INTEGER PRIMARY KEY);
668                 PRAGMA user_version = 1;",
669            )?;
670        }
671
672        let err = migrator
673            .open_database(db.path())?
674            .migrate_to(migrator.latest_version())
675            .expect_err("migration should fail");
676        assert!(err.to_string().contains("inside the retired migration range"));
677        Ok(())
678    }
679
680    #[test]
681    fn verifies_current_schema_before_applying_missing_migrations() -> Result<()> {
682        let migrator = Migrator::builder()?
683            .push_retired("create items", "CREATE TABLE items (id INTEGER PRIMARY KEY);")?
684            .build()?;
685
686        let db = TestDatabase::new("verifies_current_schema_before_applying_missing_migrations");
687        migrator.create_database(db.path())?.migrate_to(migrator.latest_version())?;
688        {
689            let conn = db.open()?;
690            conn.execute_batch("CREATE TABLE tampered (id INTEGER PRIMARY KEY);")?;
691        }
692
693        let err = migrator
694            .open_database(db.path())?
695            .migrate_to(migrator.latest_version())
696            .expect_err("migration should fail");
697        assert!(err.to_string().contains("schema hash mismatch at database version 1"));
698        Ok(())
699    }
700
701    #[test]
702    fn rolls_back_code_migration_when_schema_hash_mismatches() -> Result<()> {
703        let migrator = Migrator::builder()?
704            .push_retired("create items", "CREATE TABLE items (id INTEGER PRIMARY KEY);")?
705            .push_code("conditionally create extra", create_extra_table_when_items_exist)?
706            .build()?;
707
708        let db = TestDatabase::new("rolls_back_code_migration_when_schema_hash_mismatches");
709        {
710            let conn = db.open()?;
711            conn.execute_batch(
712                "CREATE TABLE items (id INTEGER PRIMARY KEY);
713                 INSERT INTO items (id) VALUES (1);
714                 PRAGMA user_version = 1;",
715            )?;
716        }
717
718        let err = migrator
719            .open_database(db.path())?
720            .migrate_to(migrator.latest_version())
721            .expect_err("migration should fail");
722        assert!(err.to_string().contains("failed to apply migration 2"));
723        assert!(err.chain().any(|cause| cause.to_string().contains("schema hash mismatch")));
724
725        let conn = db.open()?;
726        assert_eq!(schema::get_version(&conn)?, 1);
727        assert!(!object_exists(&conn, "unexpected")?);
728        Ok(())
729    }
730
731    #[test]
732    fn verify_latest_schema_accepts_current_database() -> Result<()> {
733        let migrator = Migrator::builder()?
734            .push_sql("create items", "CREATE TABLE items (id INTEGER PRIMARY KEY);")?
735            .build()?;
736
737        let db = TestDatabase::new("verify_latest_schema_accepts_current_database");
738        migrator.create_database(db.path())?.migrate_to(migrator.latest_version())?;
739
740        migrator.verify_latest_schema(db.path())?;
741        Ok(())
742    }
743
744    #[test]
745    fn verify_latest_schema_rejects_schema_hash_mismatch() -> Result<()> {
746        let migrator = Migrator::builder()?
747            .push_sql("create items", "CREATE TABLE items (id INTEGER PRIMARY KEY);")?
748            .build()?;
749
750        let db = TestDatabase::new("verify_latest_schema_rejects_schema_hash_mismatch");
751        {
752            let conn = db.open()?;
753            conn.execute_batch(
754                "CREATE TABLE different (id INTEGER PRIMARY KEY);
755                 PRAGMA user_version = 1;",
756            )?;
757        }
758
759        let err = migrator.verify_latest_schema(db.path()).expect_err("schema drift should fail");
760        assert!(err.to_string().contains("schema hash mismatch"));
761        Ok(())
762    }
763
764    #[test]
765    fn verify_latest_schema_rejects_missing_migrations_without_applying_them() -> Result<()> {
766        let migrator = Migrator::builder()?
767            .push_sql("create items", "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT);")?
768            .push_code("index item values", add_items_index)?
769            .build()?;
770
771        let db = TestDatabase::new("verify_latest_schema_rejects_missing_migrations");
772        {
773            let conn = db.open()?;
774            conn.execute_batch(
775                "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT);
776                 PRAGMA user_version = 1;",
777            )?;
778        }
779
780        let err = migrator.verify_latest_schema(db.path()).expect_err("old database should fail");
781        assert!(err.to_string().contains("run the migrate command first"));
782
783        let conn = db.open()?;
784        assert_eq!(schema::get_version(&conn)?, 1);
785        assert!(!object_exists(&conn, "idx_items_value")?);
786        Ok(())
787    }
788
789    #[test]
790    fn verify_latest_schema_rejects_missing_database_without_creating_it() -> Result<()> {
791        let migrator = Migrator::builder()?
792            .push_sql("create items", "CREATE TABLE items (id INTEGER PRIMARY KEY);")?
793            .build()?;
794
795        let db = TestDatabase::new("verify_latest_schema_rejects_missing_database");
796
797        let err = migrator
798            .verify_latest_schema(db.path())
799            .expect_err("missing database should fail");
800        assert!(err.to_string().contains("failed to read database"));
801        assert!(!db.path().exists());
802        Ok(())
803    }
804}