Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/src/session.rs

12.0 KiB, 1 run

created by r2519314175:959, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Session store — O3db-backed session and conversation management.
2//!
3//! Sessions are keyed by user in O3db, supporting multi-user from
4//! the start. Each user has a list of session IDs and a per-user
5//! config (default model, etc.).
6
7use oxedyne_fe2o3_core::prelude::*;
8use oxedyne_fe2o3_jdat::prelude::*;
9use oxedyne_fe2o3_iop_crypto::enc::Encrypter;
10use oxedyne_fe2o3_iop_db::api::Database;
11use oxedyne_fe2o3_iop_hash::api::Hasher;
12use oxedyne_fe2o3_jdat::id::NumIdDat;
13
14use std::sync::{Arc, RwLock};
15use std::marker::PhantomData;
16
17use crate::protocol::{
18 ChatMessage,
19 Session,
20 UserConfig,
21 generate_session_id,
22 sessions_key,
23 session_key,
24 user_config_key,
25};
26
27
28// ┌───────────────────────────────────────────────────────────────┐
29// │ SessionStore │
30// └───────────────────────────────────────────────────────────────┘
31
32/// O3db-backed store for chat sessions and user configuration.
33///
34/// All operations are scoped by username — each user sees only their
35/// own sessions. This is the foundation for multi-user support.
36///
37/// Generic over the O3db database types, matching Steel's
38/// `ServerContext` generics.
39pub struct SessionStore<
40 const UIDL: usize,
41 UID: NumIdDat<UIDL> + Clone,
42 ENC: Encrypter,
43 KH: Hasher,
44 DB: Database<UIDL, UID, ENC, KH>,
45> {
46 db: Arc<RwLock<DB>>,
47 uid: UID,
48 _phantom_enc: PhantomData<ENC>,
49 _phantom_kh: PhantomData<KH>,
50}
51
52// SessionStore is Clone because Arc is Clone and UID: Clone.
53impl<
54 const UIDL: usize,
55 UID: NumIdDat<UIDL> + Clone,
56 ENC: Encrypter,
57 KH: Hasher,
58 DB: Database<UIDL, UID, ENC, KH>,
59> Clone for SessionStore<UIDL, UID, ENC, KH, DB>
60{
61 fn clone(&self) -> Self {
62 Self {
63 db: self.db.clone(),
64 uid: self.uid.clone(),
65 _phantom_enc: PhantomData,
66 _phantom_kh: PhantomData,
67 }
68 }
69}
70
71impl<
72 const UIDL: usize,
73 UID: NumIdDat<UIDL> + Clone,
74 ENC: Encrypter,
75 KH: Hasher,
76 DB: Database<UIDL, UID, ENC, KH>,
77>
78 SessionStore<UIDL, UID, ENC, KH, DB>
79{
80 pub fn new(db: Arc<RwLock<DB>>, uid: UID) -> Self {
81 Self {
82 db,
83 uid,
84 _phantom_enc: PhantomData,
85 _phantom_kh: PhantomData,
86 }
87 }
88
89 /// Create a new session for a user.
90 pub fn create_session(
91 &self,
92 username: &str,
93 name: &str,
94 model: &str,
95 ) -> Outcome<Session> {
96 let id = generate_session_id();
97 let session = Session::new(id.clone(), name.to_string(), model.to_string());
98
99 // Store the session.
100 let session_dat = Dat::Map(session.to_datmap());
101 {
102 let db_w = match self.db.write() {
103 Ok(v) => v,
104 Err(_) => return Err(err!(
105 "SessionStore: database write lock poisoned.";
106 Lock, Poisoned)),
107 };
108 res!(db_w.insert(session_key(&id), session_dat, self.uid.clone(), None));
109 }
110
111 // Add to the user's session list.
112 res!(self.add_to_user_sessions(username, &id));
113
114 Ok(session)
115 }
116
117 /// List all sessions for a user (metadata only, no messages).
118 pub fn list_sessions(&self, username: &str) -> Outcome<Vec<Session>> {
119 let session_ids = res!(self.get_user_session_ids(username));
120 let mut sessions = Vec::new();
121 for id in session_ids {
122 match self.get_session(&id) {
123 Ok(s) => sessions.push(s),
124 Err(_) => continue, // skip missing sessions
125 }
126 }
127 // Sort by created_at descending (newest first).
128 sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at));
129 Ok(sessions)
130 }
131
132 /// Get a session by ID (includes messages).
133 pub fn get_session(&self, session_id: &str) -> Outcome<Session> {
134 let db_r = match self.db.read() {
135 Ok(v) => v,
136 Err(_) => return Err(err!(
137 "SessionStore: database read lock poisoned.";
138 Lock, Poisoned)),
139 };
140 match db_r.get(&session_key(session_id), None) {
141 Ok(Some((data, _))) => {
142 match &data {
143 Dat::Map(m) => Session::from_datmap(m),
144 _ => Err(err!(
145 "SessionStore: session '{}' is not a map.", session_id;
146 Invalid, Data)),
147 }
148 }
149 Ok(None) => Err(err!(
150 "SessionStore: session '{}' not found.", session_id;
151 NotFound, Missing)),
152 Err(e) => Err(err!(e, "SessionStore: get session failed."; IO, Data, Read)),
153 }
154 }
155
156 /// Append a message to a session's conversation history.
157 pub fn append_message(
158 &self,
159 session_id: &str,
160 message: ChatMessage,
161 ) -> Outcome<()> {
162 let mut session = res!(self.get_session(session_id));
163 session.messages.push(message);
164 self.save_session(&session)
165 }
166
167 /// Save a session (full overwrite).
168 pub fn save_session(&self, session: &Session) -> Outcome<()> {
169 let session_dat = Dat::Map(session.to_datmap());
170 let db_w = match self.db.write() {
171 Ok(v) => v,
172 Err(_) => return Err(err!(
173 "SessionStore: database write lock poisoned.";
174 Lock, Poisoned)),
175 };
176 res!(db_w.insert(session_key(&session.id), session_dat, self.uid.clone(), None));
177 Ok(())
178 }
179
180 /// Delete a session.
181 pub fn delete_session(&self, username: &str, session_id: &str) -> Outcome<()> {
182 // Remove from user's session list.
183 res!(self.remove_from_user_sessions(username, session_id));
184
185 // Delete the session record.
186 let db_w = match self.db.write() {
187 Ok(v) => v,
188 Err(_) => return Err(err!(
189 "SessionStore: database write lock poisoned.";
190 Lock, Poisoned)),
191 };
192 res!(db_w.delete(&session_key(session_id), self.uid.clone(), None));
193 Ok(())
194 }
195
196 /// Rename a session.
197 pub fn rename_session(&self, session_id: &str, new_name: &str) -> Outcome<()> {
198 let mut session = res!(self.get_session(session_id));
199 session.name = new_name.to_string();
200 self.save_session(&session)
201 }
202
203 // ── User config ───────────────────────────────────────────
204
205 /// Get or create user config.
206 pub fn get_or_create_user_config(
207 &self,
208 username: &str,
209 default_model: &str,
210 ) -> Outcome<UserConfig> {
211 // Try to read existing.
212 let existing = {
213 let db_r = match self.db.read() {
214 Ok(v) => v,
215 Err(_) => return Err(err!(
216 "SessionStore: database read lock poisoned.";
217 Lock, Poisoned)),
218 };
219 db_r.get(&user_config_key(username), None)
220 };
221 match existing {
222 Ok(Some((data, _))) => {
223 if let Dat::Map(m) = &data {
224 return UserConfig::from_datmap(m);
225 }
226 }
227 _ => {}
228 }
229 // Create new.
230 let config = UserConfig::new(username.to_string(), default_model.to_string());
231 let config_dat = Dat::Map(config.to_datmap());
232 {
233 let db_w = match self.db.write() {
234 Ok(v) => v,
235 Err(_) => return Err(err!(
236 "SessionStore: database write lock poisoned.";
237 Lock, Poisoned)),
238 };
239 res!(db_w.insert(user_config_key(username), config_dat, self.uid.clone(), None));
240 }
241 Ok(config)
242 }
243
244 /// Update user config.
245 pub fn save_user_config(&self, config: &UserConfig) -> Outcome<()> {
246 let config_dat = Dat::Map(config.to_datmap());
247 let db_w = match self.db.write() {
248 Ok(v) => v,
249 Err(_) => return Err(err!(
250 "SessionStore: database write lock poisoned.";
251 Lock, Poisoned)),
252 };
253 res!(db_w.insert(user_config_key(&config.username), config_dat, self.uid.clone(), None));
254 Ok(())
255 }
256
257 // ── Internal helpers ──────────────────────────────────────
258
259 fn get_user_session_ids(&self, username: &str) -> Outcome<Vec<String>> {
260 let db_r = match self.db.read() {
261 Ok(v) => v,
262 Err(_) => return Err(err!(
263 "SessionStore: database read lock poisoned.";
264 Lock, Poisoned)),
265 };
266 match db_r.get(&sessions_key(username), None) {
267 Ok(Some((data, _))) => {
268 match &data {
269 Dat::List(list) => {
270 let mut ids = Vec::new();
271 for item in list {
272 if let Dat::Str(s) = item {
273 ids.push(s.clone());
274 }
275 }
276 Ok(ids)
277 }
278 _ => Ok(Vec::new()),
279 }
280 }
281 _ => Ok(Vec::new()),
282 }
283 }
284
285 fn add_to_user_sessions(&self, username: &str, session_id: &str) -> Outcome<()> {
286 let mut ids = res!(self.get_user_session_ids(username));
287 ids.push(session_id.to_string());
288 let list_dat = Dat::List(ids.into_iter().map(Dat::Str).collect());
289 let db_w = match self.db.write() {
290 Ok(v) => v,
291 Err(_) => return Err(err!(
292 "SessionStore: database write lock poisoned.";
293 Lock, Poisoned)),
294 };
295 res!(db_w.insert(sessions_key(username), list_dat, self.uid.clone(), None));
296 Ok(())
297 }
298
299 fn remove_from_user_sessions(&self, username: &str, session_id: &str) -> Outcome<()> {
300 let mut ids = res!(self.get_user_session_ids(username));
301 ids.retain(|id| id != session_id);
302 let list_dat = Dat::List(ids.into_iter().map(Dat::Str).collect());
303 let db_w = match self.db.write() {
304 Ok(v) => v,
305 Err(_) => return Err(err!(
306 "SessionStore: database write lock poisoned.";
307 Lock, Poisoned)),
308 };
309 res!(db_w.insert(sessions_key(username), list_dat, self.uid.clone(), None));
310 Ok(())
311 }
312}
313
314
315// ┌───────────────────────────────────────────────────────────────┐
316// │ Tests │
317// └───────────────────────────────────────────────────────────────┘
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 // We test the protocol-level types and key generation here.
324 // Full O3db integration tests require a running database,
325 // which is tested in the Steel integration test suite.
326
327 #[test]
328 fn test_session_id_format() {
329 let id = generate_session_id();
330 assert!(!id.is_empty());
331 assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
332 }
333
334 #[test]
335 fn test_session_key_format() {
336 let key = session_key("abc123");
337 match key {
338 Dat::Str(s) => assert_eq!(s, "daimond:session:abc123"),
339 _ => panic!("expected Str"),
340 }
341 }
342
343 #[test]
344 fn test_sessions_key_format() {
345 let key = sessions_key("jason");
346 match key {
347 Dat::Str(s) => assert_eq!(s, "daimond:jason:sessions"),
348 _ => panic!("expected Str"),
349 }
350 }
351}