Skip to content

How HyperDB Works

HyperDB is built from three core pieces. Understanding how they fit together explains almost everything else in these docs.

Tables describe the shape of a stored row and its named indexes. You declare them with defineTable and the v validator library. A table is just a description: it holds no data and is not tied to any database instance, so you can share the same table definitions across many DBs.

import { defineTable, v, type ExtractSchema } from "@will-be-done/hyperdb";
export const tasksTable = defineTable("tasks", {
id: v.string(),
projectId: v.string(),
title: v.string(),
state: v.union(v.literal("todo"), v.literal("done")),
orderToken: v.string(),
}).index("byProjectOrder", ["projectId", "orderToken"]);
export type Task = ExtractSchema<typeof tasksTable>;

See Schemas and Data Types.

Reads and writes are generator functions that describe work instead of performing it directly. When you yield* a query or a mutation, you are emitting a command; you never call the storage driver yourself.

  • Selectors read data. They can compose other selectors but cannot write.
  • Actions read and write data. They emit insert, upsert, and deleteRows commands.
import { selectFrom } from "@will-be-done/hyperdb";
import { selector, action } from "./builders"; // createSelector()/createAction()
import { insert } from "@will-be-done/hyperdb";
export const projectTasks = selector({
name: "projectTasks",
args: { projectId: v.string() },
handler: function* ({ projectId }) {
return yield* selectFrom(tasksTable, "byProjectOrder")
.where((q) => q.eq("projectId", projectId))
.order("asc");
},
});
export const createTask = action({
name: "createTask",
args: { id: v.string(), projectId: v.string(), title: v.string() },
handler: function* ({ id, projectId, title }) {
yield* insert(tasksTable, [
{ id, projectId, title, state: "todo", orderToken: id },
]);
},
});

Because commands are descriptions, the same selector or action can be executed synchronously against an in-memory driver, or asynchronously against IndexedDB, and the code does not change. See Reading Data and Writing Data.

A DB ties a set of tables to a storage driver and executes commands. The driver provides the actual backend: in-memory B+trees, SQLite, or IndexedDB.

import { DB, SubscribableDB, execSync } from "@will-be-done/hyperdb";
import { BptreeInmemDriver } from "@will-be-done/hyperdb/drivers/inmemory";
const db = new SubscribableDB(new DB(new BptreeInmemDriver()));
execSync(db.loadTables([tasksTable]));

You typically wrap the core DB in a SubscribableDB, which adds revisions, subscriptions, and lifecycle hooks, the machinery that makes selectors reactive.

The driver is the main thing that changes between environments. The same tables and commands can run on a server by handing DB a native SQLite driver instead:

import { Database } from "bun:sqlite";
import { SqlDriver } from "@will-be-done/hyperdb/drivers/sqlite";
const sqlite = new Database("app.sqlite", { strict: true });
const serverDb = new DB(makeSqlDriver(sqlite)); // same tasksTable, same selectors
execSync(serverDb.loadTables([tasksTable]));

See Storage Drivers for the full adapter.

This is the loop that powers HyperDB’s reactivity:

  1. You run a selector through the runtime (or a React hook).
  2. As the selector scans indexes, the runtime records which index ranges it touched.
  3. The result is cached, keyed by the selector and a stable serialization of its arguments.
  4. When an action commits a mutation, the SubscribableDB notifies subscribers with the list of changed rows.
  5. A cached selector re-runs only if a changed row falls inside a range it previously scanned. Otherwise the cached value is reused.

More details in Selectors & Reactivity.

Every driver is either synchronous (in-memory, sync SQLite) or asynchronous (IndexedDB, async SQLite). The runtime exposes a matching pair of entry points for almost everything:

SyncAsync
syncDispatch(db, action(args))asyncDispatch(db, action(args))
select(db, gen)selectAsync(db, gen)
execSync(generator)execAsync(generator)
useSyncSelector / useDispatchuseAsyncSelector / useAsyncDispatch

Use the sync variants with the in-memory and synchronous SQLite drivers; use the async variants with IndexedDB and async SQLite. See The DB Runtime and Storage Drivers.