Skip to main content

stateset_sync/
event.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use serde_json::{Map, Value};
4use sha2::{Digest, Sha256};
5use uuid::Uuid;
6
7/// A sync event representing a state change in the system.
8///
9/// This is the Rust equivalent of the JS `OutboxEvent` and the VES v1.0
10/// event envelope. Events are immutable once created.
11///
12/// # Examples
13///
14/// ```
15/// use stateset_sync::SyncEvent;
16/// use serde_json::json;
17///
18/// let event = SyncEvent::new(
19///     "order.created",
20///     "order",
21///     "ORD-123",
22///     json!({"total": 99.99}),
23/// );
24/// assert_eq!(event.event_type, "order.created");
25/// assert_eq!(event.entity_type, "order");
26/// assert!(!event.hash.is_empty());
27/// ```
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29pub struct SyncEvent {
30    /// Unique event identifier.
31    pub id: Uuid,
32    /// Monotonically increasing sequence number (0 = unassigned).
33    pub sequence: u64,
34    /// The type of event (e.g. `order.created`, `inventory.adjusted`).
35    pub event_type: String,
36    /// The entity type this event applies to (e.g. `order`, `customer`).
37    pub entity_type: String,
38    /// The identifier of the entity.
39    pub entity_id: String,
40    /// The event payload as a JSON value.
41    pub payload: Value,
42    /// SHA-256 hash of the canonicalized payload (hex-encoded).
43    pub hash: String,
44    /// Optional cryptographic signature (hex-encoded Ed25519).
45    pub signature: Option<String>,
46    /// Timestamp when the event was created.
47    pub timestamp: DateTime<Utc>,
48}
49
50impl SyncEvent {
51    /// Create a new `SyncEvent` with an auto-generated id, hash, and timestamp.
52    #[must_use]
53    pub fn new(
54        event_type: impl Into<String>,
55        entity_type: impl Into<String>,
56        entity_id: impl Into<String>,
57        payload: Value,
58    ) -> Self {
59        let hash = Self::compute_hash(&payload);
60        Self {
61            id: Uuid::new_v4(),
62            sequence: 0,
63            event_type: event_type.into(),
64            entity_type: entity_type.into(),
65            entity_id: entity_id.into(),
66            payload,
67            hash,
68            signature: None,
69            timestamp: Utc::now(),
70        }
71    }
72
73    /// Create a `SyncEvent` with an explicit id and sequence.
74    #[must_use]
75    pub fn with_id(
76        id: Uuid,
77        sequence: u64,
78        event_type: impl Into<String>,
79        entity_type: impl Into<String>,
80        entity_id: impl Into<String>,
81        payload: Value,
82        timestamp: DateTime<Utc>,
83    ) -> Self {
84        let hash = Self::compute_hash(&payload);
85        Self {
86            id,
87            sequence,
88            event_type: event_type.into(),
89            entity_type: entity_type.into(),
90            entity_id: entity_id.into(),
91            payload,
92            hash,
93            signature: None,
94            timestamp,
95        }
96    }
97
98    /// Compute the SHA-256 hash of a JSON payload, hex-encoded.
99    #[must_use]
100    pub fn compute_hash(payload: &Value) -> String {
101        let canonical = canonicalize_json(payload);
102        let bytes = serde_json::to_vec(&canonical).unwrap_or_default();
103        let mut hasher = Sha256::new();
104        hasher.update(&bytes);
105        hex::encode(hasher.finalize())
106    }
107
108    /// Assign a sequence number to this event, returning a new event.
109    #[must_use]
110    pub const fn with_sequence(mut self, sequence: u64) -> Self {
111        self.sequence = sequence;
112        self
113    }
114
115    /// Set the signature on this event.
116    #[must_use]
117    pub fn with_signature(mut self, signature: impl Into<String>) -> Self {
118        self.signature = Some(signature.into());
119        self
120    }
121}
122
123impl PartialOrd for SyncEvent {
124    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
125        Some(self.cmp(other))
126    }
127}
128
129impl Ord for SyncEvent {
130    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
131        self.sequence
132            .cmp(&other.sequence)
133            .then_with(|| self.timestamp.cmp(&other.timestamp))
134            .then_with(|| self.id.cmp(&other.id))
135    }
136}
137
138fn canonicalize_json(value: &Value) -> Value {
139    match value {
140        Value::Object(map) => {
141            let mut keys: Vec<&String> = map.keys().collect();
142            keys.sort_unstable();
143
144            let mut canonical = Map::with_capacity(map.len());
145            for key in keys {
146                if let Some(inner) = map.get(key) {
147                    canonical.insert(key.clone(), canonicalize_json(inner));
148                }
149            }
150            Value::Object(canonical)
151        }
152        Value::Array(values) => Value::Array(values.iter().map(canonicalize_json).collect()),
153        _ => value.clone(),
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use serde_json::json;
161
162    #[test]
163    fn new_event_has_uuid_and_hash() {
164        let event = SyncEvent::new("order.created", "order", "ORD-1", json!({"total": 10}));
165        assert!(!event.id.is_nil());
166        assert!(!event.hash.is_empty());
167        assert_eq!(event.hash.len(), 64); // SHA-256 hex
168        assert_eq!(event.sequence, 0);
169        assert!(event.signature.is_none());
170    }
171
172    #[test]
173    fn event_serde_roundtrip() {
174        let event =
175            SyncEvent::new("product.updated", "product", "PROD-1", json!({"name": "Widget"}));
176        let json = serde_json::to_string(&event).unwrap();
177        let deserialized: SyncEvent = serde_json::from_str(&json).unwrap();
178        assert_eq!(deserialized.id, event.id);
179        assert_eq!(deserialized.event_type, event.event_type);
180        assert_eq!(deserialized.hash, event.hash);
181        assert_eq!(deserialized.payload, event.payload);
182    }
183
184    #[test]
185    fn event_with_sequence() {
186        let event = SyncEvent::new("order.created", "order", "ORD-1", json!({})).with_sequence(42);
187        assert_eq!(event.sequence, 42);
188    }
189
190    #[test]
191    fn event_with_signature() {
192        let event =
193            SyncEvent::new("order.created", "order", "ORD-1", json!({})).with_signature("deadbeef");
194        assert_eq!(event.signature, Some("deadbeef".to_string()));
195    }
196
197    #[test]
198    fn event_ordering_by_sequence() {
199        let e1 = SyncEvent::new("a", "x", "1", json!({})).with_sequence(1);
200        let e2 = SyncEvent::new("b", "x", "2", json!({})).with_sequence(2);
201        let e3 = SyncEvent::new("c", "x", "3", json!({})).with_sequence(3);
202
203        let mut events = vec![e3, e1, e2];
204        events.sort();
205        assert_eq!(events[0].sequence, 1);
206        assert_eq!(events[1].sequence, 2);
207        assert_eq!(events[2].sequence, 3);
208    }
209
210    #[test]
211    fn same_payload_same_hash() {
212        let payload = json!({"key": "value"});
213        let e1 = SyncEvent::new("a", "x", "1", payload.clone());
214        let e2 = SyncEvent::new("b", "y", "2", payload);
215        assert_eq!(e1.hash, e2.hash);
216    }
217
218    #[test]
219    fn different_payload_different_hash() {
220        let e1 = SyncEvent::new("a", "x", "1", json!({"key": "value1"}));
221        let e2 = SyncEvent::new("a", "x", "1", json!({"key": "value2"}));
222        assert_ne!(e1.hash, e2.hash);
223    }
224
225    #[test]
226    fn with_id_constructor() {
227        let id = Uuid::new_v4();
228        let ts = Utc::now();
229        let event = SyncEvent::with_id(id, 10, "order.created", "order", "ORD-1", json!({}), ts);
230        assert_eq!(event.id, id);
231        assert_eq!(event.sequence, 10);
232        assert_eq!(event.timestamp, ts);
233    }
234
235    #[test]
236    fn event_eq() {
237        let id = Uuid::new_v4();
238        let ts = Utc::now();
239        let e1 = SyncEvent::with_id(id, 1, "a", "b", "c", json!({}), ts);
240        let e2 = SyncEvent::with_id(id, 1, "a", "b", "c", json!({}), ts);
241        assert_eq!(e1, e2);
242    }
243
244    #[test]
245    fn event_debug() {
246        let event = SyncEvent::new("test", "entity", "id", json!({}));
247        let debug = format!("{event:?}");
248        assert!(debug.contains("SyncEvent"));
249    }
250
251    #[test]
252    fn compute_hash_deterministic() {
253        let payload = json!({"a": 1, "b": 2});
254        let h1 = SyncEvent::compute_hash(&payload);
255        let h2 = SyncEvent::compute_hash(&payload);
256        assert_eq!(h1, h2);
257    }
258
259    #[test]
260    fn compute_hash_is_canonical_for_object_key_order() {
261        let p1 = json!({"a": 1, "b": 2, "c": {"x": 1, "y": 2}});
262        let p2 = json!({"c": {"y": 2, "x": 1}, "b": 2, "a": 1});
263        assert_eq!(SyncEvent::compute_hash(&p1), SyncEvent::compute_hash(&p2));
264    }
265}