idb-ts is a declarative, type-safe ORM layer for IndexedDB. Define your data models with TypeScript decorators, and the library handles schema creation, key generation, validation, querying, transactions, and data retention automatically - with no external runtime dependencies.
npm install idb-ts
pnpm add idb-ts
yarn add idb-ts
Requirement:
reflect-metadatamust be imported once at your application entry point, andexperimentalDecoratorsandemitDecoratorMetadatamust be enabled in yourtsconfig.json.
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
| Feature | Description |
|---|---|
| Declarative entity definition | Define stores, keys, and indexes with class decorators |
| Full CRUD API | Create, read, update, delete, list, paginate, and count |
| Typed query builder | Chainable, type-checked filter, sort, and aggregation DSL |
| Key generation | Auto-increment, UUID v4, timestamp, random, or custom function |
| Composite keys | Multi-field primary keys for relational associations |
| Field validation | Per-property predicate rules enforced on write |
| Schema versioning | Automatic onupgradeneeded migration based on entity versions |
| Transaction API | Callback-based and explicit commit/rollback patterns |
| Data retention | Periodic background cleanup of expired records |
| Automatic timestamps | __idb_createdAt / __idb_updatedAt injected on every write |
import 'reflect-metadata';
import { Database, DataClass, KeyPath, Index } from 'idb-ts';
@DataClass()
class User {
@KeyPath({ generator: 'uuid' })
id!: string;
@Index({ unique: true })
email!: string;
name!: string;
age!: number;
}
const db = await Database.build<{ User: EntityRepository<User> }>('mydb', [
User,
]);
await db.User.create({
id: '',
name: 'Alice',
age: 30,
email: 'alice@example.com',
});
const alice = await db.User.findOneByIndex('email', 'alice@example.com');
Every entity class must declare exactly one primary key field and be annotated with @DataClass(). Apply decorators in the order shown - TypeScript executes decorators bottom-up, so @DataClass must appear last (i.e., closest to the class keyword).
import { Database, DataClass, KeyPath, Index, Validate } from 'idb-ts';
@DataClass({ version: 1 })
class User {
@KeyPath({ generator: 'uuid' })
id!: string;
@Index({ unique: true })
@Validate(
(v) => typeof v === 'string' && v.includes('@'),
'must be a valid email',
)
email!: string;
@Validate((v) => typeof v === 'number' && v >= 0, 'age must be non-negative')
age!: number;
name!: string;
}
@DataClass(options?)Marks a class as a managed entity. Must be applied exactly once per class, after all other idb-ts decorators.
| Option | Type | Default | Description |
|---|---|---|---|
version |
number |
1 |
Schema version. Increment when the entity's store or indexes change. |
@KeyPath(options?)Designates the decorated property as the primary key of the object store. Exactly one property per class may carry this decorator. For multi-field keys, use @CompositeKeyPath at the class level instead.
| Option | Type | Default | Description |
|---|---|---|---|
autoIncrement |
boolean |
false |
Delegate key assignment to IndexedDB's auto-increment mechanism. |
generator |
'uuid' | 'timestamp' | 'random' | (item) => string | number |
- | Automatic key generator invoked when the key field is absent or empty on create. |
@CompositeKeyPath(fields, options?)Class-level decorator for composite primary keys. Cannot be combined with @KeyPath. Write it below @DataClass (decorators are applied bottom-up, and the key path must be registered before @DataClass validates it).
Key generation is not supported for composite keys: passing generator or autoIncrement throws at decoration time. Provide every key field explicitly before create().
@DataClass()
@CompositeKeyPath(['userId', 'projectId'])
class UserProject {
userId!: string;
projectId!: string;
role!: string;
}
@Index(options?)Creates an IDB index on the decorated field, enabling efficient lookups via findByIndex and findOneByIndex.
| Option | Type | Description |
|---|---|---|
unique |
boolean |
Enforce uniqueness on the indexed field. |
@Validate(predicate, message)Attaches a validation rule to the decorated property. Rules are enforced on every create and update call. If any rule fails, the operation throws with a message listing all failing fields.
@Calculated(compute)Derives the decorated property from the rest of the entity on every create and update. See Calculated Fields.
@RetentionPolicy(options)Class-level decorator that configures automatic expiry and deletion of records. See Data Retention for full details.
const db = await Database.build<{
User: EntityRepository<User>;
Order: EntityRepository<Order>;
}>('shop', [User, Order]);
Database.build opens (or upgrades) the IDB database, reconciles the declared schema against the stored one (creating missing stores and indexes and removing indexes that are no longer declared), starts background retention jobs if applicable, and attaches typed repository properties to the returned object.
The declared database version is the highest version value across all registered entities; see Migration behaviour for how drift and downgrades are handled.
db.getDatabaseVersion(); // number - current IDB version
db.getEntityVersions(); // Map<string, number>
db.getEntityVersion('User'); // number | undefined
db.getAvailableEntities(); // string[]
db.close(); // Stops the retention cleanup timer and closes the IDB connection.
Each entity is accessible as a named property on the database object. All methods return Promise.
// Create
await db.User.create(user);
await db.User.createMany([alice, bob, charlie]);
// Read
const user = await db.User.read('u1'); // by primary key
const page = await db.User.listPaginated(1, 20); // 1-based pagination
const all = await db.User.list();
// Update
await db.User.update(updatedUser);
await db.User.updateMany([user1, user2]);
// Delete
await db.User.delete('u1');
await db.User.deleteMany(['u1', 'u2']);
await db.User.deleteWhere((q) => q.where('age').lt(18));
// Utilities
const count = await db.User.count();
const exists = await db.User.exists('u1');
const keys = await db.User.getKeys(); // primary keys only, no record values
await db.User.clear();
const allAdmins = await db.User.findByIndex('role', 'admin');
const firstAdmin = await db.User.findOneByIndex('role', 'admin');
Querying a non-existent index throws immediately.
Every record written through a repository automatically receives two internal fields:
| Field | Type | Set on |
|---|---|---|
__idb_createdAt |
number (ms since epoch) |
create only |
__idb_updatedAt |
number (ms since epoch) |
create and update |
__idb_createdAt is preserved across updates; __idb_updatedAt is refreshed on every write.
const item = await db.Session.read(key);
console.log(item.__idb_createdAt, item.__idb_updatedAt);
EntityRepository.query() returns a typed QueryBuilder<T> for constructing complex filter expressions, sorting, pagination, and aggregations.
const results = await db.User.query()
.where('age')
.gte(18)
.and('status')
.equals('active')
.execute();
| Operator | Field types | Description |
|---|---|---|
equals |
any | Strict equality (===) |
gt / gte / lt / lte |
ComparableValue |
Comparison |
between(start, end) |
ComparableValue |
Inclusive range |
notBetween(start, end) |
ComparableValue |
Outside range |
startsWith / endsWith |
string |
Prefix / suffix match |
contains |
string | array |
Substring or element membership |
matches |
string |
Regular expression test |
in(values) / notIn(values) |
any | Membership test |
containsAny(values) |
array | At least one element matches |
containsAll(values) |
array | All elements present |
TypeScript enforces operator/type compatibility at compile time - string-only operators are not exposed on numeric fields, and so on.
// OR connector
const results = await db.User.query()
.where('age')
.gte(18)
.or()
.where('hasParentalConsent')
.equals(true)
.execute();
// Grouped sub-expression
const premiumOrTrial = await db.User.query()
.where((qb) =>
qb.where('type').equals('premium').and('status').equals('active'),
)
.or()
.where('isTrial')
.equals(true)
.execute();
await db.User.query()
.where('status')
.equals('active')
.orderBy('createdAt', 'desc')
.offset(20)
.limit(10)
.execute();
A builder accumulates state: every where/orderBy/limit call mutates the same instance, so chaining more conditions onto an already-executed builder narrows it further. To derive variations from a shared base use clone(); to start over with the same instance use reset():
const adults = db.User.query().where('age').gte(18);
// Independent variations - neither affects the other or the base
const admins = await adults.clone().where('role').equals('admin').execute();
const guests = await adults.clone().where('role').equals('guest').execute();
// Reuse one instance from scratch
const query = db.User.query();
await query.where('role').equals('admin').execute();
await query.reset().where('age').lt(18).execute(); // fresh state
When a field is indexed, you can constrain the initial IDB candidate set at the storage layer before in-memory filtering begins:
await db.Product.query().useIndex('price').range(10, 100).execute();
The two mechanisms are deliberately distinct:
useIndex(...).range(start, end) narrows candidates natively at the IndexedDB layer via an IDBKeyRange — fast, but limited to one indexed field..where(...) conditions are evaluated in memory after the candidates are fetched. They can target any field (including one different from the index), at the cost of scanning the fetched candidates.Mixing them is valid and useful — the index range prunes the bulk, where() refines the rest. Calling range() without useIndex() throws at execution time instead of silently ignoring the bounds; express such bounds as where(field).between(start, end) instead.
await db.Order.query().where('status').equals('paid').count();
await db.Order.query().sum('amount');
await db.Order.query().avg('price');
await db.Order.query().min('createdAt');
await db.Order.query().max('createdAt');
// Grouped count
const byStatus = await db.Order.query().groupBy('status').count();
// [{ status: 'paid', count: 42 }, { status: 'pending', count: 7 }]
sum and avg are restricted to numeric fields. min and max accept any comparable field. groupBy(...).count() returns results sorted by group key.
@DataClass()
class Task {
@KeyPath({ autoIncrement: true })
id!: number; // Assigned by IndexedDB: 1, 2, 3, …
title!: string;
}
@DataClass()
class Document {
@KeyPath({ generator: 'uuid' }) // RFC 4122 v4
id!: string;
}
@DataClass()
class Event {
@KeyPath({ generator: 'timestamp' }) // Date.now()
id!: number;
}
@DataClass()
class Session {
@KeyPath({ generator: 'random' }) // Base-36 random string
id!: string;
}
@DataClass()
class Invoice {
@KeyPath({
generator: (entity) =>
`INV-${entity.year}-${String(entity.number).padStart(4, '0')}`,
})
invoiceId!: string;
year!: number;
number!: number;
}
// invoiceId → "INV-2024-0001"
import { KeyGenerators } from 'idb-ts';
KeyGenerators.uuid(); // "a1b2c3d4-..."
KeyGenerators.timestamp(); // 1696118400000
KeyGenerators.random(); // "xyz789abc"
Key generation (generator / autoIncrement) is not supported for composite keys and throws at decoration time.
@DataClass()
@CompositeKeyPath(['userId', 'projectId'])
class UserProject {
userId!: string;
projectId!: string;
@Index()
role!: string;
joinedAt!: Date;
}
// Create
await db.UserProject.create(new UserProject('u1', 'p1', 'developer'));
// Read / update / delete with composite key tuple
const rel = await db.UserProject.read(['u1', 'p1']);
await db.UserProject.delete(['u1', 'p1']);
Validation rules are declared per-property with @Validate. All rules for an entity are evaluated before any write; a single thrown error enumerates every failing rule.
@DataClass()
class User {
@KeyPath()
id!: string;
@Validate(
(v) => typeof v === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
'must be a valid email address',
)
email!: string;
@Validate(
(v) => Number.isInteger(v) && v >= 0,
'must be a non-negative integer',
)
age!: number;
}
Error format on failure:
Validation failed for User: email: must be a valid email address; age: must be a non-negative integer
@Calculated derives a property from the rest of the entity on every write. The compute function runs on create and update, before validation and timestamps:
import { Calculated } from 'idb-ts';
@DataClass()
class OrderLine {
@KeyPath({ generator: 'uuid' })
id!: string;
quantity!: number;
unitPrice!: number;
@Calculated<OrderLine>((line) => line.quantity * line.unitPrice)
total!: number;
}
await db.OrderLine.create({ id: '', quantity: 3, unitPrice: 9.5 } as OrderLine);
(await db.OrderLine.read(id))!.total; // 28.5 - computed and persisted
Semantics:
@Validate rules on the same field see the computed value.@Index and queried like any ordinary field.update.The callback receives a TransactionalDatabase handle. On successful return the transaction is committed automatically. Any thrown error triggers an automatic rollback before rethrowing.
await db.transaction(async (tx) => {
await tx.User.create(user);
await tx.Order.create(order);
await tx.OrderItem.create(item);
});
const tx = await db.beginTransaction(['User', 'Order'], 'readwrite');
try {
await tx.User.create(user);
await tx.Order.create(order);
await tx.commit();
} catch (error) {
await tx.rollback();
throw error;
}
All repository operations performed through the tx handle share the same native IDBTransaction, ensuring atomicity. beginTransaction accepts an array of entity names that determines the transaction scope; the callback form spans all registered entities. The default mode is 'readwrite'; pass 'readonly' for read-only workloads. Use tx.Entity.query() to run queries within the same transaction boundary.
@RetentionPolicy triggers a background cleanup job that deletes records whose age exceeds the configured threshold.
@RetentionPolicy({ seconds: 60 * 60 * 24 * 30 }) // 30-day retention
@DataClass()
class Session {
@KeyPath({ generator: 'uuid' })
id!: string;
userId!: string;
}
| Option | Type | Default | Description |
|---|---|---|---|
seconds |
number |
- | (Required) Retention window in seconds. Must be a positive integer. |
enabled |
boolean |
true |
Set to false to suspend cleanup without removing the policy. |
field |
string |
'__idb_createdAt' |
Numeric timestamp field used to compute record age. |
When multiple entities define retention policies, the cleanup interval is set to the GCD of all configured seconds values in milliseconds, so a single timer satisfies every policy efficiently. The job runs immediately on database open and then on each interval tick, using cursor-based readwrite transactions.
Increment an entity's version to trigger onupgradeneeded and update its object store on the user's next visit. The declared database version is the maximum across all registered entities, so adding a new high-version entity is sufficient to initiate a migration.
@DataClass({ version: 1 })
class User {
/* ... */
}
@DataClass({ version: 2 })
class Post {
/* ... */
}
@DataClass({ version: 3 })
class Comment {
/* ... */
}
// Database opens at version 3 and reconciles the full declared schema
// (stores + indexes) inside the upgrade transaction.
const db = await Database.build('blog', [User, Post, Comment]);
console.log(db.getDatabaseVersion()); // 3
Migration is declarative: on every upgrade the actual IndexedDB schema is reconciled against the schema declared by your decorators.
| Change | Handling |
|---|---|
| New entity / store | Created automatically. |
| Index added (even without a version bump) | Detected as schema drift after opening; the database is reopened one version higher and the index is created. Existing records are re-indexed by IndexedDB. |
| Index removed (even without a version bump) | Detected as drift; the stale index is deleted. Record data is not affected. |
Entity version lowered |
IndexedDB cannot downgrade. The database opens at the existing on-disk version instead of throwing VersionError; getDatabaseVersion() reports the on-disk version. |
Key path / autoIncrement changed |
Not applied — IndexedDB cannot change a store's key path in place. A warning is logged; migrate the data to a new entity or delete the database. |
| Entity no longer registered | Its store and data are preserved and a warning is logged. Re-register the entity to access the data again, or delete the store manually. |
Because drift detection may reopen the database one version higher than declared,
getDatabaseVersion()returns the actual IndexedDB version, which can exceed the highest entityversion.
All repository bulk helpers iterate the corresponding single-item operation and therefore enforce validation and key generation per item. They are not issued as a single atomic transaction. For atomic batch writes, use the Transaction API.
await db.User.createMany([alice, bob, charlie]);
await db.User.updateMany([alice, bob]);
await db.User.deleteMany(['u1', 'u2', 'u3']);
Bridge the local IndexedDB with any backend by implementing the two-method SyncAdapter interface. The library stays transport-agnostic - REST, WebSocket, or in-memory adapters all work the same way.
import type { SyncAdapter } from 'idb-ts';
class RestAdapter implements SyncAdapter {
async push(entityName: string, records: unknown[]): Promise<void> {
await fetch(`/api/sync/${entityName}`, {
method: 'PUT',
body: JSON.stringify(records),
});
}
async pull(entityName: string): Promise<unknown[] | undefined> {
const response = await fetch(`/api/sync/${entityName}`);
return response.ok ? response.json() : undefined;
}
}
const adapter = new RestAdapter();
await db.pushTo(adapter); // sends every entity's records to the adapter
await db.pullFrom(adapter); // upserts records returned by the adapter
pushTo calls adapter.push(entityName, records) once per registered entity.pullFrom calls adapter.pull(entityName) per entity and upserts the returned records by primary key; returning undefined leaves that store untouched.Snapshot every registered store to a plain serialisable object, and load such a snapshot back - useful for backups, test fixtures, and moving data between environments.
// Export: { EntityName: records[] } for every registered entity
const dump = await db.exportDatabase();
localStorage.setItem('backup', JSON.stringify(dump));
// Import: writes records verbatim (timestamps preserved), upserting by key
await db.importDatabase(JSON.parse(localStorage.getItem('backup')!));
// Replace instead of merge
await db.importDatabase(dump, { clear: true });
put, so importing over existing keys overwrites those records; other records are kept unless clear: true is passed.__idb_createdAt / __idb_updatedAt fields survive the round-trip verbatim; validation and key generation are bypassed so the restored data matches the exported data exactly.Already up to date Done in 383ms using pnpm v11.9.0
| Operation | Ops | Total ms | Ops/s | Avg ms | P50 | P95 | P99 | Min | Max |
|---|---|---|---|---|---|---|---|---|---|
| create (single) | 200 | 11.668 | 17,141.483 | 0.058 | 0.044 | 0.103 | 0.13 | 0.039 | 0.883 |
| read (by PK) | 200 | 7.972 | 25,087.351 | 0.039 | 0.036 | 0.062 | 0.091 | 0.03 | 0.113 |
| update (single) | 200 | 133.17 | 1,501.843 | 0.665 | 0.615 | 1.01 | 1.184 | 0.545 | 2.036 |
| findByIndex (email) | 200 | 8.942 | 22,365.168 | 0.044 | 0.041 | 0.073 | 0.086 | 0.034 | 0.111 |
| findOneByIndex (email) | 200 | 10.246 | 19,519.677 | 0.051 | 0.044 | 0.057 | 0.072 | 0.039 | 0.998 |
| count | 200 | 8.28 | 24,155.266 | 0.041 | 0.04 | 0.049 | 0.055 | 0.037 | 0.079 |
| exists | 200 | 13.325 | 15,009.758 | 0.066 | 0.052 | 0.09 | 0.118 | 0.046 | 2.085 |
| list (all) | 50 | 96.56 | 517.814 | 1.931 | 1.771 | 4.279 | 6.013 | 1.515 | 6.013 |
| listPaginated (1, 20) | 200 | 398.616 | 501.736 | 1.993 | 1.765 | 3.022 | 8.919 | 1.51 | 10.708 |
| query().where().gte().execute() | 100 | 181.336 | 551.463 | 1.813 | 1.599 | 1.896 | 2.042 | 1.524 | 14.147 |
| delete (single) | 200 | 161.278 | 1,240.095 | 0.806 | 0.773 | 0.896 | 1.019 | 0.64 | 6.043 |
| Operation | Ops | Total ms | Ops/s | Avg ms | P50 | P95 | P99 | Min | Max |
|---|---|---|---|---|---|---|---|---|---|
| createMany (10) | 3 | 1.421 | 2,111.564 | 0.473 | 0.513 | 0.528 | 0.528 | 0.377 | 0.528 |
| read batch (10 keys) | 3 | 0.5 | 5,998.488 | 0.166 | 0.16 | 0.189 | 0.189 | 0.151 | 0.189 |
| updateMany (10) | 3 | 4.472 | 670.902 | 1.49 | 1.452 | 1.646 | 1.646 | 1.37 | 1.646 |
| deleteMany (10) | 3 | 3.163 | 948.378 | 1.054 | 1.054 | 1.139 | 1.139 | 0.969 | 1.139 |
| deleteWhere (10+ match) | 3 | 0.841 | 3,566.321 | 0.28 | 0.278 | 0.312 | 0.312 | 0.249 | 0.312 |
| createMany (50) | 3 | 4.301 | 697.531 | 1.433 | 1.433 | 1.477 | 1.477 | 1.39 | 1.477 |
| read batch (50 keys) | 3 | 3.931 | 763.202 | 1.31 | 1.296 | 1.421 | 1.421 | 1.212 | 1.421 |
| updateMany (50) | 3 | 87.877 | 34.138 | 29.291 | 29.002 | 31.244 | 31.244 | 27.628 | 31.244 |
| deleteMany (50) | 3 | 93.633 | 32.04 | 31.21 | 31.53 | 33.2 | 33.2 | 28.899 | 33.2 |
| deleteWhere (50+ match) | 3 | 3.446 | 870.695 | 1.148 | 1.127 | 1.241 | 1.241 | 1.075 | 1.241 |
| createMany (100) | 3 | 9.571 | 313.443 | 3.19 | 3.074 | 3.8 | 3.8 | 2.695 | 3.8 |
| read batch (100 keys) | 3 | 12.769 | 234.949 | 4.255 | 4.077 | 5.665 | 5.665 | 3.023 | 5.665 |
| updateMany (100) | 3 | 314.564 | 9.537 | 104.853 | 104.907 | 104.963 | 104.963 | 104.69 | 104.963 |
| deleteMany (100) | 3 | 331.429 | 9.052 | 110.474 | 108.426 | 121.766 | 121.766 | 101.228 | 121.766 |
| deleteWhere (100+ match) | 3 | 6.954 | 431.435 | 2.317 | 2.254 | 2.481 | 2.481 | 2.216 | 2.481 |
| createMany (500) | 3 | 88.569 | 33.872 | 29.522 | 27.663 | 42.007 | 42.007 | 18.894 | 42.007 |
| read batch (500 keys) | 3 | 118.5 | 25.316 | 39.498 | 36.064 | 51.839 | 51.839 | 30.592 | 51.839 |
| updateMany (500) | 3 | 8,577.818 | 0.35 | 2,859.271 | 2,855.88 | 2,877.442 | 2,877.442 | 2,844.49 | 2,877.442 |
| deleteMany (500) | 3 | 9,452.42 | 0.317 | 3,150.804 | 3,097.544 | 3,450.784 | 3,450.784 | 2,904.084 | 3,450.784 |
| deleteWhere (500+ match) | 3 | 35.488 | 84.535 | 11.828 | 11.745 | 12.529 | 12.529 | 11.21 | 12.529 |
| Operation | Ops | Total ms | Ops/s | Avg ms | P50 | P95 | P99 | Min | Max |
|---|---|---|---|---|---|---|---|---|---|
| Read-heavy mix (70R/15U/10C/5D) | 200 | 38.005 | 5,262.407 | 0.19 | 0.031 | 0.716 | 0.737 | 0.001 | 0.745 |
| Write-heavy mix (20R/15U/50C/15D) | 200 | 44.985 | 4,445.915 | 0.225 | 0.035 | 0.707 | 0.739 | 0.001 | 3.708 |
| Mixed CRUD + queries | 200 | 83.386 | 2,398.49 | 0.417 | 0.033 | 2.061 | 2.141 | 0.011 | 6.234 |
| Cross-entity mix (User.read + Order.create) | 200 | 6.295 | 31,770.944 | 0.031 | 0.024 | 0.058 | 0.076 | 0.018 | 0.095 |
| Operation | Ops | Total ms | Ops/s | Avg ms | P50 | P95 | P99 | Min | Max |
|---|---|---|---|---|---|---|---|---|---|
| Cycle: createMany -> readAll -> updateMany -> deleteMany (50) | 5 | 104.266 | 47.954 | 20.852 | 15.473 | 36.333 | 36.333 | 9.223 | 36.333 |
| createMany -> query filter -> deleteMany (50) | 5 | 33.079 | 151.155 | 6.615 | 6.368 | 9.824 | 9.824 | 4.751 | 9.824 |
| 5 waves × createMany(50) + deleteMany(50) | 3 | 292.584 | 10.253 | 97.527 | 94.694 | 108.554 | 108.554 | 89.333 | 108.554 |
| Cross-entity batch: createMany(User) + createMany(Order) + deleteMany (×50) | 3 | 136.071 | 22.047 | 45.356 | 43.887 | 49.364 | 49.364 | 42.816 | 49.364 |
| Operation | Ops | Total ms | Ops/s | Avg ms | P50 | P95 | P99 | Min | Max |
|---|---|---|---|---|---|---|---|---|---|
| tx: single create | 100 | 7.5 | 13,332.676 | 0.075 | 0.063 | 0.121 | 0.209 | 0.06 | 0.37 |
| tx: create 10 users | 100 | 23.044 | 4,339.529 | 0.23 | 0.219 | 0.27 | 0.413 | 0.207 | 0.455 |
| tx: read + update | 100 | 194.689 | 513.64 | 1.947 | 1.906 | 1.986 | 3.09 | 1.836 | 5.522 |
| tx: multi-entity create (User+Order+Session) | 100 | 7.336 | 13,632.137 | 0.073 | 0.07 | 0.098 | 0.114 | 0.066 | 0.159 |
| tx: 10 reads | 100 | 15.108 | 6,618.828 | 0.151 | 0.141 | 0.176 | 0.272 | 0.135 | 0.364 |
| tx: batch create 50 users | 20 | 23.258 | 859.911 | 1.163 | 0.953 | 1.68 | 4.091 | 0.926 | 4.091 |
| tx: query().where().gte() | 100 | 946.761 | 105.623 | 9.467 | 8.473 | 18.988 | 19.246 | 8.265 | 19.705 |
| tx (explicit): begin -> create -> commit | 100 | 5.381 | 18,582.249 | 0.054 | 0.052 | 0.067 | 0.078 | 0.049 | 0.081 |
| Operation | Ops | Total ms | Ops/s | Avg ms | P50 | P95 | P99 | Min | Max |
|---|---|---|---|---|---|---|---|---|---|
| tx mixed: read User -> create Order -> update User | 100 | 53.926 | 1,854.389 | 0.539 | 0.437 | 0.462 | 0.59 | 0.41 | 10.318 |
| tx mixed: query User + read Order + create Session | 100 | 113.792 | 878.798 | 1.138 | 1.055 | 1.088 | 1.139 | 1.03 | 8.959 |
| tx multi-entity: create User+Order+Session | 100 | 8.889 | 11,250.085 | 0.089 | 0.087 | 0.102 | 0.106 | 0.082 | 0.132 |
| tx batched: create 20 Users + 40 Orders + 20 Sessions | 10 | 20.246 | 493.934 | 2.024 | 1.136 | 9.953 | 9.953 | 1.096 | 9.953 |
| tx mixed: delete old orders -> create new orders | 100 | 122.343 | 817.377 | 1.223 | 1.145 | 1.309 | 1.386 | 0.981 | 8.579 |
| tx mixed: count Orders -> conditional create | 100 | 7.079 | 14,125.357 | 0.071 | 0.069 | 0.083 | 0.099 | 0.065 | 0.103 |
| tx complex: read User+Orders -> aggregate -> create Session | 100 | 15.71 | 6,365.173 | 0.157 | 0.117 | 0.216 | 0.33 | 0.107 | 2.822 |
Building with an AI coding assistant? Paste the block below into your assistant's context (system prompt, rules file, CLAUDE.md, .cursorrules, etc.) so it generates correct idb-ts code on the first try.
idb-ts cheat sheet (TypeScript ORM for IndexedDB, zero runtime deps):
Setup
- import 'reflect-metadata' once at the app entry point.
- tsconfig: "experimentalDecorators": true, "emitDecoratorMetadata": true.
Entities
- Decorate classes with @DataClass({ version?: number }).
- Exactly one primary key: @KeyPath({ autoIncrement?, generator? }) on a
property, or @CompositeKeyPath(['fieldA','fieldB']) on the class
(written BELOW @DataClass - decorators apply bottom-up).
- generator: 'uuid' | 'timestamp' | 'random' | (item) => string | number.
- Secondary indexes: @Index({ unique?: boolean }) on properties.
- Validation: @Validate((value, item) => boolean, 'message') on properties.
- Auto-expiry: @RetentionPolicy({ seconds, field?, enabled? }) on the class.
Database
- const db = await Database.build<{ User: EntityRepository<User> }>('name', [User]);
- Repositories are attached by class name: db.User, db.Order, ...
- db.close() when done. db.getDatabaseVersion(), db.getAvailableEntities().
Repository API (all Promise-based)
- create(item), createMany(items), read(key), update(item), updateMany(items)
- delete(key), deleteMany(keys), deleteWhere(q => q.where(...))
- list(), listPaginated(page, pageSize), count(), exists(key), clear()
- findByIndex(indexName, value), findOneByIndex(indexName, value)
- query() -> QueryBuilder
QueryBuilder (chainable)
- .where('field').equals/gt/gte/lt/lte/startsWith/endsWith/contains/
matches/between/notBetween/in/notIn/containsAny/containsAll(...)
- .and('field')... / .or().where('field')...
- Nested groups: .where(qb => qb.where('a').equals(1).or().where('b').equals(2))
- .orderBy(field, 'asc'|'desc'), .limit(n), .offset(n)
- .useIndex(indexName).range(start, end) for IDB-level narrowing
- Terminals: .execute(), .count(), .sum(f), .avg(f), .min(f), .max(f),
.groupBy(f).count()
Transactions
- await db.transaction(async tx => { await tx.User.create(u); ... }) // auto commit/rollback
- const tx = await db.beginTransaction(['User','Order']); await tx.commit() / tx.rollback()
Gotchas
- Store names are lower-cased class names; renaming a class = new store.
- Every write injects __idb_createdAt / __idb_updatedAt (ms timestamps).
- where() filters run in memory after candidates are fetched; use
useIndex()+range() to narrow at the IndexedDB layer first.
- Composite keys are passed as arrays: db.UserProject.read(['u1','p1']).
Working on idb-ts itself: the entire library lives in index.ts; tests are in __tests__/ (Jest + fake-indexeddb). Key workflows: pnpm test (type check + Jest), pnpm lint, pnpm build (tsc + rollup into lib/, which is the only published artifact). Keep changes minimal, always add tests, and document user-facing behaviour in this README rather than separate files.
🎉 Enjoy seamless IndexedDB integration with TypeScript! Happy coding! 🚀
Made by Maifee Ulasad with :heart: and :tea:. Licensed under MIT.