Manual LiveTable Options

Options

elements man livetable/options Read as markdown

The declaration's options, and what view() takes.

Options
new LiveTable<T>({ table: "comments", // db table for auto-SQL; inferred from the variable name channel: "comments", // the table's channel; defaults to the table name fields: ["text", "author", { name: "createdAt", readonly: true }], select: (partition, w) => sql(`...`), // custom SELECT insert: (item) => { ... }, // custom insert, or omit for auto-SQL update: (item) => { ... }, delete: (item) => { ... }, }); table.view(); // the whole table table.view({ roomId }); // one partition table.view({ roomId }, { // one page of it orderBy: "createdAt desc", limit: 50, });

For rows read once, with no subscription, call an @rpc that returns them from sql, and e:for iterates the array it gets back. A LiveTable is always live, because keeping its rows in sync is what a view is for.

Table

table names the database table auto-SQL reads and writes. The compiler fills it in from the variable name (camelCase to snake_case), so let blogPosts = new LiveTable<Post>() reads blog_posts. Set it when the two differ.

Name the variable after the table and there is nothing to set.

Fields

Fields
fields: ["text", "author", { name: "createdAt", readonly: true }]

If omitted, fields are introspected from the database. id is always implicit and readonly, and so are createdAt and updatedAt: the database fills them in, so the createdAt: new Date() an optimistic insert carries as a placeholder is never written. A readonly field is excluded from auto-insert and auto-update. Declare fields yourself to write one of them.

camelCase in TypeScript maps to snake_case in the database (createdAt to created_at).

Ids

Every row has a string id, and the browser mints it, as a UUIDv7, before an optimistic insert is sent. That is what lets the broadcast echo find the row it belongs to. A table whose ids the database assigns (a serial column) takes its writes through an @rpc.

Channel Name

Every LiveTable has one channel, named after its table. Every write to the table notifies it, whether it comes from a view, a route, a job, another app server, or a Postgres trigger, and every app server listens on it and matches each row against the views open there (see partitions). Two declarations on the same table share the channel, so a write through either reaches the views of both.

Set channel to give it another name, such as a channel another service already publishes on:

Channel
new LiveTable<Comment>({ channel: "comments_shared", });

A trigger notifies the channel with { op, data } through channel_name('comments'), once per write, and each app server sends the row to the views it matches. See recipes/live-from-sql for the trigger. The browser store keys rows by id, and an update or delete is idempotent, so hearing about a write twice changes nothing.

Sort

The template orders the rows. The initial select returns them in database order:

Sort
<li e:for={c of comments.sort((a, b) => +a.createdAt - +b.createdAt)}>

sort() returns a plain array, sorted, and the loop is live because it iterated the view to build it. A windowed view is the exception: its orderBy is the order the rows are held in, because a page has to be a page of something. See windows.

Listen Before Select

view() joins its table's channel and waits for the LISTEN to commit before running the select. Whatever arrives in between is held, and once the select returns it is applied against the rows the snapshot holds: a row the snapshot has goes to the browser as an update, a new one as an insert, and a delete or a move out of the partition only for a row the snapshot has. No write is missed and no row arrives twice. You never call listen() yourself. For a one-shot query merged with a raw channel, see channel.

Returning a view from an RPC

A route handler that opens a view and passes it to its template is the common path. An @rpc can return a view too, for a table that depends on a runtime decision: a permission check, a parameter, a different table per call.

Server
/** @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.view({ roomId }); }
Browser
let comments = watchRoom(roomId); // browser: a LiveView, sync-style

Errors

  • AuthError, ForbiddenError: thrown by a handler that checked the session.
  • ValidationError: thrown by a handler on bad input, or by the framework when a custom select ignores the window it was given.
  • PartitionMismatchError: a row's partition columns did not match the view's partition. Fires before any handler runs.

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. A LiveTable has no lifecycle handlers of its own; the rows do.
  • html/integration: rendering a LiveTable from a template.