lifta-syntax

1.0.0 • Public • Published

lifta-syntax

There are a number of packages/repos related to liftA. This repository provides the fluent syntax that I believe improves clarity when constructing asynchronous arrows. To accomplish this, it adds a significant number of properties and functions to Function.prototype.

The "lifta" package of high-order functions (combinators) provides the underlying construction. Note that "lifta-syntax" itself requires the "lifta" package. If you are requiring lifta-syntax, there is no need to separately require lifta.

Examples of asynchronous arrow construction with lifta-syntax:

// fluent syntax
let lifta = require('lifta-syntax');
// dynamo asynchronous arrows
let dyna = require('lifta-dynamo')({
	"accessKeyId": "NOTHISISNOTREALLYANID",
	"secretAccessKey": "n0+7h15+15+n07+r3411y+4n+4cc355+k3y/K3Y",
	"region": "us-west-1"
});

// create dynamo query parameter for getting a user by id
// we are setting up the first of the tuple from information in the second
function setUserReqParams(x) {
  let second = x.second;
  return [{
    TableName: second.userTableName,
    Key: {
      id: {
        S: second.userid
      }
    }
  }, second];
}

// create an arrow that will get the user from the dynamo db
// note that dyna.getItemA acts on the first of the tuple only
// it only needs the query (first), not the context (second)
let getDynamoUser = setUserReqParams.then(dyna.getItemA.first);

// continue combining
// create an arrow to get user and read html in parallel and combine the outputs
let getUserAndHTML = getDynamoUser.fan(readHTML).then(combineUserAndHTML);

// run (p.cancelAll() allows for arrow 'in-flight'  cancellation)
let p = getUserAndHTML.run([undefined, {
  userTableName: "user-table",
  userid: "dave@daveco.co",
  htmlFile: "yourPage.html"
}]);

Some things to note about the code above:

Combining functions (like setUserReqParams) and arrows (like dyna.getItemA) into arrows is a process of construction. Arrows don't run until you tell them to run. We can easily combine into rather complex parallelized, branching, and repeating structures. Clarity is gained when easily understood arrows are combined together.

We start running an arrow with a tuple. A general practice is to use the second of the tuple as contextual information for the arrow and let the context flow through the computation, typically adding to the context, sometimes modifying it. See lifta-thumbnail for examples from a working web service.

In the descriptions below, b represents an arrow. The properties and functions of b construct a new arrow (such that b.first is a new arrow, it is not b). 'a' and 'c' represent arrows that are passed as parameters to functions such as b.then(a). 't#' represents a tuple. For example t1.first is the [0] element of the tuple. t1.second is the [1] (wunth?)

General Combinators

  • .then(a) - b.then(a) run b(t1) to produce t2, then run a(t2) to produce t3.

  • .first - b.first constructs an arrow from b to operate on only t1.first (t1.second is preserved), the new arrow produces t2[b(t1.first), t1.second]. Note that this implies that b in this case does not take in or produce a tuple! Please see "Function of x, Returning x" below.

  • .second - b.second constructs an arrow from b to operate on only t1.second (t1.first is preserved), new arrow produces t2[t1.first, b(t1.second)]

  • .product(a) - b.product(a) in parallel, run b on t1.first and a on t1.second, produces t2[b(t1.first), a(t1.second)].

  • .fan(a) - b.fan(a) in parallel, run b on t1 and a on t1, producing t2[b(t1), a(t1)], which is a tuple of tuples. Generally, when fanning, you will want to reduce the results of the fanned arrows to create a new tuple, in most cases preserving t1.second. For example b.fan(a).then(c) where your reducer c produces t3[<reduce t2.first.first and t2.second.first>, t2.first.second]

  • .either(a) - b.either(a) similar to fan in that it runs b on t1 and a on t1, but when the first of the arrows completes, the other is cancelled. Produces either t2[b(t1).first, b(t1).second] or t2[a(t1).first, a(t1).second]. Because only one arrow completes, I don't imagine that you want to reduce.

  • .repeat - b.repeat repeat the arrow b as long as b produces lifta.Repeat(tn). Continue without repeating when lifta.Done(tn) is produced. Loop forever with b.then(lifta.justRepeatA).repeat. If you want your loop to finish, your arrow must produce lifta.Done(tn), which will cause repeating to end and will proceed with tn.

  • .lor - b.lor(a, c) lor is short for "left or right", which provides branching. if b(t1) produces lifta.Left(t2), then run a on t2. If b produces lifta.Right(t2), then instead run c on t2. To branch for error handling, you can simply have your arrow produce an Error and use the .leftError property. Suppose b might produce an Error: b.leftError.lor(errorHandlerA, normalResultA). The Error here will still be produced by this arrow after error handling. When constructing it is typical to cope with Error flowing to downstream arrows by using the .barrier property, which prevents arrows from executing if t is an Error. So if there is more to do after normalResultA, use a barrier: b.leftError.lor(errorHandlerA, normalResultA).then(moreToDoA.barrier)

  • .leftError - b.leftError if b(t1) produces t2 that either is an Error or contains an error (t2.first is Error or t2.second is Error), then produce lifta.Left(t2)

  • .barrier - b.barrier if initial input t1 is or contains an Error, then do not execute b, simply produce t1

