npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@bjnstnkvc/db

v2.0.1

Published

TypeScript database layer for IndexedDB with an API modeled on Laravel.

Readme

DB

A database layer for IndexedDB, with an API modeled on Laravel's: a DB class, a fluent query builder, a schema builder and forward-only migrations that run when your app boots.

The method names and their semantics follow Laravel closely enough that the docs are worth reading side by side, and each section below links the page it draws from. It is not a port: IndexedDB is a key-value store with no query language, so the places where behaviour has to differ are called out where they arise. This project is not affiliated with the Laravel project.

Contents

Installation & setup

NPM

You can install the package via npm:

npm install @bjnstnkvc/db

and then import it into your project

import { DB, Schema, Migration, type Blueprint } from '@bjnstnkvc/db';

Usage

Configuration

Declare your connections once, at module scope, then migrate when the app boots:

import { DB, Schema, Migration, type Blueprint } from '@bjnstnkvc/db';

interface User {
    id: number;
    name: string;
    email: string;
    age: number | null;
    role: string;
    created_at: Date | null;
    updated_at: Date | null;
}

class CreateUsersTable extends Migration {
    /**
     * Run the migration.
     */
    override async up(): Promise<void> {
        await Schema.create('users', (table: Blueprint): void => {
            table.id();
            table.string('name');
            table.string('email').unique();
            table.integer('age').nullable().index();
            table.string('role').default('member').index();
            table.timestamps();
        });
    }
}

DB.configure({
    default    : 'app',
    connections: {
        app: {
            database  : 'app',
            migrations: [CreateUsersTable],
            seeders   : [UserSeeder],
            strict    : true,
        },
    },
});

await DB.migrate('app');

User is your own interface describing a row of the table. Nothing in this package generates it, and every example below passes it as DB.table<User>('users') so the builder can type its constraints, its return values and its aggregate keys.

| Option | Meaning | |--------------------------------|---------------------------------------------------------------------------------------------------------| | default | The connection used when none is named | | connections[name].database | The IndexedDB database name | | connections[name].migrations | Ordered migration classes. Their order is the schema version. | | connections[name].seeders | Ordered seeder classes, run by DB.seed(name). See Seeding. | | connections[name].strict | Defaults to true. Nullability violations and uncoercible values throw. false writes null instead. |

DB.migrate(name) is idempotent. It opens the database at the version your migrations ask for, and when that already matches, nothing runs. Calling it on every boot is the intended usage, and there is no "has this been migrated?" check for you to write.

The connection name is required here. Every method whose subject is the connection itself names it rather than falling back to the default, since a silent fallback would migrate, seed or delete the wrong database. That covers migrate, seed, fresh, status, disconnect and purge. The table-level helpers still default, because there the subject is the table:

await DB.migrate('app');
await DB.migrate('reporting');

Migrations

A migration declares up() and nothing else:

class AddRoleToUsersTable extends Migration {
    /**
     * Run the migration.
     */
    override async up(): Promise<void> {
        await Schema.table('users', (table: Blueprint): void => {
            table.string('role').default('member');
        });
    }
}

Adding a column with a default() backfills every existing record. Without one, existing records are left alone.

Migrations are forward-only

IndexedDB versions cannot decrease, so there is no down(), no rollback() and no batches. To start over, DB.fresh(name) deletes the database and replays every migration.

Migrations may only ever be appended. Reordering them, or removing one that already ran, throws MigrationMismatchException rather than corrupting the schema.

The recorded name defaults to the class name in snake case, the way Laravel names a migration file, so CreateUsersTable is recorded as create_users_table. Because it is derived from the class name, a bundler that mangles class names will look like a reordered list. If you minify with class-name mangling, override name():

class CreateUsersTable extends Migration {
    /**
     * Get the name of the migration.
     */
    override name(): string {
        return 'create_users_table';
    }

    /**
     * Run the migration.
     */
    override async up(): Promise<void> {
        // ...
    }
}

What a migration may await

A migration runs inside the version-change transaction, and IndexedDB commits a transaction the moment its request queue drains. So a migration may only await operations from this package. Awaiting Schema.* and DB.table(...) is safe. Awaiting a fetch, a timer, or any other promise ends the transaction, and the next schema call throws MigrationTransactionClosedException.

If you need data from the network, that is what a seeder is for. See Seeding.

Migration status

await DB.status('app');

Resolves to one entry per registered migration:

[
    { migration: 'create_users_table', ran: true, at: '2026-08-27T21:00:00.000Z' },
    { migration: 'add_role_to_users_table', ran: false, at: null }
]

DB.status(name) never migrates as a side effect, so you can call it before DB.migrate(name) to see what is pending.

Modeled on Laravel's Migrations. These only run forward, and they are registered in the connection config rather than discovered from a directory.

Seeding

Use this for development, demos and tests, not for state your app ships and users edit. A seeder cannot tell a row the user deleted from a row it never wrote, so re-running one puts back data the user removed on purpose. Recording that a seeder ran does not fix it. See why seeding fits development better for what to do instead.

