import type { HookContext, NextFunction } from '@feathersjs/feathers' import { checkContext, getResultIsArray } from '../../utils/index.js' import type { MaybeArray, NeverFallback } from '../../internal.utils.js' import type { InferFindParams, InferGetResult, } from '../../utility-types/infer-service-methods.js' import type { ResultSingleHookContext } from '../../utility-types/hook-context.js' export type OnDeleteAction = 'cascade' | 'set null' export interface OnDeleteOptions< H extends HookContext = HookContext, S extends keyof H['app']['services'] = keyof H['app']['services'], > { /** * The related service where related items should be manipulated */ service: S /** * The propertyKey in the related service */ keyThere: NeverFallback, string> /** * The propertyKey in the current service. */ keyHere: keyof ResultSingleHookContext /** * The action to perform on the related items. * * - `cascade`: remove related items * - `set null`: set the related property to null */ onDelete: OnDeleteAction /** * Additional query to merge into the service call. * Typed based on the related service's query type. */ query?: InferFindParams['query'] /** * If true, the hook will wait for the service to finish before continuing * * @default false */ blocking?: boolean /** * Called when a non-blocking (`blocking: false`) related-service call rejects. * Without this, fire-and-forget rejections are swallowed (but never leak as an * unhandled rejection). In `blocking` mode the error is thrown to the caller instead. */ onError?: (error: any, context: H) => void } /** * Manipulates related items when a record is deleted, similar to SQL foreign key actions. * Supports `'cascade'` (remove related records) and `'set null'` (nullify the foreign key). * Unlike database-level cascades, this hook triggers service events and hooks for related items. * * @example * ```ts * import { onDelete } from 'feathers-utils/hooks' * * app.service('users').hooks({ * after: { * remove: [onDelete({ service: 'posts', keyHere: 'id', keyThere: 'userId', onDelete: 'cascade' })] * } * }) * ``` * * @see https://utils.feathersjs.com/hooks/on-delete.html */ type OnDeleteOptionsDistributed = { [S in keyof H['app']['services'] & string]: OnDeleteOptions }[keyof H['app']['services'] & string] export const onDelete = ( options: MaybeArray>, ) => { const optionsMulti = Array.isArray(options) ? options : [options] return async (context: H, next?: NextFunction): Promise => { checkContext(context, { type: ['after', 'around'], method: 'remove', label: 'onDelete', }) if (next) { await next() } const { result } = getResultIsArray(context) if (!result.length) { return } const blockingPromises: Promise[] = [] for (const { keyHere, keyThere, onDelete, service, blocking, query, onError, } of optionsMulti) { let ids = result.map((x) => x[keyHere]).filter((x) => !!x) ids = [...new Set(ids)] if (!ids || ids.length <= 0) { continue } const params = { query: { ...query, ...(ids.length === 1 ? { [keyThere]: ids[0] } : { [keyThere]: { $in: ids } }), }, paginate: false, } let promise: Promise | undefined = undefined if (onDelete === 'cascade') { promise = context.app.service(service as string).remove(null, params) } else if (onDelete === 'set null') { const data = { [keyThere]: null } promise = context.app .service(service as string) .patch(null, data, params) } if (!promise) { continue } if (blocking) { blockingPromises.push(promise) } else { // fire-and-forget: always attach a catch so a rejection never becomes // an unhandled promise rejection. Surface it via `onError` if provided. promise.catch((error) => onError?.(error, context)) } } if (blockingPromises.length) { await Promise.all(blockingPromises) } return } }