Commit8962045fRecorded5 Jul 2026Repositorysigil-web
Extract live-stream as the base of live-collection; on-change; atomic heartbeat
Message
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.
Changed
src/sigil/web/live.sgl | 326 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------
1 file changed, 211 insertions(+), 115 deletions(-)Diff
src/sigil/web/live.sglmodified
@@ -1,29 +1,36 @@
−1
;;; (sigil web live) - Live collections+1
;;; (sigil web live) - Live streams and collections 2
;;;−3
;;; A live collection binds a data source, a per-item render function, a−4
;;; container selector, and a broadcast hub into one object. Mutating it−5
;;; (`live-add!` / `live-update!` / `live-remove!`) automatically pushes the−6
;;; right DOM patch to every connected browser: a new row is appended, an−7
;;; updated row is morphed in place by id, a removed row is deleted by id.+3
;;; A `live-stream` is a server-push channel a page subscribes to over SSE: you+4
;;; `live-push!` `ui-*` updates and every connected client applies them. It owns+5
;;; a broadcast hub, an SSE route, and a lazily-started heartbeat. No store, no+6
;;; render — the general case (a chat feed, a room log, notifications). 7
;;;−9
;;; The collection owns its store, its hub, and its heartbeat, so an app−10
;;; author never hand-writes the store / broadcast / snapshot / events-route−11
;;; plumbing. `live-view` renders the current rows plus the live subscription;−12
;;; `live-routes` registers the SSE endpoint.+8
;;; A `live-collection` is a `live-stream` plus an id-keyed store and a per-item+9
;;; render function. Mutating it (`live-add!` / `live-update!` / `live-remove!`)+10
;;; pushes the right DOM patch automatically: a new row appends, an updated row+11
;;; morphs in place by id, a removed row is deleted by id. An optional+12
;;; `on-change:` derives extra UI (e.g. a count) from the item list after every+13
;;; mutation. 14
;;; 15
;;; Mutations do NOT return the HTTP response — they return the affected item−15
;;; (or #f) for convenience. The handler returns the acting user's own−16
;;; response explicitly. Mental model: the mutation is what *everyone* sees;−17
;;; the return value is what *you* see.+16
;;; (or #f). Under `ui-routes` a handler can just end with the mutation. Mental+17
;;; model: the mutation/push is what *everyone* sees; the handler's return is+18
;;; what *you* see. 19
;;; 20
;;; Example: 21
;;; ```scheme 22
;;; (define todos−22
;;; (live-collection render: item-row container: "#todo-list" endpoint: "/events"))+23
;;; (live-collection render: item-row container: "#todo-list" path: "/events"+24
;;; on-change: (lambda (items)+25
;;; (ui-update target: "#count" mode: "inner"+26
;;; content: (count-label (length items))))))+27
;;;+28
;;; (define log (live-stream path: "/log")) ; a plain feed 29
;;; 30
;;; (define (create-handler req)−25
;;; (live-add! todos (dict text: (form-text req) done?: #f)) ; everyone: append row−26
;;; (ui-response (ui-update target: "#add-form" content: (add-form)))) ; you: reset the form+31
;;; (live-add! todos (dict text: (form-text req) done?: #f))+32
;;; (live-push! log (ui-update target: "#log" mode: "append"+33
;;; content: (log-line "Added" (form-text req))))) 34
;;; ``` 35
36
(define-library (sigil web live)@@ -37,6 +44,13 @@
44
(sigil web ui)) 45
46
(export+47
;; Streams (the base: push arbitrary ui-* updates to subscribers)+48
live-stream+49
live-stream?+50
live-push!+51
live-subscribe+52
+53
;; Collections (a stream + a store + a render fn + auto-patching) 54
live-collection 55
live-collection? 56
live-get@@ -44,19 +58,103 @@
58
live-add! 59
live-update! 60
live-remove!−47
live-view−48
live-routes−49
live-snapshot)+61
live-list-view+62
live-snapshot+63
+64
;; Routing (works on a stream OR a collection)+65
live-routes) 66
67
(begin 68
+69
;; ============================================================+70
;; Live stream — the base primitive+71
;; ============================================================+72
+73
(define-struct live-stream-rec+74
(path) ; SSE path, e.g. "/events" or "/log"+75
(hub) ; broadcast channel+76
(on-connect) ; thunk -> initial ui-update string or #f (set by collections)+77
(heartbeat-latch)) ; capacity-1 channel; first try-send wins the heartbeat start+78
+79
;;; Create a live stream: a broadcast hub + an SSE route (registered via+80
;;; `live-routes`) + a lazily-started heartbeat. Push `ui-*` updates to it+81
;;; with `live-push!`; put its subscription element on the page with+82
;;; `live-subscribe`.+83
;;;+84
;;; Parameters:+85
;;; path: the SSE path the page subscribes to (default "/events").+86
;;; on-connect: optional thunk returning an initial ui-update string (or+87
;;; #f) sent to each new subscriber. Collections use this for+88
;;; the current-state snapshot; a plain feed usually omits it.+89
(define (live-stream (keys: (path "/events")+90
(on-connect #f)))+91
(live-stream-rec+92
path: path+93
hub: (make-broadcast)+94
on-connect: on-connect+95
heartbeat-latch: (make-channel 1)))+96
+97
;;; #t if `x` is a live stream.+98
(define (live-stream? x) (live-stream-rec? x))+99
+100
;; Resolve a stream OR collection down to its underlying stream.+101
(define (as-stream x)+102
(cond+103
((live-stream-rec? x) x)+104
((live-collection-rec? x) (live-collection-rec-stream x))+105
(else (error "live: expected a live-stream or live-collection"))))+106
+107
;;; Push a UI update (or a list of them) to every client subscribed to the+108
;;; stream. `x` is a stream or a collection. Returns `update`.+109
;;;+110
;;; ```scheme+111
;;; (live-push! log (ui-update target: "#log" mode: "append" content: row))+112
;;; ```+113
(define (live-push! x update)+114
(let ((hub (live-stream-rec-hub (as-stream x))))+115
(for-each (lambda (u) (broadcast-send hub u))+116
(if (list? update) update (list update)))+117
update))+118
+119
;;; The hidden element that subscribes a page to a stream's live updates.+120
;;; `x` is a stream or a collection. Drop it into a page body.+121
(define (live-subscribe x)+122
`(div (@ (data-sg-sse ,(live-stream-rec-path (as-stream x)))+123
(style "display:none"))))+124
+125
;; Start the heartbeat exactly once, on the first connection. The latch is a+126
;; capacity-1 channel: `channel-try-send` succeeds for exactly one caller+127
;; (an atomic test-and-set), so a burst of simultaneous first-connects+128
;; starts a single heartbeat. This runs inside the connection goroutine,+129
;; already under the server's `with-async`, so the scheduler is available.+130
(define (ensure-heartbeat! stream)+131
(when (channel-try-send (live-stream-rec-heartbeat-latch stream) 'started)+132
(start-sse-heartbeat! (live-stream-rec-hub stream))))+133
+134
(define (stream-events-handler stream)+135
(lambda (request)+136
(ensure-heartbeat! stream)+137
(http-response/sse-broadcast (live-stream-rec-hub stream)+138
on-connect: (live-stream-rec-on-connect stream))))+139
+140
;;; Return a handler that serves the SSE endpoint for a stream or a+141
;;; collection. Combine with your app's routes:+142
;;; `(routes (router …) (live-routes todos) (live-routes log))`.+143
(define (live-routes x)+144
(let ((stream (as-stream x)))+145
(router+146
(route method: GET+147
pattern: (live-stream-rec-path stream)+148
handler: (stream-events-handler stream)))))+149
+150
151
;; ============================================================ 152
;; Internal store seam 153
;; ============================================================ 154
;;−57
;; A minimal id-keyed, insertion-ordered store. Items are dicts; the−58
;; store owns the `id:` field (assigned on add). This is deliberately an−59
;; internal interface (`store-all`/`store-get`/`store-add!`/`store-update!`/+155
;; A minimal id-keyed, insertion-ordered store. Items are dicts; the store+156
;; owns the `id:` field (assigned on add). Deliberately an internal+157
;; interface (`store-all`/`store-get`/`store-add!`/`store-update!`/ 158
;; `store-remove!`) with one in-memory implementation, so a database-backed 159
;; store can drop in later without touching the collection or the app. 160
;; Not exposed as a `store:` parameter yet.@@ -102,95 +200,129 @@
200
201
202
;; ============================================================−105
;; Live collection+203
;; Live collection — a stream + a store + a render fn 204
;; ============================================================ 205
206
(define-struct live-collection-rec−109
(render) ; item -> SXML row (with an id attribute)−110
(container) ; CSS selector for the list, e.g. "#todo-list"−111
(endpoint) ; SSE path, e.g. "/events"−112
(store) ; internal store−113
(hub) ; broadcast channel−114
(heartbeat-started? default: #f mutable: #t))−115
−116
;;; Create a live collection.+207
(stream) ; the underlying live-stream+208
(store) ; internal store+209
(render) ; item -> SXML row (its top-level element carries an id)+210
(container) ; CSS id-selector of the list element, e.g. "#todo-list"+211
(on-change)) ; (items -> ui-update or list) broadcast after each mutation, or #f+212
+213
;; The current-state snapshot: replace the container's contents with every+214
;; current row. Sent to each browser on connect. Kept as a free function of+215
;; (container render store) so the collection's stream can close over it at+216
;; construction without a forward reference to the collection.+217
(define (snapshot-update container render store)+218
(ui-update target: container+219
mode: "inner"+220
content: (map render (store-all store))))+221
+222
;;; Create a live collection: a stream whose subscribers see a list of+223
;;; rendered rows kept in sync as you mutate it. 224
;;; 225
;;; Parameters:−119
;;; render: (item -> SXML) rendering one row. The rendered element−120
;;; MUST carry an `id` attribute; the collection targets that−121
;;; id when it morphs or removes the row.−122
;;; container: CSS id-selector of the list element new rows append into+226
;;; render: (item -> SXML) rendering one row. The rendered element MUST+227
;;; carry an `id` attribute; the collection targets that id when+228
;;; it morphs or removes the row.+229
;;; container: CSS id-selector of the list element rows live in 230
;;; (e.g. "#todo-list").−124
;;; endpoint: SSE path the page subscribes to (default "/events").−125
;;;−126
;;; The store, broadcast hub, and heartbeat are created and managed−127
;;; internally.+231
;;; path: the SSE path the page subscribes to (default "/events").+232
;;; on-change: optional (items -> ui-update or list of them), broadcast+233
;;; after every mutation. For UI derived from the whole list+234
;;; (a count, a summary). Receives ONLY the item list. 235
(define (live-collection (keys: (render #f) 236
(container #f)−130
(endpoint "/events")))−131
(live-collection-rec−132
render: render−133
container: container−134
endpoint: endpoint−135
store: (mem-store)−136
hub: (make-broadcast)))+237
(path "/events")+238
(on-change #f)))+239
(let ((store (mem-store)))+240
(live-collection-rec+241
stream: (live-stream path: path+242
on-connect: (lambda ()+243
(snapshot-update container render store)))+244
store: store+245
render: render+246
container: container+247
on-change: on-change))) 248
249
;;; #t if `x` is a live collection. 250
(define (live-collection? x) (live-collection-rec? x)) 251
+252
;; Convenience accessors through the collection.+253
(define (coll-store coll) (live-collection-rec-store coll))+254
(define (coll-render coll) (live-collection-rec-render coll))+255
(define (coll-container coll) (live-collection-rec-container coll))+256
+257
;;; The current-state snapshot for a collection (the same update sent on+258
;;; connect). Exposed for callers who want to re-seed a target.+259
(define (live-snapshot coll)+260
(snapshot-update (coll-container coll) (coll-render coll) (coll-store coll)))+261
262
;;; Read one item by id (or #f). For read-only handlers such as an inline 263
;;; edit form that needs the item's current state; mutations go through 264
;;; `live-update!` / `live-remove!`. 265
(define (live-get coll id)−145
(store-get (live-collection-rec-store coll) id))+266
(store-get (coll-store coll) id)) 267
268
;;; All items in insertion order. Read-only. 269
(define (live-all coll)−149
(store-all (live-collection-rec-store coll)))+270
(store-all (coll-store coll))) 271
272
;; Render an item and return (selector . row), where selector targets the 273
;; row's own id attribute (e.g. "#item-3"). The render function is the 274
;; single source of truth for a row's DOM id. 275
(define (render-row+selector coll item)−155
(let* ((row ((live-collection-rec-render coll) item))+276
(let* ((row ((coll-render coll) item)) 277
(id (sxml-attr-ref row 'id))) 278
(if id 279
(cons (string-append "#" id) row)−159
(error "live-collection: rendered row is missing an id attribute"))))+280
(error+281
(string-append+282
"live-collection: the render function returned a row with no "+283
"`id` attribute, so it can't be targeted for a live update or "+284
"removal. Give the row's top-level element an id (e.g. "+285
"(li (@ (id \"item-3\")) ...)). Rendered: "+286
(sxml->xml row))))))+287
+288
;; After a mutation, broadcast any derived UI from on-change.+289
(define (broadcast-change! coll)+290
(let ((f (live-collection-rec-on-change coll)))+291
(when f+292
(live-push! coll (f (store-all (coll-store coll))))))) 293
−161
;;; Add `item` to the collection: assign an id, store it, and broadcast an−162
;;; append of its rendered row to every connected client. Returns the−163
;;; stored item (with its id). Does NOT return an HTTP response.+294
;;; Add `item`: assign an id, store it, broadcast an append of its rendered+295
;;; row (then any `on-change` update) to every client. Returns the stored+296
;;; item. Does NOT return an HTTP response. 297
(define (live-add! coll item)−165
(let* ((it (store-add! (live-collection-rec-store coll) item))−166
(row ((live-collection-rec-render coll) it)))−167
(broadcast-send (live-collection-rec-hub coll)−168
(ui-update target: (live-collection-rec-container coll)−169
mode: "append"−170
content: row))+298
(let* ((it (store-add! (coll-store coll) item))+299
(row ((coll-render coll) it)))+300
(live-push! coll+301
(ui-update target: (coll-container coll) mode: "append" content: row))+302
(broadcast-change! coll) 303
it)) 304
−173
;;; Update item `id` in place with `(f item)`: store it and broadcast a−174
;;; morph of its rendered row (targeted by the row's id) to every client.−175
;;; Returns the new item, or #f if no such id. Does NOT return a response.+305
;;; Update item `id` in place with `(f item)`: store it, broadcast a morph+306
;;; of its rendered row (then any `on-change`). Returns the new item, or #f+307
;;; if no such id. Does NOT return a response. 308
(define (live-update! coll id f)−177
(let ((it (store-update! (live-collection-rec-store coll) id f)))+309
(let ((it (store-update! (coll-store coll) id f))) 310
(when it 311
(let ((sel+row (render-row+selector coll it)))−180
(broadcast-send (live-collection-rec-hub coll)−181
(ui-update target: (car sel+row) content: (cdr sel+row)))))+312
(live-push! coll (ui-update target: (car sel+row) content: (cdr sel+row))))+313
(broadcast-change! coll)) 314
it)) 315
−184
;;; Remove item `id`: delete it from the store and broadcast a remove of−185
;;; its row (targeted by the row's id) to every client. Returns the removed−186
;;; item, or #f if no such id. Does NOT return a response.+316
;;; Remove item `id`: delete it, broadcast a remove of its row (then any+317
;;; `on-change`). Returns the removed item, or #f if no such id. Does NOT+318
;;; return a response. 319
(define (live-remove! coll id)−188
(let ((it (store-get (live-collection-rec-store coll) id)))+320
(let ((it (store-get (coll-store coll) id))) 321
(when it 322
(let ((sel (car (render-row+selector coll it))))−191
(store-remove! (live-collection-rec-store coll) id)−192
(broadcast-send (live-collection-rec-hub coll)−193
(ui-remove target: sel))))+323
(store-remove! (coll-store coll) id)+324
(live-push! coll (ui-remove target: sel)))+325
(broadcast-change! coll)) 326
it)) 327
328
;; Strip a leading "#" from an id-selector to get the bare element id.@@ -200,49 +332,13 @@
332
(substring selector 1 (string-length selector)) 333
selector)) 334
−203
;;; The current-state snapshot: a UI update that replaces the container's−204
;;; contents with every current row. Sent to each browser on connect.−205
(define (live-snapshot coll)−206
(ui-update target: (live-collection-rec-container coll)−207
mode: "inner"−208
content: (map (live-collection-rec-render coll)−209
(store-all (live-collection-rec-store coll)))))−210
−211
;;; Render the live view: the list container holding the current rows, plus−212
;;; the hidden element that subscribes the page to the live stream. Drop−213
;;; this into a page body; the collection owns the container id and the−214
;;; subscription endpoint.−215
(define (live-view coll)+335
;;; Render the collection's list view: the list container holding the+336
;;; current rows, plus the hidden element subscribing the page to the live+337
;;; stream. Drop this into a page body.+338
(define (live-list-view coll) 339
`(div−217
(ul (@ (id ,(selector->id (live-collection-rec-container coll))))−218
,@(map (live-collection-rec-render coll)−219
(store-all (live-collection-rec-store coll))))−220
(div (@ (data-sg-sse ,(live-collection-rec-endpoint coll))−221
(style "display:none")))))−222
−223
;; Start the heartbeat once, on the first SSE connection. The connection−224
;; handler runs in its own goroutine under the server's `with-async`, so−225
;; the scheduler is available here without the app calling anything at−226
;; startup. (The flag check/set isn't atomic; a burst of simultaneous−227
;; first-connects could start more than one heartbeat. Harmless for now;−228
;; revisit if it matters.)−229
(define (ensure-heartbeat! coll)−230
(unless (live-collection-rec-heartbeat-started? coll)−231
(set-live-collection-rec-heartbeat-started?! coll #t)−232
(start-sse-heartbeat! (live-collection-rec-hub coll))))−233
−234
(define (live-events-handler coll)−235
(lambda (req)−236
(ensure-heartbeat! coll)−237
(http-response/sse-broadcast (live-collection-rec-hub coll)−238
on-connect: (lambda () (live-snapshot coll)))))−239
−240
;;; Return a handler that serves the collection's SSE endpoint. Combine it−241
;;; with your app's routes: `(routes (router ...) (live-routes todos))`.−242
(define (live-routes coll)−243
(router−244
(route method: GET−245
pattern: (live-collection-rec-endpoint coll)−246
handler: (live-events-handler coll))))+340
(ul (@ (id ,(selector->id (coll-container coll))))+341
,@(map (coll-render coll) (store-all (coll-store coll))))+342
,(live-subscribe coll))) 343
344
))