Manual LiveTable Options

Options

elements man livetable/options Read as markdown

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

new LiveTable<T>({
  table: "comments",                 // db table for auto-SQL; inferred from the variable name
  channel: (p) => `comments:${p}`,   // override the pub/sub channel 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 }, { orderBy: "createdAt desc", limit: 50 }); // one page of it

A LiveTable is always live. There is no snapshot mode: a page that wants rows once, with no subscription, calls an @rpc that returns them from sql, and e:for iterates the array it gets back.

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.

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 are assigned by the database (a serial column) is written through an @rpc, not a LiveTable mutator.

Channel Name

Every LiveTable gets a channel name automatically: <table> for the whole table and <table>:<partition> for a partition, where the partition key is the view's column equalities encoded as roomId=42. File-path disambiguation keeps two declarations on the same database table from cross-broadcasting.

Set channel when something outside the LiveTable publishes on the channel: a Postgres trigger, another service, or a second LiveTable that should share the broadcast. The function receives the encoded partition key, or "" for the whole table.

new LiveTable<Comment>({
  channel: (partition) => partition ? `comments_shared:${partition}` : "comments_shared",
});

A trigger that notifies the channel with { op, data } works alongside the app's own broadcasts. The browser store dedupes an insert by id, and an update or delete is idempotent, so hearing about a write twice changes nothing. See channel for the trigger pattern.

Sort

There is no sort option. The initial select returns rows in database order and the template orders them:

<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() opens the Postgres LISTEN and awaits its commit before running the select. A NOTIFY fired between the two is queued on the listener and flushed when the browser attaches, and the browser dedupes it against the snapshot by id. 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.

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