Repository: vigetlabs/microcosm
Pull request #324 (2017-05-17 11:13:51)

Adds repo.all for parallel actions. Sugar in yield action format.
This commit adds the ability to batch together a group of actions under a parent "group" action. If all of the actions complete or cancel, the group resolves. Otherwise the action is categorically rejected.

This surfaces itself through a new Microcosm method: `repo.all([...actions])`: 

```javascript
let group = repo.all([
  repo.push(actionOne),
  repo.push(actionTwo)
])

group.onDone(function () {
  console.log('hurrah!')
})
```

Additionally, generator actions can yield an array:

```javascript
function fetchUsers(usersList) {
  return function*(repo) {
    let requests = usersList.map(repo.prepare(fetchUser))

    yield requests
  }
}
```

**What kind of change does this PR introduce?** 

- [x] Feature

**Does this PR introduce a breaking change?** (check one)

- [x] No

**Related issues:**

- https://github.com/vigetlabs/microcosm/issues/229


codecov-io: # [Codecov](https://codecov.io/gh/vigetlabs/microcosm/pull/324?src=pr&el=h1) Report
> Merging [#324](https://codecov.io/gh/vigetlabs/microcosm/pull/324?src=pr&el=desc) into [master](https://codecov.io/gh/vigetlabs/microcosm/commit/2406b1f5b9aed5ec0869436b5b04507bc903d6b9?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/vigetlabs/microcosm/pull/324/graphs/tree.svg?width=650&height=150&token=AaE0FLz9sU&src=pr)](https://codecov.io/gh/vigetlabs/microcosm/pull/324?src=pr&el=tree)

```diff
@@          Coverage Diff          @@
##           master   #324   +/-   ##
=====================================
  Coverage     100%   100%           
=====================================
  Files          23     23           
  Lines         787    800   +13     
=====================================
+ Hits          787    800   +13
```


| [Impacted Files](https://codecov.io/gh/vigetlabs/microcosm/pull/324?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/coroutine.js](https://codecov.io/gh/vigetlabs/microcosm/pull/324?src=pr&el=tree#diff-c3JjL2Nvcm91dGluZS5qcw==) | `100% <100%> (ø)` | :arrow_up: |
| [src/action.js](https://codecov.io/gh/vigetlabs/microcosm/pull/324?src=pr&el=tree#diff-c3JjL2FjdGlvbi5qcw==) | `100% <100%> (ø)` | :arrow_up: |
| [src/microcosm.js](https://codecov.io/gh/vigetlabs/microcosm/pull/324?src=pr&el=tree#diff-c3JjL21pY3JvY29zbS5qcw==) | `100% <100%> (ø)` | :arrow_up: |
| [src/default-update-strategy.js](https://codecov.io/gh/vigetlabs/microcosm/pull/324?src=pr&el=tree#diff-c3JjL2RlZmF1bHQtdXBkYXRlLXN0cmF0ZWd5Lmpz) | `100% <0%> (ø)` | :arrow_up: |
| [src/key-path.js](https://codecov.io/gh/vigetlabs/microcosm/pull/324?src=pr&el=tree#diff-c3JjL2tleS1wYXRoLmpz) | `100% <0%> (ø)` | :arrow_up: |
| [src/addons/indexing.js](https://codecov.io/gh/vigetlabs/microcosm/pull/324?src=pr&el=tree#diff-c3JjL2FkZG9ucy9pbmRleGluZy5qcw==) | `100% <0%> (ø)` | :arrow_up: |
| [src/addons/presenter.js](https://codecov.io/gh/vigetlabs/microcosm/pull/324?src=pr&el=tree#diff-c3JjL2FkZG9ucy9wcmVzZW50ZXIuanM=) | `100% <0%> (ø)` | :arrow_up: |
| ... and [1 more](https://codecov.io/gh/vigetlabs/microcosm/pull/324?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/vigetlabs/microcosm/pull/324?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/vigetlabs/microcosm/pull/324?src=pr&el=footer). Last update [2406b1f...9e34c8f](https://codecov.io/gh/vigetlabs/microcosm/pull/324?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

efatsi: `repo.all`, would `repo.sequence` be more clear? also opens up paradigm for `repo.parallel`?
efatsi: i only have renaming suggestions, code looks really good, quite impressive how small the footprint of this PR is
nhunzaker: @efatsi `repo.all` is inspired by `Promise.all`, which sort of does the same thing only for Promises. This behavior is essentially the same as a `repo.parallel`. The actions fire off at the same time and `repo.all` waits for them all to complete.

I was thinking about adding `repo.sequence`, but we have the `yield` syntax for generators.
efatsi: @nhunzaker ah, thanks for the clarification. as an outsider i would prefer to see terminology like `parallel` and `sequence` (highlighted by the fact that i misunderstood `all`'s functionality out of the gate), as they more accurately describe their functionality, but if this is a strong convention then I won't stand in it's way.

And yes while we have the `yield` generator syntax for sequential actions, I think it would be nice if there was a higher level syntax sugar for running sequential actions, and then you could dip down into the `yield` syntax if you needed more complex functionality (referencing previous actions in later ones).

thoughts?
nhunzaker: Totally. I think that's a great idea.
nhunzaker: @efatsi Thanks for the update! I really like `repo.parallel` a lot. Any outstanding concerns with merging?
efatsi: shipit!

Patch:
diff --git a/docs/api/actions.md b/docs/api/actions.md
index e143d382..a3f84e4d 100644
--- a/docs/api/actions.md
+++ b/docs/api/actions.md
@@ -163,6 +163,26 @@ is rejected or cancelled with the same payload.
 When all steps of the generator complete, the payload of the parent
 action will be the resolved payload of the final action.
 
+#### Yielding Actions in Parallel
+
+By yielding an array of actions, you can wait for multiple actions to
+complete before continuing:
+
+```javascript
+function getUser (id) {
+  return fetch(`/users/${id}`)
+}
+
+function getUsers (ids) {
+  return function * (repo) {
+    yield ids.map(id => repo.push(getUser, id))
+  }
+}
+```
+
+If all actions resolve or cancel, the generator sequence
+continues.
+
 ### Action status methods are auto-bound
 
 Action status methods like `action.resolve()` and `action.reject()`
diff --git a/docs/api/microcosm.md b/docs/api/microcosm.md
index 7d7be8a4..b0b4a0b6 100644
--- a/docs/api/microcosm.md
+++ b/docs/api/microcosm.md
@@ -329,6 +329,23 @@ fork.addDomain('page', PaginatedPeople)
 fork.push(getPeople)
 ```
 
+### `parallel([...actions])`
+
+Create a new "group" action bound to the resolution of a list of
+actions. If all actions resolve or cancel, the group action will
+resolve. If any action is rejected, the group action fails:
+
+```javascript
+let group = repo.parallel([
+  repo.push(actionOne),
+  repo.push(actionTwo)
+])
+
+group.onDone(function () {
+  console.log('hurrah!')
+})
+```
+
 ### `Microcosm.defaults`
 
 Specifies default options a Microcosm is instantiated with. This
diff --git a/src/action.js b/src/action.js
index 39930518..fd319192 100644
--- a/src/action.js
+++ b/src/action.js
@@ -154,6 +154,32 @@ class Action extends Emitter {
     return this
   }
 
+  /**
+   * Set up an action such that it depends on the result of another
+   * series of actions.
+   * @param {Array.<Action>} actions
+   * @return {this}
+   */
+  link(actions) {
+    let outstanding = actions.length
+
+    const onResolve = () => {
+      outstanding -= 1
+
+      if (outstanding <= 0) {
+        this.resolve()
+      }
+    }
+
+    actions.forEach(action => {
+      action.onDone(onResolve)
+      action.onCancel(onResolve)
+      action.onError(this.reject)
+    })
+
+    return this
+  }
+
   /**
    * @param {?Function} pass
    * @param {Function} [fail]
diff --git a/src/coroutine.js b/src/coroutine.js
index 7407c6f8..16363fe5 100644
--- a/src/coroutine.js
+++ b/src/coroutine.js
@@ -22,6 +22,10 @@ function processGenerator(action, body, repo) {
   }
 
   function progress(subAction) {
+    if (Array.isArray(subAction)) {
+      subAction = repo.parallel(subAction)
+    }
+
     console.assert(
       subAction instanceof Action,
       `Iteration of generator expected an Action. Instead got ${subAction}`
diff --git a/src/microcosm.js b/src/microcosm.js
index ff353fdd..8c8ccf60 100644
--- a/src/microcosm.js
+++ b/src/microcosm.js
@@ -368,6 +368,10 @@ class Microcosm extends Emitter {
     // Remove all listeners
     this.removeAllListeners()
   }
+
+  parallel(actions) {
+    return this.append('GROUP').link(actions)
+  }
 }
 
 export default Microcosm


Test patch:
diff --git a/test/unit/middleware/generator-middleware.test.js b/test/unit/middleware/generator-middleware.test.js
index 06a9e8f5..f456be5b 100644
--- a/test/unit/middleware/generator-middleware.test.js
+++ b/test/unit/middleware/generator-middleware.test.js
@@ -186,4 +186,53 @@ describe('Generator Middleware', function() {
 
     await repo.history.wait()
   })
+
+  it('waits for the return value to complete', function() {
+    expect.assertions(1)
+
+    let stepper = n => n + 1
+    let repo = new Microcosm()
+
+    function testReturnValue() {
+      return function*(repo) {
+        yield repo.push(stepper, 1)
+
+        return repo.push(stepper, 2)
+      }
+    }
+
+    repo.push(testReturnValue).onDone(result => {
+      expect(result).toEqual(2)
+    })
+  })
+
+  describe('when yielding an array', function() {
+    it('waits for all items to complete', function() {
+      expect.assertions(1)
+
+      let add = n => n
+      let repo = new Microcosm()
+
+      repo.addDomain('count', {
+        getInitialState() {
+          return 0
+        },
+        register() {
+          return {
+            [add]: (a, b) => a + b
+          }
+        }
+      })
+
+      function testReturnValue() {
+        return function*(repo) {
+          yield [repo.push(add, 1), repo.push(add, 1)]
+        }
+      }
+
+      repo.push(testReturnValue).onDone(() => {
+        expect(repo).toHaveState('count', 2)
+      })
+    })
+  })
 })