@hkdigital/lib-sveltekit 0.2.21 → 0.2.22

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.
Files changed (254) hide show
  1. package/README.md +149 -135
  2. package/dist/assets/autospuiten/car-paint-picker.js +41 -41
  3. package/dist/assets/autospuiten/labels.js +7 -7
  4. package/dist/classes/cache/IndexedDbCache.js +1407 -1407
  5. package/dist/classes/cache/MemoryResponseCache.js +138 -138
  6. package/dist/classes/cache/index.js +5 -5
  7. package/dist/classes/cache/typedef.js +41 -41
  8. package/dist/classes/data/IterableTree.js +243 -243
  9. package/dist/classes/data/Selector.js +190 -190
  10. package/dist/classes/data/index.js +2 -2
  11. package/dist/classes/events/EventEmitter.js +275 -275
  12. package/dist/classes/events/index.js +2 -2
  13. package/dist/classes/index.js +4 -4
  14. package/dist/classes/logging/Logger.js +210 -210
  15. package/dist/classes/logging/constants.js +16 -16
  16. package/dist/classes/logging/index.js +4 -4
  17. package/dist/classes/logging/typedef.js +17 -17
  18. package/dist/classes/promise/HkPromise.js +377 -377
  19. package/dist/classes/promise/index.js +1 -1
  20. package/dist/classes/services/ServiceBase.js +463 -463
  21. package/dist/classes/services/ServiceManager.js +614 -614
  22. package/dist/classes/services/index.js +5 -5
  23. package/dist/classes/services/service-states.js +205 -205
  24. package/dist/classes/services/typedef.js +179 -179
  25. package/dist/classes/stores/SubscribersCount.js +107 -107
  26. package/dist/classes/stores/index.js +1 -1
  27. package/dist/classes/streams/LogTransformStream.js +19 -19
  28. package/dist/classes/streams/ServerEventsStore.js +110 -110
  29. package/dist/classes/streams/TimeStampSource.js +26 -26
  30. package/dist/classes/streams/index.js +3 -3
  31. package/dist/classes/svelte/audio/AudioLoader.svelte.js +58 -58
  32. package/dist/classes/svelte/audio/AudioScene.svelte.js +324 -324
  33. package/dist/classes/svelte/audio/mocks.js +35 -35
  34. package/dist/classes/svelte/finite-state-machine/FiniteStateMachine.svelte.js +133 -133
  35. package/dist/classes/svelte/finite-state-machine/index.js +1 -1
  36. package/dist/classes/svelte/image/ImageLoader.svelte.js +45 -45
  37. package/dist/classes/svelte/image/ImageScene.svelte.js +249 -249
  38. package/dist/classes/svelte/image/ImageVariantsLoader.svelte.js +152 -152
  39. package/dist/classes/svelte/image/index.js +4 -4
  40. package/dist/classes/svelte/image/mocks.js +35 -35
  41. package/dist/classes/svelte/image/typedef.js +8 -8
  42. package/dist/classes/svelte/index.js +14 -14
  43. package/dist/classes/svelte/loading-state-machine/LoadingStateMachine.svelte.js +109 -109
  44. package/dist/classes/svelte/loading-state-machine/constants.js +16 -16
  45. package/dist/classes/svelte/loading-state-machine/index.js +3 -3
  46. package/dist/classes/svelte/network-loader/NetworkLoader.svelte.js +338 -338
  47. package/dist/classes/svelte/network-loader/constants.js +3 -3
  48. package/dist/classes/svelte/network-loader/index.js +3 -3
  49. package/dist/classes/svelte/network-loader/mocks.js +30 -30
  50. package/dist/classes/svelte/network-loader/typedef.js +8 -8
  51. package/dist/components/area/HkArea.svelte +49 -49
  52. package/dist/components/area/HkGridArea.svelte +77 -77
  53. package/dist/components/area/index.js +2 -2
  54. package/dist/components/buttons/button/Button.svelte +82 -82
  55. package/dist/components/buttons/button-icon-steeze/SteezeIconButton.svelte +30 -30
  56. package/dist/components/buttons/button-text/TextButton.svelte +21 -21
  57. package/dist/components/buttons/index.js +3 -3
  58. package/dist/components/debug/debug-panel-design-scaling/DebugPanelDesignScaling.svelte +146 -146
  59. package/dist/components/debug/index.js +1 -1
  60. package/dist/components/drag-drop/DragController.js +44 -44
  61. package/dist/components/drag-drop/DragDropContext.svelte +111 -111
  62. package/dist/components/drag-drop/Draggable.svelte +519 -519
  63. package/dist/components/drag-drop/{Dropzone.svelte → DropZone.svelte} +258 -258
  64. package/dist/components/drag-drop/DropZoneArea.svelte +119 -119
  65. package/dist/components/drag-drop/DropZoneList.svelte +125 -125
  66. package/dist/components/drag-drop/actions.js +26 -26
  67. package/dist/components/drag-drop/drag-state.svelte.js +322 -322
  68. package/dist/components/drag-drop/index.js +7 -7
  69. package/dist/components/drag-drop/util.js +85 -85
  70. package/dist/components/hkdev/blocks/TextBlock.svelte +46 -46
  71. package/dist/components/hkdev/buttons/CheckButton.svelte +62 -62
  72. package/dist/components/icons/HkIcon.svelte +86 -86
  73. package/dist/components/icons/HkTabIcon.svelte +116 -116
  74. package/dist/components/icons/SteezeIcon.svelte +97 -97
  75. package/dist/components/icons/index.js +6 -6
  76. package/dist/components/icons/typedef.js +16 -16
  77. package/dist/components/index.js +2 -2
  78. package/dist/components/inputs/index.js +1 -1
  79. package/dist/components/inputs/text-input/TestTextInput.svelte__ +102 -102
  80. package/dist/components/inputs/text-input/TextInput.svelte +223 -223
  81. package/dist/components/inputs/text-input/TextInput.svelte___ +83 -83
  82. package/dist/components/inputs/text-input/assets/IconInvalid.svelte +14 -14
  83. package/dist/components/inputs/text-input/assets/IconValid.svelte +12 -12
  84. package/dist/components/layout/grid-layers/GridLayers.svelte +63 -63
  85. package/dist/components/layout/grid-layers/GridLayers.svelte__heightFrom__ +372 -0
  86. package/dist/components/layout/grid-layers/util.js +74 -74
  87. package/dist/components/layout/index.js +1 -1
  88. package/dist/components/panels/index.js +1 -1
  89. package/dist/components/panels/panel/Panel.svelte +43 -43
  90. package/dist/components/rows/index.js +3 -3
  91. package/dist/components/rows/panel-grid-row/PanelGridRow.svelte +104 -104
  92. package/dist/components/rows/panel-row-2/PanelRow2.svelte +40 -40
  93. package/dist/components/tab-bar/HkTabBar.state.svelte.js +149 -149
  94. package/dist/components/tab-bar/HkTabBar.svelte +74 -74
  95. package/dist/components/tab-bar/HkTabBarSelector.state.svelte.js +93 -93
  96. package/dist/components/tab-bar/HkTabBarSelector.svelte +49 -49
  97. package/dist/components/tab-bar/index.js +17 -17
  98. package/dist/components/tab-bar/typedef.js +11 -11
  99. package/dist/config/imagetools-config.js +189 -189
  100. package/dist/config/imagetools.d.ts +72 -72
  101. package/dist/constants/bases.js +13 -13
  102. package/dist/constants/errors/api.js +9 -9
  103. package/dist/constants/errors/generic.js +5 -5
  104. package/dist/constants/errors/index.js +3 -3
  105. package/dist/constants/errors/jwt.js +5 -5
  106. package/dist/constants/http/headers.js +6 -6
  107. package/dist/constants/http/index.js +2 -2
  108. package/dist/constants/http/methods.js +14 -14
  109. package/dist/constants/index.js +3 -3
  110. package/dist/constants/mime/application.js +5 -5
  111. package/dist/constants/mime/audio.js +13 -13
  112. package/dist/constants/mime/image.js +3 -3
  113. package/dist/constants/mime/index.js +4 -4
  114. package/dist/constants/mime/text.js +2 -2
  115. package/dist/constants/regexp/index.js +31 -31
  116. package/dist/constants/regexp/inspiratie.js__ +95 -95
  117. package/dist/constants/regexp/text.js +49 -49
  118. package/dist/constants/regexp/user.js +32 -32
  119. package/dist/constants/regexp/web.js +3 -3
  120. package/dist/constants/state-labels/drag-states.js +6 -6
  121. package/dist/constants/state-labels/drop-states.js +6 -6
  122. package/dist/constants/state-labels/input-states.js +11 -11
  123. package/dist/constants/state-labels/submit-states.js +4 -4
  124. package/dist/constants/time.js +28 -28
  125. package/dist/css/utilities.css +43 -43
  126. package/dist/design/design-config.js +73 -73
  127. package/dist/design/tailwind-theme-extend.js +158 -158
  128. package/dist/features/button-group/ButtonGroup.svelte +82 -82
  129. package/dist/features/button-group/typedef.js +10 -10
  130. package/dist/features/compare-left-right/CompareLeftRight.svelte +179 -179
  131. package/dist/features/compare-left-right/index.js +1 -1
  132. package/dist/features/game-box/GameBox.svelte +577 -577
  133. package/dist/features/game-box/gamebox.util.js +83 -83
  134. package/dist/features/hk-app-layout/HkAppLayout.state.svelte.js +25 -25
  135. package/dist/features/hk-app-layout/HkAppLayout.svelte +251 -251
  136. package/dist/features/image-box/ImageBox.svelte +210 -210
  137. package/dist/features/image-box/index.js +5 -5
  138. package/dist/features/image-box/typedef.js +32 -32
  139. package/dist/features/index.js +23 -23
  140. package/dist/features/presenter/ImageSlide.svelte +64 -64
  141. package/dist/features/presenter/Presenter.state.svelte.js +638 -638
  142. package/dist/features/presenter/Presenter.svelte +142 -142
  143. package/dist/features/presenter/constants.js +7 -7
  144. package/dist/features/presenter/index.js +10 -10
  145. package/dist/features/presenter/typedef.js +106 -106
  146. package/dist/features/presenter/util.js +210 -210
  147. package/dist/features/virtual-viewport/VirtualViewport.svelte +196 -196
  148. package/dist/logging/adapters/console.js +114 -114
  149. package/dist/logging/adapters/pino.js +60 -60
  150. package/dist/logging/constants.js +1 -1
  151. package/dist/logging/factories/client.js +21 -21
  152. package/dist/logging/factories/server.js +22 -22
  153. package/dist/logging/factories/universal.js +23 -23
  154. package/dist/logging/index.js +8 -8
  155. package/dist/schemas/index.js +1 -1
  156. package/dist/schemas/validate-url.js +180 -180
  157. package/dist/server/index.js +1 -1
  158. package/dist/server/logger.js +94 -94
  159. package/dist/states/index.js +1 -1
  160. package/dist/states/navigation.svelte.js +55 -55
  161. package/dist/stores/index.js +1 -1
  162. package/dist/stores/theme.js +80 -80
  163. package/dist/themes/hkdev/components/blocks/text-block.css +34 -34
  164. package/dist/themes/hkdev/components/boxes/game-box.css +11 -11
  165. package/dist/themes/hkdev/components/buttons/button-icon-steeze.css +22 -22
  166. package/dist/themes/hkdev/components/buttons/button-text.css +32 -32
  167. package/dist/themes/hkdev/components/buttons/button.css +146 -146
  168. package/dist/themes/hkdev/components/buttons/skip-button.css +5 -5
  169. package/dist/themes/hkdev/components/drag-drop/draggable.css +73 -73
  170. package/dist/themes/hkdev/components/drag-drop/drop-zone.css +58 -58
  171. package/dist/themes/hkdev/components/icons/icon-steeze.css +15 -15
  172. package/dist/themes/hkdev/components/inputs/text-input.css +102 -102
  173. package/dist/themes/hkdev/components/panels/panel.css +25 -25
  174. package/dist/themes/hkdev/components/rows/panel-grid-row.css +4 -4
  175. package/dist/themes/hkdev/components/rows/panel-row-2.css +5 -5
  176. package/dist/themes/hkdev/components.css +29 -29
  177. package/dist/themes/hkdev/debug.css +1 -1
  178. package/dist/themes/hkdev/global/layout.css +32 -32
  179. package/dist/themes/hkdev/global/on-colors.css +32 -32
  180. package/dist/themes/hkdev/globals.css +3 -3
  181. package/dist/themes/hkdev/responsive.css +12 -12
  182. package/dist/themes/hkdev/theme-ext.js +12 -12
  183. package/dist/themes/hkdev/theme.css +218 -218
  184. package/dist/themes/index.js +1 -1
  185. package/dist/typedef/context.js +6 -6
  186. package/dist/typedef/drag.js +25 -25
  187. package/dist/typedef/drop.js +12 -12
  188. package/dist/typedef/image.js +38 -38
  189. package/dist/typedef/index.js +4 -4
  190. package/dist/util/array/index.js +436 -436
  191. package/dist/util/bases/base58.js +262 -262
  192. package/dist/util/bases/index.js +1 -1
  193. package/dist/util/compare/index.js +247 -247
  194. package/dist/util/css/css-vars.js +83 -83
  195. package/dist/util/css/index.js +1 -1
  196. package/dist/util/design-system/components/states.js +22 -22
  197. package/dist/util/design-system/css/clamp.js +66 -66
  198. package/dist/util/design-system/css/root-design-vars.js +102 -102
  199. package/dist/util/design-system/index.js +5 -5
  200. package/dist/util/design-system/layout/scaling.js +228 -228
  201. package/dist/util/design-system/skeleton.js +208 -208
  202. package/dist/util/design-system/tailwind.js +288 -288
  203. package/dist/util/env/index.js +9 -9
  204. package/dist/util/exceptions/index.d.ts +11 -0
  205. package/dist/util/exceptions/index.js +17 -0
  206. package/dist/util/expect/arrays.js +47 -47
  207. package/dist/util/expect/index.js +259 -259
  208. package/dist/util/expect/primitives.js +55 -55
  209. package/dist/util/expect/url.js +60 -60
  210. package/dist/util/function/index.js +218 -218
  211. package/dist/util/geo/index.js +26 -26
  212. package/dist/util/http/caching.js +263 -263
  213. package/dist/util/http/errors.js +97 -97
  214. package/dist/util/http/headers.js +75 -75
  215. package/dist/util/http/http-request.js +578 -578
  216. package/dist/util/http/index.js +22 -22
  217. package/dist/util/http/json-request.js +224 -224
  218. package/dist/util/http/mocks.js +65 -65
  219. package/dist/util/http/response.js +294 -294
  220. package/dist/util/http/test-data__/content-length-test-hkdigital-small.V4HfZyBQ.avif +0 -0
  221. package/dist/util/http/typedef.js +93 -93
  222. package/dist/util/http/url.js +52 -52
  223. package/dist/util/image/index.js +86 -86
  224. package/dist/util/index.d.ts +1 -0
  225. package/dist/util/index.js +3 -2
  226. package/dist/util/is/index.js +140 -140
  227. package/dist/util/iterate/index.js +234 -234
  228. package/dist/util/object/index.js +1361 -1361
  229. package/dist/util/singleton/index.js +97 -97
  230. package/dist/util/string/array-path.js +75 -75
  231. package/dist/util/string/convert.js +54 -54
  232. package/dist/util/string/fs.js +226 -226
  233. package/dist/util/string/index.js +5 -5
  234. package/dist/util/string/interpolate.js +61 -61
  235. package/dist/util/string/pad.js +10 -10
  236. package/dist/util/svelte/index.js +4 -4
  237. package/dist/util/svelte/loading/loading-tracker.svelte.js +108 -108
  238. package/dist/util/svelte/observe/index.js +49 -49
  239. package/dist/util/svelte/state-context/index.js +117 -117
  240. package/dist/util/svelte/wait/index.js +38 -38
  241. package/dist/util/sveltekit/index.js +1 -1
  242. package/dist/util/sveltekit/route-folders/index.js +101 -101
  243. package/dist/util/time/index.js +323 -323
  244. package/dist/util/unique/index.js +249 -249
  245. package/dist/valibot/date.js__ +10 -10
  246. package/dist/valibot/index.js +9 -9
  247. package/dist/valibot/url.js +95 -95
  248. package/dist/valibot/user.js +23 -23
  249. package/dist/zod/all.js +33 -33
  250. package/dist/zod/generic.js +11 -11
  251. package/dist/zod/javascript.js +32 -32
  252. package/dist/zod/user.js +16 -16
  253. package/dist/zod/web.js +52 -52
  254. package/package.json +133 -132
@@ -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
+ }