Repository: vigetlabs/microcosm
Pull request #500 (2018-04-11 12:28:55)

[microcosm] Unify effect and domain behavior through agent class
This commit adds a new class to describe the dispatch behavior: an agent. Agents receive action dispatches from history and can manage their own internal state:

```javascript
import { start, stop } from '../actions/gps'

class GPS extends Agent () {
  setup() {
    this.listen()
  }

  listen() {
    this.watcher = navigator.geolocation.watchPosition(this.next)
  }

  ignore() {
    navigator.geolocation.clearWatch(this.watcher);
  }

  receive(action) {
    switch(action) {
      case start: 
        return this.listen()
      case stop: 
        return this.ignore()
      default: 
        return null
    }
  }
}

let gps = new GPS()

gps.subscribe(next => console.log(next)) // location.... location .... location....

gps.send(start)
```

I still haven't figured out the public API for Agents yet, but I think they will be a useful escape hatch for creating new types of Domain/Effect-like abstractions. I'm also curious if they could be used as a type of viewmodel class, but I'm not quite there yet.


Patch:
diff --git a/jest.config.js b/jest.config.js
index 0aa2d485..b1a1cade 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -9,6 +9,7 @@ module.exports = {
     // './packages/microcosm-graphql',
     './packages/microcosm-http'
   ],
