idb-ts 3.12.0 β†’ 3.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # πŸš€ idb-ts
1
+ # idb-ts
2
2
 
3
3
  <p align="center">
4
4
  <a href="https://www.npmjs.com/package/idb-ts">
@@ -20,414 +20,400 @@
20
20
  <img src="https://img.shields.io/github/watchers/maifeeulasad/idb-ts" alt="GitHub watchers">
21
21
  </a>
22
22
  <a href="https://img.shields.io/github/commits-since/maifeeulasad/idb-ts/latest/main?include_prereleases">
23
- <img src="https://img.shields.io/github/commits-since/maifeeulasad/idb-ts/latest/main?include_prereleases" alt="Commits after release">
23
+ <img src="https://img.shields.io/github/commits-since/maifeeulasad/idb-ts/latest/main?include_prereleases" alt="Commits since release">
24
24
  </a>
25
25
  </p>
26
26
 
27
- ## πŸ“Œ Introduction
27
+ ---
28
+
29
+ ## Introduction
28
30
 
29
- **idb-ts** is a lightweight, declarative, and type-safe way to work with IndexedDB using TypeScript. Effortlessly perform CRUD operations on your database with clean, structured code! πŸ”₯
31
+ **idb-ts** is a declarative, type-safe ORM layer for [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API). 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.
30
32
 
31
- ## πŸ“¦ Installation
33
+ ---
32
34
 
33
- Install via npm and start using IndexedDB like a pro! ⚑
35
+ ## Installation
34
36
 
35
37
  ```sh
36
- npm i idb-ts # for pure npm users
37
- pnpm add idb-ts # for pnpm users
38
- yarn add idb-ts # for yarn users
38
+ npm install idb-ts
39
+ pnpm add idb-ts
40
+ yarn add idb-ts
39
41
  ```
40
42
 
41
- ## ✨ Features
43
+ > **Requirement:** `reflect-metadata` must be imported once at your application entry point, and `experimentalDecorators` and `emitDecoratorMetadata` must be enabled in your `tsconfig.json`.
42
44
 
43
- - βœ… **Declarative & Type-Safe** - Define your data models with decorators.
44
- - ⚑ **Easy CRUD Operations** - Perform create, read, update, and delete seamlessly.
45
- - πŸš€ **Fully Typed API** - Benefit from TypeScript’s powerful type system.
46
- - 🏎️ **Performance Optimized** - Minimal overhead with IndexedDB's native capabilities.
47
- - πŸ”„ **Schema Versioning** - Manage database schema evolution with automatic migration support.
48
- - πŸ”‘ **Advanced Key Management** - Auto-increment, UUID, timestamp, custom generators, and composite keys.
45
+ ```json
46
+ {
47
+ "compilerOptions": {
48
+ "experimentalDecorators": true,
49
+ "emitDecoratorMetadata": true
50
+ }
51
+ }
52
+ ```
49
53
 
50
54
  ---
51
55
 
52
- ## πŸ“– Example Usage
56
+ ## Feature Overview
57
+
58
+ | Feature | Description |
59
+ |---|---|
60
+ | **Declarative entity definition** | Define stores, keys, and indexes with class decorators |
61
+ | **Full CRUD API** | Create, read, update, delete, list, paginate, and count |
62
+ | **Typed query builder** | Chainable, type-checked filter, sort, and aggregation DSL |
63
+ | **Key generation** | Auto-increment, UUID v4, timestamp, random, or custom function |
64
+ | **Composite keys** | Multi-field primary keys for relational associations |
65
+ | **Field validation** | Per-property predicate rules enforced on write |
66
+ | **Schema versioning** | Automatic `onupgradeneeded` migration based on entity versions |
67
+ | **Transaction API** | Callback-based and explicit commit/rollback patterns |
68
+ | **Data retention** | Periodic background cleanup of expired records |
69
+ | **Automatic timestamps** | `__idb_createdAt` / `__idb_updatedAt` injected on every write |
53
70
 
54
- ### πŸ—οΈ Declaring Entities
71
+ ---
55
72
 
56
- Use decorators to define your data models. Each class must have exactly one `@KeyPath()` and be decorated with `@DataClass()`.
73
+ ## Quick Start
57
74
 
58
75
  ```typescript
76
+ import 'reflect-metadata';
59
77
  import { Database, DataClass, KeyPath, Index } from 'idb-ts';
60
78
 
61
79
  @DataClass()
62
80
  class User {
63
- @KeyPath()
81
+ @KeyPath({ generator: 'uuid' })
64
82
  id!: string;
65
83
 
66
- @Index()
84
+ @Index({ unique: true })
67
85
  email!: string;
68
86
 
69
87
  name!: string;
70
88
  age!: number;
71
-
72
- constructor(id: string, name: string, age: number, email?: string) {
73
- this.id = id;
74
- this.name = name;
75
- this.age = age;
76
- this.email = email || `${name.toLowerCase()}@example.com`;
77
- }
78
89
  }
79
90
 
80
- @DataClass()
81
- class Location {
82
- @KeyPath()
83
- id!: string;
91
+ const db = await Database.build<{ User: EntityRepository<User> }>('mydb', [User]);
84
92
 
85
- @Index()
86
- city!: string;
87
-
88
- country!: string;
89
-
90
- constructor(id: string, city: string, country: string) {
91
- this.id = id;
92
- this.city = city;
93
- this.country = country;
94
- }
95
- }
93
+ await db.User.create({ id: '', name: 'Alice', age: 30, email: 'alice@example.com' });
94
+ const alice = await db.User.findOneByIndex('email', 'alice@example.com');
96
95
  ```