Run

  • .run - b.run(t) run an arrow with initial tuple t[first, second]. This is a convenience method. You can run an arrow by simply calling it with the correct parameters: a(t, cont, p). t is the initial tuple. cont is a function (t, p) which will receive the produced tuple and the canceller. A simple do-nothing continuation is () => {}. p is required and is created with lifta.P().

Boolean Combinators

  • .and(a) - b.and(a) fan b and a over t1 and reduce with logical and. proceed with t2[b(t1).first && a(t1).first, b(t1).second]

  • .or(a) - b.or(a) fan b and a over t1 and reduce with logical or. proceed with t2[b(t1).first || a(t1).first, b(t1).second]

  • .not - b.not logical not of b(t1).first, while b(t1).second is preserved. proceed with t2[!b(t1).first, b(t1).second]

  • .true(a) - b.true(a) run b(t1) and produce t2. if t2.first === true, run a(t2), producing t3. if t2.first !== true, produce t2.

  • .false(a) - b.false(a) run b(t1) and produce t2. if t2.first === false, run a(t2), producing t3. if t2.first !== false, produce t2.

  • .falseError - b.falseError if b(t1) produces t2.first === false then produce [Error, t2.second]

Functions of x Returning x

These properties and functions have been added to Function.prototype so that they can be used to automatically "lift" functions into arrows. For arrows that are not asynchronous, you can simply write functions that take tuples and return tuples, or that take a value and return a value, and compose them along with asynchronous arrows such as those found in lifta-dynamodb or lifta-s3.

This makes your functions very simple and very testable.

We saw this "lift" in the source code above when we take the setUserReqParams(x) function and simply combine it with .then(a). Here are the bits from that example:

// create dynamo query parameter for getting a user by id
// we are setting up the first of the tuple from information in the second
function setUserReqParams(x) {
  let second = x.second;
  return [{
    TableName: second.userTableName,
    Key: {
      id: {
        S: second.userid
      }
    }
  }, second];
}

let getDynamoUser = setUserReqParams.then(dyna.getItemA.first);

We call setUserReqParams() above a "tuple-aware" function because it "knows about" the tuple nature of x (it uses x.second and when returning, creates x.first). Notice that it is a "function of x returning x", because it receives a value (in this case a tuple) and returns a value (also a tuple). Also note that it maintains the context - the second of the tuple. These are not difficult to write, and they are easy to test, but life could be simpler.

We can easily imagine another scenario where perhaps the first of the tuple contains all the information that setUserReqParams needs. In this case we can write a "function of x returning x" that operates on a single value, not a tuple, and returns a single value, not a tuple. And we can use the .first combinator along with this simpler function.

// a simple setUserReqParams
// incoming x is a simple data object containing the data we need to set up the query
// we don't need to know anything about tuples
// we produce the query object and return it
function setUserReqParams(x) {
  // return the query object
  return {
    TableName: x.userTableName,
    Key: {
      id: {
        S: x.userid
      }
    }
  };
}

