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

flyingsquirrel0419 / layercache / 29462197702

16 Jul 2026 12:43AM UTC coverage: 96.103% (+0.2%) from 95.882%
29462197702

push

github

web-flow
Merge pull request #106 from flyingsquirrel0419/release/4.0.0

Prepare Layercache 4.0.0 release

2023 of 2171 branches covered (93.18%)

Branch coverage included in aggregate %.

433 of 442 new or added lines in 25 files covered. (97.96%)

2 existing lines in 2 files now uncovered.

3550 of 3628 relevant lines covered (97.85%)

391.95 hits per line

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

93.9
/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
  resolveGenerationCleanupMaxMatches,
16
  resolveGenerationCleanupTarget,
17
  stripGenerationPrefix
18
} from './internal/CacheStackGeneration'
19
import { CacheStackInvalidationSupport } from './internal/CacheStackInvalidationSupport'
20
import { CacheStackLayerWriter, type CacheWriteFence, type CacheWriteKind } from './internal/CacheStackLayerWriter'
21
import { CacheStackMaintenance } from './internal/CacheStackMaintenance'
22
import { CacheStackReader } from './internal/CacheStackReader'
23
import {
24
  resolveRecoverableLayerFailure,
25
  shouldSkipLayer as shouldSkipDegradedLayer
26
} from './internal/CacheStackRuntimePolicy'
27
import { CacheStackSnapshotManager } from './internal/CacheStackSnapshotManager'
28
import {
29
  validateAdaptiveTtlOptions,
30
  validateCacheKey,
31
  validateCircuitBreakerOptions,
32
  validateContextEntryOptions,
33
  validateLayerNumberOption,
34
  validateNonNegativeNumber,
35
  validatePattern,
36
  validatePositiveNumber,
37
  validateRateLimitOptions,
38
  validateTag,
39
  validateTags,
40
  validateTtlPolicy
41
} from './internal/CacheStackValidation'
42
import { CircuitBreakerManager } from './internal/CircuitBreakerManager'
43
import { FetchRateLimiter } from './internal/FetchRateLimiter'
44
import { MetricsCollector } from './internal/MetricsCollector'
45
import { isStoredValueEnvelope, resolveStoredValue } from './internal/StoredValue'
46
import { TtlResolver } from './internal/TtlResolver'
47
import { TagIndex } from './invalidation/TagIndex'
48
import { JsonSerializer } from './serialization/JsonSerializer'
49
import { StampedeGuard } from './stampede/StampedeGuard'
50
import {
51
  type CacheAdaptiveTtlOptions,
52
  type CacheCircuitBreakerOptions,
53
  type CacheContextOptionsContext,
54
  type CacheEntryResult,
55
  type CacheEntryWriteKind,
56
  type CacheEntryWriteOptions,
57
  type CacheFetcher,
58
  type CacheFetcherContext,
59
  type CacheGetOptions,
60
  type CacheHealthCheckResult,
61
  type CacheHitRateSnapshot,
62
  type CacheInspectResult,
63
  type CacheLayer,
64
  type CacheLayerSetManyEntry,
65
  type CacheLogger,
66
  type CacheMGetEntry,
67
  type CacheMSetEntry,
68
  type CacheMetricsSnapshot,
69
  CacheMissError,
70
  type CacheSnapshotEntry,
71
  type CacheStackEvents,
72
  type CacheStackOptions,
73
  type CacheStatsSnapshot,
74
  type CacheTagIndex,
75
  type CacheTtlPolicy,
76
  type CacheWarmEntry,
77
  type CacheWarmOptions,
78
  type CacheWarmProgress,
79
  type CacheWrapOptions,
80
  type CacheWriteBehindOptions,
81
  type CacheWriteOptions,
82
  type InvalidationMessage,
83
  type LayerTtlMap
84
} from './types'
85

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

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

94
  constructor(enabled: boolean) {
95
    this.enabled = enabled
265✔
96
  }
97

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

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

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

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

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

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

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

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

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

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

185
    this.validateConfiguration()
280✔
186

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

654
            return existing.promise
×
655
          })
656
        )
657
      }
658

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

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

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

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

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

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

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

712
          await this.tagIndex.touch(key)
11✔
713
          await this.reader.backfill(key, stored, layerIndex - 1, undefined, readFences.get(key))
