@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.
- package/Readme.md +1 -1
- package/package.json +5 -5
- package/src/babylonjs-animation-controller.js +187 -76
- package/src/babylonjs-animation-opening.js +58 -2
- package/src/babylonjs-controller.js +1008 -359
- package/src/file-storage.js +405 -24
- package/src/gltf-resolver.js +65 -9
- package/src/gltf-storage.js +47 -35
- package/src/localization/i18n.js +1 -1
- package/src/localization/translations.js +3 -3
- package/src/pref-viewer-3d-data.js +102 -52
- package/src/pref-viewer-3d.js +71 -15
- package/src/pref-viewer-menu-3d.js +44 -3
- package/src/pref-viewer.js +134 -17
- package/src/styles.js +21 -5
package/src/file-storage.js
CHANGED
|
@@ -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
|
|
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
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
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
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
* @
|
|
285
|
-
*
|
|
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
|
|
297
|
-
const store =
|
|
298
|
-
|
|
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
|
|
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
|
-
* -
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
if (
|
|
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
|
}
|