Seeding is a separate step from migrating, and deliberately so. A migration runs inside the version change transaction and therefore cannot await a fetch. A seeder runs outside it, so it can await anything at all, which makes it the right home for any seed data that comes off the network.

import { DB, Seeder } from '@bjnstnkvc/db';

class UserSeeder extends Seeder {
    /**
     * Seed the database.
     */
    override async run(): Promise<void> {
        const fetched: User[] = await (await fetch('/users.json')).json();

        await DB.table<User>('users').insert(fetched);
    }
}

Register the seeders on the connection and run them when you want to:

await DB.seed('app');

Resolves to the names of the seeders that ran:

['UserSeeder']

DB.seed(name) opens the connection first, which migrates it, so the tables a seeder writes to are guaranteed to exist. The connection name is required for the same reason it is on migrate.

Which connection a seeder writes to

For the duration of the run, the connection being seeded stands in as the default. So a seeder registered on reporting that calls DB.table('users') writes to reporting, not to the configured default, and the previous default is restored when the run finishes or fails. Laravel's SeedCommand does the same thing.

Naming a connection explicitly still wins, so a seeder may reach across:

await DB.connection('app').table<User>('users').insert({ name: 'Alice' });

One consequence worth knowing: while a seed run is in flight, DB.table(...) anywhere in the app resolves to the connection being seeded. Seeding at boot, before the rest of the app starts, keeps that from mattering.

Seeders are not recorded

Unlike migrations, nothing records that a seeder ran. Every call to DB.seed(name) runs every registered seeder again, which matches Laravel and keeps the surface small.

This is the one place where a browser differs from a server in a way that bites. On a server db:seed is a command someone runs. In an app, boot happens on every refresh. Migrations are safe there, since the database is already at the version its migrations ask for and nothing runs. Seeding has no such guard, so a seeder inserting two rows leaves four after the second refresh and six after the third.

There are two ways to handle it, and they answer different questions.

Seed only a database that has never been migrated. DB.status reads the stored version without migrating, so it can be asked before DB.migrate whether this is a first run:

const status: MigrationStatus[] = await DB.status('app');
const fresh: boolean = status.every((entry: MigrationStatus): boolean => !entry.ran);

await DB.migrate('app');

if (fresh) {
    await DB.seed('app');
}

Do not use DB.migrate's return value for this. It reports the migrations that call ran, so it is non-empty for an existing user whenever you add a table, and they would be seeded again. Note also that this seeds a new database only, so a seeder you add later never reaches anyone who already has the app.

Or write seeders that do not care how often they run. This is the better answer for reference data, and it keeps working when you add a seeder later. upsert against a unique index, or insertOrIgnore, makes a second run a no-op:

class UserSeeder extends Seeder {
    /**
     * Seed the database.
     */
    override async run(): Promise<void> {
        await DB.table<User>('users').upsert([
            { email: '[email protected]', name: 'Admin' },
        ], 'email');
    }
}

Seeding is also not atomic across seeders. They run one after another, and a failure in the third leaves the first two committed. A seeder that needs all-or-nothing opens its own transaction:

class UserSeeder extends Seeder {
    /**
     * Seed the database.
     */
    override async run(): Promise<void> {
        await DB.transaction(async (transaction: Transaction): Promise<void> => {
            await transaction.table<User>('users').insert({ name: 'Alice' });
            await transaction.table('posts').insert({ user_id: 1, title: 'Hello' });
        });
    }
}

Why seeding fits development better

Everything above makes a seeder safe to run repeatedly. None of it makes a seeder safe to run against data a user owns, and that limit is structural rather than a gap in this package.

A seeder cannot distinguish a row the user deleted from a row it never wrote, because both are simply absent. An idempotent seeder therefore puts back whatever the user removed.

The examples below share these shapes. settings holds the configuration, seeds records which seeder last ran and what it wrote, and dismissed records the keys the user removed on purpose:

interface Setting {
    id: number;
    key: string;
    value: string;
}

interface Seed {
    seeder: string;
    digest: string;
}

interface Dismissed {
    key: string;
}

type Default = Omit<Setting, 'id'>;

const DEFAULTS: Default[] = [
    { key: 'theme', value: 'dark' },
    { key: 'locale', value: 'en' },
];

const DIGEST: string = 'v1';

Those three are your tables, so a migration creates them like any other. This package creates only migrations and schema, which is why those two names are reserved and nothing else is made for you:

class CreateSettingsTables extends Migration {
    /**
     * Run the migration.
     */
    override async up(): Promise<void> {
        await Schema.create('settings', (table: Blueprint): void => {
            table.id();
            table.string('key').unique();
            table.string('value');
        });

        await Schema.create('seeds', (table: Blueprint): void => {
            table.string('seeder').primary();
            table.string('digest');
        });

        await Schema.create('dismissed', (table: Blueprint): void => {
            table.string('key').primary();
        });
    }
}

