Skip to main content

luft_storage/
db.rs

1//! Database connection pool + schema migration.
2
3use crate::error::StorageResult;
4use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
5use sqlx::SqlitePool;
6use std::path::{Path, PathBuf};
7use std::time::Duration;
8
9/// Default DB path relative to `.luft/` directory.
10pub const DEFAULT_DB_PATH: &str = "luft.db";
11
12pub type DbPool = SqlitePool;
13
14/// Open (or create) the SQLite database at the given path.
15///
16/// Configures WAL mode + foreign keys + busy timeout. Runs migrations
17/// from the `migrations/` directory at compile time via `sqlx::migrate!`.
18pub async fn open_db(path: &Path) -> StorageResult<DbPool> {
19    if let Some(parent) = path.parent() {
20        std::fs::create_dir_all(parent)?;
21    }
22
23    let options = SqliteConnectOptions::new()
24        .filename(path)
25        .create_if_missing(true)
26        .journal_mode(SqliteJournalMode::Wal)
27        .synchronous(SqliteSynchronous::Normal)
28        .busy_timeout(Duration::from_secs(5))
29        .foreign_keys(true);
30
31    let pool = SqlitePoolOptions::new()
32        .max_connections(8)
33        .connect_with(options)
34        .await?;
35
36    sqlx::migrate!("./migrations").run(&pool).await?;
37
38    Ok(pool)
39}
40
41/// Default DB path under a given `.luft` root.
42pub fn default_db_path(luft_dir: &Path) -> PathBuf {
43    luft_dir.join(DEFAULT_DB_PATH)
44}
45
46#[cfg(test)]
47mod tests {
48    use super::*;
49    use tempfile::tempdir;
50
51    #[tokio::test]
52    async fn open_db_creates_tables() {
53        let dir = tempdir().unwrap();
54        let db_path = dir.path().join("test.db");
55
56        let pool = open_db(&db_path).await.unwrap();
57
58        // Verify all 7 tables exist.
59        let rows = sqlx::query_as::<_, (String,)>(
60            "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
61        )
62        .fetch_all(&pool)
63        .await
64        .unwrap();
65
66        let names: Vec<String> = rows.into_iter().map(|r| r.0).collect();
67        assert!(names.contains(&"runs".to_string()));
68        assert!(names.contains(&"phases".to_string()));
69        assert!(names.contains(&"agents".to_string()));
70        assert!(names.contains(&"turns".to_string()));
71        assert!(names.contains(&"spans".to_string()));
72        assert!(names.contains(&"findings".to_string()));
73        assert!(names.contains(&"events".to_string()));
74    }
75
76    #[tokio::test]
77    async fn open_db_idempotent_migration() {
78        let dir = tempdir().unwrap();
79        let db_path = dir.path().join("test.db");
80
81        let _pool1 = open_db(&db_path).await.unwrap();
82        // Second open must not re-run migrations or fail.
83        let _pool2 = open_db(&db_path).await.unwrap();
84    }
85
86    #[tokio::test]
87    async fn foreign_keys_enabled() {
88        let dir = tempdir().unwrap();
89        let db_path = dir.path().join("test.db");
90        let pool = open_db(&db_path).await.unwrap();
91
92        let enabled: i64 = sqlx::query_scalar("PRAGMA foreign_keys")
93            .fetch_one(&pool)
94            .await
95            .unwrap();
96        assert_eq!(enabled, 1);
97    }
98
99    #[tokio::test]
100    async fn wal_mode_enabled() {
101        let dir = tempdir().unwrap();
102        let db_path = dir.path().join("test.db");
103        let pool = open_db(&db_path).await.unwrap();
104
105        let mode: String = sqlx::query_scalar("PRAGMA journal_mode")
106            .fetch_one(&pool)
107            .await
108            .unwrap();
109        assert_eq!(mode.to_lowercase(), "wal");
110    }
111
112    #[tokio::test]
113    async fn missing_parent_dir_is_created() {
114        let dir = tempdir().unwrap();
115        let nested = dir.path().join("a/b/c/test.db");
116
117        // Should not error.
118        let _pool = open_db(&nested).await.unwrap();
119        assert!(nested.exists());
120    }
121
122    #[allow(dead_code)]
123    fn _assert_send_sync() {
124        fn assert<T: Send + Sync>() {}
125        assert::<DbPool>();
126    }
127}