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