The indexes are not decoration. upsert needs its conflict target to be the key path or a unique index, so key on settings is unique and seeder and key are the key paths of the other two. Without them each upsert below would throw SchemaException.

Suppose a seeder installs those defaults, and records the digest so it re-runs only when they actually change:

class ConfigSeeder extends Seeder {
    /**
     * Seed the database.
     */
    override async run(): Promise<void> {
        const seen: Seed | null = await DB.table<Seed>('seeds').find('ConfigSeeder');

        if (seen !== null && seen.digest === DIGEST) {
            return;
        }

        await DB.table<Setting>('settings').upsert(DEFAULTS, 'key');
        await DB.table<Seed>('seeds').upsert([{ seeder: 'ConfigSeeder', digest: DIGEST }], 'seeder');
    }
}

That holds up until the next time the defaults change:

boot 1, seeder v1     ['locale', 'theme']
boot 2, unchanged     ['locale', 'theme']
user deletes locale   ['theme']
boot 3, seeder v2     ['currency', 'locale', 'theme']

Adding currency re-ran the seeder, and locale came back with it. A ledger only defers the problem to the next release, which is why this package does not ship one.

There are two ways out, and neither of them is a seeder.

Keep the defaults in code. Store only what the user changed, and merge when reading:

async function settings(): Promise<Record<string, string>> {
    const defaults: Record<string, string> = Object.fromEntries(
        DEFAULTS.map((row: Default): [string, string] => [row.key, row.value]),
    );

    const overrides: Record<string, string> = await DB.table<Setting>('settings').pluck<string>('value', 'key');

    return { ...defaults, ...overrides };
}

Deleting is then an explicit override rather than an absent row, so nothing can resurrect it, and a new default ships with the app instead of needing a data migration. The cost is that defaults are not rows, so a query cannot filter or join across them.

Or record what the user dismissed. Keep the rows in the table, and have the seeder skip anything the user removed on purpose:

class ConfigSeeder extends Seeder {
    /**
     * Seed the database.
     */
    override async run(): Promise<void> {
        const dismissed: string[] = await DB.table<Dismissed>('dismissed').pluck<string>('key');
        const wanted: Default[] = DEFAULTS.filter((row: Default): boolean => !dismissed.includes(row.key));

        await DB.table<Setting>('settings').upsert(wanted, 'key');
    }
}

Your delete handler writes to dismissed as well as removing the row. The seeder is then free to run on every boot, because the user's intent is recorded rather than inferred. A source column marking which rows the seeder owns pairs well with this, so a seeder never overwrites something the user authored.

Seeding stays the right tool where nobody has edited the data yet: fixtures in tests, demo data behind a developer menu, and one-shot imports where deleting a row carries no meaning.

Rebuilding from scratch

DB.fresh(name) deletes the database and replays the migrations. Pass { seed: true } to seed it afterwards as well, the way migrate:fresh --seed does in Laravel:

await DB.fresh('app');
await DB.fresh('app', { seed: true });

Modeled on Laravel's Database: Seeding, down to the seeded connection standing in as the default for the duration of the run.

Defining a schema

IndexedDB stores whole objects and enforces only a key path, autoIncrement and indexes. Column types are recorded as metadata and enforced by this package at write time.

| Blueprint | Effect | |---------------------------------------------------------------------------------|------------------------------------------------------------------------------------| | table.id() | keyPath: 'id', autoIncrement: true | | table.uuid('id').primary() | keyPath: 'id', no autoIncrement | | table.string / integer / float / boolean / date / datetime / json | Column metadata | | table.decimal('price', 2) | Column metadata, stored as a whole number of the smallest unit | | table.enum('role', Role) | Column metadata, checked at write time. Takes a list, an enum or a constant object | | .nullable() | Metadata, enforced at write time | | .default(value) | Applied at write time, and backfilled when added to an existing table | | .primary() | Makes the column the key path. At most one per table. | | .index() | createIndex('users_name_index', 'name') | | .unique() | createIndex('users_email_unique', 'email', { unique: true }) | | table.index(['a', 'b']) | Compound index | | .multiEntry() | One index entry per array element | | table.timestamps() | Nullable created_at / updated_at, filled automatically |

Altering a table also supports dropColumn, renameColumn, dropIndex and Schema.rename. The key path may not be dropped or renamed, because IndexedDB fixes it when the store is created.

await Schema.table('users', (table: Blueprint): void => {
    table.dropColumn('legacy');
    table.renameColumn('name', 'full_name');
    table.dropIndex('users_age_index');
    table.index(['full_name']);
});

Schema.rename is implemented as create-copy-drop, so it is O(n) in the number of records.

Fixed point columns hold their smallest unit

table.decimal records a scale and stores the value as a plain integer counting the smallest unit that scale describes. A price with two places is written as 1999, not 19.99:

interface Product {
    id: number;
    name: string;
    price: number;
    weight: number;
}

