@preference-sl/pref-viewer 2.13.0-beta.2 → 2.13.0-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,12 +36,16 @@ import { openDB } from "idb";
36
36
  * - getBlob(uri): Retrieves file blob from cache or server.
37
37
  * - get(uri): Gets file from cache with automatic server sync and cache versioning.
38
38
  * - put(uri): Stores file from server in IndexedDB cache.
39
+ * - dispose(): Closes the active IndexedDB handle held by the instance.
39
40
  *
40
41
  * Features:
41
42
  * - Automatic Cache Versioning: Compares server and cached timestamps to update cache.
42
43
  * - Fallback Support: Uses direct server access when IndexedDB is unavailable.
43
44
  * - Object URL Generation: Creates efficient object URLs for cached blobs.
44
45
  * - HEAD Request Optimization: Uses HEAD requests to check metadata without downloading full files.
46
+ * - TTL per entry: Expired records are removed automatically.
47
+ * - Maximum entry limit: Old records are evicted with an LRU strategy when limit is exceeded.
48
+ * - Quota-aware cleanup: Proactively frees space and retries writes on quota errors.
45
49
  * - Promise-Based API: All operations return promises for async/await usage.
46
50
  *
47
51
  * Usage Example:
@@ -101,7 +105,8 @@ import { openDB } from "idb";
101
105
  * - Uses XMLHttpRequest for file downloads and HEAD requests
102
106
  * - Supports both HTTPS and HTTP (HTTP not recommended for production)
103
107
  * - Object URLs should be revoked after use to free memory
104
- * - IndexedDB quota management should be implemented for long-term storage
108
+ * - IndexedDB cache is auto-maintained (TTL, max entries, quota cleanup)
109
+ * - Call dispose() when the owner is torn down to release the DB connection proactively.
105
110
  *
106
111
  * Error Handling:
107
112
  * - Network failures: Returns undefined (get) or false (other methods)
@@ -114,16 +119,329 @@ import { openDB } from "idb";
114
119
  * - idb Library: https://github.com/jakearchibald/idb
115
120
  * - npm idb: https://www.npmjs.com/package/idb
116
121
  */