97
96
 
98
- ### πŸ”„ CRUD Operations
97
+ ---
99
98
 
100
- Perform database operations using the repository API:
99
+ ## Defining Entities
101
100
 
102
- ```typescript
103
- const db = await Database.build('idb-crud', [User, Location]);
101
+ 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).
104
102
 
105
- const alice = new User('u1', 'Alice', 25);
106
- const bob = new User('u2', 'Bob', 30);
107
- const nyc = new Location('1', 'New York', 'USA');
108
- const sf = new Location('2', 'San Francisco', 'USA');
103
+ ```typescript
104
+ import { Database, DataClass, KeyPath, Index, Validate } from 'idb-ts';
109
105
 
110
- await db.User.create(alice);
111
- await db.User.create(bob);
112
- await db.Location.create(nyc);
113
- await db.Location.create(sf);
106
+ @DataClass({ version: 1 })
107
+ class User {
108
+ @KeyPath({ generator: 'uuid' })
109
+ id!: string;
114
110
 
115
- const readAlice = await db.User.read('u1');
116
- console.log('πŸ‘€ Read user:', readAlice);
111
+ @Index({ unique: true })
112
+ @Validate((v) => typeof v === 'string' && v.includes('@'), 'must be a valid email')
113
+ email!: string;
117
114
 
118
- alice.age = 26;
119
- await db.User.update(alice);
115
+ @Validate((v) => typeof v === 'number' && v >= 0, 'age must be non-negative')
116
+ age!: number;
120
117
 
121
- const users = await db.User.list();
122
- console.log('πŸ“‹ All users:', users);
118
+ name!: string;
119
+ }
120
+ ```
123
121
 
124
- // Pagination
125
- const page1 = await db.User.listPaginated(1, 2); // page 1, 2 users per page
126
- console.log('πŸ“„ Page 1:', page1);
122
+ ### Decorator reference
127
123
 
128
- await db.User.delete('u1');
129
- console.log('❌ User Alice deleted.');
124
+ #### `@DataClass(options?)`
130
125
 
131
- const remainingUsers = await db.User.list();
132
- console.log('πŸ” Remaining users:', remainingUsers);
126
+ Marks a class as a managed entity. Must be applied exactly once per class, after all other idb-ts decorators.
133
127
 
134
- const locations = await db.Location.list();
135
- console.log('🌍 All locations:', locations);
136
- ```
128
+ | Option | Type | Default | Description |
129
+ |---|---|---|---|
130
+ | `version` | `number` | `1` | Schema version. Increment when the entity's store or indexes change. |
137
131
 
138
- ### πŸ” Indexing Support
132
+ #### `@KeyPath(options?)`
139
133
 
140
- Create indexes on fields for fast querying. Query indexes using the repository API:
134
+ 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.
141
135
 
142
- ```typescript
143
- @DataClass()
144
- class Product {
145
- @KeyPath()
146
- id!: string;
136
+ | Option | Type | Default | Description |
137
+ |---|---|---|---|
138
+ | `autoIncrement` | `boolean` | `false` | Delegate key assignment to IndexedDB's auto-increment mechanism. |
139
+ | `generator` | `'uuid'` \| `'timestamp'` \| `'random'` \| `(item) => string \| number` | - | Automatic key generator invoked when the key field is absent or empty on `create`. |
147
140
 
148
- @Index()
149
- category!: string;
141
+ #### `@CompositeKeyPath(fields, options?)`
150
142
 
151
- @Index()
152
- price!: number;
143
+ Class-level decorator for composite primary keys. Cannot be combined with `@KeyPath`.
153
144
 
154
- name!: string;
155
- description!: string;
156
-
157
- constructor(
158
- id: string,
159
- category: string,
160
- price: number,
161
- name: string,
162
- description: string,
163
- ) {
164
- this.id = id;
165
- this.category = category;
166
- this.price = price;
167
- this.name = name;
168
- this.description = description;
169
- }
145
+ ```typescript
146
+ @CompositeKeyPath(['userId', 'projectId'])
147
+ @DataClass()
148
+ class UserProject {
149
+ userId!: string;
150
+ projectId!: string;
151
+ role!: string;
170
152
  }