await Schema.create('products', (table: Blueprint): void => {
    table.id();
    table.string('name');
    table.decimal('price');
    table.decimal('weight', 3);
});

await DB.table<Product>('products').insert({ name: 'Keyboard', price: 1999, weight: 1250 });

Writing a fractional value throws, because rounding it silently is how money goes missing:

await DB.table<Product>('products').insert({ name: 'Keyboard', price: 19.99 });
TypeError: A decimal column stores a whole number of its smallest unit, so [19.99] cannot be
written. Scale it first, as in Math.round(19.99 * 100).

The reason for the integer is that JavaScript has one number type and it is a float, so 0.1 + 0.2 is not 0.3. Every sum, every orderBy against an index and every between range would inherit that error. An integer number of pence has none of it, and IndexedDB orders integers exactly.

Scale on the way in and format on the way out. The declared scale is metadata, so a formatter can read it back from Schema.getColumns rather than hardcoding the same 100 in two places:

const columns: ColumnSchema[] = await Schema.getColumns('products');
const places: number = columns.find((column: ColumnSchema): boolean => column.name === 'price')!.places!;

const money = (minor: number): string => (minor / 10 ** places).toFixed(places);

A loose connection rounds instead of throwing, in keeping with every other coercion.

Enumerated columns are checked on the way in

table.enum stores a string and refuses anything outside the declared list:

await Schema.create('users', (table: Blueprint): void => {
    table.id();
    table.string('email').unique();
    table.enum('role', ['admin', 'editor', 'member']).default('member');
    table.enum('tier', ['free', 'paid']).nullable();
});

await DB.table<User>('users').insert({ email: '[email protected]', role: 'owner' });
CheckConstraintViolationException: Column [role] of table [users] does not accept [owner].
It accepts [admin, editor, member].

The check runs on insert, update and upsert, and applies to a nullable column too: null is accepted, an undeclared value is not. A loose connection writes null instead of throwing, so a non-nullable enumerated column still reports the problem as NotNullConstraintViolationException.

Declaring one over an empty list throws SchemaException at migration time, since nothing could ever be written to it.

The list can come from a TypeScript string enum or an as const object instead, which keeps the values in one place and lets the compiler check them at the call site:

enum Role {
    Admin  = 'admin',
    Editor = 'editor',
    Member = 'member',
}

await Schema.create('users', (table: Blueprint): void => {
    table.enum('role', Role).default(Role.Member);
});

The column stores the enum's values, never its keys, so Role.Admin is written as admin. Two members sharing a value collapse to one, since a duplicate would otherwise reach anything rendering the column.

A numeric enum is refused. TypeScript compiles one to an object carrying a reverse mapping, so its runtime values are both the names and the numbers, and there is no string form worth storing:

enum Status {
    Draft,
    Live,
}

table.enum('status', Status);
SchemaException: Column [status] of table [items] is enumerated over a numeric enum, which has no
string form to store. Give the enum string values, or use integer() instead.

The declared values are metadata, so a form can read them back rather than repeating the list:

const columns: ColumnSchema[] = await Schema.getColumns('users');
const roles: string[] = columns.find((column: ColumnSchema): boolean => column.name === 'role')!.values!;

TypeScript is not involved in the check. Narrow the column to a union on your row type if you want the compiler to help as well:

interface User {
    // ...
    role: 'admin' | 'editor' | 'member';
}

Schema outside a migration

Schema.create, Schema.table, Schema.drop, Schema.dropIfExists and Schema.rename need the version-change transaction, so they only run inside a migration and throw SchemaException anywhere else. This is a real divergence from Laravel, where Schema::create() works from anywhere.

The read side works anywhere:

await Schema.hasTable('users');
await Schema.hasColumn('users', 'email');
await Schema.getTables();
await Schema.getColumns('users');
await Schema.getIndexes('users');
await Schema.connection('reporting').hasTable('reports');

Modeled on Laravel's Migrations: Tables. Column types are metadata this package enforces at write time, since IndexedDB stores whole objects and checks nothing itself.

Querying

Every chained method returns the builder straight away. Only a terminal returns a promise.

const users: User[] = await DB.table<User>('users')
    .where('age', '>=', 18)
    .whereIn('role', ['admin', 'owner'])
    .whereNotNull('email')
    .orderBy('created_at', 'desc')
    .limit(10)
    .get();

Constraints

where takes four forms: a column and a value for an implicit =, a column with an explicit operator, an object of column-value pairs, and a closure that opens a nested group.

DB.table<User>('users')
    .where('name', 'John')
    .where('age', '>=', 18)
    .where({ role: 'admin', age: 30 })
    .where((query: Builder<User>): void => {
        query.where('age', 25).orWhere('name', 'Jane');
    })
    .orWhere('role', 'owner')
    .whereNot('role', 'guest')
    .whereIn('role', ['admin', 'owner'])
    .whereNotIn('role', ['guest'])
    .whereNull('age')
    .whereNotNull('email')
    .whereBetween('age', [18, 65])
    .whereNotBetween('age', [0, 17])
    .whereLike('name', 'Jo%')
    .whereNotLike('name', 'Test%');

