Bug 1224528 - Provide a mechanism to populate the OneCRL kinto collection with initial records r=rnewman draft
authorMark Goodwin <mgoodwin@mozilla.com>
Fri, 19 Feb 2016 12:30:33 +0000
changeset 332085 4bcdacc97f5ae97ee25024a1cfbb4ac7121abc0e
parent 331857 fcd35e10fa17d9fd11d92be48ae9698c2a900f1c
child 514543 0abe2bb20c5059fa6e94ac89180d839ddcf8f14e
push id11161
push usermgoodwin@mozilla.com
push dateFri, 19 Feb 2016 12:39:16 +0000
reviewersrnewman
bugs1224528
milestone47.0a1
Bug 1224528 - Provide a mechanism to populate the OneCRL kinto collection with initial records r=rnewman This makes use of the kinto.js loadDump feature to populate the OneCRL collection with initial data from application defaults. This also includes a modified moz-kinto-client.js because there was an issue with the loadDump implementation in the FirefoxStorage adapter that caused breakage with empty collections. MozReview-Commit-ID: HilHc9Z9gzr
browser/app/collections/certificates.json
browser/app/collections/moz.build
browser/app/moz.build
services/common/KintoCertificateBlocklist.js
services/common/moz-kinto-client.js
services/common/tests/unit/test_kintoCertBlocklist.js
new file mode 100644
--- /dev/null
+++ b/browser/app/collections/certificates.json
@@ -0,0 +1,1 @@
+{"data":[{"issuerName":"MDcxJDAiBgNVBAMTG1JDUyBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEPMA0GA1UEChMGSFQgc3Js","serialNumber":"AN9bfYOvlR1t","id":"63e5ccb8-1798-3b9f-48f5-12b5ca13054e","last_modified":1447863870100},{"issuerName":"MFkxCzAJBgNVBAYTAk5MMR4wHAYDVQQKExVTdGFhdCBkZXIgTmVkZXJsYW5kZW4xKjAoBgNVBAMTIVN0YWF0IGRlciBOZWRlcmxhbmRlbiBPdmVyaGVpZCBDQQ","serialNumber":"ATFpsA==","id":"dabafde9-df4a-ddba-2548-748da04cc02c","last_modified":1447863870001},{"issuerName":"MIGQMQswCQYDVQQGEwJHQjEbMBkGA1UECBMSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHEwdTYWxmb3JkMRowGAYDVQQKExFDT01PRE8gQ0EgTGltaXRlZDE2MDQGA1UEAxMtQ09NT0RPIFJTQSBEb21haW4gVmFsaWRhdGlvbiBTZWN1cmUgU2VydmVyIENB","serialNumber":"UoRGnb96CUDTxIqVry6LBg==","id":"c960b86c-5c04-e455-e069-04555910f581","last_modified":1447863869813},{"issuerName":"MGcxCzAJBgNVBAYTAkRFMRMwEQYDVQQKEwpGcmF1bmhvZmVyMSEwHwYDVQQLExhGcmF1bmhvZmVyIENvcnBvcmF0ZSBQS0kxIDAeBgNVBAMTF0ZyYXVuaG9mZXIgUm9vdCBDQSAyMDA3","serialNumber":"YR3YYQAAAAAABA==","id":"22294c18-1096-e6fe-5a79-31e1ef4aef85","last_modified":1447863869907},{"issuerName":"MFkxCzAJBgNVBAYTAk5MMR4wHAYDVQQKExVTdGFhdCBkZXIgTmVkZXJsYW5kZW4xKjAoBgNVBAMTIVN0YWF0IGRlciBOZWRlcmxhbmRlbiBPdmVyaGVpZCBDQQ","serialNumber":"ATFEdg==","id":"c2bfc807-893a-1f73-5b41-d2510f71097c","last_modified":1447863870054},{"issuerName":"MEQxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwx0aGF3dGUsIEluYy4xHjAcBgNVBAMTFXRoYXd0ZSBFViBTU0wgQ0EgLSBHMw==","serialNumber":"CrTHPEE6AZSfI3jysin2bA==","id":"78cf8900-fdea-4ce5-f8fb-b78710617718","last_modified":1447863870147},{"issuerName":"MIGQMQswCQYDVQQGEwJHQjEbMBkGA1UECBMSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHEwdTYWxmb3JkMRowGAYDVQQKExFDT01PRE8gQ0EgTGltaXRlZDE2MDQGA1UEAxMtQ09NT0RPIFJTQSBEb21haW4gVmFsaWRhdGlvbiBTZWN1cmUgU2VydmVyIENB","serialNumber":"D9UltDPl4XVfSSqQOvdiwQ==","id":"a81803c3-3c06-1549-bbe3-4b7d4c739f25","last_modified":1447863869721},{"issuerName":"MDIxCzAJBgNVBAYTAkNOMQ4wDAYDVQQKEwVDTk5JQzETMBEGA1UEAxMKQ05OSUMgUk9PVA==","serialNumber":"STMAjg==","id":"85547569-b7f8-9f18-1641-ff7f056ef16a","last_modified":1447863869767},{"issuerName":"MHExCzAJBgNVBAYTAkRFMRwwGgYDVQQKExNEZXV0c2NoZSBUZWxla29tIEFHMR8wHQYDVQQLExZULVRlbGVTZWMgVHJ1c3QgQ2VudGVyMSMwIQYDVQQDExpEZXV0c2NoZSBUZWxla29tIFJvb3QgQ0EgMg==","serialNumber":"ARQ=","id":"63bfea69-bb25-911f-3f89-d54fe63a2e2f","last_modified":1447863869861},{"issuerName":"MGExCzAJBgNVBAYTAk5MMR4wHAYDVQQKDBVTdGFhdCBkZXIgTmVkZXJsYW5kZW4xMjAwBgNVBAMMKVN0YWF0IGRlciBOZWRlcmxhbmRlbiBPcmdhbmlzYXRpZSBDQSAtIEcy","serialNumber":"ATE0vw==","id":"decad8c9-204a-a0af-030f-04cbf4410b70","last_modified":1447863869955}]}
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/app/collections/moz.build
@@ -0,0 +1,7 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+FINAL_TARGET_FILES.defaults.collections.blocklists += ['certificates.json']
--- a/browser/app/moz.build
+++ b/browser/app/moz.build
@@ -1,15 +1,15 @@
 # -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
-DIRS += ['profile/extensions']
+DIRS += ['collections', 'profile/extensions']
 
 if CONFIG['OS_ARCH'] == 'WINNT' and CONFIG['MOZ_ASAN']:
     GeckoProgram(CONFIG['MOZ_APP_NAME'])
 else:
     GeckoProgram(CONFIG['MOZ_APP_NAME'], msvcrt='static')
 
 JS_PREFERENCE_PP_FILES += [
     'profile/firefox.js',
--- a/services/common/KintoCertificateBlocklist.js
+++ b/services/common/KintoCertificateBlocklist.js
@@ -9,20 +9,25 @@ this.EXPORTED_SYMBOLS = ["OneCRLClient"]
 const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
 
 Cu.import("resource://services-common/moz-kinto-client.js");
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
+Cu.importGlobalProperties(['fetch']);
+
 XPCOMUtils.defineLazyServiceGetter(this, "uuidgen",
                                    "@mozilla.org/uuid-generator;1",
                                    "nsIUUIDGenerator");
 
+XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
+  "resource://gre/modules/FileUtils.jsm");
+
 const PREF_KINTO_BASE = "services.kinto.base";
 const PREF_KINTO_BUCKET = "services.kinto.bucket";
 const PREF_KINTO_ONECRL_COLLECTION = "services.kinto.onecrl.collection";
 const PREF_KINTO_ONECRL_CHECKED_SECONDS = "services.kinto.onecrl.checked";
 
 const RE_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
 
 // Kinto.js assumes version 4 UUIDs but allows you to specify custom
