Live views
A list that other users keep changing — an order queue, a task board, a stock level — can refresh itself the moment a command commits, with two lines of YAML and no JavaScript of your own. The command declares what changed; the view declares what it watches:
# web/orders/approve/post.yml — the writeversion: tesseraql/v1id: orders.approvekind: routerecipe: command-jsonemit: orders.changed# ...input, security, sql as usual# web/orders/orders.view.yml — the screenversion: tesseraql/v1kind: viewrecipe: listtitle: OrdersrefreshOn: orders.changedWhen the command’s transaction commits, every open page whose list declares
refreshOn: orders.changed re-fetches itself and swaps its table region in place. A
rolled-back command emits nothing.
How it works — and what never leaves the server
Section titled “How it works — and what never leaves the server”The wire carries topic names, never data. A committed emit: pushes one named, empty
Server-Sent Event on GET /_tesseraql/events. That is the same per-session stream carrying
the inbox bell’s badge, so one connection serves both.
On the browser side, the bundled htmx sse extension re-issues an ordinary GET of the
page and swaps the view’s refresh region in place: a list’s table region — the same one the
search box refreshes — or a detail’s or dashboard’s #<view>-view region.
Because the refetch is a normal request, everything that guards the route guards the refresh: authentication, policies, data scoping, tenancy. Two viewers with different row authority each re-fetch their own view of the data.
- Topics are tenant-scoped: a commit in one tenant never signals another tenant’s streams.
- The stream is session-authenticated (like
auth: browserroutes) and only serves topics some route actually declares. A?topics=value no route emits is refused before the stream opens (TQL-VIEW-3320,400): it used to be filtered out silently, which opened a healthy-looking stream — heartbeats and all — that could never fire, so a typo left the page waiting for a refresh signal that was never coming. - Bounded by construction: subscriptions are capped per subject and globally.
Per subject the default is 4 (
tesseraql.live.maxPerSubject); one more evicts that subject’s own oldest stream, and the browser’s EventSource reconnects. Globally the default is 256 (tesseraql.live.maxTotal); at the cap a new subscription is refused withTQL-RATE-5030as a 503 before the stream opens. A full registry never ends someone else’s live view, and the page still works without live refresh until a reload finds a free slot. Signals coalesce per topic, and idlepingframes keep intermediaries from severing quiet streams. A connection bound sits outside both: the HTTP edge admits event streams undertesseraql.http.maxEventStreams, so the registry’s own caps are reached only where that number is the larger of the two (deployment). - The refetch carries the live client state: the typed search term and the current sort ride along, read from the DOM (the search box swaps the region without navigating, so the render-time URL can be stale) — and because the search box sits outside the swapped region, a live refresh never clobbers in-progress typing. A paginated list live-refreshes to its first page.
- Graceful without JavaScript: the page renders complete server-side; the stream only freshens it, so without the extension the list simply updates on the next reload.
Semantics and limits
Section titled “Semantics and limits”emit:belongs to a route whose write has a moment to announce —command-jsonat its commit, andfile-importwhen the background import’s transaction commits, which is later than the response that started it (csv-import.md decision 6) — and takes one topic or a list. Topic names are lowercase dot/dash-separated segments —orders.changed,stock.low— checked by lint (TQL-YAML-1038/TQL-YAML-1039).refreshOn:works on list, detail, and dashboard views — a list refreshes its table region, a detail its fields and children, a dashboard its whole panel grid. Forms are the deliberate exception (TQL-VIEW-3311): a live replacement would discard in-progress input. A topic no route emits is a lint warning (TQL-VIEW-3312), since that view would never refresh.- Signals cross nodes on PostgreSQL: a commit rides
pg_notifyover the shared main database to every peer node, whose listener forwards it into that node’s local hub — so a viewer connected to any node behind the load balancer refreshes. On other databases, signals stay per-node (the framework’s coordination stance) and viewers on other nodes converge on their next reload. Either way the signal is a best-effort freshness hint — reliable delivery is what the outbox is for. - The signal fires after commit; there is no queue and no replay. A page that was disconnected re-renders current data when it reconnects or reloads, which is always correct — the data never rode the stream.
Testing
Section titled “Testing”The emitting command is a write route: test it with a write sql case
(the emit: itself needs no case — a topic with no declared emitter is already a lint
finding, and the stream carries no logic of its own).
Further reading
Section titled “Further reading”- declarative-views.md — the list view
refreshOn:hangs off. - inbox.md — the framework’s own live surface on the same transport.
- deployment.md — the per-node coordination stance this feature follows.
- declarative-views.md — the views that refresh.
- messaging.md — events between applications, rather than screens.