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
))