Timestamp Conversions

SBE represents timestamps as uint64 wire fields with a semanticType attribute (UTCTimestamp = nanoseconds, UTCTimestampMicros = microseconds, UTCTimestampMillis = milliseconds). The chrono feature (see Feature Integrations) converts these to chrono::DateTime<Utc> and chrono::NaiveDateTime with one line of config.

Nanoseconds and microseconds — built-in

Enable the chrono feature in your Cargo.toml:

[dependencies]
ergo-sbe = { version = "0.1", features = ["chrono"] }
chrono = "0.4"

Then register the converters by semanticType in build.rs:

use ergo_sbe::{ConversionSelector, GenerationConfig};

let config = GenerationConfig::new("msgs")
    .with_domain_type(
        ConversionSelector::semantic_type("UTCTimestamp"),
        "chrono::DateTime<chrono::Utc>",
    )
    .with_domain_type(
        ConversionSelector::semantic_type("UTCTimestampMicros"),
        "chrono::NaiveDateTime",
    );

Schema:

<field name="created_at"  id="1" type="uint64" semanticType="UTCTimestamp"/>
<field name="updated_at"  id="2" type="uint64" semanticType="UTCTimestampMicros"/>

Generated API — try_created_at() returns DateTime<Utc>, try_updated_at() returns NaiveDateTime:

// Decode
let created: chrono::DateTime<chrono::Utc> = dec.try_created_at()?;
let updated: chrono::NaiveDateTime = dec.try_updated_at()?;

// Encode
enc.try_created_at(chrono::Utc::now())?;
enc.try_updated_at(chrono::NaiveDateTime::from_timestamp_micros(1_720_000_000_000_000).unwrap())?;

Conversion cost: 2.8 ns (nanos → DateTime), 5.5 ns (micros → NaiveDateTime). See the measured benchmarks.

One selector, many fields

ConversionSelector::semantic_type(..) matches every field in the schema carrying that semanticType — not just one. You register the conversion once, not once per field. A schema with three separate UTCTimestamp timestamps needs no more config than one with a single field:

<field name="createdAt"  id="1" type="uint64" semanticType="UTCTimestamp"/>
<field name="updatedAt"  id="2" type="uint64" semanticType="UTCTimestamp"/>
<field name="expiresAt"  id="3" type="uint64" semanticType="UTCTimestamp"/>
// Same one-time call as the two-field example above — no per-field repeats.
let config = GenerationConfig::new("msgs")
    .with_domain_type(
        ConversionSelector::semantic_type("UTCTimestamp"),
        "chrono::DateTime<chrono::Utc>",
    );

All three fields get their own concrete accessor, generated from that one call:

enc.try_created_at(chrono::Utc::now())?;
enc.try_updated_at(chrono::Utc::now())?;
enc.try_expires_at(chrono::Utc::now())?;

let created: chrono::DateTime<chrono::Utc> = dec.try_created_at()?;
let updated: chrono::DateTime<chrono::Utc> = dec.try_updated_at()?;
let expires: chrono::DateTime<chrono::Utc> = dec.try_expires_at()?;

This is why UTCTimestamp and UTCTimestampMicros need separate with_domain_type calls in the two-field example above — they're different semanticType strings, so they're different selectors — but adding a fourth UTCTimestamp field to the same schema needs no config change at all. ConversionSelector::field_path is the escape hatch when one specific field needs to differ from its semantic-type siblings.

Mixed precisions, one app type — DomainImpl::Manual

The built-in converters give nanos → DateTime<Utc> and micros → NaiveDateTime — two different app types, because that's what the built-in impls happen to produce. Real schemas often need the opposite: three fields at three different wire precisions, all normalized to the same DateTime<Utc> so downstream app code never branches on precision. None of the three is the bare uint64 + semanticType="UTCTimestamp" shape the built-in converter matches, so none gets an auto-generated impl — that's exactly the case DomainImpl::Manual is for: concrete try_* signatures from ergo-sbe, conversion body from you.

Distinguish the three precisions with single-element composites (a distinct Rust type per precision — TimestampMillis(u64) is not TimestampNanos(u64) even though the wire shape is identical):

<composite name="TimestampMillis"><type name="ts" primitiveType="uint64"/></composite>
<composite name="TimestampMicros"><type name="ts" primitiveType="uint64"/></composite>
<composite name="TimestampNanos"><type name="ts" primitiveType="uint64"/></composite>

