Skip to content

cache ​

Category
Tags
Export size
min 8.44 kB · gzip 3.31 kB
See also

Caches get and find results based on params. On mutating methods (create, update, patch, remove), affected cache entries are automatically invalidated. Works as a before, after, or around hook.

Use the iff option to restrict caching to certain calls (e.g. internal ones) — invalidation always runs, for every call.

ts
  import { 
cache
} from 'feathers-utils/hooks';

Example ​

ts
import { cache } from 'feathers-utils/hooks'

const myCache = new Map()

app.service('users').hooks({
  around: {
    all: [cache({ map: myCache, transformParams: (params) => ({ query: params.query }) })]
  }
})

Type declaration ​

Show Type Declarations
ts
type Cache = {
  get: (key: string) => Promisable<any>
  set: (key: string, value: any) => Promisable<any>
  delete: (key: string) => Promisable<any>
  clear: () => any
  keys: () => IterableIterator<string>
}
export type CacheEvent =
  | {
      type: "hit"
      method: string
      key: string
    }
  | {
      type: "miss"
      method: string
      key: string
    }
  | {
      type: "set"
      method: string
      key: string
    }
  | {
      type: "invalidate"
      method: string
      key: string
    }
  | {
      type: "clear"
      method: string
    }
  | {
      type: "skip"
      method: string
    }
export type CacheOptions<H extends HookContext = HookContext> = {
  /**
   * The cache implementation to use. It should implement the methods `get`, `set`, `delete`, `clear`, and `keys`.
   * This can be a Map, Redis client, or any other cache implementation.
   *
   * Use 'lru-cache' for an LRU cache implementation.
   */
  map: Cache
  /**
   * The id field to use for caching.
   *
   * @default service.options.id ?? 'id'
   */
  id?: string
  /**
   * Params are stringified for the key-value cache, and there are params
   * properties you don't want in the cache key. Use this function to transform
   * the params before they are stringified.
   *
   * The {@link gateParams} util is built for exactly this: it declaratively
   * selects/projects `params` keys (keeping `query` by default) so noise like
   * `rateLimit` never ends up in the cache key.
   *
   * @example
   * ```ts
   *
   *
   * cache({
   *   map: new Map(),
   *   transformParams: (params) => gateParams(params, { rateLimit: false }),
   * })
   * ```
   */
  transformParams: (params: Params) => Params
  /**
   * Custom serialization function for converting params into a cache key string.
   * Uses {@link stringifyParams} by default, which sorts object keys and normalizes
   * query operator arrays (`$or`, `$and`, `$in`, etc.) for order-independent caching.
   *
   * The default is crash-safe: it never throws on values that leak through
   * `transformParams`. Circular references become `[Circular]`,
   * functions/`undefined`/`symbol` are dropped, `BigInt` is stringified, and
   * objects with `toJSON` (e.g. `Date`, `ObjectId`) are serialized via it.
   *
   * Override this to use a custom serialization strategy, e.g. to hash long keys
   * for an external store — the id prefix stays separate, so invalidation keeps
   * working.
   *
   * @example
   * ```ts
   *
   *
   *
   * cache({
   *   map: redisCache,
   *   transformParams: (params) => ({ query: params.query }),
   *   serialize: (params) =>
   *     createHash('sha256').update(stringifyParams(params)).digest('base64url'),
   * })
   * ```
   */
  serialize?: (params: Params) => string
  /**
   * Optional logger callback for cache events (hit, miss, set, invalidate,
   * clear, skip). Useful for debugging and monitoring cache behavior.
   *
   * @example
   * ```ts
   * cache({
   *   map: new Map(),
   *   transformParams: (params) => ({ query: params.query }),
   *   logger: (event) => console.log(`cache ${event.type}`, event),
   * })
   * ```
   */
  logger?: (event: CacheEvent) => void
  /**
   * How to clone results on store and on hit so callers can't mutate the shared
   * cached object. The default is a `fast-copy` deep clone.
   *
   * Set to `false` to skip cloning entirely (fastest, but the caller MUST treat
   * results as immutable), or pass a custom clone function (e.g. `structuredClone`).
   *
   * @default true
   */
  clone?: boolean | (<T>(value: T) => T)
  /**
   * Only read from and write to the cache when this is truthy. Can be a boolean
   * or a (possibly async) predicate that receives the `HookContext`.
   *
   * This gates the **caching of `get`/`find` only**. Invalidation on `create`,
   * `update`, `patch` and `remove` always runs, so a call that is not allowed to
   * be served from the cache can still never leave stale entries behind.
   *
   * A gated-out call logs a `skip` event (without a key — none is computed), so
   * a cache that never fills is visible in the {@link CacheOptions.logger}
   * instead of silent.
   *
   * A predicate runs on every hook run it gates — with a `before`/`after` or
   * `around` registration that is twice per `get`/`find` call — so keep it cheap
   * and side-effect free. A sync predicate is never awaited.
   *
   * @default true
   *
   * @example
   * ```ts
   *
   *
   *
   * // only serve internal calls from the cache
   * cache({
   *   map: new Map(),
   *   transformParams: (params) => ({ query: params.query }),
   *   iff: isProvider('server'),
   * })
   * ```
   */
  iff?: boolean | PredicateFn<H>
}
/**
 * Caches `get` and `find` results based on `params`. On mutating methods (`create`, `update`,
 * `patch`, `remove`), affected cache entries are automatically invalidated.
 * Works as a `before`, `after`, or `around` hook.
 *
 * Use the `iff` option to restrict *caching* to certain calls (e.g. internal
 * ones) — invalidation always runs, for every call.
 *
 * @example
 * ```ts
 *
 *
 * const myCache = new Map()
 *
 * app.service('users').hooks({
 *   around: {
 *     all: [cache({ map: myCache, transformParams: (params) => ({ query: params.query }) })]
 *   }
 * })
 * ```
 *
 * @see https://utils.feathersjs.com/hooks/cache.html
 */
