wire types: WireTime newtype for timestamps instead of adaptor-annotated i64

This commit is contained in:
damocles 2026-07-03 19:18:33 +02:00 committed by mara
commit 1e205289c5
12 changed files with 148 additions and 162 deletions

View file

@ -1,5 +1,6 @@
//! Wire types shared between `hive-c0re` and the in-container harness.
use crate::wire_time::WireTime;
use serde::{Deserialize, Serialize};
pub mod assets;
@ -209,15 +210,10 @@ pub struct Approval {
/// hive-c0re refreshes this + re-renders the card for re-review.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fetched_sha: Option<String>,
#[serde(with = "crate::wire_time::iso")]
pub requested_at: i64,
pub requested_at: WireTime,
pub status: ApprovalStatus,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::wire_time::iso_opt"
)]
pub resolved_at: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_at: Option<WireTime>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
/// Free-text description the manager attached at submission time;
@ -453,8 +449,7 @@ pub enum LooseEnd {
id: i64,
owner: String,
message: String,
#[serde(with = "crate::wire_time::iso")]
due_at: i64,
due_at: WireTime,
age_seconds: u64,
},
/// Undelivered inbox messages waiting to be `recv`'d by this agent.
@ -1419,27 +1414,17 @@ pub struct WireSchedule {
pub body: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interval_seconds: Option<u64>,
#[serde(with = "crate::wire_time::iso")]
pub next_fire_at_unix: i64,
#[serde(with = "crate::wire_time::iso")]
pub created_at_unix: i64,
pub next_fire_at_unix: WireTime,
pub created_at_unix: WireTime,
pub source: WireScheduleSource,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::wire_time::iso_opt"
)]
pub cancelled_at_unix: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cancelled_at_unix: Option<WireTime>,
/// Set while the schedule is paused. Worker skips paused rows;
/// they keep their `next_fire_at_unix` so resuming at any time
/// fires at the next intended instant (no catch-up clamp needed
/// — a paused schedule simply slips its next fire).
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::wire_time::iso_opt"
)]
pub paused_at_unix: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub paused_at_unix: Option<WireTime>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub targets: Vec<WireScheduleTarget>,
@ -1455,18 +1440,10 @@ pub enum WireScheduleSource {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WireScheduleTarget {
pub target: String,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::wire_time::iso_opt"
)]
pub cancelled_at_unix: Option<i64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::wire_time::iso_opt"
)]
pub last_fired_at_unix: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cancelled_at_unix: Option<WireTime>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_fired_at_unix: Option<WireTime>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_result: Option<String>,
}

View file

