idb-ts 3.14.0 → 3.16.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 +395 -84
- package/lib/index.cjs +457 -102
- package/lib/index.d.ts +31 -8
- package/lib/index.esm.js +457 -103
- package/lib/index.js +457 -103
- package/package.json +15 -12
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
<a href="https://github.com/maifeeulasad/idb-ts/watchers">
|
|
20
20
|
<img src="https://img.shields.io/github/watchers/maifeeulasad/idb-ts" alt="GitHub watchers">
|
|
21
21
|
</a>
|
|
22
|
-
<a href="https://
|
|
22
|
+
<a href="https://github.com/maifeeulasad/idb-ts/commits/main">
|
|
23
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>
|
|
@@ -55,18 +55,18 @@ yarn add idb-ts
|
|
|
55
55
|
|
|
56
56
|
## Feature Overview
|
|
57
57
|
|
|
58
|
-
| Feature
|
|
59
|
-
|
|
60
|
-
| **Declarative entity definition** | Define stores, keys, and indexes with class decorators
|
|
61
|
-
| **Full CRUD API**
|
|
62
|
-
| **Typed query builder**
|
|
63
|
-
| **Key generation**
|
|
64
|
-
| **Composite keys**
|
|
65
|
-
| **Field validation**
|
|
66
|
-
| **Schema versioning**
|
|
67
|
-
| **Transaction API**
|
|
68
|
-
| **Data retention**
|
|
69
|
-
| **Automatic timestamps**
|
|
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 |
|
|
70
70
|
|
|
71
71
|
---
|
|
72
72
|
|
|
@@ -88,9 +88,16 @@ class User {
|
|
|
88
88
|
age!: number;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
const db = await Database.build<{ User: EntityRepository<User> }>('mydb', [
|
|
91
|
+
const db = await Database.build<{ User: EntityRepository<User> }>('mydb', [
|
|
92
|
+
User,
|
|
93
|
+
]);
|
|
92
94
|
|
|
93
|
-
await db.User.create({
|
|
95
|
+
await db.User.create({
|
|
96
|
+
id: '',
|
|
97
|
+
name: 'Alice',
|
|
98
|
+
age: 30,
|
|
99
|
+
email: 'alice@example.com',
|
|
100
|
+
});
|
|
94
101
|
const alice = await db.User.findOneByIndex('email', 'alice@example.com');
|
|
95
102
|
```
|
|
96
103
|
|
|
@@ -109,7 +116,10 @@ class User {
|
|
|
109
116
|
id!: string;
|
|
110
117
|
|
|
111
118
|
@Index({ unique: true })
|
|
112
|
-
@Validate(
|
|
119
|
+
@Validate(
|
|
120
|
+
(v) => typeof v === 'string' && v.includes('@'),
|
|
121
|
+
'must be a valid email',
|
|
122
|
+
)
|
|
113
123
|
email!: string;
|
|
114
124
|
|
|
115
125
|
@Validate((v) => typeof v === 'number' && v >= 0, 'age must be non-negative')
|
|
@@ -125,26 +135,28 @@ class User {
|
|
|
125
135
|
|
|
126
136
|
Marks a class as a managed entity. Must be applied exactly once per class, after all other idb-ts decorators.
|
|
127
137
|
|
|
128
|
-
| Option
|
|
129
|
-
|
|
130
|
-
| `version` | `number` | `1`
|
|
138
|
+
| Option | Type | Default | Description |
|
|
139
|
+
| --------- | -------- | ------- | -------------------------------------------------------------------- |
|
|
140
|
+
| `version` | `number` | `1` | Schema version. Increment when the entity's store or indexes change. |
|
|
131
141
|
|
|
132
142
|
#### `@KeyPath(options?)`
|
|
133
143
|
|
|
134
144
|
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.
|
|
135
145
|
|
|
136
|
-
| Option
|
|
137
|
-
|
|
138
|
-
| `autoIncrement` | `boolean`
|
|
139
|
-
| `generator`
|
|
146
|
+
| Option | Type | Default | Description |
|
|
147
|
+
| --------------- | ----------------------------------------------------------------------- | ------- | ---------------------------------------------------------------------------------- |
|
|
148
|
+
| `autoIncrement` | `boolean` | `false` | Delegate key assignment to IndexedDB's auto-increment mechanism. |
|
|
149
|
+
| `generator` | `'uuid'` \| `'timestamp'` \| `'random'` \| `(item) => string \| number` | - | Automatic key generator invoked when the key field is absent or empty on `create`. |
|
|
140
150
|
|
|
141
151
|
#### `@CompositeKeyPath(fields, options?)`
|
|
142
152
|
|
|
143
|
-
Class-level decorator for composite primary keys. Cannot be combined with `@KeyPath`.
|
|
153
|
+
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).
|
|
154
|
+
|
|
155
|
+
Key generation is not supported for composite keys: passing `generator` or `autoIncrement` throws at decoration time. Provide every key field explicitly before `create()`.
|
|
144
156
|
|
|
145
157
|
```typescript
|
|
146
|
-
@CompositeKeyPath(['userId', 'projectId'])
|
|
147
158
|
@DataClass()
|
|
159
|
+
@CompositeKeyPath(['userId', 'projectId'])
|
|
148
160
|
class UserProject {
|
|
149
161
|
userId!: string;
|
|
150
162
|
projectId!: string;
|
|
@@ -156,14 +168,18 @@ class UserProject {
|
|
|
156
168
|
|
|
157
169
|
Creates an IDB index on the decorated field, enabling efficient lookups via `findByIndex` and `findOneByIndex`.
|
|
158
170
|
|
|
159
|
-
| Option
|
|
160
|
-
|
|
171
|
+
| Option | Type | Description |
|
|
172
|
+
| -------- | --------- | ---------------------------------------- |
|
|
161
173
|
| `unique` | `boolean` | Enforce uniqueness on the indexed field. |
|
|
162
174
|
|
|
163
175
|
#### `@Validate(predicate, message)`
|
|
164
176
|
|
|
165
177
|
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.
|
|
166
178
|
|
|
179
|
+
#### `@Calculated(compute)`
|
|
180
|
+
|
|
181
|
+
Derives the decorated property from the rest of the entity on every `create` and `update`. See [Calculated Fields](#calculated-fields).
|
|
182
|
+
|
|
167
183
|
#### `@RetentionPolicy(options)`
|
|
168
184
|
|
|
169
185
|
Class-level decorator that configures automatic expiry and deletion of records. See [Data Retention](#data-retention) for full details.
|
|
@@ -174,22 +190,22 @@ Class-level decorator that configures automatic expiry and deletion of records.
|
|
|
174
190
|
|
|
175
191
|
```typescript
|
|
176
192
|
const db = await Database.build<{
|
|
177
|
-
User:
|
|
193
|
+
User: EntityRepository<User>;
|
|
178
194
|
Order: EntityRepository<Order>;
|
|
179
195
|
}>('shop', [User, Order]);
|
|
180
196
|
```
|
|
181
197
|
|
|
182
|
-
`Database.build` opens (or upgrades) the IDB database,
|
|
198
|
+
`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.
|
|
183
199
|
|
|
184
|
-
The
|
|
200
|
+
The declared database version is the highest `version` value across all registered entities; see [Migration behaviour](#migration-behaviour) for how drift and downgrades are handled.
|
|
185
201
|
|
|
186
202
|
### Inspecting database metadata
|
|
187
203
|
|
|
188
204
|
```typescript
|
|
189
|
-
db.getDatabaseVersion();
|
|
190
|
-
db.getEntityVersions();
|
|
191
|
-
db.getEntityVersion('User');
|
|
192
|
-
db.getAvailableEntities();
|
|
205
|
+
db.getDatabaseVersion(); // number - current IDB version
|
|
206
|
+
db.getEntityVersions(); // Map<string, number>
|
|
207
|
+
db.getEntityVersion('User'); // number | undefined
|
|
208
|
+
db.getAvailableEntities(); // string[]
|
|
193
209
|
```
|
|
194
210
|
|
|
195
211
|
### Closing the connection
|
|
@@ -210,9 +226,9 @@ await db.User.create(user);
|
|
|
210
226
|
await db.User.createMany([alice, bob, charlie]);
|
|
211
227
|
|
|
212
228
|
// Read
|
|
213
|
-
const user = await db.User.read('u1');
|
|
229
|
+
const user = await db.User.read('u1'); // by primary key
|
|
214
230
|
const page = await db.User.listPaginated(1, 20); // 1-based pagination
|
|
215
|
-
const all
|
|
231
|
+
const all = await db.User.list();
|
|
216
232
|
|
|
217
233
|
// Update
|
|
218
234
|
await db.User.update(updatedUser);
|
|
@@ -224,15 +240,16 @@ await db.User.deleteMany(['u1', 'u2']);
|
|
|
224
240
|
await db.User.deleteWhere((q) => q.where('age').lt(18));
|
|
225
241
|
|
|
226
242
|
// Utilities
|
|
227
|
-
const count
|
|
243
|
+
const count = await db.User.count();
|
|
228
244
|
const exists = await db.User.exists('u1');
|
|
245
|
+
const keys = await db.User.getKeys(); // primary keys only, no record values
|
|
229
246
|
await db.User.clear();
|
|
230
247
|
```
|
|
231
248
|
|
|
232
249
|
### Index lookups
|
|
233
250
|
|
|
234
251
|
```typescript
|
|
235
|
-
const allAdmins
|
|
252
|
+
const allAdmins = await db.User.findByIndex('role', 'admin');
|
|
236
253
|
const firstAdmin = await db.User.findOneByIndex('role', 'admin');
|
|
237
254
|
```
|
|
238
255
|
|
|
@@ -244,9 +261,9 @@ Querying a non-existent index throws immediately.
|
|
|
244
261
|
|
|
245
262
|
Every record written through a repository automatically receives two internal fields:
|
|
246
263
|
|
|
247
|
-
| Field
|
|
248
|
-
|
|
249
|
-
| `__idb_createdAt` | `number` (ms since epoch) | `create` only
|
|
264
|
+
| Field | Type | Set on |
|
|
265
|
+
| ----------------- | ------------------------- | --------------------- |
|
|
266
|
+
| `__idb_createdAt` | `number` (ms since epoch) | `create` only |
|
|
250
267
|
| `__idb_updatedAt` | `number` (ms since epoch) | `create` and `update` |
|
|
251
268
|
|
|
252
269
|
`__idb_createdAt` is preserved across updates; `__idb_updatedAt` is refreshed on every write.
|
|
@@ -266,25 +283,27 @@ console.log(item.__idb_createdAt, item.__idb_updatedAt);
|
|
|
266
283
|
|
|
267
284
|
```typescript
|
|
268
285
|
const results = await db.User.query()
|
|
269
|
-
.where('age')
|
|
270
|
-
.
|
|
286
|
+
.where('age')
|
|
287
|
+
.gte(18)
|
|
288
|
+
.and('status')
|
|
289
|
+
.equals('active')
|
|
271
290
|
.execute();
|
|
272
291
|
```
|
|
273
292
|
|
|
274
293
|
#### Available operators
|
|
275
294
|
|
|
276
|
-
| Operator
|
|
277
|
-
|
|
278
|
-
| `equals`
|
|
279
|
-
| `gt` / `gte` / `lt` / `lte`
|
|
280
|
-
| `between(start, end)`
|
|
281
|
-
| `notBetween(start, end)`
|
|
282
|
-
| `startsWith` / `endsWith`
|
|
283
|
-
| `contains`
|
|
284
|
-
| `matches`
|
|
285
|
-
| `in(values)` / `notIn(values)` | any
|
|
286
|
-
| `containsAny(values)`
|
|
287
|
-
| `containsAll(values)`
|
|
295
|
+
| Operator | Field types | Description |
|
|
296
|
+
| ------------------------------ | ----------------- | ------------------------------- |
|
|
297
|
+
| `equals` | any | Strict equality (`===`) |
|
|
298
|
+
| `gt` / `gte` / `lt` / `lte` | `ComparableValue` | Comparison |
|
|
299
|
+
| `between(start, end)` | `ComparableValue` | Inclusive range |
|
|
300
|
+
| `notBetween(start, end)` | `ComparableValue` | Outside range |
|
|
301
|
+
| `startsWith` / `endsWith` | `string` | Prefix / suffix match |
|
|
302
|
+
| `contains` | `string` \| array | Substring or element membership |
|
|
303
|
+
| `matches` | `string` | Regular expression test |
|
|
304
|
+
| `in(values)` / `notIn(values)` | any | Membership test |
|
|
305
|
+
| `containsAny(values)` | array | At least one element matches |
|
|
306
|
+
| `containsAll(values)` | array | All elements present |
|
|
288
307
|
|
|
289
308
|
TypeScript enforces operator/type compatibility at compile time - string-only operators are not exposed on numeric fields, and so on.
|
|
290
309
|
|
|
@@ -293,9 +312,11 @@ TypeScript enforces operator/type compatibility at compile time - string-only op
|
|
|
293
312
|
```typescript
|
|
294
313
|
// OR connector
|
|
295
314
|
const results = await db.User.query()
|
|
296
|
-
.where('age')
|
|
315
|
+
.where('age')
|
|
316
|
+
.gte(18)
|
|
297
317
|
.or()
|
|
298
|
-
.where('hasParentalConsent')
|
|
318
|
+
.where('hasParentalConsent')
|
|
319
|
+
.equals(true)
|
|
299
320
|
.execute();
|
|
300
321
|
|
|
301
322
|
// Grouped sub-expression
|
|
@@ -304,7 +325,8 @@ const premiumOrTrial = await db.User.query()
|
|
|
304
325
|
qb.where('type').equals('premium').and('status').equals('active'),
|
|
305
326
|
)
|
|
306
327
|
.or()
|
|
307
|
-
.where('isTrial')
|
|
328
|
+
.where('isTrial')
|
|
329
|
+
.equals(true)
|
|
308
330
|
.execute();
|
|
309
331
|
```
|
|
310
332
|
|
|
@@ -312,24 +334,46 @@ const premiumOrTrial = await db.User.query()
|
|
|
312
334
|
|
|
313
335
|
```typescript
|
|
314
336
|
await db.User.query()
|
|
315
|
-
.where('status')
|
|
337
|
+
.where('status')
|
|
338
|
+
.equals('active')
|
|
316
339
|
.orderBy('createdAt', 'desc')
|
|
317
340
|
.offset(20)
|
|
318
341
|
.limit(10)
|
|
319
342
|
.execute();
|
|
320
343
|
```
|
|
321
344
|
|
|
345
|
+
### Reusing builders
|
|
346
|
+
|
|
347
|
+
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()`:
|
|
348
|
+
|
|
349
|
+
```typescript
|
|
350
|
+
const adults = db.User.query().where('age').gte(18);
|
|
351
|
+
|
|
352
|
+
// Independent variations - neither affects the other or the base
|
|
353
|
+
const admins = await adults.clone().where('role').equals('admin').execute();
|
|
354
|
+
const guests = await adults.clone().where('role').equals('guest').execute();
|
|
355
|
+
|
|
356
|
+
// Reuse one instance from scratch
|
|
357
|
+
const query = db.User.query();
|
|
358
|
+
await query.where('role').equals('admin').execute();
|
|
359
|
+
await query.reset().where('age').lt(18).execute(); // fresh state
|
|
360
|
+
```
|
|
361
|
+
|
|
322
362
|
### Index and range acceleration
|
|
323
363
|
|
|
324
364
|
When a field is indexed, you can constrain the initial IDB candidate set at the storage layer before in-memory filtering begins:
|
|
325
365
|
|
|
326
366
|
```typescript
|
|
327
|
-
await db.Product.query()
|
|
328
|
-
.useIndex('price')
|
|
329
|
-
.range(10, 100)
|
|
330
|
-
.execute();
|
|
367
|
+
await db.Product.query().useIndex('price').range(10, 100).execute();
|
|
331
368
|
```
|
|
332
369
|
|
|
370
|
+
The two mechanisms are deliberately distinct:
|
|
371
|
+
|
|
372
|
+
- `useIndex(...).range(start, end)` narrows candidates **natively at the IndexedDB layer** via an `IDBKeyRange` — fast, but limited to one indexed field.
|
|
373
|
+
- `.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.
|
|
374
|
+
|
|
375
|
+
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.
|
|
376
|
+
|
|
333
377
|
### Aggregations
|
|
334
378
|
|
|
335
379
|
```typescript
|
|
@@ -356,7 +400,7 @@ const byStatus = await db.Order.query().groupBy('status').count();
|
|
|
356
400
|
@DataClass()
|
|
357
401
|
class Task {
|
|
358
402
|
@KeyPath({ autoIncrement: true })
|
|
359
|
-
id!: number;
|
|
403
|
+
id!: number; // Assigned by IndexedDB: 1, 2, 3, …
|
|
360
404
|
|
|
361
405
|
title!: string;
|
|
362
406
|
}
|
|
@@ -367,7 +411,7 @@ class Task {
|
|
|
367
411
|
```typescript
|
|
368
412
|
@DataClass()
|
|
369
413
|
class Document {
|
|
370
|
-
@KeyPath({ generator: 'uuid' })
|
|
414
|
+
@KeyPath({ generator: 'uuid' }) // RFC 4122 v4
|
|
371
415
|
id!: string;
|
|
372
416
|
}
|
|
373
417
|
|
|
@@ -379,7 +423,7 @@ class Event {
|
|
|
379
423
|
|
|
380
424
|
@DataClass()
|
|
381
425
|
class Session {
|
|
382
|
-
@KeyPath({ generator: 'random' })
|
|
426
|
+
@KeyPath({ generator: 'random' }) // Base-36 random string
|
|
383
427
|
id!: string;
|
|
384
428
|
}
|
|
385
429
|
```
|
|
@@ -406,16 +450,18 @@ class Invoice {
|
|
|
406
450
|
```typescript
|
|
407
451
|
import { KeyGenerators } from 'idb-ts';
|
|
408
452
|
|
|
409
|
-
KeyGenerators.uuid();
|
|
453
|
+
KeyGenerators.uuid(); // "a1b2c3d4-..."
|
|
410
454
|
KeyGenerators.timestamp(); // 1696118400000
|
|
411
|
-
KeyGenerators.random();
|
|
455
|
+
KeyGenerators.random(); // "xyz789abc"
|
|
412
456
|
```
|
|
413
457
|
|
|
414
458
|
### Composite keys
|
|
415
459
|
|
|
460
|
+
Key generation (`generator` / `autoIncrement`) is not supported for composite keys and throws at decoration time.
|
|
461
|
+
|
|
416
462
|
```typescript
|
|
417
|
-
@CompositeKeyPath(['userId', 'projectId'])
|
|
418
463
|
@DataClass()
|
|
464
|
+
@CompositeKeyPath(['userId', 'projectId'])
|
|
419
465
|
class UserProject {
|
|
420
466
|
userId!: string;
|
|
421
467
|
projectId!: string;
|
|
@@ -452,7 +498,10 @@ class User {
|
|
|
452
498
|
)
|
|
453
499
|
email!: string;
|
|
454
500
|
|
|
455
|
-
@Validate(
|
|
501
|
+
@Validate(
|
|
502
|
+
(v) => Number.isInteger(v) && v >= 0,
|
|
503
|
+
'must be a non-negative integer',
|
|
504
|
+
)
|
|
456
505
|
age!: number;
|
|
457
506
|
}
|
|
458
507
|
```
|
|
@@ -465,6 +514,38 @@ Validation failed for User: email: must be a valid email address; age: must be a
|
|
|
465
514
|
|
|
466
515
|
---
|
|
467
516
|
|
|
517
|
+
## Calculated Fields
|
|
518
|
+
|
|
519
|
+
`@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:
|
|
520
|
+
|
|
521
|
+
```typescript
|
|
522
|
+
import { Calculated } from 'idb-ts';
|
|
523
|
+
|
|
524
|
+
@DataClass()
|
|
525
|
+
class OrderLine {
|
|
526
|
+
@KeyPath({ generator: 'uuid' })
|
|
527
|
+
id!: string;
|
|
528
|
+
|
|
529
|
+
quantity!: number;
|
|
530
|
+
unitPrice!: number;
|
|
531
|
+
|
|
532
|
+
@Calculated<OrderLine>((line) => line.quantity * line.unitPrice)
|
|
533
|
+
total!: number;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
await db.OrderLine.create({ id: '', quantity: 3, unitPrice: 9.5 } as OrderLine);
|
|
537
|
+
(await db.OrderLine.read(id))!.total; // 28.5 - computed and persisted
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
Semantics:
|
|
541
|
+
|
|
542
|
+
- Any value the caller assigns to a calculated field is **overwritten** by the compute function on write.
|
|
543
|
+
- `@Validate` rules on the same field see the computed value.
|
|
544
|
+
- The value is persisted, so it can be indexed with `@Index` and queried like any ordinary field.
|
|
545
|
+
- It reflects entity state as of the **last write** - update the inputs and the field recomputes on the next `update`.
|
|
546
|
+
|
|
547
|
+
---
|
|
548
|
+
|
|
468
549
|
## Transactions
|
|
469
550
|
|
|
470
551
|
### Callback form (recommended)
|
|
@@ -514,11 +595,11 @@ class Session {
|
|
|
514
595
|
}
|
|
515
596
|
```
|
|
516
597
|
|
|
517
|
-
| Option
|
|
518
|
-
|
|
519
|
-
| `seconds` | `number`
|
|
520
|
-
| `enabled` | `boolean` | `true`
|
|
521
|
-
| `field`
|
|
598
|
+
| Option | Type | Default | Description |
|
|
599
|
+
| --------- | --------- | ------------------- | ----------------------------------------------------------------------- |
|
|
600
|
+
| `seconds` | `number` | - | **(Required)** Retention window in seconds. Must be a positive integer. |
|
|
601
|
+
| `enabled` | `boolean` | `true` | Set to `false` to suspend cleanup without removing the policy. |
|
|
602
|
+
| `field` | `string` | `'__idb_createdAt'` | Numeric timestamp field used to compute record age. |
|
|
522
603
|
|
|
523
604
|
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.
|
|
524
605
|
|
|
@@ -526,21 +607,44 @@ When multiple entities define retention policies, the cleanup interval is set to
|
|
|
526
607
|
|
|
527
608
|
## Schema Versioning
|
|
528
609
|
|
|
529
|
-
Increment an entity's `version` to trigger `onupgradeneeded` and update its object store on the user's next visit. The
|
|
610
|
+
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.
|
|
530
611
|
|
|
531
612
|
```typescript
|
|
532
|
-
@DataClass({ version: 1 })
|
|
533
|
-
|
|
534
|
-
|
|
613
|
+
@DataClass({ version: 1 })
|
|
614
|
+
class User {
|
|
615
|
+
/* ... */
|
|
616
|
+
}
|
|
617
|
+
@DataClass({ version: 2 })
|
|
618
|
+
class Post {
|
|
619
|
+
/* ... */
|
|
620
|
+
}
|
|
621
|
+
@DataClass({ version: 3 })
|
|
622
|
+
class Comment {
|
|
623
|
+
/* ... */
|
|
624
|
+
}
|
|
535
625
|
|
|
536
|
-
// Database opens at version 3
|
|
537
|
-
//
|
|
538
|
-
// created or updated during onupgradeneeded.
|
|
626
|
+
// Database opens at version 3 and reconciles the full declared schema
|
|
627
|
+
// (stores + indexes) inside the upgrade transaction.
|
|
539
628
|
const db = await Database.build('blog', [User, Post, Comment]);
|
|
540
629
|
|
|
541
630
|
console.log(db.getDatabaseVersion()); // 3
|
|
542
631
|
```
|
|
543
632
|
|
|
633
|
+
### Migration behaviour
|
|
634
|
+
|
|
635
|
+
Migration is **declarative**: on every upgrade the actual IndexedDB schema is reconciled against the schema declared by your decorators.
|
|
636
|
+
|
|
637
|
+
| Change | Handling |
|
|
638
|
+
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
639
|
+
| New entity / store | Created automatically. |
|
|
640
|
+
| 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. |
|
|
641
|
+
| Index removed (even without a version bump) | Detected as drift; the stale index is deleted. Record data is not affected. |
|
|
642
|
+
| 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. |
|
|
643
|
+
| 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. |
|
|
644
|
+
| 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. |
|
|
645
|
+
|
|
646
|
+
> Because drift detection may reopen the database one version higher than declared, `getDatabaseVersion()` returns the _actual_ IndexedDB version, which can exceed the highest entity `version`.
|
|
647
|
+
|
|
544
648
|
---
|
|
545
649
|
|
|
546
650
|
## Bulk Operations
|
|
@@ -555,13 +659,220 @@ await db.User.deleteMany(['u1', 'u2', 'u3']);
|
|
|
555
659
|
|
|
556
660
|
---
|
|
557
661
|
|
|
662
|
+
## Sync Adapters (experimental)
|
|
663
|
+
|
|
664
|
+
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.
|
|
665
|
+
|
|
666
|
+
```typescript
|
|
667
|
+
import type { SyncAdapter } from 'idb-ts';
|
|
668
|
+
|
|
669
|
+
class RestAdapter implements SyncAdapter {
|
|
670
|
+
async push(entityName: string, records: unknown[]): Promise<void> {
|
|
671
|
+
await fetch(`/api/sync/${entityName}`, {
|
|
672
|
+
method: 'PUT',
|
|
673
|
+
body: JSON.stringify(records),
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async pull(entityName: string): Promise<unknown[] | undefined> {
|
|
678
|
+
const response = await fetch(`/api/sync/${entityName}`);
|
|
679
|
+
return response.ok ? response.json() : undefined;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const adapter = new RestAdapter();
|
|
684
|
+
await db.pushTo(adapter); // sends every entity's records to the adapter
|
|
685
|
+
await db.pullFrom(adapter); // upserts records returned by the adapter
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
- `pushTo` calls `adapter.push(entityName, records)` once per registered entity.
|
|
689
|
+
- `pullFrom` calls `adapter.pull(entityName)` per entity and upserts the returned records by primary key; returning `undefined` leaves that store untouched.
|
|
690
|
+
- Conflict resolution is intentionally left to the adapter/backend - locally, pulled records win by primary key.
|
|
691
|
+
|
|
692
|
+
---
|
|
693
|
+
|
|
694
|
+
## Export / Import
|
|
695
|
+
|
|
696
|
+
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.
|
|
697
|
+
|
|
698
|
+
```typescript
|
|
699
|
+
// Export: { EntityName: records[] } for every registered entity
|
|
700
|
+
const dump = await db.exportDatabase();
|
|
701
|
+
localStorage.setItem('backup', JSON.stringify(dump));
|
|
702
|
+
|
|
703
|
+
// Import: writes records verbatim (timestamps preserved), upserting by key
|
|
704
|
+
await db.importDatabase(JSON.parse(localStorage.getItem('backup')!));
|
|
705
|
+
|
|
706
|
+
// Replace instead of merge
|
|
707
|
+
await db.importDatabase(dump, { clear: true });
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
- Records are written with `put`, so importing over existing keys overwrites those records; other records are kept unless `clear: true` is passed.
|
|
711
|
+
- Dump entries whose entity is not registered in this database are skipped.
|
|
712
|
+
- Internal `__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.
|
|
713
|
+
|
|
714
|
+
---
|
|
715
|
+
|
|
716
|
+
## Performance
|
|
717
|
+
|
|
718
|
+
<!-- performance start -->
|
|
719
|
+
|
|
720
|
+
Already up to date
|
|
721
|
+
Done in 383ms using pnpm v11.9.0
|
|
722
|
+
|
|
723
|
+
### Suite 1: CRUD Operations
|
|
724
|
+
|
|
725
|
+
| Operation | Ops | Total ms | Ops/s | Avg ms | P50 | P95 | P99 | Min | Max |
|
|
726
|
+
| ------------------------------- | --: | -------: | ---------: | -----: | ----: | ----: | ----: | ----: | -----: |
|
|
727
|
+
| create (single) | 200 | 11.668 | 17,141.483 | 0.058 | 0.044 | 0.103 | 0.13 | 0.039 | 0.883 |
|
|
728
|
+
| read (by PK) | 200 | 7.972 | 25,087.351 | 0.039 | 0.036 | 0.062 | 0.091 | 0.03 | 0.113 |
|
|
729
|
+
| update (single) | 200 | 133.17 | 1,501.843 | 0.665 | 0.615 | 1.01 | 1.184 | 0.545 | 2.036 |
|
|
730
|
+
| findByIndex (email) | 200 | 8.942 | 22,365.168 | 0.044 | 0.041 | 0.073 | 0.086 | 0.034 | 0.111 |
|
|
731
|
+
| findOneByIndex (email) | 200 | 10.246 | 19,519.677 | 0.051 | 0.044 | 0.057 | 0.072 | 0.039 | 0.998 |
|
|
732
|
+
| count | 200 | 8.28 | 24,155.266 | 0.041 | 0.04 | 0.049 | 0.055 | 0.037 | 0.079 |
|
|
733
|
+
| exists | 200 | 13.325 | 15,009.758 | 0.066 | 0.052 | 0.09 | 0.118 | 0.046 | 2.085 |
|
|
734
|
+
| list (all) | 50 | 96.56 | 517.814 | 1.931 | 1.771 | 4.279 | 6.013 | 1.515 | 6.013 |
|
|
735
|
+
| listPaginated (1, 20) | 200 | 398.616 | 501.736 | 1.993 | 1.765 | 3.022 | 8.919 | 1.51 | 10.708 |
|
|
736
|
+
| query().where().gte().execute() | 100 | 181.336 | 551.463 | 1.813 | 1.599 | 1.896 | 2.042 | 1.524 | 14.147 |
|
|
737
|
+
| delete (single) | 200 | 161.278 | 1,240.095 | 0.806 | 0.773 | 0.896 | 1.019 | 0.64 | 6.043 |
|
|
738
|
+
|
|
739
|
+
### Suite 2: Batched CRUD (by batch size)
|
|
740
|
+
|
|
741
|
+
| Operation | Ops | Total ms | Ops/s | Avg ms | P50 | P95 | P99 | Min | Max |
|
|
742
|
+
| ------------------------ | --: | --------: | --------: | --------: | --------: | --------: | --------: | --------: | --------: |
|
|
743
|
+
| createMany (10) | 3 | 1.421 | 2,111.564 | 0.473 | 0.513 | 0.528 | 0.528 | 0.377 | 0.528 |
|
|
744
|
+
| read batch (10 keys) | 3 | 0.5 | 5,998.488 | 0.166 | 0.16 | 0.189 | 0.189 | 0.151 | 0.189 |
|
|
745
|
+
| updateMany (10) | 3 | 4.472 | 670.902 | 1.49 | 1.452 | 1.646 | 1.646 | 1.37 | 1.646 |
|
|
746
|
+
| deleteMany (10) | 3 | 3.163 | 948.378 | 1.054 | 1.054 | 1.139 | 1.139 | 0.969 | 1.139 |
|
|
747
|
+
| deleteWhere (10+ match) | 3 | 0.841 | 3,566.321 | 0.28 | 0.278 | 0.312 | 0.312 | 0.249 | 0.312 |
|
|
748
|
+
| createMany (50) | 3 | 4.301 | 697.531 | 1.433 | 1.433 | 1.477 | 1.477 | 1.39 | 1.477 |
|
|
749
|
+
| read batch (50 keys) | 3 | 3.931 | 763.202 | 1.31 | 1.296 | 1.421 | 1.421 | 1.212 | 1.421 |
|
|
750
|
+
| updateMany (50) | 3 | 87.877 | 34.138 | 29.291 | 29.002 | 31.244 | 31.244 | 27.628 | 31.244 |
|
|
751
|
+
| deleteMany (50) | 3 | 93.633 | 32.04 | 31.21 | 31.53 | 33.2 | 33.2 | 28.899 | 33.2 |
|
|
752
|
+
| deleteWhere (50+ match) | 3 | 3.446 | 870.695 | 1.148 | 1.127 | 1.241 | 1.241 | 1.075 | 1.241 |
|
|
753
|
+
| createMany (100) | 3 | 9.571 | 313.443 | 3.19 | 3.074 | 3.8 | 3.8 | 2.695 | 3.8 |
|
|
754
|
+
| read batch (100 keys) | 3 | 12.769 | 234.949 | 4.255 | 4.077 | 5.665 | 5.665 | 3.023 | 5.665 |
|
|
755
|
+
| updateMany (100) | 3 | 314.564 | 9.537 | 104.853 | 104.907 | 104.963 | 104.963 | 104.69 | 104.963 |
|
|
756
|
+
| deleteMany (100) | 3 | 331.429 | 9.052 | 110.474 | 108.426 | 121.766 | 121.766 | 101.228 | 121.766 |
|
|
757
|
+
| deleteWhere (100+ match) | 3 | 6.954 | 431.435 | 2.317 | 2.254 | 2.481 | 2.481 | 2.216 | 2.481 |
|
|
758
|
+
| createMany (500) | 3 | 88.569 | 33.872 | 29.522 | 27.663 | 42.007 | 42.007 | 18.894 | 42.007 |
|
|
759
|
+
| read batch (500 keys) | 3 | 118.5 | 25.316 | 39.498 | 36.064 | 51.839 | 51.839 | 30.592 | 51.839 |
|
|
760
|
+
| 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 |
|
|
761
|
+
| 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 |
|
|
762
|
+
| deleteWhere (500+ match) | 3 | 35.488 | 84.535 | 11.828 | 11.745 | 12.529 | 12.529 | 11.21 | 12.529 |
|
|
763
|
+
|
|
764
|
+
### Suite 3: Mixed CRUD Operations
|
|
765
|
+
|
|
766
|
+
| Operation | Ops | Total ms | Ops/s | Avg ms | P50 | P95 | P99 | Min | Max |
|
|
767
|
+
| ------------------------------------------- | --: | -------: | ---------: | -----: | ----: | ----: | ----: | ----: | ----: |
|
|
768
|
+
| 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 |
|
|
769
|
+
| 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 |
|
|
770
|
+
| Mixed CRUD + queries | 200 | 83.386 | 2,398.49 | 0.417 | 0.033 | 2.061 | 2.141 | 0.011 | 6.234 |
|
|
771
|
+
| 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 |
|
|
772
|
+
|
|
773
|
+
### Suite 4: Mixed Batched CRUD
|
|
774
|
+
|
|
775
|
+
| Operation | Ops | Total ms | Ops/s | Avg ms | P50 | P95 | P99 | Min | Max |
|
|
776
|
+
| --------------------------------------------------------------------------- | --: | -------: | ------: | -----: | -----: | ------: | ------: | -----: | ------: |
|
|
777
|
+
| Cycle: createMany -> readAll -> updateMany -> deleteMany (50) | 5 | 104.266 | 47.954 | 20.852 | 15.473 | 36.333 | 36.333 | 9.223 | 36.333 |
|
|
778
|
+
| createMany -> query filter -> deleteMany (50) | 5 | 33.079 | 151.155 | 6.615 | 6.368 | 9.824 | 9.824 | 4.751 | 9.824 |
|
|
779
|
+
| 5 waves × createMany(50) + deleteMany(50) | 3 | 292.584 | 10.253 | 97.527 | 94.694 | 108.554 | 108.554 | 89.333 | 108.554 |
|
|
780
|
+
| 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 |
|
|
781
|
+
|
|
782
|
+
### Suite 5: Transaction Operations
|
|
783
|
+
|
|
784
|
+
| Operation | Ops | Total ms | Ops/s | Avg ms | P50 | P95 | P99 | Min | Max |
|
|
785
|
+
| -------------------------------------------- | --: | -------: | ---------: | -----: | ----: | -----: | -----: | ----: | -----: |
|
|
786
|
+
| tx: single create | 100 | 7.5 | 13,332.676 | 0.075 | 0.063 | 0.121 | 0.209 | 0.06 | 0.37 |
|
|
787
|
+
| tx: create 10 users | 100 | 23.044 | 4,339.529 | 0.23 | 0.219 | 0.27 | 0.413 | 0.207 | 0.455 |
|
|
788
|
+
| tx: read + update | 100 | 194.689 | 513.64 | 1.947 | 1.906 | 1.986 | 3.09 | 1.836 | 5.522 |
|
|
789
|
+
| 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 |
|
|
790
|
+
| tx: 10 reads | 100 | 15.108 | 6,618.828 | 0.151 | 0.141 | 0.176 | 0.272 | 0.135 | 0.364 |
|
|
791
|
+
| tx: batch create 50 users | 20 | 23.258 | 859.911 | 1.163 | 0.953 | 1.68 | 4.091 | 0.926 | 4.091 |
|
|
792
|
+
| tx: query().where().gte() | 100 | 946.761 | 105.623 | 9.467 | 8.473 | 18.988 | 19.246 | 8.265 | 19.705 |
|
|
793
|
+
| tx (explicit): begin -> create -> commit | 100 | 5.381 | 18,582.249 | 0.054 | 0.052 | 0.067 | 0.078 | 0.049 | 0.081 |
|
|
794
|
+
|
|
795
|
+
### Suite 6: Mixed Transactions
|
|
796
|
+
|
|
797
|
+
| Operation | Ops | Total ms | Ops/s | Avg ms | P50 | P95 | P99 | Min | Max |
|
|
798
|
+
| ----------------------------------------------------------- | --: | -------: | ---------: | -----: | ----: | ----: | ----: | ----: | -----: |
|
|
799
|
+
| 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 |
|
|
800
|
+
| 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 |
|
|
801
|
+
| 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 |
|
|
802
|
+
| 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 |
|
|
803
|
+
| 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 |
|
|
804
|
+
| tx mixed: count Orders -> conditional create | 100 | 7.079 | 14,125.357 | 0.071 | 0.069 | 0.083 | 0.099 | 0.065 | 0.103 |
|
|
805
|
+
| 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 |
|
|
806
|
+
|
|
807
|
+
<!-- performance end -->
|
|
808
|
+
|
|
809
|
+
## LLM / AI Assistant Instructions
|
|
810
|
+
|
|
811
|
+
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.
|
|
812
|
+
|
|
813
|
+
```text
|
|
814
|
+
idb-ts cheat sheet (TypeScript ORM for IndexedDB, zero runtime deps):
|
|
815
|
+
|
|
816
|
+
Setup
|
|
817
|
+
- import 'reflect-metadata' once at the app entry point.
|
|
818
|
+
- tsconfig: "experimentalDecorators": true, "emitDecoratorMetadata": true.
|
|
819
|
+
|
|
820
|
+
Entities
|
|
821
|
+
- Decorate classes with @DataClass({ version?: number }).
|
|
822
|
+
- Exactly one primary key: @KeyPath({ autoIncrement?, generator? }) on a
|
|
823
|
+
property, or @CompositeKeyPath(['fieldA','fieldB']) on the class
|
|
824
|
+
(written BELOW @DataClass - decorators apply bottom-up).
|
|
825
|
+
- generator: 'uuid' | 'timestamp' | 'random' | (item) => string | number.
|
|
826
|
+
- Secondary indexes: @Index({ unique?: boolean }) on properties.
|
|
827
|
+
- Validation: @Validate((value, item) => boolean, 'message') on properties.
|
|
828
|
+
- Auto-expiry: @RetentionPolicy({ seconds, field?, enabled? }) on the class.
|
|
829
|
+
|
|
830
|
+
Database
|
|
831
|
+
- const db = await Database.build<{ User: EntityRepository<User> }>('name', [User]);
|
|
832
|
+
- Repositories are attached by class name: db.User, db.Order, ...
|
|
833
|
+
- db.close() when done. db.getDatabaseVersion(), db.getAvailableEntities().
|
|
834
|
+
|
|
835
|
+
Repository API (all Promise-based)
|
|
836
|
+
- create(item), createMany(items), read(key), update(item), updateMany(items)
|
|
837
|
+
- delete(key), deleteMany(keys), deleteWhere(q => q.where(...))
|
|
838
|
+
- list(), listPaginated(page, pageSize), count(), exists(key), clear()
|
|
839
|
+
- findByIndex(indexName, value), findOneByIndex(indexName, value)
|
|
840
|
+
- query() -> QueryBuilder
|
|
841
|
+
|
|
842
|
+
QueryBuilder (chainable)
|
|
843
|
+
- .where('field').equals/gt/gte/lt/lte/startsWith/endsWith/contains/
|
|
844
|
+
matches/between/notBetween/in/notIn/containsAny/containsAll(...)
|
|
845
|
+
- .and('field')... / .or().where('field')...
|
|
846
|
+
- Nested groups: .where(qb => qb.where('a').equals(1).or().where('b').equals(2))
|
|
847
|
+
- .orderBy(field, 'asc'|'desc'), .limit(n), .offset(n)
|
|
848
|
+
- .useIndex(indexName).range(start, end) for IDB-level narrowing
|
|
849
|
+
- Terminals: .execute(), .count(), .sum(f), .avg(f), .min(f), .max(f),
|
|
850
|
+
.groupBy(f).count()
|
|
851
|
+
|
|
852
|
+
Transactions
|
|
853
|
+
- await db.transaction(async tx => { await tx.User.create(u); ... }) // auto commit/rollback
|
|
854
|
+
- const tx = await db.beginTransaction(['User','Order']); await tx.commit() / tx.rollback()
|
|
855
|
+
|
|
856
|
+
Gotchas
|
|
857
|
+
- Store names are lower-cased class names; renaming a class = new store.
|
|
858
|
+
- Every write injects __idb_createdAt / __idb_updatedAt (ms timestamps).
|
|
859
|
+
- where() filters run in memory after candidates are fetched; use
|
|
860
|
+
useIndex()+range() to narrow at the IndexedDB layer first.
|
|
861
|
+
- Composite keys are passed as arrays: db.UserProject.read(['u1','p1']).
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
### For contributors (and their assistants)
|
|
865
|
+
|
|
866
|
+
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.
|
|
867
|
+
|
|
558
868
|
## Useful Links
|
|
559
869
|
|
|
560
870
|
- **GitHub**: [maifeeulasad/idb-ts](https://github.com/maifeeulasad/idb-ts)
|
|
561
871
|
- **NPM**: [idb-ts](https://www.npmjs.com/package/idb-ts)
|
|
562
|
-
- **
|
|
872
|
+
- **Demos**: https://maifeeulasad.github.io/idb-ts/
|
|
873
|
+
- **Live Editor**: https://maifeeulasad.github.io/idb-ts/typescript/
|
|
563
874
|
- **Code Coverage report**: https://maifeeulasad.github.io/idb-ts/coverage/lcov-report/
|
|
564
875
|
|
|
565
876
|
🎉 **Enjoy seamless IndexedDB integration with TypeScript! Happy coding!** 🚀
|
|
566
877
|
|
|
567
|
-
Made by [Maifee Ulasad](https://github.com/maifeeulasad) with :heart: and :tea:. Licensed under [MIT](./LICENSE).
|
|
878
|
+
Made by [Maifee Ulasad](https://github.com/maifeeulasad) with :heart: and :tea:. Licensed under [MIT](./LICENSE).
|