Skip to content

Storage Drivers

A driver is the actual storage backend behind a DB. The same selectors and actions run unchanged against any driver, and in any environment. You can use a single driver directly, or combine a persistent primary driver with an in-memory cache through HybridDB. You also choose whether to use the sync or async runtime helpers, which depends on the storage path.

DriverImportModeEnvironmentUse for
BptreeInmemDriver.../drivers/inmemorysyncbothTests, fully loaded app state, and the fast HybridDB cache
IdbDriver.../drivers/idbasyncbrowserBrowser persistence, usually as a HybridDB primary store
SqlDriver.../drivers/sqlitesyncbothAny synchronous SQLite binding (native server SQLite, sql.js)
AsyncSqlDriver.../drivers/sqliteasyncbothAsync SQLite, including browser SQLite as a HybridDB primary

Sync drivers work with execSync / syncDispatch / selectSync. Async drivers require execAsync / asyncDispatch / selectAsync. HybridDB also uses the async helpers, because a read may miss the memory cache and fall through to the primary store.

A typical local-first browser setup uses HybridDB with IndexedDB or async SQLite as the primary store and BptreeInmemDriver as the cache. If your whole working set can be loaded eagerly, a plain SubscribableDB over BptreeInmemDriver keeps the UI path fully synchronous. On the server, use a native SqlDriver while running the same schema, selectors, and actions.

The simplest driver: a set of in-memory B+trees. Construct it with no arguments.

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

It stores normalized JS values directly. Use it in tests, for app state that can be fully loaded into memory, or as the cache tier inside HybridDB.

SqlDriver (synchronous) and AsyncSqlDriver (asynchronous) are not tied to any one SQLite build. HyperDB does not initialize SQLite for you; create the SQLite database with the package/runtime you prefer, then adapt it to the driver interface.

For synchronous SQLite, implement this tiny shape and pass it to SqlDriver:

import { SqlDriver, type SqlValue } from "@will-be-done/hyperdb/drivers/sqlite";
export interface SQLiteDB {
exec(sql: string, params?: SqlValue[]): void;
prepare(sql: string): {
values(values: SqlValue[]): SqlValue[][]; // bound query → rows as arrays
finalize(): void;
};
}
const driver = new SqlDriver(sqliteDb);

For asynchronous SQLite, implement the same shape with promises and pass it to AsyncSqlDriver:

import {
AsyncSqlDriver,
type SqlValue,
} from "@will-be-done/hyperdb/drivers/sqlite";
interface AsyncSQLiteDB {
exec(sql: string, params?: SqlValue[]): Promise<void>;
prepare(sql: string): Promise<{
values(values: SqlValue[]): Promise<SqlValue[][]>;
finalize(): void | Promise<void>;
}>;
}
const driver = new AsyncSqlDriver(sqliteDb);

The SQLite storage codec encodes bigint, ArrayBuffer, and typed-array/data-view values around JSON storage so they round-trip exactly. uniqhash indexes are created as SQLite UNIQUE indexes. Upserts delete the same primary keys first and then insert the new rows, so a secondary unique conflict throws instead of replacing a different row.

import initSqlJs from "sql.js";
import wasmUrl from "sql.js/dist/sql-wasm.wasm?url";
import { DB, execSync } from "@will-be-done/hyperdb";
import {
SqlDriver,
type SQLStatement,
type SqlValue,
} from "@will-be-done/hyperdb/drivers/sqlite";
const SQL = await initSqlJs({
locateFile: () => wasmUrl,
});
const sqljsDb = new SQL.Database();
const driver = new SqlDriver({
exec(sql: string, params?: SqlValue[]): void {
sqljsDb.exec(sql, params);
},
prepare(sql: string): SQLStatement {
const stmt = sqljsDb.prepare(sql);
return {
values(values: SqlValue[]): SqlValue[][] {
stmt.bind(values);
const rows: SqlValue[][] = [];
while (stmt.step()) {
rows.push(stmt.get());
}
return rows;
},
finalize(): void {
stmt.free();
},
};
},
});
const db = new DB(driver);
execSync(db.loadTables([tasksTable]));

SqlDriver is synchronous. Even if sql.js initialization is async, use the created driver with selectSync, syncDispatch, and execSync.

import SQLiteAsyncESMFactory from "wa-sqlite/dist/wa-sqlite-async.mjs";
import asyncSqlWasmUrl from "wa-sqlite/dist/wa-sqlite-async.wasm?url";
import * as SQLite from "wa-sqlite";
import { MemoryAsyncVFS } from "wa-sqlite/src/examples/MemoryAsyncVFS.js";
import { DB, execAsync } from "@will-be-done/hyperdb";
import {
AsyncSqlDriver,
type AsyncSQLiteDB,
type SqlValue,
} from "@will-be-done/hyperdb/drivers/sqlite";
type WaSQLiteValue =
| number
| string
| Uint8Array
| Array<number>
| bigint
| null;
type WaSQLiteDB = {
bind_collection(
stmt: number,
bindings:
| { [index: string]: WaSQLiteValue | null }
| Array<WaSQLiteValue | null>,
): number;
statements(db: number, sql: string): AsyncIterable<number>;
step(stmt: number): Promise<number>;
row(stmt: number): WaSQLiteValue[];
vfs_register(vfs: unknown, makeDefault: boolean): void;
open_v2(name: string): Promise<number>;
};
const SQLITE_ROW = 100;
const module = await SQLiteAsyncESMFactory({
locateFile: () => asyncSqlWasmUrl,
});
const sqlite3 = SQLite.Factory(module) as WaSQLiteDB;
const vfs = new MemoryAsyncVFS();
sqlite3.vfs_register(vfs, true);
const dbHandle = await sqlite3.open_v2("main.sqlite");
const sqliteDb: AsyncSQLiteDB = {
async exec(sql: string, params?: SqlValue[]): Promise<void> {
for await (const stmt of sqlite3.statements(dbHandle, sql)) {
if (params) sqlite3.bind_collection(stmt, params);
await sqlite3.step(stmt);
}
},
async prepare(sql: string) {
return {
async values(values: SqlValue[]): Promise<SqlValue[][]> {
const rows: SqlValue[][] = [];
for await (const stmt of sqlite3.statements(dbHandle, sql)) {
sqlite3.bind_collection(stmt, values);
while ((await sqlite3.step(stmt)) === SQLITE_ROW) {
rows.push(sqlite3.row(stmt) as SqlValue[]);
}
}
return rows;
},
finalize(): void {
// wa-sqlite finalizes scoped statements after iteration.
},
};
},
};
const driver = new AsyncSqlDriver(sqliteDb);
const db = new DB(driver);
await execAsync(db.loadTables([tasksTable]));

