Skip to main content

nedb_engine/
wallclock.rs

1// SPDX-FileCopyrightText: 2026 INTERCHAINED LLC
2// SPDX-License-Identifier: BUSL-1.1
3// NEDB · © 2026 INTERCHAINED LLC × Eth-Interchained × Vex (Claude Opus 5)
4
5//! Wall-clock parsing for `AS OF SYSTEM TIME '<datetime>'`.
6//!
7//! Zero-dependency by the same rule the daemon keeps: ISO-8601 subsets and
8//! unix timestamps are grammar, not an ecosystem. What is accepted:
9//!
10//! | form | example | meaning |
11//! |---|---|---|
12//! | RFC 3339 / ISO datetime | `2026-09-15T17:00:00Z`, `2026-09-15 17:00:00` | wall-clock moment |
13//! | date only | `2026-09-15` | midnight UTC of that day |
14//! | epoch with unit | `1757955600s`, `1757955600000ms` | wall-clock moment (explicit — see below) |
15//!
16//! A **bare integer stays a sequence number** and never reaches this module:
17//! that is the entire backcompat contract. Every query that worked before this
18//! existed means the same thing it always did. A unix value must carry an
19//! explicit `s`/`ms` suffix — silently reinterpreting `1757955600` as "September
20//! 2026" would answer a question about the past with whatever happens to be at
21//! seq 1.7 billion, which is the same failure shape as dropping an `AS OF`
22//! silently: a confident answer to a question nobody asked.
23//!
24//! Fractional seconds are accepted in the datetime form. Offsets other than
25//! `Z` (`+02:00`) are accepted and normalised; a naive datetime (no offset)
26//! is read as **UTC**, stated here rather than guessed per deployment.
27//!
28//! The resolution rule — "the last write at or before T" — lives with the
29//! engine's `ts` index (`Db::seq_at`), not here. This module only turns text
30//! into a moment.
31
32/// A parsed wall-clock moment, as epoch seconds (fractional).
33#[derive(Debug, Clone, Copy, PartialEq)]
34pub struct WallClock(pub f64);
35
36#[derive(Debug)]
37pub enum WallClockError {
38    /// Not recognizable as any accepted form.
39    Unrecognized(String),
40    /// Recognizable shape, impossible value (month 13, day 32, hour 25).
41    OutOfRange(&'static str),
42}
43
44impl std::error::Error for WallClockError {}
45
46impl std::fmt::Display for WallClockError {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            WallClockError::Unrecognized(s) => write!(f, "unrecognized datetime {:?}", s),
50            WallClockError::OutOfRange(what) => write!(f, "datetime out of range: {}", what),
51        }
52    }
53}
54
55impl WallClock {
56    /// Parse an accepted wall-clock form. `raw` is the UNQUOTED literal text.
57    pub fn parse(raw: &str) -> Result<WallClock, WallClockError> {
58        let s = raw.trim();
59        if s.is_empty() {
60            return Err(WallClockError::Unrecognized(raw.to_string()));
61        }
62
63        // Epoch with explicit unit: `1757955600s` / `1757955600000ms`.
64        let lower = s.to_ascii_lowercase();
65        if let Some(digits) = lower.strip_suffix("ms") {
66            let v: f64 = digits
67                .parse()
68                .map_err(|_| WallClockError::Unrecognized(raw.to_string()))?;
69            return Ok(WallClock(v / 1_000.0));
70        }
71        if let Some(digits) = lower.strip_suffix('s') {
72            if digits
73                .chars()
74                .all(|c| c.is_ascii_digit() || c == '.' || c == '-')
75            {
76                let v: f64 = digits
77                    .parse()
78                    .map_err(|_| WallClockError::Unrecognized(raw.to_string()))?;
79                return Ok(WallClock(v));
80            }
81        }
82
83        // Datetime forms. Everything else must start with a date.
84        let (date_part, rest) =
85            split_date(s).ok_or_else(|| WallClockError::Unrecognized(raw.to_string()))?;
86        let (y, m, d) = parse_ymd(&date_part)?;
87
88        // Default clock: midnight UTC of the named day.
89        let mut sec: f64 = 0.0;
90        let mut offset_sec: f64 = 0.0;
91
92        let rest = rest.trim_start();
93        if !rest.is_empty() {
94            // Separator: 'T', 't', or a single space.
95            let rest = rest.strip_prefix(['T', 't', ' ']).unwrap_or(rest);
96            if rest.is_empty() {
97                // `2026-09-15 ` with trailing space — fine, midnight.
98            } else {
99                let (hms, tail) = split_time(rest)
100                    .ok_or_else(|| WallClockError::Unrecognized(raw.to_string()))?;
101                sec = parse_hms(&hms)?;
102                let tail = tail.trim();
103                if !tail.is_empty() {
104                    offset_sec = parse_offset(tail)?;
105                }
106            }
107        }
108
109        let days = days_from_civil(y, m, d).ok_or(WallClockError::OutOfRange("date"))?;
110        // days_from_civil returns whole days since 1970-01-01.
111        let epoch = days as f64 * 86_400.0 + sec - offset_sec;
112        Ok(WallClock(epoch))
113    }
114
115    /// The epoch-seconds moment.
116    pub fn epoch_secs(&self) -> f64 {
117        self.0
118    }
119
120    /// The AS OF marker for a wall-clock moment: `WALL_CLOCK_FLAG | epoch_ms`.
121    ///
122    /// The clause's marker slot is a `u64` the executor reads as a sequence.
123    /// A wall-clock moment rides in the same slot but TAGGED with the high
124    /// bit, which no real sequence ever sets (a store would need 2^63 writes
125    /// to reach it — itcd's production chainstate sits at ~10^6). The layer
126    /// between parser and executor — the place holding both the marker and
127    /// the `Db` — checks the flag: set, resolve through `Db::seq_at` to a
128    /// real seq; clear, pass through untouched. Bare integers keep their
129    /// meaning bit-for-bit; that is the backcompat contract.
130    pub fn as_marker(&self) -> u64 {
131        WALL_CLOCK_FLAG | ((self.0 * 1000.0).round() as u64)
132    }
133
134    /// Decode a marker produced by [`as_marker`], if it is one.
135    pub fn from_marker(marker: u64) -> Option<WallClock> {
136        if marker & WALL_CLOCK_FLAG == 0 {
137            return None; // a plain sequence, not a wall-clock moment
138        }
139        Some(WallClock((marker & !WALL_CLOCK_FLAG) as f64 / 1000.0))
140    }
141}
142
143/// High bit tagging a marker as a wall-clock moment rather than a sequence.
144pub const WALL_CLOCK_FLAG: u64 = 1u64 << 63;
145
146/// Split a leading `YYYY-MM-DD` off; returns the rest after it.
147fn split_date(s: &str) -> Option<(&str, &str)> {
148    let b = s.as_bytes();
149    if b.len() < 10 {
150        return None;
151    }
152    // YYYY-MM-DD exactly (10 chars, digits at the right spots).
153    if !(b[0].is_ascii_digit()
154        && b[1].is_ascii_digit()
155        && b[2].is_ascii_digit()
156        && b[3].is_ascii_digit()
157        && b[4] == b'-'
158        && b[5].is_ascii_digit()
159        && b[6].is_ascii_digit()
160        && b[7] == b'-'
161        && b[8].is_ascii_digit()
162        && b[9].is_ascii_digit())
163    {
164        return None;
165    }
166    Some((&s[..10], &s[10..]))
167}
168
169/// Split a leading `HH:MM[:SS[.frac]]` off; returns the rest after it.
170fn split_time(s: &str) -> Option<(&str, &str)> {
171    let b = s.as_bytes();
172    // HH:MM minimum (5 chars), optional :SS(.frac)
173    let mut end = 0usize;
174    let seen_colon = b.first() != Some(&b':');
175    let _ = seen_colon;
176    while end < b.len() && (b[end].is_ascii_digit() || b[end] == b':') {
177        end += 1;
178    }
179    if end < 5 {
180        return None;
181    }
182    let mut hms_end = end;
183    // A fractional part attaches to seconds: HH:MM.frac is malformed — but
184    // accept `HH:MM:SS.frac` by pulling digits/dot that followed the last colon
185    // group. The strict shape is validated in parse_hms.
186    if end < b.len() && b[end] == b'.' {
187        hms_end += 1;
188        while hms_end < b.len() && b[hms_end].is_ascii_digit() {
189            hms_end += 1;
190        }
191    }
192    Some((&s[..hms_end], &s[hms_end..]))
193}
194
195fn parse_ymd(s: &str) -> Result<(i64, u32, u32), WallClockError> {
196    let y: i64 = s[0..4]
197        .parse()
198        .map_err(|_| WallClockError::Unrecognized(s.to_string()))?;
199    let m: u32 = s[5..7]
200        .parse()
201        .map_err(|_| WallClockError::Unrecognized(s.to_string()))?;
202    let d: u32 = s[8..10]
203        .parse()
204        .map_err(|_| WallClockError::Unrecognized(s.to_string()))?;
205    if !(1..=12).contains(&m) {
206        return Err(WallClockError::OutOfRange("month must be 01–12"));
207    }
208    if !(1..=31).contains(&d) {
209        return Err(WallClockError::OutOfRange("day must be 01–31"));
210    }
211    Ok((y, m, d))
212}
213
214fn parse_hms(s: &str) -> Result<f64, WallClockError> {
215    let parts: Vec<&str> = s.split(':').collect();
216    if parts.is_empty() || parts.len() > 3 {
217        return Err(WallClockError::Unrecognized(s.to_string()));
218    }
219    let h: f64 = parts[0]
220        .parse()
221        .map_err(|_| WallClockError::Unrecognized(s.to_string()))?;
222    if !(0.0..24.0).contains(&h) {
223        return Err(WallClockError::OutOfRange("hour must be 00–23"));
224    }
225    let (m, sec_part) = match parts.len() {
226        1 => (0.0, None),
227        2 => (
228            parts[1]
229                .parse::<f64>()
230                .map_err(|_| WallClockError::Unrecognized(s.to_string()))?,
231            None,
232        ),
233        _ => (
234            parts[1]
235                .parse::<f64>()
236                .map_err(|_| WallClockError::Unrecognized(s.to_string()))?,
237            Some(parts[2]),
238        ),
239    };
240    if !(0.0..60.0).contains(&m) {
241        return Err(WallClockError::OutOfRange("minute must be 00–59"));
242    }
243    let mut total = h * 3600.0 + m * 60.0;
244    if let Some(sp) = sec_part {
245        // `SS.fraction`
246        let mut seg = sp.split('.');
247        let ss: f64 = seg
248            .next()
249            .unwrap_or("0")
250            .parse()
251            .map_err(|_| WallClockError::Unrecognized(s.to_string()))?;
252        if !(0.0..60.0).contains(&ss) {
253            return Err(WallClockError::OutOfRange("second must be 00–59"));
254        }
255        total += ss;
256        if let Some(frac) = seg.next() {
257            let frac_val = format!("0.{}", frac)
258                .parse::<f64>()
259                .map_err(|_| WallClockError::Unrecognized(s.to_string()))?;
260            total += frac_val;
261        }
262    }
263    Ok(total)
264}
265
266/// Parse a trailing offset: `Z`, `z`, `+HH:MM`, `-HHMM`, `+HH`.
267fn parse_offset(s: &str) -> Result<f64, WallClockError> {
268    let up = s.to_ascii_uppercase();
269    if up == "Z" {
270        return Ok(0.0);
271    }
272    let (sign, body) = match up.strip_prefix('+') {
273        Some(b) => (1.0, b),
274        None => match up.strip_prefix('-') {
275            Some(b) => (-1.0, b),
276            None => return Err(WallClockError::Unrecognized(s.to_string())),
277        },
278    };
279    let digits: String = body.chars().filter(|c| c.is_ascii_digit()).collect();
280    let (h, m) = match digits.len() {
281        2 => (digits.parse::<f64>().unwrap_or(0.0), 0.0),
282        4 => {
283            let h: f64 = digits[..2]
284                .parse()
285                .map_err(|_| WallClockError::Unrecognized(s.to_string()))?;
286            let m: f64 = digits[2..]
287                .parse()
288                .map_err(|_| WallClockError::Unrecognized(s.to_string()))?;
289            (h, m)
290        }
291        _ => return Err(WallClockError::Unrecognized(s.to_string())),
292    };
293    if !(0.0..24.0).contains(&h) || !(0.0..60.0).contains(&m) {
294        return Err(WallClockError::OutOfRange("offset out of range"));
295    }
296    Ok(sign * (h * 3600.0 + m * 60.0))
297}
298
299/// Days since 1970-01-01 for a proleptic-Gregorian Y/M/D. `None` when the
300/// date does not exist (Feb 30, Apr 31, …).
301fn days_from_civil(y: i64, m: u32, d: u32) -> Option<i64> {
302    // Days per month, with the leap rule applied for the actual year.
303    let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
304    let month_lens = [
305        31u32,
306        if leap { 29 } else { 28 },
307        31,
308        30,
309        31,
310        30,
311        31,
312        31,
313        30,
314        31,
315        30,
316        31,
317    ];
318    let ml = *month_lens.get((m as usize).saturating_sub(1))?;
319    if d > ml {
320        return None;
321    }
322    // Howard Hinnant's days_from_civil algorithm.
323    let y2 = if m <= 2 { y - 1 } else { y };
324    let era = if y2 >= 0 { y2 } else { y2 - 399 } / 400;
325    let yoe: i64 = y2 - era * 400;
326    let mp: i64 = m as i64 + if m as i64 > 2 { -3 } else { 9 };
327    let doy = (153 * mp + 2) / 5 + (d as i64) - 1;
328    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
329    Some(era * 146_097 + doe - 719_468)
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn epoch_date_is_zero() {
338        assert_eq!(WallClock::parse("1970-01-01").unwrap().epoch_secs(), 0.0);
339        assert_eq!(
340            WallClock::parse("1970-01-01T00:00:00Z")
341                .unwrap()
342                .epoch_secs(),
343            0.0
344        );
345    }
346
347    #[test]
348    fn a_known_moment_parses() {
349        // 2026-09-15T00:00:00Z = 1789459200
350        assert_eq!(
351            WallClock::parse("2026-09-15").unwrap().epoch_secs(),
352            1_789_430_400.0
353        );
354        assert_eq!(
355            WallClock::parse("2026-09-15T00:00:00Z")
356                .unwrap()
357                .epoch_secs(),
358            1_789_430_400.0
359        );
360        assert_eq!(
361            WallClock::parse("2026-09-15 00:00:00")
362                .unwrap()
363                .epoch_secs(),
364            1_789_430_400.0
365        );
366    }
367
368    #[test]
369    fn time_of_day_and_fractions_count() {
370        let w = WallClock::parse("2026-09-15T17:00:00Z").unwrap();
371        assert_eq!(w.epoch_secs(), 1_789_430_400.0 + 17.0 * 3600.0);
372        let w2 = WallClock::parse("2026-09-15T17:00:00.5Z").unwrap();
373        assert_eq!(w2.epoch_secs(), 1_789_430_400.0 + 17.0 * 3600.0 + 0.5);
374    }
375
376    #[test]
377    fn offsets_shift_to_utc() {
378        // 17:00+02:00 == 15:00Z
379        let w = WallClock::parse("2026-09-15T17:00:00+02:00").unwrap();
380        assert_eq!(w.epoch_secs(), 1_789_430_400.0 + 15.0 * 3600.0);
381        // -0500 without colon
382        let w2 = WallClock::parse("2026-09-15T12:00:00-0500").unwrap();
383        assert_eq!(w2.epoch_secs(), 1_789_430_400.0 + 17.0 * 3600.0);
384        // lowercase z
385        assert_eq!(
386            WallClock::parse("2026-09-15T00:00:00z")
387                .unwrap()
388                .epoch_secs(),
389            1_789_430_400.0
390        );
391    }
392
393    #[test]
394    fn explicit_units_are_accepted_and_scaled() {
395        assert_eq!(
396            WallClock::parse("1757955600s").unwrap().epoch_secs(),
397            1_757_955_600.0
398        );
399        assert_eq!(
400            WallClock::parse("1757955600000ms").unwrap().epoch_secs(),
401            1_757_955_600.0
402        );
403        assert_eq!(
404            WallClock::parse("1757955600.5s").unwrap().epoch_secs(),
405            1_757_955_600.5
406        );
407    }
408
409    #[test]
410    fn impossible_dates_are_range_errors_not_silence() {
411        assert!(matches!(
412            WallClock::parse("2026-02-30"),
413            Err(WallClockError::OutOfRange("date"))
414        ));
415        assert!(matches!(
416            WallClock::parse("2026-13-01"),
417            Err(WallClockError::OutOfRange(_))
418        ));
419        assert!(matches!(
420            WallClock::parse("2026-09-15T25:00:00Z"),
421            Err(WallClockError::OutOfRange(_))
422        ));
423        // Leap year works; non-leap Feb 29 fails.
424        assert!(WallClock::parse("2024-02-29").is_ok());
425        assert!(matches!(
426            WallClock::parse("2026-02-29"),
427            Err(WallClockError::OutOfRange("date"))
428        ));
429    }
430
431    #[test]
432    fn garbage_is_unrecognized() {
433        for bad in ["not a time", "15/09/2026", "sep 15", "2026-9-15", "", "  "] {
434            assert!(
435                matches!(WallClock::parse(bad), Err(WallClockError::Unrecognized(_))),
436                "expected Unrecognized for {:?}",
437                bad
438            );
439        }
440        // A unix-looking value WITHOUT a unit is not wall-clock (it never
441        // reaches this module in the clause — bare integers are seqs) — but
442        // the parser itself should also refuse to bless it as a date.
443        assert!(matches!(
444            WallClock::parse("1757955600"),
445            Err(WallClockError::Unrecognized(_))
446        ));
447    }
448}