From 1e205289c597b1b646868eccab2ae6c7074bbb99 Mon Sep 17 00:00:00 2001 From: damocles Date: Fri, 3 Jul 2026 19:18:33 +0200 Subject: [PATCH] wire types: WireTime newtype for timestamps instead of adaptor-annotated i64 --- hive-c0re/src/approvals.rs | 14 ++-- hive-c0re/src/audit_log.rs | 8 +- hive-c0re/src/broker.rs | 11 ++- hive-c0re/src/coordinator.rs | 8 +- hive-c0re/src/dashboard.rs | 15 ++-- hive-c0re/src/dashboard_events.rs | 33 ++++---- hive-c0re/src/loose_ends.rs | 12 +-- hive-c0re/src/main.rs | 4 +- hive-c0re/src/operator_questions.rs | 20 ++--- hive-c0re/src/socket_server.rs | 16 ++-- hive-sh4re/src/lib.rs | 53 ++++--------- hive-sh4re/src/wire_time.rs | 116 +++++++++++++++------------- 12 files changed, 148 insertions(+), 162 deletions(-) diff --git a/hive-c0re/src/approvals.rs b/hive-c0re/src/approvals.rs index 652df40e..5254075f 100644 --- a/hive-c0re/src/approvals.rs +++ b/hive-c0re/src/approvals.rs @@ -231,9 +231,9 @@ impl Approvals { agent: row.agent, kind: kind_from_str(&row.kind)?, commit_ref: row.commit_ref, - requested_at: row.requested_at, + requested_at: hive_sh4re::wire_time::WireTime(row.requested_at), status: ApprovalStatus::Approved, - resolved_at: Some(resolved_at), + resolved_at: Some(hive_sh4re::wire_time::WireTime(resolved_at)), note: None, fetched_sha: row.fetched_sha, description: row.description, @@ -294,9 +294,9 @@ impl Approvals { agent: row.agent, kind: kind_from_str(&row.kind)?, commit_ref: row.commit_ref, - requested_at: row.requested_at, + requested_at: hive_sh4re::wire_time::WireTime(row.requested_at), status: ApprovalStatus::Cancelled, - resolved_at: Some(resolved_at), + resolved_at: Some(hive_sh4re::wire_time::WireTime(resolved_at)), note: Some(note), fetched_sha: row.fetched_sha, description: row.description, @@ -404,9 +404,11 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result { agent: row.get(1)?, kind, commit_ref: row.get(3)?, - requested_at: row.get(4)?, + requested_at: hive_sh4re::wire_time::WireTime(row.get(4)?), status, - resolved_at: row.get(6)?, + resolved_at: row + .get::<_, Option>(6)? + .map(hive_sh4re::wire_time::WireTime), note: row.get(7)?, fetched_sha: row.get(8)?, description: row.get(9)?, diff --git a/hive-c0re/src/audit_log.rs b/hive-c0re/src/audit_log.rs index 43f1772a..b8e7e509 100644 --- a/hive-c0re/src/audit_log.rs +++ b/hive-c0re/src/audit_log.rs @@ -24,6 +24,7 @@ use std::sync::{Arc, Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; +use hive_sh4re::wire_time::WireTime; use rusqlite::{Connection, params}; use serde::Serialize; @@ -89,8 +90,7 @@ impl AuditOutcome { #[derive(Debug, Clone, Serialize)] pub struct AuditEntry { pub id: i64, - #[serde(with = "hive_sh4re::wire_time::iso")] - pub ts_unix: i64, + pub ts_unix: WireTime, /// Agent on whose behalf the action was taken. pub agent: String, /// What was done (e.g. `restart_infra`). @@ -159,7 +159,7 @@ impl AuditLog { ) { Ok(_) => Some(AuditEntry { id: conn.last_insert_rowid(), - ts_unix: now, + ts_unix: hive_sh4re::wire_time::WireTime(now), agent: agent.to_owned(), action: action.to_owned(), target: target.to_owned(), @@ -252,7 +252,7 @@ pub fn spawn_vacuum(coord: &Arc) { fn row_to_entry(r: &rusqlite::Row) -> rusqlite::Result { Ok(AuditEntry { id: r.get(0)?, - ts_unix: r.get(1)?, + ts_unix: hive_sh4re::wire_time::WireTime(r.get(1)?), agent: r.get(2)?, action: r.get(3)?, target: r.get(4)?, diff --git a/hive-c0re/src/broker.rs b/hive-c0re/src/broker.rs index aedf314f..09c3215c 100644 --- a/hive-c0re/src/broker.rs +++ b/hive-c0re/src/broker.rs @@ -7,6 +7,7 @@ use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; +use hive_sh4re::wire_time::WireTime; use hive_sh4re::{InboxRow, Message}; use rusqlite::{Connection, OptionalExtension, params}; use serde::Serialize; @@ -74,10 +75,8 @@ pub struct PendingReminder { pub message: String, #[serde(skip_serializing_if = "Option::is_none")] pub file_path: Option, - #[serde(with = "hive_sh4re::wire_time::iso")] - pub due_at: i64, - #[serde(with = "hive_sh4re::wire_time::iso")] - pub created_at: i64, + pub due_at: WireTime, + pub created_at: WireTime, /// Most recent delivery failure for this row, if any. Cleared /// to NULL on operator retry. Surfaced inline in the dashboard /// so a stuck reminder doesn't just silently retry forever. @@ -926,8 +925,8 @@ impl Broker { agent: row.get(1)?, message: row.get(2)?, file_path: row.get(3)?, - due_at: row.get(4)?, - created_at: row.get(5)?, + due_at: hive_sh4re::wire_time::WireTime(row.get(4)?), + created_at: hive_sh4re::wire_time::WireTime(row.get(5)?), last_error: row.get(6)?, attempt_count: u32::try_from(attempts).unwrap_or(0), }) diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 93068813..fbb1bbda 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -805,7 +805,7 @@ impl Coordinator { approval_kind, sha_short, status, - resolved_at, + resolved_at: hive_sh4re::wire_time::WireTime(resolved_at), note, description, }); @@ -838,8 +838,8 @@ impl Coordinator { question: question.to_owned(), options: options.to_vec(), multi, - asked_at, - deadline_at, + asked_at: hive_sh4re::wire_time::WireTime(asked_at), + deadline_at: deadline_at.map(hive_sh4re::wire_time::WireTime), target: target.map(str::to_owned), question_refs, }); @@ -869,7 +869,7 @@ impl Coordinator { id, answer: answer.to_owned(), answerer: answerer.to_owned(), - answered_at, + answered_at: hive_sh4re::wire_time::WireTime(answered_at), cancelled, target: target.map(str::to_owned), answer_refs, diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 5a0399e7..e4f0709a 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -27,6 +27,7 @@ use tokio_stream::{Stream, StreamExt}; use crate::container_view::{ContainerView, claude_has_session}; use crate::coordinator::Coordinator; use crate::lifecycle::{self, MANAGER_NAME}; +use hive_sh4re::wire_time::WireTime; mod approvals; mod build_logs; @@ -433,8 +434,7 @@ struct ApprovalHistoryView { /// `approved` / `denied` / `failed`. status: &'static str, /// RFC 3339 UTC. Renders as a relative time on the dashboard. - #[serde(with = "hive_sh4re::wire_time::iso")] - resolved_at: i64, + resolved_at: WireTime, /// Operator-supplied deny reason (for `denied`) or build error /// (for `failed`). None on `approved`. #[serde(skip_serializing_if = "Option::is_none")] @@ -475,8 +475,7 @@ struct ApprovalView { /// RFC 3339 UTC time the approval was queued. Rendered as a /// relative time on the card so the operator can spot a stale /// request. - #[serde(with = "hive_sh4re::wire_time::iso")] - requested_at: i64, + requested_at: WireTime, } /// Replace silent `.unwrap_or_default()` on the data sources behind @@ -908,7 +907,7 @@ fn history_view(a: Approval) -> ApprovalHistoryView { kind, sha_short, status, - resolved_at: a.resolved_at.unwrap_or(0), + resolved_at: a.resolved_at.unwrap_or_default(), note: a.note, } } @@ -1101,7 +1100,7 @@ async fn dashboard_history(State(state): State) -> Response { from, to, body, - at, + at: hive_sh4re::wire_time::WireTime(at), in_reply_to, file_refs, }) @@ -1121,7 +1120,7 @@ async fn dashboard_history(State(state): State) -> Response { from, to, body, - at, + at: hive_sh4re::wire_time::WireTime(at), in_reply_to, file_refs, }) @@ -1376,7 +1375,7 @@ async fn api_operator_inbox(State(state): State) -> Response { "id": id, "from": from, "body": body, - "at": hive_sh4re::wire_time::to_iso(at), + "at": hive_sh4re::wire_time::WireTime(at), "in_reply_to": in_reply_to, "file_refs": file_refs, })) diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index 48610473..92e1c58e 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -9,6 +9,7 @@ use serde::Serialize; use crate::container_view::ContainerView; use crate::dashboard::{MetaInputView, TombstoneView}; use crate::rebuild_queue::QueueEntry; +use hive_sh4re::wire_time::WireTime; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "snake_case", tag = "kind")] @@ -38,8 +39,7 @@ pub enum DashboardEvent { from: String, to: String, body: String, - #[serde(with = "hive_sh4re::wire_time::iso")] - at: i64, + at: WireTime, #[serde(default, skip_serializing_if = "Option::is_none")] in_reply_to: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -54,8 +54,7 @@ pub enum DashboardEvent { from: String, to: String, body: String, - #[serde(with = "hive_sh4re::wire_time::iso")] - at: i64, + at: WireTime, #[serde(default, skip_serializing_if = "Option::is_none")] in_reply_to: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -97,8 +96,7 @@ pub enum DashboardEvent { sha_short: Option, /// `"approved"` / `"denied"` / `"failed"`. status: &'static str, - #[serde(with = "hive_sh4re::wire_time::iso")] - resolved_at: i64, + resolved_at: WireTime, note: Option, description: Option, }, @@ -114,10 +112,8 @@ pub enum DashboardEvent { question: String, options: Vec, multi: bool, - #[serde(with = "hive_sh4re::wire_time::iso")] - asked_at: i64, - #[serde(with = "hive_sh4re::wire_time::iso_opt")] - deadline_at: Option, + asked_at: WireTime, + deadline_at: Option, target: Option, /// Verified file-path tokens that appear in `question`. /// Same shape as broker `Sent`/`Delivered` events; the @@ -135,8 +131,7 @@ pub enum DashboardEvent { id: i64, answer: String, answerer: String, - #[serde(with = "hive_sh4re::wire_time::iso")] - answered_at: i64, + answered_at: WireTime, cancelled: bool, target: Option, /// Verified file-path tokens that appear in `answer`. @@ -337,7 +332,7 @@ mod tests { from: "a".into(), to: "b".into(), body: String::new(), - at: 0, + at: hive_sh4re::wire_time::WireTime(0), in_reply_to: None, file_refs: Vec::new(), }, @@ -347,7 +342,7 @@ mod tests { from: "a".into(), to: "b".into(), body: String::new(), - at: 0, + at: hive_sh4re::wire_time::WireTime(0), in_reply_to: None, file_refs: Vec::new(), }, @@ -368,7 +363,7 @@ mod tests { approval_kind: "apply_commit", sha_short: None, status: "approved", - resolved_at: 0, + resolved_at: hive_sh4re::wire_time::WireTime(0), note: None, description: None, }, @@ -379,7 +374,7 @@ mod tests { question: String::new(), options: Vec::new(), multi: false, - asked_at: 0, + asked_at: hive_sh4re::wire_time::WireTime(0), deadline_at: None, target: None, question_refs: Vec::new(), @@ -389,7 +384,7 @@ mod tests { id: 1, answer: String::new(), answerer: "a".into(), - answered_at: 0, + answered_at: hive_sh4re::wire_time::WireTime(0), cancelled: false, target: None, answer_refs: Vec::new(), @@ -452,7 +447,7 @@ mod tests { seq: 1, entry: crate::audit_log::AuditEntry { id: 1, - ts_unix: 0, + ts_unix: hive_sh4re::wire_time::WireTime(0), agent: "atlas".into(), action: "restart_infra".into(), target: "hive-ci".into(), @@ -481,7 +476,7 @@ mod tests { seq: 7, entry: crate::audit_log::AuditEntry { id: 42, - ts_unix: 1_700_000_000, + ts_unix: hive_sh4re::wire_time::WireTime(1_700_000_000), agent: "atlas".into(), action: "restart_infra".into(), target: "hive-gateway".into(), diff --git a/hive-c0re/src/loose_ends.rs b/hive-c0re/src/loose_ends.rs index 24eb34c2..70399329 100644 --- a/hive-c0re/src/loose_ends.rs +++ b/hive-c0re/src/loose_ends.rs @@ -70,7 +70,7 @@ pub fn for_agent(coord: &Coordinator, agent: &str) -> Result> { agent: a.agent, commit_ref: a.commit_ref, description: a.description, - age_seconds: saturating_age(now, a.requested_at), + age_seconds: saturating_age(now, a.requested_at.secs()), }); } for q in coord.questions.pending_all()? { @@ -83,7 +83,7 @@ pub fn for_agent(coord: &Coordinator, agent: &str) -> Result> { asker: q.asker, target: q.target, question: q.question, - age_seconds: saturating_age(now, q.asked_at), + age_seconds: saturating_age(now, q.asked_at.secs()), }); } for r in coord.broker.list_pending_reminders()? { @@ -95,7 +95,7 @@ pub fn for_agent(coord: &Coordinator, agent: &str) -> Result> { owner: r.agent, message: r.message, due_at: r.due_at, - age_seconds: saturating_age(now, r.created_at), + age_seconds: saturating_age(now, r.created_at.secs()), }); } Ok(out) @@ -114,7 +114,7 @@ pub fn hive_wide(coord: &Coordinator) -> Result> { agent: a.agent, commit_ref: a.commit_ref, description: a.description, - age_seconds: saturating_age(now, a.requested_at), + age_seconds: saturating_age(now, a.requested_at.secs()), }); } for q in coord.questions.pending_all()? { @@ -123,7 +123,7 @@ pub fn hive_wide(coord: &Coordinator) -> Result> { asker: q.asker, target: q.target, question: q.question, - age_seconds: saturating_age(now, q.asked_at), + age_seconds: saturating_age(now, q.asked_at.secs()), }); } for r in coord.broker.list_pending_reminders()? { @@ -132,7 +132,7 @@ pub fn hive_wide(coord: &Coordinator) -> Result> { owner: r.agent, message: r.message, due_at: r.due_at, - age_seconds: saturating_age(now, r.created_at), + age_seconds: saturating_age(now, r.created_at.secs()), }); } Ok(out) diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 5e5ac9e2..46b123fb 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -497,7 +497,7 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc) { from, to, body, - at, + at: hive_sh4re::wire_time::WireTime(at), in_reply_to, file_refs, }); @@ -517,7 +517,7 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc) { from, to, body, - at, + at: hive_sh4re::wire_time::WireTime(at), in_reply_to, file_refs, }); diff --git a/hive-c0re/src/operator_questions.rs b/hive-c0re/src/operator_questions.rs index ab51e9f0..bf243846 100644 --- a/hive-c0re/src/operator_questions.rs +++ b/hive-c0re/src/operator_questions.rs @@ -14,6 +14,7 @@ use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result, bail}; +use hive_sh4re::wire_time::WireTime; use rusqlite::{Connection, OptionalExtension, params}; use serde::Serialize; @@ -74,15 +75,12 @@ pub struct OpQuestion { pub question: String, pub options: Vec, pub multi: bool, - #[serde(with = "hive_sh4re::wire_time::iso")] - pub asked_at: i64, + pub asked_at: WireTime, /// Deadline after which a watchdog auto-resolves the question with /// answer `[expired]`. `None` = no expiry. Surfaced on the /// dashboard as a remaining-time chip. - #[serde(with = "hive_sh4re::wire_time::iso_opt")] - pub deadline_at: Option, - #[serde(with = "hive_sh4re::wire_time::iso_opt")] - pub answered_at: Option, + pub deadline_at: Option, + pub answered_at: Option, pub answer: Option, /// Recipient of the question. `None` = the operator (dashboard /// path); `Some()` = a peer agent asked via @@ -290,10 +288,14 @@ fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result { question: row.get(2)?, options, multi: multi != 0, - asked_at: row.get(5)?, - answered_at: row.get(6)?, + asked_at: hive_sh4re::wire_time::WireTime(row.get(5)?), + answered_at: row + .get::<_, Option>(6)? + .map(hive_sh4re::wire_time::WireTime), answer: row.get(7)?, - deadline_at: row.get(8)?, + deadline_at: row + .get::<_, Option>(8)? + .map(hive_sh4re::wire_time::WireTime), target: row.get(9)?, }) } diff --git a/hive-c0re/src/socket_server.rs b/hive-c0re/src/socket_server.rs index b21f60e3..09af25d6 100644 --- a/hive-c0re/src/socket_server.rs +++ b/hive-c0re/src/socket_server.rs @@ -2018,8 +2018,8 @@ fn schedule_to_wire(s: crate::scheduled_prompts::Schedule) -> hive_sh4re::WireSc owner: s.owner, body: s.body, interval_seconds: s.interval_seconds, - next_fire_at_unix: s.next_fire_at_unix, - created_at_unix: s.created_at_unix, + next_fire_at_unix: hive_sh4re::wire_time::WireTime(s.next_fire_at_unix), + created_at_unix: hive_sh4re::wire_time::WireTime(s.created_at_unix), source: match s.source { crate::scheduled_prompts::ScheduleSource::Operator => { hive_sh4re::WireScheduleSource::Operator @@ -2028,16 +2028,16 @@ fn schedule_to_wire(s: crate::scheduled_prompts::Schedule) -> hive_sh4re::WireSc hive_sh4re::WireScheduleSource::Approval { id } } }, - cancelled_at_unix: s.cancelled_at_unix, - paused_at_unix: s.paused_at_unix, + cancelled_at_unix: s.cancelled_at_unix.map(hive_sh4re::wire_time::WireTime), + paused_at_unix: s.paused_at_unix.map(hive_sh4re::wire_time::WireTime), description: s.description, targets: s .targets .into_iter() .map(|t| hive_sh4re::WireScheduleTarget { target: t.target, - cancelled_at_unix: t.cancelled_at_unix, - last_fired_at_unix: t.last_fired_at_unix, + cancelled_at_unix: t.cancelled_at_unix.map(hive_sh4re::wire_time::WireTime), + last_fired_at_unix: t.last_fired_at_unix.map(hive_sh4re::wire_time::WireTime), last_result: t.last_result, }) .collect(), @@ -2131,8 +2131,8 @@ mod tests { owner: "operator".to_owned(), body: "ping".to_owned(), interval_seconds: None, - next_fire_at_unix: 0, - created_at_unix: 0, + next_fire_at_unix: hive_sh4re::wire_time::WireTime(0), + created_at_unix: hive_sh4re::wire_time::WireTime(0), source: hive_sh4re::WireScheduleSource::Operator, cancelled_at_unix: None, paused_at_unix: None, diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 09931b0d..42c42c52 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -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, - #[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolved_at: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub note: Option, /// 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, - #[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cancelled_at_unix: Option, /// 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub paused_at_unix: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, pub targets: Vec, @@ -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, - #[serde( - default, - skip_serializing_if = "Option::is_none", - with = "crate::wire_time::iso_opt" - )] - pub last_fired_at_unix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cancelled_at_unix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_fired_at_unix: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub last_result: Option, } diff --git a/hive-sh4re/src/wire_time.rs b/hive-sh4re/src/wire_time.rs index cbc3dae7..1c5422c7 100644 --- a/hive-sh4re/src/wire_time.rs +++ b/hive-sh4re/src/wire_time.rs @@ -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` -//! (keep the usual `default` + `skip_serializing_if` attributes). +//! Usage: type timestamp fields as [`WireTime`] / `Option` +//! (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 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(&self, ser: S) -> Result { + ser.serialize_str(&to_iso(self.0)) + } +} + +impl<'de> Deserialize<'de> for WireTime { + fn deserialize>(de: D) -> Result { + 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(secs: &i64, ser: S) -> Result { - ser.serialize_str(&super::to_iso(*secs)) - } - - pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result { - EpochOrIso::deserialize(de)?.into_secs() - } -} - -/// Adaptor for `Option` timestamp fields. -pub mod iso_opt { - use serde::{Deserializer, Serializer}; - - use super::{Deserialize, EpochOrIso}; - - pub fn serialize(secs: &Option, ser: S) -> Result { - 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, D::Error> { - Option::::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, + at: WireTime, + #[serde(default, skip_serializing_if = "Option::is_none")] + maybe_at: Option, } #[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)); } }