@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
@@ -8,10 +8,13 @@ import type {
8
8
  IPutOptions,
9
9
  IPutResult,
10
10
  IGetResult,
11
+ IGetOrPutResult,
12
+ TFetcher,
11
13
  IListOptions,
12
14
  IPruneResult,
13
15
  ICacheStats,
14
16
  ICacheIndex,
17
+ TCacheEvent,
15
18
  } from "./types";
16
19
  import { IndexStore } from "./indexStore";
17
20
  import { Mutex, KeyedMutex } from "./mutex";
@@ -23,11 +26,51 @@ import {
23
26
  addEntryToIndex,
24
27
  touchEntry,
25
28
  createEmptyIndex,
29
+ isEntryExpired,
30
+ isEntryValid,
26
31
  } from "./prune";
27
32
 
28
33
  const ENTRIES_DIR = "entries";
29
34
  const INDEX_FILE = "index.json";
30
35
 
36
+ /**
37
+ * Common MIME type to file extension mapping.
38
+ */
39
+ const MIME_TO_EXT: Readonly<Record<string, string>> = {
40
+ "image/jpeg": ".jpg",
41
+ "image/png": ".png",
42
+ "image/gif": ".gif",
43
+ "image/webp": ".webp",
44
+ "image/svg+xml": ".svg",
45
+ "application/json": ".json",
46
+ "text/plain": ".txt",
47
+ "text/html": ".html",
48
+ "text/css": ".css",
49
+ "text/javascript": ".js",
50
+ "application/pdf": ".pdf",
51
+ "application/xml": ".xml",
52
+ "video/mp4": ".mp4",
53
+ "video/webm": ".webm",
54
+ "audio/mpeg": ".mp3",
55
+ "audio/wav": ".wav",
56
+ };
57
+
58
+ /**
59
+ * Creates an empty prune result.
60
+ */
61
+ const EMPTY_PRUNE_RESULT: IPruneResult = Object.freeze({
62
+ removedCount: 0,
63
+ freedBytes: 0,
64
+ removedKeys: [],
65
+ });
66
+
67
+ /**
68
+ * Constructs the storage path for an entry.
69
+ */
70
+ function buildEntryPath(hash: string, ext: string): string {
71
+ return `${ENTRIES_DIR}/${hash}${ext}`;
72
+ }
73
+
31
74
  /**
32
75
  * Main cache engine implementation.
33
76
  * Coordinates all cache operations through the storage adapter.
@@ -40,9 +83,13 @@ export class CacheEngine {
40
83
  private readonly _indexMutex = new Mutex();
41
84
  private readonly _keyMutex = new KeyedMutex();
42
85
  private readonly _hashFn: (input: string) => string | Promise<string>;
86
+ private readonly _now: () => number;
87
+ private readonly _debounceMs: number;
43
88
 
44
89
  private _index: ICacheIndex = createEmptyIndex();
45
90
  private _initialized = false;
91
+ private _indexDirty = false;
92
+ private _debounceTimer: ReturnType<typeof setTimeout> | null = null;
46
93
 
47
94
  constructor(config: ICacheConfig, adapter: IStorageAdapter) {
48
95
  this._config = {
@@ -53,6 +100,141 @@ export class CacheEngine {
53
100
  this._adapter = adapter;
54
101
  this._indexStore = new IndexStore(adapter, INDEX_FILE);
55
102
  this._hashFn = config.hashFn ?? defaultHash;
103
+ this._now = config.now ?? Date.now;
104
+ this._debounceMs = config.indexWriteDebounceMs ?? 0;
105
+ }
106
+
107
+ // ─────────────────────────────────────────────────────────────────
108
+ // Public Properties
109
+ // ─────────────────────────────────────────────────────────────────
110
+
111
+ /**
112
+ * Returns whether the cache has been initialized.
113
+ * Use this to check initialization state without throwing.
114
+ */
115
+ get isInitialized(): boolean {
116
+ return this._initialized;
117
+ }
118
+
119
+ /**
120
+ * Returns the configured namespace.
121
+ */
122
+ get namespace(): string {
123
+ return this._config.namespace;
124
+ }
125
+
126
+ /**
127
+ * Returns the storage adapter.
128
+ */
129
+ get adapter(): IStorageAdapter {
130
+ return this._adapter;
131
+ }
132
+
133
+ /**
134
+ * Returns whether there are pending index writes.
135
+ * Useful for checking if flush() should be called before shutdown.
136
+ */
137
+ get hasPendingWrites(): boolean {
138
+ return this._indexDirty;
139
+ }
140
+
141
+ /**
142
+ * Schedules an index save, respecting debounce configuration.
143
+ * If debounceMs is 0, saves immediately.
144
+ */
145
+ private async _scheduleIndexSave(): Promise<void> {
146
+ if (this._debounceMs <= 0) {
147
+ // Immediate save
148
+ await this._indexStore.save(this._index);
149
+ return;
150
+ }
151
+
152
+ // Mark index as dirty
153
+ this._indexDirty = true;
154
+
155
+ // Clear existing timer
156
+ if (this._debounceTimer) {
157
+ clearTimeout(this._debounceTimer);
158
+ }
159
+
160
+ // Schedule new save
161
+ this._debounceTimer = setTimeout(() => {
162
+ void this._flushIndex();
163
+ }, this._debounceMs);
164
+ }
165
+
166
+ /**
167
+ * Internal flush implementation.
168
+ */
169
+ private async _flushIndex(): Promise<void> {
170
+ if (!this._indexDirty) {
171
+ return;
172
+ }
173
+
174
+ // Clear timer
175
+ if (this._debounceTimer) {
176
+ clearTimeout(this._debounceTimer);
177
+ this._debounceTimer = null;
178
+ }
179
+
180
+ // Save index
181
+ await this._indexStore.save(this._index);
182
+ this._indexDirty = false;
183
+ }
184
+
185
+ /**
186
+ * Flushes any pending index writes immediately.
187
+ * Call this before app shutdown or when you need to ensure persistence.
188
+ */
189
+ async flush(): Promise<void> {
190
+ if (!this._initialized) {
191
+ return;
192
+ }
193
+
194
+ await this._indexMutex.runExclusive(async () => {
195
+ await this._flushIndex();
196
+ });
197
+ }
198
+
199
+ /**
200
+ * Destroys the cache engine, releasing resources.
201
+ * - Flushes any pending index writes
202
+ * - Clears debounce timers
203
+ * - Marks engine as uninitialized
204
+ *
205
+ * After calling destroy(), the engine cannot be used until init() is called again.
206
+ */
207
+ async destroy(): Promise<void> {
208
+ if (!this._initialized) {
209
+ return;
210
+ }
211
+
212
+ // Flush pending writes
213
+ await this._indexMutex.runExclusive(async () => {
214
+ await this._flushIndex();
215
+ });
216
+
217
+ // Clear debounce timer
218
+ if (this._debounceTimer) {
219
+ clearTimeout(this._debounceTimer);
220
+ this._debounceTimer = null;
221
+ }
222
+
223
+ // Reset state
224
+ this._index = createEmptyIndex();
225
+ this._indexDirty = false;
226
+ this._initialized = false;
227
+ }
228
+
229
+ /**
230
+ * Emits an event to the configured event handler (if any).
231
+ */
232
+ private _emit(event: TCacheEvent): void {
233
+ try {
234
+ this._config.onEvent?.(event);
235
+ } catch {
236
+ // Ignore errors in event handlers to prevent disrupting cache operations
237
+ }
56
238
  }