11✔
714
          resultsByKey.set(
11✔
715
            key,
716
            isStoredValueEnvelope(stored) && stored.kind === 'empty' ? undefined : (resolved.value as T)
29!
717
          )
718
          pending.delete(key)
15✔
719
          this.metricsCollector.increment('hits', indexesByKey.get(key)?.length ?? 1)
15!
720
        }
721
      }
722

723
      if (pending.size > 0) {
7✔
724
        for (const key of pending) {
3✔
725
          await this.tagIndex.remove(key)
4✔
726
          this.metricsCollector.increment('misses', indexesByKey.get(key)?.length ?? 1)
4!
727
        }
728
      }
729

730
      return normalizedEntries.map((entry) => resultsByKey.get(entry.key))
16✔
731
    })
732
  }
733

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

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

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

786
    await Promise.all(workers)
4✔
787
  }
788

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

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

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

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

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

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

862
      await this.deleteKeys(keys)
5✔
863
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
3✔
864
    })
865
  }
866

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

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

885
      await this.expireKeys(keys)
3✔
886
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'expire' })
2✔
887
    })
888
  }
889

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

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

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

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

950
  /**
951
   * Returns cumulative cache metrics since startup or the last `resetMetrics()`.
952
   */
953
  getMetrics(): CacheMetricsSnapshot {
954
    return this.metricsCollector.snapshot
58✔
955
  }
956

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

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

980
  /**
981
   * Resets cumulative metrics counters.
982
   */
983
  resetMetrics(): void {
984
    this.metricsCollector.reset()
1✔
985
  }
986

987
  /**
988
   * Returns computed hit-rate statistics (overall and per-layer).
989
   */
990
  getHitRate(): CacheHitRateSnapshot {
991
    return this.metricsCollector.hitRate()
6✔
992
  }
993

994
  /**
995
   * Runs each layer's `ping()` hook when available and returns per-layer health
996
   * and latency information.
997
   */
998
  async healthCheck(): Promise<CacheHealthCheckResult[]> {
999
    await this.startup
2✔
1000

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

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

1038
    this.currentGeneration = updatedGeneration
5✔
1039
    if (generationToCleanup !== null) {
5✔
1040
      this.scheduleGenerationCleanup(generationToCleanup)
2✔
1041
    }
1042

1043
    return this.currentGeneration
5✔
1044
  }
1045

1046
  /**
1047
   * Returns the active generation prefix number used for future cache keys.
1048
   */
1049
  getGeneration(): number | undefined {
1050
    return this.currentGeneration
3✔
1051
  }
1052

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

1063
    const foundInLayers: string[] = []
30✔
1064
    let freshTtlMs: number | null = null
30✔
1065
    let staleTtlMs: number | null = null
30✔
1066
    let errorTtlMs: number | null = null
30✔
1067
    let isStale = false
30✔
1068

1069
    for (const layer of this.layers) {
30✔
1070
      if (this.shouldSkipLayer(layer)) {
31!
1071
        continue
×
1072
      }
1073
      const stored = await this.readLayerEntry(layer, normalizedKey)
31✔
1074
      if (stored === null) {
31✔
1075
        continue
1✔
1076
      }
1077

1078
      const resolved = resolveStoredValue(stored)
30✔
1079
      if (resolved.state === 'expired') {
30!
1080
        continue
×
1081
      }
1082

1083
      foundInLayers.push(layer.name)
30✔
1084

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

1098
    if (foundInLayers.length === 0) {
30✔
1099
      return null
1✔
1100
    }
1101

1102
    const tags = await this.getTagsForKey(normalizedKey)
29✔
1103

1104
    return { key: userKey, foundInLayers, freshTtlMs, staleTtlMs, errorTtlMs, isStale, tags }
29✔
1105
  }
1106

1107
  /**
1108
   * Exports cache entries from configured layers for process-local snapshots.
1109
   */
1110
  async exportState(): Promise<CacheSnapshotEntry[]> {
1111
    await this.awaitStartup('exportState')
2✔
1112
    return this.snapshots.exportState(this.snapshotMaxEntries())
2✔
1113
  }
1114

1115
  /**
1116
   * Imports entries produced by `exportState()` into the configured layers.
1117
   */
1118
  async importState(entries: CacheSnapshotEntry[]): Promise<void> {
1119
    await this.awaitStartup('importState')
1✔
1120
    await this.snapshots.importState(entries)
1✔
1121
  }
