Repository: vigetlabs/microcosm (js)

Issue:
Add a way to alias actions in Domains
I want a better way to alias actions with string names. This would be a breaking change because it would rework the way string actions are treated.

I would like to consider putting actions right next to domains. A domain module would contain both actions and handlers.

```javascript
export function createUser (params) {
  return axios.post('/users', params)
}

class UsersDomain {
  actions = { createUser }

  add(users, user) {
    return users.concat(user)
  }

  register() {
    return {
      createUser: this.add
    }
  }
}

let repo = new Microcosm()

repo.addDomain('users', UsersDomain)

repo.push('createUsers', { name: 'Bill' }) // adds a new user
repo.push('createUser', { name: 'Bill' }) // Error: Unrecognized action "createUser". Did you mean "createUsers"?
```

When a domain is added it will:

1. Validate that no existing aliases exist for the domain
2. Tag the action with the associated key
3. If a string action is pushed and nothing responds, the action will reject with a standard error
4. If no code subscribes to "action.onError", Microcosm will send out a warning

### Things to figure out

1. Can we do this for Effects too?
2. What happens when two domains alias the same action?
3. If we declare actions ahead of time, Microcosm can know about them. This would let us make a "control box" in the debugger for firing off actions.

Pull request description:
Add the ability to alias actions
This change allows actions to be aliased to a given string. When the string name is not recognized, Microcosm raises an assertion error in strict mode.

In order to prevent this from being a breaking change, string actions that are pushed into a repo that are unrecognized through aliasing still work. **However I would like to eventually send out a minor release where this is deprecated, and throw an exception in the next major release**.

The benefit of this change is that we can use it to know about available actions and prevent the need to import actions in places like `ActionButtons`, something I know @mackermedia will love :). It also makes it so that you do not need to use computed keys for actions. Domain registration can actually happen without a function, like so:

```javascript
let repo = new Microcosm()

repo.addDomain('planets', {
  getInitialState() {
    return []
  },

  actions: { addPlanet: name => name },

  register: {
    addPlanet: (planets, params) => planets.concat(params)
  }
})
```

I need this for my GraphQL Microcosm work in order to know what Mutation types are valid, and to support some new debugger features. 

Fixes #405

Patch:
diff --git a/flow/command.js b/flow/command.js
index ee3157dd..2cbc1331 100644
--- a/flow/command.js
+++ b/flow/command.js
@@ -7,7 +7,7 @@
  * invoked, it processes a given command, returning an action to
  * represent the resolution of that command.
  */
