# Live Views

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`](/platform/realtime/using-realtime)
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`:

```typescript
// 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](#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` (`companyId` → `company`),
  reverse uses the child table name (`phone_numbers` → `phoneNumbers`).
- **`fields`** — optional column projection on the included table.

The materialized surface looks like:

```jsonc
{
  "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](#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:

```typescript
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](/define/firewall#named-tenant-anchors-tenant),
the tenant can be referenced **by name** — same resolved include, same gates:

```typescript
// 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:

```http
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 `404`s 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:

```tsx
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` when `contract.routes` is `"v2"` — 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:

```jsonc
{
  "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`](/define/changesets)) do not
flow through the per-row delta chokepoint — they emit one aggregate-level signal
via [`afterCommit`](/define/changesets#aftercommit--emit-a-realtime-signal-after-the-changeset-commits)
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.


  {/* doc-compile: skip — illustrative fragment: the point is the `read.access` line and the three 403s below it, so the host `customers` table and its columns are deliberately not repeated. Whole-file table examples live in /define/schema. */}

  ```typescript
  // customers is admin-only to read...
  export default defineTable(customers, {
    firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
    read: { access: { roles: ['admin'] } },
  });
  ```

  ```http
  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:

  | Shape | Why | Fix |
  | --- | --- | --- |
  | function-form `read.access` | can't be serialized into the materializer | narrow to a declarative `access` |
  | a `relationship:` arm | evaluated by per-record inline code the view path doesn't emit | gate on roles; keep the row scoping in the firewall `via:` predicate |
  | an `fga:` arm | same — needs a record to resolve the object id | gate on roles, or check inside a handler |
  | a `record:` condition **on an included table** | includes are fetched as a collection / display value with no per-row post-check | narrow 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.
