@dynlabs/react-native-immutable-file-cache 1.0.0-alpha.1 → 1.0.0-alpha.3

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 (74) hide show
  1. package/README.md +183 -261
  2. package/lib/commonjs/adapters/memoryAdapter.js +1 -0
  3. package/lib/commonjs/adapters/memoryAdapter.js.map +1 -1
  4. package/lib/commonjs/adapters/rnfsAdapter.js +9 -4
  5. package/lib/commonjs/adapters/rnfsAdapter.js.map +1 -1
  6. package/lib/commonjs/adapters/webAdapter.js +1 -0
  7. package/lib/commonjs/adapters/webAdapter.js.map +1 -1
  8. package/lib/commonjs/core/adapter.js +54 -0
  9. package/lib/commonjs/core/adapter.js.map +1 -1
  10. package/lib/commonjs/core/cacheEngine.js +452 -59
  11. package/lib/commonjs/core/cacheEngine.js.map +1 -1
  12. package/lib/commonjs/core/errors.js +9 -6
  13. package/lib/commonjs/core/errors.js.map +1 -1
  14. package/lib/commonjs/core/hash.js +3 -3
  15. package/lib/commonjs/core/hash.js.map +1 -1
  16. package/lib/commonjs/core/indexStore.js +85 -8
  17. package/lib/commonjs/core/indexStore.js.map +1 -1
  18. package/lib/commonjs/core/prune.js +42 -11
  19. package/lib/commonjs/core/prune.js.map +1 -1
  20. package/lib/commonjs/core/types.js +132 -0
  21. package/lib/commonjs/core/types.js.map +1 -1
  22. package/lib/commonjs/index.js +33 -0
  23. package/lib/commonjs/index.js.map +1 -1
  24. package/lib/module/adapters/memoryAdapter.js +1 -0
  25. package/lib/module/adapters/memoryAdapter.js.map +1 -1
  26. package/lib/module/adapters/rnfsAdapter.js +9 -4
  27. package/lib/module/adapters/rnfsAdapter.js.map +1 -1
  28. package/lib/module/adapters/webAdapter.js +1 -0
  29. package/lib/module/adapters/webAdapter.js.map +1 -1
  30. package/lib/module/core/adapter.js +48 -0
  31. package/lib/module/core/adapter.js.map +1 -1
  32. package/lib/module/core/cacheEngine.js +453 -60
  33. package/lib/module/core/cacheEngine.js.map +1 -1
  34. package/lib/module/core/errors.js +9 -6
  35. package/lib/module/core/errors.js.map +1 -1
  36. package/lib/module/core/hash.js +3 -3
  37. package/lib/module/core/hash.js.map +1 -1
  38. package/lib/module/core/indexStore.js +86 -8
  39. package/lib/module/core/indexStore.js.map +1 -1
  40. package/lib/module/core/prune.js +40 -11
  41. package/lib/module/core/prune.js.map +1 -1
  42. package/lib/module/core/types.js +130 -1
  43. package/lib/module/core/types.js.map +1 -1
  44. package/lib/module/index.js +4 -0
  45. package/lib/module/index.js.map +1 -1
  46. package/lib/typescript/src/adapters/memoryAdapter.d.ts.map +1 -1
  47. package/lib/typescript/src/adapters/rnfsAdapter.d.ts.map +1 -1
  48. package/lib/typescript/src/adapters/webAdapter.d.ts.map +1 -1
  49. package/lib/typescript/src/core/adapter.d.ts +16 -0
  50. package/lib/typescript/src/core/adapter.d.ts.map +1 -1
  51. package/lib/typescript/src/core/cacheEngine.d.ts +120 -1
  52. package/lib/typescript/src/core/cacheEngine.d.ts.map +1 -1
  53. package/lib/typescript/src/core/errors.d.ts +6 -5
  54. package/lib/typescript/src/core/errors.d.ts.map +1 -1
  55. package/lib/typescript/src/core/indexStore.d.ts +7 -0
  56. package/lib/typescript/src/core/indexStore.d.ts.map +1 -1
  57. package/lib/typescript/src/core/prune.d.ts +22 -8
  58. package/lib/typescript/src/core/prune.d.ts.map +1 -1
  59. package/lib/typescript/src/core/types.d.ts +153 -0
  60. package/lib/typescript/src/core/types.d.ts.map +1 -1
  61. package/lib/typescript/src/index.d.ts +5 -2
  62. package/lib/typescript/src/index.d.ts.map +1 -1
  63. package/package.json +1 -1
  64. package/src/adapters/memoryAdapter.ts +3 -0
  65. package/src/adapters/rnfsAdapter.ts +11 -4
  66. package/src/adapters/webAdapter.ts +1 -0
  67. package/src/core/adapter.ts +28 -0
  68. package/src/core/cacheEngine.ts +476 -62
  69. package/src/core/errors.ts +8 -6
  70. package/src/core/hash.ts +3 -3
  71. package/src/core/indexStore.ts +99 -11
  72. package/src/core/prune.ts +44 -14
  73. package/src/core/types.ts +194 -0
  74. package/src/index.ts +22 -0
