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//! [`SqliteStore`] wraps a [`zeph_db::DbPool`] and provides typed sub-store modules
7//! 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//! | `agent_sessions` | Fleet session lifecycle records ([`AgentSessionRow`]) |
30
31mod acp_sessions;
32pub mod admission_training;
33pub mod agent_sessions;
34pub mod channel_preferences;
35pub mod compression_guidelines;
36pub mod corrections;
37pub mod experiments;
38pub mod graph_store;
39mod history;
40mod mem_scenes;
41pub mod memory_tree;
42pub(crate) mod messages;
43pub mod overflow;
44pub mod persona;
45pub mod preferences;
46pub mod retrieval_failures;
47pub mod session_digest;
48mod skills;
49mod summaries;
50pub mod trajectory;
51mod trust;
52
53#[allow(unused_imports)]
54use zeph_db::sql;
55use zeph_db::{DbConfig, DbPool};
56
57use crate::error::MemoryError;
58
59pub use acp_sessions::{AcpSessionConfigSnapshot, AcpSessionEvent, AcpSessionInfo};
60pub use agent_sessions::{AgentSessionRow, SessionChannel, SessionKind, SessionStatus};
61pub use memory_tree::MemoryTreeRow;
62pub use messages::role_str;
63pub use persona::PersonaFactRow;
64pub use skills::{SkillMetricsRow, SkillUsageRow, SkillVersionRow};
65pub use trajectory::{NewTrajectoryEntry, TrajectoryEntryRow};
66pub use trust::{SkillTrustRow, SourceKind};
67
68/// Primary relational data store backed by a [`DbPool`].
69///
70/// Opening a `SqliteStore` 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::SqliteStore;
77///
78/// let store = SqliteStore::new(":memory:").await?;
79/// let cid = store.create_conversation().await?;
80/// # Ok(())
81/// # }
82/// ```
83#[derive(Debug, Clone)]
84pub struct SqliteStore {
85    pool: DbPool,
86    db_instance_id: String,
87}
88
89impl SqliteStore {
90    /// Open (or create) the database and run migrations.
91    ///
92    /// # Errors
93    ///
94    /// Returns an error if the database cannot be opened or migrations fail.
95    pub async fn new(path: &str) -> Result<Self, MemoryError> {
96        Self::with_pool_size(path, 5).await
97    }
98
99    /// Open (or create) the database with a configurable connection pool size.
100    ///
101    /// # Errors
102    ///
103    /// Returns an error if the database cannot be opened or migrations fail.
104    pub async fn with_pool_size(path: &str, pool_size: u32) -> Result<Self, MemoryError> {
105        let pool = DbConfig {
106            url: path.to_string(),
107            max_connections: pool_size,
108            pool_size,
109        }
110        .connect()
111        .await?;
112        let db_instance_id = zeph_db::get_or_create_instance_id(&pool).await?;
113
114        Ok(Self {
115            pool,
116            db_instance_id,
117        })
118    }
119
120    /// Create a store from an already-open pool (no migrations run).
121    ///
122    /// Use this when the pool was obtained from an existing store (e.g. the main
123    /// agent memory store) to avoid redundant migration runs.
124    ///
125    /// # Errors
126    ///
127    /// Returns an error if this database's stable [`Self::db_instance_id`] cannot be
128    /// fetched or created (#5742).
129    pub async fn from_pool(pool: DbPool) -> Result<Self, MemoryError> {
130        let db_instance_id = zeph_db::get_or_create_instance_id(&pool).await?;
131        Ok(Self {
132            pool,
133            db_instance_id,
134        })
135    }
136
137    /// Expose the underlying pool for shared access by other stores.
138    #[must_use]
139    pub fn pool(&self) -> &DbPool {
140        &self.pool
141    }
142
143    /// Stable UUID identifying this physical database (#5742).
144    ///
145    /// Generated once and persisted in the `db_instance` table. Used to disambiguate
146    /// autoincrementing IDs (e.g. `conversation_id`) when multiple independent databases
147    /// share one Qdrant instance.
148    #[must_use]
149    pub fn db_instance_id(&self) -> &str {
150        &self.db_instance_id
151    }
152
153    /// Run all migrations on the given pool.
154    ///
155    /// # Errors
156    ///
157    /// Returns an error if any migration fails.
158    pub async fn run_migrations(pool: &DbPool) -> Result<(), MemoryError> {
159        zeph_db::run_migrations(pool).await?;
160        Ok(())
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use tempfile::NamedTempFile;
168
169    // Matches `DbConfig` busy_timeout default (5 seconds in ms)
170    const DEFAULT_BUSY_TIMEOUT_MS: i64 = 5_000;
171
172    #[tokio::test]
173    async fn wal_journal_mode_enabled_on_file_db() {
174        let file = NamedTempFile::new().expect("tempfile");
175        let path = file.path().to_str().expect("valid path");
176
177        let store = SqliteStore::new(path).await.expect("SqliteStore::new");
178
179        let mode: String = zeph_db::query_scalar(sql!("PRAGMA journal_mode"))
180            .fetch_one(store.pool())
181            .await
182            .expect("PRAGMA query");
183
184        assert_eq!(mode, "wal", "expected WAL journal mode, got: {mode}");
185    }
186
187    #[tokio::test]
188    async fn busy_timeout_enabled_on_file_db() {
189        let file = NamedTempFile::new().expect("tempfile");
190        let path = file.path().to_str().expect("valid path");
191
192        let store = SqliteStore::new(path).await.expect("SqliteStore::new");
193
194        let timeout_ms: i64 = zeph_db::query_scalar(sql!("PRAGMA busy_timeout"))
195            .fetch_one(store.pool())
196            .await
197            .expect("PRAGMA query");
198
199        assert_eq!(
200            timeout_ms, DEFAULT_BUSY_TIMEOUT_MS,
201            "expected busy_timeout pragma to match configured timeout"
202        );
203    }
204
205    #[tokio::test]
206    async fn creates_parent_dirs() {
207        let dir = tempfile::tempdir().expect("tempdir");
208        let deep = dir.path().join("a/b/c/zeph.db");
209        let path = deep.to_str().expect("valid path");
210        let _store = SqliteStore::new(path).await.expect("SqliteStore::new");
211        assert!(deep.exists(), "database file should exist");
212    }
213
214    #[tokio::test]
215    async fn with_pool_size_accepts_custom_size() {
216        let store = SqliteStore::with_pool_size(":memory:", 2)
217            .await
218            .expect("with_pool_size");
219        // Verify the store is operational with the custom pool size.
220        let _cid = store
221            .create_conversation()
222            .await
223            .expect("create_conversation");
224    }
225}