Files
osiris/docs/ARCHITECTURE.md
Timur Gordon 097360ad12 first commit
2025-10-20 22:24:25 +02:00

10 KiB

OSIRIS Architecture - Trait-Based Generic Objects

Overview

OSIRIS has been refactored to use a trait-based architecture similar to heromodels, allowing any object implementing the Object trait to be stored and indexed automatically based on field attributes.

Core Concepts

1. BaseData

Every OSIRIS object must include BaseData, which provides:

  • id: Unique identifier (UUID or user-assigned)
  • ns: Namespace the object belongs to
  • created_at: Creation timestamp
  • modified_at: Last modification timestamp
  • mime: Optional MIME type
  • size: Optional content size
pub struct BaseData {
    pub id: String,
    pub ns: String,
    pub created_at: OffsetDateTime,
    pub modified_at: OffsetDateTime,
    pub mime: Option<String>,
    pub size: Option<u64>,
}

2. Object Trait

The Object trait is the core abstraction for all OSIRIS objects:

pub trait Object: Debug + Clone + Serialize + Deserialize + Send + Sync {
    /// Get the object type name
    fn object_type() -> &'static str where Self: Sized;
    
    /// Get base data reference
    fn base_data(&self) -> &BaseData;
    
    /// Get mutable base data reference
    fn base_data_mut(&mut self) -> &mut BaseData;
    
    /// Get index keys for this object (auto-generated from #[index] fields)
    fn index_keys(&self) -> Vec<IndexKey>;
    
    /// Get list of indexed field names
    fn indexed_fields() -> Vec<&'static str> where Self: Sized;
    
    /// Get searchable text content
    fn searchable_text(&self) -> Option<String>;
    
    /// Serialize to JSON
    fn to_json(&self) -> Result<String>;
    
    /// Deserialize from JSON
    fn from_json(json: &str) -> Result<Self> where Self: Sized;
}

3. IndexKey

Represents an index entry for a field:

pub struct IndexKey {
    pub name: &'static str,  // Field name
    pub value: String,        // Field value
}

Example: Note Object

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Note {
    pub base_data: BaseData,
    
    // Indexed field - marked with #[index]
    #[index]
    pub title: Option<String>,
    
    // Searchable content (not indexed)
    pub content: Option<String>,
    
    // Indexed tags - marked with #[index]
    #[index]
    pub tags: BTreeMap<String, String>,
}

impl Object for Note {
    fn object_type() -> &'static str {
        "note"
    }
    
    fn base_data(&self) -> &BaseData {
        &self.base_data
    }
    
    fn base_data_mut(&mut self) -> &mut BaseData {
        &mut self.base_data
    }
    
    fn index_keys(&self) -> Vec<IndexKey> {
        let mut keys = Vec::new();
        
        // Index title
        if let Some(title) = &self.title {
            keys.push(IndexKey::new("title", title));
        }
        
        // Index tags
        for (key, value) in &self.tags {
            keys.push(IndexKey::new(&format!("tag:{}", key), value));
        }
        
        keys
    }
    
    fn indexed_fields() -> Vec<&'static str> {
        vec!["title", "tags"]
    }
    
    fn searchable_text(&self) -> Option<String> {
        let mut text = String::new();
        if let Some(title) = &self.title {
            text.push_str(title);
            text.push(' ');
        }
        if let Some(content) = &self.content {
            text.push_str(content);
        }
        if text.is_empty() { None } else { Some(text) }
    }
}

Example: Event Object

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
    pub base_data: BaseData,
    
    #[index]
    pub title: String,
    
    pub description: Option<String>,
    
    #[index]
    pub start_time: OffsetDateTime,
    
    pub end_time: OffsetDateTime,
    
    #[index]
    pub location: Option<String>,
    
    #[index]
    pub status: EventStatus,
    
    pub all_day: bool,
    
    #[index]
    pub category: Option<String>,
}

impl Object for Event {
    fn object_type() -> &'static str {
        "event"
    }
    
    fn base_data(&self) -> &BaseData {
        &self.base_data
    }
    
    fn base_data_mut(&mut self) -> &mut BaseData {
        &mut self.base_data
    }
    
    fn index_keys(&self) -> Vec<IndexKey> {
        let mut keys = Vec::new();
        
        keys.push(IndexKey::new("title", &self.title));
        
        if let Some(location) = &self.location {
            keys.push(IndexKey::new("location", location));
        }
        
        let status_str = match self.status {
            EventStatus::Draft => "draft",
            EventStatus::Published => "published",
            EventStatus::Cancelled => "cancelled",
        };
        keys.push(IndexKey::new("status", status_str));
        
        if let Some(category) = &self.category {
            keys.push(IndexKey::new("category", category));
        }
        
        // Index by date for day-based queries
        let date_str = self.start_time.date().to_string();
        keys.push(IndexKey::new("date", date_str));
        
        keys
    }
    
    fn indexed_fields() -> Vec<&'static str> {
        vec!["title", "location", "status", "category", "start_time"]
    }
    
    fn searchable_text(&self) -> Option<String> {
        let mut text = String::new();
        text.push_str(&self.title);
        text.push(' ');
        if let Some(description) = &self.description {
            text.push_str(description);
        }
        Some(text)
    }
}

