pouchdb-community / ember-pouch

PouchDB/CouchDB adapter for Ember Data
Apache License 2.0
281 stars 76 forks source link

Ember Pouch Build Status GitHub version Ember Observer Score

Ember Pouch is a PouchDB/CouchDB adapter for Ember Data 3.16+. For older Ember Data versions down to 2.0+ use Ember Pouch version 7.0 For Ember Data versions lower than 2.0+ use Ember Pouch version 3.2.2.

With Ember Pouch, all of your app's data is automatically saved on the client-side using IndexedDB or WebSQL, and you just keep using the regular Ember Data store API. This data may be automatically synced to a remote CouchDB (or compatible servers) using PouchDB replication.

What's the point?

  1. You don't need to write any server-side logic. Just use CouchDB.

  2. Data syncs automatically.

  3. Your app works offline, and requests are super fast, because they don't need the network.

For more on PouchDB, check out pouchdb.com.

Install and setup

ember install ember-pouch

For ember-data < 2.0:

ember install ember-pouch@3.2.2

For ember-cli < 1.13.0:

npm install ember-pouch@3.2.2 --save-dev

This provides

Ember-Pouch requires you to add a @attr('string') rev field to all your models. This is for PouchDB/CouchDB to handle revisions:

// app/models/todo.js

import Model, { attr } from '@ember-data/model';

export default class TodoModel extends Model {
  @attr('string') title;
  @attr('boolean') isCompleted;
  @attr('string') rev; // <-- Add this to all your models
}

If you like, you can also use Model from Ember-Pouch that ships with the rev attribute:

// app/models/todo.js

import { attr } from '@ember-data/model';
import { Model } from 'ember-pouch';

export default class TodoModel extends Model {
  @attr('string') title;
  @attr('boolean') isCompleted;
}

The installation creates a file adapters/application.js that you can use by default to setup the database connection. Look at the Adapter blueprint section to see the settings that you have to set in your config file to work with this adapter.
It also installs the required packages.

Configuring /app/adapters/application.js

A local PouchDB that syncs with a remote CouchDB looks like this:

// app/adapters/application.js

import PouchDB from 'ember-pouch/pouchdb';
import { Adapter } from 'ember-pouch';

let remote = new PouchDB('http://localhost:5984/my_couch');
let db = new PouchDB('local_pouch');

db.sync(remote, {
  live: true, // do a live, ongoing sync
  retry: true, // retry if the connection is lost
});

export default class ApplicationAdapter extends Adapter {
  db = db;
}

You can also turn on debugging:

import PouchDB from 'ember-pouch/pouchdb';

// For v7.0.0 and newer you must first load the 'pouchdb-debug' plugin
// see https://github.com/pouchdb/pouchdb/tree/39ac9a7a1f582cf7a8d91c6bf9caa936632283a6/packages/node_modules/pouchdb-debug
import pouchDebugPlugin from 'pouchdb-debug'; // (assumed available via ember-auto-import or shim)
PouchDB.plugin(pouchDebugPlugin);

PouchDB.debug.enable('*');

See the PouchDB sync API for full usage instructions.

EmberPouch Blueprints

Model

In order to create a model run the following command from the command line:

ember g pouch-model <model-name>

Replace <model-name> with the name of your model and the file will automatically be generated for you.

Adapter

You can now create an adapter using ember-cli's blueprint functionality. Once you've installed ember-pouch into your ember-cli app you can run the following command to automatically generate an adapter.

ember g pouch-adapter foo

Now you can store your localDb and remoteDb names in your ember-cli's config. Just add the following keys to the ENV object:

ENV.emberPouch.localDb = 'test';
ENV.emberPouch.remoteDb = 'http://localhost:5984/my_couch';

This blueprint is run on installation for the application adapter.

You can use multiple adapters, but be warned that doing the .plugin calls in multiple adapter files will result in errors: TypeError: Cannot redefine property: replicate. In this case it is better to move the PouchDB.plugin calls to a separate file.

Relationships

EmberPouch supports both hasMany and belongsTo relationships.

Don't save hasMany child ids

To be more in line with the normal ember data way of saving hasMany - belongsTo relationships, ember-pouch now has an option to not save the child ids on the hasMany side. This prevents the extra need to save the hasMany side as explained below. For a more detailed explanation please read the relational-pouch documentation

This new mode can be disabled for a hasMany relationship by specifying the option save: true on the relationship. An application wide setting named ENV.emberPouch.saveHasMany can also be set to true to make all hasMany relationships behave the old way.

Using this mode does impose a slight runtime overhead, since this will use db.find and database indexes to search for the child ids. The indexes are created automatically for you. But large changes to the model might require you to clean up old, unused indexes.

ℹ️ This mode is the default from version 5 onwards. Before that it was called dontsave and dontsavehasmany

Saving child ids

When you do save child ids on the hasMany side, you have to follow the directions below to make sure the data is saved correctly.

Adding entries

When saving a hasMany - belongsTo relationship, both sides of the relationship (the child and the parent) must be saved. Note that the parent needs to have been saved at least once prior to adding children to it.

// app/controllers/posts/post.js
import Controller from '@ember/controller';
import { action } from '@ember/object';

export default class PostController extends Controller {
  @action addComment(comment, author) {
    //Create the comment
    const comment = this.store.createRecord('comment', {
      comment: comment,
      author: author,
    });
    //Add our comment to our existing post
    this.model.comments.pushObject(comment);
    //Save the child then the parent
    comment.save().then(() => this.model.save());
  }
}

Removing child ids

When removing a hasMany - belongsTo relationship, the children must be removed prior to the parent being removed.

// app/controller/posts/admin.js
import Controller from '@ember/controller';
import { action } from '@ember/object';
import { all } from 'rsvp';

export default class AdminController extends Controller {
  @action deletePost(post) {
    //collect the promises for deletion
    let deletedComments = [];
    //get and destroy the posts comments
    post.comments.then((comments) => {
      comments.map((comment) => {
        deletedComments.push(comment.destroyRecord());
      });
    });
    //Wait for comments to be destroyed then destroy the post
    all(deletedComments).then(() => {
      post.destroyRecord();
    });
  }
}

Query and QueryRecord

query and queryRecord are relying on pouchdb-find

db.createIndex(index [, callback])

Create an index if it doesn't exist.

// app/adapters/application.js
function createDb() {
  ...

  db.createIndex({
    index: {
      fields: ['data.name']
    }
  }).then((result) => {
    // {'result': 'created'} index was created
  });

  return db;
};

store.query(model, options)

Find all docs where doc.name === 'Mario'

// app/routes/smasher.js
import Route from '@ember/routing/route';

export default class SmasherRoute extends Route {
  model() {
    return this.store.query('smasher', {
      filter: { name: 'Mario' },
    });
  }
}

Find all docs where doc.name === 'Mario' and doc.debut > 1990:

// app/routes/smasher.js
import Route from '@ember/routing/route';

export default class SmasherRoute extends Route {
  model() {
    return this.store.query('smasher',  {
      filter: {
        name: 'Mario'
        debut: { $gt: 1990 }
      }
    });
  }
}

Sorted by doc.debut descending.

// app/routes/smasher.js
import Route from '@ember/routing/route';

export default class SmasherRoute extends Route {
  model() {
    return this.store.query('smasher', {
      filter: {
        name: 'Mario',
        debut: { $gte: null },
      },
      sort: [{ debut: 'desc' }],
    });
  }
}

Limit to 5 documents.

// app/routes/smasher.js
import Route from '@ember/routing/route';

export default class SmasherRoute extends Route {
  model() {
    return this.store.query('smasher', {
      filter: {
        name: 'Mario',
        debut: { $gte: null },
      },
      sort: [{ debut: 'desc' }],
      limit: 5,
    });
  }
}

Skip the first 5 documents

// app/routes/smasher.js
import Route from '@ember/routing/route';

export default class SmasherRoute extends Route {
  model() {
    return this.store.query('smasher', {
      filter: {
        name: 'Mario',
        debut: { $gte: null },
      },
      sort: [{ debut: 'desc' }],
      skip: 5,
    });
  }
}

Note that this query would require a custom index including both fields data.name and data.debut. Any field in sort must also be included in filter. Only $eq, $gt, $gte, $lt, and $lte can be used when matching a custom index.

store.queryRecord(model, options)

Find one document where doc.name === 'Mario'

// app/routes/smasher.js
import Route from '@ember/routing/route';

export default class SmasherRoute extends Route {
  model() {
    return this.store.queryRecord('smasher', {
      filter: { name: 'Mario' },
    });
  }
}

Attachments

Ember-Pouch provides an attachments transform for your models, which makes working with attachments as simple as working with any other field.

Add a DS.attr('attachments') field to your model. Provide a default value for it to be an empty array.

// myapp/models/photo-album.js
import { attr } from '@ember-data/model';
import { Model } from 'ember-pouch';

export default class PhotoAlbumModel extends Model {
  @attr('attachments', {
    defaultValue: function () {
      return [];
    },
  })
  photos;
}

Here, instances of PhotoAlbum have a photos field, which is an array of plain Ember.Objects, which have a .name and .content_type. Non-stubbed attachment also have a .data field; and stubbed attachments have a .stub instead.

<ul>
  {{#each myalbum.photos as |photo|}}
    <li>{{photo.name}}</li>
  {{/each}}
</ul>

Attach new files by adding an Ember.Object with a .name, .content_type and .data to array of attachments.

// somewhere in your controller/component:
myAlbum.photos.addObject(
  Ember.Object.create({
    name: 'kitten.jpg',
    content_type: 'image/jpg',
    data: btoa('hello world'), // base64-encoded `String`, or a DOM `Blob`, or a `File`
  })
);

Sample app

Tom Dale's blog example using Ember CLI and EmberPouch: broerse/ember-cli-blog

Notes

LocalStorage

Currently PouchDB doesn't use LocalStorage unless you include an experimental plugin. Amazingly, this is only necessary to support IE ≤ 9.0 and Opera Mini. It's recommended you read more about this, what storage mechanisms modern browsers now support, and using SQLite in Cordova on the PouchDB adapters page.

CouchDB

From day one, CouchDB and its protocol have been designed to be always Available and handle Partitioning over the network well (AP in the CAP theorem). PouchDB/CouchDB gives you a solid way to manage conflicts. It is "eventually consistent," but CouchDB has an API for listening to changes to the database, which can be then pushed down to the client in real-time.

To learn more about how CouchDB sync works, check out the PouchDB guide to replication.

Sync and the ember-data store

Out of the box, ember-pouch includes a PouchDB change listener that automatically updates any records your app has loaded when they change due to a sync. It also unloads records that are removed due to a sync.

However, ember-pouch does not automatically load new records that arrive during a sync. The records are saved in the local database, but ember-data is not told to load them into memory. Automatically loading every new record works well with a small number of records and a limited number of models. As an app grows, automatically loading every record will negatively impact app responsiveness during syncs (especially the first sync). To avoid puzzling slowdowns, ember-pouch only automatically reloads records you have already used ember-data to load.

If you have a model or two that you know will always have a small number of records, you can tell ember-data to automatically load them into memory as they arrive. Your PouchAdapter subclass has a method unloadedDocumentChanged, which is called when a document is received during sync that has not been loaded into the ember-data store. In your subclass, you can implement the following to load it automatically:

  unloadedDocumentChanged: function(obj) {
    let recordTypeName = this.getRecordTypeName(this.store.modelFor(obj.type));
    this.db.rel.find(recordTypeName, obj.id).then((doc) => {
      this.store.pushPayload(recordTypeName, doc);
    });
  },

Plugins

With PouchDB, you also get access to a whole host of PouchDB plugins.

For example, to use the pouchdb-authentication plugin like this using ember-auto-import:

import PouchDB from 'ember-pouch/pouchdb';
import auth from 'pouchdb-authentication';

PouchDB.plugin(auth);

Relational Pouch

Ember Pouch is really just a thin layer of Ember-y goodness over Relational Pouch. Before you file an issue, check to see if it's more appropriate to file over there.

Offline First

Saving data locally using PouchDB is one part of making a web application Offline First. However, you will also need to make your static assets available offline.

There are two possible approaches to this. The first one is using the Application Cache (AP) feature. The second one is using Service Workers (SW). The Application Cache specification has been removed from the Web standards. Mozilla now recommends to use Service Workers instead.

Most browser vendors still provide support for Application Cache and are in the process of implementing Service Workers. So depending on the browsers you target, you should go for one or the other. You can track the progress via caniuse.com.

1. Application Cache

You can use broccoli-manifest to create an HTML5 appcache.manifest file. This By default, will allow your index.html and assets directory to load even if the user is offline.

2. Service Workers

We recommend using Ember Service Worker to get started with Service Workers for your web application. The website provide's an easy to follow guide on getting started with the addon.

You can also take a look at Martin Broerse his ember-cli-blog configuration for the plugin.

⚠️ iOS does not yet support Service Workers. If you want to make your assets available offline for an iPhone or iPad, you have to go for the Application Cache strategy. Since Jan 10, 2018, Safari Technology Preview does support Service Workers. It's expected to land in iOS 12, but there's no certainity about that.

Security

An easy way to secure your Ember Pouch-using app is to ensure that data can only be fetched from CouchDB – not from some other server (e.g. in an XSS attack).

You can use the content-security-policy plugin to enable Content Security Policy in Ember CLI. You also will have to set the CSP HTTP header on your backend in production.

To use, add a Content Security Policy whitelist entry to /config/environment.js:

ENV.contentSecurityPolicy = {
  'connect-src': "'self' http://your_couch_host.com:5984",
};

CORS setup (important!)

To automatically set up your remote CouchDB to use CORS, you can use the plugin add-cors-to-couchdb:

npm install -g add-cors-to-couchdb
add-cors-to-couchdb http://your_couch_host.com:5984 -u your_username -p your_password

Multiple models for the same data

Ember-data can be slow to load large numbers of records which have lots of relationships. If you run into this problem, you can define multiple models and have them all point to the same set of records by defining documentType on the model class. Example (in an ember-cli app):

// app/models/post.js

import { attr, belongsTo, hasMany } from '@ember-data/model';
import { Model } from 'ember-pouch';

export default class PostModel extends Model {
  @attr('string') title;
  @attr('string') text;

  @belongsTo('author') author;
  @hasMany('comments') comments;
}

// app/models/post-summary.js

import { attr } from '@ember-data/model';
import { Model } from 'ember-pouch';

export default class PostSummaryModel extends Model {
  @attr('string') title;
}

PostSummary.reopenClass({
  documentType: 'post'
})

export default PostSummary;

The value for documentType is the camelCase version of the primary model name.

For best results, only create/update records using the full model definition. Treat the others as read-only.

Multiple databases for the same model

In some cases it might be desirable (security related, where you want a given user to only have some informations stored on his computer) to have multiple databases for the same model of data.

Ember-Pouch allows you to dynamically change the database a model is using by calling the function changeDb on the adapter.

function changeProjectDatabase(dbName, dbUser, dbPassword) {
  // CouchDB is serving at http://localhost:5455
  let remote = new PouchDB('http://localhost:5455/' + dbName);
  // here we are using pouchdb-authentication for credential supports
  remote.login(dbUser, dbPassword).then(function (user) {
    let db = new PouchDB(dbName);
    db.sync(remote, { live: true, retry: true });
    // grab the adapter, it can be any ember-pouch adapter.
    let adapter = this.store.adapterFor('project');
    // this is where we told the adapter to change the current database.
    adapter.changeDb(db);
  });
}

Eventually Consistent

Following the CouchDB consistency model, we have introduced ENV.emberPouch.eventuallyConsistent. This feature is on by default. So if you want the old behavior you'll have to disable this flag.

findRecord now returns a long running Promise if the record is not found. It only rejects the promise if a deletion of the record is found. Otherwise this promise will wait for eternity to resolve. This makes sure that belongsTo relations that have been loaded in an unexpected order will still resolve correctly. This makes sure that ember-data does not set the belongsTo to null if the Pouch replicate would have loaded the related object later on. (This only works for async belongsTo, sync versions will need this to be implemented in relational-pouch)

Upgrading

Version 8

Version 8 introduces the custom PouchDB setup in the adapter instead of having a default setup in addon/pouchdb.js. So if you used import PouchDB from 'ember-pouch/pouchdb' in your files, you now have to make your own 'PouchDB bundle' in the same way we do it in the default adapter blueprint. The simplest way to do this is to run the blueprint by doing ember g ember-pouch (which will overwrite your application adapter, so make sure to commit that file first) and take the import PouchDB from... until the final .plugin(...) line and put that into your original adapter (or a separate file if you use more than one adapter). You can also copy the lines from the blueprint file in git

We also removed the pouchdb-browser package and relational-pouch as a package.json dependency, so you will have to install the packages since the lines above depend upon.
npm install pouchdb-core pouchdb-adapter-indexeddb pouchdb-adapter-http pouchdb-mapreduce pouchdb-replication pouchdb-find relational-pouch --save-dev

This way you can now decide for yourself which PouchDB plugins you want to use. You can even remove the http or indexeddb ones if you just want to work offline or online.

Installation

Running

Running Tests

Building

For more information on using ember-cli, visit http://www.ember-cli.com/.

Credits

This project was originally based on the ember-data-hal-adapter by @locks, and I really benefited from his guidance during its creation.

And of course thanks to all our wonderful contributors, here and in Relational Pouch!

Changelog