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 | |
| 7 | use oxedyne_fe2o3_core::prelude::*; |
| 8 | use oxedyne_fe2o3_jdat::prelude::*; |
| 9 | use oxedyne_fe2o3_iop_crypto::enc::Encrypter; |
| 10 | use oxedyne_fe2o3_iop_db::api::Database; |
| 11 | use oxedyne_fe2o3_iop_hash::api::Hasher; |
| 12 | use oxedyne_fe2o3_jdat::id::NumIdDat; |
| 13 | |
| 14 | use std::sync::{Arc, RwLock}; |
| 15 | use std::marker::PhantomData; |
| 16 | |
| 17 | use 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. |
| 39 | pub 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. |
| 53 | impl< |
| 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 | |
| 71 | impl< |
| 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)] |
| 320 | mod 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 | } |