Every one of these has an or form too: orWhere, orWhereIn, orWhereNotIn, orWhereNull, orWhereNotNull, orWhereBetween, orWhereNotBetween, orWhereLike, orWhereNotLike and orWhereColumn, so a disjunction no longer needs a nested closure.

Operators: =, ==, ===, !=, <>, !==, <, >, <=, >=, like, not like. == is loose and === is strict.

Constraints follow SQL's three-valued logic: a comparison against null is unknown, and negating unknown leaves it unknown. So a record whose age is null satisfies neither whereBetween('age', [18, 65]) nor whereNotBetween('age', [18, 65]). Only whereNull matches it.

like and not like take SQL's wildcards, where % matches any run of characters and _ matches exactly one. Both are case insensitive, both cross newlines, and a backslash escapes a wildcard so '100\\%' matches a literal percent. Everything else in the pattern is a literal, so a pattern full of regular expression syntax matches only itself.

The pattern is matched by a direct scan rather than a regular expression, which matters if your patterns come from a search box. A regular expression compiled from %%%%% backtracks over every way of splitting the value between the wildcards, and that is exponential in their number. The scan walks the value once per wildcard instead, so a hostile or careless pattern costs time in proportion to its length rather than freezing the tab.

Shaping

DB.table<User>('users')
    .select('name', 'email')
    .distinct()
    .orderBy('name')
    .latest('created_at')
    .oldest('created_at')
    .limit(10)
    .offset(20)
    .forPage(2, 15)
    .when(role, (query: Builder<User>, value: unknown): void => query.where('role', value))
    .tap((query: Builder<User>): void => query.where('active', true))
    .clone()
    .dump();

select() projects in memory after the fetch. IndexedDB always returns whole records, so it shapes the result rather than saving any work.

Terminals

await DB.table<User>('users').get();
await DB.table<User>('users').first();
await DB.table<User>('users').firstOrFail();
await DB.table<User>('users').find(1);
await DB.table<User>('users').findOrFail(1);
await DB.table<User>('users').value('email');
await DB.table<User>('users').pluck('email');
await DB.table<User>('users').pluck('email', 'name');
await DB.table<User>('users').exists();
await DB.table<User>('users').doesntExist();
await DB.table<User>('users').count();
await DB.table<User>('users').sum('age');
await DB.table<User>('users').avg('age');
await DB.table<User>('users').min('age');
await DB.table<User>('users').max('age');

| Terminal | Resolves to | |-----------------------------------------------|-----------------------------------------------------------------------------| | get() | T[] | | first() | T or null | | firstOrFail() | T, or throws RecordsNotFoundException | | find(key) | T or null, by point lookup on the key path | | findOrFail(key) | T, or throws RecordsNotFoundException | | value(column) | The column of the first matching record, or null | | pluck(column) | V[] in result order | | pluck(column, key) | Record<string, V>, keyed by a second column | | exists() / doesntExist() | boolean | | count() | number | | sum(column) | number | | avg(column) / min(column) / max(column) | number or null when nothing matched | | sole() | T, or throws RecordsNotFoundException / MultipleRecordsFoundException | | paginate(page?, perPage?) | { data, total, perPage, currentPage, lastPage } |

min and max read the answer straight off the index when the column has one and the query is unconstrained, so they cost one cursor rather than a full scan.

paginate gives you the totals a pager needs, which forPage cannot, and counts what the query matches rather than what the page returns:

const page = await DB.table<User>('users').orderBy('name').paginate(2, 15);
{ data: [ ... ], total: 132, perPage: 15, currentPage: 2, lastPage: 9 }

chunk and each walk the result a page at a time, and stop early when the callback returns false:

await DB.table<User>('users').orderBy('id').chunk(100, async (records: User[], page: number): Promise<void> => {
    await send(records);
});

await DB.table<User>('users').each((user: User, index: number): void => {
    console.log(index, user.name);
});

Writes

await DB.table<User>('users').insert({ name: 'John', email: '[email protected]' });
await DB.table<User>('users').insert([{ /* ... */ }, { /* ... */ }]);
await DB.table<User>('users').insertGetId({ name: 'John', email: '[email protected]' });

await DB.table<User>('users').where('role', 'member').update({ role: 'owner' });
await DB.table<User>('users').updateOrInsert({ email: '[email protected]' }, { name: 'John' });

await DB.table<User>('users').upsert([{ email: '[email protected]', name: 'John' }], 'email');
await DB.table<User>('users').upsert([{ /* ... */ }], 'email', ['name']);

await DB.table<User>('users').where('id', 1).increment('visits');
await DB.table<User>('users').where('id', 1).decrement('credits', 5);

await DB.table<User>('users').where('role', 'guest').delete();
await DB.table<User>('users').truncate();

