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