117
- export class FileStorage {
122
+ export default class FileStorage {
118
123
  #supported = "indexedDB" in window && openDB; // true if IndexedDB is available in the browser and the idb helper is loaded
119
124
  #dbVersion = 1; // single DB version; cache is managed per-file
120
125
  #db = undefined; // IndexedDB database handle
126
+ #maxEntries = 300; // hard cap for cache records; oldest entries are evicted first
127
+ #entryTTLms = 7 * 24 * 60 * 60 * 1000; // 7 days
128
+ #cleanupIntervalMs = 5 * 60 * 1000; // run background cleanup at most every 5 minutes
129
+ #minimumFreeSpaceRatio = 0.05; // try to keep at least 5% of quota free
130
+ #maxQuotaEvictionRetries = 5; // max retries after quota error
131
+ #lastCleanupAt = 0; // timestamp of last automatic cleanup run
121
132
 
122
133
  constructor(dbName = "FilesDB", osName = "FilesObjectStore") {
123
134
  this.dbName = dbName; // database name
124
135
  this.osName = osName; // object store name used for file cache
125
136
  }
126
137
 
138
+ /**
139
+ * Returns the current timestamp in milliseconds.
140
+ * @private
141
+ * @returns {number} Epoch time in milliseconds.
142
+ */
143
+ #now() {
144
+ return Date.now();
145
+ }
146
+
147
+ /**
148
+ * Determines whether a cached record is expired.
149
+ * @private
150
+ * @param {Object|null|undefined} record Cached record with `expiresAt`.
151
+ * @param {number} [now=this.#now()] Current timestamp used for comparison.
152
+ * @returns {boolean} True when the record has expired; false otherwise.
153
+ */
154
+ #isExpired(record, now = this.#now()) {
155
+ return !!record && typeof record.expiresAt === "number" && record.expiresAt <= now;
156
+ }
157
+
158
+ /**
159
+ * Normalizes a raw cache record into the internal shape expected by the storage layer.
160
+ * Fills missing metadata (`size`, `createdAt`, `lastAccess`, `expiresAt`) using defaults.
161
+ * @private
162
+ * @param {Object|null|undefined} record Raw record from IndexedDB or network conversion.
163
+ * @returns {{blob: Blob, timeStamp: string|null, size: number, createdAt: number, lastAccess: number, expiresAt: number}|null}
164
+ * Normalized record, or null if the input is invalid.
165
+ */
166
+ #normalizeRecord(record) {
167
+ if (!record || !record.blob) {
168
+ return null;
169
+ }
170
+ const now = this.#now();
171
+ const createdAt = typeof record.createdAt === "number" ? record.createdAt : now;
172
+ const expiresAt = typeof record.expiresAt === "number" ? record.expiresAt : createdAt + this.#entryTTLms;
173
+ return {
174
+ blob: record.blob,
175
+ timeStamp: record.timeStamp ?? null,
176
+ size: typeof record.size === "number" ? record.size : record.blob.size,
177
+ createdAt: createdAt,
178
+ lastAccess: typeof record.lastAccess === "number" ? record.lastAccess : now,
179
+ expiresAt: expiresAt,
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Converts an incoming file payload into a normalized cache record.
185
+ * @private
186
+ * @param {Object|Blob} file Incoming payload ({ blob, timeStamp } or raw Blob).
187
+ * @returns {{blob: Blob, timeStamp: string|null, size: number, createdAt: number, lastAccess: number, expiresAt: number}|null}
188
+ * Normalized record ready to persist, or null when payload is invalid.
189
+ */
190
+ #createRecordFromFile(file) {
191
+ if (!file) {
192
+ return null;
193
+ }
194
+ const now = this.#now();
195
+ const record = file instanceof Blob ? { blob: file, timeStamp: null } : file;
196
+ if (!record?.blob) {
197
+ return null;
198
+ }
199
+ return this.#normalizeRecord({
200
+ ...record,
201
+ createdAt: now,
202
+ lastAccess: now,
203
+ expiresAt: now + this.#entryTTLms,
204
+ size: record.blob.size,
205
+ });
206
+ }
207
+
208
+ /**
209
+ * Checks whether an IndexedDB error corresponds to quota exhaustion.
210
+ * @private
211
+ * @param {any} error Error thrown by IndexedDB operations.
212
+ * @returns {boolean} True when the error indicates storage quota was exceeded.
213
+ */
214
+ #isQuotaExceededError(error) {
215
+ if (!error) {
216
+ return false;
217
+ }
218
+ return error.name === "QuotaExceededError" || error.name === "NS_ERROR_DOM_QUOTA_REACHED" || error.code === 22 || error.code === 1014;
219
+ }
220
+
221
+ /**
222
+ * Loads all records from the object store with their keys.
223
+ * Invalid rows are ignored.
224
+ * @private
225
+ * @returns {Promise<Array<{key: IDBValidKey, record: {blob: Blob, timeStamp: string|null, size: number, createdAt: number, lastAccess: number, expiresAt: number}}>>}
226
+ * Array of key-record pairs.
227
+ */
228
+ async #getAllRecords() {
229
+ if (this.#db === undefined) {
230
+ this.#db = await this.#openDB();
231
+ }
232
+ if (this.#db === null) {
233
+ return [];
234
+ }
235
+ const transaction = this.#db.transaction(this.osName, "readonly");
236
+ const store = transaction.objectStore(this.osName);
237
+ const [keys, values] = await Promise.all([store.getAllKeys(), store.getAll()]);
238
+ return keys.map((key, index) => ({ key: key, record: this.#normalizeRecord(values[index]) })).filter((entry) => entry.record !== null);
239
+ }
240
+
241
+ /**
242
+ * Deletes a list of keys from the object store.
243
+ * @private
244
+ * @param {IDBValidKey[]} [keys=[]] Keys to delete.
245
+ * @returns {Promise<number>} Number of requested deletions.
246
+ */
247
+ async #deleteKeys(keys = []) {
248
+ if (!keys.length) {
249
+ return 0;
250
+ }
251
+ if (this.#db === undefined) {
252
+ this.#db = await this.#openDB();
253
+ }
254
+ if (this.#db === null) {
255
+ return 0;
256
+ }
257
+ const transaction = this.#db.transaction(this.osName, "readwrite");
258
+ const store = transaction.objectStore(this.osName);
259
+ keys.forEach((key) => {
260
+ store.delete(key);
261
+ });
262
+ await transaction.done;
263
+ return keys.length;
264
+ }
265
+
266
+ /**
267
+ * Removes expired entries based on their `expiresAt` field.
268
+ * @private
269
+ * @returns {Promise<number>} Number of deleted expired entries.
270
+ */
271
+ async #deleteExpiredEntries() {
272
+ const now = this.#now();
273
+ const records = await this.#getAllRecords();
274
+ const expiredKeys = records.filter((entry) => this.#isExpired(entry.record, now)).map((entry) => entry.key);
275
+ return this.#deleteKeys(expiredKeys);
276
+ }
277
+
278
+ /**
279
+ * Evicts least-recently-used records, excluding protected keys.
280
+ * @private
281
+ * @param {number} [maxToDelete=1] Maximum number of entries to delete.
282
+ * @param {IDBValidKey[]} [protectedKeys=[]] Keys that must not be evicted.
283
+ * @returns {Promise<number>} Number of deleted entries.
284
+ */
285
+ async #evictLeastRecentlyUsed(maxToDelete = 1, protectedKeys = []) {
286
+ if (maxToDelete <= 0) {
287
+ return 0;
288
+ }
289
+ const protectedSet = new Set(protectedKeys);
290
+ const records = await this.#getAllRecords();
291
+ const candidates = records
292
+ .filter((entry) => !protectedSet.has(entry.key))
293
+ .sort((a, b) => {
294
+ const aAccess = typeof a.record.lastAccess === "number" ? a.record.lastAccess : 0;
295
+ const bAccess = typeof b.record.lastAccess === "number" ? b.record.lastAccess : 0;
296
+ if (aAccess !== bAccess) {
297
+ return aAccess - bAccess;
298
+ }
299
+ return a.record.createdAt - b.record.createdAt;
300
+ })
301
+ .slice(0, maxToDelete);
302
+ const keys = candidates.map((entry) => entry.key);
303
+ return this.#deleteKeys(keys);
304
+ }
305
+
306
+ /**
307
+ * Enforces the maximum number of cache entries by evicting LRU records.
308
+ * @private
309
+ * @param {IDBValidKey[]} [protectedKeys=[]] Keys excluded from eviction.
310
+ * @returns {Promise<number>} Number of removed entries.
311
+ */
312
+ async #enforceMaxEntries(protectedKeys = []) {
313
+ const protectedSet = new Set(protectedKeys);
314
+ const records = await this.#getAllRecords();
315
+ const count = records.length;
316
+ if (count <= this.#maxEntries) {
317
+ return 0;
318
+ }
319
+ const overflow = count - this.#maxEntries;
320
+ const candidates = records
321
+ .filter((entry) => !protectedSet.has(entry.key))
322
+ .sort((a, b) => {
323
+ const aAccess = typeof a.record.lastAccess === "number" ? a.record.lastAccess : 0;
324
+ const bAccess = typeof b.record.lastAccess === "number" ? b.record.lastAccess : 0;
325
+ if (aAccess !== bAccess) {
326
+ return aAccess - bAccess;
327
+ }
328
+ return a.record.createdAt - b.record.createdAt;
329
+ })
330
+ .slice(0, overflow)
331
+ .map((entry) => entry.key);
332
+ return this.#deleteKeys(candidates);
333
+ }
334
+
335
+ /**
336
+ * Frees cache space when estimated free quota is below threshold.
337
+ * Uses LRU eviction and can reserve bytes for an upcoming write.
338
+ * @private
339
+ * @param {number} [requiredBytes=0] Extra free bytes desired for the next write.
340
+ * @param {IDBValidKey[]} [protectedKeys=[]] Keys excluded from eviction.
341
+ * @returns {Promise<number>} Number of removed entries.
342
+ */
343
+ async #freeSpaceForQuota(requiredBytes = 0, protectedKeys = []) {
344
+ if (typeof navigator === "undefined" || !navigator.storage?.estimate) {
345
+ return 0;
346
+ }
347
+ let estimate = null;
348
+ try {
349
+ estimate = await navigator.storage.estimate();
350
+ } catch (error) {
351
+ return 0;
352
+ }
353
+ const quota = Number(estimate?.quota || 0);
354
+ const usage = Number(estimate?.usage || 0);
355
+ if (quota <= 0) {
356
+ return 0;
357
+ }
358
+ const minimumFreeBytes = Math.max(requiredBytes, Math.floor(quota * this.#minimumFreeSpaceRatio));
359
+ const freeBytes = Math.max(0, quota - usage);
360
+ if (freeBytes >= minimumFreeBytes) {
361
+ return 0;
362
+ }
363
+ const bytesToFree = minimumFreeBytes - freeBytes;
364
+
365
+ const protectedSet = new Set(protectedKeys);
366
+ const records = await this.#getAllRecords();
367
+ const candidates = records
368
+ .filter((entry) => !protectedSet.has(entry.key))
369
+ .sort((a, b) => {
370
+ const aAccess = typeof a.record.lastAccess === "number" ? a.record.lastAccess : 0;
371
+ const bAccess = typeof b.record.lastAccess === "number" ? b.record.lastAccess : 0;
372
+ if (aAccess !== bAccess) {
373
+ return aAccess - bAccess;
374
+ }
375
+ return a.record.createdAt - b.record.createdAt;
376
+ });
377
+
378
+ let accumulated = 0;
379
+ const keysToDelete = [];
380
+ for (const candidate of candidates) {
381
+ keysToDelete.push(candidate.key);
382
+ accumulated += Number(candidate.record.size || 0);
383
+ if (accumulated >= bytesToFree) {
384
+ break;
385
+ }
386
+ }
387
+ return this.#deleteKeys(keysToDelete);
388
+ }
389
+
390
+ /**
391
+ * Runs full maintenance: delete expired entries, enforce max entries, and free quota.
392
+ * @private
393
+ * @param {Object} [options={}] Cleanup options.
394
+ * @param {number} [options.requiredBytes=0] Extra bytes to reserve for a pending write.
395
+ * @param {IDBValidKey[]} [options.protectedKeys=[]] Keys excluded from eviction.
396
+ * @returns {Promise<void>}
397
+ */
398
+ async #runCleanup({ requiredBytes = 0, protectedKeys = [] } = {}) {
399
+ await this.#deleteExpiredEntries();
400
+ await this.#enforceMaxEntries(protectedKeys);
401
+ await this.#freeSpaceForQuota(requiredBytes, protectedKeys);
402
+ }
403
+
404
+ /**
405
+ * Runs maintenance only when the cleanup interval has elapsed.
406
+ * @private
407
+ * @returns {Promise<void>}
408
+ */
409
+ async #runCleanupIfDue() {
410
+ const now = this.#now();
411
+ if (now - this.#lastCleanupAt < this.#cleanupIntervalMs) {
412
+ return;
413
+ }
414
+ this.#lastCleanupAt = now;
415
+ await this.#runCleanup();
416
+ }
417
+
418
+ /**
419
+ * Updates `lastAccess` for a record to keep LRU ordering current.
420
+ * @private
421
+ * @param {String} uri Record key.
422
+ * @param {Object} record Existing normalized record.
423
+ * @returns {Promise<void>}
424
+ */
425
+ async #touchFile(uri, record) {
426
+ const normalized = this.#normalizeRecord(record);
427
+ if (!normalized) {
428
+ return;
429
+ }
430
+ normalized.lastAccess = this.#now();
431
+ if (this.#db === undefined) {
432
+ this.#db = await this.#openDB();
433
+ }
434
+ if (this.#db === null) {
435
+ return;
436
+ }
437
+ try {
438
+ const transaction = this.#db.transaction(this.osName, "readwrite");
439
+ const store = transaction.objectStore(this.osName);
440
+ store.put(normalized, uri);
441
+ await transaction.done;
442
+ } catch (error) {}
443
+ }
444
+
127
445
  /**
128
446
  * Create the object store if it does not exist.
129
447
  * @private
@@ -260,10 +578,11 @@ export class FileStorage {
260
578
  * @private
261
579
  * @param {Object|Blob} file The value to store (for example an object {blob, timeStamp} or a Blob).
262
580
  * @param {String} uri Key to use for storage (the original URI).
263
- * @returns {Promise<any|undefined>} Resolves with the value returned by the underlying idb store.put (usually the record key — string or number)
264
- * - resolves to the put result on success
265
- * - resolves to undefined if the database could not be opened
266
- * - rejects if the underlying put operation throws
581
+ * @returns {Promise<any|undefined>} Resolves with idb store.put result on success,
582
+ * or undefined when the database is unavailable, input is invalid, or write fails after quota retries.
583
+ * @description
584
+ * Runs maintenance before write (TTL, max entries, and quota-aware cleanup), then retries quota errors
585
+ * by evicting least-recently-used entries.
267
586
  */
