# Options The declaration's options, and what `view()` takes. ```ts Options new LiveTable({ table: "comments", // db table for auto-SQL; inferred from the variable name channel: "comments", // the table's channel; defaults to the table name fields: ["text", "author", { name: "createdAt", readonly: true }], select: (partition, w) => sql(`...`), // custom SELECT insert: (item) => { ... }, // custom insert, or omit for auto-SQL update: (item) => { ... }, delete: (item) => { ... }, }); table.view(); // the whole table table.view({ roomId }); // one partition table.view({ roomId }, { // one page of it orderBy: "createdAt desc", limit: 50, }); ``` For rows read once, with no subscription, call an `@rpc` that returns them from `sql`, and `e:for` iterates the array it gets back. A LiveTable is always live, because keeping its rows in sync is what a view is for. ## Table `table` names the database table auto-SQL reads and writes. The compiler fills it in from the variable name (camelCase to snake_case), so `let blogPosts = new LiveTable()` reads `blog_posts`. Set it when the two differ. Name the variable after the table and there is nothing to set. ## Fields ```ts Fields fields: ["text", "author", { name: "createdAt", readonly: true }] ``` If omitted, fields are introspected from the database. `id` is always implicit and readonly, and so are `createdAt` and `updatedAt`: the database fills them in, so the `createdAt: new Date()` an optimistic insert carries as a placeholder is never written. A `readonly` field is excluded from auto-insert and auto-update. Declare `fields` yourself to write one of them. camelCase in TypeScript maps to snake_case in the database (`createdAt` to `created_at`). ## Ids Every row has a string `id`, and the browser mints it, as a UUIDv7, before an optimistic insert is sent. That is what lets the broadcast echo find the row it belongs to. A table whose ids the database assigns (a serial column) takes its writes through an `@rpc`. ## Channel Name Every LiveTable has one channel, named after its table. Every write to the table notifies it, whether it comes from a view, a route, a job, another app server, or a Postgres trigger, and every app server listens on it and matches each row against the views open there (see `partitions`). Two declarations on the same table share the channel, so a write through either reaches the views of both. Set `channel` to give it another name, such as a channel another service already publishes on: ```ts Channel new LiveTable({ channel: "comments_shared", }); ``` A trigger notifies the channel with `{ op, data }` through `channel_name('comments')`, once per write, and each app server sends the row to the views it matches. See `recipes/live-from-sql` for the trigger. The browser store keys rows by `id`, and an update or delete is idempotent, so hearing about a write twice changes nothing. ## Sort The template orders the rows. The initial select returns them in database order: ```ehtml Sort
  • +a.createdAt - +b.createdAt)}> ``` `sort()` returns a plain array, sorted, and the loop is live because it iterated the view to build it. A windowed view is the exception: its `orderBy` is the order the rows are held in, because a page has to be a page of something. See `windows`. ## Listen Before Select `view()` joins its table's channel and waits for the LISTEN to commit before running the select. Whatever arrives in between is held, and once the select returns it is applied against the rows the snapshot holds: a row the snapshot has goes to the browser as an update, a new one as an insert, and a delete or a move out of the partition only for a row the snapshot has. No write is missed and no row arrives twice. You never call `listen()` yourself. For a one-shot query merged with a raw channel, see `channel`. ## Returning a view from an RPC A route handler that opens a view and passes it to its template is the common path. An `@rpc` can return a view too, for a table that depends on a runtime decision: a permission check, a parameter, a different table per call. ```ts Server /** @rpc */ export function watchRoom(roomId: string) { if (sql(` select 1 from roomMembers where roomId = ${roomId} and userId = ${session.getOrThrow("userId")} `).empty()) { throw new AuthError(); } return comments.view({ roomId }); } ``` ```ts Browser let comments = watchRoom(roomId); // browser: a LiveView, sync-style ``` ## Errors - `AuthError`, `ForbiddenError`: thrown by a handler that checked the session. - `ValidationError`: thrown by a handler on bad input, or by the framework when a custom select ignores the window it was given. - `PartitionMismatchError`: a row's partition columns did not match the view's partition. Fires before any handler runs. ## Related - `database`: `sql()`, `tx()`, and the camelCase-to-snake_case boundary. - `channel`: the pub/sub layer underneath LiveTable. - `html/events`: `oninit`, `oninsert`, and `onremove` on the rows a LiveTable renders. A LiveTable has no lifecycle handlers of its own; the rows do. - `html/integration`: rendering a LiveTable from a template.