@hkdigital/lib-sveltekit 0.1.72 → 0.1.74

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.
@@ -1,27 +1,28 @@
1
1
  /**
2
- * @fileoverview IndexedDbCache provides efficient persistent caching with
2
+ * @fileoverview IndexedDbCache provides efficient persistent caching with
3
3
  * automatic, non-blocking background cleanup.
4
- *
4
+ *
5
5
  * This cache automatically manages storage limits and entry expiration
6
6
  * in the background using requestIdleCallback to avoid impacting application
7
7
  * performance. It supports incremental cleanup, storage monitoring, and
8
8
  * graceful degradation on older browsers.
9
- *
9
+ *
10
10
  * @example
11
11
  * // Create a cache instance
12
12
  * const cache = new IndexedDbCache({
13
13
  * dbName: 'app-cache',
14
14
  * storeName: 'http-responses',
15
15
  * maxSize: 100 * 1024 * 1024, // 100MB
16
- * maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
16
+ * maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
17
+ * cacheVersion: '1.0.0' // For cache invalidation
17
18
  * });
18
- *
19
+ *
19
20
  * // Store a response
20
21
  * const response = await fetch('https://api.example.com/data');
21
- * await cache.set('api-data', response, {
22
+ * await cache.set('api-data', response, {
22
23
  * expiresIn: 3600000 // 1 hour
23
24
  * });
24
- *
25
+ *
25
26
  * // Retrieve cached response
26
27
  * const cached = await cache.get('api-data');
27
28
  * if (cached) {
@@ -31,16 +32,21 @@
31
32
  * }
32
33
  */
33
34
 
34
- /**
35
- * @typedef {Object} CacheEntry
36
- * @property {Response} response - Cached Response object
37
- * @property {Object} metadata - Cache entry metadata
38
- * @property {string} url - Original URL
39
- * @property {number} timestamp - When the entry was cached
40
- * @property {number|null} expires - Expiration timestamp (null if no expiration)
41
- * @property {string|null} etag - ETag header if present
42
- * @property {string|null} lastModified - Last-Modified header if present
43
- */
35
+ /** @typedef {import('./typedef').CacheEntry} CacheEntry */
36
+
37
+ /** @typedef {import('./typedef').IDBRequestEvent} IDBRequestEvent */
38
+
39
+ /** @typedef {import('./typedef').IDBVersionChangeEvent} IDBVersionChangeEvent */
40
+
41
+ const DEFAULT_DB_NAME ='http-cache';
42
+ const DEFAULT_STORE_NAME ='responses';
43
+ const DEFAULT_MAX_SIZE = 50 * 1024 * 1024; // 50 MB
44
+ const DEFAULT_MAX_AGE = 90 * 24 * 60 * 60 * 1000; // 90 days
45
+
46
+ const DEFAULT_CLEANUP_BATCH_SIZE = 100;
47
+ const DEFAULT_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes;
48
+
49
+ const DEFAULT_CLEANUP_POSTPONE_MS = 5000; // 5 seconds
44
50
 
45
51
  /**
46
52
  * IndexedDbCache with automatic background cleanup
@@ -48,7 +54,7 @@
48
54
  export default class IndexedDbCache {
49
55
  /**
50
56
  * Create a new IndexedDB cache storage
51
- *
57
+ *
52
58
  * @param {Object} [options] - Cache options
53
59
  * @param {string} [options.dbName='http-cache'] - Database name
54
60
  * @param {string} [options.storeName='responses'] - Store name
@@ -56,22 +62,38 @@ export default class IndexedDbCache {
56
62
  * @param {number} [options.maxAge=604800000] - Max age in ms (7 days)
57
63
  * @param {number} [options.cleanupBatchSize=100] - Items per cleanup batch
58
64
  * @param {number} [options.cleanupInterval=300000] - Time between cleanup attempts (5min)
65
+ * @param {number} [options.cleanupPostponeTimeout=5000] - Time to postpone cleanup after store (5sec)
66
+ * @param {string} [options.cacheVersion='1.0.0'] - Cache version, used for cache invalidation
59
67
  */