268
587
  async #putFile(file, uri) {
269
588
  if (this.#db === undefined) {
@@ -272,30 +591,71 @@ export class FileStorage {
272
591
  if (this.#db === null) {
273
592
  return undefined;
274
593
  }
275
- const transation = this.#db.transaction(this.osName, "readwrite");
276
- const store = transation.objectStore(this.osName);
277
- return store.put(file, uri);
594
+ const record = this.#createRecordFromFile(file);
595
+ if (!record) {
596
+ return undefined;
597
+ }
598
+
599
+ const currentRecord = await this.#getFile(uri, { touch: false, allowExpired: true });
600
+ const currentSize = currentRecord ? Number(currentRecord.size || currentRecord.blob?.size || 0) : 0;
601
+ const requiredBytes = Math.max(0, record.size - currentSize);
602
+
603
+ await this.#runCleanup({ requiredBytes: requiredBytes, protectedKeys: [uri] });
604
+
605
+ for (let attempt = 0; attempt <= this.#maxQuotaEvictionRetries; attempt++) {
606
+ try {
607
+ const transaction = this.#db.transaction(this.osName, "readwrite");
608
+ const store = transaction.objectStore(this.osName);
609
+ const result = await store.put(record, uri);
610
+ await transaction.done;
611
+ return result;
612
+ } catch (error) {
613
+ if (!this.#isQuotaExceededError(error) || attempt === this.#maxQuotaEvictionRetries) {
614
+ return undefined;
615
+ }
616
+ const deleted = await this.#evictLeastRecentlyUsed(1, [uri]);
617
+ if (!deleted) {
618
+ return undefined;
619
+ }
620
+ }
621
+ }
622
+ return undefined;
278
623
  }
279
624
 
280
625
  /**
281
626
  * Retrieve a file record from IndexedDB.
282
627
  * @private
283
628
  * @param {String} uri File URI
284
- * @returns {Promise<{blob: Blob, timeStamp: string}|undefined|false>} Resolves with:
285
- * - {blob, timeStamp}: object with the Blob and ISO timestamp if the entry exists in IndexedDB
629
+ * @param {{touch?: boolean, allowExpired?: boolean}} [options] Read options.
630
+ * @param {boolean} [options.touch=true] Update lastAccess when the record is found.
631
+ * @param {boolean} [options.allowExpired=false] Return expired records instead of deleting them.
632
+ * @returns {Promise<{blob: Blob, timeStamp: string|null, size: number, createdAt: number, lastAccess: number, expiresAt: number}|undefined|false>} Resolves with:
633
+ * - normalized cache record when found
286
634
  * - undefined: if there is no stored entry for that URI
287
635
  * - false: if the database could not be opened (IndexedDB unsupported or open failure)
288
636
  */
289
- async #getFile(uri) {
637
+ async #getFile(uri, { touch = true, allowExpired = false } = {}) {
290
638
  if (this.#db === undefined) {
291
639
  this.#db = await this.#openDB();
292
640
  }
293
641
  if (this.#db === null) {
294
642
  return false;
295
643
  }
296
- const transation = this.#db.transaction(this.osName, "readonly");
297
- const store = transation.objectStore(this.osName);
298
- return store.get(uri);
644
+ const transaction = this.#db.transaction(this.osName, "readonly");
645
+ const store = transaction.objectStore(this.osName);
646
+ const record = this.#normalizeRecord(await store.get(uri));
647
+ if (!record) {
648
+ return undefined;
649
+ }
650
+ if (!allowExpired && this.#isExpired(record)) {
651
+ await this.#deleteKeys([uri]);
652
+ return undefined;
653
+ }
654
+ if (touch) {
655
+ await this.#touchFile(uri, record);
656
+ record.lastAccess = this.#now();
657
+ }
658
+ return record;
299
659
  }
