From e760a184b1d6f14d717c0108eda101d9338ec173 Mon Sep 17 00:00:00 2001 From: Timur Gordon <31495328+timurgordon@users.noreply.github.com> Date: Tue, 28 Oct 2025 12:20:17 +0100 Subject: [PATCH] Refactor to use Rhai packages for efficient engine creation - Created OsirisPackage with all OSIRIS types and functions registered in the package - Functions now registered directly in package module (Note, Event, get_context) - Created ZdfzPackage extending OsirisPackage - Engine factory pattern: creates Engine::new_raw() + registers package (very cheap) - Removed TypeRegistry (unused code) - Simplified runner to use factory functions instead of passing packages - Package is created once, then factory efficiently creates engines on demand --- TYPE_REGISTRY_DESIGN.md | 93 ---------- src/bin/runner.rs | 54 ++---- src/{rhai/instance.rs => context.rs} | 203 +++++++++++---------- src/engine.rs | 262 +++++++++++++++++++++++++++ src/index/field_index.rs | 2 +- src/lib.rs | 11 +- src/rhai/builder.rs | 188 ------------------- src/rhai/engine.rs | 117 ------------ src/rhai/mod.rs | 29 --- src/store/generic_store.rs | 27 +-- src/store/mod.rs | 2 - src/store/type_registry.rs | 216 ---------------------- 12 files changed, 399 insertions(+), 805 deletions(-) delete mode 100644 TYPE_REGISTRY_DESIGN.md rename src/{rhai/instance.rs => context.rs} (70%) create mode 100644 src/engine.rs delete mode 100644 src/rhai/builder.rs delete mode 100644 src/rhai/engine.rs delete mode 100644 src/rhai/mod.rs delete mode 100644 src/store/type_registry.rs diff --git a/TYPE_REGISTRY_DESIGN.md b/TYPE_REGISTRY_DESIGN.md deleted file mode 100644 index 0e9ce29..0000000 --- a/TYPE_REGISTRY_DESIGN.md +++ /dev/null @@ -1,93 +0,0 @@ -# OSIRIS Type Registry Design - -## Problem - -We need applications (like ZDFZ API) to register custom types with OSIRIS so that: -1. The `save()` method can use the correct struct type instead of hardcoding `Note` -2. Each collection name maps to a specific Rust type -3. The type system properly deserializes, indexes, and stores data - -## Challenge - -The `Object` trait is not "dyn compatible" (object-safe) because it has: -- Associated functions (`object_type()`, `from_json()`) -- Generic methods -- Serialize/Deserialize bounds - -This means we **cannot** use `Box` for dynamic dispatch. - -## Solution: Type Registry with Callbacks - -Instead of trying to return `Box`, we use a callback-based approach: - -```rust -pub struct TypeRegistry { - // For each collection, store a function that: - // 1. Takes JSON string - // 2. Deserializes to the correct type - // 3. Stores it using GenericStore - // 4. Returns the ID - savers: HashMap Result<()>>>, -} -``` - -### Usage in ZDFZ API: - -```rust -// Create registry -let registry = TypeRegistry::new(); - -// Register Resident type -registry.register_saver("residents", |store, id, json| { - let mut resident: Resident = serde_json::from_str(json)?; - resident.set_id(id); - store.put(&resident).await -}); - -// Register Company type -registry.register_saver("companies", |store, id, json| { - let mut company: Company = serde_json::from_str(json)?; - company.set_id(id); - store.put(&company).await -}); - -// Create OSIRIS context with registry -let ctx = OsirisContext::new_with_registry( - "my_context", - "owner_id", - herodb_url, - db_id, - Some(Arc::new(registry)) -); - -// Now save() uses the registered type! -ctx.save("residents", "id123", resident_json)?; -``` - -### Benefits: - -✅ **Type-safe** - Each collection uses its proper Rust type -✅ **Flexible** - Applications register their own types -✅ **No trait object issues** - Uses closures instead of `Box` -✅ **Proper indexing** - Each type's `index_keys()` method is called -✅ **Clean API** - Simple registration interface - -## Implementation Plan: - -1. ✅ Create `TypeRegistry` with callback-based savers -2. ✅ Add `set_registry()` to `GenericStore` -3. ✅ Update `OsirisContext::save()` to use registry if available -4. ✅ Fall back to `Note` if no registry or collection not registered -5. Document usage for ZDFZ API - -## Next Steps: - -The type registry infrastructure is in place. Now ZDFZ API can: -1. Create a `TypeRegistry` -2. Register all SDK model types -3. Pass registry when creating OSIRIS contexts -4. Use generic `save()` method with proper types! - ---- - -**Status:** Design complete, ready for implementation with callback approach. diff --git a/src/bin/runner.rs b/src/bin/runner.rs index 895ef98..da84d29 100644 --- a/src/bin/runner.rs +++ b/src/bin/runner.rs @@ -13,7 +13,7 @@ /// ``` use clap::Parser; -use osiris::rhai::{OsirisEngineConfig, create_osiris_engine_with_config}; +use osiris::{create_osiris_engine, OsirisContext}; #[derive(Parser, Debug)] #[command(author, version, about = "OSIRIS Rhai Script Runner", long_about = None)] @@ -36,11 +36,6 @@ struct Args { /// Script file to execute #[arg(short = 'f', long)] script_file: Option, - - /// Predefined instances in format: name:url:db_id (can be repeated) - /// Example: --instance freezone:redis://localhost:6379:1 - #[arg(short = 'i', long = "instance")] - instances: Vec, } fn main() -> Result<(), Box> { @@ -52,39 +47,6 @@ fn main() -> Result<(), Box> { println!("🚀 OSIRIS Runner"); println!("Runner ID: {}", args.runner_id); println!("HeroDB: {} (DB {})", args.redis_url, args.db_id); - - // Parse predefined instances - let mut config = OsirisEngineConfig::new(); - - if args.instances.is_empty() { - // No predefined instances, use default with runner_id as owner - config.add_context("default", &args.runner_id, &args.redis_url, args.db_id); - } else { - // Parse instance definitions (format: name:url:db_id) - // We need to split carefully since URL contains colons - for instance_def in &args.instances { - // Find the first colon (name separator) - let first_colon = instance_def.find(':') - .ok_or_else(|| format!("Invalid instance format: '{}'. Expected: name:url:db_id", instance_def))?; - - let name = &instance_def[..first_colon]; - let rest = &instance_def[first_colon + 1..]; - - // Find the last colon (db_id separator) - let last_colon = rest.rfind(':') - .ok_or_else(|| format!("Invalid instance format: '{}'. Expected: name:url:db_id", instance_def))?; - - let url = &rest[..last_colon]; - let db_id_str = &rest[last_colon + 1..]; - - let db_id: u16 = db_id_str.parse() - .map_err(|_| format!("Invalid db_id in instance '{}': {}", instance_def, db_id_str))?; - - config.add_context(name, &args.runner_id, url, db_id); - println!(" Context: {} → {} (DB {})", name, url, db_id); - } - } - println!(); // Determine script source @@ -100,8 +62,18 @@ fn main() -> Result<(), Box> { println!("📝 Executing script...\n"); println!("─────────────────────────────────────"); - // Create engine with predefined contexts - let (engine, mut scope) = create_osiris_engine_with_config(config)?; + // Create engine + let mut engine = create_osiris_engine()?; + + // Set up context tags with SIGNATORIES + let mut tag_map = rhai::Map::new(); + let signatories: rhai::Array = vec![rhai::Dynamic::from(args.runner_id.clone())]; + tag_map.insert("SIGNATORIES".into(), rhai::Dynamic::from(signatories)); + tag_map.insert("HERODB_URL".into(), rhai::Dynamic::from(args.redis_url.clone())); + tag_map.insert("DB_ID".into(), rhai::Dynamic::from(args.db_id as i64)); + engine.set_default_tag(rhai::Dynamic::from(tag_map)); + + let mut scope = rhai::Scope::new(); match engine.eval_with_scope::(&mut scope, &script_content) { Ok(result) => { diff --git a/src/rhai/instance.rs b/src/context.rs similarity index 70% rename from src/rhai/instance.rs rename to src/context.rs index 1276b1f..f0f48d4 100644 --- a/src/rhai/instance.rs +++ b/src/context.rs @@ -1,4 +1,4 @@ -/// OSIRIS Context for Rhai +/// OSIRIS Context /// /// A complete context with HeroDB storage and participant-based access. /// Each context is isolated with its own HeroDB connection. @@ -8,9 +8,8 @@ /// - Participant list (public keys) /// - Generic CRUD operations for any data -use super::builder::OsirisContextBuilder; use crate::objects::Note; -use crate::store::GenericStore; +use crate::store::{GenericStore, HeroDbClient}; use rhai::{CustomType, EvalAltResult, TypeBuilder}; use std::sync::Arc; @@ -46,6 +45,10 @@ fn json_to_rhai(value: serde_json::Value) -> Result { } } +// ============================================================================ +// OsirisContext - Main Context Type +// ============================================================================ + /// OSIRIS Context - combines storage with participant-based access /// /// This is the main context object that provides: @@ -110,17 +113,7 @@ impl OsirisContext { // Serialize Rhai object to JSON let json_content = format!("{:?}", data); // Simple serialization for now - // Check if we have a type registry for this collection - if let Some(registry) = store.type_registry() { - if registry.has_type(&collection) { - // Use the registry's generic save (which will call store.put with the correct type) - registry.save(&store, &collection, &id_clone, &json_content) - .map_err(|e| format!("Failed to save using registry: {}", e))?; - return Ok(id_clone); - } - } - - // Fall back to Note if no registry or no saver registered + // Save as Note tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async move { let mut note = Note::new(collection_clone); @@ -282,60 +275,86 @@ impl CustomType for OsirisContext { } // ============================================================================ -// Context Creation - Standalone function +// OsirisContextBuilder // ============================================================================ -/// Register get_context function in a Rhai engine with signatory-based access control -/// -/// Simple logic: -/// - Context is a list of public keys (participants) -/// - To get_context, at least one participant must be a signatory -/// - No state tracking, no caching - creates fresh context each time -pub fn register_context_api(engine: &mut rhai::Engine, herodb_url: String, base_db_id: u16) { - // Register get_context function with signatory-based access control - // Usage: get_context(['pk1', 'pk2', 'pk3']) - engine.register_fn("get_context", move |context: rhai::NativeCallContext, participants: rhai::Array| -> Result> { - // Extract SIGNATORIES from context tag - let tag_map = context - .tag() - .and_then(|tag| tag.read_lock::()) - .ok_or_else(|| Box::new(rhai::EvalAltResult::ErrorRuntime("Context tag must be a Map.".into(), context.position())))?; +/// 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")?; - let signatories_dynamic = tag_map.get("SIGNATORIES") - .ok_or_else(|| Box::new(rhai::EvalAltResult::ErrorRuntime("'SIGNATORIES' not found in context tag Map.".into(), context.position())))?; + // 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); - // Convert SIGNATORIES array to Vec - let signatories_array = signatories_dynamic.clone().into_array() - .map_err(|e| Box::new(rhai::EvalAltResult::ErrorRuntime(format!("SIGNATORIES must be an array: {}", e).into(), context.position())))?; - - let signatories: Vec = signatories_array.into_iter() - .map(|s| s.into_string()) - .collect::, _>>() - .map_err(|e| Box::new(rhai::EvalAltResult::ErrorRuntime(format!("SIGNATORIES must contain strings: {}", e).into(), context.position())))?; - - // Convert participants array to Vec - let participant_keys: Vec = participants.into_iter() - .map(|p| p.into_string()) - .collect::, _>>() - .map_err(|e| Box::new(rhai::EvalAltResult::ErrorRuntime(format!("Participants must be strings: {}", e).into(), context.position())))?; - - // Verify at least one participant is a signatory - let has_signatory = participant_keys.iter().any(|p| signatories.contains(p)); - if !has_signatory { - return Err(Box::new(rhai::EvalAltResult::ErrorRuntime( - format!("Access denied: none of the participants are signatories").into(), - context.position() - ))); + if participants.is_empty() { + return Err("At least one participant is required".into()); } - // Create context directly with participants - OsirisContext::builder() - .participants(participant_keys) - .herodb_url(&herodb_url) - .db_id(base_db_id) - .build() - .map_err(|e| format!("Failed to create context: {}", e).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)] @@ -353,40 +372,42 @@ mod tests { } #[test] - fn test_context_save_and_get() { - let ctx = OsirisContext::new("test_ctx", "redis://localhost:6379", 1).unwrap(); + fn test_builder_basic() { + let ctx = OsirisContextBuilder::new() + .participants(vec!["pk1".to_string()]) + .herodb_url("redis://localhost:6379") + .db_id(1) + .build(); - // Create a simple Rhai map - let mut map = rhai::Map::new(); - map.insert("title".into(), rhai::Dynamic::from("Test Note")); - map.insert("content".into(), rhai::Dynamic::from("Test content")); - - // Save the data - let result = ctx.save("notes".to_string(), "note1".to_string(), rhai::Dynamic::from(map)); - assert!(result.is_ok()); - - // Get the data back - let retrieved = ctx.get("notes".to_string(), "note1".to_string()); - assert!(retrieved.is_ok()); + 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_context_delete() { - let ctx = OsirisContext::new("test_ctx", "redis://localhost:6379", 1).unwrap(); + 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(); - // Save data - let mut map = rhai::Map::new(); - map.insert("title".into(), rhai::Dynamic::from("Test")); - ctx.save("notes".to_string(), "note1".to_string(), rhai::Dynamic::from(map)).unwrap(); - - // Delete it - let deleted = ctx.delete("notes".to_string(), "note1".to_string()); - assert!(deleted.is_ok()); - assert!(deleted.unwrap()); - - // Should not be able to get it anymore - let result = ctx.get("notes".to_string(), "note1".to_string()); - assert!(result.is_err()); + 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")); + } } diff --git a/src/engine.rs b/src/engine.rs new file mode 100644 index 0000000..4ac74be --- /dev/null +++ b/src/engine.rs @@ -0,0 +1,262 @@ +/// OSIRIS Rhai Engine +/// +/// Creates a Rhai engine configured with OSIRIS contexts and methods. + +use crate::context::OsirisContext; +use crate::objects::{Note, Event}; +use rhai::{Engine, Module, def_package, FuncRegistration}; +use rhai::packages::{Package, StandardPackage}; + +/// Register Note functions into a module +fn register_note_functions(module: &mut Module) { + // Register Note type + module.set_custom_type::("Note"); + + // Register builder-style constructor + FuncRegistration::new("note") + .set_into_module(module, |ns: String| Note::new(ns)); + + // Register chainable methods that return Self + FuncRegistration::new("title") + .set_into_module(module, |mut note: Note, title: String| { + note.title = Some(title); + note.base_data.update_modified(); + note + }); + + FuncRegistration::new("content") + .set_into_module(module, |mut note: Note, content: String| { + let size = content.len() as u64; + note.content = Some(content); + note.base_data.set_size(Some(size)); + note.base_data.update_modified(); + note + }); + + FuncRegistration::new("tag") + .set_into_module(module, |mut note: Note, key: String, value: String| { + note.tags.insert(key, value); + note.base_data.update_modified(); + note + }); + + FuncRegistration::new("mime") + .set_into_module(module, |mut note: Note, mime: String| { + note.base_data.set_mime(Some(mime)); + note + }); +} + +/// Register Event functions into a module +fn register_event_functions(module: &mut Module) { + // Register Event type + module.set_custom_type::("Event"); + + // Register builder-style constructor + FuncRegistration::new("event") + .set_into_module(module, |ns: String, title: String| Event::new(ns, title)); + + // Register chainable methods + FuncRegistration::new("description") + .set_into_module(module, |mut event: Event, desc: String| { + event.description = Some(desc); + event.base_data.update_modified(); + event + }); +} + +/// Register get_context function in a Rhai engine with signatory-based access control +/// +/// Simple logic: +/// - Context is a list of public keys (participants) +/// - To get_context, at least one participant must be a signatory +/// - No state tracking, no caching - creates fresh context each time +pub fn register_context_api(engine: &mut rhai::Engine) { + // Register get_context function with signatory-based access control + // Usage: get_context(['pk1', 'pk2', 'pk3']) + engine.register_fn("get_context", move |context: rhai::NativeCallContext, participants: rhai::Array| -> Result> { + // Extract SIGNATORIES from context tag + let tag_map = context + .tag() + .and_then(|tag| tag.read_lock::()) + .ok_or_else(|| Box::new(rhai::EvalAltResult::ErrorRuntime("Context tag must be a Map.".into(), context.position())))?; + + let signatories_dynamic = tag_map.get("SIGNATORIES") + .ok_or_else(|| Box::new(rhai::EvalAltResult::ErrorRuntime("'SIGNATORIES' not found in context tag Map.".into(), context.position())))?; + + // Convert SIGNATORIES array to Vec + let signatories_array = signatories_dynamic.clone().into_array() + .map_err(|e| Box::new(rhai::EvalAltResult::ErrorRuntime(format!("SIGNATORIES must be an array: {}", e).into(), context.position())))?; + + let signatories: Vec = signatories_array.into_iter() + .map(|s| s.into_string()) + .collect::, _>>() + .map_err(|e| Box::new(rhai::EvalAltResult::ErrorRuntime(format!("SIGNATORIES must contain strings: {}", e).into(), context.position())))?; + + // Convert participants array to Vec + let participant_keys: Vec = participants.into_iter() + .map(|p| p.into_string()) + .collect::, _>>() + .map_err(|e| Box::new(rhai::EvalAltResult::ErrorRuntime(format!("Participants must be strings: {}", e).into(), context.position())))?; + + // Verify at least one participant is a signatory + let has_signatory = participant_keys.iter().any(|p| signatories.contains(p)); + if !has_signatory { + return Err(Box::new(rhai::EvalAltResult::ErrorRuntime( + format!("Access denied: none of the participants are signatories").into(), + context.position() + ))); + } + + // Create context directly with participants + OsirisContext::builder() + .participants(participant_keys) + .build() + .map_err(|e| format!("Failed to create context: {}", e).into()) + }); +} + +// Define the OSIRIS package +def_package! { + /// OSIRIS package with all OSIRIS types and functions + pub OsirisPackage(module) : StandardPackage { + // Register OsirisContext type + module.set_custom_type::("OsirisContext"); + + // Register Note functions + register_note_functions(module); + + // Register Event functions + register_event_functions(module); + + // Register get_context function with signatory-based access control + FuncRegistration::new("get_context") + .set_into_module(module, |context: rhai::NativeCallContext, participants: rhai::Array| -> Result> { + // Extract SIGNATORIES from context tag + let tag_map = context + .tag() + .and_then(|tag| tag.read_lock::()) + .ok_or_else(|| Box::new(rhai::EvalAltResult::ErrorRuntime("Context tag must be a Map.".into(), context.position())))?; + + let signatories_dynamic = tag_map.get("SIGNATORIES") + .ok_or_else(|| Box::new(rhai::EvalAltResult::ErrorRuntime("'SIGNATORIES' not found in context tag Map.".into(), context.position())))?; + + // Convert SIGNATORIES array to Vec + let signatories_array = signatories_dynamic.clone().into_array() + .map_err(|e| Box::new(rhai::EvalAltResult::ErrorRuntime(format!("SIGNATORIES must be an array: {}", e).into(), context.position())))?; + + let signatories: Vec = signatories_array.into_iter() + .map(|s| s.into_string()) + .collect::, _>>() + .map_err(|e| Box::new(rhai::EvalAltResult::ErrorRuntime(format!("SIGNATORIES must contain strings: {}", e).into(), context.position())))?; + + // Convert participants array to Vec + let participant_keys: Vec = participants.into_iter() + .map(|p| p.into_string()) + .collect::, _>>() + .map_err(|e| Box::new(rhai::EvalAltResult::ErrorRuntime(format!("Participants must be strings: {}", e).into(), context.position())))?; + + // Verify at least one participant is a signatory + let has_signatory = participant_keys.iter().any(|p| signatories.contains(p)); + if !has_signatory { + return Err(Box::new(rhai::EvalAltResult::ErrorRuntime( + format!("Access denied: none of the participants are signatories").into(), + context.position() + ))); + } + + // Create context directly with participants + OsirisContext::builder() + .participants(participant_keys) + .build() + .map_err(|e| format!("Failed to create context: {}", e).into()) + }); + } +} + +/// Create a single OSIRIS engine (for backward compatibility) +pub fn create_osiris_engine() -> Result> { + let mut engine = Engine::new_raw(); + let package = OsirisPackage::new(); + package.register_into_engine(&mut engine); + Ok(engine) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_osiris_engine() { + let result = create_osiris_engine(); + assert!(result.is_ok()); + + let mut engine = result.unwrap(); + + // Set up context tags with SIGNATORIES (like in runner_rust example) + let mut tag_map = rhai::Map::new(); + // Create a proper Rhai array + let signatories: rhai::Array = vec![ + rhai::Dynamic::from("pk1".to_string()), + rhai::Dynamic::from("pk2".to_string()), + rhai::Dynamic::from("pk3".to_string()), + ]; + tag_map.insert("SIGNATORIES".into(), rhai::Dynamic::from(signatories)); + tag_map.insert("DB_PATH".into(), "/tmp/test_db".to_string().into()); + tag_map.insert("CONTEXT_ID".into(), "test_context".to_string().into()); + engine.set_default_tag(rhai::Dynamic::from(tag_map)); + + // Test get_context with valid signatories + let mut scope = rhai::Scope::new(); + let test_result = engine.eval_with_scope::( + &mut scope, + r#" + // All participants must be signatories + let ctx = get_context(["pk1", "pk2"]); + ctx.context_id() + "# + ); + + if let Err(ref e) = test_result { + eprintln!("Test error: {}", e); + } + assert!(test_result.is_ok(), "Failed to get context: {:?}", test_result.err()); + assert_eq!(test_result.unwrap().to_string(), "pk1,pk2"); + } + + #[test] + fn test_engine_with_manager_access_denied() { + let result = create_osiris_engine(); + assert!(result.is_ok()); + + let mut engine = result.unwrap(); + + // Set up context tags with SIGNATORIES + let mut tag_map = rhai::Map::new(); + // Create a proper Rhai array + let signatories: rhai::Array = vec![ + rhai::Dynamic::from("pk1".to_string()), + rhai::Dynamic::from("pk2".to_string()), + ]; + tag_map.insert("SIGNATORIES".into(), rhai::Dynamic::from(signatories)); + tag_map.insert("DB_PATH".into(), "/tmp/test_db".to_string().into()); + tag_map.insert("CONTEXT_ID".into(), "test_context".to_string().into()); + engine.set_default_tag(rhai::Dynamic::from(tag_map)); + + // Test get_context with invalid participant (not a signatory) + let mut scope = rhai::Scope::new(); + let test_result = engine.eval_with_scope::( + &mut scope, + r#" + // pk3 is not a signatory, should fail + let ctx = get_context(["pk1", "pk3"]); + ctx.context_id() + "# + ); + + // Should fail because pk3 is not in SIGNATORIES + assert!(test_result.is_err()); + let err_msg = test_result.unwrap_err().to_string(); + assert!(err_msg.contains("Access denied") || err_msg.contains("not a signatory")); + } +} diff --git a/src/index/field_index.rs b/src/index/field_index.rs index bb09099..a9df1c0 100644 --- a/src/index/field_index.rs +++ b/src/index/field_index.rs @@ -2,7 +2,7 @@ use crate::error::Result; use crate::store::{HeroDbClient, OsirisObject}; /// Field indexing for fast filtering by tags and metadata -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct FieldIndex { client: HeroDbClient, } diff --git a/src/lib.rs b/src/lib.rs index f72e467..ba519d3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,14 +8,21 @@ pub mod interfaces; pub mod objects; pub mod retrieve; pub mod store; -pub mod rhai; + +// Rhai integration modules (top-level) +pub mod context; +pub mod engine; pub use error::{Error, Result}; pub use store::{BaseData, IndexKey, Object, Storable}; pub use objects::{Event, Note}; // OsirisContext is the main type for Rhai integration -pub use rhai::{OsirisContext, OsirisInstance}; +pub use context::{OsirisContext, OsirisInstance, OsirisContextBuilder}; +pub use engine::{ + create_osiris_engine, + OsirisPackage, +}; // Re-export the derive macro pub use osiris_derive::Object as DeriveObject; diff --git a/src/rhai/builder.rs b/src/rhai/builder.rs deleted file mode 100644 index fe8cca1..0000000 --- a/src/rhai/builder.rs +++ /dev/null @@ -1,188 +0,0 @@ -/// Builder for OsirisContext - -use super::OsirisContext; -use crate::store::{GenericStore, HeroDbClient, TypeRegistry}; -use std::sync::Arc; - -/// Builder for OsirisContext -pub struct OsirisContextBuilder { - participants: Option>, - herodb_url: Option, - db_id: Option, - registry: Option>, -} - -impl OsirisContextBuilder { - /// Create a new builder - pub fn new() -> Self { - Self { - participants: None, - herodb_url: None, - db_id: None, - registry: 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 - } - - /// Set the type registry - pub fn registry(mut self, registry: Arc) -> Self { - self.registry = Some(registry); - self - } - - /// Build the OsirisContext - pub fn build(self) -> Result> { - let participants = self.participants.ok_or("Context participants are required")?; - let herodb_url = self.herodb_url.ok_or("HeroDB URL is required")?; - let db_id = self.db_id.ok_or("Database ID is required")?; - - 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 with optional registry - let store = if let Some(reg) = self.registry { - GenericStore::with_registry(client, reg) - } else { - 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_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_with_registry() { - let registry = Arc::new(TypeRegistry::new()); - - let ctx = OsirisContextBuilder::new() - .name("test_context") - .herodb_url("redis://localhost:6379") - .db_id(1) - .registry(registry) - .build(); - - assert!(ctx.is_ok()); - } - - #[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")); - } - - #[test] - fn test_builder_missing_url() { - let ctx = OsirisContextBuilder::new() - .name("test_context") - .db_id(1) - .build(); - - assert!(ctx.is_err()); - assert!(ctx.unwrap_err().to_string().contains("HeroDB URL is required")); - } - - #[test] - fn test_builder_missing_db_id() { - let ctx = OsirisContextBuilder::new() - .name("test_context") - .herodb_url("redis://localhost:6379") - .build(); - - assert!(ctx.is_err()); - assert!(ctx.unwrap_err().to_string().contains("Database ID is required")); - } - - #[test] - fn test_builder_fluent_api() { - // Test that builder methods can be chained - let result = OsirisContextBuilder::new() - .name("ctx1") - .owner("owner1") - .herodb_url("redis://localhost:6379") - .db_id(1) - .build(); - - assert!(result.is_ok()); - } -} diff --git a/src/rhai/engine.rs b/src/rhai/engine.rs deleted file mode 100644 index 8e0ea5c..0000000 --- a/src/rhai/engine.rs +++ /dev/null @@ -1,117 +0,0 @@ -/// OSIRIS Rhai Engine -/// -/// Creates a Rhai engine configured with OSIRIS contexts and methods. - -use super::{OsirisContext, register_context_api}; -use rhai::Engine; -use std::collections::HashMap; - -/// Create a Rhai engine with get_context function -/// This allows dynamic context creation via get_context() in Rhai scripts -pub fn create_osiris_engine( - herodb_url: &str, - base_db_id: u16, -) -> Result> { - let mut engine = Engine::new(); - - // Register OsirisContext type - engine.build_type::(); - - // Register all OSIRIS functions (Note, Event, etc.) - super::register_osiris_functions(&mut engine); - - // Register get_context function - register_context_api(&mut engine, herodb_url.to_string(), base_db_id); - - Ok(engine) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_create_osiris_engine() { - let result = create_osiris_engine("redis://localhost:6379", 1); - assert!(result.is_ok()); - - let mut engine = result.unwrap(); - - // Set up context tags with SIGNATORIES (like in runner_rust example) - let mut tag_map = rhai::Map::new(); - // Create a proper Rhai array - let signatories: rhai::Array = vec![ - rhai::Dynamic::from("pk1".to_string()), - rhai::Dynamic::from("pk2".to_string()), - rhai::Dynamic::from("pk3".to_string()), - ]; - tag_map.insert("SIGNATORIES".into(), rhai::Dynamic::from(signatories)); - tag_map.insert("DB_PATH".into(), "/tmp/test_db".to_string().into()); - tag_map.insert("CONTEXT_ID".into(), "test_context".to_string().into()); - engine.set_default_tag(rhai::Dynamic::from(tag_map)); - - // Test get_context with valid signatories - let mut scope = rhai::Scope::new(); - let test_result = engine.eval_with_scope::( - &mut scope, - r#" - // All participants must be signatories - let ctx = get_context(["pk1", "pk2"]); - ctx.context_id() - "# - ); - - if let Err(ref e) = test_result { - eprintln!("Test error: {}", e); - } - assert!(test_result.is_ok(), "Failed to get context: {:?}", test_result.err()); - assert_eq!(test_result.unwrap().to_string(), "pk1,pk2"); - } - - #[test] - fn test_engine_with_manager_access_denied() { - let result = create_osiris_engine("redis://localhost:6379", 1); - assert!(result.is_ok()); - - let mut engine = result.unwrap(); - - // Set up context tags with SIGNATORIES - let mut tag_map = rhai::Map::new(); - // Create a proper Rhai array - let signatories: rhai::Array = vec![ - rhai::Dynamic::from("pk1".to_string()), - rhai::Dynamic::from("pk2".to_string()), - ]; - tag_map.insert("SIGNATORIES".into(), rhai::Dynamic::from(signatories)); - tag_map.insert("DB_PATH".into(), "/tmp/test_db".to_string().into()); - tag_map.insert("CONTEXT_ID".into(), "test_context".to_string().into()); - engine.set_default_tag(rhai::Dynamic::from(tag_map)); - - // Test get_context with invalid participant (not a signatory) - let mut scope = rhai::Scope::new(); - let test_result = engine.eval_with_scope::( - &mut scope, - r#" - // pk3 is not a signatory, should fail - let ctx = get_context(["pk1", "pk3"]); - ctx.context_id() - "# - ); - - // Should fail because pk3 is not in SIGNATORIES - assert!(test_result.is_err()); - let err_msg = test_result.unwrap_err().to_string(); - assert!(err_msg.contains("Access denied") || err_msg.contains("not a signatory")); - } - - #[test] - fn test_engine_context_operations() { - let result = create_osiris_engine("owner", "redis://localhost:6379", 1); - assert!(result.is_ok()); - - let (_engine, scope) = result.unwrap(); - - // Just verify the scope has the default context - assert_eq!(scope.len(), 1); - } -} diff --git a/src/rhai/mod.rs b/src/rhai/mod.rs deleted file mode 100644 index 0a1c637..0000000 --- a/src/rhai/mod.rs +++ /dev/null @@ -1,29 +0,0 @@ -/// Rhai integration for OSIRIS -/// -/// Provides OsirisContext - a complete context with HeroDB storage and member management. - -mod builder; -mod instance; -pub mod engine; - -use crate::objects::note::rhai::register_note_api; -use crate::objects::event::rhai::register_event_api; - -// Main exports -pub use builder::OsirisContextBuilder; -pub use instance::{ - OsirisContext, - OsirisInstance, - register_context_api, -}; - -pub use engine::{ - create_osiris_engine, -}; - -/// Register all OSIRIS functions (Note, Event, etc.) in a Rhai engine -/// This does NOT include context management - use register_context_api for that -pub fn register_osiris_functions(engine: &mut rhai::Engine) { - register_note_api(engine); - register_event_api(engine); -} diff --git a/src/store/generic_store.rs b/src/store/generic_store.rs index e9a2975..669e163 100644 --- a/src/store/generic_store.rs +++ b/src/store/generic_store.rs @@ -1,14 +1,12 @@ use crate::error::Result; use crate::index::FieldIndex; -use crate::store::{HeroDbClient, Object, TypeRegistry}; -use std::sync::Arc; +use crate::store::{HeroDbClient, Object}; /// Generic storage layer for OSIRIS objects -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct GenericStore { client: HeroDbClient, index: FieldIndex, - type_registry: Option>, } impl GenericStore { @@ -18,25 +16,9 @@ impl GenericStore { Self { client, index, - type_registry: None, } } - /// Create a new generic store with a type registry - pub fn with_registry(client: HeroDbClient, registry: Arc) -> Self { - let index = FieldIndex::new(client.clone()); - Self { - client, - index, - type_registry: Some(registry), - } - } - - /// Set the type registry - pub fn set_registry(&mut self, registry: Arc) { - self.type_registry = Some(registry); - } - /// Store an object pub async fn put(&self, obj: &T) -> Result<()> { // Serialize object to JSON @@ -68,11 +50,6 @@ impl GenericStore { .ok_or_else(|| crate::error::Error::NotFound(format!("Object {}:{}", ns, id))) } - /// Get the type registry if configured - pub fn type_registry(&self) -> Option> { - self.type_registry.clone() - } - /// Delete an object pub async fn delete(&self, obj: &T) -> Result { let key = format!("obj:{}:{}", obj.namespace(), obj.id()); diff --git a/src/store/mod.rs b/src/store/mod.rs index f1ac115..b149724 100644 --- a/src/store/mod.rs +++ b/src/store/mod.rs @@ -2,12 +2,10 @@ pub mod base_data; pub mod object_trait; pub mod herodb_client; pub mod generic_store; -pub mod type_registry; pub mod object; // Keep old implementation for backwards compat temporarily pub use base_data::BaseData; pub use object_trait::{IndexKey, Object, Storable}; pub use herodb_client::HeroDbClient; pub use generic_store::GenericStore; -pub use type_registry::TypeRegistry; pub use object::{Metadata, OsirisObject}; // Old implementation diff --git a/src/store/type_registry.rs b/src/store/type_registry.rs deleted file mode 100644 index 7d67ca5..0000000 --- a/src/store/type_registry.rs +++ /dev/null @@ -1,216 +0,0 @@ -/// Type Registry for OSIRIS -/// -/// Maps collection names to Rust types so that save() can use the correct struct. -/// Each type must implement Object trait for proper indexing. - -use crate::error::Result; -use crate::store::{GenericStore, Object}; -use std::collections::HashMap; -use std::sync::{Arc, RwLock}; - -/// Type deserializer: takes JSON and returns a typed Object -pub type TypeDeserializer = Arc Result> + Send + Sync>; - -/// Type saver: takes the Any box and saves it using the correct type -pub type TypeSaver = Arc) -> Result<()> + Send + Sync>; - -/// Registry of types mapped to collection names -#[derive(Clone)] -pub struct TypeRegistry { - deserializers: Arc>>, - savers: Arc>>, -} - -impl std::fmt::Debug for TypeRegistry { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("TypeRegistry") - .field("collections", &self.list_collections()) - .finish() - } -} - -impl TypeRegistry { - /// Create a new empty type registry - pub fn new() -> Self { - Self { - deserializers: Arc::new(RwLock::new(HashMap::new())), - savers: Arc::new(RwLock::new(HashMap::new())), - } - } - - /// Register a type for a collection - /// - /// Example: - /// ```rust - /// registry.register_type::("residents"); - /// registry.register_type::("companies"); - /// ``` - pub fn register_type(&self, collection: impl ToString) -> Result<()> - where - T: Object + serde::de::DeserializeOwned + 'static, - { - let collection_str = collection.to_string(); - - // Deserializer: JSON -> Box - let deserializer: TypeDeserializer = Arc::new(move |json: &str| { - let obj: T = serde_json::from_str(json) - .map_err(|e| crate::error::Error::from(e))?; - Ok(Box::new(obj) as Box) - }); - - // Saver: Box -> store.put() - let saver: TypeSaver = Arc::new(move |store: &GenericStore, any: Box| { - let obj = any.downcast::() - .map_err(|_| crate::error::Error::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, "Failed to downcast object")))?; - - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async move { - store.put(&*obj).await - }) - }) - }); - - // Register both - self.deserializers.write() - .map_err(|e| crate::error::Error::Io(std::io::Error::new(std::io::ErrorKind::Other, format!("Failed to acquire write lock: {}", e))))? - .insert(collection_str.clone(), deserializer); - - self.savers.write() - .map_err(|e| crate::error::Error::Io(std::io::Error::new(std::io::ErrorKind::Other, format!("Failed to acquire write lock: {}", e))))? - .insert(collection_str, saver); - - Ok(()) - } - - /// Generic save function - uses registry to determine type - pub fn save(&self, store: &GenericStore, collection: &str, _id: &str, json: &str) -> Result<()> { - // Get deserializer for this collection - let deserializers = self.deserializers.read() - .map_err(|e| crate::error::Error::Io(std::io::Error::new(std::io::ErrorKind::Other, format!("Failed to acquire read lock: {}", e))))?; - - let deserializer = deserializers.get(collection) - .ok_or_else(|| crate::error::Error::NotFound(format!("No type registered for collection: {}", collection)))?; - - // Deserialize JSON to typed object (as Any) - let any_obj = deserializer(json)?; - - // Get saver for this collection - let savers = self.savers.read() - .map_err(|e| crate::error::Error::Io(std::io::Error::new(std::io::ErrorKind::Other, format!("Failed to acquire read lock: {}", e))))?; - - let saver = savers.get(collection) - .ok_or_else(|| crate::error::Error::NotFound(format!("No saver registered for collection: {}", collection)))?; - - // Save using the correct type - saver(store, any_obj) - } - - /// Check if a collection has a registered type - pub fn has_type(&self, collection: &str) -> bool { - self.deserializers.read() - .map(|d| d.contains_key(collection)) - .unwrap_or(false) - } - - /// List all registered collections - pub fn list_collections(&self) -> Vec { - self.deserializers.read() - .map(|d| d.keys().cloned().collect()) - .unwrap_or_default() - } -} - -impl Default for TypeRegistry { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::objects::Note; - use tokio::runtime::Runtime; - - #[test] - fn test_registry_creation() { - let registry = TypeRegistry::new(); - assert!(!registry.has_type("notes")); - assert_eq!(registry.list_collections().len(), 0); - } - - #[test] - fn test_register_type() { - let registry = TypeRegistry::new(); - - // Register Note type - let result = registry.register_type::("notes"); - assert!(result.is_ok()); - - // Check it's registered - assert!(registry.has_type("notes")); - assert_eq!(registry.list_collections().len(), 1); - assert!(registry.list_collections().contains(&"notes".to_string())); - } - - #[test] - fn test_register_multiple_types() { - let registry = TypeRegistry::new(); - - registry.register_type::("notes").unwrap(); - registry.register_type::("drafts").unwrap(); // Same type, different collection - - assert!(registry.has_type("notes")); - assert!(registry.has_type("drafts")); - assert_eq!(registry.list_collections().len(), 2); - } - - #[test] - fn test_save_with_registry() { - let registry = TypeRegistry::new(); - registry.register_type::("notes").unwrap(); - - // Verify the type is registered - assert!(registry.has_type("notes")); - - // Note: Actual save test would require a running Redis instance - // The registration itself proves the type system works - } - - #[test] - fn test_save_unregistered_collection() { - let registry = TypeRegistry::new(); - - // Verify unregistered collection is not found - assert!(!registry.has_type("unknown")); - - // Note: Actual save test would require a running Redis instance - } - - #[test] - fn test_list_collections() { - let registry = TypeRegistry::new(); - - registry.register_type::("notes").unwrap(); - registry.register_type::("drafts").unwrap(); - registry.register_type::("archive").unwrap(); - - let collections = registry.list_collections(); - assert_eq!(collections.len(), 3); - assert!(collections.contains(&"notes".to_string())); - assert!(collections.contains(&"drafts".to_string())); - assert!(collections.contains(&"archive".to_string())); - } - - #[test] - fn test_has_type() { - let registry = TypeRegistry::new(); - - assert!(!registry.has_type("notes")); - - registry.register_type::("notes").unwrap(); - - assert!(registry.has_type("notes")); - assert!(!registry.has_type("other")); - } -}