@neezco/cache 0.1.0

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.
@@ -0,0 +1,782 @@
1
+ //#region src/cache/clear.ts
2
+ /**
3
+ * Clears all entries from the cache without invoking callbacks.
4
+ *
5
+ * @note The `onDelete` callback is NOT invoked during a clear operation.
6
+ * This is intentional to avoid unnecessary overhead when bulk-removing entries.
7
+ *
8
+ * @param state - The cache state.
9
+ * @returns void
10
+ */
11
+ const clear = (state) => {
12
+ state.store.clear();
13
+ };
14
+
15
+ //#endregion
16
+ //#region src/defaults.ts
17
+ const ONE_SECOND = 1e3;
18
+ const ONE_MINUTE = 60 * ONE_SECOND;
19
+ /**
20
+ * ===================================================================
21
+ * Cache Entry Lifecycle
22
+ * Default TTL and stale window settings for short-lived cache entries.
23
+ * ===================================================================
24
+ */
25
+ /**
26
+ * Default Time-To-Live in milliseconds for cache entries.
27
+ * Optimized for short-lived data (3 minutes by default).
28
+ * @default 1_800_000 (30 minutes)
29
+ */
30
+ const DEFAULT_TTL = 30 * ONE_MINUTE;
31
+ /**
32
+ * Default stale window in milliseconds after expiration.
33
+ * Allows serving slightly outdated data while fetching fresh data.
34
+ */
35
+ const DEFAULT_STALE_WINDOW = 0;
36
+ /**
37
+ * Maximum number of entries the cache can hold.
38
+ * Beyond this limit, less-used entries are evicted.
39
+ */
40
+ const DEFAULT_MAX_SIZE = Infinity;
41
+ /**
42
+ * ===================================================================
43
+ * Sweep & Cleanup Operations
44
+ * Parameters controlling how and when expired entries are removed.
45
+ * ===================================================================
46
+ */
47
+ /**
48
+ * Maximum number of keys to process in a single sweep batch.
49
+ * Higher values = more aggressive cleanup, lower latency overhead.
50
+ */
51
+ const MAX_KEYS_PER_BATCH = 1e3;
52
+ /**
53
+ * Minimal expired ratio enforced during sweeps.
54
+ * Ensures control sweeps run above {@link EXPIRED_RATIO_MEMORY_THRESHOLD}.
55
+ */
56
+ const MINIMAL_EXPIRED_RATIO = .05;
57
+ /**
58
+ * Maximum allowed expired ratio when memory usage is low.
59
+ * Upper bound for interpolation with MINIMAL_EXPIRED_RATIO.
60
+ * Recommended range: `0.3 – 0.5` .
61
+ */
62
+ const DEFAULT_MAX_EXPIRED_RATIO = .4;
63
+ /**
64
+ * ===================================================================
65
+ * Sweep Intervals & Timing
66
+ * Frequency and time budgets for cleanup operations.
67
+ * ===================================================================
68
+ */
69
+ /**
70
+ * Optimal interval in milliseconds between sweeps.
71
+ * Used when system load is minimal and metrics are available.
72
+ */
73
+ const OPTIMAL_SWEEP_INTERVAL = 2 * ONE_SECOND;
74
+ /**
75
+ * Optimal time budget in milliseconds for each sweep cycle.
76
+ * Used when performance metrics are not available or unreliable.
77
+ */
78
+ const OPTIMAL_SWEEP_TIME_BUDGET_IF_NOTE_METRICS_AVAILABLE = 15;
79
+
80
+ //#endregion
81
+ //#region src/utils/start-monitor.ts
82
+ function startMonitor() {}
83
+
84
+ //#endregion
85
+ //#region src/sweep/batchUpdateExpiredRatio.ts
86
+ /**
87
+ * Updates the expired ratio for each cache instance based on the collected ratios.
88
+ * @param currentExpiredRatios - An array of arrays containing expired ratios for each cache instance.
89
+ * @internal
90
+ */
91
+ function _batchUpdateExpiredRatio(currentExpiredRatios) {
92
+ for (const inst of _instancesCache) {
93
+ const ratios = currentExpiredRatios[inst._instanceIndexState];
94
+ if (ratios && ratios.length > 0) {
95
+ const avgRatio = ratios.reduce((sum, val) => sum + val, 0) / ratios.length;
96
+ const alpha = .6;
97
+ inst._expiredRatio = inst._expiredRatio * (1 - alpha) + avgRatio * alpha;
98
+ }
99
+ }
100
+ }
101
+
102
+ //#endregion
103
+ //#region src/sweep/select-instance-to-sweep.ts
104
+ /**
105
+ * Selects a cache instance to sweep based on sweep weights or round‑robin order.
106
+ *
107
+ * Two selection modes are supported:
108
+ * - **Round‑robin mode**: If `totalSweepWeight` ≤ 0, instances are selected
109
+ * deterministically in sequence using `batchSweep`. Once all instances
110
+ * have been processed, returns `null`.
111
+ * - **Weighted mode**: If sweep weights are available, performs a probabilistic
112
+ * selection. Each instance’s `_sweepWeight` contributes proportionally to its
113
+ * chance of being chosen.
114
+ *
115
+ * This function depends on `_updateWeightSweep` to maintain accurate sweep weights.
116
+ *
117
+ * @param totalSweepWeight - Sum of all sweep weights across instances.
118
+ * @param batchSweep - Current batch index used for round‑robin selection.
119
+ * @returns The selected `CacheState` instance, `null` if no instance remains,
120
+ * or `undefined` if the cache is empty.
121
+ */
122
+ function _selectInstanceToSweep({ totalSweepWeight, batchSweep }) {
123
+ let instanceToSweep = _instancesCache[0];
124
+ if (totalSweepWeight <= 0) {
125
+ if (batchSweep > _instancesCache.length) instanceToSweep = null;
126
+ instanceToSweep = _instancesCache[batchSweep - 1];
127
+ } else {
128
+ let threshold = Math.random() * totalSweepWeight;
129
+ for (const inst of _instancesCache) {
130
+ threshold -= inst._sweepWeight;
131
+ if (threshold <= 0) {
132
+ instanceToSweep = inst;
133
+ break;
134
+ }
135
+ }
136
+ }
137
+ return instanceToSweep;
138
+ }
139
+
140
+ //#endregion
141
+ //#region src/cache/delete.ts
142
+ let DELETE_REASON = /* @__PURE__ */ function(DELETE_REASON$1) {
143
+ DELETE_REASON$1["MANUAL"] = "manual";
144
+ DELETE_REASON$1["EXPIRED"] = "expired";
145
+ DELETE_REASON$1["STALE"] = "stale";
146
+ return DELETE_REASON$1;
147
+ }({});
148
+ /**
149
+ * Deletes a key from the cache.
150
+ * @param state - The cache state.
151
+ * @param key - The key.
152
+ * @returns A boolean indicating whether the key was successfully deleted.
153
+ */
154
+ const deleteKey = (state, key, reason = DELETE_REASON.MANUAL) => {
155
+ const onDelete = state.onDelete;
156
+ const onExpire = state.onExpire;
157
+ if (!onDelete && !onExpire) return state.store.delete(key);
158
+ const entry = state.store.get(key);
159
+ if (!entry) return false;
160
+ state.store.delete(key);
161
+ state.onDelete?.(key, entry[1], reason);
162
+ if (reason !== DELETE_REASON.MANUAL) state.onExpire?.(key, entry[1], reason);
163
+ return true;
164
+ };
165
+
166
+ //#endregion
167
+ //#region src/types.ts
168
+ /**
169
+ * Status of a cache entry.
170
+ */
171
+ let ENTRY_STATUS = /* @__PURE__ */ function(ENTRY_STATUS$1) {
172
+ /** The entry is fresh and valid. */
173
+ ENTRY_STATUS$1["FRESH"] = "fresh";
174
+ /** The entry is stale but can still be served. */
175
+ ENTRY_STATUS$1["STALE"] = "stale";
176
+ /** The entry has expired and is no longer valid. */
177
+ ENTRY_STATUS$1["EXPIRED"] = "expired";
178
+ return ENTRY_STATUS$1;
179
+ }({});
180
+
181
+ //#endregion
182
+ //#region src/utils/status-from-tags.ts
183
+ /**
184
+ * Computes the derived status of a cache entry based on its associated tags.
185
+ *
186
+ * Tags may impose stricter expiration or stale rules on the entry. Only tags
187
+ * created at or after the entry's creation timestamp are considered relevant.
188
+ *
189
+ * Resolution rules:
190
+ * - If any applicable tag marks the entry as expired, the status becomes `EXPIRED`.
191
+ * - Otherwise, if any applicable tag marks it as stale, the status becomes `STALE`.
192
+ * - If no tag imposes stricter rules, the entry remains `FRESH`.
193
+ *
194
+ * @param state - The cache state containing tag metadata.
195
+ * @param entry - The cache entry whose status is being evaluated.
196
+ * @returns A tuple containing:
197
+ * - The final {@link ENTRY_STATUS} imposed by tags.
198
+ * - The earliest timestamp at which a tag marked the entry as stale
199
+ * (or 0 if no tag imposed a stale rule).
200
+ */
201
+ function _statusFromTags(state, entry) {
202
+ const entryCreatedAt = entry[0][0];
203
+ let earliestTagStaleInvalidation = Infinity;
204
+ let status = ENTRY_STATUS.FRESH;
205
+ const tags = entry[2];
206
+ if (tags) for (const tag of tags) {
207
+ const ts = state._tags.get(tag);
208
+ if (!ts) continue;
209
+ const [tagExpiredAt, tagStaleSinceAt] = ts;
210
+ if (tagExpiredAt >= entryCreatedAt) {
211
+ status = ENTRY_STATUS.EXPIRED;
212
+ break;
213
+ }
214
+ if (tagStaleSinceAt >= entryCreatedAt) {
215
+ if (tagStaleSinceAt < earliestTagStaleInvalidation) earliestTagStaleInvalidation = tagStaleSinceAt;
216
+ status = ENTRY_STATUS.STALE;
217
+ }
218
+ }
219
+ return [status, status === ENTRY_STATUS.STALE ? earliestTagStaleInvalidation : 0];
220
+ }
221
+
222
+ //#endregion
223
+ //#region src/cache/validators.ts
224
+ /**
225
+ * Computes the final derived status of a cache entry by combining:
226
+ *
227
+ * - The entry's own expiration timestamps (TTL and stale TTL).
228
+ * - Any stricter expiration or stale rules imposed by its associated tags.
229
+ *
230
+ * Precedence rules:
231
+ * - `EXPIRED` overrides everything.
232
+ * - `STALE` overrides `FRESH`.
233
+ * - If neither the entry nor its tags impose stricter rules, the entry is `FRESH`.
234
+ *
235
+ * @param state - The cache state containing tag metadata.
236
+ * @param entry - The cache entry being evaluated.
237
+ * @returns The final {@link ENTRY_STATUS} for the entry.
238
+ */
239
+ function computeEntryStatus(state, entry, now) {
240
+ const [__createdAt, expiresAt, staleExpiresAt] = entry[0];
241
+ const [tagStatus, earliestTagStaleInvalidation] = _statusFromTags(state, entry);
242
+ if (tagStatus === ENTRY_STATUS.EXPIRED) return ENTRY_STATUS.EXPIRED;
243
+ const windowStale = staleExpiresAt - expiresAt;
244
+ if (tagStatus === ENTRY_STATUS.STALE && staleExpiresAt > 0 && now < earliestTagStaleInvalidation + windowStale) return ENTRY_STATUS.STALE;
245
+ if (now < expiresAt) return ENTRY_STATUS.FRESH;
246
+ if (staleExpiresAt > 0 && now < staleExpiresAt) return ENTRY_STATUS.STALE;
247
+ return ENTRY_STATUS.EXPIRED;
248
+ }
249
+ /**
250
+ * Determines whether a cache entry is fresh.
251
+ *
252
+ * A fresh entry is one whose final derived status is `FRESH`, meaning:
253
+ * - It has not expired according to its own timestamps, and
254
+ * - No associated tag imposes a stricter stale or expired rule.
255
+ *
256
+ * @param state - The cache state containing tag metadata.
257
+ * @param entry - The cache entry being evaluated.
258
+ * @returns True if the entry is fresh.
259
+ */
260
+ const isFresh = (state, entry, now) => computeEntryStatus(state, entry, now) === ENTRY_STATUS.FRESH;
261
+ /**
262
+ * Determines whether a cache entry is stale.
263
+ *
264
+ * A stale entry is one whose final derived status is `STALE`, meaning:
265
+ * - It has passed its TTL but is still within its stale window, or
266
+ * - A tag imposes a stale rule that applies to this entry.
267
+ *
268
+ * @param state - The cache state containing tag metadata.
269
+ * @param entry - The cache entry being evaluated.
270
+ * @returns True if the entry is stale.
271
+ */
272
+ const isStale = (state, entry, now) => computeEntryStatus(state, entry, now) === ENTRY_STATUS.STALE;
273
+ /**
274
+ * Determines whether a cache entry is expired.
275
+ *
276
+ * An expired entry is one whose final derived status is `EXPIRED`, meaning:
277
+ * - It has exceeded both its TTL and stale TTL, or
278
+ * - A tag imposes an expiration rule that applies to this entry.
279
+ *
280
+ * @param state - The cache state containing tag metadata.
281
+ * @param entry - The cache entry being evaluated.
282
+ * @returns True if the entry is expired.
283
+ */
284
+ const isExpired = (state, entry, now) => computeEntryStatus(state, entry, now) === ENTRY_STATUS.EXPIRED;
285
+
286
+ //#endregion
287
+ //#region src/sweep/sweep-once.ts
288
+ /**
289
+ * Performs a single sweep operation on the cache to remove expired and optionally stale entries.
290
+ * Uses a linear scan with a saved pointer to resume from the last processed key.
291
+ * @param state - The cache state.
292
+ * @param _maxKeysPerBatch - Maximum number of keys to process in this sweep.
293
+ * @returns An object containing statistics about the sweep operation.
294
+ */
295
+ function _sweepOnce(state, _maxKeysPerBatch = MAX_KEYS_PER_BATCH) {
296
+ if (!state._sweepIter) state._sweepIter = state.store.entries();
297
+ let processed = 0;
298
+ let expiredCount = 0;
299
+ let staleCount = 0;
300
+ for (let i = 0; i < _maxKeysPerBatch; i++) {
301
+ const next = state._sweepIter.next();
302
+ if (next.done) {
303
+ state._sweepIter = state.store.entries();
304
+ break;
305
+ }
306
+ processed += 1;
307
+ const [key, entry] = next.value;
308
+ const now = Date.now();
309
+ if (isExpired(state, entry, now)) {
310
+ deleteKey(state, key, DELETE_REASON.EXPIRED);
311
+ expiredCount += 1;
312
+ } else if (isStale(state, entry, now)) {
313
+ staleCount += 1;
314
+ if (state.purgeStaleOnSweep) deleteKey(state, key, DELETE_REASON.STALE);
315
+ }
316
+ }
317
+ const expiredStaleCount = state.purgeStaleOnSweep ? staleCount : 0;
318
+ return {
319
+ processed,
320
+ expiredCount,
321
+ staleCount,
322
+ ratio: processed > 0 ? (expiredCount + expiredStaleCount) / processed : 0
323
+ };
324
+ }
325
+
326
+ //#endregion
327
+ //#region src/sweep/update-weight.ts
328
+ /**
329
+ * Updates the sweep weight (`_sweepWeight`) for each cache instance.
330
+ *
331
+ * The sweep weight determines the probability that an instance will be selected
332
+ * for a cleanup (sweep) process. It is calculated based on the store size and
333
+ * the ratio of expired keys.
334
+ *
335
+ * This function complements (`_selectInstanceToSweep`), which is responsible
336
+ * for selecting the correct instance based on the weights assigned here.
337
+ *
338
+ * ---
339
+ *
340
+ * ### Sweep systems:
341
+ * 1. **Normal sweep**
342
+ * - Runs whenever the percentage of expired keys exceeds the allowed threshold
343
+ * calculated by `calculateOptimalMaxExpiredRatio`.
344
+ * - It is the main cleanup mechanism and is applied proportionally to the
345
+ * store size and the expired‑key ratio.
346
+ *
347
+ * 2. **Memory‑conditioned sweep (control)**
348
+ * - Works exactly like the normal sweep, except it may run even when it
349
+ * normally wouldn’t.
350
+ * - Only activates under **high memory pressure**.
351
+ * - Serves as an additional control mechanism to adjust weights, keep the
352
+ * system updated, and help prevent memory overflows.
353
+ *
354
+ * 3. **Round‑robin sweep (minimal control)**
355
+ * - Always runs, even if the expired ratio is low or memory usage does not
356
+ * require it.
357
+ * - Processes a very small number of keys per instance, much smaller than
358
+ * the normal sweep.
359
+ * - Its main purpose is to ensure that all instances receive at least a
360
+ * periodic weight update and minimal expired‑key control.
361
+ *
362
+ * ---
363
+ * #### Important notes:
364
+ * - A minimum `MINIMAL_EXPIRED_RATIO` (e.g., 5%) is assumed to ensure that
365
+ * control sweeps can always run under high‑memory scenarios.
366
+ * - Even with a minimum ratio, the normal sweep and the memory‑conditioned sweep
367
+ * may **skip execution** if memory usage allows it and the expired ratio is
368
+ * below the optimal maximum.
369
+ * - The round‑robin sweep is never skipped: it always runs with a very small,
370
+ * almost imperceptible cost.
371
+ *
372
+ * @returns The total accumulated sweep weight across all cache instances.
373
+ */
374
+ function _updateWeightSweep() {
375
+ let totalSweepWeight = 0;
376
+ for (const instCache of _instancesCache) {
377
+ if (instCache.store.size <= 0) {
378
+ instCache._sweepWeight = 0;
379
+ continue;
380
+ }
381
+ let expiredRatio = MINIMAL_EXPIRED_RATIO;
382
+ if (instCache._expiredRatio > MINIMAL_EXPIRED_RATIO) expiredRatio = instCache._expiredRatio;
383
+ instCache._sweepWeight = instCache.store.size * expiredRatio;
384
+ totalSweepWeight += instCache._sweepWeight;
385
+ }
386
+ return totalSweepWeight;
387
+ }
388
+
389
+ //#endregion
390
+ //#region src/sweep/sweep.ts
391
+ /**
392
+ * Performs a sweep operation on the cache to remove expired and optionally stale entries.
393
+ * Uses a linear scan with a saved pointer to resume from the last processed key.
394
+ * @param state - The cache state.
395
+ */
396
+ const sweep = async (state, utilities = {}) => {
397
+ const { schedule = defaultSchedule, yieldFn = defaultYieldFn, now = Date.now(), runOnlyOne = false } = utilities;
398
+ const startTime = now;
399
+ let sweepIntervalMs = OPTIMAL_SWEEP_INTERVAL;
400
+ let sweepTimeBudgetMs = OPTIMAL_SWEEP_TIME_BUDGET_IF_NOTE_METRICS_AVAILABLE;
401
+ const totalSweepWeight = _updateWeightSweep();
402
+ const currentExpiredRatios = [];
403
+ const maxKeysPerBatch = totalSweepWeight <= 0 ? MAX_KEYS_PER_BATCH / _instancesCache.length : MAX_KEYS_PER_BATCH;
404
+ let batchSweep = 0;
405
+ while (true) {
406
+ batchSweep += 1;
407
+ const instanceToSweep = _selectInstanceToSweep({
408
+ batchSweep,
409
+ totalSweepWeight
410
+ });
411
+ if (!instanceToSweep) break;
412
+ const { ratio } = _sweepOnce(instanceToSweep, maxKeysPerBatch);
413
+ (currentExpiredRatios[instanceToSweep._instanceIndexState] ??= []).push(ratio);
414
+ if (Date.now() - startTime > sweepTimeBudgetMs) break;
415
+ await yieldFn();
416
+ }
417
+ _batchUpdateExpiredRatio(currentExpiredRatios);
418
+ if (!runOnlyOne) schedule(() => void sweep(state, utilities), sweepIntervalMs);
419
+ };
420
+ const defaultSchedule = (fn, ms) => {
421
+ const t = setTimeout(fn, ms);
422
+ if (typeof t.unref === "function") t.unref();
423
+ };
424
+ const defaultYieldFn = () => new Promise((resolve) => setImmediate(resolve));
425
+
426
+ //#endregion
427
+ //#region src/cache/create-cache.ts
428
+ let _instanceCount = 0;
429
+ const INSTANCE_WARNING_THRESHOLD = 99;
430
+ const _instancesCache = [];
431
+ let _initSweepScheduled = false;
432
+ /**
433
+ * Creates the initial state for the TTL cache.
434
+ * @param options - Configuration options for the cache.
435
+ * @returns The initial cache state.
436
+ */
437
+ const createCache = (options = {}) => {
438
+ const { onExpire, onDelete, defaultTtl = DEFAULT_TTL, maxSize = DEFAULT_MAX_SIZE, _maxAllowExpiredRatio = DEFAULT_MAX_EXPIRED_RATIO, defaultStaleWindow = DEFAULT_STALE_WINDOW, purgeStaleOnGet = false, purgeStaleOnSweep = false, _autoStartSweep = true } = options;
439
+ _instanceCount++;
440
+ if (_instanceCount > INSTANCE_WARNING_THRESHOLD) console.warn(`Too many instances detected (${_instanceCount}). This may indicate a configuration issue; consider minimizing instance creation or grouping keys by expected expiration ranges. See the documentation: https://github.com/neezco/cache/docs/getting-started.md`);
441
+ const state = {
442
+ store: /* @__PURE__ */ new Map(),
443
+ _sweepIter: null,
444
+ get size() {
445
+ return state.store.size;
446
+ },
447
+ onExpire,
448
+ onDelete,
449
+ maxSize,
450
+ defaultTtl,
451
+ defaultStaleWindow,
452
+ purgeStaleOnGet,
453
+ purgeStaleOnSweep,
454
+ _maxAllowExpiredRatio,
455
+ _autoStartSweep,
456
+ _instanceIndexState: -1,
457
+ _expiredRatio: 0,
458
+ _sweepWeight: 0,
459
+ _tags: /* @__PURE__ */ new Map()
460
+ };
461
+ state._instanceIndexState = _instancesCache.push(state) - 1;
462
+ if (_autoStartSweep) {
463
+ if (_initSweepScheduled) return state;
464
+ _initSweepScheduled = true;
465
+ sweep(state);
466
+ }
467
+ /* @__PURE__ */ startMonitor();
468
+ return state;
469
+ };
470
+
471
+ //#endregion
472
+ //#region src/cache/get.ts
473
+ /**
474
+ * Retrieves a value from the cache if the entry is valid.
475
+ * @param state - The cache state.
476
+ * @param key - The key to retrieve.
477
+ * @param now - Optional timestamp override (defaults to Date.now()).
478
+ * @returns The cached value if valid, null otherwise.
479
+ */
480
+ const get = (state, key, now = Date.now()) => {
481
+ const entry = state.store.get(key);
482
+ if (!entry) return void 0;
483
+ if (isFresh(state, entry, now)) return entry[1];
484
+ if (isStale(state, entry, now)) {
485
+ if (state.purgeStaleOnGet) deleteKey(state, key, DELETE_REASON.STALE);
486
+ return entry[1];
487
+ }
488
+ deleteKey(state, key, DELETE_REASON.EXPIRED);
489
+ };
490
+
491
+ //#endregion
492
+ //#region src/cache/has.ts
493
+ /**
494
+ * Checks if a key exists in the cache and is not expired.
495
+ * @param state - The cache state.
496
+ * @param key - The key to check.
497
+ * @param now - Optional timestamp override (defaults to Date.now()).
498
+ * @returns True if the key exists and is valid, false otherwise.
499
+ */
500
+ const has = (state, key, now = Date.now()) => {
501
+ return get(state, key, now) !== void 0;
502
+ };
503
+
504
+ //#endregion
505
+ //#region src/cache/invalidate-tag.ts
506
+ /**
507
+ * Invalidates one or more tags so that entries associated with them
508
+ * become expired or stale from this moment onward.
509
+ *
510
+ * Semantics:
511
+ * - Each tag maintains two timestamps in `state._tags`:
512
+ * [expiredAt, staleSinceAt].
513
+ * - Calling this function updates one of those timestamps to `_now`,
514
+ * depending on whether the tag should force expiration or staleness.
515
+ *
516
+ * Rules:
517
+ * - If `asStale` is false (default), the tag forces expiration:
518
+ * entries created before `_now` will be considered expired.
519
+ * - If `asStale` is true, the tag forces staleness:
520
+ * entries created before `_now` will be considered stale,
521
+ * but only if they support a stale window.
522
+ *
523
+ * Behavior:
524
+ * - Each call replaces any previous invalidation timestamp for the tag.
525
+ * - Entries created after `_now` are unaffected.
526
+ *
527
+ * @param state - The cache state containing tag metadata.
528
+ * @param tags - A tag or list of tags to invalidate.
529
+ * @param options.asStale - Whether the tag should mark entries as stale.
530
+ */
531
+ function invalidateTag(state, tags, options = {}, _now = Date.now()) {
532
+ const tagList = Array.isArray(tags) ? tags : [tags];
533
+ const asStale = options.asStale ?? false;
534
+ for (const tag of tagList) {
535
+ const currentTag = state._tags.get(tag);
536
+ if (currentTag) if (asStale) currentTag[1] = _now;
537
+ else currentTag[0] = _now;
538
+ else state._tags.set(tag, [asStale ? 0 : _now, asStale ? _now : 0]);
539
+ }
540
+ }
541
+
542
+ //#endregion
543
+ //#region src/cache/set.ts
544
+ /**
545
+ * Sets or updates a value in the cache with TTL and an optional stale window.
546
+ *
547
+ * @param state - The cache state.
548
+ * @param input - Cache entry definition (key, value, ttl, staleWindow, tags).
549
+ * @param now - Optional timestamp override used as the base time (defaults to Date.now()).
550
+ *
551
+ * @remarks
552
+ * - `ttl` defines when the entry becomes expired.
553
+ * - `staleWindow` defines how long the entry may still be served as stale
554
+ * after the expiration moment (`now + ttl`).
555
+ */
556
+ const setOrUpdate = (state, input, now = Date.now()) => {
557
+ const { key, value, ttl: ttlInput, staleWindow: staleWindowInput, tags } = input;
558
+ if (value === void 0) return;
559
+ if (key == null) throw new Error("Missing key.");
560
+ const ttl = ttlInput ?? state.defaultTtl;
561
+ const staleWindow = staleWindowInput ?? state.defaultStaleWindow;
562
+ const expiresAt = ttl > 0 ? now + ttl : Infinity;
563
+ const entry = [
564
+ [
565
+ now,
566
+ expiresAt,
567
+ staleWindow > 0 ? expiresAt + staleWindow : 0
568
+ ],
569
+ value,
570
+ typeof tags === "string" ? [tags] : Array.isArray(tags) ? tags : null
571
+ ];
572
+ state.store.set(key, entry);
573
+ };
574
+
575
+ //#endregion
576
+ //#region src/index.ts
577
+ /**
578
+ * A TTL (Time-To-Live) cache implementation with support for expiration,
579
+ * stale windows, tag-based invalidation, and automatic sweeping.
580
+ *
581
+ * Provides O(1) constant-time operations for all core methods.
582
+ *
583
+ * @example
584
+ * ```typescript
585
+ * const cache = new LocalTtlCache();
586
+ * cache.set("user:123", { name: "Alice" }, { ttl: 5 * 60 * 1000 });
587
+ * const user = cache.get("user:123"); // { name: "Alice" }
588
+ * ```
589
+ */
590
+ var LocalTtlCache = class {
591
+ state;
592
+ /**
593
+ * Creates a new cache instance.
594
+ *
595
+ * @param options - Configuration options for the cache (defaultTtl, defaultStaleWindow, maxSize, etc.)
596
+ *
597
+ * @example
598
+ * ```typescript
599
+ * const cache = new LocalTtlCache({
600
+ * defaultTtl: 30 * 60 * 1000, // 30 minutes
601
+ * defaultStaleWindow: 5 * 60 * 1000, // 5 minutes
602
+ * maxSize: 500_000, // Maximum 500_000 entries
603
+ * onExpire: (key, value) => console.log(`Expired: ${key}`),
604
+ * onDelete: (key, value, reason) => console.log(`Deleted: ${key}, reason: ${reason}`),
605
+ * });
606
+ * ```
607
+ */
608
+ constructor(options) {
609
+ this.state = createCache(options);
610
+ }
611
+ /**
612
+ * Gets the current number of entries tracked by the cache.
613
+ *
614
+ * This value may include entries that are already expired but have not yet been
615
+ * removed by the lazy cleanup system. Expired keys are cleaned only when it is
616
+ * efficient to do so, so the count can temporarily be higher than the number of
617
+ * actually valid (non‑expired) entries.
618
+ *
619
+ * @returns The number of entries currently stored (including entries pending cleanup)
620
+ *
621
+ * @example
622
+ * ```typescript
623
+ * console.log(cache.size); // e.g., 42
624
+ * ```
625
+ */
626
+ get size() {
627
+ return this.state.size;
628
+ }
629
+ /**
630
+ * Retrieves a value from the cache.
631
+ *
632
+ * Returns the value if it exists and is not fully expired. If an entry is in the
633
+ * stale window (expired but still within staleWindow), the stale value is returned.
634
+ *
635
+
636
+ * @param key - The key to retrieve
637
+ * @returns The cached value if valid, undefined otherwise
638
+ *
639
+ * @example
640
+ * ```typescript
641
+ * const user = cache.get<{ name: string }>("user:123");
642
+ * ```
643
+ *
644
+ * @edge-cases
645
+ * - Returns `undefined` if the key doesn't exist
646
+ * - Returns `undefined` if the key has expired beyond the stale window
647
+ * - Returns the stale value if within the stale window
648
+ * - If `purgeStaleOnGet` is enabled, stale entries are deleted after being returned
649
+ */
650
+ get(key) {
651
+ return get(this.state, key);
652
+ }
653
+ /**
654
+ * Sets or updates a value in the cache.
655
+ *
656
+ * If the key already exists, it will be completely replaced.
657
+ *
658
+ * @param key - The key under which to store the value
659
+ * @param value - The value to cache (any type)
660
+ * @param options - Optional configuration for this specific entry
661
+ * @param options.ttl - Time-To-Live in milliseconds. Defaults to `defaultTtl`
662
+ * @param options.staleWindow - How long to serve stale data after expiration (milliseconds)
663
+ * @param options.tags - One or more tags for group invalidation
664
+ *
665
+ * @example
666
+ * ```typescript
667
+ * cache.set("user:123", { name: "Alice" }, {
668
+ * ttl: 5 * 60 * 1000,
669
+ * staleWindow: 1 * 60 * 1000,
670
+ * tags: "user:123",
671
+ * });
672
+ * ```
673
+ *
674
+ * @edge-cases
675
+ * - Overwriting an existing key replaces it completely
676
+ * - If `ttl` is 0 or Infinite, the entry never expires
677
+ * - If `staleWindow` is larger than `ttl`, the entry can be served as stale longer than it was fresh
678
+ * - Tags are optional; only necessary for group invalidation via `invalidateTag()`
679
+ */
680
+ set(key, value, options) {
681
+ setOrUpdate(this.state, {
682
+ key,
683
+ value,
684
+ ttl: options?.ttl,
685
+ staleWindow: options?.staleWindow,
686
+ tags: options?.tags
687
+ });
688
+ }
689
+ /**
690
+ * Deletes a specific key from the cache.
691
+ *
692
+ * @param key - The key to delete
693
+ * @returns True if the key was deleted, false if it didn't exist
694
+ *
695
+ * @example
696
+ * ```typescript
697
+ * const wasDeleted = cache.delete("user:123");
698
+ * ```
699
+ *
700
+ * @edge-cases
701
+ * - Triggers the `onDelete` callback with reason `'manual'`
702
+ * - Does not trigger the `onExpire` callback
703
+ * - Returns `false` if the key was already expired
704
+ * - Deleting a non-existent key returns `false` without error
705
+ */
706
+ delete(key) {
707
+ return deleteKey(this.state, key);
708
+ }
709
+ /**
710
+ * Checks if a key exists in the cache and is not fully expired.
711
+ *
712
+ * Returns true if the key exists and is either fresh or within the stale window.
713
+ * Use this when you only need to check existence without retrieving the value.
714
+ *
715
+ * @param key - The key to check
716
+ * @returns True if the key exists and is valid, false otherwise
717
+ *
718
+ * @example
719
+ * ```typescript
720
+ * if (cache.has("user:123")) {
721
+ * // Key exists (either fresh or stale)
722
+ * }
723
+ * ```
724
+ *
725
+ * @edge-cases
726
+ * - Returns `false` if the key doesn't exist
727
+ * - Returns `false` if the key has expired beyond the stale window
728
+ * - Returns `true` if the key is in the stale window (still being served)
729
+ * - Both `has()` and `get()` have O(1) complexity; prefer `get()` if you need the value
730
+ */
731
+ has(key) {
732
+ return has(this.state, key);
733
+ }
734
+ /**
735
+ * Removes all entries from the cache at once.
736
+ *
737
+ * This is useful for resetting the cache or freeing memory when needed.
738
+ * The `onDelete` callback is NOT invoked during clear (intentional optimization).
739
+ *
740
+ * @example
741
+ * ```typescript
742
+ * cache.clear(); // cache.size is now 0
743
+ * ```
744
+ *
745
+ * @edge-cases
746
+ * - The `onDelete` callback is NOT triggered during clear
747
+ * - Clears both expired and fresh entries
748
+ * - Resets `cache.size` to 0
749
+ */
750
+ clear() {
751
+ clear(this.state);
752
+ }
753
+ /**
754
+ * Marks all entries with one or more tags as expired (or stale, if requested).
755
+ *
756
+ * If an entry has multiple tags, invalidating ANY of those tags will invalidate the entry.
757
+ *
758
+ * @param tags - A single tag (string) or array of tags to invalidate
759
+ * @param asStale - If true, marks entries as stale instead of fully expired (still served from stale window)
760
+ *
761
+ * @example
762
+ * ```typescript
763
+ * // Invalidate a single tag
764
+ * cache.invalidateTag("user:123");
765
+ *
766
+ * // Invalidate multiple tags
767
+ * cache.invalidateTag(["user:123", "posts:456"]);
768
+ * ```
769
+ *
770
+ * @edge-cases
771
+ * - Does not throw errors if a tag has no associated entries
772
+ * - Invalidating a tag doesn't prevent new entries from being tagged with it later
773
+ * - The `onDelete` callback is triggered with reason `'expired'` (even if `asStale` is true)
774
+ */
775
+ invalidateTag(tags, asStale) {
776
+ invalidateTag(this.state, tags, { asStale });
777
+ }
778
+ };
779
+
780
+ //#endregion
781
+ export { LocalTtlCache };
782
+ //# sourceMappingURL=index.js.map