1use 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
18const BUSY_TIMEOUT: Duration = Duration::from_secs(10);
23
24static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
25
26pub 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
49pub 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
76pub 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
111pub 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 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}