Skip to main content

bob_adapters/
store_memory.rs

1//! # In-Memory Session Store
2//!
3//! In-memory session store — implements [`SessionStore`] via `scc::HashMap`.
4//!
5//! ## Overview
6//!
7//! This adapter provides a thread-safe, in-memory session store backed by
8//! [`scc::HashMap`](https://docs.rs/scc/latest/scc/struct.HashMap.html).
9//!
10//! Suitable for:
11//! - Development and testing
12//! - Single-process CLI applications
13//! - Scenarios where persistence across restarts is not required
14//!
15//! Not suitable for:
16//! - Multi-process deployments
17//! - Production environments requiring persistence
18//! - Horizontal scaling
19//!
20//! ## Example
21//!
22//! ```rust,ignore
23//! use bob_adapters::store_memory::InMemorySessionStore;
24//! use bob_core::{
25//!     ports::SessionStore,
26//!     types::{SessionState, Message, Role},
27//! };
28//!
29//! let store = InMemorySessionStore::new();
30//!
31//! // Save a session
32//! let state = SessionState {
33//!     messages: vec![Message {
34//!         role: Role::User,
35//!         content: "Hello".to_string(),
36//!     }],
37//!     ..Default::default()
38//! };
39//! store.save(&"session-1".to_string(), &state).await?;
40//!
41//! // Load the session
42//! let loaded = store.load(&"session-1".to_string()).await?;
43//! ```
44//!
45//! ## Thread Safety
46//!
47//! The store uses `scc::HashMap` which provides lock-free concurrent access,
48//! making it safe to share across multiple threads.
49
50use bob_core::{
51    error::StoreError,
52    ports::SessionStore,
53    types::{SessionId, SessionState},
54};
55
56/// Thread-safe, in-memory session store backed by [`scc::HashMap`].
57///
58/// Suitable for single-process / CLI usage where persistence across
59/// restarts is not required.
60#[derive(Debug)]
61pub struct InMemorySessionStore {
62    inner: scc::HashMap<SessionId, SessionState>,
63}
64
65impl Default for InMemorySessionStore {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71impl InMemorySessionStore {
72    /// Create an empty store.
73    #[must_use]
74    pub fn new() -> Self {
75        Self { inner: scc::HashMap::new() }
76    }
77}
78
79#[async_trait::async_trait]
80impl SessionStore for InMemorySessionStore {
81    async fn load(&self, id: &SessionId) -> Result<Option<SessionState>, StoreError> {
82        let state = self.inner.read_async(id, |_k, v| v.clone()).await;
83        Ok(state)
84    }
85
86    async fn save(&self, id: &SessionId, state: &SessionState) -> Result<(), StoreError> {
87        // entry_async: insert if absent, overwrite if present.
88        let entry = self.inner.entry_async(id.clone()).await;
89        match entry {
90            scc::hash_map::Entry::Occupied(mut occ) => {
91                occ.get_mut().clone_from(state);
92            }
93            scc::hash_map::Entry::Vacant(vac) => {
94                let _ = vac.insert_entry(state.clone());
95            }
96        }
97        Ok(())
98    }
99}
100
101// ── Tests ────────────────────────────────────────────────────────────
102
103#[cfg(test)]
104mod tests {
105    use std::sync::Arc;
106
107    use bob_core::types::Message;
108
109    use super::*;
110
111    #[tokio::test]
112    async fn load_missing_returns_none() {
113        let store = InMemorySessionStore::new();
114        let result = store.load(&"nonexistent".to_string()).await;
115        assert!(result.is_ok());
116        assert!(result.ok().flatten().is_none());
117    }
118
119    #[tokio::test]
120    async fn roundtrip_save_load() {
121        let store = InMemorySessionStore::new();
122        let id = "sess-1".to_string();
123        let state = SessionState {
124            messages: vec![Message { role: bob_core::types::Role::User, content: "hello".into() }],
125            ..SessionState::default()
126        };
127
128        store.save(&id, &state).await.ok();
129        let loaded = store.load(&id).await.ok().flatten();
130        assert!(loaded.is_some());
131        assert_eq!(loaded.as_ref().map(|s| s.messages.len()), Some(1));
132    }
133
134    #[tokio::test]
135    async fn overwrite_existing_session() {
136        let store = InMemorySessionStore::new();
137        let id = "sess-2".to_string();
138
139        let state1 = SessionState {
140            messages: vec![Message { role: bob_core::types::Role::User, content: "first".into() }],
141            ..SessionState::default()
142        };
143        store.save(&id, &state1).await.ok();
144
145        let state2 = SessionState {
146            messages: vec![
147                Message { role: bob_core::types::Role::User, content: "first".into() },
148                Message { role: bob_core::types::Role::Assistant, content: "second".into() },
149            ],
150            ..SessionState::default()
151        };
152        store.save(&id, &state2).await.ok();
153
154        let loaded = store.load(&id).await.ok().flatten();
155        assert_eq!(loaded.as_ref().map(|s| s.messages.len()), Some(2));
156    }
157
158    #[tokio::test]
159    async fn arc_dyn_session_store_works() {
160        let store: Arc<dyn SessionStore> = Arc::new(InMemorySessionStore::new());
161        let id = "sess-arc".to_string();
162        let state = SessionState::default();
163        store.save(&id, &state).await.ok();
164        let loaded = store.load(&id).await.ok().flatten();
165        assert!(loaded.is_some());
166    }
167}