Skip to content

will-be-done/hyperdb

Repository files navigation

HyperDB

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.

image

Open demo in StackBlitz

📖 Full documentation → hyperdb.will-be-done.app

What it solves

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. HybridDB combines 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:

image

Who needs this

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.

Installation

npm install @will-be-done/hyperdb

The 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

Quick start

// 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>
  );
}

Entry points

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)

Learn more

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.

About

A reactive, local-first database for TypeScript that runs the same typed schemas, queries, selectors, and actions in the browser and on the server.

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages