Manual LiveTable Handlers

Handlers

elements man livetable/handlers Read as markdown

Taking 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.

import { 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:

delete: () => { 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 covers most cases. Write the SQL yourself when the write derives a column from session, touches several rows, or targets a join.

export 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.

select: ({ 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. Know what that buys and does not: the stream is defined by the channel, so a row broadcast on the partition still 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.

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.