events: booking lifecycle notifications + 11 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 38s

booking-notify.sx walks the booking stream into ordered notifications by kind
(booked/promoted/held/confirmed/released/cancelled/waitlisted). Promotion
detected by folding the waitlist (a booking for a waitlisted actor is a
promotion). id=occ-key/seq -> idempotent re-derivation, no double-ping.
Connects ticketing to the delivery layer. 265/265 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 07:20:39 +00:00
parent b308effb9f
commit 98ed2eebdf
6 changed files with 257 additions and 5 deletions

View File

@@ -0,0 +1,102 @@
;; lib/events/booking-notify.sx — derive lifecycle notifications from the
;; booking stream, for delivery via notify.sx.
;;
;; Walking the append-only booking stream yields one notification per state
;; change, in order, classified by kind:
;;
;; :booked a confirmed booking
;; :promoted a booking for an actor who was on the waitlist (auto-promote)
;; :held a provisional hold (pending payment)
;; :confirmed a held seat became confirmed (payment succeeded)
;; :released a held seat was released (payment failed/expired)
;; :cancelled a seat was given up
;; :waitlisted an actor joined the waitlist
;;
;; Promotion is detected by folding the waitlist as we walk: a :booking for an
;; actor currently on the waitlist is a promotion, not a fresh booking.
;;
;; Each notification's id is occ-key/seq (the stream seq is unique and stable),
;; so re-deriving and re-delivering is idempotent — the notify transport dedups
;; on this id and never double-pings.
(define
ev-bn-kind
(fn
(typ promoted?)
(cond
((= typ :hold) :held)
((= typ :booking) (if promoted? :promoted :booked))
((= typ :confirm) :confirmed)
((= typ :cancel) :cancelled)
((= typ :release) :released)
((= typ :waitlist) :waitlisted)
(else nil))))
(define
ev-bn-update-waiting
(fn
(typ actor waiting)
(cond
((= typ :waitlist)
(if
(ev-bk-member? actor waiting)
waiting
(ev-bk-append waiting actor)))
((= typ :unwaitlist) (ev-bk-remove waiting actor))
((= typ :booking) (ev-bk-remove waiting actor))
((= typ :hold) (ev-bk-remove waiting actor))
(else waiting))))
(define ev-bn-mk (fn (occ-key label actor kind seq) {:id (str occ-key "/" seq) :event label :kind kind :recipient actor :seq seq}))
(define
ev-bn-step
(fn
(occ-key label events waiting)
(if
(empty? events)
(list)
(let
((e (first events)))
(let
((typ (persist/event-type e))
(actor (get (persist/event-data e) :actor))
(seq (persist/event-seq e)))
(let
((promoted? (and (= typ :booking) (ev-bk-member? actor waiting))))
(let
((kind (ev-bn-kind typ promoted?))
(waiting2 (ev-bn-update-waiting typ actor waiting)))
(if
(nil? kind)
(ev-bn-step occ-key label (rest events) waiting2)
(cons
(ev-bn-mk occ-key label actor kind seq)
(ev-bn-step occ-key label (rest events) waiting2))))))))))
;; The ordered lifecycle notifications for an occurrence's bookings. `label` is
;; a human-facing event id carried on each notification.
(define
ev/booking-notifications
(fn
(b occ-key label)
(ev-bn-step
occ-key
label
(persist/read b (ev-booking-stream occ-key))
(list))))
;; Filter notifications to a single kind.
(define
ev/notify-of-kind
(fn (notifs kind) (filter (fn (n) (= (get n :kind) kind)) notifs)))
;; Project a notification to notify.sx's (id recipient body) wire shape.
(define
ev/booking-notify->msg
(fn
(n)
(list
(get n :id)
(get n :recipient)
(list :booking-event (get n :kind) (get n :event)))))

View File

@@ -26,6 +26,7 @@ PRELOADS=(
lib/persist/concurrency.sx
lib/persist/api.sx
lib/events/booking.sx
lib/events/booking-notify.sx
lib/events/ticket.sx
lib/guest/lex.sx
lib/guest/reflective/env.sx
@@ -49,6 +50,7 @@ SUITES=(
"availability:lib/events/tests/availability.sx:(ev-availability-tests-run!)"
"api:lib/events/tests/api.sx:(ev-api-tests-run!)"
"booking:lib/events/tests/booking.sx:(ev-booking-tests-run!)"
"booking-notify:lib/events/tests/booking-notify.sx:(ev-booking-notify-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!)"

View File

@@ -1,17 +1,18 @@
{
"lang": "events",
"total_passed": 254,
"total_passed": 265,
"total_failed": 0,
"total": 254,
"total": 265,
"suites": [
{"name":"calendar","passed":51,"failed":0,"total":51},
{"name":"availability","passed":22,"failed":0,"total":22},
{"name":"api","passed":24,"failed":0,"total":24},
{"name":"booking","passed":82,"failed":0,"total":82},
{"name":"booking-notify","passed":11,"failed":0,"total":11},
{"name":"ticket","passed":31,"failed":0,"total":31},
{"name":"notify","passed":7,"failed":0,"total":7},
{"name":"reminders","passed":14,"failed":0,"total":14},
{"name":"federation","passed":23,"failed":0,"total":23}
],
"generated": "2026-06-07T06:51:44+00:00"
"generated": "2026-06-07T07:20:13+00:00"
}

View File

@@ -1,6 +1,6 @@
# events scoreboard
**254 / 254 passing** (0 failure(s)).
**265 / 265 passing** (0 failure(s)).
| Suite | Passed | Total | Status |
|-------|--------|-------|--------|
@@ -8,6 +8,7 @@
| availability | 22 | 22 | ok |
| api | 24 | 24 | ok |
| booking | 82 | 82 | ok |
| booking-notify | 11 | 11 | ok |
| ticket | 31 | 31 | ok |
| notify | 7 | 7 | ok |
| reminders | 14 | 14 | ok |

View File

@@ -0,0 +1,137 @@
;; lib/events/tests/booking-notify.sx — lifecycle notifications from the stream.
(define ev-bn-pass 0)
(define ev-bn-fail 0)
(define ev-bn-failures (list))
(define
ev-bn-check!
(fn
(name got expected)
(if
(= got expected)
(set! ev-bn-pass (+ ev-bn-pass 1))
(do
(set! ev-bn-fail (+ ev-bn-fail 1))
(append!
ev-bn-failures
(str name "\n expected: " expected "\n got: " got))))))
(define
ev-bn-kinds
(fn
(notifs)
(map (fn (n) (list (get n :recipient) (get n :kind))) notifs)))
(define
ev-bn-run-all!
(fn
()
(do
(let
((b (persist/open)))
(do
(ev/book! b "o" 1 (quote a))
(ev/waitlist! b "o" 1 (quote x))
(ev/cancel-promote! b "o" 1 (quote a))
(let
((ns (ev/booking-notifications b "o" (quote yoga))))
(do
(ev-bn-check!
"lifecycle notifications in order"
(ev-bn-kinds ns)
(list
(list (quote a) :booked)
(list (quote x) :waitlisted)
(list (quote a) :cancelled)
(list (quote x) :promoted)))
(ev-bn-check!
"promotion targets the waitlisted actor"
(map
(fn (n) (get n :recipient))
(ev/notify-of-kind ns :promoted))
(list (quote x)))
(ev-bn-check!
"a fresh booking is not flagged as a promotion"
(len (ev/notify-of-kind ns :booked))
1)
(ev-bn-check!
"every notification carries the event label"
(get (first ns) :event)
(quote yoga))))))
(let
((b (persist/open)))
(do
(ev/hold! b "p" 3 (quote q))
(ev/confirm! b "p" (quote q))
(ev-bn-check!
"hold then confirm notifications"
(ev-bn-kinds (ev/booking-notifications b "p" (quote gig)))
(list (list (quote q) :held) (list (quote q) :confirmed)))))
(let
((b (persist/open)))
(do
(ev/hold! b "r" 1 (quote q))
(ev/release! b "r" (quote q))
(ev-bn-check!
"hold then release notifications"
(ev-bn-kinds (ev/booking-notifications b "r" (quote gig)))
(list (list (quote q) :held) (list (quote q) :released)))))
(let
((b (persist/open)))
(do
(ev/book! b "k" 5 (quote a))
(ev/book! b "k" 5 (quote c))
(let
((ns (ev/booking-notifications b "k" (quote talk))))
(do
(ev-bn-check!
"notification ids are occ-key/seq"
(map (fn (n) (get n :id)) ns)
(list "k/1" "k/2"))
(ev-bn-check!
"re-deriving yields identical ids (idempotent)"
(map
(fn (n) (get n :id))
(ev/booking-notifications b "k" (quote talk)))
(list "k/1" "k/2"))))))
(let
((b (persist/open)))
(do
(ev/book! b "w" 5 (quote a))
(ev-bn-check!
"notification projects to (id recipient body)"
(ev/booking-notify->msg
(first (ev/booking-notifications b "w" (quote talk))))
(list
"w/1"
(quote a)
(list :booking-event :booked (quote talk))))))
(let
((b (persist/open)))
(do
(ev/book! b "u" 1 (quote a))
(ev/waitlist! b "u" 1 (quote x))
(ev/leave-waitlist! b "u" (quote x))
(ev-bn-check!
"leaving the waitlist emits no notification"
(len
(ev/notify-of-kind
(ev/booking-notifications b "u" (quote e))
:left-waitlist))
0)
(ev-bn-check!
"unbooked occurrence has no notifications"
(ev/booking-notifications b "empty" (quote e))
(list)))))))
(define
ev-booking-notify-tests-run!
(fn
()
(do
(set! ev-bn-pass 0)
(set! ev-bn-fail 0)
(set! ev-bn-failures (list))
(ev-bn-run-all!)
{:failures ev-bn-failures :total (+ ev-bn-pass ev-bn-fail) :passed ev-bn-pass :failed ev-bn-fail})))