# Chat Rooms A multi-room chat app with handle-only login. A user picks a handle on the home page, sees a list of rooms, optionally creates a new one, then clicks into `/rooms/:id` for a per-room message stream. Two LiveTables drive the recipe: a `rooms` LiveTable opened whole, which every browser watches, and a `messages` LiveTable opened per room as a window, `view({ roomId }, { orderBy, limit })`, so each room has its own broadcast channel and a browser holds the newest fifty messages, not the room's whole history. Handle-only login means the user types a handle and the server creates a `chatUsers` row. There is no password. Each browser session starts a new ephemeral user; two browsers can use the same handle without conflict because the user's identity is the row id, not the handle. ## Migration ```bash elements create migration 'add chat' -tables=chatUsers,chatRooms,chatMessages ``` `app/migrations/-add-chat.migration.sql`: ```sql -- add chat -- 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 chatUsers ( id uuid primary key default uuidGenerateV7(), createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), handle text not null ); create trigger chatUsersTouchUpdatedAt before update on chatUsers for each row execute function touchUpdatedAt(); create table chatRooms ( id uuid primary key default uuidGenerateV7(), createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), name text not null ); create trigger chatRoomsTouchUpdatedAt before update on chatRooms for each row execute function touchUpdatedAt(); create table chatMessages ( id uuid primary key default uuidGenerateV7(), createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), roomId uuid not null references chatRooms(id) on delete cascade, userId uuid not null references chatUsers(id) on delete cascade, userName text not null, body text not null ); create index chatMessagesRoomIdx on chatMessages (roomId, createdAt desc, id desc); create trigger chatMessagesTouchUpdatedAt before update on chatMessages for each row execute function touchUpdatedAt(); -- Broadcast writes that do not come from a live view, so a message inserted by -- a job, an rpc, or `elements db -c` still reaches every open page. Writes -- through the view broadcast themselves and need none of this; see -- `elements man recipes live-from-sql` for the whole story. create or replace function chatMessagesNotify() returns trigger language plpgsql as $$ declare r record; payload text; begin r := coalesce(new, old); payload := json_build_object( 'op', lower(tg_op), 'data', json_build_object( 'id', r.id, 'createdAt', json_build_object('$type', 'Date', '$value', (extract(epoch from r.createdAt) * 1000)::bigint), 'roomId', r.roomId, 'userId', r.userId, 'userName', r.userName, 'body', r.body ) )::text; -- NOTIFY takes under 8000 bytes. A larger row goes as its id, and the -- server reads the row back. if octet_length(payload) >= 8000 then payload := json_build_object('op', lower(tg_op), 'id', r.id)::text; end if; perform pg_notify(channel_name('chatMessages:roomId=' || r.roomId), payload); return r; end; $$; create trigger chatMessagesNotifyTrigger after insert or update or delete on chatMessages for each row execute function chatMessagesNotify(); ``` `chatUsers.handle` is not unique. The same display name can belong to many rows; the row id is the actual identity. `chatMessages.userName` is denormalized from `chatUsers.handle` at write time so renames in the future do not rewrite chat history. `chatMessagesRoomIdx` matches the room page's window, `where roomId = $1 order by createdAt desc, id desc`, so the first page and every older one read the index instead of sorting the room. ## LiveTables Both pages open these tables, so they live in `app/shared/services/` rather than in either page's `template.ehtml`. `app/shared/services/chat.ts`: ```ts import { LiveTable, session } from "@elements/app"; export interface Room { id: string; createdAt: Date; name: string; } export interface Message { id: string; createdAt: Date; roomId: string; userId: string; userName: string; body: string; } export let chatRooms = new LiveTable(); export let chatMessages: LiveTable = new LiveTable({ channel: (partition) => partition ? `chatMessages:${partition}` : "chatMessages", insert: (item) => { session.isLoggedInOrThrow(); return chatMessages.insert(item); }, }); ``` `channel` pins the name the trigger notifies. Without it the compiler derives a channel from where the declaration lives, which is not a name a migration can write. The partition key is `roomId=`, so one room's channel is `chatMessages:roomId=` and a browser in another room never sees the message. Each declaration is named for the table it reads: a LiveTable takes its table from the variable name, camelCase to snake_case, so `chatRooms` reads `chat_rooms`. Name the variable after the table and they cannot drift. `chatRooms` has no partition: every browser on the home page watches the same channel and sees a new room the moment someone creates it. `chatMessages` is opened per room below with `view({ roomId }, window)`; each room is its own broadcast channel, so a new message in one room only reaches subscribers of that room, and the window keeps each browser's copy to the pages it has loaded. The `insert` handler on `chatMessages` requires a logged-in session, which after the handle login means a `chatUsers` row exists, then runs the raw auto-SQL on the declaration. The declaration carries an explicit `LiveTable` annotation because its initializer refers to it. ## Home page ```bash elements create page home ``` The home page renders one of two views: an unsigned-in handle entry form, or a signed-in room list with a create-room form and a logout button. `session.isLoggedIn()` is reactive in the browser, so submitting the handle login flips the view without a full page reload. These three RPC functions belong to the home page alone, so they go in its `template.ehtml` with the markup that calls them. `signin` inserts a fresh `chatUsers` row and binds the session to it. `createRoom` returns the new room's id so the handler can navigate to `/rooms/:id` immediately. `signout` ends the session, and with it every live view that session opened, so follow it with a `redirect`. See the note below. `app/pages/home/index.ts`: ```ts import { Request, Response } from "@elements/app"; import home from "./template"; import { chatRooms } from "#app/shared/services/chat"; export default function route(req: Request, res: Response) { return new home({ rooms: chatRooms.view() }); } ``` `app/pages/home/template.ehtml`: ```ehtml import "./style.css"; import { LiveView, sql, session, redirect, ValidationError } from "@elements/app"; import { Room } from "#app/shared/services/chat"; /** @rpc */ export function signin(handle: string) { if (handle.trim().length === 0) { throw new ValidationError("handle is required"); } let user = sql<{ id: string }>( `insert into chatUsers (handle) values (${handle.trim()}) returning id`, ).firstOrThrow(); session.login({ userId: user.id, userName: handle.trim() }); } /** @rpc */ export function signout() { session.logout(); } /** @rpc */ export function createRoom(name: string): string { session.isLoggedInOrThrow(); if (name.trim().length === 0) { throw new ValidationError("room name is required"); } return sql<{ id: string }>( `insert into chatRooms (name) values (${name.trim()}) returning id`, ).firstOrThrow().id; } function onSigninSubmit(handle: { value: string }) { signin(handle.value); handle.value = ""; } function onCreateRoomSubmit(name: { value: string }) { let id = createRoom(name.value); name.value = ""; redirect(`/rooms/${id}`); } , private handle: { value: string } = { value: "" }, private newRoom: { value: string } = { value: "" }, )>

