How HyperDB Works
HyperDB is built from three core pieces. Understanding how they fit together explains almost everything else in these docs.
The core pieces
Section titled “The core pieces”1. Tables
Section titled “1. Tables”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"]) .index("byProjectState", ["projectId", "state"]);
export type Task = ExtractSchema<typeof tasksTable>;See Schemas and Data Types.
2. Commands: selectors and actions
Section titled “2. Commands: selectors and actions”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, anddeleteRowscommands, and can call selectors or other actions.
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 projectDoneTasks = selector({ name: "projectDoneTasks", args: { projectId: v.string() }, handler: function* ({ projectId }) { return yield* selectFrom(tasksTable, "byProjectState").where((q) => q.eq("projectId", projectId).eq("state", "done"), ); },});
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, asynchronously against IndexedDB or
async SQLite, or through a HybridDB that reads from memory first and falls
through to a primary store on cache misses. The code does not change. See
Selectors and Actions.
Commands also compose with normal yield* calls. A selector can call another
selector, an action can read through selectors, and a larger action can call
smaller actions inside the same transaction:
const projectSummary = selector({ name: "projectSummary", args: { projectId: v.string() }, handler: function* ({ projectId }) { const tasks = yield* projectTasks({ projectId }); // selector in selector const doneTasks = yield* projectDoneTasks({ projectId }); // indexed read return { total: tasks.length, done: doneTasks.length, }; },});
const completeTask = action({ name: "completeTask", args: { id: v.string() }, handler: function* ({ id }) { const task = yield* taskById({ id }); // selector in action if (!task) return;
yield* markTaskDone({ id }); // action in action },});3. The DB runtime
Section titled “3. The DB runtime”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.
HybridDB is a runtime wrapper that combines two DBs: a primary persistent
store and an in-memory B-tree cache.
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(new SqlDriver(sqlite)); // same tasksTable, same selectorsexecSync(serverDb.loadTables([tasksTable]));See Storage Drivers for the full adapter.
How a read executes and becomes reactive
Section titled “How a read executes and becomes reactive”This is the loop that powers HyperDB’s reads and reactivity:
- You run a selector through the runtime (or a React hook).
- The selector emits indexed reads with
selectFrom(table, "indexName")and explicit bounds. With an in-memory DB, those reads go straight to B-trees. WithHybridDB, each read checks the in-memory cache first; missing ranges fall through to the primary store, then the returned rows and covered ranges are cached for later reads. - As the selector scans indexes, the runtime records which logical index ranges it touched, regardless of whether rows came from memory or the primary store.
- The result is cached, keyed by the selector and a stable serialization of its arguments.
- When an action commits a mutation, the
SubscribableDBnotifies subscribers with the list of changed rows. - A cached selector re-runs only if a changed row falls inside a range it previously scanned. Otherwise the cached value is reused.
The same mechanism gives the devtool useful traces: it can show which selector
ran, which indexes it scanned, and whether HybridDB reads came from in-mem or
persist.
More details in Reading Data.
Sync vs. async execution
Section titled “Sync vs. async execution”Every storage path is either synchronous (in-memory, sync SQLite) or
asynchronous (IndexedDB, async SQLite, or HybridDB when a read misses memory).
The runtime exposes a matching pair of entry points for almost everything:
| Sync | Async |
|---|---|
syncDispatch(db, action(args)) | asyncDispatch(db, action(args)) |
selectSync(db, { selector, args }) | selectAsync(db, { selector, args }) |
execSync(generator) | execAsync(generator) |
useSyncSelector / useSyncDispatch | useAsyncSelector / useAsyncDispatch |
Use the sync variants with the in-memory and synchronous SQLite drivers; use the
async variants with IndexedDB, async SQLite, and HybridDB (because a selector
may miss the memory cache and read from the primary store). See
The DB Runtime and Storage Drivers.