web-idb-client
v1.0.1
Published
Browser-only typed IndexedDB client with a Prisma-like store API and schema-in-TypeScript factories.
Maintainers
Readme
IndexedDbClient
Typed IndexedDB client for the browser (web-idb-client) with a Prisma-like store API, schema-in-TypeScript factories, and explicit migrations.
Features
- Schema via
defineStore/field.*—InferSelect/InferInsertwithout codegen - Store API:
insert,find,findMany,findUnique,update,delete,count,clear - Index-only
where(equality + ranges) andorderBy/take/skip - Multi-store
transaction('r' | 'rw', stores, fn) - Versioned
openwith schema diff + optionalupgradehooks - Opt-in
liveQueryplugin for same/cross-tab UI invalidation
When to use / When not to use
Use this library when you need a typed, Prisma-shaped API over real IndexedDB in the browser — typically for private or corporate web apps: internal SPAs, account cabinets, form drafts, client-side caches, multi-step wizards with local state. Such cases exist, but they are relatively uncommon; pick this library deliberately, not as a default “better localStorage”.
Do not use this library when you need:
- Mobile / React Native as the primary platform — use mature options there (e.g. WatermelonDB, RxDB)
- Offline-first sync / replication across devices — that is a different product category (e.g. RxDB)
- A battle-tested IndexedDB ecosystem with plugins, liveQuery, and years of production hardening — prefer Dexie
- A minimal get/put/cursor wrapper with no ORM surface — prefer idb
- SQL in the browser (WASM SQLite/Postgres) — prefer Drizzle/Kysely-style stacks
Platform: browser IndexedDB only. Not aimed at Node (unless you polyfill IndexedDB) and not planned as a mobile SDK.
Installation
npm install web-idb-clientQuick start
import {
createClient,
defineStore,
field,
type InferSelect,
} from 'web-idb-client';
const tasks = defineStore('tasks', {
id: field.number().primaryKey({ autoIncrement: true }),
name: field.string().index('byName'),
isDone: field.boolean().index('byDone'),
createdAt: field.date().index('byCreatedAt'),
});
type Task = InferSelect<typeof tasks>;
const db = createClient({
name: 'myApp',
version: 1,
stores: { tasks },
});
await db.open();
const id = await db.tasks.insert({
name: 'Learn IndexedDB',
isDone: false,
createdAt: new Date(),
});
const task: Task | null = await db.tasks.find({ where: { id } });
const byName = await db.tasks.findMany({
where: { name: 'Learn IndexedDB' },
});
const newest = await db.tasks.findMany({
orderBy: { createdAt: 'desc' },
take: 10,
});
await db.tasks.update({
where: { id },
data: { isDone: true },
});
await db.transaction('rw', ['tasks'], async (tx) => {
await tx.tasks.insert({
name: 'In a transaction',
isDone: false,
createdAt: new Date(),
});
});Schema
import { defineStore, compoundIndex, field } from 'web-idb-client';
const tasks = defineStore(
'tasks',
{
id: field.number().primaryKey({ autoIncrement: true }),
title: field.string(),
status: field.string().index('byStatus'),
tags: field.array(field.string()).multiEntry('byTags'),
userId: field.string(),
dueAt: field.date().optional(),
},
() => [compoundIndex('byUserDue', ['userId', 'dueAt'])],
);Field helpers: field.string, field.number, field.boolean, field.date, field.json, field.array.
Chain: .optional(), .primaryKey({ autoIncrement? }), .ref(store), .index(name), .uniqueIndex(name), .multiEntry(name).
Only PK and fields with .index() / .uniqueIndex() / .multiEntry() / compoundIndex() become IndexedDB indexes. Other fields are typed for insert/select only.
FK fields: prefer .ref(users).index('byUserId') (or .ref('users')) then loadMany / loadOne; relation endpoints via end(users, 'id') — see docs/relations.md. There is no Prisma-style include.
Boolean fields and indexes
IndexedDB keys cannot be true / false. For every field.boolean() (indexed or not) the client:
- accepts and returns
booleanin the TypeScript API (insert,where,findMany, …); - stores
0 | 1in the object store so indexes like.index('byDone')work.
In DevTools / Application → IndexedDB you will see numbers (0 / 1), not booleans. The app still gets boolean. Legacy rows that already contain true/false are decoded on read; rewrite them to 0|1 on upgrade if you add a boolean index later.
Store methods
| Method | Returns | Notes |
| --- | --- | --- |
| insert(data) | IDBValidKey | add; autoIncrement PK optional on insert |
| insertMany(data[]) | IDBValidKey[] | Sequential adds on one store tx |
| find({ where, orderBy? }) | T \| null | First match (Prisma findFirst style) |
| findMany({ where?, orderBy?, take?, skip?, sortInMemory? }) | T[] | List |
| findUnique({ where }) | T \| null | PK or unique index only |
| update({ where, data }) / updateMany | number | Rows modified |
| delete({ where }) / deleteMany | number | Rows deleted |
| count({ where? }) | number | |
| clear() | void | Clears the store |
where must target the primary key or a declared index (field name or index name).
Range operators (map to IDBKeyRange): equals, gt / gte / lt / lte, aliases above / aboveOrEqual / below / belowOrEqual, startsWith (string prefix), between: [lo, hi] (optional open flags).
Compound indexes:
// exact key
where: { byUserDue: [userId, dueAt] }
// exact prefix + range on the last part only
where: { byUserDue: [userId, { gte: dueAt }] }
// or object form matching the compound keyPath fields
where: { userId, dueAt: { between: [a, b] } }If where and orderBy use different indexes, pass sortInMemory: true (fetches the where set, then sorts in JS — can be expensive). Otherwise the client throws QUERY_INVALID.
Bulk helpers: insertMany, updateMany, deleteMany (the last two are aliases of multi-row update / delete).
Client methods
| Method | Notes |
| --- | --- |
| open() / init() | Opens DB; creates/upgrades stores & indexes |
| close() | Closes connection |
| deleteDatabase() | Deletes the whole DB |
| transaction(mode, storeNames, fn) | mode: 'r' | 'rw'; fn receives typed stores on one IDBTransaction |
Also available: new IndexedDbClient({ name, version, stores }) (same config as createClient).
Upgrade hooks (keyed by target version):
const db = createClient({
name: 'myApp',
version: 2,
stores: { tasks },
upgrade: {
2: (tx) => {
// data transforms during versionchange on `tx`
},
},
});Changing a store keyPath in place is rejected with SCHEMA_MISMATCH unless you recreate the store in an upgrade hook via copyStoreAndDrop — see docs/upgrades.md.
Errors
Thrown as IndexedDbError with code: NOT_OPEN, SCHEMA_INVALID, SCHEMA_MISMATCH, QUERY_INVALID, TRANSACTION_FAILED, OPEN_FAILED, BLOCKED.
Optional context fields: store, index, field (also appended to message).
const db = createClient({
name: 'myApp',
version: 1,
stores: { tasks },
// Reject unknown keys on insert (helpful in corp TS codebases)
strictInsert: true,
});Breaking changes from @azarov-serge/indexed-db-client
- Install / import
web-idb-client(this package) - Removed
from().select/ string-based config (dbName,storageNames,storeNameToIndexes) - Use
defineStore+createClient/new IndexedDbClient({ name, version, stores }) - Prefer
find/findManyinstead ofselect
Docs
- Upgrades / migrations · RU
- Relations (FK helpers) · RU
- liveQuery (opt-in) · RU
- Internal notes: API design (v2) · ORM research · PLAN
Examples
Overview: examples/README.md — 5 Pages routes (hub + 4 demos). Each demo uses src/db/{stores,db,helpers}.ts.
| Page | Command | Covers |
| --- | --- | --- |
| home (/) | npm run home:dev | Links + short blurb for each demo |
| todo (/todo/) | npm run demo:dev | CRUD UI, boolean indexes, transaction, liveQuery |
| relations | npm run relations:dev | ref / loadMany / loadOne |
| queries | npm run queries:dev | Ranges, compound / multiEntry, bulk, strictInsert |
| upgrades | npm run upgrades:dev | copyStoreAndDrop, Store.migrate |
Assemble for GitHub Pages: npm run pages:build → dist-pages/.
Guides: upgrades · relations · liveQuery
License
MIT
