Public and Admin Views
elements man recipes/public-admin-views Read as markdownTwo routes over the same events data. The public route at /events lists
upcoming events without auth. The admin route at /admin/events shows the same
data plus a create form and per-row delete actions. Both pages open a view on
one shared LiveTable. Mutations are gated inside the LiveTable's insert,
update, and delete handlers: only admins succeed; the public page never
tries to mutate, but if it did the LiveTable would reject the call server-side.
The recipe demonstrates the auth model where everyone reads through the same live primitive and a small set of users mutates it. The admin gate lives on the LiveTable (defense in depth) and on the admin route (page-render gate). The public route is unauthenticated and unauthorized.
Migration
elements create migration 'add events' -tables=events
app/migrations/<timestamp>-add-events.migration.sql:
-- add events
-- 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 events (
id uuid primary key default uuidGenerateV7(),
createdAt timestamptz not null default now(),
updatedAt timestamptz not null default now(),
title text not null,
description text not null default '',
eventDate timestamptz not null
);
create index eventsEventDateIdx on events (eventDate asc);
create trigger eventsTouchUpdatedAt
before update on events
for each row execute function touchUpdatedAt();
The eventsEventDateIdx backs the where eventDate >= now() filter and the
order by eventDate asc clause. description defaults to the empty string so
the column stays not-null with no default text noise.
Services
Two pages consume the events LiveTable: the public /events route and the admin
/admin/events route. Because more than one page imports it, the file lives in
app/shared/services/ rather than colocated with either page. If only the admin
page needed it, the file would be app/pages/admin-events/services.ts instead.
app/shared/services/events.ts:
import { LiveTable, sql } from "@elements/app";
import { isUserAdminOrThrow } from "#app/shared/services/admin";
export interface Event {
id: string;
createdAt: Date;
updatedAt: Date;
title: string;
description: string;
eventDate: Date;
}
export let events: LiveTable<Event> = new LiveTable<Event>({
select: () => sql<Event>(
`select id, createdAt, updatedAt, title, description, eventDate from events
where eventDate >= now() - interval '1 hour'
order by eventDate asc`,
),
insert: (item) => {
isUserAdminOrThrow();
return events.insert(item);
},
update: (item) => {
isUserAdminOrThrow();
return events.update(item);
},
delete: (item) => {
isUserAdminOrThrow();
return events.delete(item);
},
});
The custom select limits the initial rows to events from the last hour and
into the future. The one-hour overlap gives "happening now" events a window
before they fall off the list. It ignores its (partition, window) arguments
because neither route partitions or windows the view. Both the public and the
admin route open a view on this same declaration; the difference is what each
template does with it.
Every handler calls isUserAdminOrThrow() first, the guard from
elements man recipes admin-roles. It throws when there is no session and when
the user is not an admin, so an anonymous browser or a non-admin calling
events.insert(...) from a devtools console fails before any SQL runs. The
handler then returns the raw auto-SQL on the declaration, events.insert(item)
and the rest. The declaration carries an explicit LiveTable<Event> annotation
because its initializer refers to it.
Public route
elements create page events
app/pages/events/index.ts:
import events from "./template";
import { events as eventsTable } from "#app/shared/services/events";
export default function route(req, res) {
return new events({ events: eventsTable.view() });
}
The public route is unauthenticated. Anyone can hit /events and see the
upcoming events.
app/pages/events/template.html:
import "./style.css";
import { LiveView } from "@elements/app";
import { Event } from "#app/shared/services/events";
<html class="events" (events: LiveView<Event>)>
<main>
<h1>upcoming events</h1>
<ul class="events">
<li e:for={e of events.sort((a, b) => +a.eventDate - +b.eventDate)}>
<h2>{e.title}</h2>
<time>{e.eventDate.toLocaleString()}</time>
<p e:if={e.description}>{e.description}</p>
</li>
<li e:if={events.length === 0}>no upcoming events.</li>
</ul>
</main>
</html>
The public template reads the view. There are no mutation buttons. The broadcast still patches the list when an admin adds, edits, or removes a row, so the public page updates live.
Admin route
elements create page admin-events
app/pages/admin-events/index.ts:
import { isUserAdminOrThrow } from "#app/shared/services/admin";
import adminEvents from "./template";
import { events as eventsTable } from "#app/shared/services/events";
export default function route(req, res) {
isUserAdminOrThrow();
return new adminEvents({ events: eventsTable.view() });
}
The admin route checks admin status before opening the view for the template. The page never renders for a non-admin; the LiveTable's mutator checks are the secondary gate.
app/pages/admin-events/template.html:
import "./style.css";
import { LiveView } from "@elements/app";
import { Event } from "#app/shared/services/events";
interface EventInput {
title: string;
description: string;
eventDate: string;
}
function emptyForm(): EventInput {
return { title: "", description: "", eventDate: "" };
}
function insert(events: LiveView<Event>, form: { value: EventInput }) {
if (form.value.title.trim().length === 0) {
return;
}
if (form.value.eventDate.length === 0) {
return;
}
events.insert(
{
title: form.value.title.trim(),
description: form.value.description.trim(),
eventDate: new Date(form.value.eventDate),
},
() => form.value = emptyForm(),
);
}
function onDelete(events: LiveView<Event>, event: Event) {
if (!confirm(`delete "${event.title}"?`)) {
return;
}
events.delete(event);
}
<html class="admin-events"
(events: LiveView<Event>,
private form: { value: EventInput } = { value: emptyForm() })>
<main>
<h1>events (admin)</h1>
<form class="new" onsubmit={() => insert(events, form)}>
<input type="text" value={form.value.title} placeholder="event title" required>
<input type="datetime-local" value={form.value.eventDate} required>
<textarea value={form.value.description} placeholder="description (optional)" rows="3"/>
<button type="submit">add event</button>
</form>
<ul class="events">
<li e:for={e of events.sort((a, b) => +a.eventDate - +b.eventDate)}>
<h2>{e.title}</h2>
<time>{e.eventDate.toLocaleString()}</time>
<p e:if={e.description}>{e.description}</p>
<button class="danger" onclick={() => onDelete(events, e)}>delete</button>
</li>
<li e:if={events.length === 0}>no upcoming events.</li>
</ul>
</main>
</html>
The admin template adds a creation form at the top and a delete button per row.
insert calls events.insert(...) with the form's three fields converted to
the row shape (the <input type="datetime-local"> value is a string;
new Date(...) turns it into the Date the column needs). onDelete uses the
browser's confirm() dialog before calling events.delete(event).
Both mutator calls run the LiveTable's handler before any SQL runs. A
non-admin who somehow reaches the admin template (a stale session, a bug in the
route guard) would still fail at the LiveTable layer, where
isUserAdminOrThrow() throws. The two layers carry the same check.
Routes
Register both routes in index.ts:
import events from "#app/pages/events";
import adminEvents from "#app/pages/admin-events";
// ...
app.route("/events", events);
app.route("/admin/events", adminEvents);
Notes
- One LiveTable, two templates. The shared LiveTable is the single source of truth. Both the public and the admin view subscribe to the same broadcast channel, so an admin insert or delete instantly appears on every public browser without any extra plumbing. The data flow is identical regardless of which page the viewer is on.
- Auth at two layers. The admin route's
isUserAdminOrThrowblocks page rendering. The same call inside each handler blocks the underlying operation. The route gate is the UX; the handler gate is the security boundary. - Editing existing events. Add an
updatebutton per row that callsevents.update({ ...event, title, description, eventDate })and either a modal form or a separate/admin/events/:idroute. Theupdatehandler is already gated on admin. - Past events. The custom
selectis the initial query, not a filter on what arrives: an admin who inserts an event dated last week broadcasts it and it appears in every open list. Filter in the template (events.filter((e) => +e.eventDate >= Date.now() - 3600000)) if that matters. To let admins manage archived events, add an@rpc listPastEvents()that returns rows fromsql, and render it in a separate "archived" section on the admin page. Past events don't need live sync. - Multiple admin actions. A "feature" toggle, a "highlight" flag, or any
other per-event mutation is just another column on the table plus an
updatecall. Theupdatehandler covers every field uniformly. - Public read without LiveTable. If live updates on the public page aren't
valuable (the events list barely changes), swap the view for an
@rpcthat returns rows fromsql, or a plainsqlcall in the public route handler. The admin page keeps the LiveTable for the live add/delete UX. The two pages no longer share infra, but the admin write path is the same.