@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.
- package/README.md +183 -261
- package/lib/commonjs/adapters/memoryAdapter.js +1 -0
- package/lib/commonjs/adapters/memoryAdapter.js.map +1 -1
- package/lib/commonjs/adapters/rnfsAdapter.js +9 -4
- package/lib/commonjs/adapters/rnfsAdapter.js.map +1 -1
- package/lib/commonjs/adapters/webAdapter.js +1 -0
- package/lib/commonjs/adapters/webAdapter.js.map +1 -1
- package/lib/commonjs/core/adapter.js +54 -0
- package/lib/commonjs/core/adapter.js.map +1 -1
- package/lib/commonjs/core/cacheEngine.js +452 -59
- package/lib/commonjs/core/cacheEngine.js.map +1 -1
- package/lib/commonjs/core/errors.js +9 -6
- package/lib/commonjs/core/errors.js.map +1 -1
- package/lib/commonjs/core/hash.js +3 -3
- package/lib/commonjs/core/hash.js.map +1 -1
- package/lib/commonjs/core/indexStore.js +85 -8
- package/lib/commonjs/core/indexStore.js.map +1 -1
- package/lib/commonjs/core/prune.js +42 -11
- package/lib/commonjs/core/prune.js.map +1 -1
- package/lib/commonjs/core/types.js +132 -0
- package/lib/commonjs/core/types.js.map +1 -1
- package/lib/commonjs/index.js +33 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/module/adapters/memoryAdapter.js +1 -0
- package/lib/module/adapters/memoryAdapter.js.map +1 -1
- package/lib/module/adapters/rnfsAdapter.js +9 -4
- package/lib/module/adapters/rnfsAdapter.js.map +1 -1
- package/lib/module/adapters/webAdapter.js +1 -0
- package/lib/module/adapters/webAdapter.js.map +1 -1
- package/lib/module/core/adapter.js +48 -0
- package/lib/module/core/adapter.js.map +1 -1
- package/lib/module/core/cacheEngine.js +453 -60
- package/lib/module/core/cacheEngine.js.map +1 -1
- package/lib/module/core/errors.js +9 -6
- package/lib/module/core/errors.js.map +1 -1
- package/lib/module/core/hash.js +3 -3
- package/lib/module/core/hash.js.map +1 -1
- package/lib/module/core/indexStore.js +86 -8
- package/lib/module/core/indexStore.js.map +1 -1
- package/lib/module/core/prune.js +40 -11
- package/lib/module/core/prune.js.map +1 -1
- package/lib/module/core/types.js +130 -1
- package/lib/module/core/types.js.map +1 -1
- package/lib/module/index.js +4 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/adapters/memoryAdapter.d.ts.map +1 -1
- package/lib/typescript/src/adapters/rnfsAdapter.d.ts.map +1 -1
- package/lib/typescript/src/adapters/webAdapter.d.ts.map +1 -1
- package/lib/typescript/src/core/adapter.d.ts +16 -0
- package/lib/typescript/src/core/adapter.d.ts.map +1 -1
- package/lib/typescript/src/core/cacheEngine.d.ts +120 -1
- package/lib/typescript/src/core/cacheEngine.d.ts.map +1 -1
- package/lib/typescript/src/core/errors.d.ts +6 -5
- package/lib/typescript/src/core/errors.d.ts.map +1 -1
- package/lib/typescript/src/core/indexStore.d.ts +7 -0
- package/lib/typescript/src/core/indexStore.d.ts.map +1 -1
- package/lib/typescript/src/core/prune.d.ts +22 -8
- package/lib/typescript/src/core/prune.d.ts.map +1 -1
- package/lib/typescript/src/core/types.d.ts +153 -0
- package/lib/typescript/src/core/types.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +5 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapters/memoryAdapter.ts +3 -0
- package/src/adapters/rnfsAdapter.ts +11 -4
- package/src/adapters/webAdapter.ts +1 -0
- package/src/core/adapter.ts +28 -0
- package/src/core/cacheEngine.ts +476 -62
- package/src/core/errors.ts +8 -6
- package/src/core/hash.ts +3 -3
- package/src/core/indexStore.ts +99 -11
- package/src/core/prune.ts +44 -14
- package/src/core/types.ts +194 -0
- 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
|
-
|
|
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 =
|
|
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 =
|
|
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.
|
|
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 =
|
|
204
|
-
if (
|
|
205
|
-
|
|
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.
|
|
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 =
|
|
637
|
+
const now = this._now();
|
|
268
638
|
let entries = Object.values(this._index.entries)
|
|
269
639
|
// Filter out expired entries
|
|
270
|
-
.filter(e =>
|
|
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 =
|
|
313
|
-
const validEntries = Object.values(this._index.entries).filter(e =>
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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 =
|
|
759
|
+
const now = this._now();
|
|
382
760
|
const expired = (0, _prune.getExpiredEntries)(this._index, now);
|
|
383
761
|
if (expired.length === 0) {
|
|
384
|
-
return
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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 "";
|