/// OSIRIS Context /// /// A complete context with HeroDB storage and participant-based access. /// Each context is isolated with its own HeroDB connection. /// /// Combines: /// - HeroDB storage (via GenericStore) /// - Participant list (public keys) /// - Generic CRUD operations for any data use crate::objects::Note; use crate::store::{GenericStore, HeroDbClient}; use rhai::{CustomType, EvalAltResult, TypeBuilder}; use std::sync::Arc; /// Convert serde_json::Value to rhai::Dynamic fn json_to_rhai(value: serde_json::Value) -> Result { match value { serde_json::Value::Null => Ok(rhai::Dynamic::UNIT), serde_json::Value::Bool(b) => Ok(rhai::Dynamic::from(b)), serde_json::Value::Number(n) => { if let Some(i) = n.as_i64() { Ok(rhai::Dynamic::from(i)) } else if let Some(f) = n.as_f64() { Ok(rhai::Dynamic::from(f)) } else { Err("Invalid number".to_string()) } } serde_json::Value::String(s) => Ok(rhai::Dynamic::from(s)), serde_json::Value::Array(arr) => { let rhai_arr: Result, String> = arr .into_iter() .map(json_to_rhai) .collect(); Ok(rhai::Dynamic::from(rhai_arr?)) } serde_json::Value::Object(obj) => { let mut rhai_map = rhai::Map::new(); for (k, v) in obj { rhai_map.insert(k.into(), json_to_rhai(v)?); } Ok(rhai::Dynamic::from(rhai_map)) } } } // ============================================================================ // OsirisContext - Main Context Type // ============================================================================ /// OSIRIS Context - combines storage with participant-based access /// /// This is the main context object that provides: /// - HeroDB storage via GenericStore /// - Participant list (public keys) /// - Generic CRUD operations #[derive(Clone, Debug)] pub struct OsirisContext { pub(crate) participants: Vec, // Public keys of all participants in this context pub(crate) store: Arc, } // Keep OsirisInstance as an alias for backward compatibility pub type OsirisInstance = OsirisContext; impl OsirisContext { /// Create a builder for OsirisContext pub fn builder() -> OsirisContextBuilder { OsirisContextBuilder::new() } /// Create a new OSIRIS context with minimal config (for backwards compatibility) pub fn new(name: impl ToString, herodb_url: &str, db_id: u16) -> Result> { OsirisContextBuilder::new() .name(name) .herodb_url(herodb_url) .db_id(db_id) .build() } /// Get the context participants (public keys) pub fn participants(&self) -> Vec { self.participants.clone() } /// Get the context ID (sorted, comma-separated participant keys) pub fn context_id(&self) -> String { let mut sorted = self.participants.clone(); sorted.sort(); sorted.join(",") } // ============================================================================ // Generic CRUD Operations // ============================================================================ // These methods work with any Rhai Dynamic object and store in HeroDB /// Generic save - saves any Rhai object to HeroDB /// /// Usage in Rhai: /// ```rhai /// let resident = digital_resident() /// .email("test@example.com") /// .first_name("John"); /// let id = ctx.save("residents", "resident_123", resident); /// ``` pub fn save(&self, collection: String, id: String, data: rhai::Dynamic) -> Result> { let store = self.store.clone(); let id_clone = id.clone(); let collection_clone = collection.clone(); // Serialize Rhai object to JSON let json_content = format!("{:?}", data); // Simple serialization for now // Save as Note tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async move { let mut note = Note::new(collection_clone); note.base_data.id = id_clone.clone(); note.content = Some(json_content); store.put(¬e).await .map_err(|e| format!("Failed to save: {}", e))?; Ok(id_clone) }) }).map_err(|e: String| e.into()) } /// Generic get - retrieves data from HeroDB and returns as Rhai object /// /// Usage in Rhai: /// ```rhai /// let resident = ctx.get("residents", "resident_123"); /// print(resident); // Can use the data directly /// ``` pub fn get(&self, collection: String, id: String) -> Result> { let store = self.store.clone(); tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async move { // Get raw JSON from HeroDB (generic) let json_data = store.get_raw(&collection, &id).await .map_err(|e| format!("Failed to get from HeroDB: {}", e))?; // Parse JSON to Rhai Map let parsed: serde_json::Value = serde_json::from_str(&json_data) .map_err(|e| format!("Failed to parse JSON: {}", e))?; // Convert serde_json::Value to rhai::Dynamic json_to_rhai(parsed) }) }).map_err(|e: String| e.into()) } /// Generic delete - checks if exists in HeroDB and deletes /// /// Usage in Rhai: /// ```rhai /// let deleted = ctx.delete("residents", "resident_123"); /// if deleted { /// print("Deleted successfully"); /// } /// ``` pub fn delete(&self, collection: String, id: String) -> Result> { let store = self.store.clone(); tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async move { // Check if exists by trying to get it match store.get::(&collection, &id).await { Ok(note) => { // Exists, now delete it store.delete(¬e).await .map_err(|e| format!("Failed to delete from HeroDB: {}", e)) } Err(_) => { // Doesn't exist Ok(false) } } }) }).map_err(|e: String| e.into()) } /// Check if an object exists in the context pub fn exists(&self, collection: String, id: String) -> Result> { let store = self.store.clone(); tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async move { // Check if exists by trying to get it match store.get::(&collection, &id).await { Ok(_) => Ok(true), Err(_) => Ok(false), } }) }).map_err(|e: String| e.into()) } /// List all IDs in a collection pub fn list(&self, collection: String) -> Result, Box> { let store = self.store.clone(); tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async move { store.get_all_ids(&collection).await .map(|ids| ids.into_iter().map(rhai::Dynamic::from).collect()) .map_err(|e| format!("Failed to list: {}", e)) }) }).map_err(|e: String| e.into()) } /// Query objects by field value pub fn query(&self, collection: String, field: String, value: String) -> Result, Box> { let store = self.store.clone(); tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async move { store.get_ids_by_index(&collection, &field, &value).await .map(|ids| ids.into_iter().map(rhai::Dynamic::from).collect()) .map_err(|e| format!("Failed to query: {}", e)) }) }).map_err(|e: String| e.into()) } } impl OsirisContext { /// Save a Note object (typed) pub fn save_note(&self, note: Note) -> Result> { let store = self.store.clone(); let id = note.base_data.id.clone(); tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async move { store.put(¬e).await .map_err(|e| format!("Failed to save note: {}", e))?; Ok(id) }) }).map_err(|e: String| e.into()) } /// Save an Event object (typed) pub fn save_event(&self, event: crate::objects::Event) -> Result> { let store = self.store.clone(); let id = event.base_data.id.clone(); tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async move { store.put(&event).await .map_err(|e| format!("Failed to save event: {}", e))?; Ok(id) }) }).map_err(|e: String| e.into()) } } impl CustomType for OsirisContext { fn build(mut builder: TypeBuilder) { builder .with_name("OsirisContext") .with_fn("participants", |ctx: &mut OsirisContext| ctx.participants()) .with_fn("context_id", |ctx: &mut OsirisContext| ctx.context_id()) // Generic CRUD (with collection name) .with_fn("save", |ctx: &mut OsirisContext, collection: String, id: String, data: rhai::Dynamic| ctx.save(collection, id, data)) // Typed save methods (no collection name needed) .with_fn("save", |ctx: &mut OsirisContext, note: Note| ctx.save_note(note)) .with_fn("save", |ctx: &mut OsirisContext, event: crate::objects::Event| ctx.save_event(event)) .with_fn("get", |ctx: &mut OsirisContext, collection: String, id: String| ctx.get(collection, id)) .with_fn("delete", |ctx: &mut OsirisContext, collection: String, id: String| ctx.delete(collection, id)) .with_fn("list", |ctx: &mut OsirisContext, collection: String| ctx.list(collection)) .with_fn("query", |ctx: &mut OsirisContext, collection: String, field: String, value: String| ctx.query(collection, field, value)); } } // ============================================================================ // OsirisContextBuilder // ============================================================================ /// Builder for OsirisContext pub struct OsirisContextBuilder { participants: Option>, herodb_url: Option, db_id: Option, } impl OsirisContextBuilder { /// Create a new builder pub fn new() -> Self { Self { participants: None, herodb_url: None, db_id: None, } } /// Set the context participants (public keys) pub fn participants(mut self, participants: Vec) -> Self { self.participants = Some(participants); self } /// Set a single participant (for backwards compatibility) pub fn name(mut self, name: impl ToString) -> Self { self.participants = Some(vec![name.to_string()]); self } /// Set owner (deprecated, use participants instead) #[deprecated(note = "Use participants() instead")] pub fn owner(mut self, owner_id: impl ToString) -> Self { self.participants = Some(vec![owner_id.to_string()]); self } /// Set the HeroDB URL pub fn herodb_url(mut self, url: impl ToString) -> Self { self.herodb_url = Some(url.to_string()); self } /// Set the HeroDB database ID pub fn db_id(mut self, db_id: u16) -> Self { self.db_id = Some(db_id); self } /// Build the OsirisContext pub fn build(self) -> Result> { let participants = self.participants.ok_or("Context participants are required")?; // HeroDB URL and DB ID are now optional - context can work without storage let herodb_url = self.herodb_url.unwrap_or_else(|| "redis://localhost:6379".to_string()); let db_id = self.db_id.unwrap_or(1); if participants.is_empty() { return Err("At least one participant is required".into()); } // Create HeroDB client let client = HeroDbClient::new(&herodb_url, db_id)?; // Create store let store = GenericStore::new(client); Ok(OsirisContext { participants, store: Arc::new(store), }) } } impl Default for OsirisContextBuilder { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_context_creation() { let ctx = OsirisContext::new("test_ctx", "redis://localhost:6379", 1); assert!(ctx.is_ok()); let ctx = ctx.unwrap(); assert_eq!(ctx.participants(), vec!["test_ctx".to_string()]); assert_eq!(ctx.context_id(), "test_ctx"); } #[test] fn test_builder_basic() { let ctx = OsirisContextBuilder::new() .participants(vec!["pk1".to_string()]) .herodb_url("redis://localhost:6379") .db_id(1) .build(); assert!(ctx.is_ok()); let ctx = ctx.unwrap(); assert_eq!(ctx.participants(), vec!["pk1".to_string()]); assert_eq!(ctx.context_id(), "pk1"); } #[test] fn test_builder_with_multiple_participants() { let ctx = OsirisContextBuilder::new() .participants(vec!["pk1".to_string(), "pk2".to_string(), "pk3".to_string()]) .herodb_url("redis://localhost:6379") .db_id(1) .build(); assert!(ctx.is_ok()); let ctx = ctx.unwrap(); assert_eq!(ctx.participants().len(), 3); // Context ID should be sorted assert_eq!(ctx.context_id(), "pk1,pk2,pk3"); } #[test] fn test_builder_missing_participants() { let ctx = OsirisContextBuilder::new() .herodb_url("redis://localhost:6379") .db_id(1) .build(); assert!(ctx.is_err()); assert!(ctx.unwrap_err().to_string().contains("participants are required")); } }