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
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}/"))