soar_core/database/
migration.rs

1use include_dir::Dir;
2use rusqlite::Connection;
3
4use crate::{constants::NESTS_MIGRATIONS_DIR, error::SoarError, SoarResult};
5
6pub struct Migration {
7    version: i32,
8    sql: String,
9}
10
11pub struct MigrationManager {
12    conn: Connection,
13}
14
15#[derive(PartialEq)]
16pub enum DbKind {
17    Core,
18    Metadata,
19    Nest,
20}
21
22impl MigrationManager {
23    pub fn new(conn: Connection) -> rusqlite::Result<Self> {
24        Ok(Self { conn })
25    }
26
27    fn get_current_version(&self) -> rusqlite::Result<i32> {
28        self.conn
29            .query_row("PRAGMA user_version", [], |row| row.get(0))
30    }
31
32    fn run_migration(&mut self, migration: &Migration) -> rusqlite::Result<()> {
33        let tx = self.conn.transaction()?;
34
35        match tx.execute_batch(&migration.sql) {
36            Ok(_) => {
37                tx.pragma_update(None, "user_version", migration.version)?;
38                tx.commit()?;
39                Ok(())
40            }
41            Err(err) => Err(err),
42        }
43    }
44
45    fn load_migrations_from_dir(dir: Dir) -> SoarResult<Vec<Migration>> {
46        let mut migrations = Vec::new();
47
48        for entry in dir.files() {
49            let path = entry.path();
50
51            if path.extension().and_then(|s| s.to_str()) == Some("sql") {
52                let filename = path
53                    .file_stem()
54                    .and_then(|s| s.to_str())
55                    .ok_or_else(|| SoarError::Custom("Invalid filename".into()))?;
56
57                if !filename.starts_with('V') {
58                    continue;
59                }
60
61                let parts: Vec<&str> = filename[1..].splitn(2, '_').collect();
62                if parts.len() != 2 {
63                    continue;
64                }
65
66                let version = parts[0].parse::<i32>().map_err(|_| {
67                    SoarError::Custom(format!("Invalid version number in filename: {filename}"))
68                })?;
69
70                let sql = entry.contents_utf8().unwrap().to_string();
71
72                migrations.push(Migration { version, sql });
73            }
74        }
75
76        migrations.sort_by_key(|m| m.version);
77
78        Ok(migrations)
79    }
80
81    pub fn migrate_from_dir(&mut self, dir: Dir, db_kind: DbKind) -> SoarResult<()> {
82        let migrations = Self::load_migrations_from_dir(dir)?;
83        let current_version = self.get_current_version()?;
84
85        if db_kind == DbKind::Core && current_version > 0 && current_version < 5 {
86            return Err(SoarError::Custom(
87                "Database schema v{current_version} is too old for this soar release (requires v5).\n\
88                Please temporarily downgrade to v0.7.0 and run any normal command that invokes database\n\
89                (e.g. `soar ls` or `soar health`) once to let it auto-migrate.\n\
90                After that completes, upgrade back to the latest soar".into(),
91            ));
92        }
93
94        let pending: Vec<&Migration> = migrations
95            .iter()
96            .filter(|m| m.version > current_version)
97            .collect();
98
99        for migration in pending {
100            self.run_migration(migration)?;
101        }
102
103        Ok(())
104    }
105}
106
107pub fn run_nests(conn: Connection) -> SoarResult<()> {
108    let mut manager = MigrationManager::new(conn)?;
109    manager.migrate_from_dir(NESTS_MIGRATIONS_DIR, DbKind::Nest)?;
110    Ok(())
111}