• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

flyingsquirrel0419 / layercache / 29149777778

11 Jul 2026 10:42AM UTC coverage: 96.066% (+0.2%) from 95.882%
29149777778

Pull #101

github

web-flow
Merge c2de1344d into e5107fec2
Pull Request #101: Fix cache security hardening

1969 of 2112 branches covered (93.23%)

Branch coverage included in aggregate %.

304 of 313 new or added lines in 23 files covered. (97.12%)

2 existing lines in 1 file now uncovered.

3452 of 3531 relevant lines covered (97.76%)

302.47 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.01
/src/CacheStack.ts
1
import { EventEmitter } from 'node:events'
2
import { CacheNamespace, validateNamespaceKey } from './CacheNamespace'
3
import { CacheKeyDiscovery } from './internal/CacheKeyDiscovery'
4
import {
5
  createInstanceId,
6
  normalizeForSerialization,
7
  serializeKeyPart,
8
  serializeOptions
9
} from './internal/CacheKeySerialization'
10
import {
11
  generationPrefix,
12
  qualifyGenerationKey,
13
  qualifyGenerationPattern,
14
  resolveGenerationCleanupBatchSize,
15
  resolveGenerationCleanupTarget,
16
  stripGenerationPrefix
17
} from './internal/CacheStackGeneration'
18
import { CacheStackInvalidationSupport } from './internal/CacheStackInvalidationSupport'
19
import { CacheStackLayerWriter, type CacheWriteFence, type CacheWriteKind } from './internal/CacheStackLayerWriter'
20
import { CacheStackMaintenance } from './internal/CacheStackMaintenance'
21
import { CacheStackReader } from './internal/CacheStackReader'
22
import {
23
  resolveRecoverableLayerFailure,
24
  shouldSkipLayer as shouldSkipDegradedLayer
25
} from './internal/CacheStackRuntimePolicy'
26
import { CacheStackSnapshotManager } from './internal/CacheStackSnapshotManager'
27
import {
28
  validateAdaptiveTtlOptions,
29
  validateCacheKey,
30
  validateCircuitBreakerOptions,
31
  validateContextEntryOptions,
32
  validateLayerNumberOption,
33
  validateNonNegativeNumber,
34
  validatePattern,
35
  validatePositiveNumber,
36
  validateRateLimitOptions,
37
  validateTag,
38
  validateTags,
39
  validateTtlPolicy
40
} from './internal/CacheStackValidation'
41
import { CircuitBreakerManager } from './internal/CircuitBreakerManager'
42
import { FetchRateLimiter } from './internal/FetchRateLimiter'
43
import { MetricsCollector } from './internal/MetricsCollector'
44
import { resolveStoredValue } from './internal/StoredValue'
45
import { TtlResolver } from './internal/TtlResolver'
46
import { TagIndex } from './invalidation/TagIndex'
47
import { JsonSerializer } from './serialization/JsonSerializer'
48
import { StampedeGuard } from './stampede/StampedeGuard'
49
import {
50
  type CacheAdaptiveTtlOptions,
51
  type CacheCircuitBreakerOptions,
52
  type CacheContextOptionsContext,
53
  type CacheEntryResult,
54
  type CacheEntryWriteKind,
55
  type CacheEntryWriteOptions,
56
  type CacheFetcher,
57
  type CacheFetcherContext,
58
  type CacheGetOptions,
59
  type CacheHealthCheckResult,
60
  type CacheHitRateSnapshot,
61
  type CacheInspectResult,
62
  type CacheLayer,
63
  type CacheLayerSetManyEntry,
64
  type CacheLogger,
65
  type CacheMGetEntry,
66
  type CacheMSetEntry,
67
  type CacheMetricsSnapshot,
68
  CacheMissError,
69
  type CacheSnapshotEntry,
70
  type CacheStackEvents,
71
  type CacheStackOptions,
72
  type CacheStatsSnapshot,
73
  type CacheTagIndex,
74
  type CacheTtlPolicy,
75
  type CacheWarmEntry,
76
  type CacheWarmOptions,
77
  type CacheWarmProgress,
78
  type CacheWrapOptions,
79
  type CacheWriteBehindOptions,
80
  type CacheWriteOptions,
81
  type InvalidationMessage,
82
  type LayerTtlMap
83
} from './types'
84

85
const DEFAULT_SNAPSHOT_MAX_BYTES = 16 * 1_024 * 1_024
14✔
86
const DEFAULT_SNAPSHOT_MAX_ENTRIES = 10_000
14✔
87
const DEFAULT_INVALIDATION_MAX_KEYS = 10_000
14✔
88
const DEFAULT_MAX_PROFILE_ENTRIES = 100_000
14✔
89

90
class DebugLogger implements CacheLogger {
91
  private readonly enabled: boolean
92

93
  constructor(enabled: boolean) {
94
    this.enabled = enabled
262✔
95
  }
96

97
  debug(message: string, context?: Record<string, unknown>): void {
98
    this.write('debug', message, context)
770✔
99
  }
100

101
  info(message: string, context?: Record<string, unknown>): void {
102
    this.write('info', message, context)
3✔
103
  }
104

105
  warn(message: string, context?: Record<string, unknown>): void {
106
    this.write('warn', message, context)
52✔
107
  }
108

109
  error(message: string, context?: Record<string, unknown>): void {
110
    this.write('error', message, context)
25✔
111
  }
112

113
  private write(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
114
    if (!this.enabled) {
850✔
115
      return
848✔
116
    }
117

118
    const suffix = context ? ` ${JSON.stringify(context)}` : ''
2✔
119
    console[level](`[layercache] ${message}${suffix}`)
850✔
120
  }
121
}
122

123
/** Typed overloads for EventEmitter so callers get autocomplete on event names. */
124
export interface CacheStack {
125
  /** Register a typed CacheStack event listener. */
126
  on<K extends keyof CacheStackEvents>(event: K, listener: (data: CacheStackEvents[K]) => void): this
127
  /** Register a typed CacheStack event listener that runs once. */
128
  once<K extends keyof CacheStackEvents>(event: K, listener: (data: CacheStackEvents[K]) => void): this
129
  /** Remove a typed CacheStack event listener. */
130
  off<K extends keyof CacheStackEvents>(event: K, listener: (data: CacheStackEvents[K]) => void): this
131
  /** Remove all listeners, optionally only for one typed CacheStack event. */
132
  removeAllListeners<K extends keyof CacheStackEvents>(event?: K): this
133
  /** Return listeners registered for a typed CacheStack event. */
134
  listeners<K extends keyof CacheStackEvents>(event: K): Array<(data: CacheStackEvents[K]) => void>
135
  /** Return the listener count for a typed CacheStack event. */
136
  listenerCount<K extends keyof CacheStackEvents>(event: K): number
137
  /** Emit a typed CacheStack event. Mostly useful for custom integrations. */
138
  emit<K extends keyof CacheStackEvents>(event: K, data: CacheStackEvents[K]): boolean
139
}
140

141
/**
142
 * Multi-layer read-through cache coordinator.
143
 *
144
 * Layers are checked from fastest to slowest, partial hits are backfilled into
145
 * faster layers, and misses can be resolved by read-through fetchers.
146
 */
147
export class CacheStack extends EventEmitter {
148
  private readonly stampedeGuard: StampedeGuard
149
  private readonly metricsCollector = new MetricsCollector()
274✔
150
  private readonly instanceId = createInstanceId()
274✔
151
  private readonly startup: Promise<void>
152
  private unsubscribeInvalidation?: () => Promise<void> | void
153
  private readonly logger: CacheLogger
154
  private readonly tagIndex: CacheTagIndex
155
  private readonly keyDiscovery: CacheKeyDiscovery
156
  private readonly fetchRateLimiter = new FetchRateLimiter()
274✔
157
  private readonly snapshotSerializer = new JsonSerializer()
274✔
158
  private readonly invalidation: CacheStackInvalidationSupport
159
  private readonly layerWriter: CacheStackLayerWriter
160
  private readonly snapshots: CacheStackSnapshotManager
161
  private readonly layerDegradedUntil = new Map<string, number>()
274✔
162
  private readonly maintenance = new CacheStackMaintenance()
274✔
163
  private readonly ttlResolver: TtlResolver
164
  private readonly circuitBreakerManager: CircuitBreakerManager
165
  private nextOperationId = 0
274✔
166
  private currentGeneration?: number
167
  private isDisconnecting = false
274✔
168
  private readonly reader: CacheStackReader
169
  private disconnectPromise?: Promise<void>
170

171
  /**
172
   * Creates a cache stack from ordered layers and optional global behavior settings.
173
   */
174
  constructor(
175
    private readonly layers: CacheLayer[],
274✔
176
    private readonly options: CacheStackOptions = {}
274✔
177
  ) {
178
    super()
274✔
179

180
    if (layers.length === 0) {
274✔
181
      throw new Error('CacheStack requires at least one cache layer.')
1✔
182
    }
183

184
    this.validateConfiguration()
273✔
185

186
    const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES
273✔
187
    this.ttlResolver = new TtlResolver({ maxProfileEntries })
274✔
188
    this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries })
274✔
189
    this.stampedeGuard = new StampedeGuard({
274✔
190
      maxInFlight: options.stampedeMaxInFlight,
191
      entryTimeoutMs: options.stampedeEntryTimeoutMs,
192
      onEntryTimeout: (key) => this.maintenance.bumpKeyEpochs([key])
1✔
193
    })
194
    this.currentGeneration = options.generation
274✔
195

196
    if (options.publishSetInvalidation !== undefined) {
274✔
197
      console.warn(
1✔
198
        '[layercache] CacheStackOptions.publishSetInvalidation is deprecated. ' + 'Use broadcastL1Invalidation instead.'
199
      )
200
    }
201