Storage Layer

GenericStore

The GenericStore provides a type-safe storage layer for any object implementing Object:

pub struct GenericStore {
    client: HeroDbClient,
    index: FieldIndex,
}

impl GenericStore {
    /// Store an object
    pub async fn put<T: Object>(&self, obj: &T) -> Result<()>;
    
    /// Get an object by ID
    pub async fn get<T: Object>(&self, ns: &str, id: &str) -> Result<T>;
    
    /// Delete an object
    pub async fn delete<T: Object>(&self, obj: &T) -> Result<bool>;
    
    /// Get IDs matching an index key
    pub async fn get_ids_by_index(&self, ns: &str, field: &str, value: &str) -> Result<Vec<String>>;
}

Usage Example

use osiris::objects::Note;
use osiris::store::{GenericStore, HeroDbClient};

// Create store
let client = HeroDbClient::new("redis://localhost:6379", 1)?;
let store = GenericStore::new(client);

// Create and store a note
let note = Note::new("notes".to_string())
    .set_title("My Note")
    .set_content("This is the content")
    .add_tag("topic", "rust")
    .add_tag("priority", "high");

store.put(&note).await?;

// Retrieve the note
let retrieved: Note = store.get("notes", &note.id()).await?;

// Search by index
let ids = store.get_ids_by_index("notes", "tag:topic", "rust").await?;

Index Storage

Keyspace Design

obj:<ns>:<id>              → JSON serialized object
idx:<ns>:<field>:<value>   → Set of object IDs
scan:<ns>                  → Set of all object IDs in namespace

Examples

obj:notes:abc123           → {"base_data":{...},"title":"My Note",...}
idx:notes:title:My Note    → {abc123, def456}
idx:notes:tag:topic:rust   → {abc123, xyz789}
idx:notes:mime:text/plain  → {abc123}
scan:notes                 → {abc123, def456, xyz789}

Automatic Indexing

When an object is stored:

  1. Serialize the object to JSON
  2. Store at obj:<ns>:<id>
  3. Generate index keys by calling obj.index_keys()
  4. Create indexes for each key at idx:<ns>:<field>:<value>
  5. Add to scan index at scan:<ns>

When an object is deleted:

  1. Retrieve the object
  2. Generate index keys
  3. Remove from all indexes
  4. Delete the object

Comparison with heromodels

Feature heromodels OSIRIS
Base struct BaseModelData BaseData
Core trait Model Object
ID type u32 (auto-increment) String (UUID)
Timestamps i64 (Unix) OffsetDateTime
Index macro #[index] (derive) Manual index_keys()
Storage OurDB/Postgres HeroDB (Redis)
Serialization CBOR/JSON JSON

Future Enhancements

1. Derive Macro for #[index]

Create a proc macro to automatically generate index_keys() from field attributes:

#[derive(Object)]
pub struct Note {
    pub base_data: BaseData,
    
    #[index]
    pub title: Option<String>,
    
    pub content: Option<String>,
    
    #[index]
    pub tags: BTreeMap<String, String>,
}

2. Query Builder

Type-safe query builder for indexed fields:

let results = store
    .query::<Note>("notes")
    .filter("tag:topic", "rust")
    .filter("tag:priority", "high")
    .limit(10)
    .execute()
    .await?;

3. Relations

Support for typed relations between objects:

pub struct Note {
    pub base_data: BaseData,
    pub title: String,
    
    #[relation(target = "Note", label = "references")]
    pub references: Vec<String>,
}

4. Validation

Trait-based validation:

pub trait Validate {
    fn validate(&self) -> Result<()>;
}

impl Validate for Note {
    fn validate(&self) -> Result<()> {
        if self.title.is_none() {
            return Err(Error::InvalidInput("Title required".into()));
        }
        Ok(())
    }
}

Migration from Old API

The old OsirisObject API is still available for backwards compatibility:

// Old API (still works)
use osiris::store::OsirisObject;
let obj = OsirisObject::new("notes".to_string(), Some("text".to_string()));

// New API (recommended)
use osiris::objects::Note;
let note = Note::new("notes".to_string())
    .set_title("Title")
    .set_content("text");

Benefits of Trait-Based Architecture

  1. Type Safety: Compile-time guarantees for object types
  2. Extensibility: Easy to add new object types
  3. Automatic Indexing: Index keys generated from object structure
  4. Consistency: Same pattern as heromodels
  5. Flexibility: Each object type controls its own indexing logic
  6. Testability: Easy to mock and test individual object types

Summary

The trait-based architecture makes OSIRIS:

  • More flexible: Any type can be stored by implementing Object
  • More consistent: Follows heromodels patterns
  • More powerful: Automatic indexing based on object structure
  • More maintainable: Clear separation of concerns
  • More extensible: Easy to add new object types and features