export declare const cache: <H extends HookContext = HookContext>(
  options: CacheOptions<H>,
) => (context: H, next?: NextFunction) => Promise<void>
ArgumentTypeDescription
optionsCacheOptions<H>
typemethodsmulti
before, afterfind, get, create, update, patch, removeyes

The cache hook caches get and find results based on params. On mutating methods (create, update, patch, remove), affected cache entries are automatically invalidated.

  • Cached get entries are invalidated when the same id is updated, patched, or removed.
  • Cached find entries are invalidated on any mutation, since any change could affect query results.
  • create does not invalidate cached get entries (only find).
  • The iff option restricts which calls are cached (e.g. internal ones only); invalidation always runs.

Options ​

mapCacherequired

The cache implementation to use. It should implement the methods get, set, delete, clear, and keys. This can be a Map, Redis client, or any other cache implementation.

Use 'lru-cache' for an LRU cache implementation.

idstringdefaultservice.options.id ?? 'id'

The id field to use for caching.

transformParams(params: Params) => Paramsrequired

Params are stringified for the key-value cache, and there are params properties you don't want in the cache key. Use this function to transform the params before they are stringified.

The gateParams util is built for exactly this: it declaratively selects/projects params keys (keeping query by default) so noise like rateLimit never ends up in the cache key.

Example
ts
import { gateParams } from 'feathers-utils/utils'

cache({
  map: new Map(),
  transformParams: (params) => gateParams(params, { rateLimit: false }),
})
serialize(params: Params) => string

Custom serialization function for converting params into a cache key string. Uses stringifyParams by default, which sorts object keys and normalizes query operator arrays ($or, $and, $in, etc.) for order-independent caching.

The default is crash-safe: it never throws on values that leak through transformParams. Circular references become [Circular], functions/undefined/symbol are dropped, BigInt is stringified, and objects with toJSON (e.g. Date, ObjectId) are serialized via it.

Override this to use a custom serialization strategy, e.g. to hash long keys for an external store — the id prefix stays separate, so invalidation keeps working.

Example
ts
import { createHash } from 'node:crypto'
import { stringifyParams } from 'feathers-utils/utils'

cache({
  map: redisCache,
  transformParams: (params) => ({ query: params.query }),
  serialize: (params) =>
    createHash('sha256').update(stringifyParams(params)).digest('base64url'),
})
logger(event: CacheEvent) => void

Optional logger callback for cache events (hit, miss, set, invalidate, clear, skip). Useful for debugging and monitoring cache behavior.

Example
ts
cache({
  map: new Map(),
  transformParams: (params) => ({ query: params.query }),
  logger: (event) => console.log(`cache ${event.type}`, event),
})
cloneboolean | (<T>(value: T) => T)defaulttrue

