More Patterns
elements man start/patterns Read as markdownWorked fragments for the things most apps need next: sessions, forms, validation, email, and scheduled work.
Things the example above doesn't show. Each subsection points to its topic for the full story.
RPC
Use /** @rpc */ to make a function callable from the browser. The compiler
rewrites browser call sites into network requests and removes the body from the
browser bundle. Call them like normal functions: no await, no async, no
fetch. Arguments and return types are checked end-to-end.
// app/pages/home/services.ts
import { sql, ValidationError } from "@elements/app";
import { Comment } from "./models";
/** @rpc */
export function searchComments(query: string): Comment[] {
if (query.length < 2) throw new ValidationError("query too short");
return sql<Comment>(`
select * from comments
where text ilike ${"%" + query + "%"}
order by createdAt desc limit 20
`).all();
}
@rpc is the boundary between browser-reachable and server-only code. Once
execution crosses into an rpc, anywhere downstream (the rpc body, helpers it
calls, helpers those call) can call sql(), tx(), session.login() freely.
Browser-reachable code that calls them is a compile error pointing at the call
site.
Rpc go in services.ts next to a page, or in app/shared/services/<topic>.ts
for cross-page use. Never in a page's index.ts. Full topic:
elements man rpc.
SQL and Transactions
import { sql, tx } from "@elements/app";
let user = sql<User>(`select * from users where id = ${id}`).first();
let users = sql<User>(`select * from users where active = ${true}`);
for (let u of users) { /* ... */ }
tx(() => {
let u = sql<User>(`insert into users (name) values (${name}) returning *`).firstOrThrow("insert returned no row");
sql(`insert into auditLog (event, userId) values ('user_created', ${u.id})`);
});
${value} interpolations auto-extract as parameterized arguments at
build time; SQL injection is impossible by construction. sql<T>(...)
returns a SqlResult<T>: .all() for the array, .first() for
T | undefined, .firstOrThrow(msg) for a required row. tx(fn)
commits on return, rolls back on throw. Every sql() inside joins the
same connection. Full topic: elements man database.
Auth, Errors, Validation
import { session, AuthError, NotFoundError, ForbiddenError, ValidationError } from "@elements/app";
session.isLoggedInOrThrow(); // throws AuthError if not logged in
if (!isAdmin(session.getOrThrow("userId"))) throw new ForbiddenError();
throw new NotFoundError(`room ${id} not found`); // 404
throw new ForbiddenError(); // 403
throw new ValidationError("qty must be positive"); // 422, single rule
throw new ValidationError({ email: ["must be a valid email"] }); // 422, per-field
AppError subclasses are safe = true: their messages reach the browser.
Internal errors are wrapped in ServerError before being sent.
AuthError(401): unauthenticatedForbiddenError(403): authenticated but not permittedNotFoundError(404): resource missingValidationError(422): invalid input (string orFieldErrors<T>map)NotAcceptableError(406): request body unparseableScopeMismatchError(400): LiveTable scope mismatchServerError(500): safe wrapper for internal errors
Errors thrown from an @rpc surface in the browser as the same class. Catch
with instanceof in a try/catch around the call site. validate() does not
exist. Throw ValidationError directly. Full topic: elements man rpc.
Session
The current user's session. Available everywhere on the server and in the
browser. Read methods are reactive in the browser; a template that reads
session.isLoggedIn() updates when the user logs in or out.
import { session } from "@elements/app";
session.login({ userId, userName }); // server-only
session.logout(); // server-only
session.isLoggedIn(); // boolean. reactive in browser.
session.get("userId"); // SessionData["userId"] | undefined. reactive.
session.getOrThrow("userId"); // same, but throws if absent.
<nav>
<a e:if={session.isLoggedIn()} href="/account">{session.get("userName") ?? "account"}</a>
<a e:else href="/login">log in</a>
</nav>
Session values are typed through the SessionData interface. Declare the keys
your app stores in app/types/session.d.ts:
declare module "@elements/app" {
interface SessionData {
userId: string;
userName: string;
role: "admin" | "user";
}
}
export {};
session.get(key) returns SessionData[key] | undefined; getOrThrow(key)
throws when the key is absent. There is no getUserId() / getUserName(); read
session.get("userId"). A logged-out visitor has no session at all (no row, no
token, no cookie), so identity is login state, not an anonymous id. Full topic:
elements man session.
Sync-style and Async
Elements primitives (sql, tx, @rpc, Channel, LiveTable,
session) are written sync-style. The compiler rewrites each call to
its async variant (sql → await sqlAsync) and propagates
async/await up the call stack automatically, including across
function-expression arguments to schedulers (setTimeout,
addEventListener).
For third-party libraries that don't participate (fetch, Stripe SDK,
navigator.clipboard), declare your function async and await those calls
directly. The build leaves your manual async/await alone. For explicit
promise control (e.g. Promise.all), import the xAsync variant (sqlAsync,
txAsync, etc.).
Channels
Typed pub/sub over Postgres NOTIFY/LISTEN. LiveTable uses Channel under
the hood; use Channel directly when you want raw pub/sub without table
semantics (push notifications, presence, side-channel events).
import { Channel, session } from "@elements/app";
interface Alert { userId: string; level: "info" | "warning"; text: string; }
const alerts = new Channel<Alert>("alerts");
export default function route(req: Request, res: Response) {
return new html({
alerts: alerts.listen({ filter: (a) => a.userId === session.getOrThrow("userId") }),
});
}
/** @rpc */
export function postAlert(text: string) {
alerts.notify({ userId: session.getOrThrow("userId"), level: "info", text });
}
listen() returns a subscribed listener. Pass it to a template or return it
from an @rpc; the browser receives a live, subscribed handle. Full topic:
elements man channel.
Routes
Url patterns are declared explicitly in index.ts, not file-based. Folder names
under app/pages/ are labels.
import { App } from "@elements/app";
import config from "#config";
import home from "#app/pages/home";
import roomsShow from "#app/pages/rooms-show";
import apiRouter from "#app/api";
const app = new App();
app.route("/", home);
app.route("/rooms/:id", roomsShow);
app.route("/api", apiRouter); // mount a sub-router
app.route("/api/rooms", () => sql(`select id, name from rooms`)); // data route
app.start(config);
URL patterns use the URLPattern standard. Dynamic segments: :name. A page
route returns new html({...}). A data route returns serializable data
(Elements json), or writes to res directly with res.write / res.end.
AppError subclasses work the same in routes and @rpc. A LiveTable or
listener returned from a route or rpc arrives in the browser as a live,
subscribed handle.
Fetch initial page data inside the route, not from the browser via @rpc after
the page loads. Full topic: elements man router.
Jobs and Cron
// app/jobs/send-welcome.ts
import { Job, email } from "@elements/app";
import { WelcomeEmail } from "#app/emails/welcome";
/** @job */
export class SendWelcomeJob extends Job<{ to: string }> {
static maxAttempts = 5;
run() {
email({ to: this.fields.to, subject: "welcome", body: new WelcomeEmail() });
}
}
// index.ts
app.cron("every 5m", "rank posts", () => new RankPostsJob().schedule());
app.cron("every day at 2am", "archive", () => new ArchiveOldRowsJob().schedule());
.schedule() inside tx() joins the surrounding transaction. The job becomes
visible only after commit. The project server runs the worker; jobs are stored
in Postgres. One machine runs each cron tick (Postgres-arbitrated leader
election). Keep cron callbacks small; enqueue and let the worker do the work.
Use /** @job */ and suffix the class name with Job. Full topic:
elements man jobs.
Tests
Tests run as part of every build; failures show up alongside compile errors.
Each test() body runs inside a Postgres transaction that rolls back at the
end, so writes never pollute the test database.
// app/lib/users.test.ts
import { test, assert, equal, sql } from "@elements/app";
test("users", () => {
test("create", () => {
let user = sql<User>(`insert into users (name) values ('alice') returning *`).firstOrThrow("insert returned no row");
equal(user.name, "alice");
});
test("does not see the previous test's row", () => {
let count = sql<{ n: number }>(`select count(*) as n from users`).firstOrThrow();
equal(count.n, 0);
});
});
Files: test.ts or <name>.test.ts, anywhere in the tree. Run
elements test -json to query state. test() calls nest for grouping.
Assertions: assert, equal, errorf, fatalf. Full topic:
elements man tests.
Config
config.jsoc is evaluated at build time. JSOC is JSON with comments, unquoted
keys, trailing commas, and an env(...) function. Missing required env vars or
wrong types fail the build, not the deploy.
{
database: {
name: "my_app",
host: env("DB_HOST", "127.0.0.1"), // default when unset
port: env("DB_PORT", 5433), // default when unset
},
session: {
expires: "30d", // sliding expiry
},
}
Env vars come from the OS at startup. After startup, edit
config/env/<env>.env for hot reload. Full topic: elements man config.
Css Style
Every page imports app/shared/styles/page.css, which imports
@elements/style, a built-in, CSS-only design system: design tokens for
colors, spacing, type, and radii, a reset, finished styles for bare HTML
elements, and classes for buttons, forms, cards, callouts, pills, and
tabs.
Before writing CSS for anything @elements/style already covers, read
elements man style and use what it gives you. Bare elements are already
styled. A <button> is a finished secondary button; <input>, <form>,
headings, links, tables, and code all look right with no classes. Vary a button
with is- modifiers rather than a new class: is-primary, is-ghost,
is-danger for intent, is-sm/is-lg for size, is-block for full width.
Each owns one axis, and they combine in any order. Write custom CSS only for
layout the system doesn't provide (page composition, positioning, spacing
between sections). Maintain the Elements style language in new apps unless the
user directs otherwise. Full topic: elements man @elements/style (or
elements man style).