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