Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ export * from './disable-pagination/disable-pagination.hook.js'
export * from './disallow/disallow.hook.js'
export * from './iff-else/iff-else.hook.js'
export * from './iff/iff.hook.js'
export * from './mute-event/mute-event.hook.js'
export * from './on-delete/on-delete.hook.js'
export * from './params-for-server/params-for-server.hook.js'
export * from './params-from-client/params-from-client.hook.js'
export * from './prevent-changes/prevent-changes.hook.js'
export * from './rate-limit/rate-limit.hook.js'
export * from './set-data/set-data.hook.js'
export * from './set-field/set-field.hook.js'
export * from './set-query-defaults/set-query-defaults.hook.js'
export * from './set-result/set-result.hook.js'
export * from './set-slug/set-slug.hook.js'
export * from './skippable/skippable.hook.js'
Expand Down
9 changes: 9 additions & 0 deletions src/hooks/mute-event/mute-event.hook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
title: muteEvent
category: hooks
hook:
type: ["before", "after", "around"]
method: ["all"]
multi: true
methods: ["all"]
---
118 changes: 118 additions & 0 deletions src/hooks/mute-event/mute-event.hook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type { HookContext } from '@feathersjs/feathers'
import { feathers } from '@feathersjs/feathers'
import { MemoryService } from '@feathersjs/memory'
import { muteEvent } from './mute-event.hook.js'

type User = { id: number; name: string }

const setup = (hooks: any[]) => {
const app = feathers<{ users: MemoryService<User, Partial<User>> }>()
app.use('users', new MemoryService({ id: 'id', startId: 1, multi: true }))
app.service('users').hooks({ around: { all: hooks } })
return app
}

const tick = () => new Promise((resolve) => setTimeout(resolve, 10))

describe('muteEvent', () => {
describe('unit', () => {
it('sets context.event to null', async () => {
const context = { event: 'created' } as unknown as HookContext
await muteEvent()(context)
expect(context.event).toBe(null)
})

it('does nothing when `when` is false', async () => {
const context = { event: 'created' } as unknown as HookContext
await muteEvent({ when: false })(context)
expect(context.event).toBe('created')
})

it('respects a sync predicate', async () => {
const muted = { event: 'created' } as unknown as HookContext
await muteEvent({ when: () => true })(muted)
expect(muted.event).toBe(null)

const kept = { event: 'created' } as unknown as HookContext
await muteEvent({ when: () => false })(kept)
expect(kept.event).toBe('created')
})

it('respects an async predicate', async () => {
const context = { event: 'created' } as unknown as HookContext
await muteEvent({ when: async () => true })(context)
expect(context.event).toBe(null)
})

it('calls next when provided', async () => {
const context = { event: 'created' } as unknown as HookContext
let called = false
await muteEvent()(context, async () => {
called = true
})
expect(called).toBe(true)
})
})

describe('integration', () => {
it('suppresses the created event', async () => {
const app = setup([muteEvent()])
let fired = false
app.service('users').on('created', () => {
fired = true
})

await app.service('users').create({ name: 'test' })
await tick()

expect(fired).toBe(false)
})

it('emits the event without the hook (control)', async () => {
const app = setup([])
let fired = false
app.service('users').on('created', () => {
fired = true
})

await app.service('users').create({ name: 'test' })
await tick()

expect(fired).toBe(true)
})

it('emits the event when `when` is false', async () => {
const app = setup([muteEvent({ when: false })])
let fired = false
app.service('users').on('created', () => {
fired = true
})

await app.service('users').create({ name: 'test' })
await tick()

expect(fired).toBe(true)
})

it('mutes conditionally via a predicate', async () => {
const app = setup([
muteEvent({ when: (context) => context.method === 'create' }),
])
let created = false
let patched = false
app.service('users').on('created', () => {
created = true
})
app.service('users').on('patched', () => {
patched = true
})

const user = await app.service('users').create({ name: 'test' })
await app.service('users').patch(user.id, { name: 'changed' })
await tick()

expect(created).toBe(false)
expect(patched).toBe(true)
})
})
})
56 changes: 56 additions & 0 deletions src/hooks/mute-event/mute-event.hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { HookContext, NextFunction } from '@feathersjs/feathers'
import type { PredicateFn } from '../../types.js'

export type MuteEventOptions<H extends HookContext = HookContext> = {
/**
* Only mute when this is truthy. Can be a boolean or a predicate that
* receives the `HookContext`. Defaults to always muting.
*
* @example isProvider('server')
*/
when?: boolean | PredicateFn<H>
}

/**
* Suppresses the service event for the current call by setting `context.event`
* to `null`. Feathers emits the standard `created`/`updated`/`patched`/`removed`
* event (the value of `context.event`) after the method runs; setting it to
* `null` prevents that emission so real-time subscribers and channels are not
* notified.
*
* Useful for seeding, migrations and internal syncs that should not trigger
* downstream listeners. Works as a `before`, `after` or `around` hook.
*
* @example
* ```ts
* import { muteEvent } from 'feathers-utils/hooks'
* import { isProvider } from 'feathers-utils/predicates'
*
* app.service('users').hooks({
* before: {
* all: [muteEvent()], // mute every call
* create: [muteEvent({ when: isProvider('server') })], // only server calls
* }
* })
* ```
*
* @see https://utils.feathersjs.com/hooks/mute-event.html
*/
export const muteEvent = <H extends HookContext = HookContext>(
options?: MuteEventOptions<H>,
) => {
const when = options?.when

return async (context: H, next?: NextFunction): Promise<void> => {
const should =
typeof when === 'function' ? await when(context) : (when ?? true)

if (should) {
context.event = null
}

if (next) {
await next()
}
}
}
14 changes: 14 additions & 0 deletions src/hooks/set-query-defaults/set-query-defaults.hook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
title: setQueryDefaults
category: hooks
hook:
type: ["before", "around"]
method: ["find", "get", "update", "patch", "remove"]
multi: true
see:
- utils/query-defaults
- utils/query-has-property
- utils/add-to-query
- utils/walk-query
- hooks/soft-delete
---
58 changes: 58 additions & 0 deletions src/hooks/set-query-defaults/set-query-defaults.hook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest'
import type { HookContext } from '@feathersjs/feathers'
import { setQueryDefaults } from './set-query-defaults.hook.js'

