The reactive database for local-first apps. Define schemas, queries, selectors, and actions once, then use that data layer in the browser and on the server.
📖 Full documentation → hyperdb.will-be-done.app
HyperDB brings the developer experience of a backend like Convex to a database that can run in the browser and on the server. You describe data with typed schemas, read it through reactive selectors, and change it through transactional actions. The same slice of schema, selectors, and actions can be shared by the client and backend; only the storage driver differs.
It is designed for the parts of local-first apps where plain client state starts to strain:
- Efficient inserts into sorted data. Every table index is backed by a real
B-tree, so inserting into a sorted collection stays
O(log n)instead of rebuilding or shifting a whole array. This fits fractional indexing in local-first apps. - Explicit query execution. SQL is powerful, but the query text does not usually tell you whether the database will use an index or scan a whole table. In HyperDB, selectors name the table index they read and build explicit bounds over it, so the code shows the access path it will take.
- Fine-grained reactivity. Selectors record exactly which index ranges they
scanned, so a mutation only re-runs the selectors that overlap it, without
proxies or
observer(). - Lazy persistent reads when you need them.
HybridDBcombines a persistent primary store with an in-memory B-tree cache. Reads return cached ranges when possible and load missing ranges from the primary store on demand. Writes update the cache first so the UI can respond immediately, then flush to the primary store in order. - Run the same logic on the backend. Because a table index is just a B-tree, the same schema, selectors, and actions run against a persistent store on the server (SQLite today, pg/mongodb in future). The runtime reads only the rows a selector touches instead of hydrating the whole dataset into memory.
- JavaScript selectors and actions. Selectors and actions are ordinary JS: loops, conditionals, function calls. You get fast indexed lookups underneath, not a query language to learn.
The devtool records selector runs and mutations in a call tree:
Reach for HyperDB when you want structured, queryable, reactive data shared across your whole stack:
- Local-first apps that work offline and sync to a server in the background, plus a server that runs the very same schema and sync logic.
- Apps with rich data models (tasks, documents, boards) that need indexed lookups and ordering on both client and server.
- Large sorted collections you reorder or insert into with fractional indexing, where array-based state becomes costly.
- Anywhere you'd otherwise duplicate models and queries between frontend and backend.
npm install @will-be-done/hyperdbThe React devtool ships separately. It traces every selector run and mutation
into a browsable call tree, so you can see which index a slow view scanned. For
HybridDB reads, select nodes are labeled in-mem or persist to show whether
the returned rows came from the memory cache or the primary persistent store,
and trace rows get an in-mem badge when no select fell through to a persistent
scan — every read was served from the memory cache (mutations, which flush
separately, don't affect it). You can sort traces by creation time, duration, or
rows fetched, and when you switch traces, the active detail tab stays selected so
comparison stays focused:
npm install @will-be-done/hyperdb-devtool// 1. Define a typed table (id + a queryable index)
import { defineTable, v, type ExtractSchema } from "@will-be-done/hyperdb";
export const tasksTable = defineTable("tasks", {
id: v.string(),
projectId: v.string(),
title: v.string(),
slug: v.string(),
orderToken: v.string(),
})
.index("byProjectOrder", ["projectId", "orderToken"])
.index("bySlug", ["slug"], { type: "uniqhash" })
.index("byIds", ["id"]);
export type Task = ExtractSchema<typeof tasksTable>;// 2. Create shared builders
import { createSelector, createAction } from "@will-be-done/hyperdb";
export const selector = createSelector({
validateArgs: process.env.NODE_ENV === "development",
});
export const action = createAction({
validateArgs: process.env.NODE_ENV === "development",
});// 3. Write a selector and an action as plain generators
import { selectFrom, insert, v } from "@will-be-done/hyperdb";
import { selector, action } from "./builders";
import { tasksTable } from "./schema";
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, slug: id, orderToken: id },
]);
},
});Queries can also return OR branches with or(...) or arrays from where.
When combined with .order(...), those branches are merged into the index order
before rows are returned.
// 4. Create a HybridDB (persistent primary + in-memory cache)
import { DB, HybridDB, SubscribableDB, execAsync } from "@will-be-done/hyperdb";
import { openIndexedDBDriver } from "@will-be-done/hyperdb/drivers/idb";
import { BptreeInmemDriver } from "@will-be-done/hyperdb/drivers/inmemory";
import { tasksTable } from "./schema";
export async function createAppDB() {
const primary = new DB(await openIndexedDBDriver("my-app"), {
runtimeRowsValidation: process.env.NODE_ENV === "development",
freezeArgs: process.env.NODE_ENV === "development",
freezeRows: process.env.NODE_ENV === "development",
});
const cache = new DB(new BptreeInmemDriver());
const db = new SubscribableDB(new HybridDB(primary, cache));
await execAsync(db.loadTables([tasksTable]));
// Optional: warm the cache with the whole table so future reads can stay in memory.
// await execAsync(
// db.preloadTables([{ table: tasksTable, scanIndex: "byIds" }]),
// );
return db;
}SubscribableDB also exposes lifecycle hooks: mutation hooks such as
afterInsert, afterUpsert, afterDelete, and afterChange, plus afterScan
for successful index scans. HybridDB keeps the primary store persistent while
serving cached index ranges from memory.
import {
DBProvider,
useAsyncSelector,
useAsyncDispatch,
} from "@will-be-done/hyperdb/react";
import type { SubscribableDB } from "@will-be-done/hyperdb";
import { HyperDBDevtools } from "@will-be-done/hyperdb-devtool/react";
import { createTask, projectTasks } from "./tasks";
function Tasks({ projectId }: { projectId: string }) {
const { data: tasks = [], isFetching } = useAsyncSelector({
selector: projectTasks,
args: { projectId },
defaultValue: [],
});
const dispatch = useAsyncDispatch();
return (
<>
<button
disabled={isFetching}
onClick={() =>
void dispatch(
createTask({
id: crypto.randomUUID(),
projectId,
title: "New task",
}),
)
}
>
Add task
</button>
<ul>
{tasks.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
</>
);
}
export function App({ db }: { db: SubscribableDB }) {
return (
<DBProvider value={db}>
<Tasks projectId="p1" />
{/* Drop in the devtool to trace selectors and mutations */}
<HyperDBDevtools db={db} initialIsOpen={false} />
</DBProvider>
);
}| Import path | Contents |
|---|---|
@will-be-done/hyperdb |
Core: defineTable, v, selectFrom, builders, DB, HybridDB, SubscribableDB |
@will-be-done/hyperdb/react |
React hooks and DBProvider |
@will-be-done/hyperdb/tracing |
Tracing store and tracer configuration |
@will-be-done/hyperdb/drivers/inmemory |
BptreeInmemDriver |
@will-be-done/hyperdb/drivers/sqlite |
SqlDriver, AsyncSqlDriver |
@will-be-done/hyperdb/drivers/idb |
openIndexedDBDriver, IdbDriver |
@will-be-done/hyperdb-devtool/react |
HyperDBDevtools, HyperDBDevtoolsPanel (separate package) |
- Introduction: what HyperDB is and when to use it
- Why HyperDB?: the data-modeling and reactivity problems it is built to solve
- How HyperDB Works: the mental model
- Quickstart: define a table, run a query, wire it into React
- LLM Cheat Sheet: compact context to paste into another project
- Storage Drivers: in-memory, IndexedDB, SQLite
- Devtools & Tracing: inspect selector runs and mutations
- Building a Sync Engine: share change-tracking code across client and server
On the server the persistent store is SQLite today (MongoDB and PostgreSQL are not supported yet). HyperDB gives you the storage, query, and reactivity primitives, and you build synchronization on top with the built-in primitives.