# Options Realtime modes, field selection, sort, channel naming, listen-before-select, and returning a LiveTable from an rpc. ```ts new LiveTable({ table: "comments", // db table for automatic handlers; inferred from var name scope: "roomId", // optional partition key channel: (sv) => `comments:${sv}`, // override the default channel name realtime: "app", // true / 'app' (default) | false / 'off' | 'db' generateId: true, // default; required when realtime != 'off' fields: ["text", "author", { name: "createdAt", readonly: true }], sort: "createdAt desc", select: () => sql(`...`), // custom SELECT (must honor scope) insert: { auth, handler }, update: { auth, handler }, delete: { auth, handler }, }); ``` ## Listen Before Select LiveTable subscribes via the same listen-before-select discipline that `channel` documents. The internal sequence is: open the postgres `LISTEN` (await commit), then run the initial select. Any `NOTIFY` fired between `LISTEN` commit and the browser WS attach is queued on the listener and flushed on attach. The browser-side row store (keyed by `id`) naturally dedupes: an insert echo for a row already present from the snapshot is a no-op. You don't have to call `.listen()` explicitly: passing a realtime LiveTable into html attrs or returning it from a route runs this sequence automatically. If you need a snapshot OUTSIDE a LiveTable (a one-off `sql()` combined with a raw `channel.listen()`), call `listen()` first and the `sql()` second. See `channel` for the manual pattern. ## Realtime Modes - `true` / `'app'` (default): App broadcasts on its own mutations. App listens. Pure app-driven realtime. A LiveTable is live unless you opt out. - `false` / `'off'`: No channel, no listener, no server-side state. The LiveTable is a snapshot data source. Initial select runs once per request, browser-side mutations still round-trip via rpc, but no cross-client broadcast. Lets the response remain edge-cacheable. - `'db'`: Postgres triggers broadcast (typically because mutations come from psql, jobs, or other services). App listens. `generateId: false` is only valid when `realtime` is `false` or `'off'`. Such a table defaults to `'off'` rather than picking up the realtime default, since broadcast dedup needs the client-generated id. For `'db'` mode, install a trigger that publishes via the `channel_name(text)` SQL function. See `channel` for the trigger pattern. The default channel format is `` unscoped or `
:` scoped. ## Fields ```ts fields: ["text", "author", { name: "createdAt", readonly: true }] ``` If omitted, fields are auto-introspected from the database. `id` is always implicit and readonly. camelCase in TypeScript maps to snake_case in the database (`createdAt` → `created_at`). ## Sort Declarative SQL sort. Applied on the server in `select`, and on the browser when re-sorting after broadcast inserts. ```ts sort: "createdAt desc" sort: "createdAt desc, id asc" ``` This option shapes the *query*. To order what a template shows, use `sort()` on the table, which is a view concern and never reaches the database. The two are independent: a table can be selected newest first and rendered alphabetically. ## Views `filter()` and `sort()` narrow and order a view of the table. They return a live selection, not an array, and hold no rows of their own: a selection yields the same row objects the table holds, so writing a field notifies every binding that reads it, in every selection at once. ```ts todos.filter((t) => !t.done) todos.filter((t) => !t.done).sort((a, b) => a.title.localeCompare(b.title)) ``` The predicate and the comparator belong to the table's controller, which is what keeps a rendered list cheap. A row arriving over the wire is tested once and placed once, so an insert is one dom insertion rather than a re-render. A row whose fields change but still matches emits nothing at all. Its own bindings repaint. A row that stops matching is one removal, and one that starts matching is one insertion. Both run identically on the server and the browser, so the html a page ships already carries the order the browser will maintain. Shaping the data itself, meaning which rows are ever loaded and in what order the database returns them, is a `select` handler's job. ## Channel Name Override The build tool generates a channel name for every LiveTable automatically. The default is `
` unscoped or `
:` scoped, with file-path disambiguation across files so two `LiveTable` declarations on the same database table don't cross-broadcast. For LiveTables that the app fully owns, the auto-generated name works without intervention. Set `channel` explicitly when something outside the LiveTable publishes to the channel directly: a Postgres function, a database trigger (`realtime: 'db'` mode), an external service, or another LiveTable that should share the same broadcast. The publisher needs a stable name to call `pg_notify('your_channel', ...)` or `channel_name('your_channel')`, so you give the LiveTable that name explicitly. ```ts new LiveTable({ table: "comments", channel: () => "comments_shared", }); ``` ## Returning a LiveTable A route handler that passes a LiveTable to its template (as in the At a Glance example) is the common path. The browser receives a connected handle, the template iterates it, and broadcasts flow back over the connection. An `@rpc` function can also return a LiveTable. Use this when the table to watch depends on a runtime decision: a permission check, a parameter, or a different table per call. ```ts /** @rpc */ export function watchRoom(roomId: string) { if (sql(`select 1 from roomMembers where roomId = ${roomId} and userId = ${session.getOrThrow("userId")}`).empty()) { throw new AuthError(); } return comments.scope(roomId); } ``` ```ts let comments = watchRoom(roomId); // browser: sync-style; compiler awaits ``` ## Errors - `AuthError`: mutation `auth` callback returned false. - `ValidationError`: thrown directly inside a handler when input fails per-field validation. Carries an optional `FieldErrors` map. - `ScopeMismatchError`: mutation's scope column didn't match the bound scope value. Fires before any handler runs, independent of `auth`. ## Related - `database`: `sql()`, `tx()`, and the camelCase-to-snake_case boundary. - `channel`: the pub/sub layer underneath LiveTable. - `html/events`: `oninit`, `oninsert`, and `onremove` on the rows a LiveTable renders, and the `LifecycleEvent` they receive. A LiveTable has no lifecycle handlers of its own; the rows do. - `html/integration`: rendering a LiveTable from a template.