Skip to content

Commit e96faaa

Browse files
authored
feat: new util passParams (#42)
* feat: new util passParams * docs: cache hook add see & other improvements
1 parent 95a667d commit e96faaa

12 files changed

Lines changed: 727 additions & 257 deletions

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"type": "module",
99
"sideEffects": false,
1010
"packageManager": "pnpm@11.5.2",
11+
"prettier": "@feathers-community/eslint-config/prettier",
1112
"engines": {
1213
"node": ">=22.0.0"
1314
},
@@ -97,7 +98,7 @@
9798
"neotraverse": "^0.6.18"
9899
},
99100
"devDependencies": {
100-
"@feathers-community/eslint-config": "^0.1.0",
101+
"@feathers-community/eslint-config": "^0.2.0",
101102
"@feathersjs/authentication": "^5.0.45",
102103
"@feathersjs/authentication-local": "^5.0.45",
103104
"@feathersjs/client": "^5.0.45",

pnpm-lock.yaml

Lines changed: 208 additions & 233 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ allowBuilds:
22
"@parcel/watcher": true
33
esbuild: true
44
unrs-resolver: true
5+
minimumReleaseAgeExclude:
6+
- '@feathers-community/eslint-config@0.2.0'

src/hooks/cache/cache.hook.md

Lines changed: 58 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
title: cache
33
category: hooks
44
hook:
5-
type: ["before", "after"]
6-
method: ["find", "get", "create", "update", "patch", "remove"]
5+
type: ['before', 'after']
6+
method: ['find', 'get', 'create', 'update', 'patch', 'remove']
77
multi: true
8+
see:
9+
- utils/passParams
810
---
911

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

1618
## Options
1719

18-
| Option | Type | Description |
19-
| --- | --- | --- |
20-
| `map` | `Cache` | The cache implementation. Must implement `get`, `set`, `delete`, `clear`, and `keys`. |
21-
| `id` | `string` | The id field to use. Defaults to `service.options.id`, then `'id'`. |
22-
| `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. |
20+
| Option | Type | Description |
21+
| ----------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
22+
| `map` | `Cache` | The cache implementation. Must implement `get`, `set`, `delete`, `clear`, and `keys`. |
23+
| `id` | `string` | The id field to use. Defaults to `service.options.id`, then `'id'`. |
24+
| `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). |
25+
26+
## Choosing Cache-Relevant Params (with `passParams`)
27+
28+
Deciding which `params` keys form the cache key is the trickiest part of caching, and the two failure modes are asymmetric:
29+
30+
- **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.
31+
- **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.
32+
33+
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.
34+
35+
> 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`.
36+
37+
### Exclude specific params (default)
38+
39+
Cache on everything except the keys you explicitly drop with `false`. This is the default direction — safe against false hits:
40+
41+
```ts
42+
import { passParams } from 'feathers-utils/utils'
43+
44+
cache({
45+
map: new Map(),
46+
transformParams: (params) =>
47+
passParams(params, { rateLimit: false, skipHooks: false }),
48+
})
49+
```
50+
51+
### Include only specific params
52+
53+
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:
54+
55+
```ts
56+
import { passParams } from 'feathers-utils/utils'
57+
58+
cache({
59+
map: new Map(),
60+
transformParams: (params) =>
61+
passParams(
62+
params,
63+
{ 'user.id': true }, // `query` is included automatically
64+
{
65+
dropUnknownParams: true,
66+
onUnknownParams: (keys) =>
67+
keys.forEach((key) => logger.warn('undeclared cache param', key)),
68+
},
69+
),
70+
})
71+
```
2372

2473
## Cache Interface
2574

@@ -37,7 +86,7 @@ type Cache = {
3786
3887
A plain `Map` satisfies this interface out of the box, as do many popular cache libraries.
3988
40-
## Examples
89+
## Examples of storages
4190
4291
### Basic Usage with Map
4392
@@ -115,7 +164,7 @@ const redisCache = {
115164
// For production use, consider maintaining a local Set of active keys.
116165
throw new Error(
117166
'Synchronous keys iteration is not supported with Redis. ' +
118-
'Use clear() for full invalidation instead.'
167+
'Use clear() for full invalidation instead.',
119168
)
120169
},
121170
}
@@ -211,17 +260,3 @@ app.service('users').hooks({
211260
},
212261
})
213262
```
214-
215-
### Excluding Params from Cache Key
216-
217-
Use `transformParams` to exclude properties that should not affect the cache key, such as `paginate`, `user`, or authentication info:
218-
219-
```ts
220-
cache({
221-
map: new Map(),
222-
transformParams: (params) => {
223-
const { paginate, user, authentication, ...rest } = params as any
224-
return rest
225-
},
226-
})
227-
```