202
    const debugEnv = process.env.DEBUG?.split(',').includes('layercache:debug') ?? false
269✔
203
    this.logger =
274✔
204
      typeof options.logger === 'object' ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv)
791✔
205
    this.tagIndex = options.tagIndex ?? new TagIndex()
274✔
206
    this.keyDiscovery = new CacheKeyDiscovery({
274✔
207
      layers: this.layers,
208
      tagIndex: this.tagIndex,
209
      shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
22✔
210
      handleLayerFailure: async (layer, operation, error) => {
211
        await this.handleLayerFailure(layer, operation, error)
1✔
212
      }
213
    })
214
    this.invalidation = new CacheStackInvalidationSupport({
274✔
215
      tagIndex: this.tagIndex,
216
      shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
64✔
217
      handleLayerFailure: async (layer, operation, error) => {
218
        await this.handleLayerFailure(layer, operation, error)
3✔
219
      }
220
    })
221
    this.layerWriter = new CacheStackLayerWriter({
274✔
222
      layers: this.layers,
223
      maintenance: this.maintenance,
224
      shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
440✔
225
      shouldWriteBehind: (layer) => this.shouldWriteBehind(layer),
423✔
226
      handleLayerFailure: async (layer, operation, error) => {
227
        await this.handleLayerFailure(layer, operation, error)
4✔
228
      },
229
      enqueueWriteBehind: this.enqueueWriteBehind.bind(this),
230
      resolveFreshTtl: this.resolveFreshTtl.bind(this),
231
      resolveLayerMs: this.resolveLayerMs.bind(this),
232
      globalStaleWhileRevalidate: this.options.staleWhileRevalidate,
233
      globalStaleIfError: this.options.staleIfError,
234
      writePolicy: this.options.writePolicy,
235
      onWriteFailures: (context, failures) => {
236
        this.metricsCollector.increment('writeFailures', failures.length)
3✔
237
        this.logger.debug?.('write-failure', {
3✔
238
          ...context,
239
          failures: failures.map((failure) => this.formatError(failure))
3✔
240
        })
241
      }
242
    })
243
    if (!options.tagIndex && layers.some((layer) => layer.isLocal === false)) {
300✔
244
      this.logger.warn?.(
21✔
245
        'Using the default in-memory TagIndex with a shared cache layer only tracks keys seen by this process. Use RedisTagIndex for cross-instance tag invalidation.'
246
      )
247
    }
248
    if (!options.tagIndex && layers.some((layer) => layer.isLocal === false && !layer.keys)) {
301✔
249
      this.logger.warn?.(
4✔
250
        'Using the default in-memory TagIndex with a shared cache layer that does not implement keys() can leave invalidateByPattern() and invalidateByPrefix() incomplete after restarts. Use RedisTagIndex or implement keys() on the shared layer.'
251
      )
252
    }
253
    if (
269✔
254
      options.invalidationBus &&
302✔
255
      options.broadcastL1Invalidation === undefined &&
256
      options.publishSetInvalidation === undefined
257
    ) {
258
      this.logger.warn?.(
14✔
259
        'broadcastL1Invalidation defaults to false when an invalidation bus is configured; opt in explicitly if write-triggered L1 invalidation is desired.'
260
      )
261
    }
262
    this.snapshots = new CacheStackSnapshotManager({
269✔
263
      layers: this.layers,
264
      tagIndex: this.tagIndex,
265
      snapshotSerializer: this.snapshotSerializer,
266
      readLayerEntry: (layer, key) => this.reader.readLayerEntry(layer, key),
4✔
267
      shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
3✔
268
      handleLayerFailure: async (layer, operation, error) => this.handleLayerFailure(layer, operation, error),
×
269
      qualifyKey: this.qualifyKey.bind(this),
270
      stripQualifiedKey: this.stripQualifiedKey.bind(this),
271
      validateCacheKey,
272
      formatError: this.formatError.bind(this)
273
    })
274
    this.reader = new CacheStackReader({
269✔
275
      layers: this.layers,
276
      metricsCollector: this.metricsCollector,
277
      maintenance: this.maintenance,
278
      tagIndex: this.tagIndex,
279
      circuitBreakerManager: this.circuitBreakerManager,
280
      fetchRateLimiter: this.fetchRateLimiter,
281
      stampedeGuard: this.stampedeGuard,
282
      ttlResolver: this.ttlResolver,
283
      logger: this.logger,
284
      shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
501✔
285
      handleLayerFailure: async (layer, operation, error) => this.handleLayerFailure(layer, operation, error),
5✔
286
      emit: (event, data) => this.emit(event, data as never),
443✔
287
      emitError: (operation, context) => this.emitError(operation, context),
1✔
288
      formatError: (error) => this.formatError(error),
9✔
289
      storeEntry: (key, kind, value, options, fence) => this.storeEntry(key, kind, value, options, fence),
63✔
290
      recordCircuitFailure: (key, breakerKey, options, error) =>
291
        this.recordCircuitFailure(key, breakerKey, options, error),
12✔
292
      resolveLayerMs: (layerName, override, globalDefault, fallback) =>
293
        this.resolveLayerMs(layerName, override, globalDefault, fallback),
81✔
294
      sleep: (ms) => this.sleep(ms),
4✔
295
      withTimeout: (promise, ms, createError) => this.withTimeout(promise, ms, createError),
11✔
296
      isDisconnecting: () => this.isDisconnecting,
13✔
297
      isGracefulDegradationEnabled: () => this.isGracefulDegradationEnabled(),
2✔
298
      scheduleBackgroundRefreshDispatch: <T>(
299
        key: string,
300
        fetcher: CacheFetcher<T>,
301
        options?: CacheGetOptions,
302
        fetcherContext?: CacheFetcherContext<T>
303
      ) => this.scheduleBackgroundRefresh(key, fetcher, options, fetcherContext),
1✔
304
      stampedePrevention: options.stampedePrevention,
305
      singleFlightCoordinator: options.singleFlightCoordinator,
306
      singleFlightLeaseMs: options.singleFlightLeaseMs,
307
      singleFlightTimeoutMs: options.singleFlightTimeoutMs,
308
      singleFlightPollMs: options.singleFlightPollMs,
309
      singleFlightRenewIntervalMs: options.singleFlightRenewIntervalMs,
310
      backgroundRefreshTimeoutMs: options.backgroundRefreshTimeoutMs,
311
      negativeCaching: options.negativeCaching,
312
      cacheNullValues: options.cacheNullValues,
313
      refreshAhead: options.refreshAhead,
314
      circuitBreaker: options.circuitBreaker,
315
      fetcherRateLimit: options.fetcherRateLimit
316
    })
317
    this.initializeWriteBehind(options.writeBehind)
269✔
318
    this.startup = this.initialize()
269✔
319
  }
320

321
  /**
322
   * Read-through cache get.
323
   * Returns the cached value if present and fresh, or invokes `fetcher` on a miss
324
   * and stores the result across all layers. Returns `null` if the key is not found
325
   * and no `fetcher` is provided.
326
   */
327
  async get<T>(key: string, fetcher?: CacheFetcher<T>, options?: CacheGetOptions): Promise<T | null> {
328
    return this.observeOperation('layercache.get', { 'layercache.key': String(key ?? '') }, async () => {
325✔
329
      const normalizedKey = this.qualifyKey(validateCacheKey(key))
325✔
330
      this.validateWriteOptions(options)
325✔
331
      await this.awaitStartup('get')
325✔
332
      return this.reader.getPrepared(normalizedKey, fetcher, options)
319✔
333
    })
334
  }
335

336
  /**
337
   * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
338
   * Fetches and caches the value if not already present.
339
   */
340
  async getOrSet<T>(key: string, fetcher: CacheFetcher<T>, options?: CacheGetOptions): Promise<T | null> {
341
    return this.get(key, fetcher, options)
4✔
342
  }
343

344
  /**
345
   * Returns a discriminated cache entry, or `null` on miss.
346
   * Unlike `get()`, this distinguishes a stored `null` value from an absent key.
347
   */
348
  async getEntry<T>(key: string): Promise<CacheEntryResult<T> | null> {
349
    return this.observeOperation('layercache.get_entry', { 'layercache.key': String(key ?? '') }, async () => {
6!
350
      const userKey = validateCacheKey(key)
6✔
351
      const normalizedKey = this.qualifyKey(userKey)
6✔
352
      await this.awaitStartup('getEntry')
6✔
353
      const readFence = {
6✔
354
        clearEpoch: this.maintenance.currentClearEpoch(),
355
        keyEpoch: this.maintenance.currentKeyEpoch(normalizedKey)
356
      }
357
      let sawRetainableValue = false
6✔
358

359
      for (let index = 0; index < this.layers.length; index += 1) {
6✔
360
        const layer = this.layers[index]
7✔
361
        if (!layer || this.shouldSkipLayer(layer)) {
7!
362
          continue
×
363
        }
364

365
        const readStart = performance.now()
7✔
366
        const stored = await this.readLayerEntry(layer, normalizedKey)
7✔
367
        this.metricsCollector.recordLatency(layer.name, performance.now() - readStart)
7✔
368
        if (stored === null) {
7✔
369
          this.metricsCollector.incrementLayer('missesByLayer', layer.name)
3✔
370
          continue
3✔
371
        }
372

373
        const resolved = resolveStoredValue<T>(stored)
4✔
374
        if (resolved.state === 'expired') {
4!
375
          await layer.delete(normalizedKey)
×
376
          continue
×
377
        }
378

379
        sawRetainableValue = true
4✔
380
        await this.tagIndex.touch(normalizedKey)
4✔
381
        await this.reader.backfill(normalizedKey, stored, index - 1, undefined, readFence)
4✔
382
        this.metricsCollector.increment('hits')
4✔
383
        if (resolved.state === 'stale-while-revalidate' || resolved.state === 'stale-if-error') {
4!
384
          this.metricsCollector.increment('staleHits')
×
385
        }
386
        this.metricsCollector.incrementLayer('hitsByLayer', layer.name)
4✔
387
        this.logger.debug?.('hit', { key: normalizedKey, layer: layer.name, state: resolved.state })
4✔
388
        this.emit('hit', {
7✔
389
          key: normalizedKey,
390
          layer: layer.name,
391
          state: resolved.state as CacheStackEvents['hit']['state']
392
        })
393

394
        return {
7✔
395
          key: userKey,
396
          value: resolved.value,
397
          kind: resolved.envelope?.kind ?? 'value',
8✔
398
          state: resolved.state,
399
          layer: layer.name
400
        }
401
      }
402

403
      if (!sawRetainableValue) {
2!
404
        await this.tagIndex.remove(normalizedKey)
2✔
405
      }
406
      this.metricsCollector.increment('misses')
2✔
407
      this.logger.debug?.('miss', { key: normalizedKey, mode: 'getEntry' })
2✔
408
      this.emit('miss', { key: normalizedKey, mode: 'getEntry' })
6✔
409
      return null
6✔
410
    })
411
  }
