# Shared Lists A todo list whose id lives in the URL. Two browsers on the same `/lists/:id` see each other's edits in real time as items are added, toggled, or removed. The shared id is the only piece of "access control"; anyone with the URL can read and edit. Two LiveTables drive the recipe. `lists` holds the lists themselves and is used on the home page. `listItems` is opened per list with `view({ listId })`, so a mutation on one list only broadcasts to browsers watching that list. ## Migration ```bash elements create migration 'add lists' -tables=lists,listItems ``` `app/migrations/-add-lists.migration.sql`: ```sql -- add lists -- 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 lists ( id uuid primary key default uuidGenerateV7(), createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), title text not null ); create trigger listsTouchUpdatedAt before update on lists for each row execute function touchUpdatedAt(); create table listItems ( id uuid primary key default uuidGenerateV7(), createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), listId uuid not null references lists(id) on delete cascade, text text not null, done boolean not null default false ); create trigger listItemsTouchUpdatedAt before update on listItems for each row execute function touchUpdatedAt(); ``` `listItems.listId` is the partition column: `listItems.view({ listId })` keys the channel off this value. The `on delete cascade` foreign key cleans up items when a list is deleted. ## LiveTables The LiveTables live in `app/shared/services/lists.ts` so both pages can import them. `app/shared/services/lists.ts`: ```ts import { LiveTable } from "@elements/app"; export interface List { id: string; createdAt: Date; title: string; } export interface ListItem { id: string; createdAt: Date; listId: string; text: string; done: boolean; } export let lists = new LiveTable(); export let listItems = new LiveTable(); ``` No `insert`, `update`, or `delete` handlers. The recipe's access model is "anyone with the URL", so all mutators are the open auto-SQL. To gate by ownership, add handlers; see `elements man livetable`. ## Home page ```bash elements create page home ``` The home page renders every list with a link to its URL, and a form to create a new list. Creating a list returns its id; the handler navigates to `/lists/:id` so the new list opens immediately. `app/pages/home/services.ts`: ```ts import { sql } from "@elements/app"; /** @rpc */ export function createList(title: string): string { return sql<{ id: string }>( `insert into lists (title) values (${title}) returning id`, ).firstOrThrow().id; } ``` `app/pages/home/index.ts`: ```ts import { Request, Response } from "@elements/app"; import home from "./template"; import { lists } from "#app/shared/services/lists"; export default function route(req: Request, res: Response) { return new home({ lists: lists.view() }); } ``` `app/pages/home/template.ehtml`: ```ehtml import "./style.css"; import { LiveView, redirect } from "@elements/app"; import { List } from "#app/shared/services/lists"; import { createList } from "./services"; function onSubmit(title: string) { let id = createList(title); redirect(`/lists/${id}`); } , private title: string = "")>

your lists

onSubmit(title)}>
``` The home page receives a view of the whole `lists` table from the route. Inserts from any browser appear in the list immediately. The local `title` is reactive state for the input. ## List page ```bash elements create page list ``` The list page loads the list's title from the database (a one-time read, not live) and passes the list's partition of items to the template. `app/pages/list/index.ts`: ```ts import { Request, Response, sql } from "@elements/app"; import list from "./template"; import { List, listItems } from "#app/shared/services/lists"; export default function route(req: Request, res: Response) { let row = sql( `select id, createdAt, title from lists where id = ${req.params.id}`, ).firstOrThrow("list not found"); return new list({ list: row, items: listItems.view({ listId: row.id }) }); } ``` `firstOrThrow("list not found")` throws `NotFoundError` (a safe 404) with the given message if no row matches. The message reaches the browser as the body of the response. `app/pages/list/template.ehtml`: ```ehtml import "./style.css"; import { LiveView } from "@elements/app"; import { List, ListItem } from "#app/shared/services/lists"; function onSubmit(items: LiveView, draft: { value: string }) { items.insert( { text: draft.value, done: false }, () => draft.value = "", ); } function onToggle(items: LiveView, item: ListItem) { items.update({ ...item, done: !item.done }); } function onRemove(items: LiveView, item: ListItem) { items.delete(item); } , private draft: { value: string } = { value: "" }, )>

{list.title}

  • +a.createdAt - +b.createdAt)} class={item.done && "done"}> {item.text}
onSubmit(items, draft)}>
``` `items.insert(...)` adds the row optimistically; the partition fills `listId`, so the payload carries only `text` and `done`, and the `resetUI` callback clears the input the moment the optimistic row lands. `items.update({ ...item, done: !item.done })` and `items.delete(item)` go through the view's mutators so the change broadcasts. Every browser watching `listItems.view({ listId })` for this list patches the affected row in place. `draft` is wrapped in `{ value: ... }` because the handler runs through a function parameter; assigning `draft.value = ""` mutates the wrapper object whose reference the template still holds. A plain string parameter would not flow the assignment back. ## Routes Register the pages in `index.ts`: ```ts import home from "#app/pages/home"; import list from "#app/pages/list"; // ... app.route("/", home); app.route("/lists/:id", list); ``` ## Notes - **Open access by design.** With no handlers on the LiveTables and no guard in either route, the only thing keeping a list private is the secrecy of its uuidv7 id. uuidv7 has 122 bits of entropy, so the id is effectively unguessable, but anyone you share the URL with can read and edit. To require login, wrap each route with `session.isLoggedInOrThrow()` and add handlers to the LiveTables that call it too. - **Per-list ownership.** Add an `ownerId` column to `lists`, then in `update` and `delete` handlers on `listItems` call `session.isLoggedInOrThrow()`, look the owner up, and throw `ForbiddenError` when it is not `session.getOrThrow('userId')`, for "anyone can read, only the owner can edit". The partition still splits broadcasts by list, so each browser only receives notifications for the list it has open. - **Deleting a list.** The `on delete cascade` foreign key on `listItems.listId` removes a list's items automatically when the list is deleted. Use the `lists` view's `delete` mutator from a "delete list" button on the home page. - **Cross-tab sync.** A user with the same list open in two tabs sees both views update in lockstep. The LiveTable's broadcast covers cross-tab and cross-user identically. - **The `lists` LiveTable on `/lists/:id`.** This recipe only passes `listItems.view({ listId })` to the list page. To also show "your other lists" in a sidebar on the list page, pass `lists.view()` along too. Both views coexist without interference. - **Writes from outside the view.** An item inserted by a job, an `@rpc` using `sql`, or psql does not broadcast. `elements man recipes live-from-sql` adds the trigger that makes those writes live.