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