events: reminders + digests from the agenda + 14 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 40s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 40s
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) <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,7 @@ PRELOADS=(
|
|||||||
lib/flow/api.sx
|
lib/flow/api.sx
|
||||||
lib/events/notify.sx
|
lib/events/notify.sx
|
||||||
lib/events/api.sx
|
lib/events/api.sx
|
||||||
|
lib/events/reminders.sx
|
||||||
)
|
)
|
||||||
|
|
||||||
SUITES=(
|
SUITES=(
|
||||||
@@ -49,4 +50,5 @@ SUITES=(
|
|||||||
"booking:lib/events/tests/booking.sx:(ev-booking-tests-run!)"
|
"booking:lib/events/tests/booking.sx:(ev-booking-tests-run!)"
|
||||||
"ticket:lib/events/tests/ticket.sx:(ev-ticket-tests-run!)"
|
"ticket:lib/events/tests/ticket.sx:(ev-ticket-tests-run!)"
|
||||||
"notify:lib/events/tests/notify.sx:(ev-notify-tests-run!)"
|
"notify:lib/events/tests/notify.sx:(ev-notify-tests-run!)"
|
||||||
|
"reminders:lib/events/tests/reminders.sx:(ev-reminders-tests-run!)"
|
||||||
)
|
)
|
||||||
|
|||||||
96
lib/events/reminders.sx
Normal file
96
lib/events/reminders.sx
Normal file
@@ -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}))
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"lang": "events",
|
"lang": "events",
|
||||||
"total_passed": 182,
|
"total_passed": 196,
|
||||||
"total_failed": 0,
|
"total_failed": 0,
|
||||||
"total": 182,
|
"total": 196,
|
||||||
"suites": [
|
"suites": [
|
||||||
{"name":"calendar","passed":37,"failed":0,"total":37},
|
{"name":"calendar","passed":37,"failed":0,"total":37},
|
||||||
{"name":"availability","passed":22,"failed":0,"total":22},
|
{"name":"availability","passed":22,"failed":0,"total":22},
|
||||||
{"name":"api","passed":24,"failed":0,"total":24},
|
{"name":"api","passed":24,"failed":0,"total":24},
|
||||||
{"name":"booking","passed":61,"failed":0,"total":61},
|
{"name":"booking","passed":61,"failed":0,"total":61},
|
||||||
{"name":"ticket","passed":31,"failed":0,"total":31},
|
{"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# events scoreboard
|
# events scoreboard
|
||||||
|
|
||||||
**182 / 182 passing** (0 failure(s)).
|
**196 / 196 passing** (0 failure(s)).
|
||||||
|
|
||||||
| Suite | Passed | Total | Status |
|
| Suite | Passed | Total | Status |
|
||||||
|-------|--------|-------|--------|
|
|-------|--------|-------|--------|
|
||||||
@@ -10,3 +10,4 @@
|
|||||||
| booking | 61 | 61 | ok |
|
| booking | 61 | 61 | ok |
|
||||||
| ticket | 31 | 31 | ok |
|
| ticket | 31 | 31 | ok |
|
||||||
| notify | 7 | 7 | ok |
|
| notify | 7 | 7 | ok |
|
||||||
|
| reminders | 14 | 14 | ok |
|
||||||
|
|||||||
220
lib/events/tests/reminders.sx
Normal file
220
lib/events/tests/reminders.sx
Normal file
@@ -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})))
|
||||||
@@ -18,7 +18,7 @@ capacity rules, transactional booking, and a flow-driven notification dispatcher
|
|||||||
|
|
||||||
## Status (rolling)
|
## 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
|
## 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] `notify.sx` — reminder/digest flows over injected transport
|
||||||
- [x] retry/backoff on transport failure (flow suspend/resume)
|
- [x] retry/backoff on transport failure (flow suspend/resume)
|
||||||
- [x] tests: delivery success, retry path, idempotent re-send
|
- [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
|
- [ ] NOTE: shared with `feed/notify` — candidate for later extraction to a
|
||||||
`delivery-on-sx` once a second consumer is real. **Delivery core
|
`delivery-on-sx` once a second consumer is real. **Delivery core
|
||||||
(request→dispatch→resume, idempotent, bounded retry) is the extraction seam.**
|
(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
|
## 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`:
|
- 2026-06-07 — **Phase 3 start: notification delivery flows.** `notify.sx`:
|
||||||
reminders + digests as durable `flow`s over an INJECTED transport (the host
|
reminders + digests as durable `flow`s over an INJECTED transport (the host
|
||||||
`dispatch`). A flow `request`s delivery (suspend), the host sends and resumes
|
`dispatch`). A flow `request`s delivery (suspend), the host sends and resumes
|
||||||
|
|||||||
Reference in New Issue
Block a user