idb-ts 3.10.0 → 3.11.1

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
@@ -31,7 +31,9 @@
31
31
  ## 📦 Installation
32
32
  Install via npm and start using IndexedDB like a pro! ⚡
33
33
  ```sh
34
- npm i idb-ts
34
+ npm i idb-ts # for pure npm users
35
+ pnpm add idb-ts # for pnpm users
36
+ yarn add idb-ts # for yarn users
35
37
  ```
36
38
 
37
39
  ## ✨ Features
@@ -166,6 +168,92 @@ const firstElectronic = await db.Product.findOneByIndex('category', 'Electronics
166
168
  - `findByIndex(indexName, value): Promise<T[]>` - Find all records matching the index value
167
169
  - `findOneByIndex(indexName, value): Promise<T | undefined>` - Find the first record matching the index value
168
170
 
171
+ ### Creation & Update Timestamps
172
+
173
+ Each entity managed by `idb-ts` automatically gets two internal timestamp fields:
174
+
175
+ - `__idb_createdAt`: numeric epoch milliseconds set when the record is first created.
176
+ - `__idb_updatedAt`: numeric epoch milliseconds updated on each successful update.
177
+
178
+ 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).
179
+
180
+ Example usage (reading timestamps):
181
+
182
+ ```ts
183
+ const item = await db.MyEntity.read('key');
184
+ console.log(item.__idb_createdAt, item.__idb_updatedAt);
185
+ ```
186
+
187
+ ### Retention Policy & Cleanup Job
188
+
189
+ `idb-ts` supports per-entity data retention via the `@RetentionPolicy()` class decorator. It accepts the following options:
190
+
191
+ - `seconds` (required): number of seconds after which records are considered expired.
192
+ - `enabled` (optional, default `true`): whether cleanup is active for this entity.
193
+ - `field` (optional, default `__idb_createdAt`): the numeric field to use for age calculation (usually creation timestamp).
194
+
195
+ 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.
196
+
197
+ Example:
198
+
199
+ ```ts
200
+ @RetentionPolicy({ seconds: 60 * 60 * 24 * 30 }) // 30 days
201
+ @DataClass()
202
+ class Session { /* ... */ }
203
+
204
+ // Database will run a periodic cleanup that removes sessions older than 30 days
205
+ ```
206
+
207
+ Notes:
208
+
209
+ - 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.
210
+ - To temporarily disable cleanup for an entity, set `enabled: false` on the decorator.
211
+
212
+ ### Field Validation
213
+
214
+ 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.
215
+
216
+ 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.
217
+
218
+ Example:
219
+
220
+ ```ts
221
+ @DataClass()
222
+ class User {
223
+ @KeyPath()
224
+ id!: string;
225
+
226
+ @Validate((v) => typeof v === 'string' && v.includes('@'), 'must be a valid email')
227
+ email!: string;
228
+
229
+ @Validate((v) => typeof v === 'number' && v >= 0, 'age must be >= 0')
230
+ age!: number;
231
+ }
232
+
233
+ await db.User.create(new User('u1', 'alice@example.com', 30));
234
+ ```
235
+
236
+ The thrown error contains all failing rules in the format `field: message` joined by `; `.
237
+
238
+ ### Bulk Operations
239
+
240
+ Repositories include convenience bulk helpers for common batch operations:
241
+
242
+ - `createMany(items: T[])`: creates multiple items (runs validators and generators for each item).
243
+ - `updateMany(items: T[])`: updates multiple items.
244
+ - `deleteMany(keys: Array<string | string[] | number>)`: deletes multiple keys.
245
+
246
+ 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
+
248
+ Example:
249
+
250
+ ```ts
251
+ await db.User.createMany([alice, bob, charlie]);
252
+ await db.User.deleteMany(['u1', 'u2']);
253
+ ```
254
+
255
+ 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.
256
+
169
257
  #### Error Handling
170
258
  - If you query a non-existent index, an error is thrown:
171
259
  ```typescript
@@ -415,13 +503,11 @@ const entityVersions = db.getEntityVersions();
415
503
  const userVersion = db.getEntityVersion('User');
416
504
 
417
505
  // Version upgrade flow:
418
- // v1.0: User(v1) → Database v1
419
- // v1.1: User(v1), Post(v2) → Database v2
420
- // v1.2: User(v1), Post(v2), Comment(v3) → Database v3
506
+ // v1.0: User(v1) -> Database v1
507
+ // v1.1: User(v1), Post(v2) -> Database v2
508
+ // v1.2: User(v1), Post(v2), Comment(v3) -> Database v3
421
509
  ```
422
510
 
423
- 📖 **[Complete Schema Versioning Guide](./SCHEMA_VERSIONING.md)** - Detailed documentation with examples and best practices.
424
-
425
511
  ---
426
512
 
427
513
  ## 🔗 Useful Links
