At a Glance
elements man start/at-a-glance Read as markdownOne page of the whole framework: a route, a template, an rpc, a job, a LiveTable, a test.
A small real-time comment app. Most of Elements in one place: html
templates, a route, a migration, and a LiveTable.
index.ts
import { App } from "@elements/app";
import config from "#config";
import home from "#app/pages/home";
const app = new App();
app.route("/", home);
app.start(config);
app/pages/home/models.ts
export interface Comment {
id: string;
text: string;
selected?: boolean;
}
app/migrations/20260509120000-add-comments-table.migration.sql
Generated by
elements create migration "add comments table" -tables=comments. The
-tables flag scaffolds the canonical baseline (id, createdAt,
updatedAt, and the touchUpdatedAt trigger) for each named table. Pass
-tables=comments,users to scaffold several at once.
create or replace function touchUpdatedAt()
returns trigger
language plpgsql
as $$
begin
new.updatedAt = now();
return new;
end;
$$;
create table comments (
id uuid primary key default uuidGenerateV7(),
text text not null,
createdAt timestamptz not null default now(),
updatedAt timestamptz not null default now()
);
create trigger commentsTouchUpdatedAt
before update on comments
for each row execute function touchUpdatedAt();
id is UUIDv7: time-sortable, so newer rows sort after older ones without a
separate timestamp index. Columns are camelCase in the migration. Elements
converts to snake_case at the database boundary and back to camelCase on
results.
Save the file and the project server applies the migration to the
development database. Edit the file (add a column, an index, a
constraint) and the project server rolls back and re-applies. Once a
migration has run on staging or production it's frozen and never runs
again. Agents don't run elements db migrate or touch the database
directly.
app/pages/home/index.ts
import { Request, Response, LiveTable } from "@elements/app";
import { Comment } from "./models";
import html from "./template";
// LiveTable for the `comments` table, inferred from the variable name.
// Wires up select/insert/update/delete rpc and optimistic browser-side
// mutations. Realtime is on by default, so every watching browser sees
// new rows live over Postgres NOTIFY/LISTEN. Pass `realtime: false` for
// a snapshot read.
const comments = new LiveTable<Comment>();
export default function route(req: Request, res: Response) {
return new html({
comments,
mode: req.params.mode ?? "light",
});
}
app/pages/home/template.html
import { Layout } from "#app/shared/templates/layout";
import { Comment } from "./models";
import { LiveTable } from "@elements/app";
import "./style.css";
// event handlers
function onAddComment(form: { text: string }, comments: LiveTable<Comment>) {
comments.insert({ text: form.text }, () => form.text = "");
}
// templates
<html (comments: LiveTable<Comment>, mode: string = "dark") class="home">
<Layout mode={mode}>
<CommentList>
<CommentItem e:for={comment of comments} {comment} />
</CommentList>
<CommentAdd {comments} />
</Layout>
</html>
<CommentList>
<ul class="comment-list">
<slot/>
</ul>
</CommentList>
<CommentItem (comment: Comment)>
<li tabindex={0}
class={["comment-item", comment.selected && "comment-selected"]}
onfocus={() => comment.selected = true}
onblur={() => comment.selected = false}>
{comment.text}
</li>
</CommentItem>
<CommentAdd (comments: LiveTable<Comment>, private form: { text: string } = { text: "" })>
<form class="comment-add" onsubmit={() => onAddComment(form, comments)}>
<input value={form.text} placeholder="Write a comment..." />
<button type="submit">Post</button>
</form>
</CommentAdd>
This one example covers: App and app.route(), a migration with
id/camelCase/trigger, a page route handler returning new html({...}),
a LiveTable with select/insert/update/delete and realtime, template
attributes with private and defaults, sub-templates and <slot/>,
e:for (keyed by id), event handlers, two-way form binding with
value=, reactive class arrays, and lifecycle-style reactivity via
mutating comment.selected.