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::{
7    path::{Path, PathBuf},
8    time::Duration,
9};
10
11use sqlx::{
12    Row, SqlitePool,
13    sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions},
14};
15
16use crate::error::{DbError, DbResult};
17
18/// How long a connection waits on a locked database before giving up (`SQLITE_BUSY`).
19/// Generous enough to ride out a retention sweep or a large history export running
20/// concurrently in another process, without silently hanging forever if something is
21/// actually deadlocked.
22const BUSY_TIMEOUT: Duration = Duration::from_secs(10);
23
24static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
25
26/// Opens (creating if missing) the SQLite database at `path`, applies the standard pragmas,
27/// rejects incompatible migration histories, and runs pending migrations. This is
28/// the only supported way to get a `SqlitePool` in bhtune — every caller (CLI, web server,
29/// tests) goes through this function so the pragmas and migration state can never drift
30/// between adapters.
31pub async fn connect(path: &Path) -> DbResult<SqlitePool> {
32    let options = SqliteConnectOptions::new()
33        .filename(path)
34        .create_if_missing(true)
35        .journal_mode(SqliteJournalMode::Wal)
36        .busy_timeout(BUSY_TIMEOUT)
37        .foreign_keys(true);
38
39    let pool = SqlitePoolOptions::new()
40        .connect_with(options)
41        .await
42        .map_err(DbError::Connect)?;
43
44    run_migrations(&pool, path).await?;
45
46    Ok(pool)
47}
48
49/// Opens an existing SQLite database without creating it or applying migrations.
50///
51/// Incompatible migration history is rejected before returning the pool. This is for commands
52/// that need a snapshot of persisted settings but must not mutate the database as a side effect.
53/// Callers must not use the returned pool for writes; SQLite opens it in read-only mode and
54/// rejects them.
55pub async fn connect_read_only(path: &Path) -> DbResult<SqlitePool> {
56    let options = SqliteConnectOptions::new()
57        .filename(path)
58        .read_only(true)
59        .busy_timeout(BUSY_TIMEOUT)
60        .foreign_keys(true);
61
62    let pool = SqlitePoolOptions::new()
63        .max_connections(1)
64        .connect_with(options)
65        .await
66        .map_err(DbError::Connect)?;
67
68    if let Err(error) = validate_migration_history(&pool, path).await {
69        pool.close().await;
70        return Err(error);
71    }
72
73    Ok(pool)
74}
75
76/// Returns the filesystem path attached to SQLite's `main` database, or
77/// [`DbError::DatabasePathUnavailable`] for private in-memory databases. Live controller
78/// mutations require this stable identity to acquire process-shared ownership.
79/// Callers canonicalize the returned path before comparing filesystem aliases.
80pub async fn database_path(pool: &SqlitePool) -> DbResult<PathBuf> {
81    let rows = sqlx::query("PRAGMA database_list")
82        .fetch_all(pool)
83        .await
84        .map_err(DbError::Query)?;
85    let entries = rows
86        .into_iter()
87        .map(|row| {
88            Ok((
89                row.try_get("name").map_err(DbError::Query)?,
90                row.try_get("file").map_err(DbError::Query)?,
91            ))
92        })
93        .collect::<DbResult<Vec<_>>>()?;
94    database_path_from_entries(entries)
95}
96
97fn database_path_from_entries(
98    entries: impl IntoIterator<Item = (String, String)>,
99) -> DbResult<PathBuf> {
100    for (name, path) in entries {
101        if name == "main" {
102            if path.is_empty() {
103                return Err(DbError::DatabasePathUnavailable);
104            }
105            return Ok(PathBuf::from(path));
106        }
107    }
108    Err(DbError::DatabasePathUnavailable)
109}
110
111/// Opens a private, in-process database for tests: same pragmas and migrations as
112/// [`connect`], but nothing touches disk.
113///
114/// Capped at one pooled connection deliberately — a pooled in-memory SQLite database is
115/// private per-connection, so a second connection would see an *empty* database rather than
116/// the same one. That cap is what makes "in-memory" behave like a single shared database
117/// across a test. WAL itself requires a real file (SQLite silently falls back to its
118/// in-memory journal mode here regardless of the request below), so WAL is only actually
119/// exercised by [`connect`]'s own tests, which use a real temp file.
120pub async fn connect_in_memory() -> DbResult<SqlitePool> {
121    let options = SqliteConnectOptions::new()
122        .filename(":memory:")
123        .journal_mode(SqliteJournalMode::Wal)
124        .busy_timeout(BUSY_TIMEOUT)
125        .foreign_keys(true);
126
127    let pool = SqlitePoolOptions::new()
128        .max_connections(1)
129        .connect_with(options)
130        .await
131        .map_err(DbError::Connect)?;
132
133    run_migrations(&pool, Path::new(":memory:")).await?;
134
135    Ok(pool)
136}
137
138async fn run_migrations(pool: &SqlitePool, path: &Path) -> DbResult<()> {
139    validate_migration_history(pool, path).await?;
140    MIGRATOR.run(pool).await.map_err(DbError::Migrate)
141}
142
143pub(crate) async fn validate_migration_history(pool: &SqlitePool, path: &Path) -> DbResult<()> {
144    let has_migration_table: i64 = sqlx::query_scalar(
145        "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = '_sqlx_migrations')",
146    )
147    .fetch_one(pool)
148    .await
149    .map_err(DbError::Query)?;
150    if has_migration_table == 0 {
151        return Ok(());
152    }
153
154    for row in sqlx::query("SELECT version, checksum FROM _sqlx_migrations ORDER BY version")
155        .fetch_all(pool)
156        .await
157        .map_err(DbError::Query)?
158    {
159        let version: i64 = row.try_get("version").map_err(DbError::Query)?;
160        let checksum: Vec<u8> = row.try_get("checksum").map_err(DbError::Query)?;
161        if !MIGRATOR.iter().any(|migration| {
162            migration.version == version && migration.checksum.as_ref() == checksum.as_slice()
163        }) {
164            return Err(DbError::IncompatibleMigrationHistory(path.to_path_buf()));
165        }
166    }
167
168    Ok(())
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[tokio::test]
176    async fn connect_creates_file_and_applies_wal_and_foreign_keys() {
177        let dir = tempfile::tempdir().unwrap();
178        let path = dir.path().join("bhtune.db");
179
180        let pool = connect(&path).await.unwrap();
181        assert!(path.exists());
182
183        let (journal_mode,): (String,) = sqlx::query_as("PRAGMA journal_mode")
184            .fetch_one(&pool)
185            .await
186            .unwrap();
187        assert_eq!(journal_mode, "wal");
188
189        let (foreign_keys,): (i64,) = sqlx::query_as("PRAGMA foreign_keys")
190            .fetch_one(&pool)
191            .await
192            .unwrap();
193        assert_eq!(foreign_keys, 1);
194    }
195
196    #[tokio::test]
197    async fn connect_is_idempotent_across_reopens() {
198        let dir = tempfile::tempdir().unwrap();
199        let path = dir.path().join("bhtune.db");
200
201        connect(&path).await.unwrap();
202        // Reopening an already-migrated database (the normal "app restart" case) must not
203        // fail or try to reapply migrations.
204        let pool = connect(&path).await.unwrap();
205
206        let (count,): (i64,) = sqlx::query_as(
207            "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'tune_runs'",
208        )
209        .fetch_one(&pool)
210        .await
211        .unwrap();
212        assert_eq!(count, 1);
213
214        let (migration_count,): (i64,) =
215            sqlx::query_as("SELECT COUNT(*) FROM _sqlx_migrations WHERE success = 1")
216                .fetch_one(&pool)
217                .await
218                .unwrap();
219        assert_eq!(migration_count, 1);
220    }
221
222    #[tokio::test]
223    async fn connect_read_only_does_not_create_a_missing_database() {
224        let dir = tempfile::tempdir().unwrap();
225        let path = dir.path().join("missing.db");
226
227        assert!(connect_read_only(&path).await.is_err());
228        assert!(!path.exists());
229    }
230
231    #[tokio::test]
232    async fn database_path_returns_the_main_database_file() {
233        let dir = tempfile::tempdir().unwrap();
234        let path = dir.path().join("bhtune.db");
235        let pool = connect(&path).await.unwrap();
236
237        assert_eq!(
238            std::fs::canonicalize(database_path(&pool).await.unwrap()).unwrap(),
239            std::fs::canonicalize(path).unwrap()
240        );
241    }
242
243    #[tokio::test]
244    async fn database_path_rejects_in_memory_databases() {
245        let pool = connect_in_memory().await.unwrap();
246
247        assert!(matches!(
248            database_path(&pool).await,
249            Err(DbError::DatabasePathUnavailable)
250        ));
251    }
252
253    #[test]
254    fn database_path_rejects_a_listing_without_the_main_database() {
255        assert!(matches!(
256            database_path_from_entries([("temp".to_string(), String::new())]),
257            Err(DbError::DatabasePathUnavailable)
258        ));
259    }
260
261    #[tokio::test]
262    async fn connect_read_only_queries_existing_database_and_rejects_writes() {
263        let dir = tempfile::tempdir().unwrap();
264        let path = dir.path().join("bhtune.db");
265        let pool = connect(&path).await.unwrap();
266        drop(pool);
267
268        let pool = connect_read_only(&path).await.unwrap();
269        let migration_count: i64 =
270            sqlx::query_scalar("SELECT COUNT(*) FROM _sqlx_migrations WHERE success = 1")
271                .fetch_one(&pool)
272                .await
273                .unwrap();
274        assert_eq!(migration_count, 1);
275
276        assert!(
277            sqlx::query("DELETE FROM tune_runs")
278                .execute(&pool)
279                .await
280                .is_err()
281        );
282    }
283
284    #[tokio::test]
285    async fn connect_in_memory_runs_the_final_schema_migration() {
286        let pool = connect_in_memory().await.unwrap();
287
288        let (migration_count,): (i64,) =
289            sqlx::query_as("SELECT COUNT(*) FROM _sqlx_migrations WHERE success = 1")
290                .fetch_one(&pool)
291                .await
292                .unwrap();
293        assert_eq!(migration_count, 1);
294
295        let (migration_version,): (i64,) = sqlx::query_as(
296            "SELECT version FROM _sqlx_migrations WHERE success = 1 ORDER BY version DESC LIMIT 1",
297        )
298        .fetch_one(&pool)
299        .await
300        .unwrap();
301        assert_eq!(migration_version, 1);
302    }
303
304    #[tokio::test]
305    async fn fresh_schema_contains_final_history_demo_and_actuation_objects() {
306        let pool = connect_in_memory().await.unwrap();
307
308        for (object_type, object_name) in [
309            ("table", "demo_sessions"),
310            ("table", "tune_mv_actuations"),
311            ("table", "live_operation_owners"),
312            ("table", "live_operation_claims"),
313            ("table", "live_mutation_steps"),
314            ("table", "tune_recovery_attempts"),
315            ("index", "idx_tune_samples_run_time"),
316            ("index", "idx_tune_writes_run_written"),
317            ("index", "idx_tune_runs_demo_session"),
318            ("index", "idx_tune_runs_demo_session_outcome"),
319            ("index", "idx_live_operation_owners_run"),
320            ("index", "idx_live_operation_owners_heartbeat"),
321            ("index", "idx_live_mutation_steps_owner"),
322            ("index", "idx_live_mutation_steps_run"),
323            ("index", "idx_tune_recovery_attempts_run"),
324            ("trigger", "tune_runs_demo_session_insert"),
325            ("trigger", "tune_runs_demo_session_valid_insert"),
326            ("trigger", "tune_runs_demo_global_limit_insert"),
327            ("trigger", "tune_runs_demo_session_update"),
328            ("trigger", "tune_runs_demo_session_immutable"),
329        ] {
330            let count: i64 = sqlx::query_scalar(
331                "SELECT COUNT(*) FROM sqlite_master WHERE type = ? AND name = ?",
332            )
333            .bind(object_type)
334            .bind(object_name)
335            .fetch_one(&pool)
336            .await
337            .unwrap();
338            assert_eq!(
339                count, 1,
340                "expected final schema object {object_type} {object_name}"
341            );
342        }
343
344        let result_columns: Vec<String> =
345            sqlx::query_scalar("SELECT name FROM pragma_table_info('tune_results')")
346                .fetch_all(&pool)
347                .await
348                .unwrap();
349        for column in ["status", "invalid_reason"] {
350            assert!(
351                result_columns.iter().any(|name| name == column),
352                "checked-result column {column} is missing"
353            );
354        }
355    }
356}