153
+ ```
171
154
 
172
- const db = await Database.build('products-db', [Product]);
155
+ #### `@Index(options?)`
173
156
 
174
- const electronics = await db.Product.findByIndex('category', 'Electronics');
175
- const expensiveItems = await db.Product.findByIndex('price', 999.99);
176
- const firstElectronic = await db.Product.findOneByIndex(
177
- 'category',
178
- 'Electronics',
179
- );
180
- ```
157
+ Creates an IDB index on the decorated field, enabling efficient lookups via `findByIndex` and `findOneByIndex`.
181
158
 
182
- #### Index Methods:
159
+ | Option | Type | Description |
160
+ |---|---|---|
161
+ | `unique` | `boolean` | Enforce uniqueness on the indexed field. |
183
162
 
184
- - `findByIndex(indexName, value): Promise<T[]>` - Find all records matching the index value
185
- - `findOneByIndex(indexName, value): Promise<T | undefined>` - Find the first record matching the index value
163
+ #### `@Validate(predicate, message)`
186
164
 
187
- ### Creation & Update Timestamps
165
+ 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.
188
166
 
189
- Each entity managed by `idb-ts` automatically gets two internal timestamp fields:
167
+ #### `@RetentionPolicy(options)`
190
168
 
191
- - `__idb_createdAt`: numeric epoch milliseconds set when the record is first created.
192
- - `__idb_updatedAt`: numeric epoch milliseconds updated on each successful update.
169
+ Class-level decorator that configures automatic expiry and deletion of records. See [Data Retention](#data-retention) for full details.
193
170
 
194
- These fields are applied automatically during `create` and `update` operations and can be used for auditing, sorting, or retention policies. They are stored as numbers (milliseconds since Unix epoch).
171
+ ---
195
172
 
196
- Example usage (reading timestamps):
173
+ ## Database Initialisation
197
174
 
198
- ```ts
199
- const item = await db.MyEntity.read('key');
200
- console.log(item.__idb_createdAt, item.__idb_updatedAt);
175
+ ```typescript
176
+ const db = await Database.build<{
177
+ User: EntityRepository<User>;
178
+ Order: EntityRepository<Order>;
179
+ }>('shop', [User, Order]);
201
180
  ```
202
181
 
203
- ### Retention Policy & Cleanup Job
182
+ `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.
204
183
 
205
- `idb-ts` supports per-entity data retention via the `@RetentionPolicy()` class decorator. It accepts the following options:
184
+ The effective database version is the highest `version` value declared across all registered entities.
206
185
 
207
- - `seconds` (required): number of seconds after which records are considered expired.
208
- - `enabled` (optional, default `true`): whether cleanup is active for this entity.
209
- - `field` (optional, default `__idb_createdAt`): the numeric field to use for age calculation (usually creation timestamp).
186
+ ### Inspecting database metadata
210
187
 
211
- When one or more entities register retention policies, the library computes a single cleanup interval equal to the greatest common divisor (GCD) of all configured `seconds` values and runs a background cleanup job at that interval. On each tick the job scans the configured entity stores and deletes records whose `field` value is older than the configured retention window.
188
+ ```typescript
189
+ db.getDatabaseVersion(); // number - current IDB version
190
+ db.getEntityVersions(); // Map<string, number>
191
+ db.getEntityVersion('User'); // number | undefined
192
+ db.getAvailableEntities(); // string[]
193
+ ```
212
194
 
213
- Example:
195
+ ### Closing the connection
214
196
 
215
- ```ts
216
- @RetentionPolicy({ seconds: 60 * 60 * 24 * 30 }) // 30 days
217
- @DataClass()
218
- class Session {
219
- /* ... */
220
- }
221
-
222
- // Database will run a periodic cleanup that removes sessions older than 30 days
197
+ ```typescript
198
+ db.close(); // Stops the retention cleanup timer and closes the IDB connection.
223
199
  ```
224
200
 
225
- Notes:
201
+ ---
226
202
 
227
- - Cleanup runs with readwrite transactions and deletes records one-by-one via cursors. It runs at startup and then periodically. Logs are emitted for inspection when debug logging is enabled.
228
- - To temporarily disable cleanup for an entity, set `enabled: false` on the decorator.
203
+ ## CRUD Operations
229
204
 
230
- ### Field Validation
205
+ Each entity is accessible as a named property on the database object. All methods return `Promise`.
231
206
 
232
- You can declare validation rules for individual properties using the `@Validate(predicate, message)` property decorator. Each rule must provide a predicate function that receives the property value and the full item and returns `true` when valid.
207
+ ```typescript
208
+ // Create
209
+ await db.User.create(user);
210
+ await db.User.createMany([alice, bob, charlie]);
233
211
 
234
- Validation is enforced on `create` and `update` operations. If any rule fails, the repository operation throws an error with a concise message describing the failing fields.
212
+ // Read
213
+ const user = await db.User.read('u1'); // by primary key
214
+ const page = await db.User.listPaginated(1, 20); // 1-based pagination
215
+ const all = await db.User.list();
235
216
 
236
- Example:
217
+ // Update
218
+ await db.User.update(updatedUser);
219
+ await db.User.updateMany([user1, user2]);
237
220
 
238
- ```ts
239
- @DataClass()
240
- class User {
241
- @KeyPath()
242
- id!: string;
221
+ // Delete
222
+ await db.User.delete('u1');
223
+ await db.User.deleteMany(['u1', 'u2']);
224
+ await db.User.deleteWhere((q) => q.where('age').lt(18));
243
225
 
244
- @Validate(
245
- (v) => typeof v === 'string' && v.includes('@'),
246
- 'must be a valid email',
247
- )
248
- email!: string;
226
+ // Utilities
227
+ const count = await db.User.count();
228
+ const exists = await db.User.exists('u1');
229
+ await db.User.clear();
230
+ ```
249
231
 
250
- @Validate((v) => typeof v === 'number' && v >= 0, 'age must be >= 0')
251
- age!: number;
252
- }
232
+ ### Index lookups
253
233
 