-declare type Command = Function | string
+declare type Command = (...args: *[]) => *
 
 declare type Tagged = {
   name?: string,
diff --git a/flow/domain.js b/flow/domain.js
index df6f83ef..1e7c7c14 100644
--- a/flow/domain.js
+++ b/flow/domain.js
@@ -8,6 +8,8 @@
 import type Microcosm from '../src/microcosm'
 
 declare interface Domain {
+  actions: Object,
+
   /**
    * Generate the starting value for the particular state this domain is
    * managing. This will be called by the Microcosm using this domain when
diff --git a/flow/effect.js b/flow/effect.js
index c2547f73..5b2597ee 100644
--- a/flow/effect.js
+++ b/flow/effect.js
@@ -6,6 +6,8 @@ import type Microcosm from '../src/microcosm'
 import type Action from '../src/action'
 
 declare interface Effect {
+  actions: Object,
+
   /**
    * Setup runs right after an effect is added to a Microcosm. It
    * receives that repo and any options passed as the second argument.
diff --git a/src/action.js b/src/action.js
index 1dee76fe..58886e1c 100644
--- a/src/action.js
+++ b/src/action.js
@@ -22,7 +22,7 @@ const ResolutionMap = {
 
 class Action extends Emitter {
   id: string
-  command: Tagged
+  command: Function
   status: Status
   payload: any
   disabled: boolean
@@ -33,7 +33,7 @@ class Action extends Emitter {
   children: Action[]
   revisions: Revision[]
 
-  constructor(command: Command | Tagged, status: ?Status) {
+  constructor(command: string | Command, status: ?Status) {
     super()
 
     this.id = uid('action')
diff --git a/src/addons/presenter.js b/src/addons/presenter.js
index 01d8d4fb..0173703b 100644
--- a/src/addons/presenter.js
+++ b/src/addons/presenter.js
@@ -258,7 +258,7 @@ class PresenterMediator extends React.PureComponent<Props> {
     return this.presenter.defaultRender()
   }
 
-  send(intent: Command | Tagged, ...params: *[]): * {
+  send(intent: Command, ...params: *[]): * {
     let taggedIntent = tag(intent)
     let mediator = this
 
diff --git a/src/coroutine.js b/src/coroutine.js
index 174f46e0..525cde5e 100644
--- a/src/coroutine.js
+++ b/src/coroutine.js
@@ -59,10 +59,6 @@ export default function coroutine(
   params: *[],
   repo: any
 ) {
-  if (typeof command === 'string') {
-    return action.resolve(...params)
-  }
-
   let body = command.apply(null, params)
 
   /**
diff --git a/src/domain-engine.js b/src/domain-engine.js
index 87691eca..6a3b71c9 100644
--- a/src/domain-engine.js
+++ b/src/domain-engine.js
@@ -4,7 +4,7 @@
 
 import MetaDomain from './meta-domain'
 import getRegistration from './get-registration'
-import { get, set, merge, createOrClone } from './utils'
+import { get, set, merge, result, createOrClone } from './utils'
 import { castPath, type KeyPath } from './key-path'
 
 import type Action from './action'
@@ -43,7 +43,7 @@ class DomainEngine {
   getRepoHandlers(action: Action): Registrations {
     let { command, status } = action
 
-    let steps = getRegistration(this.repo.register(), command, status)
+    let steps = getRegistration(result(this.repo, 'register'), command, status)
 
     return steps.length ? [{ key: [], scope: this.repo, steps }] : []
   }
@@ -56,12 +56,10 @@ class DomainEngine {
     for (var i = 0; i < this.domains.length; i++) {
       var [key, scope] = this.domains[i]
 
-      if (scope.register) {
-        let steps = getRegistration(scope.register(), command, status)
+      let steps = getRegistration(result(scope, 'register'), command, status)
 
-        if (steps.length) {
-          handlers.push({ key, scope, steps })
-        }
+      if (steps.length) {
+        handlers.push({ key, scope, steps })
       }
     }
 
diff --git a/src/effect-engine.js b/src/effect-engine.js
index 4edad438..90af8831 100644
--- a/src/effect-engine.js
+++ b/src/effect-engine.js
@@ -3,7 +3,7 @@
  */
 
 import getRegistration from './get-registration'
-import { get, merge, createOrClone } from './utils'
+import { get, merge, result, createOrClone } from './utils'
 
 import type Action from './action'
 import type Microcosm from './microcosm'
@@ -44,14 +44,12 @@ class EffectEngine {
     let { command, payload, status } = action
 
     for (var i = 0; i < this.effects.length; i++) {
-      var effect = this.effects[i]
+      let effect = this.effects[i]
+      let registry = result(effect, 'register')
+      let handlers = getRegistration(registry, command, status)
 
-      if (effect.register) {
-        let handlers = getRegistration(effect.register(), command, status)
-
-        for (var j = 0; j < handlers.length; j++) {
-          handlers[j].call(effect, this.repo, payload)
-        }
+      for (var j = 0; j < handlers.length; j++) {
+        handlers[j].call(effect, this.repo, payload)
       }
     }
   }
diff --git a/src/get-registration.js b/src/get-registration.js
index 275fac67..a1ffb817 100644
--- a/src/get-registration.js
+++ b/src/get-registration.js
@@ -21,7 +21,7 @@ function isPlainObject(value) {
 /**
  * Gets any registrations that match a given command and status.
  */
-function getRegistration(pool: ?Object, command: Tagged, status: Status) {
+function getRegistration(pool: any, command: Tagged, status: Status) {
   let answer = []
 
   if (pool == null) {
diff --git a/src/history.js b/src/history.js
index 0b9281c8..22eeb80c 100644
--- a/src/history.js
+++ b/src/history.js
@@ -200,7 +200,7 @@ class History extends Emitter {
   /**
    * Append a new action to the end of history
    */
-  append(command: Command | Tagged, status?: ?Status): Action {
+  append(command: string | Command, status?: ?Status): Action {
     let action = new Action(command, status)
 
     if (this.head) {
diff --git a/src/microcosm.js b/src/microcosm.js
index 633934f7..f3724c1f 100644
--- a/src/microcosm.js
+++ b/src/microcosm.js
@@ -98,6 +98,7 @@ class Microcosm extends Emitter implements Domain {
   state: Object
   history: History
   snapshots: { [key: string]: Snapshot }
+  actions: Object
   domains: DomainEngine
   effects: EffectEngine
   changes: CompareTree
@@ -116,6 +117,7 @@ class Microcosm extends Emitter implements Domain {
 
     this.history = this.parent ? this.parent.history : new History(this.options)
 
+    this.actions = Object.create(this.parent ? this.parent.actions : null)
     this.snapshots = Object.create(this.parent ? this.parent.snapshots : null)
     this.domains = new DomainEngine(this)
     this.effects = new EffectEngine(this)
@@ -126,19 +128,19 @@ class Microcosm extends Emitter implements Domain {
     // save snapshots of new state:
 
     // When an action is first created
-    this.history.on('append', this.createSnapshot, this)
+    this.history.on('append', this._createSnapshot, this)
 
     // When an action snapshot needs updating
-    this.history.on('update', this.updateSnapshot, this)
+    this.history.on('update', this._updateSnapshot, this)
 
     // When an action snapshot should be removed
-    this.history.on('remove', this.removeSnapshot, this)
+    this.history.on('remove', this._removeSnapshot, this)
 
     // When an action changes, it causes a reconcilation
-    this.history.on('reconcile', this.dispatchEffect, this)
+    this.history.on('reconcile', this._dispatchEffect, this)
 
     // A history is done reconciling and is ready for a release
-    this.history.on('release', this.release, this)
+    this.history.on('release', this._release, this)
 
     // Microcosm is now ready. Call the setup lifecycle method
     this.setup(this.options)
@@ -198,82 +200,6 @@ class Microcosm extends Emitter implements Domain {
     return this.initial == null ? {} : this.initial
   }
 
-  recall(action: ?Action): Object {
-    if (action && action.id in this.snapshots) {
-      return this.snapshots[action.id].next
-    }
-
-    return this.getInitialState()
-  }
-
-  /**
-   * Get the state prior to a given action, but include any upstream
-   * updates from parent Microcosms.
-   */
-  rebase(action: Action): Object {
-    let state = this.recall(action.parent)
-
-    if (this.parent) {
-      state = merge(state, this.parent.recall(action))
-    }
-
-    return state
-  }
-
-  /**
-   * Create the initial state snapshot for an action. This is important so
-   * that, when rolling back to this action, it always has a state value.
-   */
-  createSnapshot(action: Action): Snapshot {
-    let snapshot: Snapshot = {
-      last: this.state,
-      next: this.state,
-      status: 'inactive',
-      payload: undefined
-    }
-
-    this.snapshots[action.id] = snapshot
-
-    return snapshot
-  }
-
-  /**
-   * Update the state snapshot for a given action
-   */
-  updateSnapshot(action: Action) {
-    let snap = this.snapshots[action.id]
-    let last = this.rebase(action)
-
-    if (!action.disabled) {
-      snap.next = this.domains.dispatch(action, last, snap)
-    } else {
-      snap.next = last
-    }
-
-    this.snapshots[action.id] = merge(snap, {
-      last,
-      status: action.status,
-      payload: action.payload
-    })
-
-    this.state = snap.next
-  }
-
-  /**
-   * Remove the snapshot for a given action
-   */
-  removeSnapshot(action: Action) {
-    delete this.snapshots[action.id]
-  }
-
-  dispatchEffect(action: Action) {
-    this.effects.dispatch(action)
-  }
-
-  release() {
-    this.changes.update(this.state)
-  }
-
   on(type: *, callback: *, scope?: Object) {
     let [event, meta] = type.split(':', 2)
 
@@ -319,7 +245,11 @@ class Microcosm extends Emitter implements Domain {
    * assert.falsy(repo.state.planets.pluto.loading)
    * ```
    */
-  append(command: Command | Tagged, status?: Status): Action {
+  append(command: *, status?: Status): Action {
+    if (command in this.actions) {
+      return this.history.append(this.actions[command], status)
+    }
+
     return this.history.append(command, status)
   }
 
@@ -329,10 +259,10 @@ class Microcosm extends Emitter implements Domain {
    * repo.push(createPlanet, { name: 'Merkur' })
    * ```
    */
-  push(command: Command, ...params: *): Action {
+  push(command: any, ...params: *): Action {
     let action = this.append(command)
 
-    coroutine(action, command, params, this)
+    coroutine(action, action.command, params, this)
 
     console.assert(
       this.active,
@@ -367,6 +297,8 @@ class Microcosm extends Emitter implements Domain {
 
     this.initial = set(this.initial, key, initial)
 
+    this._alias(domain.actions)
+
     this.push(ADD_DOMAIN, domain)
 
     return domain
@@ -379,7 +311,11 @@ class Microcosm extends Emitter implements Domain {
    * options and associated repo.
    */
   addEffect(config: *, options?: Object) {
-    return this.effects.add(config, options)
+    let effect = this.effects.add(config, options)
+
+    this._alias(effect.actions)
+
+    return effect
   }
 
   /**
@@ -487,6 +423,80 @@ class Microcosm extends Emitter implements Domain {
   parallel(actions: Action[]) {
     return this.append('GROUP').link(actions)
   }
+
+  /* Private ------------------------------------------------------ */
+
+  _dispatchEffect(action: Action) {
+    this.effects.dispatch(action)
+  }
+
+  _release() {
+    this.changes.update(this.state)
+  }
+
+  _createSnapshot(action: Action): Snapshot {
+    let snapshot: Snapshot = {
+      last: this.state,
+      next: this.state,
+      status: 'inactive',
+      payload: undefined
+    }
+
+    this.snapshots[action.id] = snapshot
+
+    return snapshot
+  }
+
+  _updateSnapshot(action: Action) {
+    let snap = this.snapshots[action.id]
+    let last = this._rebase(action)
+
+    if (!action.disabled) {
+      snap.next = this.domains.dispatch(action, last, snap)
+    } else {
+      snap.next = last
+    }
+
+    this.snapshots[action.id] = merge(snap, {
+      last,
+      status: action.status,
+      payload: action.payload
+    })
+
+    this.state = snap.next
+  }
+
+  _removeSnapshot(action: Action) {
+    delete this.snapshots[action.id]
+  }
+
+  _recall(action: ?Action): Object {
+    if (action && action.id in this.snapshots) {
+      return this.snapshots[action.id].next
+    }
+
+    return this.getInitialState()
+  }
+
+  /**
+   * Get the state prior to a given action, but include any upstream
+   * updates from parent Microcosms.
+   */
+  _rebase(action: Action): Object {
+    let state = this._recall(action.parent)
+
+    if (this.parent) {
+      state = merge(state, this.parent._recall(action))
+    }
+
+    return state
+  }
+
+  _alias(actions: ?Object) {
+    for (var name in actions) {
+      this.actions[name] = tag(actions[name], name)
+    }
+  }
 }
 
 Microcosm.version = version
diff --git a/src/tag.js b/src/tag.js
index 74ce59db..e92331ab 100644
--- a/src/tag.js
+++ b/src/tag.js
@@ -8,7 +8,7 @@ const FALLBACK = '_action'
 /**
  * Uniquely tag a function. This is used to identify actions.
  */
-export default function tag(fn: Command | Tagged, name?: string): Tagged {
+export default function tag(fn: string | Command, name?: string): Command {
   console.assert(
     fn,
     `Unable to identify ${fn == null ? fn : fn.toString()} action.`
@@ -57,5 +57,5 @@ export default function tag(fn: Command | Tagged, name?: string): Tagged {
   // Mark the function as tagged so we only do this once
   fn.__tagged = true
 
-  return cast
+  return fn
 }
diff --git a/src/utils.js b/src/utils.js
index 7c2f1516..ae7e6e6c 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -226,3 +226,13 @@ export function update(
 
   return set(state, path, next)
 }
+
+export function result(target: *, keyPath: string | KeyPath): * {
+  let value = get(target, keyPath)
+
+  if (typeof value === 'function') {
+    return value.call(target)
+  }
+
+  return value
+}


Test patch:
diff --git a/test/integration/reconciliation.test.js b/test/integration/reconciliation.test.js
index f2ba8a8c..f198a539 100644
--- a/test/integration/reconciliation.test.js
+++ b/test/integration/reconciliation.test.js
@@ -66,7 +66,7 @@ describe('Reconciliation', function() {
     expect(repo).toHaveState('count', 6)
   })
 
-  it('archived actions are removed from the archive', function() {
+  it.dev('archived actions are removed from the archive', function() {
     const repo = new Microcosm()
 
     let one = repo.append('one')
@@ -78,11 +78,11 @@ describe('Reconciliation', function() {
     one.resolve()
 
     // Recalling one will find nothing, so the repo returns initial state
-    expect(repo.recall(one)).toEqual(repo.getInitialState())
+    expect(repo._recall(one)).toEqual(repo.getInitialState())
 
     // Two is in here because it is the parent of three, which we need as a "base state"
-    expect(repo.recall(two)).toBeDefined()
-    expect(repo.recall(three)).toBeDefined()
+    expect(repo._recall(two)).toBeDefined()
+    expect(repo._recall(three)).toBeDefined()
   })
 
   it('pushing actions while the root is "open" does not result in extra invocations', function() {
diff --git a/test/unit/action-aliasing.test.js b/test/unit/action-aliasing.test.js
new file mode 100644
index 00000000..ba9ceec2
--- /dev/null
+++ b/test/unit/action-aliasing.test.js
@@ -0,0 +1,61 @@
+import Microcosm from '../../src/microcosm'
+
+describe('When a domain includes an actions object', () => {
+  let repo = null
+  let addPlanet = null
+
+  beforeEach(function() {
+    repo = new Microcosm()
+    addPlanet = jest.fn(name => ({ name }))
+
+    repo.addDomain('planets', {
+      actions: { addPlanet },
+      getInitialState() {
+        return []
+      },
+      register: {
+        addPlanet: (planets, params) => planets.concat(params)
+      }
+    })
+  })
+
+  it('correctly associates the string name to the action', function() {
+    let action = repo.push('addPlanet', 'earth')
+
+    expect(action.command).toEqual(addPlanet)
+    expect(repo).toHaveState('planets', [{ name: 'earth' }])
+  })
+
+  it('correctly associates the original action', function() {
+    let action = repo.push(addPlanet, 'earth')
+
+    expect(action.command).toEqual(addPlanet)
+    expect(repo).toHaveState('planets', [{ name: 'earth' }])
+  })
+})
+
+describe('When an effect includes an actions object', () => {
+  let repo = null
+  let addPlanet = null
+
+  beforeEach(function() {
+    repo = new Microcosm()
+    addPlanet = jest.fn(name => ({ name }))
+  })
+
+  it('correctly associates the string name to the action', function() {
+    let handler = jest.fn()
+
+    repo.addEffect({
+      actions: { addPlanet },
+      register: {
+        addPlanet: handler
+      }
+    })
+
+    let action = repo.push('addPlanet', 'earth')
+
+    expect(action.command).toEqual(addPlanet)
+    expect(handler).toHaveBeenCalledWith(repo, { name: 'earth' })
+  })
+})
diff --git a/test/unit/middleware/string-middleware.test.js b/test/unit/middleware/string-middleware.test.js
new file mode 100644
index 00000000..2fade898
--- /dev/null
+++ b/test/unit/middleware/string-middleware.test.js
@@ -0,0 +1,11 @@
+import Microcosm from '../../../src/microcosm'
+
+describe('String middleware', function() {
+  it('passes strings through as identify functions', function() {
+    let repo = new Microcosm()
+    let action = repo.push('test', true)
+
+    expect(action.type).toBe('test')
+    expect(action.payload).toBe(true)
+  })
+})