Manual LiveTable Auth and Custom Handlers

Auth and Custom Handlers

elements man livetable/auth Read as markdown

Gating a LiveTable by session, and taking over read or write with your own handlers.

Auth

The insert, update, delete config keys accept three shapes:

// 1. { auth }: auto-SQL handles the mutation.
insert: { auth: (item, s) => s.isLoggedIn() }

// 2. { auth, handler }: custom handler, gated by auth.
insert: {
  auth: (item, s) => s.isLoggedIn(),
  handler: (item) => sql(`...`).first()!,
}

// 3. { handler }: custom handler, no auth check.
insert: { handler: (item) => sql(`...`).first()! }

The item and s parameters are inferred from the LiveTable's T. You don't annotate them: (item, s) => ... is enough. The auth callback receives the item and the calling Session. Read its data with s.get(...), the same session API documented in session.

When omitted, the mutator is open and uses auto-SQL.

new LiveTable<Comment>({
  insert: { auth: (item, s) => s.isLoggedIn() },
  update: { auth: (item, s) => item.author === s.get("userName") },
  delete: { auth: (item, s) => item.author === s.get("userName") },
});

auth returning false (or throwing) raises AuthError to the caller before any handler runs.

For a worked toggle pattern (likes, votes, hearts) using insert.auth + delete.auth on a scoped join table, see the likes-toggle recipe.

Custom Handlers

Auto-SQL covers most cases. Reach for a custom handler when you need behavior auto-SQL can't express: deriving a column from session, multi-row inserts, joins, aggregations. A custom handler owns the round-trip end to end (id preservation, scope handling, return shape).

let comments = new LiveTable<Comment>({
  insert: {
    auth: (item, s) => s.isLoggedIn(),
    handler: (item) => {
      return sql<Comment>(`
        insert into comments (id, text, author, roomId, createdAt)
        values (${item.id}, ${item.text}, ${session.get("userName")!}, ${item.roomId}, now())
        returning *
      `).first()!;
    },
  },
});

item is inferred as Partial<Comment>; you don't annotate it. A handler with no auth check omits the auth key:

insert: {
  handler: (item) => sql<Comment>(`
    insert into comments (id, text, author, roomId, createdAt)
    values (${item.id}, ${item.text}, ${item.author}, ${item.roomId}, now())
    returning *
  `).first()!,
}

The same shapes apply to insert, update, and delete.

A custom insert handler must preserve item.id. When generateId: true (the default), the browser assigns a UUIDv7 before sending. If the handler drops or replaces the id, the broadcast returns a different id, dedup fails, and the row duplicates.

.first() returns T | undefined, but the insert handler signature expects T (or Promise<T>). For an INSERT ... RETURNING * that is guaranteed to return a row, assert with ! or check and throw.

Select

The select callback returns the rows for the LiveTable. Use it whenever you want explicit control over the query: joins, aggregations, filtering, ordering, or any other shape the auto-generated SELECT * FROM <table> doesn't cover. Call sql<T>(\...`)` inside the function so the query actually runs.

select: () => sql<Comment>(`
  select c.id, c.text, c.author, c.createdAt, c.roomId, u.avatarUrl
  from comments c
  join users u on u.id = c.userId
  where c.archived = false and c.roomId = ${roomId}
`)

A select on a scoped table must include the scope filter in the WHERE clause. Elements cross-checks scope values on every returned row.

Composite Tables

The table option names the database table the LiveTable's automatic handlers use. To back a LiveTable with a view, a join, or any custom SQL, override select and the mutation handlers as needed.

let activeComments = new LiveTable<Comment>({
  table: "comments",
  select: () => sql(`select id, text, author, createdAt from comments where archived = false`),
});