Options
elements man livetable/options Read as markdownRealtime 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 (createdAt →
created_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: mutationauthcallback returned false.ValidationError: thrown directly inside a handler when input fails per-field validation. Carries an optionalFieldErrors<T>map.ScopeMismatchError: mutation's scope column didn't match the bound scope value. Fires before any handler runs, independent ofauth.
Related
database:sql(),tx(), and the camelCase-to-snake_case boundary.channel: the pub/sub layer underneath LiveTable.html/events:oninit,oninsert, andonremoveon the rows a LiveTable renders, and theLifecycleEventthey receive. A LiveTable has no lifecycle handlers of its own; the rows do.html/integration: rendering a LiveTable from a template.