Skip to main content

kernel/
envelope.rs

1//! What flows through the stream: the position in the source's change feed
2//! and the envelope a lane carries for one document.
3//!
4//! A [`Position`] is assigned by the source, monotonic for the life of the
5//! source instance (across reopened streams, so a restarted ingest engine
6//! never reuses one), and meaningful only to the source that issued it (the Postgres adapter maps it
7//! back to an LSN). The stream orders positions to compute its watermark; the
8//! ingest engine hands the watermark back to the source as confirmation.
9//!
10//! An [`Envelope`] is one document operation as every sink receives it: the
11//! index, the operation, the document id, the document itself (absent on a
12//! delete), the position of the change that produced it (absent for a snapshot
13//! row, `seq` on the wire), when it was built, and — stamped on the sink side —
14//! the emitting sink's name and a `meta` summary. Emitting sinks forward it
15//! as-is; a consumer deserializes the same type over its own document type.
16//!
17//! ```
18//! use kernel::{Envelope, Op, Position};
19//!
20//! let position = Position(42);
21//! assert_eq!(position.to_string(), "42");
22//! assert!(Position(41) < position);
23//!
24//! let json = serde_json::to_string(&Op::Upsert).unwrap();
25//! assert_eq!(json, "\"upsert\"");
26//! ```
27
28use std::fmt;
29
30use chrono::{DateTime, Utc};
31use serde::{Deserialize, Serialize};
32
33use crate::common::{GenericValue, IndexName, SinkName};
34
35/// An offset in the source's change feed: opaque to everyone but the source,
36/// ordered so the stream can take a minimum across lanes.
37#[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/// What happened to the document.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum Op {
51    /// The document was inserted or replaced; `document` carries its state.
52    Upsert,
53    /// The document no longer exists at the source.
54    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
66/// The envelope format version, bumped when a field changes meaning.
67pub const ENVELOPE_VERSION: u8 = 1;
68
69/// One document operation as a lane carries it and a sink receives it.
70///
71/// Generic over the document so one shape serves the pipeline
72/// (`Envelope<GenericValue>`, the default) and a consumer of an emitting sink's
73/// output (`Envelope<MyDocument>` deserialized from the same JSON). On the
74/// wire the position is `seq`, an opaque string; `sink` and `meta` are set by
75/// the sink side and absent inside the pipeline.
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77pub struct Envelope<D = GenericValue> {
78    /// The emitting sink's name, filled by that sink as it writes the envelope
79    /// out; absent inside the pipeline, where one batch is shared by every sink.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub sink: Option<SinkName>,
82    /// [`ENVELOPE_VERSION`] at the time of writing.
83    pub version: u8,
84    /// When the ingest engine built this document.
85    pub ts: DateTime<Utc>,
86    /// The position of the change that produced it; `None` for a snapshot row.
87    /// Serialized as `seq`, the position rendered as an opaque string.
88    #[serde(
89        rename = "seq",
90        default,
91        skip_serializing_if = "Option::is_none",
92        with = "seq_string"
93    )]
94    pub position: Option<Position>,
95    /// The logical index the document belongs to.
96    pub index: IndexName,
97    /// Upsert or delete.
98    pub op: Op,
99    /// The document id within the index: the root primary key as a string.
100    pub id: String,
101    /// A summary of the serialized document, filled by an emitting sink (it
102    /// knows the wire format); absent inside the pipeline and on a delete.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub meta: Option<EnvelopeMeta>,
105    /// The built document; absent on a delete.
106    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
107    pub document: Option<D>,
108}
109
110/// At-a-glance facts about a serialized document.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
112pub struct EnvelopeMeta {
113    /// Top-level field count; `None` when the document is not an object.
114    pub fields: Option<usize>,
115    /// Size of the compact serialized document, in bytes.
116    pub bytes: usize,
117}
118
119impl<D> Envelope<D> {
120    /// The same envelope over a document of another type: how an emitting sink
121    /// translates the pipeline's value into its wire format.
122    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
137/// `seq` on the wire: the position as an opaque string.
138mod 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    /// An upsert of `document` under `id` in `index`.
167    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    /// A delete of `id` from `index`.
188    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}