@@ -77,16 +82,31 @@ function CertBlocklistClient() {
       Services.prefs.setIntPref(PREF_KINTO_ONECRL_CHECKED_SECONDS,
                                 checkedServerTimeInSeconds);
     }
 
     return Task.spawn(function* () {
       try {
         yield blocklist.db.open();
         let collectionLastModified = yield blocklist.db.getLastModified();
+        // if there is no data currently in the collection, attempt to import
+        // initial data from the application defaults
+        if (!collectionLastModified) {
+          const collectionFile = FileUtils.getFile("CurProcD",
+            ["defaults", "collections", "blocklists", `${collectionName}.json`]);
+          if (collectionFile.exists()) {
+            const fileURI = Services.io.newFileURI(collectionFile);
+            const response = yield fetch(fileURI.spec);
+            if (response.ok) {
+              const initialData = yield response.json()
+              yield blocklist.db.loadDump(initialData.data);
+            }
+          }
+        }
+
         // if the data is up to date, there's no need to sync. We still need
         // to record the fact that a check happened.
         if (lastModified <= collectionLastModified) {
           updateLastCheck();
           return;
         }
         yield blocklist.sync();
         let list = yield blocklist.list();
--- a/services/common/moz-kinto-client.js
+++ b/services/common/moz-kinto-client.js
@@ -24,32 +24,35 @@ this.EXPORTED_SYMBOLS = ["loadKinto"];
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 
 var _base = require("../src/adapters/base");
 
 var _base2 = _interopRequireDefault(_base);
 
+var _utils = require("../src/utils");
+
 function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
 
-Components.utils.import("resource://gre/modules/Sqlite.jsm"); /*
-                                                               * 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
-                                                               *
-                                                               *     http://www.apache.org/licenses/LICENSE-2.0
-                                                               *
-                                                               * Unless required by applicable law or agreed to in writing, software
-                                                               * distributed under the License is distributed on an "AS IS" BASIS,
-                                                               * 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.
-                                                               */
-
+/*
+ * 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
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * 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.
+ */
+
+Components.utils.import("resource://gre/modules/Sqlite.jsm");
 Components.utils.import("resource://gre/modules/Task.jsm");
 
 const statements = {
   "createCollectionData": `
     CREATE TABLE collection_data (
       collection_name TEXT,
       record_id TEXT,
       record TEXT
@@ -121,40 +124,19 @@ class FirefoxAdapter extends _base2.defa
   }
 
   _init(connection) {
     return Task.spawn(function* () {
       yield connection.executeTransaction(function* doSetup() {
         const schema = yield connection.getSchemaVersion();
 
         if (schema == 0) {
-          var _iteratorNormalCompletion = true;
-          var _didIteratorError = false;
-          var _iteratorError = undefined;
-
-          try {
-
-            for (var _iterator = createStatements[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) {
-              const statementName = _step.value;
-
-              yield connection.execute(statements[statementName]);
-            }
-          } catch (err) {
-            _didIteratorError = true;
-            _iteratorError = err;
-          } finally {
-            try {
-              if (!_iteratorNormalCompletion && _iterator.return) {
-                _iterator.return();
-              }
-            } finally {
-              if (_didIteratorError) {
-                throw _iteratorError;
-              }
-            }
+
+          for (let statementName of createStatements) {
+            yield connection.execute(statements[statementName]);
           }
 
           yield connection.setSchemaVersion(currentSchemaVersion);
         } else if (schema != 1) {
           throw new Error("Unknown database schema: " + schema);
         }
       });
       return connection;
@@ -205,39 +187,18 @@ class FirefoxAdapter extends _base2.defa
     let result;
     try {
       result = callback(proxy);
     } catch (e) {
       return Promise.reject(e);
     }
     const conn = this._connection;
     return conn.executeTransaction(function* doExecuteTransaction() {
-      var _iteratorNormalCompletion2 = true;
-      var _didIteratorError2 = false;
-      var _iteratorError2 = undefined;
-
-      try {
-        for (var _iterator2 = proxy.operations[Symbol.iterator](), _step2; !(_iteratorNormalCompletion2 = (_step2 = _iterator2.next()).done); _iteratorNormalCompletion2 = true) {
-          const { statement, params } = _step2.value;
-
-          yield conn.executeCached(statement, params);
-        }
-      } catch (err) {
-        _didIteratorError2 = true;
-        _iteratorError2 = err;
-      } finally {
-        try {
-          if (!_iteratorNormalCompletion2 && _iterator2.return) {
-            _iterator2.return();
-          }
-        } finally {
-          if (_didIteratorError2) {
-            throw _iteratorError2;
-          }
-        }
+      for (let { statement, params } of proxy.operations) {
+        yield conn.executeCached(statement, params);
       }
     }).then(_ => result);
   }
 
   get(id) {
     const params = {
       collection_name: this.collection,
       record_id: id
@@ -245,27 +206,31 @@ class FirefoxAdapter extends _base2.defa
     return this._executeStatement(statements.getRecord, params).then(result => {
       if (result.length == 0) {
         return;
       }
       return JSON.parse(result[0].getResultByName("record"));
     });
   }
 
-  list() {
-    const params = {
+  list(params = { filters: {}, order: "" }) {
+    const parameters = {
       collection_name: this.collection
     };
-    return this._executeStatement(statements.listRecords, params).then(result => {
+    return this._executeStatement(statements.listRecords, parameters).then(result => {
       const records = [];
       for (let k = 0; k < result.length; k++) {
         const row = result[k];
         records.push(JSON.parse(row.getResultByName("record")));
       }
       return records;
+    }).then(results => {
+      // The resulting list of records is filtered and sorted.
+      // XXX: with some efforts, this could be implemented using SQL.
+      return (0, _utils.reduceRecords)(params.filters, params.order, results);
     });
   }
 
   /**
    * Load a list of records into the local database.
    *
    * Note: The adapter is not in charge of filtering the already imported
    * records. This is done in `Collection#loadDump()`, as a common behaviour
@@ -274,52 +239,30 @@ class FirefoxAdapter extends _base2.defa
    * @param  {Array} records.
    * @return {Array} imported records.
    */
   loadDump(records) {
     const connection = this._connection;
     const collection_name = this.collection;
     return Task.spawn(function* () {
       yield connection.executeTransaction(function* doImport() {
-        var _iteratorNormalCompletion3 = true;
-        var _didIteratorError3 = false;
-        var _iteratorError3 = undefined;
-
-        try {
-          for (var _iterator3 = records[Symbol.iterator](), _step3; !(_iteratorNormalCompletion3 = (_step3 = _iterator3.next()).done); _iteratorNormalCompletion3 = true) {
-            const record = _step3.value;
-
-            const params = {
-              collection_name: collection_name,
-              record_id: record.id,
-              record: JSON.stringify(record)
-            };
-            yield connection.execute(statements.importData, params);
-          }
-        } catch (err) {
-          _didIteratorError3 = true;
-          _iteratorError3 = err;
-        } finally {
-          try {
-            if (!_iteratorNormalCompletion3 && _iterator3.return) {
-              _iterator3.return();
-            }
-          } finally {
-            if (_didIteratorError3) {
-              throw _iteratorError3;
-            }
-          }
+        for (let record of records) {
+          const params = {
+            collection_name: collection_name,
+            record_id: record.id,
+            record: JSON.stringify(record)
+          };
+          yield connection.execute(statements.importData, params);
         }
-
         const lastModified = Math.max(...records.map(record => record.last_modified));
         const params = {
           collection_name: collection_name
         };
         const previousLastModified = yield connection.execute(statements.getLastModified, params).then(result => {
-          return result ? result[0].getResultByName('last_modified') : -1;
+          return result.length > 0 ? result[0].getResultByName('last_modified') : -1;
         });
         if (lastModified > previousLastModified) {
           const params = {
             collection_name: collection_name,
             last_modified: lastModified
           };
           yield connection.execute(statements.saveLastModified, params);
         }
@@ -393,17 +336,17 @@ function transactionProxy(collection, pr
 
     get(id) {
       // Gecko JS engine outputs undesired warnings if id is not in preloaded.
       return id in preloaded ? preloaded[id] : undefined;
     }
   };
 }
 
-},{"../src/adapters/base":11}],2:[function(require,module,exports){
+},{"../src/adapters/base":17,"../src/utils":19}],2:[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
  *
  *     http://www.apache.org/licenses/LICENSE-2.0
  *
@@ -468,17 +411,19 @@ function loadKinto() {
 }
 
 // This fixes compatibility with CommonJS required by browserify.
 // See http://stackoverflow.com/questions/33505992/babel-6-changes-how-it-exports-default/33683495#33683495
 if (typeof module === "object") {
   module.exports = loadKinto;
 }
 
-},{"../src/KintoBase":10,"../src/adapters/base":11,"./FirefoxStorage":1}],3:[function(require,module,exports){
+},{"../src/KintoBase":16,"../src/adapters/base":17,"./FirefoxStorage":1}],3:[function(require,module,exports){
+
+},{}],4:[function(require,module,exports){
 // http://wiki.commonjs.org/wiki/Unit_Testing/1.0
 //
 // THIS IS NOT TESTED NOR LIKELY TO WORK OUTSIDE V8!
 //
 // Originally from narwhal.js (http://narwhaljs.org)
 // Copyright (c) 2009 Thomas Robinson <280north.com>
 //
 // Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -829,17 +774,317 @@ assert.ifError = function(err) { if (err
 var objectKeys = Object.keys || function (obj) {
   var keys = [];
   for (var key in obj) {
     if (hasOwn.call(obj, key)) keys.push(key);
   }
   return keys;
 };
 
-},{"util/":7}],4:[function(require,module,exports){
+},{"util/":9}],5:[function(require,module,exports){
+// Copyright Joyent, Inc. and other Node contributors.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a
+// copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to permit
+// persons to whom the Software is furnished to do so, subject to the
+// following conditions:
+//
+// The above copyright notice and this permission notice shall be included
+// in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
+// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+// USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+function EventEmitter() {
+  this._events = this._events || {};
+  this._maxListeners = this._maxListeners || undefined;
+}
+module.exports = EventEmitter;
+
+// Backwards-compat with node 0.10.x
+EventEmitter.EventEmitter = EventEmitter;
+
+EventEmitter.prototype._events = undefined;
+EventEmitter.prototype._maxListeners = undefined;
+
+// By default EventEmitters will print a warning if more than 10 listeners are
+// added to it. This is a useful default which helps finding memory leaks.
+EventEmitter.defaultMaxListeners = 10;
+
+// Obviously not all Emitters should be limited to 10. This function allows
+// that to be increased. Set to zero for unlimited.
+EventEmitter.prototype.setMaxListeners = function(n) {
+  if (!isNumber(n) || n < 0 || isNaN(n))
+    throw TypeError('n must be a positive number');
+  this._maxListeners = n;
+  return this;
+};
+
+EventEmitter.prototype.emit = function(type) {
+  var er, handler, len, args, i, listeners;
+
+  if (!this._events)
+    this._events = {};
+
+  // If there is no 'error' event listener then throw.
+  if (type === 'error') {
+    if (!this._events.error ||
+        (isObject(this._events.error) && !this._events.error.length)) {
+      er = arguments[1];
+      if (er instanceof Error) {
+        throw er; // Unhandled 'error' event
+      }
+      throw TypeError('Uncaught, unspecified "error" event.');
+    }
+  }
+
+  handler = this._events[type];
+
+  if (isUndefined(handler))
+    return false;
+
+  if (isFunction(handler)) {
+    switch (arguments.length) {
+      // fast cases
+      case 1:
+        handler.call(this);
+        break;
+      case 2:
+        handler.call(this, arguments[1]);
+        break;
+      case 3:
+        handler.call(this, arguments[1], arguments[2]);
+        break;
+      // slower
+      default:
+        args = Array.prototype.slice.call(arguments, 1);
+        handler.apply(this, args);
+    }
+  } else if (isObject(handler)) {
+    args = Array.prototype.slice.call(arguments, 1);
+    listeners = handler.slice();
+    len = listeners.length;
+    for (i = 0; i < len; i++)
+      listeners[i].apply(this, args);
+  }
+
+  return true;
+};
+
+EventEmitter.prototype.addListener = function(type, listener) {
+  var m;
+
+  if (!isFunction(listener))
+    throw TypeError('listener must be a function');
+
+  if (!this._events)
+    this._events = {};
+
+  // To avoid recursion in the case that type === "newListener"! Before
+  // adding it to the listeners, first emit "newListener".
+  if (this._events.newListener)
+    this.emit('newListener', type,
+              isFunction(listener.listener) ?
+              listener.listener : listener);
+
+  if (!this._events[type])
+    // Optimize the case of one listener. Don't need the extra array object.
+    this._events[type] = listener;
+  else if (isObject(this._events[type]))
+    // If we've already got an array, just append.
+    this._events[type].push(listener);
+  else
+    // Adding the second element, need to change to array.
+    this._events[type] = [this._events[type], listener];
+
+  // Check for listener leak
+  if (isObject(this._events[type]) && !this._events[type].warned) {
+    if (!isUndefined(this._maxListeners)) {
+      m = this._maxListeners;
+    } else {
+      m = EventEmitter.defaultMaxListeners;
+    }
+
+    if (m && m > 0 && this._events[type].length > m) {
+      this._events[type].warned = true;
+      console.error('(node) warning: possible EventEmitter memory ' +
+                    'leak detected. %d listeners added. ' +
+                    'Use emitter.setMaxListeners() to increase limit.',
+                    this._events[type].length);
+      if (typeof console.trace === 'function') {
+        // not supported in IE 10
+        console.trace();
+      }
+    }
+  }
+
+  return this;
+};
+
+EventEmitter.prototype.on = EventEmitter.prototype.addListener;
+
+EventEmitter.prototype.once = function(type, listener) {
+  if (!isFunction(listener))
+    throw TypeError('listener must be a function');
+
+  var fired = false;
+
+  function g() {
+    this.removeListener(type, g);
+
+    if (!fired) {
+      fired = true;
+      listener.apply(this, arguments);
+    }
+  }
+
+  g.listener = listener;
+  this.on(type, g);
+
+  return this;
+};
+
+// emits a 'removeListener' event iff the listener was removed
+EventEmitter.prototype.removeListener = function(type, listener) {
+  var list, position, length, i;
+
+  if (!isFunction(listener))
+    throw TypeError('listener must be a function');
+
+  if (!this._events || !this._events[type])
+    return this;
+
+  list = this._events[type];
+  length = list.length;
+  position = -1;
+
+  if (list === listener ||
+      (isFunction(list.listener) && list.listener === listener)) {
+    delete this._events[type];
+    if (this._events.removeListener)
+      this.emit('removeListener', type, listener);
+
+  } else if (isObject(list)) {
+    for (i = length; i-- > 0;) {
+      if (list[i] === listener ||
+          (list[i].listener && list[i].listener === listener)) {
+        position = i;
+        break;
+      }
+    }
+
+    if (position < 0)
+      return this;
+
+    if (list.length === 1) {
+      list.length = 0;
+      delete this._events[type];
+    } else {
+      list.splice(position, 1);
+    }
+
+    if (this._events.removeListener)
+      this.emit('removeListener', type, listener);
+  }
+
+  return this;
+};
+
+EventEmitter.prototype.removeAllListeners = function(type) {
+  var key, listeners;
+
+  if (!this._events)
+    return this;
+
+  // not listening for removeListener, no need to emit
+  if (!this._events.removeListener) {
+    if (arguments.length === 0)
+      this._events = {};
+    else if (this._events[type])
+      delete this._events[type];
+    return this;
+  }
+
+  // emit removeListener for all listeners on all events
+  if (arguments.length === 0) {
+    for (key in this._events) {
+      if (key === 'removeListener') continue;
+      this.removeAllListeners(key);
+    }
+    this.removeAllListeners('removeListener');
+    this._events = {};
+    return this;
+  }
+
+  listeners = this._events[type];
+
+  if (isFunction(listeners)) {
+    this.removeListener(type, listeners);
+  } else if (listeners) {
+    // LIFO order
+    while (listeners.length)
+      this.removeListener(type, listeners[listeners.length - 1]);
+  }
+  delete this._events[type];
+
+  return this;
+};
+
+EventEmitter.prototype.listeners = function(type) {
+  var ret;
+  if (!this._events || !this._events[type])
+    ret = [];
+  else if (isFunction(this._events[type]))
+    ret = [this._events[type]];
+  else
+    ret = this._events[type].slice();
+  return ret;
+};
+
+EventEmitter.prototype.listenerCount = function(type) {
+  if (this._events) {
+    var evlistener = this._events[type];
+
+    if (isFunction(evlistener))
+      return 1;
+    else if (evlistener)
+      return evlistener.length;
+  }
+  return 0;
+};
+
+EventEmitter.listenerCount = function(emitter, type) {
+  return emitter.listenerCount(type);
+};
+
+function isFunction(arg) {
+  return typeof arg === 'function';
+}
+
+function isNumber(arg) {
+  return typeof arg === 'number';
+}
+
+function isObject(arg) {
+  return typeof arg === 'object' && arg !== null;
+}
+
+function isUndefined(arg) {
+  return arg === void 0;
+}
+
+},{}],6:[function(require,module,exports){
 if (typeof Object.create === 'function') {
   // implementation from standard node.js 'util' module
   module.exports = function inherits(ctor, superCtor) {
     ctor.super_ = superCtor
     ctor.prototype = Object.create(superCtor.prototype, {
       constructor: {
         value: ctor,
         enumerable: false,
@@ -854,17 +1099,17 @@ if (typeof Object.create === 'function')
     ctor.super_ = superCtor
     var TempCtor = function () {}
     TempCtor.prototype = superCtor.prototype
     ctor.prototype = new TempCtor()
     ctor.prototype.constructor = ctor
   }
 }
 
-},{}],5:[function(require,module,exports){
+},{}],7:[function(require,module,exports){
 // shim for using process in browser
 
 var process = module.exports = {};
 var queue = [];
 var draining = false;
 var currentQueue;
 var queueIndex = -1;
 
@@ -947,24 +1192,24 @@ process.binding = function (name) {
 };
 
 process.cwd = function () { return '/' };
 process.chdir = function (dir) {
     throw new Error('process.chdir is not supported');
 };
 process.umask = function() { return 0; };
 
-},{}],6:[function(require,module,exports){
+},{}],8:[function(require,module,exports){
 module.exports = function isBuffer(arg) {
   return arg && typeof arg === 'object'
     && typeof arg.copy === 'function'
     && typeof arg.fill === 'function'
     && typeof arg.readUInt8 === 'function';
 }
-},{}],7:[function(require,module,exports){
+},{}],9:[function(require,module,exports){
 (function (process,global){
 // Copyright Joyent, Inc. and other Node contributors.
 //
 // Permission is hereby granted, free of charge, to any person obtaining a
 // copy of this software and associated documentation files (the
 // "Software"), to deal in the Software without restriction, including
 // without limitation the rights to use, copy, modify, merge, publish,
 // distribute, sublicense, and/or sell copies of the Software, and to permit
@@ -1544,17 +1789,713 @@ exports._extend = function(origin, add) 
   return origin;
 };
 
 function hasOwnProperty(obj, prop) {
   return Object.prototype.hasOwnProperty.call(obj, prop);
 }
 
 }).call(this,require('_process'),typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {})
-},{"./support/isBuffer":6,"_process":5,"inherits":4}],8:[function(require,module,exports){
+},{"./support/isBuffer":8,"_process":7,"inherits":6}],10:[function(require,module,exports){
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+  value: true
+});
+/**
+ * Kinto server error code descriptors.
+ * @type {Object}
+ */
+exports.default = {
+  104: "Missing Authorization Token",
+  105: "Invalid Authorization Token",
+  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",
+  115: "Method not allowed on this end point",
+  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"
+};
+},{}],11:[function(require,module,exports){
+"use strict";
+
+var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }();
+
+Object.defineProperty(exports, "__esModule", {
+  value: true
+});
+
+var _errors = require("./errors.js");
+
+var _errors2 = _interopRequireDefault(_errors);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+
+/**
+ * Enhanced HTTP client for the Kinto protocol.
+ */
+
+var HTTP = function () {
+  _createClass(HTTP, null, [{
+    key: "DEFAULT_REQUEST_HEADERS",
+
+    /**
+     * Default HTTP request headers applied to each outgoing request.
+     *
+     * @type {Object}
+     */
+    get: function get() {
+      return {
+        "Accept": "application/json",
+        "Content-Type": "application/json"
+      };
+    }
+
+    /**
+     * Default options.
+     *
+     * @type {Object}
+     */
+
+  }, {
+    key: "defaultOptions",
+    get: function get() {
+      return { timeout: 5000, requestMode: "cors" };
+    }
+
+    /**
+     * Constructor.
+     *
+     * Options:
+     * - {Number} timeout      The request timeout in ms (default: `5000`).
+     * - {String} requestMode  The HTTP request mode (default: `"cors"`).
+     *
+     * @param {EventEmitter} events  The event handler.
+     * @param {Object}       options The options object.
+     */
+
+  }]);
+
+  function HTTP(events) {
+    var options = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1];
+
+    _classCallCheck(this, HTTP);
+
+    // public properties
+    /**
+     * The event emitter instance.
+     * @type {EventEmitter}
+     */
+    if (!events) {
+      throw new Error("No events handler provided");
+    }
+    this.events = events;
+
+    options = Object.assign({}, HTTP.defaultOptions, options);
+
+    /**
+     * The request mode.
+     * @see  https://fetch.spec.whatwg.org/#requestmode
+     * @type {String}
+     */
+    this.requestMode = options.requestMode;
+
+    /**
+     * The request timeout.
+     * @type {Number}
+     */
+    this.timeout = options.timeout;
+  }
+
+  /**
+   * Performs an HTTP request to the Kinto server.
+   *
+   * Options:
+   * - `{Object} headers` The request headers object (default: {})
+   *
+   * 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.
+   * @return {Promise}
+   */
+
+  _createClass(HTTP, [{
+    key: "request",
+    value: function request(url) {
+      var _this = this;
+
+      var options = arguments.length <= 1 || arguments[1] === undefined ? { headers: {} } : arguments[1];
+
+      var response = undefined,
+          status = undefined,
+          statusText = undefined,
+          headers = undefined,
+          _timeoutId = undefined,
+          hasTimedout = undefined;
+      // Ensure default request headers are always set
+      options.headers = Object.assign({}, HTTP.DEFAULT_REQUEST_HEADERS, options.headers);
+      options.mode = this.requestMode;
+      return new Promise(function (resolve, reject) {
+        _timeoutId = setTimeout(function () {
+          hasTimedout = true;
+          reject(new Error("Request timeout."));
+        }, _this.timeout);
+        fetch(url, options).then(function (res) {
+          if (!hasTimedout) {
+            clearTimeout(_timeoutId);
+            resolve(res);
+          }
+        }).catch(function (err) {
+          if (!hasTimedout) {
+            clearTimeout(_timeoutId);
+            reject(err);
+          }
+        });
+      }).then(function (res) {
+        response = res;
+        headers = res.headers;
+        status = res.status;
+        statusText = res.statusText;
+        _this._checkForDeprecationHeader(headers);
+        _this._checkForBackoffHeader(status, headers);
+        return res.text();
+      })
+      // Check if we have a body; if so parse it as JSON.
+      .then(function (text) {
+        if (text.length === 0) {
+          return null;
+        }
+        // Note: we can't consume the response body twice.
+        return JSON.parse(text);
+      }).catch(function (err) {
+        var error = new Error("HTTP " + (status || 0) + "; " + err);
+        error.response = response;
+        error.stack = err.stack;
+        throw error;
+      }).then(function (json) {
+        if (json && status >= 400) {
+          var message = "HTTP " + status + "; ";
+          if (json.errno && json.errno in _errors2.default) {
+            message += _errors2.default[json.errno];
+            if (json.message) {
+              message += ": " + json.message;
+            }
+          } else {
+            message += statusText || "";
+          }
+          var error = new Error(message.trim());
+          error.response = response;
+          error.data = json;
+          throw error;
+        }
+        return { status: status, json: json, headers: headers };
+      });
+    }
+  }, {
+    key: "_checkForDeprecationHeader",
+    value: function _checkForDeprecationHeader(headers) {
+      var alertHeader = headers.get("Alert");
+      if (!alertHeader) {
+        return;
+      }
+      var alert = undefined;
+      try {
+        alert = JSON.parse(alertHeader);
+      } catch (err) {
+        console.warn("Unable to parse Alert header message", alertHeader);
+        return;
+      }
+      console.warn(alert.message, alert.url);
+      this.events.emit("deprecated", alert);
+    }
+  }, {
+    key: "_checkForBackoffHeader",
+    value: function _checkForBackoffHeader(status, headers) {
+      var backoffMs = undefined;
+      var backoffSeconds = parseInt(headers.get("Backoff"), 10);
+      if (backoffSeconds > 0) {
+        backoffMs = new Date().getTime() + backoffSeconds * 1000;
+      } else {
+        backoffMs = 0;
+      }
+      this.events.emit("backoff", backoffMs);
+    }
+  }]);
+
+  return HTTP;
+}();
+
+exports.default = HTTP;
+},{"./errors.js":10}],12:[function(require,module,exports){
+"use strict";
+
+var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }();
+
+Object.defineProperty(exports, "__esModule", {
+  value: true
+});
+exports.SUPPORTED_PROTOCOL_VERSION = undefined;
+
+require("isomorphic-fetch");
+
+var _events = require("events");
+
+var _utils = require("./utils.js");
+
+var _http = require("./http.js");
+
+var _http2 = _interopRequireDefault(_http);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+
+/**
+ * Currently supported protocol version.
+ * @type {String}
+ */
+var SUPPORTED_PROTOCOL_VERSION = exports.SUPPORTED_PROTOCOL_VERSION = "v1";
+
+/**
+ * High level HTTP client for the Kinto API.
+ */
+
+var KintoApi = function () {
+  /**
+   * Constructor.
+   *
+   * Options:
+   * - {Object} headers      The key-value headers to pass to each request.
+   * - {String} requestMode  The HTTP request mode.
+   *
+   * @param  {String}       remote  The remote URL.
+   * @param  {EventEmitter} events  The events handler
+   * @param  {Object}       options The options object.
+   */
+
+  function KintoApi(remote, events) {
+    var options = arguments.length <= 2 || arguments[2] === undefined ? {} : arguments[2];
+
+    _classCallCheck(this, KintoApi);
+
+    if (typeof remote !== "string" || !remote.length) {
+      throw new Error("Invalid remote URL: " + remote);
+    }
+    if (remote[remote.length - 1] === "/") {
+      remote = remote.slice(0, -1);
+    }
+    this._backoffReleaseTime = null;
+    this.remote = remote;
+
+    // public properties
+    /**
+     * The optional generic headers.
+     * @type {Object}
+     */
+    this.optionHeaders = options.headers || {};
+    /**
+     * Current server settings, retrieved from the server.
+     * @type {Object}
+     */
+    this.serverSettings = null;
+    /**
+     * The even emitter instance.
+     * @type {EventEmitter}
+     */
+    this.events = events || new _events.EventEmitter();
+
+    /**
+     * The HTTP instance.
+     * @type {HTTP}
+     */
+    this.http = new _http2.default(this.events, { requestMode: options.requestMode });
+    this._registerHTTPEvents();
+  }
+
+  /**
+   * The remote endpoint base URL. Setting the value will also extract and
+   * validate the version.
+   * @type {String}
+   */
+
+  _createClass(KintoApi, [{
+    key: "_registerHTTPEvents",
+
+    /**
+     * Registers HTTP events.
+     */
+    value: function _registerHTTPEvents() {
+      var _this = this;
+
+      this.events.on("backoff", function (backoffMs) {
+        _this._backoffReleaseTime = backoffMs;
+      });
+    }
+
+    /**
+     * Retrieves available server enpoints.
+     *
+     * Options:
+     * - {Boolean} fullUrl: Retrieve a fully qualified URL (default: true).
+     *
+     * @param  {Object} options Options object.
+     * @return {String}
+     */
+
+  }, {
+    key: "endpoints",
+    value: function endpoints() {
+      var options = arguments.length <= 0 || arguments[0] === undefined ? { fullUrl: true } : arguments[0];
+
+      var _root = options.fullUrl ? this.remote : "/" + this.version;
+      var urls = {
+        root: function root() {
+          return _root + "/";
+        },
+        batch: function batch() {
+          return _root + "/batch";
+        },
+        bucket: function bucket(_bucket) {
+          return _root + "/buckets/" + _bucket;
+        },
+        collection: function collection(bucket, coll) {
+          return urls.bucket(bucket) + "/collections/" + coll;
+        },
+        records: function records(bucket, coll) {
+          return urls.collection(bucket, coll) + "/records";
+        },
+        record: function record(bucket, coll, id) {
+          return urls.records(bucket, coll) + "/" + id;
+        }
+      };
+      return urls;
+    }
+
+    /**
+     * Retrieves Kinto server settings.
+     *
+     * @return {Promise}
+     */
+
+  }, {
+    key: "fetchServerSettings",
+    value: function fetchServerSettings() {
+      var _this2 = this;
+
+      if (this.serverSettings) {
+        return Promise.resolve(this.serverSettings);
+      }
+      return this.http.request(this.endpoints().root()).then(function (res) {
+        _this2.serverSettings = res.json.settings;
+        return _this2.serverSettings;
+      });
+    }
+
+    /**
+     * Fetches latest changes from the remote server.
+     *
+     * @param  {String} bucketName  The bucket name.
+     * @param  {String} collName    The collection name.
+     * @param  {Object} options     The options object.
+     * @return {Promise}
+     */
+
+  }, {
+    key: "fetchChangesSince",
+    value: function fetchChangesSince(bucketName, collName) {
+      var _this3 = this;
+
+      var options = arguments.length <= 2 || arguments[2] === undefined ? { lastModified: null, headers: {} } : arguments[2];
+
+      var recordsUrl = this.endpoints().records(bucketName, collName);
+      var queryString = "";
+      var headers = Object.assign({}, this.optionHeaders, options.headers);
+
+      if (options.lastModified) {
+        queryString = "?_since=" + options.lastModified;
+        headers["If-None-Match"] = (0, _utils.quote)(options.lastModified);
+      }
+
+      return this.fetchServerSettings().then(function (_) {
+        return _this3.http.request(recordsUrl + queryString, { headers: headers });
+      }).then(function (res) {
+        // If HTTP 304, nothing has changed
+        if (res.status === 304) {
+          return {
+            lastModified: options.lastModified,
+            changes: []
+          };
+        }
+        // XXX: ETag are supposed to be opaque and stored «as-is».
+        // Extract response data
+        var etag = res.headers.get("ETag"); // e.g. '"42"'
+        etag = etag ? parseInt((0, _utils.unquote)(etag), 10) : options.lastModified;
+        var records = res.json.data;
+
+        // Check if server was flushed
+        var localSynced = options.lastModified;
+        var serverChanged = etag > options.lastModified;
+        var emptyCollection = records ? records.length === 0 : true;
+        if (localSynced && serverChanged && emptyCollection) {
+          throw Error("Server has been flushed.");
+        }
+
+        return { lastModified: etag, changes: records };
+      });
+    }
+
+    /**
+     * Builds an individual record batch request body.
+     *
+     * @param  {Object}  record The record object.
+     * @param  {String}  path   The record endpoint URL.
+     * @param  {Boolean} safe   Safe update?
+     * @return {Object}         The request body object.
+     */
+
+  }, {
+    key: "_buildRecordBatchRequest",
+    value: function _buildRecordBatchRequest(record, path, safe) {
+      var method = record.deleted ? "DELETE" : "PUT";
+      // Prepare the record body to send with any last_modified property dropped
+      var cleanedRecord = Object.assign({}, record, { last_modified: undefined });
+      var body = record.deleted ? undefined : { data: cleanedRecord };
+      var headers = {};
+      if (safe) {
+        if (record.last_modified) {
+          // Safe replace.
+          headers["If-Match"] = (0, _utils.quote)(record.last_modified);
+        } else if (!record.deleted) {
+          // Safe creation.
+          headers["If-None-Match"] = "*";
+        }
+      }
+      return { method: method, headers: headers, path: path, body: body };
+    }
+
+    /**
+     * Process a batch request response.
+     *
+     * @param  {Object}  results          The results object.
+     * @param  {Array}   records          The initial records list.
+     * @param  {Object}  response         The response HTTP object.
+     * @return {Promise}
+     */
+
+  }, {
+    key: "_processBatchResponses",
+    value: function _processBatchResponses(results, records, response) {
+      // Handle individual batch subrequests responses
+      response.json.responses.forEach(function (response, index) {
+        // TODO: handle 409 when unicity rule is violated (ex. POST with
+        // existing id, unique field, etc.)
+        if (response.status && response.status >= 200 && response.status < 400) {
+          results.published.push(response.body.data);
+        } else if (response.status === 404) {
+          results.skipped.push(records[index]);
+        } else if (response.status === 412) {
+          results.conflicts.push({
+            type: "outgoing",
+            local: records[index],
+            remote: response.body.details && response.body.details.existing || null
+          });
+        } else {
+          results.errors.push({
+            path: response.path,
+            sent: records[index],
+            error: response.body
+          });
+        }
+      });
+      return results;
+    }
+
+    /**
+     * Sends batch update requests to the remote server.
+     *
+     * Options:
+     * - {Object}  headers  Headers to attach to main and all subrequests.
+     * - {Boolean} safe     Safe update (default: `true`)
+     *
+     * @param  {String} bucketName  The bucket name.
+     * @param  {String} collName    The collection name.
+     * @param  {Array}  records     The list of record updates to send.
+     * @param  {Object} options     The options object.
+     * @return {Promise}
+     */
+
+  }, {
+    key: "batch",
+    value: function batch(bucketName, collName, records) {
+      var _this4 = this;
+
+      var options = arguments.length <= 3 || arguments[3] === undefined ? { headers: {} } : arguments[3];
+
+      var safe = options.safe || true;
+      var headers = Object.assign({}, this.optionHeaders, options.headers);
+      var results = {
+        errors: [],
+        published: [],
+        conflicts: [],
+        skipped: []
+      };
+      if (!records.length) {
+        return Promise.resolve(results);
+      }
+      return this.fetchServerSettings().then(function (serverSettings) {
+        // Kinto 1.6.1 possibly exposes multiple setting prefixes
+        var maxRequests = serverSettings["batch_max_requests"] || serverSettings["cliquet.batch_max_requests"];
+        if (maxRequests && records.length > maxRequests) {
+          return Promise.all((0, _utils.partition)(records, maxRequests).map(function (chunk) {
+            return _this4.batch(bucketName, collName, chunk, options);
+          })).then(function (batchResults) {
+            // Assemble responses of chunked batch results into one single
+            // result object
+            return batchResults.reduce(function (acc, batchResult) {
+              Object.keys(batchResult).forEach(function (key) {
+                acc[key] = results[key].concat(batchResult[key]);
+              });
+              return acc;
+            }, results);
+          });
+        }
+        return _this4.http.request(_this4.endpoints().batch(), {
+          method: "POST",
+          headers: headers,
+          body: JSON.stringify({
+            defaults: { headers: headers },
+            requests: records.map(function (record) {
+              var path = _this4.endpoints({ full: false }).record(bucketName, collName, record.id);
+              return _this4._buildRecordBatchRequest(record, path, safe);
+            })
+          })
+        }).then(function (res) {
+          return _this4._processBatchResponses(results, records, res);
+        });
+      });
+    }
+  }, {
+    key: "remote",
+    get: function get() {
+      return this._remote;
+    },
+    set: function set(url) {
+      var version = undefined;
+      try {
+        version = url.match(/\/(v\d+)\/?$/)[1];
+      } catch (err) {
+        throw new Error("The remote URL must contain the version: " + url);
+      }
+      if (version !== SUPPORTED_PROTOCOL_VERSION) {
+        throw new Error("Unsupported protocol version: " + version);
+      }
+      this._remote = url;
+      this._version = version;
+    }
+
+    /**
+     * The current server protocol version, eg. `v1`.
+     * @type {String}
+     */
+
+  }, {
+    key: "version",
+    get: function get() {
+      return this._version;
+    }
+
+    /**
+     * Backoff remaining time, in milliseconds. Defaults to zero if no backoff is
+     * ongoing.
+     *
+     * @return {Number}
+     */
+
+  }, {
+    key: "backoff",
+    get: function get() {
+      var currentTime = new Date().getTime();
+      if (this._backoffReleaseTime && currentTime < this._backoffReleaseTime) {
+        return this._backoffReleaseTime - currentTime;
+      }
+      return 0;
+    }
+  }]);
+
+  return KintoApi;
+}();
+
+exports.default = KintoApi;
+},{"./http.js":11,"./utils.js":13,"events":5,"isomorphic-fetch":3}],13:[function(require,module,exports){
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+  value: true
+});
+exports.quote = quote;
+exports.unquote = unquote;
+exports.partition = partition;
+/**
+ * Returns the specified string with double quotes.
+ *
+ * @param  {String} str  A string to quote.
+ * @return {String}
+ */
+function quote(str) {
+  return "\"" + str + "\"";
+}
+
+/**
+ * Trim double quotes from specified string.
+ *
+ * @param  {String} str  A string to unquote.
+ * @return {String}
+ */
+function unquote(str) {
+  return str.replace(/^"/, "").replace(/"$/, "");
+}
+
+/**
+ * Chunks an array into n pieces.
+ *
+ * @param  {Array}  array
+ * @param  {Number} n
+ * @return {Array}
+ */
+function partition(array, n) {
+  if (n <= 0) {
+    return array;
+  }
+  return array.reduce(function (acc, x, i) {
+    if (i === 0 || i % n === 0) {
+      acc.push([x]);
+    } else {
+      acc[acc.length - 1].push(x);
+    }
+    return acc;
+  }, []);
+}
+},{}],14:[function(require,module,exports){
 (function (global){
 
 var rng;
 
 if (global.crypto && crypto.getRandomValues) {
   // WHATWG crypto-based RNG - http://wiki.whatwg.org/wiki/Crypto
   // Moderately fast, high quality
   var _rnds8 = new Uint8Array(16);
@@ -1579,17 +2520,17 @@ if (!rng) {
     return _rnds;
   };
 }
 
 module.exports = rng;
 
 
 }).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {})
-},{}],9:[function(require,module,exports){
+},{}],15:[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
@@ -1764,26 +2705,26 @@ function v4(options, buf, offset) {
 var uuid = v4;
 uuid.v1 = v1;
 uuid.v4 = v4;
 uuid.parse = parse;
 uuid.unparse = unparse;
 
 module.exports = uuid;
 
-},{"./rng":8}],10:[function(require,module,exports){
+},{"./rng":14}],16:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 
-var _api = require("./api");
-
-var _api2 = _interopRequireDefault(_api);
+var _kintoClient = require("kinto-client");
+
+var _kintoClient2 = _interopRequireDefault(_kintoClient);
 
 var _collection = require("./collection");
 
 var _collection2 = _interopRequireDefault(_collection);
 
 var _base = require("./adapters/base");
 
 var _base2 = _interopRequireDefault(_base);