src/hooks/cache/cache.hook.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { TTLCache } from '@isaacs/ttlcache'
77
import { MemoryService } from '@feathersjs/memory'
88
import { expect, expectTypeOf } from 'vitest'
99
import { copy } from 'fast-copy'
10+
import { passParams } from '../../utils/pass-params/pass-params.util.js'
1011

1112
const setup = (options: CacheOptions, serviceOptions?: { id?: string }) => {
1213
const app = feathers<{
@@ -1147,3 +1148,99 @@ describe('cache hook as an around hook', () => {
11471148
expect(getSpy).toHaveBeenCalledTimes(2)
11481149
})
11491150
})
1151+
1152+
describe('cache hook with passParams', () => {
1153+
it('prevents false hits across users and collapses non-id user fields (whitelist)', async () => {
1154+
const { usersService, before } = setup({
1155+
map: new Map(),
1156+
// `query` is included by default; only `user.id` is added explicitly.
1157+
transformParams: (params) => passParams(params, { 'user.id': true }),
1158+
})
1159+
1160+
await usersService.create({ id: 1, name: 'John' })
1161+
1162+
// user a -> miss
1163+
await usersService.find({
1164+
query: {},
1165+
user: { id: 'a', updatedAt: 1 },
1166+
} as any)
1167+
expect(before.find).toHaveBeenCalledTimes(1)
1168+
1169+
// same user id, different transient timestamp -> hit (projection collapses it)
1170+
await usersService.find({
1171+
query: {},
1172+
user: { id: 'a', updatedAt: 999 },
1173+
} as any)
1174+
expect(before.find).toHaveBeenCalledTimes(1)
1175+
1176+
// different user id -> miss (no cross-user leak)
1177+
await usersService.find({
1178+
query: {},
1179+
user: { id: 'b', updatedAt: 1 },
1180+
} as any)
1181+
expect(before.find).toHaveBeenCalledTimes(2)
1182+
})
1183+
1184+
it('strips transient metric keys so the cache still hits (blacklist)', async () => {
1185+
const { usersService, before } = setup({
1186+
map: new Map(),
1187+
// keep everything except the transient `rateLimit` metric.
1188+
transformParams: (params) => passParams(params, { rateLimit: false }),
1189+
})
1190+
1191+
await usersService.create({ id: 1, name: 'John' })
1192+
1193+
await usersService.find({
1194+
query: { name: 'John' },
1195+
rateLimit: { remainingPoints: 9 },
1196+
} as any)
1197+
expect(before.find).toHaveBeenCalledTimes(1)
1198+
1199+
// identical query, different rate-limit metrics -> still a cache hit
1200+
await usersService.find({
1201+
query: { name: 'John' },
1202+
rateLimit: { remainingPoints: 8 },
1203+
} as any)
1204+
expect(before.find).toHaveBeenCalledTimes(1)
1205+
})
1206+
1207+
it('drops function-valued params under a whitelist so serialization never throws', async () => {
1208+
const { usersService, before } = setup({
1209+
map: new Map(),
1210+
// keep only `query` (default); `stashed` (a function) is dropped.
1211+
transformParams: (params) =>
1212+
passParams(params, {}, { dropUnknownParams: true }),
1213+
})
1214+
1215+
await usersService.create({ id: 1, name: 'John' })
1216+
1217+
// `stashed` is a function; if it reached stableStringify it would throw.
1218+
await expect(
1219+
usersService.find({
1220+
query: { name: 'John' },
1221+
stashed: () => Promise.resolve(),
1222+
} as any),
1223+
).resolves.toBeDefined()
1224+
expect(before.find).toHaveBeenCalledTimes(1)
1225+
1226+
await usersService.find({
1227+
query: { name: 'John' },
1228+
stashed: () => Promise.resolve('other'),
1229+
} as any)
1230+
expect(before.find).toHaveBeenCalledTimes(1) // cache hit
1231+
})
1232+
1233+
it('reports undeclared params keys via onUnknownParams', async () => {
1234+
const onUnknownParams = vi.fn()
1235+
const { usersService } = setup({
1236+
map: new Map(),
1237+
transformParams: (params) =>
1238+
passParams(params, { query: true }, { onUnknownParams }),
1239+
})
1240+
1241+
await usersService.create({ id: 1, name: 'John' })
1242+
await usersService.find({ query: {}, mystery: 1 } as any)
1243+
1244+
expect(onUnknownParams).toHaveBeenCalledWith(['mystery'], expect.anything())
1245+
})
1246+
})