@@ -13,6 +13,44 @@ var _prune = require("./prune");
13
13
  const ENTRIES_DIR = "entries";
14
14
  const INDEX_FILE = "index.json";
15
15
 
16
+ /**
17
+ * Common MIME type to file extension mapping.
18
+ */
19
+ const MIME_TO_EXT = {
20
+ "image/jpeg": ".jpg",
21
+ "image/png": ".png",
22
+ "image/gif": ".gif",
23
+ "image/webp": ".webp",
24
+ "image/svg+xml": ".svg",
25
+ "application/json": ".json",
26
+ "text/plain": ".txt",
27
+ "text/html": ".html",
28
+ "text/css": ".css",
29
+ "text/javascript": ".js",
30
+ "application/pdf": ".pdf",
31
+ "application/xml": ".xml",
32
+ "video/mp4": ".mp4",
33
+ "video/webm": ".webm",
34
+ "audio/mpeg": ".mp3",
35
+ "audio/wav": ".wav"
36
+ };
37
+
38
+ /**
39
+ * Creates an empty prune result.
40
+ */
41
+ const EMPTY_PRUNE_RESULT = Object.freeze({
42
+ removedCount: 0,
43
+ freedBytes: 0,
44
+ removedKeys: []
45
+ });
46
+
47
+ /**
48
+ * Constructs the storage path for an entry.
49
+ */
50
+ function buildEntryPath(hash, ext) {
51
+ return `${ENTRIES_DIR}/${hash}${ext}`;
52
+ }
53
+
16
54
  /**
17
55
  * Main cache engine implementation.
18
56
  * Coordinates all cache operations through the storage adapter.
@@ -22,6 +60,8 @@ class CacheEngine {
22
60
  _keyMutex = new _mutex.KeyedMutex();
23
61
  _index = (0, _prune.createEmptyIndex)();
24
62
  _initialized = false;
63
+ _indexDirty = false;
64
+ _debounceTimer = null;
25
65
  constructor(config, adapter) {
26
66
  this._config = {
27
67
  namespace: "default",
@@ -31,6 +71,140 @@ class CacheEngine {
31
71
  this._adapter = adapter;
32
72
  this._indexStore = new _indexStore.IndexStore(adapter, INDEX_FILE);
33
73
  this._hashFn = config.hashFn ?? _hash.hash;
74
+ this._now = config.now ?? Date.now;
75
+ this._debounceMs = config.indexWriteDebounceMs ?? 0;
76
+ }
77
+
78
+ // ─────────────────────────────────────────────────────────────────
79
+ // Public Properties
80
+ // ─────────────────────────────────────────────────────────────────
81
+
82
+ /**
83
+ * Returns whether the cache has been initialized.
84
+ * Use this to check initialization state without throwing.
85
+ */
86
+ get isInitialized() {
87
+ return this._initialized;
88
+ }
89
+
90
+ /**
91
+ * Returns the configured namespace.
92
+ */
93
+ get namespace() {
94
+ return this._config.namespace;
95
+ }
96
+
97
+ /**
98
+ * Returns the storage adapter.
99
+ */
100
+ get adapter() {
101
+ return this._adapter;
102
+ }
103
+
104
+ /**
105
+ * Returns whether there are pending index writes.
106
+ * Useful for checking if flush() should be called before shutdown.
107
+ */
108
+ get hasPendingWrites() {
109
+ return this._indexDirty;
110
+ }
111
+
112
+ /**
113
+ * Schedules an index save, respecting debounce configuration.
114
+ * If debounceMs is 0, saves immediately.
115
+ */
116
+ async _scheduleIndexSave() {
117
+ if (this._debounceMs <= 0) {
118
+ // Immediate save
119
+ await this._indexStore.save(this._index);
120
+ return;
121
+ }
122
+
123
+ // Mark index as dirty
124
+ this._indexDirty = true;
125
+
126
+ // Clear existing timer
127
+ if (this._debounceTimer) {
128
+ clearTimeout(this._debounceTimer);
129
+ }
130
+
131
+ // Schedule new save
132
+ this._debounceTimer = setTimeout(() => {
133
+ void this._flushIndex();
134
+ }, this._debounceMs);
135
+ }
136
+
137
+ /**
138
+ * Internal flush implementation.
139
+ */
140
+ async _flushIndex() {
141
+ if (!this._indexDirty) {
142
+ return;
143
+ }
144
+
145
+ // Clear timer
146
+ if (this._debounceTimer) {
147
+ clearTimeout(this._debounceTimer);
148
+ this._debounceTimer = null;
149
+ }
150
+
151
+ // Save index
152
+ await this._indexStore.save(this._index);
153
+ this._indexDirty = false;
154
+ }
155
+
156
+ /**
157
+ * Flushes any pending index writes immediately.
158
+ * Call this before app shutdown or when you need to ensure persistence.
159
+ */
160
+ async flush() {
161
+ if (!this._initialized) {
162
+ return;
163
+ }
164
+ await this._indexMutex.runExclusive(async () => {
165
+ await this._flushIndex();
166
+ });
167
+ }
168
+
169
+ /**
170
+ * Destroys the cache engine, releasing resources.
171
+ * - Flushes any pending index writes
172
+ * - Clears debounce timers
173
+ * - Marks engine as uninitialized
174
+ *
175
+ * After calling destroy(), the engine cannot be used until init() is called again.
176
+ */
177
+ async destroy() {
178
+ if (!this._initialized) {
179
+ return;
180
+ }
181
+
182
+ // Flush pending writes
183
+ await this._indexMutex.runExclusive(async () => {
184
+ await this._flushIndex();
185
+ });
186
+
187
+ // Clear debounce timer
188
+ if (this._debounceTimer) {
189
+ clearTimeout(this._debounceTimer);
190
+ this._debounceTimer = null;
191
+ }
192
+
193
+ // Reset state
194
+ this._index = (0, _prune.createEmptyIndex)();
195
+ this._indexDirty = false;
196
+ this._initialized = false;
197
+ }
198
+
199
+ /**
200
+ * Emits an event to the configured event handler (if any).
201
+ */
202
+ _emit(event) {
203
+ try {
204
+ this._config.onEvent?.(event);
205
+ } catch {
206
+ // Ignore errors in event handlers to prevent disrupting cache operations
207
+ }
34
208
  }
