Live Comments
elements man recipes/live-comments Read as markdownA 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, on every app server, sees it the moment the server
broadcasts. One LiveTable and
one <form> 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
Terminalelements create migration 'add users and comments' -tables=users,comments
app/migrations/<timestamp>-add-users-and-comments.migration.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
Terminalelements create page chat
The Comment interface and the comments LiveTable are used only by the
/chat page, so they live in the page's template.ehtml. The route gates on a
logged-in session and hands the view to the template; the template iterates
it and inserts new rows from the composer.
app/pages/chat/template.ehtmlimport "./style.css"; import { LiveTable, LiveView, session } from "@elements/app"; export interface Comment { id: string; createdAt: Date; userId: string; userName: string; body: string; } export let comments: LiveTable<Comment> = new LiveTable<Comment>({ insert: (item) => { session.isLoggedInOrThrow(); return comments.insert({ ...item, userId: session.getOrThrow("userId"), userName: session.getOrThrow("userName"), }); }, }); export function onSubmit( view: LiveView<Comment>, body: string, onSent?: () => void, ): void { view.insert( { userId: session.getOrThrow("userId"), userName: session.getOrThrow("userName"), body, }, onSent, ); } <html class="page-chat" ( comments: LiveView<Comment>, private draft: string = "", )> <main class="page-shell"> <ul class="feed"> <li e:for={c of comments.sort((a, b) => +a.createdAt - +b.createdAt)}> <strong>{c.userName}</strong> <span class="body">{c.body}</span> </li> </ul> <form onsubmit={() => onSubmit(comments, draft, () => draft = "")}> <input value={draft} placeholder="say something" required> <button type="submit">send</button> </form> </main> </html>
onSubmit is exported even though the template is the only caller, because
an .ehtml file is a module: index.ts and a sibling test.ts both import from
./template by name, and the function works unchanged from a route, an @rpc,
or a job (elements man livetable/mutations).
It takes the view as a parameter because that is the only thing there is to
write through. comments above is the declaration: it configures the table and
has no rows and no mutators. The template passes the view the route opened, and
a test passes one of its own.
The feed orders itself in the e:for above, oldest first, so new messages land
at the bottom. Ordering is a view concern and never reaches the database.
The insert handler is the server's authoritative check. session is in
context, so an anonymous client that calls insert(...) directly raises
AuthError before any row is written, and the handler stamps userId and
userName from the session, so every comment carries its real author.
comments.insert(...) inside the handler is the raw auto-SQL on the
declaration; the LiveTable infers columns from the table. The declaration carries an explicit LiveTable<Comment>
annotation because its initializer refers to it.
app/pages/chat/index.tsimport { Request, Response, session } from "@elements/app"; import chat, { comments } from "./template"; export default function route(req: Request, res: Response) { session.isLoggedInOrThrow(); return new chat({ comments: comments.view() }); }
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.
onSubmit passes every field the feed renders, userName in particular,
because the optimistic row is painted from exactly that object. 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 <li>; the existing rows don't re-render.
Routes
Register the page in index.ts:
index.tsimport chat from "#app/pages/chat"; // ... app.route("/chat", chat);
Notes
- Pass every field the template reads. The optimistic row renders before the server replies, from the payload you pass. Match the payload to the template's reads and the row paints complete.
- Display a timestamp? Pass one at insert. If you add
{formatTime(c.createdAt)}to the feed, passcreatedAt: new Date()in the insert payload so the optimistic row renders its timestamp immediately. The server'snow()replaces it on reconcile. - The author comes from the session. The browser passes
userIdanduserNameso the optimistic row renders at once; the handler overwrites both fromsession, so the stored row always names the signed-in user. - Edits and deletes. Add an
updatehandler that callssession.isLoggedInOrThrow(), refuses another user's row withif (item.userId !== session.getOrThrow('userId')) { throw new ForbiddenError(); }, then returnscomments.update(item), and the matchingdelete. ImportForbiddenErrorfrom@elements/app. The template gains an "edit" button per row that callscomments.update({ ...c, body: edited }). - Per-room threads. Open the view on a partition from the route:
comments.view({ roomId: req.params.roomId }). A comment reaches only the browsers viewing its room, the partition fillsroomIdon insert, and every mutation is checked against the room. The full worked example iselements man recipes chat-rooms. - Writes from jobs and psql. Add a Postgres trigger that notifies the
table's channel,
channel_name('comments'), with{ op, data }, and a job, cron task, or psql session inserting intocommentsshows up live on every app server, alongside the app's own broadcasts: the browser dedupes by id. Seeelements man recipes live-from-sqlfor the trigger.