Give the store a swappable clock
Release binaries were running on the frozen fixture clock and
referencing fixture code; Clock lets production keep real time while
dev and tests pin fixture ages.

Assisted-by: GLM-5.3 via pi
change ulmrtuxwlomrqlmrvyyzykzozlptktov
commit ecd2ee6f24042aa20fae6b224f367c15f6952881
author Alpha Chen <alpha@kejadlen.dev>
date
parent mktnzmmt
diff --git a/src/bin/docket/main.rs b/src/bin/docket/main.rs
index 6d625fe..04226e7 100644
--- a/src/bin/docket/main.rs
+++ b/src/bin/docket/main.rs
@@ -18,9 +18,13 @@ async fn main() -> miette::Result<()> {
 
     let args = config::Args::parse();
     let config = config::Config::load(&args.config)?;
-    // The fixtures' clock keeps their ages stable until JMAP sync brings
-    // real mail.
-    let store = docket::store::Store::open(&config.database, docket::fixtures::now())?;
+    // Dev pins the fixture clock so sample ages stay stable; production
+    // runs on real time.
+    #[cfg(feature = "dev")]
+    let clock = docket::store::Clock::Fixed(docket::fixtures::now());
+    #[cfg(not(feature = "dev"))]
+    let clock = docket::store::Clock::System;
+    let store = docket::store::Store::open(&config.database, clock)?;
     #[cfg(feature = "dev")]
     if store.is_empty()? {
         docket::fixtures::seed(&store)?;
diff --git a/src/fixtures.rs b/src/fixtures.rs
index f5534b4..42d1894 100644
--- a/src/fixtures.rs
+++ b/src/fixtures.rs
@@ -8,7 +8,7 @@ use jiff::civil::{DateTime, date};
 
 use crate::Error;
 use crate::model::{Account, Comment, Kind, Message, MessageId, State, Thread, User, Values};
-use crate::store::Store;
+use crate::store::{Clock, Store};
 
 pub const WATER: MessageId = 7;
 pub const ELI_PRACTICE: MessageId = 20;
@@ -123,7 +123,7 @@ impl Builder {
 
 /// A fresh in-memory store holding the fixtures.
 pub fn store() -> Result<Store, Error> {
-    let store = Store::open_in_memory(now())?;
+    let store = Store::open_in_memory(Clock::Fixed(now()))?;
     seed(&store)?;
     Ok(store)
 }
diff --git a/src/routes.rs b/src/routes.rs
index bb89cf0..eec248b 100644
--- a/src/routes.rs
+++ b/src/routes.rs
@@ -145,7 +145,7 @@ fn render(
     Ok(views::page(&Page {
         me,
         view,
-        now: store.now,
+        now: store.now(),
         users: store.users()?,
         folders: store.folders()?,
         nav,
diff --git a/src/store.rs b/src/store.rs
index dea0cf8..d89d6a3 100644
--- a/src/store.rs
+++ b/src/store.rs
@@ -55,12 +55,29 @@ pub enum Filter<'a> {
     Search(&'a str),
 }
 
+/// Where "now" comes from: the fixed clock fixtures are written against,
+/// or the system clock for a live server.
+#[derive(Debug, Clone, Copy)]
+pub enum Clock {
+    Fixed(DateTime),
+    System,
+}
+
+impl Clock {
+    fn now(self) -> DateTime {
+        match self {
+            Clock::Fixed(at) => at,
+            Clock::System => jiff::Zoned::now().datetime(),
+        }
+    }
+}
+
 /// One connection behind a mutex: two people's clicks never contend long
 /// enough to need a pool, and no lock is held across an await.
 #[derive(Debug)]
 pub struct Store {
-    /// The clock fixtures are written against, so ages stay stable.
-    pub now: DateTime,
+    /// The clock events and comments are stamped with.
+    clock: Clock,
     inner: Mutex<Inner>,
 }
 
@@ -74,22 +91,22 @@ struct Inner {
 impl Store {
     /// Opens (creating if needed) the database file and brings its schema
     /// up to date.
-    pub fn open(path: &Utf8Path, now: DateTime) -> Result<Self, Error> {
+    pub fn open(path: &Utf8Path, clock: Clock) -> Result<Self, Error> {
         let conn = Connection::open(path)?;
         conn.pragma_update(None, "journal_mode", "WAL")?;
-        Self::init(conn, now)
+        Self::init(conn, clock)
     }
 
     /// A database that lasts as long as the store, for tests.
-    pub fn open_in_memory(now: DateTime) -> Result<Self, Error> {
-        Self::init(Connection::open_in_memory()?, now)
+    pub fn open_in_memory(clock: Clock) -> Result<Self, Error> {
+        Self::init(Connection::open_in_memory()?, clock)
     }
 
-    fn init(mut conn: Connection, now: DateTime) -> Result<Self, Error> {
+    fn init(mut conn: Connection, clock: Clock) -> Result<Self, Error> {
         conn.pragma_update(None, "foreign_keys", true)?;
         migrate(&mut conn)?;
         Ok(Self {
-            now,
+            clock,
             inner: Mutex::new(Inner {
                 conn,
                 undo: BTreeMap::new(),
@@ -98,6 +115,11 @@ impl Store {
         })
     }
 
+    /// The current time per this store's clock.
+    pub fn now(&self) -> DateTime {
+        self.clock.now()
+    }
+
     /// True before anyone has been added: a fresh database.
     pub fn is_empty(&self) -> Result<bool, Error> {
         let inner = self.lock();
@@ -330,7 +352,7 @@ impl Store {
         };
 
         write_values(&tx, id, &next)?;
-        insert_event(&tx, id, user, self.now, &text)?;
+        insert_event(&tx, id, user, self.now(), &text)?;
         tx.commit()?;
         inner.undo.insert(user.to_owned(), (id, prev));
         inner.flash.insert(
@@ -352,7 +374,7 @@ impl Store {
             .ok_or(Error::BadRequest("nothing to undo"))?;
         let tx = inner.conn.transaction()?;
         write_values(&tx, id, &prev)?;
-        insert_event(&tx, id, user, self.now, "Undone")?;
+        insert_event(&tx, id, user, self.now(), "Undone")?;
         tx.commit()?;
         inner.undo.remove(user);
         inner.flash.insert(
@@ -383,7 +405,7 @@ impl Store {
         if !exists {
             return Err(Error::NotFound("thread"));
         }
-        insert_comment(&inner.conn, None, thread, user, self.now, text)?;
+        insert_comment(&inner.conn, None, thread, user, self.now(), text)?;
         Ok(())
     }
 
@@ -739,8 +761,8 @@ mod tests {
         assert_eq!(
             history,
             [
-                (SAM.into(), store.now, "Moved to Do".into()),
-                (SAM.into(), store.now, "Undone".into()),
+                (SAM.into(), store.now(), "Moved to Do".into()),
+                (SAM.into(), store.now(), "Undone".into()),
             ]
         );
     }
@@ -906,9 +928,18 @@ mod tests {
         assert!(store.thread_account(999).unwrap().is_none());
     }
 
+    #[test]
+    fn the_system_clock_reads_real_time() {
+        let drift = (Clock::System.now() - jiff::Zoned::now().datetime())
+            .total(jiff::Unit::Second)
+            .unwrap()
+            .abs();
+        assert!(drift < 60.0, "system clock drifted {drift}s");
+    }
+
     #[test]
     fn signing_in_adds_users_and_follows_their_slug() {
-        let store = Store::open_in_memory(fixtures::now()).unwrap();
+        let store = Store::open_in_memory(Clock::Fixed(fixtures::now())).unwrap();
         assert!(store.is_empty().unwrap());
         let pat = store.sign_in("pat@example.com", "pat").unwrap();
         assert_eq!(pat, User::new("pat@example.com", "pat"));
@@ -940,7 +971,7 @@ mod tests {
         let dir = tempfile::tempdir().unwrap();
         let path = camino::Utf8PathBuf::try_from(dir.path().join("docket.db")).unwrap();
 
-        let store = Store::open(&path, fixtures::now()).unwrap();
+        let store = Store::open(&path, Clock::Fixed(fixtures::now())).unwrap();
         assert!(store.is_empty().unwrap());
         fixtures::seed(&store).unwrap();
         assert!(!store.is_empty().unwrap());
@@ -952,7 +983,7 @@ mod tests {
         store.mark_read(SAM, fixtures::WATER).unwrap();
         drop(store);
 
-        let store = Store::open(&path, fixtures::now()).unwrap();
+        let store = Store::open(&path, Clock::Fixed(fixtures::now())).unwrap();
         assert!(!store.is_empty().unwrap());
         let v = values(&store, 4);
         assert_eq!(v.state, State::Wait);
diff --git a/tests/api.rs b/tests/api.rs
index 64ec318..64f8669 100644
--- a/tests/api.rs
+++ b/tests/api.rs
@@ -443,7 +443,10 @@ mod dev {
 
     #[tokio::test]
     async fn with_no_users_is_unauthenticated() {
-        let store = docket::store::Store::open_in_memory(docket::fixtures::now()).unwrap();
+        let store = docket::store::Store::open_in_memory(docket::store::Clock::Fixed(
+            docket::fixtures::now(),
+        ))
+        .unwrap();
         let addr = spawn_dev(store).await;
         let res = client()
             .get(format!("http://{addr}/"))