+  collectCoverageFrom: ['**/src/**/*.js'],
   modulePathIgnorePatterns: ['example', 'build'],
   moduleNameMapper: {
     '^microcosm$': `<rootDir>/../microcosm/${isBundled ? 'build' : 'src'}`,
diff --git a/packages/microcosm/src/agent.js b/packages/microcosm/src/agent.js
new file mode 100644
index 00000000..8fff7e11
--- /dev/null
+++ b/packages/microcosm/src/agent.js
@@ -0,0 +1,52 @@
+/**
+ * @fileoverview An agent is a general abstraction for intercepting actions.
+ * It is the basis of Domain and Effect
+ * @flow
+ */
+
+import { type Microcosm } from '../src/microcosm'
+import { Subject } from './subject'
+import { merge } from './data'
+
+export class Agent extends Subject {
+  repo: Microcosm
+  options: Object
+
+  static defaults: ?Object
+
+  constructor(repo: Microcosm, options?: Object) {
+    super(null, options)
+
+    this.repo = repo
+    this.options = merge(repo.options, this.constructor.defaults, options)
+
+    let tracker = repo.history.subscribe(this.receive.bind(this))
+
+    repo.subscribe({
+      complete: () => {
+        tracker.unsubscribe()
+        this.teardown(this.repo, this.options)
+      }
+    })
+
+    this.setup(this.repo, this.options)
+  }
+
+  /**
+   * Setup runs right after an agent is added to a Microcosm. It
+   * receives that repo and any options passed as the second argument.
+   */
+  setup(repo?: Microcosm, options?: Object): void {}
+
+  /**
+   * Runs whenever a Microcosm is torn down. This usually happens when
+   * a Presenter component unmounts. Useful for cleaning up work done
+   * in `setup()`.
+   */
+  teardown(repo?: Microcosm, options?: Object): void {}
+
+  /**
+   * Called whenever an agent receives a new action
+   */
+  receive(action: Subject): void {}
+}
diff --git a/packages/microcosm/src/domain.js b/packages/microcosm/src/domain.js
index 625c70ee..b33d9d81 100644
--- a/packages/microcosm/src/domain.js
+++ b/packages/microcosm/src/domain.js
@@ -7,70 +7,30 @@ import { Registry } from './registry'
 import { EMPTY_OBJECT } from './empty'
 import { RESET, PATCH } from './lifecycle'
 import { Ledger } from './ledger'
+import { Agent } from './agent'
 
 type DomainHandler<State> = (state: State, payload?: *, meta?: *) => State
 
-export class Domain<State: mixed = null> extends Subject {
-  repo: Microcosm
-  options: Object
+type DomainRegistry<State> = {
+  [string]: DomainHandler<State>
+}
+
+export class Domain<State: mixed = null> extends Agent {
   _registry: Registry
   _ledger: Ledger<State>
 
-  static defaults: ?Object
-
-  static from(config: *): Class<Domain<*>> {
-    if (typeof config === 'function') {
-      return config
-    }
-
-    function NewEntity(repo: Microcosm, options?: Object) {
-      // $FlowFixMe
-      Domain.prototype.constructor.call(this, repo, options)
-    }
-
-    NewEntity.prototype = Object.create(Domain.prototype)
-
-    for (var key in config) {
-      NewEntity.prototype[key] = config[key]
-    }
-
-    // $FlowFixMe
-    return NewEntity
-  }
-
-  constructor(repo: Microcosm, options: Object) {
-    super(null, { tag: options.key })
-
-    this.repo = repo
-    this.options = options
-
-    this.setup(repo, options)
-
-    let start = this.getInitialState()
+  constructor(repo: Microcosm, options?: Object) {
+    super(repo, options)
 
     this._registry = new Registry(this)
-    this._ledger = new Ledger(start, repo.history, options.debug)
-
-    this.next(start)
 
-    let tracker = repo.history.subscribe(action => {
-      let next = this._rollforward(action)
+    this._ledger = new Ledger(
+      this.getInitialState(),
+      this.repo.history,
+      this.options.debug
+    )
 
-      if (next !== this.payload) {
-        this.next(next)
-      }
-
-      // TODO: This could probably be a generic storage solution
-      // that cleaned up keys as actions completed
-      this._ledger.clean(action)
-    })
-
-    repo.subscribe({
-      complete: () => {
-        tracker.unsubscribe()
-        this.teardown(repo, options)
-      }
-    })
+    this.next(this._ledger.valueOf())
   }
 
   /**
@@ -80,20 +40,6 @@ export class Domain<State: mixed = null> extends Subject {
     return null
   }
 
-  /**
-   * Setup runs right after a domain is added to a Microcosm, but
-   * before it runs getInitialState. This is useful for one-time setup
-   * instructions.
-   */
-  setup(repo?: Microcosm, options?: Object): void {}
-
-  /**
-   * Runs whenever a Microcosm is torn down. This usually happens when
-   * a Presenter component unmounts. Useful for cleaning up work done
-   * in `setup()`.
-   */
-  teardown(repo?: Microcosm, options?: Object): void {}
-
   /**
    * Allows a domain to transform state into a JavaScript primitive
    * suitable for serialization into JSON. Usually during server-side
@@ -116,10 +62,22 @@ export class Domain<State: mixed = null> extends Subject {
    * Returns an object mapping actions to methods on the domain. This is the
    * communication point between a domain and the rest of the system.
    */
-  register(): { [string]: (last?: State, next?: *) => State } {
+  register(): DomainRegistry<State> {
     return EMPTY_OBJECT
   }
 
+  receive(action: Subject): void {
+    let next = this._rollforward(action)
+
+    if (next !== this.payload) {
+      this.next(next)
+    }
+
+    // TODO: This could probably be a generic storage solution
+    // that cleaned up keys as actions completed
+    this._ledger.clean(action)
+  }
+
   // Private -------------------------------------------------- //
 
   toJSON() {
diff --git a/packages/microcosm/src/effect.js b/packages/microcosm/src/effect.js
index d921186c..d71f9800 100644
--- a/packages/microcosm/src/effect.js
+++ b/packages/microcosm/src/effect.js
@@ -6,82 +6,31 @@ import { type Microcosm } from '../src/microcosm'
 import { type Subject } from './subject'
 import { Registry } from './registry'
 import { EMPTY_OBJECT } from './empty'
+import { Agent } from './agent'
 
-type EffectHandler = (repo: Microcosm, payload?: *, meta?: *) => void
+type EffectRegistry = {
+  [any]: (repo: Microcosm, payload?: *) => *
+}
 
-export class Effect {
-  repo: Microcosm
-  options: Object
+export class Effect extends Agent {
   _registry: Registry
 
-  static defaults: ?Object
-
-  static from(config: *): Class<Effect> {
-    if (typeof config === 'function') {
-      return config
-    }
-
-    function NewEntity(repo: Microcosm, options?: Object) {
-      // $FlowFixMe
-      Effect.prototype.constructor.call(this, repo, options)
-    }
-
-    NewEntity.prototype = Object.create(Effect.prototype)
+  constructor(repo: Microcosm, options?: Object) {
+    super(repo, options)
 
-    for (var key in config) {
-      NewEntity.prototype[key] = config[key]
-    }
-
-    // $FlowFixMe
-    return NewEntity
-  }
-
-  constructor(repo: Microcosm, options: Object) {
-    this.repo = repo
-    this.options = options
     this._registry = new Registry(this)
-
-    this.setup(repo, options)
-
-    let tracker = repo.history.subscribe(this._dispatch.bind(this))
-
-    repo.subscribe({
-      complete: () => {
-        tracker.unsubscribe()
-        this.teardown(repo, options)
-      }
-    })
   }
 
-  /**
-   * Setup runs right after an effect is added to a Microcosm. It
-   * receives that repo and any options passed as the second argument.
-   */
-  setup(repo?: Microcosm, options?: Object): void {}
-
-  /**
-   * Runs whenever a Microcosm is torn down. This usually happens when
-   * a Presenter component unmounts. Useful for cleaning up work done
-   * in `setup()`.
-   */
-  teardown(repo?: Microcosm, options?: Object): void {}
-
   /**
    * Returns an object mapping actions to methods on the effect. This is the
    * communication point between a effect and the rest of the system.
    */
-  register(): { [any]: (repo: Microcosm, payload?: *) => void } {
+  register(): EffectRegistry {
     return EMPTY_OBJECT
   }
 
-  // Private -------------------------------------------------- //
-
-  _resolve(action: Subject): EffectHandler[] {
-    return this._registry.resolve(action)
-  }
-
-  _dispatch(action: Subject): void {
-    var handlers = this._resolve(action)
+  receive(action: Subject) {
+    var handlers = this._registry.resolve(action)
 
     for (var i = 0, len = handlers.length; i < len; i++) {
       handlers[i].call(this, this.repo, action.payload)
diff --git a/packages/microcosm/src/history.js b/packages/microcosm/src/history.js
index fe00004a..dfad05b5 100644
--- a/packages/microcosm/src/history.js
+++ b/packages/microcosm/src/history.js
@@ -31,7 +31,7 @@ export class History extends Subject {
     this.head = null
     this._branch = new Set()
     this._tree = new Tree()
-    this._debug = options ? options.debug : false
+    this._debug = !!options.debug
   }
 
   get size(): number {
@@ -66,7 +66,7 @@ export class History extends Subject {
   }
 
   append(origin: Microcosm, command: Command, ...params: *[]): Subject {
-    let action = new Subject(params[0], { tag: String(tag(command)), origin })
+    let action = new Subject(params[0], { key: String(tag(command)), origin })
 
     this._branch.add(action)
 
diff --git a/packages/microcosm/src/index.js b/packages/microcosm/src/index.js
index 352996e2..fe5d83ab 100644
--- a/packages/microcosm/src/index.js
+++ b/packages/microcosm/src/index.js
@@ -14,3 +14,4 @@ export { tag } from './tag'
 
 export { Domain } from './domain'
 export { Effect } from './effect'
+export { Agent } from './agent'
diff --git a/packages/microcosm/src/ledger.js b/packages/microcosm/src/ledger.js
index 8d7c358d..02100849 100644
--- a/packages/microcosm/src/ledger.js
+++ b/packages/microcosm/src/ledger.js
@@ -48,10 +48,6 @@ export class Ledger<State> {
     this._versions.set(action, state)
   }
 
-  toJSON() {
-    return this.valueOf()
-  }
-
   valueOf() {
     return this.recall(this._history.head)
   }
diff --git a/packages/microcosm/src/microcosm.js b/packages/microcosm/src/microcosm.js
index a9bb8ddc..d73321ba 100644
--- a/packages/microcosm/src/microcosm.js
+++ b/packages/microcosm/src/microcosm.js
@@ -7,6 +7,7 @@ import { Domain } from './domain'
 import { Effect } from './effect'
 import { merge } from './data'
 import { version } from '../package.json'
+import { inherit } from './proto'
 
 const DEFAULTS = {
   debug: false,
@@ -22,7 +23,7 @@ export class Microcosm extends Subject {
   static version: string
 
   constructor(preOptions?: ?Object) {
-    super()
+    super(null, { key: 'repo' })
 
     let options = merge(DEFAULTS, this.constructor.defaults, preOptions || {})
     let parent = options.parent
@@ -60,7 +61,7 @@ export class Microcosm extends Subject {
     // NOOP
   }
 
-  addDomain(key: string, blueprint: *, config?: *): Subject {
+  addDomain(key: string, blueprint: *, options?: *): Subject {
     console.assert(
       key in this.domains === false,
       'Can not add domain for "' + key + '". This state is already managed.'
@@ -68,22 +69,36 @@ export class Microcosm extends Subject {
 
     console.assert(key && key.length > 0, 'Can not add domain to root level.')
 
+    console.assert(
+      blueprint != null,
+      `Unable to create domain using ` +
+        `addDomain("${key}", ${String(blueprint)}). ` +
+        `This often happens if you forget to add the second argument to ` +
+        `addDomain, or if you imported the wrong namespace from a module.`
+    )
+
     if (blueprint instanceof Observable) {
       this.domains[key] = Subject.hash(blueprint)
       this.subscribe({ complete: this.domains[key].complete })
     } else {
-      let options = merge(this.options, blueprint.defaults, { key }, config)
-      let Entity = Domain.from(blueprint)
+      let Entity = inherit(blueprint, Domain)
 
-      this.domains[key] = new Entity(this, options)
+      this.domains[key] = new Entity(this, merge({ key }, options))
     }
 
     return this.domains[key]
   }
 
-  addEffect(blueprint: *, config?: *): Effect {
-    let options = merge(this.options, blueprint.defaults, config)
-    let Entity = Effect.from(blueprint)
+  addEffect(blueprint: *, options?: *): Effect {
+    console.assert(
+      blueprint != null,
+      `Unable to create effect using ` +
+        `addEffect(${String(blueprint)}). ` +
+        `This often happens if you forget to add the second argument to ` +
+        `addEffect, or if you imported the wrong namespace from a module.`
+    )
+
+    let Entity = inherit(blueprint, Effect)
 
     return new Entity(this, options)
   }
diff --git a/packages/microcosm/src/proto.js b/packages/microcosm/src/proto.js
new file mode 100644
index 00000000..7648ea89
--- /dev/null
+++ b/packages/microcosm/src/proto.js
@@ -0,0 +1,23 @@
+/* @flow */
+
+/**
+ * Given a class and an object, convert a POJO into an ancestor
+ */
+export function inherit(config: *, Parent: Class<*>): * {
+  if (typeof config === 'function') {
+    return config
+  }
+
+  class NewEntity extends Parent {}
+
+  for (var key in config) {
+    Object.defineProperty(NewEntity.prototype, key, {
+      configurable: true,
+      enumerable: false,
+      writeable: true,
+      value: config[key]
+    })
+  }
+
+  return NewEntity
+}
diff --git a/packages/microcosm/src/registry.js b/packages/microcosm/src/registry.js
index 08ac6f98..60eea8e0 100644
--- a/packages/microcosm/src/registry.js
+++ b/packages/microcosm/src/registry.js
@@ -5,8 +5,8 @@ import { EMPTY_ARRAY, EMPTY_OBJECT } from './empty'
 import { type Domain } from './domain'
 import { type Effect } from './effect'
 
-function buildRegistry(entity: Domain<*> | Effect, tag: string): Object {
-  let handlers = entity.register()[tag] || EMPTY_OBJECT
+function buildRegistry(entity: Domain<*> | Effect, key: string): Object {
+  let handlers = entity.register()[key] || EMPTY_OBJECT
 
   if (Array.isArray(handlers) || typeof handlers === 'function') {
     return { complete: handlers }
@@ -25,13 +25,13 @@ export class Registry {
   }
 
   resolve(action: Subject): Function[] {
-    let tag = action.toString()
+    let key = action.toString()
 
-    if (!this._entries.hasOwnProperty(tag)) {
-      this._entries[tag] = buildRegistry(this._entity, tag)
+    if (!this._entries.hasOwnProperty(key)) {
+      this._entries[key] = buildRegistry(this._entity, key)
     }
 
-    let handlers = this._entries[tag][action.status] || EMPTY_ARRAY
+    let handlers = this._entries[key][action.status] || EMPTY_ARRAY
 
     return Array.isArray(handlers) ? handlers : [handlers]
   }
diff --git a/packages/microcosm/src/subject.js b/packages/microcosm/src/subject.js
index 5fa0f981..0d29ef06 100644
--- a/packages/microcosm/src/subject.js
+++ b/packages/microcosm/src/subject.js
@@ -6,14 +6,14 @@ import { noop, EMPTY_SUBSCRIPTION } from './empty'
 import { set, merge } from './data'
 
 export class Subject {
-  meta: { tag: *, status: string, origin: Microcosm }
+  meta: { key: *, status: string, origin: Microcosm }
   payload: *
   disabled: boolean
   _observers: Set<*>
   _observable: Observable
 
   constructor(payload?: *, meta?: Object) {
-    this.meta = merge({ tag: null, status: 'start' }, meta)
+    this.meta = merge({ key: 'subject', status: 'start' }, meta)
     this.payload = payload
     this.disabled = false
 
@@ -134,14 +134,14 @@ export class Subject {
   }
 
   toString(): string {
-    return this.meta.tag || 'Subject'
+    return this.meta.key
   }
 
   toJSON(): Object {
     return {
       payload: this.payload,
       status: this.meta.status,
-      tag: this.toString()
+      key: this.toString()
     }
   }
 


Test patch:
diff --git a/packages/microcosm/test/helpers.js b/packages/microcosm/test/helpers.js
index 22b2cd16..526406f7 100644
--- a/packages/microcosm/test/helpers.js
+++ b/packages/microcosm/test/helpers.js
@@ -4,7 +4,7 @@ export function asTree(history) {
   function dig(focus) {
     let children = focus.children.map(dig)
 
-    return children.length ? [focus.tag, ...children] : focus.tag
+    return children.length ? [focus.key, ...children] : focus.key
   }
 
   return asciitree(dig(history.toJSON().tree))
diff --git a/packages/microcosm/test/unit/history/__snapshots__/toJSON.test.js.snap b/packages/microcosm/test/unit/history/__snapshots__/toJSON.test.js.snap
index 8b3e3484..0bb64827 100644
--- a/packages/microcosm/test/unit/history/__snapshots__/toJSON.test.js.snap
+++ b/packages/microcosm/test/unit/history/__snapshots__/toJSON.test.js.snap
@@ -1,22 +1,30 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
+exports[`History::toJSON accommodates an empty tree 1`] = `
+Object {
+  "list": Array [],
+  "size": 0,
+  "tree": null,
+}
+`;
+
 exports[`History::toJSON serializes the tree of actions 1`] = `
 Object {
   "list": Array [
     Object {
+      "key": "one",
       "payload": true,
       "status": "complete",
-      "tag": "one",
     },
     Object {
+      "key": "two",
       "payload": true,
       "status": "complete",
-      "tag": "two",
     },
     Object {
+      "key": "four",
       "payload": true,
       "status": "complete",
-      "tag": "four",
     },
   ],
   "size": 3,
@@ -26,25 +34,25 @@ Object {
         "children": Array [
           Object {
             "children": Array [],
+            "key": "three",
             "payload": true,
             "status": "complete",
-            "tag": "three",
           },
           Object {
             "children": Array [],
+            "key": "four",
             "payload": true,
             "status": "complete",
-            "tag": "four",
           },
         ],
+        "key": "two",
         "payload": true,
         "status": "complete",
-        "tag": "two",
       },
     ],
+    "key": "one",
     "payload": true,
     "status": "complete",
-    "tag": "one",
   },
 }
 `;
diff --git a/packages/microcosm/test/unit/history/iterator.test.js b/packages/microcosm/test/unit/history/iterator.test.js
index 54d51b27..03b802da 100644
--- a/packages/microcosm/test/unit/history/iterator.test.js
+++ b/packages/microcosm/test/unit/history/iterator.test.js
@@ -9,7 +9,7 @@ describe('History @@iterator', function() {
 
     let list = Array.from(repo.history)
 
-    expect(list.map(i => i.meta.tag)).toEqual(['one', 'two'])
+    expect(list.map(i => i.meta.key)).toEqual(['one', 'two'])
   })
 
   it('works with for...of', function() {
@@ -21,7 +21,7 @@ describe('History @@iterator', function() {
     let list = []
 
     for (var action of repo.history) {
-      list.push(action.meta.tag)
+      list.push(action.meta.key)
     }
 
     expect(list).toEqual(['one', 'two'])
diff --git a/packages/microcosm/test/unit/history/toJSON.test.js b/packages/microcosm/test/unit/history/toJSON.test.js
index f88ffc48..bb04ed9f 100644
--- a/packages/microcosm/test/unit/history/toJSON.test.js
+++ b/packages/microcosm/test/unit/history/toJSON.test.js
@@ -45,4 +45,10 @@ describe('History::toJSON', function() {
 
     expect(repo.history).toMatchSnapshot()
   })
+
+  it('accommodates an empty tree', function() {
+    const repo = new Microcosm({ debug: true })
+
+    expect(repo.history).toMatchSnapshot()
+  })
 })
diff --git a/packages/microcosm/test/unit/history/toggle.test.js b/packages/microcosm/test/unit/history/toggle.test.js
new file mode 100644
index 00000000..34a45a93
--- /dev/null
+++ b/packages/microcosm/test/unit/history/toggle.test.js
@@ -0,0 +1,61 @@
+import Microcosm from 'microcosm'
+
+describe('History::toggle', function() {
+  it('replaces history, skipping disabled actions', function() {
+    let repo = new Microcosm({ debug: true })
+
+    repo.addDomain('test', {
+      getInitialState: () => 0,
+      register() {
+        return {
+          add: (a, b) => a + b
+        }
+      }
+    })
+
+    repo.push('add', 2)
+    let second = repo.push('add', 2)
+
+    expect(repo.state.test).toBe(4)
+
+    repo.history.toggle(second)
+
+    expect(repo.state.test).toBe(2)
+  })
+
+  it('does not replay inactive branches', function() {
+    let repo = new Microcosm({ debug: true })
+
+    repo.addDomain('test', {
+      getInitialState: () => 0,
+      register() {
+        return {
+          add: (a, b) => a + b
+        }
+      }
+    })
+
+    let one = repo.push('add', 1)
+    let two = repo.push('add', 2)
+
+    repo.history.checkout(one)
+
+    repo.push('add', 3)
+
+    // History is now:
+    //   *one
+    //   / \
+    // two *three
+    //
+    // * indicates active branch
+    expect(repo.state.test).toBe(4) // one + three
+
+    repo.domains.test.subscribe(() => {
+      throw 'Repo should not have changed'
+    })
+
+    repo.history.toggle(two)
+
+    expect(repo.state.test).toBe(4) // one + three
+  })
+})
diff --git a/packages/microcosm/test/unit/microcosm/addDomain.test.js b/packages/microcosm/test/unit/microcosm/addDomain.test.js
index c55d24c2..1382332a 100644
--- a/packages/microcosm/test/unit/microcosm/addDomain.test.js
+++ b/packages/microcosm/test/unit/microcosm/addDomain.test.js
@@ -141,4 +141,12 @@ describe('Microcosm::addDomain', function() {
       )
     })
   })
+
+  it.dev('will not add a null domain', () => {
+    let repo = new Microcosm()
+
+    expect(() => repo.addDomain('test', null)).toThrow(
+      'Unable to create domain using addDomain("test", null).'
+    )
+  })
 })
diff --git a/packages/microcosm/test/unit/microcosm/addEffect.test.js b/packages/microcosm/test/unit/microcosm/addEffect.test.js
new file mode 100644
index 00000000..80b2029e
--- /dev/null
+++ b/packages/microcosm/test/unit/microcosm/addEffect.test.js
@@ -0,0 +1,11 @@
+import { Microcosm } from 'microcosm'
+
+describe('Microcosm::addEffect', function() {
+  it.dev('will not add a null effect', () => {
+    let repo = new Microcosm()
+
+    expect(() => repo.addEffect(null)).toThrow(
+      'Unable to create effect using addEffect(null).'
+    )
+  })
+})
diff --git a/packages/microcosm/test/unit/microcosm/patch.test.js b/packages/microcosm/test/unit/microcosm/patch.test.js
index d32bd7ae..37359b2e 100644
--- a/packages/microcosm/test/unit/microcosm/patch.test.js
+++ b/packages/microcosm/test/unit/microcosm/patch.test.js
@@ -20,6 +20,30 @@ describe('Microcosm::patch', function() {
     expect(badPatch).toThrow()
   })
 
+  it('uses the deserialize method on domains', function() {
+    const repo = new Microcosm()
+
+    repo.addDomain('caps', {
+      deserialize(data) {
+        return data.toUpperCase()
+      }
+    })
+
+    repo.push(patch, JSON.stringify({ caps: 'hello' }), true)
+
+    expect(repo.state.caps).toEqual('HELLO')
+  })
+
+  it('just passes data through if no serialization method is provided', function() {
+    const repo = new Microcosm()
+
+    repo.addDomain('count', {})
+
+    repo.push(patch, JSON.stringify({ count: 0 }), true)
+
+    expect(repo.state.count).toEqual(0)
+  })
+
   describe('forks', function() {
     it('deeply inherits state', function() {
       const grandparent = new Microcosm()
diff --git a/packages/microcosm/test/unit/subject.test.js b/packages/microcosm/test/unit/subject.test.js
index 58ebe13f..f8b17fce 100644
--- a/packages/microcosm/test/unit/subject.test.js
+++ b/packages/microcosm/test/unit/subject.test.js
@@ -191,25 +191,25 @@ describe('Subject', function() {
   })
 
   describe('toString', function() {
-    it('stringifies to its tag name', () => {
-      expect(new Subject(null, { tag: 'foobar' }).toString()).toBe('foobar')
+    it('stringifies to its key name', () => {
+      expect(new Subject(null, { key: 'foobar' }).toString()).toBe('foobar')
     })
 
-    it('stringifies to "Subject" when given no tag', () => {
-      expect(new Subject(null).toString()).toBe('Subject')
+    it('stringifies to "subject" when given no key', () => {
+      expect(new Subject(null).toString()).toBe('subject')
     })
   })
 
   describe('toJSON', function() {
     it('generates a POJO', () => {
-      let subject = new Subject(true, { tag: 'foobar' })
+      let subject = new Subject(true, { key: 'foobar' })
 
       subject.complete()
 
       expect(subject.toJSON()).toEqual({
         payload: true,
         status: 'complete',
-        tag: 'foobar'
+        key: 'foobar'
       })
     })
   })