60
68
  constructor(options = {}) {
61
- this.dbName = options.dbName || 'http-cache';
62
- this.storeName = options.storeName || 'responses';
63
- this.maxSize = options.maxSize || 50 * 1024 * 1024; // 50MB
64
- this.maxAge = options.maxAge || 7 * 24 * 60 * 60 * 1000; // 7 days
65
- this.cleanupBatchSize = options.cleanupBatchSize || 100;
66
- this.cleanupInterval = options.cleanupInterval || 5 * 60 * 1000; // 5 minutes
67
-
69
+ this.dbName = options.dbName || DEFAULT_DB_NAME;
70
+ this.storeName = options.storeName || DEFAULT_STORE_NAME;
71
+
72
+ this.maxSize = options.maxSize || DEFAULT_MAX_SIZE;
73
+ this.maxAge = options.maxAge || DEFAULT_MAX_AGE;
74
+
75
+ this.cleanupBatchSize = options.cleanupBatchSize || DEFAULT_CLEANUP_BATCH_SIZE;
76
+ this.cleanupInterval = options.cleanupInterval || DEFAULT_CLEANUP_INTERVAL;
77
+
78
+ this.cleanupPostponeTimeout = options.cleanupPostponeTimeout || DEFAULT_CLEANUP_POSTPONE_MS;
79
+ this.cacheVersion = options.cacheVersion || '1.0.0';
80
+
81
+ // Define index names as constants to ensure consistency
82
+ this.EXPIRES_INDEX = 'expires';
83
+ this.TIMESTAMP_INDEX = 'timestamp';
84
+ this.SIZE_INDEX = 'size';
85
+ this.CACHE_VERSION_INDEX = 'cacheVersion';
86
+
87
+ // Current schema version - CRITICAL: Increment this when schema changes
88
+ this.SCHEMA_VERSION = 2;
89
+
68
90
  /**
69
91
  * Database connection promise
70
92
  * @type {Promise<IDBDatabase>}
71
93
  * @private
72
94
  */
73
- this.dbPromise = this._openDatabase();
74
-
95
+ this.dbPromise = null;
96
+
75
97
  /**
76
98
  * Cleanup state tracker
77
99
  * @type {Object}
@@ -80,135 +102,324 @@ export default class IndexedDbCache {
80
102
  this.cleanupState = {
81
103
  inProgress: false,
82
104
  lastRun: 0,
83
- lastCursor: null,
84
105
  totalRemoved: 0,
85
- nextScheduled: false
106
+ nextScheduled: false,
107
+ postponeUntil: 0
86
108
  };
87
-
88
- // Schedule initial cleanup
89
- this._scheduleCleanup();
109
+
110
+ // Cleanup postponer timer handle
111
+ this.postponeCleanupTimer = null;
112
+
113
+ // Initialize the database and schedule cleanup only after it's ready
114
+ this._initDatabase();
90
115
  }
91
-
116
+
92
117
  /**
93
- * Open the IndexedDB database
94
- *
118
+ * Initialize the database connection
119
+ *
120
+ * @private
121
+ */
122
+ async _initDatabase() {
123
+ try {
124
+ this.dbPromise = this._openDatabase();
125
+ await this.dbPromise; // Wait for connection to be established
126
+
127
+ // Only schedule cleanup after database is ready
128
+ this._scheduleCleanup();
129
+ } catch (err) {
130
+ console.error('Failed to initialize IndexedDB cache:', err);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Open the IndexedDB database with proper schema versioning
136
+ *
95
137
  * @private
96
138
  * @returns {Promise<IDBDatabase>}
97
139
  */
98
140
  async _openDatabase() {
99
141
  return new Promise((resolve, reject) => {
100
- const request = indexedDB.open(this.dbName, 1);
101
-
102
- request.onerror = () => reject(request.error);
103
- request.onsuccess = () => resolve(request.result);
104
-
105
- request.onupgradeneeded = (event) => {
106
- const db = event.target.result;
107
- if (!db.objectStoreNames.contains(this.storeName)) {
108
- // Create object store with indexes to help with cleanup
109
- const store = db.createObjectStore(this.storeName, { keyPath: 'key' });
110
-
111
- // Index for expiration-based cleanup
112
- store.createIndex('expires', 'expires', { unique: false });
113
-
114
- // Index for age-based cleanup
115
- store.createIndex('timestamp', 'timestamp', { unique: false });
116
-
117
- // Index for size-based estimate (rough approximation)
118
- store.createIndex('size', 'size', { unique: false });
119
- }
120
- };
142
+ try {
143
+ // Open with current schema version
144
+ const request = indexedDB.open(this.dbName, this.SCHEMA_VERSION);
145
+
146
+ request.onerror = (event) => {
147
+ const target = /** @type {IDBRequest} */ (event.target);
148
+
149
+ console.error('IndexedDB open error:', target.error);
150
+ reject(target.error);
151
+ };
152
+
153
+ request.onsuccess = (event) => {
154
+ const db = /** @type {IDBRequest} */ (event.target).result;
155
+
156
+ // Listen for connection errors
157
+ db.onerror = (event) => {
158
+ console.error(
159
+ 'IndexedDB error:',
160
+ /** @type {IDBRequest} */ (event.target).error
161
+ );
162
+ };
163
+
164
+ resolve(db);
165
+ };
166
+
167
+ // This runs when database is created or version is upgraded
168
+ request.onupgradeneeded = (event) => {
169
+ // console.log(
170
+ // `Upgrading database schema to version ${this.SCHEMA_VERSION}`
171
+ // );
172
+
173
+ const target = /** @type {IDBRequest} */ (event.target);
174
+ const db = target.result;
175
+
176
+ // Create or update the object store
177
+ let store;
178
+
179
+ if (!db.objectStoreNames.contains(this.storeName)) {
180
+ store = db.createObjectStore(this.storeName, { keyPath: 'key' });
181
+ // console.log(`Created object store: ${this.storeName}`);
182
+ } else {
183
+ // Get existing store for updating
184
+ const transaction = target.transaction;
185
+ store = transaction.objectStore(this.storeName);
186
+ // console.log(`Using existing object store: ${this.storeName}`);
187
+ }
188
+
189
+ // Add indexes if they don't exist
190
+ this._ensureIndexExists(store, this.EXPIRES_INDEX, 'expires', {
191
+ unique: false
192
+ });
193
+ this._ensureIndexExists(store, this.TIMESTAMP_INDEX, 'timestamp', {
194
+ unique: false
195
+ });
196
+ this._ensureIndexExists(store, this.SIZE_INDEX, 'size', {
197
+ unique: false
198
+ });
199
+ this._ensureIndexExists(
200
+ store,
201
+ this.CACHE_VERSION_INDEX,
202
+ 'cacheVersion',
203
+ { unique: false }
204
+ );
205
+ };
206
+ } catch (err) {
207
+ console.error('Error opening IndexedDB:', err);
208
+ reject(err);
209
+ }
121
210
  });
122
211
  }
123
-
212
+
213
+ /**
214
+ * Ensure an index exists in a store, create if missing
215
+ *
216
+ * @private
217
+ * @param {IDBObjectStore} store - The object store
218
+ * @param {string} indexName - Name of the index
219
+ * @param {string} keyPath - Key path for the index
220
+ * @param {Object} options - Index options (e.g. unique)
221
+ */
222
+ _ensureIndexExists(store, indexName, keyPath, options) {
223
+ if (!store.indexNames.contains(indexName)) {
224
+ store.createIndex(indexName, keyPath, options);
225
+ // console.log(`Created index: ${indexName}`);
226
+ }
227
+ // else {
228
+ // console.log(`Index already exists: ${indexName}`);
229
+ // }
230
+ }
231
+
232
+ /**
233
+ * Check if all required indexes exist in the database
234
+ *
235
+ * @private
236
+ * @returns {Promise<boolean>}
237
+ */
238
+ async _validateSchema() {
239
+ try {
240
+ const db = await this.dbPromise;
241
+
242
+ // Verify the object store exists
243
+ if (!db.objectStoreNames.contains(this.storeName)) {
244
+ console.error(`Object store ${this.storeName} does not exist`);
245
+ return false;
246
+ }
247
+
248
+ // We need to start a transaction to access the store
249
+ const transaction = db.transaction(this.storeName, 'readonly');
250
+ const store = transaction.objectStore(this.storeName);
251
+
252
+ // Check that all required indexes exist
253
+ const requiredIndexes = [
254
+ this.EXPIRES_INDEX,
255
+ this.TIMESTAMP_INDEX,
256
+ this.SIZE_INDEX,
257
+ this.CACHE_VERSION_INDEX
258
+ ];
259
+
260
+ for (const indexName of requiredIndexes) {
261
+ if (!store.indexNames.contains(indexName)) {
262
+ console.error(`Required index ${indexName} does not exist`);
263
+ return false;
264
+ }
265
+ }
266
+
267
+ return true;
268
+ } catch (err) {
269
+ console.error('Error validating schema:', err);
270
+ return false;
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Postpone cleanup for the specified duration
276
+ * Resets the postpone timer if called again before timeout
277
+ *
278
+ * @private
279
+ */
280
+ _postponeCleanup() {
281
+ // Set the postpone timestamp
282
+ this.cleanupState.postponeUntil = Date.now() + this.cleanupPostponeTimeout;
283
+
284
+ // Clear any existing timer
285
+ if (this.postponeCleanupTimer) {
286
+ clearTimeout(this.postponeCleanupTimer);
287
+ }
288
+
289
+ // Set a new timer to reset the postpone flag
290
+ this.postponeCleanupTimer = setTimeout(() => {
291
+ // Only reset if another postpone hasn't happened
292
+ if (Date.now() >= this.cleanupState.postponeUntil) {
293
+ this.cleanupState.postponeUntil = 0;
294
+
295
+ // Reschedule cleanup if it was waiting
296
+ if (!this.cleanupState.inProgress && !this.cleanupState.nextScheduled) {
297
+ this._scheduleCleanup();
298
+ }
299
+ }
300
+ }, this.cleanupPostponeTimeout);
301
+ }
302
+
303
+ /**
304
+ * Check if cleanup is postponed
305
+ *
306
+ * @private
307
+ * @returns {boolean}
308
+ */
309
+ _isCleanupPostponed() {
310
+ return Date.now() < this.cleanupState.postponeUntil;
311
+ }
312
+
124
313
  /**
125
314
  * Get a cached response
126
- *
315
+ * Supports retrieving older cache versions and migrating them
316
+ *
127
317
  * @param {string} key - Cache key
128
318
  * @returns {Promise<CacheEntry|null>} Cache entry or null if not found/expired
129
319
  */
130
320
  async get(key) {
131
321
  try {
132
322
  const db = await this.dbPromise;
133
-
323
+
134
324
  return new Promise((resolve, reject) => {
135
- const transaction = db.transaction(this.storeName, 'readonly');
136
- const store = transaction.objectStore(this.storeName);
137
- const request = store.get(key);
138
-
139
- request.onerror = () => reject(request.error);
140
- request.onsuccess = () => {
141
- const entry = request.result;
142
-
143
- if (!entry) {
144
- resolve(null);
145
- return;
146
- }
147
-
148
- // Check if expired
149
- if (entry.expires && Date.now() > entry.expires) {
150
- // Delete expired entry
151
- this._deleteEntry(key).catch(console.error);
152
- resolve(null);
153
- return;
154
- }
155
-
156
- // Update access timestamp (but don't block)
157
- this._updateAccessTime(key).catch(console.error);
158
-
159
- // Deserialize the response
160
- try {
161
- // Create headers safely
162
- let responseHeaders;
163
- try {
164
- responseHeaders = new Headers(entry.headers);
165
- } catch (err) {
166
- // Fallback for environments without Headers support
167
- responseHeaders = {
168
- get: (name) => {
169
- const header = entry.headers?.find(h => h[0].toLowerCase() === name.toLowerCase());
170
- return header ? header[1] : null;
171
- }
172
- };
325
+ try {
326
+ const transaction = db.transaction(this.storeName, 'readonly');
327
+ const store = transaction.objectStore(this.storeName);
328
+ const request = store.get(key);
329
+
330
+ request.onerror = () => reject(request.error);
331
+ request.onsuccess = () => {
332
+ const entry = request.result;
333
+
334
+ if (!entry) {
335
+ resolve(null);
336
+ return;
173
337
  }
174
338
 
175
- // Create Response safely
176
- let response;
177
- try {
178
- response = new Response(entry.body, {
179
- status: entry.status,
180
- statusText: entry.statusText,
181
- headers: responseHeaders
339
+ // Check if expired
340
+ if (entry.expires && Date.now() > entry.expires) {
341
+ // Delete expired entry (but don't block)
342
+ this._deleteEntry(key).catch((err) => {
343
+ console.error('Failed to delete expired entry:', err);
182
344
  });
183
- } catch (err) {
184
- // Simplified mock response for test environments
185
- response = {
186
- status: entry.status,
187
- statusText: entry.statusText,
188
- headers: responseHeaders,
189
- body: entry.body,
190
- url: entry.url,
191
- clone() { return this; }
192
- };
345
+ resolve(null);
346
+ return;
193
347
  }
194
348
 
195
- resolve({
196
- response,
197
- metadata: entry.metadata,
198
- url: entry.url,
199
- timestamp: entry.timestamp,
200
- expires: entry.expires,
201
- etag: entry.etag,
202
- lastModified: entry.lastModified
349
+ // Update access timestamp (but don't block)
350
+ this._updateAccessTime(key).catch((err) => {
351
+ console.error('Failed to update access time:', err);
203
352
  });
204
- } catch (err) {
205
- console.error('Failed to deserialize cached response:', err);
206
353
 
207
- // Delete corrupted entry
208
- this._deleteEntry(key).catch(console.error);
209
- resolve(null);
210
- }
211
- };
354
+ // Check if from a different cache version
355
+ if (entry.cacheVersion !== this.cacheVersion) {
356
+ // console.log(
357
+ // `Migrating entry ${key} from version ${entry.cacheVersion} to ${this.cacheVersion}`
358
+ // );
359
+
360
+ // Clone the entry for migration
361
+ const migratedEntry = {
362
+ ...entry,
363
+ cacheVersion: this.cacheVersion
364
+ };
365
+
366
+ // Store the migrated entry (don't block)
367
+ this._updateEntry(migratedEntry).catch((err) => {
368
+ console.error(
369
+ 'Failed to migrate entry to current cache version:',
370
+ err
371
+ );
372
+ });
373
+ }
374
+
375
+ // Deserialize the response
376
+ try {
377
+ let responseHeaders = new Headers(entry.headers);
378
+
379
+ // Create Response safely
380
+ let response;
381
+ try {
382
+ response = new Response(entry.body, {
383
+ status: entry.status,
384
+ statusText: entry.statusText,
385
+ headers: responseHeaders
386
+ });
387
+ } catch (err) {
388
+ // Simplified mock response for test environments
389
+ response = /** @type {Response} */ ({
390
+ status: entry.status,
391
+ statusText: entry.statusText,
392
+ headers: responseHeaders,
393
+ body: entry.body,
394
+ url: entry.url,
395
+ clone() {
396
+ return this;
397
+ }
398
+ });
399
+ }
400
+
401
+ resolve({
402
+ response,
403
+ metadata: entry.metadata,
404
+ url: entry.url,
405
+ timestamp: entry.timestamp,
406
+ expires: entry.expires,
407
+ etag: entry.etag,
408
+ lastModified: entry.lastModified,
409
+ cacheVersion: entry.cacheVersion
410
+ });
411
+ } catch (err) {
412
+ console.error('Failed to deserialize cached response:', err);
413
+
414
+ // Delete corrupted entry
415
+ this._deleteEntry(key).catch(console.error);
416
+ resolve(null);
417
+ }
418
+ };
419
+ } catch (err) {
420
+ console.error('Error in get transaction:', err);
421
+ resolve(null);
422
+ }
212
423
  });
213
424
  } catch (err) {
214
425
  console.error('Cache get error:', err);
@@ -226,6 +437,9 @@ export default class IndexedDbCache {
226
437
  */
227
438
  async set(key, response, metadata = {}) {
228
439
  try {
440
+ // Postpone cleanup when storing items
441
+ this._postponeCleanup();
442
+
229
443
  const db = await this.dbPromise;
230
444
 
231
445
  // Clone the response to avoid consuming it
@@ -238,9 +452,11 @@ export default class IndexedDbCache {
238
452
  body = await clonedResponse.blob();
239
453
  } catch (err) {
240
454
  // Fallback for test environment
241
- if (typeof clonedResponse.body === 'string' ||
242
- clonedResponse.body instanceof ArrayBuffer ||
243
- clonedResponse.body instanceof Uint8Array) {
455
+ if (
456
+ typeof clonedResponse.body === 'string' ||
457
+ clonedResponse.body instanceof ArrayBuffer ||
458
+ clonedResponse.body instanceof Uint8Array
459
+ ) {
244
460
  body = new Blob([clonedResponse.body]);
245
461
  } else {
246
462
  // Last resort - store as-is and hope it's serializable
@@ -248,20 +464,33 @@ export default class IndexedDbCache {
248
464
  }
249
465
  }
250
466
 
251
- // Extract headers safely
467
+ // Extract headers
468
+
252
469
  let headers = [];
470
+
253
471
  try {
254
472
  headers = Array.from(clonedResponse.headers.entries());
255
473
  } catch (err) {
256
- // Fallback for test environment - extract headers if available
257
- if (clonedResponse._headers && typeof clonedResponse._headers.entries === 'function') {
258
- headers = Array.from(clonedResponse._headers.entries());
259
- }
474
+ // Handle the error case
475
+ console.error('Failed to extract headers:', err);
476
+ headers = [];
260
477
  }
261
478
 
479
+ // let headers = [];
480
+ // try {
481
+ // headers = Array.from(clonedResponse.headers.entries());
482
+ // } catch (err) {
483
+ // // Fallback for test environment - extract headers if available
484
+ // if (clonedResponse._headers &&
485
+ // typeof clonedResponse._headers.entries === 'function') {
486
+ // headers = Array.from(clonedResponse._headers.entries());
487
+ // }
488
+ // }
489
+
262
490
  // Calculate rough size estimate
263
491
  const headerSize = JSON.stringify(headers).length * 2;
264
- const size = (body.size || 0) + headerSize + key.length * 2;
492
+ const size =
493
+ /** @type {Blob} */ ((body).size || 0) + headerSize + key.length * 2;
265
494
 
266
495
  const entry = {
267
496
  key,
@@ -273,24 +502,33 @@ export default class IndexedDbCache {
273
502
  metadata,
274
503
  timestamp: Date.now(),
275
504
  lastAccessed: Date.now(),
276
- expires: metadata.expires || (metadata.expiresIn ? Date.now() + metadata.expiresIn : null),
505
+ expires:
506
+ metadata.expires ||
507
+ (metadata.expiresIn ? Date.now() + metadata.expiresIn : null),
277
508
  etag: clonedResponse.headers?.get?.('ETag') || null,
278
509
  lastModified: clonedResponse.headers?.get?.('Last-Modified') || null,
510
+ cacheVersion: this.cacheVersion, // Store current cache version
279
511
  size // Store estimated size for cleanup
280
512
  };
281
513
 
282
514
  return new Promise((resolve, reject) => {
283
- const transaction = db.transaction(this.storeName, 'readwrite');
284
- const store = transaction.objectStore(this.storeName);
285
- const request = store.put(entry);
286
-
287
- request.onerror = () => reject(request.error);
288
- request.onsuccess = () => {
289
- resolve();
290
-
291
- // Check if we need cleanup after adding new entries
292
- this._checkAndScheduleCleanup();
293
- };
515
+ try {
516
+ const transaction = db.transaction(this.storeName, 'readwrite');
517
+ const store = transaction.objectStore(this.storeName);
518
+ const request = store.put(entry);
519
+
520
+ request.onerror = () => reject(request.error);
521
+ request.onsuccess = () => {
522
+ resolve();
523
+
524
+ // Check if we need cleanup after adding new entries
525
+ // Don't await to avoid blocking
526
+ this._checkAndScheduleCleanup();
527
+ };
528
+ } catch (err) {
529
+ console.error('Error in set transaction:', err);
530
+ reject(err);
531
+ }
294
532
  });
295
533
  } catch (err) {
296
534
  console.error('Cache set error:', err);
@@ -298,6 +536,36 @@ export default class IndexedDbCache {
298
536
  }
299
537
  }
300
538
 
539
+ /**
540
+ * Update an existing entry in the cache
541
+ *
542
+ * @private
543
+ * @param {Object} entry - The entry to update
544
+ * @returns {Promise<boolean>}
545
+ */
546
+ async _updateEntry(entry) {
547
+ try {
548
+ const db = await this.dbPromise;
549
+
550
+ return new Promise((resolve, reject) => {
551
+ try {
552
+ const transaction = db.transaction(this.storeName, 'readwrite');
553
+ const store = transaction.objectStore(this.storeName);
554
+ const request = store.put(entry);
555
+
556
+ request.onerror = () => reject(request.error);
557
+ request.onsuccess = () => resolve(true);
558
+ } catch (err) {
559
+ console.error('Error in update transaction:', err);
560
+ resolve(false);
561
+ }
562
+ });
563
+ } catch (err) {
564
+ console.error('Cache update error:', err);
565
+ return false;
566
+ }
567
+ }
568
+
301
569
  /**
302
570
  * Update last accessed timestamp (without blocking)
303
571
  *
@@ -306,26 +574,36 @@ export default class IndexedDbCache {
306
574
  * @returns {Promise<void>}
307
575
  */
308
576
  async _updateAccessTime(key) {
309
- const db = await this.dbPromise;
577
+ try {
578
+ const db = await this.dbPromise;
310
579
 
311
- return new Promise((resolve) => {
312
- const transaction = db.transaction(this.storeName, 'readwrite');
313
- const store = transaction.objectStore(this.storeName);
314
- const request = store.get(key);
580
+ return new Promise((resolve) => {
581
+ try {
582
+ const transaction = db.transaction(this.storeName, 'readwrite');
583
+ const store = transaction.objectStore(this.storeName);
584
+ const request = store.get(key);
315
585
 
316
- request.onerror = () => resolve(); // Don't block on errors
586
+ request.onerror = () => resolve(); // Don't block on errors
317
587
 
318
- request.onsuccess = () => {
319
- const entry = request.result;
320
- if (!entry) return resolve();
588
+ request.onsuccess = () => {
589
+ const entry = request.result;
590
+ if (!entry) return resolve();
321
591
 
322
- entry.lastAccessed = Date.now();
592
+ entry.lastAccessed = Date.now();
323
593
 
324
- const updateRequest = store.put(entry);
325
- updateRequest.onerror = () => resolve(); // Don't block
326
- updateRequest.onsuccess = () => resolve();
327
- };
328
- });
594
+ const updateRequest = store.put(entry);
595
+ updateRequest.onerror = () => resolve(); // Don't block
596
+ updateRequest.onsuccess = () => resolve();
597
+ };
598
+ } catch (err) {
599
+ console.error('Error in _updateAccessTime:', err);
600
+ resolve(); // Don't block on errors
601
+ }
602
+ });
603
+ } catch (err) {
604
+ console.error('Failed to update access time:', err);
605
+ // Don't rethrow to avoid blocking
606
+ }
329
607
  }
330
608
 
331
609
  /**
@@ -350,12 +628,17 @@ export default class IndexedDbCache {
350
628
  const db = await this.dbPromise;
351
629
 
352
630
  return new Promise((resolve, reject) => {
353
- const transaction = db.transaction(this.storeName, 'readwrite');
354
- const store = transaction.objectStore(this.storeName);
355
- const request = store.delete(key);
356
-
357
- request.onerror = () => reject(request.error);
358
- request.onsuccess = () => resolve(true);
631
+ try {
632
+ const transaction = db.transaction(this.storeName, 'readwrite');
633
+ const store = transaction.objectStore(this.storeName);
634
+ const request = store.delete(key);
635
+
636
+ request.onerror = () => reject(request.error);
637
+ request.onsuccess = () => resolve(true);
638
+ } catch (err) {
639
+ console.error('Error in delete transaction:', err);
640
+ resolve(false);
641
+ }
359
642
  });
360
643
  } catch (err) {
361
644
  console.error('Cache delete error:', err);
@@ -373,16 +656,20 @@ export default class IndexedDbCache {
373
656
  const db = await this.dbPromise;
374
657
 
375
658
  return new Promise((resolve, reject) => {
376
- const transaction = db.transaction(this.storeName, 'readwrite');
377
- const store = transaction.objectStore(this.storeName);
378
- const request = store.clear();
379
-
380
- request.onerror = () => reject(request.error);
381
- request.onsuccess = () => {
382
- this.cleanupState.lastCursor = null;
383
- this.cleanupState.totalRemoved = 0;
384
- resolve();
385
- };
659
+ try {
660
+ const transaction = db.transaction(this.storeName, 'readwrite');
661
+ const store = transaction.objectStore(this.storeName);
662
+ const request = store.clear();
663
+
664
+ request.onerror = () => reject(request.error);
665
+ request.onsuccess = () => {
666
+ this.cleanupState.totalRemoved = 0;
667
+ resolve();
668
+ };
669
+ } catch (err) {
670
+ console.error('Error in clear transaction:', err);
671
+ reject(err);
672
+ }
386
673
  });
387
674
  } catch (err) {
388
675
  console.error('Cache clear error:', err);
@@ -396,6 +683,11 @@ export default class IndexedDbCache {
396
683
  * @private
397
684
  */
398
685
  async _checkAndScheduleCleanup() {
686
+ // Skip if cleanup is postponed
687
+ if (this._isCleanupPostponed()) {
688
+ return;
689
+ }
690
+
399
691
  // Avoid multiple concurrent checks
400
692
  if (this.cleanupState.inProgress || this.cleanupState.nextScheduled) {
401
693
  return;
@@ -408,9 +700,11 @@ export default class IndexedDbCache {
408
700
  }
409
701
 
410
702
  // Use storage estimate API if available in browser environment
411
- if (typeof navigator !== 'undefined' &&
412
- navigator.storage &&
413
- typeof navigator.storage.estimate === 'function') {
703
+ if (
704
+ typeof navigator !== 'undefined' &&
705
+ navigator.storage &&
706
+ typeof navigator.storage.estimate === 'function'
707
+ ) {
414
708
  try {
415
709
  const estimate = await navigator.storage.estimate();
416
710
  const usageRatio = estimate.usage / estimate.quota;
@@ -422,7 +716,7 @@ export default class IndexedDbCache {
422
716
  }
423
717
  } catch (err) {
424
718
  // Fall back to regular scheduling if estimate fails
425
- console.error('Storage estimate error:', err);
719
+ console.warn('Storage estimate error:', err);
426
720
  }
427
721
  }
428
722
 
@@ -437,6 +731,11 @@ export default class IndexedDbCache {
437
731
  * @param {boolean} [urgent=false] - If true, clean up sooner
438
732
  */
439
733
  _scheduleCleanup(urgent = false) {
734
+ // Skip if cleanup is postponed
735
+ if (this._isCleanupPostponed()) {
736
+ return;
737
+ }
738
+
440
739
  if (this.cleanupState.nextScheduled) {
441
740
  return;
442
741
  }
@@ -444,76 +743,142 @@ export default class IndexedDbCache {
444
743
  this.cleanupState.nextScheduled = true;
445
744
 
446
745
  // Check if we're in a browser environment with requestIdleCallback
447
- if (typeof window !== 'undefined' &&
448
- typeof window.requestIdleCallback === 'function') {
746
+ if (
747
+ typeof window !== 'undefined' &&
748
+ typeof window.requestIdleCallback === 'function'
749
+ ) {
449
750
  window.requestIdleCallback(
450
751
  () => {
451
752
  this.cleanupState.nextScheduled = false;
452
- this._performCleanupStep();
753
+ // Check again if postponed before actually running
754
+ if (!this._isCleanupPostponed()) {
755
+ this._performCleanupStep();
756
+ }
453
757
  },
454
758
  { timeout: urgent ? 1000 : 10000 }
455
759
  );
456
760
  } else {
457
761
  // Fallback for Node.js or browsers without requestIdleCallback
458
- setTimeout(() => {
459
- this.cleanupState.nextScheduled = false;
460
- this._performCleanupStep();
461
- }, urgent ? 100 : 1000);
762
+ setTimeout(
763
+ () => {
764
+ this.cleanupState.nextScheduled = false;
765
+ // Check again if postponed before actually running
766
+ if (!this._isCleanupPostponed()) {
767
+ this._performCleanupStep();
768
+ }
769
+ },
770
+ urgent ? 100 : 1000
771
+ );
462
772
  }
463
773
  }
464
774
 
775
+ /**
776
+ * Get a random expiration time between 30 minutes and 90 minutes
777
+ *
778
+ * @private
779
+ * @returns {number} Expiration time in milliseconds
780
+ */
781
+ _getRandomExpiration() {
782
+ // Base time: 60 minutes (3,600,000 ms)
783
+ const baseTime = 3600000;
784
+
785
+ // Random factor: +/- 30 minutes (1,800,000 ms)
786
+ const randomFactor = Math.random() * 1800000 - 900000;
787
+
788
+ return Date.now() + baseTime + randomFactor;
789
+ }
790
+
465
791
  /**
466
792
  * Perform a single cleanup step
467
793
  *
468
794
  * @private
469
795
  */
470
796
  async _performCleanupStep() {
471
- if (this.cleanupState.inProgress) {
797
+ // Skip if already in progress or postponed
798
+ if (this.cleanupState.inProgress || this._isCleanupPostponed()) {
472
799
  return;
473
800
  }
474
801
 
475
802
  this.cleanupState.inProgress = true;
476
803
 
477
804
  try {
805
+ // First, validate the database schema
806
+ const schemaValid = await this._validateSchema();
807
+
808
+ // If schema is invalid, skip cleanup
809
+ if (!schemaValid) {
810
+ console.warn('Skipping cleanup due to invalid schema');
811
+ this.cleanupState.inProgress = false;
812
+ return;
813
+ }
814
+
478
815
  const now = Date.now();
479
- const db = await this.dbPromise;
480
816
  let removedCount = 0;
481
817
 
482
818
  // Step 1: Remove expired entries first
483
- const expiredRemoved = await this._removeExpiredEntries(
484
- this.cleanupBatchSize / 2
485
- );
486
- removedCount += expiredRemoved;
819
+ try {
820
+ const expiredRemoved = await this._removeExpiredEntries(
821
+ this.cleanupBatchSize / 2
822
+ );
823
+ removedCount += expiredRemoved;
824
+
825
+ // If we have a lot of expired entries, focus on those first
826
+ if (expiredRemoved >= this.cleanupBatchSize / 2) {
827
+ this.cleanupState.lastRun = now;
828
+ this.cleanupState.totalRemoved += removedCount;
829
+ this.cleanupState.inProgress = false;
830
+
831
+ // Schedule next cleanup step immediately if not postponed
832
+ if (!this._isCleanupPostponed()) {
833
+ this._scheduleCleanup();
834
+ }
835
+ return;
836
+ }
837
+ } catch (err) {
838
+ console.error('Error removing expired entries:', err);
839
+ // Continue to try the next cleanup step
840
+ }
487
841
 
488
- // If we have a lot of expired entries, focus on those first
489
- if (expiredRemoved >= this.cleanupBatchSize / 2) {
842
+ // Check again if cleanup has been postponed during the operation
843
+ if (this._isCleanupPostponed()) {
490
844
  this.cleanupState.inProgress = false;
491
- this.cleanupState.lastRun = now;
492
- this.cleanupState.totalRemoved += removedCount;
493
-
494
- // Schedule next cleanup step immediately
495
- this._scheduleCleanup();
496
845
  return;
497
846
  }
498
847
 
499
- // Step 2: Remove old entries if we're over size/age limits
500
- const remainingBatch = this.cleanupBatchSize - expiredRemoved;
501
- if (remainingBatch > 0) {
502
- const oldRemoved = await this._removeOldEntries(remainingBatch);
503
- removedCount += oldRemoved;
848
+ // Step 2: Mark entries from different cache versions for expiration
849
+ try {
850
+ const markedCount = await this._markOldCacheVersionsForExpiration(
851
+ this.cleanupBatchSize / 4
852
+ );
853
+
854
+ // if (markedCount > 0) {
855
+ // console.log(
856
+ // `Marked ${markedCount} entries from different cache versions for expiration`
857
+ // );
858
+ // }
859
+ } catch (err) {
860
+ console.error('Error marking old cache versions for expiration:', err);
861
+ }
862
+
863
+ // Step 3: Remove old entries if we're over size/age limits
864
+ try {
865
+ const remainingBatch = this.cleanupBatchSize - removedCount;
866
+ if (remainingBatch > 0) {
867
+ const oldRemoved = await this._removeOldEntries(remainingBatch);
868
+ removedCount += oldRemoved;
869
+ }
870
+ } catch (err) {
871
+ console.error('Error removing old entries:', err);
504
872
  }
505
873
 
506
874
  // Update cleanup state
507
875
  this.cleanupState.lastRun = now;
508
876
  this.cleanupState.totalRemoved += removedCount;
509
877
 
510
- // If we removed entries in this batch, schedule another cleanup
511
- if (removedCount > 0) {
878
+ // If we removed entries in this batch and not postponed, schedule another cleanup
879
+ if (removedCount > 0 && !this._isCleanupPostponed()) {
512
880
  this._scheduleCleanup();
513
- } else {
514
- // Reset cursor if we didn't find anything to clean
515
- this.cleanupState.lastCursor = null;
516
-
881
+ } else if (!this._isCleanupPostponed()) {
517
882
  // Schedule a check later
518
883
  setTimeout(() => {
519
884
  this._checkAndScheduleCleanup();
@@ -534,41 +899,242 @@ export default class IndexedDbCache {
534
899
  * @returns {Promise<number>} Number of entries removed
535
900
  */
536
901
  async _removeExpiredEntries(limit) {
537
- const now = Date.now();
538
- const db = await this.dbPromise;
539
- let removed = 0;
902
+ try {
903
+ const now = Date.now();
904
+ const db = await this.dbPromise;
905
+ let removed = 0;
540
906
 
541
- return new Promise((resolve) => {
542
- const transaction = db.transaction(this.storeName, 'readwrite');
543
- const store = transaction.objectStore(this.storeName);
544
- const index = store.index('expires');
907
+ return new Promise((resolve, reject) => {
908
+ try {
909
+ const transaction = db.transaction(this.storeName, 'readwrite');
910
+ const store = transaction.objectStore(this.storeName);
911
+
912
+ // Check if the index exists before using it
913
+ if (!store.indexNames.contains(this.EXPIRES_INDEX)) {
914
+ console.error(`Required index ${this.EXPIRES_INDEX} not found`);
915
+ resolve(0);
916
+ return;
917
+ }
545
918
 
546
- // Create range for all entries with expiration before now
547
- const range = IDBKeyRange.upperBound(now);
919
+ const index = store.index(this.EXPIRES_INDEX);
548
920
 
549
- // Skip non-expiring entries (null expiration)
550
- const request = index.openCursor(range);
921
+ // Create range for all entries with expiration before now
922
+ const range = IDBKeyRange.upperBound(now);
551
923
 
552
- request.onerror = () => resolve(removed);
924
+ // Skip non-expiring entries (null expiration)
925
+ const request = index.openCursor(range);
553
926
 
554
- request.onsuccess = (event) => {
555
- const cursor = event.target.result;
927
+ request.onerror = (event) => {
928
+ console.error(
929
+ 'Cursor error in _removeExpiredEntries:',
930
+ /** @type {IDBRequest} */ (event.target).error
931
+ );
932
+ reject(/** @type {IDBRequest} */ (event.target).error);
933
+ };
556
934
 
557
- if (!cursor || removed >= limit) {
558
- resolve(removed);
559
- return;
935
+ // Handle cursor results
936
+ request.onsuccess = (event) => {
937
+ const cursor = /** @type {IDBRequest} */ (event.target).result;
938
+
939
+ if (!cursor || removed >= limit) {
940
+ resolve(removed);
941
+ return;
942
+ }
943
+
944
+ try {
945
+ // Delete the expired entry
946
+ const deleteRequest = cursor.delete();
947
+
948
+ deleteRequest.onsuccess = () => {
949
+ removed++;
950
+
951
+ // Move to next entry
952
+ try {
953
+ cursor.continue();
954
+ } catch (err) {
955
+ console.error(
956
+ 'Error continuing cursor in _removeExpiredEntries:',
957
+ err
958
+ );
959
+ resolve(removed);
960
+ }
961
+ };
962
+
963
+ deleteRequest.onerror = (event) => {
964
+ console.error(
965
+ 'Delete error in _removeExpiredEntries:',
966
+ event.target.error
967
+ );
968
+ // Try to continue anyway
969
+ try {
970
+ cursor.continue();
971
+ } catch (err) {
972
+ console.error(
973
+ 'Error continuing cursor after delete error:',
974
+ err
975
+ );
976
+ resolve(removed);
977
+ }
978
+ };
979
+ } catch (err) {
980
+ console.error(
981
+ 'Error deleting entry in _removeExpiredEntries:',
982
+ err
983
+ );
984
+ resolve(removed);
985
+ }
986
+ };
987
+ } catch (err) {
988
+ console.error(
989
+ 'Error creating transaction in _removeExpiredEntries:',
990
+ err
991
+ );
992
+ resolve(0);
560
993
  }
994
+ });
995
+ } catch (err) {
996
+ console.error('_removeExpiredEntries error:', err);
997
+ return 0;
998
+ }
999
+ }
561
1000
 
562
- // Delete the expired entry
563
- const deleteRequest = cursor.delete();
564
- deleteRequest.onsuccess = () => {
565
- removed++;
566
- };
1001
+ /**
1002
+ * Mark entries from old cache versions for gradual expiration
1003
+ *
1004
+ * @private
1005
+ * @param {number} limit - Maximum number of entries to mark
1006
+ * @returns {Promise<number>} Number of entries marked
1007
+ */
1008
+ async _markOldCacheVersionsForExpiration(limit) {
1009
+ try {
1010
+ const db = await this.dbPromise;
1011
+ let marked = 0;
567
1012
 
568
- // Move to next entry
569
- cursor.continue();
570
- };
571
- });
1013
+ return new Promise((resolve, reject) => {
1014
+ try {
1015
+ const transaction = db.transaction(this.storeName, 'readwrite');
1016
+ const store = transaction.objectStore(this.storeName);
1017
+
1018
+ // Check if the index exists before using it
1019
+ if (!store.indexNames.contains(this.CACHE_VERSION_INDEX)) {
1020
+ console.error(
1021
+ `Required index ${this.CACHE_VERSION_INDEX} not found`
1022
+ );
1023
+ resolve(0);
1024
+ return;
1025
+ }
1026
+
1027
+ // Get all entries not matching the current cache version
1028
+ const index = store.index(this.CACHE_VERSION_INDEX);
1029
+
1030
+ // We need to use openCursor since we can't directly query for "not equals"
1031
+ const request = index.openCursor();
1032
+
1033
+ request.onerror = (event) => {
1034
+ console.error(
1035
+ 'Cursor error in _markOldCacheVersionsForExpiration:',
1036
+ /** @type {IDBRequest} */ (event.target).error
1037
+ );
1038
+ reject(/** @type {IDBRequest} */ (event.target).error);
1039
+ };
1040
+
1041
+ request.onsuccess = (event) => {
1042
+ const cursor = /** @type {IDBRequest} */ (event.target).result;
1043
+
1044
+ if (!cursor || marked >= limit) {
1045
+ resolve(marked);
1046
+ return;
1047
+ }
1048
+
1049
+ try {
1050
+ const entry = cursor.value;
1051
+
1052
+ // Only process entries from different cache versions
1053
+ if (entry.cacheVersion !== this.cacheVersion) {
1054
+ // Set a randomized expiration time if not already set
1055
+ if (
1056
+ !entry.expires ||
1057
+ entry.expires > this._getRandomExpiration()
1058
+ ) {
1059
+ entry.expires = this._getRandomExpiration();
1060
+
1061
+ const updateRequest = cursor.update(entry);
1062
+
1063
+ updateRequest.onsuccess = () => {
1064
+ marked++;
1065
+
1066
+ // Continue to next entry
1067
+ try {
1068
+ cursor.continue();
1069
+ } catch (err) {
1070
+ console.error(
1071
+ 'Error continuing cursor after update:',
1072
+ err
1073
+ );
1074
+ resolve(marked);
1075
+ }
1076
+ };
1077
+
1078
+ updateRequest.onerror = (event) => {
1079
+ console.error(
1080
+ 'Update error in _markOldCacheVersionsForExpiration:',
1081
+ event.target.error
1082
+ );
1083
+ // Try to continue anyway
1084
+ try {
1085
+ cursor.continue();
1086
+ } catch (err) {
1087
+ console.error(
1088
+ 'Error continuing cursor after update error:',
1089
+ err
1090
+ );
1091
+ resolve(marked);
1092
+ }
1093
+ };
1094
+ } else {
1095
+ // Entry already has an expiration set, continue to next
1096
+ try {
1097
+ cursor.continue();
1098
+ } catch (err) {
1099
+ console.error(
1100
+ 'Error continuing cursor for entry with expiration:',
1101
+ err
1102
+ );
1103
+ resolve(marked);
1104
+ }
1105
+ }
1106
+ } else {
1107
+ // Skip entries from current cache version
1108
+ try {
1109
+ cursor.continue();
1110
+ } catch (err) {
1111
+ console.error(
1112
+ 'Error continuing cursor for current version entry:',
1113
+ err
1114
+ );
1115
+ resolve(marked);
1116
+ }
1117
+ }
1118
+ } catch (err) {
1119
+ console.error(
1120
+ 'Error processing entry in _markOldCacheVersionsForExpiration:',
1121
+ err
1122
+ );
1123
+ resolve(marked);
1124
+ }
1125
+ };
1126
+ } catch (err) {
1127
+ console.error(
1128
+ 'Error creating transaction in _markOldCacheVersionsForExpiration:',
1129
+ err
1130
+ );
1131
+ resolve(0);
1132
+ }
1133
+ });
1134
+ } catch (err) {
1135
+ console.error('_markOldCacheVersionsForExpiration error:', err);
1136
+ return 0;
1137
+ }
572
1138
  }
573
1139
 
574
1140
  /**
@@ -579,51 +1145,124 @@ export default class IndexedDbCache {
579
1145
  * @returns {Promise<number>} Number of entries removed
580
1146
  */
581
1147
  async _removeOldEntries(limit) {
582
- const db = await this.dbPromise;
583
- let removed = 0;
1148
+ try {
1149
+ const db = await this.dbPromise;
1150
+ let removed = 0;
584
1151
 
585
- // Get total cache size estimate (rough)
586
- const sizeEstimate = await this._getCacheSizeEstimate();
1152
+ // Get total cache size estimate (rough)
1153
+ const sizeEstimate = await this._getCacheSizeEstimate();
587
1154
 
588
- // If we're under limits, don't remove anything
589
- if (sizeEstimate < this.maxSize) {
590
- return 0;
591
- }
1155
+ // If we're under limits, don't remove anything
1156
+ if (sizeEstimate < this.maxSize) {
1157
+ return 0;
1158
+ }
592
1159
 
593
- return new Promise((resolve) => {
594
- const transaction = db.transaction(this.storeName, 'readwrite');
595
- const store = transaction.objectStore(this.storeName);
596
- const index = store.index('timestamp');
1160
+ return new Promise((resolve, reject) => {
1161
+ try {
1162
+ const transaction = db.transaction(this.storeName, 'readwrite');
1163
+ const store = transaction.objectStore(this.storeName);
1164
+
1165
+ // Check if the index exists before using it
1166
+ if (!store.indexNames.contains(this.TIMESTAMP_INDEX)) {
1167
+ console.error(`Required index ${this.TIMESTAMP_INDEX} not found`);
1168
+ resolve(0);
1169
+ return;
1170
+ }
597
1171
 
598
- // Start from the oldest entries
599
- const request = index.openCursor();
1172
+ const index = store.index(this.TIMESTAMP_INDEX);
1173
+ const now = Date.now();
600
1174
 
601
- request.onerror = () => resolve(removed);
1175
+ // Start from the oldest entries
1176
+ const request = index.openCursor();
602
1177
 
603
- request.onsuccess = (event) => {
604
- const cursor = event.target.result;
1178
+ request.onerror = (event) => {
1179
+ console.error(
1180
+ 'Cursor error in _removeOldEntries:',
1181
+ /** @type {IDBRequest} */ (event.target).error
1182
+ );
1183
+ reject(/** @type {IDBRequest} */ (event.target).error);
1184
+ };
605
1185
 
606
- if (!cursor || removed >= limit) {
607
- resolve(removed);
608
- return;
609
- }
1186
+ // Process cursor results
1187
+ request.onsuccess = (event) => {
1188
+ const cursor = /** @type {IDBRequest} */ (event.target).result;
610
1189
 
611
- const entry = cursor.value;
612
- const now = Date.now();
613
- const age = now - entry.timestamp;
1190
+ if (!cursor || removed >= limit) {
1191
+ resolve(removed);
1192
+ return;
1193
+ }
614
1194
 
615
- // Delete if older than max age
616
- if (age > this.maxAge) {
617
- const deleteRequest = cursor.delete();
618
- deleteRequest.onsuccess = () => {
619
- removed++;
1195
+ try {
1196
+ const entry = cursor.value;
1197
+ const age = now - entry.timestamp;
1198
+
1199
+ // Delete if older than max age
1200
+ if (age > this.maxAge) {
1201
+ const deleteRequest = cursor.delete();
1202
+
1203
+ deleteRequest.onsuccess = () => {
1204
+ removed++;
1205
+
1206
+ // Move to next entry
1207
+ try {
1208
+ cursor.continue();
1209
+ } catch (err) {
1210
+ console.error(
1211
+ 'Error continuing cursor in _removeOldEntries:',
1212
+ err
1213
+ );
1214
+ resolve(removed);
1215
+ }
1216
+ };
1217
+
1218
+ deleteRequest.onerror = (event) => {
1219
+ console.error(
1220
+ 'Delete error in _removeOldEntries:',
1221
+ event.target.error
1222
+ );
1223
+ // Try to continue anyway
1224
+ try {
1225
+ cursor.continue();
1226
+ } catch (err) {
1227
+ console.error(
1228
+ 'Error continuing cursor after delete error:',
1229
+ err
1230
+ );
1231
+ resolve(removed);
1232
+ }
1233
+ };
1234
+ } else {
1235
+ // Entry is not old enough, continue to next
1236
+ try {
1237
+ cursor.continue();
1238
+ } catch (err) {
1239
+ console.error(
1240
+ 'Error continuing cursor for entry not deleted:',
1241
+ err
1242
+ );
1243
+ resolve(removed);
1244
+ }
1245
+ }
1246
+ } catch (err) {
1247
+ console.error(
1248
+ 'Error processing entry in _removeOldEntries:',
1249
+ err
1250
+ );
1251
+ resolve(removed);
1252
+ }
620
1253
  };
1254
+ } catch (err) {
1255
+ console.error(
1256
+ 'Error creating transaction in _removeOldEntries:',
1257
+ err
1258
+ );
1259
+ resolve(0);
621
1260
  }
622
-
623
- // Move to next entry
624
- cursor.continue();
625
- };
626
- });
1261
+ });
1262
+ } catch (err) {
1263
+ console.error('_removeOldEntries error:', err);
1264
+ return 0;
1265
+ }
627
1266
  }
628
1267
 
629
1268
  /**
@@ -633,41 +1272,106 @@ export default class IndexedDbCache {
633
1272
  * @returns {Promise<number>} Size estimate in bytes
634
1273
  */
635
1274
  async _getCacheSizeEstimate() {
636
- const db = await this.dbPromise;
1275
+ try {
1276
+ const db = await this.dbPromise;
1277
+ let totalSize = 0;
637
1278
 
638
- return new Promise((resolve) => {
639
- const transaction = db.transaction(this.storeName, 'readonly');
640
- const store = transaction.objectStore(this.storeName);
641
- const index = store.index('size');
1279
+ return new Promise((resolve, reject) => {
1280
+ try {
1281
+ const transaction = db.transaction(this.storeName, 'readonly');
1282
+ const store = transaction.objectStore(this.storeName);
1283
+
1284
+ // Check if the index exists before using it
1285
+ if (!store.indexNames.contains(this.SIZE_INDEX)) {
1286
+ console.error(`Required index ${this.SIZE_INDEX} not found`);
1287
+ resolve(0);
1288
+ return;
1289
+ }
642
1290
 
643
- // Get the sum of all entry sizes
644
- const request = index.openCursor();
645
- let totalSize = 0;
1291
+ const index = store.index(this.SIZE_INDEX);
646
1292
 
647
- request.onerror = () => resolve(totalSize);
1293
+ // Get the sum of all entry sizes
1294
+ const request = index.openCursor();
648
1295
 
649
- request.onsuccess = (event) => {
650
- const cursor = event.target.result;
1296
+ request.onerror = (event) => {
1297
+ console.error(
1298
+ 'Cursor error in _getCacheSizeEstimate:',
1299
+ /** @type {IDBRequest} */ (event.target).error
1300
+ );
1301
+ resolve(totalSize); // Resolve with what we have so far
1302
+ };
651
1303
 
652
- if (!cursor) {
653
- resolve(totalSize);
654
- return;
655
- }
1304
+ request.onsuccess = (event) => {
1305
+ const cursor = /** @type {IDBRequest} */ (event.target).result;
656
1306
 
657
- const entry = cursor.value;
658
- totalSize += entry.size || 0;
1307
+ if (!cursor) {
1308
+ resolve(totalSize);
1309
+ return;
1310
+ }
659
1311
 
660
- cursor.continue();
661
- };
662
- });
1312
+ try {
1313
+ const entry = cursor.value;
1314
+ totalSize += entry.size || 0;
1315
+
1316
+ // Continue to next entry
1317
+ cursor.continue();
1318
+ } catch (err) {
1319
+ console.error(
1320
+ 'Error processing cursor in _getCacheSizeEstimate:',
1321
+ err
1322
+ );
1323
+ resolve(totalSize); // Resolve with what we have so far
1324
+ }
1325
+ };
1326
+ } catch (err) {
1327
+ console.error('Error in _getCacheSizeEstimate transaction:', err);
1328
+ resolve(0);
1329
+ }
1330
+ });
1331
+ } catch (err) {
1332
+ console.error('_getCacheSizeEstimate error:', err);
1333
+ return 0;
1334
+ }
663
1335
  }
664
1336
 
665
1337
  /**
666
1338
  * Close the database connection
667
1339
  */
668
- close() {
669
- this.dbPromise.then(db => {
670
- db.close();
671
- }).catch(console.error);
1340
+ async close() {
1341
+ try {
1342
+ // Wait for any in-progress operations to complete
1343
+ if (this.cleanupState.inProgress) {
1344
+ await new Promise((resolve) => {
1345
+ const checkInterval = setInterval(() => {
1346
+ if (!this.cleanupState.inProgress) {
1347
+ clearInterval(checkInterval);
1348
+ resolve();
1349
+ }
1350
+ }, 50); // Check every 50ms
1351
+
1352
+ // Safety timeout after 2 seconds
1353
+ setTimeout(() => {
1354
+ clearInterval(checkInterval);
1355
+ resolve();
1356
+ }, 2000);
1357
+ });
1358
+ }
1359
+
1360
+ // Clear any pending cleanup timer
1361
+ if (this.postponeCleanupTimer) {
1362
+ clearTimeout(this.postponeCleanupTimer);
1363
+ this.postponeCleanupTimer = null;
1364
+ }
1365
+
1366
+ // Close the database
1367
+ const db = await this.dbPromise;
1368
+ if (db) {
1369
+ db.close();
1370
+ }
1371
+
1372
+ // console.log('IndexedDB cache closed successfully');
1373
+ } catch (err) {
1374
+ console.error('Error closing IndexedDB cache:', err);
1375
+ }
672
1376
  }
673
1377
  }