const ctx = (query?: any): HookContext =>
({ type: 'before', method: 'find', params: { query } }) as any

describe('setQueryDefaults', () => {
it('adds the default when the field is absent', () => {
const context = ctx({ status: 'active' })
setQueryDefaults({ isTemplate: false })(context)
expect(context.params.query).toEqual({
status: 'active',
isTemplate: false,
})
})

it('adds the default when there is no query at all', () => {
const context = ctx(undefined)
setQueryDefaults({ isTemplate: false })(context)
expect(context.params.query).toEqual({ isTemplate: false })
})

it('does not override when the field is already set', () => {
const context = ctx({ isTemplate: true })
setQueryDefaults({ isTemplate: false })(context)
expect(context.params.query).toEqual({ isTemplate: true })
})

it('does not override when the field is referenced nested in $or', () => {
const context = ctx({ $or: [{ isTemplate: true }, { foo: 1 }] })
setQueryDefaults({ isTemplate: false })(context)
expect(context.params.query).toEqual({
$or: [{ isTemplate: true }, { foo: 1 }],
})
})

it('applies multiple defaults independently (per-field)', () => {
const context = ctx({ a: 1 })
setQueryDefaults({ isTemplate: false, archived: false })(context)
expect(context.params.query).toEqual({
a: 1,
isTemplate: false,
archived: false,
})
})

it('calls next when used as an around hook', async () => {
const context = ctx({})
let called = false
const next = async () => {
called = true
}
await setQueryDefaults({ isTemplate: false })(context, next)
expect(called).toBe(true)
expect(context.params.query).toEqual({ isTemplate: false })
})
})
37 changes: 37 additions & 0 deletions src/hooks/set-query-defaults/set-query-defaults.hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { HookContext, NextFunction, Query } from '@feathersjs/feathers'
import { queryDefaults } from '../../utils/query-defaults/query-defaults.util.js'

/**
* Adds default properties to `context.params.query` for fields the incoming query does
* not already constrain (including fields referenced nested in `$and`/`$or`/`$nor`).
* The query equivalent of the `defaults` transformer: e.g. hide template rows by default
* while still letting callers opt in via `{ isTemplate: true }`. This is the same pattern
* `softDelete` uses to filter out deleted rows. Works as a `before` or `around` hook.
*
* @example
* ```ts
* import { setQueryDefaults } from 'feathers-utils/hooks'
*
* app.service('posts').hooks({
* before: { all: [setQueryDefaults({ isTemplate: false })] },
* })
* // find() => filters out templates
* // find({ query: { isTemplate: true } }) => caller keeps control
* ```
*
* @see https://utils.feathersjs.com/hooks/set-query-defaults.html
*/
export const setQueryDefaults = <H extends HookContext = HookContext>(
defaults: Query,
) => {
function hook(context: H): void
function hook(context: H, next: NextFunction): Promise<void>
function hook(context: H, next?: NextFunction): void | Promise<void> {
context.params.query = queryDefaults(context.params.query, defaults)

if (next) return next()

return
}
return hook
}
67 changes: 67 additions & 0 deletions src/hooks/soft-delete/soft-delete.hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,71 @@ describe('softDelete', () => {
}),
).toExtend<AroundHookFunction<App, MemoryService<Item>>>()
})

describe('allowQueryOverride', () => {
async function setupWith(extra: { allowQueryOverride?: boolean } = {}) {
const app = feathers().use(
'/users',
new MemoryService({ multi: true, id: 'id' }),
)
const userService: any = app.service('users')
userService.hooks({
around: {
all: [
softDelete({
deletedQuery: { deletedAt: null },
removeData: { deletedAt: new Date() },
...extra,
}),
],
},
})
await userService.create([
{ name: 'active', key: 'a', deletedAt: null },
{ name: 'deleted', key: 'a', deletedAt: new Date() },
])
return userService
}

it('returns deleted items when the query references the field (opt-out default)', async () => {
const userService = await setupWith()

const users = await userService.find({
query: { deletedAt: { $ne: null } },
})

assert.strictEqual(users.length, 1)
assert.strictEqual(users[0].name, 'deleted')
})

it('detects the field nested in $or', async () => {
const userService = await setupWith()

const users = await userService.find({
query: { $or: [{ deletedAt: { $ne: null } }] },
})

assert.strictEqual(users.length, 1)
assert.strictEqual(users[0].name, 'deleted')
})

it('keeps enforcing the default filter when the query omits the field', async () => {
const userService = await setupWith()

const users = await userService.find({ query: { key: 'a' } })

assert.strictEqual(users.length, 1)
assert.strictEqual(users[0].name, 'active')
})

it('allowQueryOverride: false always enforces the default filter', async () => {
const userService = await setupWith({ allowQueryOverride: false })

const users = await userService.find({
query: { deletedAt: { $ne: null } },
})

assert.strictEqual(users.length, 0)
})
})
})
Loading
Loading