events: wire persist-backed booking into api.sx + 10 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 35s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 35s
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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,19 @@
|
|||||||
;; lib/events/api.sx — public events surface over calendar + availability.
|
;; 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) ...)}
|
;; {: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
|
;; All queries are windowed: agenda/free/next-free expand recurring events into
|
||||||
;; concrete occurrences within an explicit (or derived) window before running
|
;; concrete occurrences within an explicit (or derived) window before running
|
||||||
;; the Datalog availability rules. Phase 2 replaces `ev/book` with a capacity-
|
;; the Datalog availability rules.
|
||||||
;; safe persist append; the rest of this facade stays put.
|
|
||||||
|
|
||||||
(define ev/store (fn (events bookings) {:bookings bookings :events events}))
|
(define ev/store (fn (events bookings) {:bookings bookings :events events}))
|
||||||
|
|
||||||
@@ -31,8 +37,8 @@
|
|||||||
(store id dtstart duration rrule capacity)
|
(store id dtstart duration rrule capacity)
|
||||||
(ev/add-event store (ev-event 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
|
;; Record that `actor` holds the occurrence with `key` (in-memory only — see
|
||||||
;; expanded occurrence). Phase 1: append-only, no capacity check.
|
;; ev/book-occ! for the durable, capacity-safe path).
|
||||||
(define
|
(define
|
||||||
ev/book
|
ev/book
|
||||||
(fn
|
(fn
|
||||||
@@ -41,6 +47,27 @@
|
|||||||
(ev/events store)
|
(ev/events store)
|
||||||
(cons (list actor key) (ev/bookings 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
|
;; The maximum event duration in the store (0 when empty) — used to widen
|
||||||
;; expansion windows so any occurrence overlapping a query is captured.
|
;; expansion windows so any occurrence overlapping a query is captured.
|
||||||
(define
|
(define
|
||||||
@@ -66,7 +93,7 @@
|
|||||||
((= k (first keys)) true)
|
((= k (first keys)) true)
|
||||||
(else (ev-key-member? k (rest keys))))))
|
(else (ev-key-member? k (rest keys))))))
|
||||||
|
|
||||||
;; Occurrence keys `actor` has booked.
|
;; Occurrence keys `actor` has booked (in-memory store).
|
||||||
(define
|
(define
|
||||||
ev/actor-keys
|
ev/actor-keys
|
||||||
(fn
|
(fn
|
||||||
@@ -78,7 +105,7 @@
|
|||||||
(list)
|
(list)
|
||||||
(ev/bookings store))))
|
(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
|
(define
|
||||||
ev/agenda-for
|
ev/agenda-for
|
||||||
(fn
|
(fn
|
||||||
@@ -89,7 +116,8 @@
|
|||||||
(fn (o) (ev-key-member? (ev-occ-key o) keys))
|
(fn (o) (ev-key-member? (ev-occ-key o) keys))
|
||||||
(ev/agenda store ws we)))))
|
(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
|
(define
|
||||||
ev/avail-window-db
|
ev/avail-window-db
|
||||||
(fn
|
(fn
|
||||||
@@ -135,3 +163,89 @@
|
|||||||
(fn
|
(fn
|
||||||
(store actor ws we)
|
(store actor ws we)
|
||||||
(> (len (ev/conflicts store actor ws we)) 0)))
|
(> (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)))
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ PRELOADS=(
|
|||||||
lib/datalog/magic.sx
|
lib/datalog/magic.sx
|
||||||
lib/events/calendar.sx
|
lib/events/calendar.sx
|
||||||
lib/events/availability.sx
|
lib/events/availability.sx
|
||||||
lib/events/api.sx
|
|
||||||
lib/persist/event.sx
|
lib/persist/event.sx
|
||||||
lib/persist/backend.sx
|
lib/persist/backend.sx
|
||||||
lib/persist/log.sx
|
lib/persist/log.sx
|
||||||
@@ -27,6 +26,7 @@ PRELOADS=(
|
|||||||
lib/persist/concurrency.sx
|
lib/persist/concurrency.sx
|
||||||
lib/persist/api.sx
|
lib/persist/api.sx
|
||||||
lib/events/booking.sx
|
lib/events/booking.sx
|
||||||
|
lib/events/api.sx
|
||||||
)
|
)
|
||||||
|
|
||||||
SUITES=(
|
SUITES=(
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"lang": "events",
|
"lang": "events",
|
||||||
"total_passed": 110,
|
"total_passed": 120,
|
||||||
"total_failed": 0,
|
"total_failed": 0,
|
||||||
"total": 110,
|
"total": 120,
|
||||||
"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":14,"failed":0,"total":14},
|
{"name":"api","passed":24,"failed":0,"total":24},
|
||||||
{"name":"booking","passed":37,"failed":0,"total":37}
|
{"name":"booking","passed":37,"failed":0,"total":37}
|
||||||
],
|
],
|
||||||
"generated": "2026-06-07T02:09:36+00:00"
|
"generated": "2026-06-07T02:39:08+00:00"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# events scoreboard
|
# events scoreboard
|
||||||
|
|
||||||
**110 / 110 passing** (0 failure(s)).
|
**120 / 120 passing** (0 failure(s)).
|
||||||
|
|
||||||
| Suite | Passed | Total | Status |
|
| Suite | Passed | Total | Status |
|
||||||
|-------|--------|-------|--------|
|
|-------|--------|-------|--------|
|
||||||
| calendar | 37 | 37 | ok |
|
| calendar | 37 | 37 | ok |
|
||||||
| availability | 22 | 22 | ok |
|
| availability | 22 | 22 | ok |
|
||||||
| api | 14 | 14 | ok |
|
| api | 24 | 24 | ok |
|
||||||
| booking | 37 | 37 | ok |
|
| booking | 37 | 37 | ok |
|
||||||
|
|||||||
@@ -64,6 +64,14 @@
|
|||||||
"max duration of empty store is zero"
|
"max duration of empty store is zero"
|
||||||
(ev/store-max-duration (ev/empty))
|
(ev/store-max-duration (ev/empty))
|
||||||
0)
|
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!
|
(ev-api-check!
|
||||||
"agenda-for lists only booked occurrences"
|
"agenda-for lists only booked occurrences"
|
||||||
(map
|
(map
|
||||||
@@ -162,7 +170,94 @@
|
|||||||
(quote nia)
|
(quote nia)
|
||||||
(ev-date 2026 6 1)
|
(ev-date 2026 6 1)
|
||||||
(ev-date 2026 7 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
|
(define
|
||||||
ev-api-tests-run!
|
ev-api-tests-run!
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ capacity rules, transactional booking, and a flow-driven notification dispatcher
|
|||||||
|
|
||||||
## Status (rolling)
|
## 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
|
## Ground rules
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ──
|
|||||||
|
|
||||||
## Phase 2 — Ticketing + booking
|
## Phase 2 — Ticketing + booking
|
||||||
- [x] capacity rules; transactional booking → `persist` (no overbooking)
|
- [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
|
- [x] cancellation (tombstone events) + seat release
|
||||||
- [ ] paid tickets compose with `commerce` order flow
|
- [ ] paid tickets compose with `commerce` order flow
|
||||||
- [x] tests: capacity edge, double-book guard, conflict detection
|
- [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
|
## 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
|
- 2026-06-07 — Cancellation + seat release. Booking stream now carries
|
||||||
:booking / :cancel events; the live roster is the FOLDED replay (book adds,
|
:booking / :cancel events; the live roster is the FOLDED replay (book adds,
|
||||||
cancel removes) so capacity reopens when a seat is freed. `ev/cancel!`
|
cancel removes) so capacity reopens when a seat is freed. `ev/cancel!`
|
||||||
|
|||||||
Reference in New Issue
Block a user