254
- await db.User.create(new User('u1', 'alice@example.com', 30));
234
+ ```typescript
235
+ const allAdmins = await db.User.findByIndex('role', 'admin');
236
+ const firstAdmin = await db.User.findOneByIndex('role', 'admin');
255
237
  ```
256
238
 
257
- The thrown error contains all failing rules in the format `field: message` joined by `; `.
239
+ Querying a non-existent index throws immediately.
258
240
 
259
- ### Bulk Operations
241
+ ---
260
242
 
261
- Repositories include convenience bulk helpers for common batch operations:
243
+ ## Automatic Timestamps
262
244
 
263
- - `createMany(items: T[])`: creates multiple items (runs validators and generators for each item).
264
- - `updateMany(items: T[])`: updates multiple items.
265
- - `deleteMany(keys: Array<string | string[] | number>)`: deletes multiple keys.
245
+ Every record written through a repository automatically receives two internal fields:
266
246
 
267
- These helpers are implemented by iterating the corresponding single-item operations. They are convenient for simple bulk workloads but are not currently implemented as a single atomic transaction across all items. For high-throughput or atomic requirements, consider batching items into a single transaction or performing multiple operations inside a custom `performOperation` call.
247
+ | Field | Type | Set on |
248
+ |---|---|---|
249
+ | `__idb_createdAt` | `number` (ms since epoch) | `create` only |
250
+ | `__idb_updatedAt` | `number` (ms since epoch) | `create` and `update` |
268
251
 
269
- Example:
252
+ `__idb_createdAt` is preserved across updates; `__idb_updatedAt` is refreshed on every write.
270
253
 
271
- ```ts
272
- await db.User.createMany([alice, bob, charlie]);
273
- await db.User.deleteMany(['u1', 'u2']);
254
+ ```typescript
255
+ const item = await db.Session.read(key);
256
+ console.log(item.__idb_createdAt, item.__idb_updatedAt);
274
257
  ```
275
258
 
276
- Performance note: `createMany` will trigger validation and key generation per item. If you need large batch inserts frequently, batching these into a single transaction or adding a dedicated bulk API may improve throughput.
259
+ ---
277
260
 
278
- #### Error Handling
261
+ ## Query Builder
279
262
 
280
- - If you query a non-existent index, an error is thrown:
281
- ```typescript
282
- await db.Product.findByIndex('nonexistent', 'value'); // throws
283
- ```
263
+ `EntityRepository.query()` returns a typed `QueryBuilder<T>` for constructing complex filter expressions, sorting, pagination, and aggregations.
284
264
 
285
- ---
265
+ ### Filtering
266
+
267
+ ```typescript
268
+ const results = await db.User.query()
269
+ .where('age').gte(18)
270
+ .and('status').equals('active')
271
+ .execute();
272
+ ```
286
273
 
287
- ## πŸ”‘ Multi-Field & Composite Key Support
274
+ #### Available operators
288
275
 
289
- idb-ts provides flexible key management options including auto-increment keys, key generators, and composite keys for complex data relationships.
276
+ | Operator | Field types | Description |
277
+ |---|---|---|
278
+ | `equals` | any | Strict equality (`===`) |
279
+ | `gt` / `gte` / `lt` / `lte` | `ComparableValue` | Comparison |
280
+ | `between(start, end)` | `ComparableValue` | Inclusive range |
281
+ | `notBetween(start, end)` | `ComparableValue` | Outside range |
282
+ | `startsWith` / `endsWith` | `string` | Prefix / suffix match |
283
+ | `contains` | `string` \| array | Substring or element membership |
284
+ | `matches` | `string` | Regular expression test |
285
+ | `in(values)` / `notIn(values)` | any | Membership test |
286
+ | `containsAny(values)` | array | At least one element matches |
287
+ | `containsAll(values)` | array | All elements present |
290
288
 
291
- ### Auto-Increment Keys
289
+ TypeScript enforces operator/type compatibility at compile time - string-only operators are not exposed on numeric fields, and so on.
292
290
 
293
- Perfect for entities where you want the database to automatically generate sequential IDs:
291
+ ### Logical grouping
294
292
 
295
293
  ```typescript
