Quickback Docs

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 a defineTable resource: its read.access and 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; pass kind: 'forward' | 'reverse' to disambiguate.
  • as — the response key the related data is spliced under. Defaults to a derived name: forward strips a trailing Id (companyIdcompany), reverse uses the child table name (phone_numbersphoneNumbers).
  • 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:

  • fields is a mandatory allowlist of compiler-known display columns (deny-by-default; secret/auth-internal tables and columns are never exposable).
  • tenant is mandatory for member / 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.local must 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:

ConditionStatus
Unauthenticated401
Unknown view name404
Root table's read.access denies403
An included table's read.access denies404
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:

  1. Requests a view-scoped ws-ticket (POST /broadcast/v1/ws-ticket/broadcast/v2/ws-ticket on contract-v2 projects — with { view, rootId }) — gated by exactly the same read.access + firewall checks as the read endpoint — and opens a WebSocket scoped to view:<view>:<rootId>.
  2. 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 the seq baseline before materializing, so the snapshot is at least as fresh as seq, and replay is idempotent — collections key by row id).
  3. Applies view_changes deltas in place (the hybrid-delta model):
    • target: 'root' + UPDATE → shallow-merge into the surface root
    • target: 'root' + DELETE → surface cleared
    • target: 'collection' → append / replace-by-key / remove-by-key on data[as]
    • delta.resync → refetch the surface (the stricter-child path above)
  4. 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 returns seq: null; the client then adopts the first delta's seq instead of treating it as 0, 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.access applies, per table. A view is an aggregate, so every table on the surface — the root and each included child — is gated by its own read: { access }, exactly as GET /<table>/:id is. 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 that GET /:id correctly 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.access is authenticated-only, identical to its CRUD read.

    This differs from ?include= on purpose. FK-graph ?include= requires its target to declare read.access and is a compile error otherwise; a view surface does not.

    Neither surface is weaker than the other. A table with no declared read.access is 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's read.access and 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 + masking apply 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.roles audience is honored on view deltas too: view_changes for 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 broadcasting realtime block 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 fields projection is applied to deltas too, so a delta never carries a column the surface deliberately omits.

v1 limitations

  • read.access shapes 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:

    ShapeWhyFix
    function-form read.accesscan't be serialized into the materializernarrow to a declarative access
    a relationship: armevaluated by per-record inline code the view path doesn't emitgate on roles; keep the row scoping in the firewall via: predicate
    an fga: armsame — needs a record to resolve the object idgate 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-checknarrow the child's read.access to pre-record arms, or drop the include

    A record: condition on the root is fine — the materializer holds the fetched root row and evaluates it post-firewall, the same two-phase gate GET /:id uses.

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

On this page