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.
@CompositeKeyPath(['userId', 'projectId'])
@DataClass()
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.
@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, creates object stores and indexes for any entity whose version exceeds the stored database version, starts background retention jobs if applicable, and attaches typed repository properties to the returned object.
The effective database version is the highest version value declared across all registered entities.
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');
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();
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();
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"
@CompositeKeyPath(['userId', 'projectId'])
@DataClass()
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
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 effective 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.
// If a user was on version 1, only Post (v2) and Comment (v3) stores are
// created or updated during onupgradeneeded.
const db = await Database.build('blog', [User, Post, Comment]);
console.log(db.getDatabaseVersion()); // 3
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']);
🎉 Enjoy seamless IndexedDB integration with TypeScript! Happy coding! 🚀
Made by Maifee Ulasad with :heart: and :tea:. Licensed under MIT.