1122

1123
  /**
1124
   * Writes a snapshot file containing current cache entries.
1125
   */
1126
  async persistToFile(filePath: string): Promise<void> {
1127
    this.assertActive('persistToFile')
4✔
1128
    await this.snapshots.persistToFile(filePath, this.options.snapshotBaseDir, this.snapshotMaxEntries())
4✔
1129
  }
1130

1131
  /**
1132
   * Restores cache entries from a snapshot file.
1133
   */
1134
  async restoreFromFile(filePath: string): Promise<void> {
1135
    this.assertActive('restoreFromFile')
8✔
1136
    await this.snapshots.restoreFromFile(filePath, this.options.snapshotBaseDir, this.snapshotMaxBytes())
8✔
1137
  }
1138

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

1172
    await this.disconnectPromise
28✔
1173
  }
1174

1175
  private async initialize(): Promise<void> {
1176
    if (!this.options.invalidationBus) {
272✔
1177
      return
254✔
1178
    }
1179

1180
    this.unsubscribeInvalidation = await this.options.invalidationBus.subscribe(async (message) => {
18✔
1181
      await this.handleInvalidationMessage(message)
12✔
1182
    })
1183
  }
1184

1185
  private async storeEntry(
1186
    key: string,
1187
    kind: CacheWriteKind,
1188
    value: unknown,
1189
    options?: CacheWriteOptions,
1190
    fence?: CacheWriteFence
1191
  ): Promise<boolean> {
1192
    const resolvedOptions = this.resolveContextOptions(key, kind, value, options)
190✔
1193
    const clearEpoch = this.maintenance.currentClearEpoch()
190✔
1194
    const keyEpoch = this.maintenance.currentKeyEpoch(key)
190✔
1195
    const committed = await this.layerWriter.writeAcrossLayers(key, kind, value, resolvedOptions, fence)
190✔
1196
    if (!committed) return false
185✔
1197
    if (this.maintenance.isWriteOutdated(key, clearEpoch, keyEpoch)) {
184!
NEW
1198
      return false
×
1199
    }
1200
    if (resolvedOptions?.tags) {
184✔
1201
      await this.tagIndex.track(key, resolvedOptions.tags)
29✔
1202
    } else {
1203
      await this.tagIndex.touch(key)
155✔
1204
    }
1205

1206
    this.metricsCollector.increment('sets')
184✔
1207
    this.logger.debug?.('set', { key, kind, tags: resolvedOptions?.tags })
184✔
1208
    this.emit('set', { key, kind: kind as string, tags: resolvedOptions?.tags })
190✔
1209
    if (this.shouldBroadcastL1Invalidation()) {
190✔
1210
      await this.publishInvalidation({ scope: 'key', keys: [key], sourceId: this.instanceId, operation: 'write' })
2✔
1211
    }
1212
    return true
184✔
1213
  }
1214

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

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

1237
      this.metricsCollector.increment('sets')
30✔
1238
      this.logger.debug?.('set', { key: entry.key, kind: 'value', tags: entry.options?.tags })
30✔
1239
      this.emit('set', { key: entry.key, kind: 'value', tags: entry.options?.tags })
30✔
1240
    }
1241

1242
    if (this.shouldBroadcastL1Invalidation()) {
13✔
1243
      await this.publishInvalidation({
1✔
1244
        scope: 'keys',
1245
        keys: entries.map((entry) => entry.key),
2✔
1246
        sourceId: this.instanceId,
1247
        operation: 'write'
1248
      })
1249
    }
1250
  }
1251

1252
  private resolveFreshTtl(
1253
    key: string,
1254
    layerName: string,
1255
    kind: CacheWriteKind,
1256
    options: CacheWriteOptions | undefined,
1257
    fallbackTtl: number | undefined,
1258
    value: unknown
1259
  ): number | undefined {
1260
    return this.ttlResolver.resolveFreshTtl(
238✔
1261
      key,
1262
      layerName,
1263
      kind,
1264
      options,
1265
      fallbackTtl,
1266
      this.options.negativeTtl,
1267
      undefined,
1268
      value
1269
    )
1270
  }
1271

1272
  private resolveLayerMs(
1273
    layerName: string,
1274
    override: number | LayerTtlMap | undefined,
1275
    globalDefault?: number | LayerTtlMap,
1276
    fallback?: number
1277
  ): number | undefined {
1278
    return this.ttlResolver.resolveLayerMs(layerName, override, globalDefault, fallback)
559✔
1279
  }
