# Auth and Custom Handlers 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: ```ts // 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. ```ts new LiveTable({ 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). ```ts let comments = new LiveTable({ insert: { auth: (item, s) => s.isLoggedIn(), handler: (item) => { return sql(` 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`; you don't annotate it. A handler with no auth check omits the `auth` key: ```ts insert: { handler: (item) => sql(` 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`). 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 ` doesn't cover. Call `sql(\`...\`)` inside the function so the query actually runs. ```ts select: () => sql(` 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. ```ts let activeComments = new LiveTable({ table: "comments", select: () => sql(`select id, text, author, createdAt from comments where archived = false`), }); ```