Skip to main content

spate_datagen/
events.rs

1//! The storefront event model: what a generated record *is*.
2//!
3//! Three events over one entity. An order is placed, its payment is captured,
4//! and some of those payments are refunded, so a pipeline built on this
5//! stream has a join to make, a sum to check, and a late-arriving reference to
6//! handle.
7//!
8//! # Why the string fields are `Cow<'static, str>`
9//!
10//! Generation borrows: a region or a SKU is an entry in [`crate::dims`], so
11//! producing an event copies no bytes. Consumption owns: an example decodes
12//! these types back out of JSON with `build_serde::<StorefrontEvent>()`, and
13//! `&'static str` has no `Deserialize` impl to decode *into*. `Cow` is the one
14//! spelling that serves both, with `Cow::Borrowed` on the way out and
15//! `Cow::Owned` on the way back in. `PartialEq` compares the strings either way, so a
16//! round-trip test can assert equality against the value that was generated.
17
18use serde::{Deserialize, Serialize};
19use std::borrow::Cow;
20
21/// The Avro schema of a [`StorefrontEvent`], as JSON.
22///
23/// A top-level union of the three record types, the idiomatic Avro spelling
24/// of a sum type, and what `encoding: avro` writes a bare datum against. The
25/// JSON encoding tags the same three shapes with a `type` field instead;
26/// neither derives from the other, so this constant is what a downstream
27/// reader is pinned to.
28pub const EVENT_SCHEMA_JSON: &str = r#"[
29  {
30    "type": "record",
31    "name": "OrderPlaced",
32    "namespace": "spate.datagen",
33    "fields": [
34      {"name": "order_id", "type": "long"},
35      {"name": "customer_id", "type": "int"},
36      {"name": "region", "type": "string"},
37      {"name": "placed_at", "type": {"type": "long", "logicalType": "timestamp-millis"}},
38      {"name": "lines", "type": {"type": "array", "items": {
39        "type": "record",
40        "name": "OrderLine",
41        "fields": [
42          {"name": "sku", "type": "string"},
43          {"name": "qty", "type": "int"},
44          {"name": "unit_cents", "type": "int"}
45        ]
46      }}}
47    ]
48  },
49  {
50    "type": "record",
51    "name": "PaymentCaptured",
52    "namespace": "spate.datagen",
53    "fields": [
54      {"name": "order_id", "type": "long"},
55      {"name": "amount_cents", "type": "long"}
56    ]
57  },
58  {
59    "type": "record",
60    "name": "RefundIssued",
61    "namespace": "spate.datagen",
62    "fields": [
63      {"name": "order_id", "type": "long"},
64      {"name": "amount_cents", "type": "long"},
65      {"name": "reason", "type": "string"}
66    ]
67  }
68]"#;
69
70/// One event in the storefront stream, tagged by `type` in the encoded
71/// payload (`order_placed`, `payment_captured`, `refund_issued`).
72#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
73#[serde(tag = "type", rename_all = "snake_case")]
74#[non_exhaustive]
75pub enum StorefrontEvent {
76    /// A customer placed an order.
77    OrderPlaced(OrderPlaced),
78    /// The payment for an order that was already placed came through.
79    PaymentCaptured(PaymentCaptured),
80    /// Part or all of a captured payment was refunded.
81    RefundIssued(RefundIssued),
82}
83
84impl StorefrontEvent {
85    /// The order this event is about. Every event in the stream carries one,
86    /// which is what makes the whole stream partitionable by order.
87    #[must_use]
88    pub fn order_id(&self) -> u64 {
89        match self {
90            StorefrontEvent::OrderPlaced(e) => e.order_id,
91            StorefrontEvent::PaymentCaptured(e) => e.order_id,
92            StorefrontEvent::RefundIssued(e) => e.order_id,
93        }
94    }
95}
96
97/// A new order, with between one and five lines.
98#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
99#[non_exhaustive]
100pub struct OrderPlaced {
101    /// Unique within the whole stream: lanes own disjoint id slices, so no
102    /// two lanes can ever mint the same order.
103    pub order_id: u64,
104    /// Which customer placed it, in `0..`[`CUSTOMERS`](crate::CUSTOMERS).
105    pub customer_id: u32,
106    /// One of [`REGIONS`](crate::REGIONS).
107    pub region: Cow<'static, str>,
108    /// Event time, milliseconds since the Unix epoch.
109    pub placed_at: i64,
110    /// One to five lines; the order's total is their `qty × unit_cents`.
111    pub lines: Vec<OrderLine>,
112}
113
114/// One line of an order.
115#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
116#[non_exhaustive]
117pub struct OrderLine {
118    /// One of [`SKUS`](crate::SKUS).
119    pub sku: Cow<'static, str>,
120    /// Units ordered, at least one.
121    pub qty: u32,
122    /// List price of a single unit, in cents.
123    pub unit_cents: u32,
124}
125
126/// The payment for an order, always preceded in the same partition by the
127/// [`OrderPlaced`] it names.
128#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
129#[non_exhaustive]
130pub struct PaymentCaptured {
131    /// The order being paid for.
132    pub order_id: u64,
133    /// The order's line total. A downstream sum over [`OrderPlaced::lines`]
134    /// must reproduce it.
135    pub amount_cents: u64,
136}
137
138/// A refund against a payment that was already captured.
139#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
140#[non_exhaustive]
141pub struct RefundIssued {
142    /// The order being refunded.
143    pub order_id: u64,
144    /// Never more than the captured amount; often a partial refund.
145    pub amount_cents: u64,
146    /// A short, bounded-cardinality reason string.
147    pub reason: Cow<'static, str>,
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    fn placed() -> StorefrontEvent {
155        StorefrontEvent::OrderPlaced(OrderPlaced {
156            order_id: 12,
157            customer_id: 7,
158            region: Cow::Borrowed("eu-west"),
159            placed_at: 1_767_225_600_000,
160            lines: vec![OrderLine {
161                sku: Cow::Borrowed("KBD-01"),
162                qty: 2,
163                unit_cents: 7_900,
164            }],
165        })
166    }
167
168    /// The encoded shape is a contract: a demo pipeline's YAML, a ClickHouse
169    /// column list and a docs page all name these keys.
170    #[test]
171    fn the_json_encoding_is_internally_tagged_snake_case() {
172        let json = serde_json::to_string(&placed()).unwrap();
173        assert!(
174            json.starts_with(r#"{"type":"order_placed","order_id":12"#),
175            "{json}"
176        );
177
178        let refund = StorefrontEvent::RefundIssued(RefundIssued {
179            order_id: 12,
180            amount_cents: 500,
181            reason: Cow::Borrowed("damaged"),
182        });
183        assert_eq!(
184            serde_json::to_string(&refund).unwrap(),
185            r#"{"type":"refund_issued","order_id":12,"amount_cents":500,"reason":"damaged"}"#
186        );
187    }
188
189    /// What an example does: decode the generated bytes back into these types.
190    /// Borrowed-out, owned-back-in has to compare equal, or the `Cow` choice
191    /// documented above would be buying nothing.
192    #[test]
193    fn every_variant_round_trips_through_json() {
194        for event in [
195            placed(),
196            StorefrontEvent::PaymentCaptured(PaymentCaptured {
197                order_id: 12,
198                amount_cents: 15_800,
199            }),
200            StorefrontEvent::RefundIssued(RefundIssued {
201                order_id: 12,
202                amount_cents: 500,
203                reason: Cow::Borrowed("damaged"),
204            }),
205        ] {
206            let bytes = serde_json::to_vec(&event).unwrap();
207            let back: StorefrontEvent = serde_json::from_slice(&bytes).unwrap();
208            assert_eq!(back, event, "round trip changed the value");
209            assert_eq!(back.order_id(), 12);
210        }
211    }
212
213    #[test]
214    fn an_unknown_tag_is_a_decode_error_rather_than_a_silent_drop() {
215        let err = serde_json::from_str::<StorefrontEvent>(r#"{"type":"order_shipped"}"#)
216            .expect_err("an unmodelled event type must not decode");
217        assert!(err.to_string().contains("order_shipped"), "{err}");
218    }
219}