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
171    // **One signed quantity, split into sign and magnitude exactly once**
172    // ([D-270], 0.15.27). This used to build `Duration::new(secs.unsigned_abs(),
173    // micros)` and then add or subtract it according to the sign of `secs`,
174    // which is right above the epoch and wrong below it: the microseconds
175    // always run *forward* from the second, so on the pre-epoch side that
176    // subtracted them as well. `1969-12-31T23:59:59.999999Z` — one microsecond
177    // before the epoch — came back as `1969-12-31T23:59:58.000001Z`, out by
178    // very nearly two seconds, with no error.
179    let total_micros = i128::from(secs) * 1_000_000 + i128::from(micros);
180    let magnitude = u64::try_from(total_micros.unsigned_abs())
181        .map(Duration::from_micros)
182        .map_err(|_| bad("not representable as a SystemTime on this platform"))?;
183
184    // `SystemTime` is a `timespec` on Unix and a `FILETIME` on Windows, whose
185    // own epoch is 1601-01-01 — so this refusal is genuinely per-platform and
186    // says so. It is the reason a pre-epoch defect can hide from a Windows
187    // developer box and surface on the Linux fuzz runner.
188    let t = if total_micros >= 0 {
189        SystemTime::UNIX_EPOCH.checked_add(magnitude)
190    } else {
191        SystemTime::UNIX_EPOCH.checked_sub(magnitude)
192    };
193    t.ok_or_else(|| bad("not representable as a SystemTime on this platform"))
194}
195
196/// Earliest instant the canonical form can express: `0000-01-01T00:00:00.000000Z`.
197///
198/// The bound is the four-digit year, not the epoch. See [`format`].
199const MIN_MICROS: i128 = -62_167_219_200_000_000;
200
201/// Latest instant the canonical form can express: [`OPEN_SENTINEL`].
202const MAX_MICROS: i128 = 253_402_300_799_999_999;
203
204/// Render a [`SystemTime`] in canonical form.
205///
206/// # The range is `0000-01-01` … `9999-12-31`, and it used to be `1970-…`
207///
208/// This function's doc comment used to read *"saturates at the epoch for
209/// pre-1970 inputs: the schema has no use for them, and the alternative — a
210/// negative-year string — would not be canonical."* The second clause is right
211/// and the first was wrong about its own schema ([D-270], 0.15.27): a
212/// negative year is not canonical, but **`0060-06-22` is** — four digits,
213/// [`CANONICAL_TS_GLOB`] accepts it, it sorts correctly against every later
214/// stamp, and [`parse`] has always returned a real pre-epoch `SystemTime` for
215/// it. So the two halves of this module disagreed about their own range:
216/// `parse` accepted from year 0 and `format` answered from 1970, and every
217/// instant in between came back as `1970-01-01T00:00:00.000000Z` — the wrong
218/// day, silently, with no error anywhere.
219///
220/// That matters because valid time is the caller's, not the clock's. A
221/// bitemporal ledger recording when a fact *was true* has every reason to
222/// carry a date before 1970, and the storage layer has always stored and
223/// ordered one correctly. Only the `SystemTime` round trip lost it.
224///
225/// Found by `fuzz_targets/timestamp_parse.rs` on CI run `34060567071`, on the
226/// input `"0060-06-22T22:22:24Z"` — which is [D-266]'s point about these four
227/// parsers arriving with a defect nobody had reasoned their way to.
228///
229/// Saturation remains, at the two ends the *form* imposes rather than at the
230/// epoch: an instant outside the four-digit year clamps to
231/// `0000-01-01T00:00:00.000000Z` or to [`OPEN_SENTINEL`]. `parse` cannot
232/// produce one — its input is four digits by construction — so the clamp is
233/// reachable only from a `SystemTime` built elsewhere, where returning
234/// something canonical beats panicking on a display path.
235///
236/// [D-266]: ../../docs/architecture/s13-decision-register.md#d-266
237/// [D-270]: ../../docs/architecture/s13-decision-register.md#d-270
238pub fn format(st: SystemTime) -> String {
239    // `duration_since` reports the magnitude and the direction separately, so
240    // the sign has to be put back by hand. `unwrap_or_default` is what used to
241    // discard it.
242    let signed_micros = match st.duration_since(SystemTime::UNIX_EPOCH) {
243        Ok(d) => i128::try_from(d.as_micros()).unwrap_or(i128::MAX),
244        Err(before) => i128::try_from(before.duration().as_micros())
245            .map_or(i128::MIN, |m| -m),
246    }
247    .clamp(MIN_MICROS, MAX_MICROS);
248
249    // Floor division throughout: `rem_euclid` keeps the microseconds and the
250    // time of day positive on the pre-epoch side, where truncating division
251    // would produce a negative time of day and a day off by one.
252    let secs = signed_micros.div_euclid(1_000_000) as i64;
253    let micros = signed_micros.rem_euclid(1_000_000) as u32;
254    let days = secs.div_euclid(86_400);
255    let tod = secs.rem_euclid(86_400);
256    let (y, m, dd) = civil_from_days(days);
257
258    format!(
259        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}Z",
260        y,
261        m,
262        dd,
263        tod / 3600,
264        (tod % 3600) / 60,
265        tod % 60,
266        micros
267    )
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn sentinel_is_canonical_and_maximal() {
276        assert!(is_canonical(OPEN_SENTINEL));
277        assert_eq!(OPEN_SENTINEL.len(), TIMESTAMP_LEN);
278        // Every real timestamp sorts before the sentinel, which is what makes
279        // the half-open interval [valid_from, valid_to) work by string compare.
280        assert!("2026-01-01T00:00:00.000000Z" < OPEN_SENTINEL);
281        assert!("9999-12-31T23:59:59.999998Z" < OPEN_SENTINEL);
282    }
283
284    #[test]
285    fn canonical_form_orders_lexicographically() {
286        // This is the invariant the whole module exists to guarantee, and the
287        // exact comparison that silently failed before canonicalisation.
288        let a = normalize("2026-01-01T00:00:00Z").unwrap();
289        let b = "2026-01-01T00:00:00.000000Z";
290        assert_eq!(a, b);
291        assert!(a.as_str() <= b);
292
293        let mut stamps = [
294            "2026-01-01T00:00:01.000000Z",
295            "2026-01-01T00:00:00.000001Z",
296            "2026-01-01T00:00:00.000000Z",
297            "2025-12-31T23:59:59.999999Z",
298        ];
299        stamps.sort_unstable();
300        assert_eq!(stamps[0], "2025-12-31T23:59:59.999999Z");
301        assert_eq!(stamps[3], "2026-01-01T00:00:01.000000Z");
302    }
303
304    #[test]
305    fn normalize_widens_seconds_and_rejects_everything_else() {
306        assert_eq!(
307            normalize("2026-01-01T00:00:00Z").unwrap(),
308            "2026-01-01T00:00:00.000000Z"
309        );
310        assert_eq!(normalize(OPEN_SENTINEL).unwrap(), OPEN_SENTINEL);
311
312        for bad in [
313            "2026-01-01T00:00:00",            // no zone
314            "2026-01-01T00:00:00+01:00",      // offset
315            "2026-01-01T00:00:00.000Z",       // milliseconds
316            "2026-01-01T00:00:00.000000000Z", // nanoseconds
317            "2026-01-01 00:00:00.000000Z",    // space separator
318            "",
319        ] {
320            assert!(normalize(bad).is_err(), "should reject {bad:?}");
321        }
322    }
323
324    #[test]
325    fn format_reports_the_real_date() {
326        // Regression: format() previously hard-coded a literal date, so every
327        // stamp the crate wrote claimed the same day regardless of the clock.
328        assert_eq!(
329            format(SystemTime::UNIX_EPOCH),
330            "1970-01-01T00:00:00.000000Z"
331        );
332        assert_eq!(
333            format(SystemTime::UNIX_EPOCH + Duration::from_secs(86_400)),
334            "1970-01-02T00:00:00.000000Z"
335        );
336        // 2000-03-01: the far side of a leap day in a 400-year leap year.
337        assert_eq!(
338            format(SystemTime::UNIX_EPOCH + Duration::from_secs(951_868_800)),
339            "2000-03-01T00:00:00.000000Z"
340        );
341    }
342
343    #[test]
344    fn parse_format_roundtrip() {
345        for s in [
346            "1970-01-01T00:00:00.000000Z",
347            "2000-02-29T12:34:56.654321Z",
348            "2026-07-28T09:15:00.000001Z",
349            OPEN_SENTINEL,
350        ] {
351            assert_eq!(format(parse(s).unwrap()), s, "roundtrip failed for {s}");
352        }
353    }
354
355    /// `parse` has a platform floor and `format` does not, so a test that
356    /// wants an early instant has to ask rather than assume.
357    ///
358    /// `SystemTime` is a `timespec` on Unix and a `FILETIME` on Windows, whose
359    /// own epoch is **1601-01-01** — so `parse("0060-…")` returns a real
360    /// instant on one and `InvalidTimestamp { reason: "not representable as a
361    /// SystemTime on this platform" }` on the other. That refusal predates
362    /// [D-270] and is correct: it is typed, it names the platform, and it
363    /// loses nothing silently. It is also exactly why this defect surfaced on
364    /// the Linux fuzz runner and never on the Windows developer box.
365    fn parses_here(s: &str) -> Option<SystemTime> {
366        match parse(s) {
367            Ok(t) => Some(t),
368            Err(DbError::InvalidTimestamp { reason, .. })
369                if reason.contains("not representable") =>
370            {
371                None
372            }
373            Err(e) => panic!("{s} was rejected for the wrong reason: {e}"),
374        }
375    }
376
377    /// The two bounds of the canonical form are the two bounds of `format`.
378    ///
379    /// They are written as literals in the source, so this is the arithmetic
380    /// that says the literals are the instants they claim to be. Without it a
381    /// transposed digit would clamp silently at the wrong century.
382    #[test]
383    fn the_clamp_sits_exactly_on_the_representable_ends() {
384        assert_eq!(
385            MIN_MICROS,
386            i128::from(days_from_civil(0, 1, 1)) * 86_400 * 1_000_000
387        );
388        assert_eq!(
389            MAX_MICROS,
390            (i128::from(days_from_civil(9999, 12, 31)) * 86_400 + 86_399) * 1_000_000
391                + 999_999
392        );
393
394        // The upper end is post-epoch, so every platform can build it.
395        let sentinel = parse(OPEN_SENTINEL).unwrap();
396        assert_eq!(format(sentinel), OPEN_SENTINEL);
397        assert_eq!(
398            format(sentinel + Duration::from_secs(86_400)),
399            OPEN_SENTINEL,
400            "past the end, still canonical rather than a five-digit year"
401        );
402
403        // The lower end needs a pre-year-0 `SystemTime`, which a Windows
404        // `FILETIME` cannot hold at all.
405        if let Some(zero) = parses_here("0000-01-01T00:00:00.000000Z") {
406            assert_eq!(format(zero), "0000-01-01T00:00:00.000000Z");
407            assert_eq!(
408                format(zero - Duration::from_secs(86_400)),
409                "0000-01-01T00:00:00.000000Z",
410                "before the start, still canonical rather than a negative year"
411            );
412        }
413    }
414
415    /// **A pre-1970 valid time survives the round trip** ([D-270]).
416    ///
417    /// `format` used to saturate at the epoch, so every instant `parse`
418    /// returned for a date between year 0 and 1970 came back as
419    /// `1970-01-01T00:00:00.000000Z`: the wrong day, with no error. Found by
420    /// the `timestamp_parse` fuzz target on `"0060-06-22T22:22:24Z"`, which is
421    /// the last case below and the only one that needs the platform guard.
422    ///
423    /// Valid time is the caller's. A ledger recording when a fact *was* true
424    /// has every reason to carry a date before 1970, and the storage layer has
425    /// always stored and ordered one correctly — only the `SystemTime` round
426    /// trip lost it.
427    ///
428    /// [D-270]: ../../docs/architecture/s13-decision-register.md#d-270
429    #[test]
430    fn a_pre_epoch_instant_round_trips_instead_of_collapsing_to_1970() {
431        // At or after 1601-01-01, so these run everywhere — including the
432        // Windows box where the defect was invisible.
433        for s in [
434            "1601-01-01T00:00:00.000000Z", // the Windows `FILETIME` epoch itself
435            "1900-02-28T12:00:00.000000Z", // 1900 is not a leap year
436            "1969-12-31T23:59:59.999999Z", // one microsecond before the epoch
437            "1969-12-31T23:59:59.000000Z",
438            "1970-01-01T00:00:00.000000Z", // the boundary, from the other side
439        ] {
440            assert_eq!(format(parse(s).unwrap()), s, "roundtrip failed for {s}");
441        }
442
443        // The fuzzer's own input, in the legacy second-precision form it
444        // arrived as, on the platforms that can hold it.
445        if let Some(t) = parses_here("0060-06-22T22:22:24Z") {
446            assert_eq!(format(t), "0060-06-22T22:22:24.000000Z");
447        }
448
449        // And the ordering the whole module exists for still holds across the
450        // epoch, which is what a collapsed stamp would have broken.
451        assert!("0060-06-22T22:22:24.000000Z" < "1970-01-01T00:00:00.000000Z");
452        assert!("1969-12-31T23:59:59.999999Z" < "1970-01-01T00:00:00.000000Z");
453    }
454
455    #[test]
456    fn parse_validates_the_calendar_not_just_the_shape() {
457        for bad in [
458            "2026-02-30T00:00:00.000000Z", // February has no 30th
459            "2026-13-01T00:00:00.000000Z", // no 13th month
460            "2026-00-01T00:00:00.000000Z",
461            "2025-02-29T00:00:00.000000Z", // 2025 is not a leap year
462            "2026-01-01T24:00:00.000000Z",
463            "2026-01-01T00:60:00.000000Z",
464            "2026-01-01T00:00:60.000000Z", // leap second, not representable
465        ] {
466            assert!(parse(bad).is_err(), "should reject {bad:?}");
467        }
468        // ...but a real leap day parses.
469        assert!(parse("2024-02-29T00:00:00.000000Z").is_ok());
470    }
471}