Sync multiple AngularJS apps without server via PouchDB

Nowadays several solutions exist to keep web apps in sync and/or store data offline with JavaScript:

I could go on with that list but you get the idea. Some of the mentioned technologies are more powerful and have more features out of the box than others. They usually need a server that runs your app and takes care of all the business logic. In this post I will demonstrate how to build a web app that syncs data automatically, runs on all modern platforms, stores data persistently and doesn't need a server. The app is powered by AngularJS and PouchDB.

The video shows the final app in action. On the left side we have Chrome and Firefox showing our index.html from file and on the right side you can see Futon, the admin panel for CouchDB.



Install CouchDB

First of all you have to install CouchDB. You could use some third party instance provider like Cloudant or Iris Couch. However our final example won't work with them because we need to have CORS enabled on our CouchDB. CORS was introduced with v1.3. Cloudant instances are running on v1.0.2 whereas Iris Couch actually provides v1.3. Sadly you cannot enable CORS by hand. That's why we will work with our own instance.

Go to couchdb.apache.org and click on the big fat download button. Download the package for your operating system, select a mirror, wait and then open CouchDB. On my MacBook I get a little CouchDB icon on my Menu Bar. Click on it and select Open admin console. Or just open your browser and navigate to 127.0.0.1:5984/_utils. You are now inside Futon, which is the admin tool for CouchDB.

First of all we have to enable CORS. Go to section httpd and set enable_cors to true. Navigate to the bottom of the page and click on Add a new section. Enter the following into the form that pops up

  • section: cors
  • option: origins
  • value: *

Now let's create our database. Click on Create database ... and enter ng-db. The name is important and we have to use the exact name later on for the client code. That's it! Our CouchDB instance is running, CORS is enabled and we have a database to work with. Next install PouchDB.

Download PouchDB

To add PouchDB to your app you simply have to download the JS file. As always I recommend working with the non-minified version during development and switch to the min version for deployment. Include the .js file inside your index.html.

<script src="pouchdb.js"></script>

Done. We can now start using PouchDB in our app.

Create AngularJS app

Activate AngularJS as shown in the docs.

var myApp = angular.module('myApp', []);

PouchDB service

The first service we create simply makes the PouchDB instance available inside the AngularJS world. It also turns on continuous replication so changes are automatically synced between database and client no matter where they occur.

myApp.factory('myPouch', [function() {

  var mydb = new Pouch('ng-pouch');
  Pouch.replicate('ng-pouch', 'http://127.0.0.1:5984/ng-db', {continuous: true});
  Pouch.replicate('http://127.0.0.1:5984/ng-db', 'ng-pouch', {continuous: true});
  return mydb;

}]);

PouchDB promises wrapper

Our second service is a simple wrapper around PouchDB's native API to save and remove documents. The service provides two helper functions return() and remove(id) that both return promises. That keeps the async code nice and tidy.

myApp.factory('pouchWrapper', ['$q', '$rootScope', 'myPouch', function($q, $rootScope, myPouch) {

  return {
    add: function(text) {
      var deferred = $q.defer();
      var doc = {
        type: 'todo',
        text: text
      };
      myPouch.post(doc, function(err, res) {
        $rootScope.$apply(function() {
          if (err) {
            deferred.reject(err)
          } else {
            deferred.resolve(res)
          }
        });
      });
      return deferred.promise;
    },
    remove: function(id) {
      var deferred = $q.defer();
      myPouch.get(id, function(err, doc) {
        $rootScope.$apply(function() {
          if (err) {
            deferred.reject(err);
          } else {
            myPouch.remove(doc, function(err, res) {
              $rootScope.$apply(function() {
                if (err) {
                  deferred.reject(err)
                } else {
                  deferred.resolve(res)
                }
              });
            });
          }
        });
      });
      return deferred.promise;
    }
  }

}]);

PouchDB event listener

Our last service is a listener that emits events whenever PouchDB fires the onChange event. It either emits newTodo when a new document is added to the database or delTodo when a document is deleted. The change object coming from PouchDB looks like the following

{
  "id": "6F48205D-E97B-4621-ACAD-4CD3DFAC074E",
  "seq": 1,
  "changes": [{"rev":"2-96ea3cf93a75c6510c08c95e42686aa1"}],
  "deleted": true
} 

From the key deleted we get a Boolean value that tells us if the change was a deletion or an addition to our database. If the value is true we know that an object was deleted from our database and we therefore emit a delTodo event with the document id. If the value is false we unfortunately don't get the new object directly from the onChange handler. We only get the document id and have to manually get it via GET request. At the end we fire a newTodo event with the new document as data.

myApp.factory('listener', ['$rootScope', 'myPouch', function($rootScope, myPouch) {

  myPouch.changes({
    continuous: true,
    onChange: function(change) {
      if (!change.deleted) {
        $rootScope.$apply(function() {
          myPouch.get(change.id, function(err, doc) {
            $rootScope.$apply(function() {
              if (err) console.log(err);
              $rootScope.$broadcast('newTodo', doc);
            })
          });
        })
      } else {
        $rootScope.$apply(function() {
          $rootScope.$broadcast('delTodo', change.id);
        });
      }
    }
  })
}]);

Main controller

Our controller combines the three services and creates a link to our view. First of all we create an empty array that will hold our todo objects. The submit() function is executed whenever the Add button is clicked. A click on the small cross calls the remove(id) function.

At the end of our controller we have the listener for our two custom events newTodo and delTodo. It adds or removes items from the todos array.

myApp.controller('TodoCtrl', ['$scope', 'listener', 'pouchWrapper', function($scope, listener, pouchWrapper) {

  $scope.submit = function() {
    pouchWrapper.add($scope.text).then(function(res) {
      $scope.text = '';
    }, function(reason) {
      console.log(reason);
    })
  };

  $scope.remove = function(id) {
    pouchWrapper.remove(id).then(function(res) {
//      console.log(res);
    }, function(reason) {
      console.log(reason);
    })
  };

  $scope.todos = [];

  $scope.$on('newTodo', function(event, todo) {
    $scope.todos.push(todo);
  });

  $scope.$on('delTodo', function(event, id) {
    for (var i = 0; i<$scope.todos.length; i++) {
      if ($scope.todos[i]._id === id) {
        $scope.todos.splice(i,1);
      }
    }
  });

}]);

One thing I haven't figured out yet is an error message similar to:

GET http://127.0.0.1:5984/ng-db/_local%2F4e458454e3c7031672110dc4a02c72f4 404 (Object Not Found) 

The rest works absolutely fine and although I get this error message I can't see any problems during sync.

Conclusion

We used AngularJS and PouchDB to build a small app that syncs serverless and stores data persistently. Changes are distributed automatically. They can be made inside the database, on any client or on any third party device that pushes changes to the connected CouchDB. AngularJS events update our models in the controller that pushes changes to our views via two-way data binding. It is possible to add todos to the app when offline and as soon you are back online all data is synced to the connected devices. For more information about the topic visit nobackend.org.

Google
comments powered by Disqus