1use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::sync::Arc;
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum LifecycleStatus {
13 Draft,
14 Active,
15 Deprecated,
16}
17
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct TraverseEvent {
21 pub id: String,
23 pub source: String,
25 pub event_type: String,
27 pub datacontenttype: String,
29 pub time: String,
31 pub data: Value,
33 pub owner: String,
36 pub version: String,
38 pub lifecycle_status: LifecycleStatus,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub deduplication_id: Option<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub ordering_scope: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub correlation_id: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub causation_id: Option<String>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub subject_id: Option<String>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub actor_id: Option<String>,
58}
59
60#[derive(Debug, PartialEq, Eq)]
62pub enum EventError {
63 ValidationRejected(String),
65 LifecycleViolation(String),
67 UnregisteredEventType(String),
69 InvalidCursor(String),
71 CursorExpired {
73 event_type: String,
74 oldest_available_cursor: String,
75 },
76 SubscriptionNotFound(String),
78 InvalidRetentionWindow(String),
80 JournalWrite(String),
82 JournalWriteTimeout(String),
85 JournalRead(String),
87}
88
89impl std::fmt::Display for EventError {
90 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 match self {
92 Self::ValidationRejected(code) => write!(f, "event validation rejected: {code}"),
93 Self::LifecycleViolation(msg) => write!(f, "lifecycle violation: {msg}"),
94 Self::UnregisteredEventType(t) => write!(f, "unregistered event type: {t}"),
95 Self::InvalidCursor(msg) => write!(f, "invalid cursor: {msg}"),
96 Self::CursorExpired {
97 event_type,
98 oldest_available_cursor,
99 } => write!(
100 f,
101 "cursor expired for event type '{event_type}': oldest available cursor is {oldest_available_cursor}"
102 ),
103 Self::SubscriptionNotFound(id) => write!(f, "subscription not found: {id}"),
104 Self::InvalidRetentionWindow(msg) => write!(f, "invalid retention window: {msg}"),
105 Self::JournalWrite(msg) => write!(f, "journal write failed: {msg}"),
106 Self::JournalWriteTimeout(msg) => write!(f, "journal_write_timeout: {msg}"),
107 Self::JournalRead(msg) => write!(f, "journal read failed: {msg}"),
108 }
109 }
110}
111
112impl std::error::Error for EventError {}
113
114pub trait EventBroker: Send + Sync {
116 fn publish(&self, event: TraverseEvent) -> Result<(), EventError>;
123
124 fn publish_with_cursor(&self, event: TraverseEvent, cursor: &str) -> Result<(), EventError> {
137 let _ = cursor;
138 self.publish(event)
139 }
140
141 fn seed_restart_floor(&self, floor: u64) {
151 let _ = floor;
152 }
153
154 fn subscribe(&self, event_type: &str, from_cursor: &str) -> Result<Subscription, EventError>;
165
166 fn subscribe_for_subject(
173 &self,
174 event_type: &str,
175 from_cursor: &str,
176 subject_id: Option<&str>,
177 ) -> Result<Subscription, EventError>;
178
179 fn poll(
185 &self,
186 subscription_id: &str,
187 max_events: usize,
188 ) -> Result<SubscriptionPoll, EventError>;
189
190 fn cancel(&self, subscription_id: &str) -> Result<(), EventError>;
196}
197
198pub trait RuntimeEventSink: Send + Sync + std::fmt::Debug {
203 fn emit(&self, event: TraverseEvent) -> Result<(), EventError>;
211}
212
213#[derive(Debug, Default)]
215pub struct NoopRuntimeEventSink;
216
217impl RuntimeEventSink for NoopRuntimeEventSink {
218 fn emit(&self, _event: TraverseEvent) -> Result<(), EventError> {
219 Ok(())
220 }
221}
222
223pub struct BrokerEventSink {
225 broker: Arc<dyn EventBroker>,
226}
227
228impl BrokerEventSink {
229 #[must_use]
230 pub fn new(broker: Arc<dyn EventBroker>) -> Self {
231 Self { broker }
232 }
233}
234
235impl std::fmt::Debug for BrokerEventSink {
236 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237 formatter
238 .debug_struct("BrokerEventSink")
239 .finish_non_exhaustive()
240 }
241}
242
243impl RuntimeEventSink for BrokerEventSink {
244 fn emit(&self, event: TraverseEvent) -> Result<(), EventError> {
245 self.broker.publish(event)
246 }
247}
248
249pub type EventCursor = String;
251
252pub type SubscriptionId = String;
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct BrokerEvent {
258 pub cursor: EventCursor,
259 pub event: TraverseEvent,
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct Subscription {
265 pub subscription_id: SubscriptionId,
266 pub event_type: String,
267 pub cursor: EventCursor,
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct SubscriptionPoll {
273 pub subscription_id: SubscriptionId,
274 pub event_type: String,
275 pub cursor: EventCursor,
276 pub events: Vec<BrokerEvent>,
277}
278
279#[cfg(test)]
280mod tests {
281 #![allow(clippy::expect_used)]
282
283 use super::*;
284
285 fn sample_event(event_type: &str) -> TraverseEvent {
286 TraverseEvent {
287 id: "f0f83e66-4d87-4dd6-884d-0128d94f730f".to_string(),
288 source: "traverse-runtime".to_string(),
289 event_type: event_type.to_string(),
290 datacontenttype: "application/json".to_string(),
291 time: "2026-07-14T00:00:00Z".to_string(),
292 data: serde_json::json!({"execution_id": "exec_test"}),
293 owner: "traverse-runtime".to_string(),
294 version: "1.0.0".to_string(),
295 lifecycle_status: LifecycleStatus::Active,
296 deduplication_id: Some("f0f83e66-4d87-4dd6-884d-0128d94f730f".to_string()),
297 ordering_scope: Some("subject_test".to_string()),
298 correlation_id: Some("correlation-test".to_string()),
299 causation_id: Some("command-test".to_string()),
300 subject_id: Some("subject_test".to_string()),
301 actor_id: Some("actor_test".to_string()),
302 }
303 }
304
305 #[test]
306 fn event_error_display_covers_all_variants() {
307 let cases: Vec<EventError> = vec![
308 EventError::ValidationRejected("EVP-005".to_string()),
309 EventError::LifecycleViolation("x".to_string()),
310 EventError::UnregisteredEventType("t".to_string()),
311 EventError::InvalidCursor("c".to_string()),
312 EventError::CursorExpired {
313 event_type: "evt".to_string(),
314 oldest_available_cursor: "7".to_string(),
315 },
316 EventError::SubscriptionNotFound("sub-1".to_string()),
317 EventError::InvalidRetentionWindow("bad".to_string()),
318 EventError::JournalWrite("disk gone".to_string()),
319 EventError::JournalWriteTimeout("exceeded 2000ms".to_string()),
320 EventError::JournalRead("disk gone".to_string()),
321 ];
322
323 for err in cases {
324 let rendered = err.to_string();
325 assert!(!rendered.is_empty());
326 }
327 }
328
329 #[test]
330 fn noop_runtime_event_sink_accepts_an_envelope() {
331 assert!(
332 NoopRuntimeEventSink
333 .emit(sample_event("dev.traverse.noop"))
334 .is_ok()
335 );
336 }
337
338 #[test]
339 fn broker_event_sink_forwards_the_original_envelope() {
340 let event_type = "dev.traverse.runtime.execution.completed";
341 let catalog = Arc::new(crate::events::EventCatalog::new());
342 catalog
343 .register(crate::events::EventCatalogEntry {
344 event_type: event_type.to_string(),
345 owner: "traverse-runtime".to_string(),
346 version: "1.0.0".to_string(),
347 lifecycle_status: LifecycleStatus::Active,
348 consumer_count: 0,
349 })
350 .expect("catalog registration must succeed");
351 let broker =
352 Arc::new(crate::events::InProcessBroker::new(catalog).expect("broker must be created"));
353 let sink = BrokerEventSink::new(broker.clone());
354 let event = sample_event(event_type);
355
356 sink.emit(event.clone())
357 .expect("sink delivery must succeed");
358 let subscription = broker
359 .subscribe_for_subject(event_type, "0", Some("subject_test"))
360 .expect("subject subscription must succeed");
361 let delivered = broker
362 .poll(&subscription.subscription_id, 1)
363 .expect("poll must succeed");
364
365 assert_eq!(format!("{sink:?}"), "BrokerEventSink { .. }");
366 assert_eq!(delivered.events.len(), 1);
367 assert_eq!(delivered.events[0].event.subject_id, event.subject_id);
368 assert_eq!(delivered.events[0].event.actor_id, event.actor_id);
369 assert_eq!(delivered.events[0].event.data, event.data);
370 }
371}