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