Auth and Custom Handlers
elements man livetable/auth Read as markdownGating 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`),
});