Bug 1331604 - Upgrade kinto libraries (r=glasserc,RyanVM)
authorMathieu Leplatre <mathieu@mozilla.com>
Wed, 18 Jan 2017 14:53:52 +0100
changeset 377191 99bd07f1c6869cf7d31db2a985233d6d7eefd2ef
parent 377190 d9380dbe417707fcb6813d1100cf7e62be4d5a1f
child 377192 84a59e9496ed33e1c4a78705fcadac4a7d73181c
push id1419
push userjlund@mozilla.com
push dateMon, 10 Apr 2017 20:44:07 +0000
treeherdermozilla-release@5e6801b73ef6 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersglasserc, RyanVM
bugs1331604
milestone53.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1331604 - Upgrade kinto libraries (r=glasserc,RyanVM) 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();
   });