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 +329 -415
- package/lib/index.cjs +1158 -0
- package/lib/index.esm.js +39 -46
- package/lib/index.js +5 -21
- package/package.json +3 -3
- package/lib/jest.setup.d.ts +0 -1
- package/lib/jest.setup.js +0 -6
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
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
|
|
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
|
-
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Introduction
|
|
28
30
|
|
|
29
|
-
**idb-ts** is a
|
|
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
|
-
|
|
33
|
+
---
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
## Installation
|
|
34
36
|
|
|
35
37
|
```sh
|
|
36
|
-
npm
|
|
37
|
-
pnpm add idb-ts
|
|
38
|
-
yarn add idb-ts
|
|
38
|
+
npm install idb-ts
|
|
39
|
+
pnpm add idb-ts
|
|
40
|
+
yarn add idb-ts
|
|
39
41
|
```
|
|
40
42
|
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"compilerOptions": {
|
|
48
|
+
"experimentalDecorators": true,
|
|
49
|
+
"emitDecoratorMetadata": true
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
49
53
|
|
|
50
54
|
---
|
|
51
55
|
|
|
52
|
-
##
|
|
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
|
-
|
|
71
|
+
---
|
|
55
72
|
|
|
56
|
-
|
|
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
|
-
|
|
81
|
-
class Location {
|
|
82
|
-
@KeyPath()
|
|
83
|
-
id!: string;
|
|
91
|
+
const db = await Database.build<{ User: EntityRepository<User> }>('mydb', [User]);
|
|
84
92
|
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
97
|
+
---
|
|
99
98
|
|
|
100
|
-
|
|
99
|
+
## Defining Entities
|
|
101
100
|
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
106
|
+
@DataClass({ version: 1 })
|
|
107
|
+
class User {
|
|
108
|
+
@KeyPath({ generator: 'uuid' })
|
|
109
|
+
id!: string;
|
|
114
110
|
|
|
115
|
-
|
|
116
|
-
|
|
111
|
+
@Index({ unique: true })
|
|
112
|
+
@Validate((v) => typeof v === 'string' && v.includes('@'), 'must be a valid email')
|
|
113
|
+
email!: string;
|
|
117
114
|
|
|
118
|
-
|
|
119
|
-
|
|
115
|
+
@Validate((v) => typeof v === 'number' && v >= 0, 'age must be non-negative')
|
|
116
|
+
age!: number;
|
|
120
117
|
|
|
121
|
-
|
|
122
|
-
|
|
118
|
+
name!: string;
|
|
119
|
+
}
|
|
120
|
+
```
|
|
123
121
|
|
|
124
|
-
|
|
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
|
-
|
|
129
|
-
console.log('β User Alice deleted.');
|
|
124
|
+
#### `@DataClass(options?)`
|
|
130
125
|
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
132
|
+
#### `@KeyPath(options?)`
|
|
139
133
|
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
149
|
-
category!: string;
|
|
141
|
+
#### `@CompositeKeyPath(fields, options?)`
|
|
150
142
|
|
|
151
|
-
|
|
152
|
-
price!: number;
|
|
143
|
+
Class-level decorator for composite primary keys. Cannot be combined with `@KeyPath`.
|
|
153
144
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
155
|
+
#### `@Index(options?)`
|
|
173
156
|
|
|
174
|
-
|
|
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
|
-
|
|
159
|
+
| Option | Type | Description |
|
|
160
|
+
|---|---|---|
|
|
161
|
+
| `unique` | `boolean` | Enforce uniqueness on the indexed field. |
|
|
183
162
|
|
|
184
|
-
|
|
185
|
-
- `findOneByIndex(indexName, value): Promise<T | undefined>` - Find the first record matching the index value
|
|
163
|
+
#### `@Validate(predicate, message)`
|
|
186
164
|
|
|
187
|
-
|
|
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
|
-
|
|
167
|
+
#### `@RetentionPolicy(options)`
|
|
190
168
|
|
|
191
|
-
-
|
|
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
|
-
|
|
171
|
+
---
|
|
195
172
|
|
|
196
|
-
|
|
173
|
+
## Database Initialisation
|
|
197
174
|
|
|
198
|
-
```
|
|
199
|
-
const
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
+
The effective database version is the highest `version` value declared across all registered entities.
|
|
206
185
|
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
+
### Closing the connection
|
|
214
196
|
|
|
215
|
-
```
|
|
216
|
-
|
|
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
|
-
|
|
201
|
+
---
|
|
226
202
|
|
|
227
|
-
|
|
228
|
-
- To temporarily disable cleanup for an entity, set `enabled: false` on the decorator.
|
|
203
|
+
## CRUD Operations
|
|
229
204
|
|
|
230
|
-
|
|
205
|
+
Each entity is accessible as a named property on the database object. All methods return `Promise`.
|
|
231
206
|
|
|
232
|
-
|
|
207
|
+
```typescript
|
|
208
|
+
// Create
|
|
209
|
+
await db.User.create(user);
|
|
210
|
+
await db.User.createMany([alice, bob, charlie]);
|
|
233
211
|
|
|
234
|
-
|
|
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
|
-
|
|
217
|
+
// Update
|
|
218
|
+
await db.User.update(updatedUser);
|
|
219
|
+
await db.User.updateMany([user1, user2]);
|
|
237
220
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
251
|
-
age!: number;
|
|
252
|
-
}
|
|
232
|
+
### Index lookups
|
|
253
233
|
|
|
254
|
-
|
|
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
|
-
|
|
239
|
+
Querying a non-existent index throws immediately.
|
|
258
240
|
|
|
259
|
-
|
|
241
|
+
---
|
|
260
242
|
|
|
261
|
-
|
|
243
|
+
## Automatic Timestamps
|
|
262
244
|
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
252
|
+
`__idb_createdAt` is preserved across updates; `__idb_updatedAt` is refreshed on every write.
|
|
270
253
|
|
|
271
|
-
```
|
|
272
|
-
await db.
|
|
273
|
-
|
|
254
|
+
```typescript
|
|
255
|
+
const item = await db.Session.read(key);
|
|
256
|
+
console.log(item.__idb_createdAt, item.__idb_updatedAt);
|
|
274
257
|
```
|
|
275
258
|
|
|
276
|
-
|
|
259
|
+
---
|
|
277
260
|
|
|
278
|
-
|
|
261
|
+
## Query Builder
|
|
279
262
|
|
|
280
|
-
|
|
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
|
-
|
|
274
|
+
#### Available operators
|
|
288
275
|
|
|
289
|
-
|
|
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
|
-
|
|
289
|
+
TypeScript enforces operator/type compatibility at compile time - string-only operators are not exposed on numeric fields, and so on.
|
|
292
290
|
|
|
293
|
-
|
|
291
|
+
### Logical grouping
|
|
294
292
|
|
|
295
293
|
```typescript
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
311
|
+
### Sorting and pagination
|
|
311
312
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
###
|
|
322
|
+
### Index and range acceleration
|
|
320
323
|
|
|
321
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
327
|
+
await db.Product.query()
|
|
328
|
+
.useIndex('price')
|
|
329
|
+
.range(10, 100)
|
|
330
|
+
.execute();
|
|
331
|
+
```
|
|
330
332
|
|
|
331
|
-
|
|
332
|
-
category!: string;
|
|
333
|
+
### Aggregations
|
|
333
334
|
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
console.log(doc.uuid); // e.g., "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
|
350
|
-
```
|
|
349
|
+
---
|
|
350
|
+
|
|
351
|
+
## Key Management
|
|
351
352
|
|
|
352
|
-
|
|
353
|
+
### Auto-increment
|
|
353
354
|
|
|
354
355
|
```typescript
|
|
355
356
|
@DataClass()
|
|
356
|
-
class
|
|
357
|
-
@KeyPath({
|
|
358
|
-
|
|
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
|
-
|
|
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
|
-
|
|
365
|
+
### Built-in generators
|
|
376
366
|
|
|
377
367
|
```typescript
|
|
378
368
|
@DataClass()
|
|
379
|
-
class
|
|
380
|
-
@KeyPath({ generator: '
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
userId!: string;
|
|
384
|
-
expiresAt!: Date;
|
|
369
|
+
class Document {
|
|
370
|
+
@KeyPath({ generator: 'uuid' }) // RFC 4122 v4
|
|
371
|
+
id!: string;
|
|
372
|
+
}
|
|
385
373
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
374
|
+
@DataClass()
|
|
375
|
+
class Event {
|
|
376
|
+
@KeyPath({ generator: 'timestamp' }) // Date.now()
|
|
377
|
+
id!: number;
|
|
390
378
|
}
|
|
391
379
|
|
|
392
|
-
|
|
393
|
-
|
|
380
|
+
@DataClass()
|
|
381
|
+
class Session {
|
|
382
|
+
@KeyPath({ generator: 'random' }) // Base-36 random string
|
|
383
|
+
id!: string;
|
|
384
|
+
}
|
|
394
385
|
```
|
|
395
386
|
|
|
396
|
-
### Custom
|
|
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
|
|
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
|
-
###
|
|
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 {
|
|
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
|
-
|
|
429
|
+
// Create
|
|
430
|
+
await db.UserProject.create(new UserProject('u1', 'p1', 'developer'));
|
|
451
431
|
|
|
452
|
-
//
|
|
453
|
-
await db.UserProject.
|
|
454
|
-
|
|
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
|
-
|
|
460
|
-
const relationship = await db.UserProject.read(['user123', 'project456']);
|
|
461
|
-
console.log(relationship?.role); // "developer"
|
|
437
|
+
---
|
|
462
438
|
|
|
463
|
-
|
|
464
|
-
if (relationship) {
|
|
465
|
-
relationship.role = 'maintainer';
|
|
466
|
-
await db.UserProject.update(relationship);
|
|
467
|
-
}
|
|
439
|
+
## Field Validation
|
|
468
440
|
|
|
469
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
443
|
+
```typescript
|
|
444
|
+
@DataClass()
|
|
445
|
+
class User {
|
|
446
|
+
@KeyPath()
|
|
447
|
+
id!: string;
|
|
475
448
|
|
|
476
|
-
|
|
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
|
-
|
|
455
|
+
@Validate((v) => Number.isInteger(v) && v >= 0, 'must be a non-negative integer')
|
|
456
|
+
age!: number;
|
|
457
|
+
}
|
|
458
|
+
```
|
|
479
459
|
|
|
480
|
-
|
|
481
|
-
import { KeyGenerators } from 'idb-ts';
|
|
460
|
+
Error format on failure:
|
|
482
461
|
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
466
|
+
---
|
|
489
467
|
|
|
490
|
-
|
|
468
|
+
## Transactions
|
|
491
469
|
|
|
492
|
-
|
|
470
|
+
### Callback form (recommended)
|
|
493
471
|
|
|
494
|
-
|
|
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
|
-
|
|
482
|
+
### Explicit form
|
|
505
483
|
|
|
506
|
-
```
|
|
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();
|
|
512
|
-
} catch (
|
|
513
|
-
await tx.rollback();
|
|
489
|
+
await tx.commit();
|
|
490
|
+
} catch (error) {
|
|
491
|
+
await tx.rollback();
|
|
492
|
+
throw error;
|
|
514
493
|
}
|
|
515
494
|
```
|
|
516
495
|
|
|
517
|
-
|
|
496
|
+
### Transaction semantics
|
|
518
497
|
|
|
519
|
-
|
|
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
|
-
|
|
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
|
-
|
|
502
|
+
## Data Retention
|
|
561
503
|
|
|
562
|
-
|
|
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
|
-
|
|
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
|
-
|
|
570
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
|
595
|
-
|
|
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
|
|
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
|
-
|
|
544
|
+
---
|
|
624
545
|
|
|
625
|
-
|
|
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
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
##
|
|
558
|
+
## Useful Links
|
|
647
559
|
|
|
648
|
-
-
|
|
649
|
-
-
|
|
650
|
-
- Demo
|
|
651
|
-
- Code Coverage 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).
|