On insert, the connection applies declared defaults, fills created_at/updated_at when the table declares timestamps(), coerces declared column types, and throws NotNullConstraintViolationException for an absent non-nullable column. On update, only updated_at is touched.

A violated unique index surfaces as UniqueConstraintViolationException naming the table and the index, rather than a bare DOMException.

upsert requires its conflict target to be the key path or a unique index, because IndexedDB cannot enforce anything else. Any other column throws SchemaException.

The key path may not be updated, so update, upsert and increment all refuse it.

update and delete honour limit and offset in the order the plan scans, which is index order when an index drives the query and key order otherwise. Pair them with an indexed orderBy if you need a defined order.

Modeled on Laravel's Database: Query Builder. The method names and their semantics match, and every terminal is asynchronous because IndexedDB is.

Joins

const rows = await DB.table('users')
    .join('posts', 'users.id', '=', 'posts.user_id')
    .where('users.name', 'John')
    .orderBy('posts.created_at', 'desc')
    .get();

join, leftJoin, rightJoin and crossJoin are available. The operator may be left implicit:

await DB.table('users').join('posts', 'users.id', 'posts.user_id').get();

For more than one condition, pass a closure:

await DB.table('users')
    .join('posts', (join: Join): void => {
        join.on('users.id', '=', 'posts.user_id').on('posts.published', '=', 'users.active');
    })
    .get();

The row is flat, and collisions clobber

A joined row is flattened the way SQL hands it back, so a column present on both tables keeps the value from the table joined later:

const row = await DB.table('users').join('posts', 'users.id', '=', 'posts.user_id').first();
{ id: 3, name: 'John', user_id: 2, title: 'Hello' }

That id is posts.id. Since table.timestamps() gives every table a created_at and an updated_at, collisions are the norm rather than the exception on a join. select with an alias is how both sides survive:

const rows = await DB.table('users')
    .join('posts', 'users.id', '=', 'posts.user_id')
    .select('users.id as user_id', 'posts.id as post_id', 'posts.title')
    .get();
[
    { user_id: 1, post_id: 1, title: 'Hello' }
]

as works on any query, joined or not.

Ambiguous columns are rejected, not guessed

Once a join is in play, a bare column name that two tables share cannot be resolved, so it throws SchemaException naming the tables rather than silently picking one:

await DB.table('users').join('posts', 'users.id', '=', 'posts.user_id').where('id', 1).get();
SchemaException: Column [id] is ambiguous across tables [users, posts]. Qualify it, as in [users.id].

A bare column only one table has still resolves, so where('title', 'Hello') is fine. Naming a table the query does not join, or a column no table has, throws in the same way.

A left join nulls the missing side

Every column of the unmatched table comes back null, as in SQL, which makes the usual find-the-orphans query work:

await DB.table('users')
    .leftJoin('posts', 'users.id', '=', 'posts.user_id')
    .whereNull('posts.id')
    .get();

What joins cost, and what they do not support

IndexedDB has no join, so every one is performed in memory. A single equality condition uses a hash join, and anything else falls back to a nested loop. The where clauses still narrow each table through the planner, but the join itself reads both sides in full, so memory is proportional to the tables involved. That is fine at the data volumes a browser holds, and worth knowing before joining two large tables.

Joined queries are read-only. update, delete, insert and upsert are not supported through a join. orderBy on a joined query always sorts in memory, since the row is synthesised and no index covers it, and chunk slices the materialised result rather than walking keys.

whereColumn compares two columns of the same row, and is available on any query:

await DB.table('users').whereColumn('updated_at', '>', 'created_at').get();

Modeled on Laravel's Query Builder: Joins. Rows stay flat as they do in Laravel, and the join itself runs in memory because IndexedDB has none.

Grouping

Laravel spells aggregates as raw SQL, which has nothing to hand a string to here. So the aggregates are named in an object instead, and the alias becomes the key:

const rows = await DB.table<User>('users')
    .where('active', true)
    .groupBy('role')
    .aggregate({
        total : { count: '*' },
        oldest: { max: 'age' },
    })
    .having('total', '>', 5)
    .orderBy('total', 'desc')
    .get();

Resolves to one row per group, carrying the grouped columns and the aggregates:

[
    { role: 'member', total: 12, oldest: 61 },
    { role: 'admin', total: 7, oldest: 44 }
]

Because the alias is an object key rather than a string inside an expression, the result type is inferred rather than cast. That row is typed { role: string; total: number; oldest: number | null }, and reading a column you did not group or aggregate is a compile error.

| Aggregate | Meaning | |-------------------------------------------|-------------------------------------------------------| | { count: '*' } | The number of records in the group, always a number | | { count: 'column' } | The number of records whose column is not null | | { sum: 'column' } | The total, 0 for a group with no values | | { avg: 'column' } | The mean, null for a group with no values | | { min: 'column' } / { max: 'column' } | The extreme, null for a group with no values |

Group by several columns by passing several names:

await DB.table<User>('users').groupBy('team', 'role').aggregate({ total: { count: '*' } }).get();

