Skip to content

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 write
version: tesseraql/v1
id: orders.approve
kind: route
recipe: command-json
emit: orders.changed
# ...input, security, sql as usual
# web/orders/orders.view.yml — the screen
version: tesseraql/v1
kind: view
recipe: list
title: Orders
refreshOn: orders.changed

When 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: browser routes) 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 with TQL-RATE-5030 as 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 idle ping frames keep intermediaries from severing quiet streams. A connection bound sits outside both: the HTTP edge admits event streams under tesseraql.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.
  • emit: belongs to a route whose write has a moment to announce — command-json at its commit, and file-import when 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_notify over 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.

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

  • 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.