296
- @DataClass()
297
- class Task {
298
- @KeyPath({ autoIncrement: true })
299
- id!: number;
300
-
301
- title!: string;
302
- completed!: boolean;
294
+ // OR connector
295
+ const results = await db.User.query()
296
+ .where('age').gte(18)
297
+ .or()
298
+ .where('hasParentalConsent').equals(true)
299
+ .execute();
303
300
 
304
- constructor(title: string, completed = false) {
305
- this.title = title;
306
- this.completed = completed;
307
- }
308
- }
301
+ // Grouped sub-expression
302
+ const premiumOrTrial = await db.User.query()
303
+ .where((qb) =>
304
+ qb.where('type').equals('premium').and('status').equals('active'),
305
+ )
306
+ .or()
307
+ .where('isTrial').equals(true)
308
+ .execute();
309
+ ```
309
310
 
310
- const db = await Database.build('tasks-db', [Task]);
311
+ ### Sorting and pagination
311
312
 
312
- // IDs are automatically generated: 1, 2, 3, etc.
313
- const task1 = await db.Task.create(new Task('Learn TypeScript'));
314
- const task2 = await db.Task.create(new Task('Build amazing apps'));
315
- console.log(task1.id); // 1
316
- console.log(task2.id); // 2
313
+ ```typescript
314
+ await db.User.query()
315
+ .where('status').equals('active')
316
+ .orderBy('createdAt', 'desc')
317
+ .offset(20)
318
+ .limit(10)
319
+ .execute();
317
320
  ```
318
321
 
319
- ### Key Generators
322
+ ### Index and range acceleration
320
323
 
321
- Generate keys automatically using built-in generators:
322
-
323
- #### UUID Keys
324
+ When a field is indexed, you can constrain the initial IDB candidate set at the storage layer before in-memory filtering begins:
324
325
 
325
326
  ```typescript
326
- @DataClass()
327
- class Document {
328
- @KeyPath({ generator: 'uuid' })
329
- uuid!: string;
327
+ await db.Product.query()
328
+ .useIndex('price')
329
+ .range(10, 100)
330
+ .execute();
331
+ ```
330
332
 
331
- @Index()
332
- category!: string;
333
+ ### Aggregations
333
334
 
334
- title!: string;
335
- content!: string;
335
+ ```typescript
336
+ await db.Order.query().where('status').equals('paid').count();
337
+ await db.Order.query().sum('amount');
338
+ await db.Order.query().avg('price');
339
+ await db.Order.query().min('createdAt');
340
+ await db.Order.query().max('createdAt');
336
341
 
337
- constructor(category: string, title: string, content: string) {
338
- this.category = category;
339
- this.title = title;
340
- this.content = content;
341
- }
342
- }
342
+ // Grouped count
343
+ const byStatus = await db.Order.query().groupBy('status').count();
344
+ // [{ status: 'paid', count: 42 }, { status: 'pending', count: 7 }]
345
+ ```
343
346
 
344
- const db = await Database.build('docs-db', [Document]);
347
+ `sum` and `avg` are restricted to numeric fields. `min` and `max` accept any comparable field. `groupBy(...).count()` returns results sorted by group key.
345
348
 
346
- const doc = await db.Document.create(
347
- new Document('tutorial', 'Getting Started', 'Welcome...'),
348
- );
349
- console.log(doc.uuid); // e.g., "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
350
- ```
349
+ ---
350
+
351
+ ## Key Management
351
352
 
352
- #### Timestamp Keys
353
+ ### Auto-increment
353
354
 
354
355
  ```typescript
355
356
  @DataClass()
356
- class Event {
357
- @KeyPath({ generator: 'timestamp' })
358
- timestamp!: number;
359
-
360
- @Index()
361
- type!: string;
362
-
363
- data!: any;
357
+ class Task {
358
+ @KeyPath({ autoIncrement: true })
359
+ id!: number; // Assigned by IndexedDB: 1, 2, 3, …
364
360
 
365
- constructor(type: string, data: any) {
366
- this.type = type;
367
- this.data = data;
368
- }
361
+ title!: string;
369
362
  }
370
-
371
- const event = await db.Event.create(new Event('user_login', { userId: '123' }));
372
- console.log(event.timestamp); // e.g., 1696118400000
373
363
  ```
374
364
 
375
- #### Random Keys
365
+ ### Built-in generators
376
366
 
377
367
  ```typescript
378
368
  @DataClass()
379
- class Session {
380
- @KeyPath({ generator: 'random' })
381
- sessionId!: string;
382
-
383
- userId!: string;
384
- expiresAt!: Date;
369
+ class Document {
370
+ @KeyPath({ generator: 'uuid' }) // RFC 4122 v4
371
+ id!: string;
372
+ }
385
373
 
386
- constructor(userId: string, expiresAt: Date) {
387
- this.userId = userId;
388
- this.expiresAt = expiresAt;
389
- }
374
+ @DataClass()
375
+ class Event {
376
+ @KeyPath({ generator: 'timestamp' }) // Date.now()
377
+ id!: number;
390
378
  }
391
379
 
392
- const session = await db.Session.create(new Session('user123', new Date()));
393
- console.log(session.sessionId); // e.g., "xyz789abc123"
380
+ @DataClass()
381
+ class Session {
382
+ @KeyPath({ generator: 'random' }) // Base-36 random string
383
+ id!: string;
384
+ }
394
385
  ```
