diff --git a/lib/events/booking-notify.sx b/lib/events/booking-notify.sx new file mode 100644 index 00000000..7abe87c7 --- /dev/null +++ b/lib/events/booking-notify.sx @@ -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))))) diff --git a/lib/events/conformance.conf b/lib/events/conformance.conf index 13d8d740..12710d4c 100644 --- a/lib/events/conformance.conf +++ b/lib/events/conformance.conf @@ -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!)" diff --git a/lib/events/scoreboard.json b/lib/events/scoreboard.json index b0b8558a..6979e992 100644 --- a/lib/events/scoreboard.json +++ b/lib/events/scoreboard.json @@ -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" } diff --git a/lib/events/scoreboard.md b/lib/events/scoreboard.md index 806c25fe..43e831d7 100644 --- a/lib/events/scoreboard.md +++ b/lib/events/scoreboard.md @@ -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 | diff --git a/lib/events/tests/booking-notify.sx b/lib/events/tests/booking-notify.sx new file mode 100644 index 00000000..135db0d8 --- /dev/null +++ b/lib/events/tests/booking-notify.sx @@ -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}))) diff --git a/plans/events-on-sx.md b/plans/events-on-sx.md index b91d2d95..87881707 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` → **254/254** (Phases 1-4 + ext: fed free/busy, waitlist, EXDATE/RDATE, overrides) +`bash lib/events/conformance.sh` → **265/265** (Phases 1-4 + ext: fed free/busy, waitlist, EXDATE/RDATE, overrides, booking-notify) ## Ground rules @@ -86,6 +86,15 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ── ## Progress log +- 2026-06-07 — Booking lifecycle notifications (extension). `booking-notify.sx` + walks the booking stream into ordered notifications classified by kind: + :booked / :promoted / :held / :confirmed / :released / :cancelled / + :waitlisted. Promotion is detected by folding the waitlist as we walk (a + :booking for a currently-waitlisted actor is a promotion, not a fresh + booking). id = occ-key/seq (stable stream seq → idempotent re-derivation, no + double-ping). `ev/booking-notifications`, `ev/notify-of-kind`, + `ev/booking-notify->msg` (notify wire shape). Connects ticketing to the + delivery layer. +11 tests, 265/265 green. - 2026-06-07 — Per-occurrence overrides / reschedule (RFC 5545 RECURRENCE-ID). `ev-with-override event orig-start new-start new-duration` adds an :overrides entry keyed by the occurrence's original start. `ev-expand` applies overrides