Refactor Rhai integration with context-based execution and type registry

Major Changes:
- Moved Rhai support from rhai_support/ to rhai/ module
- Implemented context-based execution with signatory access control
- Added TypeRegistry for dynamic type registration and object creation
- Refactored engine to use context (Vec<String>) instead of instance
- Removed old runner binary (moved to runner_rust crate)

Rhai Module:
- engine.rs: Core Rhai engine with context-based get_context()
- functions.rs: Rhai function bindings (create_note, create_event, etc.)
- mod.rs: Module exports and organization

Store Improvements:
- TypeRegistry for registering object types and creators
- Generic store uses type registry for dynamic object creation
- Improved error handling and type safety

Documentation:
- RHAI_REFACTOR_COMPLETE.md: Refactoring details
- SIGNATORY_ACCESS_CONTROL.md: Context-based access control
- TYPE_REGISTRY_DESIGN.md: Type registry architecture
- REFACTORING_COMPLETE.md: Overall refactoring summary
- TESTS_COMPLETE.md: Testing documentation

Build Status:  Compiles successfully with minor warnings
This commit is contained in:
Timur Gordon
2025-10-28 03:33:39 +01:00
parent 097360ad12
commit e04012c8c0
24 changed files with 1810 additions and 249 deletions

View File

@@ -6,19 +6,14 @@
/// Usage:
/// ```bash
/// # Script mode
/// cargo run --bin runner --features rhai-support -- runner1 --script "let note = note('test').title('Hi'); put_note(note);"
/// cargo run --bin runner -- runner1 --script "let note = note('test').title('Hi'); put_note(note);"
///
/// # Daemon mode (requires runner_rust infrastructure)
/// cargo run --bin runner --features rhai-support -- runner1 --redis-url redis://localhost:6379
/// cargo run --bin runner -- runner1 --redis-url redis://localhost:6379
/// ```
use clap::Parser;
#[cfg(feature = "rhai-support")]
mod engine;
#[cfg(feature = "rhai-support")]
use engine::create_osiris_engine;
use osiris::rhai::{OsirisEngineConfig, create_osiris_engine_with_config};
#[derive(Parser, Debug)]
#[command(author, version, about = "OSIRIS Rhai Script Runner", long_about = None)]
@@ -48,14 +43,6 @@ struct Args {
instances: Vec<String>,
}
#[cfg(not(feature = "rhai-support"))]
fn main() -> Result<(), Box<dyn std::error::Error>> {
eprintln!("❌ Error: OSIRIS runner requires the 'rhai-support' feature");
eprintln!("Run with: cargo run --bin runner --features rhai-support");
std::process::exit(1);
}
#[cfg(feature = "rhai-support")]
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize logging
env_logger::init();
@@ -67,11 +54,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("HeroDB: {} (DB {})", args.redis_url, args.db_id);
// Parse predefined instances
let mut config = engine::OsirisConfig::new();
let mut config = OsirisEngineConfig::new();
if args.instances.is_empty() {
// No predefined instances, use default
config.add_instance("default", &args.redis_url, args.db_id);
// 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
@@ -93,8 +80,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let db_id: u16 = db_id_str.parse()
.map_err(|_| format!("Invalid db_id in instance '{}': {}", instance_def, db_id_str))?;
config.add_instance(name, url, db_id);
println!(" Instance: {}{} (DB {})", name, url, db_id);
config.add_context(name, &args.runner_id, url, db_id);
println!(" Context: {}{} (DB {})", name, url, db_id);
}
}
@@ -113,8 +100,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("📝 Executing script...\n");
println!("─────────────────────────────────────");
// Create engine with predefined instances
let (engine, mut scope) = engine::create_osiris_engine_with_config(config)?;
// Create engine with predefined contexts
let (engine, mut scope) = create_osiris_engine_with_config(config)?;
match engine.eval_with_scope::<rhai::Dynamic>(&mut scope, &script_content) {
Ok(result) => {

View File

@@ -1,73 +0,0 @@
/// OSIRIS Engine Factory
///
/// Creates a Rhai engine configured with OSIRIS objects and methods.
use osiris::rhai_support::{register_note_api, register_event_api, OsirisInstance};
use rhai::Engine;
use std::collections::HashMap;
#[allow(dead_code)]
/// Configuration for multiple OSIRIS instances
pub struct OsirisConfig {
pub instances: HashMap<String, (String, u16)>, // name -> (url, db_id)
}
impl OsirisConfig {
pub fn new() -> Self {
Self {
instances: HashMap::new(),
}
}
pub fn add_instance(&mut self, name: impl ToString, url: impl ToString, db_id: u16) {
self.instances.insert(name.to_string(), (url.to_string(), db_id));
}
pub fn single(url: impl ToString, db_id: u16) -> Self {
let mut config = Self::new();
config.add_instance("default", url, db_id);
config
}
}
/// Create a new Rhai engine with OSIRIS support
pub fn create_osiris_engine(
herodb_url: &str,
db_id: u16,
) -> Result<(Engine, rhai::Scope<'static>), Box<dyn std::error::Error>> {
let config = OsirisConfig::single(herodb_url, db_id);
create_osiris_engine_with_config(config)
}
/// Create a new Rhai engine with multiple OSIRIS instances
/// Returns (Engine, Scope) where Scope contains predefined instances
pub fn create_osiris_engine_with_config(
config: OsirisConfig,
) -> Result<(Engine, rhai::Scope<'static>), Box<dyn std::error::Error>> {
let mut engine = Engine::new();
// Register Note API
register_note_api(&mut engine);
// Register Event API
register_event_api(&mut engine);
// Register OsirisInstance type
engine.build_type::<OsirisInstance>();
// Register osiris() constructor function for dynamic creation
engine.register_fn("osiris", |name: &str, url: &str, db_id: rhai::INT| -> Result<OsirisInstance, Box<rhai::EvalAltResult>> {
OsirisInstance::new(name, url, db_id as u16)
.map_err(|e| format!("Failed to create OSIRIS instance: {}", e).into())
});
// Create predefined instances and inject them as global constants in scope
let mut scope = rhai::Scope::new();
for (name, (url, db_id)) in config.instances {
let instance = OsirisInstance::new(&name, &url, db_id)?;
scope.push_constant(&name, instance);
}
Ok((engine, scope))
}

View File

@@ -2,6 +2,7 @@ use crate::error::Result;
use crate::store::{HeroDbClient, OsirisObject};
/// Field indexing for fast filtering by tags and metadata
#[derive(Debug)]
pub struct FieldIndex {
client: HeroDbClient,
}

View File

@@ -8,16 +8,14 @@ pub mod interfaces;
pub mod objects;
pub mod retrieve;
pub mod store;
#[cfg(feature = "rhai-support")]
pub mod rhai_support;
pub mod rhai;
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};
// Re-export the derive macro
pub use osiris_derive::Object as DeriveObject;
// OsirisInstance is the main type for Rhai integration
#[cfg(feature = "rhai-support")]
pub use rhai_support::OsirisInstance;

View File

@@ -2,7 +2,6 @@ use crate::store::BaseData;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
#[cfg(feature = "rhai-support")]
pub mod rhai;
/// Event status

View File

@@ -34,8 +34,15 @@ impl CustomType for Event {
pub fn register_event_api(engine: &mut Engine) {
engine.build_type::<Event>();
// Register builder-style constructor
engine.register_fn("event", |ns: String, title: String| Event::new(ns, title));
// Register builder-style constructor (namespace only, like note())
engine.register_fn("event", |ns: String| Event::new(ns, String::new()));
// Register title as a chainable method
engine.register_fn("title", |mut event: Event, title: String| {
event.title = title;
event.base_data.update_modified();
event
});
// Register chainable methods that return Self
engine.register_fn("description", |mut event: Event, desc: String| {

View File

@@ -2,7 +2,6 @@ use crate::store::BaseData;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[cfg(feature = "rhai-support")]
pub mod rhai;
/// A simple note object

188
src/rhai/builder.rs Normal file
View File

@@ -0,0 +1,188 @@
/// Builder for OsirisContext
use super::OsirisContext;
use crate::store::{GenericStore, HeroDbClient, TypeRegistry};
use std::sync::Arc;
/// Builder for OsirisContext
pub struct OsirisContextBuilder {
participants: Option<Vec<String>>,
herodb_url: Option<String>,
db_id: Option<u16>,
registry: Option<Arc<TypeRegistry>>,
}
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<String>) -> 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<TypeRegistry>) -> Self {
self.registry = Some(registry);
self
}
/// Build the OsirisContext
pub fn build(self) -> Result<OsirisContext, Box<dyn std::error::Error>> {
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());
}
}

117
src/rhai/engine.rs Normal file
View File

@@ -0,0 +1,117 @@
/// 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<Engine, Box<dyn std::error::Error>> {
let mut engine = Engine::new();
// Register OsirisContext type
engine.build_type::<OsirisContext>();
// 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::<rhai::Dynamic>(
&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::<rhai::Dynamic>(
&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);
}
}

