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 +92 -6
- package/lib/index.d.ts +30 -6
- package/lib/index.esm.js +256 -30
- package/lib/index.js +256 -30
- package/package.json +9 -2
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)
|
|
419
|
-
// v1.1: User(v1), Post(v2)
|
|
420
|
-
// v1.2: User(v1), Post(v2), Comment(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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
382
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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
|
-
|
|
418
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
391
|
-
|
|
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
|
-
|
|
415
|
-
|
|
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
|
-
|
|
427
|
-
|
|
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.
|
|
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
|
}
|