412

413
  /**
414
   * Like `get()`, but throws `CacheMissError` instead of returning `null`.
415
   * Useful when the value is expected to exist or the fetcher is expected to
416
   * return non-null.
417
   */
418
  async getOrThrow<T>(key: string, fetcher?: CacheFetcher<T>, options?: CacheGetOptions): Promise<T> {
419
    const value = await this.get(key, fetcher, options)
4✔
420
    if (value === null) {
4✔
421
      throw new CacheMissError(key)
3✔
422
    }
423
    return value
1✔
424
  }
425

426
  /**
427
   * Returns true if the given key exists and is not expired in any layer.
428
   */
429
  async has(key: string): Promise<boolean> {
430
    const normalizedKey = this.qualifyKey(validateCacheKey(key))
9✔
431
    await this.awaitStartup('has')
9✔
432

433
    for (const layer of this.layers) {
9✔
434
      if (this.shouldSkipLayer(layer)) {
17!
435
        continue
×
436
      }
437
      if (layer.has) {
17✔
438
        try {
5✔
439
          const exists = await layer.has(normalizedKey)
5✔
440
          if (exists) {
4✔
441
            return true
2✔
442
          }
443
        } catch {
444
          await this.reportRecoverableLayerFailure(layer, 'has', new Error(`has() failed for layer "${layer.name}"`))
1✔
445
          // fall through to next layer
446
        }
447
      } else {
448
        try {
12✔
449
          const value = await layer.get(normalizedKey)
12✔
450
          if (value !== null) {
8✔
451
            return true
2✔
452
          }
453
        } catch (error) {
454
          await this.reportRecoverableLayerFailure(layer, 'has', error)
4✔
455
          // fall through
456
        }
457
      }
458
    }
459
    return false
5✔
460
  }
461

462
  /**
463
   * Returns the remaining TTL in milliseconds for the key in the fastest layer
464
   * that has it, or null if the key is not found / has no TTL.
465
   */
466
  async ttl(key: string): Promise<number | null> {
467
    const normalizedKey = this.qualifyKey(validateCacheKey(key))
4✔
468
    await this.awaitStartup('ttl')
4✔
469

470
    for (const layer of this.layers) {
4✔
471
      if (this.shouldSkipLayer(layer)) {
8✔
472
        continue
1✔
473
      }
474
      if (layer.ttl) {
7✔
475
        try {
6✔
476
          const remaining = await layer.ttl(normalizedKey)
6✔
477
          if (remaining !== null) {
4✔
478
            return remaining
3✔
479
          }
480
        } catch {
481
          // fall through
482
        }
483
      }
484
    }
485
    return null
1✔
486
  }
487

488
  /**
489
   * Stores a value in all cache layers. Overwrites any existing value.
490
   */
491
  async set<T>(key: string, value: T, options?: CacheWriteOptions): Promise<void> {
492
    await this.observeOperation('layercache.set', { 'layercache.key': String(key ?? '') }, async () => {
130✔
493
      const normalizedKey = this.qualifyKey(validateCacheKey(key))
130✔
494
      this.validateWriteOptions(options)
130✔
495
      await this.awaitStartup('set')
130✔
496
      await this.storeEntry(normalizedKey, 'value', value, options)
125✔
497
    })
498
  }
499

500
  /**
501
   * Deletes the key from all layers and publishes an invalidation message.
502
   */
503
  async delete(key: string): Promise<void> {
504
    await this.observeOperation('layercache.delete', { 'layercache.key': String(key ?? '') }, async () => {
11✔
505
      const normalizedKey = this.qualifyKey(validateCacheKey(key))
11✔
506
      await this.awaitStartup('delete')
11✔
507
      await this.deleteKeys([normalizedKey])
10✔
508
      await this.publishInvalidation({
10✔
509
        scope: 'key',
510
        keys: [normalizedKey],
511
        sourceId: this.instanceId,
512
        operation: 'delete'
513
      })
514
    })
515
  }
516

517
  /**
518
   * Clears every configured layer, removes tag metadata, resets internal TTL
519
   * profiles, and broadcasts a clear invalidation message.
520
   */
521
  async clear(): Promise<void> {
522
    await this.awaitStartup('clear')
4✔
523
    this.maintenance.beginClearEpoch()
4✔
524
    await Promise.all(this.layers.map((layer) => layer.clear()))
5✔
525
    await this.tagIndex.clear()
4✔
526
    this.ttlResolver.clearProfiles()
4✔
527
    this.circuitBreakerManager.clear()
4✔
528
    this.metricsCollector.increment('invalidations')
4✔
529
    this.logger.debug?.('clear')
4✔
530
    await this.publishInvalidation({ scope: 'clear', sourceId: this.instanceId, operation: 'clear' })
4✔
531
  }
532

533
  /**
534
   * Deletes multiple keys at once. More efficient than calling `delete()` in a loop.
535
   */
536
  async mdelete(keys: string[]): Promise<void> {
537
    if (keys.length === 0) {
5✔
538
      return
1✔
539
    }
540
    await this.awaitStartup('mdelete')
4✔
541
    const normalizedKeys = keys.map((k) => validateCacheKey(k))
6✔
542
    const cacheKeys = normalizedKeys.map((key) => this.qualifyKey(key))
6✔
543
    await this.deleteKeys(cacheKeys)
4✔
544
    await this.publishInvalidation({
4✔
545
      scope: 'keys',
546
      keys: cacheKeys,
547
      sourceId: this.instanceId,
548
      operation: 'delete'
549
    })
550
  }
551

552
  /**
553
   * Alias for `delete(key)` that matches the `invalidateBy*` API family.
554
   */
555
  async invalidateByKey(key: string): Promise<void> {
556
    await this.delete(key)
2✔
557
  }
558

559
  /**
560
   * Alias for `mdelete(keys)` that matches the `invalidateBy*` API family.
561
   */
562
  async invalidateByKeys(keys: string[]): Promise<void> {
563
    await this.mdelete(keys)
2✔
564
  }
565

566
  /**
567
   * Marks one exact key expired without deleting its stale value.
568
   */
569
  async expireByKey(key: string): Promise<void> {
570
    await this.observeOperation('layercache.expire_by_key', { 'layercache.key': String(key ?? '') }, async () => {
2!
571
      const normalizedKey = this.qualifyKey(validateCacheKey(key))
2✔
572
      await this.awaitStartup('expireByKey')
2✔
573
      await this.expireKeys([normalizedKey])
2✔
574
      await this.publishInvalidation({
2✔
575
        scope: 'key',
576
        keys: [normalizedKey],
577
        sourceId: this.instanceId,
578
        operation: 'expire'
579
      })
580
    })
581
  }
582

583
  /**
584
   * Marks multiple exact keys expired without deleting their stale values.
585
   */
586
  async expireByKeys(keys: string[]): Promise<void> {
587
    await this.observeOperation('layercache.expire_by_keys', undefined, async () => {
3✔
588
      if (keys.length === 0) {
3✔
589
        return
1✔
590
      }
591

592
      const normalizedKeys = keys.map((k) => validateCacheKey(k))
3✔
593
      const cacheKeys = normalizedKeys.map((key) => this.qualifyKey(key))
3✔
594
      await this.awaitStartup('expireByKeys')
2✔
595
      await this.expireKeys(cacheKeys)
2✔
596
      await this.publishInvalidation({
2✔
597
        scope: 'keys',
598
        keys: cacheKeys,
599
        sourceId: this.instanceId,
600
        operation: 'expire'
601
      })
602
    })
603
  }
604

605
  /**
606
   * Reads many keys concurrently. Simple reads use layer-level bulk fast paths;
607
   * entries with fetchers or options fall back to per-entry read-through logic.
608
   */
609
  async mget<T>(entries: CacheMGetEntry<T>[]): Promise<Array<T | null>> {
610
    return this.observeOperation('layercache.mget', undefined, async () => {
9✔
611
      this.assertActive('mget')
9✔
612
      if (entries.length === 0) {
9✔
613
        return []
1✔
614
      }
615

616
      const normalizedEntries = entries.map((entry) => ({
18✔
617
        ...entry,
618
        key: this.qualifyKey(validateCacheKey(entry.key))
619
      }))
620
      normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options))
18✔
621
      const canFastPath = normalizedEntries.every((entry) => entry.fetch === undefined && entry.options === undefined)
16✔
622
      if (!canFastPath) {
8✔
623
        await this.awaitStartup('mget')
2✔
624
        const pendingReads = new Map<
2✔
625
          string,
626
          {
627
            promise: Promise<T | null>
628
            fetch?: CacheFetcher<T>
629
            optionsSignature: string
630
          }
631
        >()
632

633
        return Promise.all(
2✔
634
          normalizedEntries.map((entry) => {
635
            const optionsSignature = serializeOptions(entry.options)
4✔
636
            const existing = pendingReads.get(entry.key)
4✔
637
            if (!existing) {
4✔
638
              const promise = this.reader.getPrepared(entry.key, entry.fetch, entry.options)
2✔
639
              pendingReads.set(entry.key, {
2✔
640
                promise,
641
                fetch: entry.fetch,
642
                optionsSignature
643
              })
644
              return promise
2✔
645
            }
646

647
            if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
2!
648
              const displayKey = entry.key.length > 64 ? `${entry.key.slice(0, 64)}...` : entry.key
2!
649
              throw new Error(`mget received conflicting entries for key "${displayKey}".`)
2✔
650
            }
651

652
            return existing.promise
×
653
          })
654
        )
655
      }
