doxor.js
Typed collections for IndexedDB. Declare once, get types, indexes and migrations.
doxor.js 2.0 is a complete rewrite of the 2022 callback API. Coming from 1.x? See Migrating from 1.x.
import { collection, createDB } from 'doxor.js'
type User = { id: number; name: string; email: string; age: number }
const db = createDB({
name: 'app',
collections: {
users: collection<User>().key('id', { autoIncrement: true }).index('email', { unique: true }).index('age'),
},
})
const id = await db.users.insert({ name: 'Ali', email: 'ali@example.com', age: 30 }) // number
const adults = await db.users.where('age').gte(18).limit(20).toArray() // User[]
Why doxor
- Typed from one declaration. Record types, key types, index names and query values all come from
collection<User>(). Typos in collection or index names are compile errors. - No version bookkeeping. Add a collection or an index and doxor upgrades the database on the next load. Data changes go in numbered migrations that run exactly once.
- Promises and real errors. Every operation returns a Promise that resolves after the data is committed,
or rejects with a
DoxorErrorthat has a stablecode(Constraint,Outdated, …). - Safe across tabs. A tab never blocks another tab's upgrade, and a tab running old code is told it is outdated instead of corrupting newer data.
- Small. About 4.4 kB min+brotli, zero dependencies, ESM, tree-shakeable. Safe to import during SSR.
Contents
- Install
- Declaring a database
- Reading and writing
- Queries
- Transactions
- Schema changes and migrations
- Multiple tabs
- Errors
- SSR, frameworks and tests
- API reference
- Browser support
- Comparison
- Migrating from 1.x
Install
npm i doxor.js # pnpm add doxor.js · yarn add doxor.js · bun add doxor.js
From a CDN, without a bundler:
<script type="module">
import { collection, createDB } from 'https://cdn.jsdelivr.net/npm/doxor.js@2/+esm'
</script>
doxor.js is ESM-only and ships its own TypeScript types.
Declaring a database
import { collection, createDB } from 'doxor.js'
type User = { id: number; name: string; email: string; age: number; tags: string[] }
type Note = { slug: string; userId: number; body: string }
type Setting = { value: string }
export const db = createDB({
name: 'app',
collections: {
// Auto-generated numeric keys: `id` is optional on insert.
users: collection<User>()
.key('id', { autoIncrement: true })
.index('email', { unique: true }) // rejects duplicates
.index('name')
.index('age')
.index('tags', { multiEntry: true }), // query arrays by element
// A key you provide yourself.
notes: collection<Note>().key('slug').index('userId'),
// No key property: pass the key separately (a key-value store).
settings: collection<Setting>(),
},
})
createDB() does not touch IndexedDB. The database is opened on the first operation (or await db.ready()), so
it is safe to create at module level. Create one instance per database and share it.
Reading and writing
Each collection is available as db.<name>:
const id = await db.users.insert({ name: 'Ali', email: 'ali@example.com', age: 30, tags: ['admin'] })
await db.users.insertMany([sara, reza]) // atomic: all or nothing
const user = await db.users.get(id) // User | undefined
const some = await db.users.getMany([1, 2, 3]) // (User | undefined)[]
const all = await db.users.toArray()
const total = await db.users.count()
await db.users.update(id, { name: 'Ali R.' }) // merge a patch; false if the record is missing
await db.users.update(id, (u) => ({ ...u, tags: [...u.tags, 'dev'] }))
await db.users.put({ id, name: 'Ali', email: 'ali@example.com', age: 30, tags: [] }) // insert or replace
await db.users.delete(id)
await db.users.clear()
await db.settings.insert({ value: 'dark' }, 'theme') // out-of-line key
await db.settings.get('theme')
- Writes resolve after the transaction commits;
insertandputresolve with the key. - Keys are type-sensitive:
1and'1'are different keys. - Values are stored with the structured clone algorithm: plain objects, arrays,
Date,Map,Blob, … but not functions, class methods or framework proxies (see Vue).
Queries
Queries run over a declared index or the key property:
await db.users.where('age').gte(18).toArray()
await db.users.where('age').between(18, 65).reverse().limit(10).toArray() // [18, 65)
await db.users.where('name').startsWith('A').first()
await db.users.where('tags').anyOf(['admin', 'dev']).count() // each user once
await db.users.where('id').between(100, 200).keys()
await db.users.where('age').lt(13).filter((u) => u.tags.length === 0).delete() // resolves with the count
| Ranges | Modifiers | Results |
|---|---|---|
equals gt gte lt lte between startsWith anyOf |
reverse offset limit filter |
toArray first count keys delete |
- Results are ordered by the indexed value, then by key.
between(a, b)includesaand excludesb; pass{ includeLower, includeUpper }to change that.filter()runs in JavaScript after the index range, so put the most selective condition in the range.- Queries are immutable, so you can build on a shared base query.
Transactions
Standalone operations each run in their own transaction. Group writes across collections with
db.transaction():
const userId = await db.transaction(['users', 'notes'], 'readwrite', async (tx) => {
const id = await tx.users.insert({ name: 'Sara', email: 'sara@example.com', age: 25, tags: [] })
await tx.notes.insert({ slug: `welcome-${id}`, userId: id, body: 'Hello!' })
return id
})
- Everything commits together, or nothing does. If the callback throws, or any request fails (even one you catch), the whole transaction is rolled back.
- Only await operations on
txinside the callback. IndexedDB commits a transaction as soon as it has no pending requests, so awaitingfetch(), timers or other I/O ends it early. doxor detects this and rejects withTransactionInactive. Do that work before or after the transaction. db.users(nottx.users) inside the callback runs outside the transaction.
Schema changes and migrations
Adding a collection or an index needs no ceremony: change the declaration and doxor applies it on the next load, once, keeping the existing data.
Changing data, or removing and renaming things, goes in migrations, numbered from 1:
export const db = createDB({
name: 'app',
collections: {
users: collection<User>().key('id', { autoIncrement: true }).index('email'),
},
migrations: {
1: async (m) => {
await m.collection<User>('users').modify((user) => {
user.email = user.email.toLowerCase()
})
},
2: (m) => {
m.deleteIndex('users', 'email') // re-created from the declaration (now without `unique`)
},
},
})
- Each migration runs once per database, in order, inside the upgrade transaction. A new database gets the final schema directly and skips migrations, since there is no data to migrate.
- Keys must be
1, 2, 3, …. Never renumber or remove a published migration: old users may not have run it yet. To retire one, replace its body with() => {}. - If a migration throws, the whole upgrade (schema and data) is rolled back and the operation rejects with
Migration. Only await doxor operations inside a migration, as with transactions. - doxor never deletes a collection or index on its own. Changing a key path or an index's
unique/multiEntryis rejected withSchemaConflictuntil a migration deletes the old one; it is then rebuilt from the declaration.
Available in a migration: m.collection(name) (get, put, delete, toArray, modify), m.createStore,
m.deleteStore, m.renameStore, m.createIndex, m.deleteIndex.
Multiple tabs
All tabs of your site share one database. doxor handles the hard cases, and tells you through events:
db.on('versionchange', () => {
// Another tab upgraded or deleted the database. doxor already closed this tab's connection
// and reopens it on the next operation.
})
db.on('outdated', ({ databaseLevel, codeLevel }) => {
// This tab runs older code than the database. Reads keep working; writes reject with `Outdated`.
showBanner('A new version is available. Reload to continue.')
})
db.on('blocked', () => {
// An upgrade or delete is waiting for another tab that uses IndexedDB without doxor.
})
db.on('close', () => {
// The browser closed the connection (e.g. site data was cleared). It reopens on the next operation.
})
Errors
Every rejection is a DoxorError. Check error.code; the original error is in error.cause.
import { DoxorError } from 'doxor.js'
try {
await db.users.insert(user)
} catch (error) {
if (error instanceof DoxorError && error.code === 'Constraint') showEmailTaken()
else throw error
}
| Code | Meaning |
|---|---|
Constraint |
The key or a unique index value already exists. |
NotFound |
The collection or index does not exist. |
Data |
Not a valid key, or a required key is missing. |
DataClone |
The value cannot be stored (functions, DOM nodes, proxies). |
Quota |
The storage quota is exceeded. |
ReadOnly |
A write in a readonly transaction. |
TransactionInactive |
The transaction committed before the work finished (an await on non-IndexedDB work). |
Outdated |
Newer code upgraded the database; reload before writing. |
SchemaConflict |
An incompatible schema change without a migration. |
Migration |
A migration failed, or a previous upgrade did not finish. |
Version |
The database kept changing version during an upgrade (concurrent upgrades). |
Unavailable |
No IndexedDB in this environment (SSR, some private modes). |
Aborted |
The transaction or open was aborted. |
Unknown |
Anything else; see cause. |
SSR, frameworks and tests
SSR (Next.js, Nuxt, SvelteKit, Astro). Importing doxor and calling createDB() on the server is safe: nothing
runs until the first operation. Run operations in client-only code (effects, event handlers). On the server they
reject with Unavailable.
Vue / MobX. Reactive proxies cannot be stored. Store plain data: db.todos.put(toRaw(todo)) or
structuredClone(toRaw(todo)).
Persistence. Browsers may evict site data under storage pressure. Call navigator.storage.persist() to ask
for durable storage.
Tests. doxor works with fake-indexeddb in Node:
import 'fake-indexeddb/auto' // installs a global indexedDB
// or inject an implementation explicitly:
import { IDBFactory, IDBKeyRange } from 'fake-indexeddb'
const db = createDB({ name: 'test', collections, indexedDB: new IDBFactory(), IDBKeyRange })
API reference
createDB(options)
| Option | Type | |
|---|---|---|
name |
string |
The IndexedDB database name. |
collections |
Record<string, Collection> |
The collections and their indexes. |
migrations |
Record<number, (m) => void | Promise<void>> |
Data migrations, numbered from 1. |
indexedDB, IDBKeyRange |
A custom implementation (both or neither). |
Returns a database with one table per collection, plus:
| Member | |
|---|---|
db.<collection> |
The collection's table (below). |
db.transaction(names, mode, callback) |
Runs callback(tx) in one transaction. Resolves with its return value. |
db.on(event, listener) |
versionchange, outdated, blocked, close. Returns an unsubscribe function. |
db.ready() |
Opens the database now, to surface errors early. |
db.close() |
Closes the connection; the next operation reopens it. |
db.delete() |
Deletes the whole database. |
collection<T>()
| Method | |
|---|---|
.key(property, { autoIncrement? }) |
Use a property as the primary key. Without .key(), keys are passed separately. |
.index(property, { unique?, multiEntry? }) |
Add a queryable index. |
Tables
| Method | Returns |
|---|---|
insert(value, key?) |
Promise<Key>; rejects with Constraint on duplicates |
insertMany(values) |
Promise<Key[]>, atomic |
put(value, key?) |
Promise<Key>, insert or replace |
putMany(values) |
Promise<Key[]>, atomic |
update(key, patch | (value) => value) |
Promise<boolean> |
get(key) |
Promise<T | undefined> |
getMany(keys) |
Promise<(T | undefined)[]> |
delete(key) |
Promise<void> |
clear() |
Promise<void> |
count() |
Promise<number> |
toArray() |
Promise<T[]> |
where(index) |
A query (see Queries) |
key is only accepted for collections declared without .key().
Browser support
doxor targets ES2022: Chrome and Edge 94+, Firefox 93+, Safari 15.4+. It is tested on Chromium, Firefox and WebKit on every change.
Comparison
| Size (min+gzip) | Types from | Schema changes | Queries | Live queries / sync | |
|---|---|---|---|---|---|
| doxor.js | ~4.9 kB | One declaration | Automatic + numbered migrations | Index ranges, filter | No |
| idb | ~1.4 kB | A DBSchema interface |
Manual upgrade callback |
Raw IndexedDB | No |
| idb-keyval | ~0.8 kB | Generics per call | None (key-value only) | None | No |
| Dexie.js | ~31 kB | Table<T> declarations |
Manual version bumps | Rich query API | Yes (liveQuery, Dexie Cloud) |
| localForage | ~9 kB | Generics per call | None (key-value only) | None | No |
When not to use doxor:
- You only need to store a few values by key: use idb-keyval.
- You need live queries, complex queries or sync between devices: use Dexie.js, RxDB or TanStack DB.
- You want full control over raw IndexedDB with thin promises: use idb.
- You have large relational data or need SQL: consider SQLite (WASM) on OPFS.
Migrating from 1.x
doxor 2.0 replaces the 1.x callback API. 1.x is not maintained.
// 1.x
import Doxor from 'doxor.js'
const db = new Doxor('app')
db.Store({ name: 'users', indexes: [{ key: 'email', unique: true }] })
db.Insert('users', { name: 'Ali', email: 'ali@example.com' })
db.get('users', 1, (user) => console.log(user))
db.getAll('users', (users) => console.log(users))
db.remove('users', 1)
// 2.x
import { collection, createDB } from 'doxor.js'
const db = createDB({
name: 'app',
collections: {
users: collection<User>().key('id', { autoIncrement: true }).index('email', { unique: true }),
},
})
await db.users.insert({ name: 'Ali', email: 'ali@example.com' })
console.log(await db.users.get(1))
console.log(await db.users.toArray())
await db.users.delete(1)
Databases created by 1.x open with 2.x when you declare the same shape: the same collection names, key('id', { autoIncrement: true }) and the same indexes with the same unique settings. Existing data is kept.
Contributing
Issues and pull requests are welcome. See CONTRIBUTING.md for setup, tests and the release process. Please report security issues privately, as described in SECURITY.md.
Credits
Created by Mojtaba Afraz. Thanks to Mostafa Saadatnia for the TypeScript port and to Arsham Arya for early documentation.