//! Getting a declared kind in front of a person, once. //! //! The split this crate rests on, stated on the other side: what a //! notification says and what it is for is the description; **how it arrives is //! the host's**, exactly as a control's keys are the terminal renderer's and //! not the description's. So a host writes [`Deliver`], which is one method, //! and everything a host would otherwise re-answer lives in [`Outbox`]. //! //! # What an outbox owes, and where the list came from //! //! goingson's watcher is 483 lines of working code and four of its behaviours //! are not about goingson at all. Each one below is a bug the next app would //! have written for itself: //! //! 1. **The kind's `enabled` key is consulted before anything is sent.** An off //! kind produces no call, so a host adapter never has to know that settings //! exist. //! 2. **A duplicate is suppressed by identity, not by timing.** The watcher //! ticks every 60 seconds over a query that keeps answering; without the //! notified-set it would fire the same reminder every minute until the row //! changed. //! 3. **A restart does not spam.** See [`CatchUp`], which is declared per kind //! because goingson answers it differently for its two: a snooze that //! expired overnight still fires, and an event reminder for a meeting that //! started an hour ago does not. //! 4. **The memory is bounded.** The watcher clears its sets at ten thousand //! entries, and clearing one has to reset that kind's restart bootstrap or //! the next pass fires everything the set was holding back. //! //! # What is out of scope, and stays out //! //! Server-mediated push (APNs/FCM), declined with `ce3be80a`. Its costs stand: //! credentials, a device-token table, a send path, and the server learning //! enough about a reminder to send it, which cuts against SyncKit's E2E //! posture. Nothing here forecloses it -- a push adapter is a [`Deliver`] like //! any other -- and nothing here reaches for it. use crate::{CatchUp, Registry, config::Settings}; use std::collections::{HashMap, HashSet}; /// One notification about to happen, or not. /// /// Not a [`Kind`](crate::Kind). A kind is "snoozed items resurface", declared /// once; an occurrence is the one about the task that came back at 4pm, built /// where the app noticed. The kind carries everything a settings pane needs and /// this carries everything a person reads. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Occurrence { /// The declared [`Kind::id`](crate::Kind::id) this is one of. pub kind: &'static str, /// What makes this occurrence *this* one. /// /// The duplicate-suppression identity, and the only thing an outbox /// remembers. A task id for a snooze; an event id and its offset for a /// reminder, because one event has several and each fires once. /// /// It has to be stable across ticks: an identity built from the current /// time is a new identity every tick, which is the same as having none. pub token: String, /// The line the reader sees first. pub title: String, /// The rest of it. pub body: String, } impl Occurrence { /// An occurrence of `kind`, identified by `token`. pub fn new( kind: &'static str, token: impl Into, title: impl Into, body: impl Into, ) -> Self { Self { kind, token: token.into(), title: title.into(), body: body.into(), } } } /// A host's way of putting an [`Occurrence`] in front of a person. /// /// One method, and it cannot fail in a way the caller can act on: a host that /// could not show a notification has already lost the occurrence, and an /// outbox that retried would be a queue nobody asked for. Log it and carry on, /// which is what goingson's `send_notification` does today. pub trait Deliver { /// Show it. fn deliver(&mut self, note: &Occurrence); } /// What an outbox did with an occurrence, and why. /// /// Returned rather than logged, so a caller can count what it suppressed. That /// matters most for [`Unknown`](Self::Unknown), which is a bug in the app /// rather than a preference of the reader. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Sent { /// Handed to the host. Delivered, /// The reader has this kind off. Off, /// Already delivered under this token. Duplicate, /// It came due while the app was shut, and the kind declares /// [`CatchUp::Skip`]. Late, /// No such kind is declared. Nothing is sent, ever, for one of these. Unknown, } impl Sent { /// Whether it reached the host. #[must_use] pub const fn delivered(self) -> bool { matches!(self, Self::Delivered) } } /// The shared half of delivery: suppression, restart and bounded memory. /// /// Holds a registry and one host adapter. One per app, alongside the registry /// it reads, and it is `!Sync` by nature rather than by design -- the watcher /// that owns it is a single task, and two outboxes over one adapter would each /// have half the memory of what has already been sent. #[derive(Debug)] pub struct Outbox { registry: Registry, adapter: D, /// Per kind, the tokens already delivered. seen: HashMap<&'static str, HashSet>, /// The kinds that have finished a pass since this outbox was built. swept: HashSet<&'static str>, remember: usize, } /// How many tokens a kind remembers before the set is dropped. /// /// goingson's number, and its reasoning holds: a token is small, and clearing /// too eagerly re-fires an occurrence whose follow-up write failed. pub const REMEMBER: usize = 10_000; impl Outbox { /// An outbox over this registry, delivering through this adapter. pub fn new(registry: Registry, adapter: D) -> Self { Self { registry, adapter, seen: HashMap::new(), swept: HashSet::new(), remember: REMEMBER, } } /// Remember this many tokens per kind rather than [`REMEMBER`], chaining. #[must_use] pub const fn remembering(mut self, tokens: usize) -> Self { self.remember = tokens; self } /// The adapter, for a host that has more to say to its own. pub const fn adapter(&mut self) -> &mut D { &mut self.adapter } /// Deliver one occurrence, if everything says it should be delivered. /// /// A one-shot [`sweep`](Self::sweep): the kind counts as having finished a /// pass afterwards, so a [`CatchUp::Skip`] kind suppresses the first /// occurrence offered this way and delivers the rest. A kind that finds its /// due occurrences several at a time wants [`sweep`](Self::sweep) instead, /// or its first pass will deliver everything after the first. pub fn offer(&mut self, note: &Occurrence, settings: &impl Settings) -> Sent { let sent = self.consider(note, settings); self.swept.insert(note.kind); sent } /// Offer a whole pass of one kind's due occurrences. /// /// The pass is what [`CatchUp`] is about: everything offered before the /// [`Sweep`] is dropped belongs to the same look at the world, and for a /// [`CatchUp::Skip`] kind the first such look is the one that is noted and /// not delivered. pub fn sweep<'a>(&'a mut self, kind: &'static str) -> Sweep<'a, D> { Sweep { outbox: self, kind } } /// Whether this kind has finished a pass since the app started. #[must_use] pub fn swept(&self, kind: &str) -> bool { self.swept.contains(kind) } /// Whether this token has already been delivered under this kind. #[must_use] pub fn seen(&self, kind: &str, token: &str) -> bool { self.seen.get(kind).is_some_and(|set| set.contains(token)) } /// Everything except the bookkeeping about who has finished a pass. fn consider(&mut self, note: &Occurrence, settings: &impl Settings) -> Sent { let Some(kind) = self.registry.kind(note.kind) else { return Sent::Unknown; }; let restart = kind.restart; if !self.registry.is_on(note.kind, settings) { return Sent::Off; } if self.seen(note.kind, ¬e.token) { return Sent::Duplicate; } // Noted either way. A skipped occurrence must not be reconsidered on // the next pass, which is the whole of what "already missed" means. self.note(note.kind, note.token.clone()); if restart == CatchUp::Skip && !self.swept(note.kind) { return Sent::Late; } self.adapter.deliver(note); Sent::Delivered } /// Remember a token, forgetting the kind's whole set if it has grown past /// the bound. fn note(&mut self, kind: &'static str, token: String) { let set = self.seen.entry(kind).or_default(); if set.len() >= self.remember { // Dropping the set makes every token in it deliverable again, so a // kind that suppresses late occurrences has to go back through its // bootstrap or the next pass fires everything the set was holding // back. goingson's watcher does exactly this, and it is the one // interaction between the two mechanisms. set.clear(); self.swept.remove(kind); } self.seen.entry(kind).or_default().insert(token); } } /// One pass of a kind's due occurrences. See [`Outbox::sweep`]. /// /// The pass ends when this is dropped, which is what makes a /// [`CatchUp::Skip`] kind's *first* pass the quiet one rather than its first /// occurrence. #[derive(Debug)] pub struct Sweep<'a, D> { outbox: &'a mut Outbox, kind: &'static str, } impl Sweep<'_, D> { /// Offer one occurrence in this pass. /// /// An occurrence of another kind is still handled correctly -- it is the /// registry that decides, not this guard -- but only [`kind`](Self::kind) /// finishes its pass when the sweep ends. pub fn offer(&mut self, note: &Occurrence, settings: &impl Settings) -> Sent { self.outbox.consider(note, settings) } /// The kind whose pass this is. #[must_use] pub const fn kind(&self) -> &'static str { self.kind } } impl Drop for Sweep<'_, D> { fn drop(&mut self) { self.outbox.swept.insert(self.kind); } } #[cfg(test)] mod tests { use super::*; use crate::{Kind, Registry, config::Unset}; use std::collections::HashMap; static KINDS: &[Kind] = &[ Kind::new( "snooze-expiry", "Snoozed items resurface", "When something you snoozed comes back.", "Reminders", ) .shipping_on(), Kind::new( "event-reminder", "Event reminders", "Before an event starts.", "Reminders", ) .shipping_on() .quiet_after_restart(), Kind::new("digest", "Daily digest", "One summary.", "Summaries"), ]; static NOTIFS: Registry = Registry::new(KINDS); /// Every occurrence it was handed, in order. #[derive(Debug, Default)] struct Spy(Vec); impl Deliver for Spy { fn deliver(&mut self, note: &Occurrence) { self.0.push(format!("{}:{}", note.kind, note.token)); } } fn outbox() -> Outbox { Outbox::new(NOTIFS, Spy::default()) } fn stored(pairs: &[(&str, &str)]) -> impl Settings { let map: HashMap = pairs .iter() .map(|(k, v)| ((*k).to_string(), (*v).to_string())) .collect(); move |key: &str| map.get(key).cloned() } fn snooze(token: &str) -> Occurrence { Occurrence::new("snooze-expiry", token, "Task resurfaced", "Write the thing") } #[test] fn an_off_kind_produces_no_call_at_all() { // The reason suppression is shared: a host adapter never has to know // that settings exist. let mut outbox = outbox(); let off = stored(&[("snooze-expiry.enabled", "false")]); assert_eq!(outbox.offer(&snooze("t1"), &off), Sent::Off); assert!(outbox.adapter().0.is_empty()); } #[test] fn a_kind_that_ships_on_fires_for_a_reader_who_has_touched_nothing() { let mut outbox = outbox(); assert_eq!(outbox.offer(&snooze("t1"), &Unset), Sent::Delivered); assert_eq!(outbox.adapter().0, vec!["snooze-expiry:t1"]); } #[test] fn a_kind_that_ships_off_stays_quiet_until_it_is_turned_on() { let mut outbox = outbox(); let note = Occurrence::new("digest", "2026-08-17", "Today", "Six things"); assert_eq!(outbox.offer(¬e, &Unset), Sent::Off); let on = stored(&[("digest.enabled", "true")]); assert_eq!(outbox.offer(¬e, &on), Sent::Delivered); } #[test] fn the_same_token_is_delivered_once_however_many_ticks_ask() { // The watcher ticks every 60 seconds over a query that keeps // answering. Without this it fires every minute. let mut outbox = outbox(); assert_eq!(outbox.offer(&snooze("t1"), &Unset), Sent::Delivered); for _ in 0..5 { assert_eq!(outbox.offer(&snooze("t1"), &Unset), Sent::Duplicate); } assert_eq!(outbox.offer(&snooze("t2"), &Unset), Sent::Delivered); assert_eq!( outbox.adapter().0, vec!["snooze-expiry:t1", "snooze-expiry:t2"] ); } #[test] fn a_restart_does_not_spam_the_kind_that_says_the_moment_has_passed() { let mut outbox = outbox(); let late = |n: &'static str| Occurrence::new("event-reminder", n, "Standup", "In 15 min"); // First pass after launch: everything already due is noted, silently. { let mut pass = outbox.sweep("event-reminder"); assert_eq!(pass.offer(&late("e1"), &Unset), Sent::Late); assert_eq!(pass.offer(&late("e2"), &Unset), Sent::Late); } assert!(outbox.adapter().0.is_empty()); // Second pass: the kind is live, and what was skipped stays skipped. { let mut pass = outbox.sweep("event-reminder"); assert_eq!(pass.offer(&late("e1"), &Unset), Sent::Duplicate); assert_eq!(pass.offer(&late("e3"), &Unset), Sent::Delivered); } assert_eq!(outbox.adapter().0, vec!["event-reminder:e3"]); } #[test] fn a_kind_that_keeps_its_worth_when_late_fires_on_the_first_pass() { // The other half of the same measurement: a snooze that expired // overnight is still expired, and the reader still wants it back. let mut outbox = outbox(); let mut pass = outbox.sweep("snooze-expiry"); assert_eq!(pass.offer(&snooze("t1"), &Unset), Sent::Delivered); } #[test] fn one_kinds_pass_does_not_end_anothers() { let mut outbox = outbox(); drop(outbox.sweep("snooze-expiry")); assert!(outbox.swept("snooze-expiry")); assert!(!outbox.swept("event-reminder")); } #[test] fn forgetting_a_kinds_tokens_puts_it_back_through_its_bootstrap() { // The one interaction between the two mechanisms: clearing the set // makes every token deliverable again, so a kind that suppresses late // occurrences must not treat the next pass as a live one. let mut outbox = outbox().remembering(2); let note = |n: &'static str| Occurrence::new("event-reminder", n, "Standup", "Soon"); drop(outbox.sweep("event-reminder")); // bootstrap over { let mut pass = outbox.sweep("event-reminder"); assert_eq!(pass.offer(¬e("e1"), &Unset), Sent::Delivered); assert_eq!(pass.offer(¬e("e2"), &Unset), Sent::Delivered); // The third trips the bound: the set is dropped and so is the // knowledge that this kind has finished a pass. assert_eq!(pass.offer(¬e("e3"), &Unset), Sent::Late); } assert_eq!( outbox.adapter().0, vec!["event-reminder:e1", "event-reminder:e2"] ); assert!(!outbox.seen("event-reminder", "e1")); } #[test] fn an_undeclared_kind_never_reaches_the_host() { let mut outbox = outbox(); let note = Occurrence::new("invented", "x", "Hello", "There"); assert_eq!(outbox.offer(¬e, &Unset), Sent::Unknown); assert!(outbox.adapter().0.is_empty()); // And it is not remembered either: nothing was suppressed by // preference, so there is nothing to hold. assert!(!outbox.seen("invented", "x")); } #[test] fn what_was_suppressed_is_countable_rather_than_only_logged() { let mut outbox = outbox(); let off = stored(&[("snooze-expiry.enabled", "false")]); assert!(!outbox.offer(&snooze("t1"), &off).delivered()); assert!(outbox.offer(&snooze("t1"), &Unset).delivered()); } }