aggregate() is optional. Grouping with nothing aggregated gives you one row per distinct combination, which is what distinct() does over the same columns.

having, ordering and paging apply to groups

having and orHaving filter the grouped rows, and take the same operators as where. They can name either a grouped column or an aggregate alias, since by then both are just columns on the row.

orderBy, limit and offset on a grouping apply to groups, not records. Any ordering or paging set before groupBy is dropped, because paging records before grouping them is almost never what you meant:

await DB.table<User>('users')
    .groupBy('role')
    .aggregate({ total: { count: '*' } })
    .orderBy('total', 'desc')
    .limit(3)
    .get();

Grouping happens in memory after the records are fetched, so the planner still applies to the where clauses that select them, and a grouped query reports the plan of that underlying fetch.

Modeled on Laravel's Query Builder: Grouping, with the aggregates named in a typed object instead of raw SQL.

Query plans

The builder does not fetch everything and filter in memory. It compiles your constraints into an IndexedDB key range over one index, plus a residual predicate applied while cursoring:

await DB.table<User>('users').where('id', 1).explain();
await DB.table<User>('users').where('email', '[email protected]').explain();
await DB.table<User>('users').where('role', 'admin').explain();

Each resolves to a description of the plan chosen:

'key'
'index:users_email_unique'
'scan'
  • One index only. IndexedDB has no index intersection, so the planner picks the most selective candidate: the key path, then a unique index, then a plain index.
  • orderBy on a single indexed, non-nullable column cursors that index, which lets limit short-circuit the scan. Nullable columns are excluded because an IndexedDB index drops records with no value for its key path, which would silently lose rows.
  • When a range and an order want different indexes, the range wins and the sort happens in memory.
  • Any top-level orWhere forces a full scan.
  • count() with no residual constraints uses count() on the store or index, reading no records.

Transactions

await DB.transaction(async (transaction: Transaction): Promise<void> => {
    const id: IDBValidKey = await transaction.table<User>('users').insertGetId({ name: 'John' });

    await transaction.table('posts').insert({ user_id: id, title: 'Hello' });
});

Throwing inside the callback aborts the transaction and rethrows your error.

By default the transaction covers every table, since the callback's reach is unknowable up front. Narrow it when you care:

await DB.transaction(async (transaction: Transaction): Promise<void> => {
    await transaction.table<User>('users').insert({ name: 'John' });
}, { tables: ['users'] });

A nested DB.transaction joins the one already running. IndexedDB has no savepoints, so there is no partial rollback.

There is no beginTransaction() / commit() / rollBack(). A manually held IndexedDB transaction commits behind your back the first time you await anything outside it, so offering that API would be offering a trap. The same rule as migrations applies here: the callback may only await operations from this package.

Modeled on Laravel's Database: Transactions. The tables have to be declared up front, because an IndexedDB transaction fixes its scope when it opens.

Events

Listen for an event by its key, and the listener receives an instance of the class it maps to:

DB.listen('migration-started', (event: MigrationStarted): void => console.log(event.migration));
DB.listen('query', listener, { once: true });
DB.forget('query', listener);

Every event also has a shortcut named after its class, which takes the same options:

DB.onQueryExecuted((event: QueryExecuted): void => {
    console.log(event.plan, event.duration, event.records);
});

| Key | Class | Carries | |---------------------------|-------------------------|----------------------------------------------------------------------------------------| | query | QueryExecuted | connection, table, plan, constraints, orders, limit, duration, records | | transaction-beginning | TransactionBeginning | connection | | transaction-committed | TransactionCommitted | connection | | transaction-rolled-back | TransactionRolledBack | connection, reason | | migrations-started | MigrationsStarted | connection, migrations | | migration-started | MigrationStarted | migration | | migration-ended | MigrationEnded | migration | | migrations-ended | MigrationsEnded | connection, migrations | | no-pending-migrations | NoPendingMigrations | connection | | seeding-started | SeedingStarted | connection, seeders | | seeder-started | SeederStarted | seeder | | seeder-ended | SeederEnded | seeder | | seeding-ended | SeedingEnded | connection, seeders | | database-blocked | DatabaseBlocked | database |

A connection with no seeders announces nothing, so seeding-started firing always means at least one seeder is about to run.

Modeled on Laravel's Migrations: Events, with each event dispatched as a browser event and listened for by key.

Query log

DB.enableQueryLog();

await DB.table<User>('users').where('role', 'admin').get();

DB.getQueryLog();

Resolves to one entry per query event dispatched while the log was enabled:

[
    { connection: 'app', table: 'users', plan: 'scan', duration: 2.41, records: 7 }
]

Because plan is on every entry, the log is enough to spot a query that scans a whole table.

Durations come from performance.now(), so they are sub-millisecond. disableQueryLog() stops recording but keeps what was already recorded, and the log survives client-side navigation, so only flushQueryLog() empties it.

DB.flushQueryLog();
DB.disableQueryLog();

DB.logging() then returns false, and DB.getQueryLog() an empty array.

