feathers-utils 10.0.1 → 10.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/{hooks-CzxNpt2c.mjs → hooks-yFJ5FmU5.mjs} +37 -33
  2. package/dist/hooks-yFJ5FmU5.mjs.map +1 -0
  3. package/dist/hooks.d.mts +47 -7
  4. package/dist/hooks.mjs +4 -4
  5. package/dist/{index-CNrzxmWo.d.mts → index-CgAuzxiv.d.mts} +49 -4
  6. package/dist/index.d.mts +3 -3
  7. package/dist/index.mjs +7 -7
  8. package/dist/{internal.utils-BWQ25nOd.mjs → internal.utils-3BrB8lRY.mjs} +8 -2
  9. package/dist/internal.utils-3BrB8lRY.mjs.map +1 -0
  10. package/dist/{mutate-result.util-CCBWix-G.mjs → mutate-result.util-B6FOkwrd.mjs} +73 -11
  11. package/dist/mutate-result.util-B6FOkwrd.mjs.map +1 -0
  12. package/dist/{predicates-C2SeOKGd.mjs → predicates-BC8p_YLo.mjs} +13 -15
  13. package/dist/predicates-BC8p_YLo.mjs.map +1 -0
  14. package/dist/predicates.d.mts +2 -2
  15. package/dist/predicates.mjs +1 -1
  16. package/dist/{resolve-B81gQqXW.mjs → resolve-ClHfNFlm.mjs} +3 -3
  17. package/dist/{resolve-B81gQqXW.mjs.map → resolve-ClHfNFlm.mjs.map} +1 -1
  18. package/dist/resolvers.mjs +1 -1
  19. package/dist/{transform-result.hook-DifNj7zf.mjs → transform-result.hook-CanJtTPV.mjs} +2 -2
  20. package/dist/{transform-result.hook-DifNj7zf.mjs.map → transform-result.hook-CanJtTPV.mjs.map} +1 -1
  21. package/dist/transformers.mjs +8 -4
  22. package/dist/transformers.mjs.map +1 -1
  23. package/dist/{unless.hook-CVD7SrZh.d.mts → unless.hook-BX0mOvdN.d.mts} +2 -2
  24. package/dist/{utils-ByzrJKGQ.mjs → utils-BrvpblXB.mjs} +38 -19
  25. package/dist/utils-BrvpblXB.mjs.map +1 -0
  26. package/dist/utils.d.mts +2 -2
  27. package/dist/utils.mjs +4 -4
  28. package/package.json +1 -1
  29. package/src/common/clone.ts +9 -7
  30. package/src/hooks/cache/cache-utils.ts +5 -18
  31. package/src/hooks/cache/cache.hook.ts +28 -9
  32. package/src/hooks/disallow/disallow.hook.ts +3 -1
  33. package/src/hooks/on-delete/on-delete.hook.ts +60 -40
  34. package/src/hooks/params-for-server/params-for-server.hook.ts +4 -0
  35. package/src/hooks/set-field/set-field.hook.ts +30 -8
  36. package/src/hooks/set-result/set-result.hook.ts +7 -0
  37. package/src/hooks/skippable/skippable.hook.ts +6 -4
  38. package/src/hooks/traverse/traverse.hook.ts +20 -1
  39. package/src/internal.utils.ts +6 -1
  40. package/src/predicates/and/and.predicate.ts +5 -5
  41. package/src/predicates/is-context/is-context.predicate.ts +1 -1
  42. package/src/predicates/or/or.predicate.ts +6 -8
  43. package/src/transformers/parse-date/parse-date.transformer.ts +6 -1
  44. package/src/utils/chunk-find/chunk-find.util.ts +7 -0
  45. package/src/utils/combine/combine.util.ts +8 -15
  46. package/src/utils/get-paginate/get-paginate.util.ts +1 -3
  47. package/src/utils/index.ts +2 -0
  48. package/src/utils/iterate-find/iterate-find.util.ts +8 -0
  49. package/src/utils/mutate-data/mutate-data.util.ts +21 -4
  50. package/src/utils/mutate-result/mutate-result.util.ts +4 -10
  51. package/src/utils/patch-batch/patch-batch.util.ts +32 -14
  52. package/src/utils/replace-data/replace-data.util.ts +26 -0
  53. package/src/utils/replace-result/replace-result.util.ts +60 -0
  54. package/src/utils/sort-query-properties/sort-query-properties.util.ts +15 -8
  55. package/src/utils/walk-query/walk-query.util.ts +2 -2
  56. package/dist/hooks-CzxNpt2c.mjs.map +0 -1
  57. package/dist/internal.utils-BWQ25nOd.mjs.map +0 -1
  58. package/dist/mutate-result.util-CCBWix-G.mjs.map +0 -1
  59. package/dist/predicates-C2SeOKGd.mjs.map +0 -1
  60. package/dist/utils-ByzrJKGQ.mjs.map +0 -1