656

657
      await this.awaitStartup('mget')
6✔
658
      const pending = new Set<string>()
6✔
659
      const indexesByKey = new Map<string, number[]>()
6✔
660
      const resultsByKey = new Map<string, T | null>()
6✔
661
      const readFences = new Map(
6✔
662
        normalizedEntries.map(({ key }) => [
14✔
663
          key,
664
          {
665
            clearEpoch: this.maintenance.currentClearEpoch(),
666
            keyEpoch: this.maintenance.currentKeyEpoch(key)
667
          }
668
        ])
669
      )
670

671
      for (let index = 0; index < normalizedEntries.length; index += 1) {
6✔
672
        const entry = normalizedEntries[index]
14✔
673
        if (!entry) continue
14!
674
        const key = entry.key
14✔
675
        const indexes = indexesByKey.get(key) ?? []
14✔
676
        indexes.push(index)
14✔
677
        indexesByKey.set(key, indexes)
14✔
678
        pending.add(key)
14✔
679
      }
680

681
      for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
6✔
682
        const layer = this.layers[layerIndex]
6✔
683
        if (!layer || this.shouldSkipLayer(layer)) continue
6!
684
        const keys = [...pending]
6✔
685
        if (keys.length === 0) {
6!
686
          break
×
687
        }
688

689
        const values = layer.getMany
6!
690
          ? await layer.getMany(keys)
691
          : await Promise.all(keys.map((key) => this.reader.readLayerEntry(layer, key)))
×
692

693
        for (let offset = 0; offset < values.length; offset += 1) {
×
694
          const key = keys[offset]
13✔
695
          const stored = values[offset]
13✔
696
          if (!key || stored === null) {
13✔
697
            continue
2✔
698
          }
699

700
          const resolved = resolveStoredValue<T>(stored)
11✔
701
          if (resolved.state === 'expired') {
11✔
702
            await layer.delete(key)
1✔
703
            continue
1✔
704
          }
705

706
          if (resolved.state === 'stale-while-revalidate' || resolved.state === 'stale-if-error') {
10!
707
            this.metricsCollector.increment('staleHits', indexesByKey.get(key)?.length ?? 1)
×
708
          }
709

710
          await this.tagIndex.touch(key)
10✔
711
          await this.reader.backfill(key, stored, layerIndex - 1, undefined, readFences.get(key))
10✔
712
          resultsByKey.set(key, resolved.value)
10✔
713
          pending.delete(key)
10✔
714
          this.metricsCollector.increment('hits', indexesByKey.get(key)?.length ?? 1)
10!
715
        }
716
      }
717

718
      if (pending.size > 0) {
6✔
719
        for (const key of pending) {
2✔
720
          await this.tagIndex.remove(key)
3✔
721
          this.metricsCollector.increment('misses', indexesByKey.get(key)?.length ?? 1)
3!
722
        }
723
      }
724

725
      return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null)
14✔
726
    })
727
  }
728

729
  /**
730
   * Writes many entries concurrently using each layer's bulk write fast path
731
   * when available.
732
   */
733
  async mset<T>(entries: CacheMSetEntry<T>[]): Promise<void> {
734
    await this.observeOperation('layercache.mset', undefined, async () => {
14✔
735
      this.assertActive('mset')
14✔
736
      const normalizedEntries = entries.map((entry) => ({
32✔
737
        ...entry,
738
        key: this.qualifyKey(validateCacheKey(entry.key))
739
      }))
740
      normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options))
32✔
741
      await this.awaitStartup('mset')
14✔
742
      await this.writeBatch(normalizedEntries)
14✔
743
    })
744
  }
745

746
  /**
747
   * Pre-populates cache entries by running their fetchers with bounded
748
   * concurrency. Higher-priority entries run first.
749
   */
750
  async warm(entries: CacheWarmEntry[], options: CacheWarmOptions = {}): Promise<void> {
4✔
751
    this.assertActive('warm')
4✔
752
    const concurrency = Math.max(1, options.concurrency ?? 4)
4✔
753
    const total = entries.length
4✔
754
    let completed = 0
4✔
755
    const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0))
4!
756
    const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => {
4!
757
      while (queue.length > 0) {
4✔
758
        const entry = queue.shift()
6✔
759
        if (!entry) {
6!
760
          return
×
761
        }
762

763
        let success = false
6✔
764
        try {
6✔
765
          await this.get(entry.key, entry.fetcher, entry.options)
6✔
766
          this.emit('warm', { key: entry.key })
4✔
767
          success = true
4✔
768
        } catch (error) {
769
          this.emitError('warm', { key: entry.key, error: this.formatError(error) })
2✔
770
          if (!options.continueOnError) {
2✔
771
            throw error
1✔
772
          }
773
        } finally {
774
          completed += 1
6✔
775
          const progress: CacheWarmProgress = { completed, total, key: entry.key, success }
6✔
776
          options.onProgress?.(progress)
6✔
777
        }
778
      }
779
    })
780

781
    await Promise.all(workers)
4✔
782
  }
783

784
  /**
785
   * Returns a cached version of `fetcher`. The cache key is derived from
786
   * `prefix` plus the serialized arguments unless a `keyResolver` is provided.
787
   */
788
  wrap<TArgs extends unknown[], TResult>(
789
    prefix: string,
790
    fetcher: (...args: TArgs) => Promise<TResult>,
791
    options: CacheWrapOptions<TArgs> = {}
11✔
792
  ): (...args: TArgs) => Promise<TResult | null> {
793
    return (...args: TArgs) => {
11✔
794
      const suffix = options.keyResolver
18✔
795
        ? options.keyResolver(...args)
796
        : args.map((argument) => serializeKeyPart(argument)).join(':')
11✔
797
      const key = suffix.length > 0 ? `${prefix}:${suffix}` : prefix
18!
798
      return this.get<TResult>(key, () => fetcher(...args), options)
18✔
799
    }
800
  }
801

802
  /**
803
   * Creates a `CacheNamespace` that automatically prefixes all keys with
804
   * `prefix:`. Useful for multi-tenant or module-level isolation.
805
   */
806
  namespace(prefix: string): CacheNamespace {
807
    validateNamespaceKey(prefix)
49✔
808
    return new CacheNamespace(this, prefix)
49✔
809
  }
810

811
  /**
812
   * Deletes every key currently associated with `tag` and broadcasts an
813
   * invalidation message.
814
   */
815
  async invalidateByTag(tag: string): Promise<void> {
816
    await this.observeOperation('layercache.invalidate_by_tag', undefined, async () => {
8✔
817
      validateTag(tag)
8✔
818
      await this.awaitStartup('invalidateByTag')
8✔
819
      const keys = await this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys())
7✔
820
      await this.deleteKeys(keys)
6✔
821
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
6✔
822
    })
823
  }
824

825
  /**
826
   * Marks every key associated with `tag` as expired while preserving stale
827
   * windows for stale serving.
828
   */
829
  async expireByTag(tag: string): Promise<void> {
830
    await this.observeOperation('layercache.expire_by_tag', undefined, async () => {
4✔
831
      validateTag(tag)
4✔
832
      await this.awaitStartup('expireByTag')
4✔
833
      const keys = await this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys())
4✔
834
      await this.expireKeys(keys)
4✔
835
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'expire' })
4✔
836
    })
837
  }
838

839
  /**
840
   * Deletes keys associated with any or all of the provided tags and broadcasts
841
   * an invalidation message.
842
   */
843
  async invalidateByTags(tags: string[], mode: 'any' | 'all' = 'any'): Promise<void> {
5✔
844
    await this.observeOperation('layercache.invalidate_by_tags', undefined, async () => {
5✔
845
      if (tags.length === 0) {
5✔
846
        return
1✔
847
      }
848

849
      validateTags(tags)
4✔
850
      await this.awaitStartup('invalidateByTags')
4✔
851
      const keysByTag = await Promise.all(
4✔
852
        tags.map((tag) => this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys()))
7✔
853
      )
854
      const keys = mode === 'all' ? this.invalidation.intersectKeys(keysByTag) : [...new Set(keysByTag.flat())]
3✔
855
      this.invalidation.assertWithinInvalidationKeyLimit(keys.length, this.invalidationMaxKeys())
5✔
856

857
      await this.deleteKeys(keys)
5✔
858
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
3✔
859
    })
860
  }
861

862
  /**
863
   * Marks keys associated with any or all of the provided tags as expired while
864
   * preserving stale windows for stale serving.
865
   */
866
  async expireByTags(tags: string[], mode: 'any' | 'all' = 'any'): Promise<void> {
3✔
867
    await this.observeOperation('layercache.expire_by_tags', undefined, async () => {
3✔
868
      if (tags.length === 0) {
3✔
869
        return
1✔
870
      }
871

872
      validateTags(tags)
2✔
873
      await this.awaitStartup('expireByTags')
2✔
874
      const keysByTag = await Promise.all(
2✔
875
        tags.map((tag) => this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys()))
4✔
876
      )
877
      const keys = mode === 'all' ? this.invalidation.intersectKeys(keysByTag) : [...new Set(keysByTag.flat())]
2!
878
      this.invalidation.assertWithinInvalidationKeyLimit(keys.length, this.invalidationMaxKeys())
3✔
879

880
      await this.expireKeys(keys)
3✔
881
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'expire' })
2✔
882
    })
