@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
package/src/core/cacheEngine.ts
CHANGED
|
@@ -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
|
-
|
|
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"
|
|
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 =
|
|
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 =
|
|
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.
|
|
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"
|
|
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 =
|
|
228
|
-
if (entryMeta
|
|
229
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
691
|
+
const now = this._now();
|
|
296
692
|
let entries = Object.values(this._index.entries)
|
|
297
693
|
// Filter out expired entries
|
|
298
|
-
.filter((e) => e
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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.
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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 "";
|