From 4bba0eb2deab5895607850bb29d463a4a4ad7a69 Mon Sep 17 00:00:00 2001 From: fratzinger <22286818+fratzinger@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:29:07 +0200 Subject: [PATCH 1/2] feat: new util stringifyParams --- CLAUDE.md | 65 +++++++ package.json | 3 +- pnpm-lock.yaml | 9 + src/common/clone.test.ts | 33 ---- src/common/clone.ts | 35 ++++ src/common/early.ts | 38 ++++ src/common/has-own-property.ts | 39 ++++ src/common/index.ts | 4 + src/common/is-plain-object.ts | 44 +++++ src/common/to-array.ts | 29 +++ src/hooks/cache/cache-utils.test.ts | 63 ------ src/hooks/cache/cache-utils.ts | 17 -- src/hooks/cache/cache.hook.md | 1 + src/hooks/cache/cache.hook.test.ts | 2 +- src/hooks/cache/cache.hook.ts | 22 ++- .../check-required/check-required.hook.ts | 2 +- src/hooks/disallow/disallow.hook.ts | 2 +- .../find-or-create/find-or-create.hook.ts | 10 +- .../params-for-server.hook.ts | 2 +- .../params-from-client.hook.ts | 2 +- .../prevent-changes/prevent-changes.hook.ts | 2 +- src/hooks/soft-delete/soft-delete.hook.ts | 3 +- .../throw-if-is-provider.hook.ts | 2 +- src/internal.utils.ts | 31 --- .../is-context/is-context.predicate.ts | 2 +- .../lowercase/lowercase.transformer.ts | 2 +- src/transformers/omit/omit.transformer.ts | 2 +- .../parse-date/parse-date.transformer.ts | 3 +- src/transformers/pick/pick.transformer.ts | 2 +- .../set-now/set-now.transformer.ts | 2 +- src/transformers/trim/trim.transformer.ts | 2 +- .../gate-params/gate-params.util.test.ts | 46 +++++ src/utils/gate-params/gate-params.util.ts | 11 +- src/utils/get-paginate/get-paginate.util.ts | 2 +- src/utils/index.ts | 1 + .../query-has-property.util.ts | 3 +- src/utils/sort-query-properties/normalize.ts | 58 ++++++ .../sort-query-properties.util.ts | 39 +--- .../stringify-params/stringify-params.util.md | 6 + .../stringify-params.util.test.ts | 182 ++++++++++++++++++ .../stringify-params/stringify-params.util.ts | 32 +++ test/index.test.ts | 1 + 42 files changed, 642 insertions(+), 214 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 src/common/clone.test.ts create mode 100644 src/common/early.ts create mode 100644 src/common/has-own-property.ts create mode 100644 src/common/is-plain-object.ts create mode 100644 src/common/to-array.ts delete mode 100644 src/hooks/cache/cache-utils.test.ts delete mode 100644 src/hooks/cache/cache-utils.ts create mode 100644 src/utils/sort-query-properties/normalize.ts create mode 100644 src/utils/stringify-params/stringify-params.util.md create mode 100644 src/utils/stringify-params/stringify-params.util.test.ts create mode 100644 src/utils/stringify-params/stringify-params.util.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..afac8fa --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,65 @@ +# CLAUDE.md + +ESM-only (`type: module`) Feathers utility library. All relative imports use the +`.js` extension. + +## Entrypoints + +Seven barrels, each a package export and a tsdown build entry: +`.` · `./hooks` · `./utils` · `./predicates` · `./resolvers` · `./transformers` · `./guards` +→ `src//index.ts`. A new public feature must be re-exported from its +category barrel. + +## Feature folder convention + +Each public feature is its own **kebab-case** folder under its category, holding +sibling files named `..*` where `kind` ∈ +`util|hook|predicate|resolver|transformer|guard`: + +- `..ts` — implementation; the main export is **camelCase**. +- `..test.ts` — runtime tests (vitest). +- `..test-d.ts` — type-level tests (optional). +- `..md` — docs frontmatter (see below). + +## Shared internals + +- `src/common/` — shared helpers, exported via `src/common/index.js`, imported as + `../../common/index.js`. Small ones use in-source tests (`if (import.meta.vitest)`), + stripped from the build. +- `src/internal.utils.ts` — internal helpers/types, not public API. + +## Docs (VitePress) + +Auto-discovered from `src/**/*.md`; the prose body is generated from the sibling +`.ts` file's JSDoc — keep `@example`/`@see` authoritative there. Frontmatter: + +```yaml +--- +title: stringifyParams # camelCase export name +category: utils +see: # optional cross-links + - hooks/cache + - utils/gateParams +--- +``` + +- **`see:` references use `category/camelCaseName`** — the camelCase export name, + NOT the kebab folder/slug (`utils/gateParams`, never `utils/gate-params`). + +## Tests + +- `vitest`, globals on. Runtime `*.test.ts`; type-level `*.test-d.ts` (typecheck + enabled in vitest). Coverage thresholds 80%. +- `test/index.test.ts` asserts the **exact** public export surface per entrypoint + — update its lists when adding/removing an export. + +## Lint / format + +- ESLint (`@feathers-community/eslint-config`) has **Prettier built in as a rule** — + there is no separate prettier config; run `eslint --fix` to format. +- **No `node:` protocol imports in `src/**/*.ts`** (lint error; allowed in tests). + +## Commands + +- `npm test` — lint + typecheck + coverage (the full gate). +- `npm run lint` · `npm run typecheck` · `npm run test:unit` · `npm run build` (tsdown). diff --git a/package.json b/package.json index 0474d1c..a878913 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,8 @@ "dequal": "^2.0.3", "fast-copy": "^4.0.3", "lodash": "^4.18.1", - "neotraverse": "^0.6.18" + "neotraverse": "^0.6.18", + "safe-stable-stringify": "^2.5.0" }, "devDependencies": { "@feathers-community/eslint-config": "^0.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fb05ca..396e192 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: neotraverse: specifier: ^0.6.18 version: 0.6.18 + safe-stable-stringify: + specifier: ^2.5.0 + version: 2.5.0 devDependencies: '@feathers-community/eslint-config': specifier: ^0.2.0 @@ -3110,6 +3113,10 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -6649,6 +6656,8 @@ snapshots: safe-buffer@5.2.1: {} + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} sass@1.100.0: diff --git a/src/common/clone.test.ts b/src/common/clone.test.ts deleted file mode 100644 index 515fb97..0000000 --- a/src/common/clone.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { clone } from './clone.js' - -describe('clone', () => { - it('deep-clones plain objects', () => { - const input = { a: 1, nested: { b: 2 } } - const out = clone(input) - expect(out).toEqual(input) - expect(out).not.toBe(input) - expect(out.nested).not.toBe(input.nested) - }) - - it('preserves Date instances (not a JSON string)', () => { - const date = new Date('2023-10-01T12:00:00Z') - const out = clone({ date }) - expect(out.date).toBeInstanceOf(Date) - expect(out.date.getTime()).toBe(date.getTime()) - expect(out.date).not.toBe(date) - }) - - it('preserves undefined values', () => { - const out = clone({ a: undefined, b: 1 }) - expect('a' in out).toBe(true) - expect(out.a).toBeUndefined() - }) - - it('handles arrays', () => { - const input = [{ a: 1 }, { b: 2 }] - const out = clone(input) - expect(out).toEqual(input) - expect(out[0]).not.toBe(input[0]) - }) -}) diff --git a/src/common/clone.ts b/src/common/clone.ts index 5ff717a..736c295 100644 --- a/src/common/clone.ts +++ b/src/common/clone.ts @@ -13,3 +13,38 @@ import { copy } from 'fast-copy' * ``` */ export const clone = copy + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest + + describe('clone', () => { + it('deep-clones plain objects', () => { + const input = { a: 1, nested: { b: 2 } } + const out = clone(input) + expect(out).toEqual(input) + expect(out).not.toBe(input) + expect(out.nested).not.toBe(input.nested) + }) + + it('preserves Date instances (not a JSON string)', () => { + const date = new Date('2023-10-01T12:00:00Z') + const out = clone({ date }) + expect(out.date).toBeInstanceOf(Date) + expect(out.date.getTime()).toBe(date.getTime()) + expect(out.date).not.toBe(date) + }) + + it('preserves undefined values', () => { + const out = clone({ a: undefined, b: 1 }) + expect('a' in out).toBe(true) + expect(out.a).toBeUndefined() + }) + + it('handles arrays', () => { + const input = [{ a: 1 }, { b: 2 }] + const out = clone(input) + expect(out).toEqual(input) + expect(out[0]).not.toBe(input[0]) + }) + }) +} diff --git a/src/common/early.ts b/src/common/early.ts new file mode 100644 index 0000000..cfbf33f --- /dev/null +++ b/src/common/early.ts @@ -0,0 +1,38 @@ +import type { HookContext } from '@feathersjs/feathers' + +/** + * Can be used to early return a hook. + * + * If it's an around hook, it will call `next` if provided. + */ +export const early = ( + context: H, + next?: (context: H) => void | Promise, +): void | Promise => { + if (next) { + return next(context) + } + return +} + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest + + describe('early', () => { + it('returns undefined when no next is given', () => { + expect(early({} as HookContext)).toBeUndefined() + }) + + it('calls next with the context and returns its result', () => { + const context = { method: 'find' } as HookContext + let received: HookContext | undefined + const next = (ctx: HookContext) => { + received = ctx + return Promise.resolve() + } + const result = early(context, next) + expect(received).toBe(context) + expect(result).toBeInstanceOf(Promise) + }) + }) +} diff --git a/src/common/has-own-property.ts b/src/common/has-own-property.ts new file mode 100644 index 0000000..2095140 --- /dev/null +++ b/src/common/has-own-property.ts @@ -0,0 +1,39 @@ +/** + * Returns `true` if `obj` has at least one of the given `keys` as an own + * (non-inherited) property. + * + * @example + * ```ts + * hasOwnProperty({ a: 1 }, 'a') // => true + * hasOwnProperty({ a: 1 }, 'b', 'a') // => true + * hasOwnProperty({ a: 1 }, 'b') // => false + * ``` + */ +export const hasOwnProperty = ( + obj: Record, + ...keys: string[] +): boolean => { + return keys.some((x) => Object.prototype.hasOwnProperty.call(obj, x)) +} + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest + + describe('hasOwnProperty', () => { + it('is true when an own key exists', () => { + expect(hasOwnProperty({ a: 1 }, 'a')).toBe(true) + }) + + it('is true when any of the given keys exists', () => { + expect(hasOwnProperty({ a: 1 }, 'b', 'a')).toBe(true) + }) + + it('is false when none of the keys exist', () => { + expect(hasOwnProperty({ a: 1 }, 'b', 'c')).toBe(false) + }) + + it('ignores inherited properties', () => { + expect(hasOwnProperty({}, 'toString')).toBe(false) + }) + }) +} diff --git a/src/common/index.ts b/src/common/index.ts index 4b439ee..d180a35 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -15,7 +15,11 @@ export function isPromise(p: any): p is Promise { export { traverse } from './traverse.js' export { clone } from './clone.js' +export { hasOwnProperty } from './has-own-property.js' +export { toArray } from './to-array.js' +export { early } from './early.js' export { isEmptyObject } from './is-empty-object.js' +export { isPlainObject } from './is-plain-object.js' export { dedupeBranches } from './dedupe-branches.js' export { flattenAndBranches } from './flatten-and-branches.js' export { flattenOrBranches } from './flatten-or-branches.js' diff --git a/src/common/is-plain-object.ts b/src/common/is-plain-object.ts new file mode 100644 index 0000000..192d2cf --- /dev/null +++ b/src/common/is-plain-object.ts @@ -0,0 +1,44 @@ +/** + * Type guard for a plain object — one whose prototype is `Object.prototype` or + * `null`. Arrays, `null`, primitives and class instances (`Date`, `RegExp`, + * `Map`, bson `ObjectId`, ...) are all `false`. + * + * @example + * ```ts + * isPlainObject({}) // => true + * isPlainObject(Object.create(null)) // => true + * isPlainObject([]) // => false + * isPlainObject(new Date()) // => false + * ``` + */ +export const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== 'object') return false + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest + + describe('isPlainObject', () => { + it('is true for plain objects', () => { + expect(isPlainObject({})).toBe(true) + expect(isPlainObject({ a: 1 })).toBe(true) + expect(isPlainObject(Object.create(null))).toBe(true) + }) + + it('is false for arrays, null and primitives', () => { + expect(isPlainObject([])).toBe(false) + expect(isPlainObject(null)).toBe(false) + expect(isPlainObject(undefined)).toBe(false) + expect(isPlainObject('')).toBe(false) + expect(isPlainObject(1)).toBe(false) + }) + + it('is false for class instances', () => { + expect(isPlainObject(new Date())).toBe(false) + expect(isPlainObject(/x/)).toBe(false) + expect(isPlainObject(new Map())).toBe(false) + }) + }) +} diff --git a/src/common/to-array.ts b/src/common/to-array.ts new file mode 100644 index 0000000..9468ca5 --- /dev/null +++ b/src/common/to-array.ts @@ -0,0 +1,29 @@ +/** + * Normalizes a value or array into an array. The returned array MUST be treated + * as read-only — when the input is already an array it is returned as-is (no copy) + * to avoid a per-call allocation on hook hot paths. + * + * @example + * ```ts + * toArray(1) // => [1] + * toArray([1, 2]) // => [1, 2] (same reference) + * ``` + */ +export const toArray = (value: T | readonly T[]): T[] => + Array.isArray(value) ? (value as T[]) : [value as T] + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest + + describe('toArray', () => { + it('wraps a non-array value in an array', () => { + expect(toArray(1)).toEqual([1]) + expect(toArray('a')).toEqual(['a']) + }) + + it('returns an array as-is (same reference, no copy)', () => { + const input = [1, 2] + expect(toArray(input)).toBe(input) + }) + }) +} diff --git a/src/hooks/cache/cache-utils.test.ts b/src/hooks/cache/cache-utils.test.ts deleted file mode 100644 index 7266ab7..0000000 --- a/src/hooks/cache/cache-utils.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { stableStringify } from './cache-utils.js' - -describe('stableStringify', () => { - it('is order-independent for top-level keys', () => { - expect(stableStringify({ a: 1, b: 2 })).toBe( - stableStringify({ b: 2, a: 1 }), - ) - }) - - it('is order-independent for query keys', () => { - const a = stableStringify({ query: { name: 'John', age: 30 } }) - const b = stableStringify({ query: { age: 30, name: 'John' } }) - expect(a).toBe(b) - }) - - it('normalizes $or array order', () => { - const a = stableStringify({ - query: { $or: [{ name: 'John' }, { name: 'Jane' }] }, - }) - const b = stableStringify({ - query: { $or: [{ name: 'Jane' }, { name: 'John' }] }, - }) - expect(a).toBe(b) - }) - - it('normalizes $in array order', () => { - const a = stableStringify({ query: { name: { $in: ['John', 'Jane'] } } }) - const b = stableStringify({ query: { name: { $in: ['Jane', 'John'] } } }) - expect(a).toBe(b) - }) - - it('distinguishes different queries', () => { - expect(stableStringify({ query: { name: 'John' } })).not.toBe( - stableStringify({ query: { name: 'Jane' } }), - ) - }) - - it('handles params without a query', () => { - expect(() => - stableStringify({ provider: 'rest', authenticated: true }), - ).not.toThrow() - expect(stableStringify({ provider: 'rest' })).toBe( - stableStringify({ provider: 'rest' }), - ) - }) - - it('throws on non-JSON (function) param values', () => { - expect(() => stableStringify({ fn: () => 1 })).toThrow( - 'Cannot stringify non JSON value', - ) - }) - - it('produces a deterministic string for nested objects', () => { - const a = stableStringify({ - query: { user: { id: 1, role: 'admin' }, age: { $gt: 18 } }, - }) - const b = stableStringify({ - query: { age: { $gt: 18 }, user: { role: 'admin', id: 1 } }, - }) - expect(a).toBe(b) - }) -}) diff --git a/src/hooks/cache/cache-utils.ts b/src/hooks/cache/cache-utils.ts deleted file mode 100644 index 1dc1932..0000000 --- a/src/hooks/cache/cache-utils.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { sortQueryProperties } from '../../utils/sort-query-properties/sort-query-properties.util.js' - -export { sortQueryProperties } - -export const stableStringify = (obj: Record) => { - // Canonicalize the whole params object once (recursive key-sort + operator-array - // sort). The JSON.stringify pass then only needs to reject non-JSON values - // instead of re-sorting and re-allocating every node. - const normalized = sortQueryProperties(obj as any) - - return JSON.stringify(normalized, (_key, value) => { - if (typeof value === 'function') { - throw new Error('Cannot stringify non JSON value') - } - return value - }) -} diff --git a/src/hooks/cache/cache.hook.md b/src/hooks/cache/cache.hook.md index 41d421c..d92fac1 100644 --- a/src/hooks/cache/cache.hook.md +++ b/src/hooks/cache/cache.hook.md @@ -7,6 +7,7 @@ hook: multi: true see: - utils/gateParams + - utils/stringifyParams --- The `cache` hook caches `get` and `find` results based on `params`. On mutating methods (`create`, `update`, `patch`, `remove`), affected cache entries are automatically invalidated. diff --git a/src/hooks/cache/cache.hook.test.ts b/src/hooks/cache/cache.hook.test.ts index 8682add..35d6164 100644 --- a/src/hooks/cache/cache.hook.test.ts +++ b/src/hooks/cache/cache.hook.test.ts @@ -1214,7 +1214,7 @@ describe('cache hook with gateParams', () => { await usersService.create({ id: 1, name: 'John' }) - // `stashed` is a function; if it reached stableStringify it would throw. + // `stashed` is a function; gateParams drops it before it reaches the key. await expect( usersService.find({ query: { name: 'John' }, diff --git a/src/hooks/cache/cache.hook.ts b/src/hooks/cache/cache.hook.ts index db118ac..32440e0 100755 --- a/src/hooks/cache/cache.hook.ts +++ b/src/hooks/cache/cache.hook.ts @@ -1,5 +1,5 @@ import type { HookContext, NextFunction, Params } from '@feathersjs/feathers' -import { stableStringify } from './cache-utils.js' +import { stringifyParams } from '../../utils/stringify-params/stringify-params.util.js' import { copy } from 'fast-copy' import type { Promisable } from '../../internal.utils.js' @@ -52,17 +52,27 @@ export type CacheOptions = { transformParams: (params: Params) => Params /** * Custom serialization function for converting params into a cache key string. - * By default, uses `stableStringify` which sorts object keys and normalizes + * By default, uses {@link stringifyParams} which sorts object keys and normalizes * query operator arrays (`$or`, `$and`, `$in`, etc.) for order-independent caching. * - * Override this to use a custom serialization strategy. + * 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: new Map(), + * map: redisCache, * transformParams: (params) => ({ query: params.query }), - * serialize: (params) => JSON.stringify(params), + * serialize: (params) => + * createHash('sha256').update(stringifyParams(params)).digest('base64url'), * }) * ``` */ @@ -169,7 +179,7 @@ class ContextCacheMap { this.map = options.map this.options = options this.log = options.logger - this.serialize = options.serialize ?? stableStringify + this.serialize = options.serialize ?? stringifyParams this.clone = options.clone === false ? (value) => value diff --git a/src/hooks/check-required/check-required.hook.ts b/src/hooks/check-required/check-required.hook.ts index 6285596..c4d9620 100755 --- a/src/hooks/check-required/check-required.hook.ts +++ b/src/hooks/check-required/check-required.hook.ts @@ -5,7 +5,7 @@ import { BadRequest } from '@feathersjs/errors' import { checkContext, getDataIsArray } from '../../utils/index.js' import type { HookContext, NextFunction } from '@feathersjs/feathers' import type { MaybeArray } from '../../internal.utils.js' -import { toArray } from '../../internal.utils.js' +import { toArray } from '../../common/index.js' /** * Validates that the specified fields exist on `context.data` and are not falsy. diff --git a/src/hooks/disallow/disallow.hook.ts b/src/hooks/disallow/disallow.hook.ts index 190c7a9..c8fe1b3 100755 --- a/src/hooks/disallow/disallow.hook.ts +++ b/src/hooks/disallow/disallow.hook.ts @@ -3,7 +3,7 @@ import type { HookContext, NextFunction } from '@feathersjs/feathers' import type { TransportName } from '../../types.js' import { isProvider } from '../../predicates/index.js' import type { MaybeArray } from '../../internal.utils.js' -import { toArray } from '../../internal.utils.js' +import { toArray } from '../../common/index.js' /** * Prevents access to a service method completely or for specific transports. diff --git a/src/hooks/find-or-create/find-or-create.hook.ts b/src/hooks/find-or-create/find-or-create.hook.ts index a1dbb56..c2fe8d7 100644 --- a/src/hooks/find-or-create/find-or-create.hook.ts +++ b/src/hooks/find-or-create/find-or-create.hook.ts @@ -3,11 +3,11 @@ import _set from 'lodash/set.js' import { BadRequest } from '@feathersjs/errors' import type { HookContext, NextFunction, Params } from '@feathersjs/feathers' import { checkContext, getDataIsArray } from '../../utils/index.js' -import { - toArray, - type KeyOfOrDotNotation, - type MaybeArray, - type NeverFallback, +import { toArray } from '../../common/index.js' +import type { + KeyOfOrDotNotation, + MaybeArray, + NeverFallback, } from '../../internal.utils.js' import type { InferCreateDataSingle, diff --git a/src/hooks/params-for-server/params-for-server.hook.ts b/src/hooks/params-for-server/params-for-server.hook.ts index 0f56abc..0959419 100644 --- a/src/hooks/params-for-server/params-for-server.hook.ts +++ b/src/hooks/params-for-server/params-for-server.hook.ts @@ -1,6 +1,6 @@ import type { HookContext, NextFunction } from '@feathersjs/feathers' import type { MaybeArray } from '../../internal.utils.js' -import { toArray } from '../../internal.utils.js' +import { toArray } from '../../common/index.js' import { FROM_CLIENT_FOR_SERVER_DEFAULT_KEY } from './params-for-from-shared.js' export type ParamsForServerOptions = { diff --git a/src/hooks/params-from-client/params-from-client.hook.ts b/src/hooks/params-from-client/params-from-client.hook.ts index b2ee54f..9a84c51 100644 --- a/src/hooks/params-from-client/params-from-client.hook.ts +++ b/src/hooks/params-from-client/params-from-client.hook.ts @@ -1,6 +1,6 @@ import type { HookContext, NextFunction } from '@feathersjs/feathers' import type { MaybeArray } from '../../internal.utils.js' -import { toArray } from '../../internal.utils.js' +import { toArray } from '../../common/index.js' import { FROM_CLIENT_FOR_SERVER_DEFAULT_KEY } from '../params-for-server/params-for-from-shared.js' export type paramsFromClientOptions = { diff --git a/src/hooks/prevent-changes/prevent-changes.hook.ts b/src/hooks/prevent-changes/prevent-changes.hook.ts index f344157..e5b3b3c 100755 --- a/src/hooks/prevent-changes/prevent-changes.hook.ts +++ b/src/hooks/prevent-changes/prevent-changes.hook.ts @@ -4,7 +4,7 @@ import type { FeathersError } from '@feathersjs/errors' import { BadRequest } from '@feathersjs/errors' import { transformData } from '../transform-data/transform-data.hook.js' import type { KeyOfOrDotNotation, MaybeArray } from '../../internal.utils.js' -import { toArray } from '../../internal.utils.js' +import { toArray } from '../../common/index.js' import type { HookContext } from '@feathersjs/feathers' import type { DataSingleHookContext } from '../../utility-types/hook-context.js' diff --git a/src/hooks/soft-delete/soft-delete.hook.ts b/src/hooks/soft-delete/soft-delete.hook.ts index 070f633..84af18e 100755 --- a/src/hooks/soft-delete/soft-delete.hook.ts +++ b/src/hooks/soft-delete/soft-delete.hook.ts @@ -2,7 +2,8 @@ import type { HookContext, NextFunction } from '@feathersjs/feathers' import { addToQuery, checkContext, queryDefaults } from '../../utils/index.js' import type { TransformParamsFn } from '../../types.js' import { transformParams } from '../../utils/transform-params/transform-params.util.js' -import { early, type Promisable } from '../../internal.utils.js' +import { early } from '../../common/index.js' +import type { Promisable } from '../../internal.utils.js' import { isPromise } from '../../common/index.js' export type SoftDeleteOptionFunction = ( diff --git a/src/hooks/throw-if-is-provider/throw-if-is-provider.hook.ts b/src/hooks/throw-if-is-provider/throw-if-is-provider.hook.ts index f1fae1e..997dbf4 100644 --- a/src/hooks/throw-if-is-provider/throw-if-is-provider.hook.ts +++ b/src/hooks/throw-if-is-provider/throw-if-is-provider.hook.ts @@ -4,7 +4,7 @@ import { throwIf } from '../throw-if/throw-if.hook.js' import { every, isProvider } from '../../predicates/index.js' import type { FeathersError } from '@feathersjs/errors' import { MethodNotAllowed } from '@feathersjs/errors' -import { toArray } from '../../internal.utils.js' +import { toArray } from '../../common/index.js' const defaultError = (context: HookContext) => new MethodNotAllowed( diff --git a/src/internal.utils.ts b/src/internal.utils.ts index e23490e..148be76 100644 --- a/src/internal.utils.ts +++ b/src/internal.utils.ts @@ -1,21 +1,5 @@ -import type { HookContext } from '@feathersjs/feathers' - -export const hasOwnProperty = ( - obj: Record, - ...keys: string[] -): boolean => { - return keys.some((x) => Object.prototype.hasOwnProperty.call(obj, x)) -} - export type MaybeArray = T | readonly T[] export type UnpackMaybeArray = T extends readonly (infer E)[] ? E : T -/** - * Normalizes a value or array into an array. The returned array MUST be treated - * as read-only — when the input is already an array it is returned as-is (no copy) - * to avoid a per-call allocation on hook hot paths. - */ -export const toArray = (value: T | readonly T[]): T[] => - Array.isArray(value) ? (value as T[]) : [value as T] export type Promisable = T | Promise export type KeyOf = Extract @@ -31,18 +15,3 @@ export type NeverFallback = [Never] extends [never] : Never export type KeyOfOrDotNotation = KeyOf | `${KeyOf}.${string}` - -/**+ - * Can be used to early return a hook. - * - * If it's an around hook, it will call `next` if provided. - */ -export const early = ( - context: H, - next?: (context: H) => Promisable, -): Promisable => { - if (next) { - return next(context) - } - return -} diff --git a/src/predicates/is-context/is-context.predicate.ts b/src/predicates/is-context/is-context.predicate.ts index c4ce221..bd191c1 100644 --- a/src/predicates/is-context/is-context.predicate.ts +++ b/src/predicates/is-context/is-context.predicate.ts @@ -1,6 +1,6 @@ import type { HookContext } from '@feathersjs/feathers' import type { MaybeArray } from '../../internal.utils.js' -import { toArray } from '../../internal.utils.js' +import { toArray } from '../../common/index.js' export type IsContextOptions = { path?: MaybeArray diff --git a/src/transformers/lowercase/lowercase.transformer.ts b/src/transformers/lowercase/lowercase.transformer.ts index 8c52574..620407b 100644 --- a/src/transformers/lowercase/lowercase.transformer.ts +++ b/src/transformers/lowercase/lowercase.transformer.ts @@ -1,7 +1,7 @@ import _get from 'lodash/get.js' import _set from 'lodash/set.js' import type { MaybeArray } from '../../internal.utils.js' -import { toArray } from '../../internal.utils.js' +import { toArray } from '../../common/index.js' import { BadRequest } from '@feathersjs/errors' import type { StringFieldKey } from '../../types.js' diff --git a/src/transformers/omit/omit.transformer.ts b/src/transformers/omit/omit.transformer.ts index e978b90..efa2117 100644 --- a/src/transformers/omit/omit.transformer.ts +++ b/src/transformers/omit/omit.transformer.ts @@ -1,6 +1,6 @@ import _omit from 'lodash/omit.js' import type { MaybeArray } from '../../internal.utils.js' -import { toArray } from '../../internal.utils.js' +import { toArray } from '../../common/index.js' import type { FieldKey } from '../../types.js' /** diff --git a/src/transformers/parse-date/parse-date.transformer.ts b/src/transformers/parse-date/parse-date.transformer.ts index a36aeb6..c4e6d64 100644 --- a/src/transformers/parse-date/parse-date.transformer.ts +++ b/src/transformers/parse-date/parse-date.transformer.ts @@ -1,5 +1,6 @@ import { BadRequest } from '@feathersjs/errors' -import { toArray, type MaybeArray } from '../../internal.utils.js' +import { toArray } from '../../common/index.js' +import type { MaybeArray } from '../../internal.utils.js' import _get from 'lodash/get.js' import _set from 'lodash/set.js' import type { FieldKey } from '../../types.js' diff --git a/src/transformers/pick/pick.transformer.ts b/src/transformers/pick/pick.transformer.ts index ec9fa45..3c799ba 100644 --- a/src/transformers/pick/pick.transformer.ts +++ b/src/transformers/pick/pick.transformer.ts @@ -1,6 +1,6 @@ import _pick from 'lodash/pick.js' import type { MaybeArray } from '../../internal.utils.js' -import { toArray } from '../../internal.utils.js' +import { toArray } from '../../common/index.js' import type { FieldKey } from '../../types.js' /** diff --git a/src/transformers/set-now/set-now.transformer.ts b/src/transformers/set-now/set-now.transformer.ts index 40bee93..693587b 100644 --- a/src/transformers/set-now/set-now.transformer.ts +++ b/src/transformers/set-now/set-now.transformer.ts @@ -1,6 +1,6 @@ import _set from 'lodash/set.js' import type { MaybeArray } from '../../internal.utils.js' -import { toArray } from '../../internal.utils.js' +import { toArray } from '../../common/index.js' import type { FieldKey } from '../../types.js' /** diff --git a/src/transformers/trim/trim.transformer.ts b/src/transformers/trim/trim.transformer.ts index 0138931..af00edd 100644 --- a/src/transformers/trim/trim.transformer.ts +++ b/src/transformers/trim/trim.transformer.ts @@ -1,6 +1,6 @@ import { BadRequest } from '@feathersjs/errors' import type { MaybeArray } from '../../internal.utils.js' -import { toArray } from '../../internal.utils.js' +import { toArray } from '../../common/index.js' import _get from 'lodash/get.js' import _set from 'lodash/set.js' import type { StringFieldKey } from '../../types.js' diff --git a/src/utils/gate-params/gate-params.util.test.ts b/src/utils/gate-params/gate-params.util.test.ts index f7c8334..092b2bd 100644 --- a/src/utils/gate-params/gate-params.util.test.ts +++ b/src/utils/gate-params/gate-params.util.test.ts @@ -129,4 +129,50 @@ describe('gateParams', () => { expect(params).toEqual(snapshot) }) + + it('never writes to the input params (deep-frozen, all code paths)', () => { + const deepFreeze = (value: T): T => { + if (value && typeof value === 'object') { + Object.values(value).forEach(deepFreeze) + Object.freeze(value) + } + return value + } + + // A frozen object throws on any write attempt in strict mode (ESM), so if + // gateParams tried to mutate params or any nested object this would throw. + const params = deepFreeze({ + query: { name: 'John', $or: [{ a: 1 }, { b: 2 }] }, + user: { id: 7, updatedAt: 123 }, + provider: 'rest', + rateLimit: { remaining: 9 }, + foo: 1, + } as any) + + const onUnknownParams = vi.fn() + + // nested pick + false-drop + keep-default + observer + expect( + gateParams( + params, + { 'user.id': true, rateLimit: false }, + { onUnknownParams }, + ), + ).toEqual({ + query: { name: 'John', $or: [{ a: 1 }, { b: 2 }] }, + user: { id: 7 }, + provider: 'rest', + foo: 1, + }) + + // projection + dropUnknownParams (whitelist) + expect( + gateParams(params, { user: (u) => u.id }, { dropUnknownParams: true }), + ).toEqual({ + query: { name: 'John', $or: [{ a: 1 }, { b: 2 }] }, + user: 7, + }) + + expect(onUnknownParams).toHaveBeenCalledWith(['provider', 'foo'], params) + }) }) diff --git a/src/utils/gate-params/gate-params.util.ts b/src/utils/gate-params/gate-params.util.ts index 1303a31..604a712 100644 --- a/src/utils/gate-params/gate-params.util.ts +++ b/src/utils/gate-params/gate-params.util.ts @@ -65,13 +65,18 @@ export type GateParamsOptions

