1use std::{path::Path, time::Duration};
7
8use sqlx::{
9 SqlitePool,
10 sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions},
11};
12
13use crate::error::{DbError, DbResult};
14
15const BUSY_TIMEOUT: Duration = Duration::from_secs(10);
20
21pub 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
43pub 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 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}