From 24d4db3f0d3bb532ca49f21557da919e7b23c3e0 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 02:39:19 +0000 Subject: [PATCH] events: wire persist-backed booking into api.sx + 10 tests Durable booking path alongside in-memory: ev/book-occ!, ev/cancel-occ!, ev/roster-occ, ev/seats-left-occ (capacity from scheduled event); ev/free-p?, ev/next-free-p, ev/conflicts-p derive availability by replaying persist booking streams. Reordered conformance preloads. 120/120 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/events/api.sx | 130 +++++++++++++++++++++++++++++++++--- lib/events/conformance.conf | 2 +- lib/events/scoreboard.json | 8 +-- lib/events/scoreboard.md | 4 +- lib/events/tests/api.sx | 97 ++++++++++++++++++++++++++- plans/events-on-sx.md | 11 ++- 6 files changed, 234 insertions(+), 18 deletions(-) diff --git a/lib/events/api.sx b/lib/events/api.sx index 97fa2816..5258fc6d 100644 --- a/lib/events/api.sx +++ b/lib/events/api.sx @@ -1,13 +1,19 @@ ;; lib/events/api.sx — public events surface over calendar + availability. ;; -;; A `store` is an immutable value holding scheduled events and bookings: +;; A `store` is an immutable value holding scheduled events and (in-memory) +;; bookings: ;; ;; {:events (event ...) :bookings ((actor key) ...)} ;; +;; The in-memory `:bookings` list supports pure, value-level queries. The +;; DURABLE booking path (ev/*-occ! and ev/*-p) keeps bookings in persist +;; streams via booking.sx — capacity-safe, cancellable, replayable — and +;; derives availability from those streams. Use the persist path for real +;; bookings; the in-memory path for projections and tests. +;; ;; All queries are windowed: agenda/free/next-free expand recurring events into ;; concrete occurrences within an explicit (or derived) window before running -;; the Datalog availability rules. Phase 2 replaces `ev/book` with a capacity- -;; safe persist append; the rest of this facade stays put. +;; the Datalog availability rules. (define ev/store (fn (events bookings) {:bookings bookings :events events})) @@ -31,8 +37,8 @@ (store id dtstart duration rrule capacity) (ev/add-event store (ev-event id dtstart duration rrule capacity)))) -;; Record that `actor` holds the occurrence with `key` (ev-occ-key of an -;; expanded occurrence). Phase 1: append-only, no capacity check. +;; Record that `actor` holds the occurrence with `key` (in-memory only — see +;; ev/book-occ! for the durable, capacity-safe path). (define ev/book (fn @@ -41,6 +47,27 @@ (ev/events store) (cons (list actor key) (ev/bookings store))))) +;; The event with `id`, or nil. +(define + ev/event-by-id + (fn + (store id) + (reduce + (fn + (found ev) + (if (nil? found) (if (= (get ev :id) id) ev found) found)) + nil + (ev/events store)))) + +;; Capacity of the event an occurrence belongs to (0 if unknown). +(define + ev/capacity-of + (fn + (store occ) + (let + ((ev (ev/event-by-id store (get occ :id)))) + (if (nil? ev) 0 (get ev :capacity))))) + ;; The maximum event duration in the store (0 when empty) — used to widen ;; expansion windows so any occurrence overlapping a query is captured. (define @@ -66,7 +93,7 @@ ((= k (first keys)) true) (else (ev-key-member? k (rest keys)))))) -;; Occurrence keys `actor` has booked. +;; Occurrence keys `actor` has booked (in-memory store). (define ev/actor-keys (fn @@ -78,7 +105,7 @@ (list) (ev/bookings store)))) -;; The agenda restricted to occurrences `actor` is booked into, within window. +;; The agenda restricted to occurrences `actor` is booked into (in-memory). (define ev/agenda-for (fn @@ -89,7 +116,8 @@ (fn (o) (ev-key-member? (ev-occ-key o) keys)) (ev/agenda store ws we))))) -;; Build an availability db over occurrences expanded in [ws, we). +;; Build an availability db over occurrences expanded in [ws, we) using the +;; in-memory bookings. (define ev/avail-window-db (fn @@ -135,3 +163,89 @@ (fn (store actor ws we) (> (len (ev/conflicts store actor ws we)) 0))) + +;; ---- durable, persist-backed booking path ---- +;; These take a persist backend `b` (persist/open) plus the schedule `store`. +;; Bookings live in per-occurrence streams (booking.sx); availability is derived +;; by replaying those streams for the occurrences in the query window. + +;; Durably book `actor` into occurrence `occ` (dict {:id :start :end}), +;; capacity-safe. Returns the booking.sx result (:booked / :full / :already). +(define + ev/book-occ! + (fn + (b store actor occ) + (ev/book! b (ev-occ-key occ) (ev/capacity-of store occ) actor))) + +;; Durably cancel `actor`'s seat on `occ`, freeing capacity. +(define + ev/cancel-occ! + (fn (b store actor occ) (ev/cancel! b (ev-occ-key occ) actor))) + +;; Live roster / seats-left for a specific occurrence from persist. +(define ev/roster-occ (fn (b occ) (ev/roster b (ev-occ-key occ)))) + +(define + ev/seats-left-occ + (fn + (b store occ) + (ev/seats-left b (ev-occ-key occ) (ev/capacity-of store occ)))) + +;; Derive (actor key) booking pairs from the persist rosters of `occs`. +(define + ev/persist-bookings + (fn + (b occs) + (reduce + (fn + (acc occ) + (let + ((key (ev-occ-key occ))) + (append + acc + (map (fn (actor) (list actor key)) (ev/roster b key))))) + (list) + occs))) + +;; Availability db over [ws, we) with bookings sourced from persist streams. +(define + ev/avail-db-p + (fn + (b store ws we) + (let + ((occs (ev/agenda store ws we))) + (ev-avail-db occs (ev/persist-bookings b occs))))) + +;; Persist-backed availability queries (mirror the in-memory ev/free? etc). +(define + ev/free-p? + (fn + (b store actor qs qe) + (ev-free? + (ev/avail-db-p b store (- qs (ev/store-max-duration store)) qe) + actor + qs + qe))) + +(define + ev/next-free-p + (fn + (b store actor after duration horizon) + (ev-next-free + (ev/avail-db-p b store (- after (ev/store-max-duration store)) horizon) + actor + after + duration + horizon))) + +(define + ev/conflicts-p + (fn + (b store actor ws we) + (ev-conflicts (ev/avail-db-p b store ws we) actor))) + +(define + ev/has-conflict-p? + (fn + (b store actor ws we) + (> (len (ev/conflicts-p b store actor ws we)) 0))) diff --git a/lib/events/conformance.conf b/lib/events/conformance.conf index 3810fac1..f2355985 100644 --- a/lib/events/conformance.conf +++ b/lib/events/conformance.conf @@ -19,7 +19,6 @@ PRELOADS=( lib/datalog/magic.sx lib/events/calendar.sx lib/events/availability.sx - lib/events/api.sx lib/persist/event.sx lib/persist/backend.sx lib/persist/log.sx @@ -27,6 +26,7 @@ PRELOADS=( lib/persist/concurrency.sx lib/persist/api.sx lib/events/booking.sx + lib/events/api.sx ) SUITES=( diff --git a/lib/events/scoreboard.json b/lib/events/scoreboard.json index 21cf9dfe..8b529d61 100644 --- a/lib/events/scoreboard.json +++ b/lib/events/scoreboard.json @@ -1,13 +1,13 @@ { "lang": "events", - "total_passed": 110, + "total_passed": 120, "total_failed": 0, - "total": 110, + "total": 120, "suites": [ {"name":"calendar","passed":37,"failed":0,"total":37}, {"name":"availability","passed":22,"failed":0,"total":22}, - {"name":"api","passed":14,"failed":0,"total":14}, + {"name":"api","passed":24,"failed":0,"total":24}, {"name":"booking","passed":37,"failed":0,"total":37} ], - "generated": "2026-06-07T02:09:36+00:00" + "generated": "2026-06-07T02:39:08+00:00" } diff --git a/lib/events/scoreboard.md b/lib/events/scoreboard.md index e30bb3b6..0032b155 100644 --- a/lib/events/scoreboard.md +++ b/lib/events/scoreboard.md @@ -1,10 +1,10 @@ # events scoreboard -**110 / 110 passing** (0 failure(s)). +**120 / 120 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| | calendar | 37 | 37 | ok | | availability | 22 | 22 | ok | -| api | 14 | 14 | ok | +| api | 24 | 24 | ok | | booking | 37 | 37 | ok | diff --git a/lib/events/tests/api.sx b/lib/events/tests/api.sx index 6b1d2245..d459a2fb 100644 --- a/lib/events/tests/api.sx +++ b/lib/events/tests/api.sx @@ -64,6 +64,14 @@ "max duration of empty store is zero" (ev/store-max-duration (ev/empty)) 0) + (ev-api-check! + "event-by-id finds the scheduled event" + (get (ev/event-by-id s0 (quote yoga)) :capacity) + 20) + (ev-api-check! + "event-by-id is nil for unknown id" + (ev/event-by-id s0 (quote nope)) + nil) (ev-api-check! "agenda-for lists only booked occurrences" (map @@ -162,7 +170,94 @@ (quote nia) (ev-date 2026 6 1) (ev-date 2026 7 1)) - true)))))))) + true)) + (let + ((b (persist/open)) (occ1 (first occs))) + (do + (let + ((sp (ev/schedule (ev/empty) (quote clinic) (ev-dt 2026 6 5 9 0) 30 nil 2))) + (let + ((occ (ev-occ (quote clinic) (ev-dt 2026 6 5 9 0) 30))) + (do + (ev-api-check! + "durable book returns booked" + (get (ev/book-occ! b sp (quote a) occ) :status) + :booked) + (ev/book-occ! b sp (quote c) occ) + (ev-api-check! + "durable book past capacity is full" + (get (ev/book-occ! b sp (quote d) occ) :status) + :full) + (ev-api-check! + "durable roster reflects persisted bookings" + (ev/roster-occ b occ) + (list (quote a) (quote c))) + (ev-api-check! + "durable seats-left honours capacity" + (ev/seats-left-occ b sp occ) + 0) + (ev-api-check! + "persist free? false during a durable booking" + (ev/free-p? + b + sp + (quote a) + (ev-dt + 2026 + 6 + 5 + 9 + 10) + (ev-dt + 2026 + 6 + 5 + 9 + 20)) + false) + (ev-api-check! + "persist free? true in an open window" + (ev/free-p? + b + sp + (quote a) + (ev-dt + 2026 + 6 + 5 + 10 + 0) + (ev-dt + 2026 + 6 + 5 + 10 + 30)) + true) + (ev/cancel-occ! b sp (quote a) occ) + (ev-api-check! + "durable cancel frees a seat" + (ev/seats-left-occ b sp occ) + 1) + (ev-api-check! + "persist free? true after cancellation" + (ev/free-p? + b + sp + (quote a) + (ev-dt + 2026 + 6 + 5 + 9 + 10) + (ev-dt + 2026 + 6 + 5 + 9 + 20)) + true)))))))))))) (define ev-api-tests-run! diff --git a/plans/events-on-sx.md b/plans/events-on-sx.md index 648a3681..29406a31 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` → **110/110** (Phase 1 complete + Phase 2 booking + cancellation) +`bash lib/events/conformance.sh` → **120/120** (Phase 1 + Phase 2 booking/cancel + persist-backed api) ## Ground rules @@ -63,7 +63,7 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ── ## Phase 2 — Ticketing + booking - [x] capacity rules; transactional booking → `persist` (no overbooking) -- [ ] wire `booking.sx` into `api.sx` (persist-backed `ev/book`) +- [x] wire `booking.sx` into `api.sx` (persist-backed `ev/book-occ!` + derived availability) - [x] cancellation (tombstone events) + seat release - [ ] paid tickets compose with `commerce` order flow - [x] tests: capacity edge, double-book guard, conflict detection @@ -81,6 +81,13 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ── ## Progress log +- 2026-06-07 — Wired `booking.sx` into `api.sx`: durable persist-backed booking + path alongside the in-memory one. `ev/book-occ!`, `ev/cancel-occ!`, + `ev/roster-occ`, `ev/seats-left-occ` (capacity from the scheduled event); + `ev/free-p?`, `ev/next-free-p`, `ev/conflicts-p` derive availability by + replaying persist booking streams for in-window occurrences. Capacity-safe + + cancellable bookings now flow through the public API. Reordered conformance + preloads (persist + booking before events/api). +10 tests, 120/120 green. - 2026-06-07 — Cancellation + seat release. Booking stream now carries :booking / :cancel events; the live roster is the FOLDED replay (book adds, cancel removes) so capacity reopens when a seat is freed. `ev/cancel!`