Buckets

Actions

How defineAction works, how independent actions behave differently from buckets, and what that means for errors and unmatched items.

ActionEngine is BucketEngine’s sibling for a different shape of problem: instead of sorting items into named groups, you want to run named effects — send an email, dispatch a job, charge a fee — on the items that qualify for each one.

import { ActionEngine } from "@michaelrwalker/buckets";

const dispatch = new ActionEngine()
  .defineInput<Order>()
  .defineAction({
    name: "notifyWarehouse",
    checkFn: (order) => order.status === "paid",
    actionFn: (order) => dispatchToWarehouse(order.id),
  })
  .defineAction({
    name: "flagForReview",
    checkFn: (order) => order.riskScore > 80,
    actionFn: (order) => queueForReview(order.id),
  });

const report = await dispatch.process(orders);
report.results.notifyWarehouse; // { item, result }[]

One step, not two

BucketEngine separates naming a condition (defineCondition) from declaring a rule over named conditions (defineBucket), because a bucket’s AND/OR/ONLY expression can reuse a condition across many buckets. Actions don’t share expressions with each other, so there’s nothing to name separately: defineAction takes the predicate and the effect together.

That also means there’s no ONLY here, and no ordering requirement between actions — each one is self-contained, so declaring flagForReview before or after notifyWarehouse makes no difference.

Actions are independent, like buckets

An order that is both paid and risky triggers both notifyWarehouse and flagForReview — nothing had to be enumerated to say so. This is the same independence buckets have: checkFns that overlap just mean both actions run.

Unlike buckets, one action’s failure doesn’t poison another

A bucket’s rule can reference several conditions in one expression, so if one of those conditions throws, the engine has no sound way to evaluate the rule — the whole item fails. An action’s checkFn and actionFn don’t feed into anyone else’s decision, so ActionEngine doesn’t need that caution:

const report = await new ActionEngine()
  .defineInput<Order>()
  .defineAction({
    name: "chargeFraudFee",
    checkFn: (order) => order.riskScore > 80,
    actionFn: (order) => chargeFee(order.id), // throws for order-3
  })
  .defineAction({
    name: "flagForReview",
    checkFn: (order) => order.riskScore > 80,
    actionFn: (order) => queueForReview(order.id),
  })
  .process(orders);

report.results.flagForReview; // still includes order-3
report.errors; // [{ item: order-3, stage: "action", action: "chargeFraudFee", ... }]

chargeFraudFee failing for order-3 is recorded in report.errors and never touches flagForReview, which still ran for that same order. This is also why processOne() doesn’t throw on a checkFn/actionFn failure the way BucketEngine.processOne() does: with actions, a single failure was never the whole story for that item.

What counts as unmatched

An item lands in report.unmatched only when every action’s checkFn ran to completion and none of them matched. An item that errored on one action is never also reported as unmatched, even if none of its other actions matched either — “no action fired” and “an action fired and threw” are different facts, and conflating them would hide the error.

Writing checkFn and actionFn

Both may be sync or async:

.defineAction({
  name: "chargeFraudFee",
  checkFn: (order) => order.riskScore > 80,
  actionFn: async (order) => chargeCard(order.paymentMethodId, order.total),
})

Write checkFn as a type predicate — (item): item is X => ... — to have actionFn receive the narrowed type instead of TInput, exactly as a condition narrows a bucket in BucketEngine. See the Type Narrowing guide; the mechanics are identical.

actionFn’s return value is not required to be meaningful — an action whose whole point is a side effect can return undefined — but when it does return something, that value ends up in report.results[name] alongside the item, which is useful for actions that fetch or compute something you want back.