Options
elements man livetable/options Read as markdownThe declaration's options, and what view() takes.
Optionsnew 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
Fieldsfields: ["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:
Channelnew 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 }); }
Browserlet 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, andonremoveon 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.