@@ -1842,17 +2783,17 @@ class KintoBase {
       remote: DEFAULT_REMOTE
     };
     this._options = Object.assign(defaults, options);
     if (!this._options.adapter) {
       throw new Error("No adapter provided");
     }
 
     const { remote, events, headers, requestMode } = this._options;
-    this._api = new _api2.default(remote, events, { headers, requestMode });
+    this._api = new _kintoClient2.default(remote, events, { headers, requestMode });
 
     // public properties
     /**
      * The event emitter instance.
      * @type {EventEmitter}
      */
     this.events = this._options.events;
   }
@@ -1878,17 +2819,17 @@ class KintoBase {
       dbPrefix: this._options.dbPrefix,
       idSchema: options.idSchema,
       remoteTransformers: options.remoteTransformers
     });
   }
 }
 exports.default = KintoBase;
 
-},{"./adapters/base":11,"./api":12,"./collection":13}],11:[function(require,module,exports){
+},{"./adapters/base":17,"./collection":18,"kinto-client":12}],17:[function(require,module,exports){
 "use strict";
 
 /**
  * Base db adapter.
  *
  * @abstract
  */
 
@@ -1948,19 +2889,20 @@ class BaseAdapter {
   get(id) {
     throw new Error("Not Implemented.");
   }
 
   /**
    * Lists all records from the database.
    *
    * @abstract
+   * @param  {Object} params  The filters and order to apply to the results.
    * @return {Promise}
    */
-  list() {
+  list(params = { filters: {}, order: "" }) {
     throw new Error("Not Implemented.");
   }
 
   /**
    * Store the lastModified value.
    *
    * @abstract
    * @param  {Number}  lastModified
@@ -1987,39 +2929,36 @@ class BaseAdapter {
    * @return {Promise}
    */
   loadDump(records) {
     throw new Error("Not Implemented.");
   }
 }
 exports.default = BaseAdapter;
 
-},{}],12:[function(require,module,exports){
+},{}],18:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
-exports.SUPPORTED_PROTOCOL_VERSION = undefined;
+exports.SyncResultObject = undefined;
 exports.cleanRecord = cleanRecord;
 
-var _utils = require("./utils.js");
-
-var _http = require("./http.js");
-
-var _http2 = _interopRequireDefault(_http);
+var _base = require("./adapters/base");
+
+var _base2 = _interopRequireDefault(_base);
+
+var _utils = require("./utils");
+
+var _uuid = require("uuid");
 
 function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
 
 const RECORD_FIELDS_TO_CLEAN = ["_status", "last_modified"];
-/**
- * Currently supported protocol version.
- * @type {String}
- */
-const SUPPORTED_PROTOCOL_VERSION = exports.SUPPORTED_PROTOCOL_VERSION = "v1";
 
 /**
  * Cleans a record object, excluding passed keys.
  *
  * @param  {Object} record        The record object.
  * @param  {Array}  excludeFields The list of keys to exclude.
  * @return {Object}               A clean copy of source record object.
  */
@@ -2028,341 +2967,16 @@ function cleanRecord(record, excludeFiel
     if (excludeFields.indexOf(key) === -1) {
       acc[key] = record[key];
     }
     return acc;
   }, {});
 }
 
 /**
- * High level HTTP client for the Kinto API.
- */
-class Api {
-  /**
-   * Constructor.
-   *
-   * Options:
-   * - {Object} headers      The key-value headers to pass to each request.
-   * - {String} requestMode  The HTTP request mode.
-   *
-   * @param  {String}       remote  The remote URL.
-   * @param  {EventEmitter} events  The events handler
-   * @param  {Object}       options The options object.
-   */
-  constructor(remote, events, options = {}) {
-    if (typeof remote !== "string" || !remote.length) {
-      throw new Error("Invalid remote URL: " + remote);
-    }
-    if (remote[remote.length - 1] === "/") {
-      remote = remote.slice(0, -1);
-    }
-    this._backoffReleaseTime = null;
-    this.remote = remote;
-
-    // public properties
-    /**
-     * The optional generic headers.
-     * @type {Object}
-     */
-    this.optionHeaders = options.headers || {};
-    /**
-     * Current server settings, retrieved from the server.
-     * @type {Object}
-     */
-    this.serverSettings = null;
-    /**
-     * The even emitter instance.
-     * @type {EventEmitter}
-     */
-    if (!events) {
-      throw new Error("No events handler provided");
-    }
-    this.events = events;
-
-    /**
-     * The HTTP instance.
-     * @type {HTTP}
-     */
-    this.http = new _http2.default(this.events, { requestMode: options.requestMode });
-    this._registerHTTPEvents();
-  }
-
-  /**
-   * The remote endpoint base URL. Setting the value will also extract and
-   * validate the version.
-   * @type {String}
-   */
-  get remote() {
-    return this._remote;
-  }
-
-  set remote(url) {
-    let version;
-    try {
-      version = url.match(/\/(v\d+)\/?$/)[1];
-    } catch (err) {
-      throw new Error("The remote URL must contain the version: " + url);
-    }
-    if (version !== SUPPORTED_PROTOCOL_VERSION) {
-      throw new Error(`Unsupported protocol version: ${ version }`);
-    }
-    this._remote = url;
-    this._version = version;
-  }
-
-  /**
-   * The current server protocol version, eg. `v1`.
-   * @type {String}
-   */
-  get version() {
-    return this._version;
-  }
-
-  /**
-   * Backoff remaining time, in milliseconds. Defaults to zero if no backoff is
-   * ongoing.
-   *
-   * @return {Number}
-   */
-  get backoff() {
-    const currentTime = new Date().getTime();
-    if (this._backoffReleaseTime && currentTime < this._backoffReleaseTime) {
-      return this._backoffReleaseTime - currentTime;
-    }
-    return 0;
-  }
-
-  /**
-   * Registers HTTP events.
-   */
-  _registerHTTPEvents() {
-    this.events.on("backoff", backoffMs => {
-      this._backoffReleaseTime = backoffMs;
-    });
-  }
-
-  /**
-   * Retrieves available server enpoints.
-   *
-   * Options:
-   * - {Boolean} fullUrl: Retrieve a fully qualified URL (default: true).
-   *
-   * @param  {Object} options Options object.
-   * @return {String}
-   */
-  endpoints(options = { fullUrl: true }) {
-    const root = options.fullUrl ? this.remote : `/${ this.version }`;
-    const urls = {
-      root: () => `${ root }/`,
-      batch: () => `${ root }/batch`,
-      bucket: bucket => `${ root }/buckets/${ bucket }`,
-      collection: (bucket, coll) => `${ urls.bucket(bucket) }/collections/${ coll }`,
-      records: (bucket, coll) => `${ urls.collection(bucket, coll) }/records`,
-      record: (bucket, coll, id) => `${ urls.records(bucket, coll) }/${ id }`
-    };
-    return urls;
-  }
-
-  /**
-   * Retrieves Kinto server settings.
-   *
-   * @return {Promise}
-   */
-  fetchServerSettings() {
-    if (this.serverSettings) {
-      return Promise.resolve(this.serverSettings);
-    }
-    return this.http.request(this.endpoints().root()).then(res => {
-      this.serverSettings = res.json.settings;
-      return this.serverSettings;
-    });
-  }
-
-  /**
-   * Fetches latest changes from the remote server.
-   *
-   * @param  {String} bucketName  The bucket name.
-   * @param  {String} collName    The collection name.
-   * @param  {Object} options     The options object.
-   * @return {Promise}
-   */
-  fetchChangesSince(bucketName, collName, options = { lastModified: null, headers: {} }) {
-    const recordsUrl = this.endpoints().records(bucketName, collName);
-    let queryString = "";
-    const headers = Object.assign({}, this.optionHeaders, options.headers);
-
-    if (options.lastModified) {
-      queryString = "?_since=" + options.lastModified;
-      headers["If-None-Match"] = (0, _utils.quote)(options.lastModified);
-    }
-
-    return this.fetchServerSettings().then(_ => this.http.request(recordsUrl + queryString, { headers })).then(res => {
-      // If HTTP 304, nothing has changed
-      if (res.status === 304) {
-        return {
-          lastModified: options.lastModified,
-          changes: []
-        };
-      }
-      // XXX: ETag are supposed to be opaque and stored «as-is».
-      // Extract response data
-      let etag = res.headers.get("ETag"); // e.g. '"42"'
-      etag = etag ? parseInt((0, _utils.unquote)(etag), 10) : options.lastModified;
-      const records = res.json.data;
-
-      // Check if server was flushed
-      const localSynced = options.lastModified;
-      const serverChanged = etag > options.lastModified;
-      const emptyCollection = records ? records.length === 0 : true;
-      if (localSynced && serverChanged && emptyCollection) {
-        throw Error("Server has been flushed.");
-      }
-
-      return { lastModified: etag, changes: records };
-    });
-  }
-
-  /**
-   * Builds an individual record batch request body.
-   *
-   * @param  {Object}  record The record object.
-   * @param  {String}  path   The record endpoint URL.
-   * @param  {Boolean} safe   Safe update?
-   * @return {Object}         The request body object.
-   */
-  _buildRecordBatchRequest(record, path, safe) {
-    const isDeletion = record._status === "deleted";
-    const method = isDeletion ? "DELETE" : "PUT";
-    const body = isDeletion ? undefined : { data: cleanRecord(record) };
-    const headers = {};
-    if (safe) {
-      if (record.last_modified) {
-        // Safe replace.
-        headers["If-Match"] = (0, _utils.quote)(record.last_modified);
-      } else if (!isDeletion) {
-        // Safe creation.
-        headers["If-None-Match"] = "*";
-      }
-    }
-    return { method, headers, path, body };
-  }
-
-  /**
-   * Process a batch request response.
-   *
-   * @param  {Object}  results          The results object.
-   * @param  {Array}   records          The initial records list.
-   * @param  {Object}  response         The response HTTP object.
-   * @return {Promise}
-   */
-  _processBatchResponses(results, records, response) {
-    // Handle individual batch subrequests responses
-    response.json.responses.forEach((response, index) => {
-      // TODO: handle 409 when unicity rule is violated (ex. POST with
-      // existing id, unique field, etc.)
-      if (response.status && response.status >= 200 && response.status < 400) {
-        results.published.push(response.body.data);
-      } else if (response.status === 404) {
-        results.skipped.push(records[index]);
-      } else if (response.status === 412) {
-        results.conflicts.push({
-          type: "outgoing",
-          local: records[index],
-          remote: response.body.details && response.body.details.existing || null
-        });
-      } else {
-        results.errors.push({
-          path: response.path,
-          sent: records[index],
-          error: response.body
-        });
-      }
-    });
-    return results;
-  }
-
-  /**
-   * Sends batch update requests to the remote server.
-   *
-   * Options:
-   * - {Object}  headers  Headers to attach to main and all subrequests.
-   * - {Boolean} safe     Safe update (default: `true`)
-   *
-   * @param  {String} bucketName  The bucket name.
-   * @param  {String} collName    The collection name.
-   * @param  {Array}  records     The list of record updates to send.
-   * @param  {Object} options     The options object.
-   * @return {Promise}
-   */
-  batch(bucketName, collName, records, options = { headers: {} }) {
-    const safe = options.safe || true;
-    const headers = Object.assign({}, this.optionHeaders, options.headers);
-    const results = {
-      errors: [],
-      published: [],
-      conflicts: [],
-      skipped: []
-    };
-    if (!records.length) {
-      return Promise.resolve(results);
-    }
-    return this.fetchServerSettings().then(serverSettings => {
-      // Kinto 1.6.1 possibly exposes multiple setting prefixes
-      const maxRequests = serverSettings["batch_max_requests"] || serverSettings["cliquet.batch_max_requests"];
-      if (maxRequests && records.length > maxRequests) {
-        return Promise.all((0, _utils.partition)(records, maxRequests).map(chunk => {
-          return this.batch(bucketName, collName, chunk, options);
-        })).then(batchResults => {
-          // Assemble responses of chunked batch results into one single
-          // result object
-          return batchResults.reduce((acc, batchResult) => {
-            Object.keys(batchResult).forEach(key => {
-              acc[key] = results[key].concat(batchResult[key]);
-            });
-            return acc;
-          }, results);
-        });
-      }
-      return this.http.request(this.endpoints().batch(), {
-        method: "POST",
-        headers: headers,
-        body: JSON.stringify({
-          defaults: { headers },
-          requests: records.map(record => {
-            const path = this.endpoints({ full: false }).record(bucketName, collName, record.id);
-            return this._buildRecordBatchRequest(record, path, safe);
-          })
-        })
-      }).then(res => this._processBatchResponses(results, records, res));
-    });
-  }
-}
-exports.default = Api;
-
-},{"./http.js":15,"./utils.js":16}],13:[function(require,module,exports){
-"use strict";
-
-Object.defineProperty(exports, "__esModule", {
-  value: true
-});
-exports.SyncResultObject = undefined;
-
-var _base = require("./adapters/base");
-
-var _base2 = _interopRequireDefault(_base);
-
-var _utils = require("./utils");
-
-var _api = require("./api");
-
-var _uuid = require("uuid");
-
-function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
-
-/**
  * Synchronization result object.
  */
 class SyncResultObject {
   /**
    * Object default values.
    * @type {Object}
    */
   static get defaults() {
@@ -2461,17 +3075,17 @@ function importChange(transaction, remot
     // avoid recreation.
     if (remote.deleted) {
       return { type: "skipped", data: remote };
     }
     const synced = markSynced(remote);
     transaction.create(synced);
     return { type: "created", data: synced };
   }
-  const identical = (0, _utils.deepEquals)((0, _api.cleanRecord)(local), (0, _api.cleanRecord)(remote));
+  const identical = (0, _utils.deepEquals)(cleanRecord(local), cleanRecord(remote));
   if (local._status !== "synced") {
     // Locally deleted, unsynced: scheduled for remote deletion.
     if (local._status === "deleted") {
       return { type: "skipped", data: local };
     }
     if (identical) {
       // If records are identical, import anyway, so we bump the
       // local last_modified value from the server and set record
@@ -2749,22 +3363,17 @@ class Collection {
     if (!record.id) {
       return Promise.reject(new Error("Cannot update a record missing id."));
     }
     if (!this.idSchema.validate(record.id)) {
       return Promise.reject(new Error(`Invalid Id: ${ record.id }`));
     }
     return this.get(record.id).then(res => {
       const existing = res.data;
-      let newStatus = "updated";
-      if (record._status === "deleted") {
-        newStatus = "deleted";
-      } else if (options.synced) {
-        newStatus = "synced";
-      }
+      const newStatus = options.synced ? "synced" : "updated";
       return this.db.execute(transaction => {
         const source = options.patch ? Object.assign({}, existing, record) : record;
         const updated = markStatus(source, newStatus);
         if (existing.last_modified && !updated.last_modified) {
           updated.last_modified = existing.last_modified;
         }
         transaction.update(updated);
         return { data: updated, permissions: {} };
@@ -2822,34 +3431,34 @@ class Collection {
       });
     });
   }
 
   /**
    * Lists records from the local database.
    *
    * Params:
-   * - {Object} filters The filters to apply (default: `{}`).
+   * - {Object} filters Filter the results (default: `{}`).
    * - {String} order   The order to apply   (default: `-last_modified`).
    *
    * Options:
    * - {Boolean} includeDeleted: Include virtually deleted records.
    *
    * @param  {Object} params  The filters and order to apply to the results.
    * @param  {Object} options The options object.
    * @return {Promise}
    */
   list(params = {}, options = { includeDeleted: false }) {
     params = Object.assign({ order: "-last_modified", filters: {} }, params);
-    return this.db.list().then(results => {
-      let reduced = (0, _utils.reduceRecords)(params.filters, params.order, results);
+    return this.db.list(params).then(results => {
+      let data = results;
       if (!options.includeDeleted) {
-        reduced = reduced.filter(record => record._status !== "deleted");
+        data = results.filter(record => record._status !== "deleted");
       }
-      return { data: reduced, permissions: {} };
+      return { data, permissions: {} };
     });
   }
 
   /**
    * Import changes into the local database.
    *
    * @param  {SyncResultObject} syncResultObject The sync result object.
    * @param  {Object}           changeObject     The change object.
@@ -2857,61 +3466,42 @@ class Collection {
    */
   importChanges(syncResultObject, changeObject) {
     return Promise.all(changeObject.changes.map(change => {
       if (change.deleted) {
         return Promise.resolve(change);
       }
       return this._decodeRecord("remote", change);
     })).then(decodedChanges => {
-      // XXX: list() should filter only ids in changes.
-      return this.list({ order: "" }, { includeDeleted: true }).then(res => {
-        return { decodedChanges, existingRecords: res.data };
-      });
-    }).then(({ decodedChanges, existingRecords }) => {
-      return this.db.execute(transaction => {
-        return decodedChanges.map(remote => {
-          // Store remote change into local database.
-          return importChange(transaction, remote);
-        });
-      }, { preload: existingRecords });
-    }).catch(err => {
-      // XXX todo
-      err.type = "incoming";
-      // XXX one error of the whole transaction instead of one per atomic op
-      return [{ type: "errors", data: err }];
-    }).then(imports => {
-      var _iteratorNormalCompletion = true;
-      var _didIteratorError = false;
-      var _iteratorError = undefined;
-
-      try {
-        for (var _iterator = imports[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) {
-          const imported = _step.value;
-
+      // No change, nothing to import.
+      if (decodedChanges.length === 0) {
+        return Promise.resolve(syncResultObject);
+      }
+      // Retrieve records matching change ids.
+      const remoteIds = decodedChanges.map(change => change.id);
+      return this.list({ filters: { id: remoteIds }, order: "" }, { includeDeleted: true }).then(res => ({ decodedChanges, existingRecords: res.data })).then(({ decodedChanges, existingRecords }) => {
+        return this.db.execute(transaction => {
+          return decodedChanges.map(remote => {
+            // Store remote change into local database.
+            return importChange(transaction, remote);
+          });
+        }, { preload: existingRecords });
+      }).catch(err => {
+        // XXX todo
+        err.type = "incoming";
+        // XXX one error of the whole transaction instead of per atomic op
+        return [{ type: "errors", data: err }];
+      }).then(imports => {
+        for (let imported of imports) {
           if (imported.type !== "void") {
             syncResultObject.add(imported.type, imported.data);
           }
         }
-      } catch (err) {
-        _didIteratorError = true;
-        _iteratorError = err;
-      } finally {
-        try {
-          if (!_iteratorNormalCompletion && _iterator.return) {
-            _iterator.return();
-          }
-        } finally {
-          if (_didIteratorError) {
-            throw _iteratorError;
-          }
-        }
-      }
-
-      return syncResultObject;
+        return syncResultObject;
+      });
     }).then(syncResultObject => {
       syncResultObject.lastModified = changeObject.lastModified;
       // Don't persist lastModified value if any conflict or error occured
       if (!syncResultObject.ok) {
         return syncResultObject;
       }
       // No conflict occured, persist collection's lastModified value
       return this.db.saveLastModified(syncResultObject.lastModified).then(lastModified => {
@@ -2920,34 +3510,33 @@ class Collection {
       });
     });
   }
 
   /**
    * Resets the local records as if they were never synced; existing records are
    * marked as newly created, deleted records are dropped.
    *
-   * A next call to {@link Collection.sync} will thus republish the whole content of the
-   * local collection to the server.
+   * A next call to {@link Collection.sync} will thus republish the whole
+   * content of the local collection to the server.
    *
    * @return {Promise} Resolves with the number of processed records.
    */
   resetSyncStatus() {
     let _count;
-    // XXX filter by status
-    return this.list({}, { includeDeleted: true }).then(result => {
+    return this.list({ filters: { _status: ["deleted", "synced"] }, order: "" }, { includeDeleted: true }).then(unsynced => {
       return this.db.execute(transaction => {
-        _count = result.data.length;
-        result.data.forEach(r => {
-          // Garbage collect deleted records.
-          if (r._status === "deleted") {
-            transaction.delete(r.id);
+        _count = unsynced.data.length;
+        unsynced.data.forEach(record => {
+          if (record._status === "deleted") {
+            // Garbage collect deleted records.
+            transaction.delete(record.id);
           } else {
             // Records that were synced become «created».
-            transaction.update(Object.assign({}, r, {
+            transaction.update(Object.assign({}, record, {
               last_modified: undefined,
               _status: "created"
             }));
           }
         });
       });
     }).then(() => this.db.saveLastModified(null)).then(() => _count);
   }
@@ -2957,30 +3546,20 @@ class Collection {
    *
    * - `toDelete`: unsynced deleted records we can safely delete;
    * - `toSync`: local updates to send to the server.
    *
    * @return {Object}
    */
   gatherLocalChanges() {
     let _toDelete;
-    // XXX filter by status
-    return this.list({}, { includeDeleted: true }).then(res => {
-      return res.data.reduce((acc, record) => {
-        if (record._status === "deleted" && !record.last_modified) {
-          acc.toDelete.push(record);
-        } else if (record._status !== "synced") {
-          acc.toSync.push(record);
-        }
-        return acc;
-        // rename toSync to toPush or toPublish
-      }, { toDelete: [], toSync: [] });
-    }).then(({ toDelete, toSync }) => {
-      _toDelete = toDelete;
-      return Promise.all(toSync.map(this._encodeRecord.bind(this, "remote")));
+    return Promise.all([this.list({ filters: { _status: ["created", "updated"] }, order: "" }), this.list({ filters: { _status: "deleted" }, order: "" }, { includeDeleted: true })]).then(([unsynced, deleted]) => {
+      _toDelete = deleted.data;
+      // Encode unsynced records.
+      return Promise.all(unsynced.data.map(this._encodeRecord.bind(this, "remote")));
     }).then(toSync => ({ toDelete: _toDelete, toSync }));
   }
 
   /**
    * Fetch remote changes, import them to the local database, and handle
    * conflicts according to `options.strategy`. Then, updates the passed
    * {@link SyncResultObject} with import results.
    *
@@ -3023,40 +3602,39 @@ class Collection {
     if (!syncResultObject.ok) {
       return Promise.resolve(syncResultObject);
     }
     const safe = options.strategy === Collection.SERVER_WINS;
     options = Object.assign({ safe }, options);
 
     // Fetch local changes
     return this.gatherLocalChanges().then(({ toDelete, toSync }) => {
-      return Promise.all([
-      // Delete never synced records marked for deletion
-      this.db.execute(transaction => {
-        toDelete.forEach(record => {
-          transaction.delete(record.id);
-        });
-      }),
+      const batchChanges = toSync.concat(toDelete.map(record => {
+        return cleanRecord(Object.assign({}, record, { deleted: true }));
+      }));
       // Send batch update requests
-      this.api.batch(this.bucket, this.name, toSync, options)]);
+      return this.api.batch(this.bucket, this.name, batchChanges, options);
     })
     // Update published local records
-    .then(([deleted, synced]) => {
+    .then(synced => {
       const { errors, conflicts, published, skipped } = synced;
       // Merge outgoing errors into sync result object
       syncResultObject.add("errors", errors.map(error => {
         error.type = "outgoing";
         return error;
       }));
       // Merge outgoing conflicts into sync result object
       syncResultObject.add("conflicts", conflicts);
       // Reflect publication results locally
       const missingRemotely = skipped.map(r => Object.assign({}, r, { deleted: true }));
       const toApplyLocally = published.concat(missingRemotely);
       // Deleted records are distributed accross local and missing records
+      // XXX: When tackling the issue to avoid downloading our own changes
+      // from the server. `toDeleteLocally` should be obtained from local db.
+      // See https://github.com/Kinto/kinto.js/issues/144
       const toDeleteLocally = toApplyLocally.filter(r => r.deleted);
       const toUpdateLocally = toApplyLocally.filter(r => !r.deleted);
       // First, apply the decode transformers, if any
       return Promise.all(toUpdateLocally.map(record => {
         return this._decodeRecord("remote", record);
       }))
       // Process everything within a single transaction
       .then(results => {
@@ -3190,52 +3768,31 @@ class Collection {
    * @return {Promise} with the effectively imported records.
    */
   loadDump(records) {
     const reject = msg => Promise.reject(new Error(msg));
     if (!Array.isArray(records)) {
       return reject("Records is not an array.");
     }
 
-    var _iteratorNormalCompletion2 = true;
-    var _didIteratorError2 = false;
-    var _iteratorError2 = undefined;
-
-    try {
-      for (var _iterator2 = records[Symbol.iterator](), _step2; !(_iteratorNormalCompletion2 = (_step2 = _iterator2.next()).done); _iteratorNormalCompletion2 = true) {
-        const record = _step2.value;
-
-        if (!record.id || !this.idSchema.validate(record.id)) {
-          return reject("Record has invalid ID: " + JSON.stringify(record));
-        }
-
-        if (!record.last_modified) {
-          return reject("Record has no last_modified value: " + JSON.stringify(record));
-        }
+    for (let record of records) {
+      if (!record.id || !this.idSchema.validate(record.id)) {
+        return reject("Record has invalid ID: " + JSON.stringify(record));
       }
 
-      // Fetch all existing records from local database,
-      // and skip those who are newer or not marked as synced.
-
-      // XXX filter by status / ids in records
-    } catch (err) {
-      _didIteratorError2 = true;
-      _iteratorError2 = err;
-    } finally {
-      try {
-        if (!_iteratorNormalCompletion2 && _iterator2.return) {
-          _iterator2.return();
-        }
-      } finally {
-        if (_didIteratorError2) {
-          throw _iteratorError2;
-        }
+      if (!record.last_modified) {
+        return reject("Record has no last_modified value: " + JSON.stringify(record));
       }
     }
 
+    // Fetch all existing records from local database,
+    // and skip those who are newer or not marked as synced.
+
+    // XXX filter by status / ids in records
+
     return this.list({}, { includeDeleted: true }).then(res => {
       return res.data.reduce((acc, record) => {
         acc[record.id] = record;
         return acc;
       }, {});
     }).then(existingById => {
       return records.filter(record => {
         const localRecord = existingById[record.id];
@@ -3250,242 +3807,26 @@ class Collection {
         record.last_modified > localRecord.last_modified;
         return shouldKeep;
       });
     }).then(newRecords => newRecords.map(markSynced)).then(newRecords => this.db.loadDump(newRecords));
   }
 }
 exports.default = Collection;
 
-},{"./adapters/base":11,"./api":12,"./utils":16,"uuid":9}],14:[function(require,module,exports){
-"use strict";
-
-Object.defineProperty(exports, "__esModule", {
-  value: true
-});
-/**
- * Kinto server error code descriptors.
- * @type {Object}
- */
-exports.default = {
-  104: "Missing Authorization Token",
-  105: "Invalid Authorization Token",
-  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",
-  115: "Method not allowed on this end point",
-  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"
-};
-
-},{}],15:[function(require,module,exports){
-"use strict";
-
-Object.defineProperty(exports, "__esModule", {
-  value: true
-});
-
-var _errors = require("./errors.js");
-
-var _errors2 = _interopRequireDefault(_errors);
-
-function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
-
-/**
- * Enhanced HTTP client for the Kinto protocol.
- */
-class HTTP {
-  /**
-   * Default HTTP request headers applied to each outgoing request.
-   *
-   * @type {Object}
-   */
-  static get DEFAULT_REQUEST_HEADERS() {
-    return {
-      "Accept": "application/json",
-      "Content-Type": "application/json"
-    };
-  }
-
-  /**
-   * Default options.
-   *
-   * @type {Object}
-   */
-  static get defaultOptions() {
-    return { timeout: 5000, requestMode: "cors" };
-  }
-
-  /**
-   * Constructor.
-   *
-   * Options:
-   * - {Number} timeout      The request timeout in ms (default: `5000`).
-   * - {String} requestMode  The HTTP request mode (default: `"cors"`).
-   *
-   * @param {EventEmitter} events  The event handler.
-   * @param {Object}       options The options object.
-   */
-  constructor(events, options = {}) {
-    // public properties
-    /**
-     * The event emitter instance.
-     * @type {EventEmitter}
-     */
-    if (!events) {
-      throw new Error("No events handler provided");
-    }
-    this.events = events;
-
-    options = Object.assign({}, HTTP.defaultOptions, options);
-
-    /**
-     * The request mode.
-     * @see  https://fetch.spec.whatwg.org/#requestmode
-     * @type {String}
-     */
-    this.requestMode = options.requestMode;
-
-    /**
-     * The request timeout.
-     * @type {Number}
-     */
-    this.timeout = options.timeout;
-  }
-
-  /**
-   * Performs an HTTP request to the Kinto server.
-   *
-   * Options:
-   * - `{Object} headers` The request headers object (default: {})
-   *
-   * 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.
-   * @return {Promise}
-   */
-  request(url, options = { headers: {} }) {
-    let response, status, statusText, headers, _timeoutId, hasTimedout;
-    // Ensure default request headers are always set
-    options.headers = Object.assign({}, HTTP.DEFAULT_REQUEST_HEADERS, options.headers);
-    options.mode = this.requestMode;
-    return new Promise((resolve, reject) => {
-      _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);
-          reject(err);
-        }
-      });
-    }).then(res => {
-      response = res;
-      headers = res.headers;
-      status = res.status;
-      statusText = res.statusText;
-      this._checkForDeprecationHeader(headers);
-      this._checkForBackoffHeader(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;
-      }
-      // 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 }; `;
-        if (json.errno && json.errno in _errors2.default) {
-          message += _errors2.default[json.errno];
-          if (json.message) {
-            message += `: ${ json.message }`;
-          }
-        } else {
-          message += statusText || "";
-        }
-        const error = new Error(message.trim());
-        error.response = response;
-        error.data = json;
-        throw error;
-      }
-      return { status, json, headers };
-    });
-  }
-
-  _checkForDeprecationHeader(headers) {
-    const alertHeader = headers.get("Alert");
-    if (!alertHeader) {
-      return;
-    }
-    let alert;
-    try {
-      alert = JSON.parse(alertHeader);
-    } catch (err) {
-      console.warn("Unable to parse Alert header message", alertHeader);
-      return;
-    }
-    console.warn(alert.message, alert.url);
-    this.events.emit("deprecated", alert);
-  }
-
-  _checkForBackoffHeader(status, headers) {
-    let backoffMs;
-    const backoffSeconds = parseInt(headers.get("Backoff"), 10);
-    if (backoffSeconds > 0) {
-      backoffMs = new Date().getTime() + backoffSeconds * 1000;
-    } else {
-      backoffMs = 0;
-    }
-    this.events.emit("backoff", backoffMs);
-  }
-}
-exports.default = HTTP;
-
-},{"./errors.js":14}],16:[function(require,module,exports){
+},{"./adapters/base":17,"./utils":19,"uuid":15}],19:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 exports.deepEquals = deepEquals;
-exports.quote = quote;
-exports.unquote = unquote;
 exports.sortObjects = sortObjects;
 exports.filterObjects = filterObjects;
 exports.reduceRecords = reduceRecords;
-exports.partition = partition;
 exports.isUUID = isUUID;
 exports.waterfall = waterfall;
 exports.pFinally = pFinally;
 
 var _assert = require("assert");
 
 const RE_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
 
@@ -3501,36 +3842,16 @@ function deepEquals(a, b) {
     (0, _assert.deepEqual)(a, b);
   } catch (err) {
     return false;
   }
   return true;
 }
 
 /**
- * Returns the specified string with double quotes.
- *
- * @param  {String} str  A string to quote.
- * @return {String}
- */
-function quote(str) {
-  return `"${ str }"`;
-}
-
-/**
- * Trim double quotes from specified string.
- *
- * @param  {String} str  A string to unquote.
- * @return {String}
- */
-function unquote(str) {
-  return str.replace(/^"/, "").replace(/"$/, "");
-}
-
-/**
  * Checks if a value is undefined.
  * @param  {Any}  value
  * @return {Boolean}
  */
 function _isUndefined(value) {
   return typeof value === "undefined";
 }
 
@@ -3558,23 +3879,27 @@ function sortObjects(order, list) {
     return a[field] > b[field] ? direction : -direction;
   });
 }
 
 /**
  * Filters records in a list matching all given filters.
  *
  * @param  {String} filters  The filters object.
- * @param  {Array}  list     The collection to order.
+ * @param  {Array}  list     The collection to filter.
  * @return {Array}
  */
 function filterObjects(filters, list) {
   return list.filter(entry => {
     return Object.keys(filters).every(filter => {
-      return entry[filter] === filters[filter];
+      const value = filters[filter];
+      if (Array.isArray(value)) {
+        return value.some(candidate => candidate === entry[filter]);
+      }
+      return entry[filter] === value;
     });
   });
 }
 
 /**
  * Filter and sort list against provided filters and order.
  *
  * @param  {Object} filters  The filters to apply.
@@ -3583,37 +3908,16 @@ function filterObjects(filters, list) {
  * @return {Array}
  */
 function reduceRecords(filters, order, list) {
   const filtered = filters ? filterObjects(filters, list) : list;
   return order ? sortObjects(order, filtered) : filtered;
 }
 
 /**
- * Chunks an array into n pieces.
- *
- * @param  {Array}  array
- * @param  {Number} n
- * @return {Array}
- */
-function partition(array, n) {
-  if (n <= 0) {
-    return array;
-  }
-  return array.reduce((acc, x, i) => {
-    if (i === 0 || i % n === 0) {
-      acc.push([x]);
-    } else {
-      acc[acc.length - 1].push(x);
-    }
-    return acc;
-  }, []);
-}
-
-/**
  * Checks if a string is an UUID.
  *
  * @param  {String} uuid The uuid to validate.
  * @return {Boolean}
  */
 function isUUID(uuid) {
   return RE_UUID.test(uuid);
 }
@@ -3644,10 +3948,10 @@ function waterfall(fns, init) {
  * @return {Promise}
  */
 function pFinally(promise, fn) {
   return promise.then(value => Promise.resolve(fn()).then(() => value), reason => Promise.resolve(fn()).then(() => {
     throw reason;
   }));
 }
 
-},{"assert":3}]},{},[2])(2)
+},{"assert":4}]},{},[2])(2)
 });
\ No newline at end of file
--- a/services/common/tests/unit/test_kintoCertBlocklist.js
+++ b/services/common/tests/unit/test_kintoCertBlocklist.js
@@ -66,28 +66,50 @@ add_task(function* test_something(){
     } catch (e) {
       dump(`${e}\n`);
     }
   }
   server.registerPathHandler(configPath, handleResponse);
   server.registerPathHandler(recordsPath, handleResponse);
 
   // Test an empty db populates
-  let result = yield OneCRLClient.maybeSync(2000, Date.now());
+  yield OneCRLClient.maybeSync(0, Date.now());
 
-  // Open the collection, verify it's been populated:
-  // Our test data has a single record; it should be in the local collection
+  // Open the collection, verify it's been populated with initial data.
+  // Since the collection's lastModfied time and the server lastModified time
+  // are both zero, no sync will actuall happen; this means that any records in
+  // the collection will have come from local initial data.
   let collection = do_get_kinto_collection("certificates");
   yield collection.db.open();
   let list = yield collection.list();
+  // We know there will be initial values; just not how many.
+  do_check_neq(list.data.length, 0);
+  yield collection.db.close();
+
+  // clear the collection, save a non-zero lastModified so we don't do
+  // import of initial data when we sync again.
+  collection = do_get_kinto_collection("certificates");
+  yield collection.db.open();
+  yield collection.clear();
+  // a lastModified value of 1000 means we get a remote collection with a
+  // single record
+  yield collection.db.saveLastModified(1000);
+  yield collection.db.close();
+  yield OneCRLClient.maybeSync(2000, Date.now());
+
+  // Open the collection, verify it's been updated:
+  // Our test data now has two records; both should be in the local collection
+  collection = do_get_kinto_collection("certificates");
+  yield collection.db.open();
+  list = yield collection.list();
   do_check_eq(list.data.length, 1);
   yield collection.db.close();
 
   // Test the db is updated when we call again with a later lastModified value
-  result = yield OneCRLClient.maybeSync(4000, Date.now());
+  yield OneCRLClient.maybeSync(4000, Date.now());
 
   // Open the collection, verify it's been updated:
   // Our test data now has two records; both should be in the local collection
   collection = do_get_kinto_collection("certificates");
   yield collection.db.open();
   list = yield collection.list();
   do_check_eq(list.data.length, 3);
   yield collection.db.close();
@@ -140,17 +162,17 @@ function getSampleResponse(req, port) {
         "Access-Control-Allow-Origin: *",
         "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
         "Content-Type: application/json; charset=UTF-8",
         "Server: waitress"
       ],
       "status": {status: 200, statusText: "OK"},
       "responseBody": JSON.stringify({"settings":{"cliquet.batch_max_requests":25}, "url":`http://localhost:${port}/v1/`, "documentation":"https://kinto.readthedocs.org/", "version":"1.5.1", "commit":"cbc6f58", "hello":"kinto"})
     },
-    "GET:/v1/buckets/blocklists/collections/certificates/records?": {
+    "GET:/v1/buckets/blocklists/collections/certificates/records?_since=1000": {
       "sampleHeaders": [
         "Access-Control-Allow-Origin: *",
         "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
         "Content-Type: application/json; charset=UTF-8",
         "Server: waitress",
         "Etag: \"3000\""
       ],
       "status": {status: 200, statusText: "OK"},