diff --git a/Cargo.lock b/Cargo.lock index 18218978..8b760230 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1289,6 +1289,19 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "remote-access-demo" +version = "0.1.0" +dependencies = [ + "aimdb-core", + "aimdb-tokio-adapter", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "ring" version = "0.17.14" diff --git a/Cargo.toml b/Cargo.toml index 180c37a1..e066c2cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "examples/tokio-mqtt-connector-demo", "examples/embassy-mqtt-connector-demo", "examples/sync-api-demo", + "examples/remote-access-demo", ] exclude = ["_external"] resolver = "2" diff --git a/Makefile b/Makefile index 0ce93964..b6911e7f 100644 --- a/Makefile +++ b/Makefile @@ -52,6 +52,8 @@ test: cargo test --package aimdb-core --no-default-features @printf "$(YELLOW) → Testing aimdb-core (std platform)$(NC)\n" cargo test --package aimdb-core --features "std,tracing" + @printf "$(YELLOW) → Testing aimdb-core remote module$(NC)\n" + cargo test --package aimdb-core --lib --features "std" remote:: @printf "$(YELLOW) → Testing tokio adapter$(NC)\n" cargo test --package aimdb-tokio-adapter --features "tokio-runtime,tracing" @printf "$(YELLOW) → Testing sync wrapper$(NC)\n" diff --git a/_external/embassy b/_external/embassy index 9b1add3d..cf231d46 160000 --- a/_external/embassy +++ b/_external/embassy @@ -1 +1 @@ -Subproject commit 9b1add3d83e98c6c9ae9230bf35cd89bba530a20 +Subproject commit cf231d461ff4b16cf7d16e4dbc5db909212c2c89 diff --git a/aimdb-core/Cargo.toml b/aimdb-core/Cargo.toml index 673d343c..22b909e2 100644 --- a/aimdb-core/Cargo.toml +++ b/aimdb-core/Cargo.toml @@ -10,7 +10,14 @@ build = "build.rs" default = ["std"] # Core capabilities -std = ["thiserror", "anyhow", "serde_json", "aimdb-executor/std"] +std = [ + "serde", + "thiserror", + "anyhow", + "serde_json", + "tokio", + "aimdb-executor/std", +] # Heap allocation in no_std environments alloc = ["serde"] # Enable heap in no_std @@ -35,6 +42,14 @@ thiserror = { workspace = true, optional = true } anyhow = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } +# Async runtime - only for std environments with remote access +tokio = { workspace = true, features = [ + "net", + "io-util", + "sync", + "time", +], optional = true } + # Atomic operations for all platforms portable-atomic = { version = "1.9", default-features = false } diff --git a/aimdb-core/src/buffer/mod.rs b/aimdb-core/src/buffer/mod.rs index 5f3a8427..de10b3d7 100644 --- a/aimdb-core/src/buffer/mod.rs +++ b/aimdb-core/src/buffer/mod.rs @@ -63,6 +63,10 @@ mod traits; pub use cfg::BufferCfg; pub use traits::{Buffer, BufferReader, DynBuffer}; +// JSON streaming support (std only) +#[cfg(feature = "std")] +pub use traits::JsonBufferReader; + // Re-export buffer-specific errors from core error module // These are type aliases for convenience pub use crate::DbError as BufferError; diff --git a/aimdb-core/src/buffer/traits.rs b/aimdb-core/src/buffer/traits.rs index 475d2ebe..28a0f1c5 100644 --- a/aimdb-core/src/buffer/traits.rs +++ b/aimdb-core/src/buffer/traits.rs @@ -96,6 +96,42 @@ pub trait BufferReader: Send { fn recv(&mut self) -> Pin> + Send + '_>>; } +/// Reader trait for consuming JSON-serialized values from a buffer (std only) +/// +/// Type-erased reader that subscribes to a typed buffer and emits values as +/// `serde_json::Value`. Used by remote access protocol for subscriptions. +/// +/// This trait enables subscribing to a buffer without knowing the concrete type `T` +/// at compile time, by serializing values to JSON on each `recv_json()` call. +/// +/// # Requirements +/// - Record must be configured with `.with_serialization()` +/// - Only available with `std` feature (requires serde_json) +/// +/// # Example +/// ```rust,ignore +/// // Internal use in remote access handler +/// let json_reader: Box = record.subscribe_json()?; +/// while let Ok(json_val) = json_reader.recv_json().await { +/// // Forward JSON value to remote client... +/// } +/// ``` +#[cfg(feature = "std")] +pub trait JsonBufferReader: Send { + /// Receive the next value as JSON (async) + /// + /// Waits for the next value from the underlying buffer and serializes it to JSON. + /// + /// # Returns + /// - `Ok(JsonValue)` - Successfully received and serialized value + /// - `Err(BufferLagged)` - Missed messages (can continue reading) + /// - `Err(BufferClosed)` - Buffer closed (graceful shutdown) + /// - `Err(SerializationFailed)` - Failed to serialize value to JSON + fn recv_json( + &mut self, + ) -> Pin> + Send + '_>>; +} + /// Blanket implementation of DynBuffer for all Buffer types impl DynBuffer for B where diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index 6e622ae6..1d2e49a4 100644 --- a/aimdb-core/src/builder.rs +++ b/aimdb-core/src/builder.rs @@ -73,6 +73,91 @@ impl AimDbInner { Ok(typed_record) } + + /// Collects metadata for all registered records (std only) + /// + /// Returns a vector of `RecordMetadata` for remote access introspection. + /// Available only when the `std` feature is enabled. + #[cfg(feature = "std")] + pub fn list_records(&self) -> Vec { + self.records + .iter() + .map(|(type_id, record)| record.collect_metadata(*type_id)) + .collect() + } + + /// Try to get record's latest value as JSON by record name (std only) + /// + /// Searches for a record with the given name and returns its current value + /// serialized to JSON. Returns `None` if: + /// - Record not found + /// - Record not configured with `.with_serialization()` + /// - No value available in the atomic snapshot + /// + /// # Arguments + /// * `record_name` - The full Rust type name (e.g., "server::Temperature") + /// + /// # Returns + /// `Some(JsonValue)` with the current record value, or `None` + #[cfg(feature = "std")] + pub fn try_latest_as_json(&self, record_name: &str) -> Option { + for (type_id, record) in &self.records { + let metadata = record.collect_metadata(*type_id); + if metadata.name == record_name { + return record.latest_json(); + } + } + None + } + + /// Sets a record value from JSON (remote access API) + /// + /// Deserializes the JSON value and writes it to the record's buffer. + /// + /// **SAFETY:** Enforces the "No Producer Override" rule: + /// - Only works for records with `producer_count == 0` + /// - Returns error if the record has active producers + /// + /// # Arguments + /// * `record_name` - The full Rust type name (e.g., "server::AppConfig") + /// * `json_value` - JSON representation of the value + /// + /// # Returns + /// - `Ok(())` - Successfully set the value + /// - `Err(DbError)` - If record not found, has producers, or deserialization fails + /// + /// # Errors + /// - `RecordNotFound` - Record with given name doesn't exist + /// - `PermissionDenied` - Record has active producers (safety check) + /// - `RuntimeError` - Record not configured with `.with_serialization()` + /// - `JsonWithContext` - JSON deserialization failed (schema mismatch) + /// + /// # Example (internal use - called by remote access protocol) + /// ```rust,ignore + /// let json_val = serde_json::json!({"log_level": "debug", "version": "1.0"}); + /// db.set_record_from_json("server::AppConfig", json_val)?; + /// ``` + #[cfg(feature = "std")] + pub fn set_record_from_json( + &self, + record_name: &str, + json_value: serde_json::Value, + ) -> DbResult<()> { + // Find the record by name + for (type_id, record) in &self.records { + let metadata = record.collect_metadata(*type_id); + if metadata.name == record_name { + // Delegate to the type-erased set_from_json method + // which will enforce the "no producer override" rule + return record.set_from_json(json_value); + } + } + + // Record not found + Err(DbError::RecordNotFound { + record_name: record_name.to_string(), + }) + } } /// Database builder for producer-consumer pattern @@ -92,6 +177,10 @@ pub struct AimDbBuilder { /// Spawn functions indexed by TypeId spawn_fns: BTreeMap>, + /// Remote access configuration (std only) + #[cfg(feature = "std")] + remote_config: Option, + /// PhantomData to track the runtime type parameter _phantom: PhantomData, } @@ -106,6 +195,8 @@ impl AimDbBuilder { runtime: None, connectors: BTreeMap::new(), spawn_fns: BTreeMap::new(), + #[cfg(feature = "std")] + remote_config: None, _phantom: PhantomData, } } @@ -122,6 +213,8 @@ impl AimDbBuilder { runtime: Some(rt), connectors: self.connectors, spawn_fns: BTreeMap::new(), + #[cfg(feature = "std")] + remote_config: None, _phantom: PhantomData, } } @@ -168,6 +261,36 @@ where self } + /// Enables remote access via AimX protocol (std only) + /// + /// Configures the database to accept remote connections over a Unix domain socket, + /// allowing external clients to introspect records, subscribe to updates, and + /// (optionally) write data. + /// + /// The remote access supervisor will be spawned automatically during `build()`. + /// + /// # Arguments + /// * `config` - Remote access configuration (socket path, security policy, etc.) + /// + /// # Example + /// + /// ```rust,ignore + /// use aimdb_core::remote::{AimxConfig, SecurityPolicy}; + /// + /// let config = AimxConfig::new("/tmp/aimdb.sock") + /// .with_security(SecurityPolicy::read_only()); + /// + /// let db = AimDbBuilder::new() + /// .runtime(runtime) + /// .with_remote_access(config) + /// .build()?; + /// ``` + #[cfg(feature = "std")] + pub fn with_remote_access(mut self, config: crate::remote::AimxConfig) -> Self { + self.remote_config = Some(config); + self + } + /// Configures a record type manually /// /// Low-level method for advanced use cases. Most users should use `register_record` instead. @@ -347,6 +470,23 @@ where #[cfg(feature = "tracing")] tracing::info!("Automatic spawning complete"); + // Spawn remote access supervisor if configured (std only) + #[cfg(feature = "std")] + if let Some(remote_cfg) = self.remote_config { + #[cfg(feature = "tracing")] + tracing::info!( + "Spawning remote access supervisor on socket: {}", + remote_cfg.socket_path.display() + ); + + // Spawn the remote supervisor task + // This will be implemented in Task 6 + crate::remote::supervisor::spawn_supervisor(db.clone(), runtime.clone(), remote_cfg)?; + + #[cfg(feature = "tracing")] + tracing::info!("Remote access supervisor spawned successfully"); + } + // Unwrap the Arc to return the owned AimDb // This is safe because we just created it and hold the only reference let db_owned = Arc::try_unwrap(db).unwrap_or_else(|arc| (*arc).clone()); @@ -523,6 +663,210 @@ impl AimDb { pub fn runtime(&self) -> &R { &self.runtime } + + /// Lists all registered records (std only) + /// + /// Returns metadata for all registered records, useful for remote access introspection. + /// Available only when the `std` feature is enabled. + /// + /// # Example + /// ```rust,ignore + /// let records = db.list_records(); + /// for record in records { + /// println!("Record: {} ({})", record.name, record.type_id); + /// } + /// ``` + #[cfg(feature = "std")] + pub fn list_records(&self) -> Vec { + self.inner.list_records() + } + + /// Try to get record's latest value as JSON by name (std only) + /// + /// Convenience wrapper around `AimDbInner::try_latest_as_json()`. + /// + /// # Arguments + /// * `record_name` - The full Rust type name (e.g., "server::Temperature") + /// + /// # Returns + /// `Some(JsonValue)` with current value, or `None` if unavailable + #[cfg(feature = "std")] + pub fn try_latest_as_json(&self, record_name: &str) -> Option { + self.inner.try_latest_as_json(record_name) + } + + /// Sets a record value from JSON (remote access API) + /// + /// Deserializes JSON and produces the value to the record's buffer. + /// + /// **SAFETY:** Enforces "No Producer Override" rule - only works for configuration + /// records without active producers. + /// + /// # Arguments + /// * `record_name` - Full Rust type name + /// * `json_value` - JSON value to set + /// + /// # Returns + /// `Ok(())` on success, error if record not found, has producers, or deserialization fails + /// + /// # Example (internal use) + /// ```rust,ignore + /// db.set_record_from_json("AppConfig", json!({"debug": true}))?; + /// ``` + #[cfg(feature = "std")] + pub fn set_record_from_json( + &self, + record_name: &str, + json_value: serde_json::Value, + ) -> DbResult<()> { + self.inner.set_record_from_json(record_name, json_value) + } + + /// Subscribe to record updates as JSON stream (std only) + /// + /// Creates a subscription to a record's buffer and forwards updates as JSON + /// to a bounded channel. This is used internally by the remote access protocol + /// for implementing `record.subscribe`. + /// + /// # Architecture + /// + /// Spawns a consumer task that: + /// 1. Subscribes to the record's buffer using the existing buffer API + /// 2. Reads values as they arrive + /// 3. Serializes each value to JSON + /// 4. Sends JSON values to a bounded channel (with backpressure handling) + /// 5. Terminates when either: + /// - The cancel signal is received (unsubscribe) + /// - The channel receiver is dropped (client disconnected) + /// + /// # Arguments + /// * `type_id` - TypeId of the record to subscribe to + /// * `queue_size` - Size of the bounded channel for this subscription + /// + /// # Returns + /// `Ok((receiver, cancel_tx))` where: + /// - `receiver`: Bounded channel receiver for JSON values + /// - `cancel_tx`: One-shot sender to cancel the subscription + /// + /// `Err` if: + /// - Record not found for the given TypeId + /// - Record not configured with `.with_serialization()` + /// - Failed to subscribe to buffer + /// + /// # Example (internal use) + /// + /// ```rust,ignore + /// let type_id = TypeId::of::(); + /// let (mut rx, cancel_tx) = db.subscribe_record_updates(type_id, 100)?; + /// + /// // Read events + /// while let Some(json_value) = rx.recv().await { + /// // Forward to client... + /// } + /// + /// // Cancel subscription + /// let _ = cancel_tx.send(()); + /// ``` + #[cfg(feature = "std")] + #[allow(unused_variables)] // Variables used only in tracing feature + pub fn subscribe_record_updates( + &self, + type_id: TypeId, + queue_size: usize, + ) -> DbResult<( + tokio::sync::mpsc::Receiver, + tokio::sync::oneshot::Sender<()>, + )> { + use tokio::sync::{mpsc, oneshot}; + + // Find the record by TypeId + let record = self + .inner + .records + .get(&type_id) + .ok_or(DbError::RecordNotFound { + record_name: format!("TypeId({:?})", type_id), + })?; + + // Subscribe to the record's buffer as JSON stream + // This will fail if record not configured with .with_serialization() + let mut json_reader = record.subscribe_json()?; + + // Create channels for the subscription + let (value_tx, value_rx) = mpsc::channel(queue_size); + let (cancel_tx, mut cancel_rx) = oneshot::channel(); + + // Get metadata for logging + let record_metadata = record.collect_metadata(type_id); + let runtime = self.runtime.clone(); + + // Spawn consumer task that forwards JSON values from buffer to channel + let spawn_result = runtime.spawn(async move { + #[cfg(feature = "tracing")] + tracing::debug!( + "Subscription consumer task started for {}", + record_metadata.name + ); + + // Main event loop: read from buffer and forward to channel + loop { + tokio::select! { + // Handle cancellation signal + _ = &mut cancel_rx => { + #[cfg(feature = "tracing")] + tracing::debug!("Subscription cancelled"); + break; + } + // Read next JSON value from buffer + result = json_reader.recv_json() => { + match result { + Ok(json_val) => { + // Send JSON value to subscription channel + if value_tx.send(json_val).await.is_err() { + #[cfg(feature = "tracing")] + tracing::debug!("Subscription receiver dropped"); + break; + } + } + Err(DbError::BufferLagged { lag_count, .. }) => { + // Consumer fell behind - log warning but continue + #[cfg(feature = "tracing")] + tracing::warn!( + "Subscription for {} lagged by {} messages", + record_metadata.name, + lag_count + ); + // Continue reading - next recv will get latest + } + Err(DbError::BufferClosed { .. }) => { + // Buffer closed (shutdown) - exit gracefully + #[cfg(feature = "tracing")] + tracing::debug!("Buffer closed for {}", record_metadata.name); + break; + } + Err(e) => { + // Other error (shouldn't happen in practice) + #[cfg(feature = "tracing")] + tracing::error!( + "Subscription error for {}: {:?}", + record_metadata.name, + e + ); + break; + } + } + } + } + } + + #[cfg(feature = "tracing")] + tracing::debug!("Subscription consumer task terminated"); + }); + + spawn_result.map_err(DbError::from)?; + + Ok((value_rx, cancel_tx)) + } } #[cfg(test)] diff --git a/aimdb-core/src/error.rs b/aimdb-core/src/error.rs index 04f31030..43077e87 100644 --- a/aimdb-core/src/error.rs +++ b/aimdb-core/src/error.rs @@ -105,6 +105,15 @@ pub enum DbError { _reason: (), }, + /// Permission denied for operation + #[cfg_attr(feature = "std", error("Permission denied: {operation}"))] + PermissionDenied { + #[cfg(feature = "std")] + operation: String, + #[cfg(not(feature = "std"))] + _operation: (), + }, + // ===== Configuration Errors (0x4000-0x4FFF) ===== /// Missing required configuration parameter #[cfg_attr(feature = "std", error("Missing configuration parameter: {parameter}"))] @@ -234,6 +243,7 @@ impl core::fmt::Display for DbError { DbError::BufferClosed { .. } => (0xA002, "Buffer channel closed"), DbError::RecordNotFound { .. } => (0x7003, "Record not found"), DbError::InvalidOperation { .. } => (0x7004, "Invalid operation"), + DbError::PermissionDenied { .. } => (0x7005, "Permission denied"), DbError::MissingConfiguration { .. } => (0x4002, "Missing configuration"), DbError::RuntimeError { .. } => (0x7002, "Runtime error"), DbError::ResourceUnavailable { .. } => (0x5002, "Resource unavailable"), @@ -318,6 +328,7 @@ impl DbError { DbError::RuntimeError { .. } => 0x7002, DbError::RecordNotFound { .. } => 0x7003, DbError::InvalidOperation { .. } => 0x7004, + DbError::PermissionDenied { .. } => 0x7005, // I/O errors: 0x8000-0x8FFF (std only) #[cfg(feature = "std")] @@ -405,6 +416,10 @@ impl DbError { Self::prepend_context(&mut reason, context); DbError::InvalidOperation { operation, reason } } + DbError::PermissionDenied { mut operation } => { + Self::prepend_context(&mut operation, context); + DbError::PermissionDenied { operation } + } DbError::MissingConfiguration { mut parameter } => { Self::prepend_context(&mut parameter, context); DbError::MissingConfiguration { parameter } diff --git a/aimdb-core/src/ext_macros.rs b/aimdb-core/src/ext_macros.rs index 9e167921..d5f8b17c 100644 --- a/aimdb-core/src/ext_macros.rs +++ b/aimdb-core/src/ext_macros.rs @@ -101,15 +101,17 @@ macro_rules! impl_record_registrar_ext { use $crate::buffer::Buffer; #[cfg(feature = "std")] - let buffer = Box::new($buffer_new(&cfg)); + { + let buffer = Box::new($buffer_new(&cfg)); + self.buffer_with_cfg(buffer, cfg) + } #[cfg(not(feature = "std"))] - let buffer = { + { extern crate alloc; - alloc::boxed::Box::new($buffer_new(&cfg)) - }; - - self.buffer_raw(buffer) + let buffer = alloc::boxed::Box::new($buffer_new(&cfg)); + self.buffer_raw(buffer) + } } fn source( @@ -205,15 +207,17 @@ macro_rules! impl_record_registrar_ext { use $crate::buffer::Buffer; #[cfg(feature = "std")] - let buffer = Box::new($buffer_new(&cfg)); + { + let buffer = Box::new($buffer_new(&cfg)); + self.buffer_with_cfg(buffer, cfg) + } #[cfg(not(feature = "std"))] - let buffer = { + { extern crate alloc; - alloc::boxed::Box::new($buffer_new(&cfg)) - }; - - self.buffer_raw(buffer) + let buffer = alloc::boxed::Box::new($buffer_new(&cfg)); + self.buffer_raw(buffer) + } } fn source( diff --git a/aimdb-core/src/lib.rs b/aimdb-core/src/lib.rs index 4b30475f..b60192c0 100644 --- a/aimdb-core/src/lib.rs +++ b/aimdb-core/src/lib.rs @@ -21,6 +21,8 @@ pub mod context; pub mod database; mod error; pub mod ext_macros; +#[cfg(feature = "std")] +pub mod remote; pub mod time; pub mod transport; pub mod typed_api; diff --git a/aimdb-core/src/remote/config.rs b/aimdb-core/src/remote/config.rs new file mode 100644 index 00000000..a2c0a62b --- /dev/null +++ b/aimdb-core/src/remote/config.rs @@ -0,0 +1,250 @@ +//! Configuration types for AimX remote access + +use core::any::TypeId; +use std::{collections::HashSet, path::PathBuf, string::String, vec::Vec}; + +/// Configuration for AimX remote access +/// +/// Defines how the remote access layer behaves, including socket path, +/// security policy, connection limits, and subscription queue sizes. +#[derive(Debug, Clone)] +pub struct AimxConfig { + /// Path to Unix domain socket + pub socket_path: PathBuf, + + /// Security policy (read-only or read-write) + pub security_policy: SecurityPolicy, + + /// Maximum number of concurrent connections + pub max_connections: usize, + + /// Subscription queue size per client per subscription + pub subscription_queue_size: usize, + + /// Optional authentication token + pub auth_token: Option, + + /// File permissions for the socket (Unix only) + /// Format: octal mode (e.g., 0o600 for owner-only) + pub socket_permissions: Option, +} + +impl AimxConfig { + /// Creates a default UDS configuration + /// + /// # Defaults + /// - Socket path: `/tmp/aimdb.sock` + /// - Security policy: Read-only + /// - Max connections: 16 + /// - Subscription queue size: 100 + /// - No auth token + /// - Socket permissions: 0o600 (owner-only) + pub fn uds_default() -> Self { + Self { + socket_path: PathBuf::from("/tmp/aimdb.sock"), + security_policy: SecurityPolicy::ReadOnly, + max_connections: 16, + subscription_queue_size: 100, + auth_token: None, + socket_permissions: Some(0o600), + } + } + + /// Sets the socket path + pub fn socket_path(mut self, path: impl Into) -> Self { + self.socket_path = path.into(); + self + } + + /// Sets the security policy + pub fn security_policy(mut self, policy: SecurityPolicy) -> Self { + self.security_policy = policy; + self + } + + /// Sets the maximum number of concurrent connections + pub fn max_connections(mut self, max: usize) -> Self { + self.max_connections = max; + self + } + + /// Sets the subscription queue size per client + pub fn subscription_queue_size(mut self, size: usize) -> Self { + self.subscription_queue_size = size; + self + } + + /// Sets an authentication token + pub fn auth_token(mut self, token: impl Into) -> Self { + self.auth_token = Some(token.into()); + self + } + + /// Sets the socket file permissions (Unix only) + /// + /// # Example + /// ```rust,ignore + /// config.socket_permissions(0o600) // Owner only + /// config.socket_permissions(0o660) // Owner + group + /// ``` + pub fn socket_permissions(mut self, mode: u32) -> Self { + self.socket_permissions = Some(mode); + self + } +} + +/// Security policy for remote access +/// +/// Defines which operations are permitted and for which records. +#[derive(Debug, Clone)] +pub enum SecurityPolicy { + /// Read-only access (list, get, subscribe) + /// + /// This is the default and recommended policy for most deployments. + /// No write operations are permitted. + ReadOnly, + + /// Read-write access with explicit per-record opt-in + /// + /// Write operations (`record.set`) are only allowed for records + /// whose TypeId is in the `writable_records` set. + ReadWrite { + /// Set of TypeIds that allow write operations + writable_records: HashSet, + }, +} + +impl SecurityPolicy { + /// Creates a read-only policy + pub fn read_only() -> Self { + Self::ReadOnly + } + + /// Creates a read-write policy with no writable records initially + pub fn read_write() -> Self { + Self::ReadWrite { + writable_records: HashSet::new(), + } + } + + /// Adds a record type to the writable set + /// + /// Only has effect for ReadWrite policies. Panics if policy is ReadOnly. + pub fn allow_write(&mut self) { + match self { + Self::ReadWrite { writable_records } => { + writable_records.insert(TypeId::of::()); + } + Self::ReadOnly => { + panic!("Cannot allow writes in ReadOnly security policy"); + } + } + } + + /// Checks if a record type is writable + pub fn is_writable(&self, type_id: TypeId) -> bool { + match self { + Self::ReadOnly => false, + Self::ReadWrite { writable_records } => writable_records.contains(&type_id), + } + } + + /// Returns the list of granted permissions + pub fn permissions(&self) -> &[&str] { + match self { + Self::ReadOnly => &["read", "subscribe"], + Self::ReadWrite { .. } => &["read", "subscribe", "write"], + } + } + + /// Returns the list of writable record TypeIds (for ReadWrite policy) + pub fn writable_records(&self) -> Vec { + match self { + Self::ReadOnly => Vec::new(), + Self::ReadWrite { writable_records } => writable_records.iter().copied().collect(), + } + } +} + +/// Builder helper for SecurityPolicy with chained API +impl SecurityPolicy { + /// Builder pattern: Creates ReadWrite policy and allows write for a type + pub fn with_writable_record(mut self) -> Self { + if let Self::ReadWrite { + ref mut writable_records, + } = self + { + writable_records.insert(TypeId::of::()); + } + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg(feature = "std")] + fn test_default_config() { + let config = AimxConfig::uds_default(); + assert_eq!(config.socket_path, PathBuf::from("/tmp/aimdb.sock")); + assert_eq!(config.max_connections, 16); + assert_eq!(config.subscription_queue_size, 100); + assert!(matches!(config.security_policy, SecurityPolicy::ReadOnly)); + assert!(config.auth_token.is_none()); + } + + #[test] + #[cfg(feature = "std")] + fn test_config_builder() { + let config = AimxConfig::uds_default() + .socket_path("/var/run/aimdb.sock") + .max_connections(32) + .subscription_queue_size(200) + .auth_token("secret-token") + .socket_permissions(0o660); + + assert_eq!(config.socket_path, PathBuf::from("/var/run/aimdb.sock")); + assert_eq!(config.max_connections, 32); + assert_eq!(config.subscription_queue_size, 200); + assert_eq!(config.auth_token, Some("secret-token".to_string())); + assert_eq!(config.socket_permissions, Some(0o660)); + } + + #[test] + fn test_security_policy_read_only() { + let policy = SecurityPolicy::read_only(); + assert!(!policy.is_writable(TypeId::of::())); + assert_eq!(policy.permissions(), &["read", "subscribe"]); + } + + #[test] + fn test_security_policy_read_write() { + let mut policy = SecurityPolicy::read_write(); + assert!(!policy.is_writable(TypeId::of::())); + + policy.allow_write::(); + assert!(policy.is_writable(TypeId::of::())); + assert!(!policy.is_writable(TypeId::of::())); + assert_eq!(policy.permissions(), &["read", "subscribe", "write"]); + } + + #[test] + #[should_panic(expected = "Cannot allow writes in ReadOnly security policy")] + fn test_security_policy_read_only_panic() { + let mut policy = SecurityPolicy::read_only(); + policy.allow_write::(); + } + + #[test] + fn test_security_policy_builder() { + let policy = SecurityPolicy::read_write() + .with_writable_record::() + .with_writable_record::(); + + assert!(policy.is_writable(TypeId::of::())); + assert!(policy.is_writable(TypeId::of::())); + assert!(!policy.is_writable(TypeId::of::())); + } +} diff --git a/aimdb-core/src/remote/error.rs b/aimdb-core/src/remote/error.rs new file mode 100644 index 00000000..c27de55a --- /dev/null +++ b/aimdb-core/src/remote/error.rs @@ -0,0 +1,178 @@ +//! Error types for AimX remote access protocol + +use std::string::String; +use thiserror::Error; + +/// Error type for remote access operations +#[derive(Debug, Clone, Error)] +pub enum RemoteError { + /// Malformed message or invalid JSON + #[error("Protocol error: {message}")] + ProtocolError { message: String }, + + /// Incompatible protocol versions + #[error("Version mismatch: client {client_version}, server {server_version}")] + VersionMismatch { + client_version: String, + server_version: String, + }, + + /// Record or subscription not found + #[error("Not found: {resource}")] + NotFound { resource: String }, + + /// Operation not permitted + #[error("Permission denied: {reason}")] + PermissionDenied { reason: String }, + + /// Subscription queue overflow + #[error("Queue full: {queue_name}")] + QueueFull { queue_name: String }, + + /// Server internal error + #[error("Internal error: {message}")] + InternalError { message: String }, + + /// Too many subscriptions for this client + #[error("Too many subscriptions (limit: {limit})")] + TooManySubscriptions { limit: usize }, + + /// Record has no current value + #[error("No value: {record_name}")] + NoValue { record_name: String }, + + /// Record has no buffer configured + #[error("No buffer: {record_name}")] + NoBuffer { record_name: String }, + + /// Invalid parameter or value + #[error("Validation error: {message}")] + ValidationError { message: String }, + + /// Authentication token required + #[error("Authentication required")] + AuthRequired, + + /// Invalid authentication token + #[error("Authentication failed")] + AuthFailed, +} + +impl RemoteError { + /// Returns the protocol error code + pub fn code(&self) -> &'static str { + match self { + Self::ProtocolError { .. } => "PROTOCOL_ERROR", + Self::VersionMismatch { .. } => "VERSION_MISMATCH", + Self::NotFound { .. } => "NOT_FOUND", + Self::PermissionDenied { .. } => "PERMISSION_DENIED", + Self::QueueFull { .. } => "QUEUE_FULL", + Self::InternalError { .. } => "INTERNAL_ERROR", + Self::TooManySubscriptions { .. } => "TOO_MANY_SUBSCRIPTIONS", + Self::NoValue { .. } => "NO_VALUE", + Self::NoBuffer { .. } => "NO_BUFFER", + Self::ValidationError { .. } => "VALIDATION_ERROR", + Self::AuthRequired => "AUTH_REQUIRED", + Self::AuthFailed => "AUTH_FAILED", + } + } + + /// Returns whether this error is retryable + pub fn is_retryable(&self) -> bool { + matches!( + self, + Self::NotFound { .. } + | Self::QueueFull { .. } + | Self::InternalError { .. } + | Self::NoValue { .. } + ) + } +} + +/// Result type for remote operations +pub type RemoteResult = Result; + +// Conversion from DbError to RemoteError +impl From for RemoteError { + fn from(err: crate::DbError) -> Self { + use crate::DbError; + match err { + DbError::RecordNotFound { record_name } => RemoteError::NotFound { + resource: format!("record '{}'", record_name), + }, + DbError::InvalidOperation { operation, reason } => RemoteError::ValidationError { + message: format!("{}: {}", operation, reason), + }, + DbError::BufferFull { buffer_name, .. } => RemoteError::QueueFull { + queue_name: buffer_name, + }, + DbError::PermissionDenied { operation } => { + RemoteError::PermissionDenied { reason: operation } + } + _ => RemoteError::InternalError { + message: err.to_string(), + }, + } + } +} + +impl From for RemoteError { + fn from(err: std::io::Error) -> Self { + RemoteError::InternalError { + message: format!("I/O error: {}", err), + } + } +} + +impl From for RemoteError { + fn from(err: serde_json::Error) -> Self { + RemoteError::ProtocolError { + message: format!("JSON error: {}", err), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_codes() { + assert_eq!( + RemoteError::ProtocolError { + message: "test".to_string(), + } + .code(), + "PROTOCOL_ERROR" + ); + + assert_eq!( + RemoteError::NotFound { + resource: "test".to_string(), + } + .code(), + "NOT_FOUND" + ); + + assert_eq!( + RemoteError::PermissionDenied { + reason: "test".to_string(), + } + .code(), + "PERMISSION_DENIED" + ); + } + + #[test] + fn test_retryable() { + assert!(RemoteError::NotFound { + resource: "test".to_string(), + } + .is_retryable()); + + assert!(!RemoteError::PermissionDenied { + reason: "test".to_string(), + } + .is_retryable()); + } +} diff --git a/aimdb-core/src/remote/handler.rs b/aimdb-core/src/remote/handler.rs new file mode 100644 index 00000000..34ea3938 --- /dev/null +++ b/aimdb-core/src/remote/handler.rs @@ -0,0 +1,1172 @@ +//! Connection handler for AimX protocol +//! +//! Handles individual client connections, including handshake, authentication, +//! and protocol method dispatch. +//! +//! # Architecture: Event Funnel Pattern +//! +//! Subscriptions use a funnel pattern for clean event delivery: +//! - Each subscription spawns a consumer task that reads from the record buffer +//! - Consumer tasks send events to a shared mpsc channel (the "funnel") +//! - A single writer task drains the funnel and writes events to the UnixStream +//! - This ensures NDJSON line integrity and prevents write interleaving + +use crate::remote::{ + AimxConfig, Event, HelloMessage, RecordMetadata, Request, Response, WelcomeMessage, +}; +use crate::{AimDb, DbError, DbResult}; + +#[cfg(feature = "std")] +use std::collections::HashMap; +#[cfg(feature = "std")] +use std::sync::Arc; + +#[cfg(feature = "std")] +use serde_json::json; +#[cfg(feature = "std")] +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +#[cfg(feature = "std")] +use tokio::net::UnixStream; +#[cfg(feature = "std")] +use tokio::sync::mpsc; +#[cfg(feature = "std")] +use tokio::sync::oneshot; + +/// Handle for an active subscription +/// +/// Tracks the state needed to manage a single subscription's lifecycle. +#[cfg(feature = "std")] +#[allow(dead_code)] // record_name used only in tracing feature +struct SubscriptionHandle { + /// Unique subscription identifier (returned to client) + subscription_id: String, + + /// Record name being subscribed to + record_name: String, + + /// Signal to cancel this subscription + /// When sent, the consumer task will terminate + cancel_tx: oneshot::Sender<()>, +} + +/// Connection state for managing subscriptions +/// +/// Tracks all active subscriptions for a single client connection. +#[cfg(feature = "std")] +struct ConnectionState { + /// Active subscriptions by subscription_id + subscriptions: HashMap, + + /// Counter for generating unique subscription IDs + next_subscription_id: u64, + + /// Event funnel: all subscription tasks send events here + /// This channel feeds the single writer task + event_tx: mpsc::UnboundedSender, +} + +#[cfg(feature = "std")] +impl ConnectionState { + /// Creates a new connection state + fn new(event_tx: mpsc::UnboundedSender) -> Self { + Self { + subscriptions: HashMap::new(), + next_subscription_id: 1, + event_tx, + } + } + + /// Generates a unique subscription ID for this connection + fn generate_subscription_id(&mut self) -> String { + let id = format!("sub-{}", self.next_subscription_id); + self.next_subscription_id += 1; + id + } + + /// Adds a subscription to the connection state + fn add_subscription(&mut self, handle: SubscriptionHandle) { + self.subscriptions + .insert(handle.subscription_id.clone(), handle); + } + + /// Removes and returns a subscription by ID + #[allow(dead_code)] + fn remove_subscription(&mut self, subscription_id: &str) -> Option { + self.subscriptions.remove(subscription_id) + } + + /// Cancels all active subscriptions + /// + /// Sends cancel signals to all subscription tasks and clears the map. + /// Called when the client disconnects. + async fn cancel_all_subscriptions(&mut self) { + #[cfg(feature = "tracing")] + tracing::info!( + "Canceling {} active subscriptions", + self.subscriptions.len() + ); + + for (_id, handle) in self.subscriptions.drain() { + // Send cancel signal (ignore if receiver already dropped) + let _ = handle.cancel_tx.send(()); + } + } +} + +/// Handles an incoming client connection +/// +/// Processes the AimX protocol handshake and manages the client session. +/// Implements the event funnel pattern for subscription event delivery. +/// +/// # Architecture +/// +/// ```text +/// ┌─────────────────┐ +/// │ Subscription 1 │───┐ +/// │ Consumer Task │ │ +/// └─────────────────┘ │ +/// ├──► Event Funnel ───► select! loop ───► UnixStream +/// ┌─────────────────┐ │ (mpsc) (interleaved +/// │ Subscription 2 │───┘ writes) +/// │ Consumer Task │ +/// └─────────────────┘ +/// ``` +/// +/// The main loop uses `tokio::select!` to interleave: +/// - Reading requests from the stream +/// - Writing events from subscriptions +/// +/// This ensures both responses and events are written without blocking. +/// +/// # Arguments +/// * `db` - Database instance +/// * `config` - Remote access configuration +/// * `stream` - Unix domain socket stream +/// +/// # Errors +/// Returns error if handshake fails or stream operations error +#[cfg(feature = "std")] +pub async fn handle_connection( + db: Arc>, + config: AimxConfig, + stream: UnixStream, +) -> DbResult<()> +where + R: crate::RuntimeAdapter + crate::Spawn + 'static, +{ + #[cfg(feature = "tracing")] + tracing::info!("New remote access connection established"); + + // Perform protocol handshake + let mut stream = match perform_handshake(stream, &config, &db).await { + Ok(stream) => stream, + Err(e) => { + #[cfg(feature = "tracing")] + tracing::warn!("Handshake failed: {}", e); + return Err(e); + } + }; + + #[cfg(feature = "tracing")] + tracing::info!("Handshake complete, client ready"); + + // Create event funnel: all subscription tasks will send events here + let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); + + // Initialize connection state + let mut conn_state = ConnectionState::new(event_tx); + + // Main loop: interleave reading requests and writing events + loop { + let mut line = String::new(); + + tokio::select! { + // Handle incoming requests + read_result = stream.read_line(&mut line) => { + match read_result { + Ok(0) => { + // Client closed connection + #[cfg(feature = "tracing")] + tracing::info!("Client disconnected gracefully"); + break; + } + Ok(_) => { + #[cfg(feature = "tracing")] + tracing::debug!("Received request: {}", line.trim()); + + // Parse request + let request: Request = match serde_json::from_str(line.trim()) { + Ok(req) => req, + Err(e) => { + #[cfg(feature = "tracing")] + tracing::warn!("Failed to parse request: {}", e); + + // Send error response (use ID 0 if we can't parse the request) + let error_response = + Response::error(0, "parse_error", format!("Invalid JSON: {}", e)); + if let Err(_e) = send_response(&mut stream, &error_response).await { + #[cfg(feature = "tracing")] + tracing::error!("Failed to send error response: {}", _e); + break; + } + continue; + } + }; + + // Dispatch request to appropriate handler + let response = handle_request(&db, &config, &mut conn_state, request).await; + + // Send response + if let Err(_e) = send_response(&mut stream, &response).await { + #[cfg(feature = "tracing")] + tracing::error!("Failed to send response: {}", _e); + break; + } + } + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!("Error reading from stream: {}", _e); + break; + } + } + } + + // Handle outgoing events from subscriptions + Some(event) = event_rx.recv() => { + if let Err(_e) = send_event(&mut stream, &event).await { + #[cfg(feature = "tracing")] + tracing::error!("Failed to send event: {}", _e); + break; + } + } + } + } + + // Cleanup: cancel all active subscriptions + conn_state.cancel_all_subscriptions().await; + + #[cfg(feature = "tracing")] + tracing::info!("Connection handler terminating"); + + Ok(()) +} + +/// Sends an event to the client +/// +/// Serializes the event to JSON and writes it to the stream with a newline. +/// +/// # Arguments +/// * `stream` - The connection stream +/// * `event` - The event to send +/// +/// # Errors +/// Returns error if serialization or write fails +#[cfg(feature = "std")] +async fn send_event(stream: &mut BufReader, event: &Event) -> DbResult<()> { + // Wrap event in protocol envelope + let event_msg = json!({ "event": event }); + + let event_json = serde_json::to_string(&event_msg).map_err(|e| DbError::JsonWithContext { + context: "Failed to serialize event".to_string(), + source: e, + })?; + + stream + .get_mut() + .write_all(event_json.as_bytes()) + .await + .map_err(|e| DbError::IoWithContext { + context: "Failed to write event".to_string(), + source: e, + })?; + + stream + .get_mut() + .write_all(b"\n") + .await + .map_err(|e| DbError::IoWithContext { + context: "Failed to write event newline".to_string(), + source: e, + })?; + + #[cfg(feature = "tracing")] + tracing::trace!("Sent event for subscription: {}", event.subscription_id); + + Ok(()) +} + +/// Sends a response to the client +/// +/// Serializes the response to JSON and writes it to the stream with a newline. +/// +/// # Arguments +/// * `stream` - The connection stream +/// * `response` - The response to send +/// +/// # Errors +/// Returns error if serialization or write fails +#[cfg(feature = "std")] +async fn send_response(stream: &mut BufReader, response: &Response) -> DbResult<()> { + let response_json = serde_json::to_string(response).map_err(|e| DbError::JsonWithContext { + context: "Failed to serialize response".to_string(), + source: e, + })?; + + stream + .get_mut() + .write_all(response_json.as_bytes()) + .await + .map_err(|e| DbError::IoWithContext { + context: "Failed to write response".to_string(), + source: e, + })?; + + stream + .get_mut() + .write_all(b"\n") + .await + .map_err(|e| DbError::IoWithContext { + context: "Failed to write response newline".to_string(), + source: e, + })?; + + #[cfg(feature = "tracing")] + tracing::debug!("Sent response"); + + Ok(()) +} + +/// Performs the AimX protocol handshake +/// +/// Handshake flow: +/// 1. Client sends HelloMessage with protocol version +/// 2. Server validates version compatibility +/// 3. Server sends WelcomeMessage with accepted version +/// 4. Optional: Authenticate with token +/// +/// # Arguments +/// * `stream` - Unix domain socket stream +/// * `config` - Remote access configuration +/// * `db` - Database instance (for querying writable records) +/// +/// # Returns +/// `BufReader` if handshake succeeds +/// +/// # Errors +/// Returns error if: +/// - Protocol version incompatible +/// - Authentication fails +/// - IO error during handshake +#[cfg(feature = "std")] +async fn perform_handshake( + stream: UnixStream, + config: &AimxConfig, + db: &Arc>, +) -> DbResult> +where + R: crate::RuntimeAdapter + crate::Spawn + 'static, +{ + let (reader, mut writer) = stream.into_split(); + let mut reader = BufReader::new(reader); + + // Read Hello message from client + let mut line = String::new(); + reader + .read_line(&mut line) + .await + .map_err(|e| DbError::IoWithContext { + context: "Failed to read Hello message".to_string(), + source: e, + })?; + + #[cfg(feature = "tracing")] + tracing::debug!("Received handshake: {}", line.trim()); + + // Parse Hello message + let hello: HelloMessage = + serde_json::from_str(line.trim()).map_err(|e| DbError::JsonWithContext { + context: "Failed to parse Hello message".to_string(), + source: e, + })?; + + #[cfg(feature = "tracing")] + tracing::debug!( + "Client hello: version={}, client={}", + hello.version, + hello.client + ); + + // Version validation: accept "1.0" or "1" + if hello.version != "1.0" && hello.version != "1" { + let error_msg = format!( + r#"{{"error":"unsupported_version","message":"Server supports version 1.0, client requested {}"}}"#, + hello.version + ); + + #[cfg(feature = "tracing")] + tracing::warn!("Unsupported version: {}", hello.version); + + let _ = writer.write_all(error_msg.as_bytes()).await; + let _ = writer.write_all(b"\n").await; + let _ = writer.shutdown().await; + + return Err(DbError::InvalidOperation { + operation: "handshake".to_string(), + reason: format!("Unsupported version: {}", hello.version), + }); + } + + // Check authentication if required + let authenticated = if let Some(expected_token) = &config.auth_token { + match &hello.auth_token { + Some(provided_token) if provided_token == expected_token => { + #[cfg(feature = "tracing")] + tracing::debug!("Authentication successful"); + true + } + Some(_) => { + let error_msg = + r#"{"error":"authentication_failed","message":"Invalid auth token"}"#; + + #[cfg(feature = "tracing")] + tracing::warn!("Authentication failed: invalid token"); + + let _ = writer.write_all(error_msg.as_bytes()).await; + let _ = writer.write_all(b"\n").await; + let _ = writer.shutdown().await; + + return Err(DbError::PermissionDenied { + operation: "authentication".to_string(), + }); + } + None => { + let error_msg = + r#"{"error":"authentication_required","message":"Auth token required"}"#; + + #[cfg(feature = "tracing")] + tracing::warn!("Authentication failed: no token provided"); + + let _ = writer.write_all(error_msg.as_bytes()).await; + let _ = writer.write_all(b"\n").await; + let _ = writer.shutdown().await; + + return Err(DbError::PermissionDenied { + operation: "authentication".to_string(), + }); + } + } + } else { + false + }; + + // Determine permissions based on security policy + let permissions = match &config.security_policy { + crate::remote::SecurityPolicy::ReadOnly => vec!["read".to_string()], + crate::remote::SecurityPolicy::ReadWrite { .. } => { + vec!["read".to_string(), "write".to_string()] + } + }; + + // Get writable records by querying database for writable record names + let writable_records = match &config.security_policy { + crate::remote::SecurityPolicy::ReadOnly => vec![], + crate::remote::SecurityPolicy::ReadWrite { + writable_records: _writable_type_ids, + } => { + // Get all records from database + let all_records: Vec = db.list_records(); + + // Filter to those that are marked writable + all_records + .into_iter() + .filter(|meta| meta.writable) + .map(|meta| meta.name) + .collect() + } + }; + + // Send Welcome message + let welcome = WelcomeMessage { + version: "1.0".to_string(), + server: "aimdb".to_string(), + permissions, + writable_records, + max_subscriptions: Some(config.subscription_queue_size), + authenticated: Some(authenticated), + }; + + let welcome_json = serde_json::to_string(&welcome).map_err(|e| DbError::JsonWithContext { + context: "Failed to serialize Welcome message".to_string(), + source: e, + })?; + + writer + .write_all(welcome_json.as_bytes()) + .await + .map_err(|e| DbError::IoWithContext { + context: "Failed to write Welcome message".to_string(), + source: e, + })?; + + writer + .write_all(b"\n") + .await + .map_err(|e| DbError::IoWithContext { + context: "Failed to write Welcome newline".to_string(), + source: e, + })?; + + #[cfg(feature = "tracing")] + tracing::info!("Sent Welcome message to client"); + + // Reunite the stream + let stream = reader + .into_inner() + .reunite(writer) + .map_err(|e| DbError::Io { + source: std::io::Error::other(e.to_string()), + })?; + + Ok(BufReader::new(stream)) +} + +/// Handles a single request and returns a response +/// +/// Dispatches to the appropriate handler based on the request method. +/// +/// # Arguments +/// * `db` - Database instance +/// * `config` - Remote access configuration +/// * `conn_state` - Connection state (for subscription management) +/// * `request` - The parsed request +/// +/// # Returns +/// Response to send to the client +#[cfg(feature = "std")] +async fn handle_request( + db: &Arc>, + config: &AimxConfig, + conn_state: &mut ConnectionState, + request: Request, +) -> Response +where + R: crate::RuntimeAdapter + crate::Spawn + 'static, +{ + #[cfg(feature = "tracing")] + tracing::debug!( + "Handling request: method={}, id={}", + request.method, + request.id + ); + + match request.method.as_str() { + "record.list" => handle_record_list(db, config, request.id).await, + "record.get" => handle_record_get(db, config, request.id, request.params).await, + "record.set" => handle_record_set(db, config, request.id, request.params).await, + "record.subscribe" => { + handle_record_subscribe(db, config, conn_state, request.id, request.params).await + } + "record.unsubscribe" => { + handle_record_unsubscribe(conn_state, request.id, request.params).await + } + _ => { + #[cfg(feature = "tracing")] + tracing::warn!("Unknown method: {}", request.method); + + Response::error( + request.id, + "method_not_found", + format!("Unknown method: {}", request.method), + ) + } + } +} + +/// Handles record.list method +/// +/// Returns metadata for all registered records in the database. +/// +/// # Arguments +/// * `db` - Database instance +/// * `config` - Remote access configuration (for permission checks) +/// * `request_id` - Request ID for the response +/// +/// # Returns +/// Success response with array of RecordMetadata +#[cfg(feature = "std")] +async fn handle_record_list( + db: &Arc>, + _config: &AimxConfig, + request_id: u64, +) -> Response +where + R: crate::RuntimeAdapter + crate::Spawn + 'static, +{ + #[cfg(feature = "tracing")] + tracing::debug!("Listing records"); + + // Get all record metadata from database + let records: Vec = db.list_records(); + + #[cfg(feature = "tracing")] + tracing::debug!("Found {} records", records.len()); + + // Convert to JSON and return + Response::success(request_id, json!(records)) +} + +/// Handles record.get method +/// +/// Returns the current value of a record as JSON. +/// +/// # Arguments +/// * `db` - Database instance +/// * `config` - Remote access configuration (for permission checks) +/// * `request_id` - Request ID for the response +/// * `params` - Request parameters (must contain "record" field with record name) +/// +/// # Returns +/// Success response with record value as JSON, or error if: +/// - Missing/invalid "record" parameter +/// - Record not found +/// - Record not configured with `.with_serialization()` +/// - No value available in atomic snapshot +#[cfg(feature = "std")] +async fn handle_record_get( + db: &Arc>, + _config: &AimxConfig, + request_id: u64, + params: Option, +) -> Response +where + R: crate::RuntimeAdapter + crate::Spawn + 'static, +{ + // Extract record name from params + let record_name = match params { + Some(serde_json::Value::Object(map)) => match map.get("record") { + Some(serde_json::Value::String(name)) => name.clone(), + _ => { + #[cfg(feature = "tracing")] + tracing::warn!("Missing or invalid 'record' parameter"); + + return Response::error( + request_id, + "invalid_params", + "Missing or invalid 'record' parameter".to_string(), + ); + } + }, + _ => { + #[cfg(feature = "tracing")] + tracing::warn!("Missing params object"); + + return Response::error( + request_id, + "invalid_params", + "Missing params object".to_string(), + ); + } + }; + + #[cfg(feature = "tracing")] + tracing::debug!("Getting value for record: {}", record_name); + + // Try to peek the record's JSON value + match db.try_latest_as_json(&record_name) { + Some(value) => { + #[cfg(feature = "tracing")] + tracing::debug!("Successfully retrieved value for {}", record_name); + + Response::success(request_id, value) + } + None => { + #[cfg(feature = "tracing")] + tracing::warn!("No value available for record: {}", record_name); + + Response::error( + request_id, + "not_found", + format!("No value available for record: {}", record_name), + ) + } + } +} + +/// Handles record.set method +/// +/// Sets a record value from JSON (write operation). +/// +/// **SAFETY:** Enforces the "No Producer Override" rule: +/// - Only allows writes to configuration records (producer_count == 0) +/// - Prevents remote access from interfering with application logic +/// +/// # Arguments +/// * `db` - Database instance +/// * `config` - Remote access configuration (for permission checks) +/// * `request_id` - Request ID for the response +/// * `params` - Request parameters (must contain "name" and "value" fields) +/// +/// # Returns +/// Success response, or error if: +/// - Missing/invalid parameters +/// - Record not found +/// - Permission denied (not writable or has active producers) +/// - Deserialization failed +#[cfg(feature = "std")] +async fn handle_record_set( + db: &Arc>, + config: &AimxConfig, + request_id: u64, + params: Option, +) -> Response +where + R: crate::RuntimeAdapter + crate::Spawn + 'static, +{ + use crate::remote::SecurityPolicy; + + // Check if write operations are allowed + let writable_records = match &config.security_policy { + SecurityPolicy::ReadOnly => { + #[cfg(feature = "tracing")] + tracing::warn!("record.set called but security policy is ReadOnly"); + + return Response::error( + request_id, + "permission_denied", + "Write operations not allowed (ReadOnly security policy)".to_string(), + ); + } + SecurityPolicy::ReadWrite { writable_records } => writable_records, + }; + + // Extract record name and value from params + let (record_name, value) = match params { + Some(serde_json::Value::Object(ref map)) => { + let name = match map.get("name") { + Some(serde_json::Value::String(n)) => n.clone(), + _ => { + #[cfg(feature = "tracing")] + tracing::warn!("Missing or invalid 'name' parameter in record.set"); + + return Response::error( + request_id, + "invalid_params", + "Missing or invalid 'name' parameter (expected string)".to_string(), + ); + } + }; + + let val = match map.get("value") { + Some(v) => v.clone(), + None => { + #[cfg(feature = "tracing")] + tracing::warn!("Missing 'value' parameter in record.set"); + + return Response::error( + request_id, + "invalid_params", + "Missing 'value' parameter".to_string(), + ); + } + }; + + (name, val) + } + _ => { + #[cfg(feature = "tracing")] + tracing::warn!("Missing params object in record.set"); + + return Response::error( + request_id, + "invalid_params", + "Missing params object".to_string(), + ); + } + }; + + #[cfg(feature = "tracing")] + tracing::debug!("Setting value for record: {}", record_name); + + // Find the record's TypeId by name + let type_id_opt = db.inner().records.iter().find_map(|(tid, record)| { + let metadata = record.collect_metadata(*tid); + if metadata.name == record_name { + Some(*tid) + } else { + None + } + }); + + let type_id = match type_id_opt { + Some(tid) => tid, + None => { + #[cfg(feature = "tracing")] + tracing::warn!("Record not found: {}", record_name); + + return Response::error( + request_id, + "not_found", + format!("Record '{}' not found", record_name), + ); + } + }; + + // Check if record is in the writable_records set + if !writable_records.contains(&type_id) { + #[cfg(feature = "tracing")] + tracing::warn!("Record '{}' not in writable_records set", record_name); + + return Response::error( + request_id, + "permission_denied", + format!( + "Record '{}' is not writable. \ + Configure with .with_writable_record() to allow writes.", + record_name + ), + ); + } + + // Attempt to set the value + // This will enforce the "no producer override" rule internally + match db.set_record_from_json(&record_name, value) { + Ok(()) => { + #[cfg(feature = "tracing")] + tracing::info!("Successfully set value for record: {}", record_name); + + // Get the updated value to return in response + let result = if let Some(updated_value) = db.try_latest_as_json(&record_name) { + serde_json::json!({ + "status": "success", + "value": updated_value, + }) + } else { + serde_json::json!({ + "status": "success", + }) + }; + + Response::success(request_id, result) + } + Err(e) => { + #[cfg(feature = "tracing")] + tracing::error!("Failed to set value for record '{}': {}", record_name, e); + + // Map internal errors to appropriate response codes + let (code, message) = match e { + crate::DbError::PermissionDenied { operation } => { + // This is the "has active producers" error + ("permission_denied", operation) + } + crate::DbError::JsonWithContext { context, .. } => ( + "validation_error", + format!("JSON validation failed: {}", context), + ), + crate::DbError::RuntimeError { message } => ("internal_error", message), + _ => ("internal_error", format!("Failed to set value: {}", e)), + }; + + Response::error(request_id, code, message) + } + } +} + +/// Handles record.subscribe method +/// +/// Subscribes to real-time updates for a record. +/// +/// # Arguments +/// * `db` - Database instance +/// * `config` - Remote access configuration +/// * `conn_state` - Connection state (for subscription tracking) +/// * `request_id` - Request ID for the response +/// * `params` - Request parameters (must contain "name" field with record name) +/// +/// # Returns +/// Success response with subscription_id and queue_size, or error if: +/// - Missing/invalid parameters +/// - Record not found +/// - Too many subscriptions +#[cfg(feature = "std")] +async fn handle_record_subscribe( + db: &Arc>, + config: &AimxConfig, + conn_state: &mut ConnectionState, + request_id: u64, + params: Option, +) -> Response +where + R: crate::RuntimeAdapter + crate::Spawn + 'static, +{ + // Extract record name from params + let record_name = match params { + Some(serde_json::Value::Object(ref map)) => match map.get("name") { + Some(serde_json::Value::String(name)) => name.clone(), + _ => { + #[cfg(feature = "tracing")] + tracing::warn!("Missing or invalid 'name' parameter in record.subscribe"); + + return Response::error( + request_id, + "invalid_params", + "Missing or invalid 'name' parameter (expected string)".to_string(), + ); + } + }, + _ => { + #[cfg(feature = "tracing")] + tracing::warn!("Missing params object in record.subscribe"); + + return Response::error( + request_id, + "invalid_params", + "Missing params object".to_string(), + ); + } + }; + + // Optional: send_initial flag (default true) + let _send_initial = params + .as_ref() + .and_then(|p| p.as_object()) + .and_then(|map| map.get("send_initial")) + .and_then(|v| v.as_bool()) + .unwrap_or(true); + + #[cfg(feature = "tracing")] + tracing::debug!("Subscribing to record: {}", record_name); + + // Find the record's TypeId by name + // We need to search through the database's records to find the matching TypeId + let type_id_opt = db.inner().records.iter().find_map(|(tid, record)| { + let metadata = record.collect_metadata(*tid); + if metadata.name == record_name { + Some(*tid) + } else { + None + } + }); + + let type_id = match type_id_opt { + Some(tid) => tid, + None => { + #[cfg(feature = "tracing")] + tracing::warn!("Record not found: {}", record_name); + + return Response::error( + request_id, + "not_found", + format!("Record '{}' not found", record_name), + ); + } + }; + + // Check max subscriptions limit + if conn_state.subscriptions.len() >= config.subscription_queue_size { + #[cfg(feature = "tracing")] + tracing::warn!( + "Too many subscriptions: {} (max: {})", + conn_state.subscriptions.len(), + config.subscription_queue_size + ); + + return Response::error( + request_id, + "too_many_subscriptions", + format!( + "Maximum subscriptions reached: {}", + config.subscription_queue_size + ), + ); + } + + // Generate unique subscription ID + let subscription_id = conn_state.generate_subscription_id(); + + // Subscribe to record updates via the database API + let (value_rx, cancel_tx) = + match db.subscribe_record_updates(type_id, config.subscription_queue_size) { + Ok(channels) => channels, + Err(e) => { + #[cfg(feature = "tracing")] + tracing::error!("Failed to subscribe to record updates: {}", e); + + return Response::error( + request_id, + "internal_error", + format!("Failed to subscribe: {}", e), + ); + } + }; + + // Spawn event streaming task for this subscription + let event_tx = conn_state.event_tx.clone(); + let sub_id_clone = subscription_id.clone(); + let stream_handle = tokio::spawn(async move { + stream_subscription_events(sub_id_clone, value_rx, event_tx).await; + }); + + // Store subscription handle + let handle = SubscriptionHandle { + subscription_id: subscription_id.clone(), + record_name: record_name.clone(), + cancel_tx, + }; + conn_state.add_subscription(handle); + + // Detach the streaming task (it will run until cancelled or channel closes) + std::mem::drop(stream_handle); + + #[cfg(feature = "tracing")] + tracing::info!( + "Created subscription {} for record {}", + subscription_id, + record_name + ); + + // Return success response + Response::success( + request_id, + json!({ + "subscription_id": subscription_id, + "queue_size": config.subscription_queue_size, + }), + ) +} + +/// Streams subscription events from value channel to event channel +/// +/// Reads JSON values from the subscription's receiver and converts them +/// into Event messages with sequence numbers and timestamps. +/// +/// # Arguments +/// * `subscription_id` - Unique subscription identifier +/// * `value_rx` - Receiver for JSON values from the database +/// * `event_tx` - Sender for Event messages to the client +#[cfg(feature = "std")] +async fn stream_subscription_events( + subscription_id: String, + mut value_rx: tokio::sync::mpsc::Receiver, + event_tx: tokio::sync::mpsc::UnboundedSender, +) { + let mut sequence: u64 = 1; + + #[cfg(feature = "tracing")] + tracing::debug!( + "Event streaming task started for subscription: {}", + subscription_id + ); + + while let Some(json_value) = value_rx.recv().await { + // Generate timestamp in "secs.nanosecs" format + let timestamp = format!( + "{:?}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + ); + + // Create event + let event = Event { + subscription_id: subscription_id.clone(), + sequence, + data: json_value, + timestamp, + dropped: None, // TODO: Implement dropped event tracking + }; + + // Send event to the funnel + if event_tx.send(event).is_err() { + #[cfg(feature = "tracing")] + tracing::debug!( + "Event channel closed, terminating stream for subscription: {}", + subscription_id + ); + break; + } + + sequence += 1; + } + + #[cfg(feature = "tracing")] + tracing::debug!( + "Event streaming task terminated for subscription: {}", + subscription_id + ); +} + +/// Handles record.unsubscribe method +/// +/// Cancels an active subscription. +/// +/// # Arguments +/// * `conn_state` - Connection state (for subscription tracking) +/// * `request_id` - Request ID for the response +/// * `params` - Request parameters (must contain "subscription_id" field) +/// +/// # Returns +/// Success response, or error if subscription not found +#[cfg(feature = "std")] +async fn handle_record_unsubscribe( + conn_state: &mut ConnectionState, + request_id: u64, + params: Option, +) -> Response { + // Parse subscription_id parameter + let subscription_id = match params { + Some(serde_json::Value::Object(ref map)) => match map.get("subscription_id") { + Some(serde_json::Value::String(id)) => id.clone(), + _ => { + return Response::error( + request_id, + "invalid_params", + "Missing or invalid 'subscription_id' parameter".to_string(), + ) + } + }, + _ => { + return Response::error( + request_id, + "invalid_params", + "Missing 'subscription_id' parameter".to_string(), + ) + } + }; + + #[cfg(feature = "tracing")] + tracing::debug!("Unsubscribing from subscription_id: {}", subscription_id); + + // Look up and remove the subscription + match conn_state.subscriptions.remove(&subscription_id) { + Some(handle) => { + // Send cancellation signal to the streaming task + // It's okay if this fails (task may have already terminated) + let _ = handle.cancel_tx.send(()); + + #[cfg(feature = "tracing")] + tracing::debug!( + "Cancelled subscription {} for record {}", + subscription_id, + handle.record_name + ); + + Response::success( + request_id, + serde_json::json!({ + "subscription_id": subscription_id, + "status": "cancelled" + }), + ) + } + None => { + #[cfg(feature = "tracing")] + tracing::warn!("Subscription not found: {}", subscription_id); + + Response::error( + request_id, + "not_found", + format!("Subscription '{}' not found", subscription_id), + ) + } + } +} diff --git a/aimdb-core/src/remote/metadata.rs b/aimdb-core/src/remote/metadata.rs new file mode 100644 index 00000000..6c4f9604 --- /dev/null +++ b/aimdb-core/src/remote/metadata.rs @@ -0,0 +1,148 @@ +//! Record metadata types for remote introspection + +use core::any::TypeId; +use serde::{Deserialize, Serialize}; +use std::string::String; + +/// Metadata about a registered record type +/// +/// Provides information for remote introspection, including buffer +/// configuration, producer/consumer counts, and timestamps. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecordMetadata { + /// Record type name (Rust type name) + pub name: String, + + /// TypeId as hexadecimal string + pub type_id: String, + + /// Buffer type: "spmc_ring", "single_latest", "mailbox", or "none" + pub buffer_type: String, + + /// Buffer capacity (None for unbounded or no buffer) + #[serde(skip_serializing_if = "Option::is_none")] + pub buffer_capacity: Option, + + /// Number of registered producer services + pub producer_count: usize, + + /// Number of registered consumer services + pub consumer_count: usize, + + /// Whether write operations are permitted for this record + pub writable: bool, + + /// When the record was registered (ISO 8601) + pub created_at: String, + + /// Last update timestamp (ISO 8601), None if never updated + #[serde(skip_serializing_if = "Option::is_none")] + pub last_update: Option, + + /// Number of connector links registered + pub connector_count: usize, +} + +impl RecordMetadata { + /// Creates a new record metadata entry + /// + /// # Arguments + /// * `type_id` - The TypeId of the record + /// * `name` - The Rust type name + /// * `buffer_type` - Buffer type string + /// * `buffer_capacity` - Optional buffer capacity + /// * `producer_count` - Number of producers + /// * `consumer_count` - Number of consumers + /// * `writable` - Whether writes are permitted + /// * `created_at` - Creation timestamp (ISO 8601) + /// * `connector_count` - Number of connectors + #[allow(clippy::too_many_arguments)] + pub fn new( + type_id: TypeId, + name: String, + buffer_type: String, + buffer_capacity: Option, + producer_count: usize, + consumer_count: usize, + writable: bool, + created_at: String, + connector_count: usize, + ) -> Self { + Self { + name, + type_id: format!("{:?}", type_id), + buffer_type, + buffer_capacity, + producer_count, + consumer_count, + writable, + created_at, + last_update: None, + connector_count, + } + } + + /// Sets the last update timestamp + pub fn with_last_update(mut self, timestamp: String) -> Self { + self.last_update = Some(timestamp); + self + } + + /// Sets the last update timestamp from an Option + pub fn with_last_update_opt(mut self, timestamp: Option) -> Self { + self.last_update = timestamp; + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_record_metadata_creation() { + let type_id = TypeId::of::(); + let metadata = RecordMetadata::new( + type_id, + "i32".to_string(), + "spmc_ring".to_string(), + Some(100), + 1, + 2, + false, + "2025-10-31T10:00:00.000Z".to_string(), + 0, + ); + + assert_eq!(metadata.name, "i32"); + assert_eq!(metadata.buffer_type, "spmc_ring"); + assert_eq!(metadata.buffer_capacity, Some(100)); + assert_eq!(metadata.producer_count, 1); + assert_eq!(metadata.consumer_count, 2); + assert!(!metadata.writable); + assert_eq!(metadata.connector_count, 0); + } + + #[test] + fn test_record_metadata_serialization() { + let type_id = TypeId::of::(); + let metadata = RecordMetadata::new( + type_id, + "String".to_string(), + "single_latest".to_string(), + None, + 1, + 1, + true, + "2025-10-31T10:00:00.000Z".to_string(), + 2, + ) + .with_last_update("2025-10-31T12:00:00.000Z".to_string()); + + let json = serde_json::to_string(&metadata).unwrap(); + assert!(json.contains("\"name\":\"String\"")); + assert!(json.contains("\"buffer_type\":\"single_latest\"")); + assert!(json.contains("\"writable\":true")); + assert!(json.contains("\"connector_count\":2")); + } +} diff --git a/aimdb-core/src/remote/mod.rs b/aimdb-core/src/remote/mod.rs new file mode 100644 index 00000000..48af5f6a --- /dev/null +++ b/aimdb-core/src/remote/mod.rs @@ -0,0 +1,48 @@ +//! Remote access subsystem for AimDB (AimX protocol) +//! +//! Provides introspection and management APIs over Unix domain sockets, +//! enabling external tools (CLI, dashboards, MCP adapters) to interact +//! with running AimDB instances. +//! +//! # Protocol +//! +//! AimX v1 uses NDJSON (newline-delimited JSON) over Unix domain sockets. +//! See `docs/design/remote-access/aimx-v1.md` for full specification. +//! +//! # Security +//! +//! - **Read-only by default**: No writes unless explicitly enabled +//! - **UDS permissions**: Primary security mechanism (file permissions) +//! - **Optional auth tokens**: Additional authentication layer +//! - **Per-record write permissions**: Explicit opt-in required +//! +//! # Usage +//! +//! ```rust,ignore +//! use aimdb_core::remote::{AimxConfig, SecurityPolicy}; +//! +//! let db = AimDbBuilder::new() +//! .runtime(tokio_adapter) +//! .with_remote_access( +//! AimxConfig::uds_default() +//! .socket_path("/var/run/aimdb/aimdb.sock") +//! .security_policy(SecurityPolicy::ReadOnly) +//! .max_connections(16) +//! .subscription_queue_size(100) +//! ) +//! .build()?; +//! ``` + +mod config; +mod error; +mod metadata; +mod protocol; + +pub use config::{AimxConfig, SecurityPolicy}; +pub use error::{RemoteError, RemoteResult}; +pub use metadata::RecordMetadata; +pub use protocol::{Event, HelloMessage, Request, Response, WelcomeMessage}; + +// Internal exports for implementation +pub(crate) mod handler; +pub(crate) mod supervisor; diff --git a/aimdb-core/src/remote/protocol.rs b/aimdb-core/src/remote/protocol.rs new file mode 100644 index 00000000..a4b4b242 --- /dev/null +++ b/aimdb-core/src/remote/protocol.rs @@ -0,0 +1,323 @@ +//! AimX v1 Protocol Message Types +//! +//! Defines request, response, and event types for the remote access protocol. + +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; +use std::{string::String, vec::Vec}; + +// Allow dead code for now - these are part of the public API for future implementation +#[allow(dead_code)] +pub const PROTOCOL_VERSION: &str = "1.0"; + +/// Client hello message +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HelloMessage { + /// Protocol version + pub version: String, + + /// Client identification string + pub client: String, + + /// Desired capabilities (optional) + #[serde(skip_serializing_if = "Option::is_none")] + pub capabilities: Option>, + + /// Authentication token (optional) + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_token: Option, +} + +/// Server welcome message +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WelcomeMessage { + /// Protocol version + pub version: String, + + /// Server identification string + pub server: String, + + /// Granted permissions + pub permissions: Vec, + + /// Records that allow writes (empty for read-only) + pub writable_records: Vec, + + /// Maximum subscriptions per connection (optional) + #[serde(skip_serializing_if = "Option::is_none")] + pub max_subscriptions: Option, + + /// Whether client is authenticated + #[serde(skip_serializing_if = "Option::is_none")] + pub authenticated: Option, +} + +/// Request message from client +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Request { + /// Unique request identifier + pub id: u64, + + /// Method name + pub method: String, + + /// Method parameters (optional) + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +/// Response message from server +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Response { + /// Success response + Success { + /// Request ID + id: u64, + /// Result value + result: JsonValue, + }, + /// Error response + Error { + /// Request ID + id: u64, + /// Error details + error: ErrorObject, + }, +} + +impl Response { + /// Creates a success response + pub fn success(id: u64, result: JsonValue) -> Self { + Self::Success { id, result } + } + + /// Creates an error response + pub fn error(id: u64, code: impl Into, message: impl Into) -> Self { + Self::Error { + id, + error: ErrorObject { + code: code.into(), + message: message.into(), + details: None, + }, + } + } + + /// Creates an error response with details + pub fn error_with_details( + id: u64, + code: impl Into, + message: impl Into, + details: JsonValue, + ) -> Self { + Self::Error { + id, + error: ErrorObject { + code: code.into(), + message: message.into(), + details: Some(details), + }, + } + } +} + +/// Error object in response +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorObject { + /// Error code + pub code: String, + + /// Human-readable error message + pub message: String, + + /// Additional error details (optional) + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +/// Event message from server (subscription push) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Event { + /// Subscription identifier + pub subscription_id: String, + + /// Monotonic sequence number + pub sequence: u64, + + /// Event data (record value) + pub data: JsonValue, + + /// Unix timestamp in "secs.nanosecs" format (e.g., "1730379296.123456789") + pub timestamp: String, + + /// Number of dropped events since last delivery (optional) + #[serde(skip_serializing_if = "Option::is_none")] + pub dropped: Option, +} + +/// Top-level message envelope for protocol communication +#[allow(dead_code)] // Part of public API for future use +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Message { + /// Client hello + Hello { hello: HelloMessage }, + /// Server welcome + Welcome { welcome: WelcomeMessage }, + /// Client request + Request(Request), + /// Server response + Response(Response), + /// Server event + Event { event: Event }, +} + +#[allow(dead_code)] // Helper methods for future implementation +impl Message { + /// Creates a hello message + pub fn hello(client: impl Into) -> Self { + Self::Hello { + hello: HelloMessage { + version: PROTOCOL_VERSION.to_string(), + client: client.into(), + capabilities: None, + auth_token: None, + }, + } + } + + /// Creates a welcome message + pub fn welcome(server: impl Into, permissions: Vec) -> Self { + Self::Welcome { + welcome: WelcomeMessage { + version: PROTOCOL_VERSION.to_string(), + server: server.into(), + permissions, + writable_records: Vec::new(), + max_subscriptions: None, + authenticated: None, + }, + } + } + + /// Creates a request message + pub fn request(id: u64, method: impl Into, params: Option) -> Self { + Self::Request(Request { + id, + method: method.into(), + params, + }) + } + + /// Creates a success response message + pub fn response_success(id: u64, result: JsonValue) -> Self { + Self::Response(Response::success(id, result)) + } + + /// Creates an error response message + pub fn response_error(id: u64, code: impl Into, message: impl Into) -> Self { + Self::Response(Response::error(id, code, message)) + } + + /// Creates an event message + pub fn event( + subscription_id: impl Into, + sequence: u64, + data: JsonValue, + timestamp: impl Into, + ) -> Self { + Self::Event { + event: Event { + subscription_id: subscription_id.into(), + sequence, + data, + timestamp: timestamp.into(), + dropped: None, + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hello_serialization() { + let hello = HelloMessage { + version: "1.0".to_string(), + client: "test-client".to_string(), + capabilities: Some(vec!["read".to_string()]), + auth_token: None, + }; + + let json = serde_json::to_string(&hello).unwrap(); + assert!(json.contains("\"version\":\"1.0\"")); + assert!(json.contains("\"client\":\"test-client\"")); + } + + #[test] + fn test_request_serialization() { + let request = Request { + id: 1, + method: "record.list".to_string(), + params: Some(serde_json::json!({})), + }; + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"id\":1")); + assert!(json.contains("\"method\":\"record.list\"")); + } + + #[test] + fn test_response_success() { + let response = Response::success(1, serde_json::json!({"status": "ok"})); + + let json = serde_json::to_string(&response).unwrap(); + assert!(json.contains("\"id\":1")); + assert!(json.contains("\"result\"")); + assert!(json.contains("\"status\":\"ok\"")); + } + + #[test] + fn test_response_error() { + let response = Response::error(2, "NOT_FOUND", "Record not found"); + + let json = serde_json::to_string(&response).unwrap(); + assert!(json.contains("\"id\":2")); + assert!(json.contains("\"error\"")); + assert!(json.contains("\"code\":\"NOT_FOUND\"")); + assert!(json.contains("\"message\":\"Record not found\"")); + } + + #[test] + fn test_event_serialization() { + let event = Event { + subscription_id: "sub-123".to_string(), + sequence: 42, + data: serde_json::json!({"temp": 23.5}), + timestamp: "1730379296.123456789".to_string(), + dropped: None, + }; + + let json = serde_json::to_string(&event).unwrap(); + assert!(json.contains("\"subscription_id\":\"sub-123\"")); + assert!(json.contains("\"sequence\":42")); + assert!(json.contains("\"temp\":23.5")); + } + + #[test] + fn test_event_with_dropped() { + let event = Event { + subscription_id: "sub-456".to_string(), + sequence: 100, + data: serde_json::json!({"value": 1}), + timestamp: "1730379300.987654321".to_string(), + dropped: Some(5), + }; + + let json = serde_json::to_string(&event).unwrap(); + assert!(json.contains("\"dropped\":5")); + } +} diff --git a/aimdb-core/src/remote/supervisor.rs b/aimdb-core/src/remote/supervisor.rs new file mode 100644 index 00000000..cfcd4bca --- /dev/null +++ b/aimdb-core/src/remote/supervisor.rs @@ -0,0 +1,151 @@ +//! Remote access supervisor +//! +//! Manages the Unix domain socket server and spawns connection handlers for +//! remote clients connecting via the AimX protocol. + +use crate::remote::AimxConfig; +use crate::{AimDb, DbError, DbResult}; + +#[cfg(feature = "std")] +use std::sync::Arc; + +#[cfg(feature = "std")] +use std::os::unix::fs::PermissionsExt; + +#[cfg(feature = "std")] +use tokio::net::UnixListener; + +/// Spawns the remote access supervisor task +/// +/// This function spawns a background task that: +/// 1. Binds to the Unix domain socket +/// 2. Sets appropriate file permissions +/// 3. Accepts incoming connections +/// 4. Spawns a ConnectionHandler for each client +/// +/// # Arguments +/// * `db` - Database instance (for introspection and subscriptions) +/// * `runtime` - Runtime adapter (for spawning tasks) +/// * `config` - Remote access configuration +/// +/// # Returns +/// `DbResult<()>` - Ok if supervisor spawned successfully +/// +/// # Errors +/// Returns error if: +/// - Socket path already exists and cannot be removed +/// - Socket binding fails +/// - Permission setting fails +#[cfg(feature = "std")] +pub fn spawn_supervisor(db: Arc>, runtime: Arc, config: AimxConfig) -> DbResult<()> +where + R: aimdb_executor::Spawn + 'static, +{ + #[cfg(feature = "tracing")] + tracing::info!( + "Initializing remote access supervisor on socket: {}", + config.socket_path.display() + ); + + // Remove existing socket file if it exists + if config.socket_path.exists() { + #[cfg(feature = "tracing")] + tracing::debug!( + "Removing existing socket file: {}", + config.socket_path.display() + ); + + std::fs::remove_file(&config.socket_path).map_err(|e| DbError::IoWithContext { + context: format!( + "Failed to remove existing socket file {}", + config.socket_path.display() + ), + source: e, + })?; + } + + // Bind to Unix domain socket + let listener = UnixListener::bind(&config.socket_path).map_err(|e| DbError::IoWithContext { + context: format!( + "Failed to bind Unix socket at {}", + config.socket_path.display() + ), + source: e, + })?; + + #[cfg(feature = "tracing")] + tracing::info!( + "Unix socket bound successfully: {}", + config.socket_path.display() + ); + + // Set socket file permissions + let mut perms = std::fs::metadata(&config.socket_path) + .map_err(|e| DbError::IoWithContext { + context: format!( + "Failed to read socket metadata for {}", + config.socket_path.display() + ), + source: e, + })? + .permissions(); + + let permissions = config.socket_permissions.unwrap_or(0o600); + perms.set_mode(permissions); + + std::fs::set_permissions(&config.socket_path, perms).map_err(|e| DbError::IoWithContext { + context: format!( + "Failed to set socket permissions for {}", + config.socket_path.display() + ), + source: e, + })?; + + #[cfg(feature = "tracing")] + tracing::info!("Socket permissions set to {:o}", permissions); + + // Spawn supervisor task using runtime adapter + let _ = runtime.spawn(async move { + #[cfg(feature = "tracing")] + tracing::info!("Remote access supervisor task started"); + + loop { + match listener.accept().await { + Ok((stream, _addr)) => { + #[cfg(feature = "tracing")] + tracing::debug!("Accepted new client connection"); + + let db_clone = db.clone(); + let config_clone = config.clone(); + + // Spawn connection handler for this client + tokio::spawn(async move { + #[cfg(feature = "tracing")] + tracing::debug!("Connection handler spawned for client"); + + if let Err(_e) = crate::remote::handler::handle_connection( + db_clone, + config_clone, + stream, + ) + .await + { + #[cfg(feature = "tracing")] + tracing::error!("Connection handler error: {}", _e); + } + + #[cfg(feature = "tracing")] + tracing::debug!("Connection handler terminated"); + }); + } + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!("Failed to accept connection: {}", _e); + // Continue accepting other connections despite error + } + } + } + }); + + Ok(()) +} diff --git a/aimdb-core/src/typed_api.rs b/aimdb-core/src/typed_api.rs index 6a561f4d..e68f7e8c 100644 --- a/aimdb-core/src/typed_api.rs +++ b/aimdb-core/src/typed_api.rs @@ -269,11 +269,54 @@ where /// This method accepts a boxed buffer trait object and is used by: /// - Runtime adapter implementations to provide convenient wrappers /// - Advanced use cases requiring custom buffer implementations + /// + /// **Note:** For metadata tracking in std mode, call `buffer_with_cfg()` instead, + /// or call `buffer_cfg()` separately to set the configuration. pub fn buffer_raw(&'a mut self, buffer: Box>) -> &'a mut Self { self.rec.set_buffer(buffer); self } + /// Configures a buffer with metadata tracking (std only) + #[cfg(feature = "std")] + pub fn buffer_with_cfg( + &'a mut self, + buffer: Box>, + cfg: crate::buffer::BufferCfg, + ) -> &'a mut Self { + self.rec.set_buffer(buffer); + self.rec.set_buffer_cfg(cfg); + self + } + + /// Sets the buffer configuration for metadata tracking (std only) + #[cfg(feature = "std")] + pub fn buffer_cfg(&'a mut self, cfg: crate::buffer::BufferCfg) -> &'a mut Self { + self.rec.set_buffer_cfg(cfg); + self + } + + /// Enables JSON serialization for remote access (std only) + /// + /// Configures this record to support the `record.get` protocol method. + /// Requires `T: serde::Serialize`. + /// + /// # Example + /// ```rust,ignore + /// builder.configure::(|reg| { + /// reg.buffer(BufferCfg::SingleLatest) + /// .with_serialization(); // Enable remote queries + /// }); + /// ``` + #[cfg(feature = "std")] + pub fn with_serialization(&'a mut self) -> &'a mut Self + where + T: serde::Serialize + serde::de::DeserializeOwned, + { + self.rec.with_serialization(); + self + } + /// Adds a connector link for external system integration pub fn link(&'a mut self, url: &str) -> ConnectorBuilder<'a, T, R> { ConnectorBuilder { diff --git a/aimdb-core/src/typed_record.rs b/aimdb-core/src/typed_record.rs index b951d89f..50630cae 100644 --- a/aimdb-core/src/typed_record.rs +++ b/aimdb-core/src/typed_record.rs @@ -1,7 +1,15 @@ //! Type-safe record storage using TypeId //! -//! This module provides a type-safe alternative to string-based record -//! identification, using Rust's `TypeId` for compile-time type safety. +//! Provides type-safe record identification using Rust's `TypeId` for compile-time safety. +//! +//! # Feature Support +//! +//! **Both std and no_std**: Core API (`TypedRecord`, `latest()`, `RecordValue`, producer/consumer) +//! +//! **std only**: JSON serialization (`.with_serialization()`, `.as_json()`), remote access, metadata +//! +//! **no_std**: Use `record.latest()` for value access and `Deref` for fields. JSON requires std; +//! implement custom serialization for embedded protocols (CBOR, MessagePack, etc.). use core::any::Any; use core::fmt::Debug; @@ -17,6 +25,176 @@ use std::{boxed::Box, sync::Arc, vec::Vec}; use crate::buffer::DynBuffer; +/// Type alias for JSON serializer function (std only) +#[cfg(feature = "std")] +type JsonSerializer = Arc Option + Send + Sync>; + +/// Type alias for JSON deserializer function (std only) +#[cfg(feature = "std")] +type JsonDeserializer = Arc Option + Send + Sync>; + +/// Wrapper for a record's latest value with optional serialization +/// +/// Created by `TypedRecord::latest()`. Core methods (`get()`, `into_inner()`, `Deref`) work in +/// both std and no_std. JSON serialization (`.as_json()`) requires std feature. +pub struct RecordValue { + value: T, + #[cfg(feature = "std")] + serializer: Option>, +} + +impl RecordValue { + /// Create a new RecordValue with optional serializer + #[cfg(feature = "std")] + fn new(value: T, serializer: Option>) -> Self { + Self { value, serializer } + } + + /// Create a new RecordValue without serializer (no_std) + #[cfg(not(feature = "std"))] + fn new(value: T, _serializer: Option<()>) -> Self { + Self { value } + } + + /// Get a reference to the underlying value + pub fn get(&self) -> &T { + &self.value + } + + /// Consume the wrapper and return the underlying value + pub fn into_inner(self) -> T { + self.value + } + + /// Serialize the value to JSON (std only) + /// + /// Returns `Some(JsonValue)` if record was configured with `.with_serialization()`, + /// otherwise `None`. Requires `serde_json` (std only). For no_std, use `.get()`, + /// `.into_inner()`, or `Deref` for direct access. + #[cfg(feature = "std")] + pub fn as_json(&self) -> Option { + let serializer = self.serializer.as_ref()?; + serializer(&self.value) + } +} + +impl RecordValue { + /// Clone the underlying value + pub fn cloned(&self) -> T { + self.value.clone() + } +} + +impl core::ops::Deref for RecordValue { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.value + } +} + +/// Adapter that wraps a typed BufferReader and serializes values to JSON (std only) +/// +/// Bridges the gap between typed buffers and type-erased JSON streaming for +/// remote access subscriptions. Each `recv_json()` call: +/// 1. Receives a typed value `T` from the buffer +/// 2. Serializes it to JSON using the configured serializer +/// 3. Returns the JSON value +/// +/// Used internally by `TypedRecord::subscribe_json()`. +#[cfg(feature = "std")] +struct JsonReaderAdapter { + /// The underlying typed buffer reader + inner: Box + Send>, + /// JSON serializer function (from .with_serialization()) + serializer: JsonSerializer, +} + +#[cfg(feature = "std")] +impl crate::buffer::JsonBufferReader for JsonReaderAdapter { + fn recv_json( + &mut self, + ) -> core::pin::Pin< + Box< + dyn core::future::Future> + + Send + + '_, + >, + > { + Box::pin(async move { + // Receive typed value from buffer + let value = self.inner.recv().await?; + + // Serialize to JSON + (self.serializer)(&value).ok_or_else(|| { + #[cfg(feature = "std")] + { + crate::DbError::RuntimeError { + message: "Failed to serialize value to JSON".to_string(), + } + } + #[cfg(not(feature = "std"))] + { + crate::DbError::RuntimeError { _message: () } + } + }) + }) + } +} + +/// Metadata tracking for records (std only - used for remote access introspection) +#[cfg(feature = "std")] +#[derive(Debug, Clone)] +struct RecordMetadataTracker { + /// Human-readable record name (type name) + name: String, + /// Creation timestamp (seconds, nanoseconds since UNIX_EPOCH) + created_at: (u64, u32), + /// Last update timestamp (seconds, nanoseconds since UNIX_EPOCH, None if never updated) + last_update: std::sync::Arc>>, + /// Whether this record allows writes via remote access + writable: bool, +} + +#[cfg(feature = "std")] +impl RecordMetadataTracker { + fn new() -> Self { + use std::time::SystemTime; + + let duration = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default(); + + Self { + name: core::any::type_name::().to_string(), + created_at: (duration.as_secs(), duration.subsec_nanos()), + last_update: Arc::new(std::sync::Mutex::new(None)), + writable: false, + } + } + + fn mark_updated(&self) { + use std::time::SystemTime; + + let duration = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default(); + + if let Ok(mut last) = self.last_update.lock() { + *last = Some((duration.as_secs(), duration.subsec_nanos())); + } + } + + fn set_writable(&mut self, writable: bool) { + self.writable = writable; + } + + /// Formats a Unix timestamp as "secs.nanosecs" string + fn format_timestamp(timestamp: (u64, u32)) -> String { + format!("{}.{:09}", timestamp.0, timestamp.1) + } +} + // Type alias for boxed futures type BoxFuture<'a, T> = core::pin::Pin + Send + 'a>>; @@ -58,33 +236,99 @@ pub trait AnyRecord: Send + Sync { fn as_any_mut(&mut self) -> &mut dyn Any; /// Returns the number of registered connectors - /// - /// Allows runtime adapters to discover connectors without knowing the concrete type. fn connector_count(&self) -> usize; /// Returns the connector URLs as strings - /// - /// Provides access to connector configuration through the type-erased interface. - /// Useful for logging and debugging. #[cfg(feature = "std")] fn connector_urls(&self) -> Vec; /// Gets the connector links /// - /// Returns a reference to the connector configuration list. - /// This allows connector spawning logic to access the serializer callbacks - /// and other connector-specific configuration. + /// Returns connector configuration list for spawning logic. fn connectors(&self) -> &[crate::connector::ConnectorLink]; /// Returns the number of registered consumers (tap observers) - /// - /// Used to check if automatic spawning is needed. fn consumer_count(&self) -> usize; /// Returns whether a producer service is registered - /// - /// Used to check if producer spawning is needed. fn has_producer_service(&self) -> bool; + + /// Collects metadata for this record (std only) + #[cfg(feature = "std")] + fn collect_metadata(&self, type_id: core::any::TypeId) -> crate::remote::RecordMetadata; + + /// Internal: Returns JSON for type-erased remote access (std only) + /// + /// Used internally by remote access protocol. **Users should use `record.latest()?.as_json()`.** + #[doc(hidden)] + #[cfg(feature = "std")] + fn latest_json(&self) -> Option; + + /// Subscribe to record updates as JSON stream (std only) + /// + /// Creates a type-erased subscription that emits `serde_json::Value` instead of + /// the concrete type `T`. This enables subscribing to a record without knowing + /// its type at compile time. + /// + /// Used internally by remote access protocol for `record.subscribe` functionality. + /// + /// # Returns + /// - `Ok(Box)` - Successfully created subscription + /// - `Err(DbError)` - If serialization not configured or buffer subscription failed + /// + /// # Errors + /// Returns error if: + /// - Record not configured with `.with_serialization()` + /// - Buffer subscription fails (shouldn't happen in practice) + /// + /// # Example (internal use) + /// ```rust,ignore + /// let type_id = TypeId::of::(); + /// let record: &Box = db.records.get(&type_id)?; + /// let mut json_reader = record.subscribe_json()?; + /// + /// while let Ok(json_val) = json_reader.recv_json().await { + /// // Forward to remote client... + /// } + /// ``` + #[doc(hidden)] + #[cfg(feature = "std")] + fn subscribe_json(&self) -> crate::DbResult>; + + /// Sets a record value from JSON (std only) + /// + /// Deserializes JSON and produces the value to the record's buffer. + /// + /// **SAFETY:** This method enforces the "No Producer Override" rule: + /// - Returns error if `producer_count > 0` (prevents overriding application logic) + /// - Only configuration records (no producers) should be settable via remote access + /// + /// Used internally by remote access protocol for `record.set` functionality. + /// + /// # Arguments + /// * `json_value` - JSON representation of the value to set + /// + /// # Returns + /// - `Ok(())` - Successfully set the value + /// - `Err(DbError)` - If deserialization fails, producers exist, or no buffer + /// + /// # Errors + /// Returns error if: + /// - Record has active producers (`producer_count > 0`) - **safety check** + /// - JSON deserialization fails (schema mismatch) + /// - Record not configured with buffer + /// - Record not configured with `.with_serialization()` + /// + /// # Example (internal use) + /// ```rust,ignore + /// let type_id = TypeId::of::(); + /// let record: &Box = db.records.get(&type_id)?; + /// let json_val = serde_json::json!({"log_level": "debug"}); + /// record.set_from_json(json_val)?; // Only works if producer_count == 0 + /// ``` + #[doc(hidden)] + #[cfg(feature = "std")] + fn set_from_json(&self, json_value: serde_json::Value) -> crate::DbResult<()>; } // Helper extension trait for type-safe downcasting @@ -130,8 +374,7 @@ impl AnyRecordExt for Box { /// Helper for spawning tasks for a specific record type /// -/// This struct captures the record type `T` using PhantomData and provides -/// a way to spawn tasks without making AnyRecord non-object-safe. +/// Captures record type `T` via PhantomData to spawn tasks without making AnyRecord non-object-safe. pub struct RecordSpawner { _phantom: core::marker::PhantomData, } @@ -142,8 +385,7 @@ where { /// Spawns all tasks (producer and consumers) for a record /// - /// This function takes a type-erased AnyRecord, downcasts it to TypedRecord, - /// and spawns the producer service and consumer tasks. + /// Downcasts type-erased AnyRecord to TypedRecord and spawns tasks. pub fn spawn_all_tasks( record: &dyn AnyRecord, runtime: &Arc, @@ -185,8 +427,7 @@ where /// Typed record storage with producer/consumer functions /// -/// Stores type-safe producer and consumer functions for a specific record type, -/// with optional buffering for async dispatch patterns. +/// Stores type-safe producer and consumer functions with optional buffering for async dispatch. pub struct TypedRecord { /// Optional producer service - a task that generates data /// This will be auto-spawned during build() if present @@ -211,16 +452,45 @@ pub struct TypedRecord>>, + /// Buffer configuration (cached for metadata, std only) + #[cfg(feature = "std")] + buffer_cfg: Option, + /// List of connector links for external system integration /// Each link represents a protocol connector (MQTT, Kafka, HTTP, etc.) connectors: Vec, + + /// Metadata tracking (std only - for remote access) + #[cfg(feature = "std")] + metadata: RecordMetadataTracker, + + /// JSON serializer function (std only - for remote access) + /// When set via .with_serialization(), automatically serializes values for record.get queries + /// Stores the serialization logic where T: Serialize is known at call site + #[cfg(feature = "std")] + json_serializer: Option>, + + /// JSON deserializer function (std only - for remote access) + /// When set via .with_serialization(), automatically deserializes JSON for record.set operations + /// Stores the deserialization logic where T: Deserialize is known at call site + #[cfg(feature = "std")] + json_deserializer: Option>, + + /// Latest value snapshot - for latest() API + /// Cached atomically on every produce() call to support latest() + /// This provides a buffer-agnostic way to query the latest value + /// Available in both std and no_std environments + #[cfg(feature = "std")] + latest_snapshot: Arc>>, + + #[cfg(not(feature = "std"))] + latest_snapshot: Arc>>, } impl TypedRecord { /// Creates a new empty typed record /// - /// # Returns - /// A `TypedRecord` with no producer or consumers + /// Call `.with_serialization()` to enable JSON (std only). pub fn new() -> Self { Self { #[cfg(feature = "std")] @@ -232,32 +502,28 @@ impl Type #[cfg(not(feature = "std"))] consumers: spin::Mutex::new(alloc::vec::Vec::new()), buffer: None, + #[cfg(feature = "std")] + buffer_cfg: None, connectors: Vec::new(), + #[cfg(feature = "std")] + metadata: RecordMetadataTracker::new::(), + #[cfg(feature = "std")] + json_serializer: None, + #[cfg(feature = "std")] + json_deserializer: None, + #[cfg(feature = "std")] + latest_snapshot: Arc::new(std::sync::Mutex::new(None)), + #[cfg(not(feature = "std"))] + latest_snapshot: Arc::new(spin::Mutex::new(None)), } } /// Sets the producer service for this record /// - /// The producer service is a long-running task that generates data and calls - /// `producer.produce()` to emit values. It will be automatically spawned during `build()`. - /// - /// # Arguments - /// * `f` - A function taking `(Producer, RuntimeContext)` and returning a Future + /// Long-running task that generates data via `producer.produce()`. Auto-spawned during `build()`. /// /// # Panics - /// Panics if a producer service is already set (each record can have only one source) - /// - /// # Example - /// - /// ```rust,ignore - /// record.set_producer_service(|producer, ctx| async move { - /// loop { - /// let value = generate_data(); - /// producer.produce(value).await?; - /// ctx.time().sleep(ctx.time().secs(1)).await; - /// } - /// }); - /// ``` + /// Panics if producer already set (one producer per record). pub fn set_producer_service(&mut self, f: F) where F: FnOnce(crate::Producer, Arc) -> Fut + Send + Sync + 'static, @@ -335,26 +601,24 @@ impl Type /// Sets the buffer for this record /// - /// When a buffer is set, `produce()` will enqueue values instead of - /// calling producer/consumers directly. A separate dispatcher task - /// should drain the buffer and invoke the functions. - /// - /// # Arguments - /// * `buffer` - A buffer backend implementation - /// - /// # Example - /// - /// ```rust,ignore - /// use aimdb_core::buffer::BufferCfg; - /// - /// // Configure buffer (adapter-specific implementation) - /// let buffer = runtime.create_buffer(BufferCfg::SpmcRing { capacity: 1024 }); - /// record.set_buffer(buffer); - /// ``` + /// When set, `produce()` enqueues values for async dispatch to consumers. pub fn set_buffer(&mut self, buffer: Box>) { + // Cache buffer configuration for metadata (std only) + #[cfg(feature = "std")] + { + // Store a simplified version of the config for metadata + // We can't call cfg() on the buffer, so we'll infer from the buffer type name + self.buffer_cfg = None; // Will be set by the caller via set_buffer_cfg + } self.buffer = Some(buffer); } + /// Sets the buffer configuration (for metadata tracking, std only) + #[cfg(feature = "std")] + pub fn set_buffer_cfg(&mut self, cfg: crate::buffer::BufferCfg) { + self.buffer_cfg = Some(cfg); + } + /// Returns whether a buffer is configured /// /// # Returns @@ -373,26 +637,8 @@ impl Type /// Subscribes to the buffer for this record type /// - /// Creates a new subscription that can receive values asynchronously. - /// - /// # Returns - /// A boxed `BufferReader` for receiving values - /// /// # Errors - /// Returns `DbError::MissingConfiguration` if no buffer is configured - /// - /// # Example - /// - /// ```rust,ignore - /// let mut reader = record.subscribe()?; - /// - /// loop { - /// match reader.recv().await { - /// Ok(value) => process(value), - /// Err(_) => break, - /// } - /// } - /// ``` + /// Returns `DbError::MissingConfiguration` if no buffer configured pub fn subscribe(&self) -> crate::DbResult + Send>> { let buffer = self.buffer.as_ref().ok_or({ #[cfg(feature = "std")] @@ -412,23 +658,70 @@ impl Type /// Adds a connector link for external system integration /// - /// Connectors bridge AimDB records to external protocols (MQTT, Kafka, HTTP, etc.). - /// Multiple connectors can be registered for the same record type. + /// Bridges records to external protocols (MQTT, Kafka, HTTP, etc.). + /// Multiple connectors supported per record. + pub fn add_connector(&mut self, link: crate::connector::ConnectorLink) { + self.connectors.push(link); + } + + /// Enables JSON serialization for remote access (std only) /// - /// # Arguments - /// * `link` - The connector configuration + /// Enables `record.latest()?.as_json()` and remote access `record.get` protocol. + /// Requires `std` feature and `T: serde::Serialize`. /// - /// # Example + /// For no_std, use `record.latest()` for value access or custom serialization. + #[cfg(feature = "std")] + pub fn with_serialization(&mut self) -> &mut Self + where + T: serde::Serialize + serde::de::DeserializeOwned, + { + // Store serialization function where T: Serialize is known + self.json_serializer = Some(std::sync::Arc::new(|val: &T| { + serde_json::to_value(val).ok() + })); + + // Store deserialization function where T: DeserializeOwned is known + self.json_deserializer = Some(std::sync::Arc::new(|json_val: &serde_json::Value| { + serde_json::from_value(json_val.clone()).ok() + })); + + #[cfg(feature = "tracing")] + tracing::info!( + "with_serialization() called for record type: {}", + core::any::type_name::() + ); + + self + } + + /// Enables JSON serialization (backward compatible, read-only) /// - /// ```rust,ignore - /// use aimdb_core::connector::{ConnectorUrl, ConnectorLink}; + /// This version only adds serialization support (for `record.get` and `record.subscribe`). + /// For write support via `record.set`, use `.with_serialization()` which requires + /// both `Serialize` and `DeserializeOwned` bounds. /// - /// let url = ConnectorUrl::parse("mqtt://broker.local:1883")?; - /// let link = ConnectorLink::new(url); - /// record.add_connector(link); - /// ``` - pub fn add_connector(&mut self, link: crate::connector::ConnectorLink) { - self.connectors.push(link); + /// This method exists for backward compatibility with records that don't implement + /// `DeserializeOwned` but still need to be readable via remote access. + #[cfg(feature = "std")] + pub fn with_read_only_serialization(&mut self) -> &mut Self + where + T: serde::Serialize, + { + // Store only serialization function + self.json_serializer = Some(std::sync::Arc::new(|val: &T| { + serde_json::to_value(val).ok() + })); + + // Deserialization intentionally left as None - will fail at runtime if + // someone tries to use record.set on this record + + #[cfg(feature = "tracing")] + tracing::info!( + "with_read_only_serialization() called for record type: {}", + core::any::type_name::() + ); + + self } /// Returns a reference to the registered connectors @@ -465,19 +758,8 @@ impl Type /// Spawns all registered consumer tasks /// - /// This method takes all registered `.tap()` consumers and spawns them as - /// background tasks using the provided runtime adapter. Called automatically - /// by `Database::new()` during database construction. - /// - /// # Arguments - /// * `runtime` - The runtime adapter for spawning tasks - /// * `db` - The database instance for creating Consumer handles - /// - /// # Returns - /// `DbResult<()>` - Ok if all tasks spawned successfully - /// - /// # Note - /// This consumes all registered consumers (FnOnce), so it can only be called once. + /// Spawns `.tap()` consumers as background tasks. Called automatically by `Database::new()`. + /// Consumes registered consumers (FnOnce), can only be called once. pub fn spawn_consumer_tasks( &self, runtime: &Arc, @@ -537,15 +819,7 @@ impl Type /// Spawns consumer tasks from a type-erased runtime /// - /// This is a helper for the automatic spawning system. It uses the database's - /// spawn_task method which has access to the concrete runtime type. - /// - /// # Arguments - /// * `runtime` - The runtime adapter for spawning tasks - /// * `db` - The database instance for creating Producer handles - /// - /// # Returns - /// `DbResult<()>` - Ok if spawning succeeded + /// Helper for automatic spawning system via database's spawn_task method. pub fn spawn_producer_service( &self, runtime: &Arc, @@ -598,20 +872,26 @@ impl Type /// Produces a value by pushing to the buffer /// - /// Enqueues the value to the buffer where consumer tasks will pick it up. - /// - /// # Arguments - /// * `val` - The value to produce - /// - /// # Example - /// - /// ```rust,ignore - /// record.produce(SensorData { temp: 23.5 }).await; - /// ``` + /// Enqueues value for consumer tasks and updates latest snapshot. pub async fn produce(&self, val: T) { + // Cache snapshot for latest() API (both std and no_std) + #[cfg(feature = "std")] + { + *self.latest_snapshot.lock().unwrap() = Some(val.clone()); + } + + #[cfg(not(feature = "std"))] + { + *self.latest_snapshot.lock() = Some(val.clone()); + } + // Push to buffer - consumer tasks will receive it if let Some(buf) = &self.buffer { buf.push(val); + + // Update metadata timestamp (std only) + #[cfg(feature = "std")] + self.metadata.mark_updated(); } } @@ -626,6 +906,47 @@ impl Type self.producer_service.lock().is_some() } } + + /// Marks this record as writable for remote access (std only) + #[cfg(feature = "std")] + pub fn set_writable(&mut self, writable: bool) { + self.metadata.set_writable(writable); + } + + /// Returns the latest produced value + /// + /// Returns most recent value wrapped in `RecordValue`, updated atomically on each `produce()`. + /// Non-blocking and buffer-agnostic. + /// + /// **Both std and no_std**: Direct access via `Deref`, `.get()`, `.into_inner()` + /// + /// **std only**: `.as_json()` (if `.with_serialization()` configured) + /// + /// # Examples + /// ```rust,ignore + /// // Direct access (std and no_std) + /// if let Some(value) = record.latest() { + /// println!("Temp: {:.1}°C", value.celsius); + /// } + /// + /// // JSON serialization (std only) + /// if let Some(json) = record.latest()?.as_json() { + /// println!("{}", json); + /// } + /// ``` + pub fn latest(&self) -> Option> { + #[cfg(feature = "std")] + { + let value = self.latest_snapshot.lock().unwrap().clone()?; + Some(RecordValue::new(value, self.json_serializer.clone())) + } + + #[cfg(not(feature = "std"))] + { + let value = self.latest_snapshot.lock().clone()?; + Some(RecordValue::new(value, None)) + } + } } impl Default @@ -641,10 +962,10 @@ impl AnyR { fn validate(&self) -> Result<(), &'static str> { // Producer service is optional - some records are driven by external events - // Must have at least one consumer (tap, link, or explicit consumer) - if self.consumer_count() == 0 { - return Err("must have ≥1 consumer (use .tap() or .link())"); - } + // Consumer is also optional - records can be accessed via: + // - Explicit consumers (tap, link) + // - Remote access (AimX protocol) + // - Direct producer/consumer API Ok(()) } @@ -679,28 +1000,208 @@ impl AnyR fn has_producer_service(&self) -> bool { TypedRecord::has_producer_service(self) } -} -#[cfg(test)] -mod tests { - // NOTE: All tests commented out because TypedRecord now requires R: aimdb_executor::Spawn, - // and we can't use () as a dummy runtime type. See examples/ for working tests with real runtimes. + #[cfg(feature = "std")] + fn collect_metadata(&self, type_id: core::any::TypeId) -> crate::remote::RecordMetadata { + let (buffer_type, buffer_capacity) = if let Some(cfg) = &self.buffer_cfg { + let cap = match cfg { + crate::buffer::BufferCfg::SpmcRing { capacity } => Some(*capacity), + _ => None, + }; + (cfg.name().to_string(), cap) + } else { + ("none".to_string(), None) + }; - /* - use super::*; - #[derive(Debug, Clone, PartialEq)] - struct TestData { - value: i32, + let last_update = self + .metadata + .last_update + .lock() + .ok() + .and_then(|guard| *guard) + .map(RecordMetadataTracker::format_timestamp); + + crate::remote::RecordMetadata::new( + type_id, + self.metadata.name.clone(), + buffer_type, + buffer_capacity, + if self.has_producer_service() { 1 } else { 0 }, + self.consumer_count(), + self.metadata.writable, + RecordMetadataTracker::format_timestamp(self.metadata.created_at), + self.connector_count(), + ) + .with_last_update_opt(last_update) } - #[test] - fn test_typed_record_new() { - // Using unit type () as placeholder for R in test - let record = TypedRecord::::new(); - assert!(!record.has_producer_service()); - assert_eq!(record.consumer_count(), 0); + #[doc(hidden)] + #[cfg(feature = "std")] + fn latest_json(&self) -> Option { + #[cfg(feature = "tracing")] + tracing::debug!( + "latest_json called for type: {}", + core::any::type_name::() + ); + + // Delegate to latest() which returns RecordValue with serializer attached + let result = self.latest().and_then(|v| v.as_json()); + + #[cfg(feature = "tracing")] + tracing::debug!("Serialization result: {:?}", result.is_some()); + + result } - // ... rest of tests omitted ... - */ + #[doc(hidden)] + #[cfg(feature = "std")] + fn subscribe_json(&self) -> crate::DbResult> { + use crate::DbError; + + #[cfg(feature = "tracing")] + tracing::debug!( + "subscribe_json called for type: {}", + core::any::type_name::() + ); + + // 1. Check if serialization is configured + let serializer = self + .json_serializer + .clone() + .ok_or_else(|| DbError::RuntimeError { + message: format!( + "Record '{}' not configured with .with_serialization(). \ + Cannot subscribe to JSON stream.", + core::any::type_name::() + ), + })?; + + // 2. Subscribe to the buffer (get Box>) + let reader = self.subscribe()?; + + // 3. Wrap in JsonReaderAdapter + let json_reader = JsonReaderAdapter { + inner: reader, + serializer, + }; + + #[cfg(feature = "tracing")] + tracing::debug!( + "Successfully created JSON subscription for type: {}", + core::any::type_name::() + ); + + Ok(Box::new(json_reader)) + } + + #[doc(hidden)] + #[cfg(feature = "std")] + fn set_from_json(&self, json_value: serde_json::Value) -> crate::DbResult<()> { + use crate::DbError; + + #[cfg(feature = "tracing")] + tracing::debug!( + "set_from_json called for type: {}", + core::any::type_name::() + ); + + // SAFETY CHECK 1: Enforce "No Producer Override" rule + if self.has_producer_service() { + #[cfg(feature = "tracing")] + tracing::warn!( + "Rejected set_from_json for '{}': has active producer (producer_count=1)", + core::any::type_name::() + ); + + return Err(DbError::PermissionDenied { + operation: format!( + "Cannot set record '{}' - has active producer. \ + Use internal application logic instead. \ + Remote access can only set configuration records without producers.", + core::any::type_name::() + ), + }); + } + + // Check if deserialization is configured (need json_deserializer) + let deserializer = self + .json_deserializer + .clone() + .ok_or_else(|| DbError::RuntimeError { + message: format!( + "Record '{}' not configured with .with_serialization(). \ + Cannot deserialize from JSON.", + core::any::type_name::() + ), + })?; + + // Check if buffer exists + if self.buffer.is_none() { + return Err(DbError::RuntimeError { + message: format!( + "Record '{}' has no buffer configured. \ + Cannot produce value without buffer.", + core::any::type_name::() + ), + }); + } + + // Deserialize JSON -> T + let value: T = deserializer(&json_value).ok_or_else(|| DbError::RuntimeError { + message: format!( + "Failed to deserialize JSON to type '{}'. \ + JSON structure does not match the expected schema.", + core::any::type_name::() + ), + })?; + + #[cfg(feature = "tracing")] + tracing::debug!( + "Successfully deserialized JSON to type: {}", + core::any::type_name::() + ); + + // Check if buffer exists before trying to produce + if self.buffer.is_none() { + return Err(DbError::RuntimeError { + message: format!( + "Record '{}' has no buffer configured. \ + Cannot produce value without buffer.", + core::any::type_name::() + ), + }); + } + + // Use the existing produce() method which handles: + // 1. Updating latest_snapshot + // 2. Pushing to buffer + // 3. Updating metadata timestamp (std only) + // This is a synchronous wrapper around the async produce() + { + #[cfg(feature = "std")] + { + *self.latest_snapshot.lock().unwrap() = Some(value.clone()); + } + + #[cfg(not(feature = "std"))] + { + *self.latest_snapshot.lock() = Some(value.clone()); + } + + if let Some(buf) = &self.buffer { + buf.push(value); + + #[cfg(feature = "std")] + self.metadata.mark_updated(); + } + } + + #[cfg(feature = "tracing")] + tracing::info!( + "Successfully set value from JSON for record: {}", + core::any::type_name::() + ); + + Ok(()) + } } diff --git a/deny.toml b/deny.toml index 7aa2a2f3..e7e8a0df 100644 --- a/deny.toml +++ b/deny.toml @@ -24,3 +24,6 @@ yanked = "warn" unknown-registry = "warn" unknown-git = "warn" allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [ + "https://github.com/embassy-rs/stm32-data-generated", # Required for embassy-stm32 +] diff --git a/docs/design/008-M3-remote-access.md b/docs/design/008-M3-remote-access.md new file mode 100644 index 00000000..a7519de7 --- /dev/null +++ b/docs/design/008-M3-remote-access.md @@ -0,0 +1,956 @@ +# AimX v1: Remote Access Protocol Specification + +**Version:** 1.0 +**Status:** Design Document +**Last Updated:** October 31, 2025 + +--- + +## Table of Contents + +- [Overview](#overview) +- [Protocol Primitives](#protocol-primitives) +- [Request Methods](#request-methods) +- [Protocol Details](#protocol-details) +- [Security Model](#security-model) +- [Example Session](#example-session) +- [Error Handling](#error-handling) +- [Implementation Architecture](#implementation-architecture) +- [Performance Considerations](#performance-considerations) +- [Future Extensions](#future-extensions) + +--- + +## Overview + +**AimX (Aim eXchange)** is a lightweight request/stream protocol for remote introspection and interaction with AimDB instances. Version 1.0 provides read-only access by default, with optional write capabilities for specific records. + +### Design Goals + +1. **Simple** - NDJSON over Unix domain sockets, no binary protocols +2. **Secure** - Read-only default, OS-level permissions, optional auth tokens +3. **Efficient** - Bounded queues, drop-oldest policy, zero-copy where possible +4. **Evolvable** - Version negotiation, capability announcement +5. **Observable** - Foundation for CLI, dashboards, MCP integration + +### Non-Goals (v1) + +- ❌ Binary protocols (may come in v2) +- ❌ TCP/WebSocket transport (gateway work) +- ❌ Global control operations (shutdown, reload) +- ❌ Multi-instance coordination +- ❌ Distributed transactions + +--- + +## Protocol Primitives + +AimX v1 defines five message types: + +### 1. `hello` - Client Handshake + +Client announces version and capabilities upon connection. + +```json +{ + "hello": { + "version": "1.0", + "client": "aimdb-cli/0.1.0", + "capabilities": ["read", "subscribe"] + } +} +``` + +**Fields:** +- `version` (string, required): Protocol version (semantic versioning) +- `client` (string, required): Client identification string +- `capabilities` (array, optional): Desired capabilities + +### 2. `welcome` - Server Response + +Server responds with version, permissions, and server info. + +```json +{ + "welcome": { + "version": "1.0", + "server": "aimdb/0.3.0", + "permissions": ["read", "subscribe"], + "writable_records": [], + "max_subscriptions": 10 + } +} +``` + +**Fields:** +- `version` (string, required): Server protocol version +- `server` (string, required): Server identification string +- `permissions` (array, required): Granted capabilities +- `writable_records` (array, required): Records that allow writes +- `max_subscriptions` (integer, optional): Per-connection subscription limit + +### 3. `request` - Client Command + +Client sends a request with unique ID. + +```json +{ + "id": 1, + "method": "record.list", + "params": {} +} +``` + +**Fields:** +- `id` (integer, required): Unique request identifier (client-assigned) +- `method` (string, required): Method name (see [Request Methods](#request-methods)) +- `params` (object, optional): Method-specific parameters + +### 4. `response` - Server Reply + +Server replies with matching ID and result or error. + +```json +{ + "id": 1, + "result": { ... } +} +``` + +Or on error: + +```json +{ + "id": 1, + "error": { + "code": "NOT_FOUND", + "message": "Record 'SensorData' not found" + } +} +``` + +**Fields:** +- `id` (integer, required): Matches request ID +- `result` (any, conditional): Success result (mutually exclusive with `error`) +- `error` (object, conditional): Error details + +**Error Object:** +- `code` (string, required): Error code (see [Error Codes](#error-codes)) +- `message` (string, required): Human-readable error message +- `details` (any, optional): Additional error context + +### 5. `event` - Server Push + +Server pushes subscription updates asynchronously. + +```json +{ + "event": { + "subscription_id": "sub-123", + "sequence": 42, + "data": { ... }, + "timestamp": "1730379296.123456789" + } +} +``` + +**Fields:** +- `subscription_id` (string, required): Subscription identifier from subscribe response +- `sequence` (integer, required): Monotonic sequence number per subscription +- `data` (any, required): Record value (JSON-serialized) +- `timestamp` (string, required): Unix timestamp in "secs.nanosecs" format (e.g., "1730379296.123456789") +- `dropped` (integer, optional): Number of dropped events since last delivery (backpressure) + +--- + +## Request Methods + +### `record.list` + +Lists all registered records with metadata. + +**Request:** +```json +{ + "id": 1, + "method": "record.list", + "params": {} +} +``` + +**Response:** +```json +{ + "id": 1, + "result": { + "records": [ + { + "name": "SensorData", + "type_id": "0x7f8a4c2b1e90", + "buffer_type": "spmc_ring", + "buffer_capacity": 100, + "producer_count": 1, + "consumer_count": 2, + "writable": false, + "created_at": "2025-10-31T10:00:00.000Z", + "last_update": "2025-10-31T12:34:56.789Z" + } + ] + } +} +``` + +**Result Fields:** +- `records` (array): List of record metadata objects + +**Record Metadata Fields:** +- `name` (string): Record type name (Rust type name) +- `type_id` (string): Hex representation of TypeId +- `buffer_type` (string): Buffer type: `"spmc_ring"`, `"single_latest"`, or `"mailbox"` +- `buffer_capacity` (integer, optional): Buffer capacity (null for unbounded) +- `producer_count` (integer): Number of registered producers +- `consumer_count` (integer): Number of registered consumers +- `writable` (boolean): Whether write operations are permitted +- `created_at` (string): Unix timestamp in "secs.nanosecs" format when record was registered +- `last_update` (string, nullable): Unix timestamp in "secs.nanosecs" format of last value update + +### `record.get` + +Retrieves current snapshot of a record. + +**Request:** +```json +{ + "id": 2, + "method": "record.get", + "params": { + "name": "SensorData" + } +} +``` + +**Response:** +```json +{ + "id": 2, + "result": { + "value": { + "temperature": 23.5, + "humidity": 45.2, + "timestamp": 1698753296 + }, + "timestamp": "1730379296.123456789", + "sequence": 42 + } +} +``` + +**Request Parameters:** +- `name` (string): Record type name + +**Result Fields:** +- `value` (any): Current record value (JSON-serialized) +- `timestamp` (string): Unix timestamp in "secs.nanosecs" format when value was updated +- `sequence` (integer): Value sequence number (monotonic per record) + +**Errors:** +- `NOT_FOUND` - Record doesn't exist +- `NO_VALUE` - Record has no value yet (no producer has written) + +### `record.subscribe` + +Subscribes to real-time updates for a record. + +**Request:** +```json +{ + "id": 3, + "method": "record.subscribe", + "params": { + "name": "SensorData", + "send_initial": true + } +} +``` + +**Response:** +```json +{ + "id": 3, + "result": { + "subscription_id": "sub-123", + "queue_size": 100 + } +} +``` + +**Request Parameters:** +- `name` (string, required): Record type name +- `send_initial` (boolean, optional, default: true): Send current value immediately + +**Result Fields:** +- `subscription_id` (string): Unique subscription identifier +- `queue_size` (integer): Bounded queue size for this subscription + +**Events:** + +After subscription, server sends `event` messages: + +```json +{ + "event": { + "subscription_id": "sub-123", + "sequence": 1, + "data": { "temperature": 23.5, "humidity": 45.2 }, + "timestamp": "1730379296.123456789" + } +} +``` + +If backpressure occurs: + +```json +{ + "event": { + "subscription_id": "sub-123", + "sequence": 50, + "data": { "temperature": 24.1, "humidity": 46.0 }, + "timestamp": "1730379310.456789123", + "dropped": 5 + } +} +``` + +**Errors:** +- `NOT_FOUND` - Record doesn't exist +- `TOO_MANY_SUBSCRIPTIONS` - Client exceeded max subscriptions +- `NO_BUFFER` - Record has no buffer configured + +### `record.unsubscribe` + +Cancels an active subscription. + +**Request:** +```json +{ + "id": 4, + "method": "record.unsubscribe", + "params": { + "subscription_id": "sub-123" + } +} +``` + +**Response:** +```json +{ + "id": 4, + "result": {} +} +``` + +**Request Parameters:** +- `subscription_id` (string): Subscription to cancel + +**Errors:** +- `NOT_FOUND` - Subscription doesn't exist + +### `record.set` (Optional - Write Mode) + +Sets a record value (only allowed if explicitly enabled). + +**Request:** +```json +{ + "id": 5, + "method": "record.set", + "params": { + "name": "ConfigRecord", + "value": { "log_level": "debug" } + } +} +``` + +**Response:** +```json +{ + "id": 5, + "result": { + "sequence": 10, + "timestamp": "1730379320.987654321" + } +} +``` + +**Request Parameters:** +- `name` (string): Record type name +- `value` (any): New value (must match record schema) + +**Result Fields:** +- `sequence` (integer): New sequence number +- `timestamp` (string): Unix timestamp in "secs.nanosecs" format when value was set + +**Errors:** +- `NOT_FOUND` - Record doesn't exist +- `PERMISSION_DENIED` - Record not writable +- `VALIDATION_ERROR` - Value doesn't match schema +- `INTERNAL_ERROR` - Failed to serialize/produce value + +--- + +## Protocol Details + +### Framing: NDJSON + +**Newline-Delimited JSON (NDJSON)** +- Each message is a single line of JSON +- Lines terminated with `\n` (LF, 0x0A) +- UTF-8 encoding +- No trailing commas, whitespace minimization recommended + +**Example Stream:** +``` +{"hello":{"version":"1.0","client":"aimdb-cli/0.1.0"}}\n +{"welcome":{"version":"1.0","server":"aimdb/0.3.0","permissions":["read"]}}\n +{"id":1,"method":"record.list","params":{}}\n +{"id":1,"result":{"records":[...]}}\n +``` + +### Versioning + +**Semantic Versioning:** +- **Major**: Incompatible protocol changes +- **Minor**: Backward-compatible additions +- **Patch**: Bug fixes, clarifications + +**Version Negotiation:** +1. Client sends `hello` with desired version +2. Server responds with `welcome` containing supported version +3. If versions incompatible, server sends error and closes connection + +**Compatibility Rules:** +- Server MUST support client version within same major version +- Server MAY support older major versions +- Client SHOULD handle missing optional fields +- Server MUST reject unknown required fields + +### Error Codes + +| Code | Description | Retry Safe | +|------|-------------|-----------| +| `PROTOCOL_ERROR` | Malformed message, invalid JSON | No | +| `VERSION_MISMATCH` | Incompatible protocol versions | No | +| `NOT_FOUND` | Record or subscription not found | Yes | +| `PERMISSION_DENIED` | Operation not allowed | No | +| `QUEUE_FULL` | Subscription queue overflow | Yes (backoff) | +| `INTERNAL_ERROR` | Server internal error | Yes (backoff) | +| `TOO_MANY_SUBSCRIPTIONS` | Client exceeded subscription limit | No | +| `NO_VALUE` | Record has no current value | Yes | +| `NO_BUFFER` | Record has no buffer for subscription | No | +| `VALIDATION_ERROR` | Invalid parameter or value | No | +| `AUTH_REQUIRED` | Authentication token required | No | +| `AUTH_FAILED` | Invalid authentication token | No | + +### Auth Model + +**Optional Token-Based Authentication:** + +When auth is enabled, client must include token in `hello`: + +```json +{ + "hello": { + "version": "1.0", + "client": "aimdb-cli/0.1.0", + "auth_token": "secret-token-here" + } +} +``` + +Server validates and responds: + +```json +{ + "welcome": { + "version": "1.0", + "server": "aimdb/0.3.0", + "permissions": ["read", "subscribe"], + "authenticated": true + } +} +``` + +Or rejects: + +```json +{ + "error": { + "code": "AUTH_FAILED", + "message": "Invalid authentication token" + } +} +``` + +**Security Layers:** +1. **Primary**: UDS file permissions (owner/group/mode) +2. **Optional**: Auth token (simple bearer token for v1) +3. **Future**: JWT tokens, OAuth, mTLS (v2+) + +### Subscription Semantics + +**Bounded Queues:** +- Each subscription has a fixed-size queue (configured at server startup) +- Default: 100 events per subscription +- Drop policy: **Drop oldest** when queue full +- Client notified via `dropped` field in next event + +**Delivery Guarantees:** +- **At-most-once**: Events may be dropped under backpressure +- **Ordered**: Events delivered in sequence (within same subscription) +- **Best-effort**: Server attempts delivery but doesn't block producers + +**Backpressure Handling:** +1. Producer writes to record → triggers subscribers +2. If client queue full → oldest event dropped, `dropped` counter incremented +3. Next successfully queued event includes `dropped` field +4. Client can detect data loss and take action (reconnect, alert, etc.) + +**Example Backpressure Event:** +```json +{ + "event": { + "subscription_id": "sub-123", + "sequence": 105, + "data": { ... }, + "timestamp": "1730379600.123456789", + "dropped": 20 + } +} +``` + +This indicates 20 events (sequence 85-104) were dropped. + +--- + +## Security Model + +### Principles + +1. **Read-only by default** - No writes unless explicitly enabled +2. **Explicit opt-in for writes** - Per-record basis via builder +3. **UDS permissions primary** - Leverage OS-level security +4. **Auth optional but recommended** - Token-based for additional layer +5. **Permission-scoped channels** - Each connection has clear capabilities +6. **No global control ops in v1** - Introspection only +7. **Bounded resources** - Connection limits, queue limits prevent DoS + +### Access Control Levels + +**Level 0: No Access** +```bash +chmod 600 /var/run/aimdb/aimdb.sock # Owner only +``` + +**Level 1: Read-Only (Default)** +```rust +.with_remote_access( + AimxConfig::uds_default() + .security_policy(SecurityPolicy::ReadOnly) +) +``` + +Permissions: `["read", "subscribe"]` + +**Level 2: Read-Write (Explicit Opt-In)** +```rust +.with_remote_access( + AimxConfig::uds_default() + .security_policy(SecurityPolicy::ReadWrite) + .allow_write_to("AdminConfigRecord") + .allow_write_to("FeatureFlagRecord") +) +``` + +Permissions: `["read", "subscribe", "write"]` +Writable records announced in `welcome` message. + +### Auth Token Configuration + +```rust +.with_remote_access( + AimxConfig::uds_default() + .auth_token("secret-token-from-env") + .security_policy(SecurityPolicy::ReadOnly) +) +``` + +**Token Best Practices:** +- Store in environment variables, not hardcoded +- Rotate regularly +- Use different tokens for different deployments +- Consider per-client tokens for audit trails (future) + +### Permission Announcement + +Server must announce capabilities in `welcome`: + +```json +{ + "welcome": { + "version": "1.0", + "server": "aimdb/0.3.0", + "permissions": ["read", "subscribe", "write"], + "writable_records": ["AdminConfigRecord", "FeatureFlagRecord"] + } +} +``` + +Clients use this to: +- Display available operations to users +- Avoid sending forbidden requests +- Implement client-side validation + +--- + +## Example Session + +### Full Session: List, Get, Subscribe + +```json +→ {"hello":{"version":"1.0","client":"aimdb-cli/0.1.0"}}\n +← {"welcome":{"version":"1.0","server":"aimdb/0.3.0","permissions":["read","subscribe"],"writable_records":[],"max_subscriptions":10}}\n + +→ {"id":1,"method":"record.list","params":{}}\n +← {"id":1,"result":{"records":[{"name":"SensorData","type_id":"0x7f8a4c2b1e90","buffer_type":"spmc_ring","buffer_capacity":100,"producer_count":1,"consumer_count":2,"writable":false,"created_at":"1730372400.0","last_update":"1730379296.123456789"}]}}\n + +→ {"id":2,"method":"record.get","params":{"name":"SensorData"}}\n +← {"id":2,"result":{"value":{"temperature":23.5,"humidity":45.2},"timestamp":"1730379296.123456789","sequence":42}}\n + +→ {"id":3,"method":"record.subscribe","params":{"name":"SensorData","send_initial":true}}\n +← {"id":3,"result":{"subscription_id":"sub-123","queue_size":100}}\n +← {"event":{"subscription_id":"sub-123","sequence":43,"data":{"temperature":23.5,"humidity":45.2},"timestamp":"1730379296.123456789"}}\n +← {"event":{"subscription_id":"sub-123","sequence":44,"data":{"temperature":23.6,"humidity":45.1},"timestamp":"1730379300.456789123"}}\n +← {"event":{"subscription_id":"sub-123","sequence":45,"data":{"temperature":23.7,"humidity":45.0},"timestamp":"1730379305.789012345"}}\n + +→ {"id":4,"method":"record.unsubscribe","params":{"subscription_id":"sub-123"}}\n +← {"id":4,"result":{}}\n +``` + +### Error Handling Example + +**Request for non-existent record:** +```json +→ {"id":10,"method":"record.get","params":{"name":"NonExistent"}}\n +← {"id":10,"error":{"code":"NOT_FOUND","message":"Record 'NonExistent' not found"}}\n +``` + +**Permission denied:** +```json +→ {"id":11,"method":"record.set","params":{"name":"SensorData","value":{...}}}\n +← {"id":11,"error":{"code":"PERMISSION_DENIED","message":"Record 'SensorData' is not writable"}}\n +``` + +**Backpressure notification:** +```json +← {"event":{"subscription_id":"sub-123","sequence":150,"data":{...},"timestamp":"1730379600.123456789","dropped":25}}\n +``` + +### Manual Testing with `nc` + +```bash +# Terminal 1: Start AimDB with remote access +cargo run --example remote-access-demo + +# Terminal 2: Connect and interact +nc -U /tmp/aimdb.sock +{"hello":{"version":"1.0","client":"manual-test"}} +# Server responds with welcome + +{"id":1,"method":"record.list"} +# Server responds with record list + +{"id":2,"method":"record.get","params":{"name":"SensorData"}} +# Server responds with current value + +{"id":3,"method":"record.subscribe","params":{"name":"SensorData"}} +# Server responds with subscription_id, then streams events +``` + +--- + +## Implementation Architecture + +### Task Structure + +``` +AimDB Instance +├─ RemoteSupervisor (1 task) +│ ├─ Binds to UDS path +│ ├─ Accepts connections +│ ├─ Spawns ConnectionHandler per client +│ └─ Tracks active connections +│ +└─ ConnectionHandler (1 per client) + ├─ Protocol handshake + ├─ Request routing + ├─ Subscription management + └─ Connection cleanup +``` + +### RemoteSupervisor Responsibilities + +1. **Socket Management** + - Create UDS at configured path + - Set file permissions + - Clean up on shutdown + +2. **Connection Acceptance** + - Accept incoming connections + - Enforce connection limits + - Spawn ConnectionHandler tasks + +3. **Lifecycle Management** + - Graceful shutdown coordination + - Active connection tracking + - Resource cleanup + +### ConnectionHandler Responsibilities + +1. **Handshake** + - Validate `hello` message + - Perform version negotiation + - Verify auth token (if configured) + - Send `welcome` response + +2. **Request Processing** + - Parse NDJSON messages + - Route to appropriate handlers + - Send responses with matching IDs + - Handle errors gracefully + +3. **Subscription Management** + - Create per-subscription queues (bounded) + - Subscribe to record buffers + - Push events as they arrive + - Track dropped events for backpressure + +4. **Cleanup** + - Unsubscribe from all active subscriptions + - Close connection + - Release resources + +### Internal APIs (aimdb-core) + +```rust +impl AimDbInner { + /// List all registered records with metadata + pub(crate) async fn list_records(&self) -> Vec; + + /// Read current snapshot of a record by TypeId + pub(crate) async fn read_record_snapshot( + &self, + type_id: TypeId + ) -> DbResult; + + /// Subscribe to record updates + pub(crate) async fn subscribe_record( + &self, + type_id: TypeId, + queue_size: usize, + ) -> DbResult; +} + +pub struct RecordSubscription { + pub rx: UnboundedReceiver, + pub unsubscribe: oneshot::Sender<()>, +} +``` + +### Configuration Types + +```rust +pub struct AimxConfig { + socket_path: PathBuf, + security_policy: SecurityPolicy, + max_connections: usize, + subscription_queue_size: usize, + auth_token: Option, +} + +pub enum SecurityPolicy { + ReadOnly, + ReadWrite { + writable_records: HashSet, + }, +} +``` + +--- + +## Performance Considerations + +### Zero Overhead When Disabled + +- Remote access is **opt-in** via `.with_remote_access()` +- No supervisor task spawned if not configured +- Zero memory overhead in core database +- No performance impact on production deployments without remote access + +### Minimal Overhead When Enabled, No Clients + +- Single supervisor task parked on `accept()` +- Negligible memory: socket handle + configuration +- No CPU usage when idle +- Immediate response to connection attempts + +### Per-Client Overhead + +**Memory:** +- ConnectionHandler: ~8KB stack per task +- Subscription queue: `queue_size * value_size` (bounded) +- Default: 100 events * ~1KB = ~100KB per subscription +- Max connections: configurable, default 16 + +**CPU:** +- JSON serialization: ~1-10µs per event (depends on value size) +- Queue operations: ~100ns per event (bounded channel ops) +- Network I/O: dominated by syscall overhead (~1-10µs per write) + +**Backpressure Protection:** +- Bounded queues prevent memory exhaustion +- Drop-oldest policy prevents blocking producers +- Clients notified of drops via `dropped` field + +### Optimization Strategies + +1. **Lazy Serialization**: Only serialize when client subscribed +2. **Batch Events**: Group multiple events per NDJSON line (future) +3. **Compression**: Optional gzip for large values (future) +4. **Zero-Copy**: Use `serde_json::RawValue` for passthrough (future) + +--- + +## Future Extensions + +### v1.1 - Minor Enhancements + +- **Batch Operations**: `record.get_many`, `record.subscribe_many` +- **Filtering**: Server-side subscription filters (reduce bandwidth) +- **Pagination**: `record.list` with cursor-based pagination +- **Metrics**: Built-in AimX metrics as records + +### v2.0 - Major Extensions + +- **Binary Protocol**: MessagePack, CBOR, or custom format +- **WebSocket Transport**: Remote access via gateway +- **JWT Authentication**: Stronger auth with claims +- **Write Transactions**: Multi-record atomic writes +- **Admin Operations**: Shutdown, reload, runtime control + +### v3.0 - Distributed Features + +- **Multi-Instance Discovery**: Query across multiple AimDB instances +- **Distributed Subscriptions**: Subscribe to records across cluster +- **Consensus Integration**: Raft/Paxos for write coordination +- **Federation**: Cross-datacenter synchronization + +--- + +## Appendix A: Type Mappings + +### Rust → JSON Serialization + +| Rust Type | JSON Type | Example | +|-----------|-----------|---------| +| `bool` | `boolean` | `true`, `false` | +| `i32`, `i64`, `u32`, `u64` | `number` | `42`, `-17` | +| `f32`, `f64` | `number` | `3.14`, `2.718` | +| `String`, `&str` | `string` | `"hello"` | +| `Option` | `T \| null` | `42`, `null` | +| `Vec` | `array` | `[1, 2, 3]` | +| `HashMap` | `object` | `{"key": "value"}` | +| Struct (with `#[derive(Serialize)]`) | `object` | `{"field": value}` | +| Enum (with `#[derive(Serialize)]`) | Various | Depends on serde attributes | + +### TypeId Representation + +TypeId is a 64-bit hash in Rust. For JSON, represent as: + +- **Hexadecimal string**: `"0x7f8a4c2b1e90"` +- **Decimal string**: `"140241734827664"` (less readable, avoid) + +Recommended: Use hex strings for consistency with debugging tools. + +--- + +## Appendix B: Reference Implementation + +See `aimdb-core/src/remote/` for complete implementation. + +**Key files:** +- `mod.rs` - Public API exports +- `config.rs` - Configuration types +- `supervisor.rs` - RemoteSupervisor task +- `handler.rs` - ConnectionHandler task +- `protocol.rs` - Message types and parsing +- `errors.rs` - Protocol error codes + +**Examples:** +- `examples/remote-access-basic/` - Minimal setup +- `examples/remote-access-client/` - Rust client implementation +- `examples/remote-access-secure/` - Auth token + permissions + +--- + +## Appendix C: Testing Checklist + +### Unit Tests + +- [ ] Socket creation and cleanup +- [ ] Handshake protocol (valid and invalid) +- [ ] Version negotiation (compatible and incompatible) +- [ ] Error response formatting +- [ ] Permission checks (read-only vs read-write) +- [ ] Subscription queue overflow (drop-oldest) +- [ ] Auth token validation + +### Integration Tests + +- [ ] Full flow: connect → handshake → list → get → subscribe → unsubscribe +- [ ] Read-only enforcement (reject writes) +- [ ] Backpressure handling (dropped events) +- [ ] Multiple concurrent clients +- [ ] Graceful shutdown (active connections) +- [ ] Socket cleanup on server exit + +### Fuzzing Tests + +- [ ] Invalid JSON (malformed, truncated) +- [ ] Missing required fields +- [ ] Wrong field types +- [ ] Very large messages (>1MB) +- [ ] Connection drops mid-request +- [ ] Rapid connect/disconnect cycles +- [ ] Subscription spam (many subscriptions) + +### Manual Tests + +- [ ] `nc -U /tmp/aimdb.sock` - basic connectivity +- [ ] CLI tool against live instance +- [ ] Dashboard subscription under load +- [ ] Permission changes (file mode) +- [ ] Auth token rotation +- [ ] Multiple concurrent subscriptions + +--- + +## References + +- [NDJSON Specification](http://ndjson.org/) +- [JSON-RPC 2.0 Specification](https://www.jsonrpc.org/specification) (inspiration) +- [Semantic Versioning](https://semver.org/) +- [Unix Domain Sockets](https://en.wikipedia.org/wiki/Unix_domain_socket) + +--- + +**End of AimX v1 Specification** diff --git a/examples/remote-access-demo/Cargo.toml b/examples/remote-access-demo/Cargo.toml new file mode 100644 index 00000000..180254db --- /dev/null +++ b/examples/remote-access-demo/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "remote-access-demo" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" + +[[bin]] +name = "server" +path = "src/server.rs" + +[[bin]] +name = "client" +path = "src/client.rs" + +[dependencies] +aimdb-core = { path = "../../aimdb-core", features = ["std", "tracing"] } +aimdb-tokio-adapter = { path = "../../aimdb-tokio-adapter", features = [ + "tokio-runtime", + "tracing", +] } +tokio = { version = "1.48", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/examples/remote-access-demo/README.md b/examples/remote-access-demo/README.md new file mode 100644 index 00000000..f95091d2 --- /dev/null +++ b/examples/remote-access-demo/README.md @@ -0,0 +1,97 @@ +# Remote Access Demo + +This example demonstrates the AimX v1 remote access protocol with `record.list` functionality. + +## What It Does + +**Server** (`server.rs`): +- Creates an AimDB instance with 4 record types (Temperature, SystemStatus, UserEvent, Config) +- Enables remote access on Unix domain socket `/tmp/aimdb-demo.sock` +- Uses ReadOnly security policy +- Populates some initial data + +**Client** (`client.rs`): +- Connects to the server via Unix domain socket +- Performs protocol handshake (Hello/Welcome) +- Calls `record.list` method +- Displays all registered records with their metadata + +## Running the Demo + +### Terminal 1 - Start the Server + +```bash +cargo run --example remote-access-demo --bin server +``` + +You should see: +``` +🚀 Starting AimDB Remote Access Demo Server +📡 Remote access will be available at: /tmp/aimdb-demo.sock +🔒 Security policy: ReadOnly +✅ Database initialized with 4 record types +📝 Populated initial record data +🎯 Server ready! +``` + +### Terminal 2 - Run the Client + +```bash +cargo run --example remote-access-demo --bin client +``` + +You should see: +``` +🔌 Connecting to AimDB server... +✅ Connected! +📤 Sending handshake... +📥 Received welcome from server: aimdb +📤 Requesting record list... +✅ Success! +📋 Registered Records: +[ + { + "name": "Temperature", + "type_id": "...", + "buffer_type": "none", + "producer_count": 1, + "consumer_count": 0, + "writable": false, + ... + }, + ... +] +``` + +## Manual Testing with `socat` + +You can also test manually using `socat`: + +```bash +# Send handshake +echo '{"version":"1.0","client":"test"}' | socat - UNIX-CONNECT:/tmp/aimdb-demo.sock + +# Send record.list request (after handshake) +(echo '{"version":"1.0","client":"test"}'; sleep 0.1; echo '{"id":1,"method":"record.list"}') | socat - UNIX-CONNECT:/tmp/aimdb-demo.sock +``` + +## What to Observe + +- **Handshake**: Client sends Hello, server responds with Welcome +- **Permissions**: Server reports ReadOnly permissions +- **Record Metadata**: Each record shows: + - Type name (Rust struct name) + - TypeId (unique identifier) + - Buffer configuration + - Producer/consumer counts + - Write permissions + - Timestamps + +## Next Steps + +Future enhancements will add: +- `record.get` - Read current value of a record +- `record.subscribe` - Stream real-time updates +- `record.unsubscribe` - Stop streaming +- Authentication with tokens +- Read-write mode with `record.set` diff --git a/examples/remote-access-demo/src/client.rs b/examples/remote-access-demo/src/client.rs new file mode 100644 index 00000000..1b5819f4 --- /dev/null +++ b/examples/remote-access-demo/src/client.rs @@ -0,0 +1,536 @@ +//! Remote Access Demo - Client +//! +//! Simple client that connects to the demo server and calls record.list +//! +//! Run with: +//! ``` +//! cargo run --bin client +//! ``` + +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; + +#[derive(Debug, Serialize)] +struct Request { + id: u64, + method: String, + #[serde(skip_serializing_if = "Option::is_none")] + params: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum Response { + Success { id: u64, result: serde_json::Value }, + Error { id: u64, error: ErrorObject }, +} + +#[derive(Debug, Deserialize)] +struct EventMessage { + event: Event, +} + +#[derive(Debug, Deserialize)] +struct Event { + subscription_id: String, + sequence: u64, + timestamp: String, + data: serde_json::Value, + #[serde(default)] + dropped: Option, +} + +#[derive(Debug, Deserialize)] +struct ErrorObject { + code: String, + message: String, + #[serde(default)] + details: Option, +} + +#[derive(Debug, Deserialize)] +struct WelcomeMessage { + version: String, + server: String, + permissions: Vec, + writable_records: Vec, + #[serde(default)] + max_subscriptions: Option, + #[serde(default)] + #[allow(dead_code)] // Parsed from JSON but not used in demo + authenticated: Option, +} + +fn main() -> Result<(), Box> { + println!("🔌 Connecting to AimDB server..."); + + let socket_path = "/tmp/aimdb-demo.sock"; + let mut stream = UnixStream::connect(socket_path).map_err(|e| { + format!( + "Failed to connect to {}: {}\nMake sure the server is running!", + socket_path, e + ) + })?; + + let mut reader = BufReader::new(stream.try_clone()?); + + println!("✅ Connected!"); + println!(); + + // Send Hello message + println!("📤 Sending handshake..."); + let hello = json!({ + "version": "1.0", + "client": "aimdb-demo-client", + "capabilities": [], + }); + + writeln!(stream, "{}", hello)?; + stream.flush()?; + + // Read Welcome message + let mut line = String::new(); + reader.read_line(&mut line)?; + + let welcome: WelcomeMessage = serde_json::from_str(&line)?; + println!("📥 Received welcome from server: {}", welcome.server); + println!(" Version: {}", welcome.version); + println!(" Permissions: {:?}", welcome.permissions); + println!(" Writable records: {:?}", welcome.writable_records); + println!(" Max subscriptions: {:?}", welcome.max_subscriptions); + println!(); + + // Send record.list request + println!("📤 Requesting record list..."); + let request = Request { + id: 1, + method: "record.list".to_string(), + params: None, + }; + + let request_json = serde_json::to_string(&request)?; + writeln!(stream, "{}", request_json)?; + stream.flush()?; + + // Read response + let mut response_line = String::new(); + reader.read_line(&mut response_line)?; + + let response: Response = serde_json::from_str(&response_line)?; + + match response { + Response::Success { id, result } => { + println!("✅ Success! (request_id: {})", id); + println!(); + println!("📋 Registered Records:"); + println!("{}", serde_json::to_string_pretty(&result)?); + } + Response::Error { id, error } => { + println!("❌ Error! (request_id: {})", id); + println!(" Code: {}", error.code); + println!(" Message: {}", error.message); + if let Some(details) = error.details { + println!(" Details: {}", details); + } + } + } + + println!(); + + // Test record.get for Temperature + println!("📤 Requesting Temperature value..."); + let get_request = Request { + id: 2, + method: "record.get".to_string(), + params: Some(json!({"record": "server::Temperature"})), + }; + + let get_request_json = serde_json::to_string(&get_request)?; + writeln!(stream, "{}", get_request_json)?; + stream.flush()?; + + // Read response + let mut get_response_line = String::new(); + reader.read_line(&mut get_response_line)?; + + let get_response: Response = serde_json::from_str(&get_response_line)?; + + match get_response { + Response::Success { id, result } => { + println!("✅ Success! (request_id: {})", id); + println!(); + println!("🌡️ Current Temperature:"); + println!("{}", serde_json::to_string_pretty(&result)?); + } + Response::Error { id, error } => { + println!("❌ Error! (request_id: {})", id); + println!(" Code: {}", error.code); + println!(" Message: {}", error.message); + if let Some(details) = error.details { + println!(" Details: {}", details); + } + } + } + + println!(); + + // Test record.get for SystemStatus + println!("📤 Requesting SystemStatus value..."); + let status_request = Request { + id: 3, + method: "record.get".to_string(), + params: Some(json!({"record": "server::SystemStatus"})), + }; + + let status_request_json = serde_json::to_string(&status_request)?; + writeln!(stream, "{}", status_request_json)?; + stream.flush()?; + + let mut status_response_line = String::new(); + reader.read_line(&mut status_response_line)?; + let status_response: Response = serde_json::from_str(&status_response_line)?; + + match status_response { + Response::Success { id, result } => { + println!("✅ Success! (request_id: {})", id); + println!(); + println!("💻 Current System Status:"); + println!("{}", serde_json::to_string_pretty(&result)?); + } + Response::Error { id, error } => { + println!("❌ Error! (request_id: {})", id); + println!(" Code: {}", error.code); + println!(" Message: {}", error.message); + } + } + + println!(); + + // Test record.get for Config + println!("📤 Requesting Config value..."); + let config_request = Request { + id: 4, + method: "record.get".to_string(), + params: Some(json!({"record": "server::Config"})), + }; + + let config_request_json = serde_json::to_string(&config_request)?; + writeln!(stream, "{}", config_request_json)?; + stream.flush()?; + + let mut config_response_line = String::new(); + reader.read_line(&mut config_response_line)?; + let config_response: Response = serde_json::from_str(&config_response_line)?; + + match config_response { + Response::Success { id, result } => { + println!("✅ Success! (request_id: {})", id); + println!(); + println!("⚙️ Current Config:"); + println!("{}", serde_json::to_string_pretty(&result)?); + } + Response::Error { id, error } => { + println!("❌ Error! (request_id: {})", id); + println!(" Code: {}", error.code); + println!(" Message: {}", error.message); + } + } + + println!(); + + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!("✍️ Testing record.set (Write Operations)"); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!(); + + // Test 1: Get current AppSettings + println!("📤 Getting current AppSettings..."); + let get_settings_request = Request { + id: 5, + method: "record.get".to_string(), + params: Some(json!({"record": "server::AppSettings"})), + }; + + writeln!(stream, "{}", serde_json::to_string(&get_settings_request)?)?; + stream.flush()?; + + let mut settings_response_line = String::new(); + reader.read_line(&mut settings_response_line)?; + let settings_response: Response = serde_json::from_str(&settings_response_line)?; + + match settings_response { + Response::Success { id, result } => { + println!("✅ Success! (request_id: {})", id); + println!(); + println!("⚙️ Original AppSettings:"); + println!("{}", serde_json::to_string_pretty(&result)?); + println!(); + } + Response::Error { id, error } => { + println!("❌ Error! (request_id: {})", id); + println!(" Code: {}", error.code); + println!(" Message: {}", error.message); + return Ok(()); + } + }; + + // Test 2: Modify and set new AppSettings + println!("📤 Updating AppSettings (enabling feature_flag_alpha)..."); + let new_settings = json!({ + "log_level": "debug", + "max_connections": 200, + "feature_flag_alpha": true + }); + + let set_request = Request { + id: 6, + method: "record.set".to_string(), + params: Some(json!({ + "name": "server::AppSettings", + "value": new_settings + })), + }; + + writeln!(stream, "{}", serde_json::to_string(&set_request)?)?; + stream.flush()?; + + let mut set_response_line = String::new(); + reader.read_line(&mut set_response_line)?; + let set_response: Response = serde_json::from_str(&set_response_line)?; + + match set_response { + Response::Success { id, result } => { + println!("✅ Success! record.set completed (request_id: {})", id); + println!(); + println!("✨ Updated AppSettings:"); + println!("{}", serde_json::to_string_pretty(&result)?); + println!(); + } + Response::Error { id, error } => { + println!("❌ Error! (request_id: {})", id); + println!(" Code: {}", error.code); + println!(" Message: {}", error.message); + if let Some(details) = error.details { + println!(" Details: {}", details); + } + return Ok(()); + } + } + + // Test 3: Verify the change by getting again + println!("📤 Verifying update by getting AppSettings again..."); + let verify_request = Request { + id: 7, + method: "record.get".to_string(), + params: Some(json!({"record": "server::AppSettings"})), + }; + + writeln!(stream, "{}", serde_json::to_string(&verify_request)?)?; + stream.flush()?; + + let mut verify_response_line = String::new(); + reader.read_line(&mut verify_response_line)?; + let verify_response: Response = serde_json::from_str(&verify_response_line)?; + + match verify_response { + Response::Success { id, result } => { + println!("✅ Success! (request_id: {})", id); + println!(); + println!("✔️ Verified - AppSettings after update:"); + println!("{}", serde_json::to_string_pretty(&result)?); + println!(); + } + Response::Error { id, error } => { + println!("❌ Error! (request_id: {})", id); + println!(" Code: {}", error.code); + println!(" Message: {}", error.message); + } + } + + // Test 4: Try to set Temperature (should fail - has producer) + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!("🛡️ Testing Safety: Try to override Temperature (has producer)"); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!(); + + println!("📤 Attempting to set Temperature (SHOULD FAIL)..."); + let bad_set_request = Request { + id: 8, + method: "record.set".to_string(), + params: Some(json!({ + "name": "server::Temperature", + "value": { + "sensor_id": "hacked-sensor", + "celsius": 999.9, + "timestamp": 0 + } + })), + }; + + writeln!(stream, "{}", serde_json::to_string(&bad_set_request)?)?; + stream.flush()?; + + let mut bad_set_response_line = String::new(); + reader.read_line(&mut bad_set_response_line)?; + let bad_set_response: Response = serde_json::from_str(&bad_set_response_line)?; + + match bad_set_response { + Response::Success { id, result } => { + println!("❌ UNEXPECTED! record.set succeeded when it should have failed!"); + println!(" Request ID: {}", id); + println!(" Result: {}", result); + println!(" ⚠️ This is a security issue - producer protection not working!"); + } + Response::Error { id, error } => { + println!("✅ EXPECTED FAILURE! Safety check worked (request_id: {})", id); + println!(" Code: {}", error.code); + println!(" Message: {}", error.message); + println!(" 🛡️ Protection confirmed: Cannot override records with producers"); + if let Some(details) = error.details { + println!(" Details: {}", details); + } + } + } + + println!(); + + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!("📡 Testing Subscriptions"); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!(); + + // Subscribe to Temperature updates + println!("📤 Subscribing to Temperature updates..."); + let subscribe_request = Request { + id: 9, + method: "record.subscribe".to_string(), + params: Some(json!({ + "name": "server::Temperature", + "queue_size": 50 + })), + }; + + let subscribe_json = serde_json::to_string(&subscribe_request)?; + writeln!(stream, "{}", subscribe_json)?; + stream.flush()?; + + // Read subscription response + let mut subscribe_response_line = String::new(); + reader.read_line(&mut subscribe_response_line)?; + + let subscribe_response: Response = serde_json::from_str(&subscribe_response_line)?; + + let subscription_id = match subscribe_response { + Response::Success { id, result } => { + println!("✅ Subscribed! (request_id: {})", id); + let sub_id = result["subscription_id"].as_str().unwrap().to_string(); + let queue_size = result["queue_size"].as_u64().unwrap(); + println!(" Subscription ID: {}", sub_id); + println!(" Queue Size: {}", queue_size); + println!(); + println!("📊 Receiving live temperature updates (will receive 5 events)..."); + println!(); + sub_id + } + Response::Error { id, error } => { + println!("❌ Subscription failed! (request_id: {})", id); + println!(" Code: {}", error.code); + println!(" Message: {}", error.message); + return Ok(()); + } + }; + + // Receive 5 events + for i in 1..=5 { + let mut event_line = String::new(); + reader.read_line(&mut event_line)?; + + // Try to parse as EventMessage + if let Ok(event_msg) = serde_json::from_str::(&event_line) { + let event = event_msg.event; + println!("📨 Event #{} (seq: {})", i, event.sequence); + println!(" Subscription: {}", event.subscription_id); + println!(" Timestamp: {}", event.timestamp); + if let Some(dropped) = event.dropped { + println!(" ⚠️ Dropped events: {}", dropped); + } + println!(" Data: {}", serde_json::to_string_pretty(&event.data)?); + println!(); + } else { + println!("⚠️ Received unexpected message: {}", event_line.trim()); + } + + // Small delay to show streaming behavior + std::thread::sleep(std::time::Duration::from_millis(500)); + } + + // Unsubscribe + println!("📤 Unsubscribing from Temperature..."); + let unsubscribe_request = Request { + id: 10, + method: "record.unsubscribe".to_string(), + params: Some(json!({ + "subscription_id": subscription_id + })), + }; + + let unsubscribe_json = serde_json::to_string(&unsubscribe_request)?; + writeln!(stream, "{}", unsubscribe_json)?; + stream.flush()?; + + // Read unsubscribe response + let mut unsubscribe_response_line = String::new(); + reader.read_line(&mut unsubscribe_response_line)?; + + // Parse response - filter out any stray events + let unsubscribe_response: Result = + serde_json::from_str(&unsubscribe_response_line); + + match unsubscribe_response { + Ok(Response::Success { id, result }) => { + println!("✅ Unsubscribed! (request_id: {})", id); + println!( + " Status: {}", + result["status"].as_str().unwrap_or("unknown") + ); + println!(); + } + Ok(Response::Error { id, error }) => { + println!("❌ Unsubscribe failed! (request_id: {})", id); + println!(" Code: {}", error.code); + println!(" Message: {}", error.message); + } + Err(_) => { + // Might be a stray event, try reading next line + println!("⚠️ Received unexpected message, retrying..."); + let mut retry_line = String::new(); + reader.read_line(&mut retry_line)?; + match serde_json::from_str::(&retry_line) { + Ok(Response::Success { id, result }) => { + println!("✅ Unsubscribed! (request_id: {})", id); + println!( + " Status: {}", + result["status"].as_str().unwrap_or("unknown") + ); + println!(); + } + Ok(Response::Error { id, error }) => { + println!("❌ Unsubscribe failed! (request_id: {})", id); + println!(" Code: {}", error.code); + println!(" Message: {}", error.message); + } + Err(e) => { + println!("⚠️ Failed to parse unsubscribe response: {}", e); + } + } + } + } + + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!(); + println!("👋 Disconnecting..."); + + Ok(()) +} diff --git a/examples/remote-access-demo/src/server.rs b/examples/remote-access-demo/src/server.rs new file mode 100644 index 00000000..cd385258 --- /dev/null +++ b/examples/remote-access-demo/src/server.rs @@ -0,0 +1,253 @@ +//! Remote Access Demo - Server +//! +//! Demonstrates AimX v1 remote access protocol with record.list functionality. +//! +//! This server: +//! - Creates a database with several example records +//! - Enables remote access on a Unix domain socket +//! - Allows clients to list and inspect registered records +//! +//! Run with: +//! ``` +//! cargo run --bin server +//! ``` + +use aimdb_core::remote::{AimxConfig, SecurityPolicy}; +use aimdb_core::{buffer::BufferCfg, AimDbBuilder}; +use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tracing::info; + +/// Temperature sensor reading +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Temperature { + sensor_id: String, + celsius: f64, + timestamp: u64, +} + +/// System status information +#[derive(Debug, Clone, Serialize, Deserialize)] +struct SystemStatus { + uptime_seconds: u64, + cpu_usage: f64, + memory_usage: f64, +} + +/// User event log entry +#[derive(Debug, Clone, Serialize, Deserialize)] +struct UserEvent { + user_id: u64, + event_type: String, + message: String, +} + +/// Configuration settings (has producer, NOT remotely writable) +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Config { + app_name: String, + version: String, + debug_mode: bool, +} + +/// Application settings (NO producer, remotely writable) +#[derive(Debug, Clone, Serialize, Deserialize)] +struct AppSettings { + log_level: String, + max_connections: u32, + feature_flag_alpha: bool, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize tracing + tracing_subscriber::fmt() + .with_env_filter("info,aimdb_core=debug") + .init(); + + info!("🚀 Starting AimDB Remote Access Demo Server"); + + // Create runtime adapter + let adapter = Arc::new(TokioAdapter); + + // Configure remote access + let socket_path = "/tmp/aimdb-demo.sock"; + + // Remove existing socket if present + let _ = std::fs::remove_file(socket_path); + + let mut security_policy = SecurityPolicy::read_write(); + security_policy.allow_write::(); + + let remote_config = AimxConfig::uds_default() + .socket_path(socket_path) + .security_policy(security_policy) + .max_connections(10) + .subscription_queue_size(100); + + info!("📡 Remote access will be available at: {}", socket_path); + info!("🔒 Security policy: ReadWrite"); + info!("✍️ Writable records: AppSettings"); + + // Build database with remote access enabled + let mut builder = AimDbBuilder::new() + .runtime(adapter) + .with_remote_access(remote_config); + + // Configure records + builder.configure::(|reg| { + reg.buffer(BufferCfg::SingleLatest).with_serialization(); + }); + + builder.configure::(|reg| { + reg.buffer(BufferCfg::SingleLatest).with_serialization(); + }); + + builder.configure::(|reg| { + reg.buffer(BufferCfg::SpmcRing { capacity: 100 }) + .with_serialization(); + }); + + builder.configure::(|reg| { + reg.buffer(BufferCfg::SingleLatest).with_serialization(); + }); + + builder.configure::(|reg| { + reg.buffer(BufferCfg::SingleLatest).with_serialization(); + }); + + let db = builder.build()?; + + info!("✅ Database initialized with 5 record types"); + info!(" - Temperature (has producer, NOT writable)"); + info!(" - SystemStatus (has producer, NOT writable)"); + info!(" - UserEvent (buffer only, no data)"); + info!(" - Config (has producer, NOT writable)"); + info!(" - AppSettings (NO producer, remotely writable ✍️)"); + + info!("📝 Populating initial record data..."); + + // Produce some initial data + let temp_producer = db.producer::(); + temp_producer + .produce(Temperature { + sensor_id: "sensor-01".to_string(), + celsius: 22.5, + timestamp: 1698764400, + }) + .await?; + + let status_producer = db.producer::(); + status_producer + .produce(SystemStatus { + uptime_seconds: 3600, + cpu_usage: 15.3, + memory_usage: 42.7, + }) + .await?; + + let config_producer = db.producer::(); + config_producer + .produce(Config { + app_name: "AimDB Demo".to_string(), + version: "0.1.0".to_string(), + debug_mode: true, + }) + .await?; + + // Initialize AppSettings WITHOUT creating a producer + // This makes it writable via remote access (record.set) + db.set_record_from_json( + "server::AppSettings", + serde_json::json!({ + "log_level": "info", + "max_connections": 100, + "feature_flag_alpha": false + }), + )?; + + info!("✅ Initial data populated"); + + // Spawn background task to continuously update Temperature + info!("🌡️ Starting live temperature simulator..."); + let temp_producer_clone = temp_producer.clone(); + tokio::spawn(async move { + let mut counter = 0u64; + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + counter += 1; + let temp = 20.0 + (counter as f64 * 0.5) + (counter as f64 % 10.0); + + let reading = Temperature { + sensor_id: format!("sensor-{:02}", (counter % 3) + 1), + celsius: temp, + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + }; + + if let Err(e) = temp_producer_clone.produce(reading.clone()).await { + tracing::error!("Failed to produce temperature: {}", e); + } else { + tracing::debug!( + "📊 Produced temperature: {} °C from {}", + reading.celsius, + reading.sensor_id + ); + } + } + }); + + // Spawn background task to update SystemStatus + info!("💻 Starting system status simulator..."); + let status_producer_clone = status_producer.clone(); + tokio::spawn(async move { + let mut uptime = 3600u64; + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + + uptime += 5; + let status = SystemStatus { + uptime_seconds: uptime, + cpu_usage: 10.0 + (uptime as f64 % 30.0), + memory_usage: 40.0 + ((uptime as f64 / 10.0) % 20.0), + }; + + if let Err(e) = status_producer_clone.produce(status.clone()).await { + tracing::error!("Failed to produce system status: {}", e); + } else { + tracing::debug!( + "📊 Produced system status: CPU {:.1}%, MEM {:.1}%", + status.cpu_usage, + status.memory_usage + ); + } + } + }); + + info!(""); + info!("🎯 Server ready! Connect with:"); + info!(" cargo run --bin client"); + info!(""); + info!(" Or test manually with:"); + info!( + " echo '{{\"id\":1,\"method\":\"record.list\"}}' | socat - UNIX-CONNECT:{}", + socket_path + ); + info!(""); + info!("📡 Live updates:"); + info!(" Temperature: every 2 seconds"); + info!(" SystemStatus: every 5 seconds"); + info!(""); + info!("Press Ctrl+C to stop the server"); + + // Keep server running + tokio::signal::ctrl_c().await?; + + info!("🛑 Shutting down server..."); + + Ok(()) +}