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 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
elements 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
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:
import { LiveTable } from "@elements/app";
export interface Comment {
id: string;
createdAt: Date;
userId: string;
userName: string;
body: string;
}
export let comments = new LiveTable<Comment>({
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:
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:
import "./style.css";
import { LiveTable, session } from "@elements/app";
import { Comment } from "./services";
function insert(c: LiveTable<Comment>, body: string, onSent: () => void) {
c.insert(
{
userId: session.getOrThrow('userId'),
userName: session.getOrThrow('userName'),
body,
},
onSent,
);
}
<html class="chat" (comments: LiveTable<Comment>, private draft: string = "")>
<ul class="feed">
<li e:for={c of comments}>
<strong>{c.userName}</strong>
<span class="body">{c.body}</span>
</li>
</ul>
<form onsubmit={() => insert(comments, draft, () => draft = "")}>
<input value={draft} placeholder="say something" required>
<button type="submit">send</button>
</form>
</html>
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 <li>; the existing rows don't re-render.
Routes
Register the page in index.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.userNamebutinsertonly passesbody, the row paints withuserNameundefined 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, passcreatedAt: new Date()in the insert payload.createdAtis a server-generated column (default now()), so the optimistic row has it asundefineduntil the broadcast lands, andnew Date(undefined)rendersInvalid Datein the meantime. The client placeholder is corrected by the server's realnow()on reconcile. - Auto-fill of
userId. Auto-SQL fills declared columns from the insert payload.userIdhere is just passed through. If you want the server to inject it fromsessioninstead (so a client can't forge another user's id), use a custominsert.handlerthat takesPartial<Comment>and writes${session.getOrThrow('userId')}itself. Seeelements man livetable▸ Custom Handlers. - Edits and deletes. Add
update: { auth: (item, s) => item.userId === s.getOrThrow('userId') }and the matchingdeleteblock. The template gains an "edit" button per row that callscomments.update({ ...c, body: edited }). - Per-room threads. Set
scope: "roomId"on the LiveTable and passcomments.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 iselements man recipes chat-rooms. - Backfill from another writer. A job, cron task, or psql session inserting
directly into
commentswon't show up live by default. The LiveTable's realtime mode is'app', so only app-side mutations broadcast. Switch torealtime: 'db'plus a Postgres trigger to broadcast every write, regardless of origin. Seeelements man channelfor the trigger pattern.