@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.
- package/dist/classes/cache/IndexedDbCache.d.ts +78 -74
- package/dist/classes/cache/IndexedDbCache.js +1013 -312
- package/dist/classes/cache/index.js +0 -1
- package/dist/classes/cache/typedef.d.ts +24 -7
- package/dist/classes/cache/typedef.js +23 -7
- package/dist/widgets/presenter/Presenter.state.svelte.js +3 -1
- package/package.json +1 -1
- package/dist/classes/cache/CacheStorage.js__ +0 -45
- package/dist/classes/cache/PersistentResponseCache.js__ +0 -165
@@ -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
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
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 ||
|
62
|
-
this.storeName = options.storeName ||
|
63
|
-
|
64
|
-
this.
|
65
|
-
this.
|
66
|
-
|
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 =
|
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
|
-
//
|
89
|
-
this.
|
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
|
-
*
|
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
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
//
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
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
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
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
|
-
//
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
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
|
-
|
184
|
-
|
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
|
-
|
196
|
-
|
197
|
-
|
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
|
-
//
|
208
|
-
|
209
|
-
|
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 (
|
242
|
-
|
243
|
-
|
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
|
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
|
-
//
|
257
|
-
|
258
|
-
|
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 =
|
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:
|
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
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
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
|
-
|
577
|
+
try {
|
578
|
+
const db = await this.dbPromise;
|
310
579
|
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
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
|
-
|
586
|
+
request.onerror = () => resolve(); // Don't block on errors
|
317
587
|
|
318
|
-
|
319
|
-
|
320
|
-
|
588
|
+
request.onsuccess = () => {
|
589
|
+
const entry = request.result;
|
590
|
+
if (!entry) return resolve();
|
321
591
|
|
322
|
-
|
592
|
+
entry.lastAccessed = Date.now();
|
323
593
|
|
324
|
-
|
325
|
-
|
326
|
-
|
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
|
-
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
|
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
|
-
|
377
|
-
|
378
|
-
|
379
|
-
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
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 (
|
412
|
-
|
413
|
-
|
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.
|
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 (
|
448
|
-
|
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
|
-
|
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
|
-
|
460
|
-
|
461
|
-
|
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
|
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
|
-
|
484
|
-
|
485
|
-
|
486
|
-
|
487
|
-
|
488
|
-
|
489
|
-
|
490
|
-
|
491
|
-
|
492
|
-
|
493
|
-
|
494
|
-
|
495
|
-
|
496
|
-
|
497
|
-
|
498
|
-
|
499
|
-
|
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
|
-
//
|
503
|
-
|
504
|
-
|
505
|
-
|
506
|
-
|
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
|
-
//
|
510
|
-
|
511
|
-
|
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
|
-
//
|
514
|
-
|
515
|
-
this.
|
516
|
-
|
517
|
-
|
518
|
-
|
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
|
-
|
541
|
-
|
542
|
-
|
902
|
+
try {
|
903
|
+
const now = Date.now();
|
904
|
+
const db = await this.dbPromise;
|
905
|
+
let removed = 0;
|
543
906
|
|
544
|
-
|
545
|
-
|
546
|
-
|
547
|
-
|
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
|
-
|
550
|
-
const range = IDBKeyRange.upperBound(now);
|
919
|
+
const index = store.index(this.EXPIRES_INDEX);
|
551
920
|
|
552
|
-
|
553
|
-
|
921
|
+
// Create range for all entries with expiration before now
|
922
|
+
const range = IDBKeyRange.upperBound(now);
|
554
923
|
|
555
|
-
|
924
|
+
// Skip non-expiring entries (null expiration)
|
925
|
+
const request = index.openCursor(range);
|
556
926
|
|
557
|
-
|
558
|
-
|
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
|
-
|
561
|
-
|
562
|
-
|
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
|
-
|
566
|
-
|
567
|
-
|
568
|
-
|
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
|
-
|
572
|
-
|
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
|
-
|
586
|
-
|
1148
|
+
try {
|
1149
|
+
const db = await this.dbPromise;
|
1150
|
+
let removed = 0;
|
587
1151
|
|
588
|
-
|
589
|
-
|
1152
|
+
// Get total cache size estimate (rough)
|
1153
|
+
const sizeEstimate = await this._getCacheSizeEstimate();
|
590
1154
|
|
591
|
-
|
592
|
-
|
593
|
-
|
594
|
-
|
1155
|
+
// If we're under limits, don't remove anything
|
1156
|
+
if (sizeEstimate < this.maxSize) {
|
1157
|
+
return 0;
|
1158
|
+
}
|
595
1159
|
|
596
|
-
|
597
|
-
|
598
|
-
|
599
|
-
|
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
|
-
|
602
|
-
|
1172
|
+
const index = store.index(this.TIMESTAMP_INDEX);
|
1173
|
+
const now = Date.now();
|
603
1174
|
|
604
|
-
|
1175
|
+
// Start from the oldest entries
|
1176
|
+
const request = index.openCursor();
|
605
1177
|
|
606
|
-
|
607
|
-
|
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
|
-
|
610
|
-
|
611
|
-
|
612
|
-
}
|
1186
|
+
// Process cursor results
|
1187
|
+
request.onsuccess = (event) => {
|
1188
|
+
const cursor = /** @type {IDBRequest} */ (event.target).result;
|
613
1189
|
|
614
|
-
|
615
|
-
|
616
|
-
|
1190
|
+
if (!cursor || removed >= limit) {
|
1191
|
+
resolve(removed);
|
1192
|
+
return;
|
1193
|
+
}
|
617
1194
|
|
618
|
-
|
619
|
-
|
620
|
-
|
621
|
-
|
622
|
-
|
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
|
-
|
627
|
-
|
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
|
-
|
1275
|
+
try {
|
1276
|
+
const db = await this.dbPromise;
|
1277
|
+
let totalSize = 0;
|
640
1278
|
|
641
|
-
|
642
|
-
|
643
|
-
|
644
|
-
|
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
|
-
|
647
|
-
const request = index.openCursor();
|
648
|
-
let totalSize = 0;
|
1291
|
+
const index = store.index(this.SIZE_INDEX);
|
649
1292
|
|
650
|
-
|
1293
|
+
// Get the sum of all entry sizes
|
1294
|
+
const request = index.openCursor();
|
651
1295
|
|
652
|
-
|
653
|
-
|
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
|
-
|
656
|
-
|
657
|
-
return;
|
658
|
-
}
|
1304
|
+
request.onsuccess = (event) => {
|
1305
|
+
const cursor = /** @type {IDBRequest} */ (event.target).result;
|
659
1306
|
|
660
|
-
|
661
|
-
|
1307
|
+
if (!cursor) {
|
1308
|
+
resolve(totalSize);
|
1309
|
+
return;
|
1310
|
+
}
|
662
1311
|
|
663
|
-
|
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
|
-
|
673
|
-
|
674
|
-
|
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
|
}
|