Skip to main content

bhtune_db/
pool.rs

1//! Database connection setup: WAL journal mode, a busy timeout (so concurrent CLI + GUI
2//! access to the same file doesn't immediately error out with `SQLITE_BUSY`), and
3//! foreign-key enforcement (off by default in SQLite, and required here since every child
4//! table relies on `ON DELETE CASCADE`/`ON DELETE SET NULL`).
5
6use std::{path::Path, time::Duration};
7
8use sqlx::{
9    SqlitePool,
10    sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions},
11};
12
13use crate::error::{DbError, DbResult};
14
15/// How long a connection waits on a locked database before giving up (`SQLITE_BUSY`).
16/// Generous enough to ride out a retention sweep or a large history export running
17/// concurrently in another process, without silently hanging forever if something is
18/// actually deadlocked.
19const BUSY_TIMEOUT: Duration = Duration::from_secs(10);
20
21/// Opens (creating if missing) the SQLite database at `path`, applies the standard pragmas,
22/// and runs any pending migrations. This is the only supported way to get a `SqlitePool` in
23/// bhtune — every caller (CLI, web server, tests) goes through this function so the pragmas and
24/// migration state can never drift between adapters.
25pub async fn connect(path: &Path) -> DbResult<SqlitePool> {
26    let options = SqliteConnectOptions::new()
27        .filename(path)
28        .create_if_missing(true)
29        .journal_mode(SqliteJournalMode::Wal)
30        .busy_timeout(BUSY_TIMEOUT)
31        .foreign_keys(true);
32
33    let pool = SqlitePoolOptions::new()
34        .connect_with(options)
35        .await
36        .map_err(DbError::Connect)?;
37
38    run_migrations(&pool).await?;
39
40    Ok(pool)
41}
42
43/// Opens a private, in-process database for tests: same pragmas and migrations as
44/// [`connect`], but nothing touches disk.
45///
46/// Capped at one pooled connection deliberately — a pooled in-memory SQLite database is
47/// private per-connection, so a second connection would see an *empty* database rather than
48/// the same one. That cap is what makes "in-memory" behave like a single shared database
49/// across a test. WAL itself requires a real file (SQLite silently falls back to its
50/// in-memory journal mode here regardless of the request below), so WAL is only actually
51/// exercised by [`connect`]'s own tests, which use a real temp file.
52pub async fn connect_in_memory() -> DbResult<SqlitePool> {
53    let options = SqliteConnectOptions::new()
54        .filename(":memory:")
55        .journal_mode(SqliteJournalMode::Wal)
56        .busy_timeout(BUSY_TIMEOUT)
57        .foreign_keys(true);
58
59    let pool = SqlitePoolOptions::new()
60        .max_connections(1)
61        .connect_with(options)
62        .await
63        .map_err(DbError::Connect)?;
64
65    run_migrations(&pool).await?;
66
67    Ok(pool)
68}
69
70async fn run_migrations(pool: &SqlitePool) -> DbResult<()> {
71    sqlx::migrate!("./migrations")
72        .run(pool)
73        .await
74        .map_err(DbError::Migrate)
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[tokio::test]
82    async fn connect_creates_file_and_applies_wal_and_foreign_keys() {
83        let dir = tempfile::tempdir().unwrap();
84        let path = dir.path().join("bhtune.db");
85
86        let pool = connect(&path).await.unwrap();
87        assert!(path.exists());
88
89        let (journal_mode,): (String,) = sqlx::query_as("PRAGMA journal_mode")
90            .fetch_one(&pool)
91            .await
92            .unwrap();
93        assert_eq!(journal_mode, "wal");
94
95        let (foreign_keys,): (i64,) = sqlx::query_as("PRAGMA foreign_keys")
96            .fetch_one(&pool)
97            .await
98            .unwrap();
99        assert_eq!(foreign_keys, 1);
100    }
101
102    #[tokio::test]
103    async fn connect_is_idempotent_across_reopens() {
104        let dir = tempfile::tempdir().unwrap();
105        let path = dir.path().join("bhtune.db");
106
107        connect(&path).await.unwrap();
108        // Reopening an already-migrated database (the normal "app restart" case) must not
109        // fail or try to reapply migrations.
110        let pool = connect(&path).await.unwrap();
111
112        let (count,): (i64,) = sqlx::query_as(
113            "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'tune_runs'",
114        )
115        .fetch_one(&pool)
116        .await
117        .unwrap();
118        assert_eq!(count, 1);
119
120        let (migration_count,): (i64,) =
121            sqlx::query_as("SELECT COUNT(*) FROM _sqlx_migrations WHERE success = 1")
122                .fetch_one(&pool)
123                .await
124                .unwrap();
125        assert_eq!(migration_count, 1);
126    }
127
128    #[tokio::test]
129    async fn connect_in_memory_runs_the_final_schema_migration() {
130        let pool = connect_in_memory().await.unwrap();
131
132        let (migration_count,): (i64,) =
133            sqlx::query_as("SELECT COUNT(*) FROM _sqlx_migrations WHERE success = 1")
134                .fetch_one(&pool)
135                .await
136                .unwrap();
137        assert_eq!(migration_count, 1);
138
139        let (migration_version,): (i64,) =
140            sqlx::query_as("SELECT version FROM _sqlx_migrations WHERE success = 1")
141                .fetch_one(&pool)
142                .await
143                .unwrap();
144        assert_eq!(migration_version, 1);
145    }
146
147    #[tokio::test]
148    async fn fresh_schema_contains_final_history_demo_and_actuation_objects() {
149        let pool = connect_in_memory().await.unwrap();
150
151        for (object_type, object_name) in [
152            ("table", "demo_sessions"),
153            ("table", "tune_mv_actuations"),
154            ("index", "idx_tune_samples_run_time"),
155            ("index", "idx_tune_writes_run_written"),
156            ("index", "idx_tune_runs_demo_session"),
157            ("index", "idx_tune_runs_demo_session_outcome"),
158            ("trigger", "tune_runs_demo_session_insert"),
159            ("trigger", "tune_runs_demo_session_valid_insert"),
160            ("trigger", "tune_runs_demo_global_limit_insert"),
161            ("trigger", "tune_runs_demo_session_update"),
162            ("trigger", "tune_runs_demo_session_immutable"),
163        ] {
164            let count: i64 = sqlx::query_scalar(
165                "SELECT COUNT(*) FROM sqlite_master WHERE type = ? AND name = ?",
166            )
167            .bind(object_type)
168            .bind(object_name)
169            .fetch_one(&pool)
170            .await
171            .unwrap();
172            assert_eq!(
173                count, 1,
174                "expected final schema object {object_type} {object_name}"
175            );
176        }
177
178        let result_columns: Vec<String> =
179            sqlx::query_scalar("SELECT name FROM pragma_table_info('tune_results')")
180                .fetch_all(&pool)
181                .await
182                .unwrap();
183        for column in ["status", "invalid_reason"] {
184            assert!(
185                result_columns.iter().any(|name| name == column),
186                "checked-result column {column} is missing"
187            );
188        }
189    }
190}