57
239
 
58
240
  /**
@@ -143,13 +325,14 @@ export class CacheEngine {
143
325
  return this._keyMutex.runExclusive(key, async () => {
144
326
  // Check if key already exists
145
327
  const existingEntry = this._index.entries[key];
328
+ const startTime = this._now();
329
+
146
330
  if (existingEntry) {
147
331
  // Check if expired
148
- const now = Date.now();
149
- if (existingEntry.expiresAt === undefined || existingEntry.expiresAt >= now) {
332
+ if (isEntryValid(existingEntry, startTime)) {
150
333
  // Entry exists and is not expired, return exists
151
334
  const entry = this._entryWithPath(existingEntry);
152
- return { status: "exists" as const, entry };
335
+ return { status: "exists", entry };
153
336
  }
154
337
  // Entry is expired, remove it first
155
338
  await this._removeEntry(key);
@@ -162,7 +345,7 @@ export class CacheEngine {
162
345
  const ext = options?.ext ?? this._extractExtension(source);
163
346
 
164
347
  // Build entry path
165
- const entryPath = `${ENTRIES_DIR}/${hashValue}${ext}`;
348
+ const entryPath = buildEntryPath(hashValue, ext);
166
349
 
167
350
  // Write binary content
168
351
  const writeResult = await this._adapter.writeBinaryAtomic(entryPath, source, {
@@ -171,7 +354,7 @@ export class CacheEngine {
171
354
  });
172
355
 
173
356
  // Calculate expiration
174
- const now = Date.now();
357
+ const now = this._now();
175
358
  const ttlMs = options?.ttlMs ?? this._config.defaultTtlMs;
176
359
  const expiresAt = ttlMs !== undefined ? now + ttlMs : undefined;
177
360
 
@@ -188,10 +371,19 @@ export class CacheEngine {
188
371
  metadata: options?.metadata,
189
372
  };
190
373
 
374
+ // Emit write event
375
+ this._emit({
376
+ type: "cache_write",
377
+ key,
378
+ sizeBytes: writeResult.sizeBytes,
379
+ source: source.type,
380
+ durationMs: now - startTime,
381
+ });
382
+
191
383
  // Update index atomically
192
384
  await this._indexMutex.runExclusive(async () => {
193
385
  this._index = addEntryToIndex(this._index, entryMeta);
194
- await this._indexStore.save(this._index);
386
+ await this._scheduleIndexSave();
195
387
  });
196
388
 
197
389
  // Auto-prune if configured
@@ -203,7 +395,7 @@ export class CacheEngine {
203
395
  }
204
396
 
205
397
  const entry = this._entryWithPath(entryMeta);
206
- return { status: "created" as const, entry };
398
+ return { status: "created", entry };
207
399
  });
208
400
  }
209
401
 
@@ -220,25 +412,34 @@ export class CacheEngine {
220
412
 
221
413
  const entryMeta = this._index.entries[key];
222
414
  if (!entryMeta) {
415
+ this._emit({ type: "cache_miss", key, reason: "not_found" });
223
416
  return null;
224
417
  }
225
418
 
226
419
  // Check expiration
227
- const now = Date.now();
228
- if (entryMeta.expiresAt !== undefined && entryMeta.expiresAt < now) {
229
- // Entry is expired
420
+ const now = this._now();
421
+ if (isEntryExpired(entryMeta, now)) {
422
+ this._emit({ type: "cache_miss", key, reason: "expired" });
230
423
  return null;
231
424
  }
232
425
 
233
426
  // Update lastAccessedAt
234
427
  await this._indexMutex.runExclusive(async () => {
235
428
  this._index = touchEntry(this._index, key, now);
236
- await this._indexStore.save(this._index);
429
+ await this._scheduleIndexSave();
237
430
  });
238
431
 
239
432
  const entry = this._entryWithPath(entryMeta);
240
433
  const uri = await this._adapter.getPublicUri(entry.path);
241
434
 
435
+ // Emit hit event
436
+ this._emit({
437
+ type: "cache_hit",
438
+ key,
439
+ sizeBytes: entry.sizeBytes,
440
+ ageMs: now - entry.createdAt,
441
+ });
442
+
242
443
  return { entry, uri };
243
444
  }
244
445
 
@@ -253,13 +454,7 @@ export class CacheEngine {
253
454
  return false;
254
455
  }
255
456
 
256
- // Check expiration
257
- const now = Date.now();
258
- if (entryMeta.expiresAt !== undefined && entryMeta.expiresAt < now) {
259
- return false;
260
- }
261
-
262
- return true;
457
+ return isEntryValid(entryMeta, this._now());
263
458
  }
264
459
 
265
460
  /**
@@ -273,29 +468,230 @@ export class CacheEngine {
273
468
  return null;
274
469
  }
275
470
 
276
- // Check expiration
277
- const now = Date.now();
278
- if (entryMeta.expiresAt !== undefined && entryMeta.expiresAt < now) {
471
+ if (isEntryExpired(entryMeta, this._now())) {
279
472
  return null;
280
473
  }
281
474
 
282
475
  return this._entryWithPath(entryMeta);
283
476
  }
284
477
 
478
+ /**
479
+ * Get the public URI for a cached entry without updating lastAccessedAt.
480
+ * Useful for scenarios where you need the URI but don't want to affect LRU ordering.
481
+ * Returns null if entry doesn't exist or is expired.
482
+ */
483
+ async getUri(key: string): Promise<string | null> {
484
+ this._ensureInitialized();
485
+
486
+ const entryMeta = this._index.entries[key];
487
+ if (!entryMeta) {
488
+ return null;
489
+ }
490
+
491
+ if (isEntryExpired(entryMeta, this._now())) {
492
+ return null;
493
+ }
494
+
495
+ const entry = this._entryWithPath(entryMeta);
496
+ return this._adapter.getPublicUri(entry.path);
497
+ }
498
+
499
+ /**
500
+ * Manually update the lastAccessedAt timestamp for an entry.
501
+ * Useful for refreshing LRU ordering without reading the entry.
502
+ * Returns true if entry was touched, false if not found or expired.
503
+ */
504
+ async touch(key: string): Promise<boolean> {
505
+ this._ensureInitialized();
506
+
507
+ const entryMeta = this._index.entries[key];
508
+ if (!entryMeta) {
509
+ return false;
510
+ }
511
+
512
+ const now = this._now();
513
+ if (isEntryExpired(entryMeta, now)) {
514
+ return false;
515
+ }
516
+
517
+ await this._indexMutex.runExclusive(async () => {
518
+ this._index = touchEntry(this._index, key, now);
519
+ await this._scheduleIndexSave();
520
+ });
521
+
522
+ return true;
523
+ }
524
+
525
+ /**
526
+ * Set the TTL for an existing entry.
527
+ * @param key - The cache key
528
+ * @param ttlMs - TTL in milliseconds from now. Use undefined to remove expiration.
529
+ * @returns true if entry was updated, false if not found or already expired
530
+ */
531
+ async setTtl(key: string, ttlMs: number | undefined): Promise<boolean> {
532
+ this._ensureInitialized();
533
+
534
+ const entryMeta = this._index.entries[key];
535
+ if (!entryMeta) {
536
+ return false;
537
+ }
538
+
539
+ const now = this._now();
540
+ if (isEntryExpired(entryMeta, now)) {
541
+ return false;
542
+ }
543
+
544
+ const newExpiresAt = ttlMs !== undefined ? now + ttlMs : undefined;
545
+
546
+ await this._indexMutex.runExclusive(async () => {
547
+ const updatedEntry: ICacheEntryMeta = {
548
+ ...entryMeta,
549
+ expiresAt: newExpiresAt,
550
+ };
551
+ this._index = addEntryToIndex(this._index, updatedEntry, now);
552
+ await this._scheduleIndexSave();
553
+ });
554
+
555
+ return true;
556
+ }
557
+
558
+ /**
559
+ * Extend the TTL of an existing entry by an additional duration.
560
+ * If the entry has no expiration, this is a no-op (returns true).
561
+ * @param key - The cache key
562
+ * @param additionalMs - Additional milliseconds to add to current expiration
563
+ * @returns true if entry was updated, false if not found or already expired
564
+ */
565
+ async extendTtl(key: string, additionalMs: number): Promise<boolean> {
566
+ this._ensureInitialized();
567
+
568
+ const entryMeta = this._index.entries[key];
569
+ if (!entryMeta) {
570
+ return false;
571
+ }
572
+
573
+ const now = this._now();
574
+ if (isEntryExpired(entryMeta, now)) {
575
+ return false;
576
+ }
577
+
578
+ // If no expiration, nothing to extend
579
+ if (entryMeta.expiresAt === undefined) {
580
+ return true;
581
+ }
582
+
583
+ const newExpiresAt = entryMeta.expiresAt + additionalMs;
584
+
585
+ await this._indexMutex.runExclusive(async () => {
586
+ const updatedEntry: ICacheEntryMeta = {
587
+ ...entryMeta,
588
+ expiresAt: newExpiresAt,
589
+ };
590
+ this._index = addEntryToIndex(this._index, updatedEntry, now);
591
+ await this._scheduleIndexSave();
592
+ });
593
+
594
+ return true;
595
+ }
596
+
597
+ /**
598
+ * Read-through cache helper: get from cache or fetch and store.
599
+ *
600
+ * This implements the common pattern:
601
+ * 1. Try to get from cache
602
+ * 2. If not found/expired, call fetcher to get data
603
+ * 3. Store the fetched data in cache
604
+ * 4. Return the entry
605
+ *
606
+ * @param key - The cache key
607
+ * @param fetcher - Async function that returns bytes to cache if key doesn't exist
608
+ * @returns The cached entry with URI and whether it was fetched
609
+ *
610
+ * @example
611
+ * ```typescript
612
+ * const result = await cache.getOrPut("user:123:avatar", async (key) => {
613
+ * const response = await fetch(`https://api.example.com/avatar/${key}`);
614
+ * const bytes = new Uint8Array(await response.arrayBuffer());
615
+ * return { bytes, ext: ".png", ttlMs: 86400000 };
616
+ * });
617
+ * console.log(result.uri, result.fetched);
618
+ * ```
619
+ */
620
+ async getOrPut(key: string, fetcher: TFetcher): Promise<IGetOrPutResult> {
621
+ this._ensureInitialized();
622
+
623
+ // Try to get from cache first
624
+ const existing = await this.get(key);
625
+ if (existing) {
626
+ return {
627
+ entry: existing.entry,
628
+ uri: existing.uri,
629
+ fetched: false,
630
+ };
631
+ }
632
+
633
+ // Not in cache, fetch and store
634
+ const fetchResult = await fetcher(key);
635
+
636
+ const putResult = await this.putFromBytes(key, fetchResult.bytes, {
637
+ ttlMs: fetchResult.ttlMs,
638
+ ext: fetchResult.ext,
639
+ metadata: fetchResult.metadata,
640
+ });
641
+
642
+ const uri = await this._adapter.getPublicUri(putResult.entry.path);
643
+
644
+ return {
645
+ entry: putResult.entry,
646
+ uri,
647
+ fetched: true,
648
+ };
649
+ }
650
+
285
651
  // ─────────────────────────────────────────────────────────────────
