Manual Recipes Shared Lists

Shared Lists

elements man recipes/shared-lists Read as markdown

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

elements create migration 'add lists' -tables=lists,listItems

app/migrations/<timestamp>-add-lists.migration.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:

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<List>();

export let listItems = new LiveTable<ListItem>();

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

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:

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:

import home from "./template";
import { lists } from "#app/shared/services/lists";

export default function route(req, res) {
  return new home({ lists: lists.view() });
}

app/pages/home/template.html:

import "./style.css";
import { LiveView, redirect } from "@elements/app";
import { List } from "#app/shared/services/lists";
import { createList } from "./services";

function createAndOpen(title: string) {
  let id = createList(title);
  redirect(`/lists/${id}`);
}

<html class="home" (lists: LiveView<List>, private title: string = "")>
  <main>
    <h1>your lists</h1>

    <ul class="lists">
      <li e:for={l of lists.sort((a, b) => +b.createdAt - +a.createdAt)}>
        <a href={`/lists/${l.id}`}>{l.title}</a>
      </li>
    </ul>

    <form onsubmit={() => createAndOpen(title)}>
      <input type="text" value={title} placeholder="new list title" required>
      <button type="submit">create</button>
    </form>
  </main>
</html>

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

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:

import { sql } from "@elements/app";
import list from "./template";
import { List, listItems } from "#app/shared/services/lists";

export default function route(req, res) {
  let row = sql<List>(
    `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.html:

import "./style.css";
import { LiveView } from "@elements/app";
import { List, ListItem } from "#app/shared/services/lists";

function insert(items: LiveView<ListItem>, draft: { value: string }) {
  items.insert(
    { text: draft.value, done: false },
    () => draft.value = "",
  );
}

function toggle(items: LiveView<ListItem>, item: ListItem) {
  items.update({ ...item, done: !item.done });
}

function remove(items: LiveView<ListItem>, item: ListItem) {
  items.delete(item);
}

<html class="list"
      (list: List,
       items: LiveView<ListItem>,
       private draft: { value: string } = { value: "" })>
  <main>
    <h1>{list.title}</h1>

    <ul class="items">
      <li e:for={item of items.sort((a, b) => +a.createdAt - +b.createdAt)}
          class={item.done && "done"}>
        <button class="toggle" onclick={() => toggle(items, item)}>{item.done ? "☑" : "☐"}</button>
        <span class="text">{item.text}</span>
        <button class="remove" onclick={() => remove(items, item)}>×</button>
      </li>
    </ul>

    <form onsubmit={() => insert(items, draft)}>
      <input type="text" value={draft.value} placeholder="add an item" required>
      <button type="submit">add</button>
    </form>
  </main>
</html>

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:

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.