Manual LiveTable Options

Options

elements man livetable/options Read as markdown

Realtime modes, field selection, sort, channel naming, listen-before-select, and returning a LiveTable from an rpc.

new LiveTable<T>({
  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<T>(`...`),       // 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 <table> unscoped or <table>:<scopeValue> scoped.

Fields

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 (createdAtcreated_at).

Sort

Declarative SQL sort. Applied on the server in select, and on the browser when re-sorting after broadcast inserts.

sort: "createdAt desc"
sort: "createdAt desc, id asc"

Channel Name Override

The build tool generates a channel name for every LiveTable automatically. The default is <table> unscoped or <table>:<scopeValue> 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.

new LiveTable<Comment>({
  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.

/** @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);
}
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<T> 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.