@hkdigital/lib-sveltekit 0.1.73 → 0.1.75

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,79 +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
- //
483
- // DISABLE UNTIL FIXED!!!
484
- //
485
- // // Step 1: Remove expired entries first
486
- // const expiredRemoved = await this._removeExpiredEntries(
487
- // this.cleanupBatchSize / 2
488
- // );
489
- // removedCount += expiredRemoved;
490
-
491
- // // If we have a lot of expired entries, focus on those first
492
- // if (expiredRemoved >= this.cleanupBatchSize / 2) {
493
- // this.cleanupState.inProgress = false;
494
- // this.cleanupState.lastRun = now;
495
- // this.cleanupState.totalRemoved += removedCount;
496
-
497
- // // Schedule next cleanup step immediately
498
- // this._scheduleCleanup();
499
- // return;
500
- // }
818
+ // Step 1: Remove expired entries first
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
+ }
501
841
 
502
- // // Step 2: Remove old entries if we're over size/age limits
503
- // const remainingBatch = this.cleanupBatchSize - expiredRemoved;
504
- // if (remainingBatch > 0) {
505
- // const oldRemoved = await this._removeOldEntries(remainingBatch);
506
- // removedCount += oldRemoved;
507
- // }
842
+ // Check again if cleanup has been postponed during the operation
843
+ if (this._isCleanupPostponed()) {
844
+ this.cleanupState.inProgress = false;
845
+ return;
846
+ }
508
847
 
509
- // // Update cleanup state
510
- // this.cleanupState.lastRun = now;
511
- // this.cleanupState.totalRemoved += removedCount;
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
+ }
512
862
 
513
- // If we removed entries in this batch, schedule another cleanup
514
- if (removedCount > 0) {
515
- this._scheduleCleanup();
516
- } else {
517
- // Reset cursor if we didn't find anything to clean
518
- this.cleanupState.lastCursor = null;
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);
872
+ }
873
+
874
+ // Update cleanup state
875
+ this.cleanupState.lastRun = now;
876
+ this.cleanupState.totalRemoved += removedCount;
519
877
 
878
+ // If we removed entries in this batch and not postponed, schedule another cleanup
879
+ if (removedCount > 0 && !this._isCleanupPostponed()) {
880
+ this._scheduleCleanup();
881
+ } else if (!this._isCleanupPostponed()) {
520
882
  // Schedule a check later
521
883
  setTimeout(() => {
522
884
  this._checkAndScheduleCleanup();
@@ -537,41 +899,242 @@ export default class IndexedDbCache {
537
899
  * @returns {Promise<number>} Number of entries removed
538
900
  */
539
901
  async _removeExpiredEntries(limit) {
540
- const now = Date.now();
541
- const db = await this.dbPromise;
542
- let removed = 0;
902
+ try {
903
+ const now = Date.now();
904
+ const db = await this.dbPromise;
905
+ let removed = 0;
543
906
 
544
- return new Promise((resolve) => {
545
- const transaction = db.transaction(this.storeName, 'readwrite');
546
- const store = transaction.objectStore(this.storeName);
547
- 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
+ }
548
918
 
549
- // Create range for all entries with expiration before now
550
- const range = IDBKeyRange.upperBound(now);
919
+ const index = store.index(this.EXPIRES_INDEX);
551
920
 
552
- // Skip non-expiring entries (null expiration)
553
- const request = index.openCursor(range);
921
+ // Create range for all entries with expiration before now
922
+ const range = IDBKeyRange.upperBound(now);
554
923
 
555
- request.onerror = () => resolve(removed);
924
+ // Skip non-expiring entries (null expiration)
925
+ const request = index.openCursor(range);
556
926
 
557
- request.onsuccess = (event) => {
558
- 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
+ };
559
934
 
560
- if (!cursor || removed >= limit) {
561
- resolve(removed);
562
- 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);
563
993
  }
994
+ });
995
+ } catch (err) {
996
+ console.error('_removeExpiredEntries error:', err);
997
+ return 0;
998
+ }
999
+ }
564
1000
 
565
- // Delete the expired entry
566
- const deleteRequest = cursor.delete();
567
- deleteRequest.onsuccess = () => {
568
- removed++;
569
- };
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;
570
1012
 
571
- // Move to next entry
572
- cursor.continue();
573
- };
574
- });
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
+ }
575
1138
  }
576
1139
 
577
1140
  /**
@@ -582,51 +1145,124 @@ export default class IndexedDbCache {
582
1145
  * @returns {Promise<number>} Number of entries removed
583
1146
  */
