use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::Arc;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LifecycleStatus {
Draft,
Active,
Deprecated,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TraverseEvent {
pub id: String,
pub source: String,
pub event_type: String,
pub datacontenttype: String,
pub time: String,
pub data: Value,
pub owner: String,
pub version: String,
pub lifecycle_status: LifecycleStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deduplication_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ordering_scope: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub correlation_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub causation_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subject_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actor_id: Option<String>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum EventError {
ValidationRejected(String),
LifecycleViolation(String),
UnregisteredEventType(String),
InvalidCursor(String),
CursorExpired {
event_type: String,
oldest_available_cursor: String,
},
SubscriptionNotFound(String),
InvalidRetentionWindow(String),
JournalWrite(String),
JournalWriteTimeout(String),
JournalRead(String),
}
impl std::fmt::Display for EventError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ValidationRejected(code) => write!(f, "event validation rejected: {code}"),
Self::LifecycleViolation(msg) => write!(f, "lifecycle violation: {msg}"),
Self::UnregisteredEventType(t) => write!(f, "unregistered event type: {t}"),
Self::InvalidCursor(msg) => write!(f, "invalid cursor: {msg}"),
Self::CursorExpired {
event_type,
oldest_available_cursor,
} => write!(
f,
"cursor expired for event type '{event_type}': oldest available cursor is {oldest_available_cursor}"
),
Self::SubscriptionNotFound(id) => write!(f, "subscription not found: {id}"),
Self::InvalidRetentionWindow(msg) => write!(f, "invalid retention window: {msg}"),
Self::JournalWrite(msg) => write!(f, "journal write failed: {msg}"),
Self::JournalWriteTimeout(msg) => write!(f, "journal_write_timeout: {msg}"),
Self::JournalRead(msg) => write!(f, "journal read failed: {msg}"),
}
}
}
impl std::error::Error for EventError {}
pub trait EventBroker: Send + Sync {
fn publish(&self, event: TraverseEvent) -> Result<(), EventError>;
fn publish_with_cursor(&self, event: TraverseEvent, cursor: &str) -> Result<(), EventError> {
let _ = cursor;
self.publish(event)
}
fn seed_restart_floor(&self, floor: u64) {
let _ = floor;
}
fn subscribe(&self, event_type: &str, from_cursor: &str) -> Result<Subscription, EventError>;
fn subscribe_for_subject(
&self,
event_type: &str,
from_cursor: &str,
subject_id: Option<&str>,
) -> Result<Subscription, EventError>;
fn poll(
&self,
subscription_id: &str,
max_events: usize,
) -> Result<SubscriptionPoll, EventError>;
fn cancel(&self, subscription_id: &str) -> Result<(), EventError>;
}
pub trait RuntimeEventSink: Send + Sync + std::fmt::Debug {
fn emit(&self, event: TraverseEvent) -> Result<(), EventError>;
}
#[derive(Debug, Default)]
pub struct NoopRuntimeEventSink;
impl RuntimeEventSink for NoopRuntimeEventSink {
fn emit(&self, _event: TraverseEvent) -> Result<(), EventError> {
Ok(())
}
}
pub struct BrokerEventSink {
broker: Arc<dyn EventBroker>,
}
impl BrokerEventSink {
#[must_use]
pub fn new(broker: Arc<dyn EventBroker>) -> Self {
Self { broker }
}
}
impl std::fmt::Debug for BrokerEventSink {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("BrokerEventSink")
.finish_non_exhaustive()
}
}
impl RuntimeEventSink for BrokerEventSink {
fn emit(&self, event: TraverseEvent) -> Result<(), EventError> {
self.broker.publish(event)
}
}
pub type EventCursor = String;
pub type SubscriptionId = String;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrokerEvent {
pub cursor: EventCursor,
pub event: TraverseEvent,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Subscription {
pub subscription_id: SubscriptionId,
pub event_type: String,
pub cursor: EventCursor,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubscriptionPoll {
pub subscription_id: SubscriptionId,
pub event_type: String,
pub cursor: EventCursor,
pub events: Vec<BrokerEvent>,
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::*;
fn sample_event(event_type: &str) -> TraverseEvent {
TraverseEvent {
id: "f0f83e66-4d87-4dd6-884d-0128d94f730f".to_string(),
source: "traverse-runtime".to_string(),
event_type: event_type.to_string(),
datacontenttype: "application/json".to_string(),
time: "2026-07-14T00:00:00Z".to_string(),
data: serde_json::json!({"execution_id": "exec_test"}),
owner: "traverse-runtime".to_string(),
version: "1.0.0".to_string(),
lifecycle_status: LifecycleStatus::Active,
deduplication_id: Some("f0f83e66-4d87-4dd6-884d-0128d94f730f".to_string()),
ordering_scope: Some("subject_test".to_string()),
correlation_id: Some("correlation-test".to_string()),
causation_id: Some("command-test".to_string()),
subject_id: Some("subject_test".to_string()),
actor_id: Some("actor_test".to_string()),
}
}
#[test]
fn event_error_display_covers_all_variants() {
let cases: Vec<EventError> = vec![
EventError::ValidationRejected("EVP-005".to_string()),
EventError::LifecycleViolation("x".to_string()),
EventError::UnregisteredEventType("t".to_string()),
EventError::InvalidCursor("c".to_string()),
EventError::CursorExpired {
event_type: "evt".to_string(),
oldest_available_cursor: "7".to_string(),
},
EventError::SubscriptionNotFound("sub-1".to_string()),
EventError::InvalidRetentionWindow("bad".to_string()),
EventError::JournalWrite("disk gone".to_string()),
EventError::JournalWriteTimeout("exceeded 2000ms".to_string()),
EventError::JournalRead("disk gone".to_string()),
];
for err in cases {
let rendered = err.to_string();
assert!(!rendered.is_empty());
}
}
#[test]
fn noop_runtime_event_sink_accepts_an_envelope() {
assert!(
NoopRuntimeEventSink
.emit(sample_event("dev.traverse.noop"))
.is_ok()
);
}
#[test]
fn broker_event_sink_forwards_the_original_envelope() {
let event_type = "dev.traverse.runtime.execution.completed";
let catalog = Arc::new(crate::events::EventCatalog::new());
catalog
.register(crate::events::EventCatalogEntry {
event_type: event_type.to_string(),
owner: "traverse-runtime".to_string(),
version: "1.0.0".to_string(),
lifecycle_status: LifecycleStatus::Active,
consumer_count: 0,
})
.expect("catalog registration must succeed");
let broker =
Arc::new(crate::events::InProcessBroker::new(catalog).expect("broker must be created"));
let sink = BrokerEventSink::new(broker.clone());
let event = sample_event(event_type);
sink.emit(event.clone())
.expect("sink delivery must succeed");
let subscription = broker
.subscribe_for_subject(event_type, "0", Some("subject_test"))
.expect("subject subscription must succeed");
let delivered = broker
.poll(&subscription.subscription_id, 1)
.expect("poll must succeed");
assert_eq!(format!("{sink:?}"), "BrokerEventSink { .. }");
assert_eq!(delivered.events.len(), 1);
assert_eq!(delivered.events[0].event.subject_id, event.subject_id);
assert_eq!(delivered.events[0].event.actor_id, event.actor_id);
assert_eq!(delivered.events[0].event.data, event.data);
}
}