chat

onSigninSubmit(handle)}>

signed in as {session.get('userName')}.

rooms

  • +a.createdAt - +b.createdAt)}> {r.name}
  • no rooms yet. create one below.
onCreateRoomSubmit(newRoom)}>
``` The two branches share the same template instance. After `signin` returns, `session.isLoggedIn()` is true on the browser; the `e:if`/`e:else` switches and the rooms view renders. New rooms from any browser appear in the list immediately because `chatRooms` is a live, broadcasting LiveTable. ## Room page ```bash elements create page room ``` The room page renders the message feed and a composer. Inserting a message goes through the room's view, which broadcasts to every browser watching that room. `app/pages/room/index.ts`: ```ts import { Request, Response, sql, session } from "@elements/app"; import room from "./template"; import { Room, chatMessages } from "#app/shared/services/chat"; export default function route(req: Request, res: Response) { session.isLoggedInOrThrow(); let roomId = req.params.id; let row = sql( `select id, createdAt, name from chatRooms where id = ${roomId}`, ).firstOrThrow("room not found"); return new room({ room: row, messages: chatMessages.view({ roomId }, { orderBy: "createdAt desc", limit: 50 }), }); } ``` The route guards on a logged-in session and resolves the room before opening the partition. A 404 surfaces from `NotFoundError` if the room id in the URL does not exist. The second argument is the window: the newest fifty messages by `createdAt`, tie-broken on `id`. Opened without it, `view({ roomId })` loads every message the room has ever had into every browser that opens it. A new message sorts before the loaded range, so it is admitted live; an older page arrives only when the user asks. See `elements man livetable/windows`. `app/pages/room/template.ehtml`: ```ehtml import "./style.css"; import { LiveView, session } from "@elements/app"; import { Room, Message } from "#app/shared/services/chat"; function onSubmit(feed: LiveView, draft: { value: string }) { feed.insert( { userId: session.getOrThrow('userId'), userName: session.getOrThrow('userName'), body: draft.value, }, () => draft.value = "", ); } , private draft: { value: string } = { value: "" }, )>

