# Live Comments A single-stream chat feed with a composer pinned to the bottom: type a message, hit enter, the message appears immediately in your feed, and every other browser watching the feed sees it the moment the server broadcasts. One `LiveTable` and one `
` drive it. No rpc functions to write, no polling, no manual subscribe/unsubscribe. `LiveTable.insert(...)` adds the row optimistically. The local feed paints the new message before the server has acknowledged. The `resetUI` callback clears the draft as soon as the optimistic row lands, so the input is ready for the next message without waiting on the round-trip. ## Migration ```bash elements create migration 'add users and comments' -tables=users,comments ``` `app/migrations/-add-users-and-comments.migration.sql`: ```sql -- add users and comments -- Auto-update updatedAt on row changes. create or replace function touchUpdatedAt() returns trigger language plpgsql as $$ begin new.updatedAt = now(); return new; end; $$; create table users ( id uuid primary key default uuidGenerateV7(), createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), handle text not null unique, passwordHash text not null ); create trigger usersTouchUpdatedAt before update on users for each row execute function touchUpdatedAt(); create table comments ( id uuid primary key default uuidGenerateV7(), createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), userId uuid not null references users(id) on delete cascade, userName text not null, body text not null ); create trigger commentsTouchUpdatedAt before update on comments for each row execute function touchUpdatedAt(); ``` `comments.userName` is denormalized: the user's display name is copied at write time. A future rename doesn't rewrite history, which is what you want for a chat log. If the handle ever needs to be the source of truth, drop the column and join `users` in a custom `select`. For the signin/signup pages, see `elements man recipes authentication`. ## Page setup ```bash elements create page chat ``` The `Comment` interface and the `comments` LiveTable are used only by the `/chat` page, so they live in the page's own `services.ts`. The route gates on a logged-in session and hands the LiveTable to the template; the template iterates it and inserts new rows from the composer. `app/pages/chat/services.ts`: ```ts import { LiveTable } from "@elements/app"; export interface Comment { id: string; createdAt: Date; userId: string; userName: string; body: string; } export let comments = new LiveTable({ sort: "createdAt asc", insert: { auth: (item, s) => s.isLoggedIn() }, }); ``` `sort: "createdAt asc"` runs in the server's SELECT and is reapplied on the browser when a broadcast arrives, so newest messages keep landing at the bottom of the list. `insert.auth` is the server's authoritative check: an anonymous client that calls `comments.insert(...)` directly raises `AuthError` before any row is written. Auto-SQL handles the actual INSERT; the LiveTable infers columns from the table. `app/pages/chat/index.ts`: ```ts import { session } from "@elements/app"; import chat from "./template"; import { comments } from "./services"; export default function route(req, res) { session.isLoggedInOrThrow(); return new chat({ comments }); } ``` The route gates the page on a logged-in session and hands the LiveTable to the template. Elements server-renders the initial messages into the HTML, so the first paint already has data, and the browser receives a live, subscribed handle to the same table. `app/pages/chat/template.html`: ```html import "./style.css"; import { LiveTable, session } from "@elements/app"; import { Comment } from "./services"; function insert(c: LiveTable, body: string, onSent: () => void) { c.insert( { userId: session.getOrThrow('userId'), userName: session.getOrThrow('userName'), body, }, onSent, ); } , private draft: string = "")>
  • {c.userName} {c.body}
insert(comments, draft, () => draft = "")}> ``` `insert()` calls `comments.insert(...)` with every field the feed renders, `userName` in particular, because the template reads it. If you omit a displayed field, the optimistic row renders that cell as `undefined` for the few milliseconds before the server's reply lands. `resetUI` clears the draft the moment the optimistic row lands locally, so the input empties immediately rather than waiting on the server's WebSocket round-trip. The `e:for` is patched per row. Inserting one message into a thousand-message feed touches only the new `
  • `; the existing rows don't re-render. ## Routes Register the page in `index.ts`: ```ts import chat from "#app/pages/chat"; // ... app.route("/chat", chat); ``` ## Notes - **Pass every field the template reads.** The optimistic row renders before the server replies. If the template displays `c.userName` but `insert` only passes `body`, the row paints with `userName` undefined for a frame or two. Match the insert payload to the template's reads. - **Display a timestamp? Pass one at insert.** If you add `{formatTime(c.createdAt)}` to the feed, pass `createdAt: new Date()` in the insert payload. `createdAt` is a server-generated column (`default now()`), so the optimistic row has it as `undefined` until the broadcast lands, and `new Date(undefined)` renders `Invalid Date` in the meantime. The client placeholder is corrected by the server's real `now()` on reconcile. - **Auto-fill of `userId`.** Auto-SQL fills declared columns from the insert payload. `userId` here is just passed through. If you want the server to inject it from `session` instead (so a client can't forge another user's id), use a custom `insert.handler` that takes `Partial` and writes `${session.getOrThrow('userId')}` itself. See `elements man livetable` ▸ Custom Handlers. - **Edits and deletes.** Add `update: { auth: (item, s) => item.userId === s.getOrThrow('userId') }` and the matching `delete` block. The template gains an "edit" button per row that calls `comments.update({ ...c, body: edited })`. - **Per-room threads.** Set `scope: "roomId"` on the LiveTable and pass `comments.scope(req.params.roomId)` from the route. Every room gets its own broadcast channel; mutations only fan out to subscribers of that room. The full worked example is `elements man recipes chat-rooms`. - **Backfill from another writer.** A job, cron task, or psql session inserting directly into `comments` won't show up live by default. The LiveTable's realtime mode is `'app'`, so only app-side mutations broadcast. Switch to `realtime: 'db'` plus a Postgres trigger to broadcast every write, regardless of origin. See `elements man channel` for the trigger pattern.