1use std::fmt;
29
30use chrono::{DateTime, Utc};
31use serde::{Deserialize, Serialize};
32
33use crate::common::{GenericValue, IndexName, SinkName};
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
38#[serde(transparent)]
39pub struct Position(pub u64);
40
41impl fmt::Display for Position {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 write!(f, "{}", self.0)
44 }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum Op {
51 Upsert,
53 Delete,
55}
56
57impl fmt::Display for Op {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 f.write_str(match self {
60 Op::Upsert => "upsert",
61 Op::Delete => "delete",
62 })
63 }
64}
65
66pub const ENVELOPE_VERSION: u8 = 1;
68
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77pub struct Envelope<D = GenericValue> {
78 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub sink: Option<SinkName>,
82 pub version: u8,
84 pub ts: DateTime<Utc>,
86 #[serde(
89 rename = "seq",
90 default,
91 skip_serializing_if = "Option::is_none",
92 with = "seq_string"
93 )]
94 pub position: Option<Position>,
95 pub index: IndexName,
97 pub op: Op,
99 pub id: String,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub meta: Option<EnvelopeMeta>,
105 #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
107 pub document: Option<D>,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
112pub struct EnvelopeMeta {
113 pub fields: Option<usize>,
115 pub bytes: usize,
117}
118
119impl<D> Envelope<D> {
120 pub fn map_document<E>(self, f: impl FnOnce(D) -> E) -> Envelope<E> {
123 Envelope {
124 sink: self.sink,
125 version: self.version,
126 ts: self.ts,
127 position: self.position,
128 index: self.index,
129 op: self.op,
130 id: self.id,
131 meta: self.meta,
132 document: self.document.map(f),
133 }
134 }
135}
136
137mod seq_string {
139 use serde::{Deserialize, Deserializer, Serialize, Serializer};
140
141 use super::Position;
142
143 pub(super) fn serialize<S: Serializer>(
144 position: &Option<Position>,
145 serializer: S,
146 ) -> Result<S::Ok, S::Error> {
147 position
148 .map(|position| position.0.to_string())
149 .serialize(serializer)
150 }
151
152 pub(super) fn deserialize<'de, D: Deserializer<'de>>(
153 deserializer: D,
154 ) -> Result<Option<Position>, D::Error> {
155 Option::<String>::deserialize(deserializer)?
156 .map(|raw| {
157 raw.parse::<u64>()
158 .map(Position)
159 .map_err(serde::de::Error::custom)
160 })
161 .transpose()
162 }
163}
164
165impl Envelope {
166 pub fn upsert(
168 index: IndexName,
169 id: impl Into<String>,
170 document: GenericValue,
171 position: Option<Position>,
172 ts: DateTime<Utc>,
173 ) -> Self {
174 Self {
175 sink: None,
176 version: ENVELOPE_VERSION,
177 ts,
178 position,
179 index,
180 op: Op::Upsert,
181 id: id.into(),
182 meta: None,
183 document: Some(document),
184 }
185 }
186
187 pub fn delete(
189 index: IndexName,
190 id: impl Into<String>,
191 position: Option<Position>,
192 ts: DateTime<Utc>,
193 ) -> Self {
194 Self {
195 sink: None,
196 version: ENVELOPE_VERSION,
197 ts,
198 position,
199 index,
200 op: Op::Delete,
201 id: id.into(),
202 meta: None,
203 document: None,
204 }
205 }
206}
207
208#[cfg(test)]
209#[allow(clippy::unwrap_used)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn envelope_round_trips_through_json() {
215 let ts = DateTime::parse_from_rfc3339("2026-06-03T10:20:30.123Z")
216 .unwrap()
217 .with_timezone(&Utc);
218 let envelope = Envelope::delete(
219 IndexName::try_new("users").unwrap(),
220 "7",
221 Some(Position(9)),
222 ts,
223 );
224 let json = serde_json::to_value(&envelope).unwrap();
225 assert_eq!(json["op"], "delete");
226 assert_eq!(
227 json["seq"], "9",
228 "the position is an opaque string on the wire"
229 );
230 assert_eq!(json["ts"], "2026-06-03T10:20:30.123Z");
231 assert!(json.get("document").is_none());
232 assert!(json.get("sink").is_none());
233 assert!(json.get("meta").is_none());
234 let back: Envelope = serde_json::from_value(json).unwrap();
235 assert_eq!(back, envelope);
236 }
237
238 #[test]
239 fn snapshot_envelopes_carry_no_position() {
240 let ts = DateTime::parse_from_rfc3339("2026-06-03T10:20:30Z")
241 .unwrap()
242 .with_timezone(&Utc);
243 let envelope = Envelope::upsert(
244 IndexName::try_new("users").unwrap(),
245 "1",
246 GenericValue::Null,
247 None,
248 ts,
249 );
250 let json = serde_json::to_value(&envelope).unwrap();
251 assert!(json.get("seq").is_none());
252 assert_eq!(json["version"], ENVELOPE_VERSION);
253 }
254
255 #[test]
256 fn a_consumer_reads_the_wire_shape_over_its_own_document_type() {
257 let ts = DateTime::parse_from_rfc3339("2026-06-03T10:20:30Z")
258 .unwrap()
259 .with_timezone(&Utc);
260 let mut envelope = Envelope::upsert(
261 IndexName::try_new("users").unwrap(),
262 "1",
263 GenericValue::Map(Default::default()),
264 Some(Position(3)),
265 ts,
266 );
267 envelope.sink = Some(SinkName::try_new("audit").unwrap());
268 let wire = envelope.map_document(|_| serde_json::json!({ "email": "ada@x.io" }));
269 let json = serde_json::to_string(&wire).unwrap();
270
271 #[derive(Debug, PartialEq, serde::Deserialize)]
272 struct User {
273 email: String,
274 }
275 let back: Envelope<User> = serde_json::from_str(&json).unwrap();
276 assert_eq!(back.sink.as_ref().map(AsRef::as_ref), Some("audit"));
277 assert_eq!(back.position, Some(Position(3)));
278 assert_eq!(back.document.unwrap().email, "ada@x.io");
279 }
280}