{room.name}

back to rooms
  • {m.userName} {m.body}
onSubmit(messages, draft)}>
``` The view holds its rows in window order, newest first, so `toReversed()` puts the oldest loaded message at the top without a sort per render. `messages.more()` fetches the fifty before the oldest loaded row and adds them to the view, and `hasMore` turns false when a page comes back short, which removes the button. Day separators and author grouping go on the row as `e:if` flags, never as a loop inside the loop, and reactions are stored on the message row so they page with the window: `elements man recipes/grouped-feed` has both. `messages.insert(...)` passes every field the feed reads: `userId`, `userName`, and `body`. The partition fills the `roomId` column on insert. The `resetUI` callback clears the draft the moment the optimistic row lands. ## Routes Register the pages in `index.ts`: ```ts import home from "#app/pages/home"; import room from "#app/pages/room"; // ... app.route("/", home); app.route("/rooms/:id", room); ``` ## Notes - **Sign out is a navigation, by design.** A LiveTable view is stamped at creation with the session that opened it. `session.logout()` ends that session, so the server stops the view and the rows freeze at their last state. This is the authorization model working: the route checked the session once, and the check does not re-run when the user changes. Call `redirect("/")` after `signout()` and the new page opens a fresh view. A view opened by an anonymous request is public and keeps running, and signing in stops nothing, so a public room feed needs no redirect on login. Redirect after signin when the page's view is partitioned on `userId` and has to be re-created as the new user. Full rule: `elements man session`. - **Display a timestamp? Pass one at insert.** If the feed shows `{formatTime(m.createdAt)}`, pass `createdAt: new Date()` in the insert payload. `createdAt` is server-generated (`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 server's real `now()` reconciles the placeholder a moment later. - **Ephemeral users.** Every `signin` call inserts a new `chatUsers` row. The same browser session that signs in twice gets two different `userId` values. For a "log in to an existing handle and keep your history" UX, look up an existing row by handle first and only insert if missing, then `session.login` with the found or new id. - **Denormalized userName.** `chatMessages.userName` is copied from `chatUsers.handle` at write time. A user who changes their handle later sees the new handle on new messages but their old messages keep the old handle. This is what you want for a chat log. - **Room deletion.** The `on delete cascade` on `chatMessages.roomId` removes a room's history when the room is deleted. Add a "delete room" button that calls `rooms.delete(room)` from the home page; non-creator restrictions go in a `delete` handler on the `chatRooms` declaration. - **Auth model.** This recipe uses the lightest possible login (handle, no password). For a real chat app, build the auth flow from `elements man recipes authentication` and replace `signin` with the password-based signin. The rest of this recipe is unchanged. - **Typing indicators and presence.** Channel-driven UX layered over the same room (who's typing, who's in the room) is the next step. See `elements man recipes typing-indicator` and `elements man recipes presence`.