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