Skip to main content

outbox_core/
object.rs

1//! Newtype wrappers for the fields stored on an [`Event`](crate::model::Event).
2//!
3//! Each type hides its underlying representation and exposes a narrow,
4//! intention-revealing API. Under the `sqlx` feature every newtype derives a
5//! transparent `sqlx::Type`, so they round-trip through database columns
6//! without additional conversion code.
7
8use serde::{Deserialize, Serialize};
9use std::fmt::{Debug, Display, Formatter};
10use uuid::Uuid;
11
12/// Primary key of an outbox row.
13///
14/// Wraps a [`Uuid`] so that event identifiers are not confused with other
15/// UUID-valued columns. [`Default`] produces a fresh random v4 identifier.
16#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
17#[cfg_attr(feature = "sqlx", sqlx(transparent))]
18#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
19pub struct EventId(Uuid);
20impl Default for EventId {
21    /// Generates a fresh random identifier (UUID v4).
22    fn default() -> Self {
23        Self(Uuid::new_v4())
24    }
25}
26impl EventId {
27    /// Wraps an existing [`Uuid`] — typically used by storage adapters when
28    /// hydrating an [`Event`](crate::model::Event) from a database row.
29    #[must_use]
30    pub fn load(id: Uuid) -> Self {
31        Self(id)
32    }
33    /// Returns the underlying [`Uuid`] for use with APIs that need one
34    /// (e.g. logging or foreign-key references).
35    #[must_use]
36    pub fn as_uuid(&self) -> Uuid {
37        self.0
38    }
39}
40
41/// Deduplication token attached to an [`Event`](crate::model::Event).
42///
43/// Produced by the configured
44/// [`IdempotencyStrategy`](crate::config::IdempotencyStrategy) and, when an
45/// [`IdempotencyStorageProvider`](crate::idempotency::storage::IdempotencyStorageProvider)
46/// is wired, used to reserve uniqueness before the event is written.
47/// Accepts arbitrary strings — interpretation is left entirely to the caller.
48#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
49#[cfg_attr(feature = "sqlx", sqlx(transparent))]
50#[derive(Debug, Clone)]
51pub struct IdempotencyToken(pub String);
52impl IdempotencyToken {
53    /// Wraps a string as a token.
54    #[must_use]
55    pub fn new(token: String) -> Self {
56        Self(token)
57    }
58    /// Returns the token as a `&str` for comparison or logging.
59    #[must_use]
60    pub fn as_str(&self) -> &str {
61        &self.0
62    }
63    /// Returns the raw bytes of the token — convenient for hashing backends
64    /// such as Redis keys or BLAKE3.
65    #[must_use]
66    pub fn as_bytes(&self) -> &[u8] {
67        self.0.as_bytes()
68    }
69}
70
71/// Domain-level event name used by transports for routing (Kafka topic
72/// suffix, Redis stream key, etc).
73#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
74#[cfg_attr(feature = "sqlx", sqlx(transparent))]
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct EventType(String);
77impl EventType {
78    /// Creates an [`EventType`] from a borrowed string slice.
79    ///
80    /// Use this at call sites that produce an event — e.g. `"order.created"`.
81    #[must_use]
82    pub fn new(event_type: &str) -> Self {
83        Self(event_type.to_string())
84    }
85    /// Alternate constructor used by storage adapters when hydrating an
86    /// [`Event`](crate::model::Event) from a row. Semantically identical to
87    /// [`new`](Self::new); the name signals intent on the read path.
88    #[must_use]
89    pub fn load(value: &str) -> Self {
90        Self(value.to_owned())
91    }
92    /// Returns the event type as a `&str`.
93    #[must_use]
94    pub fn as_str(&self) -> &str {
95        &self.0
96    }
97}
98impl Display for EventType {
99    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
100        f.write_str(&self.0)
101    }
102}
103
104/// Typed wrapper around the user's domain event value.
105///
106/// Marked with `#[serde(transparent)]`, so serialization produces exactly the
107/// same JSON as the inner `T` — wrapping an existing type in [`Payload`] does
108/// not change its on-the-wire representation.
109#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
110#[cfg_attr(feature = "sqlx", sqlx(transparent))]
111#[derive(Debug, Clone, Serialize, Deserialize)]
112#[serde(transparent)]
113pub struct Payload<T>(T);
114impl<T> Payload<T>
115where
116    T: Debug + Clone + Serialize + Send + Sync,
117{
118    /// Wraps an owned payload value.
119    #[must_use]
120    pub fn new(payload: T) -> Self {
121        Self(payload)
122    }
123    /// Wraps a payload by cloning from a borrowed reference.
124    ///
125    /// Convenient when the caller needs to keep ownership of the original
126    /// value (for logging, further processing, etc.).
127    #[must_use]
128    pub fn from_ref(value: &T) -> Self {
129        Self(value.clone())
130    }
131    /// Returns a borrowed reference to the inner payload.
132    #[must_use]
133    pub fn as_value(&self) -> &T {
134        &self.0
135    }
136}
137
138#[cfg(test)]
139#[allow(clippy::unwrap_used)]
140mod tests {
141    use super::*;
142    use rstest::rstest;
143
144    // ---------------- EventId ----------------
145
146    #[rstest]
147    fn event_id_default_generates_unique_uuids_across_calls() {
148        let a = EventId::default();
149        let b = EventId::default();
150        assert_ne!(a, b);
151        assert_ne!(a.as_uuid(), b.as_uuid());
152    }
153
154    #[rstest]
155    fn event_id_load_preserves_inner_uuid() {
156        let uuid = Uuid::new_v4();
157        let id = EventId::load(uuid);
158        assert_eq!(id.as_uuid(), uuid);
159    }
160
161    #[rstest]
162    fn event_id_equality_reflects_inner_uuid() {
163        let uuid = Uuid::new_v4();
164        let a = EventId::load(uuid);
165        let b = EventId::load(uuid);
166        assert_eq!(a, b);
167        // Copy: using a after copy must not move it.
168        let copied = a;
169        assert_eq!(copied, a);
170    }
171
172    #[rstest]
173    fn event_id_default_is_v4() {
174        let id = EventId::default();
175        assert_eq!(id.as_uuid().get_version_num(), 4);
176    }
177
178    // ------------- IdempotencyToken -------------
179
180    #[rstest]
181    #[case("abc")]
182    #[case("")]
183    #[case("with spaces and 🦀")]
184    fn idempotency_token_new_preserves_string(#[case] raw: &str) {
185        let tok = IdempotencyToken::new(raw.to_string());
186        assert_eq!(tok.as_str(), raw);
187    }
188
189    #[rstest]
190    fn idempotency_token_as_bytes_matches_as_str_bytes() {
191        let tok = IdempotencyToken::new("hello".into());
192        assert_eq!(tok.as_bytes(), "hello".as_bytes());
193        assert_eq!(tok.as_bytes(), tok.as_str().as_bytes());
194    }
195
196    // ---------------- EventType ----------------
197
198    #[rstest]
199    fn event_type_new_preserves_str() {
200        let et = EventType::new("order.created");
201        assert_eq!(et.as_str(), "order.created");
202    }
203
204    #[rstest]
205    fn event_type_load_preserves_str() {
206        let et = EventType::load("order.created");
207        assert_eq!(et.as_str(), "order.created");
208    }
209
210    #[rstest]
211    fn event_type_new_and_load_produce_equal_string_views() {
212        let a = EventType::new("x");
213        let b = EventType::load("x");
214        assert_eq!(a.as_str(), b.as_str());
215    }
216
217    #[rstest]
218    fn event_type_display_matches_as_str() {
219        let et = EventType::new("payment.settled");
220        assert_eq!(format!("{et}"), et.as_str());
221    }
222
223    // ---------------- Payload ----------------
224
225    #[rstest]
226    fn payload_new_preserves_value() {
227        let p = Payload::new(42i32);
228        assert_eq!(*p.as_value(), 42);
229    }
230
231    #[rstest]
232    fn payload_from_ref_clones_without_consuming_source() {
233        let source = String::from("keep-me");
234        let p = Payload::from_ref(&source);
235        assert_eq!(p.as_value(), &source);
236        // Source is still usable — from_ref must clone, not move.
237        assert_eq!(source, "keep-me");
238    }
239
240    #[rstest]
241    fn payload_serde_is_transparent_over_inner() {
242        #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
243        struct Inner {
244            a: u32,
245            b: String,
246        }
247
248        let inner = Inner {
249            a: 7,
250            b: "x".into(),
251        };
252        let wrapped = Payload::new(inner.clone());
253
254        let inner_json = serde_json::to_string(&inner).unwrap();
255        let wrapped_json = serde_json::to_string(&wrapped).unwrap();
256        assert_eq!(inner_json, wrapped_json);
257    }
258
259    #[rstest]
260    fn payload_deserialize_is_transparent_over_inner() {
261        #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
262        struct Inner {
263            a: u32,
264        }
265
266        let json = r#"{"a":9}"#;
267        let p: Payload<Inner> = serde_json::from_str(json).unwrap();
268        assert_eq!(*p.as_value(), Inner { a: 9 });
269    }
270}