Repository: vigetlabs/microcosm
Pull request #484 (2018-03-19 11:28:14)

Domains are subjects
### What it does

This commit changes the Domain class such that it extends from the Subject prototype. A subject is kind of like a stream of values over time. They are a special kind of observable that makes it easier to send updates to multiple subscribers. 

Subjects have their own state, which allows a couple of nice things:

1. **Domains can serialize their own state.** Microcosm doesn't need to dispatch anything. The `toJSON method` can just return `this.domains`. `JSON.stringify` should take care of everything else by calling the subsequent `toJSON` methods on domains.
2. **Domains can be tested on their own.**  You can instantiate a domain as a stand-alone instance, calling `dispatch` directly on the domain itself. This isn't _quite_ true yet, as Domains require a microcosm history object, but one idea is to give each domain their own unique history.
3. **Presenter data subscriptions become more powerful**. Presenters in Microcosm 13 can subscribe to observables (so also subjects). This means we can replace our data-binding callbacks with references to the domains themselves, which is both cleaner and significantly faster.

This means the `repo.answers` namespace can be removed. You can now get updates to a domain simply by accessing the domain itself. This required some Presenter tests to be updated:

```javascript
// Before
class UsersList extends Presenter {
  getModel() {
    return {
      users: state => state.users
    }
  }
}

// After
class UsersList extends Presenter {
  getModel(repo) {
    return {
      users: repo.domains.users
    }
  }
}
```

Before this change, there was an additional `answers` namespace on repo. You'd have to subscribe to a domain update via `repo.answers["domain-key"]`. This commit consolidates that namespace while also simplify some serialization techniques because domains now fully track their own state.

Long term, I'd like to add some new methods to observables, like `map`. That would let you do stuff like:

```javascript
function prepareUsersList(users) {
  // return a list of users based on what the presenter needs
}

class UsersList extends Presenter {
  getModel(repo) {
    return {
      users: repo.domains.users.map(prepareUsersList)
    }
  }
}
```

It also means that you can build more complicated view models by composing domains into an object:

```javascript
class UsersList extends Presenter {
  getModel(repo) {
    return {
      users: {
        total: repo.domains.users.map(users => users.length)
        data: repo.domains.users.map(users => users.slice(0, 10)) // only show the first ten users
    }
  }
}
```

This is sort of the next step for `getModel`. Long term, I can see how this could start to term into a more sophisticated query language. One that could probably also handle data fetching.