= { /** * Selects and/or projects `params` keys according to a declarative path `schema`, - * returning a NEW object (never mutates `params`). General-purpose — no cache - * knowledge. Typically composed into the cache hook's `transformParams` option - * as `(p) => gateParams(p, schema, opts)`. + * returning a NEW object. General-purpose — no cache knowledge. Typically composed + * into the cache hook's `transformParams` option as `(p) => gateParams(p, schema, opts)`. * * Paths are resolved with lodash `get`/`has` and written with `set`, so nested * values can be picked declaratively (`'user.id': true`). * + * `params` is never mutated: kept values are copied over by reference into the + * fresh result (safe, since the result is only read/serialized). The one exception + * is combining a top-level rule with a nested path under the SAME parent in one + * schema (e.g. `{ query: true, 'query.x': ... }`) — the nested `set` would then + * write into the shared parent. Pick one granularity per parent to avoid it. + * * `query` is included as-is by DEFAULT (it is always relevant), unless the schema * addresses it explicitly — either as `query` or a nested `query.*` path. * diff --git a/src/utils/get-paginate/get-paginate.util.ts b/src/utils/get-paginate/get-paginate.util.ts index 18042ed..a82b919 100644 --- a/src/utils/get-paginate/get-paginate.util.ts +++ b/src/utils/get-paginate/get-paginate.util.ts @@ -1,6 +1,6 @@ import type { PaginationOptions } from '@feathersjs/adapter-commons' import type { HookContext } from '@feathersjs/feathers' -import { hasOwnProperty } from '../../internal.utils.js' +import { hasOwnProperty } from '../../common/index.js' /** * Resolves the active pagination options for the current hook context. diff --git a/src/utils/index.ts b/src/utils/index.ts index 174d056..b25badf 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -21,6 +21,7 @@ export * from './replace-result/replace-result.util.js' export * from './simplify-query/simplify-query.util.js' export * from './skip-result/skip-result.util.js' export * from './sort-query-properties/sort-query-properties.util.js' +export * from './stringify-params/stringify-params.util.js' export * from './to-paginated/to-paginated.util.js' export * from './transform-params/transform-params.util.js' export * from './wait-for-service-event/wait-for-service-event.util.js' diff --git a/src/utils/query-has-property/query-has-property.util.ts b/src/utils/query-has-property/query-has-property.util.ts index 091b115..efa229a 100644 --- a/src/utils/query-has-property/query-has-property.util.ts +++ b/src/utils/query-has-property/query-has-property.util.ts @@ -1,5 +1,6 @@ import type { Query } from '@feathersjs/feathers' -import { toArray, type MaybeArray } from '../../internal.utils.js' +import { toArray } from '../../common/index.js' +import type { MaybeArray } from '../../internal.utils.js' import { walkQuery } from '../walk-query/walk-query.util.js' /** diff --git a/src/utils/sort-query-properties/normalize.ts b/src/utils/sort-query-properties/normalize.ts new file mode 100644 index 0000000..6cae434 --- /dev/null +++ b/src/utils/sort-query-properties/normalize.ts @@ -0,0 +1,58 @@ +import safeStringify from 'safe-stable-stringify' +import { isPlainObject } from '../../common/index.js' + +const arrayOperators = new Set(['$or', '$and', '$nor', '$not', '$in', '$nin']) + +/** + * Recursively canonicalizes a value so semantically-equal inputs produce the + * same structure: + * - **sortObject**: object keys are sorted recursively. + * - **sortArray**: elements of query array operators (`$or`, `$and`, `$nor`, + * `$not`, `$in`, `$nin`) are order-normalized. + * + * Only plain objects and arrays are rebuilt; special objects (`Date`, `RegExp`, + * `ObjectId`, class instances, ...) are passed through untouched. + * + * Cycles are handled by passing the already-seen reference straight through: + * this stops our own recursion while leaving the cyclic topology intact, so a + * downstream `safe-stable-stringify` pass can replace it with `[Circular]`. + * + * @internal shared by `sortQueryProperties` and `stringifyParams`. + */ +export const normalize = ( + value: any, + seen: WeakSet = new WeakSet(), +): any => { + if (Array.isArray(value)) { + return value.map((el) => normalize(el, seen)) + } + + if (!isPlainObject(value)) { + return value + } + + if (seen.has(value)) { + return value + } + seen.add(value) + + const result: Record = {} + for (const key of Object.keys(value).sort()) { + const child = normalize(value[key], seen) + + if (arrayOperators.has(key) && Array.isArray(child)) { + // Schwartzian transform: serialize each normalized element once, sort by + // that key, then unwrap. `safeStringify` keeps this crash-safe on cyclic + // or otherwise non-serializable elements. + result[key] = child + .map((el) => ({ k: safeStringify(el) ?? '', v: el })) + .sort((a, b) => (a.k < b.k ? -1 : a.k > b.k ? 1 : 0)) + .map((entry) => entry.v) + } else { + result[key] = child + } + } + + seen.delete(value) + return result +} diff --git a/src/utils/sort-query-properties/sort-query-properties.util.ts b/src/utils/sort-query-properties/sort-query-properties.util.ts index 2f4ce07..d623e4e 100644 --- a/src/utils/sort-query-properties/sort-query-properties.util.ts +++ b/src/utils/sort-query-properties/sort-query-properties.util.ts @@ -1,9 +1,5 @@ import type { Query } from '@feathersjs/feathers' - -const arrayOperators = new Set(['$or', '$and', '$nor', '$not', '$in', '$nin']) - -const isPlainObjectLike = (value: unknown): value is Record => - value !== null && typeof value === 'object' +import { normalize } from './normalize.js' /** * Recursively normalizes a Feathers query object for order-independent comparison. @@ -31,36 +27,3 @@ const isPlainObjectLike = (value: unknown): value is Record => export const sortQueryProperties = (query: Q): Q => { return normalize(query) as Q } - -const normalize = (value: any): any => { - if (Array.isArray(value)) { - return value.map(normalize) - } - - if (!isPlainObjectLike(value)) { - return value - } - - const sorted: Record = {} - - for (const key of Object.keys(value).sort()) { - const val = value[key] - - if (arrayOperators.has(key) && Array.isArray(val)) { - // Schwartzian transform: serialize each normalized element once, sort by - // that key, then unwrap. Avoids the O(n log n) repeated JSON.stringify of - // the previous comparator (which also returned 1 for equal elements). - sorted[key] = val - .map((el) => { - const normalized = normalize(el) - return { k: JSON.stringify(normalized), v: normalized } - }) - .sort((a, b) => (a.k < b.k ? -1 : a.k > b.k ? 1 : 0)) - .map((entry) => entry.v) - } else { - sorted[key] = normalize(val) - } - } - - return sorted -} diff --git a/src/utils/stringify-params/stringify-params.util.md b/src/utils/stringify-params/stringify-params.util.md new file mode 100644 index 0000000..0f5eae8 --- /dev/null +++ b/src/utils/stringify-params/stringify-params.util.md @@ -0,0 +1,6 @@ +--- +title: stringifyParams +category: utils +see: + - hooks/cache +--- diff --git a/src/utils/stringify-params/stringify-params.util.test.ts b/src/utils/stringify-params/stringify-params.util.test.ts new file mode 100644 index 0000000..2bb2c6a --- /dev/null +++ b/src/utils/stringify-params/stringify-params.util.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect } from 'vitest' +import { stringifyParams } from './stringify-params.util.js' + +describe('stringifyParams', () => { + it('is order-independent for top-level keys', () => { + expect(stringifyParams({ a: 1, b: 2 } as any)).toBe( + stringifyParams({ b: 2, a: 1 } as any), + ) + }) + + it('is order-independent for query keys', () => { + const a = stringifyParams({ query: { name: 'John', age: 30 } }) + const b = stringifyParams({ query: { age: 30, name: 'John' } }) + expect(a).toBe(b) + }) + + it('normalizes $or array order', () => { + const a = stringifyParams({ + query: { $or: [{ name: 'John' }, { name: 'Jane' }] }, + }) + const b = stringifyParams({ + query: { $or: [{ name: 'Jane' }, { name: 'John' }] }, + }) + expect(a).toBe(b) + }) + + it('normalizes $in array order', () => { + const a = stringifyParams({ query: { name: { $in: ['John', 'Jane'] } } }) + const b = stringifyParams({ query: { name: { $in: ['Jane', 'John'] } } }) + expect(a).toBe(b) + }) + + it('normalizes $nin array order', () => { + const a = stringifyParams({ query: { status: { $nin: [3, 1, 2] } } }) + const b = stringifyParams({ query: { status: { $nin: [1, 2, 3] } } }) + expect(a).toBe(b) + }) + + it('normalizes a $in nested inside $and (recursively)', () => { + const a = stringifyParams({ + query: { + $and: [ + { status: { $in: ['active', 'pending'] } }, + { role: { $in: ['admin', 'user'] } }, + ], + }, + }) + const b = stringifyParams({ + query: { + $and: [ + { role: { $in: ['user', 'admin'] } }, + { status: { $in: ['pending', 'active'] } }, + ], + }, + }) + // both the outer $and element order and the inner $in order are normalized + expect(a).toBe(b) + }) + + it('normalizes a $in nested inside $or (recursively)', () => { + const a = stringifyParams({ + query: { $or: [{ id: { $in: [3, 1, 2] } }, { name: 'John' }] }, + }) + const b = stringifyParams({ + query: { $or: [{ name: 'John' }, { id: { $in: [1, 2, 3] } }] }, + }) + expect(a).toBe(b) + }) + + it('does NOT sort order-significant arrays like $between', () => { + const a = stringifyParams({ query: { age: { $between: [20, 30] } } as any }) + const b = stringifyParams({ query: { age: { $between: [30, 20] } } as any }) + // $between is not an order-independent operator -> order must be preserved + expect(a).not.toBe(b) + }) + + it('does NOT sort plain (non-operator) array fields', () => { + const a = stringifyParams({ query: { tags: ['b', 'a', 'c'] } as any }) + const b = stringifyParams({ query: { tags: ['a', 'b', 'c'] } as any }) + expect(a).not.toBe(b) + // and the original order survives in the output + expect(a).toContain('["b","a","c"]') + }) + + it('does NOT sort arrays nested in a JSON/data field value', () => { + const a = stringifyParams({ + query: { settings: { order: [3, 1, 2] } } as any, + }) + const b = stringifyParams({ + query: { settings: { order: [1, 2, 3] } } as any, + }) + expect(a).not.toBe(b) + expect(a).toContain('[3,1,2]') + }) + + it('does not mutate the original params (order preserved)', () => { + const params = { + query: { + $or: [{ name: 'John' }, { name: 'Jane' }], + status: { $in: [3, 1, 2] }, + tags: ['b', 'a', 'c'], + }, + } + const snapshot = structuredClone(params) + + stringifyParams(params) + + // input is left byte-for-byte identical, incl. all array orderings + expect(params).toEqual(snapshot) + expect(params.query.$or).toEqual([{ name: 'John' }, { name: 'Jane' }]) + expect(params.query.status.$in).toEqual([3, 1, 2]) + expect(params.query.tags).toEqual(['b', 'a', 'c']) + }) + + it('distinguishes different queries', () => { + expect(stringifyParams({ query: { name: 'John' } })).not.toBe( + stringifyParams({ query: { name: 'Jane' } }), + ) + }) + + it('handles params without a query', () => { + expect(() => + stringifyParams({ provider: 'rest', authenticated: true } as any), + ).not.toThrow() + expect(stringifyParams({ provider: 'rest' })).toBe( + stringifyParams({ provider: 'rest' }), + ) + }) + + it('produces a deterministic string for nested objects', () => { + const a = stringifyParams({ + query: { user: { id: 1, role: 'admin' }, age: { $gt: 18 } }, + }) + const b = stringifyParams({ + query: { age: { $gt: 18 }, user: { role: 'admin', id: 1 } }, + }) + expect(a).toBe(b) + }) + + it('drops function-valued params instead of throwing', () => { + expect(() => stringifyParams({ fn: () => 1 } as any)).not.toThrow() + // the function key is omitted, like JSON.stringify does for object props + expect( + stringifyParams({ query: { name: 'John' }, fn: () => 1 } as any), + ).toBe(stringifyParams({ query: { name: 'John' } })) + }) + + it('does not throw on circular references', () => { + const circular: Record = { query: { name: 'John' } } + circular.self = circular + expect(() => stringifyParams(circular)).not.toThrow() + expect(stringifyParams(circular)).toContain('[Circular]') + }) + + it('distinguishes different Date values via toJSON', () => { + const a = stringifyParams({ query: { createdAt: new Date('2020-01-01') } }) + const b = stringifyParams({ query: { createdAt: new Date('2024-01-01') } }) + expect(a).not.toBe(b) + }) + + it('produces the same key for equal Date values (different instances)', () => { + const a = stringifyParams({ query: { createdAt: new Date('2020-01-01') } }) + const b = stringifyParams({ query: { createdAt: new Date('2020-01-01') } }) + expect(a).toBe(b) + }) + + it('serializes BigInt without throwing', () => { + expect(() => stringifyParams({ query: { count: 10n } })).not.toThrow() + expect(stringifyParams({ query: { count: 10n } })).toBe( + stringifyParams({ query: { count: 10n } }), + ) + }) + + it('drops symbol values without throwing', () => { + expect(() => + stringifyParams({ query: { name: 'John' }, sym: Symbol('x') } as any), + ).not.toThrow() + expect( + stringifyParams({ query: { name: 'John' }, sym: Symbol('x') } as any), + ).toBe(stringifyParams({ query: { name: 'John' } })) + }) +}) diff --git a/src/utils/stringify-params/stringify-params.util.ts b/src/utils/stringify-params/stringify-params.util.ts new file mode 100644 index 0000000..76f668e --- /dev/null +++ b/src/utils/stringify-params/stringify-params.util.ts @@ -0,0 +1,32 @@ +import safeStringify from 'safe-stable-stringify' +import type { Params } from '@feathersjs/feathers' +import { normalize } from '../sort-query-properties/normalize.js' + +/** + * Serializes Feathers `params` into a stable, deterministic string — built for + * generating cache keys. + * + * Two normalizations make semantically-equal params produce the same string: + * - **sortObject**: object keys are sorted recursively. + * - **sortArray**: elements of query array operators (`$or`, `$and`, `$nor`, + * `$not`, `$in`, `$nin`) are order-normalized. + * + * Serialization is crash-safe and never throws on values that leak through a + * params whitelist: circular references become `[Circular]`, + * functions/`undefined`/`symbol` are dropped (like `JSON.stringify`), `BigInt` + * is stringified, and objects with `toJSON` (e.g. `Date`, bson `ObjectId`) are + * serialized via it. + * + * @example + * ```ts + * import { stringifyParams } from 'feathers-utils/utils' + * + * stringifyParams({ query: { name: 'John', age: 30 } }) + * // === stringifyParams({ query: { age: 30, name: 'John' } }) + * ``` + * + * @see https://utils.feathersjs.com/utils/stringify-params.html + */ +export const stringifyParams = (params: Params): string => { + return safeStringify(normalize(params)) ?? '' +} diff --git a/test/index.test.ts b/test/index.test.ts index f5063d0..45513e3 100755 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -69,6 +69,7 @@ const utils = [ 'simplifyQuery', 'skipResult', 'sortQueryProperties', + 'stringifyParams', 'toPaginated', 'transformParams', 'waitForServiceEvent', From 5ebecdbf8b538f140dea1d0d858dd13f0887c029 Mon Sep 17 00:00:00 2001 From: fratzinger <22286818+fratzinger@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:02:33 +0200 Subject: [PATCH 2/2] docs: add export size per hook/util/... --- docs/.vitepress/export-size.ts | 101 ++++++++ docs/.vitepress/plugins/utility.ts | 74 ++++-- docs/.vitepress/utilities.ts | 4 + package.json | 3 +- pnpm-lock.yaml | 271 ++++++++++++++++++++++ src/hooks/soft-delete/soft-delete.hook.ts | 3 +- 6 files changed, 429 insertions(+), 27 deletions(-) create mode 100644 docs/.vitepress/export-size.ts diff --git a/docs/.vitepress/export-size.ts b/docs/.vitepress/export-size.ts new file mode 100644 index 0000000..a4daca3 --- /dev/null +++ b/docs/.vitepress/export-size.ts @@ -0,0 +1,101 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import zlib from 'node:zlib' +import { build } from 'esbuild' +import type { Utility, UtilityCategory } from './utilities' + +export type BundleSize = { + /** Minified byte size of the export's own code (deps excluded). */ + minified: number + /** Gzipped byte size (level 9) of the minified output. */ + gzip: number +} + +const distDir = path.resolve(import.meta.dirname, '../../dist') + +// Memoized across the many discoverUtilities() calls (config load, each data +// loader, each *.paths.ts, markdownTransform). Invalidated when dist changes. +let cache: Map | null = null +let cacheKey = '' + +const keyOf = (category: UtilityCategory, name: string) => `${category}/${name}` + +/** Newest mtime across the dist barrels + chunks, used as a cache key. */ +async function distSignature(): Promise { + let files: string[] + try { + files = (await fs.readdir(distDir)).filter((f) => f.endsWith('.mjs')) + } catch { + return null // dist/ missing → measurement unavailable + } + if (!files.length) return null + + let newest = 0 + for (const file of files) { + const { mtimeMs } = await fs.stat(path.join(distDir, file)) + if (mtimeMs > newest) newest = mtimeMs + } + return `${newest}:${files.length}` +} + +/** + * Measures each utility's own-code bundle size (min + gzip) by re-bundling a + * single named export from its published dist barrel. npm dependencies are + * marked external (`packages: 'external'`), so only the util's own transitive + * lib code is counted — matching tsdown's externals and VueUse's semantics. + */ +async function computeSizes( + utilities: Utility[], +): Promise> { + const result = new Map() + + await Promise.all( + utilities.map(async (utility) => { + const barrel = path.join(distDir, `${utility.category}.mjs`) + try { + const out = await build({ + stdin: { + contents: `export { ${utility.name} } from ${JSON.stringify(barrel)}`, + resolveDir: distDir, + loader: 'js', + }, + bundle: true, + format: 'esm', + platform: 'neutral', + minify: true, + treeShaking: true, + packages: 'external', + write: false, + legalComments: 'none', + }) + const code = out.outputFiles[0].contents + result.set(keyOf(utility.category, utility.name), { + minified: code.byteLength, + gzip: zlib.gzipSync(code, { level: 9 }).byteLength, + }) + } catch { + // Export not found in barrel / bundling failed → skip this one. + } + }), + ) + + return result +} + +/** + * Attaches `bundleSize` to each utility in-place. No-op (leaves `bundleSize` + * undefined) when `dist/` is missing, so `docs:dev` works without a prior build. + */ +export async function attachExportSizes(utilities: Utility[]): Promise { + const signature = await distSignature() + if (!signature) return + + if (!cache || cacheKey !== signature) { + cache = await computeSizes(utilities) + cacheKey = signature + } + + for (const utility of utilities) { + utility.bundleSize = cache.get(keyOf(utility.category, utility.name)) + } +} diff --git a/docs/.vitepress/plugins/utility.ts b/docs/.vitepress/plugins/utility.ts index 33619b6..d411044 100644 --- a/docs/.vitepress/plugins/utility.ts +++ b/docs/.vitepress/plugins/utility.ts @@ -26,37 +26,63 @@ const resolveLinks = (text: string, utilities: Utility[]) => }, ) +const formatBytes = (bytes: number) => `${(bytes / 1024).toFixed(2)} kB` + export default (utility: Utility, utilities: Utility[]) => { - const code = [ - `# ${utility.title}`, - ` [Source Code](${utility.sourceUrl}) | [Documentation](${utility.docsUrl})`, - ] + const code = [`# ${utility.title}`] - // see also + // Meta grid (VueUse-style): fixed label column + value column. Rendered as a + // raw HTML block, so values use HTML (/) — markdown isn't parsed + // inside HTML blocks; only Vue components like survive. ;(() => { - const see = utility.frontmatter?.see ?? [] + const rows: [label: string, value: string][] = [] + + rows.push([ + 'Category', + ` Source Code | Documentation`, + ]) + + if (utility.bundleSize) { + rows.push([ + 'Export size', + `min ${formatBytes(utility.bundleSize.minified)} · gzip ${formatBytes(utility.bundleSize.gzip)}`, + ]) + } + + if (utility.aliases?.length) { + rows.push([ + 'Aliases', + utility.aliases.map((a) => `${a}`).join(', '), + ]) + } + const see: string[] = utility.frontmatter?.see ?? [] if (see.length > 0) { - const seeAlso = `_See also_: ${see - .map((x: string) => { - const found = utilities.find((u) => u.name === x) - if (found) { - return `[\`${x}\`](${found.path})` - } - const parts = x.split('/') - return `[\`${x}\`](/${parts.map(kebabCase).join('/')}${parts.length === 1 ? '/' : '.html'})` - }) - .join(' ')}` - - code.push(seeAlso) + rows.push([ + 'See also', + see + .map((x) => { + const found = utilities.find((u) => u.name === x) + const href = found + ? found.path + : `/${x.split('/').map(kebabCase).join('/')}${x.includes('/') ? '.html' : '/'}` + return `${x}` + }) + .join(' '), + ]) } - })() - if (utility.aliases?.length) { - code.push( - `_Aliases_: ${utility.aliases.map((a: string) => `\`${a}\``).join(', ')}`, - ) - } + const grid = [ + `
`, + ...rows.flatMap(([label, value]) => [ + `
${label}
`, + `
${value}
`, + ]), + `
`, + ].join('\n') + + code.push(grid) + })() code.push(`${resolveLinks(utility.description, utilities)} diff --git a/docs/.vitepress/utilities.ts b/docs/.vitepress/utilities.ts index 9818b9a..3fec06c 100644 --- a/docs/.vitepress/utilities.ts +++ b/docs/.vitepress/utilities.ts @@ -8,6 +8,7 @@ import type { Node } from 'typescript' import ts from 'typescript' import prettier from 'prettier' import path from 'node:path' +import { attachExportSizes, type BundleSize } from './export-size' export const utilityCategories = [ 'hooks', @@ -37,6 +38,7 @@ export type Utility = { transformers?: boolean predicates?: boolean dts: string + bundleSize?: BundleSize examples?: string[] aliases?: string[] args?: { @@ -283,5 +285,7 @@ export async function discoverUtilities() { utilitiesList.sort((a, b) => a.title.localeCompare(b.title)) + await attachExportSizes(utilitiesList) + return utilitiesList } diff --git a/package.json b/package.json index a878913..ef18436 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "scripts": { "build": "tsdown", "docs:dev": "vitepress dev docs --port 5177", - "docs:build": "vitepress build docs", + "docs:build": "pnpm build && vitepress build docs", "docs:preview": "vitepress preview docs --port 4177", "version": "pnpm build", "lint": "eslint .", @@ -116,6 +116,7 @@ "@types/node": "^25.9.2", "@vitest/coverage-v8": "^4.1.8", "dedent": "^1.7.2", + "esbuild": "^0.28.1", "eslint": "^10.4.1", "gray-matter": "^4.0.3", "lru-cache": "^11.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 396e192..fa9ac9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: dedent: specifier: ^1.7.2 version: 1.7.2 + esbuild: + specifier: ^0.28.1 + version: 0.28.1 eslint: specifier: ^10.4.1 version: 10.4.1(jiti@2.6.1) @@ -241,156 +244,312 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.27.3': resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.27.3': resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.27.3': resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.27.3': resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.27.3': resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.27.3': resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.3': resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.27.3': resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.27.3': resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.27.3': resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.27.3': resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.27.3': resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.27.3': resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.27.3': resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.27.3': resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.27.3': resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.27.3': resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.3': resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.27.3': resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.3': resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.27.3': resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.27.3': resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.27.3': resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.27.3': resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.27.3': resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1981,6 +2140,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -3761,81 +3925,159 @@ snapshots: '@esbuild/aix-ppc64@0.27.3': optional: true + '@esbuild/aix-ppc64@0.28.1': + optional: true + '@esbuild/android-arm64@0.27.3': optional: true + '@esbuild/android-arm64@0.28.1': + optional: true + '@esbuild/android-arm@0.27.3': optional: true + '@esbuild/android-arm@0.28.1': + optional: true + '@esbuild/android-x64@0.27.3': optional: true + '@esbuild/android-x64@0.28.1': + optional: true + '@esbuild/darwin-arm64@0.27.3': optional: true + '@esbuild/darwin-arm64@0.28.1': + optional: true + '@esbuild/darwin-x64@0.27.3': optional: true + '@esbuild/darwin-x64@0.28.1': + optional: true + '@esbuild/freebsd-arm64@0.27.3': optional: true + '@esbuild/freebsd-arm64@0.28.1': + optional: true + '@esbuild/freebsd-x64@0.27.3': optional: true + '@esbuild/freebsd-x64@0.28.1': + optional: true + '@esbuild/linux-arm64@0.27.3': optional: true + '@esbuild/linux-arm64@0.28.1': + optional: true + '@esbuild/linux-arm@0.27.3': optional: true + '@esbuild/linux-arm@0.28.1': + optional: true + '@esbuild/linux-ia32@0.27.3': optional: true + '@esbuild/linux-ia32@0.28.1': + optional: true + '@esbuild/linux-loong64@0.27.3': optional: true + '@esbuild/linux-loong64@0.28.1': + optional: true + '@esbuild/linux-mips64el@0.27.3': optional: true + '@esbuild/linux-mips64el@0.28.1': + optional: true + '@esbuild/linux-ppc64@0.27.3': optional: true + '@esbuild/linux-ppc64@0.28.1': + optional: true + '@esbuild/linux-riscv64@0.27.3': optional: true + '@esbuild/linux-riscv64@0.28.1': + optional: true + '@esbuild/linux-s390x@0.27.3': optional: true + '@esbuild/linux-s390x@0.28.1': + optional: true + '@esbuild/linux-x64@0.27.3': optional: true + '@esbuild/linux-x64@0.28.1': + optional: true + '@esbuild/netbsd-arm64@0.27.3': optional: true + '@esbuild/netbsd-arm64@0.28.1': + optional: true + '@esbuild/netbsd-x64@0.27.3': optional: true + '@esbuild/netbsd-x64@0.28.1': + optional: true + '@esbuild/openbsd-arm64@0.27.3': optional: true + '@esbuild/openbsd-arm64@0.28.1': + optional: true + '@esbuild/openbsd-x64@0.27.3': optional: true + '@esbuild/openbsd-x64@0.28.1': + optional: true + '@esbuild/openharmony-arm64@0.27.3': optional: true + '@esbuild/openharmony-arm64@0.28.1': + optional: true + '@esbuild/sunos-x64@0.27.3': optional: true + '@esbuild/sunos-x64@0.28.1': + optional: true + '@esbuild/win32-arm64@0.27.3': optional: true + '@esbuild/win32-arm64@0.28.1': + optional: true + '@esbuild/win32-ia32@0.27.3': optional: true + '@esbuild/win32-ia32@0.28.1': + optional: true + '@esbuild/win32-x64@0.27.3': optional: true + '@esbuild/win32-x64@0.28.1': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.4.1(jiti@2.6.1))': dependencies: eslint: 10.4.1(jiti@2.6.1) @@ -5380,6 +5622,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.3 '@esbuild/win32-x64': 0.27.3 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} escape-html@1.0.3: {} diff --git a/src/hooks/soft-delete/soft-delete.hook.ts b/src/hooks/soft-delete/soft-delete.hook.ts index 84af18e..d2dc3f6 100755 --- a/src/hooks/soft-delete/soft-delete.hook.ts +++ b/src/hooks/soft-delete/soft-delete.hook.ts @@ -2,9 +2,8 @@ import type { HookContext, NextFunction } from '@feathersjs/feathers' import { addToQuery, checkContext, queryDefaults } from '../../utils/index.js' import type { TransformParamsFn } from '../../types.js' import { transformParams } from '../../utils/transform-params/transform-params.util.js' -import { early } from '../../common/index.js' +import { early, isPromise } from '../../common/index.js' import type { Promisable } from '../../internal.utils.js' -import { isPromise } from '../../common/index.js' export type SoftDeleteOptionFunction = ( context?: H,