package/lib/index.d.ts CHANGED
@@ -13,14 +13,14 @@ declare class QueryBuilder<T> {
13
13
  private rangeEnd?;
14
14
  private currentField?;
15
15
  constructor(db: IDBDatabase, storeName: string);
16
- where(field: string): this;
17
- and(field: string): this;
16
+ where(field: Extract<keyof T, string>): this;
17
+ and(field: Extract<keyof T, string>): this;
18
18
  equals(value: any): this;
19
19
  gt(value: any): this;
20
20
  gte(value: any): this;
21
21
  lt(value: any): this;
22
22
  lte(value: any): this;
23
- orderBy(field: string, direction?: QueryDirection): this;
23
+ orderBy(field: Extract<keyof T, string>, direction?: QueryDirection): this;
24
24
  limit(n: number): this;
25
25
  offset(n: number): this;
26
26
  useIndex(indexName: string): this;
@@ -31,6 +31,16 @@ interface KeyPathOptions {
31
31
  autoIncrement?: boolean;
32
32
  generator?: 'uuid' | 'timestamp' | 'random' | ((item?: any) => string | number);
33
33
  }
34
+ interface RetentionPolicyOptions {
35
+ seconds: number;
36
+ enabled?: boolean;
37
+ field?: string;
38
+ }
39
+ interface RetentionPolicyMetadata {
40
+ seconds: number;
41
+ enabled: boolean;
42
+ field: string;
43
+ }
34
44
  interface KeyPathMetadata {
35
45
  fields: string | string[];
36
46
  options?: KeyPathOptions;
@@ -42,16 +52,22 @@ declare class KeyGenerators {
42
52
  }
43
53
  declare function KeyPath(options?: KeyPathOptions): PropertyDecorator;
44
54
  declare function CompositeKeyPath(fields: string[], options?: KeyPathOptions): ClassDecorator;
45
- declare function Index(): PropertyDecorator;
55
+ declare function Index(options?: IDBIndexParameters): PropertyDecorator;
56
+ declare function Validate<T = any>(predicate: (value: any, item: T) => boolean, message: string): PropertyDecorator;
57
+ declare function RetentionPolicy(options: RetentionPolicyOptions): ClassDecorator;
46
58
  interface DataClassOptions {
47
59
  version?: number;
48
60
  }
49
61
  declare function DataClass(options?: DataClassOptions): ClassDecorator;
50
62
  interface EntityRepository<T> {
51
63
  create(item: T): Promise<void>;
64
+ createMany(items: T[]): Promise<void>;
52
65
  read(key: string | string[] | number): Promise<T | undefined>;
53
66
  update(item: T): Promise<void>;
67
+ updateMany(items: T[]): Promise<void>;
54
68
  delete(key: string | string[] | number): Promise<void>;
69
+ deleteMany(keys: Array<string | string[] | number>): Promise<void>;
70
+ deleteWhere(predicate: (query: QueryBuilder<T>) => QueryBuilder<T> | void): Promise<void>;
55
71
  list(): Promise<T[]>;
56
72
  listPaginated(page: number, pageSize: number): Promise<T[]>;
57
73
  findByIndex(indexName: string, value: any): Promise<T[]>;
@@ -68,17 +84,25 @@ declare class Database {
68
84
  private db;
69
85
  private entityRepositories;
70
86
  private dbVersion;
87
+ private retentionTimer;
88
+ private retentionCleanupRunning;
89
+ private retentionPolicies;
71
90
  private constructor();
72
91
  private calculateDatabaseVersion;
73
92
  static build<T extends Record<string, EntityRepository<any>>>(dbName: string, classes: Function[]): Promise<DatabaseWithRepositories<T>>;
74
93
  private initDB;
75
94
  private generateEntityRepositories;
95
+ private calculateRetentionCleanupIntervalMs;
96
+ private startRetentionCleanup;
97
+ private runRetentionCleanup;
98
+ private cleanupExpiredRecords;
76
99
  private createEntityRepository;
77
100
  private performOperation;
101
+ close(): void;
78
102
  getAvailableEntities(): string[];
79
103
  getDatabaseVersion(): number;
80
104
  getEntityVersions(): Map<string, number>;
81
105
  getEntityVersion(entityName: string): number | undefined;
82
106
  }
83
- export { Database, KeyPath, CompositeKeyPath, DataClass, Index, EntityRepository, KeyGenerators };
84
- export type { DatabaseWithRepositories, DataClassOptions, KeyPathOptions, KeyPathMetadata };
107
+ export { Database, KeyPath, CompositeKeyPath, DataClass, Index, Validate, RetentionPolicy, EntityRepository, KeyGenerators };
108
+ export type { DatabaseWithRepositories, DataClassOptions, KeyPathOptions, KeyPathMetadata, RetentionPolicyOptions, RetentionPolicyMetadata };
package/lib/index.esm.js CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, "__esModule", { value: true });
2
- exports.KeyGenerators = exports.Index = exports.DataClass = exports.CompositeKeyPath = exports.KeyPath = exports.Database = void 0;
2
+ exports.KeyGenerators = exports.RetentionPolicy = exports.Validate = exports.Index = exports.DataClass = exports.CompositeKeyPath = exports.KeyPath = exports.Database = void 0;
3
3
  const tslib_1 = require("tslib");
4
4
  require("reflect-metadata");
5
5
  class QueryBuilder {
@@ -78,7 +78,7 @@ class QueryBuilder {
78
78
  return tslib_1.__awaiter(this, void 0, void 0, function* () {
79
79
  return new Promise((resolve, reject) => {
80
80
  const tx = this.db.transaction(this.storeName, 'readonly');
81
- let store = tx.objectStore(this.storeName);
81
+ const store = tx.objectStore(this.storeName);
82
82
  let request;
83
83
  let results = [];
84
84
  if (this.indexName) {
@@ -155,6 +155,8 @@ class QueryBuilder {
155
155
  });
156
156
  }
157
157
  }
158
+ const INTERNAL_CREATED_AT_FIELD = '__idb_createdAt';
159
+ const INTERNAL_UPDATED_AT_FIELD = '__idb_updatedAt';
158
160
  class KeyGenerators {
159
161
  static uuid() {
160
162
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
@@ -195,14 +197,39 @@ function CompositeKeyPath(fields, options) {
195
197
  };
196
198
  }
197
199
  exports.CompositeKeyPath = CompositeKeyPath;
198
- function Index() {
200
+ function Index(options) {
199
201
  return (target, propertyKey) => {
200
202
  const constructor = target.constructor;
201
203
  const existing = Reflect.getMetadata("indexes", constructor) || [];
202
- Reflect.defineMetadata("indexes", [...existing, propertyKey], constructor);
204
+ const nextIndexes = [...existing, { field: propertyKey, options }];
205
+ Reflect.defineMetadata("indexes", nextIndexes, constructor);
203
206
  };
204
207
  }
205
208
  exports.Index = Index;
209
+ function Validate(predicate, message) {
210
+ return (target, propertyKey) => {
211
+ const constructor = target.constructor;
212
+ const existing = Reflect.getMetadata('validators', constructor) || [];
213
+ const nextRules = [...existing, { field: propertyKey, predicate, message }];
214
+ Reflect.defineMetadata('validators', nextRules, constructor);
215
+ };
216
+ }
217
+ exports.Validate = Validate;
218
+ function RetentionPolicy(options) {
219
+ return (target) => {
220
+ var _a, _b;
221
+ if (!Number.isInteger(options.seconds) || options.seconds <= 0) {
222
+ throw new Error('RetentionPolicy.seconds must be a positive integer.');
223
+ }
224
+ const metadata = {
225
+ seconds: options.seconds,
226
+ enabled: (_a = options.enabled) !== null && _a !== void 0 ? _a : true,
227
+ field: (_b = options.field) !== null && _b !== void 0 ? _b : INTERNAL_CREATED_AT_FIELD
228
+ };
229
+ Reflect.defineMetadata('retention_policy', metadata, target);
230
+ };
231
+ }
232
+ exports.RetentionPolicy = RetentionPolicy;
206
233
  function DataClass(options = {}) {
207
234
  return (target) => {
208
235
  const keyPathMetadata = Reflect.getMetadata("keypath", target);
@@ -223,12 +250,27 @@ class Database {
223
250
  constructor(dbName, classes) {
224
251
  this.db = null;
225
252
  this.entityRepositories = new Map();
253
+ this.retentionTimer = null;
254
+ this.retentionCleanupRunning = false;
226
255
  this.dbName = dbName;
227
256
  if (!classes.every(cls => Reflect.getMetadata("dataclass", cls))) {
228
257
  throw new Error("All classes should be decorated with @DataClass.");
229
258
  }
230
259
  this.classes = classes;
231
260
  this.dbVersion = this.calculateDatabaseVersion();
261
+ this.retentionPolicies = this.classes
262
+ .map((cls) => {
263
+ const policy = Reflect.getMetadata('retention_policy', cls);
264
+ if (!(policy === null || policy === void 0 ? void 0 : policy.enabled)) {
265
+ return null;
266
+ }
267
+ return {
268
+ className: cls.name,
269
+ storeName: cls.name.toLowerCase(),
270
+ policy
271
+ };
272
+ })
273
+ .filter((policy) => policy !== null);
232
274
  }
233
275
  calculateDatabaseVersion() {
234
276
  const versions = this.classes.map(cls => Reflect.getMetadata("version", cls) || 1);
@@ -269,8 +311,13 @@ class Database {
269
311
  }
270
312
  const store = db.createObjectStore(storeName, storeOptions);
271
313
  indexFields.forEach((indexField) => {
272
- if (!store.indexNames.contains(indexField)) {
273
- store.createIndex(indexField, indexField, { unique: false });
314
+ var _a;
315
+ const indexName = typeof indexField === 'string' ? indexField : indexField.field;
316
+ const indexOptions = typeof indexField === 'string'
317
+ ? { unique: false }
318
+ : ((_a = indexField.options) !== null && _a !== void 0 ? _a : { unique: false });
319
+ if (!store.indexNames.contains(indexName)) {
320
+ store.createIndex(indexName, indexName, indexOptions);
274
321
  }
275
322
  });
276
323
  }
@@ -280,9 +327,14 @@ class Database {
280
327
  if (transaction) {
281
328
  const store = transaction.objectStore(storeName);
282
329
  indexFields.forEach((indexField) => {
283
- if (!store.indexNames.contains(indexField)) {
284
- console.debug(`Adding index: ${indexField} to ${storeName}`);
285
- store.createIndex(indexField, indexField, { unique: false });
330
+ var _a;
331
+ const indexName = typeof indexField === 'string' ? indexField : indexField.field;
332
+ const indexOptions = typeof indexField === 'string'
333
+ ? { unique: false }
334
+ : ((_a = indexField.options) !== null && _a !== void 0 ? _a : { unique: false });
335
+ if (!store.indexNames.contains(indexName)) {
336
+ console.debug(`Adding index: ${indexName} to ${storeName}`);
337
+ store.createIndex(indexName, indexName, indexOptions);
286
338
  }
287
339
  });
288
340
  }
@@ -293,6 +345,7 @@ class Database {
293
345
  request.onsuccess = () => {
294
346
  this.db = request.result;
295
347
  console.debug(`Database initialized (version ${this.dbVersion}) with object stores for: ${this.classes.map(cls => `${cls.name}(v${Reflect.getMetadata("version", cls) || 1})`).join(", ")}`);
348
+ this.startRetentionCleanup();
296
349
  resolve();
297
350
  };
298
351
  request.onerror = () => {
@@ -315,8 +368,114 @@ class Database {
315
368
  });
316
369
  });
317
370
  }
371
+ calculateRetentionCleanupIntervalMs() {
372
+ if (!this.retentionPolicies.length) {
373
+ return undefined;
374
+ }
375
+ const gcd = (left, right) => {
376
+ let a = left;
377
+ let b = right;
378
+ while (b !== 0) {
379
+ const remainder = a % b;
380
+ a = b;
381
+ b = remainder;
382
+ }
383
+ return Math.abs(a);
384
+ };
385
+ const seconds = this.retentionPolicies.map(({ policy }) => policy.seconds);
386
+ return seconds.reduce((accumulator, value) => gcd(accumulator, value)) * 1000;
387
+ }
388
+ startRetentionCleanup() {
389
+ const cleanupIntervalMs = this.calculateRetentionCleanupIntervalMs();
390
+ if (!cleanupIntervalMs || !this.db || this.retentionTimer) {
391
+ return;
392
+ }
393
+ console.debug(`Retention cleanup enabled for ${this.retentionPolicies.length} entities every ${cleanupIntervalMs}ms`);
394
+ void this.runRetentionCleanup();
395
+ this.retentionTimer = setInterval(() => {
396
+ void this.runRetentionCleanup();
397
+ }, cleanupIntervalMs);
398
+ }
399
+ runRetentionCleanup() {
400
+ return tslib_1.__awaiter(this, void 0, void 0, function* () {
401
+ if (!this.db || this.retentionCleanupRunning || !this.retentionPolicies.length) {
402
+ return;
403
+ }
404
+ this.retentionCleanupRunning = true;
405
+ try {
406
+ console.debug('Retention cleanup tick started');
407
+ for (const { storeName, className, policy } of this.retentionPolicies) {
408
+ yield this.cleanupExpiredRecords(storeName, className, policy);
409
+ }
410
+ console.debug('Retention cleanup tick finished');
411
+ }
412
+ finally {
413
+ this.retentionCleanupRunning = false;
414
+ }
415
+ });
416
+ }
417
+ cleanupExpiredRecords(storeName, className, policy) {
418
+ if (!this.db) {
419
+ return Promise.resolve();
420
+ }
421
+ return new Promise((resolve, reject) => {
422
+ try {
423
+ const transaction = this.db.transaction(storeName, 'readwrite');
424
+ const store = transaction.objectStore(storeName);
425
+ const cutoff = Date.now() - (policy.seconds * 1000);
426
+ const request = store.openCursor();
427
+ request.onsuccess = () => {
428
+ const cursor = request.result;
429
+ if (!cursor) {
430
+ return;
431
+ }
432
+ const value = cursor.value;
433
+ const timestamp = value === null || value === void 0 ? void 0 : value[policy.field];
434
+ console.debug(`Retention cleanup inspecting ${className}.${policy.field}:`, timestamp, 'cutoff:', cutoff);
435
+ if (typeof timestamp === 'number' && timestamp <= cutoff) {
436
+ const deleteRequest = cursor.delete();
437
+ deleteRequest.onsuccess = () => {
438
+ console.debug(`Retention cleanup removed expired record from ${className}`);
439
+ cursor.continue();
440
+ };
441
+ deleteRequest.onerror = () => { var _a; return reject((_a = deleteRequest.error) !== null && _a !== void 0 ? _a : new Error(`Retention cleanup delete failed for ${className}`)); };
442
+ return;
443
+ }
444
+ cursor.continue();
445
+ };
446
+ transaction.oncomplete = () => resolve();
447
+ transaction.onerror = () => { var _a; return reject((_a = transaction.error) !== null && _a !== void 0 ? _a : new Error(`Retention cleanup failed for ${className}`)); };
448
+ transaction.onabort = () => { var _a; return reject((_a = transaction.error) !== null && _a !== void 0 ? _a : new Error(`Retention cleanup aborted for ${className}`)); };
449
+ }
450
+ catch (error) {
451
+ reject(error);
452
+ }
453
+ });
454
+ }
318
455
  createEntityRepository(cls) {
319
456
  const self = this;
457
+ const creationTimestampField = INTERNAL_CREATED_AT_FIELD;
458
+ const updateTimestampField = INTERNAL_UPDATED_AT_FIELD;
459
+ const validators = (Reflect.getMetadata('validators', cls) || []);
460
+ const validateItem = (item) => {
461
+ const failures = [];
462
+ validators.forEach((rule) => {
463
+ const value = item[rule.field];
464
+ let valid = false;
465
+ try {
466
+ valid = rule.predicate(value, item);
467
+ }
468
+ catch (_a) {
469
+ valid = false;
470
+ }
471
+ if (!valid) {
472
+ failures.push(`${rule.field}: ${rule.message}`);
473
+ }
474
+ });
475
+ if (failures.length) {
476
+ throw new Error(`Validation failed for ${cls.name}: ${failures.join('; ')}`);
477
+ }
478
+ };
320
479
  const generateKey = (item) => {
321
480
  var _a;
322
481
  const keyPathMetadata = Reflect.getMetadata("keypath", cls);
@@ -337,6 +496,46 @@ class Database {
337
496
  return undefined;
338
497
  }
339
498
  };
499
+ const applyTimestampFields = (item, existingItem) => {
500
+ const now = Date.now();
501
+ const existingCreationValue = existingItem ? existingItem[creationTimestampField] : undefined;
502
+ item[creationTimestampField] = existingCreationValue !== undefined ? existingCreationValue : now;
503
+ item[updateTimestampField] = now;
504
+ };
505
+ const readExistingItem = (store, key) => {
506
+ return new Promise((resolve, reject) => {
507
+ const request = store.get(key);
508
+ request.onsuccess = () => resolve(request.result);
509
+ request.onerror = () => reject(request.error);
510
+ });
511
+ };
512
+ const createStoredItem = (store, item) => {
513
+ return new Promise((resolve, reject) => {
514
+ const request = store.add(item);
515
+ request.onsuccess = () => resolve();
516
+ request.onerror = () => reject(request.error);
517
+ });
518
+ };
519
+ const updateStoredItem = (store, item) => tslib_1.__awaiter(this, void 0, void 0, function* () {
520
+ const key = extractKey(item);
521
+ let existingItem;
522
+ if (key !== undefined && key !== null) {
523
+ existingItem = yield readExistingItem(store, key);
524
+ }
525
+ applyTimestampFields(item, existingItem);
526
+ yield new Promise((resolve, reject) => {
527
+ const request = store.put(item);
528
+ request.onsuccess = () => resolve();
529
+ request.onerror = () => reject(request.error);
530
+ });
531
+ });
532
+ const deleteStoredItem = (store, key) => {
533
+ return new Promise((resolve, reject) => {
534
+ const request = store.delete(key);
535
+ request.onsuccess = () => resolve();
536
+ request.onerror = () => reject(request.error);
537
+ });
538
+ };
340
539
  const extractKey = (item) => {
341
540
  const keyPathMetadata = Reflect.getMetadata("keypath", cls);
342
541
  if (!keyPathMetadata)
@@ -377,17 +576,20 @@ class Database {
377
576
  }
378
577
  }
379
578
  }
579
+ validateItem(item);
580
+ applyTimestampFields(item);
380
581
  return this.performOperation(cls.name, 'readwrite', (store) => {
381
- const request = store.add(item);
382
- return new Promise((resolve, reject) => {
383
- request.onsuccess = () => {
384
- console.debug(`Item added to ${cls.name}:`, item);
385
- resolve();
386
- };
387
- request.onerror = () => reject(request.error);
582
+ return createStoredItem(store, item).then(() => {
583
+ console.debug(`Item added to ${cls.name}:`, item);
388
584
  });
389
585
  });
390
586
  }),
587
+ createMany: (items) => tslib_1.__awaiter(this, void 0, void 0, function* () {
588
+ const repository = this.createEntityRepository(cls);
589
+ for (const item of items) {
590
+ yield repository.create(item);
591
+ }
592
+ }),
391
593
  read: (key) => tslib_1.__awaiter(this, void 0, void 0, function* () {
392
594
  return this.performOperation(cls.name, 'readonly', (store) => {
393
595
  const request = store.get(key);
@@ -401,29 +603,43 @@ class Database {
401
603
  });
402
604
  }),
403
605
  update: (item) => tslib_1.__awaiter(this, void 0, void 0, function* () {
606
+ validateItem(item);
404
607
  return this.performOperation(cls.name, 'readwrite', (store) => {
405
- const request = store.put(item);
406
- return new Promise((resolve, reject) => {
407
- request.onsuccess = () => {
408
- console.debug(`Item updated in ${cls.name}:`, item);
409
- resolve();
410
- };
411
- request.onerror = () => reject(request.error);
608
+ return updateStoredItem(store, item).then(() => {
609
+ console.debug(`Item updated in ${cls.name}:`, item);
412
610
  });
413
611
  });
414
612
  }),
613
+ updateMany: (items) => tslib_1.__awaiter(this, void 0, void 0, function* () {
614
+ const repository = this.createEntityRepository(cls);
615
+ for (const item of items) {
616
+ yield repository.update(item);
617
+ }
618
+ }),
415
619
  delete: (key) => tslib_1.__awaiter(this, void 0, void 0, function* () {
416
620
  return this.performOperation(cls.name, 'readwrite', (store) => {
417
- const request = store.delete(key);
418
- return new Promise((resolve, reject) => {
419
- request.onsuccess = () => {
420
- console.debug(`Item deleted from ${cls.name}:`, key);
421
- resolve();
422
- };
423
- request.onerror = () => reject(request.error);
621
+ return deleteStoredItem(store, key).then(() => {
622
+ console.debug(`Item deleted from ${cls.name}:`, key);
424
623
  });
425
624
  });
426
625
  }),
626
+ deleteMany: (keys) => tslib_1.__awaiter(this, void 0, void 0, function* () {
627
+ const repository = this.createEntityRepository(cls);
628
+ for (const key of keys) {
629
+ yield repository.delete(key);
630
+ }
631
+ }),
632
+ deleteWhere: (predicate) => tslib_1.__awaiter(this, void 0, void 0, function* () {
633
+ var _b;
634
+ const repository = this.createEntityRepository(cls);
635
+ const query = repository.query();
636
+ const resolvedQuery = (_b = predicate(query)) !== null && _b !== void 0 ? _b : query;
637
+ const matches = yield resolvedQuery.execute();
638
+ const keys = matches
639
+ .map((item) => extractKey(item))
640
+ .filter((key) => key !== undefined && key !== null);
641
+ yield repository.deleteMany(keys);
642
+ }),
427
643
  list: () => tslib_1.__awaiter(this, void 0, void 0, function* () {
428
644
  return this.performOperation(cls.name, 'readonly', (store) => {
429
645
  const request = store.getAll();
@@ -532,6 +748,16 @@ class Database {
532
748
  return operation(store);
533
749
  });
534
750
  }
751
+ close() {
752
+ if (this.retentionTimer) {
753
+ clearInterval(this.retentionTimer);
754
+ this.retentionTimer = null;
755
+ }
756
+ if (this.db) {
757
+ this.db.close();
758
+ this.db = null;
759
+ }
760
+ }
535
761
  getAvailableEntities() {
536
762
  return Array.from(this.entityRepositories.keys());
537
763
  }
package/lib/index.js CHANGED
@@ -9,7 +9,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  });
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.KeyGenerators = exports.Index = exports.DataClass = exports.CompositeKeyPath = exports.KeyPath = exports.Database = void 0;
12
+ exports.KeyGenerators = exports.RetentionPolicy = exports.Validate = exports.Index = exports.DataClass = exports.CompositeKeyPath = exports.KeyPath = exports.Database = void 0;
13
13
  require("reflect-metadata");
14
14
  class QueryBuilder {
15
15
  constructor(db, storeName) {
@@ -87,7 +87,7 @@ class QueryBuilder {
87
87
  return __awaiter(this, void 0, void 0, function* () {
88
88
  return new Promise((resolve, reject) => {
89
89
  const tx = this.db.transaction(this.storeName, 'readonly');
90
- let store = tx.objectStore(this.storeName);
90
+ const store = tx.objectStore(this.storeName);
91
91
  let request;
92
92
  let results = [];
93
93
  if (this.indexName) {
@@ -164,6 +164,8 @@ class QueryBuilder {
164
164
  });
165
165
  }
166
166
  }
167
+ const INTERNAL_CREATED_AT_FIELD = '__idb_createdAt';
168
+ const INTERNAL_UPDATED_AT_FIELD = '__idb_updatedAt';
167
169
  class KeyGenerators {
168
170
  static uuid() {
169
171
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
@@ -204,14 +206,39 @@ function CompositeKeyPath(fields, options) {
204
206
  };
205
207
  }
206
208
  exports.CompositeKeyPath = CompositeKeyPath;
207
- function Index() {
209
+ function Index(options) {
208
210
  return (target, propertyKey) => {
209
211
  const constructor = target.constructor;
210
212
  const existing = Reflect.getMetadata("indexes", constructor) || [];
211
- Reflect.defineMetadata("indexes", [...existing, propertyKey], constructor);
213
+ const nextIndexes = [...existing, { field: propertyKey, options }];
214
+ Reflect.defineMetadata("indexes", nextIndexes, constructor);
212
215
  };
213
216
  }
214
217
  exports.Index = Index;
218
+ function Validate(predicate, message) {
219
+ return (target, propertyKey) => {
220
+ const constructor = target.constructor;
221
+ const existing = Reflect.getMetadata('validators', constructor) || [];
222
+ const nextRules = [...existing, { field: propertyKey, predicate, message }];
223
+ Reflect.defineMetadata('validators', nextRules, constructor);
224
+ };
225
+ }
226
+ exports.Validate = Validate;
227
+ function RetentionPolicy(options) {
228
+ return (target) => {
229
+ var _a, _b;
230
+ if (!Number.isInteger(options.seconds) || options.seconds <= 0) {
231
+ throw new Error('RetentionPolicy.seconds must be a positive integer.');
232
+ }
233
+ const metadata = {
234
+ seconds: options.seconds,
235
+ enabled: (_a = options.enabled) !== null && _a !== void 0 ? _a : true,
236
+ field: (_b = options.field) !== null && _b !== void 0 ? _b : INTERNAL_CREATED_AT_FIELD
237
+ };
238
+ Reflect.defineMetadata('retention_policy', metadata, target);
239
+ };
240
+ }
241
+ exports.RetentionPolicy = RetentionPolicy;
215
242
  function DataClass(options = {}) {
216
243
  return (target) => {
217
244
  const keyPathMetadata = Reflect.getMetadata("keypath", target);
@@ -232,12 +259,27 @@ class Database {
232
259
  constructor(dbName, classes) {
233
260
  this.db = null;
234
261
  this.entityRepositories = new Map();
262
+ this.retentionTimer = null;
263
+ this.retentionCleanupRunning = false;
235
264
  this.dbName = dbName;
236
265
  if (!classes.every(cls => Reflect.getMetadata("dataclass", cls))) {
237
266
  throw new Error("All classes should be decorated with @DataClass.");
238
267
  }
239
268
  this.classes = classes;
240
269
  this.dbVersion = this.calculateDatabaseVersion();
270
+ this.retentionPolicies = this.classes
271
+ .map((cls) => {
272
+ const policy = Reflect.getMetadata('retention_policy', cls);
273
+ if (!(policy === null || policy === void 0 ? void 0 : policy.enabled)) {
274
+ return null;
275
+ }
276
+ return {
277
+ className: cls.name,
278
+ storeName: cls.name.toLowerCase(),
279
+ policy
280
+ };
281
+ })
282
+ .filter((policy) => policy !== null);
241
283
  }
242
284
  calculateDatabaseVersion() {
243
285
  const versions = this.classes.map(cls => Reflect.getMetadata("version", cls) || 1);
@@ -278,8 +320,13 @@ class Database {
278
320
  }
279
321
  const store = db.createObjectStore(storeName, storeOptions);
280
322
  indexFields.forEach((indexField) => {
281
- if (!store.indexNames.contains(indexField)) {
282
- store.createIndex(indexField, indexField, { unique: false });
323
+ var _a;
324
+ const indexName = typeof indexField === 'string' ? indexField : indexField.field;
325
+ const indexOptions = typeof indexField === 'string'
326
+ ? { unique: false }
327
+ : ((_a = indexField.options) !== null && _a !== void 0 ? _a : { unique: false });
328
+ if (!store.indexNames.contains(indexName)) {
329
+ store.createIndex(indexName, indexName, indexOptions);
283
330
  }
284
331
  });
285
332
  }
@@ -289,9 +336,14 @@ class Database {
289
336
  if (transaction) {
290
337
  const store = transaction.objectStore(storeName);
291
338
  indexFields.forEach((indexField) => {
292
- if (!store.indexNames.contains(indexField)) {
293
- console.debug(`Adding index: ${indexField} to ${storeName}`);
294
- store.createIndex(indexField, indexField, { unique: false });
339
+ var _a;
340
+ const indexName = typeof indexField === 'string' ? indexField : indexField.field;
341
+ const indexOptions = typeof indexField === 'string'
342
+ ? { unique: false }
343
+ : ((_a = indexField.options) !== null && _a !== void 0 ? _a : { unique: false });
344
+ if (!store.indexNames.contains(indexName)) {
345
+ console.debug(`Adding index: ${indexName} to ${storeName}`);
346
+ store.createIndex(indexName, indexName, indexOptions);
295
347
  }
296
348
  });
297
349
  }
@@ -302,6 +354,7 @@ class Database {
302
354
  request.onsuccess = () => {
303
355
  this.db = request.result;
304
356
  console.debug(`Database initialized (version ${this.dbVersion}) with object stores for: ${this.classes.map(cls => `${cls.name}(v${Reflect.getMetadata("version", cls) || 1})`).join(", ")}`);
357
+ this.startRetentionCleanup();
305
358
  resolve();
306
359
  };
307
360
  request.onerror = () => {
@@ -324,8 +377,114 @@ class Database {
324
377
  });
325
378
  });
326
379
  }
380
+ calculateRetentionCleanupIntervalMs() {
381
+ if (!this.retentionPolicies.length) {
382
+ return undefined;
383
+ }
384
+ const gcd = (left, right) => {
385
+ let a = left;
386
+ let b = right;
387
+ while (b !== 0) {
388
+ const remainder = a % b;
389
+ a = b;
390
+ b = remainder;
391
+ }
392
+ return Math.abs(a);
393
+ };
394
+ const seconds = this.retentionPolicies.map(({ policy }) => policy.seconds);
395
+ return seconds.reduce((accumulator, value) => gcd(accumulator, value)) * 1000;
396
+ }
397
+ startRetentionCleanup() {
398
+ const cleanupIntervalMs = this.calculateRetentionCleanupIntervalMs();
399
+ if (!cleanupIntervalMs || !this.db || this.retentionTimer) {
400
+ return;
401
+ }
402
+ console.debug(`Retention cleanup enabled for ${this.retentionPolicies.length} entities every ${cleanupIntervalMs}ms`);
403
+ void this.runRetentionCleanup();
404
+ this.retentionTimer = setInterval(() => {
405
+ void this.runRetentionCleanup();
406
+ }, cleanupIntervalMs);
407
+ }
408
+ runRetentionCleanup() {
409
+ return __awaiter(this, void 0, void 0, function* () {
410
+ if (!this.db || this.retentionCleanupRunning || !this.retentionPolicies.length) {
411
+ return;
412
+ }
413
+ this.retentionCleanupRunning = true;
414
+ try {
415
+ console.debug('Retention cleanup tick started');
416
+ for (const { storeName, className, policy } of this.retentionPolicies) {
417
+ yield this.cleanupExpiredRecords(storeName, className, policy);
418
+ }
419
+ console.debug('Retention cleanup tick finished');
420
+ }
421
+ finally {
422
+ this.retentionCleanupRunning = false;
423
+ }
424
+ });
425
+ }
426
+ cleanupExpiredRecords(storeName, className, policy) {
427
+ if (!this.db) {
428
+ return Promise.resolve();
429
+ }
430
+ return new Promise((resolve, reject) => {
431
+ try {
432
+ const transaction = this.db.transaction(storeName, 'readwrite');
433
+ const store = transaction.objectStore(storeName);
434
+ const cutoff = Date.now() - (policy.seconds * 1000);
435
+ const request = store.openCursor();
436
+ request.onsuccess = () => {
437
+ const cursor = request.result;
438
+ if (!cursor) {
439
+ return;
440
+ }
441
+ const value = cursor.value;
442
+ const timestamp = value === null || value === void 0 ? void 0 : value[policy.field];
443
+ console.debug(`Retention cleanup inspecting ${className}.${policy.field}:`, timestamp, 'cutoff:', cutoff);
444
+ if (typeof timestamp === 'number' && timestamp <= cutoff) {
445
+ const deleteRequest = cursor.delete();
446
+ deleteRequest.onsuccess = () => {
447
+ console.debug(`Retention cleanup removed expired record from ${className}`);
448
+ cursor.continue();
449
+ };
450
+ deleteRequest.onerror = () => { var _a; return reject((_a = deleteRequest.error) !== null && _a !== void 0 ? _a : new Error(`Retention cleanup delete failed for ${className}`)); };
451
+ return;
452
+ }
453
+ cursor.continue();
454
+ };
455
+ transaction.oncomplete = () => resolve();
456
+ transaction.onerror = () => { var _a; return reject((_a = transaction.error) !== null && _a !== void 0 ? _a : new Error(`Retention cleanup failed for ${className}`)); };
457
+ transaction.onabort = () => { var _a; return reject((_a = transaction.error) !== null && _a !== void 0 ? _a : new Error(`Retention cleanup aborted for ${className}`)); };
458
+ }
459
+ catch (error) {
460
+ reject(error);
461
+ }
462
+ });
463
+ }
327
464
  createEntityRepository(cls) {
328
465
  const self = this;
466
+ const creationTimestampField = INTERNAL_CREATED_AT_FIELD;
467
+ const updateTimestampField = INTERNAL_UPDATED_AT_FIELD;
468
+ const validators = (Reflect.getMetadata('validators', cls) || []);
469
+ const validateItem = (item) => {
470
+ const failures = [];
471
+ validators.forEach((rule) => {
472
+ const value = item[rule.field];
473
+ let valid = false;
474
+ try {
475
+ valid = rule.predicate(value, item);
476
+ }
477
+ catch (_a) {
478
+ valid = false;
479
+ }
480
+ if (!valid) {
481
+ failures.push(`${rule.field}: ${rule.message}`);
482
+ }
483
+ });
484
+ if (failures.length) {
485
+ throw new Error(`Validation failed for ${cls.name}: ${failures.join('; ')}`);
486
+ }
487
+ };
329
488
  const generateKey = (item) => {
330
489
  var _a;
331
490
  const keyPathMetadata = Reflect.getMetadata("keypath", cls);
@@ -346,6 +505,46 @@ class Database {
346
505
  return undefined;
347
506
  }
348
507
  };
508
+ const applyTimestampFields = (item, existingItem) => {
509
+ const now = Date.now();
510
+ const existingCreationValue = existingItem ? existingItem[creationTimestampField] : undefined;
511
+ item[creationTimestampField] = existingCreationValue !== undefined ? existingCreationValue : now;
512
+ item[updateTimestampField] = now;
513
+ };
514
+ const readExistingItem = (store, key) => {
515
+ return new Promise((resolve, reject) => {
516
+ const request = store.get(key);
517
+ request.onsuccess = () => resolve(request.result);
518
+ request.onerror = () => reject(request.error);
519
+ });
520
+ };
521
+ const createStoredItem = (store, item) => {
522
+ return new Promise((resolve, reject) => {
523
+ const request = store.add(item);
524
+ request.onsuccess = () => resolve();
525
+ request.onerror = () => reject(request.error);
526
+ });
527
+ };
528
+ const updateStoredItem = (store, item) => __awaiter(this, void 0, void 0, function* () {
529
+ const key = extractKey(item);
530
+ let existingItem;
531
+ if (key !== undefined && key !== null) {
532
+ existingItem = yield readExistingItem(store, key);
533
+ }
534
+ applyTimestampFields(item, existingItem);
535
+ yield new Promise((resolve, reject) => {
536
+ const request = store.put(item);
537
+ request.onsuccess = () => resolve();
538
+ request.onerror = () => reject(request.error);
539
+ });
540
+ });
541
+ const deleteStoredItem = (store, key) => {
542
+ return new Promise((resolve, reject) => {
543
+ const request = store.delete(key);
544
+ request.onsuccess = () => resolve();
545
+ request.onerror = () => reject(request.error);
546
+ });
547
+ };
349
548
  const extractKey = (item) => {
350
549
  const keyPathMetadata = Reflect.getMetadata("keypath", cls);
351
550
  if (!keyPathMetadata)
@@ -386,17 +585,20 @@ class Database {
386
585
  }
387
586
  }
388
587
  }
588
+ validateItem(item);
589
+ applyTimestampFields(item);
389
590
  return this.performOperation(cls.name, 'readwrite', (store) => {
390
- const request = store.add(item);
391
- return new Promise((resolve, reject) => {
392
- request.onsuccess = () => {
393
- console.debug(`Item added to ${cls.name}:`, item);
394
- resolve();
395
- };
396
- request.onerror = () => reject(request.error);
591
+ return createStoredItem(store, item).then(() => {
592
+ console.debug(`Item added to ${cls.name}:`, item);
397
593
  });
398
594
  });
399
595
  }),
596
+ createMany: (items) => __awaiter(this, void 0, void 0, function* () {
597
+ const repository = this.createEntityRepository(cls);
598
+ for (const item of items) {
599
+ yield repository.create(item);
600
+ }
601
+ }),
400
602
  read: (key) => __awaiter(this, void 0, void 0, function* () {
401
603
  return this.performOperation(cls.name, 'readonly', (store) => {
402
604
  const request = store.get(key);
@@ -410,29 +612,43 @@ class Database {
410
612
  });
411
613
  }),
412
614
  update: (item) => __awaiter(this, void 0, void 0, function* () {
615
+ validateItem(item);
413
616
  return this.performOperation(cls.name, 'readwrite', (store) => {
414
- const request = store.put(item);
415
- return new Promise((resolve, reject) => {
416
- request.onsuccess = () => {
417
- console.debug(`Item updated in ${cls.name}:`, item);
418
- resolve();
419
- };
420
- request.onerror = () => reject(request.error);
617
+ return updateStoredItem(store, item).then(() => {
618
+ console.debug(`Item updated in ${cls.name}:`, item);
421
619
  });
422
620
  });
423
621
  }),
622
+ updateMany: (items) => __awaiter(this, void 0, void 0, function* () {
623
+ const repository = this.createEntityRepository(cls);
624
+ for (const item of items) {
625
+ yield repository.update(item);
626
+ }
627
+ }),
424
628
  delete: (key) => __awaiter(this, void 0, void 0, function* () {
425
629
  return this.performOperation(cls.name, 'readwrite', (store) => {
426
- const request = store.delete(key);
427
- return new Promise((resolve, reject) => {
428
- request.onsuccess = () => {
429
- console.debug(`Item deleted from ${cls.name}:`, key);
430
- resolve();
431
- };
432
- request.onerror = () => reject(request.error);
630
+ return deleteStoredItem(store, key).then(() => {
631
+ console.debug(`Item deleted from ${cls.name}:`, key);
433
632
  });
434
633
  });
435
634
  }),
635
+ deleteMany: (keys) => __awaiter(this, void 0, void 0, function* () {
636
+ const repository = this.createEntityRepository(cls);
637
+ for (const key of keys) {
638
+ yield repository.delete(key);
639
+ }
640
+ }),
641
+ deleteWhere: (predicate) => __awaiter(this, void 0, void 0, function* () {
642
+ var _b;
643
+ const repository = this.createEntityRepository(cls);
644
+ const query = repository.query();
645
+ const resolvedQuery = (_b = predicate(query)) !== null && _b !== void 0 ? _b : query;
646
+ const matches = yield resolvedQuery.execute();
647
+ const keys = matches
648
+ .map((item) => extractKey(item))
649
+ .filter((key) => key !== undefined && key !== null);
650
+ yield repository.deleteMany(keys);
651
+ }),
436
652
  list: () => __awaiter(this, void 0, void 0, function* () {
437
653
  return this.performOperation(cls.name, 'readonly', (store) => {
438
654
  const request = store.getAll();
@@ -541,6 +757,16 @@ class Database {
541
757
  return operation(store);
542
758
  });
543
759
  }
760
+ close() {
761
+ if (this.retentionTimer) {
762
+ clearInterval(this.retentionTimer);
763
+ this.retentionTimer = null;
764
+ }
765
+ if (this.db) {
766
+ this.db.close();
767
+ this.db = null;
768
+ }
769
+ }
544
770
  getAvailableEntities() {
545
771
  return Array.from(this.entityRepositories.keys());
546
772
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idb-ts",
3
- "version": "3.10.0",
3
+ "version": "3.11.1",
4
4
  "description": "Easy CRUD for indexed-db, written in TypeScript",
5
5
  "main": "lib/index.js",
6
6
  "module": "lib/index.esm.js",
@@ -18,6 +18,8 @@
18
18
  "build": "pnpm run build:tsc && pnpm run build:rollup",
19
19
  "build:tsc": "tsc",
20
20
  "build:rollup": "rollup -c",
21
+ "lint": "eslint .",
22
+ "lint:fix": "eslint . --fix",
21
23
  "test": "jest --runInBand --detectOpenHandles",
22
24
  "test:watch": "jest --watchAll --runInBand --detectOpenHandles",
23
25
  "test:coverage": "jest --coverage --runInBand --detectOpenHandles",
@@ -52,14 +54,19 @@
52
54
  "tslib": "^2.8.1"
53
55
  },
54
56
  "devDependencies": {
57
+ "@eslint/js": "^9.31.0",
55
58
  "@rollup/plugin-typescript": "^12.3.0",
59
+ "@typescript-eslint/eslint-plugin": "^8.37.0",
60
+ "@typescript-eslint/parser": "^8.37.0",
56
61
  "@types/jest": "^30.0.0",
57
62
  "@types/node": "^24.12.0",
58
63
  "fake-indexeddb": "^6.2.5",
59
64
  "jest": "^30.3.0",
60
65
  "jest-environment-jsdom": "^30.3.0",
66
+ "eslint": "^9.31.0",
61
67
  "rollup": "^4.60.1",
62
68
  "ts-jest": "^29.4.9",
63
- "typescript": "^4.9.5"
69
+ "typescript": "^4.9.5",
70
+ "globals": "^15.15.0"
64
71
  }
65
72
  }