Skip to main content

macrame/util/
timestamp.rs

1//! Canonical timestamp form for every temporal column (§4.1).
2//!
3//! Every `valid_from`, `valid_to`, and `recorded_at` in the schema is compared
4//! **lexicographically** — by `<=` and `<` in SQL, by `str` ordering in Rust,
5//! and by `MAX()` when the clock recovers its floor. Lexicographic ordering
6//! agrees with chronological ordering only when every string has the *same
7//! shape*. A `Z` suffix alone is not enough:
8//!
9//! ```text
10//! '2026-01-01T00:00:00Z' <= '2026-01-01T00:00:00.000000Z'   -->  FALSE
11//! ```
12//!
13//! because at the first differing byte `'Z'` (0x5A) sorts after `'.'` (0x2E),
14//! so the second-precision instant compares as *later* than the identical
15//! microsecond-precision instant. A traversal predicated on `valid_from <= :ts`
16//! then silently drops every edge — no error, just an empty result.
17//!
18//! The fix is to admit exactly one width. A timestamp is canonical iff it is
19//! exactly [`TIMESTAMP_LEN`] bytes in the form `YYYY-MM-DDTHH:MM:SS.ffffffZ`.
20//! [`CANONICAL_TS_GLOB`] enforces that at the storage layer so a non-canonical
21//! value cannot be written at all, and [`normalize`] widens the legacy
22//! second-precision form at the boundary rather than rejecting it.
23
24use crate::error::{DbError, Result};
25use std::time::{Duration, SystemTime};
26
27/// Byte length of the canonical form `YYYY-MM-DDTHH:MM:SS.ffffffZ`.
28pub const TIMESTAMP_LEN: usize = 27;
29
30/// The open-interval sentinel, in canonical form.
31///
32/// Widened from `9999-12-31T23:59:59Z` in 0.5.4: a sentinel that is the one
33/// value exempt from the canonical width is a carve-out that reintroduces the
34/// very comparison bug the width exists to prevent. `.999999` also makes the
35/// sentinel the maximum representable instant, so `ts < OPEN_SENTINEL` holds
36/// for every real timestamp — which is what a half-open interval needs.
37pub const OPEN_SENTINEL: &str = "9999-12-31T23:59:59.999999Z";
38
39/// GLOB pattern matching exactly the canonical form.
40///
41/// Used in `CHECK` constraints. GLOB anchors at both ends and supports
42/// character classes, so this is a complete shape test — no separate
43/// `length()` term is needed.
44pub const CANONICAL_TS_GLOB: &str =
45    "'[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9][0-9][0-9][0-9][0-9]Z'";
46
47/// True iff `s` is exactly `YYYY-MM-DDTHH:MM:SS.ffffffZ`.
48///
49/// Shape only — this does not check that the date is a real calendar date.
50/// [`parse`] does that.
51pub fn is_canonical(s: &str) -> bool {
52    let b = s.as_bytes();
53    if b.len() != TIMESTAMP_LEN {
54        return false;
55    }
56    const DIGITS: [usize; 20] = [
57        0, 1, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18, 20, 21, 22, 23, 24, 25,
58    ];
59    const SEPS: [(usize, u8); 7] = [
60        (4, b'-'),
61        (7, b'-'),
62        (10, b'T'),
63        (13, b':'),
64        (16, b':'),
65        (19, b'.'),
66        (26, b'Z'),
67    ];
68    DIGITS.iter().all(|&i| b[i].is_ascii_digit()) && SEPS.iter().all(|&(i, c)| b[i] == c)
69}
70
71/// Widen a timestamp to canonical form.
72///
73/// Accepts the canonical form unchanged and the legacy second-precision form
74/// `YYYY-MM-DDTHH:MM:SSZ`, which is widened by appending `.000000`. Anything
75/// else — an offset like `+01:00`, a missing `Z`, millisecond precision — is
76/// rejected rather than guessed at, because every silent repair here becomes a
77/// wrong answer in a temporal query later.
78pub fn normalize(s: &str) -> Result<String> {
79    if is_canonical(s) {
80        return Ok(s.to_string());
81    }
82    // Legacy second precision: "YYYY-MM-DDTHH:MM:SSZ" (20 bytes).
83    if s.len() == 20 && s.ends_with('Z') {
84        let widened = format!("{}.000000Z", &s[..19]);
85        if is_canonical(&widened) {
86            return Ok(widened);
87        }
88    }
89    Err(DbError::InvalidTimestamp {
90        value: s.to_string(),
91        reason: "expected YYYY-MM-DDTHH:MM:SS.ffffffZ".to_string(),
92    })
93}
94
95/// Days from 1970-01-01 to `y-m-d` (proleptic Gregorian).
96///
97/// Hinnant's civil-calendar algorithm: shift the year to start in March so the
98/// leap day lands at the end, then count whole 400-year eras.
99fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
100    let y = y - if m <= 2 { 1 } else { 0 };
101    let era = if y >= 0 { y } else { y - 399 } / 400;
102    let yoe = y - era * 400; // [0, 399]
103    let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1; // [0, 365]
104    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
105    era * 146097 + doe - 719468
106}
107
108/// Inverse of [`days_from_civil`].
109fn civil_from_days(z: i64) -> (i64, i64, i64) {
110    let z = z + 719468;
111    let era = if z >= 0 { z } else { z - 146096 } / 146097;
112    let doe = z - era * 146097; // [0, 146096]
113    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
114    let y = yoe + era * 400;
115    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
116    let mp = (5 * doy + 2) / 153; // [0, 11]
117    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
118    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
119    (y + if m <= 2 { 1 } else { 0 }, m, d)
120}
121
122/// Number of days in `month` of `year` (proleptic Gregorian).
123fn days_in_month(year: i64, month: i64) -> i64 {
124    match month {
125        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
126        4 | 6 | 9 | 11 => 30,
127        2 if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 => 29,
128        2 => 28,
129        _ => 0,
130    }
131}
132
133/// Strict parser for the canonical form (second precision accepted via
134/// [`normalize`]).
135///
136/// Validates the calendar as well as the shape: `2026-02-30T00:00:00.000000Z`
137/// has canonical *shape* but is not a date, and accepting it would let a
138/// timestamp exist that no round-trip can reproduce.
139pub fn parse(s: &str) -> Result<SystemTime> {
140    let canon = normalize(s)?;
141    let b = canon.as_bytes();
142    let num = |lo: usize, hi: usize| -> i64 {
143        canon[lo..hi]
144            .parse::<i64>()
145            .expect("is_canonical checked digits")
146    };
147    let (year, month, day) = (num(0, 4), num(5, 7), num(8, 10));
148    let (hour, min, sec) = (num(11, 13), num(14, 16), num(17, 19));
149    let micros = num(20, 26);
150    debug_assert_eq!(b[26], b'Z');
151
152    let bad = |why: &str| DbError::InvalidTimestamp {
153        value: s.to_string(),
154        reason: why.to_string(),
155    };
156    if !(1..=12).contains(&month) {
157        return Err(bad("month out of range"));
158    }
159    if day < 1 || day > days_in_month(year, month) {
160        return Err(bad("day out of range for month"));
161    }
162    // Leap seconds are not representable: SystemTime counts SI seconds since
163    // the epoch, so :60 has no slot and silently aliasing it to :59 would break
164    // the strictly-increasing clock contract.
165    if hour > 23 || min > 59 || sec > 59 {
166        return Err(bad("time component out of range"));
167    }
168
169    let secs = days_from_civil(year, month, day) * 86_400 + hour * 3600 + min * 60 + sec;
170    let offset = Duration::new(secs.unsigned_abs(), micros as u32 * 1_000);
171    let t = if secs >= 0 {
172        SystemTime::UNIX_EPOCH.checked_add(offset)
173    } else {
174        SystemTime::UNIX_EPOCH.checked_sub(offset)
175    };
176    t.ok_or_else(|| bad("not representable as a SystemTime on this platform"))
177}
178
179/// Render a [`SystemTime`] in canonical form.
180///
181/// Saturates at the epoch for pre-1970 inputs: the schema has no use for them,
182/// and the alternative — a negative-year string — would not be canonical.
183pub fn format(st: SystemTime) -> String {
184    let d = st
185        .duration_since(SystemTime::UNIX_EPOCH)
186        .unwrap_or_default();
187    let secs = d.as_secs() as i64;
188    let micros = d.subsec_micros();
189
190    // Floor division: rem_euclid keeps the time-of-day positive.
191    let days = secs.div_euclid(86_400);
192    let tod = secs.rem_euclid(86_400);
193    let (y, m, dd) = civil_from_days(days);
194
195    format!(
196        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}Z",
197        y,
198        m,
199        dd,
200        tod / 3600,
201        (tod % 3600) / 60,
202        tod % 60,
203        micros
204    )
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn sentinel_is_canonical_and_maximal() {
213        assert!(is_canonical(OPEN_SENTINEL));
214        assert_eq!(OPEN_SENTINEL.len(), TIMESTAMP_LEN);
215        // Every real timestamp sorts before the sentinel, which is what makes
216        // the half-open interval [valid_from, valid_to) work by string compare.
217        assert!("2026-01-01T00:00:00.000000Z" < OPEN_SENTINEL);
218        assert!("9999-12-31T23:59:59.999998Z" < OPEN_SENTINEL);
219    }
220
221    #[test]
222    fn canonical_form_orders_lexicographically() {
223        // This is the invariant the whole module exists to guarantee, and the
224        // exact comparison that silently failed before canonicalisation.
225        let a = normalize("2026-01-01T00:00:00Z").unwrap();
226        let b = "2026-01-01T00:00:00.000000Z";
227        assert_eq!(a, b);
228        assert!(a.as_str() <= b);
229
230        let mut stamps = [
231            "2026-01-01T00:00:01.000000Z",
232            "2026-01-01T00:00:00.000001Z",
233            "2026-01-01T00:00:00.000000Z",
234            "2025-12-31T23:59:59.999999Z",
235        ];
236        stamps.sort_unstable();
237        assert_eq!(stamps[0], "2025-12-31T23:59:59.999999Z");
238        assert_eq!(stamps[3], "2026-01-01T00:00:01.000000Z");
239    }
240
241    #[test]
242    fn normalize_widens_seconds_and_rejects_everything_else() {
243        assert_eq!(
244            normalize("2026-01-01T00:00:00Z").unwrap(),
245            "2026-01-01T00:00:00.000000Z"
246        );
247        assert_eq!(normalize(OPEN_SENTINEL).unwrap(), OPEN_SENTINEL);
248
249        for bad in [
250            "2026-01-01T00:00:00",            // no zone
251            "2026-01-01T00:00:00+01:00",      // offset
252            "2026-01-01T00:00:00.000Z",       // milliseconds
253            "2026-01-01T00:00:00.000000000Z", // nanoseconds
254            "2026-01-01 00:00:00.000000Z",    // space separator
255            "",
256        ] {
257            assert!(normalize(bad).is_err(), "should reject {bad:?}");
258        }
259    }
260
261    #[test]
262    fn format_reports_the_real_date() {
263        // Regression: format() previously hard-coded a literal date, so every
264        // stamp the crate wrote claimed the same day regardless of the clock.
265        assert_eq!(
266            format(SystemTime::UNIX_EPOCH),
267            "1970-01-01T00:00:00.000000Z"
268        );
269        assert_eq!(
270            format(SystemTime::UNIX_EPOCH + Duration::from_secs(86_400)),
271            "1970-01-02T00:00:00.000000Z"
272        );
273        // 2000-03-01: the far side of a leap day in a 400-year leap year.
274        assert_eq!(
275            format(SystemTime::UNIX_EPOCH + Duration::from_secs(951_868_800)),
276            "2000-03-01T00:00:00.000000Z"
277        );
278    }
279
280    #[test]
281    fn parse_format_roundtrip() {
282        for s in [
283            "1970-01-01T00:00:00.000000Z",
284            "2000-02-29T12:34:56.654321Z",
285            "2026-07-28T09:15:00.000001Z",
286            OPEN_SENTINEL,
287        ] {
288            assert_eq!(format(parse(s).unwrap()), s, "roundtrip failed for {s}");
289        }
290    }
291
292    #[test]
293    fn parse_validates_the_calendar_not_just_the_shape() {
294        for bad in [
295            "2026-02-30T00:00:00.000000Z", // February has no 30th
296            "2026-13-01T00:00:00.000000Z", // no 13th month
297            "2026-00-01T00:00:00.000000Z",
298            "2025-02-29T00:00:00.000000Z", // 2025 is not a leap year
299            "2026-01-01T24:00:00.000000Z",
300            "2026-01-01T00:60:00.000000Z",
301            "2026-01-01T00:00:60.000000Z", // leap second, not representable
302        ] {
303            assert!(parse(bad).is_err(), "should reject {bad:?}");
304        }
305        // ...but a real leap day parses.
306        assert!(parse("2024-02-29T00:00:00.000000Z").is_ok());
307    }
308}