Manual Recipes Live Comments

Live Comments

elements man recipes/live-comments Read as markdown

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 <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 view to the template; the template iterates it and inserts new rows from the composer.

app/pages/chat/services.ts:

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);
  },
});

export function postComment(
  view: LiveView<Comment>,
  body: string,
  onSent?: () => void,
): void {
  view.insert(
    {
      userId: session.getOrThrow("userId"),
      userName: session.getOrThrow("userName"),
      body,
    },
    onSent,
  );
}

The write lives here rather than in the template because a function defined inside template.html is reachable only from that template. In services.ts a test can import it, and postComment 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 below, 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. comments.insert(item) 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.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: 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.

app/pages/chat/template.html:

import "./style.css";
import { LiveView } from "@elements/app";
import { Comment, postComment } from "./services";

<html class="chat" (comments: LiveView<Comment>, private draft: string = "")>
  <main>
    <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={() => postComment(comments, draft, () => draft = "")}>
      <input value={draft} placeholder="say something" required>
      <button type="submit">send</button>
    </form>
  </main>
</html>

postComment passes 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.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 stamp it from session instead (so a client can't forge another user's id), write it in the handler: return comments.insert({ ...item, userId: session.getOrThrow('userId') }).
  • Edits and deletes. Add an update handler that calls session.isLoggedInOrThrow(), refuses another user's row with if (item.userId !== session.getOrThrow('userId')) { throw new ForbiddenError(); }, then returns comments.update(item), and the matching delete. Import ForbiddenError from @elements/app. The template gains an "edit" button per row that calls comments.update({ ...c, body: edited }).
  • Per-room threads. Open the view on a partition from the route: comments.view({ roomId: req.params.roomId }). Every room gets its own broadcast channel, the partition fills roomId on insert, and 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 does not broadcast on its own. Add a Postgres trigger that notifies the table's channel with { op, data } and every write shows up live, whatever wrote it, alongside the app's own broadcasts: the browser dedupes by id. See elements man channel for the trigger.