use std::fmt;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::common::{GenericValue, IndexName, SinkName};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Position(pub u64);
impl fmt::Display for Position {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Op {
Upsert,
Delete,
}
impl fmt::Display for Op {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Op::Upsert => "upsert",
Op::Delete => "delete",
})
}
}
pub const ENVELOPE_VERSION: u8 = 1;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Envelope<D = GenericValue> {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sink: Option<SinkName>,
pub version: u8,
pub ts: DateTime<Utc>,
#[serde(
rename = "seq",
default,
skip_serializing_if = "Option::is_none",
with = "seq_string"
)]
pub position: Option<Position>,
pub index: IndexName,
pub op: Op,
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meta: Option<EnvelopeMeta>,
#[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
pub document: Option<D>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnvelopeMeta {
pub fields: Option<usize>,
pub bytes: usize,
}
impl<D> Envelope<D> {
pub fn map_document<E>(self, f: impl FnOnce(D) -> E) -> Envelope<E> {
Envelope {
sink: self.sink,
version: self.version,
ts: self.ts,
position: self.position,
index: self.index,
op: self.op,
id: self.id,
meta: self.meta,
document: self.document.map(f),
}
}
}
mod seq_string {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use super::Position;
pub(super) fn serialize<S: Serializer>(
position: &Option<Position>,
serializer: S,
) -> Result<S::Ok, S::Error> {
position
.map(|position| position.0.to_string())
.serialize(serializer)
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<Position>, D::Error> {
Option::<String>::deserialize(deserializer)?
.map(|raw| {
raw.parse::<u64>()
.map(Position)
.map_err(serde::de::Error::custom)
})
.transpose()
}
}
impl Envelope {
pub fn upsert(
index: IndexName,
id: impl Into<String>,
document: GenericValue,
position: Option<Position>,
ts: DateTime<Utc>,
) -> Self {
Self {
sink: None,
version: ENVELOPE_VERSION,
ts,
position,
index,
op: Op::Upsert,
id: id.into(),
meta: None,
document: Some(document),
}
}
pub fn delete(
index: IndexName,
id: impl Into<String>,
position: Option<Position>,
ts: DateTime<Utc>,
) -> Self {
Self {
sink: None,
version: ENVELOPE_VERSION,
ts,
position,
index,
op: Op::Delete,
id: id.into(),
meta: None,
document: None,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn envelope_round_trips_through_json() {
let ts = DateTime::parse_from_rfc3339("2026-06-03T10:20:30.123Z")
.unwrap()
.with_timezone(&Utc);
let envelope = Envelope::delete(
IndexName::try_new("users").unwrap(),
"7",
Some(Position(9)),
ts,
);
let json = serde_json::to_value(&envelope).unwrap();
assert_eq!(json["op"], "delete");
assert_eq!(
json["seq"], "9",
"the position is an opaque string on the wire"
);
assert_eq!(json["ts"], "2026-06-03T10:20:30.123Z");
assert!(json.get("document").is_none());
assert!(json.get("sink").is_none());
assert!(json.get("meta").is_none());
let back: Envelope = serde_json::from_value(json).unwrap();
assert_eq!(back, envelope);
}
#[test]
fn snapshot_envelopes_carry_no_position() {
let ts = DateTime::parse_from_rfc3339("2026-06-03T10:20:30Z")
.unwrap()
.with_timezone(&Utc);
let envelope = Envelope::upsert(
IndexName::try_new("users").unwrap(),
"1",
GenericValue::Null,
None,
ts,
);
let json = serde_json::to_value(&envelope).unwrap();
assert!(json.get("seq").is_none());
assert_eq!(json["version"], ENVELOPE_VERSION);
}
#[test]
fn a_consumer_reads_the_wire_shape_over_its_own_document_type() {
let ts = DateTime::parse_from_rfc3339("2026-06-03T10:20:30Z")
.unwrap()
.with_timezone(&Utc);
let mut envelope = Envelope::upsert(
IndexName::try_new("users").unwrap(),
"1",
GenericValue::Map(Default::default()),
Some(Position(3)),
ts,
);
envelope.sink = Some(SinkName::try_new("audit").unwrap());
let wire = envelope.map_document(|_| serde_json::json!({ "email": "ada@x.io" }));
let json = serde_json::to_string(&wire).unwrap();
#[derive(Debug, PartialEq, serde::Deserialize)]
struct User {
email: String,
}
let back: Envelope<User> = serde_json::from_str(&json).unwrap();
assert_eq!(back.sink.as_ref().map(AsRef::as_ref), Some("audit"));
assert_eq!(back.position, Some(Position(3)));
assert_eq!(back.document.unwrap().email, "ada@x.io");
}
}