16. Data: typed tables

Typed tables never silently disagree with your code.

import { table, transaction } from "#ucm/server";

export type User = { name: string; email: string; joined: Date; plan: "free" | "pro"; logins: number };
export const users = table<User>("users");          // declared at the top level

await users.insert({ name: "ada", email: "ada@x", joined: new Date(), plan: "free", logins: 0 });
await users.find({ name: "ada" });                    // also get / put / delete / count

TypeScript erases User at runtime, but ucm sees it where the table is declared. It derives a schema from it and attaches that schema, with its hash, to the table. A table declared anywhere but the top level, or without a row type, explains how to declare one when used.

Data lives in .ucm/data/store.sqlite, apart from the codebase. Under ucm test, tables live in memory.

16.1 Options

export const users = table<User>("users", {
  migrate: [fromV1, fromV2],   // one step per earlier row type, picked by its parameter type
  unique: ["email"],           // a duplicate write fails with a 409
  index: ["plan"],             // find({ plan: "pro" }) uses an index
  renamedFrom: "members",      // the old table's rows move over
});

16.2 Writes are checked

Every write is checked against User, and each row records the schema it was written with. Dates, maps and sets round-trip.

16.3 When the row type changes

Each stored row is checked as it's read:

migrate is one function, or a chain of typed steps, oldest first:

export function fromV1(old: { body: string }) {
  return { text: old.body, tags: [] as string[] };
}
export function fromV2(old: { text: string; tags: string[] }): Note {
  return { ...old, pinned: false };
}
export const notes = table<Note>("notes", { migrate: [fromV1, fromV2] });

ucm records each step's parameter type. A row goes through the first step whose parameter it fits, then the later ones, until it fits the row type, so a v1 row goes through both steps here and a v2 row through the second. Type changes accumulate without one function having to handle every past shape. A row no step takes fails with each step's reason ("step 1 (fromV1): row.body is missing; …").

ucm update reports this up front. When a row type changes, it checks the stored rows and says whether they fit, will be migrated (some step takes them), or need a step first. It also reports stored duplicates that would break a new unique field, and rows waiting to move from a renamedFrom table.

ucm data migrate <table> migrates every row now, through the current code, instead of as rows are read. <table> is a table name or the name of the definition declaring it. Afterwards it says whether migrate still has work: once every row has the current type, it can be removed. Keep it while an older build that writes the old type is still serving.

Old pages reload instead of misreading. A page from an older build (old builds keep serving) that reads rows written by newer code gets a 410, and its browser reloads onto the current version.

16.4 Queries and indexes

16.5 Renaming a table

renamedFrom: "members" moves the old table's rows to the new name when the table is first used, and any rows an older build writes under the old name afterwards.

16.6 Atomic updates

update(id, fn) is an atomic read-modify-write: fn gets the current value and returns the new one, synchronously, while the row is locked. It returns the new value, or undefined if there's no such row.

await users.update(id, (u) => ({ ...u, logins: u.logins + 1 }));

16.7 Transactions

await transaction(async () => {
  const a = (await accounts.get(from))!;
  const b = (await accounts.get(to))!;
  await accounts.put(from, { ...a, balance: a.balance - amount });
  if (a.balance < amount) throw new Error("insufficient funds");   // rolls back the debit
  await accounts.put(to, { ...b, balance: b.balance + amount });
});

transaction(fn) makes any number of reads and writes, across tables, all or nothing: a throw rolls them all back, and transaction returns fn's result.

Trade-offs:

16.8 ucm data

ucm data lists tables and row counts, and notes tables whose rows span several row types. ucm data <table> [n] shows the newest rows, each with the schema it was written with.

built from 5890e54f