Bug 1244776 - Update moz-kinto-client.js to include recent Kinto.js changes r=rnewman
authorMark Goodwin <mgoodwin@mozilla.com>
Mon, 01 Feb 2016 16:11:48 +0000
changeset 283075 75fe50edcf9aca16125fa9abf8011e6b36ba7634
parent 283074 d1339eeb75cc7e4a81a54005b577bacfe1035982
child 283076 ef28fb13dc0b8044567120e3eb9619a9f9a48339
push id29974
push usercbook@mozilla.com
push dateFri, 05 Feb 2016 10:53:43 +0000
treeherdermozilla-central@1dbe350b57b1 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrnewman
bugs1244776
milestone47.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1244776 - Update moz-kinto-client.js to include recent Kinto.js changes r=rnewman Please see the Kinto.js PR for the storage adapter changes: https://github.com/Kinto/kinto.js/pull/303 Additions and changes to the Firefox storage adapter tests have been made accordingly.
services/common/moz-kinto-client.js
services/common/tests/unit/test_storage_adapter.js
--- a/services/common/moz-kinto-client.js
+++ b/services/common/moz-kinto-client.js
@@ -14,327 +14,394 @@
  */
 
 /*
  * This file is generated from kinto.js - do not modify directly.
  */
 
 this.EXPORTED_SYMBOLS = ["loadKinto"];
 (function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.loadKinto = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
-/*
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     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.
- */
-
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 
-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; }; })();
-
-var _get = function get(_x, _x2, _x3) { var _again = true; _function: while (_again) { var object = _x, property = _x2, receiver = _x3; _again = false; if (object === null) object = Function.prototype; var desc = Object.getOwnPropertyDescriptor(object, property); if (desc === undefined) { var parent = Object.getPrototypeOf(object); if (parent === null) { return undefined; } else { _x = parent; _x2 = property; _x3 = receiver; _again = true; desc = parent = undefined; continue _function; } } else if ("value" in desc) { return desc.value; } else { var getter = desc.get; if (getter === undefined) { return undefined; } return getter.call(receiver); } } };
-
-function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
-
-function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) arr2[i] = arr[i]; return arr2; } else { return Array.from(arr); } }
-
-function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
-
-function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; }
-
-var _srcAdaptersBase = require("../src/adapters/base");
-
-var _srcAdaptersBase2 = _interopRequireDefault(_srcAdaptersBase);
-
-Components.utils["import"]("resource://gre/modules/Sqlite.jsm");
-Components.utils["import"]("resource://gre/modules/Task.jsm");
-
-var statements = {
-  "createCollectionData": "\n    CREATE TABLE collection_data (\n      collection_name TEXT,\n      record_id TEXT,\n      record TEXT\n    );",
-
-  "createCollectionMetadata": "\n    CREATE TABLE collection_metadata (\n      collection_name TEXT PRIMARY KEY,\n      last_modified INTEGER\n    ) WITHOUT ROWID;",
-
-  "createCollectionDataRecordIdIndex": "\n    CREATE UNIQUE INDEX unique_collection_record\n      ON collection_data(collection_name, record_id);",
-
-  "clearData": "\n    DELETE FROM collection_data\n      WHERE collection_name = :collection_name;",
-
-  "createData": "\n    INSERT INTO collection_data (collection_name, record_id, record)\n      VALUES (:collection_name, :record_id, :record);",
-
-  "updateData": "\n    UPDATE collection_data\n      SET record = :record\n        WHERE collection_name = :collection_name\n        AND record_id = :record_id;",
-
-  "deleteData": "\n    DELETE FROM collection_data\n      WHERE collection_name = :collection_name\n      AND record_id = :record_id;",
-
-  "saveLastModified": "\n    REPLACE INTO collection_metadata (collection_name, last_modified)\n      VALUES (:collection_name, :last_modified);",
-
-  "getLastModified": "\n    SELECT last_modified\n      FROM collection_metadata\n        WHERE collection_name = :collection_name;",
-
-  "getRecord": "\n    SELECT record\n      FROM collection_data\n        WHERE collection_name = :collection_name\n        AND record_id = :record_id;",
-
-  "listRecords": "\n    SELECT record\n      FROM collection_data\n        WHERE collection_name = :collection_name;",
-
-  "importData": "\n    REPLACE INTO collection_data (collection_name, record_id, record)\n      VALUES (:collection_name, :record_id, :record);"
+var _base = require("../src/adapters/base");
+
+var _base2 = _interopRequireDefault(_base);
+
+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.
+                                                               */
+
+Components.utils.import("resource://gre/modules/Task.jsm");
+
+const statements = {
+  "createCollectionData": `
+    CREATE TABLE collection_data (
+      collection_name TEXT,
+      record_id TEXT,
+      record TEXT
+    );`,
+
+  "createCollectionMetadata": `
+    CREATE TABLE collection_metadata (
+      collection_name TEXT PRIMARY KEY,
+      last_modified INTEGER
+    ) WITHOUT ROWID;`,
+
+  "createCollectionDataRecordIdIndex": `
+    CREATE UNIQUE INDEX unique_collection_record
+      ON collection_data(collection_name, record_id);`,
+
+  "clearData": `
+    DELETE FROM collection_data
+      WHERE collection_name = :collection_name;`,
+
+  "createData": `
+    INSERT INTO collection_data (collection_name, record_id, record)
+      VALUES (:collection_name, :record_id, :record);`,
+
+  "updateData": `
+    UPDATE collection_data
+      SET record = :record
+        WHERE collection_name = :collection_name
+        AND record_id = :record_id;`,
+
+  "deleteData": `
+    DELETE FROM collection_data
+      WHERE collection_name = :collection_name
+      AND record_id = :record_id;`,
+
+  "saveLastModified": `
+    REPLACE INTO collection_metadata (collection_name, last_modified)
+      VALUES (:collection_name, :last_modified);`,
+
+  "getLastModified": `
+    SELECT last_modified
+      FROM collection_metadata
+        WHERE collection_name = :collection_name;`,
+
+  "getRecord": `
+    SELECT record
+      FROM collection_data
+        WHERE collection_name = :collection_name
+        AND record_id = :record_id;`,
+
+  "listRecords": `
+    SELECT record
+      FROM collection_data
+        WHERE collection_name = :collection_name;`,
+
+  "importData": `
+    REPLACE INTO collection_data (collection_name, record_id, record)
+      VALUES (:collection_name, :record_id, :record);`
 
 };
 
-var createStatements = ["createCollectionData", "createCollectionMetadata", "createCollectionDataRecordIdIndex"];
-
-var currentSchemaVersion = 1;
-
-var FirefoxAdapter = (function (_BaseAdapter) {
-  _inherits(FirefoxAdapter, _BaseAdapter);
-
-  function FirefoxAdapter(collection) {
-    _classCallCheck(this, FirefoxAdapter);
-
-    _get(Object.getPrototypeOf(FirefoxAdapter.prototype), "constructor", this).call(this);
+const createStatements = ["createCollectionData", "createCollectionMetadata", "createCollectionDataRecordIdIndex"];
+
+const currentSchemaVersion = 1;
+
+class FirefoxAdapter extends _base2.default {
+  constructor(collection) {
+    super();
     this.collection = collection;
   }
 
-  _createClass(FirefoxAdapter, [{
-    key: "_init",
-    value: function _init(connection) {
-      return Task.spawn(function* () {
-        yield connection.executeTransaction(function* doSetup() {
-          var 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) {
-                var 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;
-                }
-              }
-            }
-
-            yield connection.setSchemaVersion(currentSchemaVersion);
-          } else if (schema != 1) {
-            throw new Error("Unknown database schema: " + schema);
-          }
-        });
-        return connection;
-      });
-    }
-  }, {
-    key: "_executeStatement",
-    value: function _executeStatement(statement, params) {
-      if (!this._connection) {
-        throw new Error("The storage adapter is not open");
-      }
-      return this._connection.executeCached(statement, params);
-    }
-  }, {
-    key: "open",
-    value: function open() {
-      var self = this;
-      return Task.spawn(function* () {
-        var opts = { path: "kinto.sqlite", sharedMemoryCache: false };
-        if (!self._connection) {
-          self._connection = yield Sqlite.openConnection(opts).then(self._init);
-        }
-      });
-    }
-  }, {
-    key: "close",
-    value: function close() {
-      if (this._connection) {
-        var promise = this._connection.close();
-        this._connection = null;
-        return promise;
-      }
-      return Promise.resolve();
-    }
-  }, {
-    key: "clear",
-    value: function clear() {
-      var params = { collection_name: this.collection };
-      return this._executeStatement(statements.clearData, params);
-    }
-  }, {
-    key: "create",
-    value: function create(record) {
-      var params = {
-        collection_name: this.collection,
-        record_id: record.id,
-        record: JSON.stringify(record)
-      };
-      return this._executeStatement(statements.createData, params).then(() => record);
-    }
-  }, {
-    key: "update",
-    value: function update(record) {
-      var params = {
-        collection_name: this.collection,
-        record_id: record.id,
-        record: JSON.stringify(record)
-      };
-      return this._executeStatement(statements.updateData, params).then(() => record);
-    }
-  }, {
-    key: "get",
-    value: function get(id) {
-      var params = {
-        collection_name: this.collection,
-        record_id: id
-      };
-      return this._executeStatement(statements.getRecord, params).then(result => {
-        if (result.length == 0) {
-          return;
-        }
-        return JSON.parse(result[0].getResultByName("record"));
-      });
-    }
-  }, {
-    key: "delete",
-    value: function _delete(id) {
-      var params = {
-        collection_name: this.collection,
-        record_id: id
-      };
-      return this._executeStatement(statements.deleteData, params).then(() => id);
-    }
-  }, {
-    key: "list",
-    value: function list() {
-      var params = {
-        collection_name: this.collection
-      };
-      return this._executeStatement(statements.listRecords, params).then(result => {
-        var records = [];
-        for (var k = 0; k < result.length; k++) {
-          var row = result[k];
-          records.push(JSON.parse(row.getResultByName("record")));
-        }
-        return records;
-      });
-    }
-
-    /**
-     * 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
-     * between every adapters.
-     *
-     * @param  {Array} records.
-     * @return {Array} imported records.
-     */
-  }, {
-    key: "loadDump",
-    value: function loadDump(records) {
-      var connection = this._connection;
-      var collection_name = this.collection;
-      return Task.spawn(function* () {
-        yield connection.executeTransaction(function* doImport() {
-          var _iteratorNormalCompletion2 = true;
-          var _didIteratorError2 = false;
-          var _iteratorError2 = undefined;
+  _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 _iterator2 = records[Symbol.iterator](), _step2; !(_iteratorNormalCompletion2 = (_step2 = _iterator2.next()).done); _iteratorNormalCompletion2 = true) {
-              var record = _step2.value;
-
-              var _params = {
-                collection_name: collection_name,
-                record_id: record.id,
-                record: JSON.stringify(record)
-              };
-              yield connection.execute(statements.importData, _params);
+
+            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) {
-            _didIteratorError2 = true;
-            _iteratorError2 = err;
+            _didIteratorError = true;
+            _iteratorError = err;
           } finally {
             try {
-              if (!_iteratorNormalCompletion2 && _iterator2["return"]) {
-                _iterator2["return"]();
+              if (!_iteratorNormalCompletion && _iterator.return) {
+                _iterator.return();
               }
             } finally {
-              if (_didIteratorError2) {
-                throw _iteratorError2;
+              if (_didIteratorError) {
+                throw _iteratorError;
               }
             }
           }
 
-          var lastModified = Math.max.apply(Math, _toConsumableArray(records.map(record => record.last_modified)));
-          var params = {
-            collection_name: collection_name
-          };
-          var previousLastModified = yield connection.execute(statements.getLastModified, params).then(result => {
-            return result ? result[0].getResultByName('last_modified') : -1;
-          });
-          if (lastModified > previousLastModified) {
-            var _params2 = {
-              collection_name: collection_name,
-              last_modified: lastModified
-            };
-            yield connection.execute(statements.saveLastModified, _params2);
-          }
-        });
-        return records;
+          yield connection.setSchemaVersion(currentSchemaVersion);
+        } else if (schema != 1) {
+          throw new Error("Unknown database schema: " + schema);
+        }
       });
+      return connection;
+    });
+  }
+
+  _executeStatement(statement, params) {
+    if (!this._connection) {
+      throw new Error("The storage adapter is not open");
+    }
+    return this._connection.executeCached(statement, params);
+  }
+
+  open() {
+    const self = this;
+    return Task.spawn(function* () {
+      const opts = { path: "kinto.sqlite", sharedMemoryCache: false };
+      if (!self._connection) {
+        self._connection = yield Sqlite.openConnection(opts).then(self._init);
+      }
+    });
+  }
+
+  close() {
+    if (this._connection) {
+      const promise = this._connection.close();
+      this._connection = null;
+      return promise;
+    }
+    return Promise.resolve();
+  }
+
+  clear() {
+    const params = { collection_name: this.collection };
+    return this._executeStatement(statements.clearData, params);
+  }
+
+  execute(callback, options = { preload: [] }) {
+    if (!this._connection) {
+      throw new Error("The storage adapter is not open");
+    }
+    const preloaded = options.preload.reduce((acc, record) => {
+      acc[record.id] = record;
+      return acc;
+    }, {});
+
+    const proxy = transactionProxy(this.collection, preloaded);
+    let result;
+    try {
+      result = callback(proxy);
+    } catch (e) {
+      return Promise.reject(e);
     }
-  }, {
-    key: "saveLastModified",
-    value: function saveLastModified(lastModified) {
-      var parsedLastModified = parseInt(lastModified, 10) || null;
-      var params = {
-        collection_name: this.collection,
-        last_modified: parsedLastModified
-      };
-      return this._executeStatement(statements.saveLastModified, params).then(() => parsedLastModified);
+    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;
+          }
+        }
+      }
+    }).then(_ => result);
+  }
+
+  get(id) {
+    const params = {
+      collection_name: this.collection,
+      record_id: id
+    };
+    return this._executeStatement(statements.getRecord, params).then(result => {
+      if (result.length == 0) {
+        return;
+      }
+      return JSON.parse(result[0].getResultByName("record"));
+    });
+  }
+
+  list() {
+    const params = {
+      collection_name: this.collection
+    };
+    return this._executeStatement(statements.listRecords, params).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;
+    });
+  }
+
+  /**
+   * 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
+   * between every adapters.
+   *
+   * @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;
+            }
+          }
+        }
+
+        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;
+        });
+        if (lastModified > previousLastModified) {
+          const params = {
+            collection_name: collection_name,
+            last_modified: lastModified
+          };
+          yield connection.execute(statements.saveLastModified, params);
+        }
+      });
+      return records;
+    });
+  }
+
+  saveLastModified(lastModified) {
+    const parsedLastModified = parseInt(lastModified, 10) || null;
+    const params = {
+      collection_name: this.collection,
+      last_modified: parsedLastModified
+    };
+    return this._executeStatement(statements.saveLastModified, params).then(() => parsedLastModified);
+  }
+
+  getLastModified() {
+    const params = {
+      collection_name: this.collection
+    };
+    return this._executeStatement(statements.getLastModified, params).then(result => {
+      if (result.length == 0) {
+        return 0;
+      }
+      return result[0].getResultByName("last_modified");
+    });
+  }
+}
+
+exports.default = FirefoxAdapter;
+function transactionProxy(collection, preloaded) {
+  const _operations = [];
+
+  return {
+    get operations() {
+      return _operations;
+    },
+
+    create(record) {
+      _operations.push({
+        statement: statements.createData,
+        params: {
+          collection_name: collection,
+          record_id: record.id,
+          record: JSON.stringify(record)
+        }
+      });
+    },
+
+    update(record) {
+      _operations.push({
+        statement: statements.updateData,
+        params: {
+          collection_name: collection,
+          record_id: record.id,
+          record: JSON.stringify(record)
+        }
+      });
+    },
+
+    delete(id) {
+      _operations.push({
+        statement: statements.deleteData,
+        params: {
+          collection_name: collection,
+          record_id: id
+        }
+      });
+    },
+
+    get(id) {
+      // Gecko JS engine outputs undesired warnings if id is not in preloaded.
+      return id in preloaded ? preloaded[id] : undefined;
     }
-  }, {
-    key: "getLastModified",
-    value: function getLastModified() {
-      var params = {
-        collection_name: this.collection
-      };
-      return this._executeStatement(statements.getLastModified, params).then(result => {
-        if (result.length == 0) {
-          return 0;
-        }
-        return result[0].getResultByName("last_modified");
-      });
-    }
-  }]);
-
-  return FirefoxAdapter;
-})(_srcAdaptersBase2["default"]);
-
-exports["default"] = FirefoxAdapter;
-module.exports = exports["default"];
+  };
+}
 
 },{"../src/adapters/base":11}],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
  *
@@ -347,87 +414,69 @@ module.exports = exports["default"];
  * limitations under the License.
  */
 
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
-
-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; }; })();
-
-var _get = function get(_x2, _x3, _x4) { var _again = true; _function: while (_again) { var object = _x2, property = _x3, receiver = _x4; _again = false; if (object === null) object = Function.prototype; var desc = Object.getOwnPropertyDescriptor(object, property); if (desc === undefined) { var parent = Object.getPrototypeOf(object); if (parent === null) { return undefined; } else { _x2 = parent; _x3 = property; _x4 = receiver; _again = true; desc = parent = undefined; continue _function; } } else if ("value" in desc) { return desc.value; } else { var getter = desc.get; if (getter === undefined) { return undefined; } return getter.call(receiver); } } };
-
-exports["default"] = loadKinto;
-
-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"); } }
-
-function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; }
-
-var _srcAdaptersBase = require("../src/adapters/base");
-
-var _srcAdaptersBase2 = _interopRequireDefault(_srcAdaptersBase);
-
-var _srcKintoBase = require("../src/KintoBase");
-
-var _srcKintoBase2 = _interopRequireDefault(_srcKintoBase);
+exports.default = loadKinto;
+
+var _base = require("../src/adapters/base");
+
+var _base2 = _interopRequireDefault(_base);
+
+var _KintoBase = require("../src/KintoBase");
+
+var _KintoBase2 = _interopRequireDefault(_KintoBase);
 
 var _FirefoxStorage = require("./FirefoxStorage");
 
 var _FirefoxStorage2 = _interopRequireDefault(_FirefoxStorage);
 
-var Cu = Components.utils;
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+const Cu = Components.utils;
 
 function loadKinto() {
-  var _Cu$import = Cu["import"]("resource://devtools/shared/event-emitter.js", {});
-
-  var EventEmitter = _Cu$import.EventEmitter;
-
-  Cu["import"]("resource://gre/modules/Timer.jsm");
+  const { EventEmitter } = Cu.import("resource://devtools/shared/event-emitter.js", {});
+
+  Cu.import("resource://gre/modules/Timer.jsm");
   Cu.importGlobalProperties(['fetch']);
 
-  var KintoFX = (function (_KintoBase) {
-    _inherits(KintoFX, _KintoBase);
-
-    _createClass(KintoFX, null, [{
-      key: "adapters",
-      get: function get() {
-        return {
-          BaseAdapter: _srcAdaptersBase2["default"],
-          FirefoxAdapter: _FirefoxStorage2["default"]
-        };
-      }
-    }]);
-
-    function KintoFX() {
-      var options = arguments.length <= 0 || arguments[0] === undefined ? {} : arguments[0];
-
-      _classCallCheck(this, KintoFX);
-
-      var emitter = {};
+  class KintoFX extends _KintoBase2.default {
+    static get adapters() {
+      return {
+        BaseAdapter: _base2.default,
+        FirefoxAdapter: _FirefoxStorage2.default
+      };
+    }
+
+    constructor(options = {}) {
+      const emitter = {};
       EventEmitter.decorate(emitter);
 
-      var defaults = {
+      const defaults = {
         events: emitter
       };
 
-      var expandedOptions = Object.assign(defaults, options);
-      _get(Object.getPrototypeOf(KintoFX.prototype), "constructor", this).call(this, expandedOptions);
+      const expandedOptions = Object.assign(defaults, options);
+      super(expandedOptions);
     }
-
-    return KintoFX;
-  })(_srcKintoBase2["default"]);
+  }
 
   return KintoFX;
 }
 
-module.exports = exports["default"];
+// 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){
 // 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>
@@ -1722,109 +1771,88 @@ module.exports = uuid;
 
 },{"./rng":8}],10:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 
-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; }; })();
-
-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"); } }
-
 var _api = require("./api");
 
 var _api2 = _interopRequireDefault(_api);
 
 var _collection = require("./collection");
 
 var _collection2 = _interopRequireDefault(_collection);
 
-var _adaptersBase = require("./adapters/base");
-
-var _adaptersBase2 = _interopRequireDefault(_adaptersBase);
-
-var DEFAULT_BUCKET_NAME = "default";
-var DEFAULT_REMOTE = "http://localhost:8888/v1";
+var _base = require("./adapters/base");
+
+var _base2 = _interopRequireDefault(_base);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+const DEFAULT_BUCKET_NAME = "default";
+const DEFAULT_REMOTE = "http://localhost:8888/v1";
 
 /**
  * KintoBase class.
  */
