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