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"])
.index("byProjectState", ["projectId", "state"]);
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, 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
},
});

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 selectors
execSync(serverDb.loadTables([tasksTable]));

See Storage Drivers for the full adapter.

This is the loop that powers HyperDB’s reads and reactivity:

  1. You run a selector through the runtime (or a React hook).
  2. The selector emits indexed reads with selectFrom(table, "indexName") and explicit bounds. With an in-memory DB, those reads go straight to B-trees. With HybridDB, 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.
  3. 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.
  4. The result is cached, keyed by the selector and a stable serialization of its arguments.
  5. When an action commits a mutation, the SubscribableDB notifies subscribers with the list of changed rows.
  6. 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.

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:

SyncAsync
syncDispatch(db, action(args))asyncDispatch(db, action(args))
selectSync(db, { selector, args })selectAsync(db, { selector, args })
execSync(generator)execAsync(generator)
useSyncSelector / useSyncDispatchuseAsyncSelector / 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.