AsyncSqlDriver is asynchronous, so use it with execAsync, asyncDispatch, and selectAsync.

For persistent browser SQLite, swap the memory VFS for WA-SQLite’s OPFS VFS:

import { OriginPrivateFileSystemVFS } from "wa-sqlite/src/examples/OriginPrivateFileSystemVFS.js";
const vfs = new OriginPrivateFileSystemVFS();
sqlite3.vfs_register(vfs, true);
const dbHandle = await sqlite3.open_v2("main.sqlite");

OriginPrivateFileSystemVFS uses OPFS access handles, so run this setup in a module Worker and expose an AsyncSQLiteDB-shaped RPC (exec and prepare().values()) to the main thread. The HyperDB demo uses that pattern for direct WA-SQLite OPFS access and for WA-SQLite OPFS as a HybridDB primary store.

On the server, point SqlDriver at a native SQLite binding, here Bun’s built-in bun:sqlite:

import { Database } from "bun:sqlite";
import { DB, execSync } from "@will-be-done/hyperdb";
import { SqlDriver, type SqlValue } from "@will-be-done/hyperdb/drivers/sqlite";
const sqliteDB = new Database("app.sqlite", { strict: true });
sqliteDB.run("PRAGMA journal_mode=WAL;");
sqliteDB.run("PRAGMA synchronous=NORMAL;");
sqliteDB.run("PRAGMA busy_timeout=5000;");
const driver = new SqlDriver({
exec(sql: string, params?: SqlValue[]): void {
if (!params) sqliteDB.run(sql);
else sqliteDB.run(sql, params);
},
prepare(sql: string) {
const stmt = sqliteDB.prepare(sql);
return {
values(values: SqlValue[]): SqlValue[][] {
return stmt.values(...values) as SqlValue[][];
},
finalize(): void {
stmt.finalize();
},
};
},
});
const db = new DB(driver);
execSync(db.loadTables([tasksTable])); // the very same tables used in the browser

The same sync shape adapts other native bindings (better-sqlite3, Node’s built-in node:sqlite, etc.); implement exec and prepare(...).values() against the binding’s API. Because the server DB runs the identical schema, selectors, and actions as the client, you can import a shared “slice” of data logic into both:

// shared between client and server
import { tasksTable, createTask, projectTasks } from "@your-app/slices";
// server (Bun + native SQLite)
syncDispatch(serverDb, createTask({ id, projectId, title }));
const tasks = selectSync(serverDb, {
selector: projectTasks,
args: { projectId },
});

This is the foundation of the sync engine: the server can run the same change-tracking actions as clients.

For persistent browser storage, open an IdbDriver by name. It is asynchronous, so load tables and dispatch through the async helpers. In a local-first app, it is commonly the primary store behind HybridDB.

import { DB, execAsync, asyncDispatch } from "@will-be-done/hyperdb";
import { openIndexedDBDriver } from "@will-be-done/hyperdb/drivers/idb";
const idbDriver = await openIndexedDBDriver("my-app-db");
const idbDb = new DB(idbDriver);
await execAsync(idbDb.loadTables([tasksTable]));
await asyncDispatch(
idbDb,
createTask({ id: "t1", projectId: "p1", title: "Ship" }),
);

The IndexedDB driver uses the same storage encoding and sort-key ordering as the SQLite driver, so data and index semantics are consistent across the two persistent backends. IndexedDB reports selector readonly transaction support, so selector reads use beginTx("readonly"); when multiple scans happen inside one selector run while the browser keeps a readonly transaction active, the driver reuses it instead of opening one transaction per scan. Concurrent selector runs get separate readonly transactions, and an inactive or finished readonly transaction is reopened once for the current scan. IndexedDB uniqhash indexes are native unique indexes. If an index changes between hash and uniqhash, HyperDB recreates the IndexedDB index during the schema upgrade.

IDB operation logs include a per-driver transaction id (tx 3) and, when the operation comes from an object selector or action, the run context (selector readTasks or action createTask). This makes scan, reopen, commit, and failure logs easier to correlate in browser consoles.

Use a synchronous driver when the whole read path can stay in memory or in a sync SQLite binding. That gives you selectSync, syncDispatch, useSyncSelector, and useSyncDispatch with no promises.

Use an asynchronous driver when storage itself is asynchronous, such as IndexedDB, async SQLite, or HybridDB. HybridDB can still serve warm reads from its in-memory cache, but the public API remains async because any selector may touch an uncached range and need the primary store.

The rule is simple: a generator that might touch an async storage path must be run with execAsync / asyncDispatch / selectAsync; purely sync paths may use execSync / syncDispatch / selectSync.