Skip to main content

recall_server/
lib.rs

1//! Recall's server half: SQLite persistence, LLM-assisted merge, and the
2//! HTTP API.
3//!
4//! The routes, status codes, JSON shapes and auth behavior are deliberately
5//! identical to the Node implementation this replaces, because during a
6//! migration a machine still on the old client and one already on this
7//! binary talk to the same deployment.
8
9#![deny(missing_docs)]
10
11pub mod admin;
12pub mod audit;
13pub mod bootstrap;
14pub mod config;
15pub mod merge;
16pub mod server;
17pub mod store;
18
19pub use config::{Config, ConfigError, TlsMode};
20pub use merge::Merger;
21pub use server::Server;
22pub use store::Store;
23
24use time::OffsetDateTime;
25
26/// A timestamp in the format every stored row and every API response uses:
27/// JavaScript's `Date.toISOString()` — millisecond precision, `Z` suffix,
28/// e.g. `2026-09-03T21:49:55.191Z`.
29///
30/// Rows written by the Node server are already in the database in this
31/// shape, and clients and the admin page display them, so the format is
32/// part of the compatibility surface rather than a style choice.
33pub fn now() -> String {
34    format_timestamp(OffsetDateTime::now_utc())
35}
36
37/// [`now`]'s format, for a moment other than now: an expiry, or the
38/// cut-off a sweep compares against. Being one fixed-width format, two of
39/// these compare as strings in the same order as the times they name, which
40/// is what lets SQLite compare them.
41pub(crate) fn format_timestamp(at: OffsetDateTime) -> String {
42    // `[subsecond digits:3]` is load-bearing: it must render actual
43    // milliseconds, not three literal zeroes.
44    let fmt = time::macros::format_description!(
45        "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"
46    );
47    at.to_offset(time::UtcOffset::UTC)
48        .format(&fmt)
49        .expect("the timestamp format is a compile-time constant")
50}
51
52/// Reads a timestamp [`now`] wrote. [`None`] for anything else.
53pub(crate) fn parse_timestamp(text: &str) -> Option<OffsetDateTime> {
54    OffsetDateTime::parse(text, &time::format_description::well_known::Rfc3339).ok()
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    #[test]
62    fn timestamps_read_back_to_the_same_moment() {
63        let at = OffsetDateTime::from_unix_timestamp_nanos(1_788_472_195_191_000_000).unwrap();
64        assert_eq!(parse_timestamp(&format_timestamp(at)), Some(at));
65        assert_eq!(parse_timestamp("yesterday"), None);
66    }
67
68    #[test]
69    fn timestamps_match_javascripts_toisostring() {
70        let t = now();
71        assert_eq!(t.len(), 24, "got {t}");
72        assert!(t.ends_with('Z'), "got {t}");
73        assert_eq!(&t[10..11], "T", "got {t}");
74        assert_eq!(&t[19..20], ".", "got {t}");
75        assert!(
76            t[20..23].chars().all(|c| c.is_ascii_digit()),
77            "milliseconds must be digits, got {t}"
78        );
79    }
80
81    /// The Go port had a bug where the layout rendered three literal zeroes
82    /// instead of milliseconds. Any real sub-second value must survive.
83    #[test]
84    fn renders_real_milliseconds_not_literal_zeroes() {
85        let at = OffsetDateTime::from_unix_timestamp_nanos(1_788_472_195_191_000_000).unwrap();
86        assert_eq!(format_timestamp(at), "2026-09-03T21:49:55.191Z");
87    }
88}