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}