395
386
 
396
- ### Custom Key Generators
397
-
398
- Create your own key generation logic:
387
+ ### Custom generator
399
388
 
400
389
  ```typescript
401
390
  @DataClass()
402
391
  class Invoice {
403
392
  @KeyPath({
404
- generator: (entity: any) =>
393
+ generator: (entity) =>
405
394
  `INV-${entity.year}-${String(entity.number).padStart(4, '0')}`,
406
395
  })
407
396
  invoiceId!: string;
408
397
 
409
398
  year!: number;
410
399
  number!: number;
411
- amount!: number;
412
-
413
- constructor(year: number, number: number, amount: number) {
414
- this.year = year;
415
- this.number = number;
416
- this.amount = amount;
417
- }
418
400
  }
419
-
420
- const invoice = await db.Invoice.create(new Invoice(2024, 1, 1500.0));
421
- console.log(invoice.invoiceId); // "INV-2024-0001"
401
+ // invoiceId β†’ "INV-2024-0001"
422
402
  ```
423
403
 
424
- ### Composite Keys
425
-
426
- Handle many-to-many relationships with composite keys using the `@CompositeKeyPath` decorator:
404
+ ### Using generators directly
427
405
 
428
406
  ```typescript
429
- import { CompositeKeyPath } from 'idb-ts';
407
+ import { KeyGenerators } from 'idb-ts';
430
408
 
409
+ KeyGenerators.uuid(); // "a1b2c3d4-..."
410
+ KeyGenerators.timestamp(); // 1696118400000
411
+ KeyGenerators.random(); // "xyz789abc"
412
+ ```
413
+
414
+ ### Composite keys
415
+
416
+ ```typescript
431
417
  @CompositeKeyPath(['userId', 'projectId'])
432
418
  @DataClass()
433
419
  class UserProject {
@@ -438,216 +424,144 @@ class UserProject {
438
424
  role!: string;
439
425
 
440
426
  joinedAt!: Date;
441
-
442
- constructor(userId: string, projectId: string, role: string) {
443
- this.userId = userId;
444
- this.projectId = projectId;
445
- this.role = role;
446
- this.joinedAt = new Date();
447
- }
448
427
  }
449
428
 
450
- const db = await Database.build('collaboration-db', [UserProject]);
429
+ // Create
430
+ await db.UserProject.create(new UserProject('u1', 'p1', 'developer'));
451
431
 
452
- // Create relationships
453
- await db.UserProject.create(
454
- new UserProject('user123', 'project456', 'developer'),
455
- );
456
- await db.UserProject.create(new UserProject('user123', 'project789', 'admin'));
457
- await db.UserProject.create(new UserProject('user456', 'project456', 'viewer'));
432
+ // Read / update / delete with composite key tuple
433
+ const rel = await db.UserProject.read(['u1', 'p1']);
434
+ await db.UserProject.delete(['u1', 'p1']);
435
+ ```
458
436
 
459
- // Read with composite key
460
- const relationship = await db.UserProject.read(['user123', 'project456']);
461
- console.log(relationship?.role); // "developer"
437
+ ---
462
438
 
463
- // Update relationship
464
- if (relationship) {
465
- relationship.role = 'maintainer';
466
- await db.UserProject.update(relationship);
467
- }
439
+ ## Field Validation
468
440
 
469
- // Delete with composite key
470
- await db.UserProject.delete(['user123', 'project789']);
441
+ 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.
471
442
 
472
- // Query by role index
473
- const developers = await db.UserProject.findByIndex('role', 'developer');
474
- ```
443
+ ```typescript
444
+ @DataClass()
445
+ class User {
446
+ @KeyPath()
447
+ id!: string;
475
448
 
476
- ### Key Generation Utilities
449
+ @Validate(
450
+ (v) => typeof v === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
451
+ 'must be a valid email address',
452
+ )
453
+ email!: string;
477
454
 
478
- Access key generators directly for your custom logic:
455
+ @Validate((v) => Number.isInteger(v) && v >= 0, 'must be a non-negative integer')
456
+ age!: number;
457
+ }
458
+ ```
479
459
 
480
- ```typescript
481
- import { KeyGenerators } from 'idb-ts';
460
+ Error format on failure:
482
461
 
483
- const uuid = KeyGenerators.uuid(); // Generate UUID
484
- const timestamp = KeyGenerators.timestamp(); // Current timestamp
485
- const random = KeyGenerators.random(); // Random string
462
+ ```
463
+ Validation failed for User: email: must be a valid email address; age: must be a non-negative integer
486
464
  ```
487
465
 
488
- ### Transaction API
466
+ ---
489
467
 
490
- idb-ts provides a simple, atomic Transaction API that lets you group multiple repository operations into a single native IndexedDB transaction. Operations performed inside a transaction are committed together or discarded together on failure.
468
+ ## Transactions
491
469
 