883
  }
884

885
  /**
886
   * Deletes keys matching a wildcard pattern such as `user:*`.
887
   */
888
  async invalidateByPattern(pattern: string): Promise<void> {
889
    await this.observeOperation('layercache.invalidate_by_pattern', undefined, async () => {
5✔
890
      validatePattern(pattern)
5✔
891
      await this.awaitStartup('invalidateByPattern')
5✔
892
      const keys = await this.keyDiscovery.collectKeysMatchingPattern(
5✔
893
        this.qualifyPattern(pattern),
894
        this.invalidationMaxKeys()
895
      )
896
      await this.deleteKeys(keys)
4✔
897
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
4✔
898
    })
899
  }
900

901
  /**
902
   * Marks keys matching a wildcard pattern as expired while preserving stale
903
   * windows for stale serving.
904
   */
905
  async expireByPattern(pattern: string): Promise<void> {
906
    await this.observeOperation('layercache.expire_by_pattern', undefined, async () => {
3✔
907
      validatePattern(pattern)
3✔
908
      await this.awaitStartup('expireByPattern')
3✔
909
      const keys = await this.keyDiscovery.collectKeysMatchingPattern(
3✔
910
        this.qualifyPattern(pattern),
911
        this.invalidationMaxKeys()
912
      )
913
      await this.expireKeys(keys)
3✔
914
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'expire' })
3✔
915
    })
916
  }
917

918
  /**
919
   * Deletes keys that start with the provided prefix.
920
   */
921
  async invalidateByPrefix(prefix: string): Promise<void> {
922
    await this.observeOperation('layercache.invalidate_by_prefix', undefined, async () => {
8✔
923
      await this.awaitStartup('invalidateByPrefix')
8✔
924
      const qualifiedPrefix = this.qualifyKey(validateCacheKey(prefix))
8✔
925
      const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix, this.invalidationMaxKeys())
8✔
926
      await this.deleteKeys(keys)
7✔
927
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
7✔
928
    })
929
  }
930

931
  /**
932
   * Marks keys that start with the provided prefix as expired while preserving
933
   * stale windows for stale serving.
934
   */
935
  async expireByPrefix(prefix: string): Promise<void> {
936
    await this.observeOperation('layercache.expire_by_prefix', undefined, async () => {
3✔
937
      await this.awaitStartup('expireByPrefix')
3✔
938
      const qualifiedPrefix = this.qualifyKey(validateCacheKey(prefix))
3✔
939
      const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix, this.invalidationMaxKeys())
3✔
940
      await this.expireKeys(keys)
2✔
941
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'expire' })
2✔
942
    })
943
  }
944

945
  /**
946
   * Returns cumulative cache metrics since startup or the last `resetMetrics()`.
947
   */
948
  getMetrics(): CacheMetricsSnapshot {
949
    return this.metricsCollector.snapshot
58✔
950
  }
951

952
  /**
953
   * Runs an operation while collecting only the metrics emitted by its async context.
954
   * Used by namespaces so metrics tracking does not serialize the operation itself.
955
   */
956
  async captureMetrics<T>(operation: () => Promise<T>): Promise<{ result: T; metrics: CacheMetricsSnapshot }> {
957
    return this.metricsCollector.capture(operation)
106✔
958
  }
959

960
  /**
961
   * Returns metrics plus layer degradation state and active background refresh count.
962
   */
963
  getStats(): CacheStatsSnapshot {
964
    return {
23✔
965
      metrics: this.getMetrics(),
966
      layers: this.layers.map((layer) => ({
27✔
967
        name: layer.name,
968
        isLocal: Boolean(layer.isLocal),
969
        degradedUntil: this.layerDegradedUntil.get(layer.name) ?? null
50✔
970
      })),
971
      backgroundRefreshes: this.reader.activeRefreshCount
972
    }
973
  }
974

975
  /**
976
   * Resets cumulative metrics counters.
977
   */
978
  resetMetrics(): void {
979
    this.metricsCollector.reset()
1✔
980
  }
981

982
  /**
983
   * Returns computed hit-rate statistics (overall and per-layer).
984
   */
985
  getHitRate(): CacheHitRateSnapshot {
986
    return this.metricsCollector.hitRate()
6✔
987
  }
988

989
  /**
990
   * Runs each layer's `ping()` hook when available and returns per-layer health
991
   * and latency information.
992
   */
993
  async healthCheck(): Promise<CacheHealthCheckResult[]> {
994
    await this.startup
2✔
995

996
    return Promise.all(
2✔
997
      this.layers.map(async (layer) => {
998
        const startedAt = performance.now()
4✔
999
        try {
4✔
1000
          const healthy = layer.ping ? await layer.ping() : true
4✔
1001
          return {
4✔
1002
            layer: layer.name,
1003
            healthy,
1004
            latencyMs: performance.now() - startedAt
1005
          }
1006
        } catch (error) {
1007
          return {
1✔
1008
            layer: layer.name,
1009
            healthy: false,
1010
            latencyMs: performance.now() - startedAt,
1011
            error: this.formatError(error)
1012
          }
1013
        }
1014
      })
1015
    )
1016
  }
1017

1018
  /**
1019
   * Rotates the active generation prefix used for all future cache keys.
1020
   * Previous-generation keys remain in the underlying layers until they expire,
1021
   * unless `generationCleanup` is enabled to prune them in the background.
1022
   */
1023
  bumpGeneration(nextGeneration?: number): number {
1024
    const current = this.currentGeneration ?? 0
5!
1025
    const previousGeneration = this.currentGeneration
5✔
1026
    const updatedGeneration = nextGeneration ?? current + 1
5✔
1027
    const generationToCleanup = resolveGenerationCleanupTarget({
5✔
1028
      previousGeneration,
1029
      nextGeneration: updatedGeneration,
1030
      generationCleanup: this.options.generationCleanup
1031
    })
1032

1033
    this.currentGeneration = updatedGeneration
5✔
1034
    if (generationToCleanup !== null) {
5✔
1035
      this.scheduleGenerationCleanup(generationToCleanup)
2✔
1036
    }
1037

1038
    return this.currentGeneration
5✔
1039
  }
1040

1041
  /**
1042
   * Returns the active generation prefix number used for future cache keys.
1043
   */
1044
  getGeneration(): number | undefined {
1045
    return this.currentGeneration
3✔
1046
  }
1047

1048
  /**
1049
   * Returns detailed metadata about a single cache key: which layers contain it,
1050
   * remaining fresh/stale/error TTLs, and associated tags.
1051
   * Returns `null` if the key does not exist in any layer.
1052
   */
1053
  async inspect(key: string): Promise<CacheInspectResult | null> {
1054
    const userKey = validateCacheKey(key)
30✔
1055
    const normalizedKey = this.qualifyKey(userKey)
30✔
1056
    await this.awaitStartup('inspect')
30✔
1057

1058
    const foundInLayers: string[] = []
30✔
1059
    let freshTtlMs: number | null = null
30✔
1060
    let staleTtlMs: number | null = null
30✔
1061
    let errorTtlMs: number | null = null
30✔
1062
    let isStale = false
30✔
1063

1064
    for (const layer of this.layers) {
30✔
1065
      if (this.shouldSkipLayer(layer)) {
31!
1066
        continue
×
1067
      }
1068
      const stored = await this.readLayerEntry(layer, normalizedKey)
31✔
1069
      if (stored === null) {
31✔
1070
        continue
1✔
1071
      }
1072

1073
      const resolved = resolveStoredValue(stored)
30✔
1074
      if (resolved.state === 'expired') {
30!
1075
        continue
×
1076
      }
1077

1078
      foundInLayers.push(layer.name)
30✔
1079

1080
      // Take TTL info from the first (fastest) layer that has it
1081
      if (foundInLayers.length === 1 && resolved.envelope) {
30✔
1082
        const now = Date.now()
29✔
1083
        freshTtlMs =
29✔
1084
          resolved.envelope.freshUntil !== null ? Math.max(0, Math.ceil(resolved.envelope.freshUntil - now)) : null
29!
1085
        staleTtlMs =
29✔
1086
          resolved.envelope.staleUntil !== null ? Math.max(0, Math.ceil(resolved.envelope.staleUntil - now)) : null
29✔
1087
        errorTtlMs =
29✔
1088
          resolved.envelope.errorUntil !== null ? Math.max(0, Math.ceil(resolved.envelope.errorUntil - now)) : null
29✔
1089
        isStale = resolved.state === 'stale-while-revalidate' || resolved.state === 'stale-if-error'
29✔
1090
      }
1091
    }
1092

1093
    if (foundInLayers.length === 0) {
30✔
1094
      return null
1✔
1095
    }
1096

1097
    const tags = await this.getTagsForKey(normalizedKey)
29✔
1098

1099
    return { key: userKey, foundInLayers, freshTtlMs, staleTtlMs, errorTtlMs, isStale, tags }
29✔
1100
  }
1101

1102
  /**
1103
   * Exports cache entries from configured layers for process-local snapshots.
1104
   */
1105
  async exportState(): Promise<CacheSnapshotEntry[]> {
1106
    await this.awaitStartup('exportState')
2✔
1107
    return this.snapshots.exportState(this.snapshotMaxEntries())
2✔
1108
  }
1109

1110
  /**
1111
   * Imports entries produced by `exportState()` into the configured layers.
1112
   */