1280

1281
  private resolveContextOptions(
1282
    key: string,
1283
    kind: CacheEntryWriteKind,
1284
    value: unknown,
1285
    options: CacheWriteOptions | undefined
1286
  ): CacheWriteOptions | undefined {
1287
    if (!options?.contextOptions) {
222✔
1288
      return options
214✔
1289
    }
1290

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

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

1320
  private isPlainObject(value: unknown): value is Record<string, unknown> {
1321
    if (!value || typeof value !== 'object' || Array.isArray(value)) {
6✔
1322
      return false
2✔
1323
    }
1324

1325
    const prototype = Object.getPrototypeOf(value)
4✔
1326
    return prototype === Object.prototype || prototype === null
4✔
1327
  }
1328

1329
  private async deleteKeys(keys: string[]): Promise<void> {
1330
    if (keys.length === 0) {
37✔
1331
      return
4✔
1332
    }
1333

1334
    this.maintenance.bumpKeyEpochs(keys)
33✔
1335
    await this.invalidation.deleteKeysFromLayers(this.layers, keys)
33✔
1336

1337
    for (const key of keys) {
33✔
1338
      await this.tagIndex.remove(key)
42✔
1339
      this.ttlResolver.deleteProfile(key)
42✔
1340
      this.circuitBreakerManager.delete(`key:${key}`)
42✔
1341
    }
1342

1343
    this.metricsCollector.increment('deletes', keys.length)
33✔
1344
    this.metricsCollector.increment('invalidations')
33✔
1345
    this.logger.debug?.('delete', { keys })
33✔
1346
    this.emit('delete', { keys })
37✔
1347
  }
1348

1349
  private async expireKeys(keys: string[]): Promise<void> {
1350
    if (keys.length === 0) {
15✔
1351
      return
1✔
1352
    }
1353

1354
    this.maintenance.bumpKeyEpochs(keys)
14✔
1355
    const foundKeys = await this.expireKeysInLayers(keys, this.layers)
14✔
1356

1357
    for (const key of keys) {
14✔
1358
      if (foundKeys.has(key)) {
18✔
1359
        continue
17✔
1360
      }
1361

1362
      await this.tagIndex.remove(key)
1✔
1363
      this.ttlResolver.deleteProfile(key)
1✔
1364
      this.circuitBreakerManager.delete(`key:${key}`)
1✔
1365
    }
1366

1367
    this.metricsCollector.increment('invalidations')
14✔
1368
    this.logger.debug?.('expire', { keys })
14✔
1369
    this.emit('expire', { keys })
15✔
1370
  }
1371

1372
  private async expireKeysInLayers(keys: string[], layers: CacheLayer[]): Promise<Set<string>> {
1373
    if (keys.length === 0) {
16✔
1374
      return new Set()
1✔
1375
    }
1376

1377
    return this.invalidation.expireKeysInLayers(layers, keys)
15✔
1378
  }
1379

1380
  private async publishInvalidation(message: InvalidationMessage): Promise<void> {
1381
    if (!this.options.invalidationBus) {
59✔
1382
      return
52✔
1383
    }
1384

1385
    await this.options.invalidationBus.publish(message)
7✔
1386
  }
1387

1388
  private async handleInvalidationMessage(message: InvalidationMessage): Promise<void> {
1389
    if (message.sourceId === this.instanceId) {
15✔
1390
      return
7✔
1391
    }
1392

1393
    const localLayers = this.layers.filter((layer) => layer.isLocal)
12✔
1394
    if (message.scope === 'clear') {
8✔
1395
      this.maintenance.beginClearEpoch()
2✔
1396
      await Promise.all(localLayers.map((layer) => layer.clear()))
2✔
1397
      await this.tagIndex.clear()
2✔
1398
      this.ttlResolver.clearProfiles()
2✔
1399
      this.circuitBreakerManager.clear()
2✔
1400
      return
2✔
1401
    }
1402

1403
    const keys = message.keys ?? []
6!
1404
    this.maintenance.bumpKeyEpochs(keys)
15✔
1405
    if (message.operation === 'expire') {
15✔
1406
      await this.expireKeysInLayers(keys, localLayers)
1✔
1407
      return
1✔
1408
    }
1409

1410
    await this.invalidation.deleteKeysFromLayers(localLayers, keys)
5✔
1411

1412
    if (message.operation !== 'write') {
5✔
1413
      for (const key of keys) {
2✔
1414
        await this.tagIndex.remove(key)
3✔
1415
        this.ttlResolver.deleteProfile(key)
3✔
1416
        this.circuitBreakerManager.delete(`key:${key}`)
3✔
1417
      }
1418
    }
1419
  }
1420

1421
  private async getTagsForKey(key: string): Promise<string[]> {
1422
    if (this.tagIndex.tagsForKey) {
31✔
1423
      return this.tagIndex.tagsForKey(key)
30✔
1424
    }
1425
    return []
1✔
1426
  }
1427

1428
  private formatError(error: unknown): string {
1429
    if (error instanceof Error) {
55✔
1430
      return error.message
54✔
1431
    }
1432

1433
    return String(error)
1✔
1434
  }
1435

1436
  private sleep(ms: number): Promise<void> {
1437
    return new Promise((resolve) => setTimeout(resolve, ms))
4✔
1438
  }
1439

1440
  private async withTimeout<T>(promise: Promise<T>, timeoutMs: number, onTimeout: () => Error): Promise<T> {
1441
    if (timeoutMs <= 0) {
14✔
1442
      return promise
1✔
1443
    }
1444

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

1472
  private shouldBroadcastL1Invalidation(): boolean {
1473
    return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false
197✔
1474
  }
1475

1476
  private async observeOperation<T>(
1477
    name: string,
1478
    attributes: Record<string, unknown> | undefined,
1479
    execute: () => Promise<T>
1480
  ): Promise<T> {
1481
    const id = this.nextOperationId
547✔
1482
    this.nextOperationId = (this.nextOperationId + 1) % Number.MAX_SAFE_INTEGER
547✔
1483
    this.emit('operation-start', { id, name, attributes })
547✔
1484

1485
    try {
547✔
1486
      const result = await execute()
547✔
1487
      this.emit('operation-end', {
505✔
1488
        id,
1489
        name,
1490
        attributes,
1491
        success: true,
1492
        result: result === null ? 'null' : result === undefined ? 'undefined' : undefined
1,003✔
1493
      })
1494
      return result
547✔
1495
    } catch (error) {
1496
      this.emit('operation-end', {
42✔
1497
        id,
1498
        name,
1499
        attributes,
1500
        success: false,
1501
        error
1502
      })
1503
      throw error
42✔
1504
    }
1505
  }
1506

1507
  private scheduleGenerationCleanup(generation: number): void {
1508
    this.maintenance.scheduleGenerationCleanup(
2✔
1509
      generation,
1510
      async (generationToClean) => this.cleanupGeneration(generationToClean),
2✔
1511
      (failedGeneration, error) => {
1512
        this.logger.warn?.('generation-cleanup-error', {
1✔
1513
          generation: failedGeneration,
1514
          error: this.formatError(error)
1515
        })
1516
      }
1517
    )
1518
  }
1519

1520
  private async cleanupGeneration(generation: number): Promise<void> {
1521
    const prefix = `v${generation}:`
4✔
1522
    const batchSize = resolveGenerationCleanupBatchSize(this.options.generationCleanup)
4✔
1523
    const maxMatches = resolveGenerationCleanupMaxMatches(this.options.generationCleanup)
4✔
1524
    let batch: string[] = []
4✔
1525

1526
    const flushBatch = async (): Promise<void> => {
4✔
1527
      if (batch.length === 0) {
5✔
1528
        return
2✔
1529
      }
1530
      const keys = batch
3✔
1531
      batch = []
3✔
1532
      await this.deleteKeys(keys)
3✔
1533
      await this.publishInvalidation({
3✔
1534
        scope: 'keys',
1535
        keys,
1536
        sourceId: this.instanceId,
1537
        operation: 'invalidate'
1538
      })
1539
    }
1540

1541
    await this.keyDiscovery.forEachKeyWithPrefix(
4✔
1542
      prefix,
1543
      async (key) => {
1544
        batch.push(key)
4✔
1545
        if (batch.length >= batchSize) {
4✔
1546
          await flushBatch()
2✔
1547
        }
1548
      },
1549
      maxMatches
1550
    )
1551
    await flushBatch()
3✔
1552
  }
1553

1554
  private initializeWriteBehind(options: CacheWriteBehindOptions | undefined): void {
1555
    this.maintenance.initializeWriteBehindTimer(
272✔
1556
      this.options.writeStrategy,
1557
      options,
1558
      this.flushWriteBehindQueue.bind(this)
1559
    )
1560
  }
1561

1562
  private shouldWriteBehind(layer: CacheLayer): boolean {
1563
    return this.options.writeStrategy === 'write-behind' && !layer.isLocal
427✔
1564
  }
1565

1566
  private async enqueueWriteBehind(operation: () => Promise<void>): Promise<void> {
1567
    await this.maintenance.enqueueWriteBehind(operation, this.options.writeBehind, this.runWriteBehindBatch.bind(this))
5✔
1568
  }
1569

1570
  private async flushWriteBehindQueue(): Promise<void> {
1571
    await this.maintenance.flushWriteBehindQueue(this.options.writeBehind, this.runWriteBehindBatch.bind(this))
28✔
1572
  }
1573

1574
  private async runWriteBehindBatch(batch: Array<() => Promise<void>>): Promise<void> {
1575
    // Queue order is part of the stale-write fence: an older operation may
1576
    // perform compensating cleanup, so it must settle before a newer write.
1577
    const failures: unknown[] = []
3✔
1578
    for (const operation of batch) {
3✔
1579
      try {
4✔
1580
        await operation()
4✔
1581
      } catch (error) {
1582
        failures.push(error)
1✔
1583
      }
1584
    }
1585
    if (failures.length === 0) {
3✔
1586
      return
2✔
1587
    }
1588

1589
    this.metricsCollector.increment('writeFailures', failures.length)
1✔
1590
    this.logger.error?.('write-behind-flush-failure', {
1✔
1591
      failed: failures.length,
1592
      total: batch.length,
1593
      errors: failures.map((failure) => this.formatError(failure))
1✔
1594
    })
1595
    this.emitError('write-behind', { failed: failures.length, total: batch.length })
3✔
1596
  }
1597

1598
  private qualifyKey(key: string): string {
1599
    return qualifyGenerationKey(key, this.currentGeneration)
595✔
1600
  }
1601

1602
  private qualifyPattern(pattern: string): string {
1603
    return qualifyGenerationPattern(pattern, this.currentGeneration)
8✔
1604
  }
1605

1606
  private stripQualifiedKey(key: string): string {
1607
    return stripGenerationPrefix(key, this.currentGeneration)
11✔
1608
  }
1609

1610
  private validateConfiguration(): void {
1611
    if (
280✔
1612
      this.options.broadcastL1Invalidation !== undefined &&
285✔
1613
      this.options.publishSetInvalidation !== undefined &&
1614
      this.options.broadcastL1Invalidation !== this.options.publishSetInvalidation
1615
    ) {
1616
      throw new Error('broadcastL1Invalidation and publishSetInvalidation cannot conflict.')
1✔
1617
    }
1618

1619
    if (this.options.stampedePrevention === false && this.options.singleFlightCoordinator) {
279✔
1620
      throw new Error('singleFlightCoordinator requires stampedePrevention to remain enabled.')
2✔
1621
    }
1622

1623
    validateLayerNumberOption('negativeTtl', this.options.negativeTtl)
277✔
1624
    validateLayerNumberOption('staleWhileRevalidate', this.options.staleWhileRevalidate)
277✔
1625
    validateLayerNumberOption('staleIfError', this.options.staleIfError)
277✔
1626
    validateLayerNumberOption('ttlJitter', this.options.ttlJitter)
277✔
1627
    validateLayerNumberOption('refreshAhead', this.options.refreshAhead)
277✔
1628
    validatePositiveNumber('singleFlightLeaseMs', this.options.singleFlightLeaseMs)
277✔
1629
    validatePositiveNumber('singleFlightTimeoutMs', this.options.singleFlightTimeoutMs)
277✔
1630
    validatePositiveNumber('singleFlightPollMs', this.options.singleFlightPollMs)
277✔
1631
    validatePositiveNumber('singleFlightRenewIntervalMs', this.options.singleFlightRenewIntervalMs)
277✔
1632
    validatePositiveNumber('backgroundRefreshTimeoutMs', this.options.backgroundRefreshTimeoutMs)
277✔
1633
    if (this.options.snapshotMaxBytes !== false) {
277✔
1634
      validatePositiveNumber('snapshotMaxBytes', this.options.snapshotMaxBytes)
275✔
1635
    }
1636
    if (this.options.snapshotMaxEntries !== false) {
276✔
1637
      validatePositiveNumber('snapshotMaxEntries', this.options.snapshotMaxEntries)
275✔
1638
    }
1639
    if (this.options.invalidationMaxKeys !== false) {
276✔
1640
      validatePositiveNumber('invalidationMaxKeys', this.options.invalidationMaxKeys)
274✔
1641
    }
1642
    validateRateLimitOptions('fetcherRateLimit', this.options.fetcherRateLimit)
276✔
1643
    validateAdaptiveTtlOptions(this.options.adaptiveTtl)
276✔
1644
    validateCircuitBreakerOptions(this.options.circuitBreaker)
276✔
1645
    if (typeof this.options.generationCleanup === 'object') {
276✔
1646
      validatePositiveNumber('generationCleanup.batchSize', this.options.generationCleanup.batchSize)
4✔
1647
      if (this.options.generationCleanup.maxMatches !== false) {
4!
1648
        validatePositiveNumber('generationCleanup.maxMatches', this.options.generationCleanup.maxMatches)
4✔
1649
      }
1650
    }
1651
    validatePositiveNumber('writeCoordination.maxPendingWrites', this.options.writeCoordination?.maxPendingWrites)
275✔
1652
    validatePositiveNumber('writeCoordination.maxActiveKeys', this.options.writeCoordination?.maxActiveKeys)
280✔
1653
    validatePositiveNumber(
280✔
1654
      'writeCoordination.maxPendingWritesPerKey',
1655
      this.options.writeCoordination?.maxPendingWritesPerKey
1656
    )
1657
    if (this.options.generation !== undefined) {
280✔
1658
      validateNonNegativeNumber('generation', this.options.generation)
7✔
1659
    }
1660
  }
1661

1662
  private validateWriteOptions(options: CacheWriteOptions | undefined): void {
1663
    if (!options) {
511✔
1664
      return
335✔
1665
    }
1666

1667
    validateLayerNumberOption('options.ttl', options.ttl)
176✔
1668
    validateLayerNumberOption('options.negativeTtl', options.negativeTtl)
176✔
1669
    validateLayerNumberOption('options.staleWhileRevalidate', options.staleWhileRevalidate)
176✔
1670
    validateLayerNumberOption('options.staleIfError', options.staleIfError)
176✔
1671
    validateLayerNumberOption('options.ttlJitter', options.ttlJitter)
176✔
1672
    validateLayerNumberOption('options.refreshAhead', options.refreshAhead)
176✔
1673
    validateTtlPolicy('options.ttlPolicy', options.ttlPolicy)
176✔
1674
    validateAdaptiveTtlOptions(options.adaptiveTtl)
176✔
1675
    validateCircuitBreakerOptions(options.circuitBreaker)
176✔
1676
    validateRateLimitOptions('options.fetcherRateLimit', options.fetcherRateLimit)
176✔
1677
    validateTags(options.tags)
176✔
1678
    if (options.contextOptions && typeof options.contextOptions !== 'function') {
176✔
1679
      throw new Error('options.contextOptions must be a function.')
1✔
1680
    }
1681
  }
1682

1683
  private assertActive(operation: string): void {
1684
    if (this.isDisconnecting) {
1,213✔
1685
      throw new Error(`CacheStack is disconnecting; cannot perform ${operation}.`)
5✔
1686
    }
1687
  }
1688

1689
  private async awaitStartup(operation: string): Promise<void> {
1690
    this.assertActive(operation)
589✔
1691
    await this.startup
589✔
1692
    this.assertActive(operation)
584✔
1693
  }
1694

1695
  private async readLayerEntry(layer: CacheLayer, key: string): Promise<unknown | null> {
1696
    return this.reader.readLayerEntry(layer, key)
39✔
1697
  }
1698

1699
  private scheduleBackgroundRefresh<T>(
1700
    key: string,
1701
    fetcher: CacheFetcher<T>,
1702
    options?: CacheGetOptions,
1703
    fetcherContext?: CacheFetcherContext<T>
1704
  ): void {
1705
    this.reader.runScheduleBackgroundRefresh(key, fetcher, options, fetcherContext)
1✔
1706
  }
1707

1708
  private async applyFreshReadPolicies<T>(
1709
    key: string,
1710
    hit: {
1711
      found: true
1712
      value: T | null
1713
      stored: unknown
1714
      state: 'fresh' | 'stale-while-revalidate' | 'stale-if-error'
1715
      layerIndex: number
1716
      layerName: string
1717
    },
1718
    options: CacheGetOptions | undefined,
1719
    fetcher?: CacheFetcher<T>
1720
  ): Promise<void> {
1721
    return this.reader.runApplyFreshReadPolicies(key, hit, options, fetcher)
2✔
1722
  }
1723

1724
  private shouldSkipLayer(layer: CacheLayer): boolean {
1725
    const degradedUntil = this.layerDegradedUntil.get(layer.name)
1,114✔
1726
    const skip = shouldSkipDegradedLayer(degradedUntil)
1,114✔
1727
    if (!skip && degradedUntil !== undefined) {
1,114✔
1728
      this.layerDegradedUntil.delete(layer.name)
1✔
1729
    }
1730
    return skip
1,114✔
1731
  }
1732

1733
  private async handleLayerFailure(layer: CacheLayer, operation: string, error: unknown): Promise<null> {
1734
    const recovery = resolveRecoverableLayerFailure(this.options.gracefulDegradation)
17✔
1735
    if (!recovery.degrade) {
17✔
1736
      throw error
4✔
1737
    }
1738

1739
    this.layerDegradedUntil.set(layer.name, recovery.degradedUntil)
13✔
1740
    this.metricsCollector.increment('degradedOperations')
13✔
1741
    this.logger.warn?.('layer-degraded', { layer: layer.name, operation, error: this.formatError(error) })
13✔
1742
    this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) })