codecov-io: # [Codecov](https://codecov.io/gh/vigetlabs/microcosm/pull/484?src=pr&el=h1) Report
> Merging [#484](https://codecov.io/gh/vigetlabs/microcosm/pull/484?src=pr&el=desc) into [master](https://codecov.io/gh/vigetlabs/microcosm/commit/1ba83ce82af6fa7c2f601d4fa06ff4efa80de96f?src=pr&el=desc) will **decrease** coverage by `0.33%`.
> The diff coverage is `81.81%`.

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

```diff
@@            Coverage Diff            @@
##           master    #484      +/-   ##
=========================================
- Coverage   98.54%   98.2%   -0.34%     
=========================================
  Files          29      28       -1     
  Lines         686     669      -17     
  Branches      134     128       -6     
=========================================
- Hits          676     657      -19     
- Misses          8      10       +2     
  Partials        2       2
```


| [Impacted Files](https://codecov.io/gh/vigetlabs/microcosm/pull/484?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [packages/microcosm/src/ledger.js](https://codecov.io/gh/vigetlabs/microcosm/pull/484/diff?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtL3NyYy9sZWRnZXIuanM=) | `88.88% <0%> (-11.12%)` | :arrow_down: |
| [packages/microcosm/src/microcosm.js](https://codecov.io/gh/vigetlabs/microcosm/pull/484/diff?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtL3NyYy9taWNyb2Nvc20uanM=) | `100% <100%> (ø)` | :arrow_up: |
| [packages/microcosm/src/domain.js](https://codecov.io/gh/vigetlabs/microcosm/pull/484/diff?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtL3NyYy9kb21haW4uanM=) | `97.95% <100%> (+0.04%)` | :arrow_up: |
| [packages/microcosm-dom/src/utilities.js](https://codecov.io/gh/vigetlabs/microcosm/pull/484/diff?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtLWRvbS9zcmMvdXRpbGl0aWVzLmpz) | | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/vigetlabs/microcosm/pull/484?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/484?src=pr&el=footer). Last update [1ba83ce...20d17a8](https://codecov.io/gh/vigetlabs/microcosm/pull/484?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/packages/microcosm/src/domain.js b/packages/microcosm/src/domain.js
index 9772740c..f7af5cec 100644
--- a/packages/microcosm/src/domain.js
+++ b/packages/microcosm/src/domain.js
@@ -1,7 +1,6 @@
 /**
  * @flow
  */
-
 import { type Microcosm } from './microcosm'
 import { Subject } from './subject'
 import { Registry } from './registry'
@@ -11,10 +10,9 @@ import { Ledger } from './ledger'
 
 type DomainHandler<State> = (state: State, payload?: *, meta?: *) => State
 
-export class Domain<State: mixed = null> {
+export class Domain<State: mixed = null> extends Subject {
   repo: Microcosm
   options: Object
-  stream: Subject
   _registry: Registry
   _ledger: Ledger<State>
 
@@ -41,9 +39,10 @@ export class Domain<State: mixed = null> {
   }
 
   constructor(repo: Microcosm, options: Object) {
+    super(null, { tag: options.key })
+
     this.repo = repo
     this.options = options
-    this.stream = new Subject()
 
     this.setup(repo, options)
 
@@ -52,13 +51,13 @@ export class Domain<State: mixed = null> {
     this._registry = new Registry(this)
     this._ledger = new Ledger(start, repo.history, options.debug)
 
-    this.stream.next(start)
+    this.next(start)
 
     let tracker = repo.history.subscribe(action => {
       let next = this._rollforward(action)
 
-      if (next !== this.stream.payload) {
-        this.stream.next(next)
+      if (next !== this.payload) {
+        this.next(next)
       }
 
       // TODO: This could probably be a generic storage solution
@@ -103,9 +102,7 @@ export class Domain<State: mixed = null> {
    * To prevent accidental data transfer, this method is "opt-in",
    * returning `undefined` by default.
    */
-  serialize(state: State): ?mixed {
-    return undefined
-  }
+  serialize(state: State): ?mixed {}
 
   /**
    * Allows data to be transformed into a valid shape before it enters a
@@ -125,6 +122,14 @@ export class Domain<State: mixed = null> {
 
   // Private -------------------------------------------------- //
 
+  toJSON() {
+    return this.serialize(this.valueOf())
+  }
+
+  valueOf() {
+    return this.payload
+  }
+
   _resolve(action: Subject): DomainHandler<State>[] {
     switch (action.toString()) {
       case String(RESET):
diff --git a/packages/microcosm/src/ledger.js b/packages/microcosm/src/ledger.js
index ee6cbd7c..8d7c358d 100644
--- a/packages/microcosm/src/ledger.js
+++ b/packages/microcosm/src/ledger.js
@@ -47,4 +47,12 @@ export class Ledger<State> {
   set(action: Subject, state: *): void {
     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 4e973589..0f34858b 100644
--- a/packages/microcosm/src/microcosm.js
+++ b/packages/microcosm/src/microcosm.js
@@ -15,7 +15,6 @@ const DEFAULTS = {
 export class Microcosm extends Subject {
   history: History
   domains: { [string]: Domain<*> }
-  answers: { [string]: Subject }
   options: Object
 
   static defaults: Object
@@ -29,7 +28,6 @@ export class Microcosm extends Subject {
 
     this.history = parent ? parent.history : new History(options)
     this.domains = parent ? Object.create(parent.domains) : {}
-    this.answers = parent ? Object.create(parent.answers) : {}
     this.options = options
 
     this.subscribe({
@@ -46,8 +44,8 @@ export class Microcosm extends Subject {
   get state(): Object {
     let value = {}
 
-    for (let key in this.answers) {
-      value[key] = this.answers[key].payload
+    for (let key in this.domains) {
+      value[key] = this.domains[key].valueOf()
     }
 
     return value
@@ -74,7 +72,6 @@ export class Microcosm extends Subject {
     let domain = new Entity(this, options)
 
     this.domains[key] = domain
-    this.answers[key] = domain.stream
 
     return domain
   }
@@ -95,15 +92,7 @@ export class Microcosm extends Subject {
   }
 
   toJSON() {
-    let json = {}
-
-    for (var key in this.domains) {
-      if (this.domains[key].serialize) {
-        json[key] = this.domains[key].serialize(this.answers[key].payload)
-      }
-    }
-
-    return json
+    return merge({}, this.domains)
   }
 
   fork() {


Test patch:
diff --git a/packages/microcosm-dom/test/presenter.test.js b/packages/microcosm-dom/test/presenter.test.js
index fbe5fcec..e0cd36fe 100644
--- a/packages/microcosm-dom/test/presenter.test.js
+++ b/packages/microcosm-dom/test/presenter.test.js
@@ -38,7 +38,7 @@ describe('::getModel', function() {
     class MyPresenter extends Presenter {
       getModel(repo) {
         return {
-          color: repo.answers.color
+          color: repo.domains.color
         }
       }
       render() {
@@ -113,7 +113,7 @@ describe('::getModel', function() {
           return {
             // TODO: We should add Observable.map
             name: new Observable(observer => {
-              return repo.answers.name.subscribe(name => {
+              return repo.domains.name.subscribe(name => {
                 observer.next(prefix + ' ' + name)
               })
             })
@@ -260,7 +260,7 @@ describe('::setup', function() {
 
       getModel(repo) {
         return {
-          prop: repo.answers.prop
+          prop: repo.domains.prop
         }
       }
 
@@ -317,7 +317,7 @@ describe('::ready', function() {
 
       getModel(repo) {
         return {
-          test: repo.answers.test
+          test: repo.domains.test
         }
       }
     }
@@ -523,7 +523,7 @@ describe('::teardown', function() {
     class Test extends Presenter {
       getModel() {
         return {
-          test: repo.answers.test
+          test: repo.domains.test
         }
       }
       getRepo(repo) {
@@ -557,7 +557,7 @@ describe('::teardown', function() {
 
       getModel(repo) {
         return {
-          test: repo.answers.test
+          test: repo.domains.test
         }
       }
 
@@ -589,7 +589,7 @@ describe('purity', function() {
 
     class Namer extends Presenter {
       getModel(repo) {
-        return { name: repo.answers.name }
+        return { name: repo.domains.name }
       }
 
       render() {
@@ -650,7 +650,7 @@ describe('Efficiency', function() {
 
     let model = jest.fn(repo => {
       return {
-        color: repo.answers.color
+        color: repo.domains.color
       }
     })
 
@@ -1101,8 +1101,8 @@ describe('forks', function() {
           }
         })
 
-        expect(repo.answers).not.toHaveProperty('middle')
-        expect(repo.answers).not.toHaveProperty('bottom')
+        expect(repo.domains).not.toHaveProperty('middle')
+        expect(repo.domains).not.toHaveProperty('bottom')
       }
     }
 
@@ -1116,8 +1116,8 @@ describe('forks', function() {
 
         // State should exist, but the fork should not manage these keys:
         expect(repo.state.top).toBe('top')
-        expect(repo.answers).not.toHaveProperty('bottom')
-        expect(repo.answers).not.toHaveProperty('top')
+        expect(repo.domains).not.toHaveProperty('bottom')
+        expect(repo.domains).not.toHaveProperty('top')
       }
     }
 
@@ -1131,9 +1131,9 @@ describe('forks', function() {
 
         // State should exist, but the fork should not manage these keys:
         expect(repo.state.top).toBe('top')
-        expect(repo.answers).not.toHaveProperty('top')
+        expect(repo.domains).not.toHaveProperty('top')
         expect(repo.state.middle).toBe('middle')
-        expect(repo.answers).not.toHaveProperty('middle')
+        expect(repo.domains).not.toHaveProperty('middle')
       }
     }
 
diff --git a/packages/microcosm/test/unit/domain/serialize.test.js b/packages/microcosm/test/unit/domain/serialize.test.js
index ee8df8b9..33ed78e4 100644
--- a/packages/microcosm/test/unit/domain/serialize.test.js
+++ b/packages/microcosm/test/unit/domain/serialize.test.js
@@ -1,10 +1,12 @@
 import Microcosm from 'microcosm'
 
+const reconstitute = obj => JSON.parse(JSON.stringify(obj))
+
 describe('Domain::serialize', function() {
   it('runs through serialize methods on domains', function() {
     const repo = new Microcosm()
 
-    repo.addDomain('serialize-test', {
+    repo.addDomain('test', {
       getInitialState() {
         return 'this will not display'
       },
@@ -13,7 +15,7 @@ describe('Domain::serialize', function() {
       }
     })
 
-    expect(repo.toJSON()['serialize-test']).toBe('this is a test')
+    expect(reconstitute(repo)).toHaveProperty('test', 'this is a test')
   })
 
   it('only includes keys that impliment serialize', function() {
@@ -34,10 +36,10 @@ describe('Domain::serialize', function() {
       }
     })
 
-    let json = repo.toJSON()
+    let json = reconstitute(repo)
 
-    expect(json.included).toEqual('HOWDY')
-    expect(json.missing).not.toBeDefined()
+    expect(json).toHaveProperty('included', 'HOWDY')
+    expect(json).not.toHaveProperty('missing')
   })
 
   describe('forks', function() {
@@ -55,7 +57,7 @@ describe('Domain::serialize', function() {
         serialize: word => word.toUpperCase()
       })
 
-      expect(child.toJSON()).toEqual({ fiz: 'FIZ', buzz: 'BUZZ' })
+      expect(reconstitute(child)).toEqual({ fiz: 'FIZ', buzz: 'BUZZ' })
     })
   })
 })
diff --git a/packages/microcosm/test/unit/microcosm/release.test.js b/packages/microcosm/test/unit/microcosm/release.test.js
index 8a251b48..8846734f 100644
--- a/packages/microcosm/test/unit/microcosm/release.test.js
+++ b/packages/microcosm/test/unit/microcosm/release.test.js
@@ -1,4 +1,4 @@
-import Microcosm from 'microcosm'
+import { Microcosm } from 'microcosm'
 
 describe('Microcosm::release', function() {
   it('will not emit a change if state is shallowly equal', function() {
@@ -6,7 +6,7 @@ describe('Microcosm::release', function() {
     const identity = n => n
     const spy = jest.fn()
 
-    repo.addDomain('test', {
+    const test = repo.addDomain('test', {
       getInitialState() {
         return 0
       },
@@ -15,7 +15,7 @@ describe('Microcosm::release', function() {
       }
     })
 
-    repo.answers.test.subscribe(spy)
+    test.subscribe(spy)
 
     expect(spy).toHaveBeenCalledTimes(1)
     repo.push(identity, 0)
@@ -27,7 +27,7 @@ describe('Microcosm::release', function() {
     const identity = n => n
     const spy = jest.fn()
 
-    repo.addDomain('test', {
+    const test = repo.addDomain('test', {
       getInitialState() {
         return 0
       },
@@ -36,7 +36,7 @@ describe('Microcosm::release', function() {
       }
     })
 
-    repo.answers.test.subscribe(spy)
+    test.subscribe(spy)
 
     expect(spy).toHaveBeenCalledTimes(1)
     repo.push(identity, 1)
@@ -60,8 +60,8 @@ describe('Microcosm::release', function() {
     })
 
     // This will fire twice:
-    fork.answers.count.subscribe(() => {
-      expect(fork.state.count).toBe(repo.state.count)
+    fork.domains.count.subscribe(() => {
+      expect(fork.state).toEqual(repo.state)
     })
 
     repo.push(step, 1)
diff --git a/packages/microcosm/test/unit/microcosm/shutdown.test.js b/packages/microcosm/test/unit/microcosm/shutdown.test.js
index 99328bb1..9d3d51ac 100644
--- a/packages/microcosm/test/unit/microcosm/shutdown.test.js
+++ b/packages/microcosm/test/unit/microcosm/shutdown.test.js
@@ -14,7 +14,7 @@ describe('Shutting down a microcosm', function() {
 
     const listener = jest.fn()
 
-    repo.answers.colors.subscribe(listener)
+    repo.domains.colors.subscribe(listener)
     listener.mockReset()
 
     repo.complete()