Modeled on Laravel's Database: Listening for Query Events, with the same enable, get and flush surface, dispatched as a browser event.

Multiple tabs

IndexedDB is shared across tabs, which produces two situations worth handling:

  • Another tab holds an older version open, blocking an upgrade. The connection emits database-blocked and rejects with DatabaseBlockedException, so you can ask the user to close the other tabs.
  • Another tab upgrades the database. The connection closes its own handle so it does not block that upgrade. If the other tab is running newer code with more migrations, this tab can no longer open the database and reports MigrationMismatchException, so reload the page.

Connections

DB.connection();
DB.connection('reporting');
DB.disconnect('app');
DB.purge('app');

| Call | Effect | |--------------------|----------------------------------------------------------------------------------| | connection() | The default connection | | connection(name) | A named connection, cached after the first resolve | | disconnect(name) | Close the handle, leaving the connection registered so the next query reopens it | | purge(name) | Close it and drop it, so the next resolve rebuilds it from configuration |

Modeled on Laravel's Database: Multiple Connections, resolved by name and cached, with one IndexedDB database behind each.

Storage quota

A browser gives each origin a finite storage budget, and a write that exceeds it fails. This is the likeliest failure a client-side database hits in production, and it has no equivalent in a server database, so it is worth handling explicitly.

The platform reports it as a bare DOMException whose message says nothing about the fix. This package names it instead:

try {
    await DB.table<User>('users').insert(records);
} catch (error) {
    if (error instanceof QuotaExceededException) {
        const { usage, quota } = await DB.estimate();

        console.warn(`Using ${usage} of ${quota} bytes.`);
    }
}

QuotaExceededException is raised from any operation the quota stops, including one that aborts a transaction, so a single catch around a transaction covers everything inside it.

Asking not to be evicted

Browsers evict an origin's IndexedDB under storage pressure. If that happens, your migrations replay against an empty database on the next boot and the data is simply gone. DB.persist() asks the browser to exempt this origin:

await DB.persist();     // true when the browser agreed
await DB.persisted();   // true when this origin is already exempt

Whether the request is granted is up to the browser and depends on things like whether the site is installed or has engagement history. Any app storing data it cares about should ask at boot.

DB.estimate() wraps navigator.storage.estimate(), and reports {} where the Storage Manager is not available rather than throwing.

Reserved tables

migrations and schema are reserved. A migration that tries to create either throws ReservedTableException. Column metadata is read from schema once per connection and cached in memory, so writes inside a narrowed transaction still get their defaults.

Exceptions

Every exception extends Error and sets its own name, so instanceof and the stack both read true. All of them are exported from the package root.

| Exception | Thrown when | |---------------------------------------|-------------------------------------------------------------------------------------------------------------| | CheckConstraintViolationException | A write gives an enumerated column a value it does not accept | | ConnectionNotConfiguredException | A connection is resolved under a name DB.configure never declared | | DatabaseBlockedException | Another tab holds the database open at an older version, so the upgrade cannot start | | MigrationMismatchException | The recorded migration list is not a prefix of the registered one, so one was removed, renamed or reordered | | MigrationTransactionClosedException | A migration awaited something outside this package, letting the versionchange transaction commit early | | MultipleRecordsFoundException | sole() matched more than one record | | NotNullConstraintViolationException | A non-nullable column is written as null, or is absent with no default | | QuotaExceededException | The origin's storage quota stopped the operation | | RecordsNotFoundException | firstOrFail(), sole() or findOrFail() matched nothing | | ReservedTableException | A migration tries to create migrations or schema | | SchemaException | A schema or query call the shape of the database cannot support | | TableNotFoundException | A query or schema read names a table the database does not have | | UniqueConstraintViolationException | A write collides with a unique index, named in the message |

SchemaException is the broad one, so here is every case that raises it:

  • Schema.create, table, drop, dropIfExists or rename called outside a migration
  • Schema.create on a table that already exists, or Schema.rename onto a name already taken
  • dropping or renaming the key path, which IndexedDB fixes when the store is created
  • declaring the same column, or the same index name, twice on one blueprint, or adding a column that the table already has
  • declaring more than one primary column, including .primary() alongside table.id()
  • dropping or renaming a column, or dropping an index, that does not exist on the table
  • declaring an enumerated column over an empty list of values
  • upsert whose conflict target is neither the key path nor a unique index
  • update, upsert, increment or decrement touching the key path
  • a qualified column naming a table the query does not join
  • an unqualified column that is ambiguous across the tables a join reads
  • a column that exists on none of the tables the query reads
  • reading a table inside DB.transaction that the transaction did not declare

Testing

IndexedDB does not exist in Node, so point your test setup at fake-indexeddb:

In tests/setup.ts:

import 'fake-indexeddb/auto';

And in vitest.config.ts:

export default defineConfig({
    test: {
        setupFiles: ['./tests/setup.ts'],
    },
});

Give each test file its own database name so the suites do not share state.