1113
  async importState(entries: CacheSnapshotEntry[]): Promise<void> {
1114
    await this.awaitStartup('importState')
1✔
1115
    await this.snapshots.importState(entries)
1✔
1116
  }
1117

1118
  /**
1119
   * Writes a snapshot file containing current cache entries.
1120
   */
1121
  async persistToFile(filePath: string): Promise<void> {
1122
    this.assertActive('persistToFile')
4✔
1123
    await this.snapshots.persistToFile(filePath, this.options.snapshotBaseDir, this.snapshotMaxEntries())
4✔
1124
  }
1125

1126
  /**
1127
   * Restores cache entries from a snapshot file.
1128
   */
1129
  async restoreFromFile(filePath: string): Promise<void> {
1130
    this.assertActive('restoreFromFile')
8✔
1131
    await this.snapshots.restoreFromFile(filePath, this.options.snapshotBaseDir, this.snapshotMaxBytes())
8✔
1132
  }
1133

1134
  /**
1135
   * Flushes background work, unsubscribes from buses, disposes timers, and then
1136
   * disposes each layer that provides `dispose()`.
1137
   */
1138
  async disconnect(): Promise<void> {
1139
    if (!this.disconnectPromise) {
28!
1140
      this.isDisconnecting = true
28✔
1141
      this.disconnectPromise = (async () => {
28✔
1142
        await this.startup
28✔
1143
        await this.unsubscribeInvalidation?.()
28✔
1144
        await this.flushWriteBehindQueue()
28✔
1145
        await this.maintenance.waitForGenerationCleanup()
28✔
1146
        this.reader.abortAllRefreshes()
28✔
1147
        await Promise.allSettled(
28✔
1148
          this.reader.getAllRefreshPromises().map((promise) => {
1149
            let timer: ReturnType<typeof setTimeout> | undefined
1150
            return Promise.race([
×
1151
              promise,
1152
              new Promise<void>((resolve) => {
1153
                timer = setTimeout(resolve, 5_000)
×
1154
                timer.unref?.()
×
1155
              })
1156
            ]).finally(() => {
1157
              if (timer) clearTimeout(timer)
×
1158
            })
1159
          })
1160
        )
1161
        this.maintenance.disposeWriteBehindTimer()
28✔
1162
        this.fetchRateLimiter.dispose()
28✔
1163
        await Promise.allSettled(this.layers.map((layer) => layer.dispose?.() ?? Promise.resolve()))
42✔
1164
      })()
1165
    }
1166

1167
    await this.disconnectPromise
28✔
1168
  }
1169

1170
  private async initialize(): Promise<void> {
1171
    if (!this.options.invalidationBus) {
269✔
1172
      return
251✔
1173
    }
1174

1175
    this.unsubscribeInvalidation = await this.options.invalidationBus.subscribe(async (message) => {
18✔
1176
      await this.handleInvalidationMessage(message)
12✔
1177
    })
1178
  }
1179

1180
  private async storeEntry(
1181
    key: string,
1182
    kind: CacheWriteKind,
1183
    value: unknown,
1184
    options?: CacheWriteOptions,
1185
    fence?: CacheWriteFence
1186
  ): Promise<boolean> {
1187
    const resolvedOptions = this.resolveContextOptions(key, kind, value, options)
188✔
1188
    const clearEpoch = this.maintenance.currentClearEpoch()
188✔
1189
    const keyEpoch = this.maintenance.currentKeyEpoch(key)
188✔
1190
    const committed = await this.layerWriter.writeAcrossLayers(key, kind, value, resolvedOptions, fence)
188✔
1191
    if (!committed) return false
183✔
1192
    if (this.maintenance.isWriteOutdated(key, clearEpoch, keyEpoch)) {
182!
NEW
1193
      return false
×
1194
    }
1195
    if (resolvedOptions?.tags) {
182✔
1196
      await this.tagIndex.track(key, resolvedOptions.tags)
29✔
1197
    } else {
1198
      await this.tagIndex.touch(key)
153✔
1199
    }
1200

1201
    this.metricsCollector.increment('sets')
182✔
1202
    this.logger.debug?.('set', { key, kind, tags: resolvedOptions?.tags })
182✔
1203
    this.emit('set', { key, kind: kind as string, tags: resolvedOptions?.tags })
188✔
1204
    if (this.shouldBroadcastL1Invalidation()) {
188✔
1205
      await this.publishInvalidation({ scope: 'key', keys: [key], sourceId: this.instanceId, operation: 'write' })
2✔
1206
    }
1207
    return true
182✔
1208
  }
1209

1210
  private async writeBatch(
1211
    entries: Array<{ key: string; value: unknown; options?: CacheWriteOptions }>
1212
  ): Promise<void> {
1213
    const resolvedEntries = entries.map((entry) => ({
32✔
1214
      ...entry,
1215
      options: this.resolveContextOptions(entry.key, 'value', entry.value, entry.options)
1216
    }))
1217
    const { clearEpoch, entryEpochs } = await this.layerWriter.writeBatch(resolvedEntries)
14✔
1218
    if (clearEpoch !== this.maintenance.currentClearEpoch()) {
13!
1219
      return
×
1220
    }
1221

1222
    for (const entry of resolvedEntries) {
13✔
1223
      if (this.maintenance.isWriteOutdated(entry.key, clearEpoch, entryEpochs.get(entry.key))) {
30!
1224
        continue
×
1225
      }
1226
      if (entry.options?.tags) {
30✔
1227
        await this.tagIndex.track(entry.key, entry.options.tags)
2✔
1228
      } else {
1229
        await this.tagIndex.touch(entry.key)
28✔
1230
      }
1231

1232
      this.metricsCollector.increment('sets')
30✔
1233
      this.logger.debug?.('set', { key: entry.key, kind: 'value', tags: entry.options?.tags })
30✔
1234
      this.emit('set', { key: entry.key, kind: 'value', tags: entry.options?.tags })
30✔
1235
    }
1236

1237
    if (this.shouldBroadcastL1Invalidation()) {
13✔
1238
      await this.publishInvalidation({
1✔
1239
        scope: 'keys',
1240
        keys: entries.map((entry) => entry.key),
2✔
1241
        sourceId: this.instanceId,
1242
        operation: 'write'
1243
      })
1244
    }
1245
  }
1246

1247
  private resolveFreshTtl(
1248
    key: string,
1249
    layerName: string,
1250
    kind: CacheWriteKind,
1251
    options: CacheWriteOptions | undefined,
1252
    fallbackTtl: number | undefined,
1253
    value: unknown
1254
  ): number | undefined {
1255
    return this.ttlResolver.resolveFreshTtl(
236✔
1256
      key,
1257
      layerName,
1258
      kind,
1259
      options,
1260
      fallbackTtl,
1261
      this.options.negativeTtl,
1262
      undefined,
1263
      value
1264
    )
1265
  }
1266

1267
  private resolveLayerMs(
1268
    layerName: string,
1269
    override: number | LayerTtlMap | undefined,
1270
    globalDefault?: number | LayerTtlMap,
1271
    fallback?: number
1272
  ): number | undefined {
1273
    return this.ttlResolver.resolveLayerMs(layerName, override, globalDefault, fallback)
553✔
1274
  }
1275

1276
  private resolveContextOptions(
1277
    key: string,
1278
    kind: CacheEntryWriteKind,
1279
    value: unknown,
1280
    options: CacheWriteOptions | undefined
1281
  ): CacheWriteOptions | undefined {
1282
    if (!options?.contextOptions) {
220✔
1283
      return options
212✔
1284
    }
1285

1286
    const { contextOptions, ...baseOptions } = options
8✔
1287
    let overrides: CacheEntryWriteOptions | undefined
1288
    try {
8✔
1289
      overrides = contextOptions({ key, value, kind } as CacheContextOptionsContext)
8✔
1290
    } catch (error) {
1291
      throw new Error(`options.contextOptions() failed for key "${key}": ${this.formatError(error)}`)
1✔
1292
    }
1293
    if (!overrides) {
7✔
1294
      return baseOptions
1✔
1295
    }
1296
    if (!this.isPlainObject(overrides)) {
6✔
1297
      throw new Error(
3✔
1298
        `options.contextOptions() must return a plain object or undefined for key "${key}". Async resolvers are not supported.`
1299
      )
1300
    }
1301

1302
    try {
3✔
1303
      validateContextEntryOptions('options.contextOptions()', overrides)
3✔
1304
    } catch (error) {
1305
      throw new Error(
1✔
1306
        `options.contextOptions() returned invalid entry options for key "${key}": ${this.formatError(error)}`
1307
      )
1308
    }
1309
    return {
2✔
1310
      ...baseOptions,
1311
      ...overrides
1312
    }
1313
  }
1314

1315
  private isPlainObject(value: unknown): value is Record<string, unknown> {
1316
    if (!value || typeof value !== 'object' || Array.isArray(value)) {
6✔
1317
      return false
2✔
1318
    }
1319

1320
    const prototype = Object.getPrototypeOf(value)
4✔
1321
    return prototype === Object.prototype || prototype === null
4✔
1322
  }
1323

1324
  private async deleteKeys(keys: string[]): Promise<void> {
1325
    if (keys.length === 0) {
37✔
1326
      return
4✔
1327
    }
1328

1329
    this.maintenance.bumpKeyEpochs(keys)
33✔
1330
    await this.invalidation.deleteKeysFromLayers(this.layers, keys)
33✔
1331

1332
    for (const key of keys) {
33✔
1333
      await this.tagIndex.remove(key)
42✔
1334
      this.ttlResolver.deleteProfile(key)
42✔
1335
      this.circuitBreakerManager.delete(`key:${key}`)
42✔
1336
    }
1337

1338
    this.metricsCollector.increment('deletes', keys.length)
33✔
1339
    this.metricsCollector.increment('invalidations')
33✔
1340
    this.logger.debug?.('delete', { keys })
33✔
1341
    this.emit('delete', { keys })
37✔
1342
  }