@ -11,13 +11,63 @@
//! safe (an old peer emitting epoch ints into a new reader) and lets
//! previously persisted JSON blobs re-load unchanged.
//!
//! Usage: `#[serde(with = "crate::wire_time::iso")]` on `i64` fields,
//! `#[serde(with = "crate::wire_time::iso_opt")]` on `Option<i64>`
//! (keep the usual `default` + `skip_serializing_if` attributes).
//! Usage: type timestamp fields as [`WireTime`] / `Option<WireTime>`
//! (keep the usual `default` + `skip_serializing_if` attributes on the
//! optional form). The type IS the adaptor — no `#[serde(with = …)]`
//! needed.
use chrono::{DateTime, SecondsFormat, Utc};
use serde::Deserialize;
/// A timestamp on the wire: unix-epoch seconds in Rust, RFC 3339 UTC
/// string in JSON. Carries "this is a timestamp" in the type system
/// instead of a bare `i64` — the module docs above describe the wire
/// behaviour (ISO out, lenient epoch-or-ISO in).
///
/// The inner value is public: arithmetic like `now + delay` stays
/// plain integer math (`WireTime(now_secs + delay)`), no chrono types
/// leak into call sites.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct WireTime(pub i64);
impl WireTime {
/// The wrapped unix-epoch seconds.
#[must_use]
pub fn secs(self) -> i64 {
self.0
}
/// RFC 3339 UTC `Z` string form (same as the serialized shape).
#[must_use]
pub fn to_iso(self) -> String {
to_iso(self.0)
}
}
impl From<i64> for WireTime {
fn from(secs: i64) -> Self {
Self(secs)
}
}
impl std::fmt::Display for WireTime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&to_iso(self.0))
}
}
impl serde::Serialize for WireTime {
fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
ser.serialize_str(&to_iso(self.0))
}
}
impl<'de> Deserialize<'de> for WireTime {
fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
EpochOrIso::deserialize(de)?.into_secs().map(Self)
}
}
/// Format unix-epoch seconds as an RFC 3339 UTC string with a `Z`
/// suffix. Out-of-range values (never produced by our clocks) clamp to
/// the epoch rather than erroring — serialization must not fail.
@ -52,61 +102,23 @@ impl EpochOrIso {
}
}
/// Adaptor for required `i64` timestamp fields.
pub mod iso {
use serde::{Deserializer, Serializer};
use super::{Deserialize, EpochOrIso};
pub fn serialize<S: Serializer>(secs: &i64, ser: S) -> Result<S::Ok, S::Error> {
ser.serialize_str(&super::to_iso(*secs))
}
pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<i64, D::Error> {
EpochOrIso::deserialize(de)?.into_secs()
}
}
/// Adaptor for `Option<i64>` timestamp fields.
pub mod iso_opt {
use serde::{Deserializer, Serializer};
use super::{Deserialize, EpochOrIso};
pub fn serialize<S: Serializer>(secs: &Option<i64>, ser: S) -> Result<S::Ok, S::Error> {
match secs {
Some(secs) => ser.serialize_str(&super::to_iso(*secs)),
None => ser.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Option<i64>, D::Error> {
Option::<EpochOrIso>::deserialize(de)?
.map(EpochOrIso::into_secs)
.transpose()
}
}
#[cfg(test)]
mod tests {
use serde::{Deserialize, Serialize};
use super::WireTime;
#[derive(Serialize, Deserialize, PartialEq, Debug)]
struct Row {
#[serde(with = "crate::wire_time::iso")]
at: i64,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::wire_time::iso_opt"
)]
maybe_at: Option<i64>,
at: WireTime,
#[serde(default, skip_serializing_if = "Option::is_none")]
maybe_at: Option<WireTime>,
}
#[test]
fn serializes_epoch_as_rfc3339_z() {
let json = serde_json::to_string(&Row {
at: 1_751_480_000,
at: WireTime(1_751_480_000),
maybe_at: None,
})
.unwrap();
@ -116,8 +128,8 @@ mod tests {
#[test]
fn round_trips_and_serializes_some() {
let row = Row {
at: 0,
maybe_at: Some(1_751_480_000),
at: WireTime(0),
maybe_at: Some(WireTime(1_751_480_000)),
};
let json = serde_json::to_string(&row).unwrap();
assert_eq!(
@ -132,13 +144,13 @@ mod tests {
// Rolling-deploy skew: an old writer still emits bare epoch
// integers — the lenient reader must accept them.
let row: Row = serde_json::from_str(r#"{"at":1751480000,"maybe_at":1751480000}"#).unwrap();
assert_eq!(row.at, 1_751_480_000);
assert_eq!(row.maybe_at, Some(1_751_480_000));
assert_eq!(row.at, WireTime(1_751_480_000));
assert_eq!(row.maybe_at, Some(WireTime(1_751_480_000)));
}
#[test]
fn deserializes_offset_form_normalized_to_utc() {
let row: Row = serde_json::from_str(r#"{"at":"2025-07-02T20:13:20+02:00"}"#).unwrap();
assert_eq!(row.at, 1_751_480_000);
assert_eq!(row.at, WireTime(1_751_480_000));
}
}