584
1147
  async _removeOldEntries(limit) {
585
- const db = await this.dbPromise;
586
- let removed = 0;
1148
+ try {
1149
+ const db = await this.dbPromise;
1150
+ let removed = 0;
587
1151
 
588
- // Get total cache size estimate (rough)
589
- const sizeEstimate = await this._getCacheSizeEstimate();
1152
+ // Get total cache size estimate (rough)
1153
+ const sizeEstimate = await this._getCacheSizeEstimate();
590
1154
 
591
- // If we're under limits, don't remove anything
592
- if (sizeEstimate < this.maxSize) {
593
- return 0;
594
- }
1155
+ // If we're under limits, don't remove anything
1156
+ if (sizeEstimate < this.maxSize) {
1157
+ return 0;
1158
+ }
595
1159
 
596
- return new Promise((resolve) => {
597
- const transaction = db.transaction(this.storeName, 'readwrite');
598
- const store = transaction.objectStore(this.storeName);
599
- 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
+ }
600
1171
 
601
- // Start from the oldest entries
602
- const request = index.openCursor();
1172
+ const index = store.index(this.TIMESTAMP_INDEX);
1173
+ const now = Date.now();
603
1174
 
604
- request.onerror = () => resolve(removed);
1175
+ // Start from the oldest entries
1176
+ const request = index.openCursor();
605
1177
 
606
- request.onsuccess = (event) => {
607
- 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
+ };
608
1185
 
609
- if (!cursor || removed >= limit) {
610
- resolve(removed);
611
- return;
612
- }
1186
+ // Process cursor results
1187
+ request.onsuccess = (event) => {
1188
+ const cursor = /** @type {IDBRequest} */ (event.target).result;
613
1189
 
614
- const entry = cursor.value;
615
- const now = Date.now();
616
- const age = now - entry.timestamp;
1190
+ if (!cursor || removed >= limit) {
1191
+ resolve(removed);
1192
+ return;
1193
+ }
617
1194
 
618
- // Delete if older than max age
619
- if (age > this.maxAge) {
620
- const deleteRequest = cursor.delete();
621
- deleteRequest.onsuccess = () => {
622
- 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
+ }
623
1253
  };
1254
+ } catch (err) {
1255
+ console.error(
1256
+ 'Error creating transaction in _removeOldEntries:',
1257
+ err
1258
+ );
1259
+ resolve(0);
624
1260
  }
625
-
626
- // Move to next entry
627
- cursor.continue();
628
- };
629
- });
1261
+ });
1262
+ } catch (err) {
1263
+ console.error('_removeOldEntries error:', err);
1264
+ return 0;
1265
+ }
630
1266
  }
631
1267
 
632
1268
  /**
@@ -636,41 +1272,106 @@ export default class IndexedDbCache {
636
1272
  * @returns {Promise<number>} Size estimate in bytes
637
1273
  */
638
1274
  async _getCacheSizeEstimate() {
639
- const db = await this.dbPromise;
1275
+ try {
1276
+ const db = await this.dbPromise;
1277
+ let totalSize = 0;
640
1278
 
641
- return new Promise((resolve) => {
642
- const transaction = db.transaction(this.storeName, 'readonly');
643
- const store = transaction.objectStore(this.storeName);
644
- 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
+ }
645
1290
 
646
- // Get the sum of all entry sizes
647
- const request = index.openCursor();
648
- let totalSize = 0;
1291
+ const index = store.index(this.SIZE_INDEX);
649
1292
 
650
- request.onerror = () => resolve(totalSize);
1293
+ // Get the sum of all entry sizes
1294
+ const request = index.openCursor();
651
1295
 
652
- request.onsuccess = (event) => {
653
- 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
+ };
654
1303
 
655
- if (!cursor) {
656
- resolve(totalSize);
657
- return;
658
- }
1304
+ request.onsuccess = (event) => {
1305
+ const cursor = /** @type {IDBRequest} */ (event.target).result;
659
1306
 
660
- const entry = cursor.value;
661
- totalSize += entry.size || 0;
1307
+ if (!cursor) {
1308
+ resolve(totalSize);
1309
+ return;
1310
+ }
662
1311
 
663
- cursor.continue();
664
- };
665
- });
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
+ }
666
1335
  }
667
1336
 
668
1337
  /**
669
1338
  * Close the database connection
670
1339
  */
671
- close() {
672
- this.dbPromise.then(db => {
673
- db.close();
674
- }).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
+ }
675
1376
  }
676
1377
  }