Skip to main content

zeph_memory/sqlite/
mod.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4mod acp_sessions;
5pub mod corrections;
6#[cfg(feature = "experiments")]
7pub mod experiments;
8pub mod graph_store;
9mod history;
10pub(crate) mod messages;
11mod skills;
12mod summaries;
13mod trust;
14
15use sqlx::SqlitePool;
16use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
17use std::str::FromStr;
18
19use crate::error::MemoryError;
20
21pub use acp_sessions::{AcpSessionEvent, AcpSessionInfo};
22pub use messages::role_str;
23pub use skills::{SkillMetricsRow, SkillUsageRow, SkillVersionRow};
24pub use trust::{SkillTrustRow, SourceKind};
25
26#[derive(Debug, Clone)]
27pub struct SqliteStore {
28    pool: SqlitePool,
29}
30
31impl SqliteStore {
32    /// Open (or create) the `SQLite` database and run migrations.
33    ///
34    /// Enables foreign key constraints at connection level so that
35    /// `ON DELETE CASCADE` and other FK rules are enforced.
36    ///
37    /// # Errors
38    ///
39    /// Returns an error if the database cannot be opened or migrations fail.
40    pub async fn new(path: &str) -> Result<Self, MemoryError> {
41        Self::with_pool_size(path, 5).await
42    }
43
44    /// Open (or create) the `SQLite` database with a configurable connection pool size.
45    ///
46    /// # Errors
47    ///
48    /// Returns an error if the database cannot be opened or migrations fail.
49    pub async fn with_pool_size(path: &str, pool_size: u32) -> Result<Self, MemoryError> {
50        let url = if path == ":memory:" {
51            "sqlite::memory:".to_string()
52        } else {
53            if let Some(parent) = std::path::Path::new(path).parent()
54                && !parent.as_os_str().is_empty()
55            {
56                std::fs::create_dir_all(parent)?;
57            }
58            format!("sqlite:{path}?mode=rwc")
59        };
60
61        let opts = SqliteConnectOptions::from_str(&url)?
62            .create_if_missing(true)
63            .foreign_keys(true)
64            .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
65            .synchronous(sqlx::sqlite::SqliteSynchronous::Normal);
66
67        let pool = SqlitePoolOptions::new()
68            .max_connections(pool_size)
69            .connect_with(opts)
70            .await?;
71
72        sqlx::migrate!("./migrations").run(&pool).await?;
73
74        Ok(Self { pool })
75    }
76
77    /// Expose the underlying pool for shared access by other stores.
78    #[must_use]
79    pub fn pool(&self) -> &SqlitePool {
80        &self.pool
81    }
82
83    /// Run all migrations on the given pool.
84    ///
85    /// # Errors
86    ///
87    /// Returns an error if any migration fails.
88    pub async fn run_migrations(pool: &SqlitePool) -> Result<(), MemoryError> {
89        sqlx::migrate!("./migrations").run(pool).await?;
90        Ok(())
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use tempfile::NamedTempFile;
98
99    #[tokio::test]
100    async fn wal_journal_mode_enabled_on_file_db() {
101        let file = NamedTempFile::new().expect("tempfile");
102        let path = file.path().to_str().expect("valid path");
103
104        let store = SqliteStore::new(path).await.expect("SqliteStore::new");
105
106        let mode: String = sqlx::query_scalar("PRAGMA journal_mode")
107            .fetch_one(store.pool())
108            .await
109            .expect("PRAGMA query");
110
111        assert_eq!(mode, "wal", "expected WAL journal mode, got: {mode}");
112    }
113
114    #[tokio::test]
115    async fn creates_parent_dirs() {
116        let dir = tempfile::tempdir().expect("tempdir");
117        let deep = dir.path().join("a/b/c/zeph.db");
118        let path = deep.to_str().expect("valid path");
119        let _store = SqliteStore::new(path).await.expect("SqliteStore::new");
120        assert!(deep.exists(), "database file should exist");
121    }
122
123    #[tokio::test]
124    async fn with_pool_size_accepts_custom_size() {
125        let store = SqliteStore::with_pool_size(":memory:", 2)
126            .await
127            .expect("with_pool_size");
128        // Verify the store is operational with the custom pool size.
129        let _cid = store
130            .create_conversation()
131            .await
132            .expect("create_conversation");
133    }
134}