soar_core/database/
migration.rs1use 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}