feathers-trigger
Version:
Add triggers and actions to your feathers app.
503 lines (499 loc) • 15.1 kB
JavaScript
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 };