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.
Choosing a driver
Section titled “Choosing a driver”| Driver | Import | Mode | Environment | Use for |
|---|---|---|---|---|
BptreeInmemDriver | .../drivers/inmemory | sync | both | Tests, fully loaded app state, and the fast HybridDB cache |
IdbDriver | .../drivers/idb | async | browser | Browser persistence, usually as a HybridDB primary store |
SqlDriver | .../drivers/sqlite | sync | both | Any synchronous SQLite binding (native server SQLite, sql.js) |
AsyncSqlDriver | .../drivers/sqlite | async | both | Async 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.
In-memory
Section titled “In-memory”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.
SQLite
Section titled “SQLite”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.
SQLite Recipes
Section titled “SQLite Recipes”SQL.js sync
Section titled “SQL.js sync”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.
wa-sqlite async
Section titled “wa-sqlite async”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.
Backend: native SQLite
Section titled “Backend: native SQLite”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 browserThe 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 serverimport { 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.
IndexedDB
Section titled “IndexedDB”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.
Sync vs. async, in practice
Section titled “Sync vs. async, in practice”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.