392
src/rhai/instance.rs Normal file
View File

@@ -0,0 +1,392 @@
/// OSIRIS Context for Rhai
///
/// 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 super::builder::OsirisContextBuilder;
use crate::objects::Note;
use crate::store::GenericStore;
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<rhai::Dynamic, String> {
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<Vec<rhai::Dynamic>, 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))
}
}
}
/// 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<String>, // Public keys of all participants in this context
pub(crate) store: Arc<GenericStore>,
}
// 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<Self, Box<dyn std::error::Error>> {
OsirisContextBuilder::new()
.name(name)
.herodb_url(herodb_url)
.db_id(db_id)
.build()
}
/// Get the context participants (public keys)
pub fn participants(&self) -> Vec<String> {
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<String, Box<EvalAltResult>> {
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
// 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
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(&note).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<rhai::Dynamic, Box<EvalAltResult>> {
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<bool, Box<EvalAltResult>> {
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::<Note>(&collection, &id).await {
Ok(note) => {
// Exists, now delete it
store.delete(&note).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<bool, Box<EvalAltResult>> {
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::<Note>(&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<Vec<rhai::Dynamic>, Box<EvalAltResult>> {
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<Vec<rhai::Dynamic>, Box<EvalAltResult>> {
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<String, Box<EvalAltResult>> {
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(&note).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<String, Box<EvalAltResult>> {
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<Self>) {
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));
}
}
// ============================================================================
// Context Creation - Standalone function
// ============================================================================
/// 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<OsirisContext, Box<rhai::EvalAltResult>> {
// Extract SIGNATORIES from context tag
let tag_map = context
.tag()
.and_then(|tag| tag.read_lock::<rhai::Map>())
.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<String>
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<String> = signatories_array.into_iter()
.map(|s| s.into_string())
.collect::<Result<Vec<_>, _>>()
.map_err(|e| Box::new(rhai::EvalAltResult::ErrorRuntime(format!("SIGNATORIES must contain strings: {}", e).into(), context.position())))?;
// Convert participants array to Vec<String>
let participant_keys: Vec<String> = participants.into_iter()
.map(|p| p.into_string())
.collect::<Result<Vec<_>, _>>()
.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)
.herodb_url(&herodb_url)
.db_id(base_db_id)
.build()
.map_err(|e| format!("Failed to create context: {}", e).into())
});
}
#[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_context_save_and_get() {
let ctx = OsirisContext::new("test_ctx", "redis://localhost:6379", 1).unwrap();
// 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());
}
#[test]
fn test_context_delete() {
let ctx = OsirisContext::new("test_ctx", "redis://localhost:6379", 1).unwrap();
// 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());
}
}

29
src/rhai/mod.rs Normal file
View File

@@ -0,0 +1,29 @@
/// 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);
}

View File

@@ -1,121 +0,0 @@
/// OSIRIS Instance for Rhai
///
/// Represents a named OSIRIS instance that can be used in Rhai scripts.
/// Multiple instances can coexist, each with their own HeroDB connection.
use crate::objects::{Event, Note};
use crate::store::{GenericStore, HeroDbClient};
use rhai::{CustomType, EvalAltResult, TypeBuilder};
use std::sync::Arc;
use tokio::runtime::Runtime;
/// A named OSIRIS instance for use in Rhai scripts
#[derive(Clone)]
pub struct OsirisInstance {
name: String,
store: Arc<GenericStore>,
runtime: Arc<Runtime>,
}
impl OsirisInstance {
/// Create a new OSIRIS instance
pub fn new(name: impl ToString, herodb_url: &str, db_id: u16) -> Result<Self, Box<dyn std::error::Error>> {
let client = HeroDbClient::new(herodb_url, db_id)?;
let store = GenericStore::new(client);
let runtime = Runtime::new()?;
Ok(Self {
name: name.to_string(),
store: Arc::new(store),
runtime: Arc::new(runtime),
})
}
/// Get the instance name
pub fn name(&self) -> String {
self.name.clone()
}
/// Put a Note object
pub fn put_note(&self, note: Note) -> Result<String, Box<EvalAltResult>> {
let store = self.store.clone();
let id = note.base_data.id.clone();
self.runtime
.block_on(async move { store.put(&note).await })
.map_err(|e| format!("[{}] Failed to put note: {}", self.name, e).into())
.map(|_| id)
}
/// Get a Note object by ID
pub fn get_note(&self, ns: String, id: String) -> Result<Note, Box<EvalAltResult>> {
let store = self.store.clone();
self.runtime
.block_on(async move { store.get::<Note>(&ns, &id).await })
.map_err(|e| format!("[{}] Failed to get note: {}", self.name, e).into())
}
/// Put an Event object
pub fn put_event(&self, event: Event) -> Result<String, Box<EvalAltResult>> {
let store = self.store.clone();
let id = event.base_data.id.clone();
self.runtime
.block_on(async move { store.put(&event).await })
.map_err(|e| format!("[{}] Failed to put event: {}", self.name, e).into())
.map(|_| id)
}
/// Get an Event object by ID
pub fn get_event(&self, ns: String, id: String) -> Result<Event, Box<EvalAltResult>> {
let store = self.store.clone();
self.runtime
.block_on(async move { store.get::<Event>(&ns, &id).await })
.map_err(|e| format!("[{}] Failed to get event: {}", self.name, e).into())
}
/// Query by index
pub fn query(&self, ns: String, field: String, value: String) -> Result<rhai::Array, Box<EvalAltResult>> {
let store = self.store.clone();
self.runtime
.block_on(async move { store.get_ids_by_index(&ns, &field, &value).await })
.map(|ids| ids.into_iter().map(rhai::Dynamic::from).collect())
.map_err(|e| format!("[{}] Failed to query: {}", self.name, e).into())
}
/// Delete a Note
pub fn delete_note(&self, note: Note) -> Result<bool, Box<EvalAltResult>> {
let store = self.store.clone();
self.runtime
.block_on(async move { store.delete(&note).await })
.map_err(|e| format!("[{}] Failed to delete note: {}", self.name, e).into())
}
/// Delete an Event
pub fn delete_event(&self, event: Event) -> Result<bool, Box<EvalAltResult>> {
let store = self.store.clone();
self.runtime
.block_on(async move { store.delete(&event).await })
.map_err(|e| format!("[{}] Failed to delete event: {}", self.name, e).into())
}
}
impl CustomType for OsirisInstance {
fn build(mut builder: TypeBuilder<Self>) {
builder
.with_name("OsirisInstance")
.with_fn("name", |instance: &mut OsirisInstance| instance.name())
.with_fn("put_note", |instance: &mut OsirisInstance, note: Note| instance.put_note(note))
.with_fn("get_note", |instance: &mut OsirisInstance, ns: String, id: String| instance.get_note(ns, id))
.with_fn("put_event", |instance: &mut OsirisInstance, event: Event| instance.put_event(event))
.with_fn("get_event", |instance: &mut OsirisInstance, ns: String, id: String| instance.get_event(ns, id))
.with_fn("query", |instance: &mut OsirisInstance, ns: String, field: String, value: String| instance.query(ns, field, value))
.with_fn("delete_note", |instance: &mut OsirisInstance, note: Note| instance.delete_note(note))
.with_fn("delete_event", |instance: &mut OsirisInstance, event: Event| instance.delete_event(event));
}
}

View File

@@ -1,12 +0,0 @@
/// Rhai support for OSIRIS
///
/// This module provides Rhai integration infrastructure for OSIRIS.
/// Object-specific Rhai support is located in each object's module (e.g., objects/note/rhai.rs).
pub mod instance;
pub use instance::OsirisInstance;
// Re-export registration functions from object modules
pub use crate::objects::note::rhai::register_note_api;
pub use crate::objects::event::rhai::register_event_api;

View File

@@ -1,18 +1,40 @@
use crate::error::Result;
use crate::index::FieldIndex;
use crate::store::{HeroDbClient, Object};
use crate::store::{HeroDbClient, Object, TypeRegistry};
use std::sync::Arc;
/// Generic storage layer for OSIRIS objects
#[derive(Debug)]
pub struct GenericStore {
client: HeroDbClient,
index: FieldIndex,
type_registry: Option<Arc<TypeRegistry>>,
}
impl GenericStore {
/// Create a new generic store
pub fn new(client: HeroDbClient) -> Self {
let index = FieldIndex::new(client.clone());
Self { client, index }
Self {
client,
index,
type_registry: None,
}
}
/// Create a new generic store with a type registry
pub fn with_registry(client: HeroDbClient, registry: Arc<TypeRegistry>) -> 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<TypeRegistry>) {
self.type_registry = Some(registry);
}
/// Store an object
@@ -39,6 +61,18 @@ impl GenericStore {
T::from_json(&json)
}
/// Get raw JSON data by ID (for generic access without type)
pub async fn get_raw(&self, ns: &str, id: &str) -> Result<String> {
let key = format!("obj:{}:{}", ns, id);
self.client.get(&key).await?
.ok_or_else(|| crate::error::Error::NotFound(format!("Object {}:{}", ns, id)))
}
/// Get the type registry if configured
pub fn type_registry(&self) -> Option<Arc<TypeRegistry>> {
self.type_registry.clone()
}
/// Delete an object
pub async fn delete<T: Object>(&self, obj: &T) -> Result<bool> {
let key = format!("obj:{}:{}", obj.namespace(), obj.id());

View File

@@ -4,7 +4,7 @@ use redis::aio::MultiplexedConnection;
use redis::{AsyncCommands, Client};
/// HeroDB client wrapper for OSIRIS operations
#[derive(Clone)]
#[derive(Clone, Debug)]
pub struct HeroDbClient {
client: Client,
pub db_id: u16,

View File

@@ -2,10 +2,12 @@ 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

View File

@@ -41,6 +41,11 @@ pub trait Object: Debug + Clone + Serialize + for<'de> Deserialize<'de> + Send +
&self.base_data().id
}
/// Set the unique ID for this object
fn set_id(&mut self, id: impl ToString) {
self.base_data_mut().id = id.to_string();
}
/// Get the namespace for this object
fn namespace(&self) -> &str {
&self.base_data().ns

216
src/store/type_registry.rs Normal file
View File

@@ -0,0 +1,216 @@
/// 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<dyn Fn(&str) -> Result<Box<dyn std::any::Any + Send>> + Send + Sync>;
/// Type saver: takes the Any box and saves it using the correct type
pub type TypeSaver = Arc<dyn Fn(&GenericStore, Box<dyn std::any::Any + Send>) -> Result<()> + Send + Sync>;
/// Registry of types mapped to collection names
#[derive(Clone)]
pub struct TypeRegistry {
deserializers: Arc<RwLock<HashMap<String, TypeDeserializer>>>,
savers: Arc<RwLock<HashMap<String, TypeSaver>>>,
}
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::<Resident>("residents");
/// registry.register_type::<Company>("companies");
/// ```
pub fn register_type<T>(&self, collection: impl ToString) -> Result<()>
where
T: Object + serde::de::DeserializeOwned + 'static,
{
let collection_str = collection.to_string();
// Deserializer: JSON -> Box<Any>
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<dyn std::any::Any + Send>)
});
// Saver: Box<Any> -> store.put()
let saver: TypeSaver = Arc::new(move |store: &GenericStore, any: Box<dyn std::any::Any + Send>| {
let obj = any.downcast::<T>()
.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<String> {
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::<Note>("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::<Note>("notes").unwrap();
registry.register_type::<Note>("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::<Note>("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::<Note>("notes").unwrap();
registry.register_type::<Note>("drafts").unwrap();
registry.register_type::<Note>("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::<Note>("notes").unwrap();
assert!(registry.has_type("notes"));
assert!(!registry.has_type("other"));
}
}