17✔
1743
    return null
17✔
1744
  }
1745

1746
  private async reportRecoverableLayerFailure(layer: CacheLayer, operation: string, error: unknown): Promise<void> {
1747
    if (this.isGracefulDegradationEnabled()) {
7✔
1748
      await this.handleLayerFailure(layer, operation, error)
5✔
1749
      return
5✔
1750
    }
1751

1752
    this.logger.warn?.('layer-operation-failed', { layer: layer.name, operation, error: this.formatError(error) })
2✔
1753
    this.emitError(operation, { layer: layer.name, degraded: false, error: this.formatError(error) })
7✔
1754
  }
1755

1756
  private isGracefulDegradationEnabled(): boolean {
1757
    return Boolean(this.options.gracefulDegradation)
9✔
1758
  }
1759

1760
  private recordCircuitFailure(
1761
    key: string,
1762
    breakerKey: string,
1763
    options: CacheCircuitBreakerOptions | undefined,
1764
    error: unknown
1765
  ): void {
1766
    if (!options) {
14✔
1767
      return
8✔
1768
    }
1769

1770
    this.circuitBreakerManager.recordFailure(breakerKey, options)
6✔
1771
    if (this.circuitBreakerManager.isOpen(breakerKey)) {
6!
1772
      this.metricsCollector.increment('circuitBreakerTrips')
6✔
1773
    }
1774
    this.emitError('fetch', { key, breakerKey, error: this.formatError(error) })
6✔
1775
  }
1776

1777
  private emitError(operation: string, context: Record<string, unknown>): void {
1778
    this.logger.error?.(operation, context)
26✔
1779
    if (this.listenerCount('error') > 0) {
26✔
1780
      this.emit('error', { operation, ...context })
9✔
1781
    }
1782
  }
1783

1784
  private snapshotMaxBytes(): number | false {
1785
    return this.options.snapshotMaxBytes === false
10✔
1786
      ? false
1787
      : (this.options.snapshotMaxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES)
17✔
1788
  }
1789

1790
  private snapshotMaxEntries(): number | false {
1791
    return this.options.snapshotMaxEntries === false
8✔
1792
      ? false
1793
      : (this.options.snapshotMaxEntries ?? DEFAULT_SNAPSHOT_MAX_ENTRIES)
13✔
1794
  }
1795

1796
  private invalidationMaxKeys(): number | false {
1797
    return this.options.invalidationMaxKeys === false
49✔
1798
      ? false
1799
      : (this.options.invalidationMaxKeys ?? DEFAULT_INVALIDATION_MAX_KEYS)
88✔
1800
  }
1801
}
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