35
209
 
36
210
  /**
@@ -115,10 +289,10 @@ class CacheEngine {
115
289
  return this._keyMutex.runExclusive(key, async () => {
116
290
  // Check if key already exists
117
291
  const existingEntry = this._index.entries[key];
292
+ const startTime = this._now();
118
293
  if (existingEntry) {
119
294
  // Check if expired
120
- const now = Date.now();
121
- if (existingEntry.expiresAt === undefined || existingEntry.expiresAt >= now) {
295
+ if ((0, _prune.isEntryValid)(existingEntry, startTime)) {
122
296
  // Entry exists and is not expired, return exists
123
297
  const entry = this._entryWithPath(existingEntry);
124
298
  return {
@@ -137,7 +311,7 @@ class CacheEngine {
137
311
  const ext = options?.ext ?? this._extractExtension(source);
138
312
 
139
313
  // Build entry path
140
- const entryPath = `${ENTRIES_DIR}/${hashValue}${ext}`;
314
+ const entryPath = buildEntryPath(hashValue, ext);
141
315
 
142
316
  // Write binary content
143
317
  const writeResult = await this._adapter.writeBinaryAtomic(entryPath, source, {
@@ -146,7 +320,7 @@ class CacheEngine {
146
320
  });
147
321
 
148
322
  // Calculate expiration
149
- const now = Date.now();
323
+ const now = this._now();
150
324
  const ttlMs = options?.ttlMs ?? this._config.defaultTtlMs;
151
325
  const expiresAt = ttlMs !== undefined ? now + ttlMs : undefined;
152
326
 
@@ -163,10 +337,19 @@ class CacheEngine {
163
337
  metadata: options?.metadata
164
338
  };
165
339
 
340
+ // Emit write event
341
+ this._emit({
342
+ type: "cache_write",
343
+ key,
344
+ sizeBytes: writeResult.sizeBytes,
345
+ source: source.type,
346
+ durationMs: now - startTime
347
+ });
348
+
166
349
  // Update index atomically
167
350
  await this._indexMutex.runExclusive(async () => {
168
351
  this._index = (0, _prune.addEntryToIndex)(this._index, entryMeta);
169
- await this._indexStore.save(this._index);
352
+ await this._scheduleIndexSave();
170
353
  });
171
354
 
172
355
  // Auto-prune if configured
@@ -196,23 +379,40 @@ class CacheEngine {
196
379
  this._ensureInitialized();
197
380
  const entryMeta = this._index.entries[key];
198
381
  if (!entryMeta) {
382
+ this._emit({
383
+ type: "cache_miss",
384
+ key,
385
+ reason: "not_found"
386
+ });
199
387
  return null;
200
388
  }
201
389
 
202
390
  // Check expiration
203
- const now = Date.now();
204
- if (entryMeta.expiresAt !== undefined && entryMeta.expiresAt < now) {
205
- // Entry is expired
391
+ const now = this._now();
392
+ if ((0, _prune.isEntryExpired)(entryMeta, now)) {
393
+ this._emit({
394
+ type: "cache_miss",
395
+ key,
396
+ reason: "expired"
397
+ });
206
398
  return null;
207
399
  }
208
400
 
209
401
  // Update lastAccessedAt
210
402
  await this._indexMutex.runExclusive(async () => {
211
403
  this._index = (0, _prune.touchEntry)(this._index, key, now);
212
- await this._indexStore.save(this._index);
404
+ await this._scheduleIndexSave();
213
405
  });
214
406
  const entry = this._entryWithPath(entryMeta);
215
407
  const uri = await this._adapter.getPublicUri(entry.path);
408
+
409
+ // Emit hit event
410
+ this._emit({
411
+ type: "cache_hit",
412
+ key,
413
+ sizeBytes: entry.sizeBytes,
414
+ ageMs: now - entry.createdAt
415
+ });
216
416
  return {
217
417
  entry,
218
418
  uri
@@ -228,13 +428,7 @@ class CacheEngine {
228
428
  if (!entryMeta) {
229
429
  return false;
230
430
  }
231
-
232
- // Check expiration
233
- const now = Date.now();
234
- if (entryMeta.expiresAt !== undefined && entryMeta.expiresAt < now) {
235
- return false;
236
- }
237
- return true;
431
+ return (0, _prune.isEntryValid)(entryMeta, this._now());
238
432
  }
239
433
 
240
434
  /**
@@ -246,28 +440,204 @@ class CacheEngine {
246
440
  if (!entryMeta) {
247
441
  return null;
248
442
  }
249
-
250
- // Check expiration
251
- const now = Date.now();
252
- if (entryMeta.expiresAt !== undefined && entryMeta.expiresAt < now) {
443
+ if ((0, _prune.isEntryExpired)(entryMeta, this._now())) {
253
444
  return null;
254
445
  }
255
446
  return this._entryWithPath(entryMeta);
256
447
  }
257
448
 
449
+ /**
450
+ * Get the public URI for a cached entry without updating lastAccessedAt.
451
+ * Useful for scenarios where you need the URI but don't want to affect LRU ordering.
452
+ * Returns null if entry doesn't exist or is expired.
453
+ */
454
+ async getUri(key) {
455
+ this._ensureInitialized();
456
+ const entryMeta = this._index.entries[key];
457
+ if (!entryMeta) {
458
+ return null;
459
+ }
460
+ if ((0, _prune.isEntryExpired)(entryMeta, this._now())) {
461
+ return null;
462
+ }
463
+ const entry = this._entryWithPath(entryMeta);
464
+ return this._adapter.getPublicUri(entry.path);
465
+ }
466
+
467
+ /**
468
+ * Manually update the lastAccessedAt timestamp for an entry.
469
+ * Useful for refreshing LRU ordering without reading the entry.
470
+ * Returns true if entry was touched, false if not found or expired.
471
+ */
472
+ async touch(key) {
473
+ this._ensureInitialized();
474
+ const entryMeta = this._index.entries[key];
475
+ if (!entryMeta) {
476
+ return false;
477
+ }
478
+ const now = this._now();
479
+ if ((0, _prune.isEntryExpired)(entryMeta, now)) {
480
+ return false;
481
+ }
482
+ await this._indexMutex.runExclusive(async () => {
483
+ this._index = (0, _prune.touchEntry)(this._index, key, now);
484
+ await this._scheduleIndexSave();
485
+ });
486
+ return true;
487
+ }
488
+
489
+ /**
490
+ * Set the TTL for an existing entry.
491
+ * @param key - The cache key
492
+ * @param ttlMs - TTL in milliseconds from now. Use undefined to remove expiration.
493
+ * @returns true if entry was updated, false if not found or already expired
494
+ */
495
+ async setTtl(key, ttlMs) {
496
+ this._ensureInitialized();
497
+ const entryMeta = this._index.entries[key];
498
+ if (!entryMeta) {
499
+ return false;
500
+ }
501
+ const now = this._now();
502
+ if ((0, _prune.isEntryExpired)(entryMeta, now)) {
503
+ return false;
504
+ }
505
+ const newExpiresAt = ttlMs !== undefined ? now + ttlMs : undefined;
506
+ await this._indexMutex.runExclusive(async () => {
507
+ const updatedEntry = {
508
+ ...entryMeta,
509
+ expiresAt: newExpiresAt
510
+ };
511
+ this._index = (0, _prune.addEntryToIndex)(this._index, updatedEntry, now);
512
+ await this._scheduleIndexSave();
513
+ });
514
+ return true;
515
+ }
516
+
517
+ /**
518
+ * Extend the TTL of an existing entry by an additional duration.
519
+ * If the entry has no expiration, this is a no-op (returns true).
520
+ * @param key - The cache key
521
+ * @param additionalMs - Additional milliseconds to add to current expiration
522
+ * @returns true if entry was updated, false if not found or already expired
523
+ */
524
+ async extendTtl(key, additionalMs) {
525
+ this._ensureInitialized();
526
+ const entryMeta = this._index.entries[key];
527
+ if (!entryMeta) {
528
+ return false;
529
+ }
530
+ const now = this._now();
531
+ if ((0, _prune.isEntryExpired)(entryMeta, now)) {
532
+ return false;
533
+ }
534
+
535
+ // If no expiration, nothing to extend
536
+ if (entryMeta.expiresAt === undefined) {
537
+ return true;
538
+ }
539
+ const newExpiresAt = entryMeta.expiresAt + additionalMs;
540
+ await this._indexMutex.runExclusive(async () => {
541
+ const updatedEntry = {
542
+ ...entryMeta,
543
+ expiresAt: newExpiresAt
544
+ };
545
+ this._index = (0, _prune.addEntryToIndex)(this._index, updatedEntry, now);
546
+ await this._scheduleIndexSave();
547
+ });
548
+ return true;
549
+ }
550
+
551
+ /**
552
+ * Read-through cache helper: get from cache or fetch and store.
553
+ *
554
+ * This implements the common pattern:
555
+ * 1. Try to get from cache
556
+ * 2. If not found/expired, call fetcher to get data
557
+ * 3. Store the fetched data in cache
558
+ * 4. Return the entry
559
+ *
560
+ * @param key - The cache key
561
+ * @param fetcher - Async function that returns bytes to cache if key doesn't exist
562
+ * @returns The cached entry with URI and whether it was fetched
563
+ *
564
+ * @example
565
+ * ```typescript
566
+ * const result = await cache.getOrPut("user:123:avatar", async (key) => {
567
+ * const response = await fetch(`https://api.example.com/avatar/${key}`);
568
+ * const bytes = new Uint8Array(await response.arrayBuffer());
569
+ * return { bytes, ext: ".png", ttlMs: 86400000 };
570
+ * });
571
+ * console.log(result.uri, result.fetched);
572
+ * ```
573
+ */
574
+ async getOrPut(key, fetcher) {
575
+ this._ensureInitialized();
576
+
577
+ // Try to get from cache first
578
+ const existing = await this.get(key);
579
+ if (existing) {
580
+ return {
581
+ entry: existing.entry,
582
+ uri: existing.uri,
583
+ fetched: false
584
+ };
585
+ }
586
+
587
+ // Not in cache, fetch and store
588
+ const fetchResult = await fetcher(key);
589
+ const putResult = await this.putFromBytes(key, fetchResult.bytes, {
590
+ ttlMs: fetchResult.ttlMs,
591
+ ext: fetchResult.ext,
592
+ metadata: fetchResult.metadata
593
+ });
594
+ const uri = await this._adapter.getPublicUri(putResult.entry.path);
595
+ return {
596
+ entry: putResult.entry,
597
+ uri,
598
+ fetched: true
599
+ };
600
+ }
601
+
258
602
  // ─────────────────────────────────────────────────────────────────
