@joystick.js/node

Actions

How to organize multi-step processes into fault-tolerant actions with Joystick.

Actions are a unique feature of Joystick, designed to help you organize complex, multi-step code into a single, fault-tolerant function. For example, when a new user signs up for your app, the "action" being taken might be called signup() which consists of a few discrete "steps:"

  1. Create a user account.
  2. Create a customer account in your payment system.
  3. Seed the database with some example data.
  4. Fire off a welcome email.
  5. Track the signup in your analytics.

While we can certainly write code that runs each of these steps independently, it can be helpful to call them together, with the end result of all of the steps being completed successfully being identified as a successful "signup."

Conversely, if any of those steps fail, we can say that the "signup" failed. We may have gotten a user account and some seed data, but the payments API was down and so now there's no payment data connected to the user.

When this happens, ideally, we can treat the entire action as a failure and alert the user, suggesting they try again.

This is where Joystick's built-in action library comes in to play.

Example Usage

An action works by creating a function composed of multiple steps that can be called together. Inside of the action, steps can reference each other's inputs and outputs, streamlining the process of completing some "action." This is helpful not only for maintenance purposes and system stability, but also for things like testing your app.

In addition to being able to call steps together in sequence, an action can also include input validation (to confirm all of the data you need to complete the action is present with the correct data types) as well as the option to "abort" the action if any one of the steps fails.

Consider the following example action signup():

/api/signup.js

import joystick, { accounts, email, origin } from "@joystick.js/node";
import Stripe from 'stripe';
import analytics from 'analytics-provider';
import seed_new_user_data from '../lib/seed_new_user_data.js';

const stripe = Stripe(joystick?.settings?.private?.stripe?.secret_key);

const signup = joystick.action(
  "signup",
  {
    input: {
      email_address: {
        type: 'string',
        required: true,
      },
      password: {
        type: 'string',
        required: true,
      },
      name: {
        type: 'string',
        required: true,
      },
    },
    steps: {
      create_user: {
        run: (email_address = '', password = '', name = '') => {
          return accounts.signup({
            email_address,
            password,
            metadata: {
              name,
            }
          });
        },
        on_error: (exception, action) => {
          action.abort(exception.message);
        },
      },
      create_customer_on_stripe: {
        run: (name = '', email_address = '', user_id = '') => {
          return stripe.customers.create({
            name,
            email_address,
            metadata: {
              user_id,
            }
          });
        },
        on_error: (exception, action) => {
          action.abort(exception.message);
        },
      },
      add_customer_to_user: {
        run: (user_id = '', stripe_customer_id = '') => {
          return process.databases.mongodb.collection('users').updateOne(
            { _id: user_id },
            {
              $set: {
                stripe_customer_id,
              },
            }
          );
        },
        on_error: (exception, action) => {
          action.abort(exception.message);
        },
      },
      seed_example_data: {
        run: (user_id = '') => {
          seed_new_user_data(user_id);
        },
        on_error: (exception, action) => {
          action.abort(exception.message);
        },
      },
      send_welcome_email: {
        run: (email_address = '', name = '') => {
          return email.send({
            to: email_address,
            from: 'customers@app.com',
            subject: 'Welcome!',
            template: 'welcome',
            props: {
              name,
              onboarding_url: `${origin}/setup`,
            },
          });
        },
      },
      track_signup: {
        run: (user_id = '') => {
          return analytics.track({ event: 'signup', user_id });
        },
      },
    },
    run: async (input = {}, steps = {}, action = {}) => {
      const user = await steps.create_user(input?.email_address, input?.password, input?.name);
      const customer_on_stripe = await steps.create_customer_on_stripe(input?.name, input?.email_address);

      await steps.add_customer_to_user(user?._id, customer_on_stripe?.id);
      await steps.seed_example_data(user?._id);
      await steps.send_welcome_email(input?.email_address, input?.name);

      return user?._id;
    },
  },
  {
    log_errors: true,
  }
);

export default signup;

Calling an action

If we look above, we've exported the return value of calling action() above. What we expect to get in return is a function that we can call, passing the expected input (relative to the input schema we defined on the action):

/api/users/setters.js

import joystick from '@joystick.js/node';
import signup from '../signup.js';

const setters = {
  create_user: {
    input: {
      email_address: {
        type: 'string',
        required: true,
      },
      password: {
        type: 'string',
        required: true,
      },
      name: {
        type: 'string',
        required: true,
      },
    },
  },
  set: (input = {}) => {
    return signup(input);
  },
};

export default setters;

API Reference

action()

Function API

action(action_name: string, action_definition: object, action_options: object) => function;

Parameters

action_name string required
The name for the action. Used internally by the action when reporting errors.
action_definition object required
The definition for the action.
input object
An object defining an input validation schema to validate the input before calling the action's run() function.
steps object required
An object containing individual steps as objects with a run() function and optional on_error()/on_success() methods.
[step_name] object required
An object defining a single step.
run function required
The function for this step. Receives arguments passed from the action's run() function.
on_error function
A function called if the step throws an error. Receives the error and the action instance.
on_success function
A function called if the step succeeds. Receives the return value from the step and the action instance.
run function required
The main function for the action, responsible for calling steps in sequence.
action_options object
Additional options for configuring the behavior of the action.
log_errors boolean
Set whether or not the action should log errors to the server console. Defaults to false.