From f6c1d1e9bf40b3ce1139c64e8a10800a918a8099 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 04:34:49 +0000 Subject: [PATCH] events: reminders + digests from the agenda + 14 tests reminders.sx bridges calendar + durable rosters to notify: ev/occurrence- reminders (one per booked attendee, fires lead before start, idempotency key occ-key/recipient/lead), ev/agenda-reminders (sorted by fire-at), ev/due-reminders (fire-at <= now), ev/reminder->msg (notify wire shape), ev/agenda-digest + ev/agenda-for-p. 196/196 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/events/conformance.conf | 2 + lib/events/reminders.sx | 96 +++++++++++++++ lib/events/scoreboard.json | 9 +- lib/events/scoreboard.md | 3 +- lib/events/tests/reminders.sx | 220 ++++++++++++++++++++++++++++++++++ plans/events-on-sx.md | 12 +- 6 files changed, 335 insertions(+), 7 deletions(-) create mode 100644 lib/events/reminders.sx create mode 100644 lib/events/tests/reminders.sx diff --git a/lib/events/conformance.conf b/lib/events/conformance.conf index 05158f07..ccf66cde 100644 --- a/lib/events/conformance.conf +++ b/lib/events/conformance.conf @@ -40,6 +40,7 @@ PRELOADS=( lib/flow/api.sx lib/events/notify.sx lib/events/api.sx + lib/events/reminders.sx ) SUITES=( @@ -49,4 +50,5 @@ SUITES=( "booking:lib/events/tests/booking.sx:(ev-booking-tests-run!)" "ticket:lib/events/tests/ticket.sx:(ev-ticket-tests-run!)" "notify:lib/events/tests/notify.sx:(ev-notify-tests-run!)" + "reminders:lib/events/tests/reminders.sx:(ev-reminders-tests-run!)" ) diff --git a/lib/events/reminders.sx b/lib/events/reminders.sx new file mode 100644 index 00000000..465d638b --- /dev/null +++ b/lib/events/reminders.sx @@ -0,0 +1,96 @@ +;; lib/events/reminders.sx — derive reminder + digest messages from the agenda. +;; +;; Bridges the schedule (calendar) and the durable roster (booking on persist) +;; to the notification layer (notify.sx). For each booked attendee of each +;; upcoming occurrence we derive a reminder message that fires `lead` minutes +;; before the occurrence starts. Each message has a deterministic idempotency +;; key — occ-key / recipient / lead — so re-deriving over an overlapping window +;; never produces a duplicate ping (the notify transport dedups on this id). +;; +;; A reminder is a dict: +;; {:id :recipient :event :start :fire-at} +;; `ev/reminder->msg` projects it to notify's (id recipient body) wire shape. + +;; Reminders for one occurrence: one per booked attendee (durable roster). +(define + ev/occurrence-reminders + (fn + (b occ lead) + (let + ((occ-key (ev-occ-key occ)) + (start (get occ :start)) + (evid (get occ :id))) + (map (fn (actor) {:id (str occ-key "/" actor "/" lead) :event evid :start start :fire-at (- start lead) :recipient actor}) (ev/roster-occ b occ))))) + +;; Insertion sort of reminder dicts ascending by :fire-at (then :id for ties). +(define + ev-rem-before? + (fn + (a c) + (cond + ((< (get a :fire-at) (get c :fire-at)) true) + ((> (get a :fire-at) (get c :fire-at)) false) + (else (< (get a :id) (get c :id)))))) + +(define + ev-rem-insert + (fn + (r sorted) + (cond + ((empty? sorted) (list r)) + ((ev-rem-before? r (first sorted)) (cons r sorted)) + (else (cons (first sorted) (ev-rem-insert r (rest sorted))))))) + +(define + ev-rem-sort + (fn (rs) (reduce (fn (acc r) (ev-rem-insert r acc)) (list) rs))) + +;; All reminders across the agenda in [ws, we), ascending by fire-at. +(define + ev/agenda-reminders + (fn + (b store ws we lead) + (let + ((acc (list))) + (begin + (for-each + (fn + (occ) + (for-each + (fn (r) (append! acc r)) + (ev/occurrence-reminders b occ lead))) + (ev/agenda store ws we)) + (ev-rem-sort acc))))) + +;; Reminders whose fire-at has arrived (fire-at <= now) — what a scheduler +;; should hand to the notify transport at time `now`. +(define + ev/due-reminders + (fn + (reminders now) + (filter (fn (r) (<= (get r :fire-at) now)) reminders))) + +;; Project a reminder to notify's (id recipient body) wire shape. +(define + ev/reminder->msg + (fn + (r) + (list + (get r :id) + (get r :recipient) + (list :reminder (get r :event) (get r :start))))) + +;; ---- digests ---- + +;; The occurrences `actor` is booked into (durable roster), within window. +(define + ev/agenda-for-p + (fn + (b store actor ws we) + (filter + (fn (occ) (ev-bk-member? actor (ev/roster-occ b occ))) + (ev/agenda store ws we)))) + +;; A single digest message summarising an actor's upcoming booked occurrences. +;; :items is ({:event :start} ...); empty when the actor has nothing booked. +(define ev/agenda-digest (fn (b store actor ws we) {:items (map (fn (occ) {:event (get occ :id) :start (get occ :start)}) (ev/agenda-for-p b store actor ws we)) :id (str actor "/digest/" ws "-" we) :recipient actor})) diff --git a/lib/events/scoreboard.json b/lib/events/scoreboard.json index beea384f..cdfff23a 100644 --- a/lib/events/scoreboard.json +++ b/lib/events/scoreboard.json @@ -1,15 +1,16 @@ { "lang": "events", - "total_passed": 182, + "total_passed": 196, "total_failed": 0, - "total": 182, + "total": 196, "suites": [ {"name":"calendar","passed":37,"failed":0,"total":37}, {"name":"availability","passed":22,"failed":0,"total":22}, {"name":"api","passed":24,"failed":0,"total":24}, {"name":"booking","passed":61,"failed":0,"total":61}, {"name":"ticket","passed":31,"failed":0,"total":31}, - {"name":"notify","passed":7,"failed":0,"total":7} + {"name":"notify","passed":7,"failed":0,"total":7}, + {"name":"reminders","passed":14,"failed":0,"total":14} ], - "generated": "2026-06-07T04:02:26+00:00" + "generated": "2026-06-07T04:34:36+00:00" } diff --git a/lib/events/scoreboard.md b/lib/events/scoreboard.md index 59dd18db..99460ce0 100644 --- a/lib/events/scoreboard.md +++ b/lib/events/scoreboard.md @@ -1,6 +1,6 @@ # events scoreboard -**182 / 182 passing** (0 failure(s)). +**196 / 196 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -10,3 +10,4 @@ | booking | 61 | 61 | ok | | ticket | 31 | 31 | ok | | notify | 7 | 7 | ok | +| reminders | 14 | 14 | ok | diff --git a/lib/events/tests/reminders.sx b/lib/events/tests/reminders.sx new file mode 100644 index 00000000..8d0988d1 --- /dev/null +++ b/lib/events/tests/reminders.sx @@ -0,0 +1,220 @@ +;; lib/events/tests/reminders.sx — reminder + digest derivation from the agenda. + +(define ev-rm-pass 0) +(define ev-rm-fail 0) +(define ev-rm-failures (list)) + +(define + ev-rm-check! + (fn + (name got expected) + (if + (= got expected) + (set! ev-rm-pass (+ ev-rm-pass 1)) + (do + (set! ev-rm-fail (+ ev-rm-fail 1)) + (append! + ev-rm-failures + (str name "\n expected: " expected "\n got: " got)))))) + +;; A store with a weekly class (Mon+Wed 18:00, 60m, 4 occurrences) and a one-off +;; talk; durable bookings on a persist backend. +(define + ev-rm-store + (fn + () + (ev/schedule + (ev/schedule + (ev/empty) + (quote yoga) + (ev-dt 2026 6 1 18 0) + 60 + {:freq :weekly :count 4 :byday (list 0 2)} + 20) + (quote talk) + (ev-dt 2026 6 2 12 0) + 30 + nil + 50))) + +(define + ev-rm-run-all! + (fn + () + (let + ((store (ev-rm-store)) (b (persist/open))) + (let + ((occs (ev/agenda store (ev-date 2026 6 1) (ev-date 2026 7 1)))) + (do + (ev/book-occ! b store (quote nia) (first occs)) + (ev/book-occ! b store (quote ola) (first occs)) + (ev/book-occ! + b + store + (quote ola) + (ev-occ + (quote talk) + (ev-dt 2026 6 2 12 0) + 30)) + (do + (let + ((rs (ev/occurrence-reminders b (first occs) 60))) + (do + (ev-rm-check! + "one reminder per booked attendee" + (len rs) + 2) + (ev-rm-check! + "reminder fires lead minutes before start" + (get (first rs) :fire-at) + (- + (ev-dt + 2026 + 6 + 1 + 18 + 0) + 60)) + (ev-rm-check! + "reminder idempotency key encodes occ/recipient/lead" + (get (first rs) :id) + (str + (ev-occ-key (first occs)) + "/" + (quote nia) + "/" + 60)) + (ev-rm-check! + "reminder names the event" + (get (first rs) :event) + (quote yoga)))) + (ev-rm-check! + "unbooked occurrence has no reminders" + (len + (ev/occurrence-reminders b (ev-occ (quote yoga) (ev-dt 2026 6 3 18 0) 60) 60)) + 0) + (let + ((all (ev/agenda-reminders b store (ev-date 2026 6 1) (ev-date 2026 7 1) 60))) + (do + (ev-rm-check! + "agenda reminders cover all bookings" + (len all) + 3) + (ev-rm-check! + "agenda reminders sorted by fire-at" + (map (fn (r) (get r :fire-at)) all) + (list + (- + (ev-dt + 2026 + 6 + 1 + 18 + 0) + 60) + (- + (ev-dt + 2026 + 6 + 1 + 18 + 0) + 60) + (- + (ev-dt + 2026 + 6 + 2 + 12 + 0) + 60))))) + (let + ((all (ev/agenda-reminders b store (ev-date 2026 6 1) (ev-date 2026 7 1) 60))) + (do + (ev-rm-check! + "nothing due before the first fire-at" + (len + (ev/due-reminders + all + (- + (ev-dt + 2026 + 6 + 1 + 17 + 0) + 1))) + 0) + (ev-rm-check! + "the two yoga reminders are due at 17:00" + (len + (ev/due-reminders + all + (ev-dt + 2026 + 6 + 1 + 17 + 0))) + 2) + (ev-rm-check! + "all reminders due once past the last fire-at" + (len + (ev/due-reminders + all + (ev-dt + 2026 + 6 + 2 + 12 + 0))) + 3))) + (let + ((r (first (ev/occurrence-reminders b (first occs) 60)))) + (ev-rm-check! + "reminder projects to (id recipient body)" + (ev/reminder->msg r) + (list + (str + (ev-occ-key (first occs)) + "/" + (quote nia) + "/" + 60) + (quote nia) + (list + :reminder (quote yoga) + (ev-dt + 2026 + 6 + 1 + 18 + 0))))) + (let + ((dig (ev/agenda-digest b store (quote ola) (ev-date 2026 6 1) (ev-date 2026 7 1)))) + (do + (ev-rm-check! + "digest is addressed to the actor" + (get dig :recipient) + (quote ola)) + (ev-rm-check! + "digest lists the actor's booked occurrences" + (map (fn (it) (get it :event)) (get dig :items)) + (list (quote yoga) (quote talk))))) + (let + ((empty-dig (ev/agenda-digest b store (quote nobody) (ev-date 2026 6 1) (ev-date 2026 7 1)))) + (ev-rm-check! + "digest empty for an actor with no bookings" + (get empty-dig :items) + (list))))))))) + +(define + ev-reminders-tests-run! + (fn + () + (do + (set! ev-rm-pass 0) + (set! ev-rm-fail 0) + (set! ev-rm-failures (list)) + (ev-rm-run-all!) + {:failures ev-rm-failures :total (+ ev-rm-pass ev-rm-fail) :passed ev-rm-pass :failed ev-rm-fail}))) diff --git a/plans/events-on-sx.md b/plans/events-on-sx.md index 63fed601..90725c5d 100644 --- a/plans/events-on-sx.md +++ b/plans/events-on-sx.md @@ -18,7 +18,7 @@ capacity rules, transactional booking, and a flow-driven notification dispatcher ## Status (rolling) -`bash lib/events/conformance.sh` → **182/182** (Phases 1-2 + Phase 3 notification delivery flows) +`bash lib/events/conformance.sh` → **196/196** (Phases 1-2 + Phase 3: notification flows + reminders) ## Ground rules @@ -73,7 +73,7 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ── - [x] `notify.sx` — reminder/digest flows over injected transport - [x] retry/backoff on transport failure (flow suspend/resume) - [x] tests: delivery success, retry path, idempotent re-send -- [ ] wire reminders to occurrences (schedule "starts in 1h" from agenda) +- [x] wire reminders to occurrences (`reminders.sx` — derive from agenda + roster) - [ ] NOTE: shared with `feed/notify` — candidate for later extraction to a `delivery-on-sx` once a second consumer is real. **Delivery core (request→dispatch→resume, idempotent, bounded retry) is the extraction seam.** @@ -84,6 +84,14 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ── ## Progress log +- 2026-06-07 — Reminders + digests from the agenda. `reminders.sx` bridges + calendar + durable rosters to notify: `ev/occurrence-reminders` (one per + booked attendee, fires `lead` before start, idempotency key + occ-key/recipient/lead), `ev/agenda-reminders` (window-wide, sorted by + fire-at), `ev/due-reminders` (fire-at ≤ now — the scheduler query), + `ev/reminder->msg` (projects to notify's (id recipient body) shape), + `ev/agenda-digest` + `ev/agenda-for-p` (an actor's upcoming booked + occurrences). +14 tests, 196/196 green. - 2026-06-07 — **Phase 3 start: notification delivery flows.** `notify.sx`: reminders + digests as durable `flow`s over an INJECTED transport (the host `dispatch`). A flow `request`s delivery (suspend), the host sends and resumes