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 scoped by 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 scope column. The LiveTable's scope: "listId" partition will key broadcasts 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>({
  sort: "createdAt desc",
});

export let listItems = new LiveTable<ListItem>({
  scope: "listId",
  sort: "createdAt asc",
});

No insert, update, or delete auth callbacks. The recipe's access model is "anyone with the URL", so all mutators are open by default. To gate by ownership, add auth callbacks; see elements man livetable ▸ Auth.

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 });
}

app/pages/home/template.html:

import "./style.css";
import { LiveTable, 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: LiveTable<List>, private title: string = "")>
  <h1>your lists</h1>

  <ul class="lists">
    <li e:for={l of lists}>
      <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>
</html>

The home page receives the unscoped lists LiveTable 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 scoped LiveTable 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.scope(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 { LiveTable } from "@elements/app";
import { List, ListItem } from "#app/shared/services/lists";

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

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

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

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

  <ul class="items">
    <li e:for={item of items} 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>
</html>

items.insert(...) adds the row optimistically; 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 LiveTable's mutators so the change broadcasts. Every browser watching listItems.scope(<this list's id>) 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 auth callbacks 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 auth callbacks to the LiveTables.
  • Per-list ownership. Add an ownerId column to lists and an auth(item, s) => item.ownerId === s.getOrThrow('userId') callback on listItems.update/delete for "anyone can read, only owner can edit". The scope still partitions broadcasts by list, so each user only receives notifications for their own lists.
  • 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 unscoped lists LiveTable'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.scope(id) to the list page. To also show "your other lists" in a sidebar on the list page, pass lists along too. Both LiveTables coexist without interference.