Extract live-stream as the base of live-collection; on-change; atomic heartbeat
Pass 2 (David-designed): - live-stream: the base primitive. A broadcast hub + SSE route + lazy heartbeat + subscription element; no store, no render. live-push! sends any ui-* update (or list) to subscribers; live-subscribe returns the hidden data-sg-sse element; live-routes registers its endpoint. The collection-free general case (chat, room log, notifications). - live-collection is now live-stream + store + render + auto-patching. live-push!, live-subscribe, live-routes all accept a stream OR a collection (via as-stream). live-view -> live-list-view. endpoint: -> path:. - on-change: (items -> ui-update or list), broadcast after every mutation, for UI derived from the whole list (e.g. a count). Receives the item list only; semantic per-action events go through live-push! in the handler. - Hardening: the lazy-heartbeat gate is now ATOMIC -- a capacity-1 channel latch (channel-try-send succeeds for exactly one first-connect; no CAS primitive in stdlib, channels are the runtime's synchronization). The row-id-missing error now names the offending rendered output and how to fix it.
src/sigil/web/live.sgl | 326 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------
1 file changed, 211 insertions(+), 115 deletions(-)src/sigil/web/live.sglmodified
;;; (sigil web live) - Live collections;;; (sigil web live) - Live streams and collections;;;;;; A live collection binds a data source, a per-item render function, a;;; container selector, and a broadcast hub into one object. Mutating it;;; (`live-add!` / `live-update!` / `live-remove!`) automatically pushes the;;; right DOM patch to every connected browser: a new row is appended, an;;; updated row is morphed in place by id, a removed row is deleted by id.;;; A `live-stream` is a server-push channel a page subscribes to over SSE: you;;; `live-push!` `ui-*` updates and every connected client applies them. It owns;;; a broadcast hub, an SSE route, and a lazily-started heartbeat. No store, no;;; render — the general case (a chat feed, a room log, notifications).;;;;;; The collection owns its store, its hub, and its heartbeat, so an app;;; author never hand-writes the store / broadcast / snapshot / events-route;;; plumbing. `live-view` renders the current rows plus the live subscription;;;; `live-routes` registers the SSE endpoint.;;; A `live-collection` is a `live-stream` plus an id-keyed store and a per-item;;; render function. Mutating it (`live-add!` / `live-update!` / `live-remove!`);;; pushes the right DOM patch automatically: a new row appends, an updated row;;; morphs in place by id, a removed row is deleted by id. An optional;;; `on-change:` derives extra UI (e.g. a count) from the item list after every;;; mutation.;;;;;; Mutations do NOT return the HTTP response — they return the affected item;;; (or #f) for convenience. The handler returns the acting user's own;;; response explicitly. Mental model: the mutation is what *everyone* sees;;;; the return value is what *you* see.;;; (or #f). Under `ui-routes` a handler can just end with the mutation. Mental;;; model: the mutation/push is what *everyone* sees; the handler's return is;;; what *you* see.;;;;;; Example:;;; ```scheme;;; (define todos;;; (live-collection render: item-row container: "#todo-list" endpoint: "/events"));;; (live-collection render: item-row container: "#todo-list" path: "/events";;; on-change: (lambda (items);;; (ui-update target: "#count" mode: "inner";;; content: (count-label (length items))))));;;;;; (define log (live-stream path: "/log")) ; a plain feed;;;;;; (define (create-handler req);;; (live-add! todos (dict text: (form-text req) done?: #f)) ; everyone: append row;;; (ui-response (ui-update target: "#add-form" content: (add-form)))) ; you: reset the form;;; (live-add! todos (dict text: (form-text req) done?: #f));;; (live-push! log (ui-update target: "#log" mode: "append";;; content: (log-line "Added" (form-text req)))));;; ```(define-library (sigil web live) (sigil web ui)) (export ;; Streams (the base: push arbitrary ui-* updates to subscribers) live-stream live-stream? live-push! live-subscribe ;; Collections (a stream + a store + a render fn + auto-patching) live-collection live-collection? live-get live-add! live-update! live-remove! live-view live-routes live-snapshot) live-list-view live-snapshot ;; Routing (works on a stream OR a collection) live-routes) (begin ;; ============================================================ ;; Live stream — the base primitive ;; ============================================================ (define-struct live-stream-rec (path) ; SSE path, e.g. "/events" or "/log" (hub) ; broadcast channel (on-connect) ; thunk -> initial ui-update string or #f (set by collections) (heartbeat-latch)) ; capacity-1 channel; first try-send wins the heartbeat start ;;; Create a live stream: a broadcast hub + an SSE route (registered via ;;; `live-routes`) + a lazily-started heartbeat. Push `ui-*` updates to it ;;; with `live-push!`; put its subscription element on the page with ;;; `live-subscribe`. ;;; ;;; Parameters: ;;; path: the SSE path the page subscribes to (default "/events"). ;;; on-connect: optional thunk returning an initial ui-update string (or ;;; #f) sent to each new subscriber. Collections use this for ;;; the current-state snapshot; a plain feed usually omits it. (define (live-stream (keys: (path "/events") (on-connect #f))) (live-stream-rec path: path hub: (make-broadcast) on-connect: on-connect heartbeat-latch: (make-channel 1))) ;;; #t if `x` is a live stream. (define (live-stream? x) (live-stream-rec? x)) ;; Resolve a stream OR collection down to its underlying stream. (define (as-stream x) (cond ((live-stream-rec? x) x) ((live-collection-rec? x) (live-collection-rec-stream x)) (else (error "live: expected a live-stream or live-collection")))) ;;; Push a UI update (or a list of them) to every client subscribed to the ;;; stream. `x` is a stream or a collection. Returns `update`. ;;; ;;; ```scheme ;;; (live-push! log (ui-update target: "#log" mode: "append" content: row)) ;;; ``` (define (live-push! x update) (let ((hub (live-stream-rec-hub (as-stream x)))) (for-each (lambda (u) (broadcast-send hub u)) (if (list? update) update (list update))) update)) ;;; The hidden element that subscribes a page to a stream's live updates. ;;; `x` is a stream or a collection. Drop it into a page body. (define (live-subscribe x) `(div (@ (data-sg-sse ,(live-stream-rec-path (as-stream x))) (style "display:none")))) ;; Start the heartbeat exactly once, on the first connection. The latch is a ;; capacity-1 channel: `channel-try-send` succeeds for exactly one caller ;; (an atomic test-and-set), so a burst of simultaneous first-connects ;; starts a single heartbeat. This runs inside the connection goroutine, ;; already under the server's `with-async`, so the scheduler is available. (define (ensure-heartbeat! stream) (when (channel-try-send (live-stream-rec-heartbeat-latch stream) 'started) (start-sse-heartbeat! (live-stream-rec-hub stream)))) (define (stream-events-handler stream) (lambda (request) (ensure-heartbeat! stream) (http-response/sse-broadcast (live-stream-rec-hub stream) on-connect: (live-stream-rec-on-connect stream)))) ;;; Return a handler that serves the SSE endpoint for a stream or a ;;; collection. Combine with your app's routes: ;;; `(routes (router …) (live-routes todos) (live-routes log))`. (define (live-routes x) (let ((stream (as-stream x))) (router (route method: GET pattern: (live-stream-rec-path stream) handler: (stream-events-handler stream))))) ;; ============================================================ ;; Internal store seam ;; ============================================================ ;; ;; A minimal id-keyed, insertion-ordered store. Items are dicts; the ;; store owns the `id:` field (assigned on add). This is deliberately an ;; internal interface (`store-all`/`store-get`/`store-add!`/`store-update!`/ ;; A minimal id-keyed, insertion-ordered store. Items are dicts; the store ;; owns the `id:` field (assigned on add). Deliberately an internal ;; interface (`store-all`/`store-get`/`store-add!`/`store-update!`/ ;; `store-remove!`) with one in-memory implementation, so a database-backed ;; store can drop in later without touching the collection or the app. ;; Not exposed as a `store:` parameter yet. ;; ============================================================ ;; Live collection ;; Live collection — a stream + a store + a render fn ;; ============================================================ (define-struct live-collection-rec (render) ; item -> SXML row (with an id attribute) (container) ; CSS selector for the list, e.g. "#todo-list" (endpoint) ; SSE path, e.g. "/events" (store) ; internal store (hub) ; broadcast channel (heartbeat-started? default: #f mutable: #t)) ;;; Create a live collection. (stream) ; the underlying live-stream (store) ; internal store (render) ; item -> SXML row (its top-level element carries an id) (container) ; CSS id-selector of the list element, e.g. "#todo-list" (on-change)) ; (items -> ui-update or list) broadcast after each mutation, or #f ;; The current-state snapshot: replace the container's contents with every ;; current row. Sent to each browser on connect. Kept as a free function of ;; (container render store) so the collection's stream can close over it at ;; construction without a forward reference to the collection. (define (snapshot-update container render store) (ui-update target: container mode: "inner" content: (map render (store-all store)))) ;;; Create a live collection: a stream whose subscribers see a list of ;;; rendered rows kept in sync as you mutate it. ;;; ;;; Parameters: ;;; render: (item -> SXML) rendering one row. The rendered element ;;; MUST carry an `id` attribute; the collection targets that ;;; id when it morphs or removes the row. ;;; container: CSS id-selector of the list element new rows append into ;;; render: (item -> SXML) rendering one row. The rendered element MUST ;;; carry an `id` attribute; the collection targets that id when ;;; it morphs or removes the row. ;;; container: CSS id-selector of the list element rows live in ;;; (e.g. "#todo-list"). ;;; endpoint: SSE path the page subscribes to (default "/events"). ;;; ;;; The store, broadcast hub, and heartbeat are created and managed ;;; internally. ;;; path: the SSE path the page subscribes to (default "/events"). ;;; on-change: optional (items -> ui-update or list of them), broadcast ;;; after every mutation. For UI derived from the whole list ;;; (a count, a summary). Receives ONLY the item list. (define (live-collection (keys: (render #f) (container #f) (endpoint "/events"))) (live-collection-rec render: render container: container endpoint: endpoint store: (mem-store) hub: (make-broadcast))) (path "/events") (on-change #f))) (let ((store (mem-store))) (live-collection-rec stream: (live-stream path: path on-connect: (lambda () (snapshot-update container render store))) store: store render: render container: container on-change: on-change))) ;;; #t if `x` is a live collection. (define (live-collection? x) (live-collection-rec? x)) ;; Convenience accessors through the collection. (define (coll-store coll) (live-collection-rec-store coll)) (define (coll-render coll) (live-collection-rec-render coll)) (define (coll-container coll) (live-collection-rec-container coll)) ;;; The current-state snapshot for a collection (the same update sent on ;;; connect). Exposed for callers who want to re-seed a target. (define (live-snapshot coll) (snapshot-update (coll-container coll) (coll-render coll) (coll-store coll))) ;;; Read one item by id (or #f). For read-only handlers such as an inline ;;; edit form that needs the item's current state; mutations go through ;;; `live-update!` / `live-remove!`. (define (live-get coll id) (store-get (live-collection-rec-store coll) id)) (store-get (coll-store coll) id)) ;;; All items in insertion order. Read-only. (define (live-all coll) (store-all (live-collection-rec-store coll))) (store-all (coll-store coll))) ;; Render an item and return (selector . row), where selector targets the ;; row's own id attribute (e.g. "#item-3"). The render function is the ;; single source of truth for a row's DOM id. (define (render-row+selector coll item) (let* ((row ((live-collection-rec-render coll) item)) (let* ((row ((coll-render coll) item)) (id (sxml-attr-ref row 'id))) (if id (cons (string-append "#" id) row) (error "live-collection: rendered row is missing an id attribute")))) (error (string-append "live-collection: the render function returned a row with no " "`id` attribute, so it can't be targeted for a live update or " "removal. Give the row's top-level element an id (e.g. " "(li (@ (id \"item-3\")) ...)). Rendered: " (sxml->xml row)))))) ;; After a mutation, broadcast any derived UI from on-change. (define (broadcast-change! coll) (let ((f (live-collection-rec-on-change coll))) (when f (live-push! coll (f (store-all (coll-store coll))))))) ;;; Add `item` to the collection: assign an id, store it, and broadcast an ;;; append of its rendered row to every connected client. Returns the ;;; stored item (with its id). Does NOT return an HTTP response. ;;; Add `item`: assign an id, store it, broadcast an append of its rendered ;;; row (then any `on-change` update) to every client. Returns the stored ;;; item. Does NOT return an HTTP response. (define (live-add! coll item) (let* ((it (store-add! (live-collection-rec-store coll) item)) (row ((live-collection-rec-render coll) it))) (broadcast-send (live-collection-rec-hub coll) (ui-update target: (live-collection-rec-container coll) mode: "append" content: row)) (let* ((it (store-add! (coll-store coll) item)) (row ((coll-render coll) it))) (live-push! coll (ui-update target: (coll-container coll) mode: "append" content: row)) (broadcast-change! coll) it)) ;;; Update item `id` in place with `(f item)`: store it and broadcast a ;;; morph of its rendered row (targeted by the row's id) to every client. ;;; Returns the new item, or #f if no such id. Does NOT return a response. ;;; Update item `id` in place with `(f item)`: store it, broadcast a morph ;;; of its rendered row (then any `on-change`). Returns the new item, or #f ;;; if no such id. Does NOT return a response. (define (live-update! coll id f) (let ((it (store-update! (live-collection-rec-store coll) id f))) (let ((it (store-update! (coll-store coll) id f))) (when it (let ((sel+row (render-row+selector coll it))) (broadcast-send (live-collection-rec-hub coll) (ui-update target: (car sel+row) content: (cdr sel+row))))) (live-push! coll (ui-update target: (car sel+row) content: (cdr sel+row)))) (broadcast-change! coll)) it)) ;;; Remove item `id`: delete it from the store and broadcast a remove of ;;; its row (targeted by the row's id) to every client. Returns the removed ;;; item, or #f if no such id. Does NOT return a response. ;;; Remove item `id`: delete it, broadcast a remove of its row (then any ;;; `on-change`). Returns the removed item, or #f if no such id. Does NOT ;;; return a response. (define (live-remove! coll id) (let ((it (store-get (live-collection-rec-store coll) id))) (let ((it (store-get (coll-store coll) id))) (when it (let ((sel (car (render-row+selector coll it)))) (store-remove! (live-collection-rec-store coll) id) (broadcast-send (live-collection-rec-hub coll) (ui-remove target: sel)))) (store-remove! (coll-store coll) id) (live-push! coll (ui-remove target: sel))) (broadcast-change! coll)) it)) ;; Strip a leading "#" from an id-selector to get the bare element id. (substring selector 1 (string-length selector)) selector)) ;;; The current-state snapshot: a UI update that replaces the container's ;;; contents with every current row. Sent to each browser on connect. (define (live-snapshot coll) (ui-update target: (live-collection-rec-container coll) mode: "inner" content: (map (live-collection-rec-render coll) (store-all (live-collection-rec-store coll))))) ;;; Render the live view: the list container holding the current rows, plus ;;; the hidden element that subscribes the page to the live stream. Drop ;;; this into a page body; the collection owns the container id and the ;;; subscription endpoint. (define (live-view coll) ;;; Render the collection's list view: the list container holding the ;;; current rows, plus the hidden element subscribing the page to the live ;;; stream. Drop this into a page body. (define (live-list-view coll) `(div (ul (@ (id ,(selector->id (live-collection-rec-container coll)))) ,@(map (live-collection-rec-render coll) (store-all (live-collection-rec-store coll)))) (div (@ (data-sg-sse ,(live-collection-rec-endpoint coll)) (style "display:none"))))) ;; Start the heartbeat once, on the first SSE connection. The connection ;; handler runs in its own goroutine under the server's `with-async`, so ;; the scheduler is available here without the app calling anything at ;; startup. (The flag check/set isn't atomic; a burst of simultaneous ;; first-connects could start more than one heartbeat. Harmless for now; ;; revisit if it matters.) (define (ensure-heartbeat! coll) (unless (live-collection-rec-heartbeat-started? coll) (set-live-collection-rec-heartbeat-started?! coll #t) (start-sse-heartbeat! (live-collection-rec-hub coll)))) (define (live-events-handler coll) (lambda (req) (ensure-heartbeat! coll) (http-response/sse-broadcast (live-collection-rec-hub coll) on-connect: (lambda () (live-snapshot coll))))) ;;; Return a handler that serves the collection's SSE endpoint. Combine it ;;; with your app's routes: `(routes (router ...) (live-routes todos))`. (define (live-routes coll) (router (route method: GET pattern: (live-collection-rec-endpoint coll) handler: (live-events-handler coll)))) (ul (@ (id ,(selector->id (coll-container coll)))) ,@(map (coll-render coll) (store-all (coll-store coll)))) ,(live-subscribe coll))) ))