Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"type": "module",
"sideEffects": false,
"packageManager": "pnpm@11.5.2",
"prettier": "@feathers-community/eslint-config/prettier",
"engines": {
"node": ">=22.0.0"
},
Expand Down Expand Up @@ -97,7 +98,7 @@
"neotraverse": "^0.6.18"
},
"devDependencies": {
"@feathers-community/eslint-config": "^0.1.0",
"@feathers-community/eslint-config": "^0.2.0",
"@feathersjs/authentication": "^5.0.45",
"@feathersjs/authentication-local": "^5.0.45",
"@feathersjs/client": "^5.0.45",
Expand Down
441 changes: 208 additions & 233 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ allowBuilds:
"@parcel/watcher": true
esbuild: true
unrs-resolver: true
minimumReleaseAgeExclude:
- '@feathers-community/eslint-config@0.2.0'
81 changes: 58 additions & 23 deletions src/hooks/cache/cache.hook.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
title: cache
category: hooks
hook:
type: ["before", "after"]
method: ["find", "get", "create", "update", "patch", "remove"]
type: ['before', 'after']
method: ['find', 'get', 'create', 'update', 'patch', 'remove']
multi: true
see:
- utils/passParams
---

The `cache` hook caches `get` and `find` results based on `params`. On mutating methods (`create`, `update`, `patch`, `remove`), affected cache entries are automatically invalidated.
Expand All @@ -15,11 +17,58 @@ The `cache` hook caches `get` and `find` results based on `params`. On mutating

## Options

| Option | Type | Description |
| --- | --- | --- |
| `map` | `Cache` | The cache implementation. Must implement `get`, `set`, `delete`, `clear`, and `keys`. |
| `id` | `string` | The id field to use. Defaults to `service.options.id`, then `'id'`. |
| `transformParams` | `(params) => params` | Transform params before they are used as cache key. Use this to exclude properties like `paginate` or `user` from the cache key. |
| Option | Type | Description |
| ----------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `map` | `Cache` | The cache implementation. Must implement `get`, `set`, `delete`, `clear`, and `keys`. |
| `id` | `string` | The id field to use. Defaults to `service.options.id`, then `'id'`. |
| `transformParams` | `(params) => params` | Transform params before they are used as cache key. Compose it with [`passParams`](/utils/pass-params) to declaratively pick/drop keys and avoid false hits — see [Choosing Cache-Relevant Params](#choosing-cache-relevant-params-with-passparams). |

## Choosing Cache-Relevant Params (with `passParams`)

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 [`passParams`](/utils/pass-params) 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 { passParams } from 'feathers-utils/utils'

cache({
map: new Map(),
transformParams: (params) =>
passParams(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 { passParams } from 'feathers-utils/utils'

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

## Cache Interface

Expand All @@ -37,7 +86,7 @@ type Cache = {

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

## Examples
## Examples of storages

### Basic Usage with Map

Expand Down Expand Up @@ -115,7 +164,7 @@ const redisCache = {
// 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.'
'Use clear() for full invalidation instead.',
)
},
}
Expand Down Expand Up @@ -211,17 +260,3 @@ app.service('users').hooks({
},
})
```

### Excluding Params from Cache Key

Use `transformParams` to exclude properties that should not affect the cache key, such as `paginate`, `user`, or authentication info:

```ts
cache({
map: new Map(),
transformParams: (params) => {
const { paginate, user, authentication, ...rest } = params as any
return rest
},
})
```
97 changes: 97 additions & 0 deletions src/hooks/cache/cache.hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { TTLCache } from '@isaacs/ttlcache'
import { MemoryService } from '@feathersjs/memory'
import { expect, expectTypeOf } from 'vitest'
import { copy } from 'fast-copy'
import { passParams } from '../../utils/pass-params/pass-params.util.js'

const setup = (options: CacheOptions, serviceOptions?: { id?: string }) => {
const app = feathers<{
Expand Down Expand Up @@ -1147,3 +1148,99 @@ describe('cache hook as an around hook', () => {
expect(getSpy).toHaveBeenCalledTimes(2)
})
})

describe('cache hook with passParams', () => {
it('prevents false hits across users and collapses non-id user fields (whitelist)', async () => {
const { usersService, before } = setup({
map: new Map(),
// `query` is included by default; only `user.id` is added explicitly.
transformParams: (params) => passParams(params, { 'user.id': true }),
})

await usersService.create({ id: 1, name: 'John' })

// user a -> miss
await usersService.find({
query: {},
user: { id: 'a', updatedAt: 1 },
} as any)
expect(before.find).toHaveBeenCalledTimes(1)

// same user id, different transient timestamp -> hit (projection collapses it)
await usersService.find({
query: {},
user: { id: 'a', updatedAt: 999 },
} as any)
expect(before.find).toHaveBeenCalledTimes(1)

// different user id -> miss (no cross-user leak)
await usersService.find({
query: {},
user: { id: 'b', updatedAt: 1 },
} as any)
expect(before.find).toHaveBeenCalledTimes(2)
})

it('strips transient metric keys so the cache still hits (blacklist)', async () => {
const { usersService, before } = setup({
map: new Map(),
// keep everything except the transient `rateLimit` metric.
transformParams: (params) => passParams(params, { rateLimit: false }),
})

await usersService.create({ id: 1, name: 'John' })

await usersService.find({
query: { name: 'John' },
rateLimit: { remainingPoints: 9 },
} as any)
expect(before.find).toHaveBeenCalledTimes(1)

// identical query, different rate-limit metrics -> still a cache hit
await usersService.find({
query: { name: 'John' },
rateLimit: { remainingPoints: 8 },
} as any)
expect(before.find).toHaveBeenCalledTimes(1)
})

it('drops function-valued params under a whitelist so serialization never throws', async () => {
const { usersService, before } = setup({
map: new Map(),
// keep only `query` (default); `stashed` (a function) is dropped.
transformParams: (params) =>
passParams(params, {}, { dropUnknownParams: true }),
})

await usersService.create({ id: 1, name: 'John' })

// `stashed` is a function; if it reached stableStringify it would throw.
await expect(
usersService.find({
query: { name: 'John' },
stashed: () => Promise.resolve(),
} as any),
).resolves.toBeDefined()
expect(before.find).toHaveBeenCalledTimes(1)

await usersService.find({
query: { name: 'John' },
stashed: () => Promise.resolve('other'),
} as any)
expect(before.find).toHaveBeenCalledTimes(1) // cache hit
})

it('reports undeclared params keys via onUnknownParams', async () => {
const onUnknownParams = vi.fn()
const { usersService } = setup({
map: new Map(),
transformParams: (params) =>
passParams(params, { query: true }, { onUnknownParams }),
})

await usersService.create({ id: 1, name: 'John' })
await usersService.find({ query: {}, mystery: 1 } as any)

expect(onUnknownParams).toHaveBeenCalledWith(['mystery'], expect.anything())
})
})
14 changes: 14 additions & 0 deletions src/hooks/cache/cache.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,20 @@ export type CacheOptions = {
* params are stringified for the key-value cache.
* There are params properties you don't want to include in the cache key.
* You can use this function to transform the params before they are stringified.
*
* The {@link passParams} 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 { passParams } from 'feathers-utils/utils'
*
* cache({
* map: new Map(),
* transformParams: (params) => passParams(params, { rateLimit: false }),
* })
* ```
*/
transformParams: (params: Params) => Params
/**
Expand Down
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export * from './iterate-find/iterate-find.util.js'
export * from './merge-query/merge-query.util.js'
export * from './mutate-data/mutate-data.util.js'
export * from './mutate-result/mutate-result.util.js'
export * from './pass-params/pass-params.util.js'
export * from './patch-batch/patch-batch.util.js'
export * from './query-defaults/query-defaults.util.js'
export * from './query-has-property/query-has-property.util.js'
Expand Down
7 changes: 7 additions & 0 deletions src/utils/pass-params/pass-params.util.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
title: passParams
category: utils
see:
- hooks/cache
- utils/transform-params
---
36 changes: 36 additions & 0 deletions src/utils/pass-params/pass-params.util.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { Params } from '@feathersjs/feathers'
import { expectTypeOf } from 'vitest'
import { passParams } from './pass-params.util.js'

it('returns a Params object', () => {
const out = passParams({ query: {} }, { query: true })
expectTypeOf(out).toEqualTypeOf<Params>()
})

it('accepts boolean and function rules, including nested paths and custom keys', () => {
passParams({ query: {}, user: { id: 1 }, custom: 1 } as Params, {
query: true,
paginate: false,
'user.id': true,
user: (value, params) => {
expectTypeOf(value).toBeAny()
expectTypeOf(params).toEqualTypeOf<Params>()
return value?.id
},
custom: true,
})
})

it('types onUnknownParams and dropUnknownParams', () => {
passParams(
{ query: {} } as Params,
{ query: true },
{
dropUnknownParams: true,
onUnknownParams: (keys, params) => {
expectTypeOf(keys).toEqualTypeOf<string[]>()
expectTypeOf(params).toEqualTypeOf<Params>()
},
},
)
})
Loading
Loading