492
- Usage β€” callback form (automatic commit/rollback):
470
+ ### Callback form (recommended)
493
471
 
494
- ```ts
472
+ The callback receives a `TransactionalDatabase` handle. On successful return the transaction is committed automatically. Any thrown error triggers an automatic rollback before rethrowing.
473
+
474
+ ```typescript
495
475
  await db.transaction(async (tx) => {
496
476
  await tx.User.create(user);
497
477
  await tx.Order.create(order);
498
478
  await tx.OrderItem.create(item);
499
- // If the callback returns successfully, the transaction is committed.
500
- // If an exception is thrown, the transaction is aborted and all writes are rolled back.
501
479
  });
502
480
  ```
503
481
 
504
- Usage β€” explicit control:
482
+ ### Explicit form
505
483
 
506
- ```ts
484
+ ```typescript
507
485
  const tx = await db.beginTransaction(['User', 'Order'], 'readwrite');
508
486
  try {
509
487
  await tx.User.create(user);
510
488
  await tx.Order.create(order);
511
- await tx.commit(); // explicitly commit, but optional
512
- } catch (e) {
513
- await tx.rollback(); // abort and rollback
489
+ await tx.commit();
490
+ } catch (error) {
491
+ await tx.rollback();
492
+ throw error;
514
493
  }
515
494
  ```
516
495
 
517
- Key notes and behavior:
496
+ ### Transaction semantics
518
497
 
519
- - Atomicity: all repository writes that use the transaction handle (`tx.Entity.*`) share the same native `IDBTransaction` and are atomic β€” either all succeed (commit) or none persist (abort/rollback).
520
- - Scope: `beginTransaction` accepts an array of entity names (e.g., `['User','Order']`) and opens a native transaction over those object stores. The callback form uses a transaction covering all registered entities.
521
- - Querying inside transactions: use `tx.Entity.query()` or other `tx.Entity.*` repository methods to ensure those reads/writes are performed on the same underlying transaction.
522
- - Modes: transactions support standard IndexedDB modes (`'readonly'` or `'readwrite'`). The default for `beginTransaction` and the callback wrapper is `'readwrite'`.
523
- - Commit/abort semantics: modern browsers may perform implicit commit when the transaction's event loop completes; `commit()` is called when available. `rollback()` triggers `transaction.abort()`.
524
- - Error handling: if an error is thrown in the callback form the library will call `rollback()` and rethrow the error to the caller.
525
- - Limitations: composite operations that span many stores still must list all involved entity names when using `beginTransaction`. Long-running synchronous work inside a transaction can increase the risk of versionchange or blocked events β€” keep transaction work asynchronous and short.
498
+ 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.
526
499
 
527
- Example patterns:
528
-
529
- - Batch inserts in one transaction for performance and atomic safety.
530
- - Run a read-modify-write sequence in a single transaction to avoid lost updates.
531
-
532
- The Transaction API is covered by the test suite in `__tests__/transaction.test.ts` which demonstrates both the callback and explicit modes.
533
-
534
- ### Advanced Querying
535
-
536
- `idb-ts` includes a typed query builder for field-level filtering, logical grouping, and basic aggregations. The available operators are constrained by the field type, so string-only and array-only operations are only exposed where they make sense.
537
-
538
- ```ts
539
- const users = await db.User.query()
540
- .where('name').startsWith('John')
541
- .and('email').endsWith('@gmail.com')
542
- .and('description').contains('important')
543
- .execute();
544
-
545
- const activeOrTrial = await db.User.query()
546
- .where('age').gte(18)
547
- .or()
548
- .where('hasParentalConsent').equals(true)
549
- .execute();
550
-
551
- const premiumUsers = await db.User.query()
552
- .where((qb) =>
553
- qb.where('type').equals('premium').and('status').equals('active'),
554
- )
555
- .or()
556
- .where('isTrial').equals(true)
557
- .execute();
558
- ```
500
+ ---
559
501
 
560
- Supported operators include:
502
+ ## Data Retention
561
503
 
562
- - String operations: `startsWith`, `endsWith`, `contains`, `matches`
563
- - Range operations: `between`, `notBetween`
564
- - Collection operations: `contains`, `containsAny`, `containsAll`, `in`, `notIn`
565
- - Logical chaining: `and()`, `or()`, and grouped predicates via `where((qb) => ...)`
504
+ `@RetentionPolicy` triggers a background cleanup job that deletes records whose age exceeds the configured threshold.
566
505
 