How to clone results on store and on hit so callers can't mutate the shared cached object. The default is a fast-copy deep clone.

Set to false to skip cloning entirely (fastest, but the caller MUST treat results as immutable), or pass a custom clone function (e.g. structuredClone).

iffboolean | PredicateFn<H>defaulttrue

Only read from and write to the cache when this is truthy. Can be a boolean or a (possibly async) predicate that receives the HookContext.

This gates the caching of get/find only. Invalidation on create, update, patch and remove always runs, so a call that is not allowed to be served from the cache can still never leave stale entries behind.

A gated-out call logs a skip event (without a key — none is computed), so a cache that never fills is visible in the CacheOptions.logger instead of silent.

A predicate runs on every hook run it gates — with a before/after or around registration that is twice per get/find call — so keep it cheap and side-effect free. A sync predicate is never awaited.

Example
ts
import { cache } from 'feathers-utils/hooks'
import { isProvider } from 'feathers-utils/predicates'

// only serve internal calls from the cache
cache({
  map: new Map(),
  transformParams: (params) => ({ query: params.query }),
  iff: isProvider('server'),
})

Caching Only Some Calls (iff) ​

iff gates the cache for get and find: when it is falsy for a call, the call is neither served from the cache nor stored in it. It takes a boolean or a (possibly async) predicate over the HookContext, so any predicate composes — e.g. isProvider, isContext, or a combination via and / or / not.

ts
import { cache } from 'feathers-utils/hooks'
import { isProvider } from 'feathers-utils/predicates'

app.service('users').hooks({
  around: {
    all: [
      cache({
        map: new Map(),
        transformParams: (params) => ({ query: params.query }),
        // only internal calls are served from / written to the cache
        iff: isProvider('server'),
      }),
    ],
  },
})

Invalidation is not gated

iff deliberately applies to get/find only. create, update, patch and remove always invalidate the affected entries, no matter who made the call — an external patch still clears the entries an internal get put there. Gating invalidation too would let external writes leave stale data behind.

A gated-out call emits a skip event to the logger, so a cache that never fills shows up in your logs instead of failing silently. The event carries no key — computing one is exactly the work the gate skips.

The predicate is evaluated on every hook run it gates — with a before/after or around registration that is twice per get/find call (and therefore two skip events) — so keep it cheap and side-effect free.

Choosing Cache-Relevant Params (with gateParams) ​

Deciding which params keys form the cache key is the trickiest part of caching, and the two failure modes are asymmetric:

  • False hits (dangerous): if a key that affects the result is left out (e.g. user/tenant, provider), two semantically different requests collapse to the same key — one user can be served another user's cached data.
  • False misses (wasteful): if a per-request/metrics key is included (e.g. rateLimit), every request produces a unique key and the cache never hits. A function-valued key (e.g. stashed from stashable) would even make serialization throw.

The gateParams utility makes this explicit and safe. It takes a declarative path schema (true include, false drop, or a predicate/projection function). query is always included by default, and keys you never classified are kept by default — the safe direction, since a forgotten key causes at worst a harmless cache miss, never a false hit.

Transient keys that feathers-utils' own hooks attach to params — rateLimit (rateLimit), skipHooks (skippable/addSkip), the stashed function and _stashable flag (stashable) — are never cache-relevant. Drop them with false, or keep only what you list via dropUnknownParams: true.

Exclude specific params (default) ​

Cache on everything except the keys you explicitly drop with false. This is the default direction — safe against false hits:

ts
import { gateParams } from 'feathers-utils/utils'

cache({
  map: new Map(),
  transformParams: (params) =>
    gateParams(params, { rateLimit: false, skipHooks: false }),
})

Include only specific params ​

Set dropUnknownParams: true so only query (always) and the listed paths form the cache key. user.id is picked via dot-notation so different tenants never collide and per-request user fields don't bloat the key. Use onUnknownParams to log anything that was dropped:

ts
import { gateParams } from 'feathers-utils/utils'

cache({
  map: new Map(),
  transformParams: (params) =>
    gateParams(
      params,
      { 'user.id': true }, // `query` is included automatically
      {
        dropUnknownParams: true,
        onUnknownParams: (keys) =>
          keys.forEach((key) => logger.warn('undeclared cache param', key)),
      },
    ),
})

Cache Interface ​

Any object that implements the following interface can be used as a cache:

ts
type Cache = {
  get: (key: string) => Promisable<any>
  set: (key: string, value: any) => Promisable<any>
  delete: (key: string) => Promisable<any>
  clear: () => any
  keys: () => Generator<string, void, unknown>
}

A plain Map satisfies this interface out of the box, as do many popular cache libraries.

Examples of storages ​

Basic Usage with Map ​

ts
import { cache } from 'feathers-utils/hooks'

const myCache = new Map()

app.service('users').hooks({
  around: {
    all: [
      cache({
        map: myCache,
        transformParams: (params) => ({ query: params.query }),
      }),
    ],
  },
})

LRU Cache (lru-cache) ​

Use lru-cache to limit the number of cached entries and automatically evict the least recently used ones.

ts
import { cache } from 'feathers-utils/hooks'
import { LRUCache } from 'lru-cache'

const lruCache = new LRUCache({
  max: 500, // Maximum number of entries
  ttl: 1000 * 60 * 5, // Entries expire after 5 minutes
})

app.service('users').hooks({
  around: {
    all: [
      cache({
        map: lruCache,
        transformParams: (params) => ({ query: params.query }),
      }),
    ],
  },
})

Redis ​

Redis (via ioredis) requires a thin adapter since its API is slightly different from the Cache interface.

ts
import { cache } from 'feathers-utils/hooks'
import Redis from 'ioredis'

const redis = new Redis()
const prefix = 'users-cache:'
const ttl = 60 * 5 // 5 minutes in seconds

const redisCache = {
  async get(key: string) {
    const value = await redis.get(prefix + key)
    return value ? JSON.parse(value) : undefined
  },
  async set(key: string, value: any) {
    await redis.set(prefix + key, JSON.stringify(value), 'EX', ttl)
  },
  async delete(key: string) {
    await redis.del(prefix + key)
  },
  async clear() {
    const keys = await redis.keys(prefix + '*')
    if (keys.length) await redis.del(...keys)
  },
  *keys() {
    // Redis keys() is async, so we track keys locally for invalidation.
    // For production use, consider maintaining a local Set of active keys.
    throw new Error(
      'Synchronous keys iteration is not supported with Redis. ' +
        'Use clear() for full invalidation instead.',
    )
  },
}

app.service('users').hooks({
  around: {
    all: [
      cache({
        map: redisCache,
        transformParams: (params) => ({ query: params.query }),
      }),
    ],
  },
})

WARNING

The keys() method is called during invalidation of mutating methods to find entries that match the affected ids. Since Redis does not support synchronous iteration, the adapter above throws on keys(). This means mutations will fail unless you provide a working keys() implementation — for example by tracking active keys in a local Set:

ts
const trackedKeys = new Set<string>()

const redisCache = {
  async get(key: string) {
    const value = await redis.get(prefix + key)
    return value ? JSON.parse(value) : undefined
  },
  async set(key: string, value: any) {
    trackedKeys.add(key)
    await redis.set(prefix + key, JSON.stringify(value), 'EX', ttl)
  },
  async delete(key: string) {
    trackedKeys.delete(key)
    await redis.del(prefix + key)
  },
  async clear() {
    const keys = [...trackedKeys]
    trackedKeys.clear()
    if (keys.length) await redis.del(...keys.map((k) => prefix + k))
  },
  *keys() {
    yield* trackedKeys
  },
}

Custom Cache ​

You can build any custom cache as long as it implements the Cache interface. Here is an example of a simple time-based cache:

ts
import { cache } from 'feathers-utils/hooks'

const ttl = 1000 * 60 * 5 // 5 minutes

const timedCache = () => {
  const store = new Map<string, { value: any; expires: number }>()

  return {
    get(key: string) {
      const entry = store.get(key)
      if (!entry) return undefined
      if (Date.now() > entry.expires) {
        store.delete(key)
        return undefined
      }
      return entry.value
    },
    set(key: string, value: any) {
      store.set(key, { value, expires: Date.now() + ttl })
    },
    delete(key: string) {
      store.delete(key)
    },
    clear() {
      store.clear()
    },
    *keys() {
      yield* store.keys()
    },
  }
}

app.service('users').hooks({
  around: {
    all: [
      cache({
        map: timedCache(),
        transformParams: (params) => ({ query: params.query }),
      }),
    ],
  },
})

Released under the MIT License.