store

tcmts.examples.reminders.store · #sfbpfylw · ts file

// tcm: namespace reminders
// Where reminders are kept: this device. An IndexedDB database in the browser, and nothing anywhere else, so the app
// works offline and nothing leaves the device. It uses indexedDB, so tcm places it in the browser.
import type { List, Reminder } from "#codebase/reminders";
import { starter } from "#codebase/reminders";

/** The device's database, opened once per tab: lists and reminders, each by id. */
const opened = new Map<string, Promise<IDBDatabase>>();

function database(): Promise<IDBDatabase> {
  let db = opened.get("reminders");
  if (!db) {
    db = new Promise((resolve, reject) => {
      const open = indexedDB.open("reminders", 1);
      open.onupgradeneeded = () => {
        open.result.createObjectStore("lists", { keyPath: "id" });
        open.result.createObjectStore("reminders", { keyPath: "id" });
      };
      open.onsuccess = () => resolve(open.result);
      open.onerror = () => reject(open.error);
    });
    opened.set("reminders", db);
  }
  return db;
}

/** A request's result, once it's done. */
const done = <T>(req: IDBRequest<T>): Promise<T> =>
  new Promise((resolve, reject) => {
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });

/** The other tabs and windows of the app on this device, told when something changes. */
const others = new Map<string, BroadcastChannel>();

const channel = (): BroadcastChannel => {
  let c = others.get("reminders");
  if (!c) others.set("reminders", (c = new BroadcastChannel("reminders")));
  return c;
};

/**
 * Everything on this device: its lists and reminders. The first time, {@link starter}'s, saved. And the first time
 * something's saved, the browser is asked to keep it (persistent storage), rather than clear it when space runs low.
 */
export async function loadAll(): Promise<{ lists: List[]; reminders: Reminder[] }> {
  const db = await database();
  const tx = db.transaction(["lists", "reminders"]);
  const [lists, reminders] = await Promise.all([
    done(tx.objectStore("lists").getAll() as IDBRequest<List[]>),
    done(tx.objectStore("reminders").getAll() as IDBRequest<Reminder[]>),
  ]);
  if (lists.length) return { lists: lists.sort((a, b) => a.created - b.created), reminders };
  const first = starter(Date.now());
  await write((s) => {
    for (const l of first.lists) s.lists.put(l);
    for (const r of first.reminders) s.reminders.put(r);
  });
  return first;
}

/** Change the database in one transaction, then tell the app's other tabs. */
async function write(change: (stores: { lists: IDBObjectStore; reminders: IDBObjectStore }) => void): Promise<void> {
  const db = await database();
  const tx = db.transaction(["lists", "reminders"], "readwrite");
  change({ lists: tx.objectStore("lists"), reminders: tx.objectStore("reminders") });
  await new Promise<void>((resolve, reject) => {
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
    tx.onabort = () => reject(tx.error);
  });
  channel().postMessage("changed");
  void navigator.storage?.persist?.();
}

/** A new reminder's or list's id, and when it's made: this device's. */
export const stamp = (): { id: string; created: number } => ({ id: crypto.randomUUID(), created: Date.now() });

/** A reminder done now, or, if it was done, not done. */
export const toggleDone = (r: Reminder): Reminder => (r.done ? { ...r, done: undefined } : { ...r, done: Date.now() });

/** Save a reminder, new or changed. */
export const saveReminder = (r: Reminder): Promise<void> => write((s) => void s.reminders.put(r));

/** Delete a reminder. */
export const deleteReminder = (id: string): Promise<void> => write((s) => void s.reminders.delete(id));

/** Save a list, new or changed. */
export const saveList = (l: List): Promise<void> => write((s) => void s.lists.put(l));

/** Delete a list, and the reminders in it. */
export async function deleteList(id: string, reminders: readonly Reminder[]): Promise<void> {
  await write((s) => {
    s.lists.delete(id);
    for (const r of reminders) if (r.list === id) s.reminders.delete(r.id);
  });
}

/** Run `f` when another tab or window of the app changes something; returns a function that stops it. */
export function onChange(f: () => void): () => void {
  const c = channel();
  c.addEventListener("message", f);
  return () => c.removeEventListener("message", f);
}

used by