@@ -1,6 +1,7 @@
1
1
  import _get from 'lodash/get.js'
2
2
  import _set from 'lodash/set.js'
3
3
  import _has from 'lodash/has.js'
4
+ import { copy } from 'fast-copy'
4
5
 
5
6
  import type { FeathersError } from '@feathersjs/errors'
6
7
  import { Forbidden } from '@feathersjs/errors'
@@ -110,6 +111,12 @@ export function setResult<H extends HookContext = HookContext>(
110
111
  }
111
112
 
112
113
  const fn = (context: H) => {
114
+ // Seed context.dispatch from result so the dispatch branch mutates (and
115
+ // persists to) a real object instead of a throwaway copy. Mirrors mutateResult.
116
+ if (!!options?.dispatch && !context.dispatch) {
117
+ context.dispatch = copy(context.result)
118
+ }
119
+
113
120
  if (options?.dispatch === 'both') {
114
121
  forResultOrDispatch(context, true)
115
122
  return forResultOrDispatch(context, false)
@@ -19,18 +19,20 @@ export const skippable = <H extends HookContext = HookContext>(
19
19
  innerHook: HookFunction<H>,
20
20
  predicate: PredicateFn<H>,
21
21
  ) => {
22
- function hook(context: H): void
22
+ function hook(context: H): H | void | Promise<H | void>
23
23
  function hook(context: H, next: NextFunction): Promise<void>
24
- function hook(context: H, next?: NextFunction): void | Promise<void> {
24
+ function hook(context: H, next?: NextFunction): H | void | Promise<H | void> {
25
25
  const skip = predicate(context)
26
26
 
27
- const skipOrRun = (shouldSkip: boolean): void | Promise<void> => {
27
+ const skipOrRun = (shouldSkip: boolean): H | void | Promise<H | void> => {
28
28
  if (shouldSkip) {
29
29
  if (next) return next()
30
30
  return
31
31
  }
32
32
  if (next) return innerHook(context, next) as Promise<void>
33
- innerHook(context)
33
+ // before/after mode: return the inner hook's result so an async hook is
34
+ // awaited and a returned/modified context is propagated to the pipeline.
35
+ return innerHook(context)
34
36
  }
35
37
 
36
38
  if (!skip || typeof skip === 'boolean') {
@@ -6,6 +6,15 @@ export type TraverseOptions = {
6
6
  getObject: (
7
7
  context: HookContext,
8
8
  ) => Record<string, any> | Record<string, any>[]
9
+ /**
10
+ * For `around` hooks only: run the traversal *after* `next()` instead of before.
11
+ * Required when `getObject` targets `context.result`, which is only populated
12
+ * once the service method has run. Defaults to `false` (run before `next()`),
13
+ * which is correct for `context.data`/`context.params.query` targets.
14
+ *
15
+ * @default false
16
+ */
17
+ runAfter?: boolean
9
18
  }
10
19
 
11
20
  /**
@@ -29,11 +38,21 @@ export type TraverseOptions = {
29
38
  export const traverse = <H extends HookContext = HookContext>({
30
39
  transformer,
31
40
  getObject,
41
+ runAfter = false,
32
42
  }: TraverseOptions) => {
43
+ const runTraverse = (context: H) => _traverse(getObject(context), transformer)
44
+
33
45
  function hook(context: H): void
34
46
  function hook(context: H, next: NextFunction): Promise<void>
35
47
  function hook(context: H, next?: NextFunction): void | Promise<void> {
36
- _traverse(getObject(context), transformer)
48
+ if (next && runAfter) {
49
+ // around hook targeting context.result: transform after the method ran
50
+ return next().then(() => {
51
+ runTraverse(context)
52
+ })
53
+ }
54
+
55
+ runTraverse(context)
37
56
 
38
57
  if (next) return next()
39
58
 
@@ -9,8 +9,13 @@ export const hasOwnProperty = (
9
9
 
10
10
  export type MaybeArray<T> = T | readonly T[]
11
11
  export type UnpackMaybeArray<T> = T extends readonly (infer E)[] ? E : T
12
+ /**
13
+ * Normalizes a value or array into an array. The returned array MUST be treated
14
+ * as read-only — when the input is already an array it is returned as-is (no copy)
15
+ * to avoid a per-call allocation on hook hot paths.
16
+ */
12
17
  export const toArray = <T>(value: T | readonly T[]): T[] =>
13
- Array.isArray(value) ? [...value] : [value as T]
18
+ Array.isArray(value) ? (value as T[]) : [value as T]
14
19
 
15
20
  export type Promisable<T> = T | Promise<T>
16
21
  export type KeyOf<T> = Extract<keyof T, string>
@@ -26,8 +26,7 @@ export const and = <H extends HookContext = HookContext>(
26
26
  )
27
27
 
28
28
  return (context: H): boolean | Promise<boolean> => {
29
- // same as Array.prototype.every for empty arrays
30
- // see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/every#description
29
+ // The identity element of logical AND is `true` (an empty AND is true).
31
30
  if (!filtered.length) {
32
31
  return true
33
32
  }
@@ -36,10 +35,11 @@ export const and = <H extends HookContext = HookContext>(
36
35
 
37
36
  for (const predicate of filtered) {
38
37
  const result = predicate(context)
39
- if (result === false) {
40
- return false
41
- } else if (isPromise(result)) {
38
+ if (isPromise(result)) {
42
39
  promises.push(result)
40
+ } else if (!result) {
41
+ // any falsy sync result short-circuits to false
42
+ return false
43
43
  }
44
44
  }
45
45
 
@@ -32,7 +32,7 @@ export const isContext = <H extends HookContext = HookContext>(
32
32
  const method = options.method != null ? toArray(options.method) : undefined
33
33
 
34
34
  return (context: any): boolean => {
35
- if (path && !path.some((x) => context.path.includes(x))) {
35
+ if (path && !path.some((x) => context.path === x)) {
36
36
  return false
37
37
  }
38
38
 
@@ -26,10 +26,9 @@ export const or = <H extends HookContext = HookContext>(
26
26
  )
27
27
 
28
28
  return (context: H): boolean | Promise<boolean> => {
29
- // same as Array.prototype.some for empty arrays
30
- // see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/some#description
29
+ // The identity element of logical OR is `false` (an empty OR is false).
31
30
  if (!filtered.length) {
32
- return true
31
+ return false
33
32
  }
34
33
 
35
34
  const promises: Promise<boolean>[] = []
@@ -37,12 +36,11 @@ export const or = <H extends HookContext = HookContext>(
37
36
  for (const predicate of filtered) {
38
37
  const result = predicate(context)
39
38
 
40
- if (result === true) {
41
- return true
42
- } else if (result === false) {
43
- continue
44
- } else if (isPromise(result)) {
39
+ if (isPromise(result)) {
45
40
  promises.push(result)
41
+ } else if (result) {
42
+ // any truthy sync result short-circuits to true
43
+ return true
46
44
  }
47
45
  }
48
46
 
@@ -1,3 +1,4 @@
1
+ import { BadRequest } from '@feathersjs/errors'
1
2
  import { toArray, type MaybeArray } from '../../internal.utils.js'
2
3
  import _get from 'lodash/get.js'
3
4
  import _set from 'lodash/set.js'
@@ -25,7 +26,11 @@ export function parseDate<T extends Record<string, any>>(
25
26
  const key = fieldNamesArr[i]
26
27
  const value = _get(item, key)
27
28
  if (value) {
28
- _set(item, key, new Date(value))
29
+ const date = new Date(value)
30
+ if (Number.isNaN(date.getTime())) {
31
+ throw new BadRequest(`Expected valid date (parseDate '${key}')`)
32
+ }
33
+ _set(item, key, date)
29
34
  }
30
35
  }
31
36
  }
@@ -68,6 +68,13 @@ export async function* chunkFind<
68
68
  do {
69
69
  result = await (service as any).find(params)
70
70
 
71
+ // Guard against an infinite loop: an empty page never advances $skip, so
72
+ // `total > $skip` could stay true forever (e.g. $limit:0, or a stale total
73
+ // when items are concurrently removed / filtered out by hooks).
74
+ if (!result.data.length) {
75
+ break
76
+ }
77
+
71
78
  yield result.data
72
79
 
73
80
  params.query.$skip = (params.query.$skip ?? 0) + result.data.length
@@ -1,5 +1,6 @@
1
1
  import type { HookContext } from '@feathersjs/feathers'
2
2
  import type { HookFunction } from '../../types.js'
3
+ import { isPromise } from '../../common/index.js'
3
4
 
4
5
  /**
5
6
  * Sequentially executes multiple hooks, passing the updated context from one to the next.
@@ -42,22 +43,14 @@ export function combine<H extends HookContext = HookContext>(
42
43
  return ctx
43
44
  }
44
45
 
45
- // Go through all hooks and chain them into our promise
46
-
47
- // @ts-expect-error TODO
48
- const promise = serviceHooks.reduce(async (current, fn) => {
49
- // @ts-expect-error TODO
50
- const hook = fn.bind(this)
51
-
52
- // Use the returned hook object or the old one
53
-
54
- const currentHook = await current
55
- const currentCtx = await hook(currentHook)
56
- return updateCurrentHook(currentCtx)
57
- }, Promise.resolve(ctx))
58
-
46
+ // Run the hooks sequentially, only awaiting when a hook is actually async.
47
+ // Avoids a microtask hop per hook and a per-hook `bind` allocation.
59
48
  try {
60
- await promise
49
+ for (const fn of serviceHooks) {
50
+ // @ts-expect-error `this` is the bound service-hook context
51
+ const currentCtx = fn.call(this, ctx)
52
+ updateCurrentHook(isPromise(currentCtx) ? await currentCtx : currentCtx)
53
+ }
61
54
  return ctx
62
55
  } catch (error: any) {
63
56
  // Add the hook information to any errors
@@ -23,12 +23,10 @@ export const getPaginate = <H extends HookContext = HookContext>(
23
23
  context: H,
24
24
  ): PaginationOptions | undefined => {
25
25
  if (hasOwnProperty(context.params, 'paginate')) {
26
+ // covers `paginate: false` too (`false || undefined` -> undefined)
26
27
  return (context.params.paginate as PaginationOptions) || undefined
27
28
  }
28
29
 
29
- if (context.params.paginate === false) {
30
- return undefined
31
- }
32
30
  let options = context.service?.options || {}
33
31
 
34
32
  options = {
@@ -12,6 +12,8 @@ export * from './iterate-find/iterate-find.util.js'
12
12
  export * from './mutate-data/mutate-data.util.js'
13
13
  export * from './mutate-result/mutate-result.util.js'
14
14
  export * from './patch-batch/patch-batch.util.js'
15
+ export * from './replace-data/replace-data.util.js'
16
+ export * from './replace-result/replace-result.util.js'
15
17
  export * from './skip-result/skip-result.util.js'
16
18
  export * from './to-paginated/to-paginated.util.js'
17
19
  export * from './transform-params/transform-params.util.js'
@@ -59,6 +59,7 @@ export async function* iterateFind<
59
59
  },
60
60
  paginate: {
61
61
  default: options?.params?.paginate?.default ?? 10,
62
+ max: options?.params?.paginate?.max ?? 100,
62
63
  },
63
64
  }
64
65
 
@@ -67,6 +68,13 @@ export async function* iterateFind<
67
68
  do {
68
69
  result = await (service as any).find(params)
69
70
 
71
+ // Guard against an infinite loop: an empty page never advances $skip, so
72
+ // `total > $skip` could stay true forever (e.g. $limit:0, or a stale total
73
+ // when items are concurrently removed / filtered out by hooks).
74
+ if (!result.data.length) {
75
+ break
76
+ }
77
+
70
78
  for (const item of result.data) {
71
79
  yield item
72
80
  }
@@ -1,5 +1,6 @@
1
1
  import type { HookContext } from '@feathersjs/feathers'
2
2
  import { getDataIsArray } from '../get-data-is-array/get-data-is-array.util.js'
3
+ import { replaceData } from '../replace-data/replace-data.util.js'
3
4
  import { isPromise } from '../../common/index.js'
4
5
  import type { Promisable } from '../../internal.utils.js'
5
6
  import type { TransformerInputFn } from '../../types.js'
@@ -26,7 +27,24 @@ export function mutateData<H extends HookContext = HookContext>(
26
27
  return context
27
28
  }
28
29
 
29
- const { data, isArray } = getDataIsArray(context)
30
+ // single-item fast path: avoid allocating a wrapper array + a mapped array
31
+ // for the common single create/update/patch case.
32
+ if (!Array.isArray(context.data)) {
33
+ const item = context.data
34
+ const result = transformer(item, { context, i: 0 })
35
+
36
+ if (isPromise(result)) {
37
+ return result.then((res: any) => {
38
+ context.data = res ?? item
39
+ return context
40
+ })
41
+ }
42
+
43
+ context.data = result ?? item
44
+ return context
45
+ }
46
+
47
+ const { data } = getDataIsArray(context)
30
48
 
31
49
  if (!data.length) {
32
50
  return context
@@ -46,9 +64,8 @@ export function mutateData<H extends HookContext = HookContext>(
46
64
  })
47
65
 
48
66
  function mutate(data: any) {
49
- context.data = isArray ? data : data[0]
50
-
51
- return context
67
+ // delegate the array writeback (single is handled by the fast path above)
68
+ return replaceData(context, data)
52
69
  }
53
70
 
54
71
  if (hasPromises) {
@@ -1,5 +1,6 @@
1
1
  import type { HookContext, NextFunction } from '@feathersjs/feathers'
2
2
  import { getResultIsArray } from '../get-result-is-array/get-result-is-array.util.js'
3
+ import { replaceResult } from '../replace-result/replace-result.util.js'
3
4
  import { isPromise } from '../../common/index.js'
4
5
  import { copy } from 'fast-copy'
5
6
  import type { Promisable } from '../../internal.utils.js'
@@ -35,7 +36,7 @@ export function mutateResult<H extends HookContext = HookContext>(
35
36
  }
36
37
 
37
38
  function forResult(dispatch: boolean): Promisable<H> {
38
- const { result, isArray, key } = getResultIsArray(context, { dispatch })
39
+ const { result } = getResultIsArray(context, { dispatch })
39
40
 
40
41
  if (!result.length) {
41
42
  return context
@@ -59,15 +60,8 @@ export function mutateResult<H extends HookContext = HookContext>(
59
60
  r = options.transform(r)
60
61
  }
61
62
 
62
- if (!isArray) {
63
- context[key] = r[0]
64
- } else if (isArray && !Array.isArray(context[key]) && context[key].data) {
65
- context[key].data = r
66
- } else {
67
- context[key] = r
68
- }
69
-
70
- return context
63
+ // delegate the single/array/paginated writeback to replaceResult
64
+ return replaceResult(context, r, { dispatch })
71
65
  }
72
66
 
73
67
  if (hasPromises) {
@@ -1,7 +1,22 @@
1
1
  import type { Id, Params } from '@feathersjs/feathers'
2
- import { dequal as deepEqual } from 'dequal'
3
2
  import type { KeyOf } from '../../internal.utils.js'
4
3
 
4
+ /**
5
+ * Deterministic, key-order-independent serialization used to group items with
6
+ * equal patch data in O(1) per item.
7
+ */
8
+ const stableKey = (value: any): string =>
9
+ JSON.stringify(value, (_key, val) =>
10
+ val && typeof val === 'object' && !Array.isArray(val)
11
+ ? Object.keys(val)
12
+ .sort()
13
+ .reduce<Record<string, any>>((acc, k) => {
14
+ acc[k] = val[k]
15
+ return acc
16
+ }, {})
17
+ : val,
18
+ )
19
+
5
20
  export type PatchBatchOptions<IdKey extends string> = {
6
21
  /** the key of the id property */
7
22
  id?: IdKey
@@ -47,27 +62,30 @@ export function patchBatch<
47
62
  items: T[],
48
63
  options?: PatchBatchOptions<IdKey>,
49
64
  ): PatchBatchResultItem<R, P>[] {
50
- const map: { ids: Id[]; data: R }[] = []
51
-
52
65
  const idKey = options?.id ?? 'id'
53
66
 
54
- for (const _data of items) {
55
- const data = _data as unknown as R
56
- const id = _data[idKey]
57
- delete (data as any)[idKey as any]
67
+ // group items with identical (id-stripped) data in O(n) via a Map keyed by a
68
+ // stable serialization, instead of an O(n^2) findIndex + deepEqual scan.
69
+ const groups = new Map<string, { ids: Id[]; data: R }>()
70
+
71
+ for (const item of items) {
72
+ const source = item as Record<string, any>
73
+ const id = source[idKey] as Id
74
+ // shallow copy then drop the id key, so the caller's input is never mutated.
75
+ const data = { ...source }
76
+ delete data[idKey as any]
58
77
 
59
- const index = map.findIndex((item) => {
60
- return deepEqual(item.data, data)
61
- })
78
+ const key = stableKey(data)
79
+ const existing = groups.get(key)
62
80
 
63
- if (index === -1) {
64
- map.push({ ids: [id], data })
81
+ if (existing) {
82
+ existing.ids.push(id)
65
83
  } else {
66
- map[index].ids.push(id)
84
+ groups.set(key, { ids: [id], data: data as unknown as R })
67
85
  }
68
86
  }
69
87
 
70
- return map.map(({ ids, data }) => {
88
+ return [...groups.values()].map(({ ids, data }) => {
71
89
  return ids.length === 1
72
90
  ? ([ids[0], data, undefined] as PatchBatchResultItem<R, P>)
73
91
  : ([
@@ -0,0 +1,26 @@
1
+ import type { HookContext } from "@feathersjs/feathers";
2
+ import type { DataSingleHookContext } from "../../utility-types/hook-context.js";
3
+
4
+ /**
5
+ * Replaces `context.data` wholesale with the given items, preserving the original
6
+ * single-vs-array shape. This is the explicit inverse of `getDataIsArray`: get the
7
+ * data as an array, modify or replace the items, then write them back.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { getDataIsArray, replaceData } from 'feathers-utils/utils'
12
+ *
13
+ * const { data } = getDataIsArray(context)
14
+ * const next = data.map((item) => ({ ...item, slug: slugify(item.name) }))
15
+ * replaceData(context, next)
16
+ * ```
17
+ *
18
+ * @see https://utils.feathersjs.com/utils/replace-data.html
19
+ */
20
+ export function replaceData<H extends HookContext = HookContext>(
21
+ context: H,
22
+ data: DataSingleHookContext<H>[],
23
+ ): H {
24
+ context.data = Array.isArray(context.data) ? data : data[0];
25
+ return context;
26
+ }
@@ -0,0 +1,60 @@
1
+ import type { HookContext } from "@feathersjs/feathers";
2
+ import { copy } from "fast-copy";
3
+ import { getResultIsArray } from "../get-result-is-array/get-result-is-array.util.js";
4
+ import type { DispatchOption } from "../../types.js";
5
+ import type { ResultSingleHookContext } from "../../utility-types/hook-context.js";
6
+
7
+ export type ReplaceResultOptions = {
8
+ /**
9
+ * Also (or only) write to `context.dispatch`. `true` writes dispatch, `'both'`
10
+ * writes both `result` and `dispatch`. When dispatch is requested and not yet
11
+ * present, it is seeded from a clone of `context.result`.
12
+ */
13
+ dispatch?: DispatchOption;
14
+ };
15
+
16
+ /**
17
+ * Replaces `context.result` (and/or `context.dispatch`) wholesale with the given
18
+ * items, preserving the original shape: single item, array, or paginated `{ data }`.
19
+ * This is the explicit inverse of `getResultIsArray`.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * import { getResultIsArray, replaceResult } from 'feathers-utils/utils'
24
+ *
25
+ * const { result } = getResultIsArray(context)
26
+ * replaceResult(context, result.filter((item) => item.public))
27
+ * ```
28
+ *
29
+ * @see https://utils.feathersjs.com/utils/replace-result.html
30
+ */
31
+ export function replaceResult<H extends HookContext = HookContext>(
32
+ context: H,
33
+ result: ResultSingleHookContext<H>[],
34
+ options?: ReplaceResultOptions,
35
+ ): H {
36
+ if (!!options?.dispatch && !context.dispatch) {
37
+ context.dispatch = copy(context.result);
38
+ }
39
+
40
+ const write = (dispatch: boolean) => {
41
+ const { isArray, key } = getResultIsArray(context, { dispatch });
42
+
43
+ if (!isArray) {
44
+ context[key] = result[0];
45
+ } else if (!Array.isArray(context[key]) && context[key]?.data) {
46
+ context[key].data = result;
47
+ } else {
48
+ context[key] = result;
49
+ }
50
+ };
51
+
52
+ if (options?.dispatch === "both") {
53
+ write(true);
54
+ write(false);
55
+ } else {
56
+ write(!!options?.dispatch);
57
+ }
58
+
59
+ return context;
60
+ }
@@ -1,8 +1,10 @@
1
1
  import type { Query } from '@feathersjs/feathers'
2
- import isObject from 'lodash/isObject.js'
3
2
 
4
3
  const arrayOperators = new Set(['$or', '$and', '$nor', '$not', '$in', '$nin'])
5
4
 
5
+ const isPlainObjectLike = (value: unknown): value is Record<string, any> =>
6
+ value !== null && typeof value === 'object'
7
+
6
8
  /**
7
9
  * Recursively normalizes a Feathers query object for order-independent comparison.
8
10
  * Sorts object keys and sorts arrays within `$or`, `$and`, `$nor`, `$not`, `$in`,
@@ -35,21 +37,26 @@ const normalize = (value: any): any => {
35
37
  return value.map(normalize)
36
38
  }
37
39
 
38
- if (!isObject(value)) {
40
+ if (!isPlainObjectLike(value)) {
39
41
  return value
40
42
  }
41
43
 
42
44
  const sorted: Record<string, any> = {}
43
45
 
44
- for (const key of Object.keys(value as Record<string, any>).sort()) {
45
- const val = (value as Record<string, any>)[key]
46
+ for (const key of Object.keys(value).sort()) {
47
+ const val = value[key]
46
48
 
47
49
  if (arrayOperators.has(key) && Array.isArray(val)) {
50
+ // Schwartzian transform: serialize each normalized element once, sort by
51
+ // that key, then unwrap. Avoids the O(n log n) repeated JSON.stringify of
52
+ // the previous comparator (which also returned 1 for equal elements).
48
53
  sorted[key] = val
49
- .map(normalize)
50
- .sort((a: any, b: any) =>
51
- JSON.stringify(a) < JSON.stringify(b) ? -1 : 1,
52
- )
54
+ .map((el) => {
55
+ const normalized = normalize(el)
56
+ return { k: JSON.stringify(normalized), v: normalized }
57
+ })
58
+ .sort((a, b) => (a.k < b.k ? -1 : a.k > b.k ? 1 : 0))
59
+ .map((entry) => entry.v)
53
60
  } else {
54
61
  sorted[key] = normalize(val)
55
62
  }
@@ -43,7 +43,7 @@ const _walkQueryUtil = <Q extends Query>(
43
43
 
44
44
  for (const key in query) {
45
45
  if (
46
- (key === '$or' || key === '$and' || key === '$nor' || key === '$not') &&
46
+ (key === '$or' || key === '$and' || key === '$nor') &&
47
47
  Array.isArray(query[key])
48
48
  ) {
49
49
  let array = query[key]
@@ -122,7 +122,7 @@ const _walkQueryUtil = <Q extends Query>(
122
122
  }
123
123
 
124
124
  /**
125
- * Walks every property of a Feathers query (including nested `$and`/`$or`/`$nor`/`$not` arrays)
125
+ * Walks every property of a Feathers query (including nested `$and`/`$or`/`$nor` arrays)
126
126
  * and calls the `walker` function for each one. The walker receives the property name, operator,
127
127
  * value, and path, and can return a replacement value. Returns a new query only if changes were made.
128
128
  *