| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent 95a667d commit b908e3f
11 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -8,6 +8,7 @@ | |||
| 8 | 8 | "type": "module", | |
| 9 | 9 | "sideEffects": false, | |
| 10 | 10 | "packageManager": "pnpm@11.5.2", | |
| 11 | + "prettier": "@feathers-community/eslint-config/prettier", | ||
| 11 | 12 | "engines": { | |
| 12 | 13 | "node": ">=22.0.0" | |
| 13 | 14 | }, | |
@@ -97,7 +98,7 @@ | |||
| 97 | 98 | "neotraverse": "^0.6.18" | |
| 98 | 99 | }, | |
| 99 | 100 | "devDependencies": { | |
| 100 | - "@feathers-community/eslint-config": "^0.1.0", | ||
| 101 | + "@feathers-community/eslint-config": "^0.2.0", | ||
| 101 | 102 | "@feathersjs/authentication": "^5.0.45", | |
| 102 | 103 | "@feathersjs/authentication-local": "^5.0.45", | |
| 103 | 104 | "@feathersjs/client": "^5.0.45", | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -2,3 +2,5 @@ allowBuilds: | |||
| 2 | 2 | "@parcel/watcher": true | |
| 3 | 3 | esbuild: true | |
| 4 | 4 | unrs-resolver: true | |
| 5 | + minimumReleaseAgeExclude: | ||
| 6 | + - '@feathers-community/eslint-config@0.2.0' | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -2,8 +2,8 @@ | |||
| 2 | 2 | title: cache | |
| 3 | 3 | category: hooks | |
| 4 | 4 | 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'] | ||
| 7 | 7 | multi: true | |
| 8 | 8 | --- | |
| 9 | 9 | ||
@@ -15,11 +15,11 @@ The `cache` hook caches `get` and `find` results based on `params`. On mutating | |||
| 15 | 15 | ||
| 16 | 16 | ## Options | |
| 17 | 17 | ||
| 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. | | ||
| 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. 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). | | ||
| 23 | 23 | ||
| 24 | 24 | ## Cache Interface | |
| 25 | 25 | ||
@@ -115,7 +115,7 @@ const redisCache = { | |||
| 115 | 115 | // For production use, consider maintaining a local Set of active keys. | |
| 116 | 116 | throw new Error( | |
| 117 | 117 | 'Synchronous keys iteration is not supported with Redis. ' + | |
| 118 | - 'Use clear() for full invalidation instead.' | ||
| 118 | + 'Use clear() for full invalidation instead.', | ||
| 119 | 119 | ) | |
| 120 | 120 | }, | |
| 121 | 121 | } | |
@@ -212,16 +212,49 @@ app.service('users').hooks({ | |||
| 212 | 212 | }) | |
| 213 | 213 | ``` | |
| 214 | 214 | ||
| 215 | - ### Excluding Params from Cache Key | ||
| 215 | + ### Choosing Cache-Relevant Params (with `passParams`) | ||
| 216 | 216 | ||
| 217 | - Use `transformParams` to exclude properties that should not affect the cache key, such as `paginate`, `user`, or authentication info: | ||
| 217 | + Deciding which `params` keys form the cache key is the trickiest part of caching, and the two failure modes are asymmetric: | ||
| 218 | + | ||
| 219 | + - **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. | ||
| 220 | + - **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. | ||
| 221 | + | ||
| 222 | + 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. | ||
| 223 | + | ||
| 224 | + > 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`. | ||
| 225 | + | ||
| 226 | + #### Exclude specific params (default) | ||
| 227 | + | ||
| 228 | + Cache on everything except the keys you explicitly drop with `false`. This is the default direction — safe against false hits: | ||
| 218 | 229 | ||
| 219 | 230 | ```ts | |
| 231 | + import { passParams } from 'feathers-utils/utils' | ||
| 232 | + | ||
| 220 | 233 | cache({ | |
| 221 | 234 | map: new Map(), | |
| 222 | - transformParams: (params) => { | ||
| 223 | - const { paginate, user, authentication, ...rest } = params as any | ||
| 224 | - return rest | ||
| 225 | - }, | ||
| 235 | + transformParams: (params) => | ||
| 236 | + passParams(params, { rateLimit: false, skipHooks: false }), | ||
| 237 | + }) | ||
| 238 | + ``` | ||
| 239 | + | ||
| 240 | + #### Include only specific params | ||
| 241 | + | ||
| 242 | + 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: | ||
| 243 | + | ||
| 244 | + ```ts | ||
| 245 | + import { passParams } from 'feathers-utils/utils' | ||
| 246 | + | ||
| 247 | + cache({ | ||
| 248 | + map: new Map(), | ||
| 249 | + transformParams: (params) => | ||
| 250 | + passParams( | ||
| 251 | + params, | ||
| 252 | + { 'user.id': true }, // `query` is included automatically | ||
| 253 | + { | ||
| 254 | + dropUnknownParams: true, | ||
| 255 | + onUnknownParams: (keys) => | ||
| 256 | + keys.forEach((key) => logger.warn('undeclared cache param', key)), | ||
| 257 | + }, | ||
| 258 | + ), | ||
| 226 | 259 | }) | |
| 227 | 260 | ``` | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -7,6 +7,7 @@ import { TTLCache } from '@isaacs/ttlcache' | |||
| 7 | 7 | import { MemoryService } from '@feathersjs/memory' | |
| 8 | 8 | import { expect, expectTypeOf } from 'vitest' | |
| 9 | 9 | import { copy } from 'fast-copy' | |
| 10 | + import { passParams } from '../../utils/pass-params/pass-params.util.js' | ||
| 10 | 11 | ||
| 11 | 12 | const setup = (options: CacheOptions, serviceOptions?: { id?: string }) => { | |
| 12 | 13 | const app = feathers<{ | |
@@ -1147,3 +1148,99 @@ describe('cache hook as an around hook', () => { | |||
| 1147 | 1148 | expect(getSpy).toHaveBeenCalledTimes(2) | |
| 1148 | 1149 | }) | |
| 1149 | 1150 | }) | |
| 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 | + }) | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -12,6 +12,7 @@ export * from './iterate-find/iterate-find.util.js' | |||
| 12 | 12 | export * from './merge-query/merge-query.util.js' | |
| 13 | 13 | export * from './mutate-data/mutate-data.util.js' | |
| 14 | 14 | export * from './mutate-result/mutate-result.util.js' | |
| 15 | + export * from './pass-params/pass-params.util.js' | ||
| 15 | 16 | export * from './patch-batch/patch-batch.util.js' | |
| 16 | 17 | export * from './query-defaults/query-defaults.util.js' | |
| 17 | 18 | export * from './query-has-property/query-has-property.util.js' | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1,7 @@ | |||
| 1 | + --- | ||
| 2 | + title: passParams | ||
| 3 | + category: utils | ||
| 4 | + see: | ||
| 5 | + - hooks/cache | ||
| 6 | + - utils/transform-params | ||
| 7 | + --- | ||
| Original file line number | Diff line number | Diff 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 | + }) | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments