Manual Getting Started More Patterns

More Patterns

elements man start/patterns Read as markdown

Worked 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): unauthenticated
  • ForbiddenError (403): authenticated but not permitted
  • NotFoundError (404): resource missing
  • ValidationError (422): invalid input (string or FieldErrors<T> map)
  • NotAcceptableError (406): request body unparseable
  • ScopeMismatchError (400): LiveTable scope mismatch
  • ServerError (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 (sqlawait 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).