567
- Aggregations are available directly on the query builder:
506
+ ```typescript
507
+ @RetentionPolicy({ seconds: 60 * 60 * 24 * 30 }) // 30-day retention
508
+ @DataClass()
509
+ class Session {
510
+ @KeyPath({ generator: 'uuid' })
511
+ id!: string;
568
512
 
569
- ```ts
570
- await db.Order.query().sum('amount');
571
- await db.Order.query().avg('price');
572
- await db.Order.query().min('date');
573
- await db.Order.query().max('date');
574
- await db.Order.query().groupBy('status').count();
513
+ userId!: string;
514
+ }
575
515
  ```
576
516
 
577
- Notes:
517
+ | Option | Type | Default | Description |
518
+ |---|---|---|---|
519
+ | `seconds` | `number` | - | **(Required)** Retention window in seconds. Must be a positive integer. |
520
+ | `enabled` | `boolean` | `true` | Set to `false` to suspend cleanup without removing the policy. |
521
+ | `field` | `string` | `'__idb_createdAt'` | Numeric timestamp field used to compute record age. |
578
522
 
579
- - `sum()` and `avg()` are numeric-only.
580
- - `min()` and `max()` are available for comparable scalar fields.
581
- - `groupBy(...).count()` returns grouped counts by the selected field.
582
- - TypeScript will reject unsupported operator/field combinations at compile time.
523
+ 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.
583
524
 
584
525
  ---
585
526
 
586
- ## πŸ”„ Schema Versioning
587
-
588
- idb-ts supports schema versioning to manage database evolution over time. Version your entities and let the library handle automatic migration!
527
+ ## Schema Versioning
589
528
 
590
- ### Basic Usage
529
+ 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.
591
530
 
592
531
  ```typescript
593
- @DataClass({ version: 1 })
594
- class User {
595
- @KeyPath() id!: string;
596
- @Index() email!: string;
597
- name!: string;
598
- }
599
-
600
- @DataClass({ version: 2 })
601
- class Post {
602
- @KeyPath() id!: string;
603
- @Index() authorId!: string;
604
- title!: string;
605
- content!: string;
606
- }
607
-
608
- @DataClass({ version: 3 })
609
- class Comment {
610
- @KeyPath() id!: string;
611
- @Index() postId!: string;
612
- @Index() authorId!: string;
613
- text!: string;
614
- }
532
+ @DataClass({ version: 1 }) class User { /* ... */ }
533
+ @DataClass({ version: 2 }) class Post { /* ... */ }
534
+ @DataClass({ version: 3 }) class Comment { /* ... */ }
615
535
 
616
- // Database version will be 3 (highest entity version)
536
+ // Database opens at version 3.
537
+ // If a user was on version 1, only Post (v2) and Comment (v3) stores are
538
+ // created or updated during onupgradeneeded.
617
539
  const db = await Database.build('blog', [User, Post, Comment]);
618
540
 
619
541
  console.log(db.getDatabaseVersion()); // 3
620
- console.log(db.getEntityVersions()); // Map with entity versions
621
542
  ```
622
543
 
623
- ### Key Features
544
+ ---
624
545
 
625
- - **Automatic Version Calculation**: Database version = highest entity version
626
- - **Seamless Migration**: Only new/updated entities are processed during upgrades
627
- - **Backward Compatibility**: Entities without version default to version 1
628
- - **Index Evolution**: New indexes are automatically created during migration
546
+ ## Bulk Operations
629
547
 
630
- ### Version Management
548
+ 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](#transactions).
631
549
 
632
550
  ```typescript
633
- // Check versions
634
- const dbVersion = db.getDatabaseVersion();
635
- const entityVersions = db.getEntityVersions();
636
- const userVersion = db.getEntityVersion('User');
637
-
638
- // Version upgrade flow:
639
- // v1.0: User(v1) -> Database v1
640
- // v1.1: User(v1), Post(v2) -> Database v2
641
- // v1.2: User(v1), Post(v2), Comment(v3) -> Database v3
551
+ await db.User.createMany([alice, bob, charlie]);
552
+ await db.User.updateMany([alice, bob]);
553
+ await db.User.deleteMany(['u1', 'u2', 'u3']);
642
554
  ```
643
555
 
644
556
  ---
645
557
 
646
- ## πŸ”— Useful Links
558
+ ## Useful Links
647
559
 
648
- - πŸ“‚ **GitHub**: [maifeeulasad/idb-ts](https://github.com/maifeeulasad/idb-ts)
649
- - πŸ“¦ **NPM**: [idb-ts](https://www.npmjs.com/package/idb-ts)
650
- - Demo: https://maifeeulasad.github.io/idb-ts/
651
- - Code Coverage report: https://maifeeulasad.github.io/idb-ts/coverage/lcov-report/
560
+ - **GitHub**: [maifeeulasad/idb-ts](https://github.com/maifeeulasad/idb-ts)
561
+ - **NPM**: [idb-ts](https://www.npmjs.com/package/idb-ts)
562
+ - **Demo**: https://maifeeulasad.github.io/idb-ts/
563
+ - **Code Coverage report**: https://maifeeulasad.github.io/idb-ts/coverage/lcov-report/
652
564
 
653
565
  πŸŽ‰ **Enjoy seamless IndexedDB integration with TypeScript! Happy coding!** πŸš€
566
+
567
+ Made by [Maifee Ulasad](https://github.com/maifeeulasad) with :heart: and :tea:. Licensed under [MIT](./LICENSE).