Skip to main content

cadmus_core/db/
mod.rs

1pub mod migrations;
2pub mod runtime;
3pub mod types;
4
5use anyhow::Error;
6use runtime::RUNTIME;
7use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
8use std::path::Path;
9use std::str::FromStr;
10
11/// The filename of the SQLite database used by Cadmus.
12pub const DB_FILENAME: &str = "cadmus.sqlite";
13
14/// Database handle providing synchronous API over async SQLx operations.
15/// Uses a bridge pattern with `RUNTIME.block_on()` to maintain synchronous interface
16/// for compatibility with existing single-threaded event loop.
17#[derive(Clone)]
18pub struct Database {
19    pool: SqlitePool,
20}
21
22impl std::fmt::Debug for Database {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        f.debug_struct("Database").finish()
25    }
26}
27
28impl Database {
29    /// Create a new database connection pool.
30    ///
31    /// Does not run any migrations — call [`Database::migrate`] after construction.
32    ///
33    /// # Arguments
34    /// * `path` - Path to the SQLite database file (will be created if it doesn't exist)
35    ///
36    /// # Returns
37    /// * `Ok(Database)` - Successfully connected database
38    /// * `Err(Error)` - Connection failure
39    #[cfg_attr(feature = "tracing", tracing::instrument(fields(db_path = %path.as_ref().display())))]
40    pub fn new<P: AsRef<Path> + std::fmt::Debug>(path: P) -> Result<Self, Error> {
41        let path = path.as_ref();
42        if let Some(parent) = path.parent()
43            && !parent.as_os_str().is_empty()
44        {
45            std::fs::create_dir_all(parent)?;
46        }
47
48        let path_str = path.display().to_string();
49
50        tracing::info!(db_path = %path_str, "connecting to database");
51
52        RUNTIME.block_on(async {
53            let options = SqliteConnectOptions::from_str(&format!("sqlite://{}", path_str))?
54                .create_if_missing(true)
55                .foreign_keys(true);
56
57            let pool = SqlitePoolOptions::new()
58                .max_connections(5)
59                .connect_with(options)
60                .await?;
61
62            tracing::info!(db_path = %path_str, "database connected");
63            Ok(Database { pool })
64        })
65    }
66
67    /// Close all connections in the pool, checkpointing WAL and releasing file handles.
68    ///
69    /// After calling this, no further database operations should be performed.
70    /// This must be called before unmounting the filesystem that contains the database file,
71    /// to ensure SQLite releases all file descriptors and flushes any pending WAL data.
72    pub fn close(&self) {
73        tracing::info!("closing database connection pool");
74        RUNTIME.block_on(async {
75            self.pool.close().await;
76        });
77        tracing::info!("database connection pool closed");
78    }
79
80    /// Returns a reference to the SQLite connection pool.
81    pub fn pool(&self) -> &SqlitePool {
82        &self.pool
83    }
84
85    /// Returns a `MigrationRunner` bound to this database's pool.
86    ///
87    /// Use this to execute all registered runtime migrations after the
88    /// database is initialized.
89    pub fn migration_runner(&self) -> migrations::MigrationRunner {
90        migrations::MigrationRunner::new(self.pool.clone())
91    }
92
93    /// Run all migrations: sqlx file migrations first, then runtime macro migrations.
94    ///
95    /// Must be called once after [`Database::new`] before the database is used.
96    /// Intended for use in the synchronous startup path.
97    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
98    pub fn migrate(&self) -> Result<(), Error> {
99        RUNTIME.block_on(async {
100            tracing::info!("running schema migrations");
101            #[cfg(feature = "tracing")]
102            let span = tracing::info_span!("sqlx_migrations").entered();
103            sqlx::migrate!("./migrations").run(&self.pool).await?;
104            #[cfg(feature = "tracing")]
105            span.exit();
106
107            tracing::info!("running runtime migrations");
108            self.migration_runner().run_all().await
109        })
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn test_database_creation() {
119        let db = Database::new(":memory:").expect("failed to create in-memory database");
120        db.migrate().expect("failed to run migrations");
121
122        RUNTIME.block_on(async {
123            let result: (i64,) = sqlx::query_as(
124                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='books'",
125            )
126            .fetch_one(&db.pool)
127            .await
128            .expect("failed to query sqlite_master");
129
130            assert_eq!(result.0, 1, "books table should exist after migrations");
131        });
132    }
133}