events: api.sx — public events facade + 14 tests (Phase 1 complete)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 37s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 37s
Immutable store ({:events :bookings}) over calendar+availability:
ev/schedule, ev/book, ev/agenda, ev/agenda-for, ev/free?, ev/next-free,
ev/conflicts. Availability queries auto-widen expansion by longest event.
73/73 green. Phase 1 done.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
137
lib/events/api.sx
Normal file
137
lib/events/api.sx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
;; lib/events/api.sx — public events surface over calendar + availability.
|
||||||
|
;;
|
||||||
|
;; A `store` is an immutable value holding scheduled events and bookings:
|
||||||
|
;;
|
||||||
|
;; {:events (event ...) :bookings ((actor key) ...)}
|
||||||
|
;;
|
||||||
|
;; 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.
|
||||||
|
|
||||||
|
(define ev/store (fn (events bookings) {:bookings bookings :events events}))
|
||||||
|
|
||||||
|
(define ev/empty (fn () (ev/store (list) (list))))
|
||||||
|
|
||||||
|
(define ev/events (fn (store) (get store :events)))
|
||||||
|
(define ev/bookings (fn (store) (get store :bookings)))
|
||||||
|
|
||||||
|
;; Add a (constructed) event to the store.
|
||||||
|
(define
|
||||||
|
ev/add-event
|
||||||
|
(fn
|
||||||
|
(store event)
|
||||||
|
(ev/store (cons event (ev/events store)) (ev/bookings store))))
|
||||||
|
|
||||||
|
;; Schedule a fresh event from parts, returning the updated store. rrule may be
|
||||||
|
;; nil for a one-off. (Booking is separate — see ev/book.)
|
||||||
|
(define
|
||||||
|
ev/schedule
|
||||||
|
(fn
|
||||||
|
(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.
|
||||||
|
(define
|
||||||
|
ev/book
|
||||||
|
(fn
|
||||||
|
(store actor key)
|
||||||
|
(ev/store
|
||||||
|
(ev/events store)
|
||||||
|
(cons (list actor key) (ev/bookings store)))))
|
||||||
|
|
||||||
|
;; The maximum event duration in the store (0 when empty) — used to widen
|
||||||
|
;; expansion windows so any occurrence overlapping a query is captured.
|
||||||
|
(define
|
||||||
|
ev/store-max-duration
|
||||||
|
(fn
|
||||||
|
(store)
|
||||||
|
(reduce
|
||||||
|
(fn (m ev) (max m (get ev :duration)))
|
||||||
|
0
|
||||||
|
(ev/events store))))
|
||||||
|
|
||||||
|
;; All occurrences across all events within [ws, we), ascending by start.
|
||||||
|
(define
|
||||||
|
ev/agenda
|
||||||
|
(fn (store ws we) (ev-expand-all (ev/events store) ws we)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-key-member?
|
||||||
|
(fn
|
||||||
|
(k keys)
|
||||||
|
(cond
|
||||||
|
((empty? keys) false)
|
||||||
|
((= k (first keys)) true)
|
||||||
|
(else (ev-key-member? k (rest keys))))))
|
||||||
|
|
||||||
|
;; Occurrence keys `actor` has booked.
|
||||||
|
(define
|
||||||
|
ev/actor-keys
|
||||||
|
(fn
|
||||||
|
(store actor)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(acc b)
|
||||||
|
(if (= (first b) actor) (cons (first (rest b)) acc) acc))
|
||||||
|
(list)
|
||||||
|
(ev/bookings store))))
|
||||||
|
|
||||||
|
;; The agenda restricted to occurrences `actor` is booked into, within window.
|
||||||
|
(define
|
||||||
|
ev/agenda-for
|
||||||
|
(fn
|
||||||
|
(store actor ws we)
|
||||||
|
(let
|
||||||
|
((keys (ev/actor-keys store actor)))
|
||||||
|
(filter
|
||||||
|
(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).
|
||||||
|
(define
|
||||||
|
ev/avail-window-db
|
||||||
|
(fn
|
||||||
|
(store ws we)
|
||||||
|
(ev-avail-db (ev/agenda store ws we) (ev/bookings store))))
|
||||||
|
|
||||||
|
;; Is `actor` free across [qs, qe)? Expands a window wide enough (back by the
|
||||||
|
;; longest event) to capture any occurrence that could overlap.
|
||||||
|
(define
|
||||||
|
ev/free?
|
||||||
|
(fn
|
||||||
|
(store actor qs qe)
|
||||||
|
(ev-free?
|
||||||
|
(ev/avail-window-db store (- qs (ev/store-max-duration store)) qe)
|
||||||
|
actor
|
||||||
|
qs
|
||||||
|
qe)))
|
||||||
|
|
||||||
|
;; Earliest free slot of `duration` for `actor` in [after, horizon), or nil.
|
||||||
|
(define
|
||||||
|
ev/next-free
|
||||||
|
(fn
|
||||||
|
(store actor after duration horizon)
|
||||||
|
(ev-next-free
|
||||||
|
(ev/avail-window-db
|
||||||
|
store
|
||||||
|
(- after (ev/store-max-duration store))
|
||||||
|
horizon)
|
||||||
|
actor
|
||||||
|
after
|
||||||
|
duration
|
||||||
|
horizon)))
|
||||||
|
|
||||||
|
;; Overlapping double-bookings for `actor` among occurrences in [ws, we).
|
||||||
|
(define
|
||||||
|
ev/conflicts
|
||||||
|
(fn
|
||||||
|
(store actor ws we)
|
||||||
|
(ev-conflicts (ev/avail-window-db store ws we) actor)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev/has-conflict?
|
||||||
|
(fn
|
||||||
|
(store actor ws we)
|
||||||
|
(> (len (ev/conflicts store actor ws we)) 0)))
|
||||||
@@ -17,9 +17,11 @@ 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
|
||||||
)
|
)
|
||||||
|
|
||||||
SUITES=(
|
SUITES=(
|
||||||
"calendar:lib/events/tests/calendar.sx:(ev-calendar-tests-run!)"
|
"calendar:lib/events/tests/calendar.sx:(ev-calendar-tests-run!)"
|
||||||
"availability:lib/events/tests/availability.sx:(ev-availability-tests-run!)"
|
"availability:lib/events/tests/availability.sx:(ev-availability-tests-run!)"
|
||||||
|
"api:lib/events/tests/api.sx:(ev-api-tests-run!)"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"lang": "events",
|
"lang": "events",
|
||||||
"total_passed": 59,
|
"total_passed": 73,
|
||||||
"total_failed": 0,
|
"total_failed": 0,
|
||||||
"total": 59,
|
"total": 73,
|
||||||
"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}
|
||||||
],
|
],
|
||||||
"generated": "2026-06-07T00:49:23+00:00"
|
"generated": "2026-06-07T01:15:49+00:00"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# events scoreboard
|
# events scoreboard
|
||||||
|
|
||||||
**59 / 59 passing** (0 failure(s)).
|
**73 / 73 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 |
|
||||||
|
|||||||
176
lib/events/tests/api.sx
Normal file
176
lib/events/tests/api.sx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
;; lib/events/tests/api.sx — public events facade (schedule/agenda/free/book).
|
||||||
|
|
||||||
|
(define ev-api-pass 0)
|
||||||
|
(define ev-api-fail 0)
|
||||||
|
(define ev-api-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-api-check!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! ev-api-pass (+ ev-api-pass 1))
|
||||||
|
(do
|
||||||
|
(set! ev-api-fail (+ ev-api-fail 1))
|
||||||
|
(append!
|
||||||
|
ev-api-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got))))))
|
||||||
|
|
||||||
|
;; A store with a weekly yoga class (Mon+Wed 18:00, 60m, 4 occurrences).
|
||||||
|
(define
|
||||||
|
ev-api-store
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(ev/schedule
|
||||||
|
(ev/empty)
|
||||||
|
(quote yoga)
|
||||||
|
(ev-dt 2026 6 1 18 0)
|
||||||
|
60
|
||||||
|
{:freq :weekly :count 4 :byday (list 0 2)}
|
||||||
|
20)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-api-run-all!
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(let
|
||||||
|
((s0 (ev-api-store)))
|
||||||
|
(let
|
||||||
|
((occs (ev/agenda s0 (ev-date 2026 6 1) (ev-date 2026 7 1))))
|
||||||
|
(let
|
||||||
|
((s1 (ev/book (ev/book s0 (quote nia) (ev-occ-key (first occs))) (quote nia) (ev-occ-key (first (rest occs))))))
|
||||||
|
(do
|
||||||
|
(ev-api-check!
|
||||||
|
"agenda expands weekly class to four occurrences"
|
||||||
|
(map (fn (o) (ev-dt->civil (get o :start))) occs)
|
||||||
|
(list
|
||||||
|
(list 2026 6 1)
|
||||||
|
(list 2026 6 3)
|
||||||
|
(list 2026 6 8)
|
||||||
|
(list 2026 6 10)))
|
||||||
|
(ev-api-check!
|
||||||
|
"empty store has empty agenda"
|
||||||
|
(ev/agenda
|
||||||
|
(ev/empty)
|
||||||
|
(ev-date 2026 6 1)
|
||||||
|
(ev-date 2026 7 1))
|
||||||
|
(list))
|
||||||
|
(ev-api-check!
|
||||||
|
"max duration reflects scheduled events"
|
||||||
|
(ev/store-max-duration s0)
|
||||||
|
60)
|
||||||
|
(ev-api-check!
|
||||||
|
"max duration of empty store is zero"
|
||||||
|
(ev/store-max-duration (ev/empty))
|
||||||
|
0)
|
||||||
|
(ev-api-check!
|
||||||
|
"agenda-for lists only booked occurrences"
|
||||||
|
(map
|
||||||
|
(fn (o) (ev-dt->civil (get o :start)))
|
||||||
|
(ev/agenda-for
|
||||||
|
s1
|
||||||
|
(quote nia)
|
||||||
|
(ev-date 2026 6 1)
|
||||||
|
(ev-date 2026 7 1)))
|
||||||
|
(list
|
||||||
|
(list 2026 6 1)
|
||||||
|
(list 2026 6 3)))
|
||||||
|
(ev-api-check!
|
||||||
|
"agenda-for empty for unbooked actor"
|
||||||
|
(ev/agenda-for
|
||||||
|
s1
|
||||||
|
(quote zed)
|
||||||
|
(ev-date 2026 6 1)
|
||||||
|
(ev-date 2026 7 1))
|
||||||
|
(list))
|
||||||
|
(ev-api-check!
|
||||||
|
"free? false during a booked occurrence"
|
||||||
|
(ev/free?
|
||||||
|
s1
|
||||||
|
(quote nia)
|
||||||
|
(ev-dt 2026 6 1 18 30)
|
||||||
|
(ev-dt 2026 6 1 19 0))
|
||||||
|
false)
|
||||||
|
(ev-api-check!
|
||||||
|
"free? true in an open window"
|
||||||
|
(ev/free?
|
||||||
|
s1
|
||||||
|
(quote nia)
|
||||||
|
(ev-dt 2026 6 1 9 0)
|
||||||
|
(ev-dt 2026 6 1 10 0))
|
||||||
|
true)
|
||||||
|
(ev-api-check!
|
||||||
|
"free? half-open at occurrence end"
|
||||||
|
(ev/free?
|
||||||
|
s1
|
||||||
|
(quote nia)
|
||||||
|
(ev-dt 2026 6 1 19 0)
|
||||||
|
(ev-dt 2026 6 1 20 0))
|
||||||
|
true)
|
||||||
|
(ev-api-check!
|
||||||
|
"free? true for an actor who booked nothing"
|
||||||
|
(ev/free?
|
||||||
|
s1
|
||||||
|
(quote zed)
|
||||||
|
(ev-dt 2026 6 1 18 0)
|
||||||
|
(ev-dt 2026 6 1 19 0))
|
||||||
|
true)
|
||||||
|
(ev-api-check!
|
||||||
|
"next-free skips the booked slot to the hour after"
|
||||||
|
(ev-dt-tod
|
||||||
|
(ev/next-free
|
||||||
|
s1
|
||||||
|
(quote nia)
|
||||||
|
(ev-dt
|
||||||
|
2026
|
||||||
|
6
|
||||||
|
1
|
||||||
|
18
|
||||||
|
0)
|
||||||
|
60
|
||||||
|
(ev-dt
|
||||||
|
2026
|
||||||
|
6
|
||||||
|
1
|
||||||
|
23
|
||||||
|
0)))
|
||||||
|
(* 19 60))
|
||||||
|
(ev-api-check!
|
||||||
|
"next-free returns `after` when already open"
|
||||||
|
(ev/next-free
|
||||||
|
s1
|
||||||
|
(quote nia)
|
||||||
|
(ev-dt 2026 6 1 9 0)
|
||||||
|
60
|
||||||
|
(ev-dt 2026 6 1 18 0))
|
||||||
|
(ev-dt 2026 6 1 9 0))
|
||||||
|
(ev-api-check!
|
||||||
|
"no conflict among disjoint bookings"
|
||||||
|
(ev/has-conflict?
|
||||||
|
s1
|
||||||
|
(quote nia)
|
||||||
|
(ev-date 2026 6 1)
|
||||||
|
(ev-date 2026 7 1))
|
||||||
|
false)
|
||||||
|
(let
|
||||||
|
((sc (ev/book (ev/schedule s1 (quote talk) (ev-dt 2026 6 1 18 30) 60 nil 5) (quote nia) (ev-occ-key (ev-occ (quote talk) (ev-dt 2026 6 1 18 30) 60)))))
|
||||||
|
(ev-api-check!
|
||||||
|
"overlapping second booking creates a conflict"
|
||||||
|
(ev/has-conflict?
|
||||||
|
sc
|
||||||
|
(quote nia)
|
||||||
|
(ev-date 2026 6 1)
|
||||||
|
(ev-date 2026 7 1))
|
||||||
|
true))))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ev-api-tests-run!
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(do
|
||||||
|
(set! ev-api-pass 0)
|
||||||
|
(set! ev-api-fail 0)
|
||||||
|
(set! ev-api-failures (list))
|
||||||
|
(ev-api-run-all!)
|
||||||
|
{:failures ev-api-failures :total (+ ev-api-pass ev-api-fail) :passed ev-api-pass :failed ev-api-fail})))
|
||||||
@@ -18,7 +18,7 @@ capacity rules, transactional booking, and a flow-driven notification dispatcher
|
|||||||
|
|
||||||
## Status (rolling)
|
## Status (rolling)
|
||||||
|
|
||||||
`bash lib/events/conformance.sh` → **59/59** (Phase 1: calendar + availability)
|
`bash lib/events/conformance.sh` → **73/73** (Phase 1 complete: calendar + availability + api)
|
||||||
|
|
||||||
## Ground rules
|
## Ground rules
|
||||||
|
|
||||||
@@ -58,8 +58,8 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ──
|
|||||||
- [x] `calendar.sx` — MONTHLY (bymonthday + nth-weekday byday)
|
- [x] `calendar.sx` — MONTHLY (bymonthday + nth-weekday byday)
|
||||||
- [x] `availability.sx` — free/busy rules (busy/conflict/busy_in on Datalog)
|
- [x] `availability.sx` — free/busy rules (busy/conflict/busy_in on Datalog)
|
||||||
- [x] `availability.sx` — next-free slot search (same rules, different bindings)
|
- [x] `availability.sx` — next-free slot search (same rules, different bindings)
|
||||||
- [ ] `api.sx` — public entry points (schedule/agenda/free-check)
|
- [x] `api.sx` — public entry points (schedule/agenda/book/free/next-free/conflicts)
|
||||||
- [ ] tests + scoreboard + conformance.sh [done incrementally; api.sx pending]
|
- [x] tests + scoreboard + conformance.sh (73/73)
|
||||||
|
|
||||||
## Phase 2 — Ticketing + booking
|
## Phase 2 — Ticketing + booking
|
||||||
- [ ] capacity rules; transactional booking → `persist` (no overbooking)
|
- [ ] capacity rules; transactional booking → `persist` (no overbooking)
|
||||||
@@ -79,6 +79,13 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ──
|
|||||||
|
|
||||||
## Progress log
|
## Progress log
|
||||||
|
|
||||||
|
- 2026-06-07 — **Phase 1 complete.** `api.sx`: immutable `store`
|
||||||
|
({:events :bookings}) facade over calendar + availability. `ev/schedule`,
|
||||||
|
`ev/book`, `ev/agenda`, `ev/agenda-for`, `ev/free?`, `ev/next-free`,
|
||||||
|
`ev/conflicts`. Availability queries auto-widen the expansion window back by
|
||||||
|
the longest event so any overlapping occurrence is captured. 14 tests,
|
||||||
|
73/73 green. Phase 2 (transactional booking on persist) is next — `ev/book`
|
||||||
|
becomes capacity-safe via a persist append at that point.
|
||||||
- 2026-06-07 — `next-free` slot search: earliest start ≥ after where
|
- 2026-06-07 — `next-free` slot search: earliest start ≥ after where
|
||||||
[s,s+duration) is free and ends ≤ horizon, else nil. Candidates are `after`
|
[s,s+duration) is free and ends ≤ horizon, else nil. Candidates are `after`
|
||||||
plus each busy-interval end (interval-packing); each probe reuses the
|
plus each busy-interval end (interval-packing); each probe reuses the
|
||||||
|
|||||||
Reference in New Issue
Block a user