Skip to main content

zeph_memory/store/
mod.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! SQLite-backed relational store for all persistent agent data.
5//!
6//! [`DbStore`] (aliased as [`SqliteStore`]) wraps a [`zeph_db::DbPool`] and provides
7//! typed sub-store modules for every data domain:
8//!
9//! | Module | Contents |
10//! |--------|----------|
11//! | `messages` | Conversation messages, role strings, metadata |
12//! | `summaries` | Compression summaries per conversation |
13//! | `persona` | Long-lived user attributes ([`PersonaFactRow`]) |
14//! | `trajectory` | Goal trajectory entries ([`TrajectoryEntryRow`]) |
15//! | `memory_tree` | Hierarchical note consolidation tree ([`MemoryTreeRow`]) |
16//! | `session_digest` | Per-session digest records |
17//! | `experiments` | A/B experiment results and session summaries |
18//! | `corrections` | User-issued corrections stored for fine-tuning |
19//! | `graph_store` | Entity/edge adjacency tables for the knowledge graph |
20//! | `overflow` | Context-overflow handling metadata |
21//! | `preferences` | User preference key-value store |
22//! | `skills` | Skill metrics, usage, and version records |
23//! | `trust` | Skill trust scores by source |
24//! | `acp_sessions` | ACP protocol session events |
25//! | `mem_scenes` | Scene segmentation records |
26//! | `compression_guidelines` | LLM compression policy guidelines |
27//! | `admission_training` | A-MAC admission training data |
28//! | `channel_preferences` | Per-channel UX preferences (e.g. last active provider) |
29
30mod acp_sessions;
31pub mod admission_training;
32pub mod channel_preferences;
33pub mod compression_guidelines;
34pub mod corrections;
35pub mod experiments;
36pub mod graph_store;
37mod history;
38mod mem_scenes;
39pub mod memory_tree;
40pub(crate) mod messages;
41pub mod overflow;
42pub mod persona;
43pub mod preferences;
44pub mod retrieval_failures;
45pub mod session_digest;
46mod skills;
47mod summaries;
48pub mod trajectory;
49mod trust;
50
51#[allow(unused_imports)]
52use zeph_db::sql;
53use zeph_db::{DbConfig, DbPool};
54
55use crate::error::MemoryError;
56
57pub use acp_sessions::{AcpSessionEvent, AcpSessionInfo};
58pub use memory_tree::MemoryTreeRow;
59pub use messages::role_str;
60pub use persona::PersonaFactRow;
61pub use skills::{SkillMetricsRow, SkillUsageRow, SkillVersionRow};
62pub use trajectory::{NewTrajectoryEntry, TrajectoryEntryRow};
63pub use trust::{SkillTrustRow, SourceKind};
64
65/// Backward-compatible type alias. Prefer [`DbStore`] in new code.
66pub type SqliteStore = DbStore;
67
68/// Primary relational data store backed by a [`DbPool`].
69///
70/// Opening a `DbStore` runs all pending `SQLite` migrations automatically.
71///
72/// # Examples
73///
74/// ```rust,no_run
75/// # async fn example() -> Result<(), zeph_memory::MemoryError> {
76/// use zeph_memory::store::DbStore;
77///
78/// let store = DbStore::new(":memory:").await?;
79/// let cid = store.create_conversation().await?;
80/// # Ok(())
81/// # }
82/// ```
83#[derive(Debug, Clone)]
84pub struct DbStore {
85    pool: DbPool,
86}
87
88impl DbStore {
89    /// Open (or create) the database and run migrations.
90    ///
91    /// # Errors
92    ///
93    /// Returns an error if the database cannot be opened or migrations fail.
94    pub async fn new(path: &str) -> Result<Self, MemoryError> {
95        Self::with_pool_size(path, 5).await
96    }
97
98    /// Open (or create) the database with a configurable connection pool size.
99    ///
100    /// # Errors
101    ///
102    /// Returns an error if the database cannot be opened or migrations fail.
103    pub async fn with_pool_size(path: &str, pool_size: u32) -> Result<Self, MemoryError> {
104        let pool = DbConfig {
105            url: path.to_string(),
106            max_connections: pool_size,
107            pool_size,
108        }
109        .connect()
110        .await?;
111
112        Ok(Self { pool })
113    }
114
115    /// Expose the underlying pool for shared access by other stores.
116    #[must_use]
117    pub fn pool(&self) -> &DbPool {
118        &self.pool
119    }
120
121    /// Run all migrations on the given pool.
122    ///
123    /// # Errors
124    ///
125    /// Returns an error if any migration fails.
126    pub async fn run_migrations(pool: &DbPool) -> Result<(), MemoryError> {
127        zeph_db::run_migrations(pool).await?;
128        Ok(())
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use tempfile::NamedTempFile;
136
137    // Matches `DbConfig` busy_timeout default (5 seconds in ms)
138    const DEFAULT_BUSY_TIMEOUT_MS: i64 = 5_000;
139
140    #[tokio::test]
141    async fn wal_journal_mode_enabled_on_file_db() {
142        let file = NamedTempFile::new().expect("tempfile");
143        let path = file.path().to_str().expect("valid path");
144
145        let store = DbStore::new(path).await.expect("DbStore::new");
146
147        let mode: String = zeph_db::query_scalar(sql!("PRAGMA journal_mode"))
148            .fetch_one(store.pool())
149            .await
150            .expect("PRAGMA query");
151
152        assert_eq!(mode, "wal", "expected WAL journal mode, got: {mode}");
153    }
154
155    #[tokio::test]
156    async fn busy_timeout_enabled_on_file_db() {
157        let file = NamedTempFile::new().expect("tempfile");
158        let path = file.path().to_str().expect("valid path");
159
160        let store = DbStore::new(path).await.expect("DbStore::new");
161
162        let timeout_ms: i64 = zeph_db::query_scalar(sql!("PRAGMA busy_timeout"))
163            .fetch_one(store.pool())
164            .await
165            .expect("PRAGMA query");
166
167        assert_eq!(
168            timeout_ms, DEFAULT_BUSY_TIMEOUT_MS,
169            "expected busy_timeout pragma to match configured timeout"
170        );
171    }
172
173    #[tokio::test]
174    async fn creates_parent_dirs() {
175        let dir = tempfile::tempdir().expect("tempdir");
176        let deep = dir.path().join("a/b/c/zeph.db");
177        let path = deep.to_str().expect("valid path");
178        let _store = DbStore::new(path).await.expect("DbStore::new");
179        assert!(deep.exists(), "database file should exist");
180    }
181
182    #[tokio::test]
183    async fn with_pool_size_accepts_custom_size() {
184        let store = DbStore::with_pool_size(":memory:", 2)
185            .await
186            .expect("with_pool_size");
187        // Verify the store is operational with the custom pool size.
188        let _cid = store
189            .create_conversation()
190            .await
191            .expect("create_conversation");
192    }
193}