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: itsread.accessand its firewall gate the surface (see Security).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 read.access, 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. See
Security for the exact gate order.
Auth-DB includes (source: 'auth')
A view may join read-only display data from the Better Auth tables
(user, member, invitation, organization) — the "staff picker" pattern:
export default defineView({
name: 'staff-board',
root: 'sessions',
include: [
// dereference the creating user's display fields
{ source: 'auth', table: 'user', on: { local: 'createdBy', auth: 'id' },
fields: ['name', 'email'], as: 'creator' },
// the creator's role in the caller's org — tenant-anchored
{ source: 'auth', table: 'member', on: { local: 'createdBy', auth: 'userId' },
tenant: { auth: 'organizationId', equals: 'ctx.activeOrgId' },
fields: ['role'], as: 'creatorMembership' },
],
});Auth tables carry no firewall/masking to inherit, so the include is the security boundary — every rule is fail-closed at compile time:
fieldsis a mandatory allowlist of compiler-known display columns (deny-by-default; secret/auth-internal tables and columns are never exposable).tenantis mandatory formember/invitation— those tables hold per-organization rows keyed by columns that recur across orgs; an un-anchored single-row join would return an arbitrary org's row.- An un-tenanted join's
on.localmust be a non-writable, auto-stamped column (createdBy/modifiedBy, or the root's declared scope columns) — a caller-writable join key would let a caller dereference arbitrary identities.
With a declared authz.tenants anchor,
the tenant can be referenced by name — same resolved include, same gates:
// quickback.config.ts
authz: { tenants: { org: { column: 'organizationId', equals: 'ctx.activeOrgId' } } }
// the include
{ source: 'auth', table: 'member', on: { local: 'createdBy', auth: 'userId' },
tenant: 'org', fields: ['role'], as: 'creatorMembership' }The referenced anchor must bind a single-segment session claim (ctx.<claim>, e.g. ctx.activeOrgId) — the same grammar the inline object form enforces. An anchor bound to a scope-principal claim (ctx.scope.*) is valid in firewall lanes but is a compile error when referenced from an auth-view include.
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.
Denials, in gate order:
| Condition | Status |
|---|---|
| Unauthenticated | 401 |
| Unknown view name | 404 |
Root table's read.access denies | 403 |
An included table's read.access denies | 404 |
| Root row misses the firewall (or doesn't exist) | 404 |
The 404s are deliberately 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-ticket—/broadcast/v2/ws-ticketon contract-v2 projects — with{ view, rootId }) — gated by exactly the sameread.access+ firewall checks 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.
Changeset writes (aggregate owns) do not
flow through the per-row delta chokepoint — they emit one aggregate-level signal
via afterCommit
on the root. Declare afterCommit on the aggregate root so live views observe
changeset writes; without it the compiler warns that changeset writes to a
realtime table go unobserved.
Security
-
read.accessapplies, per table. A view is an aggregate, so every table on the surface — the root and each included child — is gated by its ownread: { access }, exactly asGET /<table>/:idis. A firewall is a tenant filter, not a role gate: a same-org member clears it, so without this arm they would receive a row thatGET /:idcorrectly 403s them on. The gate is deny-on-any — if any table on the surface denies, the whole surface is refused.A table that declares no
read.accessis authenticated-only, identical to its CRUD read.This differs from
?include=on purpose. FK-graph?include=requires its target to declareread.accessand is a compile error otherwise; a view surface does not.Neither surface is weaker than the other. A table with no declared
read.accessis already authenticated-only-plus-firewall when read directly through CRUD, so embedding it in a view grants nothing extra — the view is exactly as strong as reading the table itself, which is the rule both surfaces are held to. The difference is authoring strictness, not enforcement:?include=makes you state the target's read policy before embedding it, while a view inherits whatever the table already does.// customers is admin-only to read... export default defineTable(customers, { firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }], read: { access: { roles: ['admin'] } }, });GET /api/v1/customers/cus_1 → 403 (org member) GET /api/v1/views/customer-detail/cus_1 → 403 (same gate, same member) POST /broadcast/v1/ws-ticket {view, rootId} → 403 (cannot subscribe either)The root's denial is an explicit 403; a denial on an included table collapses to 404 along with a firewall miss, so the aggregate never acts as an existence oracle for a table you can't read.
-
Subscribe is the same gate as read. A ws-ticket for
view:<view>:<rootId>is only minted after the caller clears the root'sread.accessand the full materialize (every table's access + firewall). That is what makes the row-bearing delta fanout sound: a ticket holder has already cleared every table on the surface. -
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. -
A table-level
realtime.access.rolesaudience is honored on view deltas too:view_changesfor that table fan out only to subscribers within the declared audience (evaluated with the same pseudo-role semantics as REST), exactly like its row-stream broadcasts. Tables that don't declare a broadcastingrealtimeblock don't emit row-stream broadcasts, but their rows can still surface through a live view they participate in. -
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
-
read.accessshapes the view path can't decide are refused at compile time, rather than silently passing. A view fails to compile when any table on its surface declares:Shape Why Fix function-form read.accesscan't be serialized into the materializer narrow to a declarative accessa relationship:armevaluated by per-record inline code the view path doesn't emit gate on roles; keep the row scoping in the firewall via:predicatean fga:armsame — needs a record to resolve the object id gate on roles, or check inside a handler a record:condition on an included tableincludes are fetched as a collection / display value with no per-row post-check narrow the child's read.accessto pre-record arms, or drop the includeA
record:condition on the root is fine — the materializer holds the fetched root row and evaluates it post-firewall, the same two-phase gateGET /:iduses. -
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.