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 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; 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 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:

  1. Requests a view-scoped ws-ticket (POST /broadcast/v1/ws-ticket with { view, rootId }) — gated by the same root firewall 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.

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 + masking apply 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 fields projection 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.

On this page