I want a function to pipe together a series of promise returning functions. However, I also want to specify an undo function for each piped function. If any function returns a rejected promise, the undo function corresponding to that step should be called.
I'm free to let my undo function return a resolved promise, which indicates a recovery and continues down the pipe, or a rejected promise, which results in breaking the promise chain and skipping to the catch
statement (if any).
Based on this SO answer, this is the form of the solution if you know how many steps you have:
steps[1]
.then(
(...args) => steps[2](...args).then(null, undos[2]),
undos[1]
)
.then(
(...args) => steps[3](...args).then(null, undos[3]),
undos[2]
)
.then(
(...args) => steps[4](...args).then(null, undos[4]),
undos[3]
);
Seems like there's some redundancy in this though, I think it can be simplified as:
steps[1]
.then((...args) => steps[2](...args).then(null, undos[2]), undos[1])
.then((...args) => steps[3](...args).then(null, undos[3]))
.then((...args) => steps[4](...args).then(null, undos[4]));
or even (just for symmetry):
Promise.resolve()
.then((...args) => steps[1](...args).then(null, undos[1]))
.then((...args) => steps[2](...args).then(null, undos[2]))
.then((...args) => steps[3](...args).then(null, undos[3]))
.then((...args) => steps[4](...args).then(null, undos[4]));
I want a general solution that will allow me to pass an arbitrary number of functions in.
I think this can be generalised using the following function:
const head = (xs) => xs[0];
const tail = (xs) => xs.slice(1);
const pipePUndo = (steps, undos) => (...args) => {
const piper = (p, s, u) =>
s.length === 0
? p
: piper(
p.then((...vargs) => head(s)(...vargs).then(null, head(u))),
tail(s),
tail(u)
);
return piper(Promise.resolve(...args), steps, undos);
};
But this just runs a single undo function. What if I wanted a more "transaction"-like behaviour, where all undo functions for steps up and including the failing one are run (in reverse order)? Then I'd need something closer to this:
const { pipeP } = require('ramda');
/**
* @param {Object[]} steps
* @param {Function} steps[].do
* @param {Function} steps[].undo
* @returns {Function}
*/
const pipePUndo = (steps) => (...args) => {
const helper = (p, s, u) =>
s.length === 0
? p
: helper(
p.then((...vargs) => head(s).do(...vargs).then(null, pipeP(...[head(s).undo].concat(u)))),
tail(s),
[head(s).undo].concat(u)
);
return helper(Promise.resolve(...args), steps, [(...vargs) => Promise.reject(...vargs)]);
};
Turns out the above isn't quite right and is also over-complicated. The following actually works:
const { pipeP } = require('ramda');
// Used to force the Promise returned by pipePWithUndo reject if there have been any failures
// in the "do" tasks, even if all the "undo" tasks resolve successfully.
const abort = (fn) => (err) => fn(err).then(() => Promise.reject(err));
const pipePWithUndo = (steps) => {
const dos = steps.map((step) => step.do);
const undos = steps.map((step) => step.undo);
const undoChain = undos.map((_, i) => pipeP(...undos.slice(0, i + 1).reverse()));
return _piper(dos, [null, ...undoChain]);
}
const _piper = (dos, undos, promise = Promise.resolve()) => {
if (dos.length !== undos.length - 1) {
throw new Error('Undos array must have prepended "null" value');
}
if (dos.length === 1) {
return promise
.then((res) => dos[0](res).then(null, abort(undos[1])), abort(undos[0]));
}
return _piper(
dos.slice(1),
undos.slice(1),
promise.then(dos[0], undos[0])
)
}
The undo
functions should normally return resolving promises. If they return rejected promises, the chain will be broken. For example, if steps[3].do
rejects, and steps[1].undo
rejects, the order of execution will be:
steps[0].do
steps[1].do
steps[2].do
steps[3].do
steps[3].undo
steps[2].undo
steps[1].undo
This is kind of cool, but it seems like too much can still go wrong with this approach -- it shouldn't be relied on for anything serious.