Compare commits
80 Commits
loops/conf
...
loops/rela
| Author | SHA1 | Date | |
|---|---|---|---|
| f1d65c0953 | |||
| c0d02c229c | |||
| b66395886b | |||
| e6ffc60040 | |||
| 1c46fc2a69 | |||
| 1dacb0c8dd | |||
| ffe3ec25ac | |||
| 7a1696490c | |||
| c67aefa211 | |||
| b821e6a79d | |||
| e3932237bd | |||
| bf7bd38010 | |||
| d59a999da6 | |||
| f040f76ebe | |||
| 644ea178c2 | |||
| c991c7c3d3 | |||
| d466ca3414 | |||
| 07e4cb5f4a | |||
| 98ed2eebdf | |||
| b308effb9f | |||
| 48f5b75cc2 | |||
| 7446c24bde | |||
| 3b782eba8a | |||
| 29127d8613 | |||
| 80174c7197 | |||
| 8130521f02 | |||
| f6c1d1e9bf | |||
| 398209d484 | |||
| e35769411e | |||
| 3c3b09688a | |||
| 05d5c46730 | |||
| ded7170540 | |||
| b1f9c6bef0 | |||
| 7153e742c8 | |||
| db885e15bc | |||
| d2f5b49d3f | |||
| 24d4db3f0d | |||
| 226d755b57 | |||
| 3f3459d129 | |||
| 9adeff1431 | |||
| 9860582b4a | |||
| a43825f25f | |||
| 80a2dee22f | |||
| e951f23f14 | |||
| 21673b6731 | |||
| e448220b33 | |||
| a5c22c5a01 | |||
| 15e9503b05 | |||
| 785faf2441 | |||
| dc00ed9786 | |||
| 4674b797cb | |||
| 5d62d08e1c | |||
| 56cf920041 | |||
| 20ba152e36 | |||
| baee67f561 | |||
| 540933bfca | |||
| 27f43dbf10 | |||
| 064bbf18b3 | |||
| db2a5dc6ab | |||
| 938e90455d | |||
| 70aea21601 | |||
| 797c5f9147 | |||
| ac63501266 | |||
| 1c6b80404e | |||
| cfa68c3db3 | |||
| cf4e613e43 | |||
| 95e981eb03 | |||
| 911a2f57c0 | |||
| c6c2cebf98 | |||
| 65f274c573 | |||
| 7231cb651f | |||
| 5945b51cfd | |||
| 3ab8270a58 | |||
| 9d3b775b25 | |||
| 77ab827b91 | |||
| a3f9d4f6c9 | |||
| 4c84decc01 | |||
| 0f0da0319c | |||
| b8cf3eb1b8 | |||
| e2de5a4675 |
@@ -1 +1 @@
|
||||
{"sessionId":"bf20a443-9df8-4cb9-932e-8c6f4c4625c2","pid":1303602,"procStart":"253831081","acquiredAt":1779865895644}
|
||||
{"sessionId":"c4d97db1-361c-4a04-a99b-c838f9385469","pid":2426590,"procStart":"349789073","acquiredAt":1780789990975}
|
||||
@@ -571,9 +571,12 @@ and cek_run_with_io state =
|
||||
Hashtbl.replace d "descent" (Number desc);
|
||||
Dict d
|
||||
| _ ->
|
||||
let args = let a = Sx_runtime.get_val request (String "args") in
|
||||
(match a with List l -> l | _ -> [a]) in
|
||||
io_request op args
|
||||
let argsv = Sx_runtime.get_val request (String "args") in
|
||||
(match Sx_persist_store.handle_op op argsv with
|
||||
| Some resp -> resp
|
||||
| None ->
|
||||
let args = (match argsv with List l -> l | _ -> [argsv]) in
|
||||
io_request op args)
|
||||
in
|
||||
s := Sx_ref.cek_resume !s response;
|
||||
loop ()
|
||||
@@ -1698,7 +1701,12 @@ let rec dispatch env cmd =
|
||||
| Some path -> load_library_file path | None -> ());
|
||||
Nil
|
||||
end
|
||||
end else Nil (* non-import IO: resume with nil *) in
|
||||
end else
|
||||
(* durable-storage ops: service against on-disk store *)
|
||||
let args = Sx_runtime.get_val request (String "args") in
|
||||
(match Sx_persist_store.handle_op op args with
|
||||
| Some resp -> resp
|
||||
| None -> Nil (* non-import IO: resume with nil *)) in
|
||||
s := Sx_ref.cek_resume !s response
|
||||
done;
|
||||
Sx_ref.cek_value !s
|
||||
@@ -4051,7 +4059,10 @@ let http_mode port =
|
||||
Dict d
|
||||
| "io-sleep" | "sleep" -> Nil
|
||||
| "import" -> Nil
|
||||
| _ -> Nil);
|
||||
| _ ->
|
||||
(match Sx_persist_store.handle_op op args with
|
||||
| Some resp -> resp
|
||||
| None -> Nil));
|
||||
(* Response cache — path → full HTTP response string.
|
||||
Populated during pre-warm, serves cached responses in <0.1ms.
|
||||
Thread-safe: reads are lock-free (Hashtbl.find_opt is atomic for
|
||||
|
||||
293
hosts/ocaml/lib/sx_persist_store.ml
Normal file
293
hosts/ocaml/lib/sx_persist_store.ml
Normal file
@@ -0,0 +1,293 @@
|
||||
(* sx_persist_store — host durable-storage adapter for lib/persist.
|
||||
Production twin of `persist/serve` (lib/persist/durable.sx): it answers the
|
||||
same `persist/...` IO ops, but backs them with real on-disk storage so writes
|
||||
survive a process restart. Stateless-on-disk: every op reads/writes the
|
||||
filesystem directly, so a fresh process recovers state with no warm-up — the
|
||||
log on disk IS the state.
|
||||
|
||||
On-disk layout under the root dir (default ./persist-data, or $SX_PERSIST_DIR):
|
||||
streams/<hex(stream)>.log append-only, one SX-serialized event per line
|
||||
streams/<hex(stream)>.seq per-stream monotonic high-water counter (int)
|
||||
kv/<hex(key)> one SX-serialized value per key
|
||||
|
||||
Invariants honoured (see plans/persist-on-sx.md Blocker spec):
|
||||
1. last-seq is a per-stream monotonic counter stored in .seq, SEPARATE from
|
||||
the rows — it keeps climbing across truncate, so a compacted stream never
|
||||
reassigns a seq.
|
||||
2. append never renumbers — the event already carries its :seq (log.sx does
|
||||
last-seq+1); the host only bumps the high-water mark to max(hw, seq).
|
||||
3. read returns surviving events in append order with :seq intact.
|
||||
4. streams is the set of streams that ever had an append — keyed off the .seq
|
||||
file, which truncate never deletes, so it survives full compaction.
|
||||
5. values round-trip structurally via the SX serializer/parser. *)
|
||||
|
||||
open Sx_types
|
||||
|
||||
(* ---- root dir ---------------------------------------------------------- *)
|
||||
|
||||
let _root : string option ref = ref None
|
||||
|
||||
let set_root dir = _root := Some dir
|
||||
|
||||
let root_dir () =
|
||||
match !_root with
|
||||
| Some d -> d
|
||||
| None -> (try Sys.getenv "SX_PERSIST_DIR" with Not_found -> "persist-data")
|
||||
|
||||
(* ---- filesystem helpers ------------------------------------------------ *)
|
||||
|
||||
let rec ensure_dir dir =
|
||||
if dir = "" || dir = "." || dir = "/" || Sys.file_exists dir then ()
|
||||
else begin
|
||||
ensure_dir (Filename.dirname dir);
|
||||
(try Unix.mkdir dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ())
|
||||
end
|
||||
|
||||
let streams_dir () = Filename.concat (root_dir ()) "streams"
|
||||
let kv_dir () = Filename.concat (root_dir ()) "kv"
|
||||
let blobs_dir () = Filename.concat (root_dir ()) "blobs"
|
||||
|
||||
let read_file path =
|
||||
let ic = open_in_bin path in
|
||||
let n = in_channel_length ic in
|
||||
let s = really_input_string ic n in
|
||||
close_in ic;
|
||||
s
|
||||
|
||||
(* Atomic write: temp file in the same dir then rename over the target. *)
|
||||
let write_file_atomic path contents =
|
||||
ensure_dir (Filename.dirname path);
|
||||
let tmp = path ^ ".tmp" in
|
||||
let oc = open_out_bin tmp in
|
||||
output_string oc contents;
|
||||
flush oc;
|
||||
close_out oc;
|
||||
Sys.rename tmp path
|
||||
|
||||
let append_line path line =
|
||||
ensure_dir (Filename.dirname path);
|
||||
let oc = open_out_gen [Open_append; Open_creat; Open_wronly] 0o644 path in
|
||||
output_string oc line;
|
||||
output_char oc '\n';
|
||||
close_out oc
|
||||
|
||||
(* ---- name <-> filename (hex, reversible, fs-safe) ---------------------- *)
|
||||
|
||||
let hex_encode s =
|
||||
let b = Buffer.create (String.length s * 2) in
|
||||
String.iter (fun c -> Buffer.add_string b (Printf.sprintf "%02x" (Char.code c))) s;
|
||||
Buffer.contents b
|
||||
|
||||
let hex_decode s =
|
||||
let n = String.length s / 2 in
|
||||
String.init n (fun i -> Char.chr (int_of_string ("0x" ^ String.sub s (i * 2) 2)))
|
||||
|
||||
let stream_log stream = Filename.concat (streams_dir ()) (hex_encode stream ^ ".log")
|
||||
let stream_seq stream = Filename.concat (streams_dir ()) (hex_encode stream ^ ".seq")
|
||||
let kv_path key = Filename.concat (kv_dir ()) (hex_encode key)
|
||||
|
||||
(* ---- value <-> SX text (round-trips through Sx_parser) ----------------- *)
|
||||
|
||||
let escape_str s =
|
||||
let len = String.length s in
|
||||
let buf = Buffer.create (len + 16) in
|
||||
for i = 0 to len - 1 do
|
||||
match s.[i] with
|
||||
| '"' -> Buffer.add_string buf "\\\""
|
||||
| '\\' -> Buffer.add_string buf "\\\\"
|
||||
| '\n' -> Buffer.add_string buf "\\n"
|
||||
| '\r' -> Buffer.add_string buf "\\r"
|
||||
| '\t' -> Buffer.add_string buf "\\t"
|
||||
| c -> Buffer.add_char buf c
|
||||
done;
|
||||
Buffer.contents buf
|
||||
|
||||
let rec serialize = function
|
||||
| Nil -> "nil"
|
||||
| Bool true -> "true"
|
||||
| Bool false -> "false"
|
||||
| Integer n -> string_of_int n
|
||||
| Number n -> format_number n
|
||||
| String s -> "\"" ^ escape_str s ^ "\""
|
||||
| Symbol s -> "(quote " ^ s ^ ")"
|
||||
| Keyword k -> ":" ^ k
|
||||
| List items | ListRef { contents = items } ->
|
||||
"(list" ^ (List.fold_left (fun acc v -> acc ^ " " ^ serialize v) "" items) ^ ")"
|
||||
| Dict d ->
|
||||
let pairs = Hashtbl.fold (fun k v acc ->
|
||||
(Printf.sprintf ":%s %s" k (serialize v)) :: acc) d [] in
|
||||
"{" ^ String.concat " " (List.sort String.compare pairs) ^ "}"
|
||||
| _ -> "nil"
|
||||
|
||||
(* Parse one serialized value back. Empty / blank -> Nil. *)
|
||||
let rec deserialize line =
|
||||
let line = String.trim line in
|
||||
if line = "" then Nil
|
||||
else match Sx_parser.parse_all line with
|
||||
| v :: _ -> eval_quote_lists v
|
||||
| [] -> Nil
|
||||
|
||||
(* serialize emits lists as `(list ...)` and symbols as `(quote s)` so the
|
||||
parser yields data, not a call — but the parser leaves those as AST. Walk
|
||||
the parsed AST and collapse `(list ...)`/`(quote s)` back to values. *)
|
||||
and eval_quote_lists v =
|
||||
match v with
|
||||
| List (Symbol "quote" :: x :: []) -> x
|
||||
| List (Symbol "list" :: rest) -> List (List.map eval_quote_lists rest)
|
||||
| List items -> List (List.map eval_quote_lists items)
|
||||
| ListRef { contents = items } -> List (List.map eval_quote_lists items)
|
||||
| Dict d ->
|
||||
let d' = Hashtbl.create (Hashtbl.length d) in
|
||||
Hashtbl.iter (fun k v -> Hashtbl.replace d' k (eval_quote_lists v)) d;
|
||||
Dict d'
|
||||
| other -> other
|
||||
|
||||
(* ---- seq counter ------------------------------------------------------- *)
|
||||
|
||||
let read_seq stream =
|
||||
let p = stream_seq stream in
|
||||
if Sys.file_exists p then (try int_of_string (String.trim (read_file p)) with _ -> 0)
|
||||
else 0
|
||||
|
||||
let write_seq stream n = write_file_atomic (stream_seq stream) (string_of_int n)
|
||||
|
||||
let value_to_int = function
|
||||
| Integer n -> n
|
||||
| Number n -> int_of_float n
|
||||
| _ -> 0
|
||||
|
||||
let event_seq ev =
|
||||
match ev with
|
||||
| Dict d -> (match Hashtbl.find_opt d "seq" with Some v -> value_to_int v | None -> 0)
|
||||
| _ -> 0
|
||||
|
||||
(* ---- ops --------------------------------------------------------------- *)
|
||||
|
||||
let do_append stream ev =
|
||||
ensure_dir (streams_dir ());
|
||||
(* bump the monotonic high-water mark; create .seq on first append so the
|
||||
stream shows up in `streams` and survives later truncation. *)
|
||||
let hw = read_seq stream in
|
||||
let s = event_seq ev in
|
||||
write_seq stream (max hw s);
|
||||
append_line (stream_log stream) (serialize ev)
|
||||
|
||||
let do_read stream =
|
||||
let p = stream_log stream in
|
||||
if not (Sys.file_exists p) then List []
|
||||
else begin
|
||||
let content = read_file p in
|
||||
let lines = String.split_on_char '\n' content in
|
||||
let evs = List.filter_map (fun l ->
|
||||
if String.trim l = "" then None else Some (deserialize l)) lines in
|
||||
List evs
|
||||
end
|
||||
|
||||
let do_last_seq stream = Number (float_of_int (read_seq stream))
|
||||
|
||||
let list_dir_suffix dir suffix =
|
||||
if not (Sys.file_exists dir) then []
|
||||
else
|
||||
Array.to_list (Sys.readdir dir)
|
||||
|> List.filter (fun f -> Filename.check_suffix f suffix)
|
||||
|> List.map (fun f -> hex_decode (Filename.chop_suffix f suffix))
|
||||
|> List.sort String.compare
|
||||
|
||||
let do_streams () = List (List.map (fun s -> String s) (list_dir_suffix (streams_dir ()) ".seq"))
|
||||
|
||||
(* drop events with seq <= n; the .seq high-water counter is untouched. *)
|
||||
let do_truncate stream n =
|
||||
let p = stream_log stream in
|
||||
if Sys.file_exists p then begin
|
||||
let evs = match do_read stream with List l -> l | _ -> [] in
|
||||
let kept = List.filter (fun ev -> event_seq ev > n) evs in
|
||||
let body = String.concat "" (List.map (fun ev -> serialize ev ^ "\n") kept) in
|
||||
write_file_atomic p body
|
||||
end
|
||||
|
||||
let do_kv_get key =
|
||||
let p = kv_path key in
|
||||
if Sys.file_exists p then deserialize (read_file p) else Nil
|
||||
|
||||
let do_kv_put key v =
|
||||
ensure_dir (kv_dir ());
|
||||
write_file_atomic (kv_path key) (serialize v)
|
||||
|
||||
let do_kv_delete key =
|
||||
let p = kv_path key in
|
||||
if Sys.file_exists p then (try Sys.remove p with _ -> ())
|
||||
|
||||
let do_kv_has key = Bool (Sys.file_exists (kv_path key))
|
||||
|
||||
let do_kv_keys () =
|
||||
if not (Sys.file_exists (kv_dir ())) then List []
|
||||
else
|
||||
List (
|
||||
Array.to_list (Sys.readdir (kv_dir ()))
|
||||
|> List.map hex_decode
|
||||
|> List.sort String.compare
|
||||
|> List.map (fun s -> String s))
|
||||
|
||||
(* ---- blob store (content-addressed) ------------------------------------ *)
|
||||
(* Same pattern as the persist ops, but a SEPARATE adapter: large objects live
|
||||
in a content-addressed directory keyed by a CIDv1 (raw codec, sha2-256).
|
||||
persist only ever stores the returned ref ({:cid :size :mime}), never bytes.
|
||||
blob/put is idempotent — identical bytes hash to the same cid + same file. *)
|
||||
|
||||
let codec_raw = 0x55
|
||||
|
||||
let blob_cid bytes =
|
||||
let digest = Sx_cid.unhex (Sx_sha2.sha256_hex bytes) in
|
||||
Sx_cid.cidv1 codec_raw (Sx_cid.multihash Sx_cid.mh_sha2_256 digest)
|
||||
|
||||
let blob_path cid = Filename.concat (blobs_dir ()) cid
|
||||
|
||||
let do_blob_put bytes =
|
||||
let cid = blob_cid bytes in
|
||||
let p = blob_path cid in
|
||||
if not (Sys.file_exists p) then write_file_atomic p bytes;
|
||||
String cid
|
||||
|
||||
let do_blob_get cid =
|
||||
let p = blob_path cid in
|
||||
if Sys.file_exists p then String (read_file p) else Nil
|
||||
|
||||
let do_blob_has cid = Bool (Sys.file_exists (blob_path cid))
|
||||
|
||||
(* ---- dispatch ---------------------------------------------------------- *)
|
||||
|
||||
let arglist = function
|
||||
| List l | ListRef { contents = l } -> l
|
||||
| Nil -> []
|
||||
| v -> [v]
|
||||
|
||||
(* Returns Some response if op is a persist op this store owns, None otherwise. *)
|
||||
let handle_op op args =
|
||||
let a = arglist args in
|
||||
let str = function String s -> s | v -> value_to_string v in
|
||||
match op with
|
||||
| "persist/append" ->
|
||||
(match a with stream :: ev :: _ -> do_append (str stream) ev | _ -> ()); Some Nil
|
||||
| "persist/read" ->
|
||||
(match a with stream :: _ -> Some (do_read (str stream)) | _ -> Some (List []))
|
||||
| "persist/last-seq" ->
|
||||
(match a with stream :: _ -> Some (do_last_seq (str stream)) | _ -> Some (Number 0.0))
|
||||
| "persist/streams" -> Some (do_streams ())
|
||||
| "persist/truncate" ->
|
||||
(match a with stream :: n :: _ -> do_truncate (str stream) (value_to_int n) | _ -> ()); Some Nil
|
||||
| "persist/kv-get" ->
|
||||
(match a with key :: _ -> Some (do_kv_get (str key)) | _ -> Some Nil)
|
||||
| "persist/kv-put" ->
|
||||
(match a with key :: v :: _ -> do_kv_put (str key) v | _ -> ()); Some Nil
|
||||
| "persist/kv-delete" ->
|
||||
(match a with key :: _ -> do_kv_delete (str key) | _ -> ()); Some Nil
|
||||
| "persist/kv-has?" ->
|
||||
(match a with key :: _ -> Some (do_kv_has (str key)) | _ -> Some (Bool false))
|
||||
| "persist/kv-keys" -> Some (do_kv_keys ())
|
||||
| "blob/put" ->
|
||||
(match a with bytes :: _ -> Some (do_blob_put (str bytes)) | _ -> Some Nil)
|
||||
| "blob/get" ->
|
||||
(match a with cid :: _ -> Some (do_blob_get (str cid)) | _ -> Some Nil)
|
||||
| "blob/has?" ->
|
||||
(match a with cid :: _ -> Some (do_blob_has (str cid)) | _ -> Some (Bool false))
|
||||
| _ -> None
|
||||
144
hosts/ocaml/test/persist_durable_test.sh
Executable file
144
hosts/ocaml/test/persist_durable_test.sh
Executable file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
# hosts/ocaml/test/persist_durable_test.sh
|
||||
# Acceptance test for the host durable-storage adapter (Sx_persist_store).
|
||||
#
|
||||
# Exercises `persist/durable-backend` (REAL `perform`, not the mock) under the
|
||||
# WORKTREE-built sx_server.exe, and asserts:
|
||||
# 1. durable: writes land on disk and read back (the silent-data-loss repro
|
||||
# from plans/persist-on-sx.md now returns correct values).
|
||||
# 2. last-seq is monotonic across truncate (compaction never reassigns a seq).
|
||||
# 3. kv ops round-trip and delete.
|
||||
# 4. recovery: a REAL process restart (write, exit, fresh process, replay)
|
||||
# recovers state from disk.
|
||||
#
|
||||
# Run from repo root or anywhere; locates the worktree binary relative to itself.
|
||||
set -uo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$HERE/../../.." && pwd)" # repo/worktree root
|
||||
cd "$ROOT"
|
||||
|
||||
SX="hosts/ocaml/_build/default/bin/sx_server.exe"
|
||||
if [ ! -x "$SX" ]; then
|
||||
echo "ERROR: worktree binary not found at $SX — build it first:" >&2
|
||||
echo " (cd hosts/ocaml && dune build bin/sx_server.exe)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DATADIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$DATADIR"' EXIT
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
check() { # check <label> <got> <expected>
|
||||
if [ "$2" = "$3" ]; then
|
||||
PASS=$((PASS + 1)); printf ' ok %-40s => %s\n' "$1" "$2"
|
||||
else
|
||||
FAIL=$((FAIL + 1)); printf ' FAIL %-40s got [%s] want [%s]\n' "$1" "$2" "$3"
|
||||
fi
|
||||
}
|
||||
|
||||
PRELUDE='(epoch 1)
|
||||
(load "spec/stdlib.sx")
|
||||
(load "lib/r7rs.sx")
|
||||
(load "lib/persist/event.sx")
|
||||
(load "lib/persist/backend.sx")
|
||||
(load "lib/persist/log.sx")
|
||||
(load "lib/persist/kv.sx")
|
||||
(load "lib/persist/durable.sx")
|
||||
(load "lib/persist/blob.sx")
|
||||
(epoch 2)'
|
||||
|
||||
# run_eval <sx-expr-string>: prints the final (ok-len 2 ...) payload line.
|
||||
run_eval() {
|
||||
local expr="$1"
|
||||
printf '%s\n(eval %s)\n' "$PRELUDE" "$expr" \
|
||||
| SX_PERSIST_DIR="$DATADIR" timeout 60 "$SX" 2>/dev/null \
|
||||
| awk '/^\(ok-len 2 / {getline; print; exit}'
|
||||
}
|
||||
|
||||
# escape an SX program into a single-line double-quoted SX string literal for
|
||||
# (eval "..."). The REPL reads one command per physical line, so newlines in the
|
||||
# program are collapsed to spaces.
|
||||
q() { printf '"%s"' "$(printf '%s' "$1" | tr '\n' ' ' | sed 's/\\/\\\\/g; s/"/\\"/g')"; }
|
||||
|
||||
echo "== durable: append/read/last-seq round-trip on disk =="
|
||||
GOT=$(run_eval "$(q '(let ((b (persist/durable-backend)))
|
||||
(begin
|
||||
(persist/append b "s" "x" 0 {:v 1})
|
||||
(persist/append b "s" "x" 0 {:v 2})
|
||||
(list (persist/event-seq (persist/append b "s" "x" 0 {:v 3}))
|
||||
(persist/count b "s")
|
||||
(len (persist/read b "s")))))')")
|
||||
check "append/count/read" "$GOT" "(3 3 3)"
|
||||
|
||||
echo "== last-seq monotonic across truncate =="
|
||||
GOT=$(run_eval "$(q '(let ((b (persist/durable-backend)))
|
||||
(begin
|
||||
(persist/append b "t" "x" 0 {})
|
||||
(persist/append b "t" "x" 0 {})
|
||||
(persist/append b "t" "x" 0 {})
|
||||
(persist/truncate b "t" 2)
|
||||
(list (persist/last-seq b "t") (persist/count b "t"))))')")
|
||||
check "last-seq survives truncate" "$GOT" "(3 1)"
|
||||
|
||||
echo "== streams set survives compaction =="
|
||||
GOT=$(run_eval "$(q '(let ((b (persist/durable-backend)))
|
||||
(sort ((get b "streams"))))')")
|
||||
check "streams" "$GOT" '("s" "t")'
|
||||
|
||||
echo "== kv round-trip + delete =="
|
||||
GOT=$(run_eval "$(q '(let ((b (persist/durable-backend)))
|
||||
(begin
|
||||
(persist/kv-put b "k" {:a 1 :b "two"})
|
||||
(persist/kv-put b "gone" 9)
|
||||
(persist/kv-delete b "gone")
|
||||
(list (get (persist/kv-get b "k") :b)
|
||||
(persist/kv-has? b "k")
|
||||
(persist/kv-has? b "gone"))))')")
|
||||
check "kv get/has/delete" "$GOT" '("two" true false)'
|
||||
|
||||
echo "== recovery: state survives a REAL process restart =="
|
||||
# write in process A then let it exit; the next run is a brand-new process.
|
||||
run_eval "$(q '(let ((b (persist/durable-backend)))
|
||||
(begin
|
||||
(persist/append b "r" "ev" 0 {:n 1})
|
||||
(persist/append b "r" "ev" 0 {:n 2})
|
||||
(persist/kv-put b "survive" "yes")
|
||||
(persist/count b "r")))')" >/dev/null
|
||||
# fresh process, same SX_PERSIST_DIR — must replay from disk.
|
||||
GOT=$(run_eval "$(q '(let ((b (persist/durable-backend)))
|
||||
(list (persist/count b "r")
|
||||
(persist/last-seq b "r")
|
||||
(get (get (nth (persist/read b "r") 1) :data) :n)
|
||||
(persist/kv-get b "survive")))')")
|
||||
check "recovered after restart" "$GOT" '(2 2 2 "yes")'
|
||||
|
||||
echo "== blob: content-addressed put/get/has? round-trip =="
|
||||
GOT=$(run_eval "$(q '(let ((bs (persist/blob-store-backend)))
|
||||
(let ((r (persist/blob-store bs "hello world" "text/plain")))
|
||||
(list (persist/blob-size r)
|
||||
(persist/blob-mime r)
|
||||
(persist/blob-fetch bs r)
|
||||
(persist/blob-exists? bs r))))')")
|
||||
check "blob size/mime/fetch/exists" "$GOT" '(11 "text/plain" "hello world" true)'
|
||||
|
||||
echo "== blob: put is content-addressed (idempotent cid) =="
|
||||
GOT=$(run_eval "$(q '(let ((bs (persist/blob-store-backend)))
|
||||
(equal? (persist/blob-cid (persist/blob-store bs "same bytes" "x"))
|
||||
(persist/blob-cid (persist/blob-store bs "same bytes" "x"))))')")
|
||||
check "same bytes -> same cid" "$GOT" "true"
|
||||
|
||||
echo "== blob: bytes + ref-in-kv survive a REAL restart =="
|
||||
# process A: store a blob, keep only its ref in the durable kv.
|
||||
run_eval "$(q '(let ((b (persist/durable-backend)) (bs (persist/blob-store-backend)))
|
||||
(begin (persist/kv-put b "logo" (persist/blob-store bs "PNGDATA" "image/png")) nil))')" >/dev/null
|
||||
# fresh process: read the ref from kv, fetch the bytes from the blob store.
|
||||
GOT=$(run_eval "$(q '(let ((b (persist/durable-backend)) (bs (persist/blob-store-backend)))
|
||||
(let ((r (persist/kv-get b "logo")))
|
||||
(list (persist/blob-fetch bs r) (persist/blob-exists? bs r) (persist/blob-mime r))))')")
|
||||
check "blob recovered via ref after restart" "$GOT" '("PNGDATA" true "image/png")'
|
||||
|
||||
echo
|
||||
echo "durable adapter: $PASS passed, $FAIL failed"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
251
lib/events/api.sx
Normal file
251
lib/events/api.sx
Normal file
@@ -0,0 +1,251 @@
|
||||
;; lib/events/api.sx — public events surface over calendar + availability.
|
||||
;;
|
||||
;; 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.
|
||||
|
||||
(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` (in-memory only — see
|
||||
;; ev/book-occ! for the durable, capacity-safe path).
|
||||
(define
|
||||
ev/book
|
||||
(fn
|
||||
(store actor key)
|
||||
(ev/store
|
||||
(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
|
||||
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 (in-memory store).
|
||||
(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 (in-memory).
|
||||
(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) using the
|
||||
;; in-memory bookings.
|
||||
(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)))
|
||||
|
||||
;; ---- 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)))
|
||||
177
lib/events/availability.sx
Normal file
177
lib/events/availability.sx
Normal file
@@ -0,0 +1,177 @@
|
||||
;; lib/events/availability.sx — free/busy + conflict detection on Datalog.
|
||||
;;
|
||||
;; Availability is per-actor and is forward-chained Datalog over two EDB
|
||||
;; relations:
|
||||
;;
|
||||
;; (occurrence Key EventId Start End) ; an expanded calendar occurrence
|
||||
;; (booking Actor Key) ; actor attends/holds that occurrence
|
||||
;;
|
||||
;; The derived relations are the whole policy:
|
||||
;;
|
||||
;; busy(A,S,E) — A is committed for [S,E) (a booked occurrence)
|
||||
;; conflict(A,O1,O2) — A double-booked into two overlapping occurrences
|
||||
;; busy_in(A,QS,QE) — A is busy somewhere inside query window [QS,QE)
|
||||
;;
|
||||
;; Intervals are half-open [Start,End) in epoch minutes (see calendar.sx), so
|
||||
;; adjacent slots (E == next start) do NOT conflict. Conflict pairs are
|
||||
;; canonical (O1 < O2 by key) so each overlap is reported once. The same `busy`
|
||||
;; rule answers "is A free in [QS,QE)?" (busy_in is empty) and feeds "when is A
|
||||
;; next free?" (ev-next-free probes candidate slots with the same rule).
|
||||
|
||||
;; A stable key for an occurrence dict {:id :start :end}.
|
||||
(define ev-occ-key (fn (occ) (str (get occ :id) "@" (get occ :start))))
|
||||
|
||||
(define
|
||||
ev-occurrence-fact
|
||||
(fn
|
||||
(occ)
|
||||
(list
|
||||
(quote occurrence)
|
||||
(ev-occ-key occ)
|
||||
(get occ :id)
|
||||
(get occ :start)
|
||||
(get occ :end))))
|
||||
|
||||
(define ev-occurrence-facts (fn (occs) (map ev-occurrence-fact occs)))
|
||||
|
||||
(define ev-booking-fact (fn (actor key) (list (quote booking) actor key)))
|
||||
|
||||
(define ev-qwindow-fact (fn (qs qe) (list (quote qwindow) qs qe)))
|
||||
|
||||
;; Range restriction: each comparison's variables are bound by an earlier
|
||||
;; positive literal (qwindow / busy precede the < tests). Conflict uses
|
||||
;; (< O1 O2) on the keys so each overlapping pair is reported once.
|
||||
(define
|
||||
ev-avail-rules
|
||||
(quote
|
||||
((busy A S E <- (booking A O) (occurrence O _ S E))
|
||||
(conflict
|
||||
A
|
||||
O1
|
||||
O2
|
||||
<-
|
||||
(booking A O1)
|
||||
(booking A O2)
|
||||
(occurrence O1 _ S1 E1)
|
||||
(occurrence O2 _ S2 E2)
|
||||
(< O1 O2)
|
||||
(< S1 E2)
|
||||
(< S2 E1))
|
||||
(busy_in A QS QE <- (qwindow QS QE) (busy A S E) (< S QE) (< QS E)))))
|
||||
|
||||
;; Build a Datalog db from EDB facts under the availability ruleset.
|
||||
(define ev-build-avail (fn (facts) (dl-program-data facts ev-avail-rules)))
|
||||
|
||||
;; Convenience: build a db from occurrence dicts + booking pairs.
|
||||
;; bookings is a list of (actor key) pairs.
|
||||
(define
|
||||
ev-avail-db
|
||||
(fn
|
||||
(occs bookings)
|
||||
(ev-build-avail
|
||||
(append
|
||||
(ev-occurrence-facts occs)
|
||||
(map
|
||||
(fn (b) (ev-booking-fact (first b) (first (rest b))))
|
||||
bookings)))))
|
||||
|
||||
;; Helper: insertion sort a list of (S E ...) lists ascending by S then E.
|
||||
(define
|
||||
ev-list-before?
|
||||
(fn
|
||||
(a b)
|
||||
(cond
|
||||
((< (first a) (first b)) true)
|
||||
((> (first a) (first b)) false)
|
||||
(else (< (first (rest a)) (first (rest b)))))))
|
||||
|
||||
(define
|
||||
ev-list-insert
|
||||
(fn
|
||||
(x sorted)
|
||||
(cond
|
||||
((empty? sorted) (list x))
|
||||
((ev-list-before? x (first sorted)) (cons x sorted))
|
||||
(else (cons (first sorted) (ev-list-insert x (rest sorted)))))))
|
||||
|
||||
(define
|
||||
ev-sort-lists
|
||||
(fn (xs) (reduce (fn (acc x) (ev-list-insert x acc)) (list) xs)))
|
||||
|
||||
(define
|
||||
ev-dedup-sorted
|
||||
(fn
|
||||
(xs)
|
||||
(cond
|
||||
((empty? xs) xs)
|
||||
((empty? (rest xs)) xs)
|
||||
((= (first xs) (first (rest xs))) (ev-dedup-sorted (rest xs)))
|
||||
(else (cons (first xs) (ev-dedup-sorted (rest xs)))))))
|
||||
|
||||
;; All busy intervals (list S E) for an actor, ascending by start.
|
||||
(define
|
||||
ev-busy
|
||||
(fn
|
||||
(db actor)
|
||||
(let
|
||||
((rows (dl-query db (list (quote busy) actor (quote S) (quote E)))))
|
||||
(ev-sort-lists (map (fn (b) (list (get b :S) (get b :E))) rows)))))
|
||||
|
||||
;; Distinct conflicting occurrence-key pairs for an actor (each pair once).
|
||||
(define
|
||||
ev-conflicts
|
||||
(fn
|
||||
(db actor)
|
||||
(dl-query db (list (quote conflict) actor (quote O1) (quote O2)))))
|
||||
|
||||
(define
|
||||
ev-has-conflict?
|
||||
(fn (db actor) (> (len (ev-conflicts db actor)) 0)))
|
||||
|
||||
;; Is `actor` free across the whole window [qs,qe)? (no booked occurrence
|
||||
;; overlaps it). Asserts a transient qwindow fact, queries, retracts.
|
||||
(define
|
||||
ev-free?
|
||||
(fn
|
||||
(db actor qs qe)
|
||||
(do
|
||||
(dl-assert! db (ev-qwindow-fact qs qe))
|
||||
(let
|
||||
((rows (dl-query db (list (quote busy_in) actor (quote QS) (quote QE)))))
|
||||
(begin (dl-retract! db (ev-qwindow-fact qs qe)) (empty? rows))))))
|
||||
|
||||
;; ---- next-free slot search ----
|
||||
;; The earliest start s >= `after` such that [s, s+duration) is entirely free
|
||||
;; for `actor` and ends at or before `horizon`, or nil if none. The earliest
|
||||
;; such slot must begin either at `after` or immediately after some busy
|
||||
;; interval ends (classic interval packing), so those are the only candidates
|
||||
;; we probe — each probe reuses the busy_in rule via ev-free?.
|
||||
|
||||
(define
|
||||
ev-first-free
|
||||
(fn
|
||||
(db actor cands duration horizon)
|
||||
(cond
|
||||
((empty? cands) nil)
|
||||
(else
|
||||
(let
|
||||
((s (first cands)))
|
||||
(if
|
||||
(and
|
||||
(<= (+ s duration) horizon)
|
||||
(ev-free? db actor s (+ s duration)))
|
||||
s
|
||||
(ev-first-free db actor (rest cands) duration horizon)))))))
|
||||
|
||||
(define
|
||||
ev-next-free
|
||||
(fn
|
||||
(db actor after duration horizon)
|
||||
(let
|
||||
((ends (filter (fn (e) (>= e after)) (map (fn (iv) (first (rest iv))) (ev-busy db actor)))))
|
||||
(ev-first-free
|
||||
db
|
||||
actor
|
||||
(ev-dedup-sorted (sort (cons after ends)))
|
||||
duration
|
||||
horizon))))
|
||||
102
lib/events/booking-notify.sx
Normal file
102
lib/events/booking-notify.sx
Normal 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)))))
|
||||
372
lib/events/booking.sx
Normal file
372
lib/events/booking.sx
Normal file
@@ -0,0 +1,372 @@
|
||||
;; lib/events/booking.sx — transactional, capacity-safe booking on persist.
|
||||
;;
|
||||
;; Each bookable occurrence has an append-only stream of booking events:
|
||||
;;
|
||||
;; :booking free booking — actor immediately holds a confirmed seat
|
||||
;; :hold provisional hold — seat reserved while payment is pending
|
||||
;; :confirm a held seat becomes confirmed (payment succeeded)
|
||||
;; :release a held seat is abandoned (payment failed/expired) — seat freed
|
||||
;; :cancel a held or confirmed seat is given up — seat freed
|
||||
;;
|
||||
;; The live state is the stream FOLDED in order into per-actor seat states
|
||||
;; (:held / :confirmed); an actor in ANY state occupies a seat, so both held and
|
||||
;; confirmed seats count toward capacity — a pending payment cannot be
|
||||
;; oversold. A freed seat (release/cancel) reopens capacity.
|
||||
;;
|
||||
;; Capacity safety is the contract: two writers racing for the last seat must
|
||||
;; NEVER both succeed. Seat-ACQUIRING writes (:booking, :hold) go through
|
||||
;; persist's optimistic concurrency — `persist/append-expect` appends only if
|
||||
;; the stream's last-seq still equals what the writer observed; else it returns
|
||||
;; a conflict the writer retries. Seat-FREEING writes (:cancel, :release) and
|
||||
;; the state transition (:confirm) never oversell, so they append directly.
|
||||
|
||||
(define ev-booking-stream (fn (occ-key) (str "booking:" occ-key)))
|
||||
|
||||
(define
|
||||
ev-bk-member?
|
||||
(fn
|
||||
(x xs)
|
||||
(cond
|
||||
((empty? xs) false)
|
||||
((= x (first xs)) true)
|
||||
(else (ev-bk-member? x (rest xs))))))
|
||||
|
||||
(define
|
||||
ev-bk-index
|
||||
(fn
|
||||
(xs x i)
|
||||
(cond
|
||||
((empty? xs) -1)
|
||||
((= (first xs) x) i)
|
||||
(else (ev-bk-index (rest xs) x (+ i 1))))))
|
||||
|
||||
(define ev-bk-append (fn (xs a) (append xs (list a))))
|
||||
(define ev-bk-remove (fn (xs a) (filter (fn (x) (not (= x a))) xs)))
|
||||
|
||||
;; ---- per-actor state association list: ((actor state) ...) in join order ----
|
||||
|
||||
(define
|
||||
ev-state-has?
|
||||
(fn
|
||||
(states actor)
|
||||
(cond
|
||||
((empty? states) false)
|
||||
((= (first (first states)) actor) true)
|
||||
(else (ev-state-has? (rest states) actor)))))
|
||||
|
||||
(define
|
||||
ev-state-get
|
||||
(fn
|
||||
(states actor)
|
||||
(cond
|
||||
((empty? states) :none)
|
||||
((= (first (first states)) actor) (first (rest (first states))))
|
||||
(else (ev-state-get (rest states) actor)))))
|
||||
|
||||
(define
|
||||
ev-state-del
|
||||
(fn (states actor) (filter (fn (p) (not (= (first p) actor))) states)))
|
||||
|
||||
(define
|
||||
ev-state-set
|
||||
(fn
|
||||
(states actor st)
|
||||
(if
|
||||
(ev-state-has? states actor)
|
||||
(map (fn (p) (if (= (first p) actor) (list actor st) p)) states)
|
||||
(append states (list (list actor st))))))
|
||||
|
||||
;; Fold the booking stream into per-actor seat states (join order preserved).
|
||||
(define
|
||||
ev-fold-states
|
||||
(fn
|
||||
(events)
|
||||
(reduce
|
||||
(fn
|
||||
(acc e)
|
||||
(let
|
||||
((typ (persist/event-type e))
|
||||
(actor (get (persist/event-data e) :actor)))
|
||||
(cond
|
||||
((= typ :booking) (ev-state-set acc actor :confirmed))
|
||||
((= typ :hold) (ev-state-set acc actor :held))
|
||||
((= typ :confirm)
|
||||
(if
|
||||
(ev-state-has? acc actor)
|
||||
(ev-state-set acc actor :confirmed)
|
||||
acc))
|
||||
((= typ :cancel) (ev-state-del acc actor))
|
||||
((= typ :release) (ev-state-del acc actor))
|
||||
(else acc))))
|
||||
(list)
|
||||
events)))
|
||||
|
||||
(define
|
||||
ev-states-of
|
||||
(fn
|
||||
(b occ-key)
|
||||
(ev-fold-states (persist/read b (ev-booking-stream occ-key)))))
|
||||
|
||||
;; Live roster (actors holding a seat — held or confirmed), oldest active first.
|
||||
(define
|
||||
ev-booked-actors
|
||||
(fn (b occ-key) (map (fn (p) (first p)) (ev-states-of b occ-key))))
|
||||
|
||||
(define
|
||||
ev-actor-booked?
|
||||
(fn (b occ-key actor) (ev-bk-member? actor (ev-booked-actors b occ-key))))
|
||||
|
||||
;; Live seat count (folded roster size — both held and confirmed seats).
|
||||
(define
|
||||
ev-booking-count
|
||||
(fn (b occ-key) (len (ev-booked-actors b occ-key))))
|
||||
|
||||
;; Seat state for an actor: :held / :confirmed / :none.
|
||||
(define
|
||||
ev/seat-state
|
||||
(fn (b occ-key actor) (ev-state-get (ev-states-of b occ-key) actor)))
|
||||
|
||||
;; 1-based seat number for an actor on the roster (0 if not booked).
|
||||
(define
|
||||
ev-seat-of
|
||||
(fn
|
||||
(actors actor)
|
||||
(let
|
||||
((i (ev-bk-index actors actor 0)))
|
||||
(if (< i 0) 0 (+ i 1)))))
|
||||
|
||||
;; ---- seat-acquiring writes (capacity-guarded via append-expect) ----
|
||||
|
||||
;; One seat-acquiring attempt of `kind` (:booking or :hold) against an OBSERVED
|
||||
;; snapshot (roster the writer saw + the last-seq). Returns :already / :full /
|
||||
;; :conflict, or a success dict tagged with `ok-status`. :conflict means a
|
||||
;; concurrent append landed since the snapshot — the caller must re-observe.
|
||||
(define
|
||||
ev-acquire-with-observed
|
||||
(fn
|
||||
(b occ-key capacity actor observed-actors expected kind ok-status)
|
||||
(cond
|
||||
((ev-bk-member? actor observed-actors) {:seat (ev-seat-of observed-actors actor) :actor actor :status :already})
|
||||
((>= (len observed-actors) capacity) {:actor actor :capacity capacity :status :full})
|
||||
(else
|
||||
(let
|
||||
((r (persist/append-expect b (ev-booking-stream occ-key) expected kind 0 {:actor actor})))
|
||||
(if (persist/conflict? r) {:actual (persist/conflict-actual r) :actor actor :status :conflict} {:seat (+ (len observed-actors) 1) :actor actor :status ok-status}))))))
|
||||
|
||||
(define
|
||||
ev-acquire!
|
||||
(fn
|
||||
(b occ-key capacity actor kind ok-status)
|
||||
(let
|
||||
((res (ev-acquire-with-observed b occ-key capacity actor (ev-booked-actors b occ-key) (persist/last-seq b (ev-booking-stream occ-key)) kind ok-status)))
|
||||
(if
|
||||
(= (get res :status) :conflict)
|
||||
(ev-acquire! b occ-key capacity actor kind ok-status)
|
||||
res))))
|
||||
|
||||
;; Capacity-safe confirmed booking (retrying on conflict).
|
||||
(define
|
||||
ev/book!
|
||||
(fn
|
||||
(b occ-key capacity actor)
|
||||
(ev-acquire! b occ-key capacity actor :booking :booked)))
|
||||
|
||||
;; Capacity-safe provisional hold (retrying on conflict). The seat is reserved
|
||||
;; (counts toward capacity) until confirmed or released.
|
||||
(define
|
||||
ev/hold!
|
||||
(fn
|
||||
(b occ-key capacity actor)
|
||||
(ev-acquire! b occ-key capacity actor :hold :held)))
|
||||
|
||||
;; Test seam: one attempt against a caller-supplied snapshot (book or hold).
|
||||
(define
|
||||
ev/book-with-observed
|
||||
(fn
|
||||
(b occ-key capacity actor observed-actors expected)
|
||||
(ev-acquire-with-observed
|
||||
b
|
||||
occ-key
|
||||
capacity
|
||||
actor
|
||||
observed-actors
|
||||
expected
|
||||
:booking :booked)))
|
||||
|
||||
(define
|
||||
ev/hold-with-observed
|
||||
(fn
|
||||
(b occ-key capacity actor observed-actors expected)
|
||||
(ev-acquire-with-observed
|
||||
b
|
||||
occ-key
|
||||
capacity
|
||||
actor
|
||||
observed-actors
|
||||
expected
|
||||
:hold :held)))
|
||||
|
||||
;; ---- state transitions / seat-freeing writes (no oversell, append direct) ----
|
||||
|
||||
;; Confirm a held seat (payment succeeded). :confirmed on success,
|
||||
;; :already-confirmed if it was confirmed, :not-held otherwise.
|
||||
(define
|
||||
ev/confirm!
|
||||
(fn
|
||||
(b occ-key actor)
|
||||
(let
|
||||
((st (ev/seat-state b occ-key actor)))
|
||||
(cond
|
||||
((= st :held)
|
||||
(begin
|
||||
(persist/append
|
||||
b
|
||||
(ev-booking-stream occ-key)
|
||||
:confirm 0
|
||||
{:actor actor})
|
||||
{:actor actor :status :confirmed}))
|
||||
((= st :confirmed) {:actor actor :status :already-confirmed})
|
||||
(else {:actor actor :status :not-held})))))
|
||||
|
||||
;; Release a held seat (payment failed/expired), freeing it. Only valid for a
|
||||
;; held seat — confirmed bookings are given up via ev/cancel!.
|
||||
(define
|
||||
ev/release!
|
||||
(fn
|
||||
(b occ-key actor)
|
||||
(let
|
||||
((st (ev/seat-state b occ-key actor)))
|
||||
(if
|
||||
(= st :held)
|
||||
(begin
|
||||
(persist/append
|
||||
b
|
||||
(ev-booking-stream occ-key)
|
||||
:release 0
|
||||
{:actor actor})
|
||||
{:actor actor :status :released})
|
||||
{:actor actor :status :not-held}))))
|
||||
|
||||
;; Cancel a held or confirmed seat, freeing it. :cancelled or :not-booked.
|
||||
(define
|
||||
ev/cancel!
|
||||
(fn
|
||||
(b occ-key actor)
|
||||
(if
|
||||
(ev-bk-member? actor (ev-booked-actors b occ-key))
|
||||
(begin
|
||||
(persist/append
|
||||
b
|
||||
(ev-booking-stream occ-key)
|
||||
:cancel 0
|
||||
{:actor actor})
|
||||
{:actor actor :status :cancelled})
|
||||
{:actor actor :status :not-booked})))
|
||||
|
||||
;; The roster as a plain list of actors (oldest active first).
|
||||
(define ev/roster (fn (b occ-key) (ev-booked-actors b occ-key)))
|
||||
|
||||
;; Seats remaining for an occurrence of the given capacity.
|
||||
(define
|
||||
ev/seats-left
|
||||
(fn
|
||||
(b occ-key capacity)
|
||||
(max 0 (- capacity (ev-booking-count b occ-key)))))
|
||||
|
||||
;; ---- waitlist ----
|
||||
;; When an occurrence is full, actors join a FIFO waitlist (:waitlist /
|
||||
;; :unwaitlist events on the same stream). Taking a seat (:booking / :hold)
|
||||
;; removes an actor from the queue, so the waitlist fold is independent of the
|
||||
;; seat fold. Cancelling/releasing a seat can auto-promote the head of the
|
||||
;; queue (a :booking appended for them).
|
||||
|
||||
(define
|
||||
ev-fold-waiting
|
||||
(fn
|
||||
(events)
|
||||
(reduce
|
||||
(fn
|
||||
(acc e)
|
||||
(let
|
||||
((typ (persist/event-type e))
|
||||
(actor (get (persist/event-data e) :actor)))
|
||||
(cond
|
||||
((= typ :waitlist) (if (ev-bk-member? actor acc) acc (ev-bk-append acc actor)))
|
||||
((= typ :unwaitlist) (ev-bk-remove acc actor))
|
||||
((= typ :booking) (ev-bk-remove acc actor))
|
||||
((= typ :hold) (ev-bk-remove acc actor))
|
||||
(else acc))))
|
||||
(list)
|
||||
events)))
|
||||
|
||||
;; The current waitlist queue (FIFO, oldest first).
|
||||
(define
|
||||
ev/waitlist
|
||||
(fn (b occ-key) (ev-fold-waiting (persist/read b (ev-booking-stream occ-key)))))
|
||||
|
||||
;; 1-based queue position for an actor (0 if not waiting).
|
||||
(define
|
||||
ev/waitlist-position
|
||||
(fn (b occ-key actor) (ev-seat-of (ev/waitlist b occ-key) actor)))
|
||||
|
||||
;; Book if a seat is free, else join the waitlist. Idempotent: already seated →
|
||||
;; :already; already queued → :already-waiting.
|
||||
(define
|
||||
ev/waitlist!
|
||||
(fn
|
||||
(b occ-key capacity actor)
|
||||
(let
|
||||
((seats (ev-booked-actors b occ-key))
|
||||
(waiting (ev/waitlist b occ-key)))
|
||||
(cond
|
||||
((ev-bk-member? actor seats)
|
||||
{:status :already :seat (ev-seat-of seats actor) :actor actor})
|
||||
((ev-bk-member? actor waiting)
|
||||
{:status :already-waiting :position (ev-seat-of waiting actor) :actor actor})
|
||||
(else
|
||||
(let
|
||||
((r (ev/book! b occ-key capacity actor)))
|
||||
(if
|
||||
(= (get r :status) :booked)
|
||||
r
|
||||
(begin
|
||||
(persist/append b (ev-booking-stream occ-key) :waitlist 0 {:actor actor})
|
||||
{:status :waitlisted
|
||||
:position (+ (len waiting) 1)
|
||||
:actor actor}))))))))
|
||||
|
||||
;; Leave the waitlist. :left or :not-waiting.
|
||||
(define
|
||||
ev/leave-waitlist!
|
||||
(fn
|
||||
(b occ-key actor)
|
||||
(if
|
||||
(ev-bk-member? actor (ev/waitlist b occ-key))
|
||||
(begin
|
||||
(persist/append b (ev-booking-stream occ-key) :unwaitlist 0 {:actor actor})
|
||||
{:status :left :actor actor})
|
||||
{:status :not-waiting :actor actor})))
|
||||
|
||||
;; Cancel a seat and, if that frees capacity, auto-promote the head of the
|
||||
;; waitlist (a confirmed booking). Returns the cancel result plus :promoted
|
||||
;; (the actor promoted, or nil).
|
||||
(define
|
||||
ev/cancel-promote!
|
||||
(fn
|
||||
(b occ-key capacity actor)
|
||||
(let
|
||||
((c (ev/cancel! b occ-key actor)))
|
||||
(if
|
||||
(= (get c :status) :cancelled)
|
||||
(let
|
||||
((waiting (ev/waitlist b occ-key))
|
||||
(seats (ev-booked-actors b occ-key)))
|
||||
(if
|
||||
(and (not (empty? waiting)) (< (len seats) capacity))
|
||||
(let
|
||||
((promoted (first waiting)))
|
||||
(begin
|
||||
(persist/append b (ev-booking-stream occ-key) :booking 0 {:actor promoted})
|
||||
{:status :cancelled :actor actor :promoted promoted}))
|
||||
{:status :cancelled :actor actor :promoted nil}))
|
||||
c))))
|
||||
614
lib/events/calendar.sx
Normal file
614
lib/events/calendar.sx
Normal file
@@ -0,0 +1,614 @@
|
||||
;; lib/events/calendar.sx — civil date arithmetic + RRULE expansion in a window.
|
||||
;;
|
||||
;; Datetimes are integer "epoch minutes": days-since-1970-01-01 * 1440 plus
|
||||
;; minute-of-day. Ordering, window bounds, and durations are plain integer
|
||||
;; arithmetic. Civil <-> day-number conversion uses Howard Hinnant's algorithm
|
||||
;; (exact, branch-free, correct for the proleptic Gregorian calendar).
|
||||
;;
|
||||
;; RRULE expansion is the bridge to Datalog: a recurring event expands to a
|
||||
;; bounded list of occurrence dicts within an explicit (win-start, win-end)
|
||||
;; window. Expansion is ALWAYS windowed — an RRULE without a window is an
|
||||
;; infinite computation and is never permitted. Supported subset (RFC 5545):
|
||||
;; FREQ=DAILY|WEEKLY|MONTHLY, INTERVAL, COUNT, UNTIL, BYDAY (weekly: weekday
|
||||
;; numbers; monthly: {:ord N :wd W} ordinal weekdays), BYMONTHDAY (monthly,
|
||||
;; negative = from month end). YEARLY and the rest are deferred.
|
||||
|
||||
;; ---- integer helpers ----
|
||||
|
||||
;; Floored integer division (modulo is already floored, so the remainder
|
||||
;; subtraction makes the quotient exact and floor-correct for any sign).
|
||||
(define ev-floor-div (fn (a b) (quotient (- a (modulo a b)) b)))
|
||||
|
||||
(define ev-or (fn (x d) (if (nil? x) d x)))
|
||||
|
||||
(define ev-filter-nil (fn (xs) (filter (fn (x) (not (nil? x))) xs)))
|
||||
|
||||
;; ---- civil date core (Hinnant) ----
|
||||
|
||||
;; Days since 1970-01-01 for civil (y, m, d). m in [1,12], d in [1,31].
|
||||
(define
|
||||
ev-days-from-civil
|
||||
(fn
|
||||
(y0 m d)
|
||||
(let
|
||||
((y (if (<= m 2) (- y0 1) y0)))
|
||||
(let
|
||||
((era (ev-floor-div (if (>= y 0) y (- y 399)) 400)))
|
||||
(let
|
||||
((yoe (- y (* era 400)))
|
||||
(doy
|
||||
(+
|
||||
(ev-floor-div
|
||||
(+
|
||||
(*
|
||||
153
|
||||
(+ m (if (> m 2) -3 9)))
|
||||
2)
|
||||
5)
|
||||
(- d 1))))
|
||||
(let
|
||||
((doe (+ (* yoe 365) (ev-floor-div yoe 4) (- (ev-floor-div yoe 100)) doy)))
|
||||
(+ (* era 146097) doe -719468)))))))
|
||||
|
||||
;; Civil (y m d) list from a day-number.
|
||||
(define
|
||||
ev-civil-from-days
|
||||
(fn
|
||||
(z0)
|
||||
(let
|
||||
((z (+ z0 719468)))
|
||||
(let
|
||||
((era (ev-floor-div (if (>= z 0) z (- z 146096)) 146097)))
|
||||
(let
|
||||
((doe (- z (* era 146097))))
|
||||
(let
|
||||
((yoe (ev-floor-div (+ (- doe (ev-floor-div doe 1460)) (ev-floor-div doe 36524) (- (ev-floor-div doe 146096))) 365)))
|
||||
(let
|
||||
((y (+ yoe (* era 400)))
|
||||
(doy
|
||||
(-
|
||||
doe
|
||||
(+
|
||||
(* 365 yoe)
|
||||
(ev-floor-div yoe 4)
|
||||
(- (ev-floor-div yoe 100))))))
|
||||
(let
|
||||
((mp (ev-floor-div (+ (* 5 doy) 2) 153)))
|
||||
(let
|
||||
((d (+ (- doy (ev-floor-div (+ (* 153 mp) 2) 5)) 1))
|
||||
(m
|
||||
(if
|
||||
(< mp 10)
|
||||
(+ mp 3)
|
||||
(- mp 9))))
|
||||
(list (if (<= m 2) (+ y 1) y) m d))))))))))
|
||||
|
||||
;; Weekday of a day-number: 0=Mon .. 6=Sun (1970-01-01 is Thursday = 3).
|
||||
(define ev-weekday-of-days (fn (z) (modulo (+ z 3) 7)))
|
||||
|
||||
(define
|
||||
ev-days-in-month
|
||||
(fn
|
||||
(y m)
|
||||
(-
|
||||
(ev-days-from-civil
|
||||
(if (= m 12) (+ y 1) y)
|
||||
(if (= m 12) 1 (+ m 1))
|
||||
1)
|
||||
(ev-days-from-civil y m 1))))
|
||||
|
||||
;; Add k months to (y,m), returning (list y2 m2).
|
||||
(define
|
||||
ev-add-months
|
||||
(fn
|
||||
(y m k)
|
||||
(let
|
||||
((total (+ (* y 12) (- m 1) k)))
|
||||
(list
|
||||
(ev-floor-div total 12)
|
||||
(+ (modulo total 12) 1)))))
|
||||
|
||||
;; ---- datetime (epoch minutes) ----
|
||||
|
||||
(define
|
||||
ev-dt
|
||||
(fn
|
||||
(y m d hh mm)
|
||||
(+ (* (ev-days-from-civil y m d) 1440) (* hh 60) mm)))
|
||||
|
||||
(define ev-date (fn (y m d) (ev-dt y m d 0 0)))
|
||||
|
||||
(define ev-dt->days (fn (t) (ev-floor-div t 1440)))
|
||||
|
||||
(define ev-dt->civil (fn (t) (ev-civil-from-days (ev-dt->days t))))
|
||||
|
||||
(define ev-dt-weekday (fn (t) (ev-weekday-of-days (ev-dt->days t))))
|
||||
|
||||
(define ev-dt-tod (fn (t) (modulo t 1440)))
|
||||
|
||||
(define ev-civ-y (fn (c) (first c)))
|
||||
(define ev-civ-m (fn (c) (first (rest c))))
|
||||
(define ev-civ-d (fn (c) (first (rest (rest c)))))
|
||||
|
||||
;; ---- event + occurrence constructors ----
|
||||
|
||||
;; rrule is nil (single event) or a dict:
|
||||
;; {:freq :daily|:weekly|:monthly :interval N :count N|nil :until DT|nil
|
||||
;; :byday ...|nil :bymonthday (list 15 -1)|nil}
|
||||
;; weekly :byday -> (list 0 2 4) weekday numbers, 0=Mon
|
||||
;; monthly :byday -> (list {:ord 2 :wd 1}) nth weekday (ord<0 from end)
|
||||
;; monthly :bymonthday -> (list 15 -1) day of month (negative from end)
|
||||
(define ev-event (fn (id dtstart duration rrule capacity) {:duration duration :id id :dtstart dtstart :capacity capacity :rrule rrule}))
|
||||
|
||||
;; Event with EXDATE/RDATE exceptions. exdate/rdate are lists of epoch-minute
|
||||
;; starts to exclude from / add to the expansion (RFC 5545 VEVENT properties).
|
||||
(define
|
||||
ev-event-full
|
||||
(fn
|
||||
(id dtstart duration rrule capacity exdate rdate)
|
||||
{:duration duration
|
||||
:id id
|
||||
:dtstart dtstart
|
||||
:capacity capacity
|
||||
:rrule rrule
|
||||
:exdate exdate
|
||||
:rdate rdate}))
|
||||
|
||||
(define ev-occ (fn (id start dur) {:id id :start start :end (+ start dur)}))
|
||||
|
||||
;; ---- DAILY expansion ----
|
||||
;; occ starts at dtstart; n counts every generated occurrence (window-
|
||||
;; independent, so COUNT/UNTIL bound the rule, not the view). Emits only
|
||||
;; occurrences inside [win-start, win-end].
|
||||
(define
|
||||
ev-daily-loop
|
||||
(fn
|
||||
(id occ duration step count until dtstart win-start win-end acc n)
|
||||
(cond
|
||||
((> occ win-end) acc)
|
||||
((and (not (nil? count)) (>= n count)) acc)
|
||||
((and (not (nil? until)) (> occ until)) acc)
|
||||
(else
|
||||
(begin
|
||||
(when (>= occ win-start) (append! acc (ev-occ id occ duration)))
|
||||
(ev-daily-loop
|
||||
id
|
||||
(+ occ step)
|
||||
duration
|
||||
step
|
||||
count
|
||||
until
|
||||
dtstart
|
||||
win-start
|
||||
win-end
|
||||
acc
|
||||
(+ n 1)))))))
|
||||
|
||||
;; ---- shared per-period emit ----
|
||||
;; Walk a start-ascending list of candidate occurrence datetimes for one
|
||||
;; period, generating (count toward COUNT) those >= dtstart within UNTIL, and
|
||||
;; emitting those also inside the window. Returns the updated running n.
|
||||
(define
|
||||
ev-emit-occs
|
||||
(fn
|
||||
(id occs duration count until dtstart win-start win-end acc n)
|
||||
(if
|
||||
(empty? occs)
|
||||
n
|
||||
(let
|
||||
((occ (first occs)))
|
||||
(let
|
||||
((generates? (and (>= occ dtstart) (or (nil? until) (<= occ until)) (or (nil? count) (< n count)))))
|
||||
(begin
|
||||
(when
|
||||
(and generates? (>= occ win-start) (<= occ win-end))
|
||||
(append! acc (ev-occ id occ duration)))
|
||||
(ev-emit-occs
|
||||
id
|
||||
(rest occs)
|
||||
duration
|
||||
count
|
||||
until
|
||||
dtstart
|
||||
win-start
|
||||
win-end
|
||||
acc
|
||||
(if generates? (+ n 1) n))))))))
|
||||
|
||||
;; ---- WEEKLY expansion ----
|
||||
;; Iterate week by week from the Monday of dtstart's week; within each active
|
||||
;; week emit each BYDAY (sorted). n counts every generated occurrence.
|
||||
|
||||
(define
|
||||
ev-week0-days
|
||||
(fn (dtstart) (- (ev-dt->days dtstart) (ev-dt-weekday dtstart))))
|
||||
|
||||
(define
|
||||
ev-byday-default
|
||||
(fn
|
||||
(byday dtstart)
|
||||
(if (nil? byday) (list (ev-dt-weekday dtstart)) (sort byday))))
|
||||
|
||||
(define
|
||||
ev-weekly-loop
|
||||
(fn
|
||||
(id
|
||||
week-days
|
||||
tod
|
||||
duration
|
||||
week-step
|
||||
bd
|
||||
count
|
||||
until
|
||||
dtstart
|
||||
win-start
|
||||
win-end
|
||||
acc
|
||||
n)
|
||||
(let
|
||||
((week-start-dt (* week-days 1440)))
|
||||
(cond
|
||||
((> week-start-dt win-end) acc)
|
||||
((and (not (nil? count)) (>= n count)) acc)
|
||||
(else
|
||||
(let
|
||||
((occs (map (fn (wd) (+ (* (+ week-days wd) 1440) tod)) bd)))
|
||||
(let
|
||||
((n2 (ev-emit-occs id occs duration count until dtstart win-start win-end acc n)))
|
||||
(ev-weekly-loop
|
||||
id
|
||||
(+ week-days week-step)
|
||||
tod
|
||||
duration
|
||||
week-step
|
||||
bd
|
||||
count
|
||||
until
|
||||
dtstart
|
||||
win-start
|
||||
win-end
|
||||
acc
|
||||
n2))))))))
|
||||
|
||||
;; ---- MONTHLY expansion ----
|
||||
;; Iterate month by month from dtstart's month, stepping by INTERVAL months.
|
||||
;; Candidate days per month come from BYMONTHDAY, then ordinal BYDAY, else the
|
||||
;; day-of-month of dtstart (skipped in months too short to contain it).
|
||||
|
||||
;; Resolve a BYMONTHDAY value to a valid day-of-month, or nil.
|
||||
(define
|
||||
ev-resolve-monthday
|
||||
(fn
|
||||
(y m bmd)
|
||||
(let
|
||||
((dim (ev-days-in-month y m)))
|
||||
(let
|
||||
((day (if (< bmd 0) (+ dim 1 bmd) bmd)))
|
||||
(if (and (>= day 1) (<= day dim)) day nil)))))
|
||||
|
||||
;; Resolve an ordinal weekday {:ord :wd} to a day-of-month, or nil.
|
||||
(define
|
||||
ev-resolve-nth-weekday
|
||||
(fn
|
||||
(y m ord wd)
|
||||
(let
|
||||
((dim (ev-days-in-month y m)))
|
||||
(if
|
||||
(> ord 0)
|
||||
(let
|
||||
((first-wd (ev-weekday-of-days (ev-days-from-civil y m 1))))
|
||||
(let
|
||||
((day (+ 1 (modulo (- wd first-wd) 7) (* (- ord 1) 7))))
|
||||
(if (<= day dim) day nil)))
|
||||
(let
|
||||
((last-wd (ev-weekday-of-days (ev-days-from-civil y m dim))))
|
||||
(let
|
||||
((day (- dim (modulo (- last-wd wd) 7) (* (- (- ord) 1) 7))))
|
||||
(if (>= day 1) day nil)))))))
|
||||
|
||||
(define
|
||||
ev-month-candidates
|
||||
(fn
|
||||
(y m rrule dtstart)
|
||||
(let
|
||||
((bmd (get rrule :bymonthday)) (byday (get rrule :byday)))
|
||||
(cond
|
||||
((not (nil? bmd))
|
||||
(ev-filter-nil (map (fn (d) (ev-resolve-monthday y m d)) bmd)))
|
||||
((not (nil? byday))
|
||||
(ev-filter-nil
|
||||
(map
|
||||
(fn
|
||||
(e)
|
||||
(ev-resolve-nth-weekday y m (get e :ord) (get e :wd)))
|
||||
byday)))
|
||||
(else
|
||||
(ev-filter-nil
|
||||
(list
|
||||
(ev-resolve-monthday y m (ev-civ-d (ev-dt->civil dtstart))))))))))
|
||||
|
||||
(define
|
||||
ev-monthly-loop
|
||||
(fn
|
||||
(id
|
||||
y
|
||||
m
|
||||
rrule
|
||||
duration
|
||||
tod
|
||||
interval
|
||||
count
|
||||
until
|
||||
dtstart
|
||||
win-start
|
||||
win-end
|
||||
acc
|
||||
n)
|
||||
(let
|
||||
((month-start (ev-dt y m 1 0 0)))
|
||||
(cond
|
||||
((> month-start win-end) acc)
|
||||
((and (not (nil? count)) (>= n count)) acc)
|
||||
(else
|
||||
(let
|
||||
((days (sort (ev-month-candidates y m rrule dtstart))))
|
||||
(let
|
||||
((occs (map (fn (d) (+ (* (ev-days-from-civil y m d) 1440) tod)) days)))
|
||||
(let
|
||||
((n2 (ev-emit-occs id occs duration count until dtstart win-start win-end acc n))
|
||||
(nm (ev-add-months y m interval)))
|
||||
(ev-monthly-loop
|
||||
id
|
||||
(ev-civ-y nm)
|
||||
(ev-civ-m nm)
|
||||
rrule
|
||||
duration
|
||||
tod
|
||||
interval
|
||||
count
|
||||
until
|
||||
dtstart
|
||||
win-start
|
||||
win-end
|
||||
acc
|
||||
n2)))))))))
|
||||
|
||||
;; ---- top-level expansion ----
|
||||
;; Raw expansion (RRULE / single event), before EXDATE/RDATE are applied.
|
||||
;; Returns a list of occurrence dicts {:id :start :end} within the window.
|
||||
(define
|
||||
ev-expand-base
|
||||
(fn
|
||||
(event win-start win-end)
|
||||
(let
|
||||
((id (get event :id))
|
||||
(dtstart (get event :dtstart))
|
||||
(duration (get event :duration))
|
||||
(rrule (get event :rrule)))
|
||||
(if
|
||||
(nil? rrule)
|
||||
(if
|
||||
(and (>= dtstart win-start) (<= dtstart win-end))
|
||||
(list (ev-occ id dtstart duration))
|
||||
(list))
|
||||
(let
|
||||
((freq (get rrule :freq))
|
||||
(interval (ev-or (get rrule :interval) 1))
|
||||
(count (get rrule :count))
|
||||
(until (get rrule :until))
|
||||
(byday (get rrule :byday))
|
||||
(acc (list)))
|
||||
(begin
|
||||
(cond
|
||||
((= freq :daily)
|
||||
(ev-daily-loop
|
||||
id
|
||||
dtstart
|
||||
duration
|
||||
(* interval 1440)
|
||||
count
|
||||
until
|
||||
dtstart
|
||||
win-start
|
||||
win-end
|
||||
acc
|
||||
0))
|
||||
((= freq :weekly)
|
||||
(ev-weekly-loop
|
||||
id
|
||||
(ev-week0-days dtstart)
|
||||
(ev-dt-tod dtstart)
|
||||
duration
|
||||
(* interval 7)
|
||||
(ev-byday-default byday dtstart)
|
||||
count
|
||||
until
|
||||
dtstart
|
||||
win-start
|
||||
win-end
|
||||
acc
|
||||
0))
|
||||
((= freq :monthly)
|
||||
(let
|
||||
((civ (ev-dt->civil dtstart)))
|
||||
(ev-monthly-loop
|
||||
id
|
||||
(ev-civ-y civ)
|
||||
(ev-civ-m civ)
|
||||
rrule
|
||||
duration
|
||||
(ev-dt-tod dtstart)
|
||||
interval
|
||||
count
|
||||
until
|
||||
dtstart
|
||||
win-start
|
||||
win-end
|
||||
acc
|
||||
0)))
|
||||
(else (error (str "ev-expand-base: unsupported freq: " freq))))
|
||||
acc))))))
|
||||
|
||||
;; ---- EXDATE / RDATE (RFC 5545 exceptions) ----
|
||||
;; Applied AFTER raw expansion: RDATE adds explicit occurrences within the
|
||||
;; window, EXDATE removes occurrences whose start matches (EXDATE wins over
|
||||
;; RDATE). Both are VEVENT-level: (get event :exdate) / (get event :rdate) are
|
||||
;; lists of epoch-minute starts; nil for plain events.
|
||||
|
||||
(define
|
||||
ev-num-member?
|
||||
(fn
|
||||
(n xs)
|
||||
(cond
|
||||
((empty? xs) false)
|
||||
((= n (first xs)) true)
|
||||
(else (ev-num-member? n (rest xs))))))
|
||||
|
||||
;; Drop duplicate-start occurrences from a start-sorted list (keep one).
|
||||
(define
|
||||
ev-dedupe-by-start
|
||||
(fn
|
||||
(occs)
|
||||
(cond
|
||||
((empty? occs) occs)
|
||||
((empty? (rest occs)) occs)
|
||||
((= (get (first occs) :start) (get (first (rest occs)) :start))
|
||||
(ev-dedupe-by-start (rest occs)))
|
||||
(else (cons (first occs) (ev-dedupe-by-start (rest occs)))))))
|
||||
|
||||
(define
|
||||
ev-apply-exceptions
|
||||
(fn
|
||||
(event base win-start win-end)
|
||||
(let
|
||||
((id (get event :id))
|
||||
(duration (get event :duration))
|
||||
(exdate (ev-or (get event :exdate) (list)))
|
||||
(rdate (ev-or (get event :rdate) (list))))
|
||||
(let
|
||||
((rdate-occs
|
||||
(reduce
|
||||
(fn
|
||||
(acc d)
|
||||
(if
|
||||
(and (>= d win-start) (<= d win-end))
|
||||
(cons (ev-occ id d duration) acc)
|
||||
acc))
|
||||
(list)
|
||||
rdate)))
|
||||
(let
|
||||
((no-ex
|
||||
(filter
|
||||
(fn (o) (not (ev-num-member? (get o :start) exdate)))
|
||||
(append base rdate-occs))))
|
||||
(ev-dedupe-by-start (ev-sort-occs no-ex)))))))
|
||||
|
||||
;; ---- per-occurrence overrides (RFC 5545 RECURRENCE-ID) ----
|
||||
;; A single instance of a recurring series can be detached and rescheduled. The
|
||||
;; event carries :overrides — a list of (orig-start {:start :duration}) — keyed
|
||||
;; by the occurrence's ORIGINAL start. Applied after EXDATE/RDATE. A moved
|
||||
;; instance whose new start leaves the window is dropped from this window (the
|
||||
;; original slot is vacated); an instance moved INTO the window from outside is
|
||||
;; out of scope for a windowed expansion (known stub limitation).
|
||||
|
||||
(define
|
||||
ev-assoc-lookup
|
||||
(fn
|
||||
(k pairs)
|
||||
(cond
|
||||
((empty? pairs) nil)
|
||||
((= (first (first pairs)) k) (first (rest (first pairs))))
|
||||
(else (ev-assoc-lookup k (rest pairs))))))
|
||||
|
||||
(define
|
||||
ev-apply-overrides
|
||||
(fn
|
||||
(id base overrides)
|
||||
(map
|
||||
(fn
|
||||
(o)
|
||||
(let
|
||||
((ov (ev-assoc-lookup (get o :start) overrides)))
|
||||
(if (nil? ov) o (ev-occ id (get ov :start) (get ov :duration)))))
|
||||
base)))
|
||||
|
||||
;; Add an override that reschedules the occurrence originally at `orig-start`
|
||||
;; to `new-start` with `new-duration`.
|
||||
(define
|
||||
ev-with-override
|
||||
(fn
|
||||
(event orig-start new-start new-duration)
|
||||
(assoc
|
||||
event
|
||||
:overrides
|
||||
(cons
|
||||
(list orig-start {:start new-start :duration new-duration})
|
||||
(ev-or (get event :overrides) (list))))))
|
||||
|
||||
;; Naive (single time-domain) expansion: RRULE + EXDATE/RDATE + overrides.
|
||||
(define
|
||||
ev-expand-naive
|
||||
(fn
|
||||
(event win-start win-end)
|
||||
(let
|
||||
((excepted
|
||||
(ev-apply-exceptions
|
||||
event
|
||||
(ev-expand-base event win-start win-end)
|
||||
win-start
|
||||
win-end))
|
||||
(overrides (ev-or (get event :overrides) (list)))
|
||||
(id (get event :id)))
|
||||
(if
|
||||
(empty? overrides)
|
||||
excepted
|
||||
(filter
|
||||
(fn (o) (and (>= (get o :start) win-start) (<= (get o :start) win-end)))
|
||||
(ev-sort-occs (ev-apply-overrides id excepted overrides)))))))
|
||||
|
||||
;; Public entry point. A tz-aware event (`:tz` set) expands in local wall-clock
|
||||
;; time and converts each occurrence to UTC (ev-expand-tz, timezone.sx); a plain
|
||||
;; event expands naively in a single time domain. The window is UTC either way.
|
||||
(define
|
||||
ev-expand
|
||||
(fn
|
||||
(event win-start win-end)
|
||||
(let
|
||||
((tz (get event :tz)))
|
||||
(if
|
||||
(nil? tz)
|
||||
(ev-expand-naive event win-start win-end)
|
||||
(ev-expand-tz event tz win-start win-end)))))
|
||||
|
||||
;; ---- multi-event expansion (sorted by start) ----
|
||||
|
||||
;; Insertion of one occurrence into a start-ascending list.
|
||||
(define
|
||||
ev-occ-insert
|
||||
(fn
|
||||
(o sorted)
|
||||
(cond
|
||||
((empty? sorted) (list o))
|
||||
((<= (get o :start) (get (first sorted) :start)) (cons o sorted))
|
||||
(else (cons (first sorted) (ev-occ-insert o (rest sorted)))))))
|
||||
|
||||
(define
|
||||
ev-sort-occs
|
||||
(fn (occs) (reduce (fn (acc o) (ev-occ-insert o acc)) (list) occs)))
|
||||
|
||||
;; Expand many events into one occurrence list, ascending by start.
|
||||
(define
|
||||
ev-expand-all
|
||||
(fn
|
||||
(events win-start win-end)
|
||||
(let
|
||||
((acc (list)))
|
||||
(begin
|
||||
(for-each
|
||||
(fn
|
||||
(ev)
|
||||
(for-each
|
||||
(fn (o) (append! acc o))
|
||||
(ev-expand ev win-start win-end)))
|
||||
events)
|
||||
(ev-sort-occs acc)))))
|
||||
60
lib/events/conformance.conf
Normal file
60
lib/events/conformance.conf
Normal file
@@ -0,0 +1,60 @@
|
||||
# events-on-sx conformance config — sourced by lib/guest/conformance.sh.
|
||||
|
||||
LANG_NAME=events
|
||||
MODE=dict
|
||||
SCOREBOARD_DIR=lib/events
|
||||
|
||||
PRELOADS=(
|
||||
spec/stdlib.sx
|
||||
lib/r7rs.sx
|
||||
lib/datalog/tokenizer.sx
|
||||
lib/datalog/parser.sx
|
||||
lib/datalog/unify.sx
|
||||
lib/datalog/db.sx
|
||||
lib/datalog/builtins.sx
|
||||
lib/datalog/aggregates.sx
|
||||
lib/datalog/strata.sx
|
||||
lib/datalog/eval.sx
|
||||
lib/datalog/api.sx
|
||||
lib/datalog/magic.sx
|
||||
lib/events/calendar.sx
|
||||
lib/events/timezone.sx
|
||||
lib/events/availability.sx
|
||||
lib/persist/event.sx
|
||||
lib/persist/backend.sx
|
||||
lib/persist/log.sx
|
||||
lib/persist/kv.sx
|
||||
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
|
||||
lib/guest/reflective/quoting.sx
|
||||
lib/scheme/parser.sx
|
||||
lib/scheme/eval.sx
|
||||
lib/scheme/runtime.sx
|
||||
lib/flow/spec.sx
|
||||
lib/flow/store.sx
|
||||
lib/flow/remote.sx
|
||||
lib/flow/host.sx
|
||||
lib/flow/api.sx
|
||||
lib/events/notify.sx
|
||||
lib/events/api.sx
|
||||
lib/events/reminders.sx
|
||||
lib/events/federation.sx
|
||||
)
|
||||
|
||||
SUITES=(
|
||||
"calendar:lib/events/tests/calendar.sx:(ev-calendar-tests-run!)"
|
||||
"timezone:lib/events/tests/timezone.sx:(ev-timezone-tests-run!)"
|
||||
"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!)"
|
||||
"federation:lib/events/tests/federation.sx:(ev-federation-tests-run!)"
|
||||
)
|
||||
3
lib/events/conformance.sh
Executable file
3
lib/events/conformance.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# Thin wrapper — see lib/guest/conformance.sh and lib/events/conformance.conf.
|
||||
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"
|
||||
232
lib/events/federation.sx
Normal file
232
lib/events/federation.sx
Normal file
@@ -0,0 +1,232 @@
|
||||
;; lib/events/federation.sx — cross-instance calendar federation (trust-gated).
|
||||
;;
|
||||
;; A peer is another events instance that publishes a schedule (an events
|
||||
;; store). We merge a peer's agenda into ours ONLY if we trust it — trust is a
|
||||
;; set of peer ids, re-checked on every merge, so revoking a peer takes effect
|
||||
;; immediately. Merged occurrences carry :origin provenance (:local for ours, or
|
||||
;; the peer id) so a consumer always knows where a slot came from.
|
||||
;;
|
||||
;; This is the trust-gated stub: peers publish plain schedules and we fold the
|
||||
;; trusted ones into a single sorted agenda. Real transport (fed-sx / signed
|
||||
;; fetch) slots in behind `ev/peer-agenda` without changing the merge.
|
||||
;;
|
||||
;; Federated FREE/BUSY follows the iCal model: a peer publishes BUSY intervals
|
||||
;; for an actor (not event details — privacy-preserving), and we union local +
|
||||
;; trusted-peer busy to answer "is this actor free?" across instances.
|
||||
|
||||
(define ev/peer (fn (id store) {:id id :busy (list) :store store}))
|
||||
|
||||
;; A peer that also publishes free/busy: `busy` is a list of
|
||||
;; (actor ((start end) ...)) pairs.
|
||||
(define ev/peer-with-busy (fn (id store busy) {:id id :busy busy :store store}))
|
||||
|
||||
(define ev/peer-id (fn (p) (get p :id)))
|
||||
(define ev/peer-store (fn (p) (get p :store)))
|
||||
(define ev/peer-busy-table (fn (p) (get p :busy)))
|
||||
|
||||
(define
|
||||
ev-fed-member?
|
||||
(fn
|
||||
(x xs)
|
||||
(cond
|
||||
((empty? xs) false)
|
||||
((= x (first xs)) true)
|
||||
(else (ev-fed-member? x (rest xs))))))
|
||||
|
||||
;; Do we trust this peer id? (trust is a list of trusted peer ids.)
|
||||
(define ev/trusts? (fn (trust peer-id) (ev-fed-member? peer-id trust)))
|
||||
|
||||
;; The trusted subset of a peer list.
|
||||
(define
|
||||
ev/trusted-peers
|
||||
(fn
|
||||
(peers trust)
|
||||
(filter (fn (p) (ev/trusts? trust (ev/peer-id p))) peers)))
|
||||
|
||||
;; Tag occurrences with provenance.
|
||||
(define ev-tag-origin (fn (occs origin) (map (fn (o) {:id (get o :id) :start (get o :start) :end (get o :end) :origin origin}) occs)))
|
||||
|
||||
;; A peer's agenda over [ws, we), tagged with the peer's id as :origin.
|
||||
(define
|
||||
ev/peer-agenda
|
||||
(fn
|
||||
(peer ws we)
|
||||
(ev-tag-origin (ev/agenda (ev/peer-store peer) ws we) (ev/peer-id peer))))
|
||||
|
||||
;; ---- merge (sorted by start, then origin for ties) ----
|
||||
|
||||
(define
|
||||
ev-fed-before?
|
||||
(fn
|
||||
(a c)
|
||||
(cond
|
||||
((< (get a :start) (get c :start)) true)
|
||||
((> (get a :start) (get c :start)) false)
|
||||
(else (< (str (get a :origin)) (str (get c :origin)))))))
|
||||
|
||||
(define
|
||||
ev-fed-insert
|
||||
(fn
|
||||
(x sorted)
|
||||
(cond
|
||||
((empty? sorted) (list x))
|
||||
((ev-fed-before? x (first sorted)) (cons x sorted))
|
||||
(else (cons (first sorted) (ev-fed-insert x (rest sorted)))))))
|
||||
|
||||
(define
|
||||
ev-fed-sort
|
||||
(fn (xs) (reduce (fn (acc x) (ev-fed-insert x acc)) (list) xs)))
|
||||
|
||||
;; Local agenda (origin :local) merged with every TRUSTED peer's agenda,
|
||||
;; sorted by start. Untrusted peers contribute nothing.
|
||||
(define
|
||||
ev/federated-agenda
|
||||
(fn
|
||||
(local-store peers trust ws we)
|
||||
(let
|
||||
((acc (list)))
|
||||
(begin
|
||||
(for-each
|
||||
(fn (o) (append! acc o))
|
||||
(ev-tag-origin (ev/agenda local-store ws we) :local))
|
||||
(for-each
|
||||
(fn
|
||||
(peer)
|
||||
(when
|
||||
(ev/trusts? trust (ev/peer-id peer))
|
||||
(for-each
|
||||
(fn (o) (append! acc o))
|
||||
(ev/peer-agenda peer ws we))))
|
||||
peers)
|
||||
(ev-fed-sort acc)))))
|
||||
|
||||
;; Filter a federated agenda to occurrences from one origin.
|
||||
(define
|
||||
ev/from-origin
|
||||
(fn
|
||||
(agenda origin)
|
||||
(filter (fn (o) (= (get o :origin) origin)) agenda)))
|
||||
|
||||
;; ---- federated free/busy ----
|
||||
|
||||
;; A peer's published busy intervals for `actor` ((start end) ...), or empty.
|
||||
(define
|
||||
ev/peer-busy
|
||||
(fn
|
||||
(peer actor)
|
||||
(let
|
||||
((row (ev-fed-assoc actor (ev/peer-busy-table peer))))
|
||||
(if (nil? row) (list) (first (rest row))))))
|
||||
|
||||
(define
|
||||
ev-fed-assoc
|
||||
(fn
|
||||
(k pairs)
|
||||
(cond
|
||||
((empty? pairs) nil)
|
||||
((= (first (first pairs)) k) (first pairs))
|
||||
(else (ev-fed-assoc k (rest pairs))))))
|
||||
|
||||
;; All busy intervals for `actor` across the LOCAL availability db plus every
|
||||
;; TRUSTED peer's published free/busy, merged and sorted by start.
|
||||
;; `local-db` is an availability db (see availability.sx ev-build-avail).
|
||||
(define
|
||||
ev/federated-busy
|
||||
(fn
|
||||
(local-db peers trust actor)
|
||||
(let
|
||||
((acc (list)))
|
||||
(begin
|
||||
(for-each (fn (iv) (append! acc iv)) (ev-busy local-db actor))
|
||||
(for-each
|
||||
(fn
|
||||
(peer)
|
||||
(when
|
||||
(ev/trusts? trust (ev/peer-id peer))
|
||||
(for-each
|
||||
(fn (iv) (append! acc iv))
|
||||
(ev/peer-busy peer actor))))
|
||||
peers)
|
||||
(ev-sort-lists acc)))))
|
||||
|
||||
;; Half-open overlap of interval (s e) with window [qs, qe).
|
||||
(define
|
||||
ev-fed-overlaps?
|
||||
(fn (iv qs qe) (and (< (first iv) qe) (< qs (first (rest iv))))))
|
||||
|
||||
;; Is `actor` free across [qs, qe) considering local + trusted-peer busy?
|
||||
(define
|
||||
ev/federated-free?
|
||||
(fn
|
||||
(local-db peers trust actor qs qe)
|
||||
(not
|
||||
(some
|
||||
(fn (iv) (ev-fed-overlaps? iv qs qe))
|
||||
(ev/federated-busy local-db peers trust actor)))))
|
||||
|
||||
;; ---- injected transport (real fed-sx / signed fetch) ----
|
||||
;; The in-process merge above expands a peer's local :store directly. In
|
||||
;; production a peer's agenda arrives over a transport. `fetch` abstracts that:
|
||||
;; (fetch peer-id ws we) -> {:status :ok :occurrences (...)} | {:status :error :reason ...}
|
||||
;; The same merge works for any transport; an unreachable peer (:error) is
|
||||
;; skipped (graceful degradation), never breaking the agenda.
|
||||
|
||||
(define
|
||||
ev-find-peer
|
||||
(fn
|
||||
(peers pid)
|
||||
(cond
|
||||
((empty? peers) nil)
|
||||
((= (ev/peer-id (first peers)) pid) (first peers))
|
||||
(else (ev-find-peer (rest peers) pid)))))
|
||||
|
||||
;; In-process transport adapter: resolves a peer-id against a peer list and
|
||||
;; expands its :store. Lets the in-process model run through the same `fetch`
|
||||
;; interface a remote transport implements.
|
||||
(define
|
||||
ev/peer-fetch
|
||||
(fn
|
||||
(peers)
|
||||
(fn
|
||||
(pid ws we)
|
||||
(let
|
||||
((p (ev-find-peer peers pid)))
|
||||
(if
|
||||
(nil? p)
|
||||
{:status :error :reason :unknown-peer}
|
||||
{:status :ok :occurrences (ev/agenda (ev/peer-store p) ws we)})))))
|
||||
|
||||
;; Local agenda (:local) merged with each trusted peer's agenda fetched via the
|
||||
;; injected `fetch` transport, sorted by start, tagged with :origin. Peers that
|
||||
;; fail to fetch contribute nothing.
|
||||
(define
|
||||
ev/federated-agenda-via
|
||||
(fn
|
||||
(local-store trusted-ids ws we fetch)
|
||||
(let
|
||||
((acc (list)))
|
||||
(begin
|
||||
(for-each
|
||||
(fn (o) (append! acc o))
|
||||
(ev-tag-origin (ev/agenda local-store ws we) :local))
|
||||
(for-each
|
||||
(fn
|
||||
(pid)
|
||||
(let
|
||||
((res (fetch pid ws we)))
|
||||
(when
|
||||
(= (get res :status) :ok)
|
||||
(for-each
|
||||
(fn (o) (append! acc o))
|
||||
(ev-tag-origin (get res :occurrences) pid)))))
|
||||
trusted-ids)
|
||||
(ev-fed-sort acc)))))
|
||||
|
||||
;; Reachability report: ((peer-id :ok|:error) ...) for the trusted peers.
|
||||
(define
|
||||
ev/federation-status
|
||||
(fn
|
||||
(trusted-ids ws we fetch)
|
||||
(map
|
||||
(fn (pid) (list pid (get (fetch pid ws we) :status)))
|
||||
trusted-ids)))
|
||||
38
lib/events/notify.sx
Normal file
38
lib/events/notify.sx
Normal file
@@ -0,0 +1,38 @@
|
||||
;; lib/events/notify.sx — durable notification delivery flows over an injected
|
||||
;; transport (lib/flow).
|
||||
;;
|
||||
;; Reminders and digests are durable `flow`s: a flow `request`s delivery (a
|
||||
;; suspend point), the HOST performs the actual send via an injected `dispatch`
|
||||
;; (the transport — email/push/etc.), and resumes the flow with the outcome.
|
||||
;; Because flow uses deterministic replay, a completed delivery is never re-run
|
||||
;; on recovery; the host owns IO and persistence.
|
||||
;;
|
||||
;; Delivery is AT-LEAST-ONCE with idempotency. Each message carries an id (the
|
||||
;; idempotency key). Two protections stop double-delivery:
|
||||
;; 1. The transport dedups by id — a re-send of a delivered id is a no-op
|
||||
;; that still reports ok, so a retry never produces two pings.
|
||||
;; 2. flow's replay log records each resolved request, so recovery replays the
|
||||
;; logged outcome instead of re-issuing the send.
|
||||
;;
|
||||
;; Retry/backoff rides flow suspend/resume: each attempt issues a request with a
|
||||
;; DISTINCT tag `(deliver <id> <n>)` — distinct tags keep deterministic replay
|
||||
;; correct across retries. The dispatch returns (ok info) to finish or
|
||||
;; (retry reason) to try again, bounded by `maxn` (then (failed id reason)).
|
||||
;;
|
||||
;; A message is a 3-element list (id recipient body). The transport is generic
|
||||
;; and injected — when feed/notify lands, both consumers share one transport,
|
||||
;; so this delivery core is a candidate for extraction to `delivery-on-sx`.
|
||||
;;
|
||||
;; The Scheme flow source below loads into a flow env (see lib/flow/api.sx).
|
||||
;; `ev/notify-run` prepends it to a caller program and evaluates in the shared
|
||||
;; flow env.
|
||||
|
||||
(define
|
||||
ev-notify-flows-src
|
||||
"(define (ev-msg-id m) (car m))\n (define (ev-msg-recipient m) (car (cdr m)))\n (define (ev-msg-body m) (car (cdr (cdr m))))\n (define (ev-mem x xs)\n (if (null? xs) #f (if (equal? x (car xs)) #t (ev-mem x (cdr xs)))))\n (define (ev-notify-attempt m n maxn)\n (let ((r (request (list (quote deliver) (ev-msg-id m) n) m)))\n (if (eq? (car r) (quote ok))\n (list (quote delivered) (ev-msg-id m) n)\n (if (>= n maxn)\n (list (quote failed) (ev-msg-id m) (car (cdr r)))\n (ev-notify-attempt m (+ n 1) maxn)))))\n (define (ev-deliver-reminder maxn)\n (flow-node (lambda (m) (ev-notify-attempt m 1 maxn))))\n (define (ev-digest-step ms maxn)\n (if (null? ms)\n (list)\n (cons (ev-notify-attempt (car ms) 1 maxn)\n (ev-digest-step (cdr ms) maxn))))\n (define (ev-deliver-digest maxn)\n (flow-node (lambda (ms) (ev-digest-step ms maxn))))")
|
||||
|
||||
;; Run a Scheme flow program with the notify flows preloaded, in the shared
|
||||
;; flow env. Returns the program's value (SX-native).
|
||||
(define
|
||||
ev/notify-run
|
||||
(fn (prog) (flow-run (str ev-notify-flows-src "\n" prog))))
|
||||
147
lib/events/reminders.sx
Normal file
147
lib/events/reminders.sx
Normal file
@@ -0,0 +1,147 @@
|
||||
;; lib/events/reminders.sx — derive reminder + digest messages from the agenda.
|
||||
;;
|
||||
;; Bridges the schedule (calendar) and the durable roster (booking on persist)
|
||||
;; to the notification layer (notify.sx). For each booked attendee of each
|
||||
;; upcoming occurrence we derive a reminder message that fires `lead` minutes
|
||||
;; before the occurrence starts. Each message has a deterministic idempotency
|
||||
;; key — occ-key / recipient / lead — so re-deriving over an overlapping window
|
||||
;; never produces a duplicate ping (the notify transport dedups on this id).
|
||||
;;
|
||||
;; A reminder is a dict:
|
||||
;; {:id :recipient :event :start :fire-at}
|
||||
;; `ev/reminder->msg` projects it to notify's (id recipient body) wire shape.
|
||||
|
||||
;; Reminders for one occurrence: one per booked attendee (durable roster).
|
||||
(define
|
||||
ev/occurrence-reminders
|
||||
(fn
|
||||
(b occ lead)
|
||||
(let
|
||||
((occ-key (ev-occ-key occ))
|
||||
(start (get occ :start))
|
||||
(evid (get occ :id)))
|
||||
(map (fn (actor) {:id (str occ-key "/" actor "/" lead) :event evid :start start :fire-at (- start lead) :recipient actor}) (ev/roster-occ b occ)))))
|
||||
|
||||
;; Insertion sort of reminder dicts ascending by :fire-at (then :id for ties).
|
||||
(define
|
||||
ev-rem-before?
|
||||
(fn
|
||||
(a c)
|
||||
(cond
|
||||
((< (get a :fire-at) (get c :fire-at)) true)
|
||||
((> (get a :fire-at) (get c :fire-at)) false)
|
||||
(else (< (get a :id) (get c :id))))))
|
||||
|
||||
(define
|
||||
ev-rem-insert
|
||||
(fn
|
||||
(r sorted)
|
||||
(cond
|
||||
((empty? sorted) (list r))
|
||||
((ev-rem-before? r (first sorted)) (cons r sorted))
|
||||
(else (cons (first sorted) (ev-rem-insert r (rest sorted)))))))
|
||||
|
||||
(define
|
||||
ev-rem-sort
|
||||
(fn (rs) (reduce (fn (acc r) (ev-rem-insert r acc)) (list) rs)))
|
||||
|
||||
;; All reminders across the agenda in [ws, we), ascending by fire-at.
|
||||
(define
|
||||
ev/agenda-reminders
|
||||
(fn
|
||||
(b store ws we lead)
|
||||
(let
|
||||
((acc (list)))
|
||||
(begin
|
||||
(for-each
|
||||
(fn
|
||||
(occ)
|
||||
(for-each
|
||||
(fn (r) (append! acc r))
|
||||
(ev/occurrence-reminders b occ lead)))
|
||||
(ev/agenda store ws we))
|
||||
(ev-rem-sort acc)))))
|
||||
|
||||
;; Reminders whose fire-at has arrived (fire-at <= now) — what a scheduler
|
||||
;; should hand to the notify transport at time `now`.
|
||||
(define
|
||||
ev/due-reminders
|
||||
(fn
|
||||
(reminders now)
|
||||
(filter (fn (r) (<= (get r :fire-at) now)) reminders)))
|
||||
|
||||
;; Project a reminder to notify's (id recipient body) wire shape.
|
||||
(define
|
||||
ev/reminder->msg
|
||||
(fn
|
||||
(r)
|
||||
(list
|
||||
(get r :id)
|
||||
(get r :recipient)
|
||||
(list :reminder (get r :event) (get r :start)))))
|
||||
|
||||
;; ---- digests ----
|
||||
|
||||
;; The occurrences `actor` is booked into (durable roster), within window.
|
||||
(define
|
||||
ev/agenda-for-p
|
||||
(fn
|
||||
(b store actor ws we)
|
||||
(filter
|
||||
(fn (occ) (ev-bk-member? actor (ev/roster-occ b occ)))
|
||||
(ev/agenda store ws we))))
|
||||
|
||||
;; A single digest message summarising an actor's upcoming booked occurrences.
|
||||
;; :items is ({:event :start} ...); empty when the actor has nothing booked.
|
||||
(define ev/agenda-digest (fn (b store actor ws we) {:items (map (fn (occ) {:event (get occ :id) :start (get occ :start)}) (ev/agenda-for-p b store actor ws we)) :id (str actor "/digest/" ws "-" we) :recipient actor}))
|
||||
|
||||
;; ---- reschedule notifications ----
|
||||
;; When an event carries per-occurrence overrides (ev-with-override), every
|
||||
;; attendee booked at the ORIGINAL start should be told the new time. Bookings
|
||||
;; were made against the original occ-key (id@orig-start), so we read that
|
||||
;; roster. Idempotency key encodes the original key and the new start, so
|
||||
;; re-deriving the same reschedule never double-notifies.
|
||||
(define
|
||||
ev/reschedule-notifications
|
||||
(fn
|
||||
(b event)
|
||||
(let
|
||||
((overrides (ev-or (get event :overrides) (list)))
|
||||
(evid (get event :id))
|
||||
(dur (get event :duration)))
|
||||
(reduce
|
||||
(fn
|
||||
(acc entry)
|
||||
(let
|
||||
((orig-start (first entry))
|
||||
(ov (first (rest entry))))
|
||||
(let
|
||||
((occ (ev-occ evid orig-start dur))
|
||||
(new-start (get ov :start))
|
||||
(new-duration (get ov :duration)))
|
||||
(let
|
||||
((key (ev-occ-key occ)))
|
||||
(append
|
||||
acc
|
||||
(map
|
||||
(fn
|
||||
(actor)
|
||||
{:id (str key "/reschedule/" new-start)
|
||||
:recipient actor
|
||||
:event evid
|
||||
:old-start orig-start
|
||||
:new-start new-start
|
||||
:new-duration new-duration})
|
||||
(ev/roster-occ b occ)))))))
|
||||
(list)
|
||||
overrides))))
|
||||
|
||||
;; Project a reschedule notification to notify's (id recipient body) shape.
|
||||
(define
|
||||
ev/reschedule-notify->msg
|
||||
(fn
|
||||
(r)
|
||||
(list
|
||||
(get r :id)
|
||||
(get r :recipient)
|
||||
(list :rescheduled (get r :event) (get r :old-start) (get r :new-start)))))
|
||||
19
lib/events/scoreboard.json
Normal file
19
lib/events/scoreboard.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"lang": "events",
|
||||
"total_passed": 295,
|
||||
"total_failed": 0,
|
||||
"total": 295,
|
||||
"suites": [
|
||||
{"name":"calendar","passed":51,"failed":0,"total":51},
|
||||
{"name":"timezone","passed":17,"failed":0,"total":17},
|
||||
{"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":21,"failed":0,"total":21},
|
||||
{"name":"federation","passed":29,"failed":0,"total":29}
|
||||
],
|
||||
"generated": "2026-06-07T09:30:28+00:00"
|
||||
}
|
||||
16
lib/events/scoreboard.md
Normal file
16
lib/events/scoreboard.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# events scoreboard
|
||||
|
||||
**295 / 295 passing** (0 failure(s)).
|
||||
|
||||
| Suite | Passed | Total | Status |
|
||||
|-------|--------|-------|--------|
|
||||
| calendar | 51 | 51 | ok |
|
||||
| timezone | 17 | 17 | ok |
|
||||
| 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 | 21 | 21 | ok |
|
||||
| federation | 29 | 29 | ok |
|
||||
271
lib/events/tests/api.sx
Normal file
271
lib/events/tests/api.sx
Normal file
@@ -0,0 +1,271 @@
|
||||
;; 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!
|
||||
"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
|
||||
(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))
|
||||
(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!
|
||||
(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})))
|
||||
331
lib/events/tests/availability.sx
Normal file
331
lib/events/tests/availability.sx
Normal file
@@ -0,0 +1,331 @@
|
||||
;; lib/events/tests/availability.sx — free/busy + conflict rules on Datalog.
|
||||
|
||||
(define ev-av-pass 0)
|
||||
(define ev-av-fail 0)
|
||||
(define ev-av-failures (list))
|
||||
|
||||
(define
|
||||
ev-av-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! ev-av-pass (+ ev-av-pass 1))
|
||||
(do
|
||||
(set! ev-av-fail (+ ev-av-fail 1))
|
||||
(append!
|
||||
ev-av-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
;; Fixture: three occurrences on 2026-06-01.
|
||||
;; standup 09:00–09:30 review 09:15–10:15 (overlaps standup)
|
||||
;; lunch 12:00–13:00
|
||||
(define
|
||||
ev-av-occs
|
||||
(fn
|
||||
()
|
||||
(list
|
||||
(ev-occ
|
||||
(quote standup)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
30)
|
||||
(ev-occ
|
||||
(quote review)
|
||||
(ev-dt 2026 6 1 9 15)
|
||||
60)
|
||||
(ev-occ
|
||||
(quote lunch)
|
||||
(ev-dt 2026 6 1 12 0)
|
||||
60))))
|
||||
|
||||
(define ev-av-key (fn (id start) (str id "@" start)))
|
||||
|
||||
;; alice: standup + review (overlap → conflict). bob: lunch only.
|
||||
(define
|
||||
ev-av-db
|
||||
(fn
|
||||
()
|
||||
(ev-avail-db
|
||||
(ev-av-occs)
|
||||
(list
|
||||
(list
|
||||
(quote alice)
|
||||
(ev-av-key
|
||||
(quote standup)
|
||||
(ev-dt 2026 6 1 9 0)))
|
||||
(list
|
||||
(quote alice)
|
||||
(ev-av-key
|
||||
(quote review)
|
||||
(ev-dt 2026 6 1 9 15)))
|
||||
(list
|
||||
(quote bob)
|
||||
(ev-av-key
|
||||
(quote lunch)
|
||||
(ev-dt 2026 6 1 12 0)))))))
|
||||
|
||||
;; Disjoint fixture for slot search: 09:00–10:00 then 10:30–11:30 (a 30m gap).
|
||||
(define
|
||||
ev-av-gap-db
|
||||
(fn
|
||||
()
|
||||
(ev-avail-db
|
||||
(list
|
||||
(ev-occ
|
||||
(quote a)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
60)
|
||||
(ev-occ
|
||||
(quote b)
|
||||
(ev-dt 2026 6 1 10 30)
|
||||
60))
|
||||
(list
|
||||
(list
|
||||
(quote sam)
|
||||
(ev-av-key
|
||||
(quote a)
|
||||
(ev-dt 2026 6 1 9 0)))
|
||||
(list
|
||||
(quote sam)
|
||||
(ev-av-key
|
||||
(quote b)
|
||||
(ev-dt 2026 6 1 10 30)))))))
|
||||
|
||||
(define
|
||||
ev-av-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((db (ev-av-db)))
|
||||
(do
|
||||
(ev-av-check!
|
||||
"busy lists alice committed intervals ascending"
|
||||
(ev-busy db (quote alice))
|
||||
(list
|
||||
(list
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
(ev-dt 2026 6 1 9 30))
|
||||
(list
|
||||
(ev-dt 2026 6 1 9 15)
|
||||
(ev-dt 2026 6 1 10 15))))
|
||||
(ev-av-check!
|
||||
"busy lists bob single interval"
|
||||
(ev-busy db (quote bob))
|
||||
(list
|
||||
(list
|
||||
(ev-dt 2026 6 1 12 0)
|
||||
(ev-dt 2026 6 1 13 0))))
|
||||
(ev-av-check!
|
||||
"busy empty for unknown actor"
|
||||
(ev-busy db (quote carol))
|
||||
(list))
|
||||
(ev-av-check!
|
||||
"alice has an overlap conflict"
|
||||
(ev-has-conflict? db (quote alice))
|
||||
true)
|
||||
(ev-av-check!
|
||||
"alice conflict reported once (canonical pair)"
|
||||
(len (ev-conflicts db (quote alice)))
|
||||
1)
|
||||
(ev-av-check!
|
||||
"bob has no conflict"
|
||||
(ev-has-conflict? db (quote bob))
|
||||
false)
|
||||
(ev-av-check!
|
||||
"non-overlapping bookings do not conflict"
|
||||
(ev-has-conflict?
|
||||
(ev-avail-db
|
||||
(list
|
||||
(ev-occ
|
||||
(quote a)
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
1
|
||||
9
|
||||
0)
|
||||
30)
|
||||
(ev-occ
|
||||
(quote b)
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
1
|
||||
9
|
||||
30)
|
||||
30))
|
||||
(list
|
||||
(list
|
||||
(quote dave)
|
||||
(ev-av-key
|
||||
(quote a)
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
1
|
||||
9
|
||||
0)))
|
||||
(list
|
||||
(quote dave)
|
||||
(ev-av-key
|
||||
(quote b)
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
1
|
||||
9
|
||||
30)))))
|
||||
(quote dave))
|
||||
false)
|
||||
(ev-av-check!
|
||||
"alice free in an empty window"
|
||||
(ev-free?
|
||||
db
|
||||
(quote alice)
|
||||
(ev-dt 2026 6 1 13 0)
|
||||
(ev-dt 2026 6 1 14 0))
|
||||
true)
|
||||
(ev-av-check!
|
||||
"alice not free overlapping a booking"
|
||||
(ev-free?
|
||||
db
|
||||
(quote alice)
|
||||
(ev-dt 2026 6 1 9 20)
|
||||
(ev-dt 2026 6 1 9 40))
|
||||
false)
|
||||
(ev-av-check!
|
||||
"free? is half-open at the trailing edge"
|
||||
(ev-free?
|
||||
db
|
||||
(quote alice)
|
||||
(ev-dt 2026 6 1 10 15)
|
||||
(ev-dt 2026 6 1 11 0))
|
||||
true)
|
||||
(ev-av-check!
|
||||
"free? is half-open at the leading edge"
|
||||
(ev-free?
|
||||
db
|
||||
(quote bob)
|
||||
(ev-dt 2026 6 1 11 0)
|
||||
(ev-dt 2026 6 1 12 0))
|
||||
true)
|
||||
(ev-av-check!
|
||||
"free? false when window straddles a booking edge"
|
||||
(ev-free?
|
||||
db
|
||||
(quote bob)
|
||||
(ev-dt 2026 6 1 11 0)
|
||||
(ev-dt 2026 6 1 12 1))
|
||||
false)
|
||||
(ev-av-check!
|
||||
"free? query leaves db reusable (no leaked qwindow)"
|
||||
(do
|
||||
(ev-free?
|
||||
db
|
||||
(quote alice)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
(ev-dt 2026 6 1 9 30))
|
||||
(ev-busy db (quote bob)))
|
||||
(list
|
||||
(list
|
||||
(ev-dt 2026 6 1 12 0)
|
||||
(ev-dt 2026 6 1 13 0))))
|
||||
(let
|
||||
((gdb (ev-av-gap-db)))
|
||||
(do
|
||||
(ev-av-check!
|
||||
"next-free finds the gap between bookings"
|
||||
(ev-next-free
|
||||
gdb
|
||||
(quote sam)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
30
|
||||
(ev-dt 2026 6 1 18 0))
|
||||
(ev-dt 2026 6 1 10 0))
|
||||
(ev-av-check!
|
||||
"next-free skips a gap too short for the duration"
|
||||
(ev-next-free
|
||||
gdb
|
||||
(quote sam)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
60
|
||||
(ev-dt 2026 6 1 18 0))
|
||||
(ev-dt 2026 6 1 11 30))
|
||||
(ev-av-check!
|
||||
"next-free returns `after` when already free"
|
||||
(ev-next-free
|
||||
gdb
|
||||
(quote sam)
|
||||
(ev-dt 2026 6 1 14 0)
|
||||
60
|
||||
(ev-dt 2026 6 1 18 0))
|
||||
(ev-dt 2026 6 1 14 0))
|
||||
(ev-av-check!
|
||||
"next-free returns nil when nothing fits before horizon"
|
||||
(ev-next-free
|
||||
gdb
|
||||
(quote sam)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
120
|
||||
(ev-dt 2026 6 1 11 0))
|
||||
nil)
|
||||
(ev-av-check!
|
||||
"next-free for actor with no bookings is `after`"
|
||||
(ev-next-free
|
||||
gdb
|
||||
(quote nobody)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
60
|
||||
(ev-dt 2026 6 1 18 0))
|
||||
(ev-dt 2026 6 1 9 0))
|
||||
(ev-av-check!
|
||||
"next-free at exact edge of a booking (half-open)"
|
||||
(ev-next-free
|
||||
gdb
|
||||
(quote sam)
|
||||
(ev-dt 2026 6 1 10 0)
|
||||
30
|
||||
(ev-dt 2026 6 1 18 0))
|
||||
(ev-dt 2026 6 1 10 0))))
|
||||
(let
|
||||
((daily (ev-expand (ev-event (quote class) (ev-dt 2026 6 1 9 0) 60 {:freq :daily :count 3} 1) (ev-date 2026 6 1) (ev-date 2026 7 1))))
|
||||
(let
|
||||
((db2 (ev-avail-db daily (map (fn (o) (list (quote sam) (ev-occ-key o))) daily))))
|
||||
(do
|
||||
(ev-av-check!
|
||||
"expanded daily occurrences become busy intervals"
|
||||
(len (ev-busy db2 (quote sam)))
|
||||
3)
|
||||
(ev-av-check!
|
||||
"no conflicts among disjoint daily occurrences"
|
||||
(ev-has-conflict? db2 (quote sam))
|
||||
false)
|
||||
(ev-av-check!
|
||||
"busy on day two of the series"
|
||||
(ev-free?
|
||||
db2
|
||||
(quote sam)
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
2
|
||||
9
|
||||
30)
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
2
|
||||
9
|
||||
45))
|
||||
false))))))))
|
||||
|
||||
(define
|
||||
ev-availability-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! ev-av-pass 0)
|
||||
(set! ev-av-fail 0)
|
||||
(set! ev-av-failures (list))
|
||||
(ev-av-run-all!)
|
||||
{:failures ev-av-failures :total (+ ev-av-pass ev-av-fail) :passed ev-av-pass :failed ev-av-fail})))
|
||||
137
lib/events/tests/booking-notify.sx
Normal file
137
lib/events/tests/booking-notify.sx
Normal 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})))
|
||||
431
lib/events/tests/booking.sx
Normal file
431
lib/events/tests/booking.sx
Normal file
@@ -0,0 +1,431 @@
|
||||
;; lib/events/tests/booking.sx — capacity-safe booking, cancel, and holds.
|
||||
|
||||
(define ev-bk-pass 0)
|
||||
(define ev-bk-fail 0)
|
||||
(define ev-bk-failures (list))
|
||||
|
||||
(define
|
||||
ev-bk-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! ev-bk-pass (+ ev-bk-pass 1))
|
||||
(do
|
||||
(set! ev-bk-fail (+ ev-bk-fail 1))
|
||||
(append!
|
||||
ev-bk-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
;; Take a consistent (roster, last-seq) snapshot of an occurrence's stream.
|
||||
(define ev-bk-snap (fn (b k) (ev-booked-actors b k)))
|
||||
(define ev-bk-seq (fn (b k) (persist/last-seq b (ev-booking-stream k))))
|
||||
|
||||
(define
|
||||
ev-bk-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev-bk-check!
|
||||
"first booking takes seat 1"
|
||||
(get (ev/book! b "o1" 3 (quote a)) :seat)
|
||||
1)
|
||||
(ev-bk-check!
|
||||
"second booking takes seat 2"
|
||||
(get (ev/book! b "o1" 3 (quote c)) :seat)
|
||||
2)
|
||||
(ev-bk-check!
|
||||
"booked status reported"
|
||||
(get (ev/book! b "o1" 3 (quote d)) :status)
|
||||
:booked)
|
||||
(ev-bk-check!
|
||||
"roster is oldest-first"
|
||||
(ev/roster b "o1")
|
||||
(list (quote a) (quote c) (quote d)))
|
||||
(ev-bk-check!
|
||||
"seats-left is zero when full"
|
||||
(ev/seats-left b "o1" 3)
|
||||
0)
|
||||
(ev-bk-check!
|
||||
"free booking is confirmed state"
|
||||
(ev/seat-state b "o1" (quote a))
|
||||
:confirmed)))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "o2" 1 (quote a))
|
||||
(ev-bk-check!
|
||||
"booking past capacity is refused"
|
||||
(get (ev/book! b "o2" 1 (quote c)) :status)
|
||||
:full)
|
||||
(ev-bk-check!
|
||||
"full does not grow the roster"
|
||||
(ev/roster b "o2")
|
||||
(list (quote a)))
|
||||
(ev-bk-check!
|
||||
"seats-left zero at capacity"
|
||||
(ev/seats-left b "o2" 1)
|
||||
0)))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "o3" 5 (quote a))
|
||||
(ev-bk-check!
|
||||
"re-booking the same actor is idempotent"
|
||||
(get (ev/book! b "o3" 5 (quote a)) :status)
|
||||
:already)
|
||||
(ev-bk-check!
|
||||
"idempotent re-book reports existing seat"
|
||||
(get (ev/book! b "o3" 5 (quote a)) :seat)
|
||||
1)
|
||||
(ev-bk-check!
|
||||
"roster unchanged after re-book"
|
||||
(ev/roster b "o3")
|
||||
(list (quote a)))
|
||||
(ev-bk-check!
|
||||
"count unchanged after re-book"
|
||||
(ev-booking-count b "o3")
|
||||
1)))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "last" 2 (quote x))
|
||||
(let
|
||||
((snap (ev-bk-snap b "last")) (exp (ev-bk-seq b "last")))
|
||||
(let
|
||||
((ra (ev/book-with-observed b "last" 2 (quote a) snap exp))
|
||||
(rb
|
||||
(ev/book-with-observed
|
||||
b
|
||||
"last"
|
||||
2
|
||||
(quote bee)
|
||||
snap
|
||||
exp)))
|
||||
(do
|
||||
(ev-bk-check!
|
||||
"race winner is booked"
|
||||
(get ra :status)
|
||||
:booked)
|
||||
(ev-bk-check!
|
||||
"race winner takes the last seat"
|
||||
(get ra :seat)
|
||||
2)
|
||||
(ev-bk-check!
|
||||
"race loser is rejected with a conflict"
|
||||
(get rb :status)
|
||||
:conflict)
|
||||
(ev-bk-check!
|
||||
"conflict reports the advanced seq"
|
||||
(get rb :actual)
|
||||
(+ exp 1))
|
||||
(ev-bk-check!
|
||||
"no overbooking: exactly two on roster"
|
||||
(ev-booking-count b "last")
|
||||
2)
|
||||
(ev-bk-check!
|
||||
"race loser is NOT on the roster"
|
||||
(ev-bk-member? (quote bee) (ev/roster b "last"))
|
||||
false)
|
||||
(ev-bk-check!
|
||||
"race loser retrying gets full"
|
||||
(get (ev/book! b "last" 2 (quote bee)) :status)
|
||||
:full))))))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "room" 3 (quote x))
|
||||
(let
|
||||
((snap (ev-bk-snap b "room")) (exp (ev-bk-seq b "room")))
|
||||
(let
|
||||
((ra (ev/book-with-observed b "room" 3 (quote a) snap exp))
|
||||
(rb
|
||||
(ev/book-with-observed
|
||||
b
|
||||
"room"
|
||||
3
|
||||
(quote bee)
|
||||
snap
|
||||
exp)))
|
||||
(do
|
||||
(ev-bk-check!
|
||||
"room winner booked seat 2"
|
||||
(get ra :seat)
|
||||
2)
|
||||
(ev-bk-check!
|
||||
"room loser first conflicts"
|
||||
(get rb :status)
|
||||
:conflict)
|
||||
(ev-bk-check!
|
||||
"room loser retry books seat 3"
|
||||
(get (ev/book! b "room" 3 (quote bee)) :seat)
|
||||
3)
|
||||
(ev-bk-check!
|
||||
"room roster is x,a,bee"
|
||||
(ev/roster b "room")
|
||||
(list (quote x) (quote a) (quote bee)))
|
||||
(ev-bk-check!
|
||||
"room is now full"
|
||||
(ev/seats-left b "room" 3)
|
||||
0))))))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "cx" 2 (quote a))
|
||||
(ev/book! b "cx" 2 (quote c))
|
||||
(ev-bk-check!
|
||||
"occupied to capacity before cancel"
|
||||
(ev/seats-left b "cx" 2)
|
||||
0)
|
||||
(ev-bk-check!
|
||||
"booking when full (pre-cancel) is refused"
|
||||
(get (ev/book! b "cx" 2 (quote d)) :status)
|
||||
:full)
|
||||
(ev-bk-check!
|
||||
"cancel reports cancelled"
|
||||
(get (ev/cancel! b "cx" (quote a)) :status)
|
||||
:cancelled)
|
||||
(ev-bk-check!
|
||||
"cancel removes actor from roster"
|
||||
(ev/roster b "cx")
|
||||
(list (quote c)))
|
||||
(ev-bk-check!
|
||||
"cancel frees a seat"
|
||||
(ev/seats-left b "cx" 2)
|
||||
1)
|
||||
(ev-bk-check!
|
||||
"freed seat is bookable again"
|
||||
(get (ev/book! b "cx" 2 (quote d)) :status)
|
||||
:booked)
|
||||
(ev-bk-check!
|
||||
"roster after rebook is c,d"
|
||||
(ev/roster b "cx")
|
||||
(list (quote c) (quote d)))))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "ce" 3 (quote a))
|
||||
(ev-bk-check!
|
||||
"cancelling an unbooked actor is a no-op"
|
||||
(get (ev/cancel! b "ce" (quote z)) :status)
|
||||
:not-booked)
|
||||
(ev-bk-check!
|
||||
"no-op cancel leaves roster intact"
|
||||
(ev/roster b "ce")
|
||||
(list (quote a)))
|
||||
(ev/cancel! b "ce" (quote a))
|
||||
(ev-bk-check!
|
||||
"double cancel is not-booked the second time"
|
||||
(get (ev/cancel! b "ce" (quote a)) :status)
|
||||
:not-booked)
|
||||
(ev-bk-check!
|
||||
"empty roster after cancel"
|
||||
(ev/roster b "ce")
|
||||
(list))
|
||||
(ev-bk-check!
|
||||
"cancelled actor may re-book"
|
||||
(get (ev/book! b "ce" 3 (quote a)) :status)
|
||||
:booked)
|
||||
(ev-bk-check!
|
||||
"re-booked actor back on roster"
|
||||
(ev/roster b "ce")
|
||||
(list (quote a)))))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "h" 2 (quote a))
|
||||
(ev-bk-check!
|
||||
"hold reports held"
|
||||
(get (ev/hold! b "h" 2 (quote p)) :status)
|
||||
:held)
|
||||
(ev-bk-check!
|
||||
"held seat is :held state"
|
||||
(ev/seat-state b "h" (quote p))
|
||||
:held)
|
||||
(ev-bk-check!
|
||||
"held actor is on the roster"
|
||||
(ev/roster b "h")
|
||||
(list (quote a) (quote p)))
|
||||
(ev-bk-check!
|
||||
"held seat blocks the last booking"
|
||||
(get (ev/book! b "h" 2 (quote x)) :status)
|
||||
:full)
|
||||
(ev-bk-check!
|
||||
"no seats left with one held"
|
||||
(ev/seats-left b "h" 2)
|
||||
0)))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/hold! b "hc" 3 (quote p))
|
||||
(ev-bk-check!
|
||||
"confirm reports confirmed"
|
||||
(get (ev/confirm! b "hc" (quote p)) :status)
|
||||
:confirmed)
|
||||
(ev-bk-check!
|
||||
"confirmed seat is :confirmed state"
|
||||
(ev/seat-state b "hc" (quote p))
|
||||
:confirmed)
|
||||
(ev-bk-check!
|
||||
"re-confirm is already-confirmed"
|
||||
(get (ev/confirm! b "hc" (quote p)) :status)
|
||||
:already-confirmed)
|
||||
(ev-bk-check!
|
||||
"confirming a non-holder is not-held"
|
||||
(get (ev/confirm! b "hc" (quote z)) :status)
|
||||
:not-held)
|
||||
(ev-bk-check!
|
||||
"confirmed seat still occupies"
|
||||
(ev/seats-left b "hc" 3)
|
||||
2)))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "hr" 2 (quote a))
|
||||
(ev/hold! b "hr" 2 (quote p))
|
||||
(ev-bk-check!
|
||||
"full while hold pending"
|
||||
(ev/seats-left b "hr" 2)
|
||||
0)
|
||||
(ev-bk-check!
|
||||
"release reports released"
|
||||
(get (ev/release! b "hr" (quote p)) :status)
|
||||
:released)
|
||||
(ev-bk-check!
|
||||
"release frees the held seat"
|
||||
(ev/seats-left b "hr" 2)
|
||||
1)
|
||||
(ev-bk-check!
|
||||
"released actor off the roster"
|
||||
(ev/roster b "hr")
|
||||
(list (quote a)))
|
||||
(ev-bk-check!
|
||||
"freed seat bookable after release"
|
||||
(get (ev/book! b "hr" 2 (quote x)) :status)
|
||||
:booked)
|
||||
(ev/hold! b "hr2" 1 (quote q))
|
||||
(ev/confirm! b "hr2" (quote q))
|
||||
(ev-bk-check!
|
||||
"release on a confirmed seat is not-held"
|
||||
(get (ev/release! b "hr2" (quote q)) :status)
|
||||
:not-held)
|
||||
(ev-bk-check!
|
||||
"cancel frees a confirmed-from-hold seat"
|
||||
(get (ev/cancel! b "hr2" (quote q)) :status)
|
||||
:cancelled)))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "hlast" 2 (quote x))
|
||||
(let
|
||||
((snap (ev-bk-snap b "hlast")) (exp (ev-bk-seq b "hlast")))
|
||||
(let
|
||||
((ra (ev/hold-with-observed b "hlast" 2 (quote p) snap exp))
|
||||
(rb
|
||||
(ev/hold-with-observed
|
||||
b
|
||||
"hlast"
|
||||
2
|
||||
(quote q)
|
||||
snap
|
||||
exp)))
|
||||
(do
|
||||
(ev-bk-check! "hold race winner held" (get ra :status) :held)
|
||||
(ev-bk-check!
|
||||
"hold race loser conflicts"
|
||||
(get rb :status)
|
||||
:conflict)
|
||||
(ev-bk-check!
|
||||
"no oversell via concurrent holds"
|
||||
(ev-booking-count b "hlast")
|
||||
2)
|
||||
(ev-bk-check!
|
||||
"hold loser retry gets full"
|
||||
(get (ev/hold! b "hlast" 2 (quote q)) :status)
|
||||
:full))))))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/hold! b "hi" 4 (quote p))
|
||||
(ev-bk-check!
|
||||
"re-holding the same actor is idempotent"
|
||||
(get (ev/hold! b "hi" 4 (quote p)) :status)
|
||||
:already)
|
||||
(ev-bk-check!
|
||||
"hold idempotency keeps one seat"
|
||||
(ev-booking-count b "hi")
|
||||
1))))))
|
||||
|
||||
;; ---- waitlist ----
|
||||
(define
|
||||
ev-bk-wl-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
;; join the waitlist when full; book directly when a seat is free
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev-bk-check! "waitlist! books when a seat is free" (get (ev/waitlist! b "w" 2 (quote a)) :status) :booked)
|
||||
(ev-bk-check! "second booking still fits" (get (ev/waitlist! b "w" 2 (quote c)) :status) :booked)
|
||||
(ev-bk-check! "third joins the waitlist when full" (get (ev/waitlist! b "w" 2 (quote x)) :status) :waitlisted)
|
||||
(ev-bk-check! "fourth is next in line" (get (ev/waitlist! b "w" 2 (quote y)) :position) 2)
|
||||
(ev-bk-check! "waitlist is FIFO" (ev/waitlist b "w") (list (quote x) (quote y)))
|
||||
(ev-bk-check! "seats unaffected by waitlisting" (ev/roster b "w") (list (quote a) (quote c)))
|
||||
(ev-bk-check! "waitlist-position reports a queued actor" (ev/waitlist-position b "w" (quote y)) 2)
|
||||
(ev-bk-check! "waitlist-position 0 for a seated actor" (ev/waitlist-position b "w" (quote a)) 0)))
|
||||
;; idempotency
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/waitlist! b "wi" 1 (quote a))
|
||||
(ev/waitlist! b "wi" 1 (quote x))
|
||||
(ev-bk-check! "re-joining as a seated actor is :already" (get (ev/waitlist! b "wi" 1 (quote a)) :status) :already)
|
||||
(ev-bk-check! "re-joining the queue is :already-waiting" (get (ev/waitlist! b "wi" 1 (quote x)) :status) :already-waiting)
|
||||
(ev-bk-check! "queue did not grow on re-join" (ev/waitlist b "wi") (list (quote x)))))
|
||||
;; leaving the waitlist
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/waitlist! b "wl" 1 (quote a))
|
||||
(ev/waitlist! b "wl" 1 (quote x))
|
||||
(ev/waitlist! b "wl" 1 (quote y))
|
||||
(ev-bk-check! "leave-waitlist reports left" (get (ev/leave-waitlist! b "wl" (quote x)) :status) :left)
|
||||
(ev-bk-check! "leaving removes from the queue" (ev/waitlist b "wl") (list (quote y)))
|
||||
(ev-bk-check! "leaving when not queued is not-waiting" (get (ev/leave-waitlist! b "wl" (quote z)) :status) :not-waiting)))
|
||||
;; auto-promotion on cancel
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/waitlist! b "wp" 1 (quote a))
|
||||
(ev/waitlist! b "wp" 1 (quote x))
|
||||
(ev/waitlist! b "wp" 1 (quote y))
|
||||
(let
|
||||
((r (ev/cancel-promote! b "wp" 1 (quote a))))
|
||||
(do
|
||||
(ev-bk-check! "cancel-promote cancels the seat holder" (get r :status) :cancelled)
|
||||
(ev-bk-check! "cancel-promote promotes the head of the queue" (get r :promoted) (quote x))))
|
||||
(ev-bk-check! "promoted actor now holds the seat" (ev/roster b "wp") (list (quote x)))
|
||||
(ev-bk-check! "promoted actor left the queue" (ev/waitlist b "wp") (list (quote y)))
|
||||
(ev-bk-check! "promoted seat is confirmed" (ev/seat-state b "wp" (quote x)) :confirmed)
|
||||
;; cancelling with an empty waitlist promotes nobody
|
||||
(ev/leave-waitlist! b "wp" (quote y))
|
||||
(let
|
||||
((r2 (ev/cancel-promote! b "wp" 1 (quote x))))
|
||||
(ev-bk-check! "cancel with empty waitlist promotes nobody" (get r2 :promoted) nil))
|
||||
(ev-bk-check! "seat is free after the last cancel" (ev/seats-left b "wp" 1) 1))))))
|
||||
|
||||
(define
|
||||
ev-booking-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! ev-bk-pass 0)
|
||||
(set! ev-bk-fail 0)
|
||||
(set! ev-bk-failures (list))
|
||||
(ev-bk-run-all!)
|
||||
(ev-bk-wl-run-all!)
|
||||
{:failures ev-bk-failures :total (+ ev-bk-pass ev-bk-fail) :passed ev-bk-pass :failed ev-bk-fail})))
|
||||
592
lib/events/tests/calendar.sx
Normal file
592
lib/events/tests/calendar.sx
Normal file
@@ -0,0 +1,592 @@
|
||||
;; lib/events/tests/calendar.sx — civil date core + RRULE window expansion.
|
||||
|
||||
(define ev-cal-pass 0)
|
||||
(define ev-cal-fail 0)
|
||||
(define ev-cal-failures (list))
|
||||
|
||||
(define
|
||||
ev-cal-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! ev-cal-pass (+ ev-cal-pass 1))
|
||||
(do
|
||||
(set! ev-cal-fail (+ ev-cal-fail 1))
|
||||
(append!
|
||||
ev-cal-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
;; Project occurrences to (civil weekday) pairs for legible assertions.
|
||||
(define
|
||||
ev-cal-shape
|
||||
(fn
|
||||
(occs)
|
||||
(map
|
||||
(fn
|
||||
(o)
|
||||
(list (ev-dt->civil (get o :start)) (ev-dt-weekday (get o :start))))
|
||||
occs)))
|
||||
|
||||
(define
|
||||
ev-cal-starts
|
||||
(fn (occs) (map (fn (o) (ev-dt->civil (get o :start))) occs)))
|
||||
|
||||
(define
|
||||
ev-cal-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(ev-cal-check!
|
||||
"epoch day zero"
|
||||
(ev-days-from-civil 1970 1 1)
|
||||
0)
|
||||
(ev-cal-check!
|
||||
"y2k day number"
|
||||
(ev-days-from-civil 2000 1 1)
|
||||
10957)
|
||||
(ev-cal-check!
|
||||
"leap day round trip"
|
||||
(ev-civil-from-days
|
||||
(ev-days-from-civil 2024 2 29))
|
||||
(list 2024 2 29))
|
||||
(ev-cal-check!
|
||||
"pre-epoch round trip"
|
||||
(ev-civil-from-days
|
||||
(ev-days-from-civil 1969 12 31))
|
||||
(list 1969 12 31))
|
||||
(ev-cal-check!
|
||||
"epoch is thursday"
|
||||
(ev-weekday-of-days 0)
|
||||
3)
|
||||
(ev-cal-check!
|
||||
"2026-06-06 is saturday"
|
||||
(ev-dt-weekday (ev-date 2026 6 6))
|
||||
5)
|
||||
(ev-cal-check!
|
||||
"dt carries time of day"
|
||||
(ev-dt-tod
|
||||
(ev-dt 2026 6 1 9 30))
|
||||
570)
|
||||
(ev-cal-check!
|
||||
"civil from dt"
|
||||
(ev-dt->civil
|
||||
(ev-dt 2026 12 25 8 0))
|
||||
(list 2026 12 25))
|
||||
(ev-cal-check!
|
||||
"days in feb 2024 (leap)"
|
||||
(ev-days-in-month 2024 2)
|
||||
29)
|
||||
(ev-cal-check!
|
||||
"days in feb 2026"
|
||||
(ev-days-in-month 2026 2)
|
||||
28)
|
||||
(ev-cal-check!
|
||||
"add months wraps year"
|
||||
(ev-add-months 2026 11 3)
|
||||
(list 2027 2))
|
||||
(ev-cal-check!
|
||||
"add months within year"
|
||||
(ev-add-months 2026 1 5)
|
||||
(list 2026 6))
|
||||
(let
|
||||
((ev (ev-event (quote one) (ev-dt 2026 6 10 14 0) 60 nil 1)))
|
||||
(do
|
||||
(ev-cal-check!
|
||||
"single inside window emits once"
|
||||
(len
|
||||
(ev-expand
|
||||
ev
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 7 1)))
|
||||
1)
|
||||
(ev-cal-check!
|
||||
"single before window omitted"
|
||||
(len
|
||||
(ev-expand
|
||||
ev
|
||||
(ev-date 2026 7 1)
|
||||
(ev-date 2026 8 1)))
|
||||
0)
|
||||
(ev-cal-check!
|
||||
"single after window omitted"
|
||||
(len
|
||||
(ev-expand
|
||||
ev
|
||||
(ev-date 2026 1 1)
|
||||
(ev-date 2026 2 1)))
|
||||
0)
|
||||
(ev-cal-check!
|
||||
"occurrence end is start plus duration"
|
||||
(get
|
||||
(first
|
||||
(ev-expand
|
||||
ev
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 7 1)))
|
||||
:end)
|
||||
(+
|
||||
(ev-dt 2026 6 10 14 0)
|
||||
60))))
|
||||
(let
|
||||
((daily (ev-event (quote d) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 5} 1)))
|
||||
(do
|
||||
(ev-cal-check!
|
||||
"daily count caps occurrences"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
daily
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 7 1)))
|
||||
(list
|
||||
(list 2026 6 1)
|
||||
(list 2026 6 2)
|
||||
(list 2026 6 3)
|
||||
(list 2026 6 4)
|
||||
(list 2026 6 5)))
|
||||
(ev-cal-check!
|
||||
"daily preserves time of day"
|
||||
(ev-dt-tod
|
||||
(get
|
||||
(first
|
||||
(ev-expand
|
||||
daily
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 7 1)))
|
||||
:start))
|
||||
540)))
|
||||
(let
|
||||
((di (ev-event (quote di) (ev-dt 2026 6 1 0 0) 30 {:interval 3 :freq :daily :until (ev-date 2026 6 30)} 1)))
|
||||
(ev-cal-check!
|
||||
"daily interval 3 steps by three days"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
di
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 6 13)))
|
||||
(list
|
||||
(list 2026 6 1)
|
||||
(list 2026 6 4)
|
||||
(list 2026 6 7)
|
||||
(list 2026 6 10)
|
||||
(list 2026 6 13))))
|
||||
(let
|
||||
((dc (ev-event (quote dc) (ev-dt 2026 6 1 0 0) 30 {:freq :daily :count 10} 1)))
|
||||
(ev-cal-check!
|
||||
"count is window-independent (clip middle)"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
dc
|
||||
(ev-date 2026 6 5)
|
||||
(ev-date 2026 6 8)))
|
||||
(list
|
||||
(list 2026 6 5)
|
||||
(list 2026 6 6)
|
||||
(list 2026 6 7)
|
||||
(list 2026 6 8))))
|
||||
(let
|
||||
((dc2 (ev-event (quote dc2) (ev-dt 2026 6 1 0 0) 30 {:freq :daily :count 3} 1)))
|
||||
(ev-cal-check!
|
||||
"count exhausted before window yields nothing"
|
||||
(len
|
||||
(ev-expand
|
||||
dc2
|
||||
(ev-date 2026 6 10)
|
||||
(ev-date 2026 6 20)))
|
||||
0))
|
||||
(let
|
||||
((wk (ev-event (quote w) (ev-dt 2026 6 1 18 0) 90 {:freq :weekly :byday (list 0 2 4)} 1)))
|
||||
(ev-cal-check!
|
||||
"weekly byday mon/wed/fri first two weeks"
|
||||
(ev-cal-shape
|
||||
(ev-expand
|
||||
wk
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 6 13)))
|
||||
(list
|
||||
(list (list 2026 6 1) 0)
|
||||
(list (list 2026 6 3) 2)
|
||||
(list (list 2026 6 5) 4)
|
||||
(list (list 2026 6 8) 0)
|
||||
(list (list 2026 6 10) 2)
|
||||
(list (list 2026 6 12) 4))))
|
||||
(let
|
||||
((wu (ev-event (quote wu) (ev-dt 2026 6 1 18 0) 90 {:freq :weekly :until (ev-dt 2026 6 10 23 0) :byday (list 0 2)} 1)))
|
||||
(ev-cal-check!
|
||||
"weekly until clips trailing occurrences"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
wu
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 7 1)))
|
||||
(list
|
||||
(list 2026 6 1)
|
||||
(list 2026 6 3)
|
||||
(list 2026 6 8)
|
||||
(list 2026 6 10))))
|
||||
(let
|
||||
((wi (ev-event (quote wi) (ev-dt 2026 6 1 18 0) 90 {:interval 2 :freq :weekly :byday (list 0)} 1)))
|
||||
(ev-cal-check!
|
||||
"weekly interval 2 skips alternate weeks"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
wi
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 7 6)))
|
||||
(list
|
||||
(list 2026 6 1)
|
||||
(list 2026 6 15)
|
||||
(list 2026 6 29))))
|
||||
(let
|
||||
((wd (ev-event (quote wd) (ev-dt 2026 6 3 12 0) 60 {:freq :weekly :count 3} 1)))
|
||||
(ev-cal-check!
|
||||
"weekly default byday is dtstart weekday"
|
||||
(ev-cal-shape
|
||||
(ev-expand
|
||||
wd
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 8 1)))
|
||||
(list
|
||||
(list (list 2026 6 3) 2)
|
||||
(list (list 2026 6 10) 2)
|
||||
(list (list 2026 6 17) 2))))
|
||||
(let
|
||||
((wc (ev-event (quote wc) (ev-dt 2026 6 1 18 0) 90 {:freq :weekly :count 10 :byday (list 0 2)} 1)))
|
||||
(ev-cal-check!
|
||||
"weekly count window-independent (clip middle)"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
wc
|
||||
(ev-date 2026 6 15)
|
||||
(ev-date 2026 7 5)))
|
||||
(list
|
||||
(list 2026 6 15)
|
||||
(list 2026 6 17)
|
||||
(list 2026 6 22)
|
||||
(list 2026 6 24)
|
||||
(list 2026 6 29)
|
||||
(list 2026 7 1))))
|
||||
(let
|
||||
((wf (ev-event (quote wf) (ev-dt 2026 6 3 18 0) 90 {:freq :weekly :count 4 :byday (list 0 2 4)} 1)))
|
||||
(ev-cal-check!
|
||||
"first week skips byday earlier than dtstart"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
wf
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 7 1)))
|
||||
(list
|
||||
(list 2026 6 3)
|
||||
(list 2026 6 5)
|
||||
(list 2026 6 8)
|
||||
(list 2026 6 10))))
|
||||
(let
|
||||
((md (ev-event (quote md) (ev-dt 2026 1 15 9 0) 60 {:bymonthday (list 15) :freq :monthly} 1)))
|
||||
(do
|
||||
(ev-cal-check!
|
||||
"monthly bymonthday 15th"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
md
|
||||
(ev-date 2026 1 1)
|
||||
(ev-date 2026 4 1)))
|
||||
(list
|
||||
(list 2026 1 15)
|
||||
(list 2026 2 15)
|
||||
(list 2026 3 15)))
|
||||
(ev-cal-check!
|
||||
"monthly preserves time of day"
|
||||
(ev-dt-tod
|
||||
(get
|
||||
(first
|
||||
(ev-expand
|
||||
md
|
||||
(ev-date 2026 1 1)
|
||||
(ev-date 2026 4 1)))
|
||||
:start))
|
||||
540)))
|
||||
(let
|
||||
((mm (ev-event (quote mm) (ev-dt 2026 1 1 9 0) 60 {:bymonthday (list 1 15) :freq :monthly :count 4} 1)))
|
||||
(ev-cal-check!
|
||||
"monthly multiple bymonthday sorted within month"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
mm
|
||||
(ev-date 2026 1 1)
|
||||
(ev-date 2026 12 1)))
|
||||
(list
|
||||
(list 2026 1 1)
|
||||
(list 2026 1 15)
|
||||
(list 2026 2 1)
|
||||
(list 2026 2 15))))
|
||||
(let
|
||||
((ml (ev-event (quote ml) (ev-dt 2026 1 31 9 0) 60 {:bymonthday (list -1) :freq :monthly} 1)))
|
||||
(ev-cal-check!
|
||||
"monthly bymonthday -1 is last day"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
ml
|
||||
(ev-date 2026 1 1)
|
||||
(ev-date 2026 4 1)))
|
||||
(list
|
||||
(list 2026 1 31)
|
||||
(list 2026 2 28)
|
||||
(list 2026 3 31))))
|
||||
(let
|
||||
((mn (ev-event (quote mn) (ev-dt 2026 1 1 9 0) 60 {:freq :monthly :byday (list {:ord 2 :wd 1})} 1)))
|
||||
(ev-cal-check!
|
||||
"monthly 2nd tuesday"
|
||||
(ev-cal-shape
|
||||
(ev-expand
|
||||
mn
|
||||
(ev-date 2026 1 1)
|
||||
(ev-date 2026 4 1)))
|
||||
(list
|
||||
(list (list 2026 1 13) 1)
|
||||
(list (list 2026 2 10) 1)
|
||||
(list (list 2026 3 10) 1))))
|
||||
(let
|
||||
((mz (ev-event (quote mz) (ev-dt 2026 1 1 9 0) 60 {:freq :monthly :byday (list {:ord -1 :wd 4})} 1)))
|
||||
(ev-cal-check!
|
||||
"monthly last friday"
|
||||
(ev-cal-shape
|
||||
(ev-expand
|
||||
mz
|
||||
(ev-date 2026 1 1)
|
||||
(ev-date 2026 4 1)))
|
||||
(list
|
||||
(list (list 2026 1 30) 4)
|
||||
(list (list 2026 2 27) 4)
|
||||
(list (list 2026 3 27) 4))))
|
||||
(let
|
||||
((m31 (ev-event (quote m31) (ev-dt 2026 1 31 9 0) 60 {:freq :monthly :count 4} 1)))
|
||||
(ev-cal-check!
|
||||
"monthly default day-of-month skips short months"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
m31
|
||||
(ev-date 2026 1 1)
|
||||
(ev-date 2026 12 1)))
|
||||
(list
|
||||
(list 2026 1 31)
|
||||
(list 2026 3 31)
|
||||
(list 2026 5 31)
|
||||
(list 2026 7 31))))
|
||||
(let
|
||||
((mi (ev-event (quote mi) (ev-dt 2026 1 10 9 0) 60 {:interval 3 :freq :monthly :count 3} 1)))
|
||||
(ev-cal-check!
|
||||
"monthly interval 3 steps by quarter"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
mi
|
||||
(ev-date 2026 1 1)
|
||||
(ev-date 2027 1 1)))
|
||||
(list
|
||||
(list 2026 1 10)
|
||||
(list 2026 4 10)
|
||||
(list 2026 7 10))))
|
||||
(let
|
||||
((mc (ev-event (quote mc) (ev-dt 2026 1 5 9 0) 60 {:freq :monthly :count 12} 1)))
|
||||
(ev-cal-check!
|
||||
"monthly count window-independent (clip middle)"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
mc
|
||||
(ev-date 2026 4 1)
|
||||
(ev-date 2026 6 30)))
|
||||
(list
|
||||
(list 2026 4 5)
|
||||
(list 2026 5 5)
|
||||
(list 2026 6 5))))
|
||||
(let
|
||||
((a (ev-event (quote a) (ev-dt 2026 6 2 10 0) 30 {:freq :daily :count 2} 1))
|
||||
(b
|
||||
(ev-event
|
||||
(quote b)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
30
|
||||
{:freq :daily :count 2}
|
||||
1)))
|
||||
(ev-cal-check!
|
||||
"expand-all sorts merged occurrences by start"
|
||||
(map
|
||||
(fn (o) (list (get o :id) (ev-dt->civil (get o :start))))
|
||||
(ev-expand-all
|
||||
(list a b)
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 7 1)))
|
||||
(list
|
||||
(list (quote b) (list 2026 6 1))
|
||||
(list (quote b) (list 2026 6 2))
|
||||
(list (quote a) (list 2026 6 2))
|
||||
(list (quote a) (list 2026 6 3))))))))
|
||||
|
||||
;; ---- EXDATE / RDATE exceptions ----
|
||||
(define
|
||||
ev-cal-ex-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
;; EXDATE removes a matching occurrence from the recurrence
|
||||
(let
|
||||
((ex
|
||||
(ev-event-full
|
||||
(quote standup)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
30
|
||||
{:freq :daily :count 5}
|
||||
1
|
||||
(list (ev-dt 2026 6 3 9 0))
|
||||
(list))))
|
||||
(ev-cal-check!
|
||||
"EXDATE excludes the matching occurrence"
|
||||
(ev-cal-starts (ev-expand ex (ev-date 2026 6 1) (ev-date 2026 7 1)))
|
||||
(list (list 2026 6 1) (list 2026 6 2) (list 2026 6 4) (list 2026 6 5))))
|
||||
;; EXDATE that matches nothing is a no-op
|
||||
(let
|
||||
((ex2
|
||||
(ev-event-full
|
||||
(quote s)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
30
|
||||
{:freq :daily :count 3}
|
||||
1
|
||||
(list (ev-dt 2026 6 9 9 0))
|
||||
(list))))
|
||||
(ev-cal-check!
|
||||
"EXDATE not matching any occurrence is a no-op"
|
||||
(len (ev-expand ex2 (ev-date 2026 6 1) (ev-date 2026 7 1)))
|
||||
3))
|
||||
;; RDATE adds an explicit occurrence (within the window)
|
||||
(let
|
||||
((rd
|
||||
(ev-event-full
|
||||
(quote s)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
30
|
||||
{:freq :daily :count 3}
|
||||
1
|
||||
(list)
|
||||
(list (ev-dt 2026 6 10 9 0)))))
|
||||
(do
|
||||
(ev-cal-check!
|
||||
"RDATE adds an explicit occurrence, sorted in"
|
||||
(ev-cal-starts (ev-expand rd (ev-date 2026 6 1) (ev-date 2026 7 1)))
|
||||
(list (list 2026 6 1) (list 2026 6 2) (list 2026 6 3) (list 2026 6 10)))
|
||||
(ev-cal-check!
|
||||
"RDATE outside the window is dropped"
|
||||
(len (ev-expand rd (ev-date 2026 6 1) (ev-date 2026 6 5)))
|
||||
3)))
|
||||
;; RDATE coinciding with an rrule occurrence is de-duplicated
|
||||
(let
|
||||
((rdup
|
||||
(ev-event-full
|
||||
(quote s)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
30
|
||||
{:freq :daily :count 3}
|
||||
1
|
||||
(list)
|
||||
(list (ev-dt 2026 6 2 9 0)))))
|
||||
(ev-cal-check!
|
||||
"RDATE duplicating an occurrence does not double it"
|
||||
(len (ev-expand rdup (ev-date 2026 6 1) (ev-date 2026 7 1)))
|
||||
3))
|
||||
;; EXDATE wins over RDATE for the same datetime
|
||||
(let
|
||||
((both
|
||||
(ev-event-full
|
||||
(quote s)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
30
|
||||
{:freq :daily :count 3}
|
||||
1
|
||||
(list (ev-dt 2026 6 2 9 0))
|
||||
(list (ev-dt 2026 6 2 9 0)))))
|
||||
(ev-cal-check!
|
||||
"EXDATE wins over RDATE and the rrule for the same date"
|
||||
(ev-cal-starts (ev-expand both (ev-date 2026 6 1) (ev-date 2026 7 1)))
|
||||
(list (list 2026 6 1) (list 2026 6 3))))
|
||||
;; RDATE-only event (no rrule)
|
||||
(let
|
||||
((ronly
|
||||
(ev-event-full
|
||||
(quote s)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
30
|
||||
nil
|
||||
1
|
||||
(list)
|
||||
(list (ev-dt 2026 6 5 9 0) (ev-dt 2026 6 3 9 0)))))
|
||||
(ev-cal-check!
|
||||
"RDATE-only event yields dtstart plus the extra dates, sorted"
|
||||
(ev-cal-starts (ev-expand ronly (ev-date 2026 6 1) (ev-date 2026 7 1)))
|
||||
(list (list 2026 6 1) (list 2026 6 3) (list 2026 6 5))))
|
||||
;; plain ev-event (no exception keys) is unaffected
|
||||
(let
|
||||
((plain (ev-event (quote p) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 3} 1)))
|
||||
(ev-cal-check!
|
||||
"plain event without exceptions expands unchanged"
|
||||
(len (ev-expand plain (ev-date 2026 6 1) (ev-date 2026 7 1)))
|
||||
3)))))
|
||||
|
||||
;; ---- per-occurrence overrides (reschedule one instance) ----
|
||||
(define
|
||||
ev-cal-ov-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((base (ev-event (quote standup) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 4} 1)))
|
||||
(do
|
||||
;; reschedule one instance to a new time + duration
|
||||
(let
|
||||
((moved (ev-with-override base (ev-dt 2026 6 2 9 0) (ev-dt 2026 6 2 14 0) 45)))
|
||||
(let
|
||||
((occs (ev-expand moved (ev-date 2026 6 1) (ev-date 2026 6 5))))
|
||||
(do
|
||||
(ev-cal-check!
|
||||
"override moves only the targeted instance"
|
||||
(map (fn (o) (ev-dt-tod (get o :start))) occs)
|
||||
(list 540 840 540 540))
|
||||
(ev-cal-check!
|
||||
"override applies the new duration"
|
||||
(map (fn (o) (- (get o :end) (get o :start))) occs)
|
||||
(list 30 45 30 30))
|
||||
(ev-cal-check!
|
||||
"override keeps the series length"
|
||||
(len occs)
|
||||
4))))
|
||||
;; an instance moved out of the window vacates its slot
|
||||
(let
|
||||
((movedout (ev-with-override base (ev-dt 2026 6 2 9 0) (ev-dt 2026 6 20 9 0) 30)))
|
||||
(ev-cal-check!
|
||||
"instance moved out of window is dropped, slot vacated"
|
||||
(ev-cal-starts (ev-expand movedout (ev-date 2026 6 1) (ev-date 2026 6 5)))
|
||||
(list (list 2026 6 1) (list 2026 6 3) (list 2026 6 4))))
|
||||
;; override for a non-existent original start is a no-op
|
||||
(let
|
||||
((noop (ev-with-override base (ev-dt 2026 6 9 9 0) (ev-dt 2026 6 9 14 0) 45)))
|
||||
(ev-cal-check!
|
||||
"override for a non-occurring start is a no-op"
|
||||
(len (ev-expand noop (ev-date 2026 6 1) (ev-date 2026 6 5)))
|
||||
4))
|
||||
;; overrides re-sort the agenda when an instance moves earlier
|
||||
(let
|
||||
((early (ev-with-override base (ev-dt 2026 6 3 9 0) (ev-dt 2026 6 1 7 0) 30)))
|
||||
(ev-cal-check!
|
||||
"an instance moved earlier re-sorts into place"
|
||||
(map (fn (o) (ev-dt-tod (get o :start))) (ev-expand early (ev-date 2026 6 1) (ev-date 2026 6 5)))
|
||||
(list 420 540 540 540)))))))
|
||||
|
||||
(define
|
||||
ev-calendar-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! ev-cal-pass 0)
|
||||
(set! ev-cal-fail 0)
|
||||
(set! ev-cal-failures (list))
|
||||
(ev-cal-run-all!)
|
||||
(ev-cal-ex-run-all!)
|
||||
(ev-cal-ov-run-all!)
|
||||
{:failures ev-cal-failures :total (+ ev-cal-pass ev-cal-fail) :passed ev-cal-pass :failed ev-cal-fail})))
|
||||
289
lib/events/tests/federation.sx
Normal file
289
lib/events/tests/federation.sx
Normal file
@@ -0,0 +1,289 @@
|
||||
;; lib/events/tests/federation.sx — trust-gated cross-instance agenda merge.
|
||||
|
||||
(define ev-fd-pass 0)
|
||||
(define ev-fd-fail 0)
|
||||
(define ev-fd-failures (list))
|
||||
|
||||
(define
|
||||
ev-fd-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! ev-fd-pass (+ ev-fd-pass 1))
|
||||
(do
|
||||
(set! ev-fd-fail (+ ev-fd-fail 1))
|
||||
(append!
|
||||
ev-fd-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
;; Local schedule + two peers. Distinct start times make ordering legible.
|
||||
(define
|
||||
ev-fd-local
|
||||
(fn
|
||||
()
|
||||
(ev/schedule
|
||||
(ev/empty)
|
||||
(quote yoga)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
60
|
||||
nil
|
||||
20)))
|
||||
|
||||
(define
|
||||
ev-fd-berlin
|
||||
(fn
|
||||
()
|
||||
(ev/peer
|
||||
(quote berlin)
|
||||
(ev/schedule
|
||||
(ev/empty)
|
||||
(quote meetup)
|
||||
(ev-dt 2026 6 1 12 0)
|
||||
90
|
||||
nil
|
||||
100))))
|
||||
|
||||
(define
|
||||
ev-fd-paris
|
||||
(fn
|
||||
()
|
||||
(ev/peer
|
||||
(quote paris)
|
||||
(ev/schedule
|
||||
(ev/empty)
|
||||
(quote salon)
|
||||
(ev-dt 2026 6 1 15 0)
|
||||
60
|
||||
nil
|
||||
30))))
|
||||
|
||||
(define
|
||||
ev-fd-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((local (ev-fd-local))
|
||||
(peers (list (ev-fd-berlin) (ev-fd-paris)))
|
||||
(ws (ev-date 2026 6 1))
|
||||
(we (ev-date 2026 6 2)))
|
||||
(do
|
||||
(ev-fd-check!
|
||||
"trusts a peer in the trust set"
|
||||
(ev/trusts? (list (quote berlin)) (quote berlin))
|
||||
true)
|
||||
(ev-fd-check!
|
||||
"does not trust a peer outside the set"
|
||||
(ev/trusts? (list (quote berlin)) (quote paris))
|
||||
false)
|
||||
(ev-fd-check!
|
||||
"trusted-peers filters to the trust set"
|
||||
(map ev/peer-id (ev/trusted-peers peers (list (quote berlin))))
|
||||
(list (quote berlin)))
|
||||
(let
|
||||
((fed (ev/federated-agenda local peers (list (quote berlin)) ws we)))
|
||||
(do
|
||||
(ev-fd-check!
|
||||
"merge includes local + trusted peer only"
|
||||
(map (fn (o) (list (get o :origin) (get o :id))) fed)
|
||||
(list
|
||||
(list :local (quote yoga))
|
||||
(list (quote berlin) (quote meetup))))
|
||||
(ev-fd-check!
|
||||
"merge is sorted by start"
|
||||
(map (fn (o) (get o :start)) fed)
|
||||
(list
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
(ev-dt 2026 6 1 12 0)))
|
||||
(ev-fd-check!
|
||||
"untrusted peer (paris) contributes nothing"
|
||||
(len (ev/from-origin fed (quote paris)))
|
||||
0)
|
||||
(ev-fd-check!
|
||||
"local occurrences tagged :local"
|
||||
(map (fn (o) (get o :id)) (ev/from-origin fed :local))
|
||||
(list (quote yoga)))
|
||||
(ev-fd-check!
|
||||
"peer occurrences tagged with the peer id"
|
||||
(map
|
||||
(fn (o) (get o :id))
|
||||
(ev/from-origin fed (quote berlin)))
|
||||
(list (quote meetup)))))
|
||||
(let
|
||||
((fed2 (ev/federated-agenda local peers (list (quote berlin) (quote paris)) ws we)))
|
||||
(ev-fd-check!
|
||||
"trusting both peers merges all three, sorted"
|
||||
(map (fn (o) (list (get o :origin) (get o :id))) fed2)
|
||||
(list
|
||||
(list :local (quote yoga))
|
||||
(list (quote berlin) (quote meetup))
|
||||
(list (quote paris) (quote salon)))))
|
||||
(let
|
||||
((fed3 (ev/federated-agenda local peers (list) ws we)))
|
||||
(do
|
||||
(ev-fd-check!
|
||||
"empty trust yields only local occurrences"
|
||||
(map (fn (o) (get o :origin)) fed3)
|
||||
(list :local))
|
||||
(ev-fd-check!
|
||||
"empty trust still includes local"
|
||||
(len fed3)
|
||||
1)))
|
||||
(let
|
||||
((rpeer (ev/peer (quote tokyo) (ev/schedule (ev/empty) (quote standup) (ev-dt 2026 6 1 8 0) 15 {:freq :daily :count 3} 5))))
|
||||
(let
|
||||
((pa (ev/peer-agenda rpeer ws (ev-date 2026 6 4))))
|
||||
(do
|
||||
(ev-fd-check!
|
||||
"peer recurrence expands in the window"
|
||||
(len pa)
|
||||
3)
|
||||
(ev-fd-check!
|
||||
"every peer occurrence is tagged with the peer id"
|
||||
(map (fn (o) (get o :origin)) pa)
|
||||
(list (quote tokyo) (quote tokyo) (quote tokyo))))))))))
|
||||
|
||||
;; ---- federated free/busy ----
|
||||
(define
|
||||
ev-fd-fb-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((local-db
|
||||
(ev-avail-db
|
||||
(list (ev-occ (quote yoga) (ev-dt 2026 6 1 9 0) 60))
|
||||
(list (list (quote nia) (str (quote yoga) "@" (ev-dt 2026 6 1 9 0))))))
|
||||
(berlin
|
||||
(ev/peer-with-busy
|
||||
(quote berlin)
|
||||
(ev/empty)
|
||||
(list
|
||||
(list (quote nia)
|
||||
(list (list (ev-dt 2026 6 1 14 0) (ev-dt 2026 6 1 15 0)))))))
|
||||
(paris
|
||||
(ev/peer-with-busy
|
||||
(quote paris)
|
||||
(ev/empty)
|
||||
(list
|
||||
(list (quote nia)
|
||||
(list (list (ev-dt 2026 6 1 11 0) (ev-dt 2026 6 1 12 0))))))))
|
||||
(let
|
||||
((peers (list berlin paris)))
|
||||
(do
|
||||
;; peer-busy reads a peer's published intervals
|
||||
(ev-fd-check!
|
||||
"peer-busy returns published intervals for an actor"
|
||||
(ev/peer-busy berlin (quote nia))
|
||||
(list (list (ev-dt 2026 6 1 14 0) (ev-dt 2026 6 1 15 0))))
|
||||
(ev-fd-check!
|
||||
"peer-busy empty for an actor with nothing published"
|
||||
(ev/peer-busy berlin (quote zed))
|
||||
(list))
|
||||
;; federated-busy unions local + trusted-peer busy, sorted
|
||||
(ev-fd-check!
|
||||
"federated-busy unions local + trusted peer, sorted"
|
||||
(ev/federated-busy local-db (list berlin) (list (quote berlin)) (quote nia))
|
||||
(list
|
||||
(list (ev-dt 2026 6 1 9 0) (ev-dt 2026 6 1 10 0))
|
||||
(list (ev-dt 2026 6 1 14 0) (ev-dt 2026 6 1 15 0))))
|
||||
(ev-fd-check!
|
||||
"untrusted peer busy is excluded from federated-busy"
|
||||
(ev/federated-busy local-db peers (list (quote berlin)) (quote nia))
|
||||
(list
|
||||
(list (ev-dt 2026 6 1 9 0) (ev-dt 2026 6 1 10 0))
|
||||
(list (ev-dt 2026 6 1 14 0) (ev-dt 2026 6 1 15 0))))
|
||||
;; federated-free? considers both local and trusted-peer commitments
|
||||
(ev-fd-check!
|
||||
"free locally and on peers in an open window"
|
||||
(ev/federated-free? local-db peers (list (quote berlin)) (quote nia) (ev-dt 2026 6 1 16 0) (ev-dt 2026 6 1 17 0))
|
||||
true)
|
||||
(ev-fd-check!
|
||||
"not free during a LOCAL booking"
|
||||
(ev/federated-free? local-db peers (list (quote berlin)) (quote nia) (ev-dt 2026 6 1 9 30) (ev-dt 2026 6 1 9 45))
|
||||
false)
|
||||
(ev-fd-check!
|
||||
"not free during a TRUSTED PEER busy interval"
|
||||
(ev/federated-free? local-db peers (list (quote berlin)) (quote nia) (ev-dt 2026 6 1 14 30) (ev-dt 2026 6 1 14 45))
|
||||
false)
|
||||
(ev-fd-check!
|
||||
"free during an UNTRUSTED peer's busy interval (paris not trusted)"
|
||||
(ev/federated-free? local-db peers (list (quote berlin)) (quote nia) (ev-dt 2026 6 1 11 15) (ev-dt 2026 6 1 11 45))
|
||||
true)
|
||||
(ev-fd-check!
|
||||
"not free once paris is trusted too"
|
||||
(ev/federated-free? local-db peers (list (quote berlin) (quote paris)) (quote nia) (ev-dt 2026 6 1 11 15) (ev-dt 2026 6 1 11 45))
|
||||
false)
|
||||
(ev-fd-check!
|
||||
"federated-free? half-open at a busy edge"
|
||||
(ev/federated-free? local-db peers (list (quote berlin)) (quote nia) (ev-dt 2026 6 1 15 0) (ev-dt 2026 6 1 16 0))
|
||||
true))))))
|
||||
|
||||
;; ---- injected transport (fed-sx) ----
|
||||
(define
|
||||
ev-fd-tx-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((local (ev/schedule (ev/empty) (quote yoga) (ev-dt 2026 6 1 9 0) 60 nil 20))
|
||||
(berlin (ev/peer (quote berlin) (ev/schedule (ev/empty) (quote meetup) (ev-dt 2026 6 1 12 0) 90 nil 100)))
|
||||
(ws (ev-date 2026 6 1))
|
||||
(we (ev-date 2026 6 2)))
|
||||
(let
|
||||
((fetch (ev/peer-fetch (list berlin))))
|
||||
(do
|
||||
;; in-process adapter merges through the transport interface
|
||||
(ev-fd-check!
|
||||
"federated-agenda-via merges local + fetched peer"
|
||||
(map (fn (o) (list (get o :origin) (get o :id)))
|
||||
(ev/federated-agenda-via local (list (quote berlin)) ws we fetch))
|
||||
(list (list :local (quote yoga)) (list (quote berlin) (quote meetup))))
|
||||
;; an unreachable / unknown peer degrades gracefully
|
||||
(ev-fd-check!
|
||||
"an unreachable peer is skipped, agenda still served"
|
||||
(map (fn (o) (get o :origin))
|
||||
(ev/federated-agenda-via local (list (quote berlin) (quote ghost)) ws we fetch))
|
||||
(list :local (quote berlin)))
|
||||
;; reachability report
|
||||
(ev-fd-check!
|
||||
"federation-status reports per-peer reachability"
|
||||
(ev/federation-status (list (quote berlin) (quote ghost)) ws we fetch)
|
||||
(list (list (quote berlin) :ok) (list (quote ghost) :error)))
|
||||
;; an explicit remote transport (returns occurrences directly)
|
||||
(let
|
||||
((remote-fetch
|
||||
(fn
|
||||
(pid rws rwe)
|
||||
(if (= pid (quote tokyo))
|
||||
{:status :ok
|
||||
:occurrences (list (ev-occ (quote standup) (ev-dt 2026 6 1 8 0) 15))}
|
||||
{:status :error :reason :unreachable}))))
|
||||
(do
|
||||
(ev-fd-check!
|
||||
"a remote transport's occurrences merge with origin tags"
|
||||
(map (fn (o) (list (get o :origin) (get o :id)))
|
||||
(ev/federated-agenda-via local (list (quote tokyo)) ws we remote-fetch))
|
||||
(list (list (quote tokyo) (quote standup)) (list :local (quote yoga))))
|
||||
(ev-fd-check!
|
||||
"remote transport error degrades to local only"
|
||||
(map (fn (o) (get o :origin))
|
||||
(ev/federated-agenda-via local (list (quote osaka)) ws we remote-fetch))
|
||||
(list :local))))
|
||||
;; no trusted peers -> only local
|
||||
(ev-fd-check!
|
||||
"no trusted peer ids yields only local"
|
||||
(map (fn (o) (get o :origin))
|
||||
(ev/federated-agenda-via local (list) ws we fetch))
|
||||
(list :local)))))))
|
||||
|
||||
(define
|
||||
ev-federation-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! ev-fd-pass 0)
|
||||
(set! ev-fd-fail 0)
|
||||
(set! ev-fd-failures (list))
|
||||
(ev-fd-run-all!)
|
||||
(ev-fd-fb-run-all!)
|
||||
(ev-fd-tx-run-all!)
|
||||
{:failures ev-fd-failures :total (+ ev-fd-pass ev-fd-fail) :passed ev-fd-pass :failed ev-fd-fail})))
|
||||
77
lib/events/tests/notify.sx
Normal file
77
lib/events/tests/notify.sx
Normal file
@@ -0,0 +1,77 @@
|
||||
;; lib/events/tests/notify.sx — durable notification delivery flows.
|
||||
|
||||
(define ev-nt-pass 0)
|
||||
(define ev-nt-fail 0)
|
||||
(define ev-nt-failures (list))
|
||||
|
||||
(define
|
||||
ev-nt-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! ev-nt-pass (+ ev-nt-pass 1))
|
||||
(do
|
||||
(set! ev-nt-fail (+ ev-nt-fail 1))
|
||||
(append!
|
||||
ev-nt-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
;; Each case runs a Scheme flow program (notify flows preloaded) and asserts on
|
||||
;; the SX-native result. Scheme symbols come back as strings.
|
||||
(define
|
||||
ev-nt-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(ev-nt-check!
|
||||
"reminder delivers on the first attempt"
|
||||
(ev/notify-run
|
||||
"(define s (flow/start (ev-deliver-reminder 3) (list (quote m1) (quote alice) (quote hello))))\n (flow-run-host (lambda (k p) (list (quote ok) (quote sent))) 5)\n (list (flow/status (car (cdr s))) (flow/result (car (cdr s))))")
|
||||
(list "done" (list "delivered" "m1" 1)))
|
||||
(ev-nt-check!
|
||||
"reminder retries a transient failure then delivers"
|
||||
(ev/notify-run
|
||||
"(define hits 0)\n (define s (flow/start (ev-deliver-reminder 3) (list (quote m1) (quote bob) (quote hi))))\n (flow-run-host (lambda (k p) (begin (set! hits (+ hits 1)) (if (< hits 2) (list (quote retry) (quote down)) (list (quote ok) (quote sent))))) 10)\n (list (flow/result (car (cdr s))) hits)")
|
||||
(list (list "delivered" "m1" 2) 2))
|
||||
(ev-nt-check!
|
||||
"reminder gives up after maxn attempts"
|
||||
(ev/notify-run
|
||||
"(define s (flow/start (ev-deliver-reminder 2) (list (quote m1) (quote x) (quote y))))\n (flow-run-host (lambda (k p) (list (quote retry) (quote down))) 10)\n (flow/result (car (cdr s)))")
|
||||
(list "failed" "m1" "down"))
|
||||
(ev-nt-check!
|
||||
"redelivery of the same id sends only once (at-least-once, idempotent)"
|
||||
(ev/notify-run
|
||||
"(define sent (list)) (define deliveries 0)\n (define (xport k p)\n (let ((id (ev-msg-id p)))\n (if (ev-mem id sent)\n (list (quote ok) (quote duplicate))\n (begin (set! sent (cons id sent)) (set! deliveries (+ deliveries 1)) (list (quote ok) (quote sent))))))\n (define s1 (flow/start (ev-deliver-reminder 3) (list (quote m1) (quote a) (quote hi))))\n (flow-run-host xport 5)\n (define s2 (flow/start (ev-deliver-reminder 3) (list (quote m1) (quote a) (quote hi))))\n (flow-run-host xport 5)\n (list deliveries (flow/result (car (cdr s2))))")
|
||||
(list 1 (list "delivered" "m1" 1)))
|
||||
(ev-nt-check!
|
||||
"digest delivers every message in the batch"
|
||||
(ev/notify-run
|
||||
"(define s (flow/start (ev-deliver-digest 3) (list (list (quote a) (quote u1) (quote hi)) (list (quote b) (quote u2) (quote yo)))))\n (flow-run-host (lambda (k p) (list (quote ok) (quote sent))) 10)\n (flow/result (car (cdr s)))")
|
||||
(list
|
||||
(list "delivered" "a" 1)
|
||||
(list "delivered" "b" 1)))
|
||||
(ev-nt-check!
|
||||
"digest reports per-message outcomes independently"
|
||||
(ev/notify-run
|
||||
"(define (xport k p)\n (let ((id (ev-msg-id p)))\n (if (equal? id (quote b)) (list (quote retry) (quote flaky)) (list (quote ok) (quote sent)))))\n (define s (flow/start (ev-deliver-digest 2) (list (list (quote a) (quote u1) (quote hi)) (list (quote b) (quote u2) (quote yo)) (list (quote c) (quote u3) (quote ya)))))\n (flow-run-host xport 12)\n (flow/result (car (cdr s)))")
|
||||
(list
|
||||
(list "delivered" "a" 1)
|
||||
(list "failed" "b" "flaky")
|
||||
(list "delivered" "c" 1)))
|
||||
(ev-nt-check!
|
||||
"delivery suspends until the transport responds"
|
||||
(ev/notify-run
|
||||
"(define s (flow/start (ev-deliver-reminder 3) (list (quote m1) (quote a) (quote hi))))\n (flow/status (car (cdr s)))")
|
||||
"suspended"))))
|
||||
|
||||
(define
|
||||
ev-notify-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! ev-nt-pass 0)
|
||||
(set! ev-nt-fail 0)
|
||||
(set! ev-nt-failures (list))
|
||||
(ev-nt-run-all!)
|
||||
{:failures ev-nt-failures :total (+ ev-nt-pass ev-nt-fail) :passed ev-nt-pass :failed ev-nt-fail})))
|
||||
276
lib/events/tests/reminders.sx
Normal file
276
lib/events/tests/reminders.sx
Normal file
@@ -0,0 +1,276 @@
|
||||
;; lib/events/tests/reminders.sx — reminder + digest derivation from the agenda.
|
||||
|
||||
(define ev-rm-pass 0)
|
||||
(define ev-rm-fail 0)
|
||||
(define ev-rm-failures (list))
|
||||
|
||||
(define
|
||||
ev-rm-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! ev-rm-pass (+ ev-rm-pass 1))
|
||||
(do
|
||||
(set! ev-rm-fail (+ ev-rm-fail 1))
|
||||
(append!
|
||||
ev-rm-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
;; A store with a weekly class (Mon+Wed 18:00, 60m, 4 occurrences) and a one-off
|
||||
;; talk; durable bookings on a persist backend.
|
||||
(define
|
||||
ev-rm-store
|
||||
(fn
|
||||
()
|
||||
(ev/schedule
|
||||
(ev/schedule
|
||||
(ev/empty)
|
||||
(quote yoga)
|
||||
(ev-dt 2026 6 1 18 0)
|
||||
60
|
||||
{:freq :weekly :count 4 :byday (list 0 2)}
|
||||
20)
|
||||
(quote talk)
|
||||
(ev-dt 2026 6 2 12 0)
|
||||
30
|
||||
nil
|
||||
50)))
|
||||
|
||||
(define
|
||||
ev-rm-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((store (ev-rm-store)) (b (persist/open)))
|
||||
(let
|
||||
((occs (ev/agenda store (ev-date 2026 6 1) (ev-date 2026 7 1))))
|
||||
(do
|
||||
(ev/book-occ! b store (quote nia) (first occs))
|
||||
(ev/book-occ! b store (quote ola) (first occs))
|
||||
(ev/book-occ!
|
||||
b
|
||||
store
|
||||
(quote ola)
|
||||
(ev-occ
|
||||
(quote talk)
|
||||
(ev-dt 2026 6 2 12 0)
|
||||
30))
|
||||
(do
|
||||
(let
|
||||
((rs (ev/occurrence-reminders b (first occs) 60)))
|
||||
(do
|
||||
(ev-rm-check!
|
||||
"one reminder per booked attendee"
|
||||
(len rs)
|
||||
2)
|
||||
(ev-rm-check!
|
||||
"reminder fires lead minutes before start"
|
||||
(get (first rs) :fire-at)
|
||||
(-
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
1
|
||||
18
|
||||
0)
|
||||
60))
|
||||
(ev-rm-check!
|
||||
"reminder idempotency key encodes occ/recipient/lead"
|
||||
(get (first rs) :id)
|
||||
(str
|
||||
(ev-occ-key (first occs))
|
||||
"/"
|
||||
(quote nia)
|
||||
"/"
|
||||
60))
|
||||
(ev-rm-check!
|
||||
"reminder names the event"
|
||||
(get (first rs) :event)
|
||||
(quote yoga))))
|
||||
(ev-rm-check!
|
||||
"unbooked occurrence has no reminders"
|
||||
(len
|
||||
(ev/occurrence-reminders b (ev-occ (quote yoga) (ev-dt 2026 6 3 18 0) 60) 60))
|
||||
0)
|
||||
(let
|
||||
((all (ev/agenda-reminders b store (ev-date 2026 6 1) (ev-date 2026 7 1) 60)))
|
||||
(do
|
||||
(ev-rm-check!
|
||||
"agenda reminders cover all bookings"
|
||||
(len all)
|
||||
3)
|
||||
(ev-rm-check!
|
||||
"agenda reminders sorted by fire-at"
|
||||
(map (fn (r) (get r :fire-at)) all)
|
||||
(list
|
||||
(-
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
1
|
||||
18
|
||||
0)
|
||||
60)
|
||||
(-
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
1
|
||||
18
|
||||
0)
|
||||
60)
|
||||
(-
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
2
|
||||
12
|
||||
0)
|
||||
60)))))
|
||||
(let
|
||||
((all (ev/agenda-reminders b store (ev-date 2026 6 1) (ev-date 2026 7 1) 60)))
|
||||
(do
|
||||
(ev-rm-check!
|
||||
"nothing due before the first fire-at"
|
||||
(len
|
||||
(ev/due-reminders
|
||||
all
|
||||
(-
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
1
|
||||
17
|
||||
0)
|
||||
1)))
|
||||
0)
|
||||
(ev-rm-check!
|
||||
"the two yoga reminders are due at 17:00"
|
||||
(len
|
||||
(ev/due-reminders
|
||||
all
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
1
|
||||
17
|
||||
0)))
|
||||
2)
|
||||
(ev-rm-check!
|
||||
"all reminders due once past the last fire-at"
|
||||
(len
|
||||
(ev/due-reminders
|
||||
all
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
2
|
||||
12
|
||||
0)))
|
||||
3)))
|
||||
(let
|
||||
((r (first (ev/occurrence-reminders b (first occs) 60))))
|
||||
(ev-rm-check!
|
||||
"reminder projects to (id recipient body)"
|
||||
(ev/reminder->msg r)
|
||||
(list
|
||||
(str
|
||||
(ev-occ-key (first occs))
|
||||
"/"
|
||||
(quote nia)
|
||||
"/"
|
||||
60)
|
||||
(quote nia)
|
||||
(list
|
||||
:reminder (quote yoga)
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
1
|
||||
18
|
||||
0)))))
|
||||
(let
|
||||
((dig (ev/agenda-digest b store (quote ola) (ev-date 2026 6 1) (ev-date 2026 7 1))))
|
||||
(do
|
||||
(ev-rm-check!
|
||||
"digest is addressed to the actor"
|
||||
(get dig :recipient)
|
||||
(quote ola))
|
||||
(ev-rm-check!
|
||||
"digest lists the actor's booked occurrences"
|
||||
(map (fn (it) (get it :event)) (get dig :items))
|
||||
(list (quote yoga) (quote talk)))))
|
||||
(let
|
||||
((empty-dig (ev/agenda-digest b store (quote nobody) (ev-date 2026 6 1) (ev-date 2026 7 1))))
|
||||
(ev-rm-check!
|
||||
"digest empty for an actor with no bookings"
|
||||
(get empty-dig :items)
|
||||
(list)))))))))
|
||||
|
||||
;; ---- reschedule notifications ----
|
||||
(define
|
||||
ev-rm-rs-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((b (persist/open))
|
||||
(ev (ev-event (quote yoga) (ev-dt 2026 6 1 9 0) 60 {:freq :daily :count 3} 20)))
|
||||
(let
|
||||
((occ2 (ev-occ (quote yoga) (ev-dt 2026 6 2 9 0) 60)))
|
||||
(do
|
||||
(ev/book-occ! b (ev/add-event (ev/empty) ev) (quote nia) occ2)
|
||||
(ev/book-occ! b (ev/add-event (ev/empty) ev) (quote ola) occ2)
|
||||
;; reschedule the Jun 2 occurrence to 14:00 / 90 min
|
||||
(let
|
||||
((moved (ev-with-override ev (ev-dt 2026 6 2 9 0) (ev-dt 2026 6 2 14 0) 90)))
|
||||
(let
|
||||
((ns (ev/reschedule-notifications b moved)))
|
||||
(do
|
||||
(ev-rm-check!
|
||||
"every booked attendee is notified of the reschedule"
|
||||
(map (fn (n) (get n :recipient)) ns)
|
||||
(list (quote nia) (quote ola)))
|
||||
(ev-rm-check!
|
||||
"reschedule carries old and new start"
|
||||
(list (get (first ns) :old-start) (get (first ns) :new-start))
|
||||
(list (ev-dt 2026 6 2 9 0) (ev-dt 2026 6 2 14 0)))
|
||||
(ev-rm-check!
|
||||
"reschedule carries the new duration"
|
||||
(get (first ns) :new-duration)
|
||||
90)
|
||||
(ev-rm-check!
|
||||
"reschedule idempotency key encodes original key + new start"
|
||||
(get (first ns) :id)
|
||||
(str (ev-occ-key occ2) "/reschedule/" (ev-dt 2026 6 2 14 0)))
|
||||
(ev-rm-check!
|
||||
"reschedule projects to notify wire shape"
|
||||
(ev/reschedule-notify->msg (first ns))
|
||||
(list
|
||||
(str (ev-occ-key occ2) "/reschedule/" (ev-dt 2026 6 2 14 0))
|
||||
(quote nia)
|
||||
(list :rescheduled (quote yoga) (ev-dt 2026 6 2 9 0) (ev-dt 2026 6 2 14 0)))))))
|
||||
;; an override on an occurrence nobody booked notifies no one
|
||||
(let
|
||||
((moved2 (ev-with-override ev (ev-dt 2026 6 3 9 0) (ev-dt 2026 6 3 10 0) 60)))
|
||||
(ev-rm-check!
|
||||
"rescheduling an unbooked occurrence notifies no one"
|
||||
(len (ev/reschedule-notifications b moved2))
|
||||
0))
|
||||
;; an event with no overrides yields no reschedule notifications
|
||||
(ev-rm-check!
|
||||
"event without overrides has no reschedule notifications"
|
||||
(len (ev/reschedule-notifications b ev))
|
||||
0))))))
|
||||
|
||||
(define
|
||||
ev-reminders-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! ev-rm-pass 0)
|
||||
(set! ev-rm-fail 0)
|
||||
(set! ev-rm-failures (list))
|
||||
(ev-rm-run-all!)
|
||||
(ev-rm-rs-run-all!)
|
||||
{:failures ev-rm-failures :total (+ ev-rm-pass ev-rm-fail) :passed ev-rm-pass :failed ev-rm-fail})))
|
||||
252
lib/events/tests/ticket.sx
Normal file
252
lib/events/tests/ticket.sx
Normal file
@@ -0,0 +1,252 @@
|
||||
;; lib/events/tests/ticket.sx — paid-ticket contract + settlement orchestration.
|
||||
|
||||
(define ev-tk-pass 0)
|
||||
(define ev-tk-fail 0)
|
||||
(define ev-tk-failures (list))
|
||||
|
||||
(define
|
||||
ev-tk-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! ev-tk-pass (+ ev-tk-pass 1))
|
||||
(do
|
||||
(set! ev-tk-fail (+ ev-tk-fail 1))
|
||||
(append!
|
||||
ev-tk-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
(define
|
||||
ev-tk-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(let
|
||||
((req (ev/checkout-request "occ1" (quote nia) 1500 "GBP" "ref-1")))
|
||||
(do
|
||||
(ev-tk-check!
|
||||
"checkout-request is tagged"
|
||||
(ev/checkout-request? req)
|
||||
true)
|
||||
(ev-tk-check!
|
||||
"payment-result is not a checkout-request"
|
||||
(ev/checkout-request? (ev/payment-paid "o" (quote a) "r"))
|
||||
false)
|
||||
(ev-tk-check!
|
||||
"request occ-key accessor"
|
||||
(ev/req-occ-key req)
|
||||
"occ1")
|
||||
(ev-tk-check!
|
||||
"request actor accessor"
|
||||
(ev/req-actor req)
|
||||
(quote nia))
|
||||
(ev-tk-check!
|
||||
"request amount accessor"
|
||||
(ev/req-amount req)
|
||||
1500)
|
||||
(ev-tk-check!
|
||||
"request currency accessor"
|
||||
(ev/req-currency req)
|
||||
"GBP")
|
||||
(ev-tk-check! "request ref accessor" (ev/req-ref req) "ref-1")))
|
||||
(let
|
||||
((res (ev/payment-paid "occ1" (quote nia) "ref-1")))
|
||||
(do
|
||||
(ev-tk-check!
|
||||
"payment-result is tagged"
|
||||
(ev/payment-result? res)
|
||||
true)
|
||||
(ev-tk-check! "result status accessor" (ev/result-status res) :paid)
|
||||
(ev-tk-check!
|
||||
"failed constructor carries status"
|
||||
(ev/result-status (ev/payment-failed "o" (quote a) "r"))
|
||||
:failed)
|
||||
(ev-tk-check!
|
||||
"expired constructor carries status"
|
||||
(ev/result-status (ev/payment-expired "o" (quote a) "r"))
|
||||
:expired)))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(let
|
||||
((r (ev/request-ticket! b "show" 1 (quote a) 2000 "GBP" "ref-a")))
|
||||
(do
|
||||
(ev-tk-check!
|
||||
"request-ticket awaiting-payment"
|
||||
(get r :status)
|
||||
:awaiting-payment)
|
||||
(ev-tk-check!
|
||||
"request-ticket returns a checkout-request"
|
||||
(ev/checkout-request? (get r :request))
|
||||
true)
|
||||
(ev-tk-check!
|
||||
"checkout-request carries the amount"
|
||||
(ev/req-amount (get r :request))
|
||||
2000)))
|
||||
(ev-tk-check!
|
||||
"held seat reserves capacity"
|
||||
(ev/seats-left b "show" 1)
|
||||
0)
|
||||
(ev-tk-check!
|
||||
"second buyer is full while payment pends"
|
||||
(get
|
||||
(ev/request-ticket!
|
||||
b
|
||||
"show"
|
||||
1
|
||||
(quote c)
|
||||
2000
|
||||
"GBP"
|
||||
"ref-c")
|
||||
:status)
|
||||
:full)
|
||||
(ev-tk-check!
|
||||
"held seat state pending"
|
||||
(ev/seat-state b "show" (quote a))
|
||||
:held)))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/request-ticket!
|
||||
b
|
||||
"gig"
|
||||
2
|
||||
(quote a)
|
||||
2000
|
||||
"GBP"
|
||||
"ref-a")
|
||||
(let
|
||||
((s (ev/settle-payment! b (ev/payment-paid "gig" (quote a) "ref-a"))))
|
||||
(ev-tk-check! "settle paid confirms" (get s :status) :confirmed))
|
||||
(ev-tk-check!
|
||||
"confirmed seat state"
|
||||
(ev/seat-state b "gig" (quote a))
|
||||
:confirmed)
|
||||
(ev-tk-check!
|
||||
"redelivered paid is still confirmed (idempotent)"
|
||||
(get
|
||||
(ev/settle-payment!
|
||||
b
|
||||
(ev/payment-paid "gig" (quote a) "ref-a"))
|
||||
:status)
|
||||
:confirmed)
|
||||
(ev-tk-check!
|
||||
"still exactly one seat taken"
|
||||
(ev-booking-count b "gig")
|
||||
1)))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/request-ticket!
|
||||
b
|
||||
"fail"
|
||||
1
|
||||
(quote a)
|
||||
2000
|
||||
"GBP"
|
||||
"ref-a")
|
||||
(ev-tk-check!
|
||||
"seat held before failure"
|
||||
(ev/seats-left b "fail" 1)
|
||||
0)
|
||||
(let
|
||||
((s (ev/settle-payment! b (ev/payment-failed "fail" (quote a) "ref-a"))))
|
||||
(ev-tk-check! "settle failed releases" (get s :status) :released))
|
||||
(ev-tk-check!
|
||||
"released seat frees capacity"
|
||||
(ev/seats-left b "fail" 1)
|
||||
1)
|
||||
(ev-tk-check!
|
||||
"redelivered failure is a noop"
|
||||
(get
|
||||
(ev/settle-payment!
|
||||
b
|
||||
(ev/payment-failed "fail" (quote a) "ref-a"))
|
||||
:status)
|
||||
:noop)
|
||||
(ev-tk-check!
|
||||
"freed seat available to next buyer"
|
||||
(get
|
||||
(ev/request-ticket!
|
||||
b
|
||||
"fail"
|
||||
1
|
||||
(quote c)
|
||||
2000
|
||||
"GBP"
|
||||
"ref-c")
|
||||
:status)
|
||||
:awaiting-payment)
|
||||
(ev/request-ticket!
|
||||
b
|
||||
"exp"
|
||||
1
|
||||
(quote a)
|
||||
2000
|
||||
"GBP"
|
||||
"ref-a")
|
||||
(ev-tk-check!
|
||||
"settle expired releases"
|
||||
(get
|
||||
(ev/settle-payment!
|
||||
b
|
||||
(ev/payment-expired "exp" (quote a) "ref-a"))
|
||||
:status)
|
||||
:released)))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/request-ticket!
|
||||
b
|
||||
"race"
|
||||
1
|
||||
(quote a)
|
||||
2000
|
||||
"GBP"
|
||||
"ref-a")
|
||||
(ev/settle-payment!
|
||||
b
|
||||
(ev/payment-expired "race" (quote a) "ref-a"))
|
||||
(ev-tk-check!
|
||||
"late paid for a vanished hold needs a refund"
|
||||
(get
|
||||
(ev/settle-payment!
|
||||
b
|
||||
(ev/payment-paid "race" (quote a) "ref-a"))
|
||||
:status)
|
||||
:paid-but-no-hold)
|
||||
(ev-tk-check!
|
||||
"no phantom seat created"
|
||||
(ev-booking-count b "race")
|
||||
0)))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(let
|
||||
((start (ev/request-ticket! b "e2e" 3 (quote nia) 2500 "GBP" "ref-nia")))
|
||||
(ev/settle-payment!
|
||||
b
|
||||
(ev/payment-paid
|
||||
(ev/req-occ-key (get start :request))
|
||||
(ev/req-actor (get start :request))
|
||||
(ev/req-ref (get start :request)))))
|
||||
(ev-tk-check!
|
||||
"e2e roster holds the buyer"
|
||||
(ev/roster b "e2e")
|
||||
(list (quote nia)))
|
||||
(ev-tk-check!
|
||||
"e2e seat confirmed"
|
||||
(ev/seat-state b "e2e" (quote nia))
|
||||
:confirmed))))))
|
||||
|
||||
(define
|
||||
ev-ticket-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! ev-tk-pass 0)
|
||||
(set! ev-tk-fail 0)
|
||||
(set! ev-tk-failures (list))
|
||||
(ev-tk-run-all!)
|
||||
{:failures ev-tk-failures :total (+ ev-tk-pass ev-tk-fail) :passed ev-tk-pass :failed ev-tk-fail})))
|
||||
173
lib/events/tests/timezone.sx
Normal file
173
lib/events/tests/timezone.sx
Normal file
@@ -0,0 +1,173 @@
|
||||
;; lib/events/tests/timezone.sx — timezones + DST.
|
||||
|
||||
(define ev-tz-pass 0)
|
||||
(define ev-tz-fail 0)
|
||||
(define ev-tz-failures (list))
|
||||
|
||||
(define
|
||||
ev-tz-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! ev-tz-pass (+ ev-tz-pass 1))
|
||||
(do
|
||||
(set! ev-tz-fail (+ ev-tz-fail 1))
|
||||
(append!
|
||||
ev-tz-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
;; Wall-clock (civil + minute-of-day) an occurrence's UTC start maps to in a tz.
|
||||
(define
|
||||
ev-tz-local-of
|
||||
(fn
|
||||
(tz utc-dt)
|
||||
(let
|
||||
((l (ev-tz-utc->local tz utc-dt)))
|
||||
(list (ev-dt->civil l) (ev-dt-tod l)))))
|
||||
|
||||
(define
|
||||
ev-tz-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(let
|
||||
((nyc (ev-tz-fixed "EST" -300)))
|
||||
(do
|
||||
(ev-tz-check!
|
||||
"fixed zone: utc -> local subtracts 5h"
|
||||
(ev-tz-utc->local
|
||||
nyc
|
||||
(ev-dt 2026 1 1 17 0))
|
||||
(ev-dt 2026 1 1 12 0))
|
||||
(ev-tz-check!
|
||||
"fixed zone: local -> utc adds 5h back"
|
||||
(ev-tz-local->utc
|
||||
nyc
|
||||
(ev-dt 2026 1 1 12 0))
|
||||
(ev-dt 2026 1 1 17 0))
|
||||
(ev-tz-check!
|
||||
"UTC zone is identity"
|
||||
(ev-tz-local->utc
|
||||
ev-tz-utc
|
||||
(ev-dt 2026 6 1 9 0))
|
||||
(ev-dt 2026 6 1 9 0))))
|
||||
(ev-tz-check!
|
||||
"London winter offset is 0 (GMT)"
|
||||
(ev-tz-offset
|
||||
ev-tz-london
|
||||
(ev-dt 2026 1 15 12 0))
|
||||
0)
|
||||
(ev-tz-check!
|
||||
"London summer offset is 60 (BST)"
|
||||
(ev-tz-offset
|
||||
ev-tz-london
|
||||
(ev-dt 2026 7 15 12 0))
|
||||
60)
|
||||
(ev-tz-check!
|
||||
"Paris winter offset is 60 (CET)"
|
||||
(ev-tz-offset
|
||||
ev-tz-paris
|
||||
(ev-dt 2026 1 15 12 0))
|
||||
60)
|
||||
(ev-tz-check!
|
||||
"Paris summer offset is 120 (CEST)"
|
||||
(ev-tz-offset
|
||||
ev-tz-paris
|
||||
(ev-dt 2026 7 15 12 0))
|
||||
120)
|
||||
(ev-tz-check!
|
||||
"DST starts last Sunday of March"
|
||||
(ev-dt->civil
|
||||
(ev-tz-transition
|
||||
2026
|
||||
(ev-tz-rule 3 -1 6 60)))
|
||||
(list 2026 3 29))
|
||||
(ev-tz-check!
|
||||
"DST ends last Sunday of October"
|
||||
(ev-dt->civil
|
||||
(ev-tz-transition
|
||||
2026
|
||||
(ev-tz-rule 10 -1 6 60)))
|
||||
(list 2026 10 25))
|
||||
(ev-tz-check!
|
||||
"09:00 London in winter is 09:00 UTC"
|
||||
(ev-tz-local->utc
|
||||
ev-tz-london
|
||||
(ev-dt 2026 1 15 9 0))
|
||||
(ev-dt 2026 1 15 9 0))
|
||||
(ev-tz-check!
|
||||
"09:00 London in summer is 08:00 UTC"
|
||||
(ev-tz-local->utc
|
||||
ev-tz-london
|
||||
(ev-dt 2026 7 15 9 0))
|
||||
(ev-dt 2026 7 15 8 0))
|
||||
(ev-tz-check!
|
||||
"round trip utc -> local -> utc"
|
||||
(ev-tz-local->utc
|
||||
ev-tz-london
|
||||
(ev-tz-utc->local
|
||||
ev-tz-london
|
||||
(ev-dt 2026 7 15 8 0)))
|
||||
(ev-dt 2026 7 15 8 0))
|
||||
(let
|
||||
((ev (ev-event-tz (quote standup) (ev-dt 2026 3 27 9 0) 60 {:freq :daily :count 5} 10 ev-tz-london)))
|
||||
(let
|
||||
((occs (ev-expand ev (ev-date 2026 3 1) (ev-date 2026 4 5))))
|
||||
(do
|
||||
(ev-tz-check!
|
||||
"daily occurrences shift in UTC across the DST boundary"
|
||||
(map (fn (o) (ev-dt-tod (get o :start))) occs)
|
||||
(list 540 540 480 480 480))
|
||||
(ev-tz-check!
|
||||
"but every occurrence stays 09:00 local wall-clock"
|
||||
(map
|
||||
(fn
|
||||
(o)
|
||||
(first
|
||||
(rest (ev-tz-local-of ev-tz-london (get o :start)))))
|
||||
occs)
|
||||
(list 540 540 540 540 540))
|
||||
(ev-tz-check!
|
||||
"occurrence dates are stable in local time"
|
||||
(map
|
||||
(fn
|
||||
(o)
|
||||
(ev-civ-d
|
||||
(first (ev-tz-local-of ev-tz-london (get o :start)))))
|
||||
occs)
|
||||
(list 27 28 29 30 31)))))
|
||||
(let
|
||||
((wk (ev-event-tz (quote class) (ev-dt 2026 3 23 18 0) 90 {:freq :weekly :byday (list 0)} 5 ev-tz-london)))
|
||||
(let
|
||||
((occs (ev-expand wk (ev-date 2026 3 1) (ev-date 2026 4 20))))
|
||||
(ev-tz-check!
|
||||
"weekly Monday 18:00 London stays 18:00 local each week"
|
||||
(map
|
||||
(fn
|
||||
(o)
|
||||
(first (rest (ev-tz-local-of ev-tz-london (get o :start)))))
|
||||
occs)
|
||||
(list 1080 1080 1080 1080))))
|
||||
(let
|
||||
((plain (ev-event (quote p) (ev-dt 2026 3 27 9 0) 60 {:freq :daily :count 3} 1)))
|
||||
(ev-tz-check!
|
||||
"plain event expands naively (no UTC shift)"
|
||||
(map
|
||||
(fn (o) (ev-dt-tod (get o :start)))
|
||||
(ev-expand
|
||||
plain
|
||||
(ev-date 2026 3 1)
|
||||
(ev-date 2026 4 5)))
|
||||
(list 540 540 540))))))
|
||||
|
||||
(define
|
||||
ev-timezone-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! ev-tz-pass 0)
|
||||
(set! ev-tz-fail 0)
|
||||
(set! ev-tz-failures (list))
|
||||
(ev-tz-run-all!)
|
||||
{:failures ev-tz-failures :total (+ ev-tz-pass ev-tz-fail) :passed ev-tz-pass :failed ev-tz-fail})))
|
||||
101
lib/events/ticket.sx
Normal file
101
lib/events/ticket.sx
Normal file
@@ -0,0 +1,101 @@
|
||||
;; lib/events/ticket.sx — paid-ticket contract between events and commerce.
|
||||
;;
|
||||
;; A paid booking spans two subsystems. events does NOT import commerce; instead
|
||||
;; this module defines the CONTRACT — the two messages on the wire — and the
|
||||
;; events-side orchestration over provisional holds (booking.sx). commerce
|
||||
;; imports these shapes; the dependency only points one way.
|
||||
;;
|
||||
;; checkout-request events -> commerce "take payment for this seat"
|
||||
;; {:kind :events.checkout :occ-key :actor :amount :currency :ref}
|
||||
;;
|
||||
;; payment-result commerce -> events "here's how payment went"
|
||||
;; {:kind :events.payment :occ-key :actor :ref :status}
|
||||
;; :status ∈ :paid | :failed | :expired
|
||||
;;
|
||||
;; Flow: ev/request-ticket! places a capacity-safe HOLD (reserving the seat so
|
||||
;; it can't be oversold while payment pends) and returns a checkout-request to
|
||||
;; hand to commerce. When commerce reports back, ev/settle-payment! confirms the
|
||||
;; hold on :paid or releases it otherwise. Settlement is idempotent — an
|
||||
;; at-least-once redelivery of the same result is safe. `ref` is the opaque
|
||||
;; correlation/idempotency id; occ-key + actor locate the hold, so settlement
|
||||
;; needs no side table.
|
||||
|
||||
;; ---- contract: checkout request (events -> commerce) ----
|
||||
|
||||
(define
|
||||
ev/checkout-request
|
||||
(fn (occ-key actor amount currency ref) {:actor actor :amount amount :kind :events.checkout :ref ref :currency currency :occ-key occ-key}))
|
||||
|
||||
(define
|
||||
ev/checkout-request?
|
||||
(fn (m) (and (dict? m) (= (get m :kind) :events.checkout))))
|
||||
|
||||
(define ev/req-occ-key (fn (r) (get r :occ-key)))
|
||||
(define ev/req-actor (fn (r) (get r :actor)))
|
||||
(define ev/req-amount (fn (r) (get r :amount)))
|
||||
(define ev/req-currency (fn (r) (get r :currency)))
|
||||
(define ev/req-ref (fn (r) (get r :ref)))
|
||||
|
||||
;; ---- contract: payment result (commerce -> events) ----
|
||||
|
||||
(define ev/payment-result (fn (occ-key actor ref status) {:actor actor :kind :events.payment :status status :ref ref :occ-key occ-key}))
|
||||
|
||||
(define
|
||||
ev/payment-result?
|
||||
(fn (m) (and (dict? m) (= (get m :kind) :events.payment))))
|
||||
|
||||
(define ev/result-occ-key (fn (r) (get r :occ-key)))
|
||||
(define ev/result-actor (fn (r) (get r :actor)))
|
||||
(define ev/result-ref (fn (r) (get r :ref)))
|
||||
(define ev/result-status (fn (r) (get r :status)))
|
||||
|
||||
(define
|
||||
ev/payment-paid
|
||||
(fn (occ-key actor ref) (ev/payment-result occ-key actor ref :paid)))
|
||||
(define
|
||||
ev/payment-failed
|
||||
(fn (occ-key actor ref) (ev/payment-result occ-key actor ref :failed)))
|
||||
(define
|
||||
ev/payment-expired
|
||||
(fn (occ-key actor ref) (ev/payment-result occ-key actor ref :expired)))
|
||||
|
||||
;; ---- orchestration ----
|
||||
|
||||
;; Begin a paid booking: place a capacity-safe hold and, if reserved, return a
|
||||
;; checkout-request for commerce. :full when no seat; :already when the actor
|
||||
;; already holds/booked this occurrence (no duplicate request).
|
||||
(define
|
||||
ev/request-ticket!
|
||||
(fn
|
||||
(b occ-key capacity actor amount currency ref)
|
||||
(let
|
||||
((h (ev/hold! b occ-key capacity actor)))
|
||||
(cond
|
||||
((= (get h :status) :held) {:seat (get h :seat) :request (ev/checkout-request occ-key actor amount currency ref) :status :awaiting-payment})
|
||||
((= (get h :status) :already) {:seat (get h :seat) :status :already})
|
||||
(else {:capacity capacity :status :full})))))
|
||||
|
||||
;; Settle a payment result from commerce. :paid confirms the hold; :failed /
|
||||
;; :expired release it. Idempotent: a redelivered :paid stays :confirmed, a
|
||||
;; redelivered release is a :noop. If a :paid arrives for a hold that is already
|
||||
;; gone (released/expired first), returns :paid-but-no-hold so the caller can
|
||||
;; trigger a refund.
|
||||
(define
|
||||
ev/settle-payment!
|
||||
(fn
|
||||
(b result)
|
||||
(let
|
||||
((occ-key (ev/result-occ-key result))
|
||||
(actor (ev/result-actor result))
|
||||
(ref (ev/result-ref result)))
|
||||
(if
|
||||
(= (ev/result-status result) :paid)
|
||||
(let
|
||||
((c (ev/confirm! b occ-key actor)))
|
||||
(cond
|
||||
((= (get c :status) :confirmed) {:actor actor :status :confirmed :ref ref})
|
||||
((= (get c :status) :already-confirmed) {:actor actor :status :confirmed :ref ref})
|
||||
(else {:actor actor :status :paid-but-no-hold :ref ref})))
|
||||
(let
|
||||
((r (ev/release! b occ-key actor)))
|
||||
(if (= (get r :status) :released) {:actor actor :status :released :ref ref} {:actor actor :status :noop :ref ref}))))))
|
||||
131
lib/events/timezone.sx
Normal file
131
lib/events/timezone.sx
Normal file
@@ -0,0 +1,131 @@
|
||||
;; lib/events/timezone.sx — timezones + DST for the calendar.
|
||||
;;
|
||||
;; Datetimes in calendar.sx are naive epoch-minutes (wall clock). A timezone
|
||||
;; maps between wall-clock LOCAL time and absolute UTC. An event is authored in
|
||||
;; local time + a tz; recurrence is expanded in local time (so a "09:00 weekly"
|
||||
;; meeting stays 09:00 across a DST change), then each occurrence is converted
|
||||
;; to UTC for storage/comparison.
|
||||
;;
|
||||
;; Offset convention: offset = local - utc (minutes). London summer (BST) = +60.
|
||||
;; UTC = local - offset; local = utc + offset.
|
||||
;;
|
||||
;; Two kinds of zone, no IANA database:
|
||||
;; :fixed — a constant offset.
|
||||
;; :dst — std/dst offsets + two transition rules. Transitions are given in
|
||||
;; UTC (EU zones all switch at 01:00 UTC), so the offset at any UTC
|
||||
;; instant is a direct range check; no recursion. Northern-hemisphere
|
||||
;; ordering (dst-start < dst-end within a year) is assumed.
|
||||
;;
|
||||
;; Requires calendar.sx (ev-dt, ev-days-from-civil, ev-civil-from-days,
|
||||
;; ev-civ-y, ev-floor-div, ev-resolve-nth-weekday).
|
||||
|
||||
;; A DST transition rule: the ord-th weekday `wd` (0=Mon..6=Sun) of `month`, at
|
||||
;; `time` minutes-of-day UTC. EU: last Sunday (ord -1, wd 6) at 01:00 UTC.
|
||||
(define ev-tz-rule (fn (month ord wd time) {:ord ord :wd wd :month month :time time}))
|
||||
|
||||
(define ev-tz-fixed (fn (name offset) {:name name :offset offset :kind :fixed}))
|
||||
|
||||
(define ev-tz-dst (fn (name std dst start-rule end-rule) {:name name :kind :dst :dst-end end-rule :dst-start start-rule :std-offset std :dst-offset dst}))
|
||||
|
||||
;; Standard (winter) offset — the initial guess when inverting local -> utc.
|
||||
(define
|
||||
ev-tz-std-offset
|
||||
(fn
|
||||
(tz)
|
||||
(if (= (get tz :kind) :fixed) (get tz :offset) (get tz :std-offset))))
|
||||
|
||||
;; The UTC instant (epoch-minutes) of a transition rule in a given year.
|
||||
(define
|
||||
ev-tz-transition
|
||||
(fn
|
||||
(year rule)
|
||||
(let
|
||||
((day (ev-resolve-nth-weekday year (get rule :month) (get rule :ord) (get rule :wd))))
|
||||
(+
|
||||
(* (ev-days-from-civil year (get rule :month) day) 1440)
|
||||
(get rule :time)))))
|
||||
|
||||
;; The offset (minutes) in effect at a UTC instant.
|
||||
(define
|
||||
ev-tz-offset
|
||||
(fn
|
||||
(tz utc-dt)
|
||||
(cond
|
||||
((= (get tz :kind) :fixed) (get tz :offset))
|
||||
((= (get tz :kind) :dst)
|
||||
(let
|
||||
((year (ev-civ-y (ev-civil-from-days (ev-floor-div utc-dt 1440)))))
|
||||
(let
|
||||
((start (ev-tz-transition year (get tz :dst-start)))
|
||||
(end (ev-tz-transition year (get tz :dst-end))))
|
||||
(if
|
||||
(and (>= utc-dt start) (< utc-dt end))
|
||||
(get tz :dst-offset)
|
||||
(get tz :std-offset)))))
|
||||
(else 0))))
|
||||
|
||||
;; UTC instant -> local wall-clock.
|
||||
(define
|
||||
ev-tz-utc->local
|
||||
(fn (tz utc-dt) (+ utc-dt (ev-tz-offset tz utc-dt))))
|
||||
|
||||
;; Local wall-clock -> UTC instant. The offset depends on the instant, so we
|
||||
;; guess with the standard offset and refine once (correct except within the
|
||||
;; one-hour DST gap/overlap, where it resolves to the pre-transition offset).
|
||||
(define
|
||||
ev-tz-local->utc
|
||||
(fn
|
||||
(tz local-dt)
|
||||
(let
|
||||
((utc1 (- local-dt (ev-tz-offset tz (- local-dt (ev-tz-std-offset tz))))))
|
||||
(- local-dt (ev-tz-offset tz utc1)))))
|
||||
|
||||
;; ---- predefined zones ----
|
||||
(define ev-tz-utc (ev-tz-fixed "UTC" 0))
|
||||
(define
|
||||
ev-tz-london
|
||||
(ev-tz-dst
|
||||
"Europe/London"
|
||||
0
|
||||
60
|
||||
(ev-tz-rule 3 -1 6 60)
|
||||
(ev-tz-rule 10 -1 6 60)))
|
||||
(define
|
||||
ev-tz-paris
|
||||
(ev-tz-dst
|
||||
"Europe/Paris"
|
||||
60
|
||||
120
|
||||
(ev-tz-rule 3 -1 6 60)
|
||||
(ev-tz-rule 10 -1 6 60)))
|
||||
|
||||
;; ---- tz-aware event expansion ----
|
||||
|
||||
;; An event authored in local time + a tz. dtstart-local / rrule / exceptions
|
||||
;; are all wall-clock in `tz`; expansion converts each occurrence to UTC.
|
||||
(define
|
||||
ev-event-tz
|
||||
(fn (id dtstart-local duration rrule capacity tz) {:id id :duration duration :dtstart dtstart-local :rrule rrule :capacity capacity :tz tz}))
|
||||
|
||||
;; Expand a tz-aware event over a UTC window. Local recurrence is expanded over
|
||||
;; a window widened by a day each side (to catch occurrences whose UTC lands in
|
||||
;; range), converted to UTC, then filtered to [win-start, win-end].
|
||||
(define
|
||||
ev-expand-tz
|
||||
(fn
|
||||
(event tz win-start win-end)
|
||||
(let
|
||||
((local-ws (- (ev-tz-utc->local tz win-start) 1440))
|
||||
(local-we (+ (ev-tz-utc->local tz win-end) 1440)))
|
||||
(let
|
||||
((local-occs (ev-expand-naive event local-ws local-we)))
|
||||
(let
|
||||
((utc-occs (map (fn (o) (let ((u (ev-tz-local->utc tz (get o :start))) (dur (- (get o :end) (get o :start)))) {:id (get o :id) :start u :end (+ u dur)})) local-occs)))
|
||||
(ev-sort-occs
|
||||
(filter
|
||||
(fn
|
||||
(o)
|
||||
(and
|
||||
(>= (get o :start) win-start)
|
||||
(<= (get o :start) win-end)))
|
||||
utc-occs)))))))
|
||||
36
lib/identity/api.sx
Normal file
36
lib/identity/api.sx
Normal file
File diff suppressed because one or more lines are too long
27
lib/identity/audit.sx
Normal file
27
lib/identity/audit.sx
Normal file
@@ -0,0 +1,27 @@
|
||||
;; identity/audit.sx — the grant audit ledger.
|
||||
;;
|
||||
;; Every transition that changes a grant — issue, refresh, revoke (and,
|
||||
;; wired from oauth, consent) — appends an immutable event to this
|
||||
;; append-only process. The ledger is queryable by subject, which is what
|
||||
;; `(identity/audit subject)` answers. This is the in-memory realisation
|
||||
;; of the event stream; a persist-backed stream is a later substrate
|
||||
;; concern (Erlang↔persist bridge), kept out of scope here per the loop's
|
||||
;; \"in-memory log until persist lands\" allowance — the queryable
|
||||
;; semantics are identical.
|
||||
;;
|
||||
;; Events are {Seq, Subject, Action}; Seq is a monotonic sequence number.
|
||||
;; Reads return chronological (oldest-first) order:
|
||||
;;
|
||||
;; record(A, Subject, Action) -> ok (one-way; FIFO-ordered)
|
||||
;; audit(A, Subject) -> [{Seq, Subject, Action}, ...]
|
||||
;; actions(A, Subject) -> [Action, ...]
|
||||
;; count(A, Subject) -> N
|
||||
;; all(A) -> [{Seq, Subject, Action}, ...]
|
||||
|
||||
(define
|
||||
identity-audit-source
|
||||
"-module(identity_audit).\n\n start() ->\n spawn(fun () -> loop([], 0) end).\n\n record(A, Subject, Action) ->\n A ! {event, Subject, Action},\n ok.\n\n audit(A, Subject) ->\n A ! {audit, Subject, self()},\n receive {audit_reply, R} -> R end.\n\n actions(A, Subject) ->\n A ! {actions, Subject, self()},\n receive {audit_reply, R} -> R end.\n\n count(A, Subject) ->\n A ! {count, Subject, self()},\n receive {audit_reply, R} -> R end.\n\n all(A) ->\n A ! {all, self()},\n receive {audit_reply, R} -> R end.\n\n loop(Events, Seq) ->\n receive\n {event, Subject, Action} ->\n loop([{Seq, Subject, Action} | Events], Seq + 1);\n {audit, Subject, From} ->\n From ! {audit_reply, collect(Subject, Events, [])},\n loop(Events, Seq);\n {actions, Subject, From} ->\n From ! {audit_reply, action_list(Subject, Events, [])},\n loop(Events, Seq);\n {count, Subject, From} ->\n From ! {audit_reply, count_subj(Subject, Events, 0)},\n loop(Events, Seq);\n {all, From} ->\n From ! {audit_reply, reverse(Events, [])},\n loop(Events, Seq);\n {stop, From} ->\n From ! {audit_reply, ok}\n end.\n\n collect(_, [], Acc) -> Acc;\n collect(Subject, [{Seq, S, A} | Rest], Acc) ->\n case S =:= Subject of\n true -> collect(Subject, Rest, [{Seq, S, A} | Acc]);\n false -> collect(Subject, Rest, Acc)\n end.\n\n action_list(_, [], Acc) -> Acc;\n action_list(Subject, [{_, S, A} | Rest], Acc) ->\n case S =:= Subject of\n true -> action_list(Subject, Rest, [A | Acc]);\n false -> action_list(Subject, Rest, Acc)\n end.\n\n count_subj(_, [], N) -> N;\n count_subj(Subject, [{_, S, _} | Rest], N) ->\n case S =:= Subject of\n true -> count_subj(Subject, Rest, N + 1);\n false -> count_subj(Subject, Rest, N)\n end.\n\n reverse([], Acc) -> Acc;\n reverse([H | T], Acc) -> reverse(T, [H | Acc]).")
|
||||
|
||||
(define
|
||||
identity-load-audit!
|
||||
(fn () (erlang-load-module identity-audit-source)))
|
||||
29
lib/identity/cache.sx
Normal file
29
lib/identity/cache.sx
Normal file
@@ -0,0 +1,29 @@
|
||||
;; identity/cache.sx — a delegated grant-verification cache, mirroring the
|
||||
;; Redis-cache pattern apps use in front of grant verification.
|
||||
;;
|
||||
;; The cache is a process wrapping a token registry. introspect() is
|
||||
;; memoised; issue/issue_grant/refresh/revoke pass through. The danger
|
||||
;; with any cache is staleness: a revoked token must NOT keep reading
|
||||
;; valid out of the cache, not even for a millisecond (the loop's hard
|
||||
;; rule). We get that for free with GENERATION invalidation:
|
||||
;;
|
||||
;; - each cache entry records the generation it was written at;
|
||||
;; - a hit requires entry.generation == current generation;
|
||||
;; - any state-changing op that can invalidate an existing token
|
||||
;; (revoke — which cascades to a grant; refresh — whose reuse cascades)
|
||||
;; bumps the generation.
|
||||
;;
|
||||
;; So a single revoke instantly invalidates every cached positive: the
|
||||
;; next introspect is a miss and re-validates against the live registry,
|
||||
;; which returns {inactive}. Revocation stays real; the cache only ever
|
||||
;; accelerates the steady state, never overrides a revocation.
|
||||
;;
|
||||
;; stats() -> {Hits, Misses} so callers can see the cache is live.
|
||||
|
||||
(define
|
||||
identity-cache-source
|
||||
"-module(identity_grant_cache).\n\n start() ->\n spawn(fun () ->\n Reg = identity_tokens:start(),\n loop(Reg, 1, [], 0, 0)\n end).\n\n issue(C, Subject, Client, Scope) ->\n C ! {issue, Subject, Client, Scope, self()},\n receive {cache_reply, R} -> R end.\n\n issue_grant(C, Subject, Client, Scope) ->\n C ! {issue_grant, Subject, Client, Scope, self()},\n receive {cache_reply, R} -> R end.\n\n refresh(C, RefreshTok) ->\n C ! {refresh, RefreshTok, self()},\n receive {cache_reply, R} -> R end.\n\n introspect(C, Token) ->\n C ! {introspect, Token, self()},\n receive {cache_reply, R} -> R end.\n\n revoke(C, Token) ->\n C ! {revoke, Token, self()},\n receive {cache_reply, R} -> R end.\n\n stats(C) ->\n C ! {stats, self()},\n receive {cache_reply, R} -> R end.\n\n loop(Reg, Gen, Entries, Hits, Misses) ->\n receive\n {introspect, Tok, From} ->\n case lookup_fresh(Tok, Gen, Entries) of\n {hit, Result} ->\n From ! {cache_reply, Result},\n loop(Reg, Gen, Entries, Hits + 1, Misses);\n miss ->\n Result = identity_tokens:introspect(Reg, Tok),\n From ! {cache_reply, Result},\n loop(Reg, Gen, put_entry(Tok, Result, Gen, Entries), Hits, Misses + 1)\n end;\n {issue, Subject, Client, Scope, From} ->\n From ! {cache_reply, identity_tokens:issue(Reg, Subject, Client, Scope)},\n loop(Reg, Gen, Entries, Hits, Misses);\n {issue_grant, Subject, Client, Scope, From} ->\n From ! {cache_reply, identity_tokens:issue_grant(Reg, Subject, Client, Scope)},\n loop(Reg, Gen, Entries, Hits, Misses);\n {refresh, RTok, From} ->\n From ! {cache_reply, identity_tokens:refresh(Reg, RTok)},\n loop(Reg, Gen + 1, Entries, Hits, Misses);\n {revoke, Tok, From} ->\n identity_tokens:revoke(Reg, Tok),\n From ! {cache_reply, ok},\n loop(Reg, Gen + 1, Entries, Hits, Misses);\n {stats, From} ->\n From ! {cache_reply, {Hits, Misses}},\n loop(Reg, Gen, Entries, Hits, Misses)\n end.\n\n lookup_fresh(_, _, []) -> miss;\n lookup_fresh(Tok, Gen, [{T, {Result, G}} | Rest]) ->\n case T =:= Tok of\n true ->\n case G =:= Gen of\n true -> {hit, Result};\n false -> miss\n end;\n false -> lookup_fresh(Tok, Gen, Rest)\n end.\n\n put_entry(Tok, Result, Gen, Entries) ->\n [{Tok, {Result, Gen}} | remove(Tok, Entries)].\n\n remove(_, []) -> [];\n remove(Tok, [{T, V} | Rest]) ->\n case T =:= Tok of\n true -> remove(Tok, Rest);\n false -> [{T, V} | remove(Tok, Rest)]\n end.")
|
||||
|
||||
(define
|
||||
identity-load-cache!
|
||||
(fn () (erlang-load-module identity-cache-source)))
|
||||
28
lib/identity/clients.sx
Normal file
28
lib/identity/clients.sx
Normal file
@@ -0,0 +1,28 @@
|
||||
;; identity/clients.sx — the OAuth client registry (RFC 6749 §2).
|
||||
;;
|
||||
;; A client is registered with a type, a secret, and its allow-listed
|
||||
;; redirect_uris:
|
||||
;;
|
||||
;; public — cannot keep a secret (SPAs, native apps, §2.1);
|
||||
;; identified but not authenticated.
|
||||
;; confidential — can authenticate; MUST present its secret at the token
|
||||
;; endpoint (§3.2.1, §4.1.3). A wrong secret is
|
||||
;; invalid_client — never a soft pass.
|
||||
;;
|
||||
;; Redirect URIs must be pre-registered (§3.1.2.2 + OAuth Security BCP):
|
||||
;; valid_redirect/3 is the exact-match check the authorize/exchange steps
|
||||
;; consult so an attacker cannot redirect the code to an unregistered URI.
|
||||
;;
|
||||
;; register(C, ClientId, Type, Secret, RedirectUris) -> ok | {error, exists}
|
||||
;; lookup(C, ClientId) -> {ok, Type, RedirectUris} | {error, unknown_client}
|
||||
;; authenticate(C, ClientId, Sec) -> {ok, public} | {ok, confidential}
|
||||
;; | {error, invalid_client} | {error, unknown_client}
|
||||
;; valid_redirect(C, ClientId, U) -> true | false
|
||||
|
||||
(define
|
||||
identity-clients-source
|
||||
"-module(identity_clients).\n\n start() ->\n spawn(fun () -> loop([]) end).\n\n register(C, ClientId, Type, Secret, RedirectUris) ->\n C ! {register, ClientId, Type, Secret, RedirectUris, self()},\n receive {client_reply, R} -> R end.\n\n lookup(C, ClientId) ->\n C ! {lookup, ClientId, self()},\n receive {client_reply, R} -> R end.\n\n authenticate(C, ClientId, Secret) ->\n C ! {authenticate, ClientId, Secret, self()},\n receive {client_reply, R} -> R end.\n\n valid_redirect(C, ClientId, Uri) ->\n C ! {valid_redirect, ClientId, Uri, self()},\n receive {client_reply, R} -> R end.\n\n loop(Clients) ->\n receive\n {register, ClientId, Type, Secret, RedirectUris, From} ->\n case find(ClientId, Clients) of\n {ok, _} ->\n From ! {client_reply, {error, exists}},\n loop(Clients);\n none ->\n From ! {client_reply, ok},\n loop([{ClientId, {Type, Secret, RedirectUris}} | Clients])\n end;\n {lookup, ClientId, From} ->\n case find(ClientId, Clients) of\n none -> From ! {client_reply, {error, unknown_client}};\n {ok, {Type, _, Uris}} -> From ! {client_reply, {ok, Type, Uris}}\n end,\n loop(Clients);\n {authenticate, ClientId, Secret, From} ->\n case find(ClientId, Clients) of\n none ->\n From ! {client_reply, {error, unknown_client}};\n {ok, {public, _, _}} ->\n From ! {client_reply, {ok, public}};\n {ok, {confidential, S, _}} ->\n case S =:= Secret of\n true -> From ! {client_reply, {ok, confidential}};\n false -> From ! {client_reply, {error, invalid_client}}\n end\n end,\n loop(Clients);\n {valid_redirect, ClientId, Uri, From} ->\n case find(ClientId, Clients) of\n none -> From ! {client_reply, false};\n {ok, {_, _, Uris}} -> From ! {client_reply, member(Uri, Uris)}\n end,\n loop(Clients);\n {stop, From} ->\n From ! {client_reply, ok}\n end.\n\n member(_, []) -> false;\n member(X, [Y | Rest]) ->\n case X =:= Y of\n true -> true;\n false -> member(X, Rest)\n end.\n\n find(_, []) -> none;\n find(Key, [{K, V} | Rest]) ->\n case K =:= Key of\n true -> {ok, V};\n false -> find(Key, Rest)\n end.")
|
||||
|
||||
(define
|
||||
identity-load-clients!
|
||||
(fn () (erlang-load-module identity-clients-source)))
|
||||
215
lib/identity/conformance.sh
Executable file
215
lib/identity/conformance.sh
Executable file
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env bash
|
||||
# identity-on-sx conformance runner.
|
||||
#
|
||||
# Loads the Erlang-on-SX substrate, the identity library, and every
|
||||
# identity test suite via the epoch protocol, collects pass/fail counts,
|
||||
# and writes lib/identity/scoreboard.json + .md.
|
||||
#
|
||||
# Usage:
|
||||
# bash lib/identity/conformance.sh # run all suites
|
||||
# bash lib/identity/conformance.sh -v # verbose per-suite
|
||||
|
||||
set -uo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}"
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe"
|
||||
fi
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
echo "ERROR: sx_server.exe not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERBOSE="${1:-}"
|
||||
TMPFILE=$(mktemp)
|
||||
OUTFILE=$(mktemp)
|
||||
trap "rm -f $TMPFILE $OUTFILE" EXIT
|
||||
|
||||
# Each suite: name | counter pass | counter total
|
||||
SUITES=(
|
||||
"session|id-session-test-pass|id-session-test-count"
|
||||
"token|id-token-test-pass|id-token-test-count"
|
||||
"registry|id-registry-test-pass|id-registry-test-count"
|
||||
"api|id-api-test-pass|id-api-test-count"
|
||||
"oauth|id-oauth-test-pass|id-oauth-test-count"
|
||||
"sso|id-sso-test-pass|id-sso-test-count"
|
||||
"membership|id-membership-test-pass|id-membership-test-count"
|
||||
"cache|id-cache-test-pass|id-cache-test-count"
|
||||
"audit|id-audit-test-pass|id-audit-test-count"
|
||||
"federation|id-fed-test-pass|id-fed-test-count"
|
||||
"expiry|id-expiry-test-pass|id-expiry-test-count"
|
||||
"clients|id-clients-test-pass|id-clients-test-count"
|
||||
"grants|id-grants-test-pass|id-grants-test-count"
|
||||
"device|id-device-test-pass|id-device-test-count"
|
||||
"facade|id-facade-test-pass|id-facade-test-count"
|
||||
"delegation|id-deleg-test-pass|id-deleg-test-count"
|
||||
"session-mgmt|id-smgmt-test-pass|id-smgmt-test-count"
|
||||
"exchange|id-xchg-test-pass|id-xchg-test-count"
|
||||
"introspect|id-intr-test-pass|id-intr-test-count"
|
||||
"par|id-par-test-pass|id-par-test-count"
|
||||
"dynreg|id-dyn-test-pass|id-dyn-test-count"
|
||||
"account|id-acct-test-pass|id-acct-test-count"
|
||||
)
|
||||
|
||||
cat > "$TMPFILE" << 'EPOCHS'
|
||||
(epoch 1)
|
||||
(load "lib/erlang/tokenizer.sx")
|
||||
(load "lib/erlang/parser.sx")
|
||||
(load "lib/erlang/parser-core.sx")
|
||||
(load "lib/erlang/parser-expr.sx")
|
||||
(load "lib/erlang/parser-module.sx")
|
||||
(load "lib/erlang/transpile.sx")
|
||||
(load "lib/erlang/runtime.sx")
|
||||
(load "lib/identity/session.sx")
|
||||
(load "lib/identity/token.sx")
|
||||
(load "lib/identity/registry.sx")
|
||||
(load "lib/identity/api.sx")
|
||||
(load "lib/identity/oauth.sx")
|
||||
(load "lib/identity/membership.sx")
|
||||
(load "lib/identity/cache.sx")
|
||||
(load "lib/identity/audit.sx")
|
||||
(load "lib/identity/federation.sx")
|
||||
(load "lib/identity/clients.sx")
|
||||
(load "lib/identity/device.sx")
|
||||
(load "lib/identity/delegation.sx")
|
||||
(load "lib/identity/tests/session.sx")
|
||||
(load "lib/identity/tests/token.sx")
|
||||
(load "lib/identity/tests/registry.sx")
|
||||
(load "lib/identity/tests/api.sx")
|
||||
(load "lib/identity/tests/oauth.sx")
|
||||
(load "lib/identity/tests/sso.sx")
|
||||
(load "lib/identity/tests/membership.sx")
|
||||
(load "lib/identity/tests/cache.sx")
|
||||
(load "lib/identity/tests/audit.sx")
|
||||
(load "lib/identity/tests/federation.sx")
|
||||
(load "lib/identity/tests/expiry.sx")
|
||||
(load "lib/identity/tests/clients.sx")
|
||||
(load "lib/identity/tests/grants.sx")
|
||||
(load "lib/identity/tests/device.sx")
|
||||
(load "lib/identity/tests/facade.sx")
|
||||
(load "lib/identity/tests/delegation.sx")
|
||||
(load "lib/identity/tests/session_mgmt.sx")
|
||||
(load "lib/identity/tests/exchange.sx")
|
||||
(load "lib/identity/tests/introspect.sx")
|
||||
(load "lib/identity/tests/par.sx")
|
||||
(load "lib/identity/tests/dynreg.sx")
|
||||
(load "lib/identity/tests/account.sx")
|
||||
(epoch 100)
|
||||
(eval "(list id-session-test-pass id-session-test-count)")
|
||||
(epoch 101)
|
||||
(eval "(list id-token-test-pass id-token-test-count)")
|
||||
(epoch 102)
|
||||
(eval "(list id-registry-test-pass id-registry-test-count)")
|
||||
(epoch 103)
|
||||
(eval "(list id-api-test-pass id-api-test-count)")
|
||||
(epoch 104)
|
||||
(eval "(list id-oauth-test-pass id-oauth-test-count)")
|
||||
(epoch 105)
|
||||
(eval "(list id-sso-test-pass id-sso-test-count)")
|
||||
(epoch 106)
|
||||
(eval "(list id-membership-test-pass id-membership-test-count)")
|
||||
(epoch 107)
|
||||
(eval "(list id-cache-test-pass id-cache-test-count)")
|
||||
(epoch 108)
|
||||
(eval "(list id-audit-test-pass id-audit-test-count)")
|
||||
(epoch 109)
|
||||
(eval "(list id-fed-test-pass id-fed-test-count)")
|
||||
(epoch 110)
|
||||
(eval "(list id-expiry-test-pass id-expiry-test-count)")
|
||||
(epoch 111)
|
||||
(eval "(list id-clients-test-pass id-clients-test-count)")
|
||||
(epoch 112)
|
||||
(eval "(list id-grants-test-pass id-grants-test-count)")
|
||||
(epoch 113)
|
||||
(eval "(list id-device-test-pass id-device-test-count)")
|
||||
(epoch 114)
|
||||
(eval "(list id-facade-test-pass id-facade-test-count)")
|
||||
(epoch 115)
|
||||
(eval "(list id-deleg-test-pass id-deleg-test-count)")
|
||||
(epoch 116)
|
||||
(eval "(list id-smgmt-test-pass id-smgmt-test-count)")
|
||||
(epoch 117)
|
||||
(eval "(list id-xchg-test-pass id-xchg-test-count)")
|
||||
(epoch 118)
|
||||
(eval "(list id-intr-test-pass id-intr-test-count)")
|
||||
(epoch 119)
|
||||
(eval "(list id-par-test-pass id-par-test-count)")
|
||||
(epoch 120)
|
||||
(eval "(list id-dyn-test-pass id-dyn-test-count)")
|
||||
(epoch 121)
|
||||
(eval "(list id-acct-test-pass id-acct-test-count)")
|
||||
EPOCHS
|
||||
|
||||
timeout 1200 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1
|
||||
|
||||
parse_pair() {
|
||||
local epoch="$1"
|
||||
local line
|
||||
line=$(grep -A1 "^(ok-len $epoch " "$OUTFILE" | tail -1)
|
||||
echo "$line" | sed -E 's/[()]//g'
|
||||
}
|
||||
|
||||
TOTAL_PASS=0
|
||||
TOTAL_COUNT=0
|
||||
JSON_SUITES=""
|
||||
MD_ROWS=""
|
||||
|
||||
idx=0
|
||||
for entry in "${SUITES[@]}"; do
|
||||
name="${entry%%|*}"
|
||||
epoch=$((100 + idx))
|
||||
pair=$(parse_pair "$epoch")
|
||||
pass=$(echo "$pair" | awk '{print $1}')
|
||||
count=$(echo "$pair" | awk '{print $2}')
|
||||
if [ -z "$pass" ] || [ -z "$count" ]; then
|
||||
pass=0
|
||||
count=0
|
||||
fi
|
||||
TOTAL_PASS=$((TOTAL_PASS + pass))
|
||||
TOTAL_COUNT=$((TOTAL_COUNT + count))
|
||||
status="ok"
|
||||
marker="✅"
|
||||
if [ "$pass" != "$count" ]; then
|
||||
status="fail"
|
||||
marker="❌"
|
||||
fi
|
||||
if [ "$VERBOSE" = "-v" ]; then
|
||||
printf " %-12s %s/%s\n" "$name" "$pass" "$count"
|
||||
fi
|
||||
if [ -n "$JSON_SUITES" ]; then JSON_SUITES+=","; fi
|
||||
JSON_SUITES+=$'\n '
|
||||
JSON_SUITES+="{\"name\":\"$name\",\"pass\":$pass,\"total\":$count,\"status\":\"$status\"}"
|
||||
MD_ROWS+="| $marker | $name | $pass | $count |"$'\n'
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
|
||||
printf '\nidentity-on-sx conformance: %d / %d\n' "$TOTAL_PASS" "$TOTAL_COUNT"
|
||||
|
||||
cat > lib/identity/scoreboard.json <<JSON
|
||||
{
|
||||
"language": "identity",
|
||||
"total_pass": $TOTAL_PASS,
|
||||
"total": $TOTAL_COUNT,
|
||||
"suites": [$JSON_SUITES
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
cat > lib/identity/scoreboard.md <<MD
|
||||
# identity-on-sx Scoreboard
|
||||
|
||||
**Total: ${TOTAL_PASS} / ${TOTAL_COUNT} tests passing**
|
||||
|
||||
| | Suite | Pass | Total |
|
||||
|---|---|---|---|
|
||||
$MD_ROWS
|
||||
|
||||
Generated by \`lib/identity/conformance.sh\`.
|
||||
MD
|
||||
|
||||
if [ "$TOTAL_PASS" -eq "$TOTAL_COUNT" ]; then
|
||||
exit 0
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
34
lib/identity/delegation.sx
Normal file
34
lib/identity/delegation.sx
Normal file
@@ -0,0 +1,34 @@
|
||||
;; identity/delegation.sx — the identity -> acl delegation boundary.
|
||||
;;
|
||||
;; This is the loop's central architectural rule made concrete:
|
||||
;; AUTHENTICATION is identity's job; AUTHORIZATION is acl's. A request is
|
||||
;; checked in two stages, and the order matters:
|
||||
;;
|
||||
;; 1. identity proves WHO via the opaque token (introspect). If the token
|
||||
;; is inactive, the answer is {error, unauthenticated} — a 401. acl is
|
||||
;; NEVER consulted; \"I don't know who you are\" is not a permission
|
||||
;; question.
|
||||
;; 2. only for an authenticated subject does identity construct the
|
||||
;; permission query {Subject, Scope, Action, Resource} and HAND IT OFF
|
||||
;; to acl. acl returns permit | deny; deny is {error, forbidden} — a
|
||||
;; 403. identity itself never decides permission.
|
||||
;;
|
||||
;; The real decider is acl-on-sx (Datalog), which runs as a different
|
||||
;; guest language on SX and is wired in at the integration layer. Here the
|
||||
;; acl side is a labelled STUB process so the boundary is exercised: it
|
||||
;; permits when the Action is within the token's granted Scope. Swap the
|
||||
;; stub pid for the acl adapter and the boundary is unchanged.
|
||||
;;
|
||||
;; check(TokReg, Acl, Token, Action, Resource) ->
|
||||
;; {ok, Subject} | {error, unauthenticated} | {error, forbidden}
|
||||
|
||||
(define
|
||||
identity-delegation-source
|
||||
"-module(identity_delegation).\n\n check(TokReg, Acl, Token, Action, Resource) ->\n case identity_tokens:introspect(TokReg, Token) of\n {inactive} ->\n {error, unauthenticated};\n {active, Subject, _Client, Scope} ->\n Acl ! {acl_query, Subject, Scope, Action, Resource, self()},\n receive {acl_verdict, V} ->\n case V of\n permit -> {ok, Subject};\n deny -> {error, forbidden}\n end\n end\n end.\n\n %% --- stub acl decider (stands in for acl-on-sx / Datalog) ---\n %% Permits iff the Action is one of the token's granted scopes. The real\n %% acl decides on rules + facts; this only exercises the handoff shape.\n stub_acl() ->\n spawn(fun () -> acl_loop() end).\n\n acl_loop() ->\n receive\n {acl_query, _Subject, Scope, Action, _Resource, From} ->\n From ! {acl_verdict, decide(Action, Scope)},\n acl_loop();\n stop ->\n ok\n end.\n\n decide(Action, Scope) ->\n case member(Action, Scope) of\n true -> permit;\n false -> deny\n end.\n\n member(_, []) -> false;\n member(X, [Y | Rest]) ->\n case X =:= Y of\n true -> true;\n false -> member(X, Rest)\n end.")
|
||||
|
||||
(define
|
||||
identity-load-delegation!
|
||||
(fn
|
||||
()
|
||||
(identity-load-token!)
|
||||
(erlang-load-module identity-delegation-source)))
|
||||
33
lib/identity/device.sx
Normal file
33
lib/identity/device.sx
Normal file
@@ -0,0 +1,33 @@
|
||||
;; identity/device.sx — the device authorization grant (RFC 8628).
|
||||
;;
|
||||
;; For input-constrained devices (TVs, CLIs): the device gets a device_code
|
||||
;; + user_code, the user approves out-of-band on another device, and the
|
||||
;; device polls the token endpoint until it flips. The poll status machine
|
||||
;; is RFC 8628 §3.5:
|
||||
;;
|
||||
;; authorize(ClientId, Scope) -> {ok, DeviceCode, UserCode}
|
||||
;; approve(UserCode, Subject) -> ok | {error, ...} (the human's browser)
|
||||
;; deny(UserCode) -> ok | {error, ...}
|
||||
;; poll(DeviceCode) ->
|
||||
;; pending -> {error, authorization_pending}
|
||||
;; denied -> {error, access_denied}
|
||||
;; approved -> {ok, Token} (device code is then single-use)
|
||||
;; consumed -> {error, invalid_grant}
|
||||
;; unknown -> {error, invalid_grant}
|
||||
;;
|
||||
;; Tokens are grant-backed (token.sx) so revocation stays real. Device-code
|
||||
;; expiry and slow_down (poll-rate limiting) are deferred — the substrate
|
||||
;; has no wall clock and the core status machine is the security-relevant
|
||||
;; part; introspect via token.sx already honours token TTL.
|
||||
;;
|
||||
;; State: loop(TokReg, Requests) where Requests is
|
||||
;; [{DeviceCode, UserCode, ClientId, Scope, Status}]
|
||||
;; Status :: pending | {approved, Subject} | denied | consumed
|
||||
|
||||
(define
|
||||
identity-device-source
|
||||
"-module(identity_device).\n\n start() ->\n spawn(fun () ->\n TokReg = identity_tokens:start(),\n loop(TokReg, [])\n end).\n\n authorize(D, ClientId, Scope) ->\n D ! {authorize, ClientId, Scope, self()},\n receive {device_reply, R} -> R end.\n\n approve(D, UserCode, Subject) ->\n D ! {approve, UserCode, Subject, self()},\n receive {device_reply, R} -> R end.\n\n deny(D, UserCode) ->\n D ! {deny, UserCode, self()},\n receive {device_reply, R} -> R end.\n\n poll(D, DeviceCode) ->\n D ! {poll, DeviceCode, self()},\n receive {device_reply, R} -> R end.\n\n introspect(D, Token) ->\n D ! {introspect, Token, self()},\n receive {device_reply, R} -> R end.\n\n loop(TokReg, Requests) ->\n receive\n {authorize, ClientId, Scope, From} ->\n DeviceCode = make_ref(),\n UserCode = make_ref(),\n From ! {device_reply, {ok, DeviceCode, UserCode}},\n loop(TokReg, [{DeviceCode, UserCode, ClientId, Scope, pending} | Requests]);\n {approve, UserCode, Subject, From} ->\n case find_user(UserCode, Requests) of\n none ->\n From ! {device_reply, {error, unknown_code}},\n loop(TokReg, Requests);\n {ok, {_, _, _, _, pending}} ->\n From ! {device_reply, ok},\n loop(TokReg, set_user(UserCode, {approved, Subject}, Requests));\n {ok, {_, _, _, _, St}} ->\n From ! {device_reply, {error, St}},\n loop(TokReg, Requests)\n end;\n {deny, UserCode, From} ->\n case find_user(UserCode, Requests) of\n none ->\n From ! {device_reply, {error, unknown_code}},\n loop(TokReg, Requests);\n {ok, {_, _, _, _, pending}} ->\n From ! {device_reply, ok},\n loop(TokReg, set_user(UserCode, denied, Requests));\n {ok, {_, _, _, _, St}} ->\n From ! {device_reply, {error, St}},\n loop(TokReg, Requests)\n end;\n {poll, DeviceCode, From} ->\n case find_device(DeviceCode, Requests) of\n none ->\n From ! {device_reply, {error, invalid_grant}},\n loop(TokReg, Requests);\n {ok, {_, _, _, _, pending}} ->\n From ! {device_reply, {error, authorization_pending}},\n loop(TokReg, Requests);\n {ok, {_, _, _, _, denied}} ->\n From ! {device_reply, {error, access_denied}},\n loop(TokReg, Requests);\n {ok, {_, _, _, _, consumed}} ->\n From ! {device_reply, {error, invalid_grant}},\n loop(TokReg, Requests);\n {ok, {_, _, ClientId, Scope, {approved, Subject}}} ->\n {ok, Token} = identity_tokens:issue(TokReg, Subject, ClientId, Scope),\n From ! {device_reply, {ok, Token}},\n loop(TokReg, set_device(DeviceCode, consumed, Requests))\n end;\n {introspect, Token, From} ->\n From ! {device_reply, identity_tokens:introspect(TokReg, Token)},\n loop(TokReg, Requests);\n {stop, From} ->\n From ! {device_reply, ok}\n end.\n\n find_device(_, []) -> none;\n find_device(DCode, [{D, U, C, S, St} | Rest]) ->\n case D =:= DCode of\n true -> {ok, {D, U, C, S, St}};\n false -> find_device(DCode, Rest)\n end.\n\n find_user(_, []) -> none;\n find_user(UCode, [{D, U, C, S, St} | Rest]) ->\n case U =:= UCode of\n true -> {ok, {D, U, C, S, St}};\n false -> find_user(UCode, Rest)\n end.\n\n set_device(_, _, []) -> [];\n set_device(DCode, NewSt, [{D, U, C, S, St} | Rest]) ->\n case D =:= DCode of\n true -> [{D, U, C, S, NewSt} | Rest];\n false -> [{D, U, C, S, St} | set_device(DCode, NewSt, Rest)]\n end.\n\n set_user(_, _, []) -> [];\n set_user(UCode, NewSt, [{D, U, C, S, St} | Rest]) ->\n case U =:= UCode of\n true -> [{D, U, C, S, NewSt} | Rest];\n false -> [{D, U, C, S, St} | set_user(UCode, NewSt, Rest)]\n end.")
|
||||
|
||||
(define
|
||||
identity-load-device!
|
||||
(fn () (identity-load-token!) (erlang-load-module identity-device-source)))
|
||||
30
lib/identity/federation.sx
Normal file
30
lib/identity/federation.sx
Normal file
@@ -0,0 +1,30 @@
|
||||
;; identity/federation.sx — federated identity: peer-asserted subjects,
|
||||
;; advisory and trust-gated.
|
||||
;;
|
||||
;; A peer instance can assert \"this remote subject authenticated with me\".
|
||||
;; We accept such an assertion ONLY from a peer we explicitly trust
|
||||
;; (trust-gated); an assertion from an unknown peer is {error, untrusted},
|
||||
;; never silently honoured. Even when accepted, the resulting identity is
|
||||
;; ADVISORY: it is flagged peer_asserted with its origin peer, never
|
||||
;; promoted to local authority. Downstream (acl) decides how much a
|
||||
;; peer-asserted identity may do; identity only records who asserted it.
|
||||
;;
|
||||
;; Cross-instance subject mapping turns a (Peer, RemoteSubject) pair into a
|
||||
;; stable local subject. By default it is namespaced — {federated, Peer,
|
||||
;; RemoteSubject} — so two peers' \"alice\" never collide; an explicit map
|
||||
;; can alias a remote subject to a local one.
|
||||
;;
|
||||
;; trust(F, Peer) / untrust(F, Peer) / trusted(F, Peer)
|
||||
;; map(F, Peer, Remote, Local) -> ok (optional alias)
|
||||
;; resolve(F, Peer, Remote) -> {ok, LocalSubject}
|
||||
;; assert_id(F, Peer, Remote) -> {ok, LocalSubject}
|
||||
;; | {error, untrusted}
|
||||
;; provenance(F, LocalSubject) -> {peer_asserted, Peer} | {local}
|
||||
|
||||
(define
|
||||
identity-federation-source
|
||||
"-module(identity_federation).\n\n start() ->\n spawn(fun () -> loop([], [], []) end).\n\n trust(F, Peer) ->\n F ! {trust, Peer, self()},\n receive {fed_reply, R} -> R end.\n\n untrust(F, Peer) ->\n F ! {untrust, Peer, self()},\n receive {fed_reply, R} -> R end.\n\n trusted(F, Peer) ->\n F ! {trusted, Peer, self()},\n receive {fed_reply, R} -> R end.\n\n map(F, Peer, Remote, Local) ->\n F ! {map, Peer, Remote, Local, self()},\n receive {fed_reply, R} -> R end.\n\n resolve(F, Peer, Remote) ->\n F ! {resolve, Peer, Remote, self()},\n receive {fed_reply, R} -> R end.\n\n assert_id(F, Peer, Remote) ->\n F ! {assert_id, Peer, Remote, self()},\n receive {fed_reply, R} -> R end.\n\n provenance(F, Local) ->\n F ! {provenance, Local, self()},\n receive {fed_reply, R} -> R end.\n\n loop(Trusted, Maps, Asserted) ->\n receive\n {trust, Peer, From} ->\n From ! {fed_reply, ok},\n loop(add_unique(Peer, Trusted), Maps, Asserted);\n {untrust, Peer, From} ->\n From ! {fed_reply, ok},\n loop(drop(Peer, Trusted), Maps, Asserted);\n {trusted, Peer, From} ->\n From ! {fed_reply, member(Peer, Trusted)},\n loop(Trusted, Maps, Asserted);\n {map, Peer, Remote, Local, From} ->\n From ! {fed_reply, ok},\n loop(Trusted, [{{Peer, Remote}, Local} | drop_map(Peer, Remote, Maps)], Asserted);\n {resolve, Peer, Remote, From} ->\n From ! {fed_reply, {ok, resolve_local(Peer, Remote, Maps)}},\n loop(Trusted, Maps, Asserted);\n {assert_id, Peer, Remote, From} ->\n case member(Peer, Trusted) of\n false ->\n From ! {fed_reply, {error, untrusted}},\n loop(Trusted, Maps, Asserted);\n true ->\n Local = resolve_local(Peer, Remote, Maps),\n From ! {fed_reply, {ok, Local}},\n loop(Trusted, Maps, [{Local, Peer} | drop_assert(Local, Asserted)])\n end;\n {provenance, Local, From} ->\n case find_assert(Local, Asserted) of\n {ok, Peer} -> From ! {fed_reply, {peer_asserted, Peer}};\n none -> From ! {fed_reply, {local}}\n end,\n loop(Trusted, Maps, Asserted);\n {stop, From} ->\n From ! {fed_reply, ok}\n end.\n\n resolve_local(Peer, Remote, Maps) ->\n case find_map(Peer, Remote, Maps) of\n {ok, Local} -> Local;\n none -> {federated, Peer, Remote}\n end.\n\n find_map(_, _, []) -> none;\n find_map(Peer, Remote, [{{P, R}, Local} | Rest]) ->\n case same(P, Peer, R, Remote) of\n true -> {ok, Local};\n false -> find_map(Peer, Remote, Rest)\n end.\n\n drop_map(_, _, []) -> [];\n drop_map(Peer, Remote, [{{P, R}, Local} | Rest]) ->\n case same(P, Peer, R, Remote) of\n true -> drop_map(Peer, Remote, Rest);\n false -> [{{P, R}, Local} | drop_map(Peer, Remote, Rest)]\n end.\n\n same(P, Peer, R, Remote) ->\n case P =:= Peer of\n true -> R =:= Remote;\n false -> false\n end.\n\n find_assert(_, []) -> none;\n find_assert(Local, [{L, Peer} | Rest]) ->\n case L =:= Local of\n true -> {ok, Peer};\n false -> find_assert(Local, Rest)\n end.\n\n drop_assert(_, []) -> [];\n drop_assert(Local, [{L, Peer} | Rest]) ->\n case L =:= Local of\n true -> drop_assert(Local, Rest);\n false -> [{L, Peer} | drop_assert(Local, Rest)]\n end.\n\n add_unique(X, Xs) ->\n case member(X, Xs) of\n true -> Xs;\n false -> [X | Xs]\n end.\n\n drop(_, []) -> [];\n drop(X, [Y | Rest]) ->\n case X =:= Y of\n true -> drop(X, Rest);\n false -> [Y | drop(X, Rest)]\n end.\n\n member(_, []) -> false;\n member(X, [Y | Rest]) ->\n case X =:= Y of\n true -> true;\n false -> member(X, Rest)\n end.")
|
||||
|
||||
(define
|
||||
identity-load-federation!
|
||||
(fn () (erlang-load-module identity-federation-source)))
|
||||
31
lib/identity/membership.sx
Normal file
31
lib/identity/membership.sx
Normal file
@@ -0,0 +1,31 @@
|
||||
;; identity/membership.sx — coop membership state + per-app projection.
|
||||
;;
|
||||
;; Membership is canonical subject state held by one process, a guarded
|
||||
;; state machine (invalid transitions are explicit errors, never silent
|
||||
;; no-ops):
|
||||
;;
|
||||
;; none --request--> pending --approve--> active
|
||||
;; active --lapse--> lapsed --reinstate--> active
|
||||
;; {pending|active|lapsed} --revoke--> revoked (terminal)
|
||||
;;
|
||||
;; A per-app GRANT PROJECTION renders that one canonical state into the
|
||||
;; view a given client app consumes — mirroring rose-ash's per-app grant
|
||||
;; verification. The projection is pure identity: it reports WHAT the
|
||||
;; subject's membership is for that app; it does NOT decide whether the
|
||||
;; app should let them in. That permission question is acl's, keyed off
|
||||
;; this projection.
|
||||
;;
|
||||
;; project(Subject, App) ->
|
||||
;; active -> {member, Tier, App}
|
||||
;; pending -> {pending, App}
|
||||
;; lapsed -> {lapsed, App}
|
||||
;; revoked -> {denied, App}
|
||||
;; none -> {non_member, App}
|
||||
|
||||
(define
|
||||
identity-membership-source
|
||||
"-module(identity_membership).\n\n start() ->\n spawn(fun () -> loop([]) end).\n\n request(M, Subject, Tier) ->\n M ! {request, Subject, Tier, self()},\n receive {membership_reply, R} -> R end.\n\n approve(M, Subject) ->\n M ! {approve, Subject, self()},\n receive {membership_reply, R} -> R end.\n\n lapse(M, Subject) ->\n M ! {lapse, Subject, self()},\n receive {membership_reply, R} -> R end.\n\n reinstate(M, Subject) ->\n M ! {reinstate, Subject, self()},\n receive {membership_reply, R} -> R end.\n\n revoke(M, Subject) ->\n M ! {revoke, Subject, self()},\n receive {membership_reply, R} -> R end.\n\n status(M, Subject) ->\n M ! {status, Subject, self()},\n receive {membership_reply, R} -> R end.\n\n project(M, Subject, App) ->\n M ! {project, Subject, App, self()},\n receive {membership_reply, R} -> R end.\n\n loop(Members) ->\n receive\n {request, Subject, Tier, From} ->\n case find(Subject, Members) of\n none ->\n From ! {membership_reply, ok},\n loop([{Subject, {pending, Tier}} | Members]);\n {ok, _} ->\n From ! {membership_reply, {error, exists}},\n loop(Members)\n end;\n {approve, Subject, From} ->\n case find(Subject, Members) of\n none ->\n From ! {membership_reply, {error, not_found}},\n loop(Members);\n {ok, {pending, Tier}} ->\n From ! {membership_reply, ok},\n loop(set_record(Subject, {active, Tier}, Members));\n {ok, {St, _}} ->\n From ! {membership_reply, {error, St}},\n loop(Members)\n end;\n {lapse, Subject, From} ->\n case find(Subject, Members) of\n none ->\n From ! {membership_reply, {error, not_found}},\n loop(Members);\n {ok, {active, Tier}} ->\n From ! {membership_reply, ok},\n loop(set_record(Subject, {lapsed, Tier}, Members));\n {ok, {St, _}} ->\n From ! {membership_reply, {error, St}},\n loop(Members)\n end;\n {reinstate, Subject, From} ->\n case find(Subject, Members) of\n none ->\n From ! {membership_reply, {error, not_found}},\n loop(Members);\n {ok, {lapsed, Tier}} ->\n From ! {membership_reply, ok},\n loop(set_record(Subject, {active, Tier}, Members));\n {ok, {St, _}} ->\n From ! {membership_reply, {error, St}},\n loop(Members)\n end;\n {revoke, Subject, From} ->\n case find(Subject, Members) of\n none ->\n From ! {membership_reply, {error, not_found}},\n loop(Members);\n {ok, {_, Tier}} ->\n From ! {membership_reply, ok},\n loop(set_record(Subject, {revoked, Tier}, Members))\n end;\n {status, Subject, From} ->\n case find(Subject, Members) of\n none -> From ! {membership_reply, {none}};\n {ok, {St, Tier}} -> From ! {membership_reply, {ok, St, Tier}}\n end,\n loop(Members);\n {project, Subject, App, From} ->\n From ! {membership_reply, project_view(Subject, App, Members)},\n loop(Members);\n {stop, From} ->\n From ! {membership_reply, ok}\n end.\n\n project_view(Subject, App, Members) ->\n case find(Subject, Members) of\n none -> {non_member, App};\n {ok, {active, Tier}} -> {member, Tier, App};\n {ok, {pending, _}} -> {pending, App};\n {ok, {lapsed, _}} -> {lapsed, App};\n {ok, {revoked, _}} -> {denied, App}\n end.\n\n set_record(_, _, []) -> [];\n set_record(Subject, Rec, [{S, Old} | Rest]) ->\n case S =:= Subject of\n true -> [{S, Rec} | Rest];\n false -> [{S, Old} | set_record(Subject, Rec, Rest)]\n end.\n\n find(_, []) -> none;\n find(Key, [{K, V} | Rest]) ->\n case K =:= Key of\n true -> {ok, V};\n false -> find(Key, Rest)\n end.")
|
||||
|
||||
(define
|
||||
identity-load-membership!
|
||||
(fn () (erlang-load-module identity-membership-source)))
|
||||
37
lib/identity/oauth.sx
Normal file
37
lib/identity/oauth.sx
Normal file
File diff suppressed because one or more lines are too long
22
lib/identity/registry.sx
Normal file
22
lib/identity/registry.sx
Normal file
@@ -0,0 +1,22 @@
|
||||
;; identity/registry.sx — routes sessions by id and by (subject, client).
|
||||
;;
|
||||
;; The registry is the directory that makes SSO possible: one subject can
|
||||
;; hold many sessions (one per client), and the OAuth machine asks it the
|
||||
;; single question that drives silent login — \"is there a live session
|
||||
;; for this subject + this client?\". It stores (SessionId, Subject,
|
||||
;; Client, Pid) rows and answers:
|
||||
;;
|
||||
;; whereis_session(Id) -> {ok, Pid} | {error, not_found}
|
||||
;; lookup(Subject, Client) -> {ok, Pid} | {error, not_found} (SSO probe)
|
||||
;; sessions_for(Subject) -> {ok, [SessionId, ...]} (fan-out)
|
||||
;;
|
||||
;; The registry only routes — it holds no grant state and decides nothing.
|
||||
;; Liveness of the routed-to session is that session process's own affair.
|
||||
|
||||
(define
|
||||
identity-registry-source
|
||||
"-module(identity_registry).\n\n start() ->\n spawn(fun () -> loop([]) end).\n\n register(Reg, SessionId, Subject, Client, Pid) ->\n Reg ! {register, SessionId, Subject, Client, Pid, self()},\n receive {registry_reply, R} -> R end.\n\n whereis_session(Reg, SessionId) ->\n Reg ! {whereis_session, SessionId, self()},\n receive {registry_reply, R} -> R end.\n\n lookup(Reg, Subject, Client) ->\n Reg ! {lookup, Subject, Client, self()},\n receive {registry_reply, R} -> R end.\n\n sessions_for(Reg, Subject) ->\n Reg ! {sessions_for, Subject, self()},\n receive {registry_reply, R} -> R end.\n\n deregister(Reg, SessionId) ->\n Reg ! {deregister, SessionId, self()},\n receive {registry_reply, R} -> R end.\n\n stop(Reg) ->\n Reg ! {stop, self()},\n receive {registry_reply, R} -> R end.\n\n loop(Entries) ->\n receive\n {register, SessionId, Subject, Client, Pid, From} ->\n From ! {registry_reply, ok},\n loop([{SessionId, Subject, Client, Pid} | remove_id(SessionId, Entries)]);\n {whereis_session, SessionId, From} ->\n From ! {registry_reply, find_id(SessionId, Entries)},\n loop(Entries);\n {lookup, Subject, Client, From} ->\n From ! {registry_reply, find_sc(Subject, Client, Entries)},\n loop(Entries);\n {sessions_for, Subject, From} ->\n From ! {registry_reply, {ok, collect_subject(Subject, Entries)}},\n loop(Entries);\n {deregister, SessionId, From} ->\n From ! {registry_reply, ok},\n loop(remove_id(SessionId, Entries));\n {stop, From} ->\n From ! {registry_reply, ok}\n end.\n\n find_id(_, []) -> {error, not_found};\n find_id(Id, [{Sid, _, _, Pid} | Rest]) ->\n case Sid =:= Id of\n true -> {ok, Pid};\n false -> find_id(Id, Rest)\n end.\n\n find_sc(_, _, []) -> {error, not_found};\n find_sc(Subject, Client, [{_, Su, Cl, Pid} | Rest]) ->\n case Su =:= Subject of\n true ->\n case Cl =:= Client of\n true -> {ok, Pid};\n false -> find_sc(Subject, Client, Rest)\n end;\n false -> find_sc(Subject, Client, Rest)\n end.\n\n collect_subject(_, []) -> [];\n collect_subject(Subject, [{Sid, Su, _, _} | Rest]) ->\n case Su =:= Subject of\n true -> [Sid | collect_subject(Subject, Rest)];\n false -> collect_subject(Subject, Rest)\n end.\n\n remove_id(_, []) -> [];\n remove_id(Id, [{Sid, Su, Cl, Pid} | Rest]) ->\n case Sid =:= Id of\n true -> remove_id(Id, Rest);\n false -> [{Sid, Su, Cl, Pid} | remove_id(Id, Rest)]\n end.")
|
||||
|
||||
(define
|
||||
identity-load-registry!
|
||||
(fn () (erlang-load-module identity-registry-source)))
|
||||
29
lib/identity/scoreboard.json
Normal file
29
lib/identity/scoreboard.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"language": "identity",
|
||||
"total_pass": 233,
|
||||
"total": 233,
|
||||
"suites": [
|
||||
{"name":"session","pass":11,"total":11,"status":"ok"},
|
||||
{"name":"token","pass":24,"total":24,"status":"ok"},
|
||||
{"name":"registry","pass":9,"total":9,"status":"ok"},
|
||||
{"name":"api","pass":10,"total":10,"status":"ok"},
|
||||
{"name":"oauth","pass":17,"total":17,"status":"ok"},
|
||||
{"name":"sso","pass":10,"total":10,"status":"ok"},
|
||||
{"name":"membership","pass":17,"total":17,"status":"ok"},
|
||||
{"name":"cache","pass":9,"total":9,"status":"ok"},
|
||||
{"name":"audit","pass":11,"total":11,"status":"ok"},
|
||||
{"name":"federation","pass":12,"total":12,"status":"ok"},
|
||||
{"name":"expiry","pass":8,"total":8,"status":"ok"},
|
||||
{"name":"clients","pass":11,"total":11,"status":"ok"},
|
||||
{"name":"grants","pass":9,"total":9,"status":"ok"},
|
||||
{"name":"device","pass":10,"total":10,"status":"ok"},
|
||||
{"name":"facade","pass":9,"total":9,"status":"ok"},
|
||||
{"name":"delegation","pass":8,"total":8,"status":"ok"},
|
||||
{"name":"session-mgmt","pass":8,"total":8,"status":"ok"},
|
||||
{"name":"exchange","pass":8,"total":8,"status":"ok"},
|
||||
{"name":"introspect","pass":9,"total":9,"status":"ok"},
|
||||
{"name":"par","pass":7,"total":7,"status":"ok"},
|
||||
{"name":"dynreg","pass":5,"total":5,"status":"ok"},
|
||||
{"name":"account","pass":11,"total":11,"status":"ok"}
|
||||
]
|
||||
}
|
||||
31
lib/identity/scoreboard.md
Normal file
31
lib/identity/scoreboard.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# identity-on-sx Scoreboard
|
||||
|
||||
**Total: 233 / 233 tests passing**
|
||||
|
||||
| | Suite | Pass | Total |
|
||||
|---|---|---|---|
|
||||
| ✅ | session | 11 | 11 |
|
||||
| ✅ | token | 24 | 24 |
|
||||
| ✅ | registry | 9 | 9 |
|
||||
| ✅ | api | 10 | 10 |
|
||||
| ✅ | oauth | 17 | 17 |
|
||||
| ✅ | sso | 10 | 10 |
|
||||
| ✅ | membership | 17 | 17 |
|
||||
| ✅ | cache | 9 | 9 |
|
||||
| ✅ | audit | 11 | 11 |
|
||||
| ✅ | federation | 12 | 12 |
|
||||
| ✅ | expiry | 8 | 8 |
|
||||
| ✅ | clients | 11 | 11 |
|
||||
| ✅ | grants | 9 | 9 |
|
||||
| ✅ | device | 10 | 10 |
|
||||
| ✅ | facade | 9 | 9 |
|
||||
| ✅ | delegation | 8 | 8 |
|
||||
| ✅ | session-mgmt | 8 | 8 |
|
||||
| ✅ | exchange | 8 | 8 |
|
||||
| ✅ | introspect | 9 | 9 |
|
||||
| ✅ | par | 7 | 7 |
|
||||
| ✅ | dynreg | 5 | 5 |
|
||||
| ✅ | account | 11 | 11 |
|
||||
|
||||
|
||||
Generated by `lib/identity/conformance.sh`.
|
||||
20
lib/identity/session.sx
Normal file
20
lib/identity/session.sx
Normal file
@@ -0,0 +1,20 @@
|
||||
;; identity/session.sx — a session is an Erlang process.
|
||||
;;
|
||||
;; create = spawn a session process holding {subject, client, status}
|
||||
;; lookup = a message; the live process answers {ok, ...} or {error, S}
|
||||
;; expire = explicit message OR an idle timeout the process arms itself
|
||||
;; revoke = explicit message; the grant tombstones immediately
|
||||
;;
|
||||
;; Expiry is the process's own `receive ... after Ttl` timeout, never a
|
||||
;; global sweep. On timeout the process notifies its Owner and becomes a
|
||||
;; tombstone that still answers lookups — with {error, expired}, never a
|
||||
;; silent dead mailbox. A revoked or expired session is an explicit
|
||||
;; negative state, not the absence of a positive one.
|
||||
|
||||
(define
|
||||
identity-session-source
|
||||
"-module(identity_session).\n\n start(SessionId, Subject, Client, Owner, Ttl) ->\n spawn(fun () -> active(SessionId, Subject, Client, Owner, Ttl) end).\n\n lookup(Pid) ->\n Pid ! {lookup, self()},\n receive {session_reply, R} -> R end.\n\n touch(Pid) ->\n Pid ! {touch, self()},\n receive {session_reply, R} -> R end.\n\n expire(Pid) ->\n Pid ! {expire, self()},\n receive {session_reply, R} -> R end.\n\n revoke(Pid) ->\n Pid ! {revoke, self()},\n receive {session_reply, R} -> R end.\n\n stop(Pid) ->\n Pid ! {stop, self()},\n receive {session_reply, R} -> R end.\n\n active(SessionId, Subject, Client, Owner, Ttl) ->\n receive\n {lookup, From} ->\n From ! {session_reply, {ok, {SessionId, Subject, Client, active}}},\n active(SessionId, Subject, Client, Owner, Ttl);\n {touch, From} ->\n From ! {session_reply, ok},\n active(SessionId, Subject, Client, Owner, Ttl);\n {expire, From} ->\n From ! {session_reply, ok},\n tombstone(SessionId, Subject, Client, expired);\n {revoke, From} ->\n From ! {session_reply, ok},\n tombstone(SessionId, Subject, Client, revoked);\n {stop, From} ->\n From ! {session_reply, ok}\n after Ttl ->\n Owner ! {session_expired, SessionId},\n tombstone(SessionId, Subject, Client, expired)\n end.\n\n tombstone(SessionId, Subject, Client, Status) ->\n receive\n {lookup, From} ->\n From ! {session_reply, {error, Status}},\n tombstone(SessionId, Subject, Client, Status);\n {touch, From} ->\n From ! {session_reply, {error, Status}},\n tombstone(SessionId, Subject, Client, Status);\n {expire, From} ->\n From ! {session_reply, ok},\n tombstone(SessionId, Subject, Client, Status);\n {revoke, From} ->\n From ! {session_reply, ok},\n tombstone(SessionId, Subject, Client, revoked);\n {stop, From} ->\n From ! {session_reply, ok}\n end.")
|
||||
|
||||
(define
|
||||
identity-load-session!
|
||||
(fn () (erlang-load-module identity-session-source)))
|
||||
102
lib/identity/tests/account.sx
Normal file
102
lib/identity/tests/account.sx
Normal file
@@ -0,0 +1,102 @@
|
||||
;; identity/tests/account.sx — the account-security surface: \"apps with
|
||||
;; access\" (grants_for / identity:grants) plus \"disconnect this app\"
|
||||
;; (revoke_app / identity:revoke_app). Completes the per-subject view+action
|
||||
;; pair alongside sessions and history.
|
||||
|
||||
(define id-acct-test-count 0)
|
||||
(define id-acct-test-pass 0)
|
||||
(define id-acct-test-fails (list))
|
||||
|
||||
(define
|
||||
id-acct-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! id-acct-test-count (+ id-acct-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! id-acct-test-pass (+ id-acct-test-pass 1))
|
||||
(append! id-acct-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define ida-ev erlang-eval-ast)
|
||||
(define idanm (fn (v) (get v :name)))
|
||||
|
||||
(identity-load-all!)
|
||||
|
||||
;; ── token-level grants_for ───────────────────────────────────────
|
||||
|
||||
(id-acct-test
|
||||
"grants_for lists a subject's active grants"
|
||||
(ida-ev
|
||||
"R = identity_tokens:start(),\n identity_tokens:issue(R, alice, web, read),\n identity_tokens:issue(R, alice, cli, write),\n identity_tokens:issue(R, bob, web, read),\n length(identity_tokens:grants_for(R, alice))")
|
||||
2)
|
||||
|
||||
(id-acct-test
|
||||
"grants_for excludes revoked grants"
|
||||
(ida-ev
|
||||
"R = identity_tokens:start(),\n {ok, A} = identity_tokens:issue(R, alice, web, read),\n identity_tokens:issue(R, alice, cli, write),\n identity_tokens:revoke(R, A),\n length(identity_tokens:grants_for(R, alice))")
|
||||
1)
|
||||
|
||||
(id-acct-test
|
||||
"grants_for is empty for a subject with none"
|
||||
(ida-ev
|
||||
"R = identity_tokens:start(),\n identity_tokens:issue(R, alice, web, read),\n length(identity_tokens:grants_for(R, ghost))")
|
||||
0)
|
||||
|
||||
(id-acct-test
|
||||
"each grant entry carries the client"
|
||||
(idanm
|
||||
(ida-ev
|
||||
"R = identity_tokens:start(),\n identity_tokens:issue(R, alice, web, read),\n case identity_tokens:grants_for(R, alice) of\n [{Client, _Scope}] -> Client;\n _ -> other\n end"))
|
||||
"web")
|
||||
|
||||
;; ── token-level revoke_app (\"disconnect this app\") ────────────────
|
||||
|
||||
(id-acct-test
|
||||
"revoke_app revokes all of a subject's grants for one client"
|
||||
(ida-ev
|
||||
"R = identity_tokens:start(),\n identity_tokens:issue(R, alice, web, read),\n identity_tokens:issue(R, alice, web, write),\n identity_tokens:issue(R, alice, cli, read),\n identity_tokens:revoke_app(R, alice, web),\n length(identity_tokens:grants_for(R, alice))")
|
||||
1)
|
||||
|
||||
(id-acct-test
|
||||
"revoke_app deactivates that client's tokens"
|
||||
(idanm
|
||||
(ida-ev
|
||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read),\n identity_tokens:revoke_app(R, alice, web),\n case identity_tokens:introspect(R, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"inactive")
|
||||
|
||||
(id-acct-test
|
||||
"revoke_app leaves another subject's same-client grant intact"
|
||||
(ida-ev
|
||||
"R = identity_tokens:start(),\n identity_tokens:issue(R, alice, web, read),\n identity_tokens:issue(R, bob, web, read),\n identity_tokens:revoke_app(R, alice, web),\n length(identity_tokens:grants_for(R, bob))")
|
||||
1)
|
||||
|
||||
;; ── facade-level grants + revoke_app ─────────────────────────────
|
||||
|
||||
(id-acct-test
|
||||
"identity:grants lists apps a subject has logged into"
|
||||
(ida-ev
|
||||
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, alice, mobile, read),\n length(identity:grants(Svc, alice))")
|
||||
2)
|
||||
|
||||
(id-acct-test
|
||||
"identity:revoke_app disconnects one app, leaving the rest"
|
||||
(ida-ev
|
||||
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, alice, mobile, read),\n identity:revoke_app(Svc, alice, web),\n length(identity:grants(Svc, alice))")
|
||||
1)
|
||||
|
||||
(id-acct-test
|
||||
"identity:grants is per-subject"
|
||||
(ida-ev
|
||||
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, bob, web, read),\n length(identity:grants(Svc, bob))")
|
||||
1)
|
||||
|
||||
(id-acct-test
|
||||
"revoke_app is audited as a revoke"
|
||||
(idanm
|
||||
(ida-ev
|
||||
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:revoke_app(Svc, alice, web),\n case identity:history(Svc, alice) of\n [login, issue, revoke] -> audited;\n Other -> Other\n end"))
|
||||
"audited")
|
||||
|
||||
(define
|
||||
id-acct-test-summary
|
||||
(str "account " id-acct-test-pass "/" id-acct-test-count))
|
||||
111
lib/identity/tests/api.sx
Normal file
111
lib/identity/tests/api.sx
Normal file
@@ -0,0 +1,111 @@
|
||||
;; identity/tests/api.sx — the service facade end-to-end: login issues a
|
||||
;; session + token, verify proves identity, revoke and logout take effect
|
||||
;; immediately. Exercises session + token + registry through one door.
|
||||
|
||||
(define id-api-test-count 0)
|
||||
(define id-api-test-pass 0)
|
||||
(define id-api-test-fails (list))
|
||||
|
||||
(define
|
||||
id-api-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! id-api-test-count (+ id-api-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! id-api-test-pass (+ id-api-test-pass 1))
|
||||
(append! id-api-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define ida-ev erlang-eval-ast)
|
||||
(define idanm (fn (v) (get v :name)))
|
||||
|
||||
(identity-load-all!)
|
||||
|
||||
;; ── login + verify (happy path) ──────────────────────────────────
|
||||
|
||||
(id-api-test
|
||||
"login then verify is active"
|
||||
(idanm
|
||||
(ida-ev
|
||||
"Svc = identity:start(),\n {ok, _Sid, Tok} = identity:login(Svc, alice, web, read),\n case identity:verify(Svc, Tok) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"active")
|
||||
|
||||
(id-api-test
|
||||
"verify returns the logged-in subject"
|
||||
(idanm
|
||||
(ida-ev
|
||||
"Svc = identity:start(),\n {ok, _Sid, Tok} = identity:login(Svc, alice, web, read),\n case identity:verify(Svc, Tok) of\n {active, Subject, _, _} -> Subject\n end"))
|
||||
"alice")
|
||||
|
||||
(id-api-test
|
||||
"verify returns the granted scope"
|
||||
(idanm
|
||||
(ida-ev
|
||||
"Svc = identity:start(),\n {ok, _Sid, Tok} = identity:login(Svc, bob, cli, write),\n case identity:verify(Svc, Tok) of\n {active, _, _, Scope} -> Scope\n end"))
|
||||
"write")
|
||||
|
||||
;; ── revoke is real through the facade ────────────────────────────
|
||||
|
||||
(id-api-test
|
||||
"revoked token verifies inactive immediately"
|
||||
(idanm
|
||||
(ida-ev
|
||||
"Svc = identity:start(),\n {ok, _Sid, Tok} = identity:login(Svc, alice, web, read),\n identity:revoke(Svc, Tok),\n case identity:verify(Svc, Tok) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end"))
|
||||
"inactive")
|
||||
|
||||
;; ── session lifecycle through the facade ─────────────────────────
|
||||
|
||||
(id-api-test
|
||||
"fresh session reports active"
|
||||
(idanm
|
||||
(ida-ev
|
||||
"Svc = identity:start(),\n {ok, Sid, _Tok} = identity:login(Svc, alice, web, read),\n identity:session_status(Svc, Sid)"))
|
||||
"active")
|
||||
|
||||
(id-api-test
|
||||
"logout makes the session gone"
|
||||
(idanm
|
||||
(ida-ev
|
||||
"Svc = identity:start(),\n {ok, Sid, _Tok} = identity:login(Svc, alice, web, read),\n identity:logout(Svc, Sid),\n identity:session_status(Svc, Sid)"))
|
||||
"gone")
|
||||
|
||||
(id-api-test
|
||||
"status of an unknown session is gone"
|
||||
(idanm
|
||||
(ida-ev "Svc = identity:start(),\n identity:session_status(Svc, 999)"))
|
||||
"gone")
|
||||
|
||||
;; ── independence: logins do not bleed into each other ────────────
|
||||
|
||||
(id-api-test
|
||||
"revoking one login leaves the other active"
|
||||
(idanm
|
||||
(ida-ev
|
||||
"Svc = identity:start(),\n {ok, _S1, T1} = identity:login(Svc, alice, web, read),\n {ok, _S2, T2} = identity:login(Svc, bob, cli, write),\n identity:revoke(Svc, T1),\n case identity:verify(Svc, T2) of\n {active, Subject, _, _} -> Subject;\n {inactive} -> inactive\n end"))
|
||||
"bob")
|
||||
|
||||
(id-api-test
|
||||
"logging out one session leaves the other active"
|
||||
(idanm
|
||||
(ida-ev
|
||||
"Svc = identity:start(),\n {ok, S1, _T1} = identity:login(Svc, alice, web, read),\n {ok, S2, _T2} = identity:login(Svc, alice, cli, read),\n identity:logout(Svc, S1),\n identity:session_status(Svc, S2)"))
|
||||
"active")
|
||||
|
||||
;; ── coordinator deregisters on a session_expired notification ────
|
||||
;; A live idle session fires its own `after` timeout and notifies its
|
||||
;; owner (the coordinator), which then deregisters it — timeout-driven,
|
||||
;; never swept. The owner-internal path can't be observed by driving the
|
||||
;; scheduler idle from the test's main process, so we assert the handler
|
||||
;; directly: the mailbox is FIFO, so the expiry notification is processed
|
||||
;; before the following status query.
|
||||
|
||||
(id-api-test
|
||||
"session_expired notification deregisters the session"
|
||||
(idanm
|
||||
(ida-ev
|
||||
"Svc = identity:start(),\n {ok, Sid, _Tok} = identity:login(Svc, alice, web, read, 50),\n active = identity:session_status(Svc, Sid),\n Svc ! {session_expired, Sid},\n identity:session_status(Svc, Sid)"))
|
||||
"gone")
|
||||
|
||||
(define
|
||||
id-api-test-summary
|
||||
(str "api " id-api-test-pass "/" id-api-test-count))
|
||||
117
lib/identity/tests/audit.sx
Normal file
117
lib/identity/tests/audit.sx
Normal file
@@ -0,0 +1,117 @@
|
||||
;; identity/tests/audit.sx — the grant audit ledger. Every grant
|
||||
;; transition is recorded; the ledger is queryable per subject and
|
||||
;; chronological. Covers issue/refresh/revoke wiring through the token
|
||||
;; registry, reuse-triggered revoke, per-subject isolation, completeness,
|
||||
;; and direct ledger use.
|
||||
|
||||
(define id-audit-test-count 0)
|
||||
(define id-audit-test-pass 0)
|
||||
(define id-audit-test-fails (list))
|
||||
|
||||
(define
|
||||
id-audit-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! id-audit-test-count (+ id-audit-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! id-audit-test-pass (+ id-audit-test-pass 1))
|
||||
(append! id-audit-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define ida-ev erlang-eval-ast)
|
||||
(define idanm (fn (v) (get v :name)))
|
||||
|
||||
(identity-load-audit!)
|
||||
(identity-load-token!)
|
||||
|
||||
;; ── issue is audited ─────────────────────────────────────────────
|
||||
|
||||
(id-audit-test
|
||||
"issue records one event for the subject"
|
||||
(ida-ev
|
||||
"A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n identity_tokens:issue(Reg, alice, web, read),\n identity_audit:count(A, alice)")
|
||||
1)
|
||||
|
||||
(id-audit-test
|
||||
"the recorded action is issue"
|
||||
(idanm
|
||||
(ida-ev
|
||||
"A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n identity_tokens:issue(Reg, alice, web, read),\n case identity_audit:actions(A, alice) of\n [issue] -> matched;\n _ -> nomatch\n end"))
|
||||
"matched")
|
||||
|
||||
;; ── full grant lifecycle is audited in order ─────────────────────
|
||||
|
||||
(id-audit-test
|
||||
"issue, refresh, revoke are recorded in order"
|
||||
(idanm
|
||||
(ida-ev
|
||||
"A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n {ok, G, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n identity_tokens:refresh(Reg, R),\n identity_tokens:revoke(Reg, G),\n case identity_audit:actions(A, alice) of\n [issue, refresh, revoke] -> matched;\n _ -> nomatch\n end"))
|
||||
"matched")
|
||||
|
||||
;; ── reuse-triggered revoke is audited ────────────────────────────
|
||||
|
||||
(id-audit-test
|
||||
"a refresh-reuse cascade records a revoke event"
|
||||
(idanm
|
||||
(ida-ev
|
||||
"A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n {ok, _G, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n identity_tokens:refresh(Reg, R),\n identity_tokens:refresh(Reg, R),\n case identity_audit:actions(A, alice) of\n [issue, refresh, revoke] -> matched;\n _ -> nomatch\n end"))
|
||||
"matched")
|
||||
|
||||
;; ── per-subject isolation ────────────────────────────────────────
|
||||
|
||||
(id-audit-test
|
||||
"the ledger separates subjects"
|
||||
(ida-ev
|
||||
"A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n identity_tokens:issue(Reg, alice, web, read),\n identity_tokens:issue(Reg, bob, cli, write),\n identity_tokens:issue(Reg, alice, mobile, read),\n identity_audit:count(A, alice)")
|
||||
2)
|
||||
|
||||
(id-audit-test
|
||||
"an unaudited subject has zero events"
|
||||
(ida-ev
|
||||
"A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n identity_tokens:issue(Reg, alice, web, read),\n identity_audit:count(A, ghost)")
|
||||
0)
|
||||
|
||||
;; ── the full log accumulates across subjects ─────────────────────
|
||||
|
||||
(id-audit-test
|
||||
"all events accumulate in the ledger"
|
||||
(ida-ev
|
||||
"A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n identity_tokens:issue(Reg, alice, web, read),\n identity_tokens:issue(Reg, bob, cli, write),\n length(identity_audit:all(A))")
|
||||
2)
|
||||
|
||||
;; ── completeness: no grant transition is dropped ─────────────────
|
||||
|
||||
(id-audit-test
|
||||
"the ledger is complete across a mixed transition stream"
|
||||
(ida-ev
|
||||
"A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n identity_tokens:issue(Reg, alice, web, read),\n {ok, _G, R} = identity_tokens:issue_grant(Reg, alice, cli, read),\n identity_tokens:refresh(Reg, R),\n {ok, B} = identity_tokens:issue(Reg, bob, web, read),\n identity_tokens:revoke(Reg, B),\n length(identity_audit:all(A))")
|
||||
5)
|
||||
|
||||
;; ── start/0 stays unaudited (no regression) ──────────────────────
|
||||
|
||||
(id-audit-test
|
||||
"an unaudited registry still issues working tokens"
|
||||
(idanm
|
||||
(ida-ev
|
||||
"Reg = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(Reg, alice, web, read),\n case identity_tokens:introspect(Reg, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"active")
|
||||
|
||||
;; ── direct ledger use (e.g. login/consent events) ────────────────
|
||||
|
||||
(id-audit-test
|
||||
"events can be recorded directly on the ledger"
|
||||
(idanm
|
||||
(ida-ev
|
||||
"A = identity_audit:start(),\n identity_audit:record(A, alice, login),\n identity_audit:record(A, alice, consent),\n case identity_audit:actions(A, alice) of\n [login, consent] -> matched;\n _ -> nomatch\n end"))
|
||||
"matched")
|
||||
|
||||
(id-audit-test
|
||||
"an audit entry carries its subject"
|
||||
(idanm
|
||||
(ida-ev
|
||||
"A = identity_audit:start(),\n identity_audit:record(A, alice, login),\n case identity_audit:audit(A, alice) of\n [{_, Subject, _}] -> Subject;\n _ -> nomatch\n end"))
|
||||
"alice")
|
||||
|
||||
(define
|
||||
id-audit-test-summary
|
||||
(str "audit " id-audit-test-pass "/" id-audit-test-count))
|
||||
102
lib/identity/tests/cache.sx
Normal file
102
lib/identity/tests/cache.sx
Normal file
@@ -0,0 +1,102 @@
|
||||
;; identity/tests/cache.sx — delegated grant-verification cache. Proves
|
||||
;; the cache is live (hits/misses) AND that revocation stays real: a
|
||||
;; revoked token never reads valid out of the cache, because any revoke
|
||||
;; bumps the generation and forces re-validation.
|
||||
|
||||
(define id-cache-test-count 0)
|
||||
(define id-cache-test-pass 0)
|
||||
(define id-cache-test-fails (list))
|
||||
|
||||
(define
|
||||
id-cache-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! id-cache-test-count (+ id-cache-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! id-cache-test-pass (+ id-cache-test-pass 1))
|
||||
(append! id-cache-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define idc-ev erlang-eval-ast)
|
||||
(define idcnm (fn (v) (get v :name)))
|
||||
|
||||
(identity-load-token!)
|
||||
(identity-load-cache!)
|
||||
|
||||
;; ── delegation: cache forwards to the registry ───────────────────
|
||||
|
||||
(id-cache-test
|
||||
"introspect through the cache returns active"
|
||||
(idcnm
|
||||
(idc-ev
|
||||
"C = identity_grant_cache:start(),\n {ok, T} = identity_grant_cache:issue(C, alice, web, read),\n case identity_grant_cache:introspect(C, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"active")
|
||||
|
||||
;; ── the cache is actually caching ────────────────────────────────
|
||||
|
||||
(id-cache-test
|
||||
"a repeated introspect is a cache hit"
|
||||
(idc-ev
|
||||
"C = identity_grant_cache:start(),\n {ok, T} = identity_grant_cache:issue(C, alice, web, read),\n identity_grant_cache:introspect(C, T),\n identity_grant_cache:introspect(C, T),\n case identity_grant_cache:stats(C) of {H, _} -> H end")
|
||||
1)
|
||||
|
||||
(id-cache-test
|
||||
"the first introspect of a token is a miss"
|
||||
(idc-ev
|
||||
"C = identity_grant_cache:start(),\n {ok, T} = identity_grant_cache:issue(C, alice, web, read),\n identity_grant_cache:introspect(C, T),\n identity_grant_cache:introspect(C, T),\n case identity_grant_cache:stats(C) of {_, M} -> M end")
|
||||
1)
|
||||
|
||||
;; ── revocation stays real through the cache (the centrepiece) ─────
|
||||
|
||||
(id-cache-test
|
||||
"a revoked token introspects inactive through the cache"
|
||||
(idcnm
|
||||
(idc-ev
|
||||
"C = identity_grant_cache:start(),\n {ok, T} = identity_grant_cache:issue(C, alice, web, read),\n identity_grant_cache:introspect(C, T),\n identity_grant_cache:revoke(C, T),\n case identity_grant_cache:introspect(C, T) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end"))
|
||||
"inactive")
|
||||
|
||||
(id-cache-test
|
||||
"revoke invalidates the cache (post-revoke read re-validates)"
|
||||
(idc-ev
|
||||
"C = identity_grant_cache:start(),\n {ok, T} = identity_grant_cache:issue(C, alice, web, read),\n identity_grant_cache:introspect(C, T),\n identity_grant_cache:revoke(C, T),\n identity_grant_cache:introspect(C, T),\n case identity_grant_cache:stats(C) of {_, M} -> M end")
|
||||
2)
|
||||
|
||||
;; ── cascade visibility through the cache ──────────────────────────
|
||||
|
||||
(id-cache-test
|
||||
"cascade revocation is visible through the cache"
|
||||
(idcnm
|
||||
(idc-ev
|
||||
"C = identity_grant_cache:start(),\n {ok, A, R} = identity_grant_cache:issue_grant(C, alice, web, read),\n identity_grant_cache:introspect(C, A),\n identity_grant_cache:revoke(C, R),\n case identity_grant_cache:introspect(C, A) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end"))
|
||||
"inactive")
|
||||
|
||||
;; ── a sibling token re-validates correctly after a revoke ────────
|
||||
|
||||
(id-cache-test
|
||||
"revoking one token leaves an independent token valid"
|
||||
(idcnm
|
||||
(idc-ev
|
||||
"C = identity_grant_cache:start(),\n {ok, A} = identity_grant_cache:issue(C, alice, web, read),\n {ok, B} = identity_grant_cache:issue(C, bob, cli, write),\n identity_grant_cache:introspect(C, A),\n identity_grant_cache:introspect(C, B),\n identity_grant_cache:revoke(C, A),\n case identity_grant_cache:introspect(C, B) of\n {active, Subject, _, _} -> Subject;\n {inactive} -> inactive\n end"))
|
||||
"bob")
|
||||
|
||||
;; ── refresh flows through the cache and stays correct ────────────
|
||||
|
||||
(id-cache-test
|
||||
"a refreshed token introspects active through the cache"
|
||||
(idcnm
|
||||
(idc-ev
|
||||
"C = identity_grant_cache:start(),\n {ok, _A, R} = identity_grant_cache:issue_grant(C, alice, web, read),\n {ok, A2, _R2} = identity_grant_cache:refresh(C, R),\n case identity_grant_cache:introspect(C, A2) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"active")
|
||||
|
||||
;; ── unknown token is inactive, and cached as such ────────────────
|
||||
|
||||
(id-cache-test
|
||||
"an unknown token introspects inactive through the cache"
|
||||
(idcnm
|
||||
(idc-ev
|
||||
"C = identity_grant_cache:start(),\n Bogus = make_ref(),\n case identity_grant_cache:introspect(C, Bogus) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"inactive")
|
||||
|
||||
(define
|
||||
id-cache-test-summary
|
||||
(str "cache " id-cache-test-pass "/" id-cache-test-count))
|
||||
108
lib/identity/tests/clients.sx
Normal file
108
lib/identity/tests/clients.sx
Normal file
@@ -0,0 +1,108 @@
|
||||
;; identity/tests/clients.sx — OAuth client registry: registration,
|
||||
;; public vs confidential authentication, and redirect_uri allow-listing.
|
||||
|
||||
(define id-clients-test-count 0)
|
||||
(define id-clients-test-pass 0)
|
||||
(define id-clients-test-fails (list))
|
||||
|
||||
(define
|
||||
id-clients-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! id-clients-test-count (+ id-clients-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! id-clients-test-pass (+ id-clients-test-pass 1))
|
||||
(append! id-clients-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define idc-ev erlang-eval-ast)
|
||||
(define idcnm (fn (v) (get v :name)))
|
||||
|
||||
(identity-load-clients!)
|
||||
|
||||
;; ── registration + lookup ────────────────────────────────────────
|
||||
|
||||
(id-clients-test
|
||||
"a registered client looks up its type"
|
||||
(idcnm
|
||||
(idc-ev
|
||||
"C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1]),\n case identity_clients:lookup(C, app1) of\n {ok, Type, _} -> Type;\n {error, W} -> W\n end"))
|
||||
"confidential")
|
||||
|
||||
(id-clients-test
|
||||
"registering the same client twice is an error"
|
||||
(idcnm
|
||||
(idc-ev
|
||||
"C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1]),\n case identity_clients:register(C, app1, public, none, [uri1]) of\n ok -> ok;\n {error, W} -> W\n end"))
|
||||
"exists")
|
||||
|
||||
(id-clients-test
|
||||
"looking up an unregistered client is unknown_client"
|
||||
(idcnm
|
||||
(idc-ev
|
||||
"C = identity_clients:start(),\n case identity_clients:lookup(C, ghost) of\n {ok, _, _} -> found;\n {error, W} -> W\n end"))
|
||||
"unknown_client")
|
||||
|
||||
;; ── confidential client authentication ───────────────────────────
|
||||
|
||||
(id-clients-test
|
||||
"a confidential client authenticates with the right secret"
|
||||
(idcnm
|
||||
(idc-ev
|
||||
"C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1]),\n case identity_clients:authenticate(C, app1, s3cret) of\n {ok, Kind} -> Kind;\n {error, W} -> W\n end"))
|
||||
"confidential")
|
||||
|
||||
(id-clients-test
|
||||
"a confidential client with the wrong secret is invalid_client"
|
||||
(idcnm
|
||||
(idc-ev
|
||||
"C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1]),\n case identity_clients:authenticate(C, app1, wrongsecret) of\n {ok, _} -> accepted;\n {error, W} -> W\n end"))
|
||||
"invalid_client")
|
||||
|
||||
(id-clients-test
|
||||
"a public client needs no secret to authenticate"
|
||||
(idcnm
|
||||
(idc-ev
|
||||
"C = identity_clients:start(),\n identity_clients:register(C, spa, public, none, [uri1]),\n case identity_clients:authenticate(C, spa, anything) of\n {ok, Kind} -> Kind;\n {error, W} -> W\n end"))
|
||||
"public")
|
||||
|
||||
(id-clients-test
|
||||
"authenticating an unknown client is unknown_client"
|
||||
(idcnm
|
||||
(idc-ev
|
||||
"C = identity_clients:start(),\n case identity_clients:authenticate(C, ghost, x) of\n {ok, _} -> accepted;\n {error, W} -> W\n end"))
|
||||
"unknown_client")
|
||||
|
||||
;; ── redirect_uri allow-listing ───────────────────────────────────
|
||||
|
||||
(id-clients-test
|
||||
"a registered redirect_uri is valid"
|
||||
(idcnm
|
||||
(idc-ev
|
||||
"C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1, uri2]),\n case identity_clients:valid_redirect(C, app1, uri1) of\n true -> yes;\n false -> no\n end"))
|
||||
"yes")
|
||||
|
||||
(id-clients-test
|
||||
"a second registered redirect_uri is also valid"
|
||||
(idcnm
|
||||
(idc-ev
|
||||
"C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1, uri2]),\n case identity_clients:valid_redirect(C, app1, uri2) of\n true -> yes;\n false -> no\n end"))
|
||||
"yes")
|
||||
|
||||
(id-clients-test
|
||||
"an unregistered redirect_uri is rejected"
|
||||
(idcnm
|
||||
(idc-ev
|
||||
"C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1]),\n case identity_clients:valid_redirect(C, app1, evil_uri) of\n true -> yes;\n false -> no\n end"))
|
||||
"no")
|
||||
|
||||
(id-clients-test
|
||||
"redirect validation for an unknown client is rejected"
|
||||
(idcnm
|
||||
(idc-ev
|
||||
"C = identity_clients:start(),\n case identity_clients:valid_redirect(C, ghost, uri1) of\n true -> yes;\n false -> no\n end"))
|
||||
"no")
|
||||
|
||||
(define
|
||||
id-clients-test-summary
|
||||
(str "clients " id-clients-test-pass "/" id-clients-test-count))
|
||||
102
lib/identity/tests/delegation.sx
Normal file
102
lib/identity/tests/delegation.sx
Normal file
@@ -0,0 +1,102 @@
|
||||
;; identity/tests/delegation.sx — the identity -> acl boundary.
|
||||
;; Authentication (identity) gates BEFORE authorization (acl): an inactive
|
||||
;; token is unauthenticated (401) and acl is never consulted; only an
|
||||
;; authenticated subject's request is delegated to acl for permit/deny.
|
||||
|
||||
(define id-deleg-test-count 0)
|
||||
(define id-deleg-test-pass 0)
|
||||
(define id-deleg-test-fails (list))
|
||||
|
||||
(define
|
||||
id-deleg-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! id-deleg-test-count (+ id-deleg-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! id-deleg-test-pass (+ id-deleg-test-pass 1))
|
||||
(append! id-deleg-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define idl-ev erlang-eval-ast)
|
||||
(define idlnm (fn (v) (get v :name)))
|
||||
|
||||
(identity-load-delegation!)
|
||||
|
||||
;; Shared prelude: a token registry, a stub acl, and a token granting
|
||||
;; [read, write] to alice, all bound.
|
||||
(define
|
||||
idl-setup
|
||||
"R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n {ok, T} = identity_tokens:issue(R, alice, web, [read, write])")
|
||||
|
||||
;; ── authenticated + acl permits ──────────────────────────────────
|
||||
|
||||
(id-deleg-test
|
||||
"an authenticated, permitted request returns the subject"
|
||||
(idlnm
|
||||
(idl-ev
|
||||
(str
|
||||
idl-setup
|
||||
", case identity_delegation:check(R, A, T, read, doc1) of\n {ok, S} -> S;\n {error, W} -> W\n end")))
|
||||
"alice")
|
||||
|
||||
;; ── authenticated + acl denies → 403 ─────────────────────────────
|
||||
|
||||
(id-deleg-test
|
||||
"an authenticated but unpermitted request is forbidden"
|
||||
(idlnm
|
||||
(idl-ev
|
||||
"R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n {ok, T} = identity_tokens:issue(R, alice, web, [read]),\n case identity_delegation:check(R, A, T, write, doc1) of\n {ok, _} -> permitted;\n {error, W} -> W\n end"))
|
||||
"forbidden")
|
||||
|
||||
;; ── unauthenticated → 401, acl never consulted ───────────────────
|
||||
|
||||
(id-deleg-test
|
||||
"a revoked token is unauthenticated, not forbidden"
|
||||
(idlnm
|
||||
(idl-ev
|
||||
(str
|
||||
idl-setup
|
||||
", identity_tokens:revoke(R, T),\n case identity_delegation:check(R, A, T, read, doc1) of\n {ok, _} -> permitted;\n {error, W} -> W\n end")))
|
||||
"unauthenticated")
|
||||
|
||||
(id-deleg-test
|
||||
"an unknown token is unauthenticated"
|
||||
(idlnm
|
||||
(idl-ev
|
||||
"R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n Bogus = make_ref(),\n case identity_delegation:check(R, A, Bogus, read, doc1) of\n {ok, _} -> permitted;\n {error, W} -> W\n end"))
|
||||
"unauthenticated")
|
||||
|
||||
(id-deleg-test
|
||||
"an expired token is unauthenticated"
|
||||
(idlnm
|
||||
(idl-ev
|
||||
"R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n {ok, T} = identity_tokens:issue(R, alice, web, [read], 100),\n identity_tokens:advance(R, 100),\n case identity_delegation:check(R, A, T, read, doc1) of\n {ok, _} -> permitted;\n {error, W} -> W\n end"))
|
||||
"unauthenticated")
|
||||
|
||||
;; ── 401 takes precedence over 403 (identity gates first) ─────────
|
||||
|
||||
(id-deleg-test
|
||||
"a revoked token with no matching scope is still unauthenticated"
|
||||
(idlnm
|
||||
(idl-ev
|
||||
"R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n {ok, T} = identity_tokens:issue(R, alice, web, [admin]),\n identity_tokens:revoke(R, T),\n case identity_delegation:check(R, A, T, read, doc1) of\n {ok, _} -> permitted;\n {error, W} -> W\n end"))
|
||||
"unauthenticated")
|
||||
|
||||
;; ── acl is what decides for an authenticated subject ─────────────
|
||||
|
||||
(id-deleg-test
|
||||
"the same subject is permitted one action and denied another"
|
||||
(idl-ev
|
||||
"R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n {ok, T} = identity_tokens:issue(R, alice, web, [read]),\n Allowed = case identity_delegation:check(R, A, T, read, doc1) of\n {ok, _} -> 1; {error, _} -> 0 end,\n Denied = case identity_delegation:check(R, A, T, write, doc1) of\n {ok, _} -> 1; {error, _} -> 0 end,\n Allowed - Denied")
|
||||
1)
|
||||
|
||||
(id-deleg-test
|
||||
"identity does not widen permission beyond the token scope"
|
||||
(idlnm
|
||||
(idl-ev
|
||||
"R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n {ok, T} = identity_tokens:issue(R, alice, web, [read, write]),\n case identity_delegation:check(R, A, T, delete, doc1) of\n {ok, _} -> permitted;\n {error, W} -> W\n end"))
|
||||
"forbidden")
|
||||
|
||||
(define
|
||||
id-deleg-test-summary
|
||||
(str "delegation " id-deleg-test-pass "/" id-deleg-test-count))
|
||||
109
lib/identity/tests/device.sx
Normal file
109
lib/identity/tests/device.sx
Normal file
@@ -0,0 +1,109 @@
|
||||
;; identity/tests/device.sx — device authorization grant (RFC 8628):
|
||||
;; authorize → poll(pending) → approve/deny out-of-band → poll(token/denied).
|
||||
|
||||
(define id-device-test-count 0)
|
||||
(define id-device-test-pass 0)
|
||||
(define id-device-test-fails (list))
|
||||
|
||||
(define
|
||||
id-device-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! id-device-test-count (+ id-device-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! id-device-test-pass (+ id-device-test-pass 1))
|
||||
(append! id-device-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define idd-ev erlang-eval-ast)
|
||||
(define iddnm (fn (v) (get v :name)))
|
||||
|
||||
(identity-load-device!)
|
||||
|
||||
;; ── polling before approval ──────────────────────────────────────
|
||||
|
||||
(id-device-test
|
||||
"polling a pending device code is authorization_pending"
|
||||
(iddnm
|
||||
(idd-ev
|
||||
"D = identity_device:start(),\n {ok, Dc, _Uc} = identity_device:authorize(D, tv, watch),\n case identity_device:poll(D, Dc) of\n {ok, _} -> got;\n {error, W} -> W\n end"))
|
||||
"authorization_pending")
|
||||
|
||||
;; ── approve → token ──────────────────────────────────────────────
|
||||
|
||||
(id-device-test
|
||||
"after approval, polling yields a working token"
|
||||
(iddnm
|
||||
(idd-ev
|
||||
"D = identity_device:start(),\n {ok, Dc, Uc} = identity_device:authorize(D, tv, watch),\n identity_device:approve(D, Uc, alice),\n {ok, T} = identity_device:poll(D, Dc),\n case identity_device:introspect(D, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"active")
|
||||
|
||||
(id-device-test
|
||||
"the device token carries the approving subject"
|
||||
(iddnm
|
||||
(idd-ev
|
||||
"D = identity_device:start(),\n {ok, Dc, Uc} = identity_device:authorize(D, tv, watch),\n identity_device:approve(D, Uc, alice),\n {ok, T} = identity_device:poll(D, Dc),\n case identity_device:introspect(D, T) of\n {active, Subject, _, _} -> Subject\n end"))
|
||||
"alice")
|
||||
|
||||
(id-device-test
|
||||
"the device token carries the requested scope"
|
||||
(iddnm
|
||||
(idd-ev
|
||||
"D = identity_device:start(),\n {ok, Dc, Uc} = identity_device:authorize(D, tv, stream),\n identity_device:approve(D, Uc, alice),\n {ok, T} = identity_device:poll(D, Dc),\n case identity_device:introspect(D, T) of\n {active, _, _, Scope} -> Scope\n end"))
|
||||
"stream")
|
||||
|
||||
;; ── deny ─────────────────────────────────────────────────────────
|
||||
|
||||
(id-device-test
|
||||
"after denial, polling is access_denied"
|
||||
(iddnm
|
||||
(idd-ev
|
||||
"D = identity_device:start(),\n {ok, Dc, Uc} = identity_device:authorize(D, tv, watch),\n identity_device:deny(D, Uc),\n case identity_device:poll(D, Dc) of\n {ok, _} -> got;\n {error, W} -> W\n end"))
|
||||
"access_denied")
|
||||
|
||||
;; ── unknown codes ────────────────────────────────────────────────
|
||||
|
||||
(id-device-test
|
||||
"polling an unknown device code is invalid_grant"
|
||||
(iddnm
|
||||
(idd-ev
|
||||
"D = identity_device:start(),\n Bogus = make_ref(),\n case identity_device:poll(D, Bogus) of\n {ok, _} -> got;\n {error, W} -> W\n end"))
|
||||
"invalid_grant")
|
||||
|
||||
(id-device-test
|
||||
"approving an unknown user code is unknown_code"
|
||||
(iddnm
|
||||
(idd-ev
|
||||
"D = identity_device:start(),\n Bogus = make_ref(),\n case identity_device:approve(D, Bogus, alice) of\n ok -> ok;\n {error, W} -> W\n end"))
|
||||
"unknown_code")
|
||||
|
||||
;; ── single-use device code ───────────────────────────────────────
|
||||
|
||||
(id-device-test
|
||||
"the device code is single-use after issuing a token"
|
||||
(iddnm
|
||||
(idd-ev
|
||||
"D = identity_device:start(),\n {ok, Dc, Uc} = identity_device:authorize(D, tv, watch),\n identity_device:approve(D, Uc, alice),\n identity_device:poll(D, Dc),\n case identity_device:poll(D, Dc) of\n {ok, _} -> got;\n {error, W} -> W\n end"))
|
||||
"invalid_grant")
|
||||
|
||||
;; ── guarded transitions ──────────────────────────────────────────
|
||||
|
||||
(id-device-test
|
||||
"approving an already-denied request is rejected"
|
||||
(iddnm
|
||||
(idd-ev
|
||||
"D = identity_device:start(),\n {ok, _Dc, Uc} = identity_device:authorize(D, tv, watch),\n identity_device:deny(D, Uc),\n case identity_device:approve(D, Uc, alice) of\n ok -> ok;\n {error, W} -> W\n end"))
|
||||
"denied")
|
||||
|
||||
;; ── independence ─────────────────────────────────────────────────
|
||||
|
||||
(id-device-test
|
||||
"two device requests are independent"
|
||||
(iddnm
|
||||
(idd-ev
|
||||
"D = identity_device:start(),\n {ok, Dc1, Uc1} = identity_device:authorize(D, tv, watch),\n {ok, Dc2, _Uc2} = identity_device:authorize(D, cli, deploy),\n identity_device:approve(D, Uc1, alice),\n case identity_device:poll(D, Dc2) of\n {ok, _} -> got;\n {error, W} -> W\n end"))
|
||||
"authorization_pending")
|
||||
|
||||
(define
|
||||
id-device-test-summary
|
||||
(str "device " id-device-test-pass "/" id-device-test-count))
|
||||
68
lib/identity/tests/dynreg.sx
Normal file
68
lib/identity/tests/dynreg.sx
Normal file
@@ -0,0 +1,68 @@
|
||||
;; identity/tests/dynreg.sx — dynamic client registration (RFC 7591): the
|
||||
;; server generates the client_id + secret for self-service onboarding.
|
||||
|
||||
(define id-dyn-test-count 0)
|
||||
(define id-dyn-test-pass 0)
|
||||
(define id-dyn-test-fails (list))
|
||||
|
||||
(define
|
||||
id-dyn-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! id-dyn-test-count (+ id-dyn-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! id-dyn-test-pass (+ id-dyn-test-pass 1))
|
||||
(append! id-dyn-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define idd-ev erlang-eval-ast)
|
||||
(define iddnm (fn (v) (get v :name)))
|
||||
|
||||
(identity-load-oauth!)
|
||||
|
||||
;; ── self-service registration yields usable credentials ──────────
|
||||
|
||||
(id-dyn-test
|
||||
"a dynamically registered confidential client can get a token"
|
||||
(iddnm
|
||||
(idd-ev
|
||||
"O = identity_oauth:start(),\n {ok, Cid, Sec} = identity_oauth:register_dynamic(O, confidential, [uri1]),\n {ok, T} = identity_oauth:client_credentials(O, Cid, Sec, batch),\n case identity_oauth:introspect(O, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"active")
|
||||
|
||||
(id-dyn-test
|
||||
"the token's subject is the generated client id"
|
||||
(iddnm
|
||||
(idd-ev
|
||||
"O = identity_oauth:start(),\n {ok, Cid, Sec} = identity_oauth:register_dynamic(O, confidential, [uri1]),\n {ok, T} = identity_oauth:client_credentials(O, Cid, Sec, batch),\n case identity_oauth:introspect(O, T) of\n {active, Sub, _, _} ->\n case Sub =:= Cid of true -> matches; false -> mismatch end;\n {inactive} -> inactive\n end"))
|
||||
"matches")
|
||||
|
||||
;; ── the generated secret is required ─────────────────────────────
|
||||
|
||||
(id-dyn-test
|
||||
"a wrong secret for a dynamic client is invalid_client"
|
||||
(iddnm
|
||||
(idd-ev
|
||||
"O = identity_oauth:start(),\n {ok, Cid, _Sec} = identity_oauth:register_dynamic(O, confidential, [uri1]),\n case identity_oauth:client_credentials(O, Cid, wrongsecret, batch) of\n {ok, _} -> issued;\n {error, W} -> W\n end"))
|
||||
"invalid_client")
|
||||
|
||||
;; ── uniqueness ───────────────────────────────────────────────────
|
||||
|
||||
(id-dyn-test
|
||||
"two registrations yield distinct client ids"
|
||||
(iddnm
|
||||
(idd-ev
|
||||
"O = identity_oauth:start(),\n {ok, C1, _} = identity_oauth:register_dynamic(O, confidential, [uri1]),\n {ok, C2, _} = identity_oauth:register_dynamic(O, confidential, [uri1]),\n case C1 =:= C2 of true -> collision; false -> distinct end"))
|
||||
"distinct")
|
||||
|
||||
;; ── a dynamic public client still cannot use client-credentials ──
|
||||
|
||||
(id-dyn-test
|
||||
"a dynamic public client is unauthorized for client-credentials"
|
||||
(iddnm
|
||||
(idd-ev
|
||||
"O = identity_oauth:start(),\n {ok, Cid, Sec} = identity_oauth:register_dynamic(O, public, [uri1]),\n case identity_oauth:client_credentials(O, Cid, Sec, batch) of\n {ok, _} -> issued;\n {error, W} -> W\n end"))
|
||||
"unauthorized_client")
|
||||
|
||||
(define
|
||||
id-dyn-test-summary
|
||||
(str "dynreg " id-dyn-test-pass "/" id-dyn-test-count))
|
||||
110
lib/identity/tests/exchange.sx
Normal file
110
lib/identity/tests/exchange.sx
Normal file
@@ -0,0 +1,110 @@
|
||||
;; identity/tests/exchange.sx — token exchange (RFC 8693 §2.1): downscope a
|
||||
;; valid access token into a new independent token for a downstream service.
|
||||
|
||||
(define id-xchg-test-count 0)
|
||||
(define id-xchg-test-pass 0)
|
||||
(define id-xchg-test-fails (list))
|
||||
|
||||
(define
|
||||
id-xchg-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! id-xchg-test-count (+ id-xchg-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! id-xchg-test-pass (+ id-xchg-test-pass 1))
|
||||
(append! id-xchg-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define idx-ev erlang-eval-ast)
|
||||
(define idxnm (fn (v) (get v :name)))
|
||||
|
||||
(identity-load-oauth!)
|
||||
|
||||
;; Shared prelude: an access token A for alice with scope [read, write].
|
||||
(define
|
||||
idx-token
|
||||
"O = identity_oauth:start(),\n {consent_required, Rq} = identity_oauth:authorize(O, web, uri1, [read, write], alice, v),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n {ok, A, _R} = identity_oauth:exchange(O, Cd, web, uri1, v)")
|
||||
|
||||
;; ── downscoping ──────────────────────────────────────────────────
|
||||
|
||||
(id-xchg-test
|
||||
"exchange downscopes to a subset"
|
||||
(idxnm
|
||||
(idx-ev
|
||||
(str
|
||||
idx-token
|
||||
", {ok, X} = identity_oauth:token_exchange(O, A, [read]),\n case identity_oauth:introspect(O, X) of\n {active, _, _, [read]} -> downscoped;\n {active, _, _, _} -> other;\n {inactive} -> inactive\n end")))
|
||||
"downscoped")
|
||||
|
||||
(id-xchg-test
|
||||
"the exchanged token keeps the subject"
|
||||
(idxnm
|
||||
(idx-ev
|
||||
(str
|
||||
idx-token
|
||||
", {ok, X} = identity_oauth:token_exchange(O, A, [read]),\n case identity_oauth:introspect(O, X) of\n {active, Subject, _, _} -> Subject\n end")))
|
||||
"alice")
|
||||
|
||||
(id-xchg-test
|
||||
"exchange to the same scope is allowed"
|
||||
(idxnm
|
||||
(idx-ev
|
||||
(str
|
||||
idx-token
|
||||
", {ok, X} = identity_oauth:token_exchange(O, A, [read, write]),\n case identity_oauth:introspect(O, X) of\n {active, _, _, [read, write]} -> full;\n {active, _, _, _} -> other;\n {inactive} -> inactive\n end")))
|
||||
"full")
|
||||
|
||||
;; ── scope cannot be widened ──────────────────────────────────────
|
||||
|
||||
(id-xchg-test
|
||||
"exchange cannot widen beyond the subject token's scope"
|
||||
(idxnm
|
||||
(idx-ev
|
||||
"O = identity_oauth:start(),\n {consent_required, Rq} = identity_oauth:authorize(O, web, uri1, [read], alice, v),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n {ok, A, _R} = identity_oauth:exchange(O, Cd, web, uri1, v),\n case identity_oauth:token_exchange(O, A, [read, write]) of\n {ok, _} -> widened;\n {error, W} -> W\n end"))
|
||||
"invalid_scope")
|
||||
|
||||
;; ── inactive subject token cannot be exchanged ───────────────────
|
||||
|
||||
(id-xchg-test
|
||||
"exchanging a revoked subject token is invalid_grant"
|
||||
(idxnm
|
||||
(idx-ev
|
||||
(str
|
||||
idx-token
|
||||
", identity_oauth:revoke(O, A),\n case identity_oauth:token_exchange(O, A, [read]) of\n {ok, _} -> issued;\n {error, W} -> W\n end")))
|
||||
"invalid_grant")
|
||||
|
||||
;; ── independent lifecycles ───────────────────────────────────────
|
||||
|
||||
(id-xchg-test
|
||||
"revoking the subject token does not revoke the exchanged token"
|
||||
(idxnm
|
||||
(idx-ev
|
||||
(str
|
||||
idx-token
|
||||
", {ok, X} = identity_oauth:token_exchange(O, A, [read]),\n identity_oauth:revoke(O, A),\n case identity_oauth:introspect(O, X) of\n {active, _, _, _} -> still_active;\n {inactive} -> inactive\n end")))
|
||||
"still_active")
|
||||
|
||||
(id-xchg-test
|
||||
"revoking the exchanged token does not revoke the subject token"
|
||||
(idxnm
|
||||
(idx-ev
|
||||
(str
|
||||
idx-token
|
||||
", {ok, X} = identity_oauth:token_exchange(O, A, [read]),\n identity_oauth:revoke(O, X),\n case identity_oauth:introspect(O, A) of\n {active, _, _, _} -> still_active;\n {inactive} -> inactive\n end")))
|
||||
"still_active")
|
||||
|
||||
;; ── chained downscoping ──────────────────────────────────────────
|
||||
|
||||
(id-xchg-test
|
||||
"an exchanged token can itself be exchanged (chain)"
|
||||
(idxnm
|
||||
(idx-ev
|
||||
(str
|
||||
idx-token
|
||||
", {ok, X1} = identity_oauth:token_exchange(O, A, [read, write]),\n {ok, X2} = identity_oauth:token_exchange(O, X1, [read]),\n case identity_oauth:introspect(O, X2) of\n {active, _, _, [read]} -> chained;\n {active, _, _, _} -> other;\n {inactive} -> inactive\n end")))
|
||||
"chained")
|
||||
|
||||
(define
|
||||
id-xchg-test-summary
|
||||
(str "exchange " id-xchg-test-pass "/" id-xchg-test-count))
|
||||
92
lib/identity/tests/expiry.sx
Normal file
92
lib/identity/tests/expiry.sx
Normal file
@@ -0,0 +1,92 @@
|
||||
;; identity/tests/expiry.sx — access-token expiry on a logical clock
|
||||
;; (RFC 6749 §4.2.2 expires_in). `advance` stands in for time passing;
|
||||
;; introspect returns inactive once the clock reaches a token's expiry.
|
||||
;; Refresh mints a fresh short-lived access token — the point of refresh.
|
||||
|
||||
(define id-expiry-test-count 0)
|
||||
(define id-expiry-test-pass 0)
|
||||
(define id-expiry-test-fails (list))
|
||||
|
||||
(define
|
||||
id-expiry-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! id-expiry-test-count (+ id-expiry-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! id-expiry-test-pass (+ id-expiry-test-pass 1))
|
||||
(append! id-expiry-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define ide-ev erlang-eval-ast)
|
||||
(define idenm (fn (v) (get v :name)))
|
||||
|
||||
(identity-load-token!)
|
||||
|
||||
;; ── within TTL is active; past TTL is inactive ───────────────────
|
||||
|
||||
(id-expiry-test
|
||||
"a token within its TTL is active"
|
||||
(idenm
|
||||
(ide-ev
|
||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n identity_tokens:advance(R, 50),\n case identity_tokens:introspect(R, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"active")
|
||||
|
||||
(id-expiry-test
|
||||
"a token at its TTL boundary is expired"
|
||||
(idenm
|
||||
(ide-ev
|
||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n identity_tokens:advance(R, 100),\n case identity_tokens:introspect(R, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"inactive")
|
||||
|
||||
(id-expiry-test
|
||||
"a token just before its TTL is still active"
|
||||
(idenm
|
||||
(ide-ev
|
||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n identity_tokens:advance(R, 99),\n case identity_tokens:introspect(R, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"active")
|
||||
|
||||
;; ── no TTL (infinity) never expires ──────────────────────────────
|
||||
|
||||
(id-expiry-test
|
||||
"a token issued without a TTL never expires"
|
||||
(idenm
|
||||
(ide-ev
|
||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read),\n identity_tokens:advance(R, 100000),\n case identity_tokens:introspect(R, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"active")
|
||||
|
||||
;; ── refresh mints a fresh short-lived token ──────────────────────
|
||||
|
||||
(id-expiry-test
|
||||
"refresh renews access after the old token expired"
|
||||
(idenm
|
||||
(ide-ev
|
||||
"R = identity_tokens:start(),\n {ok, A, Rt} = identity_tokens:issue_grant(R, alice, web, read, 100),\n identity_tokens:advance(R, 100),\n inactive = case identity_tokens:introspect(R, A) of\n {active, _, _, _} -> active; {inactive} -> inactive end,\n {ok, A2, _R2} = identity_tokens:refresh(R, Rt),\n case identity_tokens:introspect(R, A2) of\n {active, _, _, _} -> renewed;\n {inactive} -> inactive\n end"))
|
||||
"renewed")
|
||||
|
||||
(id-expiry-test
|
||||
"the renewed token also expires after its own TTL"
|
||||
(idenm
|
||||
(ide-ev
|
||||
"R = identity_tokens:start(),\n {ok, _A, Rt} = identity_tokens:issue_grant(R, alice, web, read, 100),\n identity_tokens:advance(R, 100),\n {ok, A2, _R2} = identity_tokens:refresh(R, Rt),\n identity_tokens:advance(R, 100),\n case identity_tokens:introspect(R, A2) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"inactive")
|
||||
|
||||
;; ── the logical clock ────────────────────────────────────────────
|
||||
|
||||
(id-expiry-test
|
||||
"the clock starts at zero and advances"
|
||||
(ide-ev
|
||||
"R = identity_tokens:start(),\n identity_tokens:advance(R, 7),\n identity_tokens:advance(R, 35),\n identity_tokens:now(R)")
|
||||
42)
|
||||
|
||||
;; ── expiry composes with revocation ──────────────────────────────
|
||||
|
||||
(id-expiry-test
|
||||
"an expired token is also inactive after revoke (no contradiction)"
|
||||
(idenm
|
||||
(ide-ev
|
||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n identity_tokens:advance(R, 200),\n identity_tokens:revoke(R, T),\n case identity_tokens:introspect(R, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"inactive")
|
||||
|
||||
(define
|
||||
id-expiry-test-summary
|
||||
(str "expiry " id-expiry-test-pass "/" id-expiry-test-count))
|
||||
97
lib/identity/tests/facade.sx
Normal file
97
lib/identity/tests/facade.sx
Normal file
@@ -0,0 +1,97 @@
|
||||
;; identity/tests/facade.sx — the unified facade: one coordinator wiring
|
||||
;; sessions+tokens, the audit ledger, and membership. Exercises the
|
||||
;; cross-module integration (login/logout auditing, audit history, member
|
||||
;; enrollment + projection) through the single `identity` door.
|
||||
|
||||
(define id-facade-test-count 0)
|
||||
(define id-facade-test-pass 0)
|
||||
(define id-facade-test-fails (list))
|
||||
|
||||
(define
|
||||
id-facade-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! id-facade-test-count (+ id-facade-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! id-facade-test-pass (+ id-facade-test-pass 1))
|
||||
(append! id-facade-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define idfc-ev erlang-eval-ast)
|
||||
(define idfcnm (fn (v) (get v :name)))
|
||||
|
||||
(identity-load-all!)
|
||||
|
||||
;; ── login + logout are audited through the ledger ────────────────
|
||||
|
||||
(id-facade-test
|
||||
"login then logout records login, issue, logout in order"
|
||||
(idfcnm
|
||||
(idfc-ev
|
||||
"Svc = identity:start(),\n {ok, Sid, _Tok} = identity:login(Svc, alice, web, read),\n identity:logout(Svc, Sid),\n case identity:history(Svc, alice) of\n [login, issue, logout] -> ordered;\n Other -> Other\n end"))
|
||||
"ordered")
|
||||
|
||||
(id-facade-test
|
||||
"revoking a token is audited"
|
||||
(idfcnm
|
||||
(idfc-ev
|
||||
"Svc = identity:start(),\n {ok, _Sid, Tok} = identity:login(Svc, alice, web, read),\n identity:revoke(Svc, Tok),\n case identity:history(Svc, alice) of\n [login, issue, revoke] -> ordered;\n Other -> Other\n end"))
|
||||
"ordered")
|
||||
|
||||
(id-facade-test
|
||||
"history is per-subject"
|
||||
(idfc-ev
|
||||
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, bob, cli, read),\n identity:login(Svc, alice, mobile, read),\n length(identity:history(Svc, alice))")
|
||||
4)
|
||||
|
||||
;; ── membership through the facade ────────────────────────────────
|
||||
|
||||
(id-facade-test
|
||||
"enroll makes the subject an active member"
|
||||
(idfcnm
|
||||
(idfc-ev
|
||||
"Svc = identity:start(),\n identity:enroll(Svc, alice, supporter),\n case identity:member_status(Svc, alice) of\n {ok, St, _} -> St;\n {none} -> none\n end"))
|
||||
"active")
|
||||
|
||||
(id-facade-test
|
||||
"enroll keeps the tier"
|
||||
(idfcnm
|
||||
(idfc-ev
|
||||
"Svc = identity:start(),\n identity:enroll(Svc, alice, supporter),\n case identity:member_status(Svc, alice) of\n {ok, _, Tier} -> Tier\n end"))
|
||||
"supporter")
|
||||
|
||||
(id-facade-test
|
||||
"an enrolled member projects per-app"
|
||||
(idfcnm
|
||||
(idfc-ev
|
||||
"Svc = identity:start(),\n identity:enroll(Svc, alice, basic),\n case identity:member_project(Svc, alice, market) of\n {member, _, App} -> App;\n {Tag, _} -> Tag\n end"))
|
||||
"market")
|
||||
|
||||
(id-facade-test
|
||||
"a non-member projects as non_member"
|
||||
(idfcnm
|
||||
(idfc-ev
|
||||
"Svc = identity:start(),\n case identity:member_project(Svc, stranger, blog) of\n {member, _, _} -> member;\n {Tag, _} -> Tag\n end"))
|
||||
"non_member")
|
||||
|
||||
;; ── the facade still proves identity ─────────────────────────────
|
||||
|
||||
(id-facade-test
|
||||
"verify still returns the subject after login"
|
||||
(idfcnm
|
||||
(idfc-ev
|
||||
"Svc = identity:start(),\n {ok, _Sid, Tok} = identity:login(Svc, alice, web, read),\n case identity:verify(Svc, Tok) of\n {active, Subject, _, _} -> Subject;\n {inactive} -> inactive\n end"))
|
||||
"alice")
|
||||
|
||||
;; ── identity and membership are distinct axes ────────────────────
|
||||
|
||||
(id-facade-test
|
||||
"logging in does not enroll membership"
|
||||
(idfcnm
|
||||
(idfc-ev
|
||||
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n case identity:member_status(Svc, alice) of\n {ok, St, _} -> St;\n {none} -> none\n end"))
|
||||
"none")
|
||||
|
||||
(define
|
||||
id-facade-test-summary
|
||||
(str "facade " id-facade-test-pass "/" id-facade-test-count))
|
||||
115
lib/identity/tests/federation.sx
Normal file
115
lib/identity/tests/federation.sx
Normal file
@@ -0,0 +1,115 @@
|
||||
;; identity/tests/federation.sx — federated identity: trust-gated,
|
||||
;; advisory peer assertions + cross-instance subject mapping.
|
||||
|
||||
(define id-fed-test-count 0)
|
||||
(define id-fed-test-pass 0)
|
||||
(define id-fed-test-fails (list))
|
||||
|
||||
(define
|
||||
id-fed-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! id-fed-test-count (+ id-fed-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! id-fed-test-pass (+ id-fed-test-pass 1))
|
||||
(append! id-fed-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define idf-ev erlang-eval-ast)
|
||||
(define idfnm (fn (v) (get v :name)))
|
||||
|
||||
(identity-load-federation!)
|
||||
|
||||
;; ── trust gating ─────────────────────────────────────────────────
|
||||
|
||||
(id-fed-test
|
||||
"an assertion from an untrusted peer is rejected"
|
||||
(idfnm
|
||||
(idf-ev
|
||||
"F = identity_federation:start(),\n case identity_federation:assert_id(F, peer1, alice) of\n {ok, _} -> accepted;\n {error, Why} -> Why\n end"))
|
||||
"untrusted")
|
||||
|
||||
(id-fed-test
|
||||
"a trusted peer's assertion is accepted"
|
||||
(idfnm
|
||||
(idf-ev
|
||||
"F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n case identity_federation:assert_id(F, peer1, alice) of\n {ok, _} -> accepted;\n {error, Why} -> Why\n end"))
|
||||
"accepted")
|
||||
|
||||
(id-fed-test
|
||||
"untrust closes the door to future assertions"
|
||||
(idfnm
|
||||
(idf-ev
|
||||
"F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n identity_federation:untrust(F, peer1),\n case identity_federation:assert_id(F, peer1, alice) of\n {ok, _} -> accepted;\n {error, Why} -> Why\n end"))
|
||||
"untrusted")
|
||||
|
||||
(id-fed-test
|
||||
"trusted? is true for a trusted peer"
|
||||
(idfnm
|
||||
(idf-ev
|
||||
"F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n case identity_federation:trusted(F, peer1) of\n true -> yes;\n false -> no\n end"))
|
||||
"yes")
|
||||
|
||||
(id-fed-test
|
||||
"trusted? is false for an unknown peer"
|
||||
(idfnm
|
||||
(idf-ev
|
||||
"F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n case identity_federation:trusted(F, peer2) of\n true -> yes;\n false -> no\n end"))
|
||||
"no")
|
||||
|
||||
;; ── advisory provenance ──────────────────────────────────────────
|
||||
|
||||
(id-fed-test
|
||||
"an asserted identity is flagged peer_asserted with its origin"
|
||||
(idfnm
|
||||
(idf-ev
|
||||
"F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n {ok, L} = identity_federation:assert_id(F, peer1, alice),\n case identity_federation:provenance(F, L) of\n {peer_asserted, P} -> P;\n {local} -> local\n end"))
|
||||
"peer1")
|
||||
|
||||
(id-fed-test
|
||||
"a non-federated subject has local provenance"
|
||||
(idfnm
|
||||
(idf-ev
|
||||
"F = identity_federation:start(),\n case identity_federation:provenance(F, alice) of\n {peer_asserted, _} -> peer_asserted;\n {local} -> local\n end"))
|
||||
"local")
|
||||
|
||||
;; ── cross-instance subject mapping ───────────────────────────────
|
||||
|
||||
(id-fed-test
|
||||
"remote subjects are namespaced by peer by default"
|
||||
(idfnm
|
||||
(idf-ev
|
||||
"F = identity_federation:start(),\n case identity_federation:resolve(F, peer1, alice) of\n {ok, {federated, _, Remote}} -> Remote;\n _ -> other\n end"))
|
||||
"alice")
|
||||
|
||||
(id-fed-test
|
||||
"the same remote name from two peers maps to distinct subjects"
|
||||
(idfnm
|
||||
(idf-ev
|
||||
"F = identity_federation:start(),\n {ok, L1} = identity_federation:resolve(F, peer1, alice),\n {ok, L2} = identity_federation:resolve(F, peer2, alice),\n case L1 =:= L2 of\n true -> collision;\n false -> distinct\n end"))
|
||||
"distinct")
|
||||
|
||||
(id-fed-test
|
||||
"an explicit map aliases a remote subject to a local one"
|
||||
(idfnm
|
||||
(idf-ev
|
||||
"F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n identity_federation:map(F, peer1, alice, alice_local),\n case identity_federation:assert_id(F, peer1, alice) of\n {ok, alice_local} -> mapped;\n {ok, _} -> unmapped;\n {error, W} -> W\n end"))
|
||||
"mapped")
|
||||
|
||||
(id-fed-test
|
||||
"a mapped subject keeps peer_asserted provenance"
|
||||
(idfnm
|
||||
(idf-ev
|
||||
"F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n identity_federation:map(F, peer1, alice, alice_local),\n identity_federation:assert_id(F, peer1, alice),\n case identity_federation:provenance(F, alice_local) of\n {peer_asserted, P} -> P;\n {local} -> local\n end"))
|
||||
"peer1")
|
||||
|
||||
(id-fed-test
|
||||
"two peers asserting same name keep separate provenance"
|
||||
(idfnm
|
||||
(idf-ev
|
||||
"F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n identity_federation:trust(F, peer2),\n {ok, L1} = identity_federation:assert_id(F, peer1, alice),\n {ok, _L2} = identity_federation:assert_id(F, peer2, alice),\n case identity_federation:provenance(F, L1) of\n {peer_asserted, P} -> P;\n {local} -> local\n end"))
|
||||
"peer1")
|
||||
|
||||
(define
|
||||
id-fed-test-summary
|
||||
(str "federation " id-fed-test-pass "/" id-fed-test-count))
|
||||
96
lib/identity/tests/grants.sx
Normal file
96
lib/identity/tests/grants.sx
Normal file
@@ -0,0 +1,96 @@
|
||||
;; identity/tests/grants.sx — the client-credentials grant (RFC 6749
|
||||
;; §4.4): a confidential client authenticates and gets a token acting on
|
||||
;; its own behalf — no end-user, no refresh token (§4.4.3). Public clients
|
||||
;; cannot use it.
|
||||
|
||||
(define id-grants-test-count 0)
|
||||
(define id-grants-test-pass 0)
|
||||
(define id-grants-test-fails (list))
|
||||
|
||||
(define
|
||||
id-grants-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! id-grants-test-count (+ id-grants-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! id-grants-test-pass (+ id-grants-test-pass 1))
|
||||
(append! id-grants-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define idg-ev erlang-eval-ast)
|
||||
(define idgnm (fn (v) (get v :name)))
|
||||
|
||||
(identity-load-oauth!)
|
||||
|
||||
;; ── confidential client-credentials happy path ───────────────────
|
||||
|
||||
(id-grants-test
|
||||
"a confidential client obtains a working token"
|
||||
(idgnm
|
||||
(idg-ev
|
||||
"O = identity_oauth:start(),\n identity_oauth:register_client(O, svc, confidential, sk, [uri1]),\n {ok, T} = identity_oauth:client_credentials(O, svc, sk, batch),\n case identity_oauth:introspect(O, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"active")
|
||||
|
||||
(id-grants-test
|
||||
"the client-credentials token's subject is the client itself"
|
||||
(idgnm
|
||||
(idg-ev
|
||||
"O = identity_oauth:start(),\n identity_oauth:register_client(O, svc, confidential, sk, [uri1]),\n {ok, T} = identity_oauth:client_credentials(O, svc, sk, batch),\n case identity_oauth:introspect(O, T) of\n {active, Subject, _, _} -> Subject\n end"))
|
||||
"svc")
|
||||
|
||||
(id-grants-test
|
||||
"the client-credentials token carries the requested scope"
|
||||
(idgnm
|
||||
(idg-ev
|
||||
"O = identity_oauth:start(),\n identity_oauth:register_client(O, svc, confidential, sk, [uri1]),\n {ok, T} = identity_oauth:client_credentials(O, svc, sk, reports),\n case identity_oauth:introspect(O, T) of\n {active, _, _, Scope} -> Scope\n end"))
|
||||
"reports")
|
||||
|
||||
(id-grants-test
|
||||
"client-credentials issues no refresh token (single value)"
|
||||
(idgnm
|
||||
(idg-ev
|
||||
"O = identity_oauth:start(),\n identity_oauth:register_client(O, svc, confidential, sk, [uri1]),\n case identity_oauth:client_credentials(O, svc, sk, batch) of\n {ok, _, _} -> pair;\n {ok, _} -> single;\n {error, W} -> W\n end"))
|
||||
"single")
|
||||
|
||||
;; ── authentication failures ──────────────────────────────────────
|
||||
|
||||
(id-grants-test
|
||||
"a wrong client secret is invalid_client"
|
||||
(idgnm
|
||||
(idg-ev
|
||||
"O = identity_oauth:start(),\n identity_oauth:register_client(O, svc, confidential, sk, [uri1]),\n case identity_oauth:client_credentials(O, svc, wrong, batch) of\n {ok, _} -> issued;\n {error, W} -> W\n end"))
|
||||
"invalid_client")
|
||||
|
||||
(id-grants-test
|
||||
"a public client cannot use client-credentials"
|
||||
(idgnm
|
||||
(idg-ev
|
||||
"O = identity_oauth:start(),\n identity_oauth:register_client(O, spa, public, none, [uri1]),\n case identity_oauth:client_credentials(O, spa, none, batch) of\n {ok, _} -> issued;\n {error, W} -> W\n end"))
|
||||
"unauthorized_client")
|
||||
|
||||
(id-grants-test
|
||||
"an unregistered client cannot use client-credentials"
|
||||
(idgnm
|
||||
(idg-ev
|
||||
"O = identity_oauth:start(),\n case identity_oauth:client_credentials(O, ghost, x, batch) of\n {ok, _} -> issued;\n {error, W} -> W\n end"))
|
||||
"invalid_client")
|
||||
|
||||
;; ── independence + real revocation for client tokens ─────────────
|
||||
|
||||
(id-grants-test
|
||||
"two confidential clients get independent tokens"
|
||||
(idgnm
|
||||
(idg-ev
|
||||
"O = identity_oauth:start(),\n identity_oauth:register_client(O, svc1, confidential, k1, [uri1]),\n identity_oauth:register_client(O, svc2, confidential, k2, [uri1]),\n {ok, _T1} = identity_oauth:client_credentials(O, svc1, k1, batch),\n {ok, T2} = identity_oauth:client_credentials(O, svc2, k2, batch),\n case identity_oauth:introspect(O, T2) of\n {active, Subject, _, _} -> Subject\n end"))
|
||||
"svc2")
|
||||
|
||||
(id-grants-test
|
||||
"a client-credentials token can be revoked"
|
||||
(idgnm
|
||||
(idg-ev
|
||||
"O = identity_oauth:start(),\n identity_oauth:register_client(O, svc, confidential, sk, [uri1]),\n {ok, T} = identity_oauth:client_credentials(O, svc, sk, batch),\n identity_oauth:revoke(O, T),\n case identity_oauth:introspect(O, T) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end"))
|
||||
"inactive")
|
||||
|
||||
(define
|
||||
id-grants-test-summary
|
||||
(str "grants " id-grants-test-pass "/" id-grants-test-count))
|
||||
93
lib/identity/tests/introspect.sx
Normal file
93
lib/identity/tests/introspect.sx
Normal file
@@ -0,0 +1,93 @@
|
||||
;; identity/tests/introspect.sx — RFC 7662 §2.2 full introspection metadata
|
||||
;; (sub, client_id, scope, exp, iat, token_type) alongside the live-lookup
|
||||
;; active/inactive semantics.
|
||||
|
||||
(define id-intr-test-count 0)
|
||||
(define id-intr-test-pass 0)
|
||||
(define id-intr-test-fails (list))
|
||||
|
||||
(define
|
||||
id-intr-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! id-intr-test-count (+ id-intr-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! id-intr-test-pass (+ id-intr-test-pass 1))
|
||||
(append! id-intr-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define idi-ev erlang-eval-ast)
|
||||
(define idinm (fn (v) (get v :name)))
|
||||
|
||||
(identity-load-token!)
|
||||
|
||||
;; ── metadata fields ──────────────────────────────────────────────
|
||||
|
||||
(id-intr-test
|
||||
"introspect_full reports token_type bearer"
|
||||
(idinm
|
||||
(idi-ev
|
||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n case identity_tokens:introspect_full(R, T) of\n {active, _, _, _, _, _, Tt} -> Tt;\n {inactive} -> inactive\n end"))
|
||||
"bearer")
|
||||
|
||||
(id-intr-test
|
||||
"introspect_full reports the subject"
|
||||
(idinm
|
||||
(idi-ev
|
||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n case identity_tokens:introspect_full(R, T) of\n {active, Sub, _, _, _, _, _} -> Sub\n end"))
|
||||
"alice")
|
||||
|
||||
(id-intr-test
|
||||
"introspect_full reports the client_id"
|
||||
(idinm
|
||||
(idi-ev
|
||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, mobile, read, 100),\n case identity_tokens:introspect_full(R, T) of\n {active, _, Cl, _, _, _, _} -> Cl\n end"))
|
||||
"mobile")
|
||||
|
||||
(id-intr-test
|
||||
"introspect_full reports the scope"
|
||||
(idinm
|
||||
(idi-ev
|
||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, write, 100),\n case identity_tokens:introspect_full(R, T) of\n {active, _, _, Sc, _, _, _} -> Sc\n end"))
|
||||
"write")
|
||||
|
||||
;; ── exp / iat reflect the logical clock ──────────────────────────
|
||||
|
||||
(id-intr-test
|
||||
"iat is the clock value at issue"
|
||||
(idi-ev
|
||||
"R = identity_tokens:start(),\n identity_tokens:advance(R, 7),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n case identity_tokens:introspect_full(R, T) of\n {active, _, _, _, _, Iat, _} -> Iat\n end")
|
||||
7)
|
||||
|
||||
(id-intr-test
|
||||
"exp is iat plus the ttl"
|
||||
(idi-ev
|
||||
"R = identity_tokens:start(),\n identity_tokens:advance(R, 7),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n case identity_tokens:introspect_full(R, T) of\n {active, _, _, _, Exp, Iat, _} -> Exp - Iat\n end")
|
||||
100)
|
||||
|
||||
;; ── inactive / expired / revoked ─────────────────────────────────
|
||||
|
||||
(id-intr-test
|
||||
"an expired token introspects inactive in full mode too"
|
||||
(idinm
|
||||
(idi-ev
|
||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n identity_tokens:advance(R, 100),\n case identity_tokens:introspect_full(R, T) of\n {active, _, _, _, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"inactive")
|
||||
|
||||
(id-intr-test
|
||||
"a revoked token introspects inactive in full mode"
|
||||
(idinm
|
||||
(idi-ev
|
||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read),\n identity_tokens:revoke(R, T),\n case identity_tokens:introspect_full(R, T) of\n {active, _, _, _, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"inactive")
|
||||
|
||||
(id-intr-test
|
||||
"an unknown token introspects inactive in full mode"
|
||||
(idinm
|
||||
(idi-ev
|
||||
"R = identity_tokens:start(),\n Bogus = make_ref(),\n case identity_tokens:introspect_full(R, Bogus) of\n {active, _, _, _, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"inactive")
|
||||
|
||||
(define
|
||||
id-intr-test-summary
|
||||
(str "introspect " id-intr-test-pass "/" id-intr-test-count))
|
||||
155
lib/identity/tests/membership.sx
Normal file
155
lib/identity/tests/membership.sx
Normal file
@@ -0,0 +1,155 @@
|
||||
;; identity/tests/membership.sx — membership state machine + per-app
|
||||
;; grant projection. Valid transitions advance state; invalid ones are
|
||||
;; explicit errors. The projection renders one canonical state per app.
|
||||
|
||||
(define id-membership-test-count 0)
|
||||
(define id-membership-test-pass 0)
|
||||
(define id-membership-test-fails (list))
|
||||
|
||||
(define
|
||||
id-membership-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! id-membership-test-count (+ id-membership-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! id-membership-test-pass (+ id-membership-test-pass 1))
|
||||
(append! id-membership-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define idm-ev erlang-eval-ast)
|
||||
(define idmnm (fn (v) (get v :name)))
|
||||
|
||||
(identity-load-membership!)
|
||||
|
||||
;; ── request → pending → approve → active ─────────────────────────
|
||||
|
||||
(id-membership-test
|
||||
"request leaves the subject pending"
|
||||
(idmnm
|
||||
(idm-ev
|
||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n case identity_membership:status(M, alice) of\n {ok, St, _} -> St;\n {none} -> none\n end"))
|
||||
"pending")
|
||||
|
||||
(id-membership-test
|
||||
"approve activates a pending membership"
|
||||
(idmnm
|
||||
(idm-ev
|
||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n case identity_membership:status(M, alice) of\n {ok, St, _} -> St;\n {none} -> none\n end"))
|
||||
"active")
|
||||
|
||||
(id-membership-test
|
||||
"status keeps the requested tier"
|
||||
(idmnm
|
||||
(idm-ev
|
||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, supporter),\n identity_membership:approve(M, alice),\n case identity_membership:status(M, alice) of\n {ok, _, Tier} -> Tier\n end"))
|
||||
"supporter")
|
||||
|
||||
;; ── guarded transitions: invalid moves are explicit errors ───────
|
||||
|
||||
(id-membership-test
|
||||
"requesting twice is an error"
|
||||
(idmnm
|
||||
(idm-ev
|
||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n case identity_membership:request(M, alice, basic) of\n ok -> ok;\n {error, Why} -> Why\n end"))
|
||||
"exists")
|
||||
|
||||
(id-membership-test
|
||||
"approving an unknown subject is not_found"
|
||||
(idmnm
|
||||
(idm-ev
|
||||
"M = identity_membership:start(),\n case identity_membership:approve(M, ghost) of\n ok -> ok;\n {error, Why} -> Why\n end"))
|
||||
"not_found")
|
||||
|
||||
(id-membership-test
|
||||
"approving an already-active membership is an error"
|
||||
(idmnm
|
||||
(idm-ev
|
||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n case identity_membership:approve(M, alice) of\n ok -> ok;\n {error, Why} -> Why\n end"))
|
||||
"active")
|
||||
|
||||
;; ── lapse / reinstate ────────────────────────────────────────────
|
||||
|
||||
(id-membership-test
|
||||
"active member can lapse"
|
||||
(idmnm
|
||||
(idm-ev
|
||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:lapse(M, alice),\n case identity_membership:status(M, alice) of\n {ok, St, _} -> St\n end"))
|
||||
"lapsed")
|
||||
|
||||
(id-membership-test
|
||||
"lapsing a pending membership is an error"
|
||||
(idmnm
|
||||
(idm-ev
|
||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n case identity_membership:lapse(M, alice) of\n ok -> ok;\n {error, Why} -> Why\n end"))
|
||||
"pending")
|
||||
|
||||
(id-membership-test
|
||||
"lapsed member can reinstate to active"
|
||||
(idmnm
|
||||
(idm-ev
|
||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:lapse(M, alice),\n identity_membership:reinstate(M, alice),\n case identity_membership:status(M, alice) of\n {ok, St, _} -> St\n end"))
|
||||
"active")
|
||||
|
||||
;; ── revoke is terminal ───────────────────────────────────────────
|
||||
|
||||
(id-membership-test
|
||||
"any member can be revoked"
|
||||
(idmnm
|
||||
(idm-ev
|
||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:revoke(M, alice),\n case identity_membership:status(M, alice) of\n {ok, St, _} -> St\n end"))
|
||||
"revoked")
|
||||
|
||||
(id-membership-test
|
||||
"a revoked membership cannot be reinstated"
|
||||
(idmnm
|
||||
(idm-ev
|
||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:revoke(M, alice),\n case identity_membership:reinstate(M, alice) of\n ok -> ok;\n {error, Why} -> Why\n end"))
|
||||
"revoked")
|
||||
|
||||
;; ── per-app grant projection ─────────────────────────────────────
|
||||
|
||||
(id-membership-test
|
||||
"active member projects as member"
|
||||
(idmnm
|
||||
(idm-ev
|
||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n case identity_membership:project(M, alice, blog) of\n {member, _, _} -> member;\n {Tag, _} -> Tag\n end"))
|
||||
"member")
|
||||
|
||||
(id-membership-test
|
||||
"projection carries the requesting app"
|
||||
(idmnm
|
||||
(idm-ev
|
||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n case identity_membership:project(M, alice, market) of\n {member, _, App} -> App\n end"))
|
||||
"market")
|
||||
|
||||
(id-membership-test
|
||||
"the same subject projects consistently across apps"
|
||||
(idmnm
|
||||
(idm-ev
|
||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, supporter),\n identity_membership:approve(M, alice),\n {member, T1, blog} = identity_membership:project(M, alice, blog),\n {member, T2, events} = identity_membership:project(M, alice, events),\n case T1 =:= T2 of\n true -> T1;\n false -> mismatch\n end"))
|
||||
"supporter")
|
||||
|
||||
(id-membership-test
|
||||
"unknown subject projects as non_member"
|
||||
(idmnm
|
||||
(idm-ev
|
||||
"M = identity_membership:start(),\n case identity_membership:project(M, ghost, blog) of\n {Tag, _} -> Tag;\n {Tag, _, _} -> Tag\n end"))
|
||||
"non_member")
|
||||
|
||||
(id-membership-test
|
||||
"lapsed member projects as lapsed"
|
||||
(idmnm
|
||||
(idm-ev
|
||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:lapse(M, alice),\n case identity_membership:project(M, alice, blog) of\n {Tag, _} -> Tag;\n {Tag, _, _} -> Tag\n end"))
|
||||
"lapsed")
|
||||
|
||||
(id-membership-test
|
||||
"revoked member projects as denied"
|
||||
(idmnm
|
||||
(idm-ev
|
||||
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:revoke(M, alice),\n case identity_membership:project(M, alice, blog) of\n {Tag, _} -> Tag;\n {Tag, _, _} -> Tag\n end"))
|
||||
"denied")
|
||||
|
||||
(define
|
||||
id-membership-test-summary
|
||||
(str "membership " id-membership-test-pass "/" id-membership-test-count))
|
||||
192
lib/identity/tests/oauth.sx
Normal file
192
lib/identity/tests/oauth.sx
Normal file
@@ -0,0 +1,192 @@
|
||||
;; identity/tests/oauth.sx — OAuth2 authorization-code flow (RFC 6749
|
||||
;; §4.1) + PKCE (RFC 7636) + refresh grant (§6). Covers the full happy
|
||||
;; path end-to-end (code exchange → access+refresh → refresh rotation) and
|
||||
;; every rejection: denied consent, single-use codes, client/redirect
|
||||
;; binding, PKCE mismatch, unknown code/request, refresh-token reuse, and
|
||||
;; revoke-then-use (which must fail).
|
||||
|
||||
(define id-oauth-test-count 0)
|
||||
(define id-oauth-test-pass 0)
|
||||
(define id-oauth-test-fails (list))
|
||||
|
||||
(define
|
||||
id-oauth-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! id-oauth-test-count (+ id-oauth-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! id-oauth-test-pass (+ id-oauth-test-pass 1))
|
||||
(append! id-oauth-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define ido-ev erlang-eval-ast)
|
||||
(define idonm (fn (v) (get v :name)))
|
||||
|
||||
(identity-load-token!)
|
||||
(identity-load-oauth!)
|
||||
|
||||
;; Shared prelude: authorize + consent(allow) leaving Code bound.
|
||||
(define
|
||||
ido-granted
|
||||
"O = identity_oauth:start(),\n {consent_required, ReqId} =\n identity_oauth:authorize(O, webapp, uri1, read, alice, verif1),\n {code, Code} = identity_oauth:consent(O, ReqId, allow)")
|
||||
|
||||
;; ── full happy path ──────────────────────────────────────────────
|
||||
|
||||
(id-oauth-test
|
||||
"authorize asks for consent"
|
||||
(idonm
|
||||
(ido-ev
|
||||
"O = identity_oauth:start(),\n case identity_oauth:authorize(O, webapp, uri1, read, alice, verif1) of\n {consent_required, _} -> consent_required;\n Other -> Other\n end"))
|
||||
"consent_required")
|
||||
|
||||
(id-oauth-test
|
||||
"consent(allow) returns a code"
|
||||
(idonm (ido-ev (str ido-granted ", case Code of _ -> issued end")))
|
||||
"issued")
|
||||
|
||||
(id-oauth-test
|
||||
"exchanged access token introspects active"
|
||||
(idonm
|
||||
(ido-ev
|
||||
(str
|
||||
ido-granted
|
||||
", {ok, Tok, _R} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n case identity_oauth:introspect(O, Tok) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")))
|
||||
"active")
|
||||
|
||||
(id-oauth-test
|
||||
"exchanged token carries the authorized subject"
|
||||
(idonm
|
||||
(ido-ev
|
||||
(str
|
||||
ido-granted
|
||||
", {ok, Tok, _R} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n case identity_oauth:introspect(O, Tok) of\n {active, Subject, _, _} -> Subject\n end")))
|
||||
"alice")
|
||||
|
||||
(id-oauth-test
|
||||
"exchanged token carries the authorized scope"
|
||||
(idonm
|
||||
(ido-ev
|
||||
(str
|
||||
ido-granted
|
||||
", {ok, Tok, _R} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n case identity_oauth:introspect(O, Tok) of\n {active, _, _, Scope} -> Scope\n end")))
|
||||
"read")
|
||||
|
||||
;; ── refresh grant (RFC 6749 §6) end-to-end ───────────────────────
|
||||
|
||||
(id-oauth-test
|
||||
"refresh after exchange yields a working access token"
|
||||
(idonm
|
||||
(ido-ev
|
||||
(str
|
||||
ido-granted
|
||||
", {ok, _A, R} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n {ok, A2, _R2} = identity_oauth:refresh(O, R),\n case identity_oauth:introspect(O, A2) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")))
|
||||
"active")
|
||||
|
||||
(id-oauth-test
|
||||
"reusing a rotated refresh token is invalid_grant"
|
||||
(idonm
|
||||
(ido-ev
|
||||
(str
|
||||
ido-granted
|
||||
", {ok, _A, R} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n {ok, _A2, _R2} = identity_oauth:refresh(O, R),\n case identity_oauth:refresh(O, R) of\n {ok, _, _} -> rotated;\n {error, Why} -> Why\n end")))
|
||||
"invalid_grant")
|
||||
|
||||
;; ── consent denied (§4.1.2.1) ────────────────────────────────────
|
||||
|
||||
(id-oauth-test
|
||||
"denied consent yields access_denied"
|
||||
(idonm
|
||||
(ido-ev
|
||||
"O = identity_oauth:start(),\n {consent_required, ReqId} =\n identity_oauth:authorize(O, webapp, uri1, read, alice, verif1),\n case identity_oauth:consent(O, ReqId, deny) of\n {error, Why} -> Why;\n {code, _} -> issued\n end"))
|
||||
"access_denied")
|
||||
|
||||
;; ── single-use codes (§10.5) ─────────────────────────────────────
|
||||
|
||||
(id-oauth-test
|
||||
"code cannot be exchanged twice"
|
||||
(idonm
|
||||
(ido-ev
|
||||
(str
|
||||
ido-granted
|
||||
", identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n case identity_oauth:exchange(O, Code, webapp, uri1, verif1) of\n {ok, _, _} -> replayed;\n {error, Why} -> Why\n end")))
|
||||
"invalid_grant")
|
||||
|
||||
;; ── code binding to client + redirect_uri (§4.1.3) ───────────────
|
||||
|
||||
(id-oauth-test
|
||||
"exchange with wrong client is invalid_grant"
|
||||
(idonm
|
||||
(ido-ev
|
||||
(str
|
||||
ido-granted
|
||||
", case identity_oauth:exchange(O, Code, attacker, uri1, verif1) of\n {ok, _, _} -> ok;\n {error, Why} -> Why\n end")))
|
||||
"invalid_grant")
|
||||
|
||||
(id-oauth-test
|
||||
"exchange with wrong redirect_uri is invalid_grant"
|
||||
(idonm
|
||||
(ido-ev
|
||||
(str
|
||||
ido-granted
|
||||
", case identity_oauth:exchange(O, Code, webapp, evil_uri, verif1) of\n {ok, _, _} -> ok;\n {error, Why} -> Why\n end")))
|
||||
"invalid_grant")
|
||||
|
||||
;; ── PKCE verifier mismatch (RFC 7636) ────────────────────────────
|
||||
|
||||
(id-oauth-test
|
||||
"exchange with wrong PKCE verifier is invalid_grant"
|
||||
(idonm
|
||||
(ido-ev
|
||||
(str
|
||||
ido-granted
|
||||
", case identity_oauth:exchange(O, Code, webapp, uri1, badverif) of\n {ok, _, _} -> ok;\n {error, Why} -> Why\n end")))
|
||||
"invalid_grant")
|
||||
|
||||
;; ── unknown code / request ───────────────────────────────────────
|
||||
|
||||
(id-oauth-test
|
||||
"exchanging an unknown code is invalid_grant"
|
||||
(idonm
|
||||
(ido-ev
|
||||
"O = identity_oauth:start(),\n Bogus = make_ref(),\n case identity_oauth:exchange(O, Bogus, webapp, uri1, verif1) of\n {ok, _, _} -> ok;\n {error, Why} -> Why\n end"))
|
||||
"invalid_grant")
|
||||
|
||||
(id-oauth-test
|
||||
"consent on an unknown request is unknown_request"
|
||||
(idonm
|
||||
(ido-ev
|
||||
"O = identity_oauth:start(),\n Bogus = make_ref(),\n case identity_oauth:consent(O, Bogus, allow) of\n {code, _} -> issued;\n {error, Why} -> Why\n end"))
|
||||
"unknown_request")
|
||||
|
||||
;; ── revoke-then-use must fail (RFC 7009) ─────────────────────────
|
||||
|
||||
(id-oauth-test
|
||||
"revoked exchanged token introspects inactive"
|
||||
(idonm
|
||||
(ido-ev
|
||||
(str
|
||||
ido-granted
|
||||
", {ok, Tok, _R} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n identity_oauth:revoke(O, Tok),\n case identity_oauth:introspect(O, Tok) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end")))
|
||||
"inactive")
|
||||
|
||||
(id-oauth-test
|
||||
"revoking the access token blocks a later refresh (cascade)"
|
||||
(idonm
|
||||
(ido-ev
|
||||
(str
|
||||
ido-granted
|
||||
", {ok, A, R} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n identity_oauth:revoke(O, A),\n case identity_oauth:refresh(O, R) of\n {ok, _, _} -> refreshed;\n {error, Why} -> Why\n end")))
|
||||
"invalid_grant")
|
||||
|
||||
;; ── independence: two concurrent authorizations don't collide ────
|
||||
|
||||
(id-oauth-test
|
||||
"two authorizations issue independent grants"
|
||||
(idonm
|
||||
(ido-ev
|
||||
"O = identity_oauth:start(),\n {consent_required, R1} =\n identity_oauth:authorize(O, webapp, uri1, read, alice, va),\n {consent_required, R2} =\n identity_oauth:authorize(O, cli, uri2, write, bob, vb),\n {code, C1} = identity_oauth:consent(O, R1, allow),\n {code, C2} = identity_oauth:consent(O, R2, allow),\n {ok, _A1, _RR1} = identity_oauth:exchange(O, C1, webapp, uri1, va),\n {ok, A2, _RR2} = identity_oauth:exchange(O, C2, cli, uri2, vb),\n case identity_oauth:introspect(O, A2) of\n {active, Subject, _, _} -> Subject\n end"))
|
||||
"bob")
|
||||
|
||||
(define
|
||||
id-oauth-test-summary
|
||||
(str "oauth " id-oauth-test-pass "/" id-oauth-test-count))
|
||||
84
lib/identity/tests/par.sx
Normal file
84
lib/identity/tests/par.sx
Normal file
@@ -0,0 +1,84 @@
|
||||
;; identity/tests/par.sx — pushed authorization requests (PAR, RFC 9126):
|
||||
;; lodge the authorization params up front under a single-use request_uri,
|
||||
;; then redeem it into the normal consent flow. The binding (client,
|
||||
;; redirect, PKCE) carried by the pushed request is enforced at exchange.
|
||||
|
||||
(define id-par-test-count 0)
|
||||
(define id-par-test-pass 0)
|
||||
(define id-par-test-fails (list))
|
||||
|
||||
(define
|
||||
id-par-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! id-par-test-count (+ id-par-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! id-par-test-pass (+ id-par-test-pass 1))
|
||||
(append! id-par-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define idp-ev erlang-eval-ast)
|
||||
(define idpnm (fn (v) (get v :name)))
|
||||
|
||||
(identity-load-oauth!)
|
||||
|
||||
;; ── pushed request redeems into consent ──────────────────────────
|
||||
|
||||
(id-par-test
|
||||
"authorize_pushed on a fresh request_uri asks for consent"
|
||||
(idpnm
|
||||
(idp-ev
|
||||
"O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, v),\n case identity_oauth:authorize_pushed(O, Ru) of\n {consent_required, _} -> consent_required;\n {error, W} -> W\n end"))
|
||||
"consent_required")
|
||||
|
||||
;; ── full PAR flow ────────────────────────────────────────────────
|
||||
|
||||
(id-par-test
|
||||
"the full PAR flow yields a working token"
|
||||
(idpnm
|
||||
(idp-ev
|
||||
"O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, v),\n {consent_required, Rq} = identity_oauth:authorize_pushed(O, Ru),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n {ok, A, _R} = identity_oauth:exchange(O, Cd, web, uri1, v),\n case identity_oauth:introspect(O, A) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"active")
|
||||
|
||||
(id-par-test
|
||||
"the PAR token carries the pushed subject"
|
||||
(idpnm
|
||||
(idp-ev
|
||||
"O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, v),\n {consent_required, Rq} = identity_oauth:authorize_pushed(O, Ru),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n {ok, A, _R} = identity_oauth:exchange(O, Cd, web, uri1, v),\n case identity_oauth:introspect(O, A) of\n {active, Subject, _, _} -> Subject\n end"))
|
||||
"alice")
|
||||
|
||||
;; ── request_uri is single-use ────────────────────────────────────
|
||||
|
||||
(id-par-test
|
||||
"a request_uri cannot be redeemed twice"
|
||||
(idpnm
|
||||
(idp-ev
|
||||
"O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, v),\n identity_oauth:authorize_pushed(O, Ru),\n case identity_oauth:authorize_pushed(O, Ru) of\n {consent_required, _} -> reused;\n {error, W} -> W\n end"))
|
||||
"invalid_request_uri")
|
||||
|
||||
(id-par-test
|
||||
"an unknown request_uri is rejected"
|
||||
(idpnm
|
||||
(idp-ev
|
||||
"O = identity_oauth:start(),\n Bogus = make_ref(),\n case identity_oauth:authorize_pushed(O, Bogus) of\n {consent_required, _} -> ok;\n {error, W} -> W\n end"))
|
||||
"invalid_request_uri")
|
||||
|
||||
;; ── the pushed binding is still enforced at exchange ─────────────
|
||||
|
||||
(id-par-test
|
||||
"a PAR-issued code still enforces PKCE"
|
||||
(idpnm
|
||||
(idp-ev
|
||||
"O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, v),\n {consent_required, Rq} = identity_oauth:authorize_pushed(O, Ru),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n case identity_oauth:exchange(O, Cd, web, uri1, wrongverif) of\n {ok, _, _} -> ok;\n {error, W} -> W\n end"))
|
||||
"invalid_grant")
|
||||
|
||||
(id-par-test
|
||||
"a PAR-issued code still enforces client binding"
|
||||
(idpnm
|
||||
(idp-ev
|
||||
"O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, v),\n {consent_required, Rq} = identity_oauth:authorize_pushed(O, Ru),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n case identity_oauth:exchange(O, Cd, attacker, uri1, v) of\n {ok, _, _} -> ok;\n {error, W} -> W\n end"))
|
||||
"invalid_grant")
|
||||
|
||||
(define
|
||||
id-par-test-summary
|
||||
(str "par " id-par-test-pass "/" id-par-test-count))
|
||||
99
lib/identity/tests/registry.sx
Normal file
99
lib/identity/tests/registry.sx
Normal file
@@ -0,0 +1,99 @@
|
||||
;; identity/tests/registry.sx — routing by id and by (subject, client),
|
||||
;; SSO fan-out (one subject, many clients), and integration with live
|
||||
;; session processes routed through the registry.
|
||||
|
||||
(define id-registry-test-count 0)
|
||||
(define id-registry-test-pass 0)
|
||||
(define id-registry-test-fails (list))
|
||||
|
||||
(define
|
||||
id-registry-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! id-registry-test-count (+ id-registry-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! id-registry-test-pass (+ id-registry-test-pass 1))
|
||||
(append! id-registry-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define idr-ev erlang-eval-ast)
|
||||
(define idrnm (fn (v) (get v :name)))
|
||||
|
||||
(identity-load-session!)
|
||||
(identity-load-registry!)
|
||||
|
||||
;; ── whereis by session id ────────────────────────────────────────
|
||||
|
||||
(id-registry-test
|
||||
"registered session is found by id"
|
||||
(idrnm
|
||||
(idr-ev
|
||||
"Me = self(),\n Reg = identity_registry:start(),\n identity_registry:register(Reg, s1, alice, web, Me),\n case identity_registry:whereis_session(Reg, s1) of\n {ok, _} -> found;\n {error, _} -> missing\n end"))
|
||||
"found")
|
||||
|
||||
(id-registry-test
|
||||
"unknown session id is not_found, not a crash"
|
||||
(idrnm
|
||||
(idr-ev
|
||||
"Reg = identity_registry:start(),\n case identity_registry:whereis_session(Reg, nope) of\n {ok, _} -> found;\n {error, Why} -> Why\n end"))
|
||||
"not_found")
|
||||
|
||||
;; ── lookup by (subject, client) — the SSO probe ──────────────────
|
||||
|
||||
(id-registry-test
|
||||
"lookup finds a session for subject+client"
|
||||
(idrnm
|
||||
(idr-ev
|
||||
"Me = self(),\n Reg = identity_registry:start(),\n identity_registry:register(Reg, s1, alice, web, Me),\n case identity_registry:lookup(Reg, alice, web) of\n {ok, _} -> found;\n {error, _} -> missing\n end"))
|
||||
"found")
|
||||
|
||||
(id-registry-test
|
||||
"lookup is precise: right subject, wrong client misses"
|
||||
(idrnm
|
||||
(idr-ev
|
||||
"Me = self(),\n Reg = identity_registry:start(),\n identity_registry:register(Reg, s1, alice, web, Me),\n case identity_registry:lookup(Reg, alice, cli) of\n {ok, _} -> found;\n {error, _} -> missing\n end"))
|
||||
"missing")
|
||||
|
||||
;; ── SSO fan-out: one subject, many clients ───────────────────────
|
||||
|
||||
(id-registry-test
|
||||
"sessions_for returns all of a subject's sessions"
|
||||
(idr-ev
|
||||
"Me = self(),\n Reg = identity_registry:start(),\n identity_registry:register(Reg, s1, alice, web, Me),\n identity_registry:register(Reg, s2, alice, cli, Me),\n identity_registry:register(Reg, s3, bob, web, Me),\n case identity_registry:sessions_for(Reg, alice) of\n {ok, L} -> length(L)\n end")
|
||||
2)
|
||||
|
||||
(id-registry-test
|
||||
"sessions_for an unknown subject is empty"
|
||||
(idr-ev
|
||||
"Reg = identity_registry:start(),\n case identity_registry:sessions_for(Reg, ghost) of\n {ok, L} -> length(L)\n end")
|
||||
0)
|
||||
|
||||
;; ── re-register replaces the row for that id (no duplicates) ──────
|
||||
|
||||
(id-registry-test
|
||||
"re-registering an id does not duplicate it"
|
||||
(idr-ev
|
||||
"Me = self(),\n Reg = identity_registry:start(),\n identity_registry:register(Reg, s1, alice, web, Me),\n identity_registry:register(Reg, s1, alice, web, Me),\n case identity_registry:sessions_for(Reg, alice) of\n {ok, L} -> length(L)\n end")
|
||||
1)
|
||||
|
||||
;; ── deregister removes routing ───────────────────────────────────
|
||||
|
||||
(id-registry-test
|
||||
"deregistered session is no longer found"
|
||||
(idrnm
|
||||
(idr-ev
|
||||
"Me = self(),\n Reg = identity_registry:start(),\n identity_registry:register(Reg, s1, alice, web, Me),\n identity_registry:deregister(Reg, s1),\n case identity_registry:whereis_session(Reg, s1) of\n {ok, _} -> found;\n {error, _} -> missing\n end"))
|
||||
"missing")
|
||||
|
||||
;; ── integration: route to a live session and look it up ──────────
|
||||
|
||||
(id-registry-test
|
||||
"routed-to session answers lookup as active"
|
||||
(idrnm
|
||||
(idr-ev
|
||||
"Me = self(),\n Reg = identity_registry:start(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n identity_registry:register(Reg, s1, alice, web, S),\n {ok, Pid} = identity_registry:lookup(Reg, alice, web),\n case identity_session:lookup(Pid) of\n {ok, {_,_,_,St}} -> St;\n {error, St} -> St\n end"))
|
||||
"active")
|
||||
|
||||
(define
|
||||
id-registry-test-summary
|
||||
(str "registry " id-registry-test-pass "/" id-registry-test-count))
|
||||
118
lib/identity/tests/session.sx
Normal file
118
lib/identity/tests/session.sx
Normal file
@@ -0,0 +1,118 @@
|
||||
;; identity/tests/session.sx — session-as-process: create, lookup,
|
||||
;; touch, explicit expire, revoke, and idle-timeout self-expiry.
|
||||
;; Negative paths are tested as first-class: a tombstoned session
|
||||
;; answers {error, Status}, it does not go silent.
|
||||
|
||||
(define id-session-test-count 0)
|
||||
(define id-session-test-pass 0)
|
||||
(define id-session-test-fails (list))
|
||||
|
||||
(define
|
||||
id-session-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! id-session-test-count (+ id-session-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! id-session-test-pass (+ id-session-test-pass 1))
|
||||
(append! id-session-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define id-ev erlang-eval-ast)
|
||||
(define idnm (fn (v) (get v :name)))
|
||||
|
||||
(identity-load-session!)
|
||||
|
||||
;; ── create + lookup ──────────────────────────────────────────────
|
||||
|
||||
(id-session-test
|
||||
"lookup of live session is active"
|
||||
(idnm
|
||||
(id-ev
|
||||
"Me = self(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n case identity_session:lookup(S) of {ok, {_,_,_,St}} -> St end"))
|
||||
"active")
|
||||
|
||||
(id-session-test
|
||||
"lookup preserves subject"
|
||||
(idnm
|
||||
(id-ev
|
||||
"Me = self(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n case identity_session:lookup(S) of {ok, {_,Subject,_,_}} -> Subject end"))
|
||||
"alice")
|
||||
|
||||
(id-session-test
|
||||
"lookup preserves client"
|
||||
(idnm
|
||||
(id-ev
|
||||
"Me = self(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n case identity_session:lookup(S) of {ok, {_,_,Client,_}} -> Client end"))
|
||||
"web")
|
||||
|
||||
;; ── touch keeps a live session ───────────────────────────────────
|
||||
|
||||
(id-session-test
|
||||
"touch on live session is ok"
|
||||
(idnm
|
||||
(id-ev
|
||||
"Me = self(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n identity_session:touch(S)"))
|
||||
"ok")
|
||||
|
||||
;; ── explicit expire ──────────────────────────────────────────────
|
||||
|
||||
(id-session-test
|
||||
"expire then lookup is error expired"
|
||||
(idnm
|
||||
(id-ev
|
||||
"Me = self(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n identity_session:expire(S),\n case identity_session:lookup(S) of {error, St} -> St end"))
|
||||
"expired")
|
||||
|
||||
(id-session-test
|
||||
"touch on expired session is error"
|
||||
(idnm
|
||||
(id-ev
|
||||
"Me = self(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n identity_session:expire(S),\n case identity_session:touch(S) of {error, St} -> St end"))
|
||||
"expired")
|
||||
|
||||
;; ── revoke is immediate ──────────────────────────────────────────
|
||||
|
||||
(id-session-test
|
||||
"revoke then lookup is error revoked"
|
||||
(idnm
|
||||
(id-ev
|
||||
"Me = self(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n identity_session:revoke(S),\n case identity_session:lookup(S) of {error, St} -> St end"))
|
||||
"revoked")
|
||||
|
||||
;; ── idle-timeout self-expiry ─────────────────────────────────────
|
||||
|
||||
(id-session-test
|
||||
"idle timeout notifies owner"
|
||||
(idnm
|
||||
(id-ev
|
||||
"Me = self(),\n S = identity_session:start(s1, alice, web, Me, 50),\n _ = identity_session:lookup(S),\n receive {session_expired, Sid} -> Sid end"))
|
||||
"s1")
|
||||
|
||||
(id-session-test
|
||||
"lookup after idle timeout is error expired"
|
||||
(idnm
|
||||
(id-ev
|
||||
"Me = self(),\n S = identity_session:start(s1, alice, web, Me, 50),\n _ = identity_session:lookup(S),\n receive {session_expired, _} -> ok end,\n case identity_session:lookup(S) of {error, St} -> St end"))
|
||||
"expired")
|
||||
|
||||
;; ── isolation: sessions are independent processes ────────────────
|
||||
|
||||
(id-session-test
|
||||
"expiring one session leaves the other active"
|
||||
(idnm
|
||||
(id-ev
|
||||
"Me = self(),\n A = identity_session:start(s1, alice, web, Me, infinity),\n B = identity_session:start(s2, bob, web, Me, infinity),\n identity_session:expire(A),\n case identity_session:lookup(B) of {ok, {_,_,_,St}} -> St end"))
|
||||
"active")
|
||||
|
||||
;; ── clean stop ───────────────────────────────────────────────────
|
||||
|
||||
(id-session-test
|
||||
"stop returns ok"
|
||||
(idnm
|
||||
(id-ev
|
||||
"Me = self(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n identity_session:stop(S)"))
|
||||
"ok")
|
||||
|
||||
(define
|
||||
id-session-test-summary
|
||||
(str "session " id-session-test-pass "/" id-session-test-count))
|
||||
81
lib/identity/tests/session_mgmt.sx
Normal file
81
lib/identity/tests/session_mgmt.sx
Normal file
@@ -0,0 +1,81 @@
|
||||
;; identity/tests/session_mgmt.sx — subject-wide session management:
|
||||
;; enumerate a subject's sessions and \"log out everywhere\".
|
||||
|
||||
(define id-smgmt-test-count 0)
|
||||
(define id-smgmt-test-pass 0)
|
||||
(define id-smgmt-test-fails (list))
|
||||
|
||||
(define
|
||||
id-smgmt-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! id-smgmt-test-count (+ id-smgmt-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! id-smgmt-test-pass (+ id-smgmt-test-pass 1))
|
||||
(append! id-smgmt-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define idsm-ev erlang-eval-ast)
|
||||
(define idsmnm (fn (v) (get v :name)))
|
||||
|
||||
(identity-load-all!)
|
||||
|
||||
;; ── enumerate a subject's sessions ───────────────────────────────
|
||||
|
||||
(id-smgmt-test
|
||||
"sessions lists all of a subject's sessions"
|
||||
(idsm-ev
|
||||
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, alice, cli, read),\n length(identity:sessions(Svc, alice))")
|
||||
2)
|
||||
|
||||
(id-smgmt-test
|
||||
"sessions is empty for a subject with none"
|
||||
(idsm-ev
|
||||
"Svc = identity:start(),\n length(identity:sessions(Svc, stranger))")
|
||||
0)
|
||||
|
||||
;; ── log out everywhere ───────────────────────────────────────────
|
||||
|
||||
(id-smgmt-test
|
||||
"logout_all ends every session of the subject"
|
||||
(idsmnm
|
||||
(idsm-ev
|
||||
"Svc = identity:start(),\n {ok, S1, _} = identity:login(Svc, alice, web, read),\n {ok, S2, _} = identity:login(Svc, alice, cli, read),\n identity:logout_all(Svc, alice),\n case {identity:session_status(Svc, S1), identity:session_status(Svc, S2)} of\n {gone, gone} -> both_gone;\n _ -> some_left\n end"))
|
||||
"both_gone")
|
||||
|
||||
(id-smgmt-test
|
||||
"after logout_all the subject has no sessions"
|
||||
(idsm-ev
|
||||
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, alice, cli, read),\n identity:logout_all(Svc, alice),\n length(identity:sessions(Svc, alice))")
|
||||
0)
|
||||
|
||||
(id-smgmt-test
|
||||
"logout_all leaves other subjects' sessions intact"
|
||||
(idsm-ev
|
||||
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, bob, web, read),\n identity:logout_all(Svc, alice),\n length(identity:sessions(Svc, bob))")
|
||||
1)
|
||||
|
||||
(id-smgmt-test
|
||||
"logout_all on an unknown subject is ok, not a crash"
|
||||
(idsmnm
|
||||
(idsm-ev "Svc = identity:start(),\n identity:logout_all(Svc, ghost)"))
|
||||
"ok")
|
||||
|
||||
;; ── logout_all is audited ────────────────────────────────────────
|
||||
|
||||
(id-smgmt-test
|
||||
"logout_all records a logout event"
|
||||
(idsmnm
|
||||
(idsm-ev
|
||||
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:logout_all(Svc, alice),\n case identity:history(Svc, alice) of\n [login, issue, logout] -> audited;\n Other -> Other\n end"))
|
||||
"audited")
|
||||
|
||||
(id-smgmt-test
|
||||
"logout_all audits each of several sessions"
|
||||
(idsm-ev
|
||||
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, alice, cli, read),\n identity:logout_all(Svc, alice),\n length(identity:history(Svc, alice))")
|
||||
6)
|
||||
|
||||
(define
|
||||
id-smgmt-test-summary
|
||||
(str "session-mgmt " id-smgmt-test-pass "/" id-smgmt-test-count))
|
||||
115
lib/identity/tests/sso.sx
Normal file
115
lib/identity/tests/sso.sx
Normal file
@@ -0,0 +1,115 @@
|
||||
;; identity/tests/sso.sx — silent SSO (prompt=none, OIDC §3.1.2.1) as a
|
||||
;; fast-path through the authorization-code machine. One subject session,
|
||||
;; many client apps; no session → login_required (a negative state, not a
|
||||
;; redirect). Silently-issued codes carry the same client/redirect/PKCE
|
||||
;; binding as consented codes.
|
||||
|
||||
(define id-sso-test-count 0)
|
||||
(define id-sso-test-pass 0)
|
||||
(define id-sso-test-fails (list))
|
||||
|
||||
(define
|
||||
id-sso-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! id-sso-test-count (+ id-sso-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! id-sso-test-pass (+ id-sso-test-pass 1))
|
||||
(append! id-sso-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define ids-ev erlang-eval-ast)
|
||||
(define idsnm (fn (v) (get v :name)))
|
||||
|
||||
(identity-load-token!)
|
||||
(identity-load-session!)
|
||||
(identity-load-registry!)
|
||||
(identity-load-oauth!)
|
||||
|
||||
;; ── no session → login_required ──────────────────────────────────
|
||||
|
||||
(id-sso-test
|
||||
"silent authorize without a session is login_required"
|
||||
(idsnm
|
||||
(ids-ev
|
||||
"O = identity_oauth:start(),\n case identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv) of\n {code, _} -> got_code;\n {error, Why} -> Why\n end"))
|
||||
"login_required")
|
||||
|
||||
;; ── established session → silent code ────────────────────────────
|
||||
|
||||
(id-sso-test
|
||||
"silent authorize for the same client returns a code"
|
||||
(idsnm
|
||||
(ids-ev
|
||||
"O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n case identity_oauth:silent_authorize(O, web, uri1, read, alice, vv) of\n {code, _} -> got_code;\n {error, Why} -> Why\n end"))
|
||||
"got_code")
|
||||
|
||||
;; ── one session, many clients ────────────────────────────────────
|
||||
|
||||
(id-sso-test
|
||||
"a different client gets a silent code off the same session"
|
||||
(idsnm
|
||||
(ids-ev
|
||||
"O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n case identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv) of\n {code, _} -> got_code;\n {error, Why} -> Why\n end"))
|
||||
"got_code")
|
||||
|
||||
(id-sso-test
|
||||
"many clients all silently authorize off one session"
|
||||
(idsnm
|
||||
(ids-ev
|
||||
"O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n {code, _C1} = identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv),\n {code, _C2} = identity_oauth:silent_authorize(O, mobile, uri3, read, alice, vv),\n case identity_oauth:silent_authorize(O, billing, uri4, read, alice, vv) of\n {code, _} -> got_code;\n {error, Why} -> Why\n end"))
|
||||
"got_code")
|
||||
|
||||
;; ── full SSO → token ─────────────────────────────────────────────
|
||||
|
||||
(id-sso-test
|
||||
"silent code exchanges to a working token"
|
||||
(idsnm
|
||||
(ids-ev
|
||||
"O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n {code, C} = identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv),\n {ok, A, _R} = identity_oauth:exchange(O, C, dashboard, uri2, vv),\n case identity_oauth:introspect(O, A) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"active")
|
||||
|
||||
(id-sso-test
|
||||
"SSO token carries the subject"
|
||||
(idsnm
|
||||
(ids-ev
|
||||
"O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n {code, C} = identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv),\n {ok, A, _R} = identity_oauth:exchange(O, C, dashboard, uri2, vv),\n case identity_oauth:introspect(O, A) of\n {active, Subject, _, _} -> Subject\n end"))
|
||||
"alice")
|
||||
|
||||
;; ── silent codes keep the full binding ───────────────────────────
|
||||
|
||||
(id-sso-test
|
||||
"silent code still enforces PKCE at exchange"
|
||||
(idsnm
|
||||
(ids-ev
|
||||
"O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n {code, C} = identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv),\n case identity_oauth:exchange(O, C, dashboard, uri2, wrongverif) of\n {ok, _, _} -> ok;\n {error, Why} -> Why\n end"))
|
||||
"invalid_grant")
|
||||
|
||||
(id-sso-test
|
||||
"silent code still enforces client binding at exchange"
|
||||
(idsnm
|
||||
(ids-ev
|
||||
"O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n {code, C} = identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv),\n case identity_oauth:exchange(O, C, attacker, uri2, vv) of\n {ok, _, _} -> ok;\n {error, Why} -> Why\n end"))
|
||||
"invalid_grant")
|
||||
|
||||
;; ── subject scoping: SSO is per subject ──────────────────────────
|
||||
|
||||
(id-sso-test
|
||||
"another subject is still login_required"
|
||||
(idsnm
|
||||
(ids-ev
|
||||
"O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n case identity_oauth:silent_authorize(O, dashboard, uri2, read, bob, vv) of\n {code, _} -> got_code;\n {error, Why} -> Why\n end"))
|
||||
"login_required")
|
||||
|
||||
;; ── ending the session closes the SSO fast-path ──────────────────
|
||||
|
||||
(id-sso-test
|
||||
"after end_session, silent authorize is login_required"
|
||||
(idsnm
|
||||
(ids-ev
|
||||
"O = identity_oauth:start(),\n {ok, Sid} = identity_oauth:establish(O, alice, web),\n identity_oauth:end_session(O, Sid),\n case identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv) of\n {code, _} -> got_code;\n {error, Why} -> Why\n end"))
|
||||
"login_required")
|
||||
|
||||
(define
|
||||
id-sso-test-summary
|
||||
(str "sso " id-sso-test-pass "/" id-sso-test-count))
|
||||
215
lib/identity/tests/token.sx
Normal file
215
lib/identity/tests/token.sx
Normal file
@@ -0,0 +1,215 @@
|
||||
;; identity/tests/token.sx — opaque tokens, grant-backed lookup, real
|
||||
;; revocation, refresh-token rotation, cascading revocation, and scope
|
||||
;; narrowing on refresh. The revoke-then-introspect and refresh-reuse
|
||||
;; paths are the security centrepieces.
|
||||
|
||||
(define id-token-test-count 0)
|
||||
(define id-token-test-pass 0)
|
||||
(define id-token-test-fails (list))
|
||||
|
||||
(define
|
||||
id-token-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! id-token-test-count (+ id-token-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! id-token-test-pass (+ id-token-test-pass 1))
|
||||
(append! id-token-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define idt-ev erlang-eval-ast)
|
||||
(define idtnm (fn (v) (get v :name)))
|
||||
|
||||
(identity-load-token!)
|
||||
|
||||
;; ── issue + introspect (happy path) ──────────────────────────────
|
||||
|
||||
(id-token-test
|
||||
"fresh token introspects active"
|
||||
(idtnm
|
||||
(idt-ev
|
||||
"Reg = identity_tokens:start(),\n {ok, Tok} = identity_tokens:issue(Reg, alice, web, read),\n case identity_tokens:introspect(Reg, Tok) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"active")
|
||||
|
||||
(id-token-test
|
||||
"introspect returns the granted subject"
|
||||
(idtnm
|
||||
(idt-ev
|
||||
"Reg = identity_tokens:start(),\n {ok, Tok} = identity_tokens:issue(Reg, alice, web, read),\n case identity_tokens:introspect(Reg, Tok) of\n {active, Subject, _, _} -> Subject\n end"))
|
||||
"alice")
|
||||
|
||||
(id-token-test
|
||||
"introspect returns the granted scope"
|
||||
(idtnm
|
||||
(idt-ev
|
||||
"Reg = identity_tokens:start(),\n {ok, Tok} = identity_tokens:issue(Reg, alice, web, write),\n case identity_tokens:introspect(Reg, Tok) of\n {active, _, _, Scope} -> Scope\n end"))
|
||||
"write")
|
||||
|
||||
;; ── opacity: distinct tokens, no cross-talk ──────────────────────
|
||||
|
||||
(id-token-test
|
||||
"two issues yield independent grants"
|
||||
(idtnm
|
||||
(idt-ev
|
||||
"Reg = identity_tokens:start(),\n {ok, A} = identity_tokens:issue(Reg, alice, web, read),\n {ok, B} = identity_tokens:issue(Reg, bob, cli, write),\n identity_tokens:revoke(Reg, A),\n case identity_tokens:introspect(Reg, B) of\n {active, Subject, _, _} -> Subject;\n {inactive} -> inactive\n end"))
|
||||
"bob")
|
||||
|
||||
;; ── revocation is real (RFC 7009) ────────────────────────────────
|
||||
|
||||
(id-token-test
|
||||
"revoked token introspects inactive immediately"
|
||||
(idtnm
|
||||
(idt-ev
|
||||
"Reg = identity_tokens:start(),\n {ok, Tok} = identity_tokens:issue(Reg, alice, web, read),\n active = case identity_tokens:introspect(Reg, Tok) of\n {active, _, _, _} -> active end,\n identity_tokens:revoke(Reg, Tok),\n case identity_tokens:introspect(Reg, Tok) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end"))
|
||||
"inactive")
|
||||
|
||||
(id-token-test
|
||||
"revoke is idempotent"
|
||||
(idtnm
|
||||
(idt-ev
|
||||
"Reg = identity_tokens:start(),\n {ok, Tok} = identity_tokens:issue(Reg, alice, web, read),\n identity_tokens:revoke(Reg, Tok),\n identity_tokens:revoke(Reg, Tok)"))
|
||||
"ok")
|
||||
|
||||
;; ── unknown tokens are inactive, never an error/crash ────────────
|
||||
|
||||
(id-token-test
|
||||
"introspecting an unknown token is inactive"
|
||||
(idtnm
|
||||
(idt-ev
|
||||
"Reg = identity_tokens:start(),\n Bogus = make_ref(),\n case identity_tokens:introspect(Reg, Bogus) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"inactive")
|
||||
|
||||
(id-token-test
|
||||
"revoking an unknown token is ok, not a crash"
|
||||
(idtnm
|
||||
(idt-ev
|
||||
"Reg = identity_tokens:start(),\n Bogus = make_ref(),\n identity_tokens:revoke(Reg, Bogus)"))
|
||||
"ok")
|
||||
|
||||
;; ── one revocation does not affect a sibling token ───────────────
|
||||
|
||||
(id-token-test
|
||||
"revoking one token leaves the other active"
|
||||
(idtnm
|
||||
(idt-ev
|
||||
"Reg = identity_tokens:start(),\n {ok, A} = identity_tokens:issue(Reg, alice, web, read),\n {ok, B} = identity_tokens:issue(Reg, alice, cli, read),\n identity_tokens:revoke(Reg, A),\n case identity_tokens:introspect(Reg, B) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"active")
|
||||
|
||||
;; ── issue_grant: access + refresh pair (RFC 6749 §4.1.4 / §5.1) ───
|
||||
|
||||
(id-token-test
|
||||
"issue_grant access token introspects active"
|
||||
(idtnm
|
||||
(idt-ev
|
||||
"Reg = identity_tokens:start(),\n {ok, A, _R} = identity_tokens:issue_grant(Reg, alice, web, read),\n case identity_tokens:introspect(Reg, A) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"active")
|
||||
|
||||
;; ── refresh rotation (RFC 6749 §6) ───────────────────────────────
|
||||
|
||||
(id-token-test
|
||||
"refresh mints a working new access token"
|
||||
(idtnm
|
||||
(idt-ev
|
||||
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R),\n case identity_tokens:introspect(Reg, A2) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"active")
|
||||
|
||||
(id-token-test
|
||||
"rotated token keeps the grant's subject"
|
||||
(idtnm
|
||||
(idt-ev
|
||||
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R),\n case identity_tokens:introspect(Reg, A2) of\n {active, Subject, _, _} -> Subject\n end"))
|
||||
"alice")
|
||||
|
||||
(id-token-test
|
||||
"refresh chains across rotations"
|
||||
(idtnm
|
||||
(idt-ev
|
||||
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n {ok, _A2, R2} = identity_tokens:refresh(Reg, R),\n {ok, A3, _R3} = identity_tokens:refresh(Reg, R2),\n case identity_tokens:introspect(Reg, A3) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"active")
|
||||
|
||||
(id-token-test
|
||||
"refreshing an unknown token is invalid_grant"
|
||||
(idtnm
|
||||
(idt-ev
|
||||
"Reg = identity_tokens:start(),\n Bogus = make_ref(),\n case identity_tokens:refresh(Reg, Bogus) of\n {ok, _, _} -> rotated;\n {error, Why} -> Why\n end"))
|
||||
"invalid_grant")
|
||||
|
||||
;; ── refresh-token reuse = theft → revoke the family (RFC 6819) ────
|
||||
|
||||
(id-token-test
|
||||
"reusing a superseded refresh token is invalid_grant"
|
||||
(idtnm
|
||||
(idt-ev
|
||||
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n {ok, _A2, _R2} = identity_tokens:refresh(Reg, R),\n case identity_tokens:refresh(Reg, R) of\n {ok, _, _} -> rotated;\n {error, Why} -> Why\n end"))
|
||||
"invalid_grant")
|
||||
|
||||
(id-token-test
|
||||
"refresh reuse revokes the live descendant too"
|
||||
(idtnm
|
||||
(idt-ev
|
||||
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R),\n identity_tokens:refresh(Reg, R),\n case identity_tokens:introspect(Reg, A2) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end"))
|
||||
"inactive")
|
||||
|
||||
;; ── cascading revocation: revoke any token, the grant dies ───────
|
||||
|
||||
(id-token-test
|
||||
"revoking the access token blocks refresh"
|
||||
(idtnm
|
||||
(idt-ev
|
||||
"Reg = identity_tokens:start(),\n {ok, A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n identity_tokens:revoke(Reg, A),\n case identity_tokens:refresh(Reg, R) of\n {ok, _, _} -> refreshed;\n {error, Why} -> Why\n end"))
|
||||
"invalid_grant")
|
||||
|
||||
(id-token-test
|
||||
"revoking the refresh token deactivates the access token"
|
||||
(idtnm
|
||||
(idt-ev
|
||||
"Reg = identity_tokens:start(),\n {ok, A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n identity_tokens:revoke(Reg, R),\n case identity_tokens:introspect(Reg, A) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"inactive")
|
||||
|
||||
;; ── scope as a set + narrowing on refresh (RFC 6749 §6 / §3.3) ───
|
||||
|
||||
(id-token-test
|
||||
"a list scope round-trips through introspect"
|
||||
(idtnm
|
||||
(idt-ev
|
||||
"Reg = identity_tokens:start(),\n {ok, A, _R} = identity_tokens:issue_grant(Reg, alice, web, [read, write]),\n case identity_tokens:introspect(Reg, A) of\n {active, _, _, [read, write]} -> matched;\n {active, _, _, _} -> other;\n {inactive} -> inactive\n end"))
|
||||
"matched")
|
||||
|
||||
(id-token-test
|
||||
"refresh can narrow the scope to a subset"
|
||||
(idtnm
|
||||
(idt-ev
|
||||
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, [read, write]),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R, [read]),\n case identity_tokens:introspect(Reg, A2) of\n {active, _, _, [read]} -> narrowed;\n {active, _, _, _} -> other;\n {inactive} -> inactive\n end"))
|
||||
"narrowed")
|
||||
|
||||
(id-token-test
|
||||
"refresh cannot widen scope beyond the grant"
|
||||
(idtnm
|
||||
(idt-ev
|
||||
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, [read]),\n case identity_tokens:refresh(Reg, R, [read, write]) of\n {ok, _, _} -> widened;\n {error, Why} -> Why\n end"))
|
||||
"invalid_scope")
|
||||
|
||||
(id-token-test
|
||||
"an invalid_scope refresh does not consume the refresh token"
|
||||
(idtnm
|
||||
(idt-ev
|
||||
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, [read, write]),\n identity_tokens:refresh(Reg, R, [admin]),\n case identity_tokens:refresh(Reg, R, [read]) of\n {ok, _, _} -> still_usable;\n {error, Why} -> Why\n end"))
|
||||
"still_usable")
|
||||
|
||||
(id-token-test
|
||||
"plain refresh keeps the full grant scope"
|
||||
(idtnm
|
||||
(idt-ev
|
||||
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, [read, write]),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R),\n case identity_tokens:introspect(Reg, A2) of\n {active, _, _, [read, write]} -> full;\n {active, _, _, _} -> other;\n {inactive} -> inactive\n end"))
|
||||
"full")
|
||||
|
||||
(id-token-test
|
||||
"a narrowed token still cascades on revoke"
|
||||
(idtnm
|
||||
(idt-ev
|
||||
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, [read, write]),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R, [read]),\n identity_tokens:revoke(Reg, A2),\n case identity_tokens:introspect(Reg, A2) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end"))
|
||||
"inactive")
|
||||
|
||||
(define
|
||||
id-token-test-summary
|
||||
(str "token " id-token-test-pass "/" id-token-test-count))
|
||||
40
lib/identity/token.sx
Normal file
40
lib/identity/token.sx
Normal file
File diff suppressed because one or more lines are too long
141
lib/relations/api.sx
Normal file
141
lib/relations/api.sx
Normal file
@@ -0,0 +1,141 @@
|
||||
;; lib/relations/api.sx — relationship lifecycle + current-db convenience layer.
|
||||
;;
|
||||
;; A relations db is a live Datalog db holding rel(Src,Dst,Kind) facts (and, for
|
||||
;; federation, peer_rel/trust facts) under the engine ruleset
|
||||
;; (lib/relations/engine.sx). The query functions live in engine.sx; this module
|
||||
;; owns db construction, the assert/retract lifecycle, and a current-db
|
||||
;; convenience layer for callers that load a fact base once and query without
|
||||
;; threading the db around. This mirrors lib/acl/api.sx.
|
||||
|
||||
(define
|
||||
relations-build-db
|
||||
(fn (facts) (dl-program-data facts relations-rules)))
|
||||
|
||||
(define relations-current-db nil)
|
||||
|
||||
(define
|
||||
relations/load!
|
||||
(fn
|
||||
(facts)
|
||||
(do
|
||||
(set! relations-current-db (relations-build-db facts))
|
||||
relations-current-db)))
|
||||
|
||||
(define
|
||||
relations-ensure-db!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(when
|
||||
(= relations-current-db nil)
|
||||
(set! relations-current-db (relations-build-db (list))))
|
||||
relations-current-db)))
|
||||
|
||||
;; Add a relationship to the current db (re-saturates).
|
||||
(define
|
||||
relations/relate
|
||||
(fn
|
||||
(src dst kind)
|
||||
(dl-assert! (relations-ensure-db!) (relations-rel src dst kind))))
|
||||
|
||||
;; Remove a relationship from the current db (re-saturates).
|
||||
(define
|
||||
relations/unrelate
|
||||
(fn
|
||||
(src dst kind)
|
||||
(dl-retract! (relations-ensure-db!) (relations-rel src dst kind))))
|
||||
|
||||
(define
|
||||
relations/children
|
||||
(fn (node kind) (relations-children-of (relations-ensure-db!) node kind)))
|
||||
|
||||
(define
|
||||
relations/parents
|
||||
(fn (node kind) (relations-parents-of (relations-ensure-db!) node kind)))
|
||||
|
||||
(define
|
||||
relations/related
|
||||
(fn (node kind) (relations-related (relations-ensure-db!) node kind)))
|
||||
|
||||
(define
|
||||
relations/descendants
|
||||
(fn (node kind) (relations-descendants (relations-ensure-db!) node kind)))
|
||||
|
||||
(define
|
||||
relations/ancestors
|
||||
(fn (node kind) (relations-ancestors (relations-ensure-db!) node kind)))
|
||||
|
||||
(define
|
||||
relations/reachable?
|
||||
(fn (a b kind) (relations-reachable? (relations-ensure-db!) a b kind)))
|
||||
|
||||
(define
|
||||
relations/roots
|
||||
(fn (kind) (relations-roots (relations-ensure-db!) kind)))
|
||||
|
||||
(define
|
||||
relations/leaves
|
||||
(fn (kind) (relations-leaves (relations-ensure-db!) kind)))
|
||||
|
||||
(define
|
||||
relations/cycle?
|
||||
(fn (node kind) (relations-cycle? (relations-ensure-db!) node kind)))
|
||||
|
||||
(define
|
||||
relations/acyclic?
|
||||
(fn (kind) (relations-acyclic? (relations-ensure-db!) kind)))
|
||||
|
||||
(define
|
||||
relations/siblings
|
||||
(fn (node kind) (relations-siblings (relations-ensure-db!) node kind)))
|
||||
|
||||
(define
|
||||
relations/out-degree
|
||||
(fn (node kind) (relations-out-degree (relations-ensure-db!) node kind)))
|
||||
|
||||
(define
|
||||
relations/in-degree
|
||||
(fn (node kind) (relations-in-degree (relations-ensure-db!) node kind)))
|
||||
|
||||
(define
|
||||
relations/connected?
|
||||
(fn (a b kind) (relations-connected? (relations-ensure-db!) a b kind)))
|
||||
|
||||
(define
|
||||
relations-relate-many!
|
||||
(fn
|
||||
(db triples)
|
||||
(do
|
||||
(for-each
|
||||
(fn
|
||||
(t)
|
||||
(dl-assert!
|
||||
db
|
||||
(relations-rel (first t) (nth t 1) (nth t 2))))
|
||||
triples)
|
||||
db)))
|
||||
|
||||
(define
|
||||
relations-unrelate-node!
|
||||
(fn
|
||||
(db node)
|
||||
(do
|
||||
(for-each
|
||||
(fn
|
||||
(s)
|
||||
(dl-retract! db (relations-rel node (get s :Dst) (get s :Kind))))
|
||||
(dl-query db (list (quote rel) node (quote Dst) (quote Kind))))
|
||||
(for-each
|
||||
(fn
|
||||
(s)
|
||||
(dl-retract! db (relations-rel (get s :Src) node (get s :Kind))))
|
||||
(dl-query db (list (quote rel) (quote Src) node (quote Kind))))
|
||||
db)))
|
||||
|
||||
(define
|
||||
relations/relate-many!
|
||||
(fn (triples) (relations-relate-many! (relations-ensure-db!) triples)))
|
||||
|
||||
(define
|
||||
relations/unrelate-node!
|
||||
(fn (node) (relations-unrelate-node! (relations-ensure-db!) node)))
|
||||
35
lib/relations/conformance.conf
Normal file
35
lib/relations/conformance.conf
Normal file
@@ -0,0 +1,35 @@
|
||||
# relations conformance config — sourced by lib/guest/conformance.sh.
|
||||
|
||||
LANG_NAME=relations
|
||||
MODE=dict
|
||||
|
||||
PRELOADS=(
|
||||
lib/datalog/tokenizer.sx
|
||||
lib/datalog/parser.sx
|
||||
lib/datalog/unify.sx
|
||||
lib/datalog/db.sx
|
||||
lib/datalog/builtins.sx
|
||||
lib/datalog/aggregates.sx
|
||||
lib/datalog/strata.sx
|
||||
lib/datalog/eval.sx
|
||||
lib/datalog/api.sx
|
||||
lib/datalog/magic.sx
|
||||
lib/relations/schema.sx
|
||||
lib/relations/engine.sx
|
||||
lib/relations/api.sx
|
||||
lib/relations/explain.sx
|
||||
lib/relations/federation.sx
|
||||
lib/relations/tree.sx
|
||||
)
|
||||
|
||||
SUITES=(
|
||||
"direct:lib/relations/tests/direct.sx:(relations-direct-tests-run!)"
|
||||
"reach:lib/relations/tests/reach.sx:(relations-reach-tests-run!)"
|
||||
"path:lib/relations/tests/path.sx:(relations-path-tests-run!)"
|
||||
"fed:lib/relations/tests/fed.sx:(relations-fed-tests-run!)"
|
||||
"shape:lib/relations/tests/shape.sx:(relations-shape-tests-run!)"
|
||||
"tree:lib/relations/tests/tree.sx:(relations-tree-tests-run!)"
|
||||
"routes:lib/relations/tests/routes.sx:(relations-routes-tests-run!)"
|
||||
"bulk:lib/relations/tests/bulk.sx:(relations-bulk-tests-run!)"
|
||||
"comp:lib/relations/tests/comp.sx:(relations-comp-tests-run!)"
|
||||
)
|
||||
3
lib/relations/conformance.sh
Executable file
3
lib/relations/conformance.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# Thin wrapper — see lib/guest/conformance.sh and lib/relations/conformance.conf.
|
||||
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"
|
||||
236
lib/relations/engine.sx
Normal file
236
lib/relations/engine.sx
Normal file
@@ -0,0 +1,236 @@
|
||||
;; lib/relations/engine.sx — recursive reachability + roots/leaves + cycles +
|
||||
;; shape queries.
|
||||
;;
|
||||
;; The Datalog ruleset is deliberately MINIMAL — every dl-query re-saturates it,
|
||||
;; so each added recursive relation taxes every query in every suite. Reachability
|
||||
;; (`reach`/`reach_any`), node membership (`rnode`) and root/leaf are the only
|
||||
;; derived relations; the shape queries (siblings, undirected connectivity) are
|
||||
;; computed in SX over the fast direct `erel` queries, NOT as extra closures.
|
||||
;;
|
||||
;; The ruleset derives from the EFFECTIVE relation `erel`, not raw `rel`. `erel`
|
||||
;; unions local edges with trust-gated federated edges:
|
||||
;;
|
||||
;; erel(S,D,K) :- rel(S,D,K). ; local edge, always
|
||||
;; erel(S,D,K) :- peer_rel(P,S,D,K), trust(P). ; peer edge, gated by trust
|
||||
;;
|
||||
;; Trust is a body literal, re-checked every query, so revoking trust (or a peer
|
||||
;; link) takes effect on the next saturation. Trust is NOT transitive — only a
|
||||
;; peer's own links, under a local trust(P) fact, bind. With no peer_rel/trust
|
||||
;; facts, erel ≡ rel, so non-federated behaviour is unchanged.
|
||||
;;
|
||||
;; Reachability is the bottom-up transitive closure acl-on-sx uses for
|
||||
;; inheritance, parameterised by Kind so closures never leak across kinds:
|
||||
;;
|
||||
;; reach(K,X,Y) :- erel(X,Y,K). ; one hop
|
||||
;; reach(K,X,Y) :- erel(X,Z,K), reach(K,Z,Y). ; transitive
|
||||
;;
|
||||
;; `reach_any` is the kind-agnostic closure (any edge, any kind) for mixed-kind
|
||||
;; reachability. rnode collects the nodes touched by a kind; root/leaf are those
|
||||
;; with no incoming / no outgoing edge (stratified negation). Cycles are ordinary
|
||||
;; data: `reach(K,X,X)` simply holds for nodes on a cycle — cycle?/acyclic? are
|
||||
;; queries, not errors. Do not assume a DAG.
|
||||
|
||||
(define
|
||||
relations-rules
|
||||
(quote
|
||||
((erel S D K <- (rel S D K))
|
||||
(erel S D K <- (peer_rel P S D K) (trust P))
|
||||
(reach K X Y <- (erel X Y K))
|
||||
(reach K X Y <- (erel X Z K) (reach K Z Y))
|
||||
(reach_any X Y <- (erel X Y K))
|
||||
(reach_any X Y <- (erel X Z K) (reach_any Z Y))
|
||||
(rnode K X <- (erel X Y K))
|
||||
(rnode K Y <- (erel X Y K))
|
||||
(has_parent K Y <- (erel X Y K))
|
||||
(has_child K X <- (erel X Y K))
|
||||
(root K X <- (rnode K X) {:neg (has_parent K X)})
|
||||
(leaf K X <- (rnode K X) {:neg (has_child K X)}))))
|
||||
|
||||
;; Pull one column (by keyword key) out of a list of substitution dicts.
|
||||
(define
|
||||
relations-pluck
|
||||
(fn (substs key) (map (fn (s) (get s key)) substs)))
|
||||
|
||||
;; Membership without host-name clashes (schema.sx defines relations-member?,
|
||||
;; but engine.sx may load before schema in ad-hoc sessions — keep a local copy).
|
||||
(define
|
||||
relations-eng-member?
|
||||
(fn
|
||||
(x xs)
|
||||
(cond
|
||||
((= (len xs) 0) false)
|
||||
((= (first xs) x) true)
|
||||
(else (relations-eng-member? x (rest xs))))))
|
||||
|
||||
(define
|
||||
relations-concat-map
|
||||
(fn
|
||||
(f xs)
|
||||
(if
|
||||
(= (len xs) 0)
|
||||
(list)
|
||||
(append (f (first xs)) (relations-concat-map f (rest xs))))))
|
||||
|
||||
(define
|
||||
relations-dedup
|
||||
(fn
|
||||
(xs)
|
||||
(if
|
||||
(= (len xs) 0)
|
||||
(list)
|
||||
(let
|
||||
((r (relations-dedup (rest xs))))
|
||||
(if
|
||||
(relations-eng-member? (first xs) r)
|
||||
r
|
||||
(append (list (first xs)) r))))))
|
||||
|
||||
(define
|
||||
relations-without
|
||||
(fn (x xs) (filter (fn (e) (not (= e x))) xs)))
|
||||
|
||||
;; Direct children: every Dst with an effective edge erel(node, Dst, kind).
|
||||
(define
|
||||
relations-children-of
|
||||
(fn
|
||||
(db node kind)
|
||||
(relations-pluck
|
||||
(dl-query db (list (quote erel) node (quote Dst) kind))
|
||||
:Dst)))
|
||||
|
||||
;; Direct parents: every Src with an effective edge erel(Src, node, kind).
|
||||
(define
|
||||
relations-parents-of
|
||||
(fn
|
||||
(db node kind)
|
||||
(relations-pluck
|
||||
(dl-query db (list (quote erel) (quote Src) node kind))
|
||||
:Src)))
|
||||
|
||||
;; Directly related: neighbours in either direction under kind.
|
||||
(define
|
||||
relations-related
|
||||
(fn
|
||||
(db node kind)
|
||||
(append
|
||||
(relations-children-of db node kind)
|
||||
(relations-parents-of db node kind))))
|
||||
|
||||
;; Transitive descendants of node under kind (everything reachable forward).
|
||||
(define
|
||||
relations-descendants
|
||||
(fn
|
||||
(db node kind)
|
||||
(relations-pluck
|
||||
(dl-query db (list (quote reach) kind node (quote Y)))
|
||||
:Y)))
|
||||
|
||||
;; Transitive ancestors of node under kind (everything that reaches node).
|
||||
(define
|
||||
relations-ancestors
|
||||
(fn
|
||||
(db node kind)
|
||||
(relations-pluck
|
||||
(dl-query db (list (quote reach) kind (quote X) node))
|
||||
:X)))
|
||||
|
||||
;; Is b reachable from a under kind (transitive)?
|
||||
(define
|
||||
relations-reachable?
|
||||
(fn
|
||||
(db a b kind)
|
||||
(> (len (dl-query db (list (quote reach) kind a b))) 0)))
|
||||
|
||||
;; Mixed-kind: descendants reachable from node over edges of ANY kind.
|
||||
(define
|
||||
relations-descendants-any
|
||||
(fn
|
||||
(db node)
|
||||
(relations-pluck
|
||||
(dl-query db (list (quote reach_any) node (quote Y)))
|
||||
:Y)))
|
||||
|
||||
;; Mixed-kind: is b reachable from a over edges of ANY kind?
|
||||
(define
|
||||
relations-reachable-any?
|
||||
(fn
|
||||
(db a b)
|
||||
(> (len (dl-query db (list (quote reach_any) a b))) 0)))
|
||||
|
||||
;; Roots: nodes touched by kind with no incoming edge.
|
||||
(define
|
||||
relations-roots
|
||||
(fn
|
||||
(db kind)
|
||||
(relations-pluck (dl-query db (list (quote root) kind (quote X))) :X)))
|
||||
|
||||
;; Leaves: nodes touched by kind with no outgoing edge.
|
||||
(define
|
||||
relations-leaves
|
||||
(fn
|
||||
(db kind)
|
||||
(relations-pluck (dl-query db (list (quote leaf) kind (quote X))) :X)))
|
||||
|
||||
;; Is node on a cycle under kind (reachable from itself)?
|
||||
(define
|
||||
relations-cycle?
|
||||
(fn
|
||||
(db node kind)
|
||||
(> (len (dl-query db (list (quote reach) kind node node))) 0)))
|
||||
|
||||
;; Has the kind any cycle at all? (no node reaches itself)
|
||||
(define
|
||||
relations-acyclic?
|
||||
(fn
|
||||
(db kind)
|
||||
(=
|
||||
(len (dl-query db (list (quote reach) kind (quote X) (quote X))))
|
||||
0)))
|
||||
|
||||
;; Siblings: nodes sharing a parent with node under kind (excluding node).
|
||||
;; Computed in SX over direct queries — no extra Datalog closure.
|
||||
(define
|
||||
relations-siblings
|
||||
(fn
|
||||
(db node kind)
|
||||
(relations-without
|
||||
node
|
||||
(relations-dedup
|
||||
(relations-concat-map
|
||||
(fn (p) (relations-children-of db p kind))
|
||||
(relations-parents-of db node kind))))))
|
||||
|
||||
;; Out-degree: number of direct children under kind.
|
||||
(define
|
||||
relations-out-degree
|
||||
(fn (db node kind) (len (relations-children-of db node kind))))
|
||||
|
||||
;; In-degree: number of direct parents under kind.
|
||||
(define
|
||||
relations-in-degree
|
||||
(fn (db node kind) (len (relations-parents-of db node kind))))
|
||||
|
||||
;; Undirected BFS frontier expansion: grow `visited` by neighbours (either
|
||||
;; direction) until the frontier is empty. Reuses the fast `erel` queries.
|
||||
(define
|
||||
relations-ureach-bfs
|
||||
(fn
|
||||
(db kind frontier visited)
|
||||
(if
|
||||
(= (len frontier) 0)
|
||||
visited
|
||||
(let
|
||||
((fresh (filter (fn (n) (not (relations-eng-member? n visited))) (relations-dedup (relations-concat-map (fn (n) (relations-related db n kind)) frontier)))))
|
||||
(relations-ureach-bfs db kind fresh (append visited fresh))))))
|
||||
|
||||
;; Weakly connected: a and b joined by a path ignoring edge direction, under
|
||||
;; kind. (Reflexive — a node is connected to itself.)
|
||||
(define
|
||||
relations-connected?
|
||||
(fn
|
||||
(db a b kind)
|
||||
(or
|
||||
(= a b)
|
||||
(relations-eng-member?
|
||||
b
|
||||
(relations-ureach-bfs db kind (list a) (list a))))))
|
||||
112
lib/relations/explain.sx
Normal file
112
lib/relations/explain.sx
Normal file
@@ -0,0 +1,112 @@
|
||||
;; lib/relations/explain.sx — the connecting path: relations' answer to acl's
|
||||
;; proof tree.
|
||||
;;
|
||||
;; A `reach(K,a,b)` derivation is a chain of one-hop `rel` facts a→…→b. The path
|
||||
;; IS that derivation read off as the node sequence. lib/datalog/ records derived
|
||||
;; facts but not provenance, so we re-derive the chain over the saturated edge
|
||||
;; set — but breadth-first, so the path returned is a SHORTEST derivation (fewest
|
||||
;; hops). Every consecutive pair in the result is a real rel(x,y,kind) fact; no
|
||||
;; edges are invented. Cycles are handled by a visited set, so cyclic data
|
||||
;; terminates rather than looping.
|
||||
;;
|
||||
;; (relations-path db a b kind) → (a … b) | nil
|
||||
;; (relations-distance db a b k) → hop count | nil
|
||||
|
||||
(define relations-last (fn (xs) (nth xs (- (len xs) 1))))
|
||||
|
||||
(define
|
||||
relations-filter-unseen
|
||||
(fn (xs seen) (filter (fn (x) (not (relations-member? x seen))) xs)))
|
||||
|
||||
;; Breadth-first over the kind's edge set. `queue` is a list of partial paths
|
||||
;; (each a node list ending at its frontier node); `visited` is every node ever
|
||||
;; enqueued, so each node is expanded once and the first path to reach b is a
|
||||
;; shortest one.
|
||||
(define
|
||||
relations-path-bfs
|
||||
(fn
|
||||
(db b kind queue visited)
|
||||
(if
|
||||
(= (len queue) 0)
|
||||
nil
|
||||
(let
|
||||
((path (first queue)))
|
||||
(let
|
||||
((node (relations-last path)))
|
||||
(if
|
||||
(= node b)
|
||||
path
|
||||
(let
|
||||
((succs (relations-filter-unseen (relations-children-of db node kind) visited)))
|
||||
(relations-path-bfs
|
||||
db
|
||||
b
|
||||
kind
|
||||
(append
|
||||
(rest queue)
|
||||
(map (fn (s) (append path (list s))) succs))
|
||||
(append visited succs)))))))))
|
||||
|
||||
;; The connecting chain a→…→b under kind (shortest), or nil if unreachable.
|
||||
;; a = b returns the trivial one-node path.
|
||||
(define
|
||||
relations-path
|
||||
(fn
|
||||
(db a b kind)
|
||||
(if
|
||||
(= a b)
|
||||
(list a)
|
||||
(relations-path-bfs db b kind (list (list a)) (list a)))))
|
||||
|
||||
;; Hop count of the shortest path (0 for a=b), or nil if unreachable.
|
||||
(define
|
||||
relations-distance
|
||||
(fn
|
||||
(db a b kind)
|
||||
(let
|
||||
((p (relations-path db a b kind)))
|
||||
(if (= p nil) nil (- (len p) 1)))))
|
||||
|
||||
;; --- current-db convenience layer ---
|
||||
|
||||
(define
|
||||
relations-ap-dfs
|
||||
(fn
|
||||
(db b kind path node)
|
||||
(if
|
||||
(= node b)
|
||||
(list path)
|
||||
(relations-concat-map
|
||||
(fn
|
||||
(nbr)
|
||||
(if
|
||||
(relations-eng-member? nbr path)
|
||||
(list)
|
||||
(relations-ap-dfs db b kind (append path (list nbr)) nbr)))
|
||||
(relations-children-of db node kind)))))
|
||||
|
||||
(define
|
||||
relations-all-paths
|
||||
(fn
|
||||
(db a b kind)
|
||||
(if (= a b) (list (list a)) (relations-ap-dfs db b kind (list a) a))))
|
||||
|
||||
(define
|
||||
relations/path
|
||||
(fn (a b kind) (relations-path (relations-ensure-db!) a b kind)))
|
||||
|
||||
(define
|
||||
relations/distance
|
||||
(fn (a b kind) (relations-distance (relations-ensure-db!) a b kind)))
|
||||
|
||||
(define
|
||||
relations/descendants-any
|
||||
(fn (node) (relations-descendants-any (relations-ensure-db!) node)))
|
||||
|
||||
(define
|
||||
relations/reachable-any?
|
||||
(fn (a b) (relations-reachable-any? (relations-ensure-db!) a b)))
|
||||
|
||||
(define
|
||||
relations/all-paths
|
||||
(fn (a b kind) (relations-all-paths (relations-ensure-db!) a b kind)))
|
||||
70
lib/relations/federation.sx
Normal file
70
lib/relations/federation.sx
Normal file
@@ -0,0 +1,70 @@
|
||||
;; lib/relations/federation.sx — cross-instance links + trust + revocation.
|
||||
;;
|
||||
;; fed-sx replicates relationship facts between instances; this module models the
|
||||
;; local side. A peer's link arrives as `peer_rel(Peer, Src, Dst, Kind)` and only
|
||||
;; becomes an effective edge when a local `trust(Peer)` fact authorises it — the
|
||||
;; gating is the engine rule (lib/relations/engine.sx), re-checked every query,
|
||||
;; so revoking trust or a link takes effect on the next saturation. The network
|
||||
;; transport is fed-sx's job and is mocked here as a dict.
|
||||
;;
|
||||
;; Trust is NOT transitive: trusting peer α binds only links α itself asserts;
|
||||
;; α's own trust in some β does not flow.
|
||||
|
||||
;; A federated link asserted by `peer`: peer claims rel(src,dst,kind) holds.
|
||||
(define
|
||||
relations-peer-rel
|
||||
(fn (peer src dst kind) (list (quote peer_rel) peer src dst kind)))
|
||||
|
||||
;; Local trust in a peer. Gates that peer's links at query time.
|
||||
(define relations-trust (fn (peer) (list (quote trust) peer)))
|
||||
|
||||
;; Mock fed-sx pull: `transport` maps a peer address (its string name) to the
|
||||
;; list of peer_rel facts that peer asserts. Returns the facts for `addr`, or an
|
||||
;; empty list if the peer is unknown / unreachable.
|
||||
(define
|
||||
relations-fed-fetch
|
||||
(fn
|
||||
(transport addr)
|
||||
(let
|
||||
((k (if (symbol? addr) (symbol->string addr) addr)))
|
||||
(if (has-key? transport k) (get transport k) (list)))))
|
||||
|
||||
;; Gather peer_rel facts from every peer in `addrs` via the transport.
|
||||
(define
|
||||
relations-fed-collect
|
||||
(fn
|
||||
(transport addrs)
|
||||
(let
|
||||
((acc (list)))
|
||||
(do
|
||||
(for-each
|
||||
(fn
|
||||
(addr)
|
||||
(for-each
|
||||
(fn (f) (append! acc f))
|
||||
(relations-fed-fetch transport addr)))
|
||||
addrs)
|
||||
acc))))
|
||||
|
||||
;; Build a db from local facts plus peer_rel facts pulled from `peers`. Local
|
||||
;; facts must carry the trust policy (trust(...) facts); replicated links are
|
||||
;; gated against it by the engine rule at query time.
|
||||
(define
|
||||
relations-fed-build-db
|
||||
(fn
|
||||
(local-facts transport peers)
|
||||
(let
|
||||
((all (list)))
|
||||
(do
|
||||
(for-each (fn (f) (append! all f)) local-facts)
|
||||
(for-each
|
||||
(fn (f) (append! all f))
|
||||
(relations-fed-collect transport peers))
|
||||
(relations-build-db all)))))
|
||||
|
||||
;; Ingest a newly replicated fact into a live db (re-saturates).
|
||||
(define relations-fed-assert! (fn (db fact) (do (dl-assert! db fact) db)))
|
||||
|
||||
;; Propagated revocation: retract a replicated link or a local trust fact from a
|
||||
;; live db. The next query re-saturates and reflects it.
|
||||
(define relations-revoke! (fn (db fact) (do (dl-retract! db fact) db)))
|
||||
40
lib/relations/schema.sx
Normal file
40
lib/relations/schema.sx
Normal file
@@ -0,0 +1,40 @@
|
||||
;; lib/relations/schema.sx — relationship fact vocabulary over lib/datalog/.
|
||||
;;
|
||||
;; relations is content-agnostic: a node is an opaque id (a symbol or string);
|
||||
;; domains own what ids mean. A relationship is a single Datalog fact
|
||||
;;
|
||||
;; rel(Src, Dst, Kind)
|
||||
;;
|
||||
;; meaning "Src is related to Dst under Kind" (read directionally: Src is the
|
||||
;; parent/owner/origin, Dst the child/member/reply). Kind is an open vocabulary;
|
||||
;; the names below are the platform's well-known kinds but relate accepts any
|
||||
;; kind symbol — Datalog is untyped and domains may coin their own.
|
||||
|
||||
(define relations-kinds (quote (parent member reply variant origin link)))
|
||||
|
||||
(define relations-rel (fn (src dst kind) (list (quote rel) src dst kind)))
|
||||
|
||||
(define relations-rel-src (fn (f) (nth f 1)))
|
||||
|
||||
(define relations-rel-dst (fn (f) (nth f 2)))
|
||||
|
||||
(define relations-rel-kind (fn (f) (nth f 3)))
|
||||
|
||||
(define
|
||||
relations-member?
|
||||
(fn
|
||||
(x xs)
|
||||
(cond
|
||||
((= (len xs) 0) false)
|
||||
((= (first xs) x) true)
|
||||
(else (relations-member? x (rest xs))))))
|
||||
|
||||
(define
|
||||
relations-known-kind?
|
||||
(fn (k) (relations-member? k relations-kinds)))
|
||||
|
||||
(define
|
||||
relations-fact-valid?
|
||||
(fn
|
||||
(f)
|
||||
(and (list? f) (= (len f) 4) (= (first f) (quote rel)))))
|
||||
18
lib/relations/scoreboard.json
Normal file
18
lib/relations/scoreboard.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"lang": "relations",
|
||||
"total_passed": 158,
|
||||
"total_failed": 0,
|
||||
"total": 158,
|
||||
"suites": [
|
||||
{"name":"direct","passed":22,"failed":0,"total":22},
|
||||
{"name":"reach","passed":24,"failed":0,"total":24},
|
||||
{"name":"path","passed":24,"failed":0,"total":24},
|
||||
{"name":"fed","passed":22,"failed":0,"total":22},
|
||||
{"name":"shape","passed":18,"failed":0,"total":18},
|
||||
{"name":"tree","passed":16,"failed":0,"total":16},
|
||||
{"name":"routes","passed":9,"failed":0,"total":9},
|
||||
{"name":"bulk","passed":12,"failed":0,"total":12},
|
||||
{"name":"comp","passed":11,"failed":0,"total":11}
|
||||
],
|
||||
"generated": "2026-06-07T13:42:22+00:00"
|
||||
}
|
||||
15
lib/relations/scoreboard.md
Normal file
15
lib/relations/scoreboard.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# relations scoreboard
|
||||
|
||||
**158 / 158 passing** (0 failure(s)).
|
||||
|
||||
| Suite | Passed | Total | Status |
|
||||
|-------|--------|-------|--------|
|
||||
| direct | 22 | 22 | ok |
|
||||
| reach | 24 | 24 | ok |
|
||||
| path | 24 | 24 | ok |
|
||||
| fed | 22 | 22 | ok |
|
||||
| shape | 18 | 18 | ok |
|
||||
| tree | 16 | 16 | ok |
|
||||
| routes | 9 | 9 | ok |
|
||||
| bulk | 12 | 12 | ok |
|
||||
| comp | 11 | 11 | ok |
|
||||
142
lib/relations/tests/bulk.sx
Normal file
142
lib/relations/tests/bulk.sx
Normal file
@@ -0,0 +1,142 @@
|
||||
;; lib/relations/tests/bulk.sx — extension: bulk lifecycle (relate-many,
|
||||
;; unrelate-node cascade cleanup).
|
||||
|
||||
(define relations-bk-pass 0)
|
||||
(define relations-bk-fail 0)
|
||||
(define relations-bk-failures (list))
|
||||
|
||||
(define
|
||||
relations-bk-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! relations-bk-pass (+ relations-bk-pass 1))
|
||||
(do
|
||||
(set! relations-bk-fail (+ relations-bk-fail 1))
|
||||
(append!
|
||||
relations-bk-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
(define
|
||||
relations-bk-subset?
|
||||
(fn
|
||||
(xs ys)
|
||||
(cond
|
||||
((= (len xs) 0) true)
|
||||
((relations-member? (first xs) ys)
|
||||
(relations-bk-subset? (rest xs) ys))
|
||||
(else false))))
|
||||
|
||||
(define
|
||||
relations-bk-set=?
|
||||
(fn
|
||||
(xs ys)
|
||||
(and
|
||||
(= (len xs) (len ys))
|
||||
(relations-bk-subset? xs ys)
|
||||
(relations-bk-subset? ys xs))))
|
||||
|
||||
(define
|
||||
relations-bk-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(let
|
||||
((db (relations-build-db (list))))
|
||||
(do
|
||||
(relations-relate-many!
|
||||
db
|
||||
(list
|
||||
(list (quote a) (quote b) (quote parent))
|
||||
(list (quote a) (quote c) (quote parent))
|
||||
(list (quote x) (quote a) (quote parent))
|
||||
(list (quote a) (quote m) (quote member))))
|
||||
(relations-bk-check!
|
||||
"relate-many: parent children of a"
|
||||
(relations-bk-set=?
|
||||
(relations-children-of db (quote a) (quote parent))
|
||||
(list (quote b) (quote c)))
|
||||
true)
|
||||
(relations-bk-check!
|
||||
"relate-many: member child of a"
|
||||
(relations-bk-set=?
|
||||
(relations-children-of db (quote a) (quote member))
|
||||
(list (quote m)))
|
||||
true)
|
||||
(relations-bk-check!
|
||||
"relate-many: x is a parent of a"
|
||||
(relations-bk-set=?
|
||||
(relations-parents-of db (quote a) (quote parent))
|
||||
(list (quote x)))
|
||||
true)
|
||||
(relations-unrelate-node! db (quote a))
|
||||
(relations-bk-check!
|
||||
"after cleanup: a has no parent children"
|
||||
(relations-children-of db (quote a) (quote parent))
|
||||
(list))
|
||||
(relations-bk-check!
|
||||
"after cleanup: a has no parent parents"
|
||||
(relations-parents-of db (quote a) (quote parent))
|
||||
(list))
|
||||
(relations-bk-check!
|
||||
"after cleanup: a has no member children"
|
||||
(relations-children-of db (quote a) (quote member))
|
||||
(list))
|
||||
(relations-bk-check!
|
||||
"after cleanup: x no longer points at a"
|
||||
(relations-children-of db (quote x) (quote parent))
|
||||
(list))))
|
||||
(let
|
||||
((db (relations-build-db (list (relations-rel (quote a) (quote b) (quote parent)) (relations-rel (quote c) (quote d) (quote parent))))))
|
||||
(do
|
||||
(relations-unrelate-node! db (quote a))
|
||||
(relations-bk-check!
|
||||
"cleanup leaves unrelated edge intact"
|
||||
(relations-bk-set=?
|
||||
(relations-children-of db (quote c) (quote parent))
|
||||
(list (quote d)))
|
||||
true)
|
||||
(relations-bk-check!
|
||||
"cleanup removed the a edge"
|
||||
(relations-children-of db (quote a) (quote parent))
|
||||
(list))))
|
||||
(let
|
||||
((db (relations-build-db (list (relations-rel (quote a) (quote b) (quote parent))))))
|
||||
(do
|
||||
(relations-unrelate-node! db (quote nobody))
|
||||
(relations-bk-check!
|
||||
"cleanup of unknown node is a no-op"
|
||||
(relations-bk-set=?
|
||||
(relations-children-of db (quote a) (quote parent))
|
||||
(list (quote b)))
|
||||
true)))
|
||||
(do
|
||||
(relations/load! (list))
|
||||
(relations/relate-many!
|
||||
(list
|
||||
(list (quote o) (quote i1) (quote member))
|
||||
(list (quote o) (quote i2) (quote member))))
|
||||
(relations-bk-check!
|
||||
"api relate-many"
|
||||
(relations-bk-set=?
|
||||
(relations/children (quote o) (quote member))
|
||||
(list (quote i1) (quote i2)))
|
||||
true)
|
||||
(relations/unrelate-node! (quote o))
|
||||
(relations-bk-check!
|
||||
"api unrelate-node"
|
||||
(relations/children (quote o) (quote member))
|
||||
(list))
|
||||
(relations/load! (list))))))
|
||||
|
||||
(define
|
||||
relations-bulk-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! relations-bk-pass 0)
|
||||
(set! relations-bk-fail 0)
|
||||
(set! relations-bk-failures (list))
|
||||
(relations-bk-run-all!)
|
||||
{:failures relations-bk-failures :total (+ relations-bk-pass relations-bk-fail) :passed relations-bk-pass :failed relations-bk-fail})))
|
||||
144
lib/relations/tests/comp.sx
Normal file
144
lib/relations/tests/comp.sx
Normal file
@@ -0,0 +1,144 @@
|
||||
;; lib/relations/tests/comp.sx — extension: weakly-connected components.
|
||||
|
||||
(define relations-cp-pass 0)
|
||||
(define relations-cp-fail 0)
|
||||
(define relations-cp-failures (list))
|
||||
|
||||
(define
|
||||
relations-cp-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! relations-cp-pass (+ relations-cp-pass 1))
|
||||
(do
|
||||
(set! relations-cp-fail (+ relations-cp-fail 1))
|
||||
(append!
|
||||
relations-cp-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
(define
|
||||
relations-cp-subset?
|
||||
(fn
|
||||
(xs ys)
|
||||
(cond
|
||||
((= (len xs) 0) true)
|
||||
((relations-member? (first xs) ys)
|
||||
(relations-cp-subset? (rest xs) ys))
|
||||
(else false))))
|
||||
|
||||
(define
|
||||
relations-cp-set=?
|
||||
(fn
|
||||
(xs ys)
|
||||
(and
|
||||
(= (len xs) (len ys))
|
||||
(relations-cp-subset? xs ys)
|
||||
(relations-cp-subset? ys xs))))
|
||||
|
||||
;; Does `comps` (a list of node-lists) contain a component set-equal to `want`?
|
||||
(define
|
||||
relations-cp-has-comp?
|
||||
(fn
|
||||
(comps want)
|
||||
(cond
|
||||
((= (len comps) 0) false)
|
||||
((relations-cp-set=? (first comps) want) true)
|
||||
(else (relations-cp-has-comp? (rest comps) want)))))
|
||||
|
||||
;; Three parent components + a separate member graph.
|
||||
;; parent: a->b, b->c ; x->y ; z->z (self-loop, its own component)
|
||||
;; member: m->n
|
||||
(define
|
||||
relations-cp-fixture
|
||||
(fn
|
||||
()
|
||||
(relations-build-db
|
||||
(list
|
||||
(relations-rel (quote a) (quote b) (quote parent))
|
||||
(relations-rel (quote b) (quote c) (quote parent))
|
||||
(relations-rel (quote x) (quote y) (quote parent))
|
||||
(relations-rel (quote z) (quote z) (quote parent))
|
||||
(relations-rel (quote m) (quote n) (quote member))))))
|
||||
|
||||
(define
|
||||
relations-cp-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((db (relations-cp-fixture)))
|
||||
(do
|
||||
(relations-cp-check!
|
||||
"component of a"
|
||||
(relations-cp-set=?
|
||||
(relations-component db (quote a) (quote parent))
|
||||
(list (quote a) (quote b) (quote c)))
|
||||
true)
|
||||
(relations-cp-check!
|
||||
"component of c (same as a, undirected)"
|
||||
(relations-cp-set=?
|
||||
(relations-component db (quote c) (quote parent))
|
||||
(list (quote a) (quote b) (quote c)))
|
||||
true)
|
||||
(relations-cp-check!
|
||||
"self-loop node is its own component"
|
||||
(relations-cp-set=?
|
||||
(relations-component db (quote z) (quote parent))
|
||||
(list (quote z)))
|
||||
true)
|
||||
(relations-cp-check!
|
||||
"three parent components"
|
||||
(relations-component-count db (quote parent))
|
||||
3)
|
||||
(relations-cp-check!
|
||||
"one member component"
|
||||
(relations-component-count db (quote member))
|
||||
1)
|
||||
(let
|
||||
((comps (relations-components db (quote parent))))
|
||||
(do
|
||||
(relations-cp-check!
|
||||
"partition includes a-b-c"
|
||||
(relations-cp-has-comp?
|
||||
comps
|
||||
(list (quote a) (quote b) (quote c)))
|
||||
true)
|
||||
(relations-cp-check!
|
||||
"partition includes x-y"
|
||||
(relations-cp-has-comp? comps (list (quote x) (quote y)))
|
||||
true)
|
||||
(relations-cp-check!
|
||||
"partition includes z"
|
||||
(relations-cp-has-comp? comps (list (quote z)))
|
||||
true)))
|
||||
(relations-cp-check!
|
||||
"kind isolation: member component count is 1"
|
||||
(relations-component-count db (quote member))
|
||||
1)
|
||||
(do
|
||||
(relations/load!
|
||||
(list
|
||||
(relations-rel (quote p) (quote q) (quote parent))
|
||||
(relations-rel (quote r) (quote s) (quote parent))))
|
||||
(relations-cp-check!
|
||||
"api component"
|
||||
(relations-cp-set=?
|
||||
(relations/component (quote p) (quote parent))
|
||||
(list (quote p) (quote q)))
|
||||
true)
|
||||
(relations-cp-check!
|
||||
"api component-count"
|
||||
(relations/component-count (quote parent))
|
||||
2)
|
||||
(relations/load! (list)))))))
|
||||
|
||||
(define
|
||||
relations-comp-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! relations-cp-pass 0)
|
||||
(set! relations-cp-fail 0)
|
||||
(set! relations-cp-failures (list))
|
||||
(relations-cp-run-all!)
|
||||
{:failures relations-cp-failures :total (+ relations-cp-pass relations-cp-fail) :passed relations-cp-pass :failed relations-cp-fail})))
|
||||
197
lib/relations/tests/direct.sx
Normal file
197
lib/relations/tests/direct.sx
Normal file
@@ -0,0 +1,197 @@
|
||||
;; lib/relations/tests/direct.sx — Phase 1: schema + direct relations.
|
||||
|
||||
(define relations-dt-pass 0)
|
||||
(define relations-dt-fail 0)
|
||||
(define relations-dt-failures (list))
|
||||
|
||||
(define
|
||||
relations-dt-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! relations-dt-pass (+ relations-dt-pass 1))
|
||||
(do
|
||||
(set! relations-dt-fail (+ relations-dt-fail 1))
|
||||
(append!
|
||||
relations-dt-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
;; Order-insensitive membership: every x in xs is in ys.
|
||||
(define
|
||||
relations-dt-subset?
|
||||
(fn
|
||||
(xs ys)
|
||||
(cond
|
||||
((= (len xs) 0) true)
|
||||
((relations-member? (first xs) ys)
|
||||
(relations-dt-subset? (rest xs) ys))
|
||||
(else false))))
|
||||
|
||||
(define
|
||||
relations-dt-set=?
|
||||
(fn
|
||||
(xs ys)
|
||||
(and
|
||||
(= (len xs) (len ys))
|
||||
(relations-dt-subset? xs ys)
|
||||
(relations-dt-subset? ys xs))))
|
||||
|
||||
;; Fixture: a small forest with two kinds.
|
||||
;; parent: a -> b, a -> c, b -> d
|
||||
;; reply: p -> q
|
||||
(define
|
||||
relations-dt-fixture
|
||||
(fn
|
||||
()
|
||||
(relations-build-db
|
||||
(list
|
||||
(relations-rel (quote a) (quote b) (quote parent))
|
||||
(relations-rel (quote a) (quote c) (quote parent))
|
||||
(relations-rel (quote b) (quote d) (quote parent))
|
||||
(relations-rel (quote p) (quote q) (quote reply))))))
|
||||
|
||||
(define
|
||||
relations-dt-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((db (relations-dt-fixture)))
|
||||
(do
|
||||
(relations-dt-check!
|
||||
"direct children of a"
|
||||
(relations-dt-set=?
|
||||
(relations-children-of db (quote a) (quote parent))
|
||||
(list (quote b) (quote c)))
|
||||
true)
|
||||
(relations-dt-check!
|
||||
"direct children of b"
|
||||
(relations-dt-set=?
|
||||
(relations-children-of db (quote b) (quote parent))
|
||||
(list (quote d)))
|
||||
true)
|
||||
(relations-dt-check!
|
||||
"leaf has no children"
|
||||
(relations-children-of db (quote d) (quote parent))
|
||||
(list))
|
||||
(relations-dt-check!
|
||||
"direct parents of b"
|
||||
(relations-dt-set=?
|
||||
(relations-parents-of db (quote b) (quote parent))
|
||||
(list (quote a)))
|
||||
true)
|
||||
(relations-dt-check!
|
||||
"root has no parents"
|
||||
(relations-parents-of db (quote a) (quote parent))
|
||||
(list))
|
||||
(relations-dt-check!
|
||||
"related is both directions"
|
||||
(relations-dt-set=?
|
||||
(relations-related db (quote b) (quote parent))
|
||||
(list (quote d) (quote a)))
|
||||
true)
|
||||
(relations-dt-check!
|
||||
"kind isolation: parent query skips reply edge"
|
||||
(relations-children-of db (quote p) (quote parent))
|
||||
(list))
|
||||
(relations-dt-check!
|
||||
"reply children of p"
|
||||
(relations-dt-set=?
|
||||
(relations-children-of db (quote p) (quote reply))
|
||||
(list (quote q)))
|
||||
true)
|
||||
(relations-dt-check!
|
||||
"unknown node -> empty"
|
||||
(relations-children-of db (quote zzz) (quote parent))
|
||||
(list))
|
||||
(let
|
||||
((db2 (relations-build-db (list (relations-rel (quote x) (quote y) (quote parent))))))
|
||||
(do
|
||||
(relations-dt-check!
|
||||
"before retract: y is a child of x"
|
||||
(relations-dt-set=?
|
||||
(relations-children-of db2 (quote x) (quote parent))
|
||||
(list (quote y)))
|
||||
true)
|
||||
(dl-retract!
|
||||
db2
|
||||
(relations-rel (quote x) (quote y) (quote parent)))
|
||||
(relations-dt-check!
|
||||
"after retract: x has no children"
|
||||
(relations-children-of db2 (quote x) (quote parent))
|
||||
(list))))
|
||||
(do
|
||||
(relations/load! (list))
|
||||
(relations/relate (quote o1) (quote li1) (quote member))
|
||||
(relations/relate (quote o1) (quote li2) (quote member))
|
||||
(relations-dt-check!
|
||||
"api relate then children"
|
||||
(relations-dt-set=?
|
||||
(relations/children (quote o1) (quote member))
|
||||
(list (quote li1) (quote li2)))
|
||||
true)
|
||||
(relations-dt-check!
|
||||
"api parents"
|
||||
(relations-dt-set=?
|
||||
(relations/parents (quote li1) (quote member))
|
||||
(list (quote o1)))
|
||||
true)
|
||||
(relations/unrelate (quote o1) (quote li1) (quote member))
|
||||
(relations-dt-check!
|
||||
"api unrelate removes one child"
|
||||
(relations-dt-set=?
|
||||
(relations/children (quote o1) (quote member))
|
||||
(list (quote li2)))
|
||||
true)
|
||||
(relations/load! (list))
|
||||
(relations-dt-check!
|
||||
"api reload clears prior facts"
|
||||
(relations/children (quote o1) (quote member))
|
||||
(list)))
|
||||
(relations-dt-check!
|
||||
"rel constructor shape"
|
||||
(relations-rel (quote s) (quote d) (quote parent))
|
||||
(list (quote rel) (quote s) (quote d) (quote parent)))
|
||||
(relations-dt-check!
|
||||
"fact valid"
|
||||
(relations-fact-valid?
|
||||
(relations-rel (quote s) (quote d) (quote parent)))
|
||||
true)
|
||||
(relations-dt-check!
|
||||
"fact bad arity invalid"
|
||||
(relations-fact-valid? (list (quote rel) (quote s)))
|
||||
false)
|
||||
(relations-dt-check!
|
||||
"fact wrong head invalid"
|
||||
(relations-fact-valid?
|
||||
(list (quote edge) (quote s) (quote d) (quote parent)))
|
||||
false)
|
||||
(relations-dt-check!
|
||||
"known kind"
|
||||
(relations-known-kind? (quote parent))
|
||||
true)
|
||||
(relations-dt-check!
|
||||
"unknown kind"
|
||||
(relations-known-kind? (quote frobnicate))
|
||||
false)
|
||||
(relations-dt-check!
|
||||
"accessors"
|
||||
(list
|
||||
(relations-rel-src
|
||||
(relations-rel (quote s) (quote d) (quote k)))
|
||||
(relations-rel-dst
|
||||
(relations-rel (quote s) (quote d) (quote k)))
|
||||
(relations-rel-kind
|
||||
(relations-rel (quote s) (quote d) (quote k))))
|
||||
(list (quote s) (quote d) (quote k)))))))
|
||||
|
||||
(define
|
||||
relations-direct-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! relations-dt-pass 0)
|
||||
(set! relations-dt-fail 0)
|
||||
(set! relations-dt-failures (list))
|
||||
(relations-dt-run-all!)
|
||||
{:failures relations-dt-failures :total (+ relations-dt-pass relations-dt-fail) :passed relations-dt-pass :failed relations-dt-fail})))
|
||||
203
lib/relations/tests/fed.sx
Normal file
203
lib/relations/tests/fed.sx
Normal file
@@ -0,0 +1,203 @@
|
||||
;; lib/relations/tests/fed.sx — Phase 4: federation (peer links, trust gating,
|
||||
;; cross-instance chains, revocation). fed-sx transport is mocked as a dict.
|
||||
|
||||
(define relations-ft-pass 0)
|
||||
(define relations-ft-fail 0)
|
||||
(define relations-ft-failures (list))
|
||||
|
||||
(define
|
||||
relations-ft-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! relations-ft-pass (+ relations-ft-pass 1))
|
||||
(do
|
||||
(set! relations-ft-fail (+ relations-ft-fail 1))
|
||||
(append!
|
||||
relations-ft-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
(define
|
||||
relations-ft-subset?
|
||||
(fn
|
||||
(xs ys)
|
||||
(cond
|
||||
((= (len xs) 0) true)
|
||||
((relations-member? (first xs) ys)
|
||||
(relations-ft-subset? (rest xs) ys))
|
||||
(else false))))
|
||||
|
||||
(define
|
||||
relations-ft-set=?
|
||||
(fn
|
||||
(xs ys)
|
||||
(and
|
||||
(= (len xs) (len ys))
|
||||
(relations-ft-subset? xs ys)
|
||||
(relations-ft-subset? ys xs))))
|
||||
|
||||
;; Local edge a->b; peerA claims b->c; peerB claims c->d. Local trust only in
|
||||
;; peerA. With trust gating, a reaches c (via peerA's b->c) but not d.
|
||||
(define
|
||||
relations-ft-facts
|
||||
(fn
|
||||
()
|
||||
(list
|
||||
(relations-rel (quote a) (quote b) (quote parent))
|
||||
(relations-peer-rel (quote peerA) (quote b) (quote c) (quote parent))
|
||||
(relations-peer-rel (quote peerB) (quote c) (quote d) (quote parent))
|
||||
(relations-trust (quote peerA)))))
|
||||
|
||||
(define
|
||||
relations-ft-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(let
|
||||
((db (relations-build-db (list (relations-rel (quote a) (quote b) (quote parent)) (relations-peer-rel (quote peerX) (quote b) (quote c) (quote parent))))))
|
||||
(do
|
||||
(relations-ft-check!
|
||||
"untrusted link: c not a child of b"
|
||||
(relations-children-of db (quote b) (quote parent))
|
||||
(list))
|
||||
(relations-ft-check!
|
||||
"untrusted link: a cannot reach c"
|
||||
(relations-reachable? db (quote a) (quote c) (quote parent))
|
||||
false)))
|
||||
(let
|
||||
((db (relations-build-db (list (relations-rel (quote a) (quote b) (quote parent)) (relations-peer-rel (quote peerX) (quote b) (quote c) (quote parent)) (relations-trust (quote peerX))))))
|
||||
(do
|
||||
(relations-ft-check!
|
||||
"trusted link: c is a child of b"
|
||||
(relations-ft-set=?
|
||||
(relations-children-of db (quote b) (quote parent))
|
||||
(list (quote c)))
|
||||
true)
|
||||
(relations-ft-check!
|
||||
"trusted link: federated reachability a->c"
|
||||
(relations-reachable? db (quote a) (quote c) (quote parent))
|
||||
true)
|
||||
(relations-ft-check!
|
||||
"trusted link: connecting path crosses the federated edge"
|
||||
(relations-path db (quote a) (quote c) (quote parent))
|
||||
(list (quote a) (quote b) (quote c)))))
|
||||
(let
|
||||
((db (relations-build-db (relations-ft-facts))))
|
||||
(do
|
||||
(relations-ft-check!
|
||||
"non-transitive: a reaches c (peerA trusted)"
|
||||
(relations-reachable? db (quote a) (quote c) (quote parent))
|
||||
true)
|
||||
(relations-ft-check!
|
||||
"non-transitive: a does not reach d (peerB untrusted)"
|
||||
(relations-reachable? db (quote a) (quote d) (quote parent))
|
||||
false)
|
||||
(relations-ft-check!
|
||||
"non-transitive: d is not a child of c"
|
||||
(relations-children-of db (quote c) (quote parent))
|
||||
(list))))
|
||||
(let
|
||||
((db (relations-build-db (list (relations-rel (quote a) (quote b) (quote parent)) (relations-peer-rel (quote peerX) (quote b) (quote c) (quote parent)) (relations-trust (quote peerX))))))
|
||||
(do
|
||||
(relations-ft-check!
|
||||
"before link revoke: a reaches c"
|
||||
(relations-reachable? db (quote a) (quote c) (quote parent))
|
||||
true)
|
||||
(relations-revoke!
|
||||
db
|
||||
(relations-peer-rel
|
||||
(quote peerX)
|
||||
(quote b)
|
||||
(quote c)
|
||||
(quote parent)))
|
||||
(relations-ft-check!
|
||||
"after link revoke: a cannot reach c"
|
||||
(relations-reachable? db (quote a) (quote c) (quote parent))
|
||||
false)))
|
||||
(let
|
||||
((db (relations-build-db (list (relations-rel (quote a) (quote b) (quote parent)) (relations-peer-rel (quote peerX) (quote b) (quote c) (quote parent)) (relations-trust (quote peerX))))))
|
||||
(do
|
||||
(relations-ft-check!
|
||||
"before trust revoke: c is a child of b"
|
||||
(relations-ft-set=?
|
||||
(relations-children-of db (quote b) (quote parent))
|
||||
(list (quote c)))
|
||||
true)
|
||||
(relations-revoke! db (relations-trust (quote peerX)))
|
||||
(relations-ft-check!
|
||||
"after trust revoke: federated edge gone"
|
||||
(relations-children-of db (quote b) (quote parent))
|
||||
(list))
|
||||
(relations-ft-check!
|
||||
"after trust revoke: local edge survives"
|
||||
(relations-ft-set=?
|
||||
(relations-children-of db (quote a) (quote parent))
|
||||
(list (quote b)))
|
||||
true)))
|
||||
(let
|
||||
((transport {:peerB (list (relations-peer-rel (quote peerB) (quote c) (quote d) (quote parent))) :peerA (list (relations-peer-rel (quote peerA) (quote b) (quote c) (quote parent)))}))
|
||||
(do
|
||||
(relations-ft-check!
|
||||
"fed-fetch returns a peer's links"
|
||||
(len (relations-fed-fetch transport (quote peerA)))
|
||||
1)
|
||||
(relations-ft-check!
|
||||
"fed-fetch unknown peer -> empty"
|
||||
(relations-fed-fetch transport (quote nobody))
|
||||
(list))
|
||||
(relations-ft-check!
|
||||
"fed-collect over two peers"
|
||||
(len
|
||||
(relations-fed-collect
|
||||
transport
|
||||
(list (quote peerA) (quote peerB))))
|
||||
2)
|
||||
(let
|
||||
((db (relations-fed-build-db (list (relations-rel (quote a) (quote b) (quote parent)) (relations-trust (quote peerA))) transport (list (quote peerA) (quote peerB)))))
|
||||
(do
|
||||
(relations-ft-check!
|
||||
"fed-build: trusted peerA link binds (a->c)"
|
||||
(relations-reachable? db (quote a) (quote c) (quote parent))
|
||||
true)
|
||||
(relations-ft-check!
|
||||
"fed-build: untrusted peerB link does not bind (a->d)"
|
||||
(relations-reachable? db (quote a) (quote d) (quote parent))
|
||||
false)))))
|
||||
(let
|
||||
((db (relations-build-db (list (relations-rel (quote a) (quote b) (quote parent)) (relations-trust (quote peerX))))))
|
||||
(do
|
||||
(relations-ft-check!
|
||||
"before fed-assert: a cannot reach c"
|
||||
(relations-reachable? db (quote a) (quote c) (quote parent))
|
||||
false)
|
||||
(relations-fed-assert!
|
||||
db
|
||||
(relations-peer-rel
|
||||
(quote peerX)
|
||||
(quote b)
|
||||
(quote c)
|
||||
(quote parent)))
|
||||
(relations-ft-check!
|
||||
"after fed-assert: a reaches c"
|
||||
(relations-reachable? db (quote a) (quote c) (quote parent))
|
||||
true)))
|
||||
(relations-ft-check!
|
||||
"peer-rel constructor shape"
|
||||
(relations-peer-rel (quote p) (quote s) (quote d) (quote k))
|
||||
(list (quote peer_rel) (quote p) (quote s) (quote d) (quote k)))
|
||||
(relations-ft-check!
|
||||
"trust constructor shape"
|
||||
(relations-trust (quote p))
|
||||
(list (quote trust) (quote p))))))
|
||||
|
||||
(define
|
||||
relations-fed-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! relations-ft-pass 0)
|
||||
(set! relations-ft-fail 0)
|
||||
(set! relations-ft-failures (list))
|
||||
(relations-ft-run-all!)
|
||||
{:failures relations-ft-failures :total (+ relations-ft-pass relations-ft-fail) :passed relations-ft-pass :failed relations-ft-fail})))
|
||||
192
lib/relations/tests/path.sx
Normal file
192
lib/relations/tests/path.sx
Normal file
@@ -0,0 +1,192 @@
|
||||
;; lib/relations/tests/path.sx — Phase 3: typed relations, path, distance.
|
||||
|
||||
(define relations-pt-pass 0)
|
||||
(define relations-pt-fail 0)
|
||||
(define relations-pt-failures (list))
|
||||
|
||||
(define
|
||||
relations-pt-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! relations-pt-pass (+ relations-pt-pass 1))
|
||||
(do
|
||||
(set! relations-pt-fail (+ relations-pt-fail 1))
|
||||
(append!
|
||||
relations-pt-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
(define
|
||||
relations-pt-subset?
|
||||
(fn
|
||||
(xs ys)
|
||||
(cond
|
||||
((= (len xs) 0) true)
|
||||
((relations-member? (first xs) ys)
|
||||
(relations-pt-subset? (rest xs) ys))
|
||||
(else false))))
|
||||
|
||||
(define
|
||||
relations-pt-set=?
|
||||
(fn
|
||||
(xs ys)
|
||||
(and
|
||||
(= (len xs) (len ys))
|
||||
(relations-pt-subset? xs ys)
|
||||
(relations-pt-subset? ys xs))))
|
||||
|
||||
;; Two kinds coexisting in one db.
|
||||
;; parent: a->b, b->c, c->d, a->c (shortcut), x->y (disconnected)
|
||||
;; member: c->m, m->n (crosses into a different kind)
|
||||
(define
|
||||
relations-pt-fixture
|
||||
(fn
|
||||
()
|
||||
(relations-build-db
|
||||
(list
|
||||
(relations-rel (quote a) (quote b) (quote parent))
|
||||
(relations-rel (quote b) (quote c) (quote parent))
|
||||
(relations-rel (quote c) (quote d) (quote parent))
|
||||
(relations-rel (quote a) (quote c) (quote parent))
|
||||
(relations-rel (quote x) (quote y) (quote parent))
|
||||
(relations-rel (quote c) (quote m) (quote member))
|
||||
(relations-rel (quote m) (quote n) (quote member))))))
|
||||
|
||||
;; A cycle with an exit: u->v->w->u, w->exit.
|
||||
(define
|
||||
relations-pt-cyc-fixture
|
||||
(fn
|
||||
()
|
||||
(relations-build-db
|
||||
(list
|
||||
(relations-rel (quote u) (quote v) (quote parent))
|
||||
(relations-rel (quote v) (quote w) (quote parent))
|
||||
(relations-rel (quote w) (quote u) (quote parent))
|
||||
(relations-rel (quote w) (quote exit) (quote parent))))))
|
||||
|
||||
(define
|
||||
relations-pt-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((db (relations-pt-fixture)) (cyc (relations-pt-cyc-fixture)))
|
||||
(do
|
||||
(relations-pt-check!
|
||||
"shortest path a->d"
|
||||
(relations-path db (quote a) (quote d) (quote parent))
|
||||
(list (quote a) (quote c) (quote d)))
|
||||
(relations-pt-check!
|
||||
"distance a->d is 2"
|
||||
(relations-distance db (quote a) (quote d) (quote parent))
|
||||
2)
|
||||
(relations-pt-check!
|
||||
"direct edge path a->c"
|
||||
(relations-path db (quote a) (quote c) (quote parent))
|
||||
(list (quote a) (quote c)))
|
||||
(relations-pt-check!
|
||||
"distance a->c is 1"
|
||||
(relations-distance db (quote a) (quote c) (quote parent))
|
||||
1)
|
||||
(relations-pt-check!
|
||||
"path b->d"
|
||||
(relations-path db (quote b) (quote d) (quote parent))
|
||||
(list (quote b) (quote c) (quote d)))
|
||||
(relations-pt-check!
|
||||
"self path"
|
||||
(relations-path db (quote a) (quote a) (quote parent))
|
||||
(list (quote a)))
|
||||
(relations-pt-check!
|
||||
"self distance is 0"
|
||||
(relations-distance db (quote a) (quote a) (quote parent))
|
||||
0)
|
||||
(relations-pt-check!
|
||||
"unknown target -> nil path"
|
||||
(relations-path db (quote a) (quote zzz) (quote parent))
|
||||
nil)
|
||||
(relations-pt-check!
|
||||
"unknown target -> nil distance"
|
||||
(relations-distance db (quote a) (quote zzz) (quote parent))
|
||||
nil)
|
||||
(relations-pt-check!
|
||||
"disconnected -> nil path"
|
||||
(relations-path db (quote a) (quote y) (quote parent))
|
||||
nil)
|
||||
(relations-pt-check!
|
||||
"no parent path crosses into member edge"
|
||||
(relations-path db (quote a) (quote m) (quote parent))
|
||||
nil)
|
||||
(relations-pt-check!
|
||||
"member path c->m"
|
||||
(relations-path db (quote c) (quote m) (quote member))
|
||||
(list (quote c) (quote m)))
|
||||
(relations-pt-check!
|
||||
"member path c->n"
|
||||
(relations-path db (quote c) (quote n) (quote member))
|
||||
(list (quote c) (quote m) (quote n)))
|
||||
(relations-pt-check!
|
||||
"mixed-kind reachable a->m"
|
||||
(relations-reachable-any? db (quote a) (quote m))
|
||||
true)
|
||||
(relations-pt-check!
|
||||
"mixed-kind reachable a->n"
|
||||
(relations-reachable-any? db (quote a) (quote n))
|
||||
true)
|
||||
(relations-pt-check!
|
||||
"single-kind a->m not reachable under parent"
|
||||
(relations-reachable? db (quote a) (quote m) (quote parent))
|
||||
false)
|
||||
(relations-pt-check!
|
||||
"mixed-kind descendants of a include cross-kind nodes"
|
||||
(relations-pt-set=?
|
||||
(relations-descendants-any db (quote a))
|
||||
(list (quote b) (quote c) (quote d) (quote m) (quote n)))
|
||||
true)
|
||||
(relations-pt-check!
|
||||
"single-kind descendants of a under parent only"
|
||||
(relations-pt-set=?
|
||||
(relations-descendants db (quote a) (quote parent))
|
||||
(list (quote b) (quote c) (quote d)))
|
||||
true)
|
||||
(relations-pt-check!
|
||||
"path out of a cycle"
|
||||
(relations-path cyc (quote u) (quote exit) (quote parent))
|
||||
(list (quote u) (quote v) (quote w) (quote exit)))
|
||||
(relations-pt-check!
|
||||
"distance out of a cycle is 3"
|
||||
(relations-distance cyc (quote u) (quote exit) (quote parent))
|
||||
3)
|
||||
(do
|
||||
(relations/load!
|
||||
(list
|
||||
(relations-rel (quote r1) (quote r2) (quote parent))
|
||||
(relations-rel (quote r2) (quote r3) (quote parent))
|
||||
(relations-rel (quote r3) (quote r4) (quote link))))
|
||||
(relations-pt-check!
|
||||
"api path"
|
||||
(relations/path (quote r1) (quote r3) (quote parent))
|
||||
(list (quote r1) (quote r2) (quote r3)))
|
||||
(relations-pt-check!
|
||||
"api distance"
|
||||
(relations/distance (quote r1) (quote r3) (quote parent))
|
||||
2)
|
||||
(relations-pt-check!
|
||||
"api mixed-kind reachable across parent+link"
|
||||
(relations/reachable-any? (quote r1) (quote r4))
|
||||
true)
|
||||
(relations-pt-check!
|
||||
"api single-kind not reachable across kinds"
|
||||
(relations/reachable? (quote r1) (quote r4) (quote parent))
|
||||
false)
|
||||
(relations/load! (list)))))))
|
||||
|
||||
(define
|
||||
relations-path-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! relations-pt-pass 0)
|
||||
(set! relations-pt-fail 0)
|
||||
(set! relations-pt-failures (list))
|
||||
(relations-pt-run-all!)
|
||||
{:failures relations-pt-failures :total (+ relations-pt-pass relations-pt-fail) :passed relations-pt-pass :failed relations-pt-fail})))
|
||||
204
lib/relations/tests/reach.sx
Normal file
204
lib/relations/tests/reach.sx
Normal file
@@ -0,0 +1,204 @@
|
||||
;; lib/relations/tests/reach.sx — Phase 2: reachability, roots/leaves, cycles.
|
||||
|
||||
(define relations-rt-pass 0)
|
||||
(define relations-rt-fail 0)
|
||||
(define relations-rt-failures (list))
|
||||
|
||||
(define
|
||||
relations-rt-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! relations-rt-pass (+ relations-rt-pass 1))
|
||||
(do
|
||||
(set! relations-rt-fail (+ relations-rt-fail 1))
|
||||
(append!
|
||||
relations-rt-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
(define
|
||||
relations-rt-subset?
|
||||
(fn
|
||||
(xs ys)
|
||||
(cond
|
||||
((= (len xs) 0) true)
|
||||
((relations-member? (first xs) ys)
|
||||
(relations-rt-subset? (rest xs) ys))
|
||||
(else false))))
|
||||
|
||||
(define
|
||||
relations-rt-set=?
|
||||
(fn
|
||||
(xs ys)
|
||||
(and
|
||||
(= (len xs) (len ys))
|
||||
(relations-rt-subset? xs ys)
|
||||
(relations-rt-subset? ys xs))))
|
||||
|
||||
;; Diamond + a disconnected pair under parent, plus a reply cross-edge.
|
||||
;; parent: a->b, a->c, b->d, c->d ; e->f
|
||||
;; reply: b->z
|
||||
(define
|
||||
relations-rt-fixture
|
||||
(fn
|
||||
()
|
||||
(relations-build-db
|
||||
(list
|
||||
(relations-rel (quote a) (quote b) (quote parent))
|
||||
(relations-rel (quote a) (quote c) (quote parent))
|
||||
(relations-rel (quote b) (quote d) (quote parent))
|
||||
(relations-rel (quote c) (quote d) (quote parent))
|
||||
(relations-rel (quote e) (quote f) (quote parent))
|
||||
(relations-rel (quote b) (quote z) (quote reply))))))
|
||||
|
||||
;; Cycles: c1<->c2, self-loop s->s, plus acyclic t->u, all under parent.
|
||||
(define
|
||||
relations-rt-cyc-fixture
|
||||
(fn
|
||||
()
|
||||
(relations-build-db
|
||||
(list
|
||||
(relations-rel (quote c1) (quote c2) (quote parent))
|
||||
(relations-rel (quote c2) (quote c1) (quote parent))
|
||||
(relations-rel (quote s) (quote s) (quote parent))
|
||||
(relations-rel (quote t) (quote u) (quote parent))))))
|
||||
|
||||
(define
|
||||
relations-rt-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((db (relations-rt-fixture)) (cyc (relations-rt-cyc-fixture)))
|
||||
(do
|
||||
(relations-rt-check!
|
||||
"descendants of a (diamond)"
|
||||
(relations-rt-set=?
|
||||
(relations-descendants db (quote a) (quote parent))
|
||||
(list (quote b) (quote c) (quote d)))
|
||||
true)
|
||||
(relations-rt-check!
|
||||
"ancestors of d (diamond)"
|
||||
(relations-rt-set=?
|
||||
(relations-ancestors db (quote d) (quote parent))
|
||||
(list (quote a) (quote b) (quote c)))
|
||||
true)
|
||||
(relations-rt-check!
|
||||
"reachable a->d"
|
||||
(relations-reachable? db (quote a) (quote d) (quote parent))
|
||||
true)
|
||||
(relations-rt-check!
|
||||
"not reachable d->a"
|
||||
(relations-reachable? db (quote d) (quote a) (quote parent))
|
||||
false)
|
||||
(relations-rt-check!
|
||||
"disconnected components"
|
||||
(relations-reachable? db (quote a) (quote f) (quote parent))
|
||||
false)
|
||||
(relations-rt-check!
|
||||
"leaf has no descendants"
|
||||
(relations-descendants db (quote d) (quote parent))
|
||||
(list))
|
||||
(relations-rt-check!
|
||||
"root has no ancestors"
|
||||
(relations-ancestors db (quote a) (quote parent))
|
||||
(list))
|
||||
(relations-rt-check!
|
||||
"roots under parent"
|
||||
(relations-rt-set=?
|
||||
(relations-roots db (quote parent))
|
||||
(list (quote a) (quote e)))
|
||||
true)
|
||||
(relations-rt-check!
|
||||
"leaves under parent"
|
||||
(relations-rt-set=?
|
||||
(relations-leaves db (quote parent))
|
||||
(list (quote d) (quote f)))
|
||||
true)
|
||||
(relations-rt-check!
|
||||
"parent descendants exclude reply target"
|
||||
(relations-member?
|
||||
(quote z)
|
||||
(relations-descendants db (quote a) (quote parent)))
|
||||
false)
|
||||
(relations-rt-check!
|
||||
"reply reachable b->z"
|
||||
(relations-reachable? db (quote b) (quote z) (quote reply))
|
||||
true)
|
||||
(relations-rt-check!
|
||||
"parent unreachable a->z"
|
||||
(relations-reachable? db (quote a) (quote z) (quote parent))
|
||||
false)
|
||||
(relations-rt-check!
|
||||
"diamond is acyclic"
|
||||
(relations-acyclic? db (quote parent))
|
||||
true)
|
||||
(relations-rt-check!
|
||||
"no node cycles in diamond"
|
||||
(relations-cycle? db (quote a) (quote parent))
|
||||
false)
|
||||
(relations-rt-check!
|
||||
"c1 is on a cycle"
|
||||
(relations-cycle? cyc (quote c1) (quote parent))
|
||||
true)
|
||||
(relations-rt-check!
|
||||
"self-loop counts as cycle"
|
||||
(relations-cycle? cyc (quote s) (quote parent))
|
||||
true)
|
||||
(relations-rt-check!
|
||||
"acyclic node t not on cycle"
|
||||
(relations-cycle? cyc (quote t) (quote parent))
|
||||
false)
|
||||
(relations-rt-check!
|
||||
"kind with a cycle is not acyclic"
|
||||
(relations-acyclic? cyc (quote parent))
|
||||
false)
|
||||
(relations-rt-check!
|
||||
"cycle reachable both ways"
|
||||
(and
|
||||
(relations-reachable? cyc (quote c1) (quote c2) (quote parent))
|
||||
(relations-reachable? cyc (quote c2) (quote c1) (quote parent)))
|
||||
true)
|
||||
(relations-rt-check!
|
||||
"node in cycle reaches itself"
|
||||
(relations-member?
|
||||
(quote c1)
|
||||
(relations-descendants cyc (quote c1) (quote parent)))
|
||||
true)
|
||||
(do
|
||||
(relations/load!
|
||||
(list
|
||||
(relations-rel (quote r) (quote m) (quote parent))
|
||||
(relations-rel (quote m) (quote n) (quote parent))))
|
||||
(relations-rt-check!
|
||||
"api descendants"
|
||||
(relations-rt-set=?
|
||||
(relations/descendants (quote r) (quote parent))
|
||||
(list (quote m) (quote n)))
|
||||
true)
|
||||
(relations-rt-check!
|
||||
"api reachable"
|
||||
(relations/reachable? (quote r) (quote n) (quote parent))
|
||||
true)
|
||||
(relations-rt-check!
|
||||
"api roots"
|
||||
(relations-rt-set=?
|
||||
(relations/roots (quote parent))
|
||||
(list (quote r)))
|
||||
true)
|
||||
(relations-rt-check!
|
||||
"api acyclic"
|
||||
(relations/acyclic? (quote parent))
|
||||
true)
|
||||
(relations/load! (list)))))))
|
||||
|
||||
(define
|
||||
relations-reach-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! relations-rt-pass 0)
|
||||
(set! relations-rt-fail 0)
|
||||
(set! relations-rt-failures (list))
|
||||
(relations-rt-run-all!)
|
||||
{:failures relations-rt-failures :total (+ relations-rt-pass relations-rt-fail) :passed relations-rt-pass :failed relations-rt-fail})))
|
||||
130
lib/relations/tests/routes.sx
Normal file
130
lib/relations/tests/routes.sx
Normal file
@@ -0,0 +1,130 @@
|
||||
;; lib/relations/tests/routes.sx — extension: all simple paths (route enumeration).
|
||||
|
||||
(define relations-ro-pass 0)
|
||||
(define relations-ro-fail 0)
|
||||
(define relations-ro-failures (list))
|
||||
|
||||
(define
|
||||
relations-ro-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! relations-ro-pass (+ relations-ro-pass 1))
|
||||
(do
|
||||
(set! relations-ro-fail (+ relations-ro-fail 1))
|
||||
(append!
|
||||
relations-ro-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
(define
|
||||
relations-ro-subset?
|
||||
(fn
|
||||
(xs ys)
|
||||
(cond
|
||||
((= (len xs) 0) true)
|
||||
((relations-member? (first xs) ys)
|
||||
(relations-ro-subset? (rest xs) ys))
|
||||
(else false))))
|
||||
|
||||
;; Order-insensitive set equality; elements compared structurally (works for
|
||||
;; lists-of-paths since `=` is structural).
|
||||
(define
|
||||
relations-ro-set=?
|
||||
(fn
|
||||
(xs ys)
|
||||
(and
|
||||
(= (len xs) (len ys))
|
||||
(relations-ro-subset? xs ys)
|
||||
(relations-ro-subset? ys xs))))
|
||||
|
||||
;; Diamond + branch + a cycle with an exit.
|
||||
;; parent: a->b, a->c, b->d, c->d, b->e
|
||||
;; member: a->z (a different kind, to test isolation)
|
||||
;; parent cycle: g->h, h->g, h->out
|
||||
(define
|
||||
relations-ro-fixture
|
||||
(fn
|
||||
()
|
||||
(relations-build-db
|
||||
(list
|
||||
(relations-rel (quote a) (quote b) (quote parent))
|
||||
(relations-rel (quote a) (quote c) (quote parent))
|
||||
(relations-rel (quote b) (quote d) (quote parent))
|
||||
(relations-rel (quote c) (quote d) (quote parent))
|
||||
(relations-rel (quote b) (quote e) (quote parent))
|
||||
(relations-rel (quote a) (quote z) (quote member))
|
||||
(relations-rel (quote g) (quote h) (quote parent))
|
||||
(relations-rel (quote h) (quote g) (quote parent))
|
||||
(relations-rel (quote h) (quote out) (quote parent))))))
|
||||
|
||||
(define
|
||||
relations-ro-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((db (relations-ro-fixture)))
|
||||
(do
|
||||
(relations-ro-check!
|
||||
"two routes a->d"
|
||||
(relations-ro-set=?
|
||||
(relations-all-paths db (quote a) (quote d) (quote parent))
|
||||
(list
|
||||
(list (quote a) (quote b) (quote d))
|
||||
(list (quote a) (quote c) (quote d))))
|
||||
true)
|
||||
(relations-ro-check!
|
||||
"single route a->e"
|
||||
(relations-all-paths db (quote a) (quote e) (quote parent))
|
||||
(list (list (quote a) (quote b) (quote e))))
|
||||
(relations-ro-check!
|
||||
"no route -> empty"
|
||||
(relations-all-paths db (quote a) (quote zzz) (quote parent))
|
||||
(list))
|
||||
(relations-ro-check!
|
||||
"self route is the singleton path"
|
||||
(relations-all-paths db (quote a) (quote a) (quote parent))
|
||||
(list (list (quote a))))
|
||||
(relations-ro-check!
|
||||
"route through a cycle terminates"
|
||||
(relations-all-paths db (quote g) (quote out) (quote parent))
|
||||
(list (list (quote g) (quote h) (quote out))))
|
||||
(relations-ro-check!
|
||||
"route count a->d is 2"
|
||||
(len (relations-all-paths db (quote a) (quote d) (quote parent)))
|
||||
2)
|
||||
(relations-ro-check!
|
||||
"kind isolation: no parent route to member target"
|
||||
(relations-all-paths db (quote a) (quote z) (quote parent))
|
||||
(list))
|
||||
(relations-ro-check!
|
||||
"member route a->z"
|
||||
(relations-all-paths db (quote a) (quote z) (quote member))
|
||||
(list (list (quote a) (quote z))))
|
||||
(do
|
||||
(relations/load!
|
||||
(list
|
||||
(relations-rel (quote p) (quote q) (quote parent))
|
||||
(relations-rel (quote p) (quote r) (quote parent))
|
||||
(relations-rel (quote q) (quote s) (quote parent))
|
||||
(relations-rel (quote r) (quote s) (quote parent))))
|
||||
(relations-ro-check!
|
||||
"api all-paths two routes p->s"
|
||||
(relations-ro-set=?
|
||||
(relations/all-paths (quote p) (quote s) (quote parent))
|
||||
(list
|
||||
(list (quote p) (quote q) (quote s))
|
||||
(list (quote p) (quote r) (quote s))))
|
||||
true)
|
||||
(relations/load! (list)))))))
|
||||
|
||||
(define
|
||||
relations-routes-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! relations-ro-pass 0)
|
||||
(set! relations-ro-fail 0)
|
||||
(set! relations-ro-failures (list))
|
||||
(relations-ro-run-all!)
|
||||
{:failures relations-ro-failures :total (+ relations-ro-pass relations-ro-fail) :passed relations-ro-pass :failed relations-ro-fail})))
|
||||
161
lib/relations/tests/shape.sx
Normal file
161
lib/relations/tests/shape.sx
Normal file
@@ -0,0 +1,161 @@
|
||||
;; lib/relations/tests/shape.sx — extension: siblings, degree, undirected
|
||||
;; connectivity.
|
||||
|
||||
(define relations-st-pass 0)
|
||||
(define relations-st-fail 0)
|
||||
(define relations-st-failures (list))
|
||||
|
||||
(define
|
||||
relations-st-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! relations-st-pass (+ relations-st-pass 1))
|
||||
(do
|
||||
(set! relations-st-fail (+ relations-st-fail 1))
|
||||
(append!
|
||||
relations-st-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
(define
|
||||
relations-st-subset?
|
||||
(fn
|
||||
(xs ys)
|
||||
(cond
|
||||
((= (len xs) 0) true)
|
||||
((relations-member? (first xs) ys)
|
||||
(relations-st-subset? (rest xs) ys))
|
||||
(else false))))
|
||||
|
||||
(define
|
||||
relations-st-set=?
|
||||
(fn
|
||||
(xs ys)
|
||||
(and
|
||||
(= (len xs) (len ys))
|
||||
(relations-st-subset? xs ys)
|
||||
(relations-st-subset? ys xs))))
|
||||
|
||||
;; A small tree plus a disconnected pair.
|
||||
;; parent: p->a, p->b, p->c, a->d ; q->r (disconnected)
|
||||
;; member: m->x, m->y (a different kind, same db)
|
||||
(define
|
||||
relations-st-fixture
|
||||
(fn
|
||||
()
|
||||
(relations-build-db
|
||||
(list
|
||||
(relations-rel (quote p) (quote a) (quote parent))
|
||||
(relations-rel (quote p) (quote b) (quote parent))
|
||||
(relations-rel (quote p) (quote c) (quote parent))
|
||||
(relations-rel (quote a) (quote d) (quote parent))
|
||||
(relations-rel (quote q) (quote r) (quote parent))
|
||||
(relations-rel (quote m) (quote x) (quote member))
|
||||
(relations-rel (quote m) (quote y) (quote member))))))
|
||||
|
||||
(define
|
||||
relations-st-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((db (relations-st-fixture)))
|
||||
(do
|
||||
(relations-st-check!
|
||||
"siblings of a"
|
||||
(relations-st-set=?
|
||||
(relations-siblings db (quote a) (quote parent))
|
||||
(list (quote b) (quote c)))
|
||||
true)
|
||||
(relations-st-check!
|
||||
"only child has no siblings"
|
||||
(relations-siblings db (quote d) (quote parent))
|
||||
(list))
|
||||
(relations-st-check!
|
||||
"siblings respect kind"
|
||||
(relations-st-set=?
|
||||
(relations-siblings db (quote x) (quote member))
|
||||
(list (quote y)))
|
||||
true)
|
||||
(relations-st-check!
|
||||
"no cross-kind siblings"
|
||||
(relations-siblings db (quote a) (quote member))
|
||||
(list))
|
||||
(relations-st-check!
|
||||
"out-degree of p"
|
||||
(relations-out-degree db (quote p) (quote parent))
|
||||
3)
|
||||
(relations-st-check!
|
||||
"out-degree of a"
|
||||
(relations-out-degree db (quote a) (quote parent))
|
||||
1)
|
||||
(relations-st-check!
|
||||
"out-degree of leaf"
|
||||
(relations-out-degree db (quote d) (quote parent))
|
||||
0)
|
||||
(relations-st-check!
|
||||
"in-degree of a"
|
||||
(relations-in-degree db (quote a) (quote parent))
|
||||
1)
|
||||
(relations-st-check!
|
||||
"in-degree of root"
|
||||
(relations-in-degree db (quote p) (quote parent))
|
||||
0)
|
||||
(relations-st-check!
|
||||
"siblings are connected"
|
||||
(relations-connected? db (quote b) (quote c) (quote parent))
|
||||
true)
|
||||
(relations-st-check!
|
||||
"cousin connected (b <-> d)"
|
||||
(relations-connected? db (quote b) (quote d) (quote parent))
|
||||
true)
|
||||
(relations-st-check!
|
||||
"self connected"
|
||||
(relations-connected? db (quote a) (quote a) (quote parent))
|
||||
true)
|
||||
(relations-st-check!
|
||||
"disconnected components not connected"
|
||||
(relations-connected? db (quote a) (quote q) (quote parent))
|
||||
false)
|
||||
(relations-st-check!
|
||||
"directed-unreachable but undirected-connected"
|
||||
(and
|
||||
(not
|
||||
(relations-reachable? db (quote b) (quote c) (quote parent)))
|
||||
(relations-connected? db (quote b) (quote c) (quote parent)))
|
||||
true)
|
||||
(relations-st-check!
|
||||
"connectivity respects kind"
|
||||
(relations-connected? db (quote a) (quote x) (quote member))
|
||||
false)
|
||||
(do
|
||||
(relations/load!
|
||||
(list
|
||||
(relations-rel (quote g) (quote h) (quote parent))
|
||||
(relations-rel (quote g) (quote i) (quote parent))))
|
||||
(relations-st-check!
|
||||
"api siblings"
|
||||
(relations-st-set=?
|
||||
(relations/siblings (quote h) (quote parent))
|
||||
(list (quote i)))
|
||||
true)
|
||||
(relations-st-check!
|
||||
"api out-degree"
|
||||
(relations/out-degree (quote g) (quote parent))
|
||||
2)
|
||||
(relations-st-check!
|
||||
"api connected"
|
||||
(relations/connected? (quote h) (quote i) (quote parent))
|
||||
true)
|
||||
(relations/load! (list)))))))
|
||||
|
||||
(define
|
||||
relations-shape-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! relations-st-pass 0)
|
||||
(set! relations-st-fail 0)
|
||||
(set! relations-st-failures (list))
|
||||
(relations-st-run-all!)
|
||||
{:failures relations-st-failures :total (+ relations-st-pass relations-st-fail) :passed relations-st-pass :failed relations-st-fail})))
|
||||
206
lib/relations/tests/tree.sx
Normal file
206
lib/relations/tests/tree.sx
Normal file
@@ -0,0 +1,206 @@
|
||||
;; lib/relations/tests/tree.sx — extension: common ancestors, LCA, topo order.
|
||||
|
||||
(define relations-tt-pass 0)
|
||||
(define relations-tt-fail 0)
|
||||
(define relations-tt-failures (list))
|
||||
|
||||
(define
|
||||
relations-tt-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! relations-tt-pass (+ relations-tt-pass 1))
|
||||
(do
|
||||
(set! relations-tt-fail (+ relations-tt-fail 1))
|
||||
(append!
|
||||
relations-tt-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
(define
|
||||
relations-tt-subset?
|
||||
(fn
|
||||
(xs ys)
|
||||
(cond
|
||||
((= (len xs) 0) true)
|
||||
((relations-member? (first xs) ys)
|
||||
(relations-tt-subset? (rest xs) ys))
|
||||
(else false))))
|
||||
|
||||
(define
|
||||
relations-tt-set=?
|
||||
(fn
|
||||
(xs ys)
|
||||
(and
|
||||
(= (len xs) (len ys))
|
||||
(relations-tt-subset? xs ys)
|
||||
(relations-tt-subset? ys xs))))
|
||||
|
||||
;; Is xs a valid topo order? every node appears once and no node precedes one of
|
||||
;; its ancestors. We check the simpler invariant: for each edge u->v (parent),
|
||||
;; u appears before v in the order.
|
||||
(define
|
||||
relations-tt-index-of
|
||||
(fn
|
||||
(x xs i)
|
||||
(cond
|
||||
((= (len xs) 0) -1)
|
||||
((= (first xs) x) i)
|
||||
(else (relations-tt-index-of x (rest xs) (+ i 1))))))
|
||||
|
||||
;; Diamond with an extra branch:
|
||||
;; parent: a->b, a->c, b->d, c->d, b->e
|
||||
;; member (different kind): m->n
|
||||
(define
|
||||
relations-tt-fixture
|
||||
(fn
|
||||
()
|
||||
(relations-build-db
|
||||
(list
|
||||
(relations-rel (quote a) (quote b) (quote parent))
|
||||
(relations-rel (quote a) (quote c) (quote parent))
|
||||
(relations-rel (quote b) (quote d) (quote parent))
|
||||
(relations-rel (quote c) (quote d) (quote parent))
|
||||
(relations-rel (quote b) (quote e) (quote parent))
|
||||
(relations-rel (quote m) (quote n) (quote member))))))
|
||||
|
||||
;; A cyclic kind, to confirm topo-order refuses it.
|
||||
(define
|
||||
relations-tt-cyc-fixture
|
||||
(fn
|
||||
()
|
||||
(relations-build-db
|
||||
(list
|
||||
(relations-rel (quote x) (quote y) (quote parent))
|
||||
(relations-rel (quote y) (quote x) (quote parent))))))
|
||||
|
||||
(define
|
||||
relations-tt-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((db (relations-tt-fixture)) (cyc (relations-tt-cyc-fixture)))
|
||||
(do
|
||||
(relations-tt-check!
|
||||
"common ancestors of d and e"
|
||||
(relations-tt-set=?
|
||||
(relations-common-ancestors
|
||||
db
|
||||
(quote d)
|
||||
(quote e)
|
||||
(quote parent))
|
||||
(list (quote a) (quote b)))
|
||||
true)
|
||||
(relations-tt-check!
|
||||
"common ancestors of b and c"
|
||||
(relations-tt-set=?
|
||||
(relations-common-ancestors
|
||||
db
|
||||
(quote b)
|
||||
(quote c)
|
||||
(quote parent))
|
||||
(list (quote a)))
|
||||
true)
|
||||
(relations-tt-check!
|
||||
"no common ancestors across kinds"
|
||||
(relations-common-ancestors db (quote d) (quote n) (quote parent))
|
||||
(list))
|
||||
(relations-tt-check!
|
||||
"lca of d and e is b"
|
||||
(relations-tt-set=?
|
||||
(relations-lca db (quote d) (quote e) (quote parent))
|
||||
(list (quote b)))
|
||||
true)
|
||||
(relations-tt-check!
|
||||
"lca of b and c is a"
|
||||
(relations-tt-set=?
|
||||
(relations-lca db (quote b) (quote c) (quote parent))
|
||||
(list (quote a)))
|
||||
true)
|
||||
(relations-tt-check!
|
||||
"lca of d and d-sibling-path picks deepest"
|
||||
(relations-tt-set=?
|
||||
(relations-lca db (quote d) (quote d) (quote parent))
|
||||
(list (quote b) (quote c)))
|
||||
true)
|
||||
(relations-tt-check!
|
||||
"no lca when unrelated"
|
||||
(relations-lca db (quote a) (quote n) (quote parent))
|
||||
(list))
|
||||
(let
|
||||
((order (relations-topo-order db (quote parent))))
|
||||
(do
|
||||
(relations-tt-check!
|
||||
"topo order covers all nodes"
|
||||
(relations-tt-set=?
|
||||
order
|
||||
(list (quote a) (quote b) (quote c) (quote d) (quote e)))
|
||||
true)
|
||||
(relations-tt-check!
|
||||
"topo: a before b"
|
||||
(<
|
||||
(relations-tt-index-of (quote a) order 0)
|
||||
(relations-tt-index-of (quote b) order 0))
|
||||
true)
|
||||
(relations-tt-check!
|
||||
"topo: b before d"
|
||||
(<
|
||||
(relations-tt-index-of (quote b) order 0)
|
||||
(relations-tt-index-of (quote d) order 0))
|
||||
true)
|
||||
(relations-tt-check!
|
||||
"topo: c before d"
|
||||
(<
|
||||
(relations-tt-index-of (quote c) order 0)
|
||||
(relations-tt-index-of (quote d) order 0))
|
||||
true)
|
||||
(relations-tt-check!
|
||||
"topo: b before e"
|
||||
(<
|
||||
(relations-tt-index-of (quote b) order 0)
|
||||
(relations-tt-index-of (quote e) order 0))
|
||||
true)))
|
||||
(relations-tt-check!
|
||||
"topo order of cyclic kind is nil"
|
||||
(relations-topo-order cyc (quote parent))
|
||||
nil)
|
||||
(do
|
||||
(relations/load!
|
||||
(list
|
||||
(relations-rel (quote r) (quote s) (quote parent))
|
||||
(relations-rel (quote r) (quote t) (quote parent))
|
||||
(relations-rel (quote s) (quote u) (quote parent))
|
||||
(relations-rel (quote t) (quote u) (quote parent))))
|
||||
(relations-tt-check!
|
||||
"api common-ancestors"
|
||||
(relations-tt-set=?
|
||||
(relations/common-ancestors
|
||||
(quote u)
|
||||
(quote u)
|
||||
(quote parent))
|
||||
(list (quote r) (quote s) (quote t)))
|
||||
true)
|
||||
(relations-tt-check!
|
||||
"api lca"
|
||||
(relations-tt-set=?
|
||||
(relations/lca (quote s) (quote t) (quote parent))
|
||||
(list (quote r)))
|
||||
true)
|
||||
(relations-tt-check!
|
||||
"api topo-order covers nodes"
|
||||
(relations-tt-set=?
|
||||
(relations/topo-order (quote parent))
|
||||
(list (quote r) (quote s) (quote t) (quote u)))
|
||||
true)
|
||||
(relations/load! (list)))))))
|
||||
|
||||
(define
|
||||
relations-tree-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! relations-tt-pass 0)
|
||||
(set! relations-tt-fail 0)
|
||||
(set! relations-tt-failures (list))
|
||||
(relations-tt-run-all!)
|
||||
{:failures relations-tt-failures :total (+ relations-tt-pass relations-tt-fail) :passed relations-tt-pass :failed relations-tt-fail})))
|
||||
161
lib/relations/tree.sx
Normal file
161
lib/relations/tree.sx
Normal file
@@ -0,0 +1,161 @@
|
||||
;; lib/relations/tree.sx — tree/DAG queries: common ancestors, LCA, topo order.
|
||||
;;
|
||||
;; All computed in SX over the engine's fast `reach`/`ancestors`/`rnode` queries
|
||||
;; — no new Datalog closures (every dl-query re-saturates, so derived graph
|
||||
;; algorithms stay in SX). Kind-parameterised throughout, like the rest of the
|
||||
;; engine. LCA returns a SET (a DAG may have several lowest common ancestors; a
|
||||
;; tree yields exactly one). topo-order returns nil for a cyclic kind.
|
||||
|
||||
(define
|
||||
relations-tree-any?
|
||||
(fn
|
||||
(pred xs)
|
||||
(cond
|
||||
((= (len xs) 0) false)
|
||||
((pred (first xs)) true)
|
||||
(else (relations-tree-any? pred (rest xs))))))
|
||||
|
||||
(define
|
||||
relations-intersect
|
||||
(fn (xs ys) (filter (fn (x) (relations-eng-member? x ys)) xs)))
|
||||
|
||||
(define
|
||||
relations-subset?
|
||||
(fn
|
||||
(xs ys)
|
||||
(cond
|
||||
((= (len xs) 0) true)
|
||||
((relations-eng-member? (first xs) ys)
|
||||
(relations-subset? (rest xs) ys))
|
||||
(else false))))
|
||||
|
||||
;; All nodes touched by a kind (the materialised rnode relation — one query).
|
||||
(define
|
||||
relations-nodes
|
||||
(fn
|
||||
(db kind)
|
||||
(relations-dedup
|
||||
(relations-pluck
|
||||
(dl-query db (list (quote rnode) kind (quote X)))
|
||||
:X))))
|
||||
|
||||
;; Common ancestors of a and b under kind (set intersection of the two
|
||||
;; ancestor sets).
|
||||
(define
|
||||
relations-common-ancestors
|
||||
(fn
|
||||
(db a b kind)
|
||||
(relations-intersect
|
||||
(relations-ancestors db a kind)
|
||||
(relations-ancestors db b kind))))
|
||||
|
||||
;; Lowest common ancestors: common ancestors with no other common ancestor
|
||||
;; strictly below them (none reachable from them). A tree gives a singleton; a
|
||||
;; DAG may give several. Empty when a and b share no ancestor.
|
||||
(define
|
||||
relations-lca
|
||||
(fn
|
||||
(db a b kind)
|
||||
(let
|
||||
((common (relations-common-ancestors db a b kind)))
|
||||
(filter
|
||||
(fn
|
||||
(x)
|
||||
(not
|
||||
(relations-tree-any?
|
||||
(fn
|
||||
(y)
|
||||
(and (not (= x y)) (relations-reachable? db x y kind)))
|
||||
common)))
|
||||
common))))
|
||||
|
||||
;; Kahn-style topological order: repeatedly place every node whose parents are
|
||||
;; all already placed. Returns the node list in topological order, or nil if the
|
||||
;; kind has a cycle.
|
||||
(define
|
||||
relations-topo-kahn
|
||||
(fn
|
||||
(db kind remaining placed)
|
||||
(if
|
||||
(= (len remaining) 0)
|
||||
placed
|
||||
(let
|
||||
((ready (filter (fn (n) (relations-subset? (relations-parents-of db n kind) placed)) remaining)))
|
||||
(if
|
||||
(= (len ready) 0)
|
||||
placed
|
||||
(relations-topo-kahn
|
||||
db
|
||||
kind
|
||||
(filter
|
||||
(fn (n) (not (relations-eng-member? n ready)))
|
||||
remaining)
|
||||
(append placed ready)))))))
|
||||
|
||||
(define
|
||||
relations-topo-order
|
||||
(fn
|
||||
(db kind)
|
||||
(if
|
||||
(relations-acyclic? db kind)
|
||||
(relations-topo-kahn db kind (relations-nodes db kind) (list))
|
||||
nil)))
|
||||
|
||||
;; --- current-db convenience layer ---
|
||||
|
||||
(define
|
||||
relations-component
|
||||
(fn
|
||||
(db node kind)
|
||||
(relations-ureach-bfs db kind (list node) (list node))))
|
||||
|
||||
(define
|
||||
relations-components-loop
|
||||
(fn
|
||||
(db kind remaining acc)
|
||||
(if
|
||||
(= (len remaining) 0)
|
||||
acc
|
||||
(let
|
||||
((comp (relations-component db (first remaining) kind)))
|
||||
(relations-components-loop
|
||||
db
|
||||
kind
|
||||
(filter (fn (n) (not (relations-eng-member? n comp))) remaining)
|
||||
(append acc (list comp)))))))
|
||||
|
||||
(define
|
||||
relations-component-count
|
||||
(fn (db kind) (len (relations-components db kind))))
|
||||
|
||||
(define
|
||||
relations-components
|
||||
(fn
|
||||
(db kind)
|
||||
(relations-components-loop db kind (relations-nodes db kind) (list))))
|
||||
|
||||
(define
|
||||
relations/common-ancestors
|
||||
(fn
|
||||
(a b kind)
|
||||
(relations-common-ancestors (relations-ensure-db!) a b kind)))
|
||||
|
||||
(define
|
||||
relations/lca
|
||||
(fn (a b kind) (relations-lca (relations-ensure-db!) a b kind)))
|
||||
|
||||
(define
|
||||
relations/topo-order
|
||||
(fn (kind) (relations-topo-order (relations-ensure-db!) kind)))
|
||||
|
||||
(define
|
||||
relations/component
|
||||
(fn (node kind) (relations-component (relations-ensure-db!) node kind)))
|
||||
|
||||
(define
|
||||
relations/components
|
||||
(fn (kind) (relations-components (relations-ensure-db!) kind)))
|
||||
|
||||
(define
|
||||
relations/component-count
|
||||
(fn (kind) (relations-component-count (relations-ensure-db!) kind)))
|
||||
44
lib/search/api.sx
Normal file
44
lib/search/api.sx
Normal file
@@ -0,0 +1,44 @@
|
||||
;; search public API — assembles the canonical Haskell source from all layers.
|
||||
;; Tests and callers concatenate `search/src` with their own top-level bindings
|
||||
;; (e.g. "result = lookupTerm \"cat\" idx\n") and evaluate via the haskell-on-sx
|
||||
;; interpreter. Public Haskell entry points: indexDoc, lookupTerm, deleteDoc,
|
||||
;; docFreq, allTerms, tokens, positioned, evalQuery, parseQuery, searchQuery,
|
||||
;; rankTfIdf, rankBm25, topNTfIdf, topNBm25, fedIndex, aclFilter, searchTfIdfAcl,
|
||||
;; topNTfIdfAcl, searchBm25Acl, prefixTerms, prefixDocs, prefixRankTfIdf,
|
||||
;; paginate, pageTfIdf, pageBm25, resultCount, editDist, fuzzyTerms, fuzzyDocs,
|
||||
;; fuzzyRankTfIdf, highlight, snippet, stem, stemText, stemTokens, indexStemmed,
|
||||
;; nearDocs, expandTerm, synDocs, synRankTfIdf, queryTerms, searchRankTfIdf,
|
||||
;; searchRankBm25, suggestN, suggest.
|
||||
|
||||
(define
|
||||
search/src
|
||||
(str
|
||||
search/tokenize-src
|
||||
"\n"
|
||||
search/index-src
|
||||
"\n"
|
||||
search/query-src
|
||||
"\n"
|
||||
search/parse-src
|
||||
"\n"
|
||||
search/rank-src
|
||||
"\n"
|
||||
search/fed-src
|
||||
"\n"
|
||||
search/prefix-src
|
||||
"\n"
|
||||
search/page-src
|
||||
"\n"
|
||||
search/fuzzy-src
|
||||
"\n"
|
||||
search/highlight-src
|
||||
"\n"
|
||||
search/stem-src
|
||||
"\n"
|
||||
search/near-src
|
||||
"\n"
|
||||
search/syn-src
|
||||
"\n"
|
||||
search/rankq-src
|
||||
"\n"
|
||||
search/suggest-src))
|
||||
55
lib/search/conformance.conf
Normal file
55
lib/search/conformance.conf
Normal file
@@ -0,0 +1,55 @@
|
||||
# search-on-sx conformance config — sourced by lib/guest/conformance.sh.
|
||||
|
||||
LANG_NAME=search
|
||||
SCOREBOARD_DIR=lib/search
|
||||
MODE=counters
|
||||
COUNTERS_PASS=hk-test-pass
|
||||
COUNTERS_FAIL=hk-test-fail
|
||||
TIMEOUT_PER_SUITE=600
|
||||
|
||||
PRELOADS=(
|
||||
lib/haskell/tokenizer.sx
|
||||
lib/haskell/layout.sx
|
||||
lib/haskell/parser.sx
|
||||
lib/haskell/desugar.sx
|
||||
lib/haskell/runtime.sx
|
||||
lib/haskell/match.sx
|
||||
lib/haskell/eval.sx
|
||||
lib/haskell/map.sx
|
||||
lib/haskell/set.sx
|
||||
lib/haskell/testlib.sx
|
||||
lib/search/tokenize.sx
|
||||
lib/search/index.sx
|
||||
lib/search/query.sx
|
||||
lib/search/parse.sx
|
||||
lib/search/rank.sx
|
||||
lib/search/fed.sx
|
||||
lib/search/prefix.sx
|
||||
lib/search/page.sx
|
||||
lib/search/fuzzy.sx
|
||||
lib/search/highlight.sx
|
||||
lib/search/stem.sx
|
||||
lib/search/near.sx
|
||||
lib/search/syn.sx
|
||||
lib/search/rankq.sx
|
||||
lib/search/suggest.sx
|
||||
lib/search/api.sx
|
||||
lib/search/testlib.sx
|
||||
)
|
||||
|
||||
SUITES=(
|
||||
"index:lib/search/tests/index.sx"
|
||||
"boolean:lib/search/tests/boolean.sx"
|
||||
"parse:lib/search/tests/parse.sx"
|
||||
"rank:lib/search/tests/rank.sx"
|
||||
"integration:lib/search/tests/integration.sx"
|
||||
"prefix:lib/search/tests/prefix.sx"
|
||||
"page:lib/search/tests/page.sx"
|
||||
"fuzzy:lib/search/tests/fuzzy.sx"
|
||||
"highlight:lib/search/tests/highlight.sx"
|
||||
"stem:lib/search/tests/stem.sx"
|
||||
"near:lib/search/tests/near.sx"
|
||||
"syn:lib/search/tests/syn.sx"
|
||||
"rankq:lib/search/tests/rankq.sx"
|
||||
"suggest:lib/search/tests/suggest.sx"
|
||||
)
|
||||
3
lib/search/conformance.sh
Executable file
3
lib/search/conformance.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# Thin wrapper — see lib/guest/conformance.sh and lib/search/conformance.conf.
|
||||
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"
|
||||
16
lib/search/fed.sx
Normal file
16
lib/search/fed.sx
Normal file
@@ -0,0 +1,16 @@
|
||||
;; search federation + ACL — Haskell source fragment. Depends on index + rank.
|
||||
;; Federation merges per-peer INDICES (not ranked results): each peer's local
|
||||
;; DocIds are relabelled to global ids `gid peer local = peer*1000 + local`
|
||||
;; (dedupe by (peer,doc-id) is automatic via the bijection), then posting lists
|
||||
;; are unioned per term. Ranking then runs once over the merged index, which is
|
||||
;; rank-correct. ACL is a post-rank filter: an injected `permit :: DocId -> Bool`
|
||||
;; predicate (viewer baked in by the caller) — never baked into the index.
|
||||
;; fedIndex :: [(PeerId, Index)] -> Index
|
||||
;; aclFilter :: (DocId -> Bool) -> [DocId] -> [DocId]
|
||||
;; searchTfIdfAcl :: (DocId -> Bool) -> [Term] -> Index -> [DocId]
|
||||
;; topNTfIdfAcl :: Int -> (DocId -> Bool) -> [Term] -> Index -> [DocId]
|
||||
;; searchBm25Acl :: (DocId -> Bool) -> Float -> Float -> [Term] -> Index -> [DocId]
|
||||
|
||||
(define
|
||||
search/fed-src
|
||||
"gid peer local = peer * 1000 + local\nfedRelabelPosting peer p = (gid peer (fst p), snd p)\nfedRelabelEntry peer e = (fst e, map (fedRelabelPosting peer) (snd e))\nfedRelabelIndex peer idx = map (fedRelabelEntry peer) idx\nfedInsP p [] = [p]\nfedInsP p (q:qs) = if fst p < fst q then p : q : qs else if fst p == fst q then p : qs else q : fedInsP p qs\nfedMergePL a b = foldr fedInsP b a\nfedInsTerm t pl [] = [(t, pl)]\nfedInsTerm t pl (x:xs) = if t < fst x then (t, pl) : x : xs else if t == fst x then (fst x, fedMergePL pl (snd x)) : xs else x : fedInsTerm t pl xs\nfedMergeEntry idx e = fedInsTerm (fst e) (snd e) idx\nfedMergeTwo a b = foldl fedMergeEntry a b\nfedAddPeer acc pair = fedMergeTwo acc (fedRelabelIndex (fst pair) (snd pair))\nfedIndex pairs = foldl fedAddPeer emptyIndex pairs\naclFilter permit docs = filter permit docs\nsearchTfIdfAcl permit ts idx = aclFilter permit (rankTfIdf ts idx)\ntopNTfIdfAcl n permit ts idx = take n (aclFilter permit (rankTfIdf ts idx))\nsearchBm25Acl permit k1 b ts idx = aclFilter permit (rankBm25 k1 b ts idx)\n")
|
||||
12
lib/search/fuzzy.sx
Normal file
12
lib/search/fuzzy.sx
Normal file
@@ -0,0 +1,12 @@
|
||||
;; search fuzzy matching — Haskell source fragment. Depends on index + rank.
|
||||
;; Levenshtein edit distance (O(m*n) row-based DP — the naive recursive version is
|
||||
;; exponential and far too slow under load) expands a query term to all indexed
|
||||
;; terms within a max distance, then unions / ranks their docs.
|
||||
;; editDist :: String -> String -> Int
|
||||
;; fuzzyTerms :: Int -> String -> Index -> [Term] (sorted)
|
||||
;; fuzzyDocs :: Int -> String -> Index -> [DocId] (sorted union)
|
||||
;; fuzzyRankTfIdf :: Int -> String -> Index -> [DocId]
|
||||
|
||||
(define
|
||||
search/fuzzy-src
|
||||
"edMin3 a b c = min a (min b c)\nedCost x y = if x == y then 0 else 1\nedUpto i n = if i > n then [] else i : edUpto (i + 1) n\nedLast [x] = x\nedLast (x:xs) = edLast xs\nedNrow x [] prev left = []\nedNrow x (y:ys) prev left = let v = edMin3 (head (tail prev) + 1) (left + 1) (head prev + edCost x y) in v : edNrow x ys (tail prev) v\nedRow x ys prev = let f = head prev + 1 in f : edNrow x ys prev f\nedRows [] ys prev = prev\nedRows (x:xs) ys prev = edRows xs ys (edRow x ys prev)\neditDist xs ys = edLast (edRows xs ys (edUpto 0 (length ys)))\nqWithinDist maxd term t = editDist term t <= maxd\nfuzzyTerms maxd term idx = filter (qWithinDist maxd term) (allTerms idx)\nfuzzyDocs maxd term idx = foldl (candStep idx) [] (fuzzyTerms maxd term idx)\nfuzzyRankTfIdf maxd term idx = rankTfIdf (fuzzyTerms maxd term idx) idx\n")
|
||||
10
lib/search/highlight.sx
Normal file
10
lib/search/highlight.sx
Normal file
@@ -0,0 +1,10 @@
|
||||
;; search highlight / snippet — Haskell source fragment. Depends on tokenize.
|
||||
;; Operates on document text (not the index): marks query-matching tokens with
|
||||
;; [..] and extracts a context window around the first match. Tokens are
|
||||
;; normalized (lowercase, punctuation-stripped) by `tokens`, matching index side.
|
||||
;; highlight :: [Term] -> String -> String
|
||||
;; snippet :: Int -> [Term] -> String -> String (ctx tokens each side of 1st match)
|
||||
|
||||
(define
|
||||
search/highlight-src
|
||||
"hlMark terms t = if elem t terms then \"[\" ++ t ++ \"]\" else t\nhighlight terms text = unwords (map (hlMark terms) (tokens text))\nhlIdxFrom terms [] i = 0 - 1\nhlIdxFrom terms (t:ts) i = if elem t terms then i else hlIdxFrom terms ts (i + 1)\nhlIdx terms toks = hlIdxFrom terms toks 0\nhlMax0 x = if x < 0 then 0 else x\nsnipStart ctx i = if i < 0 then 0 else hlMax0 (i - ctx)\nsnipToks ctx terms toks = unwords (map (hlMark terms) (take (2 * ctx + 1) (drop (snipStart ctx (hlIdx terms toks)) toks)))\nsnippet ctx terms text = snipToks ctx terms (tokens text)\n")
|
||||
15
lib/search/index.sx
Normal file
15
lib/search/index.sx
Normal file
@@ -0,0 +1,15 @@
|
||||
;; search inverted index — Haskell source fragment (depends on tokenize).
|
||||
;; Index = [(Term, [(DocId, [Pos])])], sorted by Term; postings sorted by DocId.
|
||||
;; Data.Map's public API lacks toList/keys/map/filter, so a sorted assoc-list
|
||||
;; index is used — it is the conceptual `Map Term [(DocId,[Pos])]` and exposes
|
||||
;; term iteration (allTerms) and df naturally for ranking.
|
||||
;; emptyIndex :: Index
|
||||
;; indexDoc :: DocId -> String -> Index -> Index (re-index replaces)
|
||||
;; lookupTerm :: Term -> Index -> [(DocId, [Pos])]
|
||||
;; deleteDoc :: DocId -> Index -> Index
|
||||
;; docFreq :: Term -> Index -> Int
|
||||
;; allTerms :: Index -> [Term]
|
||||
|
||||
(define
|
||||
search/index-src
|
||||
"emptyIndex = []\ngroupBump [] t p = [(t, [p])]\ngroupBump (g:gs) t p = if fst g == t then (t, snd g ++ [p]) : gs else g : groupBump gs t p\ngroupStep acc tp = groupBump acc (fst tp) (snd tp)\ngroupTok pairs = foldl groupStep [] pairs\ninsPosting d ps [] = [(d, ps)]\ninsPosting d ps (q:qs) = if d < fst q then (d, ps) : q : qs else if d == fst q then (d, ps) : qs else q : insPosting d ps qs\ninsTerm t d ps [] = [(t, [(d, ps)])]\ninsTerm t d ps (e:es) = if t < fst e then (t, [(d, ps)]) : e : es else if t == fst e then (fst e, insPosting d ps (snd e)) : es else e : insTerm t d ps es\nindexStep d ix tp = insTerm (fst tp) d (snd tp) ix\nindexDoc d text idx = foldl (indexStep d) idx (groupTok (positioned text))\nlookupTerm t idx = case lookup t idx of { Nothing -> []; Just pl -> pl }\ndocFreq t idx = length (lookupTerm t idx)\nallTerms idx = map fst idx\npostingKeep d q = fst q /= d\ndropTermDoc d e = (fst e, filter (postingKeep d) (snd e))\nplKeep e = not (null (snd e))\ndeleteDoc d idx = filter plKeep (map (dropTermDoc d) idx)\n")
|
||||
8
lib/search/near.sx
Normal file
8
lib/search/near.sx
Normal file
@@ -0,0 +1,8 @@
|
||||
;; search proximity (NEAR) — Haskell source fragment. Depends on query (posIn,
|
||||
;; docsWith, sortedInter). Finds docs where two terms occur within k positions of
|
||||
;; each other (unordered), using the positional postings.
|
||||
;; nearDocs :: Int -> Term -> Term -> Index -> [DocId] (sorted)
|
||||
|
||||
(define
|
||||
search/near-src
|
||||
"nrAbsDiff a b = if a > b then a - b else b - a\nnrCloseTo k x [] = False\nnrCloseTo k x (y:ys) = if nrAbsDiff x y <= k then True else nrCloseTo k x ys\nnrAnyClose k [] ys = False\nnrAnyClose k (x:xs) ys = if nrCloseTo k x ys then True else nrAnyClose k xs ys\nnearInDoc k t1 t2 d idx = nrAnyClose k (posIn t1 d idx) (posIn t2 d idx)\nnearHere k t1 t2 idx d = nearInDoc k t1 t2 d idx\nnearDocs k t1 t2 idx = filter (nearHere k t1 t2 idx) (sortedInter (docsWith t1 idx) (docsWith t2 idx))\n")
|
||||
11
lib/search/page.sx
Normal file
11
lib/search/page.sx
Normal file
@@ -0,0 +1,11 @@
|
||||
;; search pagination — Haskell source fragment. Depends on rank.
|
||||
;; Windows a ranked result list by offset/limit (offset >= length -> empty;
|
||||
;; limit clamps to what remains).
|
||||
;; paginate :: Int -> Int -> [DocId] -> [DocId] (offset, limit)
|
||||
;; pageTfIdf :: Int -> Int -> [Term] -> Index -> [DocId]
|
||||
;; pageBm25 :: Int -> Int -> Float -> Float -> [Term] -> Index -> [DocId]
|
||||
;; resultCount :: [Term] -> Index -> Int
|
||||
|
||||
(define
|
||||
search/page-src
|
||||
"paginate off lim docs = take lim (drop off docs)\npageTfIdf off lim ts idx = paginate off lim (rankTfIdf ts idx)\npageBm25 off lim k1 b ts idx = paginate off lim (rankBm25 k1 b ts idx)\nresultCount ts idx = length (rankTfIdf ts idx)\n")
|
||||
18
lib/search/parse.sx
Normal file
18
lib/search/parse.sx
Normal file
@@ -0,0 +1,18 @@
|
||||
;; search query parser — Haskell source fragment. Depends on tokenize + query.
|
||||
;; Grammar (precedence OR < AND < NOT):
|
||||
;; expr = orExpr
|
||||
;; orExpr = andExpr (OR andExpr)*
|
||||
;; andExpr= notExpr ((AND | <implicit>) notExpr)* -- adjacency means AND
|
||||
;; notExpr= NOT notExpr | atom
|
||||
;; atom = '(' expr ')' | '"' word+ '"' | word
|
||||
;; Keywords AND/OR/NOT are case-insensitive; bare words are normalized via tokens.
|
||||
;; Gotchas: delimiters matched by ord (escaped char literals like '\"' break the
|
||||
;; haskell-on-sx tokenizer); an [] *pattern* inside a `case` alt also breaks the
|
||||
;; parser, so qNormTerm/qDropRP/showQ are written as multi-clause functions.
|
||||
;; parseQuery :: String -> Query
|
||||
;; searchQuery :: String -> Index -> [DocId]
|
||||
;; showQ :: Query -> String -- canonical render for tests/debug
|
||||
|
||||
(define
|
||||
search/parse-src
|
||||
"data QTok = TAnd | TOr | TNot | TLP | TRP | TWord String | TPhrase [String]\nqIsSpace c = ord c == 32\nqIsLP c = ord c == 40\nqIsRP c = ord c == 41\nqIsQuote c = ord c == 34\nqDelim c = qIsSpace c || qIsLP c || qIsRP c || qIsQuote c\nqReadWord [] = ([], [])\nqReadWord (c:cs) = if qDelim c then ([], c:cs) else let (w, rest) = qReadWord cs in (c:w, rest)\nqReadPhrase [] = ([], [])\nqReadPhrase (c:cs) = if qIsQuote c then ([], cs) else let (w, rest) = qReadPhrase cs in (c:w, rest)\ntoUpperCh c = chr (toUpper (ord c))\nqUpper w = joinChars (map toUpperCh w)\nqFirstTok [] = \"\"\nqFirstTok (x:xs) = x\nqNormTerm w = qFirstTok (tokens w)\nqClassify w = if qUpper w == \"AND\" then TAnd else if qUpper w == \"OR\" then TOr else if qUpper w == \"NOT\" then TNot else TWord (qNormTerm w)\nqPhraseTok cs = let (p, rest) = qReadPhrase cs in TPhrase (tokens p) : qtokens rest\nqWordTok cs = let (w, rest) = qReadWord cs in qClassify w : qtokens rest\nqtokens [] = []\nqtokens (c:cs) = if qIsSpace c then qtokens cs else if qIsLP c then TLP : qtokens cs else if qIsRP c then TRP : qtokens cs else if qIsQuote c then qPhraseTok cs else qWordTok (c:cs)\nqDropRP (q, (TRP:rest)) = (q, rest)\nqDropRP (q, ts) = (q, ts)\nparseAtom [] = (Term \"\", [])\nparseAtom (TLP:ts) = qDropRP (parseExpr ts)\nparseAtom (TPhrase ps : ts) = (Phrase ps, ts)\nparseAtom (TWord w : ts) = (Term w, ts)\nparseAtom ts = (Term \"\", ts)\nqWrapNot (q, ts) = (Not q, ts)\nparseNot (TNot:ts) = qWrapNot (parseNot ts)\nparseNot ts = parseAtom ts\nqStartsAtom (TWord w : ts) = True\nqStartsAtom (TPhrase p : ts) = True\nqStartsAtom (TLP : ts) = True\nqStartsAtom (TNot : ts) = True\nqStartsAtom ts = False\nqAndStep left ts = let (r, rest) = parseNot ts in parseAndR (And left r) rest\nparseAndR left (TAnd:ts) = qAndStep left ts\nparseAndR left ts = if qStartsAtom ts then qAndStep left ts else (left, ts)\nparseAnd ts = let (l, rest) = parseNot ts in parseAndR l rest\nparseOrR left (TOr:ts) = let (r, rest) = parseAnd ts in parseOrR (Or left r) rest\nparseOrR left ts = (left, ts)\nparseExpr ts = let (l, rest) = parseAnd ts in parseOrR l rest\nparseQuery s = fst (parseExpr (qtokens s))\nsearchQuery s idx = evalQuery idx (parseQuery s)\njoinSp [] = \"\"\njoinSp [x] = x\njoinSp (x:xs) = x ++ \"-\" ++ joinSp xs\nshowQ (Term t) = \"T:\" ++ t\nshowQ (And a b) = \"(\" ++ showQ a ++ \" & \" ++ showQ b ++ \")\"\nshowQ (Or a b) = \"(\" ++ showQ a ++ \" | \" ++ showQ b ++ \")\"\nshowQ (Not a) = \"!\" ++ showQ a\nshowQ (Phrase ts) = \"P:\" ++ joinSp ts\n")
|
||||
10
lib/search/prefix.sx
Normal file
10
lib/search/prefix.sx
Normal file
@@ -0,0 +1,10 @@
|
||||
;; search prefix / wildcard queries — Haskell source fragment. Depends on index +
|
||||
;; rank (reuses candStep / rankTfIdf). A prefix matches every indexed term that
|
||||
;; starts with it; the matching terms are unioned (OR) into a docid set.
|
||||
;; prefixTerms :: String -> Index -> [Term] (sorted, from allTerms)
|
||||
;; prefixDocs :: String -> Index -> [DocId] (sorted union)
|
||||
;; prefixRankTfIdf :: String -> Index -> [DocId] (ranked by the matched terms)
|
||||
|
||||
(define
|
||||
search/prefix-src
|
||||
"prefixTerms pre idx = filter (isPrefixOf pre) (allTerms idx)\nprefixDocs pre idx = foldl (candStep idx) [] (prefixTerms pre idx)\nprefixRankTfIdf pre idx = rankTfIdf (prefixTerms pre idx) idx\n")
|
||||
11
lib/search/query.sx
Normal file
11
lib/search/query.sx
Normal file
@@ -0,0 +1,11 @@
|
||||
;; search query AST + boolean/phrase evaluation — Haskell source fragment.
|
||||
;; Depends on tokenize + index.
|
||||
;; data Query = Term String | And Query Query | Or Query Query
|
||||
;; | Not Query | Phrase [String]
|
||||
;; evalQuery :: Index -> Query -> [DocId] (sorted, unique)
|
||||
;; Boolean ops are linear merges over docid-sorted posting lists; Not uses
|
||||
;; allDocs as the universe; Phrase checks positional adjacency.
|
||||
|
||||
(define
|
||||
search/query-src
|
||||
"data Query = Term String | And Query Query | Or Query Query | Not Query | Phrase [String]\ndocsWith t idx = map fst (lookupTerm t idx)\nsortedUnion [] ys = ys\nsortedUnion xs [] = xs\nsortedUnion (x:xs) (y:ys) = if x < y then x : sortedUnion xs (y:ys) else if x > y then y : sortedUnion (x:xs) ys else x : sortedUnion xs ys\nsortedInter [] ys = []\nsortedInter xs [] = []\nsortedInter (x:xs) (y:ys) = if x < y then sortedInter xs (y:ys) else if x > y then sortedInter (x:xs) ys else x : sortedInter xs ys\nsortedDiff [] ys = []\nsortedDiff xs [] = xs\nsortedDiff (x:xs) (y:ys) = if x < y then x : sortedDiff xs (y:ys) else if x > y then sortedDiff (x:xs) ys else sortedDiff xs ys\nmergeDocs acc e = sortedUnion acc (map fst (snd e))\nallDocs idx = foldl mergeDocs [] idx\nposIn t d idx = case lookup d (lookupTerm t idx) of { Nothing -> []; Just ps -> ps }\nelemSorted x [] = False\nelemSorted x (y:ys) = if x == y then True else if x < y then False else elemSorted x ys\nphraseAtAll [] d idx p i = True\nphraseAtAll (t:ts) d idx p i = if elemSorted (p + i) (posIn t d idx) then phraseAtAll ts d idx p (i + 1) else False\nphraseStartsAt ts d idx p = phraseAtAll ts d idx p 0\nphraseInDoc [] d idx = True\nphraseInDoc (t0:rest) d idx = any (phraseStartsAt (t0:rest) d idx) (posIn t0 d idx)\nphraseHere ts idx d = phraseInDoc ts d idx\ninterStep idx acc tt = sortedInter acc (docsWith tt idx)\nphraseCands [] idx = allDocs idx\nphraseCands (t:ts) idx = foldl (interStep idx) (docsWith t idx) ts\nphraseDocs ts idx = filter (phraseHere ts idx) (phraseCands ts idx)\nevalQuery idx q = case q of { Term t -> docsWith t idx ; And a b -> sortedInter (evalQuery idx a) (evalQuery idx b) ; Or a b -> sortedUnion (evalQuery idx a) (evalQuery idx b) ; Not a -> sortedDiff (allDocs idx) (evalQuery idx a) ; Phrase ts -> phraseDocs ts idx }\n")
|
||||
14
lib/search/rank.sx
Normal file
14
lib/search/rank.sx
Normal file
@@ -0,0 +1,14 @@
|
||||
;; search ranking — Haskell source fragment. Depends on tokenize + index + query.
|
||||
;; Ranked retrieval over the candidate set (docs containing any query term).
|
||||
;; Scores are floats; ties broken by DocId ascending (deterministic).
|
||||
;; numDocs :: Index -> Int
|
||||
;; docFreq :: Term -> Index -> Int (from index)
|
||||
;; docLen :: DocId -> Index -> Int
|
||||
;; rankTfIdf :: [Term] -> Index -> [DocId]
|
||||
;; topNTfIdf :: Int -> [Term] -> Index -> [DocId]
|
||||
;; rankBm25 :: Float -> Float -> [Term] -> Index -> [DocId] (k1, b)
|
||||
;; topNBm25 :: Int -> Float -> Float -> [Term] -> Index -> [DocId]
|
||||
|
||||
(define
|
||||
search/rank-src
|
||||
"numDocs idx = length (allDocs idx)\ntfIn t d idx = length (posIn t d idx)\nqIdf n df = if df == 0 then 0 else log (n / df)\nidf t idx = qIdf (numDocs idx) (docFreq t idx)\ntermScoreTf idx d t = tfIn t d idx * idf t idx\ntfidfDoc ts idx d = sum (map (termScoreTf idx d) ts)\ncandStep idx acc t = sortedUnion acc (docsWith t idx)\ncandDocs ts idx = foldl (candStep idx) [] ts\ncmpScore p1 p2 = if fst p1 > fst p2 then LT else if fst p1 < fst p2 then GT else compare (snd p1) (snd p2)\nmkPair f ts idx d = (f ts idx d, d)\nrankWith f ts idx = map snd (sortBy cmpScore (map (mkPair f ts idx) (candDocs ts idx)))\nrankTfIdf ts idx = rankWith tfidfDoc ts idx\ntopNTfIdf n ts idx = take n (rankTfIdf ts idx)\ntfAt d idx t = tfIn t d idx\ndocLen d idx = sum (map (tfAt d idx) (allTerms idx))\nlenAt idx d = docLen d idx\navgDocLen idx = sum (map (lenAt idx) (allDocs idx)) / numDocs idx\nbm25idf t idx = log ((numDocs idx - docFreq t idx + 0.5) / (docFreq t idx + 0.5) + 1)\nbm25Term k1 b avgdl idx d t = bm25idf t idx * (tfIn t d idx * (k1 + 1)) / (tfIn t d idx + k1 * (1 - b + b * docLen d idx / avgdl))\nbm25Doc k1 b ts idx d = sum (map (bm25Term k1 b (avgDocLen idx) idx d) ts)\nrankBm25 k1 b ts idx = rankWith (bm25Doc k1 b) ts idx\ntopNBm25 n k1 b ts idx = take n (rankBm25 k1 b ts idx)\n")
|
||||
11
lib/search/rankq.sx
Normal file
11
lib/search/rankq.sx
Normal file
@@ -0,0 +1,11 @@
|
||||
;; search boolean-filtered ranked search — Haskell source fragment.
|
||||
;; Depends on parse (parseQuery/Query), query (evalQuery), rank (tfidfDoc/bm25Doc/
|
||||
;; cmpScore). Filters by the boolean query, then ranks the surviving docs by
|
||||
;; relevance over the query's leaf terms — the real-world filter-then-rank pattern.
|
||||
;; queryTerms :: Query -> [Term]
|
||||
;; searchRankTfIdf :: String -> Index -> [DocId]
|
||||
;; searchRankBm25 :: Float -> Float -> String -> Index -> [DocId]
|
||||
|
||||
(define
|
||||
search/rankq-src
|
||||
"queryTerms (Term t) = [t]\nqueryTerms (And a b) = queryTerms a ++ queryTerms b\nqueryTerms (Or a b) = queryTerms a ++ queryTerms b\nqueryTerms (Not a) = queryTerms a\nqueryTerms (Phrase ts) = ts\nmkSubPair f terms idx d = (f terms idx d, d)\nrankSubsetWith f terms docs idx = map snd (sortBy cmpScore (map (mkSubPair f terms idx) docs))\nsearchRankTfIdf s idx = let q = parseQuery s in rankSubsetWith tfidfDoc (queryTerms q) (evalQuery idx q) idx\nsearchRankBm25 k1 b s idx = let q = parseQuery s in rankSubsetWith (bm25Doc k1 b) (queryTerms q) (evalQuery idx q) idx\n")
|
||||
23
lib/search/scoreboard.json
Normal file
23
lib/search/scoreboard.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"lang": "search",
|
||||
"total_passed": 234,
|
||||
"total_failed": 0,
|
||||
"total": 234,
|
||||
"suites": [
|
||||
{"name":"index","passed":18,"failed":0,"total":18},
|
||||
{"name":"boolean","passed":28,"failed":0,"total":28},
|
||||
{"name":"parse","passed":32,"failed":0,"total":32},
|
||||
{"name":"rank","passed":23,"failed":0,"total":23},
|
||||
{"name":"integration","passed":21,"failed":0,"total":21},
|
||||
{"name":"prefix","passed":14,"failed":0,"total":14},
|
||||
{"name":"page","passed":12,"failed":0,"total":12},
|
||||
{"name":"fuzzy","passed":18,"failed":0,"total":18},
|
||||
{"name":"highlight","passed":12,"failed":0,"total":12},
|
||||
{"name":"stem","passed":18,"failed":0,"total":18},
|
||||
{"name":"near","passed":9,"failed":0,"total":9},
|
||||
{"name":"syn","passed":9,"failed":0,"total":9},
|
||||
{"name":"rankq","passed":11,"failed":0,"total":11},
|
||||
{"name":"suggest","passed":9,"failed":0,"total":9}
|
||||
],
|
||||
"generated": "2026-06-07T00:44:05+00:00"
|
||||
}
|
||||
20
lib/search/scoreboard.md
Normal file
20
lib/search/scoreboard.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# search scoreboard
|
||||
|
||||
**234 / 234 passing** (0 failure(s)).
|
||||
|
||||
| Suite | Passed | Total | Status |
|
||||
|-------|--------|-------|--------|
|
||||
| index | 18 | 18 | ok |
|
||||
| boolean | 28 | 28 | ok |
|
||||
| parse | 32 | 32 | ok |
|
||||
| rank | 23 | 23 | ok |
|
||||
| integration | 21 | 21 | ok |
|
||||
| prefix | 14 | 14 | ok |
|
||||
| page | 12 | 12 | ok |
|
||||
| fuzzy | 18 | 18 | ok |
|
||||
| highlight | 12 | 12 | ok |
|
||||
| stem | 18 | 18 | ok |
|
||||
| near | 9 | 9 | ok |
|
||||
| syn | 9 | 9 | ok |
|
||||
| rankq | 11 | 11 | ok |
|
||||
| suggest | 9 | 9 | ok |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user