From 6a8690a9d0e6f9b3e99f291ccf7cb1d871f682ed Mon Sep 17 00:00:00 2001 From: fratzinger <22286818+fratzinger@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:16:06 +0200 Subject: [PATCH] feat: stash util / stashable --- docs/migrating-from-feathers-fletching.md | 23 +- docs/migrating-from-feathers-hooks-common.md | 49 +- src/hooks/cache/cache-utils.ts | 17 +- src/hooks/stashable/stashable.hook.md | 61 +- src/hooks/stashable/stashable.hook.test.ts | 757 +++++++++++++----- src/hooks/stashable/stashable.hook.ts | 98 ++- src/utils/index.ts | 2 + .../stable-stringify/stable-stringify.util.ts | 31 + src/utils/stash/stash.util.test.ts | 251 ++++++ src/utils/stash/stash.util.ts | 415 ++++++++++ test/index.test.ts | 5 + 11 files changed, 1427 insertions(+), 282 deletions(-) create mode 100644 src/utils/stable-stringify/stable-stringify.util.ts create mode 100644 src/utils/stash/stash.util.test.ts create mode 100644 src/utils/stash/stash.util.ts diff --git a/docs/migrating-from-feathers-fletching.md b/docs/migrating-from-feathers-fletching.md index de0f4b5..197d5a9 100644 --- a/docs/migrating-from-feathers-fletching.md +++ b/docs/migrating-from-feathers-fletching.md @@ -327,7 +327,7 @@ app.service("albums").find(addSkip({}, "myHook")); ## `stashable` -In `feathers-fletching`, `stashable` lazily stashes the pre-mutation state of a record. In `feathers-utils`, [`stashable`](/hooks/stashable.html) eagerly starts the fetch but exposes a memoized function — calling it multiple times only hits the database once. +In `feathers-fletching`, `stashable` lazily stashes the pre-mutation state of a record and exposes it as a memoized function on `context.params.stashed`. In `feathers-utils`, [`stashable`](/hooks/stashable.html) stashes all affected records by their id and stores the result as plain data on `context.params.stash` (`Record`). Enable `fetchBefore` to capture the pre-mutation state. ```ts // old @@ -346,22 +346,25 @@ const before = await context.params.stashed(); import { stashable } from "feathers-utils/hooks"; app.service("users").hooks({ - before: { - patch: [stashable()], + around: { + patch: [stashable(undefined, { fetchBefore: true })], }, }); -// Access in a later hook (before or after): -const before = await context.params.stashed(); +// Access in a later (after) hook by id: +const { before, item } = context.params.stash[id]; ``` ### Key differences -| `feathers-fletching` | `feathers-utils` | -| ------------------------------------------ | -------------------------------------------------------------------- | -| Lazy — only fetches when `stashed()` is called | Eager start — fetch begins immediately, result is memoized | -| `propName` option (default: `'stashed'`) | Same — `propName` option (default: `'stashed'`) | -| `stashFunc` option for custom fetch | Same — `stashFunc` option for custom fetch | +| `feathers-fletching` | `feathers-utils` | +| ----------------------------------------------- | ---------------------------------------------------------------------------- | +| Lazy — only fetches when `stashed()` is called | Captures all affected records; opt into the before-state with `fetchBefore` | +| `context.params.stashed()` returns the record | `context.params.stash` holds `Record` | +| `propName` option (default: `'stashed'`) | `name` option (default: `'stash'`) | +| `stashFunc` option for custom fetch | `params` / `skipHooks` / `deleteParams` options to control the (re)fetch | + +> The low-level [`stash`](/hooks/stashable.html) util (`stash(context, options?)`) lets you do the same imperatively inside your own hooks. ## `joinQuery` diff --git a/docs/migrating-from-feathers-hooks-common.md b/docs/migrating-from-feathers-hooks-common.md index fe720cd..5bab824 100644 --- a/docs/migrating-from-feathers-hooks-common.md +++ b/docs/migrating-from-feathers-hooks-common.md @@ -364,7 +364,7 @@ import { some } from "feathers-utils/predicates"; ## `stashBefore` -The `stashBefore` hook has been renamed to [`stashable`](/hooks/stashable.html). Instead of eagerly fetching and storing the result directly on `context.params.before`, it now exposes a memoized function that returns a promise. The fetch starts immediately but multiple calls to `stashed()` only hit the database once. +The `stashBefore` hook has been merged into [`stashable`](/hooks/stashable.html). Instead of storing the pre-mutation record directly on `context.params.before`, `stashable` captures all affected records by their id on `context.params.stash` (`Record`). Enable `fetchBefore` to capture the pre-mutation state. ```ts // old @@ -383,16 +383,53 @@ const before = context.params.before; import { stashable } from "feathers-utils/hooks"; app.service("users").hooks({ - before: { - patch: [stashable()], + around: { + patch: [stashable(undefined, { fetchBefore: true })], + }, +}); + +// Access by id in a later (after) hook: +const { before } = context.params.stash[id]; +``` + +The default property name changed from `before` to `stash`, and the stored shape is now `Record`. Use the `name` option to store it elsewhere. + +## `changesById` + +The `changesById` hook has been merged into [`stashable`](/hooks/stashable.html). It tracks the `{ before, item }` of all affected records by their id and passes them to an optional callback (the callback is now the first argument). The result is stored on `context.params.stash` instead of `context.params.changesById`. + +```ts +// old +import { changesById } from "feathers-hooks-common"; + +app.service("users").hooks({ + after: { + all: [ + changesById((context, changes) => { + // react to the changes + }), + ], }, }); -// Access via memoized function: -const before = await context.params.stashed(); +// new +import { stashable } from "feathers-utils/hooks"; + +app.service("users").hooks({ + around: { + all: [ + stashable((changes, context) => { + for (const id in changes) { + const { before, item } = changes[id]; + // react to the change + } + }, { fetchBefore: true }), + ], + }, +}); ``` -The default property name changed from `before` to `stashed`. You can restore the old name with `stashable({ propName: 'before' })`. +Enable `fetchBefore` to include the pre-mutation state. The same result is always available on `context.params.stash`, and the low-level [`stash`](/hooks/stashable.html) util lets you compute it imperatively. ## `traverse` diff --git a/src/hooks/cache/cache-utils.ts b/src/hooks/cache/cache-utils.ts index 1dc1932..b46964f 100644 --- a/src/hooks/cache/cache-utils.ts +++ b/src/hooks/cache/cache-utils.ts @@ -1,17 +1,4 @@ import { sortQueryProperties } from '../../utils/sort-query-properties/sort-query-properties.util.js' +import { stableStringify } from '../../utils/stable-stringify/stable-stringify.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 - }) -} +export { sortQueryProperties, stableStringify } diff --git a/src/hooks/stashable/stashable.hook.md b/src/hooks/stashable/stashable.hook.md index b9ae3d8..55c02b8 100644 --- a/src/hooks/stashable/stashable.hook.md +++ b/src/hooks/stashable/stashable.hook.md @@ -2,7 +2,64 @@ title: stashable category: hooks hook: - type: ["before", "around"] - method: ["update", "patch", "remove"] + type: ["before", "after", "around"] + method: ["create", "update", "patch", "remove"] multi: true --- + +The `stashable` hook stashes all affected records by their id for a `create`, `update`, `patch` or `remove` call and (optionally) passes them to a callback. For every affected id it provides the state `before` the mutation and the resulting `item` after it. + +It runs in `before` + `after` (or a single `around`) and stores the result at `context.params[name]` (default `stash`) as a `Record`. + +## Example + +```ts +// users.hooks.ts +import { stashable } from "feathers-utils/hooks"; + +export default { + around: { + all: [ + stashable( + (stash, context) => { + for (const id in stash) { + const { before, item } = stash[id]; + // react to the change + } + }, + { fetchBefore: true }, + ), + ], + }, +}; +``` + +The callback is optional — without it, the result is still available on `context.params.stash`: + +```ts +const { before, item } = context.params.stash[id]; +``` + +## Options + +| Property | Description | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `fetchBefore` | Whether to fetch the items before the mutation (for `update`, `patch`, `remove`). Disabled by default for performance.
**Type:** `boolean` - _Default:_ `false` | +| `params` | Manipulate the params used for (re)fetching the items if needed.
**Type:** `(params: Params, context: HookContext) => Promisable` - _Default:_ `undefined` | +| `skipHooks` | Use `_find`/`_get` instead of `find`/`get` for (re)fetching, bypassing the service hooks.
**Type:** `boolean` - _Default:_ `false` | +| `deleteParams` | Keys to delete from the params before (re)fetching.
**Type:** `string[]` - _Default:_ `[]` | +| `name` | The property on `context.params` to store the stash at.
**Type:** `string \| string[]` - _Default:_ `"stash"` | + +## `stash` util + +`stashable` is built on top of the `stash` util. Use it to stash imperatively inside your own hooks — it is phase-aware and writes to `context.params.stash`: + +```ts +import { stash } from "feathers-utils"; + +// in a before hook +await stash(context, { fetchBefore: true }); + +// in an after hook +const changes = await stash(context, { fetchBefore: true }); +``` diff --git a/src/hooks/stashable/stashable.hook.test.ts b/src/hooks/stashable/stashable.hook.test.ts index 5a68bdf..27a92e0 100644 --- a/src/hooks/stashable/stashable.hook.test.ts +++ b/src/hooks/stashable/stashable.hook.test.ts @@ -1,260 +1,621 @@ -import { assert, expect, expectTypeOf } from 'vitest' -import type { - Application, - AroundHookFunction, - HookContext, -} from '@feathersjs/feathers' +import assert from 'node:assert' +import { expectTypeOf } from 'vitest' +import type { AroundHookFunction, HookContext } from '@feathersjs/feathers' import { feathers } from '@feathersjs/feathers' import { MemoryService } from '@feathersjs/memory' import { stashable } from './stashable.hook.js' -import { clone } from '../../common/index.js' - -const startId = 6 -const storeInit = { - 0: { name: 'Jane Doe', key: 'a', id: 0 }, - 1: { name: 'Jack Doe', key: 'a', id: 1 }, - 2: { name: 'John Doe', key: 'a', id: 2 }, - 3: { name: 'Rick Doe', key: 'b', id: 3 }, - 4: { name: 'Dick Doe', key: 'b', id: 4 }, - 5: { name: 'Dork Doe', key: 'b', id: 5 }, -} - -let store: any -let finalParams: any - -function services(app: Application) { - store = clone(storeInit) - - app.use( - 'users', - new MemoryService({ - store, - startId, - multi: true, - }), - ) - - app.service('users').hooks({ +import type { StashOptions } from '../../utils/stash/stash.util.js' + +function mock( + cb: any, + hookName: string, + options?: Partial, + beforeHook?: any, + afterHook?: any, +) { + const app = feathers() + app.use('/test', new MemoryService({ multi: true })) + const service = app.service('test') + const hook = stashable(cb, options) + + const beforeAll = [hook] + if (beforeHook) { + beforeAll.push(beforeHook) + } + + const afterAll = [hook] + if (afterHook) { + afterAll.push(afterHook) + } + + service.hooks({ before: { - all: [ - stashable(), - (context: any) => { - finalParams = context.params - }, - ], + [hookName]: beforeAll, + }, + after: { + [hookName]: afterAll, }, }) + + return { + app, + service, + } } -describe('stashable', () => { - let app: Application<{ users: MemoryService }> - let users: any +function mockAround( + cb: any, + hookName: string, + options?: Partial, +) { + const app = feathers() + app.use('/test', new MemoryService({ multi: true })) + const service = app.service('test') + const hook = stashable(cb, options) + + service.hooks({ + around: { + [hookName]: [hook], + }, + }) - beforeEach(() => { - finalParams = null - app = feathers().configure(services) - users = app.service('users') + return { + app, + service, + } +} + +describe('hook - stashable (before/after)', function () { + describe('create', function () { + it('basic create', async function () { + let calledCb = false + const cb = (byId: any, context: any) => { + calledCb = true + assert.strictEqual(context.path, 'test', 'cb has context') + assert.strictEqual(byId['0'].before, undefined, 'before is undefined') + assert.deepStrictEqual( + byId['0'].item, + { id: 0, test: true, comment: 'awesome' }, + 'has right item', + ) + } + + const { service } = mock(cb, 'create') + assert.ok(!calledCb, 'not called cb') + + const item = await service.create({ test: true, comment: 'awesome' }) + + assert.ok(calledCb, 'called cb') + assert.deepStrictEqual( + item, + { id: 0, test: true, comment: 'awesome' }, + 'has right result', + ) + }) }) - it('stashes lazily on patch', async () => { - await users.patch(0, { name: 'Updated' }) + describe('update', function () { + it('basic update', async function () { + let calledCb = false + const cb = (byId: any, context: any) => { + calledCb = true + assert.strictEqual(context.path, 'test', 'cb has context') + assert.deepStrictEqual( + byId['0'].before, + { id: 0, test: true, comment: 'awesome' }, + 'has right before', + ) + assert.deepStrictEqual( + byId['0'].item, + { id: 0, test: false }, + 'has right item', + ) + } + + const { service } = mock(cb, 'update', { fetchBefore: true }) + + const item = await service.create({ test: true, comment: 'awesome' }) + + assert.ok(!calledCb, 'not called cb') + + const result = await service.update(item.id, { test: false }) + + assert.ok(calledCb, 'called cb') + assert.deepStrictEqual(result, { id: 0, test: false }, 'has right result') + }) - expect(typeof finalParams.stashed).toBe('function') - const before = await finalParams.stashed() - assert.deepEqual(before, storeInit[0]) + it('basic update with $select', async function () { + let calledCb = false + const cb = (byId: any, context: any) => { + calledCb = true + assert.strictEqual(context.path, 'test', 'cb has context') + assert.deepStrictEqual( + byId['0'].before, + { id: 0, test: true, comment: 'awesome' }, + 'has right before', + ) + assert.deepStrictEqual( + byId['0'].item, + { id: 0, test: false }, + 'has right item', + ) + } + const { service } = mock(cb, 'update', { fetchBefore: true }) + + const item = await service.create({ test: true, comment: 'awesome' }) + + assert.ok(!calledCb, 'not called cb') + + const result = await service.update( + item.id, + { test: false }, + { query: { $select: ['id'] } }, + ) + + assert.ok(calledCb, 'called cb') + assert.deepStrictEqual(result, { id: 0 }, 'has right result') + }) }) - it('stashes lazily on update', async () => { - await users.update(0, { name: 'Updated' }) + describe('patch', function () { + it('basic patch', async function () { + let calledCb = false + const cb = (byId: any, context: any) => { + calledCb = true + assert.strictEqual(context.path, 'test', 'cb has context') + assert.deepStrictEqual( + byId['0'].before, + { id: 0, test: true, comment: 'awesome' }, + 'has right before', + ) + assert.deepStrictEqual( + byId['0'].item, + { id: 0, test: false, comment: 'awesome' }, + 'has right item', + ) + } + const { service } = mock(cb, 'patch', { fetchBefore: true }) + + const item = await service.create({ test: true, comment: 'awesome' }) + + assert.ok(!calledCb, 'not called cb') + + const result = await service.patch(item.id, { test: false }) + + assert.ok(calledCb, 'called cb') + assert.deepStrictEqual( + result, + { id: 0, test: false, comment: 'awesome' }, + 'has right result', + ) + }) - const before = await finalParams.stashed() - assert.deepEqual(before, storeInit[0]) + it('basic patch with $select', async function () { + let calledCb = false + const cb = (byId: any, context: any) => { + calledCb = true + assert.strictEqual(context.path, 'test', 'cb has context') + assert.deepStrictEqual( + byId['0'].before, + { id: 0, test: true, comment: 'awesome' }, + 'has right before', + ) + assert.deepStrictEqual( + byId['0'].item, + { id: 0, test: false, comment: 'awesome' }, + 'has right item', + ) + } + const { service } = mock(cb, 'patch', { fetchBefore: true }) + + const item = await service.create({ test: true, comment: 'awesome' }) + + assert.ok(!calledCb, 'not called cb') + + const result = await service.patch( + item.id, + { test: false }, + { query: { $select: ['id'] } }, + ) + + assert.ok(calledCb, 'called cb') + assert.deepStrictEqual(result, { id: 0 }, 'has right result') + }) }) - it('stashes lazily on remove', async () => { - await users.remove(0) + describe('remove', function () { + it('basic remove', async function () { + let calledCb = false + const cb = (byId: any, context: any) => { + calledCb = true + assert.strictEqual(context.path, 'test', 'cb has context') + assert.deepStrictEqual( + byId['0'].before, + { id: 0, test: true, comment: 'awesome' }, + 'has right before', + ) + assert.deepStrictEqual( + byId['0'].item, + { id: 0, test: true, comment: 'awesome' }, + 'has right item', + ) + } + + const { service } = mock(cb, 'remove', { fetchBefore: true }) + + const item = await service.create({ test: true, comment: 'awesome' }) + + assert.ok(!calledCb, 'not called cb') + + await service.remove(item.id) + + assert.ok(calledCb, 'called cb') + }) + }) +}) - const before = await finalParams.stashed() - assert.deepEqual(before, storeInit[0]) +describe('hook - stashable (around)', function () { + describe('create', function () { + it('basic create', async function () { + let calledCb = false + const cb = (byId: any, context: any) => { + calledCb = true + assert.strictEqual(context.path, 'test', 'cb has context') + assert.strictEqual(byId['0'].before, undefined, 'before is undefined') + assert.deepStrictEqual( + byId['0'].item, + { id: 0, test: true, comment: 'awesome' }, + 'has right item', + ) + } + + const { service } = mockAround(cb, 'create') + assert.ok(!calledCb, 'not called cb') + + const item = await service.create({ test: true, comment: 'awesome' }) + + assert.ok(calledCb, 'called cb') + assert.deepStrictEqual( + item, + { id: 0, test: true, comment: 'awesome' }, + 'has right result', + ) + }) }) - it("throws on 'create'", async () => { - await expect(users.create({})).rejects.toThrow() + describe('update', function () { + it('basic update', async function () { + let calledCb = false + const cb = (byId: any, context: any) => { + calledCb = true + assert.strictEqual(context.path, 'test', 'cb has context') + assert.deepStrictEqual( + byId['0'].before, + { id: 0, test: true, comment: 'awesome' }, + 'has right before', + ) + assert.deepStrictEqual( + byId['0'].item, + { id: 0, test: false }, + 'has right item', + ) + } + + const { service } = mockAround(cb, 'update', { fetchBefore: true }) + + const item = await service.create({ test: true, comment: 'awesome' }) + + assert.ok(!calledCb, 'not called cb') + + const result = await service.update(item.id, { test: false }) + + assert.ok(calledCb, 'called cb') + assert.deepStrictEqual(result, { id: 0, test: false }, 'has right result') + }) + + it('basic update with $select', async function () { + let calledCb = false + const cb = (byId: any, context: any) => { + calledCb = true + assert.strictEqual(context.path, 'test', 'cb has context') + assert.deepStrictEqual( + byId['0'].before, + { id: 0, test: true, comment: 'awesome' }, + 'has right before', + ) + assert.deepStrictEqual( + byId['0'].item, + { id: 0, test: false }, + 'has right item', + ) + } + const { service } = mockAround(cb, 'update', { fetchBefore: true }) + + const item = await service.create({ test: true, comment: 'awesome' }) + + assert.ok(!calledCb, 'not called cb') + + const result = await service.update( + item.id, + { test: false }, + { query: { $select: ['id'] } }, + ) + + assert.ok(calledCb, 'called cb') + assert.deepStrictEqual(result, { id: 0 }, 'has right result') + }) }) - it("throws on 'find'", async () => { - await expect(users.find({})).rejects.toThrow() + describe('patch', function () { + it('basic patch', async function () { + let calledCb = false + const cb = (byId: any, context: any) => { + calledCb = true + assert.strictEqual(context.path, 'test', 'cb has context') + assert.deepStrictEqual( + byId['0'].before, + { id: 0, test: true, comment: 'awesome' }, + 'has right before', + ) + assert.deepStrictEqual( + byId['0'].item, + { id: 0, test: false, comment: 'awesome' }, + 'has right item', + ) + } + const { service } = mockAround(cb, 'patch', { fetchBefore: true }) + + const item = await service.create({ test: true, comment: 'awesome' }) + + assert.ok(!calledCb, 'not called cb') + + const result = await service.patch(item.id, { test: false }) + + assert.ok(calledCb, 'called cb') + assert.deepStrictEqual( + result, + { id: 0, test: false, comment: 'awesome' }, + 'has right result', + ) + }) + + it('basic patch with $select', async function () { + let calledCb = false + const cb = (byId: any, context: any) => { + calledCb = true + assert.strictEqual(context.path, 'test', 'cb has context') + assert.deepStrictEqual( + byId['0'].before, + { id: 0, test: true, comment: 'awesome' }, + 'has right before', + ) + assert.deepStrictEqual( + byId['0'].item, + { id: 0, test: false, comment: 'awesome' }, + 'has right item', + ) + } + const { service } = mockAround(cb, 'patch', { fetchBefore: true }) + + const item = await service.create({ test: true, comment: 'awesome' }) + + assert.ok(!calledCb, 'not called cb') + + const result = await service.patch( + item.id, + { test: false }, + { query: { $select: ['id'] } }, + ) + + assert.ok(calledCb, 'called cb') + assert.deepStrictEqual(result, { id: 0 }, 'has right result') + }) }) - it("throws on 'get'", async () => { - await expect(users.get(0)).rejects.toThrow() + describe('remove', function () { + it('basic remove', async function () { + let calledCb = false + const cb = (byId: any, context: any) => { + calledCb = true + assert.strictEqual(context.path, 'test', 'cb has context') + assert.deepStrictEqual( + byId['0'].before, + { id: 0, test: true, comment: 'awesome' }, + 'has right before', + ) + assert.deepStrictEqual( + byId['0'].item, + { id: 0, test: true, comment: 'awesome' }, + 'has right item', + ) + } + + const { service } = mockAround(cb, 'remove', { fetchBefore: true }) + + const item = await service.create({ test: true, comment: 'awesome' }) + + assert.ok(!calledCb, 'not called cb') + + await service.remove(item.id) + + assert.ok(calledCb, 'called cb') + }) }) +}) - it('memoizes the stash call', async () => { - let getCalls = 0 - const origGet = app.service('users').get.bind(app.service('users')) - app.service('users').get = async (id: any, params: any) => { - getCalls++ - return origGet(id, params) +describe('hook - stashable (multi & options)', function () { + it('skips when shouldSkip("checkMulti") matches', async function () { + let calledCb = false + const cb = () => { + calledCb = true } - await users.patch(0, { name: 'Updated' }) + const { service } = mock(cb, 'patch', { fetchBefore: true }) - const first = await finalParams.stashed() - const second = await finalParams.stashed() - const third = await finalParams.stashed() + const item = await service.create({ test: true }) + await service.patch(item.id, { test: false }, { + skipHooks: ['checkMulti'], + } as any) - expect(first).toStrictEqual(second) - expect(second).toStrictEqual(third) - // Only one get call despite three stashed() calls - // (1 extra from the patch internal get) - expect(getCalls).toBeLessThanOrEqual(2) + assert.ok(!calledCb, 'cb is not called when skipped') }) - it('returns the same promise on every call', async () => { - await users.patch(0, { name: 'Updated' }) + it('uses _find when skipHooks is true (multi)', async function () { + let byId: any + const cb = (changes: any) => { + byId = changes + } - const p1 = finalParams.stashed() - const p2 = finalParams.stashed() - expect(p1).toBe(p2) - }) + const { service } = mock(cb, 'patch', { + fetchBefore: true, + skipHooks: true, + }) - it('stashes multi patch', async () => { - const items = [storeInit[0], storeInit[1], storeInit[2]] - await users.patch( - null, - { key: 'c' }, - { query: { id: { $in: items.map((x) => x.id) } } }, - ) + const item0 = await service.create({ test: true }) + const item1 = await service.create({ test: true }) + + await service.patch(null, { test: false }, { query: {} }) - const before = await finalParams.stashed() - assert.deepEqual(before, items) + assert.strictEqual(byId[item0.id].before.test, true, 'item0 before') + assert.strictEqual(byId[item1.id].item.test, false, 'item1 item') }) - it('stashes multi remove', async () => { - const items = [storeInit[0], storeInit[1], storeInit[2]] - await users.remove(null, { - query: { id: { $in: items.map((x) => x.id) } }, + it('respects deleteParams when (re)fetching', async function () { + let byId: any + const cb = (changes: any) => { + byId = changes + } + + const { service } = mock(cb, 'patch', { + fetchBefore: true, + deleteParams: ['foo'], }) - const before = await finalParams.stashed() - assert.deepEqual(before, items) - }) + const item = await service.create({ test: true, comment: 'awesome' }) - it('supports custom propName', async () => { - // Reconfigure with custom propName - const app2 = feathers() - app2.use( - 'users', - new MemoryService({ store: clone(storeInit), startId, multi: true }), + await service.patch(item.id, { test: false }, { foo: 'bar' } as any) + + assert.deepStrictEqual( + byId[item.id].before, + { id: item.id, test: true, comment: 'awesome' }, + 'has right before', ) - let params2: any - app2.service('users').hooks({ - before: { - all: [ - stashable({ propName: 'before' }), - (context: any) => { - params2 = context.params - }, - ], - }, - }) + assert.strictEqual(byId[item.id].item.test, false, 'has right item') + }) - await app2.service('users').patch(0, { name: 'Updated' }) + it('multi patch with fetchBefore tracks all affected items', async function () { + let byId: any + const cb = (changes: any) => { + byId = changes + } - expect(typeof params2.before).toBe('function') - const before = await params2.before() - expect(before.name).toBe('Jane Doe') - }) + const { service } = mock(cb, 'patch', { fetchBefore: true }) - it('supports custom stashFunc', async () => { - const app2 = feathers() - app2.use( - 'users', - new MemoryService({ store: clone(storeInit), startId, multi: true }), + const item0 = await service.create({ test: true }) + const item1 = await service.create({ test: true }) + + await service.patch(null, { test: false }, { query: {} }) + + assert.deepStrictEqual( + byId[item0.id].before, + { id: item0.id, test: true }, + 'item0 before', + ) + assert.deepStrictEqual( + byId[item0.id].item, + { id: item0.id, test: false }, + 'item0 item', + ) + assert.deepStrictEqual( + byId[item1.id].before, + { id: item1.id, test: true }, + 'item1 before', ) - let params2: any - app2.service('users').hooks({ - before: { - all: [ - stashable({ - stashFunc: async () => ({ custom: true }), - }), - (context: any) => { - params2 = context.params - }, - ], + assert.deepStrictEqual( + byId[item1.id].item, + { id: item1.id, test: false }, + 'item1 item', + ) + }) + + it('supports a params function and a custom (array) name', async function () { + let byId: any + let manipulated = false + const cb = (changes: any) => { + byId = changes + } + + const { service } = mock(cb, 'patch', { + fetchBefore: true, + name: ['custom', 'nested'], + params: (params: any) => { + manipulated = true + return params }, }) - await app2.service('users').patch(0, { name: 'Updated' }) + const item = await service.create({ test: true, comment: 'awesome' }) - const stashed = await params2.stashed() - expect(stashed).toStrictEqual({ custom: true }) - }) + await service.patch(item.id, { test: false }) - it('works as around hook with stashed available after next()', async () => { - const app2 = feathers() - app2.use( - 'users', - new MemoryService({ store: clone(storeInit), startId, multi: true }), + assert.ok(manipulated, 'params function was called') + assert.deepStrictEqual( + byId[item.id].before, + { id: item.id, test: true, comment: 'awesome' }, + 'has right before', ) - let stashedBefore: any - let stashedAfter: any - let resultAfter: any - - app2.service('users').hooks({ - around: { - patch: [ - stashable(), - async (context: any, next: any) => { - stashedBefore = await context.params.stashed() - await next() - stashedAfter = await context.params.stashed() - resultAfter = context.result - }, - ], + assert.strictEqual(byId[item.id].item.test, false, 'has right item') + }) + + it('works without a callback and stores the result on context.params.stash', async function () { + let stash: any + const { service } = mock( + undefined, + 'patch', + { fetchBefore: true }, + null, + (context: any) => { + stash = context.params.stash }, - }) + ) - await app2.service('users').patch(0, { name: 'Updated' }) + const item = await service.create({ test: true, comment: 'awesome' }) - // Before next(): stashed has the original data - expect(stashedBefore.name).toBe('Jane Doe') - // After next(): stashed still returns the same (pre-mutation) data - expect(stashedAfter.name).toBe('Jane Doe') - // But the actual result has the updated data - expect(resultAfter.name).toBe('Updated') - // Same reference — memoized - expect(stashedBefore).toBe(stashedAfter) - }) + await service.patch(item.id, { test: false }) - it('returns undefined on fetch error', async () => { - const app2 = feathers() - app2.use( - 'users', - new MemoryService({ store: clone(storeInit), startId, multi: true }), + assert.deepStrictEqual( + stash[item.id].before, + { id: item.id, test: true, comment: 'awesome' }, + 'context.params.stash has right before', ) - let params2: any - app2.service('users').hooks({ - before: { - all: [ - stashable({ - stashFunc: async () => { - throw new Error('DB error') - }, - }), - (context: any) => { - params2 = context.params - }, - ], + assert.strictEqual(stash[item.id].item.test, false, 'has right item') + }) + + it('supports a custom (string) name', async function () { + let stash: any + const { service } = mock( + undefined, + 'patch', + { + fetchBefore: true, + name: 'changes', }, - }) + null, + (context: any) => { + stash = context.params.changes + }, + ) - await app2.service('users').patch(0, { name: 'Updated' }) + const item = await service.create({ test: true, comment: 'awesome' }) - const stashed = await params2.stashed() - expect(stashed).toBeUndefined() + await service.patch(item.id, { test: false }) + + assert.deepStrictEqual( + stash[item.id].before, + { id: item.id, test: true, comment: 'awesome' }, + 'context.params.changes has right before', + ) + assert.strictEqual(stash[item.id].item.test, false, 'has right item') }) it('is type-compatible with AroundHookFunction', () => { diff --git a/src/hooks/stashable/stashable.hook.ts b/src/hooks/stashable/stashable.hook.ts index 783816b..6c428fa 100644 --- a/src/hooks/stashable/stashable.hook.ts +++ b/src/hooks/stashable/stashable.hook.ts @@ -1,74 +1,70 @@ -import type { HookContext, NextFunction } from '@feathersjs/feathers' -import { checkContext } from '../../utils/index.js' - -export type StashableOptions = { - /** The property name on `context.params` to store the stash function. @default 'stashed' */ - propName?: string - /** Custom function to fetch the pre-mutation state. Defaults to `service.get` or `service.find`. */ - stashFunc?: (context: HookContext) => Promise -} - -const defaultStashFunc = (context: HookContext) => { - const isMulti = context.id == null - - const params = { - ...context.params, - _stashable: true, - ...(isMulti ? { paginate: false } : {}), - } - - return isMulti - ? context.service.find(params) - : context.service.get(context.id, params) -} +import type { HookContext, Id, NextFunction } from '@feathersjs/feathers' +import { shouldSkip } from '../../predicates/should-skip/should-skip.predicate.js' +import { stashBefore, stashAfter } from '../../utils/stash/stash.util.js' +import type { Change, StashOptions } from '../../utils/stash/stash.util.js' /** - * Stashes the pre-mutation state of a record into `context.params`. - * Eagerly starts the fetch but exposes a memoized function — calling it - * multiple times only hits the database once. - * Use in `before` hooks on `update`, `patch`, or `remove` methods. + * Stashes the affected records of a `create`, `update`, `patch` or `remove` + * call by their id and (optionally) passes them to a callback. + * + * Runs in `before` + `after` (or a single `around`) and stores the result at + * `context.params[name]` (default `stash`) as a `Record`. + * For every affected id it provides the state `before` the mutation (when + * `fetchBefore` is enabled) and the resulting `item` after it. + * + * Built on top of the {@link stash} util — use that directly if you need to + * stash imperatively inside your own hooks. * * @example * ```ts * import { stashable } from 'feathers-utils/hooks' * * app.service('users').hooks({ - * before: { patch: [stashable()] } + * around: { + * all: [ + * stashable((stash, context) => { + * for (const id in stash) { + * const { before, item } = stash[id] + * // react to the change + * } + * }, { fetchBefore: true }), + * ], + * }, * }) * - * // In a later hook (before or after): - * const before = await context.params.stashed() + * // or just read it later: + * const stash = context.params.stash * ``` * * @see https://utils.feathersjs.com/hooks/stashable.html */ -export function stashable( - options?: StashableOptions, -) { - const propName = options?.propName ?? 'stashed' - const stashFunc = options?.stashFunc ?? defaultStashFunc - - function hook(context: H): void +export const stashable = ( + cb?: (stash: Record>, context: H) => void | Promise, + options?: Partial>, +) => { + function hook(context: H): Promise function hook(context: H, next: NextFunction): Promise - function hook(context: H, next?: NextFunction): void | Promise { - if (context.params._stashable) { - if (next) return next() - return + async function hook(context: H, next?: NextFunction): Promise { + if (shouldSkip('checkMulti')(context)) { + return context } - checkContext(context, { - type: ['before', 'around'], - method: ['update', 'patch', 'remove'], - label: 'stashable', - }) - - const promise = stashFunc(context).catch(() => undefined) + if (context.type === 'before' || context.type === 'around') { + await stashBefore(context, options) + } - context.params[propName] = () => promise + if (next) { + await next() + } - if (next) return next() + if (context.type === 'after' || context.type === 'around') { + const stash = await stashAfter(context, options) + if (stash && cb) { + await cb(stash as Record>, context) + } + } - return + return context } return hook } diff --git a/src/utils/index.ts b/src/utils/index.ts index 96d86c2..bd60ed7 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -15,8 +15,10 @@ export * from './patch-batch/patch-batch.util.js' export * from './replace-data/replace-data.util.js' export * from './replace-result/replace-result.util.js' export * from './skip-result/skip-result.util.js' +export * from './stash/stash.util.js' export * from './to-paginated/to-paginated.util.js' export * from './transform-params/transform-params.util.js' export * from './sort-query-properties/sort-query-properties.util.js' +export * from './stable-stringify/stable-stringify.util.js' export * from './walk-query/walk-query.util.js' export * from './zip-data-result/zip-data-result.util.js' diff --git a/src/utils/stable-stringify/stable-stringify.util.ts b/src/utils/stable-stringify/stable-stringify.util.ts new file mode 100644 index 0000000..a982f32 --- /dev/null +++ b/src/utils/stable-stringify/stable-stringify.util.ts @@ -0,0 +1,31 @@ +import { sortQueryProperties } from '../sort-query-properties/sort-query-properties.util.js' + +/** + * Serializes an object into a deterministic, order-independent string — useful + * for cache keys. Canonicalizes the object once via {@link sortQueryProperties} + * (recursive key-sort + operator-array sort) so that `{ a: 1, b: 2 }` and + * `{ b: 2, a: 1 }` (and `{ $in: [1, 2] }` vs `{ $in: [2, 1] }`) produce the same + * string. Throws on non-JSON values (functions). + * + * @example + * ```ts + * import { stableStringify } from 'feathers-utils/utils' + * + * stableStringify({ b: 2, a: 1 }) === stableStringify({ a: 1, b: 2 }) // true + * ``` + * + * @see https://utils.feathersjs.com/utils/stable-stringify.html + */ +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/utils/stash/stash.util.test.ts b/src/utils/stash/stash.util.test.ts new file mode 100644 index 0000000..c8605ea --- /dev/null +++ b/src/utils/stash/stash.util.test.ts @@ -0,0 +1,251 @@ +import assert from 'node:assert' +import { feathers } from '@feathersjs/feathers' +import { MemoryService } from '@feathersjs/memory' +import { + stash, + stashBefore, + stashAfter, + getOrFindByIdParams, +} from './stash.util.js' +import type { StashOptions } from './stash.util.js' + +function mock(options?: Partial) { + const app = feathers() + app.use('test', new MemoryService({ multi: true })) + const service = app.service('test') + + let afterStash: any + let afterParamsStash: any + + service.hooks({ + before: { + all: [ + async (context: any) => { + await stash(context, options) + return context + }, + ], + }, + after: { + all: [ + async (context: any) => { + afterStash = await stash(context, options) + afterParamsStash = context.params.stash + return context + }, + ], + }, + }) + + return { + service, + getAfterStash: () => afterStash, + getAfterParamsStash: () => afterParamsStash, + } +} + +describe('util - stash', function () { + it('writes the changes to context.params.stash and returns them (patch)', async function () { + const { service, getAfterStash, getAfterParamsStash } = mock({ + fetchBefore: true, + }) + + const item = await service.create({ test: true, comment: 'awesome' }) + + await service.patch(item.id, { test: false }) + + const afterStash = getAfterStash() + + assert.deepStrictEqual( + afterStash[item.id].before, + { id: item.id, test: true, comment: 'awesome' }, + 'returned stash has right before', + ) + assert.strictEqual(afterStash[item.id].item.test, false, 'has right item') + assert.deepStrictEqual( + getAfterParamsStash(), + afterStash, + 'context.params.stash equals the returned stash', + ) + }) + + it('before is undefined on create', async function () { + const { service, getAfterStash } = mock() + + const item = await service.create({ test: true, comment: 'awesome' }) + + const afterStash = getAfterStash() + + assert.strictEqual( + afterStash[item.id].before, + undefined, + 'before undefined', + ) + assert.deepStrictEqual( + afterStash[item.id].item, + { id: item.id, test: true, comment: 'awesome' }, + 'has right item', + ) + }) + + it('tracks all affected items on multi patch', async function () { + const { service, getAfterStash } = mock({ fetchBefore: true }) + + const item0 = await service.create({ test: true }) + const item1 = await service.create({ test: true }) + + await service.patch(null, { test: false }, { query: {} }) + + const afterStash = getAfterStash() + + assert.strictEqual(afterStash[item0.id].before.test, true, 'item0 before') + assert.strictEqual(afterStash[item0.id].item.test, false, 'item0 item') + assert.strictEqual(afterStash[item1.id].before.test, true, 'item1 before') + assert.strictEqual(afterStash[item1.id].item.test, false, 'item1 item') + }) + + it('stashBefore stores itemsBefore on context.params..itemsBefore', async function () { + const app = feathers() + app.use('test', new MemoryService({ multi: true })) + const service = app.service('test') + + const item = await service.create({ test: true, comment: 'awesome' }) + + const context: any = { + id: item.id, + method: 'patch', + type: 'before', + service, + params: {}, + } + + const itemsBefore = await stashBefore(context, { fetchBefore: true }) + + assert.deepStrictEqual( + itemsBefore[item.id], + { id: item.id, test: true, comment: 'awesome' }, + 'stashBefore returns itemsBefore by id', + ) + assert.deepStrictEqual( + context.params.stash.itemsBefore, + itemsBefore, + 'stored at context.params.stash.itemsBefore', + ) + }) + + it('stashAfter returns undefined when no before-phase ran', async function () { + const app = feathers() + app.use('test', new MemoryService({ multi: true })) + const service = app.service('test') + + const item = await service.create({ test: true }) + + const context: any = { + id: item.id, + method: 'patch', + type: 'after', + service, + params: {}, + result: { id: item.id, test: false }, + } + + const result = await stashAfter(context) + + assert.strictEqual(result, undefined, 'no itemsBefore -> undefined') + }) + + it('reuses context.result in the after-phase when params are unchanged (no refetch)', async function () { + const app = feathers() + app.use('test', new MemoryService({ multi: true })) + const service = app.service('test') + + const item = await service.create({ test: true, comment: 'awesome' }) + + let getCalls = 0 + const origGet = service.get.bind(service) + ;(service as any).get = (id: any, params: any) => { + getCalls++ + return origGet(id, params) + } + + // a params fn (returning params unchanged) forces getOrFindByIdParams to + // build params in the after-phase; the deepEqual optimization must then + // detect they match the current request params and skip the refetch. + const options = { fetchBefore: true, params: (p: any) => p } + service.hooks({ + before: { + patch: [ + async (context: any) => { + await stash(context, options) + return context + }, + ], + }, + after: { + patch: [ + async (context: any) => { + await stash(context, options) + return context + }, + ], + }, + }) + + await service.patch(item.id, { test: false }) + + assert.strictEqual( + getCalls, + 1, + 'only the before-phase fetched; the after-phase reused context.result', + ) + }) + + describe('getOrFindByIdParams', function () { + it('builds params for multi before, stripping stash and $select', async function () { + const app = feathers() + app.use('test', new MemoryService({ multi: true })) + const service = app.service('test') + + const context: any = { + id: null, + method: 'patch', + type: 'before', + service, + params: { query: { test: true, $select: ['id'] }, stash: { foo: 1 } }, + } + + const params = await getOrFindByIdParams(context, { + type: 'before', + skipHooks: false, + deleteParams: [], + }) + + assert.strictEqual(params?.paginate, false, 'paginate disabled') + assert.strictEqual((params as any)?.stash, undefined, 'stash removed') + assert.strictEqual(params?.query?.$select, undefined, '$select removed') + assert.strictEqual(params?.query?.test, true, 'keeps other query') + }) + + it('returns undefined for after when no params fn and no $select', async function () { + const app = feathers() + app.use('test', new MemoryService({ multi: true })) + const service = app.service('test') + + const context: any = { + id: 1, + method: 'patch', + type: 'after', + service, + params: { query: {} }, + } + + const params = await getOrFindByIdParams(context, { + type: 'after', + skipHooks: false, + deleteParams: [], + }) + + assert.strictEqual(params, undefined) + }) + }) +}) diff --git a/src/utils/stash/stash.util.ts b/src/utils/stash/stash.util.ts new file mode 100644 index 0000000..42807f6 --- /dev/null +++ b/src/utils/stash/stash.util.ts @@ -0,0 +1,415 @@ +import _get from 'lodash/get.js' +import _set from 'lodash/set.js' +import { dequal as deepEqual } from 'dequal' +import _omit from 'lodash/omit.js' + +import type { HookContext, Id, Params } from '@feathersjs/feathers' +import { getResultIsArray } from '../get-result-is-array/get-result-is-array.util.js' +import type { Promisable } from '../../internal.utils.js' + +export type Change = { + before: T + item: T +} + +export type Stash = { + [key: string]: Change + [key: number]: Change +} + +export type ManipulateParams = ( + params: Params, + context: H, +) => Promisable + +export interface StashOptions { + /** + * @default false + */ + skipHooks: boolean + params?: ManipulateParams + /** + * @default [] + */ + deleteParams?: string[] + /** + * The name of the property to store the stash in `context.params` + * + * @default "stash" + */ + name?: string | string[] + /** + * @default false + */ + fetchBefore?: boolean +} + +const defaultOptions = { + skipHooks: false, + params: undefined, + name: 'stash', + deleteParams: [], + fetchBefore: false, +} satisfies Partial + +export interface StashParams extends Params { + stash: any +} + +declare module '@feathersjs/feathers' { + interface Params { + paginate?: any + stash?: any + /** Internal marker set on the (re)fetch params to prevent the stashing from recursing into itself. */ + _stash?: boolean + } +} + +/** + * Captures the affected records of a `create`, `update`, `patch` or `remove` + * call and writes the result to `context.params[name]` (default `stash`). + * + * It is phase-aware: + * - In a `before` hook it stashes the pre-mutation state (when `fetchBefore` is + * enabled) at `context.params[name].itemsBefore`. + * - In an `after` hook it computes the `Record` of all + * affected records, writes it to `context.params[name]` and returns it. + * + * Use it imperatively in your own `before`/`after` hooks, or use the + * {@link stashable} hook which orchestrates both phases (and `around`). + * + * @example + * ```ts + * import { stash } from 'feathers-utils' + * + * // before hook + * await stash(context, { fetchBefore: true }) + * + * // after hook + * const changes = await stash(context, { fetchBefore: true }) + * for (const id in changes) { + * const { before, item } = changes[id] + * } + * ``` + * + * @see https://utils.feathersjs.com/hooks/stashable.html + */ +export const stash = async ( + context: H, + _options?: Partial>, +): Promise | Record | T[] | undefined> => { + const options = { ...defaultOptions, ..._options } + + if (context.type === 'before') { + return stashBefore(context, _options) + } + + if (context.type === 'after') { + return stashAfter(context, _options) + } + + // around: the before part has not run yet if `itemsBefore` is missing + if (_get(context, getPath(options.name, true)) === undefined) { + return stashBefore(context, _options) + } + + return stashAfter(context, _options) +} + +export const stashBefore = async ( + context: H, + _options?: Partial>, +): Promise | T[]> => { + // skip the (re)fetches that the stashing triggers itself + if (context.params?._stash) { + return {} + } + + const options = { + ...defaultOptions, + ..._options, + type: 'before' as const, + } + + let byId: Record | T[] + + if (context.method === 'create' || !options.fetchBefore) { + byId = {} + } else if ( + context.method === 'update' || + context.method === 'patch' || + context.method === 'remove' + ) { + byId = ((await getOrFindById(context, options)) ?? {}) as Record + } else { + byId = [] + } + + _set(context, getPath(options.name, true), byId) + + return byId +} + +export const stashAfter = async ( + context: H, + _options?: Partial>, +): Promise | undefined> => { + // skip the (re)fetches that the stashing triggers itself + if (context.params?._stash) { + return + } + + const options = { + ...defaultOptions, + ..._options, + type: 'after' as const, + } + + const itemsBefore = _get(context, getPath(options.name, true)) + + if (!itemsBefore) { + return + } + + const items = await resultById(context, options) + + if (!items) { + return + } + const itemsBeforeOrAfter = + context.method === 'remove' && options.fetchBefore ? itemsBefore : items + + const stash = Object.keys(itemsBeforeOrAfter).reduce( + (result: Stash, id: string): Stash => { + if ( + options.fetchBefore && + ((context.method !== 'create' && !itemsBefore[id]) || + (context.method !== 'remove' && !items[id])) + ) { + throw new Error('Mismatch!') + //return result; + } + + const before = itemsBefore[id] + const item = items[id] + + result[id] = { + before: before, + item: item, + } + + return result + }, + {}, + ) + + _set(context, getPath(options.name, false), stash) + + return stash as Stash +} + +export type GetOrFindByIdParamsOptions = + Pick, 'params' | 'skipHooks' | 'deleteParams'> & { + type: 'before' | 'after' + } + +export const getOrFindByIdParams = async ( + context: H, + options: GetOrFindByIdParamsOptions, +): Promise => { + if (context.id == null) { + if (options.type === 'before') { + const { stash: _stash, query: contextQuery, ...rest } = context.params + const { $select, ...query } = contextQuery ?? {} + + const base: Params = { ...rest, query, paginate: false } + const params = options.deleteParams?.length + ? _omit(base, options.deleteParams) + : base + + return ( + (typeof options.params === 'function' + ? await options.params(params, context) + : params) ?? {} + ) + } else if (options.type === 'after') { + if (!options.params && !context.params.query?.$select) { + return + } + + const { result: fetchedItems } = getResultIsArray(context) + + const idField = getIdField(context) + + if (!fetchedItems.length) { + return + } + + const ids = fetchedItems.map((x) => x && x[idField]) + + let params: Params | null = { + query: { + [idField]: { $in: ids }, + }, + paginate: false, + } + + params = options.params ? await options.params(params, context) : params + return params ?? {} + } + } else { + if ( + options.type === 'after' && + !options.params && + !context.params.query?.$select + ) { + return + } + + const { stash: _stash, ...rest } = context.params + const { $select, ...query } = context.params.query ?? {} + + const base: Params = { ...rest, query } + const params = options.deleteParams?.length + ? _omit(base, options.deleteParams) + : base + + return ( + (typeof options.params === 'function' + ? await options.params(params, context) + : params) ?? {} + ) + } +} + +export type GetOrFindByIdOptions = + GetOrFindByIdParamsOptions & { + byId?: boolean + } + +const getOrFindById = async ( + context: H, + _options: GetOrFindByIdOptions, +): Promise | T[] | undefined> => { + const options = { + byId: true, + ..._options, + } + + let itemOrItems + const idField = getIdField(context) + + const params = await getOrFindByIdParams(context, options) + + if (context.id == null) { + const method = options.skipHooks ? '_find' : 'find' + + itemOrItems = await context.service[method]({ ...params, _stash: true }) + + itemOrItems = itemOrItems && (itemOrItems.data || itemOrItems) + } else { + const method = options.skipHooks ? '_get' : 'get' + + itemOrItems = await context.service[method](context.id, { + ...params, + _stash: true, + }) + } + + const items = !itemOrItems + ? [] + : Array.isArray(itemOrItems) + ? itemOrItems + : [itemOrItems] + + if (options.byId) { + return items.reduce((byId, item) => { + const id = item[idField] + byId[id] = item + return byId + }, {}) + } else { + return items + } +} + +const resultById = async ( + context: H, + options: GetOrFindByIdParamsOptions, +): Promise> => { + if (!context.result) { + return {} + } + + let items: Record[] + let params: Params | null | undefined = await getOrFindByIdParams( + context, + options, + ) + + if (params) { + // mirror how `getOrFindByIdParams` builds the (re)fetch params (minus the + // `$select` stripping) so an unchanged request skips the refetch + const { stash: _stash, ...rest } = context.params + const base: Params = { ...rest, query: context.params.query ?? {} } + const contextParams = options.deleteParams?.length + ? _omit(base, options.deleteParams) + : base + + if (deepEqual(params, contextParams)) { + params = null + } + } + + if (context.method === 'remove' || !params) { + let itemOrItems = context.result + itemOrItems = Array.isArray(itemOrItems.data) + ? itemOrItems.data + : itemOrItems + items = Array.isArray(itemOrItems) ? itemOrItems : [itemOrItems] + } else { + items = (await getOrFindById(context, { + skipHooks: options?.skipHooks ?? false, + byId: false, + params: () => params, + type: options.type, + })) as Record[] + } + + const idField = context.service.id + + return items.reduce( + ( + byId: Record>, + item: Record, + ) => { + const id = item[idField] as Id + byId[id] = item + return byId + }, + {}, + ) +} + +const getIdField = (context: Pick): string => { + return context.service.options.id +} + +const getPath = ( + path: string | string[], + isBefore: boolean, +): string | string[] => { + if (isBefore) { + if (typeof path === 'string') { + return `params.${path}.itemsBefore` + } else { + return ['params', ...path, 'itemsBefore'] + } + } else { + if (typeof path === 'string') { + return `params.${path}` + } else { + return ['params', ...path] + } + } +} diff --git a/test/index.test.ts b/test/index.test.ts index b4ad759..12f34a4 100755 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -61,6 +61,11 @@ const utils = [ 'replaceResult', 'skipResult', 'sortQueryProperties', + 'stableStringify', + 'stash', + 'stashBefore', + 'stashAfter', + 'getOrFindByIdParams', 'toPaginated', 'transformParams', 'walkQuery',