Going from Promises to Aff in Purescript

Nov 28, 2023

The other day, I was fooling around with an idea that has come up often in my chats with Jon Udell at work. We do not have a good test suite that can verify and flag errors in the hundreds / thousands of example queries listed on Steampipe Hub and – since a lot many weeks had passed since I wrote some program for fun – I decided to take a shot at this. I chose Purescript.

Very briefly, the idea is to:

Needless to point out, this involved running some CLI commands and reading files etc. That is to say, a bunch of Node.* modules in Purescript.

At first, I wrote the thing to run sequentially. This is slow but for development and quick prototyping, this was OK. This meant I could get away with Effect monads all the way through. Most notably, Node.ChildProcess's execSync was very handy to run commands and grab the results without having to fall into callback traps.

The first draft ran painfully slowly because it was running validation checks on some dozen queries in series/sequence and that took a handful of minutes. The validation and logging worked – great – but so many minutes to check just one plugin? That won't fly. I had to parallelize it now.

parTraverse is a go-to for parallelizing traverse (and the equivalent for sequence is parSequence) but the big problem I had at this point was that all my "effect-ful" functions were in the Effect monad... and that monad has no Parallel instance. (To the uninitiated, your monad needs a Parallel instance to be able to use functions like parTraverse on it).

This meant I had to convert all those Effect monads into Aff monads.

And that's where converting execSync to Aff took me on a goose-chase.

Obviously, the first thing I tried to ChatGPT and Google was "how to convert an Effect to Aff" and the only standard library function to do this is the seemingly-complicated makeAff function. I've yet to wrap my mind around that.

The other thing to try involved FFI with EffectFnAff. This seems straightforward at first and probably is to people who have successfully intuited it, but it mandates your foreign function to be in a particular shape.

In both the makeAff and EffectFnAff cases, I was able to get the code to compile (which is a great sign that your code works in Purescript) but the query validation continued to run in sequence instead of parallel.

Turns out JS's execSync, even when converted into a promise on the JS side and then imported into an Aff on Purescript via EffectFnAff will continue to block. (That or the way I wrote it was blocking).

Finally, I stumbled on aff-promise through this post and discovered a very simple way to convert Node's exec into a Purescript Aff:

On the JS side, you have this:

import { exec } from "child_process";

export const exec_ = (cmd) => {
  return () => {
    return new Promise((res, rej) => {
      exec(cmd, { encoding: "utf-8" }, (err, stdout, stderr) => {
        if (err || stderr) {
          rej(err || stderr);
        } else {
          res(stdout);
        }
      });
    });
  };
};

which is essentially exporting a function that returns a "thunk" which returns a Promise.

And on the Purescript side, where you import this "exec_" as an FFI, you have this:

import Control.Promise (Promise, toAffE)
import Data.Either (Either)
import Effect (Effect)
import Effect.Aff (Aff, try)

foreign import exec_ :: String -> Effect (Promise String)

execAff :: String -> Aff (Either Error String)
execAff = try <<< toAffE <<< exec_

which is essentially:

I must've spent as much time on finding a way to convert execSync into an Aff as I did writing the entire program, but c'est la vie.

I hope to move to the query extraction part – where you point the script to a folder and the program extracts all queries it finds in the markdown or config files – in the next iteration.