286
652
  // List/Query Operations
287
653
  // ─────────────────────────────────────────────────────────────────
288
654
 
655
+ /**
656
+ * Get all valid (non-expired) cache keys.
657
+ * More efficient than list() when you only need keys.
658
+ */
659
+ async keys(): Promise<ReadonlyArray<string>> {
660
+ this._ensureInitialized();
661
+
662
+ const now = this._now();
663
+ return Object.values(this._index.entries)
664
+ .filter((e) => isEntryValid(e, now))
665
+ .map((e) => e.key);
666
+ }
667
+
668
+ /**
669
+ * Get the count of valid (non-expired) entries.
670
+ * More efficient than list().length when you only need the count.
671
+ */
672
+ async count(): Promise<number> {
673
+ this._ensureInitialized();
674
+
675
+ const now = this._now();
676
+ let validCount = 0;
677
+ for (const entry of Object.values(this._index.entries)) {
678
+ if (isEntryValid(entry, now)) {
679
+ validCount++;
680
+ }
681
+ }
682
+ return validCount;
683
+ }
684
+
289
685
  /**
290
686
  * List entries with sorting, filtering, pagination.
291
687
  */
292
688
  async list(options?: IListOptions): Promise<ReadonlyArray<ICacheEntry>> {
293
689
  this._ensureInitialized();
294
690
 
295
- const now = Date.now();
691
+ const now = this._now();
296
692
  let entries = Object.values(this._index.entries)
297
693
  // Filter out expired entries
298
- .filter((e) => e.expiresAt === undefined || e.expiresAt >= now)
694
+ .filter((e) => isEntryValid(e, now))
299
695
  // Apply custom filter if provided
300
696
  .filter((e) => (options?.filter ? options.filter(e) : true));
301
697
 
@@ -340,10 +736,8 @@ export class CacheEngine {
340
736
  async stats(): Promise<ICacheStats> {
341
737
  this._ensureInitialized();
342
738
 
343
- const now = Date.now();
344
- const validEntries = Object.values(this._index.entries).filter(
345
- (e) => e.expiresAt === undefined || e.expiresAt >= now
346
- );
739
+ const now = this._now();
740
+ const validEntries = Object.values(this._index.entries).filter((e) => isEntryValid(e, now));
347
741
 
348
742
  if (validEntries.length === 0) {
349
743
  return {
@@ -386,7 +780,11 @@ export class CacheEngine {
386
780
  this._ensureInitialized();
387
781
 
388
782
  return this._keyMutex.runExclusive(key, async () => {
389
- return this._removeEntry(key);
783
+ const removed = await this._removeEntry(key);
784
+ if (removed) {
785
+ this._emit({ type: "cache_remove", key, reason: "explicit" });
786
+ }
787
+ return removed;
390
788
  });
391
789
  }
392
790
 
@@ -399,7 +797,7 @@ export class CacheEngine {
399
797
  return false;
400
798
  }
401
799
 
402
- const entryPath = `${ENTRIES_DIR}/${entryMeta.hash}${entryMeta.ext}`;
800
+ const entryPath = buildEntryPath(entryMeta.hash, entryMeta.ext);
403
801
 
404
802
  // Remove file
405
803
  await this._adapter.remove(entryPath);
@@ -407,7 +805,7 @@ export class CacheEngine {
407
805
  // Update index
408
806
  await this._indexMutex.runExclusive(async () => {
409
807
  this._index = removeEntriesFromIndex(this._index, [key]);
410
- await this._indexStore.save(this._index);
808
+ await this._scheduleIndexSave();
411
809
  });
412
810
 
413
811
  return true;
@@ -419,18 +817,25 @@ export class CacheEngine {
419
817
  async removeExpired(): Promise<IPruneResult> {
420
818
  this._ensureInitialized();
421
819
 
422
- const now = Date.now();
820
+ const now = this._now();
423
821
  const expired = getExpiredEntries(this._index, now);
424
822
 
425
823
  if (expired.length === 0) {
426
- return {
427
- removedCount: 0,
428
- freedBytes: 0,
429
- removedKeys: [],
430
- };
824
+ return EMPTY_PRUNE_RESULT;
431
825
  }
432
826
 
433
- return this._removeEntries(expired);
827
+ const result = await this._removeEntries(expired, "expired");
828
+
829
+ if (result.removedCount > 0) {
830
+ this._emit({
831
+ type: "cache_prune",
832
+ reason: "expired",
833
+ removedCount: result.removedCount,
834
+ freedBytes: result.freedBytes,
835
+ });
836
+ }
837
+
838
+ return result;
434
839
  }
435
840
 
436
841
  /**
@@ -442,29 +847,40 @@ export class CacheEngine {
442
847
  const targets = getLruPruneTargets(this._index, maxSizeBytes);
443
848
 
444
849
  if (targets.length === 0) {
445
- return {
446
- removedCount: 0,
447
- freedBytes: 0,
448
- removedKeys: [],
449
- };
850
+ return EMPTY_PRUNE_RESULT;
450
851
  }
451
852
 
452
- return this._removeEntries(targets);
853
+ const result = await this._removeEntries(targets, "lru");
854
+
855
+ if (result.removedCount > 0) {
856
+ this._emit({
857
+ type: "cache_prune",
858
+ reason: "lru",
859
+ removedCount: result.removedCount,
860
+ freedBytes: result.freedBytes,
861
+ });
862
+ }
863
+
864
+ return result;
453
865
  }
454
866
 
455
867
  /**
456
868
  * Remove multiple entries.
457
869
  */
458
- private async _removeEntries(entries: ReadonlyArray<ICacheEntryMeta>): Promise<IPruneResult> {
870
+ private async _removeEntries(
871
+ entries: ReadonlyArray<ICacheEntryMeta>,
872
+ reason: "expired" | "lru" = "expired"
873
+ ): Promise<IPruneResult> {
459
874
  const removedKeys: string[] = [];
460
875
  let freedBytes = 0;
461
876
 
462
877
  for (const entry of entries) {
463
- const entryPath = `${ENTRIES_DIR}/${entry.hash}${entry.ext}`;
878
+ const entryPath = buildEntryPath(entry.hash, entry.ext);
464
879
  try {
465
880
  await this._adapter.remove(entryPath);
466
881
  removedKeys.push(entry.key);
467
882
  freedBytes += entry.sizeBytes;
883
+ this._emit({ type: "cache_remove", key: entry.key, reason });
468
884
  } catch {
469
885
  // Ignore removal errors
470
886
  }
@@ -474,7 +890,7 @@ export class CacheEngine {
474
890
  if (removedKeys.length > 0) {
475
891
  await this._indexMutex.runExclusive(async () => {
476
892
  this._index = removeEntriesFromIndex(this._index, removedKeys);
477
- await this._indexStore.save(this._index);
893
+ await this._scheduleIndexSave();
478
894
  });
479
895
  }
480
896
 
@@ -504,7 +920,9 @@ export class CacheEngine {
504
920
 
505
921
  // Clear index
506
922
  this._index = createEmptyIndex();
923
+ // Always save immediately on clear (not debounced)
507
924
  await this._indexStore.save(this._index);
925
+ this._indexDirty = false;
508
926
  });
509
927
  }
510
928
 
@@ -523,7 +941,7 @@ export class CacheEngine {
523
941
  await this._indexMutex.runExclusive(async () => {
524
942
  // Check each entry in index exists on filesystem
525
943
  for (const entry of Object.values(this._index.entries)) {
526
- const entryPath = `${ENTRIES_DIR}/${entry.hash}${entry.ext}`;
944
+ const entryPath = buildEntryPath(entry.hash, entry.ext);
527
945
  const exists = await this._adapter.exists(entryPath);
528
946
  if (!exists) {
529
947
  issues.push(`Missing file for entry "${entry.key}"`);
@@ -564,19 +982,28 @@ export class CacheEngine {
564
982
  // Private Helpers
565
983
  // ─────────────────────────────────────────────────────────────────
566
984
 
985
+ /**
986
+ * Throws if the engine has not been initialized.
987
+ */
567
988
  private _ensureInitialized(): void {
568
989
  if (!this._initialized) {
569
990
  throw new Error("CacheEngine not initialized. Call init() first.");
570
991
  }
571
992
  }
572
993
 
994
+ /**
995
+ * Constructs a full cache entry with path from metadata.
996
+ */
573
997
  private _entryWithPath(meta: ICacheEntryMeta): ICacheEntry {
574
998
  return {
575
999
  ...meta,
576
- path: `${ENTRIES_DIR}/${meta.hash}${meta.ext}`,
1000
+ path: buildEntryPath(meta.hash, meta.ext),
577
1001
  };
578
1002
  }
579
1003
 
1004
+ /**
1005
+ * Extracts file extension from binary source if possible.
1006
+ */
580
1007
  private _extractExtension(source: TBinarySource): string {
581
1008
  switch (source.type) {
582
1009
  case "url": {
@@ -602,20 +1029,7 @@ export class CacheEngine {
602
1029
  case "blob": {
603
1030
  // Try to extract from MIME type
604
1031
  const mime = source.blob.type;
605
- if (mime) {
606
- const mimeToExt: Record<string, string> = {
607
- "image/jpeg": ".jpg",
608
- "image/png": ".png",
609
- "image/gif": ".gif",
610
- "image/webp": ".webp",
611
- "application/json": ".json",
612
- "text/plain": ".txt",
613
- "text/html": ".html",
614
- "application/pdf": ".pdf",
615
- };
616
- return mimeToExt[mime] ?? "";
617
- }
618
- return "";
1032
+ return mime ? (MIME_TO_EXT[mime] ?? "") : "";
619
1033
  }
620
1034
  case "bytes":
621
1035
  return "";