1343

1344
  private async expireKeys(keys: string[]): Promise<void> {
1345
    if (keys.length === 0) {
15✔
1346
      return
1✔
1347
    }
1348

1349
    this.maintenance.bumpKeyEpochs(keys)
14✔
1350
    const foundKeys = await this.expireKeysInLayers(keys, this.layers)
14✔
1351

1352
    for (const key of keys) {
14✔
1353
      if (foundKeys.has(key)) {
18✔
1354
        continue
17✔
1355
      }
1356

1357
      await this.tagIndex.remove(key)
1✔
1358
      this.ttlResolver.deleteProfile(key)
1✔
1359
      this.circuitBreakerManager.delete(`key:${key}`)
1✔
1360
    }
1361

1362
    this.metricsCollector.increment('invalidations')
14✔
1363
    this.logger.debug?.('expire', { keys })
14✔
1364
    this.emit('expire', { keys })
15✔
1365
  }
1366

1367
  private async expireKeysInLayers(keys: string[], layers: CacheLayer[]): Promise<Set<string>> {
1368
    if (keys.length === 0) {
16✔
1369
      return new Set()
1✔
1370
    }
1371

1372
    return this.invalidation.expireKeysInLayers(layers, keys)
15✔
1373
  }
1374

1375
  private async publishInvalidation(message: InvalidationMessage): Promise<void> {
1376
    if (!this.options.invalidationBus) {
59✔
1377
      return
52✔
1378
    }
1379

1380
    await this.options.invalidationBus.publish(message)
7✔
1381
  }
1382

1383
  private async handleInvalidationMessage(message: InvalidationMessage): Promise<void> {
1384
    if (message.sourceId === this.instanceId) {
15✔
1385
      return
7✔
1386
    }
1387

1388
    const localLayers = this.layers.filter((layer) => layer.isLocal)
12✔
1389
    if (message.scope === 'clear') {
8✔
1390
      this.maintenance.beginClearEpoch()
2✔
1391
      await Promise.all(localLayers.map((layer) => layer.clear()))
2✔
1392
      await this.tagIndex.clear()
2✔
1393
      this.ttlResolver.clearProfiles()
2✔
1394
      this.circuitBreakerManager.clear()
2✔
1395
      return
2✔
1396
    }
1397

1398
    const keys = message.keys ?? []
6!
1399
    this.maintenance.bumpKeyEpochs(keys)
15✔
1400
    if (message.operation === 'expire') {
15✔
1401
      await this.expireKeysInLayers(keys, localLayers)
1✔
1402
      return
1✔
1403
    }
1404

1405
    await this.invalidation.deleteKeysFromLayers(localLayers, keys)
5✔
1406

1407
    if (message.operation !== 'write') {
5✔
1408
      for (const key of keys) {
2✔
1409
        await this.tagIndex.remove(key)
3✔
1410
        this.ttlResolver.deleteProfile(key)
3✔
1411
        this.circuitBreakerManager.delete(`key:${key}`)
3✔
1412
      }
1413
    }
1414
  }
1415

1416
  private async getTagsForKey(key: string): Promise<string[]> {
1417
    if (this.tagIndex.tagsForKey) {
31✔
1418
      return this.tagIndex.tagsForKey(key)
30✔
1419
    }
1420
    return []
1✔
1421
  }
1422

1423
  private formatError(error: unknown): string {
1424
    if (error instanceof Error) {
55✔
1425
      return error.message
54✔
1426
    }
1427

1428
    return String(error)
1✔
1429
  }
1430

1431
  private sleep(ms: number): Promise<void> {
1432
    return new Promise((resolve) => setTimeout(resolve, ms))
4✔
1433
  }
1434

1435
  private async withTimeout<T>(promise: Promise<T>, timeoutMs: number, onTimeout: () => Error): Promise<T> {
1436
    if (timeoutMs <= 0) {
14✔
1437
      return promise
1✔
1438
    }
1439

1440
    let timer: ReturnType<typeof setTimeout> | undefined
1441
    const observedPromise = promise.then(
13✔
1442
      (value) => ({ kind: 'value' as const, value }),
10✔
1443
      (error) => ({ kind: 'error' as const, error })
1✔
1444
    )
1445
    try {
13✔
1446
      const result = await Promise.race([
13✔
1447
        observedPromise,
1448
        new Promise<T>((_, reject) => {
1449
          timer = setTimeout(() => reject(onTimeout()), timeoutMs)
13✔
1450
          timer.unref?.()
13✔
1451
        })
1452
      ])
1453
      if (result !== null && result !== undefined && typeof result === 'object' && 'kind' in result) {
10!
1454
        if (result.kind === 'error') {
10✔
1455
          throw result.error
1✔
1456
        }
1457
        return result.value
9✔
1458
      }
1459
      return result
×
1460
    } finally {
1461
      if (timer) {
13!
1462
        clearTimeout(timer)
13✔
1463
      }
1464
    }
1465
  }
1466

1467
  private shouldBroadcastL1Invalidation(): boolean {
1468
    return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false
195✔
1469
  }
1470

1471
  private async observeOperation<T>(
1472
    name: string,
1473
    attributes: Record<string, unknown> | undefined,
1474
    execute: () => Promise<T>
1475
  ): Promise<T> {
1476
    const id = this.nextOperationId
539✔
1477
    this.nextOperationId = (this.nextOperationId + 1) % Number.MAX_SAFE_INTEGER
539✔
1478
    this.emit('operation-start', { id, name, attributes })
539✔
1479

1480
    try {
539✔
1481
      const result = await execute()
539✔
1482
      this.emit('operation-end', {
497✔
1483
        id,
1484
        name,
1485
        attributes,
1486
        success: true,
1487
        result: result === null ? 'null' : undefined
497✔
1488
      })
1489
      return result
539✔
1490
    } catch (error) {
1491
      this.emit('operation-end', {
42✔
1492
        id,
1493
        name,
1494
        attributes,
1495
        success: false,
1496
        error
1497
      })
1498
      throw error
42✔
1499
    }
1500
  }
1501

1502
  private scheduleGenerationCleanup(generation: number): void {
1503
    this.maintenance.scheduleGenerationCleanup(
2✔
1504
      generation,
1505
      async (generationToClean) => this.cleanupGeneration(generationToClean),
2✔
1506
      (failedGeneration, error) => {
1507
        this.logger.warn?.('generation-cleanup-error', {
1✔
1508
          generation: failedGeneration,
1509
          error: this.formatError(error)
1510
        })
1511
      }
1512
    )
1513
  }
1514

1515
  private async cleanupGeneration(generation: number): Promise<void> {
1516
    const prefix = `v${generation}:`
4✔
1517
    const batchSize = resolveGenerationCleanupBatchSize(this.options.generationCleanup)
4✔
1518
    let batch: string[] = []
4✔
1519

1520
    const flushBatch = async (): Promise<void> => {
4✔
1521
      if (batch.length === 0) {
5✔
1522
        return
2✔
1523
      }
1524
      const keys = batch
3✔
1525
      batch = []
3✔
1526
      await this.deleteKeys(keys)
3✔
1527
      await this.publishInvalidation({
3✔
1528
        scope: 'keys',
1529
        keys,
1530
        sourceId: this.instanceId,
1531
        operation: 'invalidate'
1532
      })
1533
    }
1534

1535
    await this.keyDiscovery.forEachKeyWithPrefix(prefix, async (key) => {
4✔
1536
      batch.push(key)
4✔
1537
      if (batch.length >= batchSize) {
4✔
1538
        await flushBatch()
2✔
1539
      }
1540
    })
1541
    await flushBatch()
3✔
1542
  }
1543

1544
  private initializeWriteBehind(options: CacheWriteBehindOptions | undefined): void {
1545
    this.maintenance.initializeWriteBehindTimer(
269✔
1546
      this.options.writeStrategy,
1547
      options,
1548
      this.flushWriteBehindQueue.bind(this)
1549
    )
1550
  }
1551

1552
  private shouldWriteBehind(layer: CacheLayer): boolean {
1553
    return this.options.writeStrategy === 'write-behind' && !layer.isLocal
423✔
1554
  }
1555

1556
  private async enqueueWriteBehind(operation: () => Promise<void>): Promise<void> {
1557
    await this.maintenance.enqueueWriteBehind(operation, this.options.writeBehind, this.runWriteBehindBatch.bind(this))
5✔
1558
  }
1559

1560
  private async flushWriteBehindQueue(): Promise<void> {
1561
    await this.maintenance.flushWriteBehindQueue(this.options.writeBehind, this.runWriteBehindBatch.bind(this))
28✔
1562
  }
1563

1564
  private async runWriteBehindBatch(batch: Array<() => Promise<void>>): Promise<void> {
1565
    const results = await Promise.allSettled(batch.map((operation) => operation()))
4✔
1566
    const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
4✔
1567
    if (failures.length === 0) {
3✔
1568
      return
2✔
1569
    }
1570

1571
    this.metricsCollector.increment('writeFailures', failures.length)
1✔
1572
    this.logger.error?.('write-behind-flush-failure', {
1✔
1573
      failed: failures.length,
1574
      total: batch.length,
1575
      errors: failures.map((failure) => this.formatError(failure.reason))
1✔
1576
    })
1577
    this.emitError('write-behind', { failed: failures.length, total: batch.length })
3✔
1578
  }
1579

1580
  private qualifyKey(key: string): string {
1581
    return qualifyGenerationKey(key, this.currentGeneration)
586✔
1582
  }
1583

1584
  private qualifyPattern(pattern: string): string {
1585
    return qualifyGenerationPattern(pattern, this.currentGeneration)
8✔
1586
  }
1587