// notice that 'first' is applied to the complete arrow
// so setUserReqParams only receives t.first
let getDynamoUser = setUserReqParams.then(dyna.getItemA).first;

getDynamoUser.run([{
  userTableName: "user-table",
  userid: "dave@daveco.co"
}, { context: "all the other context" }]);

The properties and functions added to Function.prototype recognize when a function has an arity of 1 (has one parameter). When you use a combinator, a special property is added to your function that is a "lifted" arrow version of your function. It has the arrow signature. The combinators use this lifted version. If you use the combinators on one-parameter functions that take a tuple but that do not return a tuple, things will not work well. If you use the combinators on one-parameter functions that do not return anything, you will get undefined, which probably will not work well. Please write code carefully and unit test your functions.

Writing functions of x returning x that deal with values, not tuples, lets you write and test much simpler code that can combine into powerful, complex arrows. But it is typical to need to move data from context (t.second) to data (t.first) - and vice versa - at various points. You need to understand both tuple-aware and single-value forms.

Writing Your Own Arrows

When you need to write an asynchronous arrow, you simply use the three-parameter signature of an arrow: a(t, cont, p). When your asynchronous work completes, you call cont(t2, p). If you follow this pattern, and make your arrow cancellable, all of the combinators will work.

It is not overly complicated to convert Node.js "errorback" methods to arrows, and there is significant benefit to using combinators with converted "errorbacks": callback hell disappears. Here is a converted fs.readFile.

  • We've created a new arrow readFileA
  • Note the arrow signature (x, cont, p).
  • Note that a simple canceller is added to p
  • Look at the provided errorback (err, data) passed to fs.readFile.
  • "cont" is what "continues" moving data through the arrow.
  • When the callback completes we check for cancelled and do nothing if cancelled.
  • If the error back produces an Error, we simply continue with it (we have the tools, like .leftError and .lor to handle errors strategically when we combine arrows)
  • Otherwise we continue with the data.
let readFileA = (x, cont, p) => {
  let cancelled = false;
  let cancelId;
  fs.readFile(x, (err, data) => {
    // if not cancelled, advance and continue
    if (!cancelled) {
      p.advance(cancelId);
      if (err) {
        err.x = x;
        return cont(err, p);
      } else {
        return cont(data, p);
      }
    }
  });
  cancelId = p.add(() => cancelled = true);
  return p;
};

For many libraries, it is possible to write some simple transformation functions to convert the entire API to an arrow form. For example here is a function "dynamoErrorBack" that converts many of the dynamoDB API calls into arrows. The method parameter is the name of the api call such as "batchGetItem". The "dynamo" variable is a configured dynamo instance. This can be found in lifta-dynamodb.

  • dynamoErrorBack returns a function with the arrow signature (x, cont, p) for the specified method
  • The arrow uses x as the request object and calls dynamo[method] (so use .first when combining)
  • The SDK's req.abort feature is easily incorporated into the canceller, so if cancelled, the request will abort and the callback is never called.
  • The callback will continue the arrow with an Error or with data (if req not aborted)
let cb = (x, cont, p, advance, err, data) => {
  if (err) {
    err.x = x;
    x = err;
  } else {
    x = data;
  }
  advance();
  cont(x, p);
};

function dynamoErrorBack(method) {
  return function (x, cont, p) {
    let cancelId;
    let advance = () => p.advance(cancelId);
    let req = dynamo[method](x, cb.bind(undefined, x, cont, p, advance));
    cancelId = p.add(() => req.abort());
    return cancelId;
  };
}

Your own arrows will have a signature of (x, cont, p). The combinators added to Function.prototype recognize when a function has an arity of 3 and assume the function is already an arrow and does not need to be transformed. If you use the combinators with a 3-parameter function that is not an arrow, you will get surprising results. So please don't do it.

TL; DR

Did you scroll to the bottom? Regardless, thank you.

lifta-thumbnail has significant examples of using "functions of x returning x", home-brewed arrows (one for exec'ing phantomjs), and dynamodb usage.

Package Sidebar

Install

npm i lifta-syntax

Weekly Downloads

0

Version

1.0.0

License

MIT

Unpacked Size

41.3 kB

Total Files

8

Last publish

Collaborators

  • demolish