Skip to main content

stateset_embedded/events/
mod.rs

1//! Event streaming infrastructure
2//!
3//! This module provides real-time event streaming capabilities:
4//! - In-process pub/sub via broadcast channels
5//! - Event persistence via `EventStore`
6//! - Webhook delivery to external endpoints
7//!
8//! # Example
9//!
10//! ```rust,ignore
11//! use stateset_embedded::{Commerce, CommerceEvent};
12//! use tokio_stream::StreamExt;
13//!
14//! #[tokio::main]
15//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
16//!     let commerce = Commerce::new(":memory:")?;
17//!
18//!     // Subscribe to all events
19//!     let mut stream = commerce.events().subscribe();
20//!
21//!     // Process events in background
22//!     tokio::spawn(async move {
23//!         while let Some(event) = stream.next().await {
24//!             println!("Event: {:?}", event);
25//!         }
26//!     });
27//!
28//!     // Events are emitted automatically when operations occur
29//!     commerce.orders().create(...)?;  // Emits OrderCreated event
30//!
31//!     Ok(())
32//! }
33//! ```
34
35mod bus;
36mod emitter;
37mod store;
38mod webhook;
39
40pub use bus::{EventBus, EventReceiver, EventSubscription};
41pub use emitter::EventEmitter;
42pub use store::InMemoryEventStore;
43pub(crate) use webhook::validate_outbound_webhook_target_url;
44pub use webhook::{
45    Webhook, WebhookConfig, WebhookDelivery, WebhookManager, WebhookRegistrationError,
46};
47
48#[cfg(feature = "sqlite-events")]
49pub use store::SqliteEventStore;
50
51#[cfg(feature = "postgres")]
52pub use store::PostgresEventStore;
53
54use stateset_core::{CommerceEvent, EventStore};
55use std::sync::Arc;
56
57/// Configuration for the event system
58#[derive(Clone)]
59pub struct EventConfig {
60    /// Channel buffer size for broadcast
61    pub channel_capacity: usize,
62    /// Whether to persist events to the store
63    pub persist_events: bool,
64    /// Optional event store for persistence
65    pub event_store: Option<Arc<dyn EventStore + Send + Sync>>,
66    /// Maximum events to keep in the default in-memory store
67    pub max_in_memory_events: usize,
68    /// Whether to enable webhook delivery
69    pub enable_webhooks: bool,
70    /// Maximum retry attempts for webhook delivery
71    pub webhook_max_retries: u32,
72    /// Webhook timeout in seconds
73    pub webhook_timeout_secs: u64,
74    /// Maximum number of webhook requests in flight concurrently
75    /// (values <= 1 are treated as 1 at runtime)
76    pub webhook_max_in_flight: usize,
77    /// Base delay in milliseconds for webhook retry backoff.
78    /// Each retry uses exponential backoff up to a hard cap.
79    pub webhook_retry_delay_ms: u64,
80    /// Maximum webhook delivery records retained per webhook.
81    /// Zero disables history retention for that webhook.
82    pub webhook_max_delivery_history: usize,
83    /// Optional outbound webhook host allowlist.
84    ///
85    /// Empty keeps current strict default behavior (allow any public host).
86    /// Non-empty requires matching one of the configured host rules.
87    pub webhook_outbound_allowlist: Vec<String>,
88}
89
90impl Default for EventConfig {
91    fn default() -> Self {
92        Self {
93            channel_capacity: 1024,
94            persist_events: true,
95            event_store: None,
96            max_in_memory_events: 10_000,
97            enable_webhooks: true,
98            webhook_max_retries: 3,
99            webhook_timeout_secs: 30,
100            webhook_max_in_flight: 8,
101            webhook_retry_delay_ms: 1000,
102            webhook_max_delivery_history: 1_000,
103            webhook_outbound_allowlist: Vec::new(),
104        }
105    }
106}
107
108impl std::fmt::Debug for EventConfig {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        f.debug_struct("EventConfig")
111            .field("channel_capacity", &self.channel_capacity)
112            .field("persist_events", &self.persist_events)
113            .field("event_store", &self.event_store.as_ref().map(|_| "<custom>"))
114            .field("max_in_memory_events", &self.max_in_memory_events)
115            .field("enable_webhooks", &self.enable_webhooks)
116            .field("webhook_max_retries", &self.webhook_max_retries)
117            .field("webhook_timeout_secs", &self.webhook_timeout_secs)
118            .field("webhook_max_in_flight", &self.webhook_max_in_flight)
119            .field("webhook_retry_delay_ms", &self.webhook_retry_delay_ms)
120            .field("webhook_max_delivery_history", &self.webhook_max_delivery_history)
121            .field("webhook_outbound_allowlist", &self.webhook_outbound_allowlist)
122            .finish()
123    }
124}
125
126/// Central event system that coordinates all event delivery mechanisms
127pub struct EventSystem {
128    bus: Arc<EventBus>,
129    emitter: EventEmitter,
130    webhook_manager: Option<WebhookManager>,
131    event_store: Option<Arc<dyn EventStore + Send + Sync>>,
132    config: EventConfig,
133}
134
135impl std::fmt::Debug for EventSystem {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        f.debug_struct("EventSystem").field("config", &self.config).finish_non_exhaustive()
138    }
139}
140
141impl EventSystem {
142    /// Create a new event system with default configuration
143    #[must_use]
144    pub fn new() -> Self {
145        Self::with_config(EventConfig::default())
146    }
147
148    /// Create a new event system with custom configuration
149    #[must_use]
150    pub fn with_config(config: EventConfig) -> Self {
151        let config = EventConfig {
152            channel_capacity: config.channel_capacity.max(1),
153            max_in_memory_events: config.max_in_memory_events.max(1),
154            webhook_max_in_flight: config.webhook_max_in_flight.max(1),
155            webhook_timeout_secs: config.webhook_timeout_secs.max(1),
156            webhook_retry_delay_ms: config.webhook_retry_delay_ms.max(1),
157            ..config
158        };
159
160        let bus = Arc::new(EventBus::new(config.channel_capacity));
161        let emitter = EventEmitter::new(bus.clone());
162        let webhook_manager = if config.enable_webhooks {
163            Some(WebhookManager::with_config(WebhookConfig {
164                max_retries: config.webhook_max_retries,
165                timeout_secs: config.webhook_timeout_secs,
166                max_in_flight: config.webhook_max_in_flight,
167                retry_delay_ms: config.webhook_retry_delay_ms,
168                max_delivery_history: config.webhook_max_delivery_history,
169                outbound_allowlist: config.webhook_outbound_allowlist.clone(),
170            }))
171        } else {
172            None
173        };
174        let event_store =
175            if config.persist_events {
176                Some(config.event_store.clone().unwrap_or_else(|| {
177                    Arc::new(InMemoryEventStore::new(config.max_in_memory_events))
178                }))
179            } else {
180                None
181            };
182
183        Self { bus, emitter, webhook_manager, event_store, config }
184    }
185
186    #[cfg(test)]
187    const fn is_webhooks_enabled(&self) -> bool {
188        self.webhook_manager.is_some()
189    }
190}
191
192impl EventSystem {
193    /// Get the event emitter for publishing events
194    pub const fn emitter(&self) -> &EventEmitter {
195        &self.emitter
196    }
197
198    /// Subscribe to all events
199    pub fn subscribe(&self) -> EventSubscription {
200        self.bus.subscribe()
201    }
202
203    /// Subscribe to events matching a filter
204    pub fn subscribe_filtered<F>(&self, filter: F) -> FilteredSubscription<F>
205    where
206        F: Fn(&CommerceEvent) -> bool + Send + 'static,
207    {
208        FilteredSubscription { inner: self.bus.subscribe(), filter }
209    }
210
211    /// Register a webhook endpoint.
212    ///
213    /// This is a legacy API that preserves the previous `Uuid`-returning
214    /// contract. Prefer [`register_webhook_strict`](Self::register_webhook_strict) or
215    /// [`try_register_webhook`](Self::try_register_webhook) when you need explicit
216    /// failure handling.
217    pub fn register_webhook(&self, webhook: Webhook) -> uuid::Uuid {
218        self.register_webhook_strict(webhook).unwrap_or_else(|error| {
219            tracing::warn!(error = %error, "Webhook registration fallback returned nil");
220            uuid::Uuid::nil()
221        })
222    }
223
224    /// Register a webhook endpoint with explicit failure semantics.
225    pub fn register_webhook_strict(
226        &self,
227        webhook: Webhook,
228    ) -> Result<uuid::Uuid, WebhookRegistrationError> {
229        self.webhook_manager
230            .as_ref()
231            .ok_or(WebhookRegistrationError::WebhooksDisabled)
232            .and_then(|wm| wm.register_strict(webhook))
233    }
234
235    /// Register a webhook endpoint.
236    /// Returns `None` when registration is rejected (e.g. unsafe URL),
237    /// or when webhooks are disabled.
238    pub fn try_register_webhook(&self, webhook: Webhook) -> Option<uuid::Uuid> {
239        self.webhook_manager.as_ref().and_then(|wm| wm.try_register(webhook))
240    }
241
242    /// Unregister a webhook
243    pub fn unregister_webhook(&self, id: uuid::Uuid) -> bool {
244        self.webhook_manager.as_ref().is_some_and(|wm| wm.unregister(id))
245    }
246
247    /// List all registered webhooks
248    pub fn list_webhooks(&self) -> Vec<Webhook> {
249        self.webhook_manager.as_ref().map(webhook::WebhookManager::list).unwrap_or_default()
250    }
251
252    /// Get delivery history for a webhook (newest-first).
253    pub fn webhook_deliveries(&self, webhook_id: uuid::Uuid) -> Vec<WebhookDelivery> {
254        self.webhook_manager.as_ref().map(|wm| wm.deliveries(webhook_id)).unwrap_or_default()
255    }
256
257    /// Get the event bus for advanced usage
258    pub const fn bus(&self) -> &Arc<EventBus> {
259        &self.bus
260    }
261
262    /// Get configuration
263    pub const fn config(&self) -> &EventConfig {
264        &self.config
265    }
266
267    /// Access the configured event store, if enabled.
268    pub fn event_store(&self) -> Option<&(dyn EventStore + Send + Sync)> {
269        self.event_store.as_deref()
270    }
271
272    /// Emit an event.
273    ///
274    /// Broadcast delivery is non-blocking. Webhook delivery is dispatched to a background task.
275    /// Event persistence (if enabled) is best-effort and may block depending on the configured store.
276    pub fn emit(&self, event: CommerceEvent) {
277        if let Some(store) = &self.event_store {
278            if let Err(err) = store.append(&event) {
279                tracing::error!(
280                    error = %err,
281                    event_type = event.event_type(),
282                    "Failed to persist event"
283                );
284            }
285        }
286
287        // Send to broadcast channel (non-blocking)
288        self.emitter.emit(event.clone());
289
290        // Send to webhooks (spawns async task)
291        if let Some(ref wm) = self.webhook_manager {
292            wm.deliver(event);
293        }
294    }
295
296    /// Get the number of active subscribers
297    pub fn subscriber_count(&self) -> usize {
298        self.bus.receiver_count()
299    }
300
301    /// Get total publish failures on the event bus
302    pub fn bus_publish_failures(&self) -> u64 {
303        self.emitter.total_publish_failures()
304    }
305}
306
307impl Default for EventSystem {
308    fn default() -> Self {
309        Self::new()
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn test_event_system_config_normalizes_zero_limits() {
319        let event_system = EventSystem::with_config(EventConfig {
320            channel_capacity: 0,
321            max_in_memory_events: 0,
322            webhook_max_in_flight: 0,
323            webhook_timeout_secs: 0,
324            webhook_retry_delay_ms: 0,
325            persist_events: false,
326            enable_webhooks: false,
327            ..Default::default()
328        });
329
330        let config = event_system.config();
331        assert_eq!(config.channel_capacity, 1);
332        assert_eq!(config.max_in_memory_events, 1);
333        assert_eq!(config.webhook_max_in_flight, 1);
334        assert_eq!(config.webhook_timeout_secs, 1);
335        assert_eq!(config.webhook_retry_delay_ms, 1);
336        assert!(!event_system.is_webhooks_enabled());
337        assert!(event_system.event_store().is_none());
338    }
339
340    #[test]
341    fn test_event_system_config_keeps_webhook_disabled() {
342        let event_system = EventSystem::with_config(EventConfig {
343            enable_webhooks: false,
344            persist_events: false,
345            ..Default::default()
346        });
347
348        assert!(!event_system.is_webhooks_enabled());
349        assert!(event_system.event_store().is_none());
350    }
351}
352
353/// A filtered event subscription
354pub struct FilteredSubscription<F> {
355    inner: EventSubscription,
356    filter: F,
357}
358
359impl<F> std::fmt::Debug for FilteredSubscription<F> {
360    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
361        f.debug_struct("FilteredSubscription").finish_non_exhaustive()
362    }
363}
364
365impl<F> FilteredSubscription<F>
366where
367    F: Fn(&CommerceEvent) -> bool,
368{
369    /// Receive the next event matching the filter
370    pub async fn recv(&mut self) -> Option<CommerceEvent> {
371        loop {
372            match self.inner.recv().await {
373                Some(event) if (self.filter)(&event) => return Some(event),
374                Some(_) => continue,
375                None => return None,
376            }
377        }
378    }
379}
380
381/// Event type filter helpers
382pub mod filters {
383    use stateset_core::CommerceEvent;
384
385    /// Filter for order events only
386    #[must_use]
387    pub const fn orders_only(event: &CommerceEvent) -> bool {
388        matches!(
389            event,
390            CommerceEvent::OrderCreated { .. }
391                | CommerceEvent::OrderStatusChanged { .. }
392                | CommerceEvent::OrderPaymentStatusChanged { .. }
393                | CommerceEvent::OrderFulfillmentStatusChanged { .. }
394                | CommerceEvent::OrderCancelled { .. }
395                | CommerceEvent::OrderItemAdded { .. }
396                | CommerceEvent::OrderItemRemoved { .. }
397        )
398    }
399
400    /// Filter for inventory events only
401    #[must_use]
402    pub const fn inventory_only(event: &CommerceEvent) -> bool {
403        matches!(
404            event,
405            CommerceEvent::InventoryItemCreated { .. }
406                | CommerceEvent::InventoryAdjusted { .. }
407                | CommerceEvent::InventoryReserved { .. }
408                | CommerceEvent::InventoryReservationReleased { .. }
409                | CommerceEvent::InventoryReservationConfirmed { .. }
410                | CommerceEvent::LowStockAlert { .. }
411        )
412    }
413
414    /// Filter for customer events only
415    #[must_use]
416    pub const fn customers_only(event: &CommerceEvent) -> bool {
417        matches!(
418            event,
419            CommerceEvent::CustomerCreated { .. }
420                | CommerceEvent::CustomerUpdated { .. }
421                | CommerceEvent::CustomerStatusChanged { .. }
422                | CommerceEvent::CustomerAddressAdded { .. }
423        )
424    }
425
426    /// Filter for product events only
427    #[must_use]
428    pub const fn products_only(event: &CommerceEvent) -> bool {
429        matches!(
430            event,
431            CommerceEvent::ProductCreated { .. }
432                | CommerceEvent::ProductUpdated { .. }
433                | CommerceEvent::ProductStatusChanged { .. }
434                | CommerceEvent::ProductVariantAdded { .. }
435                | CommerceEvent::ProductVariantUpdated { .. }
436        )
437    }
438
439    /// Filter for return events only
440    #[must_use]
441    pub const fn returns_only(event: &CommerceEvent) -> bool {
442        matches!(
443            event,
444            CommerceEvent::ReturnRequested { .. }
445                | CommerceEvent::ReturnStatusChanged { .. }
446                | CommerceEvent::ReturnApproved { .. }
447                | CommerceEvent::ReturnRejected { .. }
448                | CommerceEvent::ReturnCompleted { .. }
449                | CommerceEvent::RefundIssued { .. }
450        )
451    }
452
453    /// Filter for low stock alerts
454    #[must_use]
455    pub const fn low_stock_alerts(event: &CommerceEvent) -> bool {
456        matches!(event, CommerceEvent::LowStockAlert { .. })
457    }
458
459    /// Create a filter for events matching specific types
460    pub fn event_types(types: &'static [&'static str]) -> impl Fn(&CommerceEvent) -> bool {
461        move |event| types.contains(&event.event_type())
462    }
463}