Live Views
Keep an open view's full join-surface live in realtime — root row plus related child collections — with hybrid-delta updates and resync-on-reconnect.
A live view is a named surface: a root row plus the related data a screen
shows around it — forward foreign-key display values and reverse-FK child
collections. When a client subscribes to a live view, the open surface stays
whole in realtime: any write to the root row or a related row that shares a
foreign key is pushed as a view_changes delta the client splices in place.
This is the difference from table-level postgres_changes
broadcasts, which are single-row, single-table. A person-detail screen that
shows a people row plus rows from a normalized phone_numbers table would
otherwise have to stitch together separate per-table events and figure out that
a phone_numbers insert with personId = X belongs to the person it has open.
Live views do that mapping for you.
Live views ride the realtime Broadcaster. You must have realtime enabled (
providers.database.config.realtime: true). Declaring a view without realtime is a compile-time error.
Defining a view
Views live in services/views/<name>.ts:
// services/views/person-detail.ts
import { defineView } from 'quickback';
export default defineView({
name: 'person-detail',
root: 'people',
include: [
{ relation: 'companyId' }, // forward FK → company display value
{ relation: 'phone_numbers' }, // reverse FK → phoneNumbers[] collection
],
});root— the table the surface is keyed by. Must be adefineTableresource (its firewall + read access gate the surface).include[].relation— a forward-FK column on the root (companyId), or the name of a child table that references the root (phone_numbers). The compiler auto-detects the direction from the foreign-key graph; passkind: 'forward' | 'reverse'to disambiguate.as— the response key the related data is spliced under. Defaults to a derived name: forward strips a trailingId(companyId→company), reverse uses the child table name (phone_numbers→phoneNumbers).fields— optional column projection on the included table.
The materialized surface looks like:
{
"id": "person_123",
"name": "Ada Lovelace",
"email": "ad***@example.com", // masked per the people table's masking config
"company": "Analytical Engines", // forward display value
"phoneNumbers": [ // reverse-FK child collection
{ "id": "ph_1", "number": "+1..." }
]
}Every included table is read through its own firewall and masking — a
subscriber only ever sees related rows they're already allowed to read, masked
exactly as they would be on a normal read.
Reading a surface
The compiler generates a read endpoint per view:
GET /api/v1/views/person-detail/person_123
→ { "data": { ...surface }, "seq": 7 }seq is the surface's current monotonic sequence — the client uses it to detect
dropped deltas. A 404 is returned when the caller can't read the root row
(indistinguishable from "doesn't exist", closing the id-probe channel).
Subscribing (client)
The built-in CMS and Account SPAs ship a useView hook:
import { useView } from '~/lib/realtime/use-view';
function PersonDetail({ personId }: { personId: string }) {
const { data, connected, loading } = useView('person-detail', personId);
// `data` stays live: editing the person merges into the root; adding a phone
// appends to data.phoneNumbers; deleting one removes it — no refetch.
}Under the hood useView uses a subscribe-then-snapshot flow so no change
slips through the gap between reading the surface and the socket going live:
- Requests a view-scoped ws-ticket (
POST /broadcast/v1/ws-ticketwith{ view, rootId }) — gated by the same root firewall as the read endpoint — and opens a WebSocket scoped toview:<view>:<rootId>. - Once the socket is open, fetches the full surface via
GET /api/v1/views/<view>/:rootId. Deltas arriving during that fetch are buffered, then replayed after the snapshot applies (the route reads theseqbaseline before materializing, so the snapshot is at least as fresh asseq, and replay is idempotent — collections key by rowid). - Applies
view_changesdeltas in place (the hybrid-delta model):target: 'root'+UPDATE→ shallow-merge into the surface roottarget: 'root'+DELETE→ surface clearedtarget: 'collection'→ append / replace-by-key / remove-by-key ondata[as]delta.resync→ refetch the surface (the stricter-child path above)
- Tracks
seq. A gap (seq !== last + 1) or a WebSocket reconnect triggers a full resync — the safety net that heals any dropped delta (Cloudflare D1 has no transactions, so emit is best-effort by design). If the server can't read the baseline it returnsseq: null; the client then adopts the first delta'sseqinstead of treating it as0, so a transient read error never spirals into a resync loop. Overlapping resyncs are token-guarded so a slow earlier fetch can't clobber a newer surface.
To subscribe from a custom client, request a ticket with { view, rootId } and
connect to …/broadcast/v1/websocket?ws_ticket=<ticket>. Messages are:
{
"type": "view_changes",
"view": "person-detail",
"rootId": "person_123",
"seq": 8,
"delta": { "target": "collection", "op": "INSERT", "as": "phoneNumbers",
"key": "ph_2", "row": { "id": "ph_2", "number": "+1..." } }
}How changes are captured
Deltas are emitted from the write chokepoint — the same wrapper that injects
audit fields wraps every db.insert/update/delete. Because CRUD routes and
action handlers write through that db, both emit deltas; there's no separate
hook to wire. A write taps its result and, when it used .returning(), fans a
delta to each affected surface. Soft deletes (an UPDATE that sets deletedAt)
are emitted as DELETE so the row leaves the surface.
Security
- The subscribe gate reuses the view materializer: a ticket is only minted when the caller can read the root row.
- Each included table's
firewall+maskingapply on both the initial materialize and the live deltas (masking is applied per subscriber role at fanout), so the two are consistent. - Stricter-child guard. When a reverse-FK child's firewall is stricter than the root's (e.g. an owner-scoped note under an org-scoped person), the raw child row is never broadcast. Instead the server emits a resync signal and the client refetches the surface through the materializer, which applies the child's own firewall per subscriber. Row-bearing deltas are sent only when the child is no stricter than the root.
- Masking fails closed. A mask type the Broadcaster can't replicate —
notably
type: 'custom', whose function is stripped during codegen — is fully redacted ([REDACTED]) in deltas rather than passed through. - Projection parity. An include's
fieldsprojection is applied to deltas too, so a delta never carries a column the surface deliberately omits.
v1 limitations
- Depth-1 includes only. Nested includes are a compile-time error — flatten the surface or split into multiple views.
- Forward includes (e.g. a company name on a person) are materialized into the surface and refreshed on resync, but a change to the referenced parent does not push a live delta — it surfaces on the next resync/reconnect.
- Writes without
.returning()(and raw action writes that don't return rows) don't push a live delta; resync-on-reconnect heals the surface.