1588
  private stripQualifiedKey(key: string): string {
1589
    return stripGenerationPrefix(key, this.currentGeneration)
11✔
1590
  }
1591

1592
  private validateConfiguration(): void {
1593
    if (
273✔
1594
      this.options.broadcastL1Invalidation !== undefined &&
278✔
1595
      this.options.publishSetInvalidation !== undefined &&
1596
      this.options.broadcastL1Invalidation !== this.options.publishSetInvalidation
1597
    ) {
1598
      throw new Error('broadcastL1Invalidation and publishSetInvalidation cannot conflict.')
1✔
1599
    }
1600

1601
    if (this.options.stampedePrevention === false && this.options.singleFlightCoordinator) {
272✔
1602
      throw new Error('singleFlightCoordinator requires stampedePrevention to remain enabled.')
2✔
1603
    }
1604

1605
    validateLayerNumberOption('negativeTtl', this.options.negativeTtl)
270✔
1606
    validateLayerNumberOption('staleWhileRevalidate', this.options.staleWhileRevalidate)
270✔
1607
    validateLayerNumberOption('staleIfError', this.options.staleIfError)
270✔
1608
    validateLayerNumberOption('ttlJitter', this.options.ttlJitter)
270✔
1609
    validateLayerNumberOption('refreshAhead', this.options.refreshAhead)
270✔
1610
    validatePositiveNumber('singleFlightLeaseMs', this.options.singleFlightLeaseMs)
270✔
1611
    validatePositiveNumber('singleFlightTimeoutMs', this.options.singleFlightTimeoutMs)
270✔
1612
    validatePositiveNumber('singleFlightPollMs', this.options.singleFlightPollMs)
270✔
1613
    validatePositiveNumber('singleFlightRenewIntervalMs', this.options.singleFlightRenewIntervalMs)
270✔
1614
    validatePositiveNumber('backgroundRefreshTimeoutMs', this.options.backgroundRefreshTimeoutMs)
270✔
1615
    if (this.options.snapshotMaxBytes !== false) {
270✔
1616
      validatePositiveNumber('snapshotMaxBytes', this.options.snapshotMaxBytes)
268✔
1617
    }
1618
    if (this.options.snapshotMaxEntries !== false) {
269✔
1619
      validatePositiveNumber('snapshotMaxEntries', this.options.snapshotMaxEntries)
268✔
1620
    }
1621
    if (this.options.invalidationMaxKeys !== false) {
269✔
1622
      validatePositiveNumber('invalidationMaxKeys', this.options.invalidationMaxKeys)
267✔
1623
    }
1624
    validateRateLimitOptions('fetcherRateLimit', this.options.fetcherRateLimit)
269✔
1625
    validateAdaptiveTtlOptions(this.options.adaptiveTtl)
269✔
1626
    validateCircuitBreakerOptions(this.options.circuitBreaker)
269✔
1627
    if (typeof this.options.generationCleanup === 'object') {
269✔
1628
      validatePositiveNumber('generationCleanup.batchSize', this.options.generationCleanup.batchSize)
3✔
1629
    }
1630
    if (this.options.generation !== undefined) {
269✔
1631
      validateNonNegativeNumber('generation', this.options.generation)
7✔
1632
    }
1633
  }
1634

1635
  private validateWriteOptions(options: CacheWriteOptions | undefined): void {
1636
    if (!options) {
502✔
1637
      return
329✔
1638
    }
1639

1640
    validateLayerNumberOption('options.ttl', options.ttl)
173✔
1641
    validateLayerNumberOption('options.negativeTtl', options.negativeTtl)
173✔
1642
    validateLayerNumberOption('options.staleWhileRevalidate', options.staleWhileRevalidate)
173✔
1643
    validateLayerNumberOption('options.staleIfError', options.staleIfError)
173✔
1644
    validateLayerNumberOption('options.ttlJitter', options.ttlJitter)
173✔
1645
    validateLayerNumberOption('options.refreshAhead', options.refreshAhead)
173✔
1646
    validateTtlPolicy('options.ttlPolicy', options.ttlPolicy)
173✔
1647
    validateAdaptiveTtlOptions(options.adaptiveTtl)
173✔
1648
    validateCircuitBreakerOptions(options.circuitBreaker)
173✔
1649
    validateRateLimitOptions('options.fetcherRateLimit', options.fetcherRateLimit)
173✔
1650
    validateTags(options.tags)
173✔
1651
    if (options.contextOptions && typeof options.contextOptions !== 'function') {
173✔
1652
      throw new Error('options.contextOptions must be a function.')
1✔
1653
    }
1654
  }
1655

1656
  private assertActive(operation: string): void {
1657
    if (this.isDisconnecting) {
1,196✔
1658
      throw new Error(`CacheStack is disconnecting; cannot perform ${operation}.`)
5✔
1659
    }
1660
  }
1661

1662
  private async awaitStartup(operation: string): Promise<void> {
1663
    this.assertActive(operation)
581✔
1664
    await this.startup
581✔
1665
    this.assertActive(operation)
576✔
1666
  }
1667

1668
  private async readLayerEntry(layer: CacheLayer, key: string): Promise<unknown | null> {
1669
    return this.reader.readLayerEntry(layer, key)
39✔
1670
  }
1671

1672
  private scheduleBackgroundRefresh<T>(
1673
    key: string,
1674
    fetcher: CacheFetcher<T>,
1675
    options?: CacheGetOptions,
1676
    fetcherContext?: CacheFetcherContext<T>
1677
  ): void {
1678
    this.reader.runScheduleBackgroundRefresh(key, fetcher, options, fetcherContext)
1✔
1679
  }
1680

1681
  private async applyFreshReadPolicies<T>(
1682
    key: string,
1683
    hit: {
1684
      found: true
1685
      value: T | null
1686
      stored: unknown
1687
      state: 'fresh' | 'stale-while-revalidate' | 'stale-if-error'
1688
      layerIndex: number
1689
      layerName: string
1690
    },
1691
    options: CacheGetOptions | undefined,
1692
    fetcher?: CacheFetcher<T>
1693
  ): Promise<void> {
1694
    return this.reader.runApplyFreshReadPolicies(key, hit, options, fetcher)
2✔
1695
  }
1696

1697
  private shouldSkipLayer(layer: CacheLayer): boolean {
1698
    const degradedUntil = this.layerDegradedUntil.get(layer.name)
1,100✔
1699
    const skip = shouldSkipDegradedLayer(degradedUntil)
1,100✔
1700
    if (!skip && degradedUntil !== undefined) {
1,100✔
1701
      this.layerDegradedUntil.delete(layer.name)
1✔
1702
    }
1703
    return skip
1,100✔
1704
  }
1705

1706
  private async handleLayerFailure(layer: CacheLayer, operation: string, error: unknown): Promise<null> {
1707
    const recovery = resolveRecoverableLayerFailure(this.options.gracefulDegradation)
17✔
1708
    if (!recovery.degrade) {
17✔
1709
      throw error
4✔
1710
    }
1711

1712
    this.layerDegradedUntil.set(layer.name, recovery.degradedUntil)
13✔
1713
    this.metricsCollector.increment('degradedOperations')
13✔
1714
    this.logger.warn?.('layer-degraded', { layer: layer.name, operation, error: this.formatError(error) })
13✔
1715
    this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) })
17✔
1716
    return null
17✔
1717
  }
1718

1719
  private async reportRecoverableLayerFailure(layer: CacheLayer, operation: string, error: unknown): Promise<void> {
1720
    if (this.isGracefulDegradationEnabled()) {
7✔
1721
      await this.handleLayerFailure(layer, operation, error)
5✔
1722
      return
5✔
1723
    }
1724

1725
    this.logger.warn?.('layer-operation-failed', { layer: layer.name, operation, error: this.formatError(error) })
2✔
1726
    this.emitError(operation, { layer: layer.name, degraded: false, error: this.formatError(error) })
7✔
1727
  }
1728

1729
  private isGracefulDegradationEnabled(): boolean {
1730
    return Boolean(this.options.gracefulDegradation)
9✔
1731
  }
1732

1733
  private recordCircuitFailure(
1734
    key: string,
1735
    breakerKey: string,
1736
    options: CacheCircuitBreakerOptions | undefined,
1737
    error: unknown
1738
  ): void {
1739
    if (!options) {
14✔
1740
      return
8✔
1741
    }
1742

1743
    this.circuitBreakerManager.recordFailure(breakerKey, options)
6✔
1744
    if (this.circuitBreakerManager.isOpen(breakerKey)) {
6!
1745
      this.metricsCollector.increment('circuitBreakerTrips')
6✔
1746
    }
1747
    this.emitError('fetch', { key, breakerKey, error: this.formatError(error) })
6✔
1748
  }
1749

1750
  private emitError(operation: string, context: Record<string, unknown>): void {
1751
    this.logger.error?.(operation, context)
26✔
1752
    if (this.listenerCount('error') > 0) {
26✔
1753
      this.emit('error', { operation, ...context })
9✔
1754
    }
1755
  }
1756

1757
  private snapshotMaxBytes(): number | false {
1758
    return this.options.snapshotMaxBytes === false
10✔
1759
      ? false
1760
      : (this.options.snapshotMaxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES)
17✔
1761
  }
1762

1763
  private snapshotMaxEntries(): number | false {
1764
    return this.options.snapshotMaxEntries === false
8✔
1765
      ? false
1766
      : (this.options.snapshotMaxEntries ?? DEFAULT_SNAPSHOT_MAX_ENTRIES)
13✔
1767
  }
1768

1769
  private invalidationMaxKeys(): number | false {
1770
    return this.options.invalidationMaxKeys === false
49✔
1771
      ? false
1772
      : (this.options.invalidationMaxKeys ?? DEFAULT_INVALIDATION_MAX_KEYS)
88✔
1773
  }
1774
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc