Repository: vigetlabs/microcosm
Pull request #487 (2018-03-20 13:50:49)

Domains can be defined as observables
This commit allows a domain to be a simple observable. Observable domains do not have a ledger and can not respond to actions. Their primary use case is to synchronize state with some other source. Like:

1. Window history
2. Websocket state
3. Geolocation

For these cases, you mostly just want to push an action to synchronize state, but that's a ton of boilerplate. Instead, you could do:

```javascript
import { history } from 'push-state-lib'

repo.addDomain('location', new Observable(observer => {
  observer.next(history.getLocation())

  let listener = history.listen(location => observer.next(location)

  // The return value of an observable's callback is a clean-up function
  // sort of like Domain::teardown
  return () => listener.remove()
})

repo.domains.location.subscribe(location => {
  console.log("The next location is", location)
})
```

codecov-io: # [Codecov](https://codecov.io/gh/vigetlabs/microcosm/pull/487?src=pr&el=h1) Report
> Merging [#487](https://codecov.io/gh/vigetlabs/microcosm/pull/487?src=pr&el=desc) into [observable-map](https://codecov.io/gh/vigetlabs/microcosm/commit/91553f9ec6e7042f86fcc8fa79375ef1ec77caf2?src=pr&el=desc) will **increase** coverage by `<.01%`.
> The diff coverage is `100%`.

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

```diff
@@               Coverage Diff                @@
##           observable-map   #487      +/-   ##
================================================
+ Coverage           97.99%    98%   +<.01%     
================================================
  Files                  29     29              
  Lines                 698    701       +3     
  Branches              135    136       +1     
================================================
+ Hits                  684    687       +3     
  Misses                 12     12              
  Partials                2      2
```


| [Impacted Files](https://codecov.io/gh/vigetlabs/microcosm/pull/487?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [packages/microcosm/src/domain.js](https://codecov.io/gh/vigetlabs/microcosm/pull/487/diff?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtL3NyYy9kb21haW4uanM=) | `97.91% <ø> (-0.05%)` | :arrow_down: |
| [packages/microcosm/src/subject.js](https://codecov.io/gh/vigetlabs/microcosm/pull/487/diff?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtL3NyYy9zdWJqZWN0Lmpz) | `100% <100%> (ø)` | :arrow_up: |
| [packages/microcosm/src/microcosm.js](https://codecov.io/gh/vigetlabs/microcosm/pull/487/diff?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtL3NyYy9taWNyb2Nvc20uanM=) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/vigetlabs/microcosm/pull/487?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/487?src=pr&el=footer). Last update [91553f9...5dd8ffa](https://codecov.io/gh/vigetlabs/microcosm/pull/487?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 f7af5cec..625c70ee 100644
--- a/packages/microcosm/src/domain.js
+++ b/packages/microcosm/src/domain.js
@@ -126,10 +126,6 @@ export class Domain<State: mixed = null> extends Subject {
     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/microcosm.js b/packages/microcosm/src/microcosm.js
index 0f34858b..a9bb8ddc 100644
--- a/packages/microcosm/src/microcosm.js
+++ b/packages/microcosm/src/microcosm.js
@@ -2,6 +2,7 @@
 import { installDevtools } from './install-devtools'
 import { History } from './history'
 import { Subject } from './subject'
+import { Observable } from './observable'
 import { Domain } from './domain'
 import { Effect } from './effect'
 import { merge } from './data'
@@ -14,7 +15,7 @@ const DEFAULTS = {
 
 export class Microcosm extends Subject {
   history: History
-  domains: { [string]: Domain<*> }
+  domains: { [string]: Subject }
   options: Object
 
   static defaults: Object
@@ -59,7 +60,7 @@ export class Microcosm extends Subject {
     // NOOP
   }
 
-  addDomain(key: string, blueprint: *, config?: *): Domain<*> {
+  addDomain(key: string, blueprint: *, config?: *): Subject {
     console.assert(
       key in this.domains === false,
       'Can not add domain for "' + key + '". This state is already managed.'
@@ -67,13 +68,17 @@ export class Microcosm extends Subject {
 
     console.assert(key && key.length > 0, 'Can not add domain to root level.')
 
-    let options = merge(this.options, blueprint.defaults, { key }, config)
-    let Entity = Domain.from(blueprint)
-    let domain = new Entity(this, options)
+    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)
 
-    this.domains[key] = domain
+      this.domains[key] = new Entity(this, options)
+    }
 
-    return domain
+    return this.domains[key]
   }
 
   addEffect(blueprint: *, config?: *): Effect {
diff --git a/packages/microcosm/src/subject.js b/packages/microcosm/src/subject.js
index 9ed260a0..5fa0f981 100644
--- a/packages/microcosm/src/subject.js
+++ b/packages/microcosm/src/subject.js
@@ -145,6 +145,10 @@ export class Subject {
     }
   }
 
+  valueOf() {
+    return this.payload
+  }
+
   map(fn: (*) => *, scope: any): Observable {
     return new Observable(observer => {
       if (this.started) {
@@ -160,12 +164,13 @@ export class Subject {
   }
 
   static hash(obj: *): Subject {
+    let subject = new Subject()
+
     if (getObservable(obj)) {
-      return obj
+      obj.subscribe(subject)
+      return subject
     }
 
-    let subject = new Subject()
-
     if (obj == null || typeof obj !== 'object') {
       subject.next(obj)
       subject.complete()


Test patch:
diff --git a/packages/microcosm/test/integration/observable-domains.test.js b/packages/microcosm/test/integration/observable-domains.test.js
new file mode 100644
index 00000000..c8de629e
--- /dev/null
+++ b/packages/microcosm/test/integration/observable-domains.test.js
@@ -0,0 +1,42 @@
+import { Microcosm, Observable } from 'microcosm'
+import { EventEmitter } from 'events'
+
+describe('Observable domains', function() {
+  it('can synchronize state from an outside source', function() {
+    let repo = new Microcosm()
+    let handler = jest.fn()
+
+    let emitter = new EventEmitter()
+
+    let socket = new Observable(observer => {
+      emitter.on('change', observer.next)
+      return () => emitter.removeListener('change', observer.next)
+    })
+
+    repo.addDomain('socket', socket).subscribe(handler)
+
+    emitter.emit('change', 'test')
+
+    expect(handler).toHaveBeenCalledTimes(1)
+    expect(handler).toHaveBeenCalledWith('test')
+  })
+
+  it('calls the cleanup method when the repo tears down', function() {
+    let repo = new Microcosm()
+    let handler = jest.fn()
+
+    let emitter = new EventEmitter()
+    let socket = new Observable(observer => {
+      emitter.on('change', observer.next)
+      return () => emitter.removeListener('change', observer.next)
+    })
+
+    repo.addDomain('socket', socket).subscribe(handler)
+
+    repo.complete()
+
+    emitter.emit('change', 'test')
+
+    expect(handler).not.toHaveBeenCalled()
+  })
+})
diff --git a/packages/microcosm/test/unit/history/archive.test.js b/packages/microcosm/test/unit/history/archive.test.js
index 5668f3d0..47251792 100644
--- a/packages/microcosm/test/unit/history/archive.test.js
+++ b/packages/microcosm/test/unit/history/archive.test.js
@@ -48,10 +48,10 @@ describe('History::archive', function() {
 
     repo.history.archive()
 
-    expect(`${repo.history.root}`).toBe('second')
+    expect(repo.history.root.toString()).toBe('second')
 
     // This is two because we checked out two earlier
-    expect(`${repo.history.head}`).toBe('second')
+    expect(repo.history.head.toString()).toBe('second')
   })
 
   it('builds up back-pressure', function() {
diff --git a/packages/microcosm/test/unit/microcosm/addDomain.test.js b/packages/microcosm/test/unit/microcosm/addDomain.test.js
index ebcd342b..c55d24c2 100644
--- a/packages/microcosm/test/unit/microcosm/addDomain.test.js
+++ b/packages/microcosm/test/unit/microcosm/addDomain.test.js
@@ -1,4 +1,4 @@
-import Microcosm, { reset } from 'microcosm'
+import { Microcosm, Observable, reset } from 'microcosm'
 
 describe('Microcosm::addDomain', function() {
   it('adding a domain backfills the initial state', function() {
@@ -30,6 +30,19 @@ describe('Microcosm::addDomain', function() {
     )
   })
 
+  it('can add observables as domains', function() {
+    let repo = new Microcosm()
+
+    repo.addDomain(
+      'test',
+      new Observable(observer => {
+        observer.next('test')
+      })
+    )
+
+    expect(repo.domains.test.valueOf()).toEqual('test')
+  })
+
   describe('forks', function() {
     it('receives initial state from parents', function() {
       let repo = new Microcosm()
diff --git a/packages/microcosm/test/unit/subject.test.js b/packages/microcosm/test/unit/subject.test.js
index e638fb82..58ebe13f 100644
--- a/packages/microcosm/test/unit/subject.test.js
+++ b/packages/microcosm/test/unit/subject.test.js
@@ -192,11 +192,11 @@ describe('Subject', function() {
 
   describe('toString', function() {
     it('stringifies to its tag name', () => {
-      expect(`${new Subject(null, { tag: 'foobar' })}`).toBe('foobar')
+      expect(new Subject(null, { tag: 'foobar' }).toString()).toBe('foobar')
     })
 
     it('stringifies to "Subject" when given no tag', () => {
-      expect(`${new Subject(null)}`).toBe('Subject')
+      expect(new Subject(null).toString()).toBe('Subject')
     })
   })