wire types: use chrono DateTime<Utc> as the timestamp type throughout
This commit is contained in:
parent
1e205289c5
commit
2c5d9ed336
15 changed files with 108 additions and 190 deletions
|
|
@ -1,6 +1,6 @@
|
|||
//! Wire types shared between `hive-c0re` and the in-container harness.
|
||||
|
||||
use crate::wire_time::WireTime;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod assets;
|
||||
|
|
@ -210,10 +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>,
|
||||
pub requested_at: WireTime,
|
||||
pub requested_at: DateTime<Utc>,
|
||||
pub status: ApprovalStatus,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resolved_at: Option<WireTime>,
|
||||
pub resolved_at: Option<DateTime<Utc>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub note: Option<String>,
|
||||
/// Free-text description the manager attached at submission time;
|
||||
|
|
@ -449,7 +449,7 @@ pub enum LooseEnd {
|
|||
id: i64,
|
||||
owner: String,
|
||||
message: String,
|
||||
due_at: WireTime,
|
||||
due_at: DateTime<Utc>,
|
||||
age_seconds: u64,
|
||||
},
|
||||
/// Undelivered inbox messages waiting to be `recv`'d by this agent.
|
||||
|
|
@ -1414,17 +1414,17 @@ pub struct WireSchedule {
|
|||
pub body: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub interval_seconds: Option<u64>,
|
||||
pub next_fire_at_unix: WireTime,
|
||||
pub created_at_unix: WireTime,
|
||||
pub next_fire_at_unix: DateTime<Utc>,
|
||||
pub created_at_unix: DateTime<Utc>,
|
||||
pub source: WireScheduleSource,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cancelled_at_unix: Option<WireTime>,
|
||||
pub cancelled_at_unix: Option<DateTime<Utc>>,
|
||||
/// 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")]
|
||||
pub paused_at_unix: Option<WireTime>,
|
||||
pub paused_at_unix: Option<DateTime<Utc>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub targets: Vec<WireScheduleTarget>,
|
||||
|
|
@ -1441,9 +1441,9 @@ pub enum WireScheduleSource {
|
|||
pub struct WireScheduleTarget {
|
||||
pub target: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cancelled_at_unix: Option<WireTime>,
|
||||
pub cancelled_at_unix: Option<DateTime<Utc>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_fired_at_unix: Option<WireTime>,
|
||||
pub last_fired_at_unix: Option<DateTime<Utc>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_result: Option<String>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,124 +1,38 @@
|
|||
//! Serde adaptors for timestamp fields: `i64` unix-epoch seconds in
|
||||
//! Rust, RFC 3339 UTC strings (`2026-07-02T18:30:00Z`) in JSON.
|
||||
//!
|
||||
//! Rust code keeps doing plain integer arithmetic on these fields —
|
||||
//! only the serialized representation changes, so the dashboard (and
|
||||
//! any other JSON consumer) can feed the value straight into
|
||||
//! `new Date(s)` without the `* 1000` epoch dance.
|
||||
//!
|
||||
//! Deserialization is lenient: both the RFC 3339 string form and the
|
||||
//! legacy bare-integer form are accepted. That keeps a rolling deploy
|
||||
//! safe (an old peer emitting epoch ints into a new reader) and lets
|
||||
//! previously persisted JSON blobs re-load unchanged.
|
||||
//!
|
||||
//! 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.
|
||||
//! Timestamp conventions for the wire types: fields are
|
||||
//! `chrono::DateTime<Utc>` (serde serializes them as RFC 3339 UTC `Z`
|
||||
//! strings, e.g. `2026-07-02T18:30:00Z`), while sqlite storage and
|
||||
//! agent-facing *input* args stay unix-epoch seconds (`i64`). This
|
||||
//! module owns the two conversions at those boundaries.
|
||||
|
||||
use chrono::{DateTime, SecondsFormat, Utc};
|
||||
use serde::Deserialize;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// 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.
|
||||
/// Convert unix-epoch seconds (the sqlite column / input-arg form)
|
||||
/// into the wire timestamp type. Out-of-range values (never produced
|
||||
/// by our clocks) clamp to the epoch rather than erroring — the db
|
||||
/// read path must not fail on a weird row.
|
||||
#[must_use]
|
||||
pub fn to_iso(secs: i64) -> String {
|
||||
DateTime::<Utc>::from_timestamp(secs, 0)
|
||||
.unwrap_or_default()
|
||||
.to_rfc3339_opts(SecondsFormat::Secs, true)
|
||||
}
|
||||
|
||||
/// Parse an RFC 3339 string back to unix-epoch seconds. Any UTC offset
|
||||
/// is accepted and normalized.
|
||||
pub fn from_iso(s: &str) -> Result<i64, chrono::ParseError> {
|
||||
Ok(DateTime::parse_from_rfc3339(s)?.timestamp())
|
||||
}
|
||||
|
||||
/// Lenient wire form: either the legacy epoch integer or the RFC 3339
|
||||
/// string. `untagged` tries the integer first (cheap), then the string.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum EpochOrIso {
|
||||
Epoch(i64),
|
||||
Iso(String),
|
||||
}
|
||||
|
||||
impl EpochOrIso {
|
||||
fn into_secs<E: serde::de::Error>(self) -> Result<i64, E> {
|
||||
match self {
|
||||
Self::Epoch(secs) => Ok(secs),
|
||||
Self::Iso(s) => from_iso(&s).map_err(E::custom),
|
||||
}
|
||||
}
|
||||
pub fn from_secs(secs: i64) -> DateTime<Utc> {
|
||||
DateTime::<Utc>::from_timestamp(secs, 0).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::WireTime;
|
||||
use super::from_secs;
|
||||
|
||||
#[derive(Serialize, Deserialize, PartialEq, Debug)]
|
||||
struct Row {
|
||||
at: WireTime,
|
||||
at: DateTime<Utc>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
maybe_at: Option<WireTime>,
|
||||
maybe_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_epoch_as_rfc3339_z() {
|
||||
fn serializes_as_rfc3339_z() {
|
||||
let json = serde_json::to_string(&Row {
|
||||
at: WireTime(1_751_480_000),
|
||||
at: from_secs(1_751_480_000),
|
||||
maybe_at: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
|
@ -128,8 +42,8 @@ mod tests {
|
|||
#[test]
|
||||
fn round_trips_and_serializes_some() {
|
||||
let row = Row {
|
||||
at: WireTime(0),
|
||||
maybe_at: Some(WireTime(1_751_480_000)),
|
||||
at: from_secs(0),
|
||||
maybe_at: Some(from_secs(1_751_480_000)),
|
||||
};
|
||||
let json = serde_json::to_string(&row).unwrap();
|
||||
assert_eq!(
|
||||
|
|
@ -140,17 +54,13 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_legacy_epoch_ints() {
|
||||
// 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, WireTime(1_751_480_000));
|
||||
assert_eq!(row.maybe_at, Some(WireTime(1_751_480_000)));
|
||||
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, from_secs(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, WireTime(1_751_480_000));
|
||||
fn from_secs_clamps_out_of_range_to_epoch() {
|
||||
assert_eq!(from_secs(i64::MAX), from_secs(0));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue