Skip to main content

entrenar/storage/sqlite/backend/
sqlite_backend.rs

1//! SQLite Backend core implementation.
2//!
3//! Per-project durable experiment store using rusqlite with WAL mode.
4//! Follows the `.pmat/context.db` pattern from paiml-mcp-agent-toolkit.
5
6use super::schema;
7use crate::storage::Result;
8use crate::storage::StorageError;
9use rusqlite::Connection;
10use std::path::Path;
11use std::sync::Mutex;
12
13/// SQLite backend for experiment storage
14///
15/// Uses a real SQLite database (WAL mode) for durable, per-project
16/// experiment tracking. The database is created lazily at
17/// `<project>/.entrenar/experiments.db`.
18///
19/// The `Connection` is wrapped in a `Mutex` to satisfy the `Send + Sync`
20/// bounds on `ExperimentStorage`. SQLite in WAL mode handles concurrent
21/// readers natively; the mutex serializes writers.
22pub struct SqliteBackend {
23    pub(crate) conn: Mutex<Connection>,
24    pub(crate) path: String,
25}
26
27// Manual Debug impl since rusqlite::Connection doesn't implement Debug
28impl std::fmt::Debug for SqliteBackend {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        f.debug_struct("SqliteBackend").field("path", &self.path).finish_non_exhaustive()
31    }
32}
33
34impl SqliteBackend {
35    /// Open or create a SQLite database at the given path
36    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
37        let path_str = path.as_ref().to_string_lossy().to_string();
38        let conn = Connection::open(path.as_ref())
39            .map_err(|e| StorageError::Backend(format!("Failed to open SQLite database: {e}")))?;
40        schema::init_schema(&conn)
41            .map_err(|e| StorageError::Backend(format!("Failed to initialize schema: {e}")))?;
42        Ok(Self { conn: Mutex::new(conn), path: path_str })
43    }
44
45    /// Open an in-memory database (for tests — backward compatible)
46    pub fn open_in_memory() -> Result<Self> {
47        let conn = Connection::open_in_memory()
48            .map_err(|e| StorageError::Backend(format!("Failed to open in-memory SQLite: {e}")))?;
49        schema::init_schema(&conn)
50            .map_err(|e| StorageError::Backend(format!("Failed to initialize schema: {e}")))?;
51        Ok(Self { conn: Mutex::new(conn), path: ":memory:".to_string() })
52    }
53
54    /// Open or create at project-local path: `<project>/.entrenar/experiments.db`
55    pub fn open_project<P: AsRef<Path>>(project_dir: P) -> Result<Self> {
56        let dir = project_dir.as_ref().join(".entrenar");
57        std::fs::create_dir_all(&dir)?;
58        Self::open(dir.join("experiments.db"))
59    }
60
61    /// Get the database path
62    pub fn path(&self) -> &str {
63        &self.path
64    }
65
66    /// Generate a unique ID
67    pub(crate) fn generate_id() -> String {
68        use std::time::{SystemTime, UNIX_EPOCH};
69        let ts = SystemTime::now()
70            .duration_since(UNIX_EPOCH)
71            .expect("system clock must not be before UNIX epoch")
72            .as_nanos();
73        format!("{ts:x}")
74    }
75
76    /// Acquire the connection lock, mapping poison errors to StorageError
77    pub(crate) fn lock_conn(
78        &self,
79    ) -> std::result::Result<std::sync::MutexGuard<'_, Connection>, StorageError> {
80        self.conn
81            .lock()
82            .map_err(|e| StorageError::Backend(format!("Failed to acquire connection lock: {e}")))
83    }
84}