-
-var KintoBase = (function () {
-  _createClass(KintoBase, null, [{
-    key: "adapters",
-
-    /**
-     * Provides a public access to the base adapter class. Users can create a
-     * custom DB adapter by extending {@link BaseAdapter}.
-     *
-     * @type {Object}
-     */
-    get: function get() {
-      return {
-        BaseAdapter: _adaptersBase2["default"]
-      };
-    }
-
-    /**
-     * Synchronization strategies. Available strategies are:
-     *
-     * - `MANUAL`: Conflicts will be reported in a dedicated array.
-     * - `SERVER_WINS`: Conflicts are resolved using remote data.
-     * - `CLIENT_WINS`: Conflicts are resolved using local data.
-     *
-     * @type {Object}
-     */
-  }, {
-    key: "syncStrategy",
-    get: function get() {
-      return _collection2["default"].strategy;
-    }
-
-    /**
-     * Constructor.
-     *
-     * Options:
-     * - `{String}`       `remote`      The server URL to use.
-     * - `{String}`       `bucket`      The collection bucket name.
-     * - `{EventEmitter}` `events`      Events handler.
-     * - `{BaseAdapter}`  `adapter`     The base DB adapter class.
-     * - `{String}`       `dbPrefix`    The DB name prefix.
-     * - `{Object}`       `headers`     The HTTP headers to use.
-     * - `{String}`       `requestMode` The HTTP CORS mode to use.
-     *
-     * @param  {Object} options The options object.
-     */
-  }]);
-
-  function KintoBase() {
-    var options = arguments.length <= 0 || arguments[0] === undefined ? {} : arguments[0];
-
-    _classCallCheck(this, KintoBase);
-
-    var defaults = {
+class KintoBase {
+  /**
+   * Provides a public access to the base adapter class. Users can create a
+   * custom DB adapter by extending {@link BaseAdapter}.
+   *
+   * @type {Object}
+   */
+  static get adapters() {
+    return {
+      BaseAdapter: _base2.default
+    };
+  }
+
+  /**
+   * Synchronization strategies. Available strategies are:
+   *
+   * - `MANUAL`: Conflicts will be reported in a dedicated array.
+   * - `SERVER_WINS`: Conflicts are resolved using remote data.
+   * - `CLIENT_WINS`: Conflicts are resolved using local data.
+   *
+   * @type {Object}
+   */
+  static get syncStrategy() {
+    return _collection2.default.strategy;
+  }
+
+  /**
+   * Constructor.
+   *
+   * Options:
+   * - `{String}`       `remote`      The server URL to use.
+   * - `{String}`       `bucket`      The collection bucket name.
+   * - `{EventEmitter}` `events`      Events handler.
+   * - `{BaseAdapter}`  `adapter`     The base DB adapter class.
+   * - `{String}`       `dbPrefix`    The DB name prefix.
+   * - `{Object}`       `headers`     The HTTP headers to use.
+   * - `{String}`       `requestMode` The HTTP CORS mode to use.
+   *
+   * @param  {Object} options The options object.
+   */
+  constructor(options = {}) {
+    const defaults = {
       bucket: DEFAULT_BUCKET_NAME,
       remote: DEFAULT_REMOTE
     };
     this._options = Object.assign(defaults, options);
     if (!this._options.adapter) {
       throw new Error("No adapter provided");
     }
 
-    var _options = this._options;
-    var remote = _options.remote;
-    var events = _options.events;
-    var headers = _options.headers;
-    var requestMode = _options.requestMode;
-
-    this._api = new _api2["default"](remote, events, { headers: headers, requestMode: requestMode });
+    const { remote, events, headers, requestMode } = this._options;
+    this._api = new _api2.default(remote, events, { headers, requestMode });
 
     // public properties
     /**
      * The event emitter instance.
      * @type {EventEmitter}
      */
     this.events = this._options.events;
   }
@@ -1833,295 +1861,209 @@ var KintoBase = (function () {
    * Creates a {@link Collection} instance. The second (optional) parameter
    * will set collection-level options like e.g. `remoteTransformers`.
    *
    * @param  {String} collName The collection name.
    * @param  {Object} options  May contain the following fields:
    *                           remoteTransformers: Array<RemoteTransformer>
    * @return {Collection}
    */
-
-  _createClass(KintoBase, [{
-    key: "collection",
-    value: function collection(collName) {
-      var options = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1];
-
-      if (!collName) {
-        throw new Error("missing collection name");
-      }
-
-      var bucket = this._options.bucket;
-      return new _collection2["default"](bucket, collName, this._api, {
-        events: this._options.events,
-        adapter: this._options.adapter,
-        dbPrefix: this._options.dbPrefix,
-        idSchema: options.idSchema,
-        remoteTransformers: options.remoteTransformers
-      });
+  collection(collName, options = {}) {
+    if (!collName) {
+      throw new Error("missing collection name");
     }
-  }]);
-
-  return KintoBase;
-})();
-
-exports["default"] = KintoBase;
-module.exports = exports["default"];
+
+    const bucket = this._options.bucket;
+    return new _collection2.default(bucket, collName, this._api, {
+      events: this._options.events,
+      adapter: this._options.adapter,
+      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){
 "use strict";
 
 /**
  * Base db adapter.
  *
  * @abstract
  */
+
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
-
-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; }; })();
-
-function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
-
-var BaseAdapter = (function () {
-  function BaseAdapter() {
-    _classCallCheck(this, BaseAdapter);
+class BaseAdapter {
+  /**
+   * Opens a connection to the database.
+   *
+   * @abstract
+   * @return {Promise}
+   */
+  open() {
+    return Promise.resolve();
+  }
+
+  /**
+   * Closes current connection to the database.
+   *
+   * @abstract
+   * @return {Promise}
+   */
+  close() {
+    return Promise.resolve();
+  }
+
+  /**
+   * Deletes every records present in the database.
+   *
+   * @abstract
+   * @return {Promise}
+   */
+  clear() {
+    throw new Error("Not Implemented.");
+  }
+
+  /**
+   * Executes a batch of operations within a single transaction.
+   *
+   * @abstract
+   * @param  {Function} callback The operation callback.
+   * @param  {Object}   options  The options object.
+   * @return {Promise}
+   */
+  execute(callback, options = { preload: [] }) {
+    throw new Error("Not Implemented.");
   }
 
-  _createClass(BaseAdapter, [{
-    key: "open",
-
-    /**
-     * Opens a connection to the database.
-     *
-     * @abstract
-     * @return {Promise}
-     */
-    value: function open() {
-      return Promise.resolve();
-    }
-
-    /**
-     * Closes current connection to the database.
-     *
-     * @abstract
-     * @return {Promise}
-     */
-  }, {
-    key: "close",
-    value: function close() {
-      return Promise.resolve();
-    }
-
-    /**
-     * Deletes every records present in the database.
-     *
-     * @abstract
-     * @return {Promise}
-     */
-  }, {
-    key: "clear",
-    value: function clear() {
-      throw new Error("Not Implemented.");
-    }
-
-    /**
-     * Adds a record to the database.
-     *
-     * Note: An id value is required.
-     *
-     * @abstract
-     * @param  {Object} record The record object, including an id.
-     * @return {Promise}
-     */
-  }, {
-    key: "create",
-    value: function create(record) {
-      throw new Error("Not Implemented.");
-    }
-
-    /**
-     * Updates a record from the IndexedDB database.
-     *
-     * @abstract
-     * @param  {Object} record
-     * @return {Promise}
-     */
-  }, {
-    key: "update",
-    value: function update(record) {
-      throw new Error("Not Implemented.");
-    }
-
-    /**
-     * Retrieve a record by its primary key from the database.
-     *
-     * @abstract
-     * @param  {String} id The record id.
-     * @return {Promise}
-     */
-  }, {
-    key: "get",
-    value: function get(id) {
-      throw new Error("Not Implemented.");
-    }
-
-    /**
-     * Deletes a record from the database.
-     *
-     * @abstract
-     * @param  {String} id The record id.
-     * @return {Promise}
-     */
-  }, {
-    key: "delete",
-    value: function _delete(id) {
-      throw new Error("Not Implemented.");
-    }
-
-    /**
-     * Lists all records from the database.
-     *
-     * @abstract
-     * @return {Promise}
-     */
-  }, {
-    key: "list",
-    value: function list() {
-      throw new Error("Not Implemented.");
-    }
-
-    /**
-     * Store the lastModified value.
-     *
-     * @abstract
-     * @param  {Number}  lastModified
-     * @return {Promise}
-     */
-  }, {
-    key: "saveLastModified",
-    value: function saveLastModified(lastModified) {
-      throw new Error("Not Implemented.");
-    }
-
-    /**
-     * Retrieve saved lastModified value.
-     *
-     * @abstract
-     * @return {Promise}
-     */
-  }, {
-    key: "getLastModified",
-    value: function getLastModified() {
-      throw new Error("Not Implemented.");
-    }
-
-    /**
-     * Load a dump of records exported from a server.
-     *
-     * @abstract
-     * @return {Promise}
-     */
-  }, {
-    key: "loadDump",
-    value: function loadDump(records) {
-      throw new Error("Not Implemented.");
-    }
-  }]);
-
-  return BaseAdapter;
-})();
-
-exports["default"] = BaseAdapter;
-module.exports = exports["default"];
+  /**
+   * Retrieve a record by its primary key from the database.
+   *
+   * @abstract
+   * @param  {String} id The record id.
+   * @return {Promise}
+   */
+  get(id) {
+    throw new Error("Not Implemented.");
+  }
+
+  /**
+   * Lists all records from the database.
+   *
+   * @abstract
+   * @return {Promise}
+   */
+  list() {
+    throw new Error("Not Implemented.");
+  }
+
+  /**
+   * Store the lastModified value.
+   *
+   * @abstract
+   * @param  {Number}  lastModified
+   * @return {Promise}
+   */
+  saveLastModified(lastModified) {
+    throw new Error("Not Implemented.");
+  }
+
+  /**
+   * Retrieve saved lastModified value.
+   *
+   * @abstract
+   * @return {Promise}
+   */
+  getLastModified() {
+    throw new Error("Not Implemented.");
+  }
+
+  /**
+   * Load a dump of records exported from a server.
+   *
+   * @abstract
+   * @return {Promise}
+   */
+  loadDump(records) {
+    throw new Error("Not Implemented.");
+  }
+}
+exports.default = BaseAdapter;
 
 },{}],12:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
-
-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; }; })();
-
+exports.SUPPORTED_PROTOCOL_VERSION = undefined;
 exports.cleanRecord = cleanRecord;
 
-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"); } }
-
-var _utilsJs = require("./utils.js");
-
-var _httpJs = require("./http.js");
-
-var _httpJs2 = _interopRequireDefault(_httpJs);
-
-var RECORD_FIELDS_TO_CLEAN = ["_status", "last_modified"];
+var _utils = require("./utils.js");
+
+var _http = require("./http.js");
+
+var _http2 = _interopRequireDefault(_http);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+const RECORD_FIELDS_TO_CLEAN = ["_status", "last_modified"];
 /**
  * Currently supported protocol version.
  * @type {String}
  */
-var SUPPORTED_PROTOCOL_VERSION = "v1";
-
-exports.SUPPORTED_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSION;
+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.
  */
-
-function cleanRecord(record) {
-  var excludeFields = arguments.length <= 1 || arguments[1] === undefined ? RECORD_FIELDS_TO_CLEAN : arguments[1];
-
+function cleanRecord(record, excludeFields = RECORD_FIELDS_TO_CLEAN) {
   return Object.keys(record).reduce((acc, key) => {
     if (excludeFields.indexOf(key) === -1) {
       acc[key] = record[key];
     }
     return acc;
   }, {});
 }
 
 /**
  * High level HTTP client for the Kinto API.
  */
-
-var Api = (function () {
+class Api {
   /**
    * Constructor.
    *
    * Options:
-   * - {Object}       headers The key-value headers to pass to each request.
-   * - {String}       events  The HTTP request mode.
+   * - {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 Api(remote, events) {
-    var options = arguments.length <= 2 || arguments[2] === undefined ? {} : arguments[2];
-
-    _classCallCheck(this, Api);
-
+  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 remote endpoint base URL.
-     * @type {String}
-     */
-    this.remote = remote;
-    /**
      * The optional generic headers.
      * @type {Object}
      */
     this.optionHeaders = options.headers || {};
     /**
      * Current server settings, retrieved from the server.
      * @type {Object}
      */
@@ -2129,439 +2071,465 @@ var Api = (function () {
     /**
      * The even emitter instance.
      * @type {EventEmitter}
      */
     if (!events) {
       throw new Error("No events handler provided");
     }
     this.events = events;
-    try {
-      /**
-       * The current server protocol version, eg. `v1`.
-       * @type {String}
-       */
-      this.version = remote.match(/\/(v\d+)\/?$/)[1];
-    } catch (err) {
-      throw new Error("The remote URL must contain the version: " + remote);
-    }
-    if (this.version !== SUPPORTED_PROTOCOL_VERSION) {
-      throw new Error("Unsupported protocol version: " + this.version);
-    }
+
     /**
      * The HTTP instance.
      * @type {HTTP}
      */
-    this.http = new _httpJs2["default"](this.events, { requestMode: options.requestMode });
+    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}
    */
-
-  _createClass(Api, [{
-    key: "_registerHTTPEvents",
-
-    /**
-     * Registers HTTP events.
-     */
-    value: function _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}
-     */
-  }, {
-    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: () => _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;
+  get backoff() {
+    const currentTime = new Date().getTime();
+    if (this._backoffReleaseTime && currentTime < this._backoffReleaseTime) {
+      return this._backoffReleaseTime - currentTime;
     }
-
-    /**
-     * Retrieves Kinto server settings.
-     *
-     * @return {Promise}
-     */
-  }, {
-    key: "fetchServerSettings",
-    value: function 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;
-      });
+    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);
     }
-
-    /**
-     * 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 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, _utilsJs.quote)(options.lastModified);
-      }
-
-      return this.fetchServerSettings().then(_ => this.http.request(recordsUrl + queryString, { headers: 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
-        var etag = res.headers.get("ETag"); // e.g. '"42"'
-        etag = etag ? parseInt((0, _utilsJs.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 };
-      });
+    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);
     }
 
-    /**
-     * 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 isDeletion = record._status === "deleted";
-      var method = isDeletion ? "DELETE" : "PUT";
-      var body = isDeletion ? undefined : { data: cleanRecord(record) };
-      var headers = {};
-      if (safe) {
-        if (record.last_modified) {
-          // Safe replace.
-          headers["If-Match"] = (0, _utilsJs.quote)(record.last_modified);
-        } else if (!isDeletion) {
-          // Safe creation.
-          headers["If-None-Match"] = "*";
-        }
+    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 { 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((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(response.body);
-        } 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;
+
+      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"] = "*";
+      }
     }
-
-    /**
-     * 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 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 { 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 this.fetchServerSettings().then(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, _utilsJs.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: headers },
-            requests: records.map(record => {
-              var path = this.endpoints({ full: false }).record(bucketName, collName, record.id);
-              return this._buildRecordBatchRequest(record, path, safe);
-            })
+    });
+    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));
-      });
-    }
-  }, {
-    key: "backoff",
-    get: function get() {
-      var currentTime = new Date().getTime();
-      if (this._backoffReleaseTime && currentTime < this._backoffReleaseTime) {
-        return this._backoffReleaseTime - currentTime;
-      }
-      return 0;
-    }
-  }]);
-
-  return Api;
-})();
-
-exports["default"] = Api;
+        })
+      }).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
 });
-
-var _slicedToArray = (function () { function sliceIterator(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"]) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } return function (arr, i) { if (Array.isArray(arr)) { return arr; } else if (Symbol.iterator in Object(arr)) { return sliceIterator(arr, i); } else { throw new TypeError("Invalid attempt to destructure non-iterable instance"); } }; })();
-
-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; }; })();
-
-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"); } }
-
-var _adaptersBase = require("./adapters/base");
-
-var _adaptersBase2 = _interopRequireDefault(_adaptersBase);
+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.
  */
-
-var SyncResultObject = (function () {
-  _createClass(SyncResultObject, null, [{
-    key: "defaults",
-
-    /**
-     * Object default values.
-     * @type {Object}
-     */
-    get: function get() {
-      return {
-        ok: true,
-        lastModified: null,
-        errors: [],
-        created: [],
-        updated: [],
-        deleted: [],
-        published: [],
-        conflicts: [],
-        skipped: [],
-        resolved: []
-      };
-    }
-
-    /**
-     * Public constructor.
-     */
-  }]);
-
-  function SyncResultObject() {
-    _classCallCheck(this, SyncResultObject);
-
+class SyncResultObject {
+  /**
+   * Object default values.
+   * @type {Object}
+   */
+  static get defaults() {
+    return {
+      ok: true,
+      lastModified: null,
+      errors: [],
+      created: [],
+      updated: [],
+      deleted: [],
+      published: [],
+      conflicts: [],
+      skipped: [],
+      resolved: []
+    };
+  }
+
+  /**
+   * Public constructor.
+   */
+  constructor() {
     /**
      * Current synchronization result status; becomes `false` when conflicts or
      * errors are registered.
      * @type {Boolean}
      */
     this.ok = true;
     Object.assign(this, SyncResultObject.defaults);
   }
 
   /**
    * Adds entries for a given result type.
    *
    * @param {String} type    The result type.
    * @param {Array}  entries The result entries.
    * @return {SyncResultObject}
    */
-
-  _createClass(SyncResultObject, [{
-    key: "add",
-    value: function add(type, entries) {
-      if (!Array.isArray(this[type])) {
-        return;
-      }
-      this[type] = this[type].concat(entries);
-      this.ok = this.errors.length + this.conflicts.length === 0;
-      return this;
+  add(type, entries) {
+    if (!Array.isArray(this[type])) {
+      return;
     }
-
-    /**
-     * Reinitializes result entries for a given result type.
-     *
-     * @param  {String} type The result type.
-     * @return {SyncResultObject}
-     */
-  }, {
-    key: "reset",
-    value: function reset(type) {
-      this[type] = SyncResultObject.defaults[type];
-      this.ok = this.errors.length + this.conflicts.length === 0;
-      return this;
-    }
-  }]);
-
-  return SyncResultObject;
-})();
+    this[type] = this[type].concat(entries);
+    this.ok = this.errors.length + this.conflicts.length === 0;
+    return this;
+  }
+
+  /**
+   * Reinitializes result entries for a given result type.
+   *
+   * @param  {String} type The result type.
+   * @return {SyncResultObject}
+   */
+  reset(type) {
+    this[type] = SyncResultObject.defaults[type];
+    this.ok = this.errors.length + this.conflicts.length === 0;
+    return this;
+  }
+}
 
 exports.SyncResultObject = SyncResultObject;
-
 function createUUIDSchema() {
   return {
-    generate: function generate() {
+    generate() {
       return (0, _uuid.v4)();
     },
 
-    validate: function validate(id) {
+    validate(id) {
       return (0, _utils.isUUID)(id);
     }
   };
 }
 
+function markStatus(record, status) {
+  return Object.assign({}, record, { _status: status });
+}
+
+function markDeleted(record) {
+  return markStatus(record, "deleted");
+}
+
+function markSynced(record) {
+  return markStatus(record, "synced");
+}
+
+/**
+ * Import a remote change into the local database.
+ *
+ * @param  {IDBTransactionProxy} transaction The transaction handler.
+ * @param  {Object}              remote      The remote change object to import.
+ * @return {Object}
+ */
+function importChange(transaction, remote) {
+  const local = transaction.get(remote.id);
+  if (!local) {
+    // Not found locally but remote change is marked as deleted; skip to
+    // 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));
+  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
+      // status to "synced".
+      const synced = markSynced(remote);
+      transaction.update(synced);
+      return { type: "updated", data: synced };
+    }
+    return {
+      type: "conflicts",
+      data: { type: "incoming", local: local, remote: remote }
+    };
+  }
+  if (remote.deleted) {
+    transaction.delete(remote.id);
+    return { type: "deleted", data: { id: local.id } };
+  }
+  const synced = markSynced(remote);
+  transaction.update(synced);
+  // if identical, simply exclude it from all lists
+  const type = identical ? "void" : "updated";
+  return { type, data: synced };
+}
+
 /**
  * Abstracts a collection of records stored in the local database, providing
  * CRUD operations and synchronization helpers.
  */
-
-var Collection = (function () {
+class Collection {
   /**
    * Constructor.
    *
    * Options:
    * - `{BaseAdapter} adapter` The DB adapter (default: `IDB`)
    * - `{String} dbPrefix`     The DB name prefix (default: `""`)
    *
    * @param  {String} bucket  The bucket identifier.
    * @param  {String} name    The collection name.
    * @param  {Api}    api     The Api instance.
    * @param  {Object} options The options object.
    */
-
-  function Collection(bucket, name, api) {
-    var options = arguments.length <= 3 || arguments[3] === undefined ? {} : arguments[3];
-
-    _classCallCheck(this, Collection);
-
+  constructor(bucket, name, api, options = {}) {
     this._bucket = bucket;
     this._name = name;
     this._lastModified = null;
 
-    var DBAdapter = options.adapter;
+    const DBAdapter = options.adapter;
     if (!DBAdapter) {
       throw new Error("No adapter provided");
     }
-    var dbPrefix = options.dbPrefix || "";
-    var db = new DBAdapter("" + dbPrefix + bucket + "/" + name);
-    if (!(db instanceof _adaptersBase2["default"])) {
+    const dbPrefix = options.dbPrefix || "";
+    const db = new DBAdapter(`${ dbPrefix }${ bucket }/${ name }`);
+    if (!(db instanceof _base2.default)) {
       throw new Error("Unsupported adapter.");
     }
     // public properties
     /**
      * The db adapter instance
      * @type {BaseAdapter}
      */
     this.db = db;
@@ -2586,826 +2554,723 @@ var Collection = (function () {
      */
     this.remoteTransformers = this._validateRemoteTransformers(options.remoteTransformers);
   }
 
   /**
    * The collection name.
    * @type {String}
    */
-
-  _createClass(Collection, [{
-    key: "_validateIdSchema",
-
-    /**
-     * Validates an idSchema.
-     *
-     * @param  {Object|undefined} idSchema
-     * @return {Object}
-     */
-    value: function _validateIdSchema(idSchema) {
-      if (typeof idSchema === "undefined") {
-        return createUUIDSchema();
-      }
-      if (typeof idSchema !== "object") {
-        throw new Error("idSchema must be an object.");
-      } else if (typeof idSchema.generate !== "function") {
-        throw new Error("idSchema must provide a generate function.");
-      } else if (typeof idSchema.validate !== "function") {
-        throw new Error("idSchema must provide a validate function.");
-      }
-      return idSchema;
+  get name() {
+    return this._name;
+  }
+
+  /**
+   * The bucket name.
+   * @type {String}
+   */
+  get bucket() {
+    return this._bucket;
+  }
+
+  /**
+   * The last modified timestamp.
+   * @type {Number}
+   */
+  get lastModified() {
+    return this._lastModified;
+  }
+
+  /**
+   * Synchronization strategies. Available strategies are:
+   *
+   * - `MANUAL`: Conflicts will be reported in a dedicated array.
+   * - `SERVER_WINS`: Conflicts are resolved using remote data.
+   * - `CLIENT_WINS`: Conflicts are resolved using local data.
+   *
+   * @type {Object}
+   */
+  static get strategy() {
+    return {
+      CLIENT_WINS: "client_wins",
+      SERVER_WINS: "server_wins",
+      MANUAL: "manual"
+    };
+  }
+
+  /**
+   * Validates an idSchema.
+   *
+   * @param  {Object|undefined} idSchema
+   * @return {Object}
+   */
+  _validateIdSchema(idSchema) {
+    if (typeof idSchema === "undefined") {
+      return createUUIDSchema();
     }
-
-    /**
-     * Validates a list of remote transformers.
-     *
-     * @param  {Array|undefined} remoteTransformers
-     * @return {Array}
-     */
-  }, {
-    key: "_validateRemoteTransformers",
-    value: function _validateRemoteTransformers(remoteTransformers) {
-      if (typeof remoteTransformers === "undefined") {
-        return [];
-      }
-      if (!Array.isArray(remoteTransformers)) {
-        throw new Error("remoteTransformers should be an array.");
+    if (typeof idSchema !== "object") {
+      throw new Error("idSchema must be an object.");
+    } else if (typeof idSchema.generate !== "function") {
+      throw new Error("idSchema must provide a generate function.");
+    } else if (typeof idSchema.validate !== "function") {
+      throw new Error("idSchema must provide a validate function.");
+    }
+    return idSchema;
+  }
+
+  /**
+   * Validates a list of remote transformers.
+   *
+   * @param  {Array|undefined} remoteTransformers
+   * @return {Array}
+   */
+  _validateRemoteTransformers(remoteTransformers) {
+    if (typeof remoteTransformers === "undefined") {
+      return [];
+    }
+    if (!Array.isArray(remoteTransformers)) {
+      throw new Error("remoteTransformers should be an array.");
+    }
+    return remoteTransformers.map(transformer => {
+      if (typeof transformer !== "object") {
+        throw new Error("A transformer must be an object.");
+      } else if (typeof transformer.encode !== "function") {
+        throw new Error("A transformer must provide an encode function.");
+      } else if (typeof transformer.decode !== "function") {
+        throw new Error("A transformer must provide a decode function.");
       }
-      return remoteTransformers.map(transformer => {
-        if (typeof transformer !== "object") {
-          throw new Error("A transformer must be an object.");
-        } else if (typeof transformer.encode !== "function") {
-          throw new Error("A transformer must provide an encode function.");
-        } else if (typeof transformer.decode !== "function") {
-          throw new Error("A transformer must provide a decode function.");
-        }
-        return transformer;
-      });
+      return transformer;
+    });
+  }
+
+  /**
+   * Deletes every records in the current collection and marks the collection as
+   * never synced.
+   *
+   * @return {Promise}
+   */
+  clear() {
+    return this.db.clear().then(_ => this.db.saveLastModified(null)).then(_ => ({ data: [], permissions: {} }));
+  }
+
+  /**
+   * Encodes a record.
+   *
+   * @param  {String} type   Either "remote" or "local".
+   * @param  {Object} record The record object to encode.
+   * @return {Promise}
+   */
+  _encodeRecord(type, record) {
+    if (!this[`${ type }Transformers`].length) {
+      return Promise.resolve(record);
     }
-
-    /**
-     * Deletes every records in the current collection and marks the collection as
-     * never synced.
-     *
-     * @return {Promise}
-     */
-  }, {
-    key: "clear",
-    value: function clear() {
-      return this.db.clear().then(_ => this.db.saveLastModified(null)).then(_ => ({ data: [], permissions: {} }));
+    return (0, _utils.waterfall)(this[`${ type }Transformers`].map(transformer => {
+      return record => transformer.encode(record);
+    }), record);
+  }
+
+  /**
+   * Decodes a record.
+   *
+   * @param  {String} type   Either "remote" or "local".
+   * @param  {Object} record The record object to decode.
+   * @return {Promise}
+   */
+  _decodeRecord(type, record) {
+    if (!this[`${ type }Transformers`].length) {
+      return Promise.resolve(record);
     }
-
-    /**
-     * Encodes a record.
-     *
-     * @param  {String} type   Either "remote" or "local".
-     * @param  {Object} record The record object to encode.
-     * @return {Promise}
-     */
-  }, {
-    key: "_encodeRecord",
-    value: function _encodeRecord(type, record) {
-      if (!this[type + "Transformers"].length) {
-        return Promise.resolve(record);
-      }
-      return (0, _utils.waterfall)(this[type + "Transformers"].map(transformer => {
-        return record => transformer.encode(record);
-      }), record);
+    return (0, _utils.waterfall)(this[`${ type }Transformers`].reverse().map(transformer => {
+      return record => transformer.decode(record);
+    }), record);
+  }
+
+  /**
+   * Adds a record to the local database.
+   *
+   * Note: If either the `useRecordId` or `synced` options are true, then the
+   * record object must contain the id field to be validated. If none of these
+   * options are true, an id is generated using the current IdSchema; in this
+   * case, the record passed must not have an id.
+   *
+   * Options:
+   * - {Boolean} synced       Sets record status to "synced" (default: `false`).
+   * - {Boolean} useRecordId  Forces the `id` field from the record to be used,
+   *                          instead of one that is generated automatically
+   *                          (default: `false`).
+   *
+   * @param  {Object} record
+   * @param  {Object} options
+   * @return {Promise}
+   */
+  create(record, options = { useRecordId: false, synced: false }) {
+    const reject = msg => Promise.reject(new Error(msg));
+    if (typeof record !== "object") {
+      return reject("Record is not an object.");
     }
-
-    /**
-     * Decodes a record.
-     *
-     * @param  {String} type   Either "remote" or "local".
-     * @param  {Object} record The record object to decode.
-     * @return {Promise}
-     */
-  }, {
-    key: "_decodeRecord",
-    value: function _decodeRecord(type, record) {
-      if (!this[type + "Transformers"].length) {
-        return Promise.resolve(record);
-      }
-      return (0, _utils.waterfall)(this[type + "Transformers"].reverse().map(transformer => {
-        return record => transformer.decode(record);
-      }), record);
+    if ((options.synced || options.useRecordId) && !record.id) {
+      return reject("Missing required Id; synced and useRecordId options require one");
+    }
+    if (!options.synced && !options.useRecordId && record.id) {
+      return reject("Extraneous Id; can't create a record having one set.");
+    }
+    const newRecord = Object.assign({}, record, {
+      id: options.synced || options.useRecordId ? record.id : this.idSchema.generate(),
+      _status: options.synced ? "synced" : "created"
+    });
+    if (!this.idSchema.validate(newRecord.id)) {
+      return reject(`Invalid Id: ${ newRecord.id }`);
     }
-
-    /**
-     * Adds a record to the local database.
-     *
-     * Note: If either the `useRecordId` or `synced` options are true, then the
-     * record object must contain the id field to be validated. If none of these
-     * options are true, an id is generated using the current IdSchema; in this
-     * case, the record passed must not have an id.
-     *
-     * Options:
-     * - {Boolean} synced       Sets record status to "synced" (default: `false`).
-     * - {Boolean} useRecordId  Forces the `id` field from the record to be used,
-     *                          instead of one that is generated automatically
-     *                          (default: `false`).
-     *
-     * @param  {Object} record
-     * @param  {Object} options
-     * @return {Promise}
-     */
-  }, {
-    key: "create",
-    value: function create(record) {
-      var options = arguments.length <= 1 || arguments[1] === undefined ? { useRecordId: false, synced: false } : arguments[1];
-
-      var reject = msg => Promise.reject(new Error(msg));
-      if (typeof record !== "object") {
-        return reject("Record is not an object.");
-      }
-      if ((options.synced || options.useRecordId) && !record.id) {
-        return reject("Missing required Id; synced and useRecordId options require one");
-      }
-      if (!options.synced && !options.useRecordId && record.id) {
-        return reject("Extraneous Id; can't create a record having one set.");
+    return this.db.execute(transaction => {
+      transaction.create(newRecord);
+      return { data: newRecord, permissions: {} };
+    }).catch(err => {
+      if (options.useRecordId) {
+        throw new Error("Couldn't create record. It may have been virtually deleted.");
       }
-      var newRecord = Object.assign({}, record, {
-        id: options.synced || options.useRecordId ? record.id : this.idSchema.generate(),
-        _status: options.synced ? "synced" : "created"
-      });
-      if (!this.idSchema.validate(newRecord.id)) {
-        return reject("Invalid Id: " + newRecord.id);
-      }
-      return this.db.create(newRecord).then(record => {
-        return { data: record, permissions: {} };
-      });
+      throw err;
+    });
+  }
+
+  /**
+   * Updates a record from the local database.
+   *
+   * Options:
+   * - {Boolean} synced: Sets record status to "synced" (default: false)
+   * - {Boolean} patch:  Extends the existing record instead of overwriting it
+   *   (default: false)
+   *
+   * @param  {Object} record
+   * @param  {Object} options
+   * @return {Promise}
+   */
+  update(record, options = { synced: false, patch: false }) {
+    if (typeof record !== "object") {
+      return Promise.reject(new Error("Record is not an object."));
+    }
+    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 }`));
     }
-
-    /**
-     * Updates a record from the local database.
-     *
-     * Options:
-     * - {Boolean} synced: Sets record status to "synced" (default: false)
-     *
-     * @param  {Object} record
-     * @param  {Object} options
-     * @return {Promise}
-     */
-  }, {
-    key: "update",
-    value: function update(record) {
-      var options = arguments.length <= 1 || arguments[1] === undefined ? { synced: false } : arguments[1];
-
-      if (typeof record !== "object") {
-        return Promise.reject(new Error("Record is not an object."));
+    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";
       }
-      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.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: {} };
+      });
+    });
+  }
+
+  /**
+   * Retrieve a record by its id from the local database.
+   *
+   * @param  {String} id
+   * @param  {Object} options
+   * @return {Promise}
+   */
+  get(id, options = { includeDeleted: false }) {
+    if (!this.idSchema.validate(id)) {
+      return Promise.reject(Error(`Invalid Id: ${ id }`));
+    }
+    return this.db.get(id).then(record => {
+      if (!record || !options.includeDeleted && record._status === "deleted") {
+        throw new Error(`Record with id=${ id } not found.`);
+      } else {
+        return { data: record, permissions: {} };
       }
-      return this.get(record.id).then(_ => {
-        var newStatus = "updated";
-        if (record._status === "deleted") {
-          newStatus = "deleted";
-        } else if (options.synced) {
-          newStatus = "synced";
-        }
-        var updatedRecord = Object.assign({}, record, { _status: newStatus });
-        return this.db.update(updatedRecord).then(record => {
-          return { data: record, permissions: {} };
-        });
-      });
+    });
+  }
+
+  /**
+   * Deletes a record from the local database.
+   *
+   * Options:
+   * - {Boolean} virtual: When set to `true`, doesn't actually delete the record,
+   *   update its `_status` attribute to `deleted` instead (default: true)
+   *
+   * @param  {String} id       The record's Id.
+   * @param  {Object} options  The options object.
+   * @return {Promise}
+   */
+  delete(id, options = { virtual: true }) {
+    if (!this.idSchema.validate(id)) {
+      return Promise.reject(new Error(`Invalid Id: ${ id }`));
     }
-
-    /**
-     * Retrieve a record by its id from the local database.
-     *
-     * @param  {String} id
-     * @param  {Object} options
-     * @return {Promise}
-     */
-  }, {
-    key: "get",
-    value: function get(id) {
-      var options = arguments.length <= 1 || arguments[1] === undefined ? { includeDeleted: false } : arguments[1];
-
-      if (!this.idSchema.validate(id)) {
-        return Promise.reject(Error("Invalid Id: " + id));
-      }
-      return this.db.get(id).then(record => {
-        if (!record || !options.includeDeleted && record._status === "deleted") {
-          throw new Error("Record with id=" + id + " not found.");
+    // Ensure the record actually exists.
+    return this.get(id, { includeDeleted: true }).then(res => {
+      const existing = res.data;
+      return this.db.execute(transaction => {
+        // Virtual updates status.
+        if (options.virtual) {
+          transaction.update(markDeleted(existing));
         } else {
-          return { data: record, permissions: {} };
+          // Delete for real.
+          transaction.delete(id);
         }
+        return { data: { id: id }, permissions: {} };
       });
-    }
-
-    /**
-     * Deletes a record from the local database.
-     *
-     * Options:
-     * - {Boolean} virtual: When set to `true`, doesn't actually delete the record,
-     *   update its `_status` attribute to `deleted` instead (default: true)
-     *
-     * @param  {String} id       The record's Id.
-     * @param  {Object} options  The options object.
-     * @return {Promise}
-     */
-  }, {
-    key: "delete",
-    value: function _delete(id) {
-      var options = arguments.length <= 1 || arguments[1] === undefined ? { virtual: true } : arguments[1];
-
-      if (!this.idSchema.validate(id)) {
-        return Promise.reject(new Error("Invalid Id: " + id));
+    });
+  }
+
+  /**
+   * Lists records from the local database.
+   *
+   * Params:
+   * - {Object} filters The filters to apply (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);
+      if (!options.includeDeleted) {
+        reduced = reduced.filter(record => record._status !== "deleted");
       }
-      // Ensure the record actually exists.
-      return this.get(id, { includeDeleted: true }).then(res => {
-        if (options.virtual) {
-          if (res.data._status === "deleted") {
-            // Record is already deleted
-            return Promise.resolve({
-              data: { id: id },
-              permissions: {}
-            });
-          } else {
-            return this.update(Object.assign({}, res.data, {
-              _status: "deleted"
-            }));
+      return { data: reduced, permissions: {} };
+    });
+  }
+
+  /**
+   * Import changes into the local database.
+   *
+   * @param  {SyncResultObject} syncResultObject The sync result object.
+   * @param  {Object}           changeObject     The change object.
+   * @return {Promise}
+   */
+  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;
+
+          if (imported.type !== "void") {
+            syncResultObject.add(imported.type, imported.data);
           }
         }
-        return this.db["delete"](id).then(id => {
-          return { data: { id: id }, permissions: {} };
-        });
-      });
-    }
-
-    /**
-     * Lists records from the local database.
-     *
-     * Params:
-     * - {Object} filters The filters to apply (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}
-     */
-  }, {
-    key: "list",
-    value: function list() {
-      var params = arguments.length <= 0 || arguments[0] === undefined ? {} : arguments[0];
-      var options = arguments.length <= 1 || arguments[1] === undefined ? { includeDeleted: false } : arguments[1];
-
-      params = Object.assign({ order: "-last_modified", filters: {} }, params);
-      return this.db.list().then(results => {
-        var reduced = (0, _utils.reduceRecords)(params.filters, params.order, results);
-        if (!options.includeDeleted) {
-          reduced = reduced.filter(record => record._status !== "deleted");
-        }
-        return { data: reduced, permissions: {} };
-      });
-    }
-
-    /**
-     * Attempts to apply a remote change to its local matching record. Note that
-     * at this point, remote record data are already decoded.
-     *
-     * @param  {Object} local  The local record object.
-     * @param  {Object} remote The remote change object.
-     * @return {Promise}
-     */
-  }, {
-    key: "_processChangeImport",
-    value: function _processChangeImport(local, remote) {
-      var identical = (0, _utils.deepEquals)((0, _api.cleanRecord)(local), (0, _api.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
-          // status to "synced".
-          return this.update(remote, { synced: true }).then(res => {
-            return { type: "updated", data: res.data };
-          });
-        }
-        return {
-          type: "conflicts",
-          data: { type: "incoming", local: local, remote: remote }
-        };
-      }
-      if (remote.deleted) {
-        return this["delete"](remote.id, { virtual: false }).then(res => {
-          return { type: "deleted", data: res.data };
-        });
-      }
-      return this.update(remote, { synced: true }).then(updated => {
-        // if identical, simply exclude it from all lists
-        var type = identical ? "void" : "updated";
-        return { type: type, data: updated.data };
-      });
-    }
-
-    /**
-     * Import a single change into the local database.
-     *
-     * @param  {Object} change
-     * @return {Promise}
-     */
-  }, {
-    key: "_importChange",
-    value: function _importChange(change) {
-      var _decodedChange = undefined,
-          decodePromise = undefined;
-      // if change is a deletion, skip decoding
-      if (change.deleted) {
-        decodePromise = Promise.resolve(change);
-      } else {
-        decodePromise = this._decodeRecord("remote", change);
-      }
-      return decodePromise.then(change => {
-        _decodedChange = change;
-        return this.get(_decodedChange.id, { includeDeleted: true });
-      })
-      // Matching local record found
-      .then(res => this._processChangeImport(res.data, _decodedChange))["catch"](err => {
-        if (!/not found/i.test(err.message)) {
-          err.type = "incoming";
-          return { type: "errors", data: err };
-        }
-        // Not found locally but remote change is marked as deleted; skip to
-        // avoid recreation.
-        if (_decodedChange.deleted) {
-          return { type: "skipped", data: _decodedChange };
-        }
-        return this.create(_decodedChange, { synced: true })
-        // If everything went fine, expose created record data
-        .then(res => ({ type: "created", data: res.data }))
-        // Expose individual creation errors
-        ["catch"](err => ({ type: "errors", data: err }));
-      });
-    }
-
-    /**
-     * Import changes into the local database.
-     *
-     * @param  {SyncResultObject} syncResultObject The sync result object.
-     * @param  {Object}           changeObject     The change object.
-     * @return {Promise}
-     */
-  }, {
-    key: "importChanges",
-    value: function importChanges(syncResultObject, changeObject) {
-      return Promise.all(changeObject.changes.map(change => {
-        return this._importChange(change);
-      })).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) {
-            var imported = _step.value;
-
-            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;
-      }).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 => {
-          this._lastModified = lastModified;
-          return syncResultObject;
-        });
-      });
-    }
-
-    /**
-     * 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.
-     *
-     * @return {Promise} Resolves with the number of processed records.
-     */
-  }, {
-    key: "resetSyncStatus",
-    value: function resetSyncStatus() {
-      var _count = undefined;
-      return this.list({}, { includeDeleted: true }).then(res => {
-        return Promise.all(res.data.map(r => {
-          // Garbage collect deleted records.
-          if (r._status === "deleted") {
-            return this.db["delete"](r.id);
-          }
-          // Records that were synced become «created».
-          return this.db.update(Object.assign({}, r, {
-            last_modified: undefined,
-            _status: "created"
-          }));
-        }));
-      }).then(res => {
-        _count = res.length;
-        return this.db.saveLastModified(null);
-      }).then(_ => _count);
-    }
-
-    /**
-     * Returns an object containing two lists:
-     *
-     * - `toDelete`: unsynced deleted records we can safely delete;
-     * - `toSync`: local updates to send to the server.
-     *
-     * @return {Object}
-     */
-  }, {
-    key: "gatherLocalChanges",
-    value: function gatherLocalChanges() {
-      var _toDelete = undefined;
-      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(_ref => {
-        var toDelete = _ref.toDelete;
-        var toSync = _ref.toSync;
-
-        _toDelete = toDelete;
-        return Promise.all(toSync.map(this._encodeRecord.bind(this, "remote")));
-      }).then(toSync => ({ toDelete: _toDelete, toSync: 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.
-     *
-     * Options:
-     * - {String} strategy: The selected sync strategy.
-     *
-     * @param  {SyncResultObject} syncResultObject
-     * @param  {Object}           options
-     * @return {Promise}
-     */
-  }, {
-    key: "pullChanges",
-    value: function pullChanges(syncResultObject) {
-      var options = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1];
-
-      if (!syncResultObject.ok) {
-        return Promise.resolve(syncResultObject);
-      }
-      options = Object.assign({
-        strategy: Collection.strategy.MANUAL,
-        lastModified: this.lastModified,
-        headers: {}
-      }, options);
-      // First fetch remote changes from the server
-      return this.api.fetchChangesSince(this.bucket, this.name, {
-        lastModified: options.lastModified,
-        headers: options.headers
-      })
-      // Reflect these changes locally
-      .then(changes => this.importChanges(syncResultObject, changes))
-      // Handle conflicts, if any
-      .then(result => this._handleConflicts(result, options.strategy));
-    }
-
-    /**
-     * Publish local changes to the remote server and updates the passed
-     * {@link SyncResultObject} with publication results.
-     *
-     * @param  {SyncResultObject} syncResultObject The sync result object.
-     * @param  {Object}           options          The options object.
-     * @return {Promise}
-     */
-  }, {
-    key: "pushChanges",
-    value: function pushChanges(syncResultObject) {
-      var options = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1];
-
-      if (!syncResultObject.ok) {
-        return Promise.resolve(syncResultObject);
-      }
-      var safe = options.strategy === Collection.SERVER_WINS;
-      options = Object.assign({ safe: safe }, options);
-
-      // Fetch local changes
-      return this.gatherLocalChanges().then(_ref2 => {
-        var toDelete = _ref2.toDelete;
-        var toSync = _ref2.toSync;
-
-        return Promise.all([
-        // Delete never synced records marked for deletion
-        Promise.all(toDelete.map(record => {
-          return this["delete"](record.id, { virtual: false });
-        })),
-        // Send batch update requests
-        this.api.batch(this.bucket, this.name, toSync, options)]);
-      })
-      // Update published local records
-      .then(_ref3 => {
-        var _ref32 = _slicedToArray(_ref3, 2);
-
-        var deleted = _ref32[0];
-        var synced = _ref32[1];
-
-        // Merge outgoing errors into sync result object
-        syncResultObject.add("errors", synced.errors.map(error => {
-          error.type = "outgoing";
-          return error;
-        }));
-        // Merge outgoing conflicts into sync result object
-        syncResultObject.add("conflicts", synced.conflicts);
-        // Process local updates following published changes
-        return Promise.all(synced.published.map(record => {
-          if (record.deleted) {
-            // Remote deletion was successful, refect it locally
-            return this["delete"](record.id, { virtual: false }).then(res => {
-              // Amend result data with the deleted attribute set
-              return { data: { id: res.data.id, deleted: true } };
-            });
-          } else {
-            // Remote create/update was successful, reflect it locally
-            return this._decodeRecord("remote", record).then(record => this.update(record, { synced: true }));
-          }
-        })).then(published => {
-          syncResultObject.add("published", published.map(res => res.data));
-          return syncResultObject;
-        });
-      })
-      // Handle conflicts, if any
-      .then(result => this._handleConflicts(result, options.strategy)).then(result => {
-        var resolvedUnsynced = result.resolved.filter(record => record._status !== "synced");
-        // No resolved conflict to reflect anywhere
-        if (resolvedUnsynced.length === 0 || options.resolved) {
-          return result;
-        } else if (options.strategy === Collection.strategy.CLIENT_WINS && !options.resolved) {
-          // We need to push local versions of the records to the server
-          return this.pushChanges(result, Object.assign({}, options, { resolved: true }));
-        } else if (options.strategy === Collection.strategy.SERVER_WINS) {
-          // If records have been automatically resolved according to strategy and
-          // are in non-synced status, mark them as synced.
-          return Promise.all(resolvedUnsynced.map(record => {
-            return this.update(record, { synced: true });
-          })).then(_ => result);
-        }
-      });
-    }
-
-    /**
-     * Resolves a conflict, updating local record according to proposed
-     * resolution — keeping remote record `last_modified` value as a reference for
-     * further batch sending.
-     *
-     * @param  {Object} conflict   The conflict object.
-     * @param  {Object} resolution The proposed record.
-     * @return {Promise}
-     */
-  }, {
-    key: "resolve",
-    value: function resolve(conflict, resolution) {
-      return this.update(Object.assign({}, resolution, {
-        // Ensure local record has the latest authoritative timestamp
-        last_modified: conflict.remote.last_modified
-      }));
-    }
-
-    /**
-     * Handles synchronization conflicts according to specified strategy.
-     *
-     * @param  {SyncResultObject} result    The sync result object.
-     * @param  {String}           strategy  The {@link Collection.strategy}.
-     * @return {Promise}
-     */
-  }, {
-    key: "_handleConflicts",
-    value: function _handleConflicts(result) {
-      var strategy = arguments.length <= 1 || arguments[1] === undefined ? Collection.strategy.MANUAL : arguments[1];
-
-      if (strategy === Collection.strategy.MANUAL || result.conflicts.length === 0) {
-        return Promise.resolve(result);
-      }
-      return Promise.all(result.conflicts.map(conflict => {
-        var resolution = strategy === Collection.strategy.CLIENT_WINS ? conflict.local : conflict.remote;
-        return this.resolve(conflict, resolution);
-      })).then(imports => {
-        return result.reset("conflicts").add("resolved", imports.map(res => res.data));
-      });
-    }
-
-    /**
-     * Synchronize remote and local data. The promise will resolve with a
-     * {@link SyncResultObject}, though will reject:
-     *
-     * - if the server is currently backed off;
-     * - if the server has been detected flushed.
-     *
-     * Options:
-     * - {Object} headers: HTTP headers to attach to outgoing requests.
-     * - {Collection.strategy} strategy: See {@link Collection.strategy}.
-     * - {Boolean} ignoreBackoff: Force synchronization even if server is currently
-     *   backed off.
-     *
-     * @param  {Object} options Options.
-     * @return {Promise}
-     */
-  }, {
-    key: "sync",
-    value: function sync() {
-      var options = arguments.length <= 0 || arguments[0] === undefined ? { strategy: Collection.strategy.MANUAL, headers: {}, ignoreBackoff: false } : arguments[0];
-
-      if (!options.ignoreBackoff && this.api.backoff > 0) {
-        var seconds = Math.ceil(this.api.backoff / 1000);
-        return Promise.reject(new Error("Server is backed off; retry in " + seconds + "s or use the ignoreBackoff option."));
-      }
-      var result = new SyncResultObject();
-      return this.db.getLastModified().then(lastModified => this._lastModified = lastModified).then(_ => this.pullChanges(result, options)).then(result => this.pushChanges(result, options)).then(result => {
-        // Avoid performing a last pull if nothing has been published.
-        if (result.published.length === 0) {
-          return result;
-        }
-        return this.pullChanges(result, options);
-      });
-    }
-
-    /**
-     * Load a list of records already synced with the remote server.
-     *
-     * The local records which are unsynced or whose timestamp is either missing
-     * or superior to those being loaded will be ignored.
-     *
-     * @param  {Array} records.
-     * @param  {Object} options Options.
-     * @return {Promise} with the effectively imported records.
-     */
-  }, {
-    key: "loadDump",
-    value: function loadDump(records) {
-      var 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) {
-          var 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));
-          }
-        }
-
-        // Fetch all existing records from local database,
-        // and skip those who are newer or not marked as synced.
       } catch (err) {
-        _didIteratorError2 = true;
-        _iteratorError2 = err;
+        _didIteratorError = true;
+        _iteratorError = err;
       } finally {
         try {
-          if (!_iteratorNormalCompletion2 && _iterator2["return"]) {
-            _iterator2["return"]();
+          if (!_iteratorNormalCompletion && _iterator.return) {
+            _iterator.return();
           }
         } finally {
-          if (_didIteratorError2) {
-            throw _iteratorError2;
+          if (_didIteratorError) {
+            throw _iteratorError;
           }
         }
       }
 
-      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 => {
-          var localRecord = existingById[record.id];
-          var shouldKeep =
-          // No local record with this id.
-          localRecord === undefined ||
-          // Or local record is synced
-          localRecord._status === "synced" &&
-          // And was synced from server
-          localRecord.last_modified !== undefined &&
-          // And is older than imported one.
-          record.last_modified > localRecord.last_modified;
-          return shouldKeep;
+      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 => {
+        this._lastModified = lastModified;
+        return syncResultObject;
+      });
+    });
+  }
+
+  /**
+   * 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.
+   *
+   * @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.db.execute(transaction => {
+        _count = result.data.length;
+        result.data.forEach(r => {
+          // Garbage collect deleted records.
+          if (r._status === "deleted") {
+            transaction.delete(r.id);
+          } else {
+            // Records that were synced become «created».
+            transaction.update(Object.assign({}, r, {
+              last_modified: undefined,
+              _status: "created"
+            }));
+          }
         });
-      }).then(newRecords => {
-        return newRecords.map(record => {
-          return Object.assign({}, record, {
-            _status: "synced"
-          });
-        });
-      }).then(newRecords => this.db.loadDump(newRecords));
+      });
+    }).then(() => this.db.saveLastModified(null)).then(() => _count);
+  }
+
+  /**
+   * Returns an object containing two lists:
+   *
+   * - `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")));
+    }).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.
+   *
+   * Options:
+   * - {String} strategy: The selected sync strategy.
+   *
+   * @param  {SyncResultObject} syncResultObject
+   * @param  {Object}           options
+   * @return {Promise}
+   */
+  pullChanges(syncResultObject, options = {}) {
+    if (!syncResultObject.ok) {
+      return Promise.resolve(syncResultObject);
     }
-  }, {
-    key: "name",
-    get: function get() {
-      return this._name;
+    options = Object.assign({
+      strategy: Collection.strategy.MANUAL,
+      lastModified: this.lastModified,
+      headers: {}
+    }, options);
+    // First fetch remote changes from the server
+    return this.api.fetchChangesSince(this.bucket, this.name, {
+      lastModified: options.lastModified,
+      headers: options.headers
+    })
+    // Reflect these changes locally
+    .then(changes => this.importChanges(syncResultObject, changes))
+    // Handle conflicts, if any
+    .then(result => this._handleConflicts(result, options.strategy));
+  }
+
+  /**
+   * Publish local changes to the remote server and updates the passed
+   * {@link SyncResultObject} with publication results.
+   *
+   * @param  {SyncResultObject} syncResultObject The sync result object.
+   * @param  {Object}           options          The options object.
+   * @return {Promise}
+   */
+  pushChanges(syncResultObject, options = {}) {
+    if (!syncResultObject.ok) {
+      return Promise.resolve(syncResultObject);
     }
-
-    /**
-     * The bucket name.
-     * @type {String}
-     */
-  }, {
-    key: "bucket",
-    get: function get() {
-      return this._bucket;
-    }
-
-    /**
-     * The last modified timestamp.
-     * @type {Number}
-     */
-  }, {
-    key: "lastModified",
-    get: function get() {
-      return this._lastModified;
+    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);
+        });
+      }),
+      // Send batch update requests
+      this.api.batch(this.bucket, this.name, toSync, options)]);
+    })
+    // Update published local records
+    .then(([deleted, 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
+      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 => {
+        return this.db.execute(transaction => {
+          const updated = results.map(record => {
+            const synced = markSynced(record);
+            transaction.update(synced);
+            return { data: synced };
+          });
+          const deleted = toDeleteLocally.map(record => {
+            transaction.delete(record.id);
+            // Amend result data with the deleted attribute set
+            return { data: { id: record.id, deleted: true } };
+          });
+          return updated.concat(deleted);
+        });
+      }).then(published => {
+        syncResultObject.add("published", published.map(res => res.data));
+        return syncResultObject;
+      });
+    })
+    // Handle conflicts, if any
+    .then(result => this._handleConflicts(result, options.strategy)).then(result => {
+      const resolvedUnsynced = result.resolved.filter(record => record._status !== "synced");
+      // No resolved conflict to reflect anywhere
+      if (resolvedUnsynced.length === 0 || options.resolved) {
+        return result;
+      } else if (options.strategy === Collection.strategy.CLIENT_WINS && !options.resolved) {
+        // We need to push local versions of the records to the server
+        return this.pushChanges(result, Object.assign({}, options, { resolved: true }));
+      } else if (options.strategy === Collection.strategy.SERVER_WINS) {
+        // If records have been automatically resolved according to strategy and
+        // are in non-synced status, mark them as synced.
+        return this.db.execute(transaction => {
+          resolvedUnsynced.forEach(record => {
+            transaction.update(markSynced(record));
+          });
+          return result;
+        });
+      }
+    });
+  }
+
+  /**
+   * Resolves a conflict, updating local record according to proposed
+   * resolution — keeping remote record `last_modified` value as a reference for
+   * further batch sending.
+   *
+   * @param  {Object} conflict   The conflict object.
+   * @param  {Object} resolution The proposed record.
+   * @return {Promise}
+   */
+  resolve(conflict, resolution) {
+    return this.update(Object.assign({}, resolution, {
+      // Ensure local record has the latest authoritative timestamp
+      last_modified: conflict.remote.last_modified
+    }));
+  }
+
+  /**
+   * Handles synchronization conflicts according to specified strategy.
+   *
+   * @param  {SyncResultObject} result    The sync result object.
+   * @param  {String}           strategy  The {@link Collection.strategy}.
+   * @return {Promise}
+   */
+  _handleConflicts(result, strategy = Collection.strategy.MANUAL) {
+    if (strategy === Collection.strategy.MANUAL || result.conflicts.length === 0) {
+      return Promise.resolve(result);
     }
-
-    /**
-     * Synchronization strategies. Available strategies are:
-     *
-     * - `MANUAL`: Conflicts will be reported in a dedicated array.
-     * - `SERVER_WINS`: Conflicts are resolved using remote data.
-     * - `CLIENT_WINS`: Conflicts are resolved using local data.
-     *
-     * @type {Object}
-     */
-  }], [{
-    key: "strategy",
-    get: function get() {
-      return {
-        CLIENT_WINS: "client_wins",
-        SERVER_WINS: "server_wins",
-        MANUAL: "manual"
-      };
+    return Promise.all(result.conflicts.map(conflict => {
+      const resolution = strategy === Collection.strategy.CLIENT_WINS ? conflict.local : conflict.remote;
+      return this.resolve(conflict, resolution);
+    })).then(imports => {
+      return result.reset("conflicts").add("resolved", imports.map(res => res.data));
+    });
+  }
+
+  /**
+   * Synchronize remote and local data. The promise will resolve with a
+   * {@link SyncResultObject}, though will reject:
+   *
+   * - if the server is currently backed off;
+   * - if the server has been detected flushed.
+   *
+   * Options:
+   * - {Object} headers: HTTP headers to attach to outgoing requests.
+   * - {Collection.strategy} strategy: See {@link Collection.strategy}.
+   * - {Boolean} ignoreBackoff: Force synchronization even if server is currently
+   *   backed off.
+   * - {String} remote The remote Kinto server endpoint to use (default: null).
+   *
+   * @param  {Object} options Options.
+   * @return {Promise}
+   * @throws {Error} If an invalid remote option is passed.
+   */
+  sync(options = {
+    strategy: Collection.strategy.MANUAL,
+    headers: {},
+    ignoreBackoff: false,
+    remote: null
+  }) {
+    const previousRemote = this.api.remote;
+    if (options.remote) {
+      // Note: setting the remote ensures it's valid, throws when invalid.
+      this.api.remote = options.remote;
+    }
+    if (!options.ignoreBackoff && this.api.backoff > 0) {
+      const seconds = Math.ceil(this.api.backoff / 1000);
+      return Promise.reject(new Error(`Server is backed off; retry in ${ seconds }s or use the ignoreBackoff option.`));
     }
-  }]);
-
-  return Collection;
-})();
-
-exports["default"] = Collection;
+    const result = new SyncResultObject();
+    const syncPromise = this.db.getLastModified().then(lastModified => this._lastModified = lastModified).then(_ => this.pullChanges(result, options)).then(result => this.pushChanges(result, options)).then(result => {
+      // Avoid performing a last pull if nothing has been published.
+      if (result.published.length === 0) {
+        return result;
+      }
+      return this.pullChanges(result, options);
+    });
+    // Ensure API default remote is reverted if a custom one's been used
+    return (0, _utils.pFinally)(syncPromise, () => this.api.remote = previousRemote);
+  }
+
+  /**
+   * Load a list of records already synced with the remote server.
+   *
+   * The local records which are unsynced or whose timestamp is either missing
+   * or superior to those being loaded will be ignored.
+   *
+   * @param  {Array} records The previously exported list of records to load.
+   * @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));
+        }
+      }
+
+      // 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;
+        }
+      }
+    }
+
+    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];
+        const shouldKeep =
+        // No local record with this id.
+        localRecord === undefined ||
+        // Or local record is synced
+        localRecord._status === "synced" &&
+        // And was synced from server
+        localRecord.last_modified !== undefined &&
+        // And is older than imported one.
+        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){
-/**
- * Kinto server error code descriptors.
- * @type {Object}
- */
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
-exports["default"] = {
+/**
+ * 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",
@@ -3416,83 +3281,66 @@ exports["default"] = {
   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"
 };
-module.exports = exports["default"];
 
 },{}],15:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 
-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; }; })();
-
-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"); } }
-
-var _errorsJs = require("./errors.js");
-
-var _errorsJs2 = _interopRequireDefault(_errorsJs);
+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.
  */
-
-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);
-
+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");
     }
@@ -3524,179 +3372,160 @@ var HTTP = (function () {
    * - `{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 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((resolve, reject) => {
-        _timeoutId = setTimeout(() => {
-          hasTimedout = true;
-          reject(new Error("Request timeout."));
-        }, this.timeout);
-        fetch(url, options).then(res => {
-          if (!hasTimedout) {
-            clearTimeout(_timeoutId);
-            resolve(res);
+  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 }`;
           }
-        })["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;
+        } else {
+          message += statusText || "";
         }
-        // Note: we can't consume the response body twice.
-        return JSON.parse(text);
-      })["catch"](err => {
-        var error = new Error("HTTP " + (status || 0) + "; " + err);
+        const error = new Error(message.trim());
         error.response = response;
-        error.stack = err.stack;
+        error.data = json;
         throw error;
-      }).then(json => {
-        if (json && status >= 400) {
-          var message = "HTTP " + status + "; ";
-          if (json.errno && json.errno in _errorsJs2["default"]) {
-            message += _errorsJs2["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 };
-      });
+      }
+      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;
     }
-  }, {
-    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);
+    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;
     }
-  }, {
-    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;
-module.exports = exports["default"];
+    this.events.emit("backoff", backoffMs);
+  }
+}
+exports.default = HTTP;
 
 },{"./errors.js":14}],16:[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");
 
-var RE_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+const RE_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
 
 /**
  * Deeply checks if two structures are equals.
  *
  * @param  {Any} a
  * @param  {Any} b
  * @return {Boolean}
  */
-
 function deepEquals(a, b) {
   try {
     (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 + "\"";
+  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}
@@ -3707,21 +3536,20 @@ function _isUndefined(value) {
 
 /**
  * Sorts records in a list according to a given ordering.
  *
  * @param  {String} order The ordering, eg. `-last_modified`.
  * @param  {Array}  list  The collection to order.
  * @return {Array}
  */
-
 function sortObjects(order, list) {
-  var hasDash = order[0] === "-";
-  var field = hasDash ? order.slice(1) : order;
-  var direction = hasDash ? -1 : 1;
+  const hasDash = order[0] === "-";
+  const field = hasDash ? order.slice(1) : order;
+  const direction = hasDash ? -1 : 1;
   return list.slice().sort((a, b) => {
     if (a[field] && _isUndefined(b[field])) {
       return direction;
     }
     if (b[field] && _isUndefined(a[field])) {
       return -direction;
     }
     if (_isUndefined(a[field]) && _isUndefined(b[field])) {
@@ -3733,46 +3561,44 @@ function sortObjects(order, list) {
 
 /**
  * Filters records in a list matching all given filters.
  *
  * @param  {String} filters  The filters object.
  * @param  {Array}  list     The collection to order.
  * @return {Array}
  */
-
 function filterObjects(filters, list) {
   return list.filter(entry => {
     return Object.keys(filters).every(filter => {
       return entry[filter] === filters[filter];
     });
   });
 }
 
 /**
  * Filter and sort list against provided filters and order.
  *
  * @param  {Object} filters  The filters to apply.
  * @param  {String} order    The order to apply.
  * @param  {Array}  list     The list to reduce.
  * @return {Array}
  */
-
 function reduceRecords(filters, order, list) {
-  return sortObjects(order, filterObjects(filters, 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 {
@@ -3783,33 +3609,45 @@ function partition(array, n) {
 }
 
 /**
  * Checks if a string is an UUID.
  *
  * @param  {String} uuid The uuid to validate.
  * @return {Boolean}
  */
-
 function isUUID(uuid) {
   return RE_UUID.test(uuid);
 }
 
 /**
  * Resolves a list of functions sequentially, which can be sync or async; in
  * case of async, functions must return a promise.
  *
  * @param  {Array} fns  The list of functions.
  * @param  {Any}   init The initial value.
  * @return {Promise}
  */
-
 function waterfall(fns, init) {
   if (!fns.length) {
     return Promise.resolve(init);
   }
   return fns.reduce((promise, nextFn) => {
     return promise.then(nextFn);
   }, Promise.resolve(init));
 }
 
+/**
+ * Ensure a callback is always executed at the end of the passed promise flow.
+ *
+ * @link   https://github.com/domenic/promises-unwrapping/issues/18
+ * @param  {Promise}  promise  The promise.
+ * @param  {Function} fn       The callback.
+ * @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)
 });
\ No newline at end of file
--- a/services/common/tests/unit/test_storage_adapter.js
+++ b/services/common/tests/unit/test_storage_adapter.js
@@ -43,36 +43,34 @@ function test_collection_operations() {
     yield adapter.close();
   });
 
   // test creating new records... and getting them again
   add_task(function* test_kinto_create_new_get_existing() {
     let adapter = do_get_kinto_adapter();
     yield adapter.open();
     let record = {id:"test-id", foo:"bar"};
-    yield adapter.create(record);
+    yield adapter.execute((transaction) => transaction.create(record));
     let newRecord = yield adapter.get("test-id");
     // ensure the record is the same as when it was added
     deepEqual(record, newRecord);
     yield adapter.close();
   });
 
   // test removing records
   add_task(function* test_kinto_can_remove_some_records() {
     let adapter = do_get_kinto_adapter();
     yield adapter.open();
     // create a second record
     let record = {id:"test-id-2", foo:"baz"};
-    yield adapter.create(record);
+    yield adapter.execute((transaction) => transaction.create(record));
     let newRecord = yield adapter.get("test-id-2");
     deepEqual(record, newRecord);
     // delete the record
-    let id = yield adapter.delete(record.id);
-    // ensure the delete resolved with the record id
-    do_check_eq(record.id, id);
+    yield adapter.execute((transaction) => transaction.delete(record.id));
     newRecord = yield adapter.get(record.id);
     // ... and ensure it's no longer there
     do_check_eq(newRecord, undefined);
     // ensure the other record still exists
     newRecord = yield adapter.get("test-id");
     do_check_neq(newRecord, undefined);
     yield adapter.close();
   });
@@ -90,38 +88,59 @@ function test_collection_operations() {
 
   // test updating records... and getting them again
   add_task(function* test_kinto_update_get_existing() {
     let adapter = do_get_kinto_adapter();
     yield adapter.open();
     let originalRecord = {id:"test-id", foo:"bar"};
     let updatedRecord = {id:"test-id", foo:"baz"};
     yield adapter.clear();
-    yield adapter.create(originalRecord);
-    yield adapter.update(updatedRecord);
+    yield adapter.execute((transaction) => transaction.create(originalRecord));
+    yield adapter.execute((transaction) => transaction.update(updatedRecord));
     // ensure the record exists
     let newRecord = yield adapter.get("test-id");
     // ensure the record is the same as when it was added
     deepEqual(updatedRecord, newRecord);
     yield adapter.close();
   });
 
   // test listing records
   add_task(function* test_kinto_list() {
     let adapter = do_get_kinto_adapter();
     yield adapter.open();
     let originalRecord = {id:"test-id-1", foo:"bar"};
     let records = yield adapter.list();
     do_check_eq(records.length, 1);
-    yield adapter.create(originalRecord);
+    yield adapter.execute((transaction) => transaction.create(originalRecord));
     records = yield adapter.list();
     do_check_eq(records.length, 2);
     yield adapter.close();
   });
 
+  // test aborting transaction
+  add_task(function* test_kinto_aborting_transaction() {
+    let adapter = do_get_kinto_adapter();
+    yield adapter.open();
+    yield adapter.clear();
+    let record = {id: 1, foo: "bar"};
+    let error = null;
+    try {
+      yield adapter.execute((transaction) => {
+        transaction.create(record);
+        throw new Error("unexpected");
+      });
+    } catch (e) {
+      error = e;
+    }
+    do_check_neq(error, null);
+    records = yield adapter.list();
+    do_check_eq(records.length, 0);
+    yield adapter.close();
+  });
+
   // test save and get last modified
   add_task(function* test_kinto_last_modified() {
     const initialValue = 0;
     const intendedValue = 12345678;
 
     let adapter = do_get_kinto_adapter();
     yield adapter.open();
     let lastModified = yield adapter.getLastModified();