Home Blog CV Projects Patterns Notes Book Colophon Search

Capabilities

16 Jan, 2017

The key idea here is that you have things called actions that make up part of your platform. Each action is a single function that does something useful. An action always returns a promise that resolves to something JSON serialisable object - it can't return a promise that contains a structure with other promises or functions, just JSON-serialisable data.

Actions require a set of capabilities in order to do their work. All the capabilities they need are given to them explicitly as function in an object as their first argument. Actions don't call other non-pure functions directly or import modules. What they need to get their work done must be in the capabilities object they receive.

Any arguments an action receives are given as a plain, JSON serialisable object passed as the second argument, even if it is just {}.

A minimal example of a action might look like this:

function action(caps, args) {
    return Promise.resolve("Hello, world!");
}

// Used like this
action({}, {});

A more sophisticated action that takes an argument might look like this:

function action(caps, args) {
    const {msg} = args;
    return Promise.resolve(msg);
}

// Used like this
action({}, {msg: "Hello, world!"});

If the action requires capabilities, to get its input such as to read a file from somewhere, or log a message you need to give it a caps argument before args:

function action(caps, args) {
    const {log, readFile} = caps;
    const {filename} = args;
    log(`Returning ${filename}`);
    return readFile(filename);
}


// Used like this
const fs = require("fs");

action(
    {
        log(...args) {
            console.log(...args);
        },
        readFile(filename) {
            return new Promise((resolve, reject) => {
                fs.readFile(filename, (err, args) => {
                    if (err) {
                        reject(err);
                    } else {
                        resolve(args);
                    }
                }) 
            });
        }
    },
    {
        filename: "hello.txt",
    }
);

With this set up:

In reality, it might be convenient to create some helper functions that allow you to provide different capabilities to an action at different times. For example, when code is first executed you might set up any capabilities that provide configuration from the environment. Once a server is started you might set up capabilities that won't change such as ones that provide the port or read from the filesystem. On each request you might set up capabilities that are specific to a particular user, such as accessing their data in a database.

I call helpers that facilitate this build-up of capabilities builders since they build the capabilities an action has access too over time.

Here's an example with just one level:

// The action
function say(caps, args) {
    const {log} = caps;
    const {msg} = args;
    log(msg);
    return Promise.resolve(msg);
}

// The buillder
function actionWithLogger(targetAction, caps) {
    const {log} = caps;
    return (caps, args) => {
        if (!(log in caps)) {
            caps.log = log;
        }
        return targetAction(caps, args);
    }
}

// The built action
let action = actionWithLogger(say, {log: (...args) => {console.log("LOG:", ...args)}})

// Has access to a `log()` capability even though it isn't explicitly passed in:
action({}, {msg: "Hello, world!"}).then((msg) => {
    console.log("Result:", msg);
});

This gives:

LOG: Hello, world!
Result: Hello, world!

Copyright James Gardner 1996-2020 All Rights Reserved. Admin.