@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
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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.
|
|
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 =
|
|
198
|
-
if (entryMeta
|
|
199
|
-
|
|
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.
|
|
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 =
|
|
631
|
+
const now = this._now();
|
|
262
632
|
let entries = Object.values(this._index.entries)
|
|
263
633
|
// Filter out expired entries
|
|
264
|
-
.filter(e => e
|
|
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 =
|
|
307
|
-
const validEntries = Object.values(this._index.entries).filter(e => e
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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 =
|
|
753
|
+
const now = this._now();
|
|
376
754
|
const expired = getExpiredEntries(this._index, now);
|
|
377
755
|
if (expired.length === 0) {
|
|
378
|
-
return
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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 "";
|