259
603
  // List/Query Operations
260
604
  // ─────────────────────────────────────────────────────────────────
261
605
 
606
+ /**
607
+ * Get all valid (non-expired) cache keys.
608
+ * More efficient than list() when you only need keys.
609
+ */
610
+ async keys() {
611
+ this._ensureInitialized();
612
+ const now = this._now();
613
+ return Object.values(this._index.entries).filter(e => (0, _prune.isEntryValid)(e, now)).map(e => e.key);
614
+ }
615
+
616
+ /**
617
+ * Get the count of valid (non-expired) entries.
618
+ * More efficient than list().length when you only need the count.
619
+ */
620
+ async count() {
621
+ this._ensureInitialized();
622
+ const now = this._now();
623
+ let validCount = 0;
624
+ for (const entry of Object.values(this._index.entries)) {
625
+ if ((0, _prune.isEntryValid)(entry, now)) {
626
+ validCount++;
627
+ }
628
+ }
629
+ return validCount;
630
+ }
631
+
262
632
  /**
263
633
  * List entries with sorting, filtering, pagination.
264
634
  */
265
635
  async list(options) {
266
636
  this._ensureInitialized();
267
- const now = Date.now();
637
+ const now = this._now();
268
638
  let entries = Object.values(this._index.entries)
269
639
  // Filter out expired entries
270
- .filter(e => e.expiresAt === undefined || e.expiresAt >= now)
640
+ .filter(e => (0, _prune.isEntryValid)(e, now))
271
641
  // Apply custom filter if provided
272
642
  .filter(e => options?.filter ? options.filter(e) : true);
273
643
 
@@ -309,8 +679,8 @@ class CacheEngine {
309
679
  */
310
680
  async stats() {
311
681
  this._ensureInitialized();
312
- const now = Date.now();
313
- const validEntries = Object.values(this._index.entries).filter(e => e.expiresAt === undefined || e.expiresAt >= now);
682
+ const now = this._now();
683
+ const validEntries = Object.values(this._index.entries).filter(e => (0, _prune.isEntryValid)(e, now));
314
684
  if (validEntries.length === 0) {
315
685
  return {
316
686
  entryCount: 0,
@@ -348,7 +718,15 @@ class CacheEngine {
348
718
  async remove(key) {
349
719
  this._ensureInitialized();
350
720
  return this._keyMutex.runExclusive(key, async () => {
351
- return this._removeEntry(key);
721
+ const removed = await this._removeEntry(key);
722
+ if (removed) {
723
+ this._emit({
724
+ type: "cache_remove",
725
+ key,
726
+ reason: "explicit"
727
+ });
728
+ }
729
+ return removed;
352
730
  });
353
731
  }
354
732
 
@@ -360,7 +738,7 @@ class CacheEngine {
360
738
  if (!entryMeta) {
361
739
  return false;
362
740
  }
363
- const entryPath = `${ENTRIES_DIR}/${entryMeta.hash}${entryMeta.ext}`;
741
+ const entryPath = buildEntryPath(entryMeta.hash, entryMeta.ext);
364
742
 
365
743
  // Remove file
366
744
  await this._adapter.remove(entryPath);
@@ -368,7 +746,7 @@ class CacheEngine {
368
746
  // Update index
369
747
  await this._indexMutex.runExclusive(async () => {
370
748
  this._index = (0, _prune.removeEntriesFromIndex)(this._index, [key]);
371
- await this._indexStore.save(this._index);
749
+ await this._scheduleIndexSave();
372
750
  });
373
751
  return true;
374
752
  }
@@ -378,16 +756,21 @@ class CacheEngine {
378
756
  */
379
757
  async removeExpired() {
380
758
  this._ensureInitialized();
381
- const now = Date.now();
759
+ const now = this._now();
382
760
  const expired = (0, _prune.getExpiredEntries)(this._index, now);
383
761
  if (expired.length === 0) {
384
- return {
385
- removedCount: 0,
386
- freedBytes: 0,
387
- removedKeys: []
388
- };
762
+ return EMPTY_PRUNE_RESULT;
763
+ }
764
+ const result = await this._removeEntries(expired, "expired");
765
+ if (result.removedCount > 0) {
766
+ this._emit({
767
+ type: "cache_prune",
768
+ reason: "expired",
769
+ removedCount: result.removedCount,
770
+ freedBytes: result.freedBytes
771
+ });
389
772
  }
390
- return this._removeEntries(expired);
773
+ return result;
391
774
  }
392
775
 
393
776
  /**
@@ -397,27 +780,37 @@ class CacheEngine {
397
780
  this._ensureInitialized();
398
781
  const targets = (0, _prune.getLruPruneTargets)(this._index, maxSizeBytes);
399
782
  if (targets.length === 0) {
400
- return {
401
- removedCount: 0,
402
- freedBytes: 0,
403
- removedKeys: []
404
- };
783
+ return EMPTY_PRUNE_RESULT;
405
784
  }
406
- return this._removeEntries(targets);
785
+ const result = await this._removeEntries(targets, "lru");
786
+ if (result.removedCount > 0) {
787
+ this._emit({
788
+ type: "cache_prune",
789
+ reason: "lru",
790
+ removedCount: result.removedCount,
791
+ freedBytes: result.freedBytes
792
+ });
793
+ }
794
+ return result;
407
795
  }
408
796
 
409
797
  /**
410
798
  * Remove multiple entries.
411
799
  */
412
- async _removeEntries(entries) {
800
+ async _removeEntries(entries, reason = "expired") {
413
801
  const removedKeys = [];
414
802
  let freedBytes = 0;
415
803
  for (const entry of entries) {
416
- const entryPath = `${ENTRIES_DIR}/${entry.hash}${entry.ext}`;
804
+ const entryPath = buildEntryPath(entry.hash, entry.ext);
417
805
  try {
418
806
  await this._adapter.remove(entryPath);
419
807
  removedKeys.push(entry.key);
420
808
  freedBytes += entry.sizeBytes;
809
+ this._emit({
810
+ type: "cache_remove",
811
+ key: entry.key,
812
+ reason
813
+ });
421
814
  } catch {
422
815
  // Ignore removal errors
423
816
  }
@@ -427,7 +820,7 @@ class CacheEngine {
427
820
  if (removedKeys.length > 0) {
428
821
  await this._indexMutex.runExclusive(async () => {
429
822
  this._index = (0, _prune.removeEntriesFromIndex)(this._index, removedKeys);
430
- await this._indexStore.save(this._index);
823
+ await this._scheduleIndexSave();
431
824
  });
432
825
  }
433
826
  return {
@@ -455,7 +848,9 @@ class CacheEngine {
455
848
 
456
849
  // Clear index
457
850
  this._index = (0, _prune.createEmptyIndex)();
851
+ // Always save immediately on clear (not debounced)
458
852
  await this._indexStore.save(this._index);
853
+ this._indexDirty = false;
459
854
  });
460
855
  }
461
856
 
@@ -472,7 +867,7 @@ class CacheEngine {
472
867
  await this._indexMutex.runExclusive(async () => {
473
868
  // Check each entry in index exists on filesystem
474
869
  for (const entry of Object.values(this._index.entries)) {
475
- const entryPath = `${ENTRIES_DIR}/${entry.hash}${entry.ext}`;
870
+ const entryPath = buildEntryPath(entry.hash, entry.ext);
476
871
  const exists = await this._adapter.exists(entryPath);
477
872
  if (!exists) {
478
873
  issues.push(`Missing file for entry "${entry.key}"`);
@@ -509,17 +904,28 @@ class CacheEngine {
509
904
  // Private Helpers
510
905
  // ─────────────────────────────────────────────────────────────────
511
906
 
907
+ /**
908
+ * Throws if the engine has not been initialized.
909
+ */
512
910
  _ensureInitialized() {
513
911
  if (!this._initialized) {
514
912
  throw new Error("CacheEngine not initialized. Call init() first.");
515
913
  }
516
914
  }
915
+
916
+ /**
917
+ * Constructs a full cache entry with path from metadata.
918
+ */
517
919
  _entryWithPath(meta) {
518
920
  return {
519
921
  ...meta,
520
- path: `${ENTRIES_DIR}/${meta.hash}${meta.ext}`
922
+ path: buildEntryPath(meta.hash, meta.ext)
521
923
  };
522
924
  }
925
+
926
+ /**
927
+ * Extracts file extension from binary source if possible.
928
+ */
523
929
  _extractExtension(source) {
524
930
  switch (source.type) {
525
931
  case "url":
@@ -545,20 +951,7 @@ class CacheEngine {
545
951
  {
546
952
  // Try to extract from MIME type
547
953
  const mime = source.blob.type;
548
- if (mime) {
549
- const mimeToExt = {
550
- "image/jpeg": ".jpg",
551
- "image/png": ".png",
552
- "image/gif": ".gif",
553
- "image/webp": ".webp",
554
- "application/json": ".json",
555
- "text/plain": ".txt",
556
- "text/html": ".html",
557
- "application/pdf": ".pdf"
558
- };
559
- return mimeToExt[mime] ?? "";
560
- }
561
- return "";
954
+ return mime ? MIME_TO_EXT[mime] ?? "" : "";
562
955
  }
563
956
  case "bytes":
564
957
  return "";