<field name="createdAt" id="1" type="TimestampNanos"/>
<field name="updatedAt" id="2" type="TimestampMicros"/>
<field name="deletedAt" id="3" type="TimestampMillis"/>

One with_manual_domain_type(selector, path) call per composite — same target type, rust_decimal-style — in build.rs:

use ergo_sbe::{ConversionSelector, GenerationConfig};

let config = GenerationConfig::new("msgs")
    .with_manual_domain_type(
        ConversionSelector::named_type("TimestampNanos"),
        "chrono::DateTime<chrono::Utc>",
    )
    .with_manual_domain_type(
        ConversionSelector::named_type("TimestampMicros"),
        "chrono::DateTime<chrono::Utc>",
    )
    .with_manual_domain_type(
        ConversionSelector::named_type("TimestampMillis"),
        "chrono::DateTime<chrono::Utc>",
    );

Each composite is a genuinely new named type, so there is no built-in template to offer — write the three impls yourself, one per precision (the traits live in the generated module as sbe_rt::TryFromSbe / sbe_rt::TryToSbe; import from there, not from ergo_sbe::codegen, which is crate-private):

// my_msgs is the module name passed to GenerationConfig::new("my_msgs")
use my_msgs::sbe_rt::{TryFromSbe, TryToSbe};
use my_msgs::{TimestampMillis, TimestampMicros, TimestampNanos};

impl TryFromSbe<TimestampNanos> for chrono::DateTime<chrono::Utc> {
    type Error = &'static str;
    fn try_from_sbe(wire: TimestampNanos) -> Result<Self, Self::Error> {
        let ns = wire.0; // single-element composite is a transparent wrapper
        chrono::DateTime::from_timestamp((ns / 1_000_000_000) as i64, (ns % 1_000_000_000) as u32)
            .ok_or("timestamp out of range")
    }
}
impl TryToSbe<TimestampNanos> for chrono::DateTime<chrono::Utc> {
    type Error = &'static str;
    fn try_to_sbe(&self) -> Result<TimestampNanos, Self::Error> {
        Ok(TimestampNanos(self.timestamp_nanos_opt().ok_or("overflow")? as u64))
    }
}

impl TryFromSbe<TimestampMicros> for chrono::DateTime<chrono::Utc> {
    type Error = &'static str;
    fn try_from_sbe(wire: TimestampMicros) -> Result<Self, Self::Error> {
        let us = wire.0;
        chrono::DateTime::from_timestamp((us / 1_000_000) as i64, ((us % 1_000_000) * 1_000) as u32)
            .ok_or("timestamp out of range")
    }
}
impl TryToSbe<TimestampMicros> for chrono::DateTime<chrono::Utc> {
    type Error = &'static str;
    fn try_to_sbe(&self) -> Result<TimestampMicros, Self::Error> {
        Ok(TimestampMicros(self.timestamp_micros() as u64))
    }
}

impl TryFromSbe<TimestampMillis> for chrono::DateTime<chrono::Utc> {
    type Error = &'static str;
    fn try_from_sbe(wire: TimestampMillis) -> Result<Self, Self::Error> {
        let ms = wire.0;
        chrono::DateTime::from_timestamp((ms / 1000) as i64, ((ms % 1000) * 1_000_000) as u32)
            .ok_or("timestamp out of range")
    }
}
impl TryToSbe<TimestampMillis> for chrono::DateTime<chrono::Utc> {
    type Error = &'static str;
    fn try_to_sbe(&self) -> Result<TimestampMillis, Self::Error> {
        Ok(TimestampMillis(self.timestamp_millis() as u64))
    }
}

All three fields now return the exact same app type despite three different wire precisions — the caller never has to know or care which precision a given field was wire-encoded at:

let created: chrono::DateTime<chrono::Utc> = dec.try_created_at()?;
let updated: chrono::DateTime<chrono::Utc> = dec.try_updated_at()?;
let deleted: chrono::DateTime<chrono::Utc> = dec.try_deleted_at()?;

enc.try_created_at(chrono::Utc::now())?;
enc.try_updated_at(chrono::Utc::now())?;
enc.try_deleted_at(chrono::Utc::now())?;

If you forget one of the three impls, the compile error names it directly — `chrono::DateTime<Utc>` has no `TryFromSbe<TimestampMillis>` impl — instead of the default trait-bound message.