300
660
 
301
661
  /**
@@ -309,7 +669,7 @@ export class FileStorage {
309
669
  * @public
310
670
  * @param {String} uri - File URI
311
671
  * @returns {Promise<string|boolean>} Resolves with:
312
- * - object URL string for the cached Blob
672
+ * - object URL string for the cached Blob (caller must revoke it when done)
313
673
  * - original URI string if IndexedDB unsupported but server file exists
314
674
  * - false if the file is not available on the server
315
675
  */
@@ -317,6 +677,7 @@ export class FileStorage {
317
677
  if (!this.#supported) {
318
678
  return (await this.#getServerFileTimeStamp(uri)) ? uri : false;
319
679
  }
680
+ await this.#runCleanupIfDue();
320
681
  const storedFile = await this.get(uri);
321
682
  return storedFile ? URL.createObjectURL(storedFile.blob) : (await this.#getServerFileTimeStamp(uri)) ? uri : false;
322
683
  }
@@ -326,7 +687,7 @@ export class FileStorage {
326
687
  * @public
327
688
  * @param {String} uri - File URI
328
689
  * @returns {Promise<string|null>} Resolves with:
329
- * - ISO 8601 string: Last-Modified timestamp from the cached record (when using IndexedDB) or from the server HEAD response
690
+ * - ISO 8601 string from the cached record (when using IndexedDB) or from the server HEAD response
330
691
  * - null: when no timestamp is available or on error
331
692
  */
332
693
  async getTimeStamp(uri) {
@@ -343,7 +704,7 @@ export class FileStorage {
343
704
  * @public
344
705
  * @param {String} uri - File URI
345
706
  * @returns {Promise<number|null>} Resolves with:
346
- * - number: size in bytes when available (from cache or server)
707
+ * - number: size in bytes when available (from cache metadata/blob or server HEAD)
347
708
  * - null: when size is not available or on error
348
709
  */
349
710
  async getSize(uri) {
@@ -376,8 +737,8 @@ export class FileStorage {
376
737
  * Get the file Blob from IndexedDB if stored; otherwise download and store it, then return the Blob.
377
738
  * @public
378
739
  * @param {String} uri - File URI
379
- * @returns {Promise<Blob|undefined|false>} Resolves with:
380
- * - Blob of the stored file
740
+ * @returns {Promise<{blob: Blob, timeStamp: string|null, size?: number, createdAt?: number, lastAccess?: number, expiresAt?: number}|undefined|false>} Resolves with:
741
+ * - cached/server record containing at least `{ blob, timeStamp }`
381
742
  * - undefined if the download fails
382
743
  * - false if uri is falsy
383
744
  * @description
@@ -392,10 +753,14 @@ export class FileStorage {
392
753
  if (!this.#supported) {
393
754
  return await this.#getServerFile(uri);
394
755
  }
756
+ await this.#runCleanupIfDue();
395
757
  let storedFile = await this.#getFile(uri);
396
- const serverFileTimeStamp = storedFile ? await this.#getServerFileTimeStamp(uri) : 0;
397
- const storedFileTimeStamp = storedFile ? storedFile.timeStamp : 0;
398
- if (!storedFile || (storedFile && serverFileTimeStamp !== null && serverFileTimeStamp !== storedFileTimeStamp)) {
758
+ // If there is already a cached file, return it without HEAD revalidation.
759
+ // In ecommerce flow, new IDs/URLs from WASM drive real resource updates.
760
+ if (storedFile) {
761
+ return storedFile;
762
+ }
763
+ if (!storedFile) {
399
764
  const fileToStore = await this.#getServerFile(uri);
400
765
  if (fileToStore && !!(await this.#putFile(fileToStore, uri))) {
401
766
  storedFile = await this.#getFile(uri);
@@ -412,13 +777,29 @@ export class FileStorage {
412
777
  * @param {String} uri - File URI
413
778
  * @returns {Promise<boolean>} Resolves with:
414
779
  * - true if the file was stored successfully
415
- * - false if IndexedDB is not supported or storing failed
780
+ * - false if IndexedDB is not supported, download failed, or storing failed after cleanup/retries
416
781
  */
417
782
  async put(uri) {
418
783
  if (!this.#supported) {
419
784
  return false;
420
785
  }
786
+ await this.#runCleanupIfDue();
421
787
  const fileToStore = await this.#getServerFile(uri);
422
788
  return fileToStore ? !!(await this.#putFile(fileToStore, uri)) : false;
423
789
  }
790
+
791
+ /**
792
+ * Closes the IndexedDB handle held by this storage instance.
793
+ * Safe to call multiple times; next storage operation will lazily reopen the DB.
794
+ * @public
795
+ * @returns {void}
796
+ */
797
+ dispose() {
798
+ if (this.#db) {
799
+ try {
800
+ this.#db.close();
801
+ } catch {}
802
+ }
803
+ this.#db = undefined;
804
+ }
424
805
  }