UNPKG

feathers-trigger

Version:

Add triggers and actions to your feathers app.

503 lines (499 loc) 15.1 kB
import { getItems, checkContext } from 'feathers-hooks-common'; import _get from 'lodash/get.js'; import _set from 'lodash/set.js'; import _isEqual from 'lodash/isEqual.js'; import { shouldSkip } from 'feathers-utils'; import sift from 'sift'; const defaultOptions = { skipHooks: false, params: void 0, name: "changesById", deleteParams: [], fetchBefore: false }; const changesById = (cb, _options) => { const options = { ...defaultOptions, ..._options }; return async (context, next) => { if (shouldSkip("checkMulti", context)) { return context; } const pathBefore = getPath(options.name, true); if (context.type === "before" || context.type === "around") { const changes = await changesByIdBefore(context, options); if (!changes) { return context; } _set(context, pathBefore, changes); } if (next) { await next(); } if (context.type === "after" || context.type === "around") { const itemsBefore = _get(context, pathBefore); const changes = await changesByIdAfter(context, itemsBefore, cb, options); if (!changes) { return context; } _set(context, getPath(options.name, false), changes); } return context; }; }; const changesByIdBefore = async (context, _options) => { const options = { ...defaultOptions, ..._options, type: "before" }; let byId; if (context.method === "create" || !options.fetchBefore) { byId = {}; } else if (context.method === "update" || context.method === "patch" || context.method === "remove") { byId = await getOrFindById(context, options) ?? {}; } else { return []; } return byId; }; const changesByIdAfter = async (context, itemsBefore, cb, _options) => { if (!itemsBefore) { return; } const options = { ...defaultOptions, ..._options, type: "after" }; const items = await resultById(context, options); if (!items) { return; } const itemsBeforeOrAfter = context.method === "remove" && options.fetchBefore ? itemsBefore : items; const changesById2 = Object.keys(itemsBeforeOrAfter).reduce( (result, id) => { if (options.fetchBefore && (context.method !== "create" && !itemsBefore[id] || context.method !== "remove" && !items[id])) { throw new Error("Mismatch!"); } const before = itemsBefore[id]; const item = items[id]; result[id] = { before, item }; return result; }, {} ); if (cb && typeof cb === "function") { await cb(changesById2, context); } return changesById2; }; const getOrFindByIdParams = async (context, options) => { if (context.id == null) { if (options.type === "before") { let params = { ...context.params, query: { ...context.params?.query }, paginate: false }; delete params.changesById; if (options?.deleteParams) { options.deleteParams.forEach((key) => { delete params[key]; }); } if (params.query?.$select) { delete params.query.$select; } params = typeof options.params === "function" ? await options.params(params, context) : params; return params; } else if (options.type === "after") { if (!options.params && !context.params.query?.$select) { return; } const itemOrItems = getItems(context); const idField = getIdField(context); if (!itemOrItems) { return; } const fetchedItems = Array.isArray(itemOrItems) ? itemOrItems : [itemOrItems]; const ids = fetchedItems.map((x) => x && x[idField]); let params = { 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 query = Object.assign({}, context.params.query); delete query.$select; let params = Object.assign({}, context.params, { query }); delete params.changesById; if (options?.deleteParams) { options.deleteParams.forEach((key) => { delete params[key]; }); } params = (typeof options.params === "function" ? await options.params(params, context) : params) ?? {}; return params; } }; const getOrFindById = async (context, _options) => { 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); itemOrItems = itemOrItems && (itemOrItems.data || itemOrItems); } else { const method = options.skipHooks ? "_get" : "get"; itemOrItems = await context.service[method](context.id, params); } 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, options) => { if (!context.result) { return {}; } let items; let params = await getOrFindByIdParams( context, options ); if (params) { const contextParams = Object.assign({}, context.params); delete contextParams.changesById; if (options?.deleteParams) { options.deleteParams.forEach((key) => { delete contextParams[key]; }); } if (_isEqual(params, context.params)) { 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 }); } const idField = context.service.id; return items.reduce( (byId, item) => { const id = item[idField]; byId[id] = item; return byId; }, {} ); }; const getIdField = (context) => { return context.service.options.id; }; const getPath = (path, isBefore) => { 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]; } } }; const trigger = (options) => { if (!options) { throw new Error("You should define subscriptions"); } return async (context, next) => { checkContext( context, null, ["create", "update", "patch", "remove"], "trigger" ); if (context.type === "before") { return await triggerBefore(context, options); } else if (context.type === "after") { return await triggerAfter(context); } else if (context.type === "around" && next) { context = await triggerBefore(context, options); await next(); context = await triggerAfter(context); return context; } else { return context; } }; }; const makeDebug = (sub, context) => { if (!sub.debug) { return () => { }; } const prepend = [ "[FEATHERS_TRIGGER DEBUG]", ...sub.name ? [sub.name] : [], context.type, `service('${context.path}').${context.method}()` ]; return console.log.bind(console, ...prepend); }; const triggerBefore = async (context, options) => { let subs = await getSubscriptions(context, options); if (!subs?.length) { return context; } let debug = false; if (!Array.isArray(context.data)) { const result = []; await Promise.all( subs.map(async (sub) => { if (sub.debug) { debug = true; } const log = makeDebug(sub, context); if (!("action" in sub) && !("batchAction" in sub)) { log("skipping because no action provided"); return; } if (sub.name && context.params.skipTrigger && (context.params.skipTrigger === sub.name || Array.isArray(context.params.skipTrigger) && context.params.skipTrigger.includes(sub.name))) { log("skipping because of context.params.skipTrigger"); return; } if (sub.data !== void 0 && !await testCondition({ condition: sub.data, item: context.data, context })) { log("skipping because of data mismatch"); return; } if (sub.params !== void 0 && !await testCondition({ condition: sub.params, item: context.params, context })) { log("skipping because of params mismatch"); return; } result.push(sub); }) ); subs = result; } if (!subs?.length) { if (debug) { console.log( "[FEATHERS_TRIGGER DEBUG]", context.path, context.method, "skipping because no subscriptions left" ); } return context; } for (const sub of subs) { const log = makeDebug(sub, context); sub.paramsResolved = await getOrFindByIdParams(context, { params: sub.manipulateParams, deleteParams: ["trigger"], type: "before", skipHooks: false }) ?? {}; sub.identifier = JSON.stringify(sub.paramsResolved.query || {}); if (context.params.changesById?.[sub.identifier]?.itemsBefore) { continue; } log("fetching before with 'changesByIdBefore'"); const before = await changesByIdBefore(context, { skipHooks: false, params: () => sub.paramsResolved ? sub.paramsResolved : null, deleteParams: ["trigger"], fetchBefore: sub.fetchBefore || !!sub.before }); _set( context, ["params", "changesById", sub.identifier, "itemsBefore"], before ); } setConfig(context, subs); return context; }; const triggerAfter = async (context) => { const subs = getConfig(context); if (!subs?.length) { return context; } const promises = []; for (const sub of subs) { const log = makeDebug(sub, context); if (sub.name && context.params.skipTrigger && (context.params.skipTrigger === sub.name || Array.isArray(context.params.skipTrigger) && context.params.skipTrigger.includes(sub.name))) { log("skipping because of context.params.skipTrigger"); return context; } const itemsBefore = sub.identifier ? context.params.changesById?.[sub.identifier]?.itemsBefore : void 0; let changesById; if (sub.identifier && itemsBefore) { log("fetching after with 'changesByIdAfter'"); changesById = await changesByIdAfter(context, itemsBefore, null, { name: ["changesById", sub.identifier], params: sub.manipulateParams, skipHooks: false, deleteParams: ["trigger"], fetchBefore: sub.fetchBefore }); _set(context, ["params", "changesById", sub.identifier], changesById); } changesById = sub.identifier ? context.params.changesById?.[sub.identifier] : void 0; if (!changesById) { log("no changesById"); continue; } const changes = Object.values(changesById); const batchActionArguments = []; for (const change of changes) { const { before } = change; const { item } = change; const changeForSub = change; if (sub.result !== void 0 && !await testCondition({ item, before, withBefore: true, condition: sub.result, context })) { log("skipping because of result mismatch"); continue; } if (sub.before !== void 0 && !await testCondition({ item, before, testItem: "before", condition: sub.before, context })) { log("skipping because of before mismatch", before); continue; } if (isSubscriptionInBatchMode(sub)) { log("adding to batchActionArguments"); batchActionArguments.push([ changeForSub, { subscription: sub, items: changes, context } ]); } else if (isSubscriptionNormalMode(sub)) { const _action = sub.action; log("running action"); const promise = _action(changeForSub, { subscription: sub, items: changes, context }); if (sub.isBlocking) { promises.push(promise); } } } if (isSubscriptionInBatchMode(sub) && batchActionArguments.length) { log("running batch action"); const promise = sub.batchAction(batchActionArguments, context); if (sub.isBlocking) { promises.push(promise); } } } await Promise.all(promises); return context; }; const CONFIG_KEY = "subscriptions"; function setConfig(context, val) { context.params.trigger = context.params.trigger || {}; context.params.trigger[CONFIG_KEY] = val; } function getConfig(context) { return context.params.trigger?.[CONFIG_KEY]; } const getSubscriptions = async (context, options) => { const _subscriptionOrSubscriptions = typeof options === "function" ? await options(context) : options; if (!_subscriptionOrSubscriptions) { return; } const _subscriptions = Array.isArray(_subscriptionOrSubscriptions) ? _subscriptionOrSubscriptions : [_subscriptionOrSubscriptions]; const subscriptions = _subscriptions.map( (x) => ({ isBlocking: true, fetchBefore: false, ...x }) ); const { path, method } = context; return subscriptions.filter((sub) => { if (sub.service && (typeof sub.service === "string" && sub.service !== path || Array.isArray(sub.service) && !sub.service.includes(path))) { return false; } if (sub.method && (typeof sub.method === "string" && sub.method !== method || Array.isArray(sub.method) && !sub.method.includes(method))) { return false; } return true; }); }; const isSubscriptionInBatchMode = (sub) => "batchAction" in sub; const isSubscriptionNormalMode = (sub) => "action" in sub; const testCondition = async (options) => { if (options.condition === void 0) { return true; } const { item, before, context, testItem = "item" } = options; let condition; if (typeof options.condition === "function") { const data = options.withBefore ? { item, before } : item; condition = await options.condition(data, context); } else { condition = options.condition; } if (typeof condition === "boolean") { return condition; } const sifter = sift(condition); return sifter(options[testItem]); }; export { changesById, changesByIdAfter, changesByIdBefore, getOrFindByIdParams, trigger };