diff --git a/.gitignore b/.gitignore index 5c162c370..69681ac5a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,6 @@ hack/ /tmp /data claude.md -.cargo/ \ No newline at end of file +.cargo/ +*/Cargo.lock +Cargo.lock \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index c1582435d..7862e2c03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1407,7 +1407,7 @@ dependencies = [ "rand 0.9.1", "serde", "serde_json", - "sonic-rs", + "sonic-rs 0.5.3", "tokio", "tracing", "tracing-subscriber", @@ -1446,7 +1446,7 @@ dependencies = [ "reqwest", "serde", "sha2", - "sonic-rs", + "sonic-rs 0.5.3", "subtle", "tempfile", "thiserror 2.0.12", @@ -1459,6 +1459,20 @@ dependencies = [ "uuid", ] +[[package]] +name = "helix-lib" +version = "0.1.0" +dependencies = [ + "bumpalo", + "bytes", + "chrono", + "heed3", + "helix-db", + "helix-macros", + "inventory", + "sonic-rs 0.3.17", +] + [[package]] name = "helix-macros" version = "0.1.7" @@ -1479,7 +1493,7 @@ dependencies = [ "num_cpus", "reqwest", "serde", - "sonic-rs", + "sonic-rs 0.5.3", "tokio", "uuid", ] @@ -4231,6 +4245,26 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "sonic-rs" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0275f9f2f07d47556fe60c2759da8bc4be6083b047b491b2d476aa0bfa558eb1" +dependencies = [ + "bumpalo", + "bytes", + "cfg-if", + "faststr", + "itoa", + "ref-cast", + "ryu", + "serde", + "simdutf8", + "sonic-number", + "sonic-simd", + "thiserror 2.0.12", +] + [[package]] name = "sonic-rs" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index 3e44c6523..822079808 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = [ "helix-macros", "helix-cli", "hql-tests", - "metrics", + "metrics", "helix-lib", ] resolver = "2" diff --git a/helix-db/src/helixc/generator/utils.rs b/helix-db/src/helixc/generator/utils.rs index 0710649fe..5edc78f90 100644 --- a/helix-db/src/helixc/generator/utils.rs +++ b/helix-db/src/helixc/generator/utils.rs @@ -415,6 +415,13 @@ impl Separator { } pub fn write_headers() -> String { r#" +// Generated by HelixDB compiler +// This file is auto-generated. Do not edit manually. + +#![allow(non_snake_case)] +#![allow(unused)] +#![allow(dead_code)] + // DEFAULT CODE // use helix_db::helix_engine::traversal_core::config::Config; @@ -477,6 +484,7 @@ use helix_db::{ node_matches, props, embed, embed_async, field_addition_from_old_field, field_type_cast, field_addition_from_value, protocol::{ + request::{Request, RequestType}, response::Response, value::{casting::{cast, CastType}, Value}, format::Format, diff --git a/helix-lib/Cargo.toml b/helix-lib/Cargo.toml new file mode 100644 index 000000000..69efb724b --- /dev/null +++ b/helix-lib/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "helix-lib" +version = "0.1.0" +edition = "2024" + +[dependencies] +helix-db = { path = "../helix-db" } +helix-macros = { path = "../helix-macros" } +sonic-rs = "0.3" +bumpalo = "3.17" +heed3 = "0.22" +chrono = "0.4" +inventory = "0.3.16" +bytes = "1.9" + diff --git a/helix-lib/README.md b/helix-lib/README.md new file mode 100644 index 000000000..4831dcdb0 --- /dev/null +++ b/helix-lib/README.md @@ -0,0 +1,160 @@ +# helix-lib + +Embeddable graph database SDK for HelixDB. + +## What It Does + +- Compiles `.hx` schema and queries at build time +- Generates type-safe Rust handlers +- Provides simple query execution API +- Compatible with HelixDB server databases + +## Quick Start + +### 1. Add to Cargo.toml + +```toml +[dependencies] +helix-lib = "0.1.0" +serde = { version = "1.0", features = ["derive"] } +sonic-rs = "0.3" + +[build-dependencies] +helix-lib = "0.1.0" +``` + +### 2. Create build.rs + +```rust +fn main() { + helix_lib::build::compile_queries_default() + .expect("Failed to compile queries"); +} +``` + +### 3. Create Schema + +`queries/schema.hx`: + +```hql +NODE User { + name: String + email: String +} +``` + +### 4. Create Queries + +`queries/queries.hx`: + +```hql +QUERY GetUsers() => + users <- N + RETURN users +``` + +### 5. Use in Code + +```rust +mod queries; // Generated by build.rs + +use helix_lib::{HelixDB, ResponseExt}; +use serde::Deserialize; +use sonic_rs::json; + +#[derive(Deserialize)] +struct GetUsersResponse { + users: Vec, +} + +fn main() -> Result<(), Box> { + let config = queries::config().expect("Failed to load config"); + let db = HelixDB::new("./data", config)?; + + let data: GetUsersResponse = db.execute("GetUsers", json!({}))?.deserialize()?; + + println!("Found {} users", data.users.len()); + Ok(()) +} +``` + +## API + +### HelixDB::new(path, config) + +Create a new database instance. + +```rust +let db = HelixDB::new("./my_data", config)?; +``` + +### execute(query_name, params) + +Execute a query by name. + +```rust +let response = db.execute("GetUsers", json!({}))?; +``` + +### Response Methods + +Chain these on the response: + +```rust +// Deserialize to type +let data: MyType = response.deserialize()?; + +// Get JSON value +let json = response.json()?; + +// Extract field +let users: Vec = response.get_field("users")?; +``` + +## File Structure + +``` +your-project/ +├── build.rs # Compiles .hx files +├── queries/ +│ ├── schema.hx # Define nodes/edges +│ └── queries.hx # Define queries +└── src/ + ├── main.rs # Your code + └── queries.rs # Generated (don't edit) +``` + +## Build Process + +1. `cargo build` runs `build.rs` +2. Compiles `queries/*.hx` files +3. Generates `src/queries.rs` with handlers +4. Your code imports `mod queries` + +## Examples + +### Execute with Parameters + +```rust +let response = db.execute("CreateUser", json!({ + "name": "Alice", + "email": "alice@example.com" +}))?; +``` + +### Dynamic JSON Access + +```rust +let json = db.execute("GetUsers", json!({}))?.json()?; +println!("{}", json["users"]); +``` + +### Field Extraction + +```rust +let users: Vec = db.execute("GetUsers", json!({}))?.get_field("users")?; +``` + +## That's It + +No runtime compilation. No servers. Just embedded graph database queries. diff --git a/helix-lib/examples/simple/Cargo.toml b/helix-lib/examples/simple/Cargo.toml new file mode 100644 index 000000000..b888983e0 --- /dev/null +++ b/helix-lib/examples/simple/Cargo.toml @@ -0,0 +1,22 @@ +[workspace] +# This is a standalone test project, not part of the helix-db workspace + +[package] +name = "simple" +version = "0.1.0" +edition = "2021" + +[dependencies] +helix-lib = { path = "../.." } +# Minimal dependencies needed by generated queries.rs +helix-db = { path = "../../../helix-db" } +helix-macros = { path = "../../../helix-macros" } +bumpalo = "3.17" +heed3 = "0.22" +sonic-rs = "0.3" +chrono = "0.4" +serde = "1.0.228" +inventory = "0.3.21" + +[build-dependencies] +helix-lib = { path = "../.." } diff --git a/helix-lib/examples/simple/README.md b/helix-lib/examples/simple/README.md new file mode 100644 index 000000000..0b1a86f8d --- /dev/null +++ b/helix-lib/examples/simple/README.md @@ -0,0 +1,260 @@ +# simple + +Working example of using helix-lib SDK. + +## What This Is + +A complete example project showing how to: + +- Set up build-time compilation +- Define schemas and queries +- Execute queries with type-safe responses +- Use all response deserialization patterns + +## File Structure + +``` +simple/ +├── Cargo.toml # Dependencies +├── build.rs # Compiles queries at build time +├── queries/ +│ ├── schema.hx # Graph schema definition +│ └── queries.hx # Query definitions +└── src/ + ├── main.rs # Example code + └── queries.rs # Generated (auto-created by build.rs) +``` + +## The Schema + +`queries/schema.hx` defines the graph structure: + +```hql +NODE User { + name: String + email: String +} + +NODE Memory { + user_id: ID + content: String + title: String +} + +NODE Space { + user_id: ID + name: String + description: String +} + +# Edges define relationships +EDGE Owns: User -> Memory +EDGE HasSpace: User -> Space +EDGE BelongsTo: Memory -> Space +``` + +## The Queries + +`queries/queries.hx` defines 5 queries: + +### 1. GetUsers - Read all users + +```hql +QUERY GetUsers() => + users <- N + RETURN users +``` + +### 2. GetMemories - Read all memories + +```hql +QUERY GetMemories() => + memories <- N + RETURN memories +``` + +### 3. CreateUser - Create a new user + +```hql +QUERY CreateUser(name: String, email: String) => + user <- CREATE N { + name: name, + email: email + } + RETURN user +``` + +### 4. CreateMemory - Create a memory for a user + +```hql +QUERY CreateMemory(user_id: ID, content: String, title: String) => + memory <- CREATE N { + user_id: user_id, + content: content, + title: title + } + RETURN memory +``` + +### 5. GetUserMemories - Get memories owned by a user + +```hql +QUERY GetUserMemories(user_id: ID) => + user <- N WHERE id == user_id + memories <- user -> Owns -> N + RETURN memories +``` + +## Build Process + +### 1. build.rs + +```rust +fn main() { + helix_lib::build::compile_queries_default() + .expect("Failed to compile Helix queries"); +} +``` + +What it does: + +1. Reads `queries/*.hx` files +2. Parses schema and queries +3. Generates `src/queries.rs` with: + - Handler functions for each query + - `config()` function with embedded schema + - Type definitions for nodes/edges + +### 2. Generated Code + +After `cargo build`, `src/queries.rs` contains: + +```rust +// Node type definitions +pub struct User { name: String, email: String, ... } +pub struct Memory { user_id: ID, content: String, ... } + +// Handler functions +#[handler] +pub fn GetUsers(input: HandlerInput) -> Result { + // Generated query execution code +} + +#[handler] +pub fn CreateUser(input: HandlerInput) -> Result { + // Generated mutation code +} + +// Config with embedded schema +pub fn config() -> Option { + Some(Config { + schema: Some("...embedded schema JSON..."), + // ... + }) +} +``` + +## The Example Code + +`src/main.rs` demonstrates 4 response patterns: + +### Pattern 1: Two-Step + +```rust +let response = db.execute("GetUsers", json!({}))?; +let data: GetUsersResponse = response.deserialize()?; +``` + +### Pattern 2: Chained + +```rust +let data: GetUsersResponse = db.execute("GetUsers", json!({}))?.deserialize()?; +``` + +### Pattern 3: Dynamic JSON + +```rust +let json_value = db.execute("GetUsers", json!({}))?.json()?; +println!("{}", json_value["users"]); +``` + +### Pattern 4: Field Extraction + +```rust +let users: Vec = db.execute("GetUsers", json!({}))?.get_field("users")?; +``` + +## Running It + +```bash +cd helix-lib/tests/test_consumer +cargo run +``` + +Output: + +``` +Test Consumer: Simplified execute() API Demo + +✓ Config loaded from generated queries.rs + Schema embedded: true + +✓ Database initialized successfully + Path: /tmp/test_consumer_db + +--- Demo 1: execute().deserialize() pattern --- +✓ Got 0 users + +--- Demo 2: Chained pattern --- +✓ Got 0 users (chained) + +--- Demo 3: Dynamic JSON with .json() --- +✓ JSON value: {"users":[]} + +--- Demo 4: Field extraction with .get_field() --- +✓ Extracted 'users' field: 0 items + +🎉 Simplified execute() API works! +``` + +## Dependencies + +```toml +[dependencies] +helix-lib = { path = "../.." } +helix-db = { path = "../../../helix-db" } +helix-macros = { path = "../../../helix-macros" } +serde = { version = "1.0", features = ["derive"] } +sonic-rs = "0.3" +inventory = "0.3" +# ... other deps needed by generated code +``` + +Why these dependencies: + +- `helix-lib` - SDK for building/running +- `helix-db` - Core database (re-exported by helix-lib) +- `helix-macros` - `#[handler]` macro (re-exported by helix-lib) +- `serde` - Deserialization of responses +- `sonic-rs` - JSON handling +- `inventory` - Handler registration + +## Key Takeaways + +1. **Build-time compilation** - Queries become Rust code during `cargo build` +2. **Type safety** - Define your response types, get compile-time checks +3. **Handler-based** - Each query becomes a handler function +4. **Flexible responses** - 4 ways to access data depending on your needs +5. **No runtime overhead** - Schema embedded, queries pre-compiled + +## Use This As Template + +Copy this structure for your own projects: + +1. Copy file structure +2. Replace schema with your graph +3. Replace queries with your operations +4. Update response types in main.rs +5. Run it + +That's all you need to know. diff --git a/helix-lib/examples/simple/build.rs b/helix-lib/examples/simple/build.rs new file mode 100644 index 000000000..b9409bfb4 --- /dev/null +++ b/helix-lib/examples/simple/build.rs @@ -0,0 +1,4 @@ +// Minimal build.rs using helix-lib's compile_queries_default() +fn main() { + helix_lib::build::compile_queries_default().expect("Failed to compile Helix queries"); +} diff --git a/helix-lib/examples/simple/queries/queries.hx b/helix-lib/examples/simple/queries/queries.hx new file mode 100644 index 000000000..09e1c9065 --- /dev/null +++ b/helix-lib/examples/simple/queries/queries.hx @@ -0,0 +1,37 @@ +// Test queries for test_consumer + +// Get all users from the database +QUERY GetUsers() => + users <- N + RETURN users + +// Get all memories +QUERY GetMemories() => + memories <- N + RETURN memories + +// Create a new user +QUERY CreateUser(name: String, email: String) => + user <- AddN({name: name, email: email}) + RETURN user + +// Create a memory for a user +QUERY CreateMemory(user_id: ID, content: String, title: String) => + user <- N(user_id) + memory <- AddN({ + user_id: user_id, + content: content, + title: title, + content_type: "text", + original_input: content, + url: "", + metadata: "{}", + chunk_count: 0 + }) + AddE::From(user)::To(memory) + RETURN memory + +// Get memories for a specific user +QUERY GetUserMemories(user_id: ID) => + memories <- N(user_id)::Out + RETURN memories diff --git a/helix-lib/examples/simple/queries/schema.hx b/helix-lib/examples/simple/queries/schema.hx new file mode 100644 index 000000000..585d8641e --- /dev/null +++ b/helix-lib/examples/simple/queries/schema.hx @@ -0,0 +1,91 @@ +// User node - represents users of the memory system with email indexing +N::User { + email: String, + name: String, + created_at: String, + updated_at: String, +} + +// Memory node - always chunked content storage with multimodal support +N::Memory { + user_id: ID, // Reference to user - changed from String to ID + content: String, // Full original processed content + original_input: String, // Original URL/text user provided + title: String, + content_type: String, // "page", "tweet", "document", "notion", "note" + url: String, // Canonical URL (if applicable) + metadata: String, // JSON metadata specific to content type + chunk_count: U32, // Number of chunks (always > 0) + created_at: String, + updated_at: String, +} + +// Chunk vector - individual searchable text segments with embeddings +V::Chunk { + memory_id: ID, // Reference to parent memory + text_content: String, // Chunk text content + order_in_document: U32, // Position in original document (0-based) + metadata: String, // JSON metadata specific to chunk + created_at: String, +} + +// Space node - organizational container for memories +N::Space { + user_id: ID, + name: String, + description: String, + created_at: String, + updated_at: String, +} + +// Edges for relationships in chunked memory system + +// User owns memories +E::Owns { + From: User, + To: Memory, + Properties: { + created_at: String, + } +} + +// Memory has chunks (replaces direct embedding relationship) +E::HasChunk { + From: Memory, + To: Chunk, + Properties: { + similarity_score: F32 DEFAULT 0.0, // For search result ranking + created_at: String, + } +} + +// User owns spaces +E::HasSpace { + From: User, + To: Space, + Properties: { + role: String, // owner, collaborator, viewer + created_at: String, + } +} + +// Memory belongs to space +E::BelongsTo { + From: Memory, + To: Space, + Properties: { + added_at: String, + } +} + +// Memory relates to memory (for semantic connections via shared chunks) +E::RelatedTo { + From: Memory, + To: Memory, + Properties: { + similarity_score: F32, + relationship_type: String, // semantic, contextual, temporal + shared_chunks: U32, // Number of similar chunks + created_at: String, + } +} \ No newline at end of file diff --git a/helix-lib/examples/simple/src/main.rs b/helix-lib/examples/simple/src/main.rs new file mode 100644 index 000000000..4acc15d5f --- /dev/null +++ b/helix-lib/examples/simple/src/main.rs @@ -0,0 +1,50 @@ +mod queries; + +use helix_lib::{HelixDB, ResponseExt}; +use serde::Deserialize; +use sonic_rs::json; + +#[derive(Deserialize, Debug)] +struct GetUsersResponse { + users: Vec, +} + +#[derive(Deserialize, Debug)] +struct User { + id: String, + label: String, + name: Option, + email: Option, +} + +fn main() -> Result<(), Box> { + // Load config from generated queries + let config = queries::config().expect("Failed to load config"); + + // Create database + let db = HelixDB::new("/tmp/memory_example", config)?; + + // Create a user + let create_response = db.execute( + "CreateUser", + json!({ + "name": "Alice", + "email": "alice@example.com" + }), + )?; + println!("Created user: {}", create_response.json()?); + + // Get all users + let users: GetUsersResponse = db.execute("GetUsers", json!({}))?.deserialize()?; + println!("Found {} users", users.users.len()); + + for user in users.users { + println!( + " - {}: {}", + user.name.unwrap_or_default(), + user.email.unwrap_or_default() + ); + } + + Ok(()) +} diff --git a/helix-lib/src/build.rs b/helix-lib/src/build.rs new file mode 100644 index 000000000..50a108276 --- /dev/null +++ b/helix-lib/src/build.rs @@ -0,0 +1,194 @@ +//! Build-time compilation utilities for HelixDB. +//! +//! This module provides functions to compile .hx schema and query files +//! into Rust code during the build process. +//! + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use helix_db::helixc::{ + analyzer::analyze, + generator::generate, + parser::{ + HelixParser, + types::{Content, HxFile, Source}, + }, +}; + +pub use crate::errors::{HelixError, Result}; + +/// Compiles .hx files from the default queries directory to src/queries.rs +/// +/// This is the recommended function for most use cases. It: +/// - Looks for .hx files in `./queries/` directory +/// - Outputs to `./src/queries.rs` +/// - Tells cargo to rerun if queries/ changes +/// + +pub fn compile_queries_default() -> Result<()> { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR") + .map_err(|_| HelixError::StorageError("CARGO_MANIFEST_DIR not set".to_string()))?; + + let queries_dir = PathBuf::from(&manifest_dir).join("queries"); + let output_dir = PathBuf::from(&manifest_dir).join("src"); + + compile_queries(&queries_dir, &output_dir)?; + + // Tell cargo to rerun if any .hx file changes + println!("cargo:rerun-if-changed=queries/"); + + Ok(()) +} + +/// Compiles .hx files from a custom directory +/// +/// Use this if your .hx files are not in the default `./queries/` directory. +/// +/// # Arguments +/// * `queries_dir` - Directory containing .hx schema and query files +/// * `output_dir` - Directory where queries.rs will be created +/// +/// # Example +/// +/// ```no_run +/// use std::path::PathBuf; +/// +/// // build.rs +/// fn main() { +/// let queries = PathBuf::from("./my_schemas"); +/// let output = PathBuf::from("./src"); +/// +/// helix_lib::build::compile_queries(&queries, &output) +/// .expect("Failed to compile Helix queries"); +/// +/// println!("cargo:rerun-if-changed=my_schemas/"); +/// } +/// ``` +pub fn compile_queries(queries_dir: &Path, output_dir: &Path) -> Result<()> { + // 1. Collect all .hx files + let hx_files = collect_hx_files(queries_dir)?; + + if hx_files.is_empty() { + // No .hx files found - create default queries.rs with no schema + create_default_queries_rs(output_dir)?; + return Ok(()); + } + + // 2. Read files into HxFile structs + let hx_files_content: Vec = hx_files + .iter() + .filter_map(|path| { + let name = path.to_string_lossy().into_owned(); + fs::read_to_string(path) + .ok() + .map(|content| HxFile { name, content }) + }) + .collect(); + + if hx_files_content.is_empty() { + return Err(HelixError::StorageError( + "Failed to read any .hx files".to_string(), + )); + } + + // 3. Create Content for parser + let content_str = hx_files_content + .iter() + .map(|f| f.content.as_str()) + .collect::>() + .join("\n"); + + let content = Content { + content: content_str, + files: hx_files_content.clone(), + source: Source::default(), + }; + + // 4. Parse using HelixParser + let source = HelixParser::parse_source(&content) + .map_err(|e| HelixError::StorageError(format!("Failed to parse .hx files: {}", e)))?; + + // 5. Analyze the parsed source + let (diagnostics, generated_source) = analyze(&source) + .map_err(|e| HelixError::StorageError(format!("Failed to analyze .hx files: {}", e)))?; + + // 6. Check for compilation errors + if !diagnostics.is_empty() { + let error_messages: Vec = diagnostics.iter().map(|d| format!("{:?}", d)).collect(); + return Err(HelixError::StorageError(format!( + "Helix compilation errors:\n{}", + error_messages.join("\n") + ))); + } + + // 7. Generate queries.rs + fs::create_dir_all(output_dir)?; + generate(generated_source, output_dir) + .map_err(|e| HelixError::StorageError(format!("Failed to generate queries.rs: {}", e)))?; + + Ok(()) +} + +/// Recursively collect all .hx files from a directory +fn collect_hx_files(dir: &Path) -> Result> { + let mut hx_files = Vec::new(); + + if !dir.exists() { + // No queries directory - that's ok, we'll create default queries.rs + return Ok(hx_files); + } + + if !dir.is_dir() { + return Err(HelixError::InvalidPath(format!( + "{} is not a directory", + dir.display() + ))); + } + + collect_recursive(dir, &mut hx_files)?; + Ok(hx_files) +} + +fn collect_recursive(dir: &Path, files: &mut Vec) -> Result<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + // Skip .helix directory + if path.file_name().and_then(|n| n.to_str()) == Some(".helix") { + continue; + } + + if path.is_file() { + if path.extension().and_then(|s| s.to_str()) == Some("hx") { + files.push(path); + } + } else if path.is_dir() { + collect_recursive(&path, files)?; + } + } + Ok(()) +} + +/// Create a default queries.rs with no schema +fn create_default_queries_rs(output_dir: &Path) -> Result<()> { + fs::create_dir_all(output_dir)?; + + let default_content = r#"// Generated by helix-lib build script +// No .hx files found, using default configuration + +use helix_db::helix_engine::traversal_core::config::Config; + +pub fn config() -> Option { + None +} +"#; + + let output_file = output_dir.join("queries.rs"); + let mut file = fs::File::create(output_file)?; + file.write_all(default_content.as_bytes())?; + + Ok(()) +} diff --git a/helix-lib/src/errors.rs b/helix-lib/src/errors.rs new file mode 100644 index 000000000..6072779ac --- /dev/null +++ b/helix-lib/src/errors.rs @@ -0,0 +1,59 @@ +/// Errors that can occur during database operations. +#[derive(Debug)] +pub enum HelixError { + /// Database path is invalid or inaccessible + InvalidPath(String), + + /// Database already exists at the specified path + AlreadyExists(String), + + /// Database does not exist at the specified path + NotFound(String), + + /// Storage engine error + StorageError(String), + + /// I/O error + IoError(std::io::Error), + + /// Error during deserialization + DeserializationError(String), + + /// A required field was missing from a response or data structure + MissingField(String), +} + +pub type Result = std::result::Result; + +impl From for HelixError { + fn from(err: std::io::Error) -> Self { + HelixError::IoError(err) + } +} + +impl std::fmt::Display for HelixError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + HelixError::InvalidPath(path) => write!(f, "Invalid database path: {}", path), + HelixError::AlreadyExists(path) => write!(f, "Database already exists at: {}", path), + HelixError::NotFound(path) => write!(f, "Database not found at: {}", path), + HelixError::StorageError(msg) => write!(f, "Storage error: {}", msg), + HelixError::IoError(err) => write!(f, "I/O error: {}", err), + HelixError::DeserializationError(msg) => { + write!(f, "Deserialization error: {}", msg) + } + HelixError::MissingField(field) => { + write!(f, "Missing field in response: {}", field) + } + } + } +} + +impl std::error::Error for HelixError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + HelixError::IoError(err) => Some(err), + _ => None, + } + } +} diff --git a/helix-lib/src/lib.rs b/helix-lib/src/lib.rs new file mode 100644 index 000000000..d13b03782 --- /dev/null +++ b/helix-lib/src/lib.rs @@ -0,0 +1,324 @@ +//! HelixDB Embeddable SDK +//! +//! A lightweight, embeddable graph database with vector search capabilities. +//! +//! # Features +//! - Build-time schema compilation +//! - Handler-based query execution +//! - Type-safe response deserialization +//! - Compatible with HelixDB server databases +//! +//! # Example +//! ```no_run +//! use helix_lib::{HelixDB, ResponseExt}; +//! use serde::Deserialize; +//! use sonic_rs::json; +//! +//! mod queries; // Generated by build.rs +//! +//! #[derive(Deserialize)] +//! struct GetUsersResponse { +//! users: Vec, +//! } +//! +//! # fn main() -> Result<(), Box> { +//! let config = queries::config().expect("Failed to load config"); +//! let db = HelixDB::new("./my_data", config)?; +//! +//! // Execute query and deserialize +//! let response = db.execute("GetUsers", json!({}))?; +//! let data: GetUsersResponse = response.deserialize()?; +//! +//! // Or chain it +//! let data: GetUsersResponse = db.execute("GetUsers", json!({}))?.deserialize()?; +//! # Ok(()) +//! # } +//! ``` + +pub mod errors; +pub use errors::{HelixError, Result}; + +pub mod build; + +// Re-export everything needed by generated queries.rs +// This allows users to only depend on helix-lib + +// Re-export helix-db modules +pub use helix_db::{ + embed, + embed_async, + field_addition_from_old_field, + field_addition_from_value, + field_type_cast, + helix_engine::{ + reranker, + traversal_core::{ + config::{Config, GraphConfig, VectorConfig}, + ops, + traversal_value::TraversalValue, + }, + types::GraphError, + vector_core::vector::HVector, + }, + helix_gateway::{ + embedding_providers::{EmbeddingModel, get_embedding_model}, + mcp::mcp::{MCPHandler, MCPHandlerSubmission, MCPToolInput}, + router::router::{BasicHandlerFn, Handler, HandlerFn, HandlerInput, HandlerSubmission}, + }, + // Re-export macros + node_matches, + props, + protocol::{ + format::Format, + request::{Request, RequestType}, + response::Response, + value::{ + Value, + casting::{CastType, cast}, + }, + }, + utils::{ + id::{ID, uuid_str}, + items::{Edge, Node}, + properties::ImmutablePropertiesMap, + }, +}; + +// Re-export helix-macros +pub use helix_macros::{handler, mcp_handler, migration, tool_call}; + +// Re-export third-party dependencies +pub use bumpalo::Bump; +pub use chrono::{DateTime, Utc}; +pub use heed3::RoTxn; +pub use sonic_rs::{Deserialize, Serialize, json}; + +// Re-export std types commonly used +pub mod collections { + pub use std::collections::{HashMap, HashSet}; +} +pub mod sync { + pub use std::sync::Arc; +} +pub mod time { + pub use std::time::Instant; +} + +// Internal imports for HelixDB implementation +use helix_db::helix_engine::storage_core::{HelixGraphStorage, version_info::VersionInfo}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// The main embeddable HelixDB instance. +/// +/// This struct provides a lightweight, embeddable graph database with vector search capabilities. +/// Database directories created with HelixDB are compatible with the HelixDB server, +/// allowing seamless transitions between embedded and server deployments. +/// +/// # Example +/// ```no_run +/// mod queries; // Generated by build.rs +/// use helix_lib::HelixDB; +/// +/// let config = queries::config().unwrap_or_default(); +/// let db = HelixDB::new("./my_data", config)?; +/// ``` +pub struct HelixDB { + path: PathBuf, + storage: Arc, +} + +impl HelixDB { + /// Creates a new HelixDB database with pre-compiled schema. + /// + /// # Arguments + /// * `path` - Directory path where the database files will be created + /// * `config` - Pre-compiled configuration from `queries::config()` + /// + /// # Returns + /// A new `HelixDB` instance ready for use. + /// + /// # Example + /// ```no_run + /// mod queries; // Generated by build.rs + /// use helix_lib::HelixDB; + /// + /// let config = queries::config().expect("Failed to load config"); + /// let db = HelixDB::new("./my_graph_db", config)?; + /// ``` + pub fn new>(path: P, config: Config) -> Result { + let path = path.as_ref(); + + // Initialize storage with pre-compiled config from queries::config() + let version_info = VersionInfo::default(); + let storage = Arc::new( + HelixGraphStorage::new( + path.to_str() + .ok_or_else(|| HelixError::InvalidPath(path.display().to_string()))?, + config, + version_info, + ) + .map_err(|e| { + HelixError::StorageError(format!("Failed to initialize storage: {}", e)) + })?, + ); + + Ok(Self { + path: path.to_path_buf(), + storage, + }) + } + + /// Execute a query by name using handlers registered via #[handler] macro. + /// + /// Returns a raw Response that can be deserialized using ResponseExt methods. + /// + /// # Arguments + /// * `query_name` - Name of the query (e.g., "GetUsers", "CreateUser") + /// * `params` - Parameters for the query (will be serialized to JSON) + /// + /// # Returns + /// The raw query Response. + /// + /// # Example + /// ```no_run + /// use helix_lib::{HelixDB, ResponseExt}; + /// use sonic_rs::json; + /// use serde::Deserialize; + /// + /// #[derive(Deserialize)] + /// struct GetUsersResponse { + /// users: Vec, + /// } + /// + /// # fn example(db: &HelixDB) -> Result<(), Box> { + /// // Execute and deserialize + /// let response = db.execute("GetUsers", json!({}))?; + /// let data: GetUsersResponse = response.deserialize()?; + /// + /// // Or chain it + /// let data: GetUsersResponse = db.execute("GetUsers", json!({}))?.deserialize()?; + /// + /// // Or use json() for dynamic access + /// let json_value = db.execute("GetUsers", json!({}))?.json()?; + /// + /// // Or extract a specific field + /// let users: Vec = db.execute("GetUsers", json!({}))?.get_field("users")?; + /// # Ok(()) + /// # } + /// ``` + pub fn execute(&self, query_name: &str, params: T) -> Result { + use std::collections::HashMap; + + // Collect all registered handlers from the user's binary + let handlers: HashMap = inventory::iter::() + .map(|submission| { + let handler = &submission.0; + (handler.name.to_string(), handler.func) + }) + .collect(); + + // Find the requested handler + let handler = handlers.get(query_name).ok_or_else(|| { + HelixError::StorageError(format!( + "Query '{}' not found. Available queries: {:?}", + query_name, + handlers.keys().collect::>() + )) + })?; + + // Serialize parameters to JSON + let body = sonic_rs::to_vec(¶ms).map_err(|e| { + HelixError::StorageError(format!("Failed to serialize parameters: {}", e)) + })?; + + // Create Request struct + let body_bytes = bytes::Bytes::from(body); + let request = Request { + name: query_name.to_string(), + req_type: RequestType::Query, + api_key: None, + body: body_bytes, + in_fmt: Format::Json, + out_fmt: Format::Json, + }; + + // Create HelixGraphEngine wrapper + let graph = Arc::new(helix_db::helix_engine::traversal_core::HelixGraphEngine { + storage: self.storage.clone(), + mcp_backend: None, + mcp_connections: None, + }); + + // Create HandlerInput + let input = HandlerInput { graph, request }; + + // Execute handler + handler(input) + .map_err(|e| HelixError::StorageError(format!("Query execution failed: {}", e))) + } + + /// Returns the database path. + pub fn path(&self) -> &Path { + &self.path + } + + /// Returns a reference to the underlying storage engine. + pub fn storage(&self) -> &Arc { + &self.storage + } +} + +/// Extension trait for Response to provide deserialization helpers +pub trait ResponseExt { + /// Deserialize the response body to a specific type + fn deserialize(&self) -> Result + where + T: for<'de> Deserialize<'de>; + + /// Get the response as a dynamic JSON Value + fn json(&self) -> Result; + + /// Extract a specific field from the response + fn get_field(&self, field: &str) -> Result + where + T: for<'de> Deserialize<'de>; +} + +impl ResponseExt for Response { + fn deserialize(&self) -> Result + where + T: for<'de> Deserialize<'de>, + { + sonic_rs::from_slice(&self.body).map_err(|e| { + HelixError::DeserializationError(format!("Failed to deserialize response: {}", e)) + }) + } + + fn json(&self) -> Result { + self.deserialize() + } + + fn get_field(&self, field: &str) -> Result + where + T: for<'de> Deserialize<'de>, + { + use std::collections::HashMap; + + // Deserialize to HashMap first + let map: HashMap = self.deserialize()?; + + // Get field + let field_value = map + .get(field) + .ok_or_else(|| HelixError::MissingField(field.to_string()))?; + + // Deserialize the field value + sonic_rs::from_value(field_value).map_err(|e| { + HelixError::DeserializationError(format!( + "Failed to deserialize field '{}': {}", + field, e + )) + }) + } +}