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

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 (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +415 -0
  3. package/lib/commonjs/adapters/memoryAdapter.js +266 -0
  4. package/lib/commonjs/adapters/memoryAdapter.js.map +1 -0
  5. package/lib/commonjs/adapters/rnfsAdapter.js +259 -0
  6. package/lib/commonjs/adapters/rnfsAdapter.js.map +1 -0
  7. package/lib/commonjs/adapters/webAdapter.js +432 -0
  8. package/lib/commonjs/adapters/webAdapter.js.map +1 -0
  9. package/lib/commonjs/core/adapter.js +2 -0
  10. package/lib/commonjs/core/adapter.js.map +1 -0
  11. package/lib/commonjs/core/cacheEngine.js +578 -0
  12. package/lib/commonjs/core/cacheEngine.js.map +1 -0
  13. package/lib/commonjs/core/errors.js +83 -0
  14. package/lib/commonjs/core/errors.js.map +1 -0
  15. package/lib/commonjs/core/hash.js +83 -0
  16. package/lib/commonjs/core/hash.js.map +1 -0
  17. package/lib/commonjs/core/indexStore.js +175 -0
  18. package/lib/commonjs/core/indexStore.js.map +1 -0
  19. package/lib/commonjs/core/mutex.js +143 -0
  20. package/lib/commonjs/core/mutex.js.map +1 -0
  21. package/lib/commonjs/core/prune.js +127 -0
  22. package/lib/commonjs/core/prune.js.map +1 -0
  23. package/lib/commonjs/core/types.js +6 -0
  24. package/lib/commonjs/core/types.js.map +1 -0
  25. package/lib/commonjs/factory.js +56 -0
  26. package/lib/commonjs/factory.js.map +1 -0
  27. package/lib/commonjs/index.js +110 -0
  28. package/lib/commonjs/index.js.map +1 -0
  29. package/lib/commonjs/index.native.js +74 -0
  30. package/lib/commonjs/index.native.js.map +1 -0
  31. package/lib/commonjs/index.web.js +75 -0
  32. package/lib/commonjs/index.web.js.map +1 -0
  33. package/lib/commonjs/types/react-native-fs.d.js +2 -0
  34. package/lib/commonjs/types/react-native-fs.d.js.map +1 -0
  35. package/lib/module/adapters/memoryAdapter.js +261 -0
  36. package/lib/module/adapters/memoryAdapter.js.map +1 -0
  37. package/lib/module/adapters/rnfsAdapter.js +251 -0
  38. package/lib/module/adapters/rnfsAdapter.js.map +1 -0
  39. package/lib/module/adapters/webAdapter.js +426 -0
  40. package/lib/module/adapters/webAdapter.js.map +1 -0
  41. package/lib/module/core/adapter.js +2 -0
  42. package/lib/module/core/adapter.js.map +1 -0
  43. package/lib/module/core/cacheEngine.js +571 -0
  44. package/lib/module/core/cacheEngine.js.map +1 -0
  45. package/lib/module/core/errors.js +71 -0
  46. package/lib/module/core/errors.js.map +1 -0
  47. package/lib/module/core/hash.js +76 -0
  48. package/lib/module/core/hash.js.map +1 -0
  49. package/lib/module/core/indexStore.js +168 -0
  50. package/lib/module/core/indexStore.js.map +1 -0
  51. package/lib/module/core/mutex.js +135 -0
  52. package/lib/module/core/mutex.js.map +1 -0
  53. package/lib/module/core/prune.js +116 -0
  54. package/lib/module/core/prune.js.map +1 -0
  55. package/lib/module/core/types.js +2 -0
  56. package/lib/module/core/types.js.map +1 -0
  57. package/lib/module/factory.js +49 -0
  58. package/lib/module/factory.js.map +1 -0
  59. package/lib/module/index.js +41 -0
  60. package/lib/module/index.js.map +1 -0
  61. package/lib/module/index.native.js +54 -0
  62. package/lib/module/index.native.js.map +1 -0
  63. package/lib/module/index.web.js +55 -0
  64. package/lib/module/index.web.js.map +1 -0
  65. package/lib/module/types/react-native-fs.d.js +2 -0
  66. package/lib/module/types/react-native-fs.d.js.map +1 -0
  67. package/lib/typescript/src/adapters/memoryAdapter.d.ts +23 -0
  68. package/lib/typescript/src/adapters/memoryAdapter.d.ts.map +1 -0
  69. package/lib/typescript/src/adapters/rnfsAdapter.d.ts +18 -0
  70. package/lib/typescript/src/adapters/rnfsAdapter.d.ts.map +1 -0
  71. package/lib/typescript/src/adapters/webAdapter.d.ts +30 -0
  72. package/lib/typescript/src/adapters/webAdapter.d.ts.map +1 -0
  73. package/lib/typescript/src/core/adapter.d.ts +105 -0
  74. package/lib/typescript/src/core/adapter.d.ts.map +1 -0
  75. package/lib/typescript/src/core/cacheEngine.d.ts +99 -0
  76. package/lib/typescript/src/core/cacheEngine.d.ts.map +1 -0
  77. package/lib/typescript/src/core/errors.d.ts +54 -0
  78. package/lib/typescript/src/core/errors.d.ts.map +1 -0
  79. package/lib/typescript/src/core/hash.d.ts +20 -0
  80. package/lib/typescript/src/core/hash.d.ts.map +1 -0
  81. package/lib/typescript/src/core/indexStore.d.ts +34 -0
  82. package/lib/typescript/src/core/indexStore.d.ts.map +1 -0
  83. package/lib/typescript/src/core/mutex.d.ts +49 -0
  84. package/lib/typescript/src/core/mutex.d.ts.map +1 -0
  85. package/lib/typescript/src/core/prune.d.ts +39 -0
  86. package/lib/typescript/src/core/prune.d.ts.map +1 -0
  87. package/lib/typescript/src/core/types.d.ts +109 -0
  88. package/lib/typescript/src/core/types.d.ts.map +1 -0
  89. package/lib/typescript/src/factory.d.ts +46 -0
  90. package/lib/typescript/src/factory.d.ts.map +1 -0
  91. package/lib/typescript/src/index.d.ts +20 -0
  92. package/lib/typescript/src/index.d.ts.map +1 -0
  93. package/lib/typescript/src/index.native.d.ts +37 -0
  94. package/lib/typescript/src/index.native.d.ts.map +1 -0
  95. package/lib/typescript/src/index.web.d.ts +38 -0
  96. package/lib/typescript/src/index.web.d.ts.map +1 -0
  97. package/package.json +125 -0
  98. package/src/adapters/memoryAdapter.ts +307 -0
  99. package/src/adapters/rnfsAdapter.ts +283 -0
  100. package/src/adapters/webAdapter.ts +480 -0
  101. package/src/core/adapter.ts +128 -0
  102. package/src/core/cacheEngine.ts +634 -0
  103. package/src/core/errors.ts +82 -0
  104. package/src/core/hash.ts +78 -0
  105. package/src/core/indexStore.ts +184 -0
  106. package/src/core/mutex.ts +134 -0
  107. package/src/core/prune.ts +145 -0
  108. package/src/core/types.ts +165 -0
  109. package/src/factory.ts +60 -0
  110. package/src/index.native.ts +58 -0
  111. package/src/index.ts +82 -0
  112. package/src/index.web.ts +59 -0
  113. package/src/types/react-native-fs.d.ts +75 -0
@@ -0,0 +1,634 @@
1
+ /* eslint-disable @typescript-eslint/require-await */
2
+
3
+ import type { IStorageAdapter, TBinarySource } from "./adapter";
4
+ import type {
5
+ ICacheConfig,
6
+ ICacheEntry,
7
+ ICacheEntryMeta,
8
+ IPutOptions,
9
+ IPutResult,
10
+ IGetResult,
11
+ IListOptions,
12
+ IPruneResult,
13
+ ICacheStats,
14
+ ICacheIndex,
15
+ } from "./types";
16
+ import { IndexStore } from "./indexStore";
17
+ import { Mutex, KeyedMutex } from "./mutex";
18
+ import { hash as defaultHash } from "./hash";
19
+ import {
20
+ getExpiredEntries,
21
+ getLruPruneTargets,
22
+ removeEntriesFromIndex,
23
+ addEntryToIndex,
24
+ touchEntry,
25
+ createEmptyIndex,
26
+ } from "./prune";
27
+
28
+ const ENTRIES_DIR = "entries";
29
+ const INDEX_FILE = "index.json";
30
+
31
+ /**
32
+ * Main cache engine implementation.
33
+ * Coordinates all cache operations through the storage adapter.
34
+ */
35
+ export class CacheEngine {
36
+ private readonly _config: Required<Pick<ICacheConfig, "namespace" | "autoPruneExpired">> &
37
+ ICacheConfig;
38
+ private readonly _adapter: IStorageAdapter;
39
+ private readonly _indexStore: IndexStore;
40
+ private readonly _indexMutex = new Mutex();
41
+ private readonly _keyMutex = new KeyedMutex();
42
+ private readonly _hashFn: (input: string) => string | Promise<string>;
43
+
44
+ private _index: ICacheIndex = createEmptyIndex();
45
+ private _initialized = false;
46
+
47
+ constructor(config: ICacheConfig, adapter: IStorageAdapter) {
48
+ this._config = {
49
+ namespace: "default",
50
+ autoPruneExpired: true,
51
+ ...config,
52
+ };
53
+ this._adapter = adapter;
54
+ this._indexStore = new IndexStore(adapter, INDEX_FILE);
55
+ this._hashFn = config.hashFn ?? defaultHash;
56
+ }
57
+
58
+ /**
59
+ * Initialize cache (ensure directories, load index).
60
+ */
61
+ async init(): Promise<void> {
62
+ if (this._initialized) {
63
+ return;
64
+ }
65
+
66
+ await this._indexMutex.runExclusive(async () => {
67
+ // Ensure entries directory exists
68
+ await this._adapter.ensureDir(ENTRIES_DIR);
69
+
70
+ // Load or rebuild index
71
+ try {
72
+ this._index = await this._indexStore.load();
73
+ } catch {
74
+ // Index is corrupt, rebuild from filesystem
75
+ this._index = await this._indexStore.rebuild(ENTRIES_DIR);
76
+ }
77
+
78
+ this._initialized = true;
79
+ });
80
+ }
81
+
82
+ // ─────────────────────────────────────────────────────────────────
83
+ // Put Operations (Immutable - no overwrites)
84
+ // ─────────────────────────────────────────────────────────────────
85
+
86
+ /**
87
+ * Store content from URL.
88
+ * Returns {status: "exists"} if key already exists.
89
+ */
90
+ async putFromUrl(key: string, url: string, options?: IPutOptions): Promise<IPutResult> {
91
+ const source: TBinarySource = {
92
+ type: "url",
93
+ url,
94
+ headers: options?.headers,
95
+ };
96
+ return this._put(key, source, options);
97
+ }
98
+
99
+ /**
100
+ * Store content from local file path (native only).
101
+ */
102
+ async putFromFile(key: string, filePath: string, options?: IPutOptions): Promise<IPutResult> {
103
+ const source: TBinarySource = {
104
+ type: "file",
105
+ filePath,
106
+ };
107
+ return this._put(key, source, options);
108
+ }
109
+
110
+ /**
111
+ * Store content from Blob (web only).
112
+ */
113
+ async putFromBlob(key: string, blob: Blob, options?: IPutOptions): Promise<IPutResult> {
114
+ const source: TBinarySource = {
115
+ type: "blob",
116
+ blob,
117
+ };
118
+ return this._put(key, source, options);
119
+ }
120
+
121
+ /**
122
+ * Store content from raw bytes.
123
+ */
124
+ async putFromBytes(key: string, bytes: Uint8Array, options?: IPutOptions): Promise<IPutResult> {
125
+ const source: TBinarySource = {
126
+ type: "bytes",
127
+ bytes,
128
+ };
129
+ return this._put(key, source, options);
130
+ }
131
+
132
+ /**
133
+ * Internal put implementation.
134
+ */
135
+ private async _put(
136
+ key: string,
137
+ source: TBinarySource,
138
+ options?: IPutOptions
139
+ ): Promise<IPutResult> {
140
+ this._ensureInitialized();
141
+
142
+ // Use keyed mutex to serialize operations on the same key
143
+ return this._keyMutex.runExclusive(key, async () => {
144
+ // Check if key already exists
145
+ const existingEntry = this._index.entries[key];
146
+ if (existingEntry) {
147
+ // Check if expired
148
+ const now = Date.now();
149
+ if (existingEntry.expiresAt === undefined || existingEntry.expiresAt >= now) {
150
+ // Entry exists and is not expired, return exists
151
+ const entry = this._entryWithPath(existingEntry);
152
+ return { status: "exists" as const, entry };
153
+ }
154
+ // Entry is expired, remove it first
155
+ await this._removeEntry(key);
156
+ }
157
+
158
+ // Compute hash
159
+ const hashValue = await this._hashFn(key);
160
+
161
+ // Determine extension
162
+ const ext = options?.ext ?? this._extractExtension(source);
163
+
164
+ // Build entry path
165
+ const entryPath = `${ENTRIES_DIR}/${hashValue}${ext}`;
166
+
167
+ // Write binary content
168
+ const writeResult = await this._adapter.writeBinaryAtomic(entryPath, source, {
169
+ onProgress: options?.onProgress,
170
+ headers: options?.headers,
171
+ });
172
+
173
+ // Calculate expiration
174
+ const now = Date.now();
175
+ const ttlMs = options?.ttlMs ?? this._config.defaultTtlMs;
176
+ const expiresAt = ttlMs !== undefined ? now + ttlMs : undefined;
177
+
178
+ // Create entry metadata
179
+ const entryMeta: ICacheEntryMeta = {
180
+ key,
181
+ hash: hashValue,
182
+ ext,
183
+ sizeBytes: writeResult.sizeBytes,
184
+ contentType: writeResult.contentType,
185
+ createdAt: now,
186
+ lastAccessedAt: now,
187
+ expiresAt,
188
+ metadata: options?.metadata,
189
+ };
190
+
191
+ // Update index atomically
192
+ await this._indexMutex.runExclusive(async () => {
193
+ this._index = addEntryToIndex(this._index, entryMeta);
194
+ await this._indexStore.save(this._index);
195
+ });
196
+
197
+ // Auto-prune if configured
198
+ if (this._config.autoPruneExpired) {
199
+ // Don't await - run in background
200
+ this._autoPrune().catch(() => {
201
+ // Ignore prune errors
202
+ });
203
+ }
204
+
205
+ const entry = this._entryWithPath(entryMeta);
206
+ return { status: "created" as const, entry };
207
+ });
208
+ }
209
+
210
+ // ─────────────────────────────────────────────────────────────────
211
+ // Get Operations
212
+ // ─────────────────────────────────────────────────────────────────
213
+
214
+ /**
215
+ * Get entry by key. Returns null if not found or expired.
216
+ * Updates lastAccessedAt.
217
+ */
218
+ async get(key: string): Promise<IGetResult | null> {
219
+ this._ensureInitialized();
220
+
221
+ const entryMeta = this._index.entries[key];
222
+ if (!entryMeta) {
223
+ return null;
224
+ }
225
+
226
+ // Check expiration
227
+ const now = Date.now();
228
+ if (entryMeta.expiresAt !== undefined && entryMeta.expiresAt < now) {
229
+ // Entry is expired
230
+ return null;
231
+ }
232
+
233
+ // Update lastAccessedAt
234
+ await this._indexMutex.runExclusive(async () => {
235
+ this._index = touchEntry(this._index, key, now);
236
+ await this._indexStore.save(this._index);
237
+ });
238
+
239
+ const entry = this._entryWithPath(entryMeta);
240
+ const uri = await this._adapter.getPublicUri(entry.path);
241
+
242
+ return { entry, uri };
243
+ }
244
+
245
+ /**
246
+ * Check if key exists and is not expired.
247
+ */
248
+ async has(key: string): Promise<boolean> {
249
+ this._ensureInitialized();
250
+
251
+ const entryMeta = this._index.entries[key];
252
+ if (!entryMeta) {
253
+ return false;
254
+ }
255
+
256
+ // Check expiration
257
+ const now = Date.now();
258
+ if (entryMeta.expiresAt !== undefined && entryMeta.expiresAt < now) {
259
+ return false;
260
+ }
261
+
262
+ return true;
263
+ }
264
+
265
+ /**
266
+ * Get entry metadata without updating lastAccessedAt.
267
+ */
268
+ async peek(key: string): Promise<ICacheEntry | null> {
269
+ this._ensureInitialized();
270
+
271
+ const entryMeta = this._index.entries[key];
272
+ if (!entryMeta) {
273
+ return null;
274
+ }
275
+
276
+ // Check expiration
277
+ const now = Date.now();
278
+ if (entryMeta.expiresAt !== undefined && entryMeta.expiresAt < now) {
279
+ return null;
280
+ }
281
+
282
+ return this._entryWithPath(entryMeta);
283
+ }
284
+
285
+ // ─────────────────────────────────────────────────────────────────
286
+ // List/Query Operations
287
+ // ─────────────────────────────────────────────────────────────────
288
+
289
+ /**
290
+ * List entries with sorting, filtering, pagination.
291
+ */
292
+ async list(options?: IListOptions): Promise<ReadonlyArray<ICacheEntry>> {
293
+ this._ensureInitialized();
294
+
295
+ const now = Date.now();
296
+ let entries = Object.values(this._index.entries)
297
+ // Filter out expired entries
298
+ .filter((e) => e.expiresAt === undefined || e.expiresAt >= now)
299
+ // Apply custom filter if provided
300
+ .filter((e) => (options?.filter ? options.filter(e) : true));
301
+
302
+ // Sort
303
+ const sortBy = options?.sortBy ?? "createdAt";
304
+ const order = options?.order ?? "desc";
305
+ const multiplier = order === "desc" ? -1 : 1;
306
+
307
+ entries.sort((a, b) => {
308
+ let comparison = 0;
309
+ switch (sortBy) {
310
+ case "createdAt":
311
+ comparison = a.createdAt - b.createdAt;
312
+ break;
313
+ case "lastAccessedAt":
314
+ comparison = a.lastAccessedAt - b.lastAccessedAt;
315
+ break;
316
+ case "sizeBytes":
317
+ comparison = a.sizeBytes - b.sizeBytes;
318
+ break;
319
+ case "key":
320
+ comparison = a.key.localeCompare(b.key);
321
+ break;
322
+ }
323
+ return comparison * multiplier;
324
+ });
325
+
326
+ // Pagination
327
+ if (options?.offset !== undefined) {
328
+ entries = entries.slice(options.offset);
329
+ }
330
+ if (options?.limit !== undefined) {
331
+ entries = entries.slice(0, options.limit);
332
+ }
333
+
334
+ return entries.map((e) => this._entryWithPath(e));
335
+ }
336
+
337
+ /**
338
+ * Get cache statistics.
339
+ */
340
+ async stats(): Promise<ICacheStats> {
341
+ this._ensureInitialized();
342
+
343
+ const now = Date.now();
344
+ const validEntries = Object.values(this._index.entries).filter(
345
+ (e) => e.expiresAt === undefined || e.expiresAt >= now
346
+ );
347
+
348
+ if (validEntries.length === 0) {
349
+ return {
350
+ entryCount: 0,
351
+ totalSizeBytes: 0,
352
+ };
353
+ }
354
+
355
+ // Find oldest and newest by createdAt
356
+ let oldest = validEntries[0];
357
+ let newest = validEntries[0];
358
+
359
+ for (const entry of validEntries) {
360
+ if (entry.createdAt < oldest.createdAt) {
361
+ oldest = entry;
362
+ }
363
+ if (entry.createdAt > newest.createdAt) {
364
+ newest = entry;
365
+ }
366
+ }
367
+
368
+ const totalSizeBytes = validEntries.reduce((sum, e) => sum + e.sizeBytes, 0);
369
+
370
+ return {
371
+ entryCount: validEntries.length,
372
+ totalSizeBytes,
373
+ oldestEntry: oldest,
374
+ newestEntry: newest,
375
+ };
376
+ }
377
+
378
+ // ─────────────────────────────────────────────────────────────────
379
+ // Remove/Prune Operations
380
+ // ─────────────────────────────────────────────────────────────────
381
+
382
+ /**
383
+ * Remove specific entry by key.
384
+ */
385
+ async remove(key: string): Promise<boolean> {
386
+ this._ensureInitialized();
387
+
388
+ return this._keyMutex.runExclusive(key, async () => {
389
+ return this._removeEntry(key);
390
+ });
391
+ }
392
+
393
+ /**
394
+ * Internal remove implementation (must be called within keyed mutex).
395
+ */
396
+ private async _removeEntry(key: string): Promise<boolean> {
397
+ const entryMeta = this._index.entries[key];
398
+ if (!entryMeta) {
399
+ return false;
400
+ }
401
+
402
+ const entryPath = `${ENTRIES_DIR}/${entryMeta.hash}${entryMeta.ext}`;
403
+
404
+ // Remove file
405
+ await this._adapter.remove(entryPath);
406
+
407
+ // Update index
408
+ await this._indexMutex.runExclusive(async () => {
409
+ this._index = removeEntriesFromIndex(this._index, [key]);
410
+ await this._indexStore.save(this._index);
411
+ });
412
+
413
+ return true;
414
+ }
415
+
416
+ /**
417
+ * Remove all expired entries.
418
+ */
419
+ async removeExpired(): Promise<IPruneResult> {
420
+ this._ensureInitialized();
421
+
422
+ const now = Date.now();
423
+ const expired = getExpiredEntries(this._index, now);
424
+
425
+ if (expired.length === 0) {
426
+ return {
427
+ removedCount: 0,
428
+ freedBytes: 0,
429
+ removedKeys: [],
430
+ };
431
+ }
432
+
433
+ return this._removeEntries(expired);
434
+ }
435
+
436
+ /**
437
+ * Prune to fit within size limit using LRU.
438
+ */
439
+ async pruneLru(maxSizeBytes: number): Promise<IPruneResult> {
440
+ this._ensureInitialized();
441
+
442
+ const targets = getLruPruneTargets(this._index, maxSizeBytes);
443
+
444
+ if (targets.length === 0) {
445
+ return {
446
+ removedCount: 0,
447
+ freedBytes: 0,
448
+ removedKeys: [],
449
+ };
450
+ }
451
+
452
+ return this._removeEntries(targets);
453
+ }
454
+
455
+ /**
456
+ * Remove multiple entries.
457
+ */
458
+ private async _removeEntries(entries: ReadonlyArray<ICacheEntryMeta>): Promise<IPruneResult> {
459
+ const removedKeys: string[] = [];
460
+ let freedBytes = 0;
461
+
462
+ for (const entry of entries) {
463
+ const entryPath = `${ENTRIES_DIR}/${entry.hash}${entry.ext}`;
464
+ try {
465
+ await this._adapter.remove(entryPath);
466
+ removedKeys.push(entry.key);
467
+ freedBytes += entry.sizeBytes;
468
+ } catch {
469
+ // Ignore removal errors
470
+ }
471
+ }
472
+
473
+ // Update index atomically
474
+ if (removedKeys.length > 0) {
475
+ await this._indexMutex.runExclusive(async () => {
476
+ this._index = removeEntriesFromIndex(this._index, removedKeys);
477
+ await this._indexStore.save(this._index);
478
+ });
479
+ }
480
+
481
+ return {
482
+ removedCount: removedKeys.length,
483
+ freedBytes,
484
+ removedKeys,
485
+ };
486
+ }
487
+
488
+ /**
489
+ * Clear all entries.
490
+ */
491
+ async clear(): Promise<void> {
492
+ this._ensureInitialized();
493
+
494
+ await this._indexMutex.runExclusive(async () => {
495
+ // Remove all entry files
496
+ try {
497
+ await this._adapter.removeDir(ENTRIES_DIR);
498
+ } catch {
499
+ // Ignore errors
500
+ }
501
+
502
+ // Recreate entries directory
503
+ await this._adapter.ensureDir(ENTRIES_DIR);
504
+
505
+ // Clear index
506
+ this._index = createEmptyIndex();
507
+ await this._indexStore.save(this._index);
508
+ });
509
+ }
510
+
511
+ // ─────────────────────────────────────────────────────────────────
512
+ // Maintenance
513
+ // ─────────────────────────────────────────────────────────────────
514
+
515
+ /**
516
+ * Validate index against filesystem and rebuild if needed.
517
+ */
518
+ async validateAndRepair(): Promise<{ repaired: boolean; issues: string[] }> {
519
+ this._ensureInitialized();
520
+
521
+ const issues: string[] = [];
522
+
523
+ await this._indexMutex.runExclusive(async () => {
524
+ // Check each entry in index exists on filesystem
525
+ for (const entry of Object.values(this._index.entries)) {
526
+ const entryPath = `${ENTRIES_DIR}/${entry.hash}${entry.ext}`;
527
+ const exists = await this._adapter.exists(entryPath);
528
+ if (!exists) {
529
+ issues.push(`Missing file for entry "${entry.key}"`);
530
+ }
531
+ }
532
+
533
+ // Check for orphan files not in index
534
+ try {
535
+ const files = await this._adapter.listDir(ENTRIES_DIR);
536
+ const indexedHashes = new Set(
537
+ Object.values(this._index.entries).map((e) => `${e.hash}${e.ext}`)
538
+ );
539
+
540
+ for (const file of files) {
541
+ if (!indexedHashes.has(file)) {
542
+ issues.push(`Orphan file "${file}" not in index`);
543
+ }
544
+ }
545
+ } catch {
546
+ issues.push("Could not list entries directory");
547
+ }
548
+ });
549
+
550
+ // If there are issues, rebuild index
551
+ if (issues.length > 0) {
552
+ await this._indexMutex.runExclusive(async () => {
553
+ this._index = await this._indexStore.rebuild(ENTRIES_DIR);
554
+ });
555
+ }
556
+
557
+ return {
558
+ repaired: issues.length > 0,
559
+ issues,
560
+ };
561
+ }
562
+
563
+ // ─────────────────────────────────────────────────────────────────
564
+ // Private Helpers
565
+ // ─────────────────────────────────────────────────────────────────
566
+
567
+ private _ensureInitialized(): void {
568
+ if (!this._initialized) {
569
+ throw new Error("CacheEngine not initialized. Call init() first.");
570
+ }
571
+ }
572
+
573
+ private _entryWithPath(meta: ICacheEntryMeta): ICacheEntry {
574
+ return {
575
+ ...meta,
576
+ path: `${ENTRIES_DIR}/${meta.hash}${meta.ext}`,
577
+ };
578
+ }
579
+
580
+ private _extractExtension(source: TBinarySource): string {
581
+ switch (source.type) {
582
+ case "url": {
583
+ const url = new URL(source.url);
584
+ const pathname = url.pathname;
585
+ const lastDot = pathname.lastIndexOf(".");
586
+ if (lastDot > 0 && lastDot > pathname.lastIndexOf("/")) {
587
+ return pathname.substring(lastDot);
588
+ }
589
+ return "";
590
+ }
591
+ case "file": {
592
+ const lastDot = source.filePath.lastIndexOf(".");
593
+ const lastSlash = Math.max(
594
+ source.filePath.lastIndexOf("/"),
595
+ source.filePath.lastIndexOf("\\")
596
+ );
597
+ if (lastDot > 0 && lastDot > lastSlash) {
598
+ return source.filePath.substring(lastDot);
599
+ }
600
+ return "";
601
+ }
602
+ case "blob": {
603
+ // Try to extract from MIME type
604
+ 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 "";
619
+ }
620
+ case "bytes":
621
+ return "";
622
+ }
623
+ }
624
+
625
+ private async _autoPrune(): Promise<void> {
626
+ // Remove expired entries
627
+ await this.removeExpired();
628
+
629
+ // LRU prune if max size configured
630
+ if (this._config.maxSizeBytes !== undefined) {
631
+ await this.pruneLru(this._config.maxSizeBytes);
632
+ }
633
+ }
634
+ }
@@ -0,0 +1,82 @@
1
+ import type { TBinarySource } from "./adapter";
2
+
3
+ /**
4
+ * Base error class for cache operations.
5
+ */
6
+ export abstract class CacheError extends Error {
7
+ abstract readonly code: string;
8
+
9
+ constructor(message: string) {
10
+ super(message);
11
+ this.name = this.constructor.name;
12
+ Object.setPrototypeOf(this, new.target.prototype);
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Thrown when an adapter receives a TBinarySource type it cannot handle.
18
+ */
19
+ export class UnsupportedSourceError extends CacheError {
20
+ readonly code = "UNSUPPORTED_SOURCE" as const;
21
+
22
+ constructor(
23
+ public readonly sourceType: TBinarySource["type"],
24
+ public readonly adapterKind: string
25
+ ) {
26
+ super(`Adapter "${adapterKind}" does not support source type "${sourceType}"`);
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Thrown when an adapter I/O operation fails.
32
+ */
33
+ export class AdapterIOError extends CacheError {
34
+ readonly code = "ADAPTER_IO_ERROR" as const;
35
+
36
+ constructor(
37
+ public readonly operation: string,
38
+ public readonly path: string,
39
+ public readonly cause?: Error
40
+ ) {
41
+ super(
42
+ `Adapter I/O error during "${operation}" at path "${path}": ${cause?.message ?? "unknown"}`
43
+ );
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Thrown when the cache index is corrupt and cannot be parsed.
49
+ */
50
+ export class CorruptIndexError extends CacheError {
51
+ readonly code = "CORRUPT_INDEX" as const;
52
+
53
+ constructor(
54
+ public readonly reason: string,
55
+ public readonly cause?: Error
56
+ ) {
57
+ super(`Cache index is corrupt: ${reason}`);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Internal error when attempting to overwrite an immutable entry.
63
+ * Public API converts this to {status: "exists"} response.
64
+ */
65
+ export class ImmutableConflictError extends CacheError {
66
+ readonly code = "IMMUTABLE_CONFLICT" as const;
67
+
68
+ constructor(public readonly key: string) {
69
+ super(`Cannot overwrite immutable entry with key "${key}"`);
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Thrown when a required entry is not found.
75
+ */
76
+ export class EntryNotFoundError extends CacheError {
77
+ readonly code = "ENTRY_NOT_FOUND" as const;
78
+
79
+ constructor(public readonly key: string) {
80
+ super(`Cache entry not found for key "${key}"`);
81
+ }
82
+ }