Handlers
elements man livetable/handlers Read as markdownTaking over a read or a write with your own SQL, and where authorization goes.
Mutators
Each of insert, update, and delete is one function. It receives the row
and returns the row it wrote. session is in context, so authorization is the
first line of the handler, the same as in an @rpc.
Mutatorsimport { LiveTable, session, sql } from "@elements/app"; export let comments: LiveTable<Comment> = new LiveTable<Comment>({ insert: (item) => { session.isLoggedInOrThrow(); return comments.insert(item); }, update: (item) => { if (item.author !== session.get("userName")) { throw new ForbiddenError(); } return comments.update(item); }, delete: (item) => { session.isLoggedInOrThrow(); comments.delete(item); }, });
comments.insert(item) on the declaration is the raw auto-SQL: one insert ... returning against the table, no handler, no broadcast. It is what runs
when you declare no handler, and what a handler calls when it only wants to
gate the default. comments.update and comments.delete are the same for the
other two. Because the handler names the variable it is assigned to, the
declaration needs its type written out, let comments: LiveTable<Comment>.
When a handler is omitted, the mutator is open and uses auto-SQL. A handler that should never be reached throws:
Refused deletedelete: () => { throw new ForbiddenError(); },
Thrown errors reach the browser as the same class: AuthError,
ForbiddenError, and ValidationError are safe and carry their message, and
anything else is wrapped in ServerError. See rpc for catching them.
Custom SQL
Auto-SQL writes one row to one table. Write the SQL yourself when the write
derives a column from session, touches several rows, or targets a join.
Custom insertexport let comments: LiveTable<Comment> = new LiveTable<Comment>({ insert: (item) => { session.isLoggedInOrThrow(); return sql<Comment>(` insert into comments ( id, text, author, postId, createdAt ) values ( ${item.id}, ${item.text}, ${session.getOrThrow("userName")}, ${item.postId}, now() ) returning * `).firstOrThrow(); }, });
item is Partial<Comment> for insert and Comment for update and delete;
you do not annotate it.
Two things a custom insert must do. It must preserve item.id: the browser
minted it, the optimistic row carries it, and if the stored row comes back
with a different id the broadcast cannot find the row it belongs to and the
list shows two. And it must return the row: the return value is what the server
broadcasts, and it has to carry the partition columns and, on a windowed view,
the orderBy columns, so the browser can place it. returning * satisfies
both.
Select
select is the initial query. It receives the partition the view was opened
with and the window, and returns the rows.
Custom selectselect: ({ postId }) => sql<Comment>(` select c.id, c.text, c.author, c.createdAt, c.postId, u.avatarUrl from comments c join users u on u.id = c.userId where c.postId = ${postId} `)
Use it for joins, projections, and computed columns. Every row it returns is
checked against the partition, so a select that ignores the object it was
given is refused rather than served. A windowed view also hands the select its
order, limit and cursor, which the query must honor; see windows for the
three fragments that make that one line each.
A select may filter rows further than the partition, where archived = false
say, to keep the first render small. The stream follows the partition, so a
row that matches the partition reaches the browser whether or not the select
would have returned it. The template's filter() is the rule; the select's
WHERE is an optimization of the snapshot. See partitions.
Rows of any size broadcast. A row over the 8000-byte Postgres notification
limit is broadcast as its id. Each app server reads the stored row once,
matches it against its views, and reads it through select once per partition
it matched, with the window's keyset() narrowed to that id, then sends the
row to its browsers as usual. A select that includes ${w.keyset()} reads one
row; one that leaves it out reads the partition and the row is picked out.
Composite tables
table names the database table the automatic handlers use. To back a
LiveTable with a view, a join, or any custom SQL, override select and the
mutators as needed. The rows still need an id, and mutations still return the
row they wrote.