src/hooks/cache/cache.hook.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,20 @@ export type CacheOptions = {
3434
* params are stringified for the key-value cache.
3535
* There are params properties you don't want to include in the cache key.
3636
* You can use this function to transform the params before they are stringified.
37+
*
38+
* The {@link passParams} util is built for exactly this: it declaratively
39+
* selects/projects `params` keys (keeping `query` by default) so noise like
40+
* `rateLimit` never ends up in the cache key.
41+
*
42+
* @example
43+
* ```ts
44+
* import { passParams } from 'feathers-utils/utils'
45+
*
46+
* cache({
47+
* map: new Map(),
48+
* transformParams: (params) => passParams(params, { rateLimit: false }),
49+
* })
50+
* ```
3751
*/
3852
transformParams: (params: Params) => Params
3953
/**

src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export * from './iterate-find/iterate-find.util.js'
1212
export * from './merge-query/merge-query.util.js'
1313
export * from './mutate-data/mutate-data.util.js'
1414
export * from './mutate-result/mutate-result.util.js'
15+
export * from './pass-params/pass-params.util.js'
1516
export * from './patch-batch/patch-batch.util.js'
1617
export * from './query-defaults/query-defaults.util.js'
1718
export * from './query-has-property/query-has-property.util.js'
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
title: passParams
3+
category: utils
4+
see:
5+
- hooks/cache
6+
- utils/transform-params
7+
---
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { Params } from '@feathersjs/feathers'
2+
import { expectTypeOf } from 'vitest'
3+
import { passParams } from './pass-params.util.js'
4+
5+
it('returns a Params object', () => {
6+
const out = passParams({ query: {} }, { query: true })
7+
expectTypeOf(out).toEqualTypeOf<Params>()
8+
})
9+
10+
it('accepts boolean and function rules, including nested paths and custom keys', () => {
11+
passParams({ query: {}, user: { id: 1 }, custom: 1 } as Params, {
12+
query: true,
13+
paginate: false,
14+
'user.id': true,
15+
user: (value, params) => {
16+
expectTypeOf(value).toBeAny()
17+
expectTypeOf(params).toEqualTypeOf<Params>()
18+
return value?.id
19+
},
20+
custom: true,
21+
})
22+
})
23+
24+
it('types onUnknownParams and dropUnknownParams', () => {
25+
passParams(
26+
{ query: {} } as Params,
27+
{ query: true },
28+
{
29+
dropUnknownParams: true,
30+
onUnknownParams: (keys, params) => {
31+
expectTypeOf(keys).toEqualTypeOf<string[]>()
32+
expectTypeOf(params).toEqualTypeOf<Params>()
33+
},
34+
},
35+
)
36+
})

0 commit comments

Comments
 (0)