stateset_embedded/events/
mod.rs1mod 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#[derive(Clone)]
59pub struct EventConfig {
60 pub channel_capacity: usize,
62 pub persist_events: bool,
64 pub event_store: Option<Arc<dyn EventStore + Send + Sync>>,
66 pub max_in_memory_events: usize,
68 pub enable_webhooks: bool,
70 pub webhook_max_retries: u32,
72 pub webhook_timeout_secs: u64,
74 pub webhook_max_in_flight: usize,
77 pub webhook_retry_delay_ms: u64,
80 pub webhook_max_delivery_history: usize,
83 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
126pub 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 #[must_use]
144 pub fn new() -> Self {
145 Self::with_config(EventConfig::default())
146 }
147
148 #[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 pub const fn emitter(&self) -> &EventEmitter {
195 &self.emitter
196 }
197
198 pub fn subscribe(&self) -> EventSubscription {
200 self.bus.subscribe()
201 }
202
203 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 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 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 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 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 pub fn list_webhooks(&self) -> Vec<Webhook> {
249 self.webhook_manager.as_ref().map(webhook::WebhookManager::list).unwrap_or_default()
250 }
251
252 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 pub const fn bus(&self) -> &Arc<EventBus> {
259 &self.bus
260 }
261
262 pub const fn config(&self) -> &EventConfig {
264 &self.config
265 }
266
267 pub fn event_store(&self) -> Option<&(dyn EventStore + Send + Sync)> {
269 self.event_store.as_deref()
270 }
271
272 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 self.emitter.emit(event.clone());
289
290 if let Some(ref wm) = self.webhook_manager {
292 wm.deliver(event);
293 }
294 }
295
296 pub fn subscriber_count(&self) -> usize {
298 self.bus.receiver_count()
299 }
300
301 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
353pub 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 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
381pub mod filters {
383 use stateset_core::CommerceEvent;
384
385 #[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 #[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 #[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 #[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 #[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 #[must_use]
455 pub const fn low_stock_alerts(event: &CommerceEvent) -> bool {
456 matches!(event, CommerceEvent::LowStockAlert { .. })
457 }
458
459 pub fn event_types(types: &'static [&'static str]) -> impl Fn(&CommerceEvent) -> bool {
461 move |event| types.contains(&event.event_type())
462 }
463}