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