Bug 1331604 - Upgrade kinto libraries (r?glasserc) draft
authorMathieu Leplatre <mathieu@mozilla.com>
Wed, 18 Jan 2017 14:53:52 +0100
changeset 463091 1579c47aea46600d16e9316e05bcbfefc7d0b4ea
parent 462769 80eac484366ad881c6a10bf81e8d9b8f7a676c75
child 463092 6c681a3e018188cd3613ffb29cbbbee30d027347
push id41950
push usermleplatre@mozilla.com
push dateWed, 18 Jan 2017 14:00:38 +0000
reviewersglasserc
bugs1331604
milestone53.0a1
Bug 1331604 - Upgrade kinto libraries (r?glasserc) MozReview-Commit-ID: AfbkZozKDzb
services/common/kinto-http-client.js
services/common/kinto-offline-client.js
services/common/tests/unit/test_storage_adapter.js
--- a/services/common/kinto-http-client.js
+++ b/services/common/kinto-http-client.js
@@ -11,21 +11,22 @@
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
 
 /*
  * This file is generated from kinto-http.js - do not modify directly.
  */
+const global = this;
 
 this.EXPORTED_SYMBOLS = ["KintoHttpClient"];
 
 /*
- * Version 2.0.0 - 61435f3
+ * Version 2.7.0 - dae7787
  */
 
 (function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.KintoHttpClient = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
 /*
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
@@ -71,27 +72,246 @@ let KintoHttpClient = class KintoHttpCli
 // This fixes compatibility with CommonJS required by browserify.
 // See http://stackoverflow.com/questions/33505992/babel-6-changes-how-it-exports-default/33683495#33683495
 
 exports.default = KintoHttpClient;
 if (typeof module === "object") {
   module.exports = KintoHttpClient;
 }
 
-},{"../src/base":2}],2:[function(require,module,exports){
+},{"../src/base":4}],2:[function(require,module,exports){
+
+var rng;
+
+var crypto = global.crypto || global.msCrypto; // for IE 11
+if (crypto && crypto.getRandomValues) {
+  // WHATWG crypto-based RNG - http://wiki.whatwg.org/wiki/Crypto
+  // Moderately fast, high quality
+  var _rnds8 = new Uint8Array(16);
+  rng = function whatwgRNG() {
+    crypto.getRandomValues(_rnds8);
+    return _rnds8;
+  };
+}
+
+if (!rng) {
+  // Math.random()-based (RNG)
+  //
+  // If all else fails, use Math.random().  It's fast, but is of unspecified
+  // quality.
+  var  _rnds = new Array(16);
+  rng = function() {
+    for (var i = 0, r; i < 16; i++) {
+      if ((i & 0x03) === 0) r = Math.random() * 0x100000000;
+      _rnds[i] = r >>> ((i & 0x03) << 3) & 0xff;
+    }
+
+    return _rnds;
+  };
+}
+
+module.exports = rng;
+
+
+},{}],3:[function(require,module,exports){
+//     uuid.js
+//
+//     Copyright (c) 2010-2012 Robert Kieffer
+//     MIT License - http://opensource.org/licenses/mit-license.php
+
+// Unique ID creation requires a high quality random # generator.  We feature
+// detect to determine the best RNG source, normalizing to a function that
+// returns 128-bits of randomness, since that's what's usually required
+var _rng = require('./rng');
+
+// Maps for number <-> hex string conversion
+var _byteToHex = [];
+var _hexToByte = {};
+for (var i = 0; i < 256; i++) {
+  _byteToHex[i] = (i + 0x100).toString(16).substr(1);
+  _hexToByte[_byteToHex[i]] = i;
+}
+
+// **`parse()` - Parse a UUID into it's component bytes**
+function parse(s, buf, offset) {
+  var i = (buf && offset) || 0, ii = 0;
+
+  buf = buf || [];
+  s.toLowerCase().replace(/[0-9a-f]{2}/g, function(oct) {
+    if (ii < 16) { // Don't overflow!
+      buf[i + ii++] = _hexToByte[oct];
+    }
+  });
+
+  // Zero out remaining bytes if string was short
+  while (ii < 16) {
+    buf[i + ii++] = 0;
+  }
+
+  return buf;
+}
+
+// **`unparse()` - Convert UUID byte array (ala parse()) into a string**
+function unparse(buf, offset) {
+  var i = offset || 0, bth = _byteToHex;
+  return  bth[buf[i++]] + bth[buf[i++]] +
+          bth[buf[i++]] + bth[buf[i++]] + '-' +
+          bth[buf[i++]] + bth[buf[i++]] + '-' +
+          bth[buf[i++]] + bth[buf[i++]] + '-' +
+          bth[buf[i++]] + bth[buf[i++]] + '-' +
+          bth[buf[i++]] + bth[buf[i++]] +
+          bth[buf[i++]] + bth[buf[i++]] +
+          bth[buf[i++]] + bth[buf[i++]];
+}
+
+// **`v1()` - Generate time-based UUID**
+//
+// Inspired by https://github.com/LiosK/UUID.js
+// and http://docs.python.org/library/uuid.html
+
+// random #'s we need to init node and clockseq
+var _seedBytes = _rng();
+
+// Per 4.5, create and 48-bit node id, (47 random bits + multicast bit = 1)
+var _nodeId = [
+  _seedBytes[0] | 0x01,
+  _seedBytes[1], _seedBytes[2], _seedBytes[3], _seedBytes[4], _seedBytes[5]
+];
+
+// Per 4.2.2, randomize (14 bit) clockseq
+var _clockseq = (_seedBytes[6] << 8 | _seedBytes[7]) & 0x3fff;
+
+// Previous uuid creation time
+var _lastMSecs = 0, _lastNSecs = 0;
+
+// See https://github.com/broofa/node-uuid for API details
+function v1(options, buf, offset) {
+  var i = buf && offset || 0;
+  var b = buf || [];
+
+  options = options || {};
+
+  var clockseq = options.clockseq !== undefined ? options.clockseq : _clockseq;
+
+  // UUID timestamps are 100 nano-second units since the Gregorian epoch,
+  // (1582-10-15 00:00).  JSNumbers aren't precise enough for this, so
+  // time is handled internally as 'msecs' (integer milliseconds) and 'nsecs'
+  // (100-nanoseconds offset from msecs) since unix epoch, 1970-01-01 00:00.
+  var msecs = options.msecs !== undefined ? options.msecs : new Date().getTime();
+
+  // Per 4.2.1.2, use count of uuid's generated during the current clock
+  // cycle to simulate higher resolution clock
+  var nsecs = options.nsecs !== undefined ? options.nsecs : _lastNSecs + 1;
+
+  // Time since last uuid creation (in msecs)
+  var dt = (msecs - _lastMSecs) + (nsecs - _lastNSecs)/10000;
+
+  // Per 4.2.1.2, Bump clockseq on clock regression
+  if (dt < 0 && options.clockseq === undefined) {
+    clockseq = clockseq + 1 & 0x3fff;
+  }
+
+  // Reset nsecs if clock regresses (new clockseq) or we've moved onto a new
+  // time interval
+  if ((dt < 0 || msecs > _lastMSecs) && options.nsecs === undefined) {
+    nsecs = 0;
+  }
+
+  // Per 4.2.1.2 Throw error if too many uuids are requested
+  if (nsecs >= 10000) {
+    throw new Error('uuid.v1(): Can\'t create more than 10M uuids/sec');
+  }
+
+  _lastMSecs = msecs;
+  _lastNSecs = nsecs;
+  _clockseq = clockseq;
+
+  // Per 4.1.4 - Convert from unix epoch to Gregorian epoch
+  msecs += 12219292800000;
+
+  // `time_low`
+  var tl = ((msecs & 0xfffffff) * 10000 + nsecs) % 0x100000000;
+  b[i++] = tl >>> 24 & 0xff;
+  b[i++] = tl >>> 16 & 0xff;
+  b[i++] = tl >>> 8 & 0xff;
+  b[i++] = tl & 0xff;
+
+  // `time_mid`
+  var tmh = (msecs / 0x100000000 * 10000) & 0xfffffff;
+  b[i++] = tmh >>> 8 & 0xff;
+  b[i++] = tmh & 0xff;
+
+  // `time_high_and_version`
+  b[i++] = tmh >>> 24 & 0xf | 0x10; // include version
+  b[i++] = tmh >>> 16 & 0xff;
+
+  // `clock_seq_hi_and_reserved` (Per 4.2.2 - include variant)
+  b[i++] = clockseq >>> 8 | 0x80;
+
+  // `clock_seq_low`
+  b[i++] = clockseq & 0xff;
+
+  // `node`
+  var node = options.node || _nodeId;
+  for (var n = 0; n < 6; n++) {
+    b[i + n] = node[n];
+  }
+
+  return buf ? buf : unparse(b);
+}
+
+// **`v4()` - Generate random UUID**
+
+// See https://github.com/broofa/node-uuid for API details
+function v4(options, buf, offset) {
+  // Deprecated - 'format' argument, as supported in v1.2
+  var i = buf && offset || 0;
+
+  if (typeof(options) == 'string') {
+    buf = options == 'binary' ? new Array(16) : null;
+    options = null;
+  }
+  options = options || {};
+
+  var rnds = options.random || (options.rng || _rng)();
+
+  // Per 4.4, set bits for version and `clock_seq_hi_and_reserved`
+  rnds[6] = (rnds[6] & 0x0f) | 0x40;
+  rnds[8] = (rnds[8] & 0x3f) | 0x80;
+
+  // Copy bytes to buffer, if provided
+  if (buf) {
+    for (var ii = 0; ii < 16; ii++) {
+      buf[i + ii] = rnds[ii];
+    }
+  }
+
+  return buf || unparse(rnds);
+}
+
+// Export public API
+var uuid = v4;
+uuid.v1 = v1;
+uuid.v4 = v4;
+uuid.parse = parse;
+uuid.unparse = unparse;
+
+module.exports = uuid;
+
+},{"./rng":2}],4:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 exports.default = exports.SUPPORTED_PROTOCOL_VERSION = undefined;
 
 var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
 
-var _dec, _dec2, _dec3, _dec4, _dec5, _dec6, _desc, _value, _class;
+var _dec, _dec2, _dec3, _dec4, _dec5, _dec6, _dec7, _desc, _value, _class;
 
 var _utils = require("./utils");
 
 var _http = require("./http");
 
 var _http2 = _interopRequireDefault(_http);
 
 var _endpoint = require("./endpoint");
@@ -153,25 +373,26 @@ const SUPPORTED_PROTOCOL_VERSION = expor
  * @example
  * const client = new KintoClient("https://kinto.dev.mozaws.net/v1");
  * client.bucket("default")
 *    .collection("my-blog")
 *    .createRecord({title: "First article"})
  *   .then(console.log.bind(console))
  *   .catch(console.error.bind(console));
  */
-let KintoClientBase = (_dec = (0, _utils.nobatch)("This operation is not supported within a batch operation."), _dec2 = (0, _utils.nobatch)("This operation is not supported within a batch operation."), _dec3 = (0, _utils.nobatch)("This operation is not supported within a batch operation."), _dec4 = (0, _utils.nobatch)("This operation is not supported within a batch operation."), _dec5 = (0, _utils.nobatch)("Can't use batch within a batch!"), _dec6 = (0, _utils.support)("1.4", "2.0"), (_class = class KintoClientBase {
+let KintoClientBase = (_dec = (0, _utils.nobatch)("This operation is not supported within a batch operation."), _dec2 = (0, _utils.nobatch)("This operation is not supported within a batch operation."), _dec3 = (0, _utils.nobatch)("This operation is not supported within a batch operation."), _dec4 = (0, _utils.nobatch)("This operation is not supported within a batch operation."), _dec5 = (0, _utils.nobatch)("Can't use batch within a batch!"), _dec6 = (0, _utils.capable)(["permissions_endpoint"]), _dec7 = (0, _utils.support)("1.4", "2.0"), (_class = class KintoClientBase {
   /**
    * Constructor.
    *
    * @param  {String}       remote  The remote URL.
    * @param  {Object}       [options={}]                  The options object.
    * @param  {Boolean}      [options.safe=true]           Adds concurrency headers to every requests.
    * @param  {EventEmitter} [options.events=EventEmitter] The events handler instance.
    * @param  {Object}       [options.headers={}]          The key-value headers to pass to each request.
+   * @param  {Object}       [options.retry=0]             Number of retries when request fails (default: 0)
    * @param  {String}       [options.bucket="default"]    The default bucket to use.
    * @param  {String}       [options.requestMode="cors"]  The HTTP request mode (from ES6 fetch spec).
    * @param  {Number}       [options.timeout=5000]        The requests timeout in ms.
    */
   constructor(remote, options = {}) {
     if (typeof remote !== "string" || !remote.length) {
       throw new Error("Invalid remote URL: " + remote);
     }
@@ -183,16 +404,17 @@ let KintoClientBase = (_dec = (0, _utils
     /**
      * Default request options container.
      * @private
      * @type {Object}
      */
     this.defaultReqOptions = {
       bucket: options.bucket || "default",
       headers: options.headers || {},
+      retry: options.retry || 0,
       safe: !!options.safe
     };
 
     this._options = options;
     this._requests = [];
     this._isBatch = !!options.batch;
 
     // public properties
@@ -328,19 +550,18 @@ let KintoClientBase = (_dec = (0, _utils
    *
    * @param  {Object}  [options={}] The request options.
    * @return {Promise<Object, Error>}
    */
   fetchServerInfo(options = {}) {
     if (this.serverInfo) {
       return Promise.resolve(this.serverInfo);
     }
-    return this.http.request(this.remote + (0, _endpoint2.default)("root"), {
-      headers: _extends({}, this.defaultReqOptions.headers, options.headers)
-    }).then(({ json }) => {
+    const reqOptions = this._getRequestOptions(options);
+    return this.http.request(this.remote + (0, _endpoint2.default)("root"), reqOptions).then(({ json }) => {
       this.serverInfo = json;
       return this.serverInfo;
     });
   }
 
   /**
    * Retrieves Kinto server settings.
    *
@@ -391,50 +612,51 @@ let KintoClientBase = (_dec = (0, _utils
    * Process batch requests, chunking them according to the batch_max_requests
    * server setting when needed.
    *
    * @param  {Array}  requests     The list of batch subrequests to perform.
    * @param  {Object} [options={}] The options object.
    * @return {Promise<Object, Error>}
    */
   _batchRequests(requests, options = {}) {
-    const headers = _extends({}, this.defaultReqOptions.headers, options.headers);
+    const reqOptions = this._getRequestOptions(options);
+    const { headers } = reqOptions;
     if (!requests.length) {
       return Promise.resolve([]);
     }
     return this.fetchServerSettings().then(serverSettings => {
       const maxRequests = serverSettings["batch_max_requests"];
       if (maxRequests && requests.length > maxRequests) {
         const chunks = (0, _utils.partition)(requests, maxRequests);
         return (0, _utils.pMap)(chunks, chunk => this._batchRequests(chunk, options));
       }
-      return this.execute({
+      return this.execute(_extends({}, reqOptions, {
         path: (0, _endpoint2.default)("batch"),
         method: "POST",
-        headers: headers,
         body: {
           defaults: { headers },
           requests: requests
         }
-      })
+      }))
       // we only care about the responses
       .then(({ responses }) => responses);
     });
   }
 
   /**
    * Sends batch requests to the remote server.
    *
    * Note: Reserved for internal use only.
    *
    * @ignore
    * @param  {Function} fn                        The function to use for describing batch ops.
    * @param  {Object}   [options={}]              The options object.
    * @param  {Boolean}  [options.safe]            The safe option.
    * @param  {String}   [options.bucket]          The bucket name option.
+   * @param  {String}   [options.collection]      The collection name option.
    * @param  {Object}   [options.headers]         The headers object option.
    * @param  {Boolean}  [options.aggregate=false] Produces an aggregated result object.
    * @return {Promise<Object, Error>}
    */
 
   batch(fn, options = {}) {
     const rootBatch = new KintoClientBase(this.remote, _extends({}, this._options, this._getRequestOptions(options), {
       batch: true
@@ -461,48 +683,130 @@ let KintoClientBase = (_dec = (0, _utils
   }
 
   /**
    * Executes an atomic HTTP request.
    *
    * @private
    * @param  {Object}  request             The request object.
    * @param  {Object}  [options={}]        The options object.
-   * @param  {Boolean} [options.raw=false] If true, resolve with full response object, including json body and headers instead of just json.
+   * @param  {Boolean} [options.raw=false] If true, resolve with full response
+   * @param  {Boolean} [options.stringify=true] If true, serialize body data to
+   * JSON.
    * @return {Promise<Object, Error>}
    */
-  execute(request, options = { raw: false }) {
+  execute(request, options = { raw: false, stringify: true }) {
+    const { raw, stringify } = options;
     // If we're within a batch, add the request to the stack to send at once.
     if (this._isBatch) {
       this._requests.push(request);
       // Resolve with a message in case people attempt at consuming the result
       // from within a batch operation.
       const msg = "This result is generated from within a batch " + "operation and should not be consumed.";
-      return Promise.resolve(options.raw ? { json: msg } : msg);
+      return Promise.resolve(raw ? { json: msg, headers: { get() {} } } : msg);
     }
     const promise = this.fetchServerSettings().then(_ => {
       return this.http.request(this.remote + request.path, _extends({}, request, {
-        body: JSON.stringify(request.body)
+        body: stringify ? JSON.stringify(request.body) : request.body
       }));
     });
-    return options.raw ? promise : promise.then(({ json }) => json);
+    return raw ? promise : promise.then(({ json }) => json);
+  }
+
+  paginatedList(path, params, options = {}) {
+    const { sort, filters, limit, pages, since } = _extends({
+      sort: "-last_modified"
+    }, params);
+    // Safety/Consistency check on ETag value.
+    if (since && typeof since !== "string") {
+      throw new Error(`Invalid value for since (${ since }), should be ETag value.`);
+    }
+
+    const querystring = (0, _utils.qsify)(_extends({}, filters, {
+      _sort: sort,
+      _limit: limit,
+      _since: since
+    }));
+    let results = [],
+        current = 0;
+
+    const next = function (nextPage) {
+      if (!nextPage) {
+        throw new Error("Pagination exhausted.");
+      }
+      return processNextPage(nextPage);
+    };
+
+    const processNextPage = nextPage => {
+      const { headers } = options;
+      return this.http.request(nextPage, { headers }).then(handleResponse);
+    };
+
+    const pageResults = (results, nextPage, etag, totalRecords) => {
+      // ETag string is supposed to be opaque and stored «as-is».
+      // ETag header values are quoted (because of * and W/"foo").
+      return {
+        last_modified: etag ? etag.replace(/"/g, "") : etag,
+        data: results,
+        next: next.bind(null, nextPage),
+        hasNextPage: !!nextPage,
+        totalRecords
+      };
+    };
+
+    const handleResponse = ({ headers, json }) => {
+      const nextPage = headers.get("Next-Page");
+      const etag = headers.get("ETag");
+      const totalRecords = parseInt(headers.get("Total-Records"), 10);
+
+      if (!pages) {
+        return pageResults(json.data, nextPage, etag, totalRecords);
+      }
+      // Aggregate new results with previous ones
+      results = results.concat(json.data);
+      current += 1;
+      if (current >= pages || !nextPage) {
+        // Pagination exhausted
+        return pageResults(results, nextPage, etag, totalRecords);
+      }
+      // Follow next page
+      return processNextPage(nextPage);
+    };
+
+    return this.execute(_extends({
+      path: path + "?" + querystring
+    }, options), { raw: true }).then(handleResponse);
+  }
+
+  /**
+   * Lists all permissions.
+   *
+   * @param  {Object} [options={}]      The options object.
+   * @param  {Object} [options.headers] The headers object option.
+   * @return {Promise<Object[], Error>}
+   */
+
+  listPermissions(options = {}) {
+    const reqOptions = this._getRequestOptions(options);
+    return this.execute(_extends({
+      path: (0, _endpoint2.default)("permissions")
+    }, reqOptions));
   }
 
   /**
    * Retrieves the list of buckets.
    *
    * @param  {Object} [options={}]      The options object.
    * @param  {Object} [options.headers] The headers object option.
    * @return {Promise<Object[], Error>}
    */
   listBuckets(options = {}) {
-    return this.execute({
-      path: (0, _endpoint2.default)("bucket"),
-      headers: _extends({}, this.defaultReqOptions.headers, options.headers)
-    });
+    const path = (0, _endpoint2.default)("bucket");
+    const reqOptions = this._getRequestOptions(options);
+    return this.paginatedList(path, options, reqOptions);
   }
 
   /**
    * Creates a new bucket on the server.
    *
    * @param  {String}   id                The bucket name.
    * @param  {Object}   [options={}]      The options object.
    * @param  {Boolean}  [options.data]    The bucket data option.
@@ -556,20 +860,20 @@ let KintoClientBase = (_dec = (0, _utils
    * @return {Promise<Object, Error>}
    */
 
   deleteBuckets(options = {}) {
     const reqOptions = this._getRequestOptions(options);
     const path = (0, _endpoint2.default)("bucket");
     return this.execute(requests.deleteRequest(path, reqOptions));
   }
-}, (_applyDecoratedDescriptor(_class.prototype, "fetchServerSettings", [_dec], Object.getOwnPropertyDescriptor(_class.prototype, "fetchServerSettings"), _class.prototype), _applyDecoratedDescriptor(_class.prototype, "fetchServerCapabilities", [_dec2], Object.getOwnPropertyDescriptor(_class.prototype, "fetchServerCapabilities"), _class.prototype), _applyDecoratedDescriptor(_class.prototype, "fetchUser", [_dec3], Object.getOwnPropertyDescriptor(_class.prototype, "fetchUser"), _class.prototype), _applyDecoratedDescriptor(_class.prototype, "fetchHTTPApiVersion", [_dec4], Object.getOwnPropertyDescriptor(_class.prototype, "fetchHTTPApiVersion"), _class.prototype), _applyDecoratedDescriptor(_class.prototype, "batch", [_dec5], Object.getOwnPropertyDescriptor(_class.prototype, "batch"), _class.prototype), _applyDecoratedDescriptor(_class.prototype, "deleteBuckets", [_dec6], Object.getOwnPropertyDescriptor(_class.prototype, "deleteBuckets"), _class.prototype)), _class));
+}, (_applyDecoratedDescriptor(_class.prototype, "fetchServerSettings", [_dec], Object.getOwnPropertyDescriptor(_class.prototype, "fetchServerSettings"), _class.prototype), _applyDecoratedDescriptor(_class.prototype, "fetchServerCapabilities", [_dec2], Object.getOwnPropertyDescriptor(_class.prototype, "fetchServerCapabilities"), _class.prototype), _applyDecoratedDescriptor(_class.prototype, "fetchUser", [_dec3], Object.getOwnPropertyDescriptor(_class.prototype, "fetchUser"), _class.prototype), _applyDecoratedDescriptor(_class.prototype, "fetchHTTPApiVersion", [_dec4], Object.getOwnPropertyDescriptor(_class.prototype, "fetchHTTPApiVersion"), _class.prototype), _applyDecoratedDescriptor(_class.prototype, "batch", [_dec5], Object.getOwnPropertyDescriptor(_class.prototype, "batch"), _class.prototype), _applyDecoratedDescriptor(_class.prototype, "listPermissions", [_dec6], Object.getOwnPropertyDescriptor(_class.prototype, "listPermissions"), _class.prototype), _applyDecoratedDescriptor(_class.prototype, "deleteBuckets", [_dec7], Object.getOwnPropertyDescriptor(_class.prototype, "deleteBuckets"), _class.prototype)), _class));
 exports.default = KintoClientBase;
 
-},{"./batch":3,"./bucket":4,"./endpoint":6,"./http":8,"./requests":9,"./utils":10}],3:[function(require,module,exports){
+},{"./batch":5,"./bucket":6,"./endpoint":8,"./http":10,"./requests":11,"./utils":12}],5:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 exports.aggregate = aggregate;
 /**
  * Exports batch responses as a result object.
@@ -586,48 +890,58 @@ function aggregate(responses = [], reque
   const results = {
     errors: [],
     published: [],
     conflicts: [],
     skipped: []
   };
   return responses.reduce((acc, response, index) => {
     const { status } = response;
+    const request = requests[index];
     if (status >= 200 && status < 400) {
       acc.published.push(response.body);
     } else if (status === 404) {
-      acc.skipped.push(response.body);
+      // Extract the id manually from request path while waiting for Kinto/kinto#818
+      const extracts = request.path.match(/(buckets|groups|collections|records)\/([^\/]+)$/);
+      const id = extracts.length === 3 ? extracts[2] : undefined;
+      acc.skipped.push({
+        id,
+        path: request.path,
+        error: response.body
+      });
     } else if (status === 412) {
       acc.conflicts.push({
         // XXX: specifying the type is probably superfluous
         type: "outgoing",
-        local: requests[index].body,
+        local: request.body,
         remote: response.body.details && response.body.details.existing || null
       });
     } else {
       acc.errors.push({
-        path: response.path,
-        sent: requests[index],
+        path: request.path,
+        sent: request,
         error: response.body
       });
     }
     return acc;
   }, results);
 }
 
-},{}],4:[function(require,module,exports){
+},{}],6:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 exports.default = undefined;
 
 var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
 
+var _dec, _desc, _value, _class;
+
 var _utils = require("./utils");
 
 var _collection = require("./collection");
 
 var _collection2 = _interopRequireDefault(_collection);
 
 var _requests = require("./requests");
 
@@ -636,21 +950,50 @@ var requests = _interopRequireWildcard(_
 var _endpoint = require("./endpoint");
 
 var _endpoint2 = _interopRequireDefault(_endpoint);
 
 function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } }
 
 function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
 
+function _applyDecoratedDescriptor(target, property, decorators, descriptor, context) {
+  var desc = {};
+  Object['ke' + 'ys'](descriptor).forEach(function (key) {
+    desc[key] = descriptor[key];
+  });
+  desc.enumerable = !!desc.enumerable;
+  desc.configurable = !!desc.configurable;
+
+  if ('value' in desc || desc.initializer) {
+    desc.writable = true;
+  }
+
+  desc = decorators.slice().reverse().reduce(function (desc, decorator) {
+    return decorator(target, property, desc) || desc;
+  }, desc);
+
+  if (context && desc.initializer !== void 0) {
+    desc.value = desc.initializer ? desc.initializer.call(context) : void 0;
+    desc.initializer = undefined;
+  }
+
+  if (desc.initializer === void 0) {
+    Object['define' + 'Property'](target, property, desc);
+    desc = null;
+  }
+
+  return desc;
+}
+
 /**
  * Abstract representation of a selected bucket.
  *
  */
-let Bucket = class Bucket {
+let Bucket = (_dec = (0, _utils.capable)(["history"]), (_class = class Bucket {
   /**
    * Constructor.
    *
    * @param  {KintoClient} client            The client instance.
    * @param  {String}      name              The bucket name.
    * @param  {Object}      [options={}]      The headers object option.
    * @param  {Object}      [options.headers] The headers object option.
    * @param  {Boolean}     [options.safe]    The safe option.
@@ -709,20 +1052,19 @@ let Bucket = class Bucket {
   /**
    * Retrieves bucket data.
    *
    * @param  {Object} [options={}]      The options object.
    * @param  {Object} [options.headers] The headers object option.
    * @return {Promise<Object, Error>}
    */
   getData(options = {}) {
-    return this.client.execute({
-      path: (0, _endpoint2.default)("bucket", this.name),
-      headers: _extends({}, this.options.headers, options.headers)
-    }).then(res => res.data);
+    const reqOptions = _extends({}, this._bucketOptions(options));
+    const request = _extends({}, reqOptions, { path: (0, _endpoint2.default)("bucket", this.name) });
+    return this.client.execute(request).then(res => res.data);
   }
 
   /**
    * Set bucket data.
    * @param  {Object}  data                    The bucket data object.
    * @param  {Object}  [options={}]            The options object.
    * @param  {Object}  [options.headers]       The headers object option.
    * @param  {Boolean} [options.safe]          The safe option.
@@ -747,27 +1089,40 @@ let Bucket = class Bucket {
     const path = (0, _endpoint2.default)("bucket", bucketId);
     const { permissions } = options;
     const reqOptions = _extends({}, this._bucketOptions(options));
     const request = requests.updateRequest(path, { data: bucket, permissions }, reqOptions);
     return this.client.execute(request);
   }
 
   /**
+   * Retrieves the list of history entries in the current bucket.
+   *
+   * @param  {Object} [options={}]      The options object.
+   * @param  {Object} [options.headers] The headers object option.
+   * @return {Promise<Array<Object>, Error>}
+   */
+
+  listHistory(options = {}) {
+    const path = (0, _endpoint2.default)("history", this.name);
+    const reqOptions = this._bucketOptions(options);
+    return this.client.paginatedList(path, options, reqOptions);
+  }
+
+  /**
    * Retrieves the list of collections in the current bucket.
    *
    * @param  {Object} [options={}]      The options object.
    * @param  {Object} [options.headers] The headers object option.
    * @return {Promise<Array<Object>, Error>}
    */
   listCollections(options = {}) {
-    return this.client.execute({
-      path: (0, _endpoint2.default)("collection", this.name),
-      headers: _extends({}, this.options.headers, options.headers)
-    });
+    const path = (0, _endpoint2.default)("collection", this.name);
+    const reqOptions = this._bucketOptions(options);
+    return this.client.paginatedList(path, options, reqOptions);
   }
 
   /**
    * Creates a new collection in current bucket.
    *
    * @param  {String|undefined}  id          The collection id.
    * @param  {Object}  [options={}]          The options object.
    * @param  {Boolean} [options.safe]        The safe option.
@@ -810,35 +1165,33 @@ let Bucket = class Bucket {
   /**
    * Retrieves the list of groups in the current bucket.
    *
    * @param  {Object} [options={}]      The options object.
    * @param  {Object} [options.headers] The headers object option.
    * @return {Promise<Array<Object>, Error>}
    */
   listGroups(options = {}) {
-    return this.client.execute({
-      path: (0, _endpoint2.default)("group", this.name),
-      headers: _extends({}, this.options.headers, options.headers)
-    });
+    const path = (0, _endpoint2.default)("group", this.name);
+    const reqOptions = this._bucketOptions(options);
+    return this.client.paginatedList(path, options, reqOptions);
   }
 
   /**
    * Creates a new group in current bucket.
    *
    * @param  {String} id                The group id.
    * @param  {Object} [options={}]      The options object.
    * @param  {Object} [options.headers] The headers object option.
    * @return {Promise<Object, Error>}
    */
   getGroup(id, options = {}) {
-    return this.client.execute({
-      path: (0, _endpoint2.default)("group", this.name, id),
-      headers: _extends({}, this.options.headers, options.headers)
-    });
+    const reqOptions = _extends({}, this._bucketOptions(options));
+    const request = _extends({}, reqOptions, { path: (0, _endpoint2.default)("group", this.name, id) });
+    return this.client.execute(request);
   }
 
   /**
    * Creates a new group in current bucket.
    *
    * @param  {String|undefined}  id                    The group id.
    * @param  {Array<String>}     [members=[]]          The list of principals.
    * @param  {Object}            [options={}]          The options object.
@@ -909,20 +1262,19 @@ let Bucket = class Bucket {
   /**
    * Retrieves the list of permissions for this bucket.
    *
    * @param  {Object} [options={}]      The options object.
    * @param  {Object} [options.headers] The headers object option.
    * @return {Promise<Object, Error>}
    */
   getPermissions(options = {}) {
-    return this.client.execute({
-      path: (0, _endpoint2.default)("bucket", this.name),
-      headers: _extends({}, this.options.headers, options.headers)
-    }).then(res => res.permissions);
+    const reqOptions = this._bucketOptions(options);
+    const request = _extends({}, reqOptions, { path: (0, _endpoint2.default)("bucket", this.name) });
+    return this.client.execute(request).then(res => res.permissions);
   }
 
   /**
    * Replaces all existing bucket permissions with the ones provided.
    *
    * @param  {Object}  permissions             The permissions object.
    * @param  {Object}  [options={}]            The options object
    * @param  {Boolean} [options.safe]          The safe option.
@@ -950,48 +1302,81 @@ let Bucket = class Bucket {
    * @param  {Object}   [options.headers]    The headers object option.
    * @param  {Boolean}  [options.safe]       The safe option.
    * @param  {Boolean}  [options.aggregate]  Produces a grouped result object.
    * @return {Promise<Object, Error>}
    */
   batch(fn, options = {}) {
     return this.client.batch(fn, this._bucketOptions(options));
   }
-};
+}, (_applyDecoratedDescriptor(_class.prototype, "listHistory", [_dec], Object.getOwnPropertyDescriptor(_class.prototype, "listHistory"), _class.prototype)), _class));
 exports.default = Bucket;
 
-},{"./collection":5,"./endpoint":6,"./requests":9,"./utils":10}],5:[function(require,module,exports){
+},{"./collection":7,"./endpoint":8,"./requests":11,"./utils":12}],7:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 exports.default = undefined;
 
 var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
 
+var _dec, _dec2, _desc, _value, _class;
+
+var _uuid = require("uuid");
+
 var _utils = require("./utils");
 
 var _requests = require("./requests");
 
 var requests = _interopRequireWildcard(_requests);
 
 var _endpoint = require("./endpoint");
 
 var _endpoint2 = _interopRequireDefault(_endpoint);
 
 function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
 
 function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } }
 
+function _applyDecoratedDescriptor(target, property, decorators, descriptor, context) {
+  var desc = {};
+  Object['ke' + 'ys'](descriptor).forEach(function (key) {
+    desc[key] = descriptor[key];
+  });
+  desc.enumerable = !!desc.enumerable;
+  desc.configurable = !!desc.configurable;
+
+  if ('value' in desc || desc.initializer) {
+    desc.writable = true;
+  }
+
+  desc = decorators.slice().reverse().reduce(function (desc, decorator) {
+    return decorator(target, property, desc) || desc;
+  }, desc);
+
+  if (context && desc.initializer !== void 0) {
+    desc.value = desc.initializer ? desc.initializer.call(context) : void 0;
+    desc.initializer = undefined;
+  }
+
+  if (desc.initializer === void 0) {
+    Object['define' + 'Property'](target, property, desc);
+    desc = null;
+  }
+
+  return desc;
+}
+
 /**
  * Abstract representation of a selected collection.
  *
  */
-let Collection = class Collection {
+let Collection = (_dec = (0, _utils.capable)(["attachments"]), _dec2 = (0, _utils.capable)(["attachments"]), (_class = class Collection {
   /**
    * Constructor.
    *
    * @param  {KintoClient}  client            The client instance.
    * @param  {Bucket}       bucket            The bucket instance.
    * @param  {String}       name              The collection name.
    * @param  {Object}       [options={}]      The options object.
    * @param  {Object}       [options.headers] The headers object option.
@@ -1037,28 +1422,41 @@ let Collection = class Collection {
   _collOptions(options = {}) {
     const headers = _extends({}, this.options && this.options.headers, options.headers);
     return _extends({}, this.options, options, {
       headers
     });
   }
 
   /**
+   * Retrieves the total number of records in this collection.
+   *
+   * @param  {Object} [options={}]      The options object.
+   * @param  {Object} [options.headers] The headers object option.
+   * @return {Promise<Number, Error>}
+   */
+  getTotalRecords(options = {}) {
+    const path = (0, _endpoint2.default)("record", this.bucket.name, this.name);
+    const reqOptions = this._collOptions(options);
+    const request = _extends({}, reqOptions, { path, method: "HEAD" });
+    return this.client.execute(request, { raw: true }).then(({ headers }) => parseInt(headers.get("Total-Records"), 10));
+  }
+
+  /**
    * Retrieves collection data.
    *
    * @param  {Object} [options={}]      The options object.
    * @param  {Object} [options.headers] The headers object option.
    * @return {Promise<Object, Error>}
    */
   getData(options = {}) {
-    const { headers } = this._collOptions(options);
-    return this.client.execute({
-      path: (0, _endpoint2.default)("collection", this.bucket.name, this.name),
-      headers
-    }).then(res => res.data);
+    const reqOptions = this._collOptions(options);
+    const path = (0, _endpoint2.default)("collection", this.bucket.name, this.name);
+    const request = _extends({}, reqOptions, { path });
+    return this.client.execute(request).then(res => res.data);
   }
 
   /**
    * Set collection data.
    * @param  {Object}   data                    The collection data object.
    * @param  {Object}   [options={}]            The options object.
    * @param  {Object}   [options.headers]       The headers object option.
    * @param  {Boolean}  [options.safe]          The safe option.
@@ -1081,21 +1479,20 @@ let Collection = class Collection {
   /**
    * Retrieves the list of permissions for this collection.
    *
    * @param  {Object} [options={}]      The options object.
    * @param  {Object} [options.headers] The headers object option.
    * @return {Promise<Object, Error>}
    */
   getPermissions(options = {}) {
-    const { headers } = this._collOptions(options);
-    return this.client.execute({
-      path: (0, _endpoint2.default)("collection", this.bucket.name, this.name),
-      headers
-    }).then(res => res.permissions);
+    const path = (0, _endpoint2.default)("collection", this.bucket.name, this.name);
+    const reqOptions = this._collOptions(options);
+    const request = _extends({}, reqOptions, { path });
+    return this.client.execute(request).then(res => res.permissions);
   }
 
   /**
    * Replaces all existing collection permissions with the ones provided.
    *
    * @param  {Object}   permissions             The permissions object.
    * @param  {Object}   [options={}]            The options object
    * @param  {Object}   [options.headers]       The headers object option.
@@ -1112,38 +1509,84 @@ let Collection = class Collection {
     const data = { last_modified: options.last_modified };
     const request = requests.updateRequest(path, { data, permissions }, reqOptions);
     return this.client.execute(request);
   }
 
   /**
    * Creates a record in current collection.
    *
-   * @param  {Object}  record            The record to create.
-   * @param  {Object}  [options={}]      The options object.
-   * @param  {Object}  [options.headers] The headers object option.
-   * @param  {Boolean} [options.safe]    The safe option.
+   * @param  {Object}  record                The record to create.
+   * @param  {Object}  [options={}]          The options object.
+   * @param  {Object}  [options.headers]     The headers object option.
+   * @param  {Boolean} [options.safe]        The safe option.
+   * @param  {Object}  [options.permissions] The permissions option.
    * @return {Promise<Object, Error>}
    */
   createRecord(record, options = {}) {
     const reqOptions = this._collOptions(options);
     const { permissions } = reqOptions;
     const path = (0, _endpoint2.default)("record", this.bucket.name, this.name, record.id);
     const request = requests.createRequest(path, { data: record, permissions }, reqOptions);
     return this.client.execute(request);
   }
 
   /**
+   * Adds an attachment to a record, creating the record when it doesn't exist.
+   *
+   * @param  {String}  dataURL                 The data url.
+   * @param  {Object}  [record={}]             The record data.
+   * @param  {Object}  [options={}]            The options object.
+   * @param  {Object}  [options.headers]       The headers object option.
+   * @param  {Boolean} [options.safe]          The safe option.
+   * @param  {Number}  [options.last_modified] The last_modified option.
+   * @param  {Object}  [options.permissions]   The permissions option.
+   * @param  {String}  [options.filename]      Force the attachment filename.
+   * @param  {String}  [options.gzipped]       Force the attachment to be gzipped or not.
+   * @return {Promise<Object, Error>}
+   */
+
+  addAttachment(dataURI, record = {}, options = {}) {
+    const reqOptions = this._collOptions(options);
+    const { permissions } = reqOptions;
+    const id = record.id || _uuid.v4.v4();
+    const path = (0, _endpoint2.default)("attachment", this.bucket.name, this.name, id);
+    const addAttachmentRequest = requests.addAttachmentRequest(path, dataURI, {
+      data: record,
+      permissions
+    }, reqOptions);
+    return this.client.execute(addAttachmentRequest, { stringify: false }).then(() => this.getRecord(id));
+  }
+
+  /**
+   * Removes an attachment from a given record.
+   *
+   * @param  {Object}  recordId                The record id.
+   * @param  {Object}  [options={}]            The options object.
+   * @param  {Object}  [options.headers]       The headers object option.
+   * @param  {Boolean} [options.safe]          The safe option.
+   * @param  {Number}  [options.last_modified] The last_modified option.
+   */
+
+  removeAttachment(recordId, options = {}) {
+    const reqOptions = this._collOptions(options);
+    const path = (0, _endpoint2.default)("attachment", this.bucket.name, this.name, recordId);
+    const request = requests.deleteRequest(path, reqOptions);
+    return this.client.execute(request);
+  }
+
+  /**
    * Updates a record in current collection.
    *
    * @param  {Object}  record                  The record to update.
    * @param  {Object}  [options={}]            The options object.
    * @param  {Object}  [options.headers]       The headers object option.
    * @param  {Boolean} [options.safe]          The safe option.
    * @param  {Number}  [options.last_modified] The last_modified option.
+   * @param  {Object}  [options.permissions]   The permissions option.
    * @return {Promise<Object, Error>}
    */
   updateRecord(record, options = {}) {
     if (!(0, _utils.isObject)(record)) {
       throw new Error("A record object is required.");
     }
     if (!record.id) {
       throw new Error("A record id is required.");
@@ -1181,113 +1624,58 @@ let Collection = class Collection {
    * Retrieves a record from the current collection.
    *
    * @param  {String} id                The record id to retrieve.
    * @param  {Object} [options={}]      The options object.
    * @param  {Object} [options.headers] The headers object option.
    * @return {Promise<Object, Error>}
    */
   getRecord(id, options = {}) {
-    return this.client.execute(_extends({
-      path: (0, _endpoint2.default)("record", this.bucket.name, this.name, id)
-    }, this._collOptions(options)));
+    const path = (0, _endpoint2.default)("record", this.bucket.name, this.name, id);
+    const reqOptions = this._collOptions(options);
+    const request = _extends({}, reqOptions, { path });
+    return this.client.execute(request);
   }
 
   /**
    * Lists records from the current collection.
    *
    * Sorting is done by passing a `sort` string option:
    *
    * - The field to order the results by, prefixed with `-` for descending.
    * Default: `-last_modified`.
    *
-   * @see http://kinto.readthedocs.io/en/stable/core/api/resource.html#sorting
+   * @see http://kinto.readthedocs.io/en/stable/api/1.x/sorting.html
    *
    * Filtering is done by passing a `filters` option object:
    *
    * - `{fieldname: "value"}`
    * - `{min_fieldname: 4000}`
    * - `{in_fieldname: "1,2,3"}`
    * - `{not_fieldname: 0}`
    * - `{exclude_fieldname: "0,1"}`
    *
-   * @see http://kinto.readthedocs.io/en/stable/core/api/resource.html#filtering
+   * @see http://kinto.readthedocs.io/en/stable/api/1.x/filtering.html
    *
    * Paginating is done by passing a `limit` option, then calling the `next()`
    * method from the resolved result object to fetch the next page, if any.
    *
    * @param  {Object}   [options={}]                    The options object.
    * @param  {Object}   [options.headers]               The headers object option.
    * @param  {Object}   [options.filters=[]]            The filters object.
    * @param  {String}   [options.sort="-last_modified"] The sort field.
    * @param  {String}   [options.limit=null]            The limit field.
    * @param  {String}   [options.pages=1]               The number of result pages to aggregate.
    * @param  {Number}   [options.since=null]            Only retrieve records modified since the provided timestamp.
    * @return {Promise<Object, Error>}
    */
   listRecords(options = {}) {
-    const { http } = this.client;
-    const { sort, filters, limit, pages, since } = _extends({
-      sort: "-last_modified"
-    }, options);
-    // Safety/Consistency check on ETag value.
-    if (since && typeof since !== "string") {
-      throw new Error(`Invalid value for since (${ since }), should be ETag value.`);
-    }
-    const collHeaders = this.options.headers;
     const path = (0, _endpoint2.default)("record", this.bucket.name, this.name);
-    const querystring = (0, _utils.qsify)(_extends({}, filters, {
-      _sort: sort,
-      _limit: limit,
-      _since: since
-    }));
-    let results = [],
-        current = 0;
-
-    const next = function (nextPage) {
-      if (!nextPage) {
-        throw new Error("Pagination exhausted.");
-      }
-      return processNextPage(nextPage);
-    };
-
-    const processNextPage = nextPage => {
-      return http.request(nextPage, { headers: collHeaders }).then(handleResponse);
-    };
-
-    const pageResults = (results, nextPage, etag) => {
-      // ETag string is supposed to be opaque and stored «as-is».
-      // ETag header values are quoted (because of * and W/"foo").
-      return {
-        last_modified: etag ? etag.replace(/"/g, "") : etag,
-        data: results,
-        next: next.bind(null, nextPage)
-      };
-    };
-
-    const handleResponse = ({ headers, json }) => {
-      const nextPage = headers.get("Next-Page");
-      const etag = headers.get("ETag");
-      if (!pages) {
-        return pageResults(json.data, nextPage, etag);
-      }
-      // Aggregate new results with previous ones
-      results = results.concat(json.data);
-      current += 1;
-      if (current >= pages || !nextPage) {
-        // Pagination exhausted
-        return pageResults(results, nextPage, etag);
-      }
-      // Follow next page
-      return processNextPage(nextPage);
-    };
-
-    return this.client.execute(_extends({
-      path: path + "?" + querystring
-    }, this._collOptions(options)), { raw: true }).then(handleResponse);
+    const reqOptions = this._collOptions(options);
+    return this.client.paginatedList(path, options, reqOptions);
   }
 
   /**
    * Performs batch operations at the current collection level.
    *
    * @param  {Function} fn                   The batch operation function.
    * @param  {Object}   [options={}]         The options object.
    * @param  {Object}   [options.headers]    The headers object option.
@@ -1297,52 +1685,55 @@ let Collection = class Collection {
    */
   batch(fn, options = {}) {
     const reqOptions = this._collOptions(options);
     return this.client.batch(fn, _extends({}, reqOptions, {
       bucket: this.bucket.name,
       collection: this.name
     }));
   }
-};
+}, (_applyDecoratedDescriptor(_class.prototype, "addAttachment", [_dec], Object.getOwnPropertyDescriptor(_class.prototype, "addAttachment"), _class.prototype), _applyDecoratedDescriptor(_class.prototype, "removeAttachment", [_dec2], Object.getOwnPropertyDescriptor(_class.prototype, "removeAttachment"), _class.prototype)), _class));
 exports.default = Collection;
 
-},{"./endpoint":6,"./requests":9,"./utils":10}],6:[function(require,module,exports){
+},{"./endpoint":8,"./requests":11,"./utils":12,"uuid":3}],8:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 exports.default = endpoint;
 /**
  * Endpoints templates.
  * @type {Object}
  */
 const ENDPOINTS = {
   root: () => "/",
   batch: () => "/batch",
+  permissions: () => "/permissions",
   bucket: bucket => "/buckets" + (bucket ? `/${ bucket }` : ""),
+  history: bucket => `${ ENDPOINTS.bucket(bucket) }/history`,
   collection: (bucket, coll) => `${ ENDPOINTS.bucket(bucket) }/collections` + (coll ? `/${ coll }` : ""),
   group: (bucket, group) => `${ ENDPOINTS.bucket(bucket) }/groups` + (group ? `/${ group }` : ""),
-  record: (bucket, coll, id) => `${ ENDPOINTS.collection(bucket, coll) }/records` + (id ? `/${ id }` : "")
+  record: (bucket, coll, id) => `${ ENDPOINTS.collection(bucket, coll) }/records` + (id ? `/${ id }` : ""),
+  attachment: (bucket, coll, id) => `${ ENDPOINTS.record(bucket, coll, id) }/attachment`
 };
 
 /**
  * Retrieves a server enpoint by its name.
  *
  * @private
  * @param  {String}    name The endpoint name.
  * @param  {...string} args The endpoint parameters.
  * @return {String}
  */
 function endpoint(name, ...args) {
   return ENDPOINTS[name](...args);
 }
 
-},{}],7:[function(require,module,exports){
+},{}],9:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 /**
  * Kinto server error code descriptors.
  * @type {Object}
@@ -1353,35 +1744,37 @@ exports.default = {
   106: "Request body was not valid JSON",
   107: "Invalid request parameter",
   108: "Missing request parameter",
   109: "Invalid posted data",
   110: "Invalid Token / id",
   111: "Missing Token / id",
   112: "Content-Length header was not provided",
   113: "Request body too large",
-  114: "Resource was modified meanwhile",
+  114: "Resource was created, updated or deleted meanwhile",
   115: "Method not allowed on this end point (hint: server may be readonly)",
   116: "Requested version not available on this server",
   117: "Client has sent too many requests",
   121: "Resource access is forbidden for this user",
   122: "Another resource violates constraint",
   201: "Service Temporary unavailable due to high load",
   202: "Service deprecated",
   999: "Internal Server Error"
 };
 
-},{}],8:[function(require,module,exports){
+},{}],10:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 exports.default = undefined;
 
+var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
+
 var _errors = require("./errors");
 
 var _errors2 = _interopRequireDefault(_errors);
 
 function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
 
 /**
  * Enhanced HTTP client for the Kinto protocol.
@@ -1448,28 +1841,36 @@ let HTTP = class HTTP {
    * Resolves with an objet containing the following HTTP response properties:
    * - `{Number}  status`  The HTTP status code.
    * - `{Object}  json`    The JSON response body.
    * - `{Headers} headers` The response headers object; see the ES6 fetch() spec.
    *
    * @param  {String} url               The URL.
    * @param  {Object} [options={}]      The fetch() options object.
    * @param  {Object} [options.headers] The request headers object (default: {})
+   * @param  {Object} [options.retry]   Number of retries (default: 0)
    * @return {Promise}
    */
-  request(url, options = { headers: {} }) {
+  request(url, options = { headers: {}, retry: 0 }) {
     let response, status, statusText, headers, hasTimedout;
     // Ensure default request headers are always set
-    options.headers = Object.assign({}, HTTP.DEFAULT_REQUEST_HEADERS, options.headers);
+    options.headers = _extends({}, HTTP.DEFAULT_REQUEST_HEADERS, options.headers);
+    // If a multipart body is provided, remove any custom Content-Type header as
+    // the fetch() implementation will add the correct one for us.
+    if (options.body && typeof options.body.append === "function") {
+      delete options.headers["Content-Type"];
+    }
     options.mode = this.requestMode;
     return new Promise((resolve, reject) => {
+      // Detect if a request has timed out.
       const _timeoutId = setTimeout(() => {
         hasTimedout = true;
         reject(new Error("Request timeout."));
       }, this.timeout);
+
       fetch(url, options).then(res => {
         if (!hasTimedout) {
           clearTimeout(_timeoutId);
           resolve(res);
         }
       }).catch(err => {
         if (!hasTimedout) {
           clearTimeout(_timeoutId);
@@ -1478,49 +1879,60 @@ let HTTP = class HTTP {
       });
     }).then(res => {
       response = res;
       headers = res.headers;
       status = res.status;
       statusText = res.statusText;
       this._checkForDeprecationHeader(headers);
       this._checkForBackoffHeader(status, headers);
-      this._checkForRetryAfterHeader(status, headers);
-      return res.text();
-    })
-    // Check if we have a body; if so parse it as JSON.
-    .then(text => {
-      if (text.length === 0) {
-        return null;
+
+      // Check if the server summons the client to retry after a while.
+      const retryAfter = this._checkForRetryAfterHeader(status, headers);
+      // If number of allowed of retries is not exhausted, retry the same request.
+      if (retryAfter && options.retry > 0) {
+        return new Promise((resolve, reject) => {
+          setTimeout(() => {
+            resolve(this.request(url, _extends({}, options, { retry: options.retry - 1 })));
+          }, retryAfter);
+        });
       }
-      // Note: we can't consume the response body twice.
-      return JSON.parse(text);
-    }).catch(err => {
-      const error = new Error(`HTTP ${ status || 0 }; ${ err }`);
-      error.response = response;
-      error.stack = err.stack;
-      throw error;
-    }).then(json => {
-      if (json && status >= 400) {
-        let message = `HTTP ${ status } ${ json.error || "" }: `;
-        if (json.errno && json.errno in _errors2.default) {
-          const errnoMsg = _errors2.default[json.errno];
-          message += errnoMsg;
-          if (json.message && json.message !== errnoMsg) {
-            message += ` (${ json.message })`;
+
+      return Promise.resolve(res.text())
+      // Check if we have a body; if so parse it as JSON.
+      .then(text => {
+        if (text.length === 0) {
+          return null;
+        }
+        // Note: we can't consume the response body twice.
+        return JSON.parse(text);
+      }).catch(err => {
+        const error = new Error(`HTTP ${ status || 0 }; ${ err }`);
+        error.response = response;
+        error.stack = err.stack;
+        throw error;
+      }).then(json => {
+        if (json && status >= 400) {
+          let message = `HTTP ${ status } ${ json.error || "" }: `;
+          if (json.errno && json.errno in _errors2.default) {
+            const errnoMsg = _errors2.default[json.errno];
+            message += errnoMsg;
+            if (json.message && json.message !== errnoMsg) {
+              message += ` (${ json.message })`;
+            }
+          } else {
+            message += statusText || "";
           }
-        } else {
-          message += statusText || "";
+          const error = new Error(message.trim());
+          error.response = response;
+          error.data = json;
+          throw error;
         }
-        const error = new Error(message.trim());
-        error.response = response;
-        error.data = json;
-        throw error;
-      }
-      return { status, json, headers };
+        return { status, json, headers };
+      });
     });
   }
 
   _checkForDeprecationHeader(headers) {
     const alertHeader = headers.get("Alert");
     if (!alertHeader) {
       return;
     }
@@ -1546,34 +1958,37 @@ let HTTP = class HTTP {
     this.events.emit("backoff", backoffMs);
   }
 
   _checkForRetryAfterHeader(status, headers) {
     let retryAfter = headers.get("Retry-After");
     if (!retryAfter) {
       return;
     }
-    retryAfter = new Date().getTime() + parseInt(retryAfter, 10) * 1000;
+    const delay = parseInt(retryAfter, 10) * 1000;
+    retryAfter = new Date().getTime() + delay;
     this.events.emit("retry-after", retryAfter);
+    return delay;
   }
 };
 exports.default = HTTP;
 
-},{"./errors":7}],9:[function(require,module,exports){
+},{"./errors":9}],11:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 
 var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
 
 exports.createRequest = createRequest;
 exports.updateRequest = updateRequest;
 exports.deleteRequest = deleteRequest;
+exports.addAttachmentRequest = addAttachmentRequest;
 
 var _utils = require("./utils");
 
 const requestDefaults = {
   safe: false,
   // check if we should set default content type here
   headers: {},
   permissions: undefined,
@@ -1646,32 +2061,63 @@ function deleteRequest(path, options = {
   }
   return {
     method: "DELETE",
     path,
     headers: _extends({}, headers, safeHeader(safe, last_modified))
   };
 }
 
-},{"./utils":10}],10:[function(require,module,exports){
+/**
+ * @private
+ */
+function addAttachmentRequest(path, dataURI, { data, permissions } = {}, options = {}) {
+  const { headers, safe, gzipped } = _extends({}, requestDefaults, options);
+  const { last_modified } = _extends({}, data, options);
+
+  const body = { data, permissions };
+  const formData = (0, _utils.createFormData)(dataURI, body, options);
+  let customPath;
+
+  if (gzipped != null) {
+    customPath = path + "?gzipped=" + (gzipped ? "true" : "false");
+  } else {
+    customPath = path;
+  }
+
+  return {
+    method: "POST",
+    path: customPath,
+    headers: _extends({}, headers, safeHeader(safe, last_modified)),
+    body: formData
+  };
+}
+
+},{"./utils":12}],12:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
+
+var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
+
 exports.partition = partition;
 exports.pMap = pMap;
 exports.omit = omit;
 exports.toDataBody = toDataBody;
 exports.qsify = qsify;
 exports.checkVersion = checkVersion;
 exports.support = support;
 exports.capable = capable;
 exports.nobatch = nobatch;
 exports.isObject = isObject;
+exports.parseDataURL = parseDataURL;
+exports.extractFileInfo = extractFileInfo;
+exports.createFormData = createFormData;
 /**
  * Chunks an array into n pieces.
  *
  * @private
  * @param  {Array}  array
  * @param  {Number} n
  * @return {Array}
  */
@@ -1745,28 +2191,27 @@ function toDataBody(resource) {
 /**
  * Transforms an object into an URL query string, stripping out any undefined
  * values.
  *
  * @param  {Object} obj
  * @return {String}
  */
 function qsify(obj) {
-  const sep = "&";
   const encode = v => encodeURIComponent(typeof v === "boolean" ? String(v) : v);
   const stripUndefined = o => JSON.parse(JSON.stringify(o));
   const stripped = stripUndefined(obj);
   return Object.keys(stripped).map(k => {
     const ks = encode(k) + "=";
     if (Array.isArray(stripped[k])) {
-      return stripped[k].map(v => ks + encode(v)).join(sep);
+      return ks + stripped[k].map(v => encode(v)).join(",");
     } else {
       return ks + encode(stripped[k]);
     }
-  }).join(sep);
+  }).join("&");
 }
 
 /**
  * Checks if a version is within the provided range.
  *
  * @param  {String} version    The version to check.
  * @param  {String} minVersion The minimum supported version (inclusive).
  * @param  {String} maxVersion The minimum supported version (exclusive).
@@ -1795,17 +2240,17 @@ function support(min, max) {
   return function (target, key, descriptor) {
     const fn = descriptor.value;
     return {
       configurable: true,
       get() {
         const wrappedMethod = (...args) => {
           // "this" is the current instance which its method is decorated.
           const client = "client" in this ? this.client : this;
-          return client.fetchHTTPApiVersion().then(version => checkVersion(version, min, max)).then(Promise.resolve(fn.apply(this, args)));
+          return client.fetchHTTPApiVersion().then(version => checkVersion(version, min, max)).then(() => fn.apply(this, args));
         };
         Object.defineProperty(this, key, {
           value: wrappedMethod,
           configurable: true,
           writable: true
         });
         return wrappedMethod;
       }
@@ -1825,21 +2270,21 @@ function capable(capabilities) {
     const fn = descriptor.value;
     return {
       configurable: true,
       get() {
         const wrappedMethod = (...args) => {
           // "this" is the current instance which its method is decorated.
           const client = "client" in this ? this.client : this;
           return client.fetchServerCapabilities().then(available => {
-            const missing = capabilities.filter(c => available.indexOf(c) < 0);
+            const missing = capabilities.filter(c => !available.hasOwnProperty(c));
             if (missing.length > 0) {
               throw new Error(`Required capabilities ${ missing.join(", ") } ` + "not present on server");
             }
-          }).then(Promise.resolve(fn.apply(this, args)));
+          }).then(() => fn.apply(this, args));
         };
         Object.defineProperty(this, key, {
           value: wrappedMethod,
           configurable: true,
           writable: true
         });
         return wrappedMethod;
       }
@@ -1882,10 +2327,69 @@ function nobatch(message) {
  * Returns true if the specified value is an object (i.e. not an array nor null).
  * @param  {Object} thing The value to inspect.
  * @return {bool}
  */
 function isObject(thing) {
   return typeof thing === "object" && thing !== null && !Array.isArray(thing);
 }
 
+/**
+ * Parses a data url.
+ * @param  {String} dataURL The data url.
+ * @return {Object}
+ */
+function parseDataURL(dataURL) {
+  const regex = /^data:(.*);base64,(.*)/;
+  const match = dataURL.match(regex);
+  if (!match) {
+    throw new Error(`Invalid data-url: ${ String(dataURL).substr(0, 32) }...`);
+  }
+  const props = match[1];
+  const base64 = match[2];
+  const [type, ...rawParams] = props.split(";");
+  const params = rawParams.reduce((acc, param) => {
+    const [key, value] = param.split("=");
+    return _extends({}, acc, { [key]: value });
+  }, {});
+  return _extends({}, params, { type, base64 });
+}
+
+/**
+ * Extracts file information from a data url.
+ * @param  {String} dataURL The data url.
+ * @return {Object}
+ */
+function extractFileInfo(dataURL) {
+  const { name, type, base64 } = parseDataURL(dataURL);
+  const binary = atob(base64);
+  const array = [];
+  for (let i = 0; i < binary.length; i++) {
+    array.push(binary.charCodeAt(i));
+  }
+  const blob = new Blob([new Uint8Array(array)], { type });
+  return { blob, name };
+}
+
+/**
+ * Creates a FormData instance from a data url and an existing JSON response
+ * body.
+ * @param  {String} dataURL            The data url.
+ * @param  {Object} body               The response body.
+ * @param  {Object} [options={}]       The options object.
+ * @param  {Object} [options.filename] Force attachment file name.
+ * @return {FormData}
+ */
+function createFormData(dataURL, body, options = {}) {
+  const { filename = "untitled" } = options;
+  const { blob, name } = extractFileInfo(dataURL);
+  const formData = new FormData();
+  formData.append("attachment", blob, name || filename);
+  for (const property in body) {
+    if (typeof body[property] !== "undefined") {
+      formData.append(property, JSON.stringify(body[property]));
+    }
+  }
+  return formData;
+}
+
 },{}]},{},[1])(1)
 });
\ No newline at end of file
--- a/services/common/kinto-offline-client.js
+++ b/services/common/kinto-offline-client.js
@@ -15,17 +15,17 @@
 
 /*
  * This file is generated from kinto.js - do not modify directly.
  */
 
 this.EXPORTED_SYMBOLS = ["Kinto"];
 
 /*
- * Version 6.0.0 - de9dd38
+ * Version 7.1.0 - a6f42f1
  */
 
 (function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.Kinto = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
 /*
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
@@ -113,16 +113,17 @@ var _collection2 = _interopRequireDefaul
 var _base = require("./adapters/base");
 
 var _base2 = _interopRequireDefault(_base);
 
 function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
 
 const DEFAULT_BUCKET_NAME = "default";
 const DEFAULT_REMOTE = "http://localhost:8888/v1";
+const DEFAULT_RETRY = 1;
 
 /**
  * KintoBase class.
  */
 class KintoBase {
   /**
    * Provides a public access to the base adapter class. Users can create a
    * custom DB adapter by extending {@link BaseAdapter}.
@@ -154,70 +155,85 @@ class KintoBase {
    * Options:
    * - `{String}`       `remote`         The server URL to use.
    * - `{String}`       `bucket`         The collection bucket name.
    * - `{EventEmitter}` `events`         Events handler.
    * - `{BaseAdapter}`  `adapter`        The base DB adapter class.
    * - `{Object}`       `adapterOptions` Options given to the adapter.
    * - `{String}`       `dbPrefix`       The DB name prefix.
    * - `{Object}`       `headers`        The HTTP headers to use.
+   * - `{Object}`       `retry`          Number of retries when the server fails to process the request (default: `1`)
    * - `{String}`       `requestMode`    The HTTP CORS mode to use.
    * - `{Number}`       `timeout`        The requests timeout in ms (default: `5000`).
    *
    * @param  {Object} options The options object.
    */
   constructor(options = {}) {
     const defaults = {
       bucket: DEFAULT_BUCKET_NAME,
-      remote: DEFAULT_REMOTE
+      remote: DEFAULT_REMOTE,
+      retry: DEFAULT_RETRY
     };
     this._options = _extends({}, defaults, options);
     if (!this._options.adapter) {
       throw new Error("No adapter provided");
     }
 
-    const { remote, events, headers, requestMode, timeout, ApiClass } = this._options;
+    const { remote, events, headers, retry, requestMode, timeout, ApiClass } = this._options;
 
     // public properties
 
     /**
      * The kinto HTTP client instance.
      * @type {KintoClient}
      */
-    this.api = new ApiClass(remote, { events, headers, requestMode, timeout });
+    this.api = new ApiClass(remote, { events, headers, retry, requestMode, timeout });
     /**
      * The event emitter instance.
      * @type {EventEmitter}
      */
     this.events = this._options.events;
   }
 
   /**
    * Creates a {@link Collection} instance. The second (optional) parameter
    * will set collection-level options like e.g. `remoteTransformers`.
    *
    * @param  {String} collName The collection name.
-   * @param  {Object} options  May contain the following fields:
-   *                           remoteTransformers: Array<RemoteTransformer>
+   * @param  {Object} [options={}]                 Extra options or override client's options.
+   * @param  {Object} [options.idSchema]           IdSchema instance (default: UUID)
+   * @param  {Object} [options.remoteTransformers] Array<RemoteTransformer> (default: `[]`])
+   * @param  {Object} [options.hooks]              Array<Hook> (default: `[]`])
    * @return {Collection}
    */
   collection(collName, options = {}) {
     if (!collName) {
       throw new Error("missing collection name");
     }
+    const {
+      bucket,
+      events,
+      adapter,
+      adapterOptions,
+      dbPrefix
+    } = _extends({}, this._options, options);
+    const {
+      idSchema,
+      remoteTransformers,
+      hooks
+    } = options;
 
-    const bucket = this._options.bucket;
     return new _collection2.default(bucket, collName, this.api, {
-      events: this._options.events,
-      adapter: this._options.adapter,
-      adapterOptions: this._options.adapterOptions,
-      dbPrefix: this._options.dbPrefix,
-      idSchema: options.idSchema,
-      remoteTransformers: options.remoteTransformers,
-      hooks: options.hooks
+      events,
+      adapter,
+      adapterOptions,
+      dbPrefix,
+      idSchema,
+      remoteTransformers,
+      hooks
     });
   }
 }
 exports.default = KintoBase;
 
 },{"./adapters/base":5,"./collection":6}],4:[function(require,module,exports){
 "use strict";
 
@@ -412,17 +428,17 @@ class IDB extends _base2.default {
    * @override
    * @return {Promise}
    */
   close() {
     if (this._db) {
       this._db.close(); // indexedDB.close is synchronous
       this._db = null;
     }
-    return super.close();
+    return Promise.resolve();
   }
 
   /**
    * Returns a transaction and a store objects for this collection.
    *
    * To determine if a transaction has completed successfully, we should rather
    * listen to the transaction’s complete event rather than the IDBObjectStore
    * request’s success event, because the transaction may still fail after the
@@ -742,36 +758,16 @@ function transactionProxy(store, preload
  * @abstract
  */
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 class BaseAdapter {
   /**
-   * Opens a connection to the database.
-   *
-   * @abstract
-   * @return {Promise}
-   */
-  open() {
-    return Promise.resolve();
-  }
-
-  /**
-   * Closes current connection to the database.
-   *
-   * @abstract
-   * @return {Promise}
-   */
-  close() {
-    return Promise.resolve();
-  }
-
-  /**
    * Deletes every records present in the database.
    *
    * @abstract
    * @return {Promise}
    */
   clear() {
     throw new Error("Not Implemented.");
   }
@@ -1732,16 +1728,17 @@ class Collection {
         }).join(",");
         filters = { exclude_id };
       }
       // First fetch remote changes from the server
       const { data, last_modified } = yield client.listRecords({
         // Since should be ETag (see https://github.com/Kinto/kinto.js/issues/356)
         since: options.lastModified ? `${ options.lastModified }` : undefined,
         headers: options.headers,
+        retry: options.retry,
         filters
       });
       // last_modified is the ETag header value (string).
       // For retro-compatibility with first kinto.js versions
       // parse it to integer.
       const unquoted = last_modified ? parseInt(last_modified, 10) : undefined;
 
       // Check if server was flushed.
@@ -1823,26 +1820,33 @@ class Collection {
           // Clean local fields (like _status) before sending to server.
           const published = _this8.cleanLocalFields(r);
           if (r._status === "created") {
             batch.createRecord(published);
           } else {
             batch.updateRecord(published);
           }
         });
-      }, { headers: options.headers, safe, aggregate: true });
+      }, {
+        headers: options.headers,
+        retry: options.retry,
+        safe,
+        aggregate: true
+      });
 
       // Store outgoing errors into sync result object
       syncResultObject.add("errors", synced.errors.map(function (e) {
         return _extends({}, e, { type: "outgoing" });
       }));
 
       // Store outgoing conflicts into sync result object
       const conflicts = [];
-      for (let { type, local, remote } of synced.conflicts) {
+      for (let _ref of synced.conflicts) {
+        let { type, local, remote } = _ref;
+
         // Note: we ensure that local data are actually available, as they may
         // be missing in the case of a published deletion.
         const safeLocal = local && local.data || { id: remote.id };
         const realLocal = yield _this8._decodeRecord("remote", safeLocal);
         const realRemote = yield _this8._decodeRecord("remote", remote);
         const conflict = { type, local: realLocal, remote: realRemote };
         conflicts.push(conflict);
       }
@@ -1925,49 +1929,56 @@ class Collection {
    * Synchronize remote and local data. The promise will resolve with a
    * {@link SyncResultObject}, though will reject:
    *
    * - if the server is currently backed off;
    * - if the server has been detected flushed.
    *
    * Options:
    * - {Object} headers: HTTP headers to attach to outgoing requests.
+   * - {Number} retry: Number of retries when server fails to process the request (default: 1).
    * - {Collection.strategy} strategy: See {@link Collection.strategy}.
    * - {Boolean} ignoreBackoff: Force synchronization even if server is currently
    *   backed off.
    * - {String} bucket: The remove bucket id to use (default: null)
    * - {String} collection: The remove collection id to use (default: null)
    * - {String} remote The remote Kinto server endpoint to use (default: null).
    *
    * @param  {Object} options Options.
    * @return {Promise}
    * @throws {Error} If an invalid remote option is passed.
    */
   sync(options = {
     strategy: Collection.strategy.MANUAL,
     headers: {},
+    retry: 1,
     ignoreBackoff: false,
     bucket: null,
     collection: null,
     remote: null
   }) {
     var _this9 = this;
 
     return _asyncToGenerator(function* () {
+      options = _extends({}, options, {
+        bucket: options.bucket || _this9.bucket,
+        collection: options.collection || _this9.name
+      });
+
       const previousRemote = _this9.api.remote;
       if (options.remote) {
         // Note: setting the remote ensures it's valid, throws when invalid.
         _this9.api.remote = options.remote;
       }
       if (!options.ignoreBackoff && _this9.api.backoff > 0) {
         const seconds = Math.ceil(_this9.api.backoff / 1000);
         return Promise.reject(new Error(`Server is asking clients to back off; retry in ${ seconds }s or use the ignoreBackoff option.`));
       }
 
-      const client = _this9.api.bucket(options.bucket || _this9.bucket).collection(options.collection || _this9.name);
+      const client = _this9.api.bucket(options.bucket).collection(options.collection);
 
       const result = new SyncResultObject();
       try {
         // Fetch last changes from the server.
         yield _this9.pullChanges(client, result, options);
         const { lastModified } = result;
 
         // Fetch local changes
@@ -1992,20 +2003,24 @@ class Collection {
           yield _this9.pullChanges(client, result, pullOpts);
         }
 
         // Don't persist lastModified value if any conflict or error occured
         if (result.ok) {
           // No conflict occured, persist collection's lastModified value
           _this9._lastModified = yield _this9.db.saveLastModified(result.lastModified);
         }
+      } catch (e) {
+        _this9.events.emit("sync:error", _extends({}, options, { error: e }));
+        throw e;
       } finally {
         // Ensure API default remote is reverted if a custom one's been used
         _this9.api.remote = previousRemote;
       }
+      _this9.events.emit("sync:success", _extends({}, options, { result }));
       return result;
     })();
   }
 
   /**
    * Load a list of records already synced with the remote server.
    *
    * The local records which are unsynced or whose timestamp is either missing
@@ -2082,17 +2097,19 @@ class CollectionTransaction {
     this._events.push({ action, payload });
   }
 
   /**
    * Emit queued events, to be called once every transaction operations have
    * been executed successfully.
    */
   emitEvents() {
-    for (let { action, payload } of this._events) {
+    for (let _ref2 of this._events) {
+      let { action, payload } = _ref2;
+
       this.collection.events.emit(action, payload);
     }
     if (this._events.length > 0) {
       const targets = this._events.map(({ action, payload }) => _extends({ action }, payload));
       this.collection.events.emit("change", { targets });
     }
     this._events = [];
   }
--- a/services/common/tests/unit/test_storage_adapter.js
+++ b/services/common/tests/unit/test_storage_adapter.js
@@ -70,17 +70,16 @@ function test_collection_operations() {
     do_check_neq(newRecord, undefined);
     yield sqliteHandle.close();
   });
 
   // test getting records that don't exist
   add_task(function* test_kinto_get_non_existant() {
     let sqliteHandle = yield do_get_kinto_connection();
     let adapter = do_get_kinto_adapter(sqliteHandle);
-    yield adapter.open();
     // Kinto expects adapters to either:
     let newRecord = yield adapter.get("missing-test-id");
     // resolve with an undefined record
     do_check_eq(newRecord, undefined);
     yield sqliteHandle.close();
   });
 
   // test updating records... and getting them again
@@ -196,17 +195,16 @@ function test_collection_operations() {
     let newRecord1 = yield adapter.get("1");
     deepEqual(newRecord1.foo, "baz");
     yield sqliteHandle.close();
   });
 
   add_task(function* test_import_updates_lastModified() {
     let sqliteHandle = yield do_get_kinto_connection();
     let adapter = do_get_kinto_adapter(sqliteHandle);
-    yield adapter.open();
     yield adapter.loadDump([
       {id: 1, foo: "bar", last_modified: 1457896541},
       {id: 2, foo: "baz", last_modified: 1458796542},
     ]);
     let lastModified = yield adapter.getLastModified();
     do_check_eq(lastModified, 1458796542);
     yield sqliteHandle.close();
   });