Bug 1282109: update kinto-offline-client.js, r=MattN
authorEthan Glasser-Camp <eglassercamp@mozilla.com>
Wed, 13 Jul 2016 15:09:42 -0400
changeset 305702 80b9e593a3aeddb2fade68ad88619b9b31d599d2
parent 305701 7bfa8f091fcc2b1755b5a8eaae50f4cf59870a55
child 305703 92442d706b644c71044249e8a1cb4eb4437f1dbc
push id30467
push usercbook@mozilla.com
push dateWed, 20 Jul 2016 09:21:53 +0000
treeherdermozilla-central@e904e18d7dfc [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMattN
bugs1282109
milestone50.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 1282109: update kinto-offline-client.js, r=MattN MozReview-Commit-ID: BiE4UvlLu6T
services/common/blocklist-clients.js
services/common/kinto-http-client.js
services/common/kinto-offline-client.js
services/common/tests/unit/test_kinto.js
--- a/services/common/blocklist-clients.js
+++ b/services/common/blocklist-clients.js
@@ -64,17 +64,17 @@ function mergeChanges(localRecords, chan
     .filter((record) => record.deleted != true)
     // Sort list by record id.
     .sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
 }
 
 
 function fetchCollectionMetadata(collection) {
   const client = new KintoHttpClient(collection.api.remote);
-  return client.bucket(collection.bucket).collection(collection.name).getMetadata()
+  return client.bucket(collection.bucket).collection(collection.name).getData()
     .then(result => {
       return result.signature;
     });
 }
 
 function fetchRemoteCollection(collection) {
   const client = new KintoHttpClient(collection.api.remote);
   return client.bucket(collection.bucket)
--- a/services/common/kinto-http-client.js
+++ b/services/common/kinto-http-client.js
@@ -9,23 +9,23 @@
  * 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.
  */
 
 /*
- * This file is generated from kinto-client.js - do not modify directly.
+ * This file is generated from kinto-http.js - do not modify directly.
  */
 
 this.EXPORTED_SYMBOLS = ["KintoHttpClient"];
 
 /*
- * Version 0.6.0 - 6b6c736
+ * Version 2.0.0 - 61435f3
  */
 
 (function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.KintoHttpClient = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
 /*
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
@@ -99,19 +99,19 @@ var _endpoint = require("./endpoint");
 var _endpoint2 = _interopRequireDefault(_endpoint);
 
 var _requests = require("./requests");
 
 var requests = _interopRequireWildcard(_requests);
 
 var _batch = require("./batch");
 
-var _bucket2 = require("./bucket");
+var _bucket = require("./bucket");
 
-var _bucket3 = _interopRequireDefault(_bucket2);
+var _bucket2 = _interopRequireDefault(_bucket);
 
 function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } }
 
 function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
 
 function _applyDecoratedDescriptor(target, property, decorators, descriptor, context) {
   var desc = {};
   Object['ke' + 'ys'](descriptor).forEach(function (key) {
@@ -157,28 +157,24 @@ const SUPPORTED_PROTOCOL_VERSION = expor
 *    .createRecord({title: "First article"})
  *   .then(console.log.bind(console))
  *   .catch(console.error.bind(console));
  */
 let KintoClientBase = (_dec = (0, _utils.nobatch)("This operation is not supported within a batch operation."), _dec2 = (0, _utils.nobatch)("This operation is not supported within a batch operation."), _dec3 = (0, _utils.nobatch)("This operation is not supported within a batch operation."), _dec4 = (0, _utils.nobatch)("This operation is not supported within a batch operation."), _dec5 = (0, _utils.nobatch)("Can't use batch within a batch!"), _dec6 = (0, _utils.support)("1.4", "2.0"), (_class = class KintoClientBase {
   /**
    * Constructor.
    *
-   * @param  {String} remote  The remote URL.
-   * @param  {Object}  options The options object.
-   * @param  {Boolean} options.safe        Adds concurrency headers to every
-   * requests (default: `true`).
-   * @param  {EventEmitter} options.events The events handler. If none provided
-   * an `EventEmitter` instance will be created.
-   * @param  {Object}  options.headers     The key-value headers to pass to each
-   * request (default: `{}`).
-   * @param  {String}  options.bucket      The default bucket to use (default:
-   * `"default"`)
-   * @param  {String}  options.requestMode The HTTP request mode (from ES6 fetch
-   * spec).
+   * @param  {String}       remote  The remote URL.
+   * @param  {Object}       [options={}]                  The options object.
+   * @param  {Boolean}      [options.safe=true]           Adds concurrency headers to every requests.
+   * @param  {EventEmitter} [options.events=EventEmitter] The events handler instance.
+   * @param  {Object}       [options.headers={}]          The key-value headers to pass to each request.
+   * @param  {String}       [options.bucket="default"]    The default bucket to use.
+   * @param  {String}       [options.requestMode="cors"]  The HTTP request mode (from ES6 fetch spec).
+   * @param  {Number}       [options.timeout=5000]        The requests timeout in ms.
    */
   constructor(remote, options = {}) {
     if (typeof remote !== "string" || !remote.length) {
       throw new Error("Invalid remote URL: " + remote);
     }
     if (remote[remote.length - 1] === "/") {
       remote = remote.slice(0, -1);
     }
@@ -214,22 +210,23 @@ let KintoClientBase = (_dec = (0, _utils
     /**
      * The event emitter instance. Should comply with the `EventEmitter`
      * interface.
      * @ignore
      * @type {Class}
      */
     this.events = options.events;
 
+    const { requestMode, timeout } = options;
     /**
      * The HTTP instance.
      * @ignore
      * @type {HTTP}
      */
-    this.http = new _http2.default(this.events, { requestMode: options.requestMode });
+    this.http = new _http2.default(this.events, { requestMode, timeout });
     this._registerHTTPEvents();
   }
 
   /**
    * The remote endpoint base URL. Setting the value will also extract and
    * validate the version.
    * @type {String}
    */
@@ -276,123 +273,131 @@ let KintoClientBase = (_dec = (0, _utils
     return 0;
   }
 
   /**
    * Registers HTTP events.
    * @private
    */
   _registerHTTPEvents() {
-    this.events.on("backoff", backoffMs => {
-      this._backoffReleaseTime = backoffMs;
-    });
+    // Prevent registering event from a batch client instance
+    if (!this._isBatch) {
+      this.events.on("backoff", backoffMs => {
+        this._backoffReleaseTime = backoffMs;
+      });
+    }
   }
 
   /**
    * Retrieve a bucket object to perform operations on it.
    *
-   * @param  {String}  name    The bucket name.
-   * @param  {Object}  options The request options.
-   * @param  {Boolean} safe    The resulting safe option.
-   * @param  {String}  bucket  The resulting bucket name option.
-   * @param  {Object}  headers The extended headers object option.
+   * @param  {String}  name              The bucket name.
+   * @param  {Object}  [options={}]      The request options.
+   * @param  {Boolean} [options.safe]    The resulting safe option.
+   * @param  {String}  [options.bucket]  The resulting bucket name option.
+   * @param  {Object}  [options.headers] The extended headers object option.
    * @return {Bucket}
    */
   bucket(name, options = {}) {
     const bucketOptions = (0, _utils.omit)(this._getRequestOptions(options), "bucket");
-    return new _bucket3.default(this, name, bucketOptions);
+    return new _bucket2.default(this, name, bucketOptions);
   }
 
   /**
    * Generates a request options object, deeply merging the client configured
    * defaults with the ones provided as argument.
    *
    * Note: Headers won't be overriden but merged with instance default ones.
    *
    * @private
-   * @param    {Object} options The request options.
+   * @param    {Object}  [options={}]      The request options.
+   * @property {Boolean} [options.safe]    The resulting safe option.
+   * @property {String}  [options.bucket]  The resulting bucket name option.
+   * @property {Object}  [options.headers] The extended headers object option.
    * @return   {Object}
-   * @property {Boolean} safe    The resulting safe option.
-   * @property {String}  bucket  The resulting bucket name option.
-   * @property {Object}  headers The extended headers object option.
    */
   _getRequestOptions(options = {}) {
     return _extends({}, this.defaultReqOptions, options, {
       batch: this._isBatch,
       // Note: headers should never be overriden but extended
       headers: _extends({}, this.defaultReqOptions.headers, options.headers)
     });
   }
 
   /**
    * Retrieves server information and persist them locally. This operation is
    * usually performed a single time during the instance lifecycle.
    *
+   * @param  {Object}  [options={}] The request options.
    * @return {Promise<Object, Error>}
    */
-  fetchServerInfo() {
+  fetchServerInfo(options = {}) {
     if (this.serverInfo) {
       return Promise.resolve(this.serverInfo);
     }
     return this.http.request(this.remote + (0, _endpoint2.default)("root"), {
-      headers: this.defaultReqOptions.headers
+      headers: _extends({}, this.defaultReqOptions.headers, options.headers)
     }).then(({ json }) => {
       this.serverInfo = json;
       return this.serverInfo;
     });
   }
 
   /**
    * Retrieves Kinto server settings.
    *
+   * @param  {Object}  [options={}] The request options.
    * @return {Promise<Object, Error>}
    */
 
-  fetchServerSettings() {
-    return this.fetchServerInfo().then(({ settings }) => settings);
+  fetchServerSettings(options = {}) {
+    return this.fetchServerInfo(options).then(({ settings }) => settings);
   }
 
   /**
    * Retrieve server capabilities information.
    *
+   * @param  {Object}  [options={}] The request options.
    * @return {Promise<Object, Error>}
    */
 
-  fetchServerCapabilities() {
-    return this.fetchServerInfo().then(({ capabilities }) => capabilities);
+  fetchServerCapabilities(options = {}) {
+    return this.fetchServerInfo(options).then(({ capabilities }) => capabilities);
   }
 
   /**
    * Retrieve authenticated user information.
    *
+   * @param  {Object}  [options={}] The request options.
    * @return {Promise<Object, Error>}
    */
 
-  fetchUser() {
-    return this.fetchServerInfo().then(({ user }) => user);
+  fetchUser(options = {}) {
+    return this.fetchServerInfo(options).then(({ user }) => user);
   }
 
   /**
    * Retrieve authenticated user information.
    *
+   * @param  {Object}  [options={}] The request options.
    * @return {Promise<Object, Error>}
    */
 
-  fetchHTTPApiVersion() {
-    return this.fetchServerInfo().then(({ http_api_version }) => {
+  fetchHTTPApiVersion(options = {}) {
+    return this.fetchServerInfo(options).then(({ http_api_version }) => {
       return http_api_version;
     });
   }
 
   /**
    * Process batch requests, chunking them according to the batch_max_requests
    * server setting when needed.
    *
-   * @param  {Array}  requests The list of batch subrequests to perform.
-   * @param  {Object} options  The options object.
+   * @param  {Array}  requests     The list of batch subrequests to perform.
+   * @param  {Object} [options={}] The options object.
    * @return {Promise<Object, Error>}
    */
   _batchRequests(requests, options = {}) {
     const headers = _extends({}, this.defaultReqOptions.headers, options.headers);
     if (!requests.length) {
       return Promise.resolve([]);
     }
     return this.fetchServerSettings().then(serverSettings => {
@@ -416,23 +421,22 @@ let KintoClientBase = (_dec = (0, _utils
   }
 
   /**
    * Sends batch requests to the remote server.
    *
    * Note: Reserved for internal use only.
    *
    * @ignore
-   * @param  {Function} fn      The function to use for describing batch ops.
-   * @param  {Object}   options The options object.
-   * @param  {Boolean}  options.safe      The safe option.
-   * @param  {String}   options.bucket    The bucket name option.
-   * @param  {Object}   options.headers   The headers object option.
-   * @param  {Boolean}  options.aggregate Produces an aggregated result object
-   * (default: `false`).
+   * @param  {Function} fn                        The function to use for describing batch ops.
+   * @param  {Object}   [options={}]              The options object.
+   * @param  {Boolean}  [options.safe]            The safe option.
+   * @param  {String}   [options.bucket]          The bucket name option.
+   * @param  {Object}   [options.headers]         The headers object option.
+   * @param  {Boolean}  [options.aggregate=false] Produces an aggregated result object.
    * @return {Promise<Object, Error>}
    */
 
   batch(fn, options = {}) {
     const rootBatch = new KintoClientBase(this.remote, _extends({}, this._options, this._getRequestOptions(options), {
       batch: true
     }));
     let bucketBatch, collBatch;
@@ -455,21 +459,19 @@ let KintoClientBase = (_dec = (0, _utils
       return responses;
     });
   }
 
   /**
    * Executes an atomic HTTP request.
    *
    * @private
-   * @param  {Object}  request     The request object.
-   * @param  {Object}  options     The options object.
-   * @param  {Boolean} options.raw Resolve with full response object, including
-   * json body and headers (Default: `false`, so only the json body is
-   * retrieved).
+   * @param  {Object}  request             The request object.
+   * @param  {Object}  [options={}]        The options object.
+   * @param  {Boolean} [options.raw=false] If true, resolve with full response object, including json body and headers instead of just json.
    * @return {Promise<Object, Error>}
    */
   execute(request, options = { raw: false }) {
     // If we're within a batch, add the request to the stack to send at once.
     if (this._isBatch) {
       this._requests.push(request);
       // Resolve with a message in case people attempt at consuming the result
       // from within a batch operation.
@@ -482,70 +484,87 @@ let KintoClientBase = (_dec = (0, _utils
       }));
     });
     return options.raw ? promise : promise.then(({ json }) => json);
   }
 
   /**
    * Retrieves the list of buckets.
    *
-   * @param  {Object} options         The options object.
-   * @param  {Object} options.headers The headers object option.
+   * @param  {Object} [options={}]      The options object.
+   * @param  {Object} [options.headers] The headers object option.
    * @return {Promise<Object[], Error>}
    */
   listBuckets(options = {}) {
     return this.execute({
-      path: (0, _endpoint2.default)("buckets"),
+      path: (0, _endpoint2.default)("bucket"),
       headers: _extends({}, this.defaultReqOptions.headers, options.headers)
     });
   }
 
   /**
    * Creates a new bucket on the server.
    *
-   * @param  {String}   bucketName      The bucket name.
-   * @param  {Object}   options         The options object.
-   * @param  {Boolean}  options.safe    The safe option.
-   * @param  {Object}   options.headers The headers object option.
+   * @param  {String}   id                The bucket name.
+   * @param  {Object}   [options={}]      The options object.
+   * @param  {Boolean}  [options.data]    The bucket data option.
+   * @param  {Boolean}  [options.safe]    The safe option.
+   * @param  {Object}   [options.headers] The headers object option.
    * @return {Promise<Object, Error>}
    */
-  createBucket(bucketName, options = {}) {
+  createBucket(id, options = {}) {
+    if (!id) {
+      throw new Error("A bucket id is required.");
+    }
+    // Note that we simply ignore any "bucket" option passed here, as the one
+    // we're interested in is the one provided as a required argument.
     const reqOptions = this._getRequestOptions(options);
-    return this.execute(requests.createBucket(bucketName, reqOptions));
+    const { data = {}, permissions } = reqOptions;
+    data.id = id;
+    const path = (0, _endpoint2.default)("bucket", id);
+    return this.execute(requests.createRequest(path, { data, permissions }, reqOptions));
   }
 
   /**
    * Deletes a bucket from the server.
    *
    * @ignore
-   * @param  {Object|String} bucket          The bucket to delete.
-   * @param  {Object}        options         The options object.
-   * @param  {Boolean}       options.safe    The safe option.
-   * @param  {Object}        options.headers The headers object option.
+   * @param  {Object|String} bucket                  The bucket to delete.
+   * @param  {Object}        [options={}]            The options object.
+   * @param  {Boolean}       [options.safe]          The safe option.
+   * @param  {Object}        [options.headers]       The headers object option.
+   * @param  {Number}        [options.last_modified] The last_modified option.
    * @return {Promise<Object, Error>}
    */
   deleteBucket(bucket, options = {}) {
-    const _bucket = typeof bucket === "object" ? bucket : { id: bucket };
-    const reqOptions = this._getRequestOptions(options);
-    return this.execute(requests.deleteBucket(_bucket, reqOptions));
+    const bucketObj = (0, _utils.toDataBody)(bucket);
+    if (!bucketObj.id) {
+      throw new Error("A bucket id is required.");
+    }
+    const path = (0, _endpoint2.default)("bucket", bucketObj.id);
+    const { last_modified } = { bucketObj };
+    const reqOptions = this._getRequestOptions(_extends({ last_modified }, options));
+    return this.execute(requests.deleteRequest(path, reqOptions));
   }
 
   /**
    * Deletes all buckets on the server.
    *
    * @ignore
-   * @param  {Object}  options         The options object.
-   * @param  {Boolean} options.safe    The safe option.
-   * @param  {Object}  options.headers The headers object option.
+   * @param  {Object}  [options={}]            The options object.
+   * @param  {Boolean} [options.safe]          The safe option.
+   * @param  {Object}  [options.headers]       The headers object option.
+   * @param  {Number}  [options.last_modified] The last_modified option.
    * @return {Promise<Object, Error>}
    */
 
   deleteBuckets(options = {}) {
     const reqOptions = this._getRequestOptions(options);
-    return this.execute(requests.deleteBuckets(reqOptions));
+    const path = (0, _endpoint2.default)("bucket");
+    return this.execute(requests.deleteRequest(path, reqOptions));
   }
 }, (_applyDecoratedDescriptor(_class.prototype, "fetchServerSettings", [_dec], Object.getOwnPropertyDescriptor(_class.prototype, "fetchServerSettings"), _class.prototype), _applyDecoratedDescriptor(_class.prototype, "fetchServerCapabilities", [_dec2], Object.getOwnPropertyDescriptor(_class.prototype, "fetchServerCapabilities"), _class.prototype), _applyDecoratedDescriptor(_class.prototype, "fetchUser", [_dec3], Object.getOwnPropertyDescriptor(_class.prototype, "fetchUser"), _class.prototype), _applyDecoratedDescriptor(_class.prototype, "fetchHTTPApiVersion", [_dec4], Object.getOwnPropertyDescriptor(_class.prototype, "fetchHTTPApiVersion"), _class.prototype), _applyDecoratedDescriptor(_class.prototype, "batch", [_dec5], Object.getOwnPropertyDescriptor(_class.prototype, "batch"), _class.prototype), _applyDecoratedDescriptor(_class.prototype, "deleteBuckets", [_dec6], Object.getOwnPropertyDescriptor(_class.prototype, "deleteBuckets"), _class.prototype)), _class));
 exports.default = KintoClientBase;
 
 },{"./batch":3,"./bucket":4,"./endpoint":6,"./http":8,"./requests":9,"./utils":10}],3:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
@@ -625,20 +644,21 @@ function _interopRequireDefault(obj) { r
 /**
  * Abstract representation of a selected bucket.
  *
  */
 let Bucket = class Bucket {
   /**
    * Constructor.
    *
-   * @param  {KintoClient} client          The client instance.
-   * @param  {String}      name            The bucket name.
-   * @param  {Object}      options.headers The headers object option.
-   * @param  {Boolean}     options.safe    The safe option.
+   * @param  {KintoClient} client            The client instance.
+   * @param  {String}      name              The bucket name.
+   * @param  {Object}      [options={}]      The headers object option.
+   * @param  {Object}      [options.headers] The headers object option.
+   * @param  {Boolean}     [options.safe]    The safe option.
    */
   constructor(client, name, options = {}) {
     /**
      * @ignore
      */
     this.client = client;
     /**
      * The bucket name.
@@ -656,142 +676,288 @@ let Bucket = class Bucket {
      */
     this._isBatch = !!options.batch;
   }
 
   /**
    * Merges passed request options with default bucket ones, if any.
    *
    * @private
-   * @param  {Object} options The options to merge.
-   * @return {Object}         The merged options.
+   * @param  {Object} [options={}] The options to merge.
+   * @return {Object}              The merged options.
    */
   _bucketOptions(options = {}) {
     const headers = _extends({}, this.options && this.options.headers, options.headers);
     return _extends({}, this.options, options, {
       headers,
       bucket: this.name,
       batch: this._isBatch
     });
   }
 
   /**
    * Selects a collection.
    *
-   * @param  {String} name            The collection name.
-   * @param  {Object} options         The options object.
-   * @param  {Object} options.headers The headers object option.
-   * @param  {Boolean}  options.safe  The safe option.
+   * @param  {String}  name              The collection name.
+   * @param  {Object}  [options={}]      The options object.
+   * @param  {Object}  [options.headers] The headers object option.
+   * @param  {Boolean} [options.safe]    The safe option.
    * @return {Collection}
    */
-  collection(name, options) {
+  collection(name, options = {}) {
     return new _collection2.default(this.client, this, name, this._bucketOptions(options));
   }
 
   /**
-   * Retrieves bucket properties.
+   * Retrieves bucket data.
    *
-   * @param  {Object} options         The options object.
-   * @param  {Object} options.headers The headers object option.
+   * @param  {Object} [options={}]      The options object.
+   * @param  {Object} [options.headers] The headers object option.
    * @return {Promise<Object, Error>}
    */
-  getAttributes(options = {}) {
+  getData(options = {}) {
     return this.client.execute({
       path: (0, _endpoint2.default)("bucket", this.name),
       headers: _extends({}, this.options.headers, options.headers)
-    });
+    }).then(res => res.data);
+  }
+
+  /**
+   * Set bucket data.
+   * @param  {Object}  data                    The bucket data object.
+   * @param  {Object}  [options={}]            The options object.
+   * @param  {Object}  [options.headers]       The headers object option.
+   * @param  {Boolean} [options.safe]          The safe option.
+   * @param  {Boolean} [options.patch]         The patch option.
+   * @param  {Number}  [options.last_modified] The last_modified option.
+   * @return {Promise<Object, Error>}
+   */
+  setData(data, options = {}) {
+    if (!(0, _utils.isObject)(data)) {
+      throw new Error("A bucket object is required.");
+    }
+
+    const bucket = _extends({}, data, { id: this.name });
+
+    // For default bucket, we need to drop the id from the data object.
+    // Bug in Kinto < 3.1.1
+    const bucketId = bucket.id;
+    if (bucket.id === "default") {
+      delete bucket.id;
+    }
+
+    const path = (0, _endpoint2.default)("bucket", bucketId);
+    const { permissions } = options;
+    const reqOptions = _extends({}, this._bucketOptions(options));
+    const request = requests.updateRequest(path, { data: bucket, permissions }, reqOptions);
+    return this.client.execute(request);
   }
 
   /**
    * Retrieves the list of collections in the current bucket.
    *
-   * @param  {Object} options         The options object.
-   * @param  {Object} options.headers The headers object option.
+   * @param  {Object} [options={}]      The options object.
+   * @param  {Object} [options.headers] The headers object option.
    * @return {Promise<Array<Object>, Error>}
    */
   listCollections(options = {}) {
     return this.client.execute({
-      path: (0, _endpoint2.default)("collections", this.name),
+      path: (0, _endpoint2.default)("collection", this.name),
       headers: _extends({}, this.options.headers, options.headers)
     });
   }
 
   /**
    * Creates a new collection in current bucket.
    *
-   * @param  {String|undefined}  id        The collection id.
-   * @param  {Object}  options             The options object.
-   * @param  {Boolean} options.safe        The safe option.
-   * @param  {Object}  options.headers     The headers object option.
-   * @param  {Object}  options.permissions The permissions object.
-   * @param  {Object}  options.data        The metadadata object.
-   * @param  {Object}  options.schema      The JSONSchema object.
+   * @param  {String|undefined}  id          The collection id.
+   * @param  {Object}  [options={}]          The options object.
+   * @param  {Boolean} [options.safe]        The safe option.
+   * @param  {Object}  [options.headers]     The headers object option.
+   * @param  {Object}  [options.permissions] The permissions object.
+   * @param  {Object}  [options.data]        The data object.
    * @return {Promise<Object, Error>}
    */
-  createCollection(id, options) {
+  createCollection(id, options = {}) {
     const reqOptions = this._bucketOptions(options);
-    const request = requests.createCollection(id, reqOptions);
+    const { permissions, data = {} } = reqOptions;
+    data.id = id;
+    const path = (0, _endpoint2.default)("collection", this.name, id);
+    const request = requests.createRequest(path, { data, permissions }, reqOptions);
     return this.client.execute(request);
   }
 
   /**
    * Deletes a collection from the current bucket.
    *
-   * @param  {Object|String} collection  The collection to delete.
-   * @param  {Object}    options         The options object.
-   * @param  {Object}    options.headers The headers object option.
-   * @param  {Boolean}   options.safe    The safe option.
+   * @param  {Object|String} collection              The collection to delete.
+   * @param  {Object}        [options={}]            The options object.
+   * @param  {Object}        [options.headers]       The headers object option.
+   * @param  {Boolean}       [options.safe]          The safe option.
+   * @param  {Number}        [options.last_modified] The last_modified option.
+   * @return {Promise<Object, Error>}
+   */
+  deleteCollection(collection, options = {}) {
+    const collectionObj = (0, _utils.toDataBody)(collection);
+    if (!collectionObj.id) {
+      throw new Error("A collection id is required.");
+    }
+    const { id, last_modified } = collectionObj;
+    const reqOptions = this._bucketOptions(_extends({ last_modified }, options));
+    const path = (0, _endpoint2.default)("collection", this.name, id);
+    const request = requests.deleteRequest(path, reqOptions);
+    return this.client.execute(request);
+  }
+
+  /**
+   * Retrieves the list of groups in the current bucket.
+   *
+   * @param  {Object} [options={}]      The options object.
+   * @param  {Object} [options.headers] The headers object option.
+   * @return {Promise<Array<Object>, Error>}
+   */
+  listGroups(options = {}) {
+    return this.client.execute({
+      path: (0, _endpoint2.default)("group", this.name),
+      headers: _extends({}, this.options.headers, options.headers)
+    });
+  }
+
+  /**
+   * Creates a new group in current bucket.
+   *
+   * @param  {String} id                The group id.
+   * @param  {Object} [options={}]      The options object.
+   * @param  {Object} [options.headers] The headers object option.
    * @return {Promise<Object, Error>}
    */
-  deleteCollection(collection, options) {
+  getGroup(id, options = {}) {
+    return this.client.execute({
+      path: (0, _endpoint2.default)("group", this.name, id),
+      headers: _extends({}, this.options.headers, options.headers)
+    });
+  }
+
+  /**
+   * Creates a new group in current bucket.
+   *
+   * @param  {String|undefined}  id                    The group id.
+   * @param  {Array<String>}     [members=[]]          The list of principals.
+   * @param  {Object}            [options={}]          The options object.
+   * @param  {Object}            [options.data]        The data object.
+   * @param  {Object}            [options.permissions] The permissions object.
+   * @param  {Boolean}           [options.safe]        The safe option.
+   * @param  {Object}            [options.headers]     The headers object option.
+   * @return {Promise<Object, Error>}
+   */
+  createGroup(id, members = [], options = {}) {
     const reqOptions = this._bucketOptions(options);
-    const request = requests.deleteCollection((0, _utils.toDataBody)(collection), reqOptions);
+    const data = _extends({}, options.data, {
+      id,
+      members
+    });
+    const path = (0, _endpoint2.default)("group", this.name, id);
+    const { permissions } = options;
+    const request = requests.createRequest(path, { data, permissions }, reqOptions);
+    return this.client.execute(request);
+  }
+
+  /**
+   * Updates an existing group in current bucket.
+   *
+   * @param  {Object}  group                   The group object.
+   * @param  {Object}  [options={}]            The options object.
+   * @param  {Object}  [options.data]          The data object.
+   * @param  {Object}  [options.permissions]   The permissions object.
+   * @param  {Boolean} [options.safe]          The safe option.
+   * @param  {Object}  [options.headers]       The headers object option.
+   * @param  {Number}  [options.last_modified] The last_modified option.
+   * @return {Promise<Object, Error>}
+   */
+  updateGroup(group, options = {}) {
+    if (!(0, _utils.isObject)(group)) {
+      throw new Error("A group object is required.");
+    }
+    if (!group.id) {
+      throw new Error("A group id is required.");
+    }
+    const reqOptions = this._bucketOptions(options);
+    const data = _extends({}, options.data, group);
+    const path = (0, _endpoint2.default)("group", this.name, group.id);
+    const { permissions } = options;
+    const request = requests.updateRequest(path, { data, permissions }, reqOptions);
+    return this.client.execute(request);
+  }
+
+  /**
+   * Deletes a group from the current bucket.
+   *
+   * @param  {Object|String} group                   The group to delete.
+   * @param  {Object}        [options={}]            The options object.
+   * @param  {Object}        [options.headers]       The headers object option.
+   * @param  {Boolean}       [options.safe]          The safe option.
+   * @param  {Number}        [options.last_modified] The last_modified option.
+   * @return {Promise<Object, Error>}
+   */
+  deleteGroup(group, options = {}) {
+    const groupObj = (0, _utils.toDataBody)(group);
+    const { id, last_modified } = groupObj;
+    const reqOptions = this._bucketOptions(_extends({ last_modified }, options));
+    const path = (0, _endpoint2.default)("group", this.name, id);
+    const request = requests.deleteRequest(path, reqOptions);
     return this.client.execute(request);
   }
 
   /**
    * Retrieves the list of permissions for this bucket.
    *
-   * @param  {Object} options         The options object.
-   * @param  {Object} options.headers The headers object option.
+   * @param  {Object} [options={}]      The options object.
+   * @param  {Object} [options.headers] The headers object option.
    * @return {Promise<Object, Error>}
    */
-  getPermissions(options) {
-    return this.getAttributes(this._bucketOptions(options)).then(res => res.permissions);
+  getPermissions(options = {}) {
+    return this.client.execute({
+      path: (0, _endpoint2.default)("bucket", this.name),
+      headers: _extends({}, this.options.headers, options.headers)
+    }).then(res => res.permissions);
   }
 
   /**
-   * Recplaces all existing bucket permissions with the ones provided.
+   * Replaces all existing bucket permissions with the ones provided.
    *
-   * @param  {Object}  permissions           The permissions object.
-   * @param  {Object}  options               The options object
-   * @param  {Object}  options               The options object.
-   * @param  {Boolean} options.safe          The safe option.
-   * @param  {Object}  options.headers       The headers object option.
-   * @param  {Object}  options.last_modified The last_modified option.
+   * @param  {Object}  permissions             The permissions object.
+   * @param  {Object}  [options={}]            The options object
+   * @param  {Boolean} [options.safe]          The safe option.
+   * @param  {Object}  [options.headers]       The headers object option.
+   * @param  {Object}  [options.last_modified] The last_modified option.
    * @return {Promise<Object, Error>}
    */
   setPermissions(permissions, options = {}) {
-    return this.client.execute(requests.updateBucket({
-      id: this.name,
-      last_modified: options.last_modified
-    }, _extends({}, this._bucketOptions(options), { permissions })));
+    if (!(0, _utils.isObject)(permissions)) {
+      throw new Error("A permissions object is required.");
+    }
+    const path = (0, _endpoint2.default)("bucket", this.name);
+    const reqOptions = _extends({}, this._bucketOptions(options));
+    const { last_modified } = options;
+    const data = { last_modified };
+    const request = requests.updateRequest(path, { data, permissions }, reqOptions);
+    return this.client.execute(request);
   }
 
   /**
    * Performs batch operations at the current bucket level.
    *
-   * @param  {Function} fn                 The batch operation function.
-   * @param  {Object}   options            The options object.
-   * @param  {Object}   options.headers    The headers object option.
-   * @param  {Boolean}  options.safe       The safe option.
-   * @param  {Boolean}  options.aggregate  Produces a grouped result object.
+   * @param  {Function} fn                   The batch operation function.
+   * @param  {Object}   [options={}]         The options object.
+   * @param  {Object}   [options.headers]    The headers object option.
+   * @param  {Boolean}  [options.safe]       The safe option.
+   * @param  {Boolean}  [options.aggregate]  Produces a grouped result object.
    * @return {Promise<Object, Error>}
    */
-  batch(fn, options) {
+  batch(fn, options = {}) {
     return this.client.batch(fn, this._bucketOptions(options));
   }
 };
 exports.default = Bucket;
 
 },{"./collection":5,"./endpoint":6,"./requests":9,"./utils":10}],5:[function(require,module,exports){
 "use strict";
 
@@ -819,21 +985,22 @@ function _interopRequireWildcard(obj) { 
 /**
  * Abstract representation of a selected collection.
  *
  */
 let Collection = class Collection {
   /**
    * Constructor.
    *
-   * @param  {KintoClient}  client          The client instance.
-   * @param  {Bucket}       bucket          The bucket instance.
-   * @param  {String}       name            The collection name.
-   * @param  {Object}       options.headers The headers object option.
-   * @param  {Boolean}      options.safe    The safe option.
+   * @param  {KintoClient}  client            The client instance.
+   * @param  {Bucket}       bucket            The bucket instance.
+   * @param  {String}       name              The collection name.
+   * @param  {Object}       [options={}]      The options object.
+   * @param  {Object}       [options.headers] The headers object option.
+   * @param  {Boolean}      [options.safe]    The safe option.
    */
   constructor(client, bucket, name, options = {}) {
     /**
      * @ignore
      */
     this.client = client;
     /**
      * @ignore
@@ -859,235 +1026,219 @@ let Collection = class Collection {
     this._isBatch = !!options.batch;
   }
 
   /**
    * Merges passed request options with default bucket and collection ones, if
    * any.
    *
    * @private
-   * @param  {Object} options The options to merge.
-   * @return {Object}         The merged options.
+   * @param  {Object} [options={}] The options to merge.
+   * @return {Object}              The merged options.
    */
   _collOptions(options = {}) {
     const headers = _extends({}, this.options && this.options.headers, options.headers);
     return _extends({}, this.options, options, {
-      headers,
-      // XXX soon to be removed once we've migrated everything from KintoClient
-      bucket: this.bucket.name
-    });
-  }
-
-  /**
-   * Updates current collection properties.
-   *
-   * @private
-   * @param  {Object} options  The request options.
-   * @return {Promise<Object, Error>}
-   */
-  _updateAttributes(options = {}) {
-    const collection = (0, _utils.toDataBody)(this.name);
-    const reqOptions = this._collOptions(options);
-    const request = requests.updateCollection(collection, reqOptions);
-    return this.client.execute(request);
-  }
-
-  /**
-   * Retrieves collection properties.
-   *
-   * @param  {Object} options         The options object.
-   * @param  {Object} options.headers The headers object option.
-   * @return {Promise<Object, Error>}
-   */
-  getAttributes(options) {
-    const { headers } = this._collOptions(options);
-    return this.client.execute({
-      path: (0, _endpoint2.default)("collection", this.bucket.name, this.name),
       headers
     });
   }
 
   /**
+   * Retrieves collection data.
+   *
+   * @param  {Object} [options={}]      The options object.
+   * @param  {Object} [options.headers] The headers object option.
+   * @return {Promise<Object, Error>}
+   */
+  getData(options = {}) {
+    const { headers } = this._collOptions(options);
+    return this.client.execute({
+      path: (0, _endpoint2.default)("collection", this.bucket.name, this.name),
+      headers
+    }).then(res => res.data);
+  }
+
+  /**
+   * Set collection data.
+   * @param  {Object}   data                    The collection data object.
+   * @param  {Object}   [options={}]            The options object.
+   * @param  {Object}   [options.headers]       The headers object option.
+   * @param  {Boolean}  [options.safe]          The safe option.
+   * @param  {Boolean}  [options.patch]         The patch option.
+   * @param  {Number}   [options.last_modified] The last_modified option.
+   * @return {Promise<Object, Error>}
+   */
+  setData(data, options = {}) {
+    if (!(0, _utils.isObject)(data)) {
+      throw new Error("A collection object is required.");
+    }
+    const reqOptions = this._collOptions(options);
+    const { permissions } = reqOptions;
+
+    const path = (0, _endpoint2.default)("collection", this.bucket.name, this.name);
+    const request = requests.updateRequest(path, { data, permissions }, reqOptions);
+    return this.client.execute(request);
+  }
+
+  /**
    * Retrieves the list of permissions for this collection.
    *
-   * @param  {Object} options         The options object.
-   * @param  {Object} options.headers The headers object option.
+   * @param  {Object} [options={}]      The options object.
+   * @param  {Object} [options.headers] The headers object option.
    * @return {Promise<Object, Error>}
    */
-  getPermissions(options) {
-    return this.getAttributes(options).then(res => res.permissions);
+  getPermissions(options = {}) {
+    const { headers } = this._collOptions(options);
+    return this.client.execute({
+      path: (0, _endpoint2.default)("collection", this.bucket.name, this.name),
+      headers
+    }).then(res => res.permissions);
   }
 
   /**
    * Replaces all existing collection permissions with the ones provided.
    *
-   * @param  {Object}   permissions     The permissions object.
-   * @param  {Object}   options         The options object
-   * @param  {Object}   options.headers The headers object option.
-   * @param  {Boolean}  options.safe    The safe option.
-   * @param  {Number}   options.last_modified The last_modified option.
+   * @param  {Object}   permissions             The permissions object.
+   * @param  {Object}   [options={}]            The options object
+   * @param  {Object}   [options.headers]       The headers object option.
+   * @param  {Boolean}  [options.safe]          The safe option.
+   * @param  {Number}   [options.last_modified] The last_modified option.
    * @return {Promise<Object, Error>}
    */
-  setPermissions(permissions, options) {
-    return this._updateAttributes(_extends({}, options, { permissions }));
-  }
-
-  /**
-   * Retrieves the JSON schema for this collection, if any.
-   *
-   * @param  {Object} options         The options object.
-   * @param  {Object} options.headers The headers object option.
-   * @return {Promise<Object|null, Error>}
-   */
-  getSchema(options) {
-    return this.getAttributes(options).then(res => res.data && res.data.schema || null);
-  }
-
-  /**
-   * Sets the JSON schema for this collection.
-   *
-   * @param  {Object}   schema          The JSON schema object.
-   * @param  {Object}   options         The options object.
-   * @param  {Object}   options.headers The headers object option.
-   * @param  {Boolean}  options.safe    The safe option.
-   * @param  {Number}   options.last_modified The last_modified option.
-   * @return {Promise<Object|null, Error>}
-   */
-  setSchema(schema, options) {
-    return this._updateAttributes(_extends({}, options, { schema }));
-  }
-
-  /**
-   * Retrieves metadata attached to current collection.
-   *
-   * @param  {Object} options         The options object.
-   * @param  {Object} options.headers The headers object option.
-   * @return {Promise<Object, Error>}
-   */
-  getMetadata(options) {
-    return this.getAttributes(options).then(({ data }) => (0, _utils.omit)(data, "schema"));
-  }
-
-  /**
-   * Sets metadata for current collection.
-   *
-   * @param  {Object}   metadata        The metadata object.
-   * @param  {Object}   options         The options object.
-   * @param  {Object}   options.headers The headers object option.
-   * @param  {Boolean}  options.safe  The safe option.
-   * @param  {Number}   options.last_modified The last_modified option.
-   * @return {Promise<Object, Error>}
-   */
-  setMetadata(metadata, options) {
-    // Note: patching allows preventing overridding the schema, which lives
-    // within the "data" namespace.
-    return this._updateAttributes(_extends({}, options, { metadata, patch: true }));
+  setPermissions(permissions, options = {}) {
+    if (!(0, _utils.isObject)(permissions)) {
+      throw new Error("A permissions object is required.");
+    }
+    const reqOptions = this._collOptions(options);
+    const path = (0, _endpoint2.default)("collection", this.bucket.name, this.name);
+    const data = { last_modified: options.last_modified };
+    const request = requests.updateRequest(path, { data, permissions }, reqOptions);
+    return this.client.execute(request);
   }
 
   /**
    * Creates a record in current collection.
    *
-   * @param  {Object} record          The record to create.
-   * @param  {Object} options         The options object.
-   * @param  {Object} options.headers The headers object option.
-   * @param  {Boolean}  options.safe  The safe option.
+   * @param  {Object}  record            The record to create.
+   * @param  {Object}  [options={}]      The options object.
+   * @param  {Object}  [options.headers] The headers object option.
+   * @param  {Boolean} [options.safe]    The safe option.
    * @return {Promise<Object, Error>}
    */
-  createRecord(record, options) {
+  createRecord(record, options = {}) {
     const reqOptions = this._collOptions(options);
-    const request = requests.createRecord(this.name, record, reqOptions);
+    const { permissions } = reqOptions;
+    const path = (0, _endpoint2.default)("record", this.bucket.name, this.name, record.id);
+    const request = requests.createRequest(path, { data: record, permissions }, reqOptions);
     return this.client.execute(request);
   }
 
   /**
    * Updates a record in current collection.
    *
-   * @param  {Object}  record                The record to update.
-   * @param  {Object}  options               The options object.
-   * @param  {Object}  options.headers       The headers object option.
-   * @param  {Boolean} options.safe          The safe option.
-   * @param  {Number}  options.last_modified The last_modified option.
+   * @param  {Object}  record                  The record to update.
+   * @param  {Object}  [options={}]            The options object.
+   * @param  {Object}  [options.headers]       The headers object option.
+   * @param  {Boolean} [options.safe]          The safe option.
+   * @param  {Number}  [options.last_modified] The last_modified option.
    * @return {Promise<Object, Error>}
    */
-  updateRecord(record, options) {
+  updateRecord(record, options = {}) {
+    if (!(0, _utils.isObject)(record)) {
+      throw new Error("A record object is required.");
+    }
+    if (!record.id) {
+      throw new Error("A record id is required.");
+    }
     const reqOptions = this._collOptions(options);
-    const request = requests.updateRecord(this.name, record, reqOptions);
+    const { permissions } = reqOptions;
+    const path = (0, _endpoint2.default)("record", this.bucket.name, this.name, record.id);
+    const request = requests.updateRequest(path, { data: record, permissions }, reqOptions);
     return this.client.execute(request);
   }
 
   /**
    * Deletes a record from the current collection.
    *
-   * @param  {Object|String} record          The record to delete.
-   * @param  {Object}        options         The options object.
-   * @param  {Object}        options.headers The headers object option.
-   * @param  {Boolean}       options.safe    The safe option.
-   * @param  {Number}        options.last_modified The last_modified option.
+   * @param  {Object|String} record                  The record to delete.
+   * @param  {Object}        [options={}]            The options object.
+   * @param  {Object}        [options.headers]       The headers object option.
+   * @param  {Boolean}       [options.safe]          The safe option.
+   * @param  {Number}        [options.last_modified] The last_modified option.
    * @return {Promise<Object, Error>}
    */
-  deleteRecord(record, options) {
-    const reqOptions = this._collOptions(options);
-    const request = requests.deleteRecord(this.name, (0, _utils.toDataBody)(record), reqOptions);
+  deleteRecord(record, options = {}) {
+    const recordObj = (0, _utils.toDataBody)(record);
+    if (!recordObj.id) {
+      throw new Error("A record id is required.");
+    }
+    const { id, last_modified } = recordObj;
+    const reqOptions = this._collOptions(_extends({ last_modified }, options));
+    const path = (0, _endpoint2.default)("record", this.bucket.name, this.name, id);
+    const request = requests.deleteRequest(path, reqOptions);
     return this.client.execute(request);
   }
 
   /**
    * Retrieves a record from the current collection.
    *
-   * @param  {String} id              The record id to retrieve.
-   * @param  {Object} options         The options object.
-   * @param  {Object} options.headers The headers object option.
+   * @param  {String} id                The record id to retrieve.
+   * @param  {Object} [options={}]      The options object.
+   * @param  {Object} [options.headers] The headers object option.
    * @return {Promise<Object, Error>}
    */
-  getRecord(id, options) {
+  getRecord(id, options = {}) {
     return this.client.execute(_extends({
       path: (0, _endpoint2.default)("record", this.bucket.name, this.name, id)
     }, this._collOptions(options)));
   }
 
   /**
    * Lists records from the current collection.
    *
    * Sorting is done by passing a `sort` string option:
    *
    * - The field to order the results by, prefixed with `-` for descending.
    * Default: `-last_modified`.
    *
-   * @see http://kinto.readthedocs.org/en/latest/api/1.x/cliquet/resource.html#sorting
+   * @see http://kinto.readthedocs.io/en/stable/core/api/resource.html#sorting
    *
    * Filtering is done by passing a `filters` option object:
    *
    * - `{fieldname: "value"}`
    * - `{min_fieldname: 4000}`
    * - `{in_fieldname: "1,2,3"}`
    * - `{not_fieldname: 0}`
    * - `{exclude_fieldname: "0,1"}`
    *
-   * @see http://kinto.readthedocs.org/en/latest/api/1.x/cliquet/resource.html#filtering
+   * @see http://kinto.readthedocs.io/en/stable/core/api/resource.html#filtering
    *
    * Paginating is done by passing a `limit` option, then calling the `next()`
    * method from the resolved result object to fetch the next page, if any.
    *
-   * @param  {Object}   options         The options object.
-   * @param  {Object}   options.headers The headers object option.
-   * @param  {Object}   options.filters The filters object.
-   * @param  {String}   options.sort    The sort field.
-   * @param  {String}   options.limit   The limit field.
-   * @param  {String}   options.pages   The number of result pages to aggregate.
-   * @param  {Number}   options.since   Only retrieve records modified since the
-   * provided timestamp.
+   * @param  {Object}   [options={}]                    The options object.
+   * @param  {Object}   [options.headers]               The headers object option.
+   * @param  {Object}   [options.filters=[]]            The filters object.
+   * @param  {String}   [options.sort="-last_modified"] The sort field.
+   * @param  {String}   [options.limit=null]            The limit field.
+   * @param  {String}   [options.pages=1]               The number of result pages to aggregate.
+   * @param  {Number}   [options.since=null]            Only retrieve records modified since the provided timestamp.
    * @return {Promise<Object, Error>}
    */
   listRecords(options = {}) {
     const { http } = this.client;
     const { sort, filters, limit, pages, since } = _extends({
       sort: "-last_modified"
     }, options);
+    // Safety/Consistency check on ETag value.
+    if (since && typeof since !== "string") {
+      throw new Error(`Invalid value for since (${ since }), should be ETag value.`);
+    }
     const collHeaders = this.options.headers;
-    const path = (0, _endpoint2.default)("records", this.bucket.name, this.name);
+    const path = (0, _endpoint2.default)("record", this.bucket.name, this.name);
     const querystring = (0, _utils.qsify)(_extends({}, filters, {
       _sort: sort,
       _limit: limit,
       _since: since
     }));
     let results = [],
         current = 0;
 
@@ -1098,26 +1249,27 @@ let Collection = class Collection {
       return processNextPage(nextPage);
     };
 
     const processNextPage = nextPage => {
       return http.request(nextPage, { headers: collHeaders }).then(handleResponse);
     };
 
     const pageResults = (results, nextPage, etag) => {
+      // ETag string is supposed to be opaque and stored «as-is».
+      // ETag header values are quoted (because of * and W/"foo").
       return {
-        last_modified: etag,
+        last_modified: etag ? etag.replace(/"/g, "") : etag,
         data: results,
         next: next.bind(null, nextPage)
       };
     };
 
     const handleResponse = ({ headers, json }) => {
       const nextPage = headers.get("Next-Page");
-      // ETag are supposed to be opaque and stored «as-is».
       const etag = headers.get("ETag");
       if (!pages) {
         return pageResults(json.data, nextPage, etag);
       }
       // Aggregate new results with previous ones
       results = results.concat(json.data);
       current += 1;
       if (current >= pages || !nextPage) {
@@ -1131,26 +1283,27 @@ let Collection = class Collection {
     return this.client.execute(_extends({
       path: path + "?" + querystring
     }, this._collOptions(options)), { raw: true }).then(handleResponse);
   }
 
   /**
    * Performs batch operations at the current collection level.
    *
-   * @param  {Function} fn                 The batch operation function.
-   * @param  {Object}   options            The options object.
-   * @param  {Object}   options.headers    The headers object option.
-   * @param  {Boolean}  options.safe       The safe option.
-   * @param  {Boolean}  options.aggregate  Produces a grouped result object.
+   * @param  {Function} fn                   The batch operation function.
+   * @param  {Object}   [options={}]         The options object.
+   * @param  {Object}   [options.headers]    The headers object option.
+   * @param  {Boolean}  [options.safe]       The safe option.
+   * @param  {Boolean}  [options.aggregate]  Produces a grouped result object.
    * @return {Promise<Object, Error>}
    */
-  batch(fn, options) {
+  batch(fn, options = {}) {
     const reqOptions = this._collOptions(options);
     return this.client.batch(fn, _extends({}, reqOptions, {
+      bucket: this.bucket.name,
       collection: this.name
     }));
   }
 };
 exports.default = Collection;
 
 },{"./endpoint":6,"./requests":9,"./utils":10}],6:[function(require,module,exports){
 "use strict";
@@ -1161,22 +1314,20 @@ Object.defineProperty(exports, "__esModu
 exports.default = endpoint;
 /**
  * Endpoints templates.
  * @type {Object}
  */
 const ENDPOINTS = {
   root: () => "/",
   batch: () => "/batch",
-  buckets: () => "/buckets",
-  bucket: bucket => `/buckets/${ bucket }`,
-  collections: bucket => `${ ENDPOINTS.bucket(bucket) }/collections`,
-  collection: (bucket, coll) => `${ ENDPOINTS.bucket(bucket) }/collections/${ coll }`,
-  records: (bucket, coll) => `${ ENDPOINTS.collection(bucket, coll) }/records`,
-  record: (bucket, coll, id) => `${ ENDPOINTS.records(bucket, coll) }/${ id }`
+  bucket: bucket => "/buckets" + (bucket ? `/${ bucket }` : ""),
+  collection: (bucket, coll) => `${ ENDPOINTS.bucket(bucket) }/collections` + (coll ? `/${ coll }` : ""),
+  group: (bucket, group) => `${ ENDPOINTS.bucket(bucket) }/groups` + (group ? `/${ group }` : ""),
+  record: (bucket, coll, id) => `${ ENDPOINTS.collection(bucket, coll) }/records` + (id ? `/${ id }` : "")
 };
 
 /**
  * Retrieves a server enpoint by its name.
  *
  * @private
  * @param  {String}    name The endpoint name.
  * @param  {...string} args The endpoint parameters.
@@ -1203,17 +1354,17 @@ exports.default = {
   107: "Invalid request parameter",
   108: "Missing request parameter",
   109: "Invalid posted data",
   110: "Invalid Token / id",
   111: "Missing Token / id",
   112: "Content-Length header was not provided",
   113: "Request body too large",
   114: "Resource was modified meanwhile",
-  115: "Method not allowed on this end point",
+  115: "Method not allowed on this end point (hint: server may be readonly)",
   116: "Requested version not available on this server",
   117: "Client has sent too many requests",
   121: "Resource access is forbidden for this user",
   122: "Another resource violates constraint",
   201: "Service Temporary unavailable due to high load",
   202: "Service deprecated",
   999: "Internal Server Error"
 };
@@ -1256,63 +1407,57 @@ let HTTP = class HTTP {
    */
   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.
+   * @param {EventEmitter} events                       The event handler.
+   * @param {Object}       [options={}}                 The options object.
+   * @param {Number}       [options.timeout=5000]       The request timeout in ms (default: `5000`).
+   * @param {String}       [options.requestMode="cors"] The HTTP request mode (default: `"cors"`).
    */
   constructor(events, options = {}) {
     // public properties
     /**
      * The event emitter instance.
      * @type {EventEmitter}
      */
     if (!events) {
       throw new Error("No events handler provided");
     }
     this.events = events;
 
-    options = Object.assign({}, HTTP.defaultOptions, options);
-
     /**
      * The request mode.
      * @see  https://fetch.spec.whatwg.org/#requestmode
      * @type {String}
      */
-    this.requestMode = options.requestMode;
+    this.requestMode = options.requestMode || HTTP.defaultOptions.requestMode;
 
     /**
      * The request timeout.
      * @type {Number}
      */
-    this.timeout = options.timeout;
+    this.timeout = options.timeout || HTTP.defaultOptions.timeout;
   }
 
   /**
    * Performs an HTTP request to the Kinto server.
    *
-   * Options:
-   * - `{Object} headers` The request headers object (default: {})
-   *
    * Resolves with an objet containing the following HTTP response properties:
    * - `{Number}  status`  The HTTP status code.
    * - `{Object}  json`    The JSON response body.
    * - `{Headers} headers` The response headers object; see the ES6 fetch() spec.
    *
-   * @param  {String} url     The URL.
-   * @param  {Object} options The fetch() options object.
+   * @param  {String} url               The URL.
+   * @param  {Object} [options={}]      The fetch() options object.
+   * @param  {Object} [options.headers] The request headers object (default: {})
    * @return {Promise}
    */
   request(url, options = { headers: {} }) {
     let response, status, statusText, headers, 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) => {
@@ -1333,16 +1478,17 @@ let HTTP = class HTTP {
       });
     }).then(res => {
       response = res;
       headers = res.headers;
       status = res.status;
       statusText = res.statusText;
       this._checkForDeprecationHeader(headers);
       this._checkForBackoffHeader(status, headers);
+      this._checkForRetryAfterHeader(status, headers);
       return res.text();
     })
     // Check if we have a body; if so parse it as JSON.
     .then(text => {
       if (text.length === 0) {
         return null;
       }
       // Note: we can't consume the response body twice.
@@ -1394,302 +1540,138 @@ let HTTP = class HTTP {
     const backoffSeconds = parseInt(headers.get("Backoff"), 10);
     if (backoffSeconds > 0) {
       backoffMs = new Date().getTime() + backoffSeconds * 1000;
     } else {
       backoffMs = 0;
     }
     this.events.emit("backoff", backoffMs);
   }
+
+  _checkForRetryAfterHeader(status, headers) {
+    let retryAfter = headers.get("Retry-After");
+    if (!retryAfter) {
+      return;
+    }
+    retryAfter = new Date().getTime() + parseInt(retryAfter, 10) * 1000;
+    this.events.emit("retry-after", retryAfter);
+  }
 };
 exports.default = HTTP;
 
 },{"./errors":7}],9:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 
 var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
 
-exports.createBucket = createBucket;
-exports.updateBucket = updateBucket;
-exports.deleteBucket = deleteBucket;
-exports.deleteBuckets = deleteBuckets;
-exports.createCollection = createCollection;
-exports.updateCollection = updateCollection;
-exports.deleteCollection = deleteCollection;
-exports.createRecord = createRecord;
-exports.updateRecord = updateRecord;
-exports.deleteRecord = deleteRecord;
+exports.createRequest = createRequest;
+exports.updateRequest = updateRequest;
+exports.deleteRequest = deleteRequest;
 
-var _endpoint = require("./endpoint");
-
-var _endpoint2 = _interopRequireDefault(_endpoint);
-
-function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+var _utils = require("./utils");
 
 const requestDefaults = {
   safe: false,
   // check if we should set default content type here
   headers: {},
-  bucket: "default",
-  permissions: {},
-  data: {},
+  permissions: undefined,
+  data: undefined,
   patch: false
 };
 
+/**
+ * @private
+ */
 function safeHeader(safe, last_modified) {
   if (!safe) {
     return {};
   }
   if (last_modified) {
     return { "If-Match": `"${ last_modified }"` };
   }
   return { "If-None-Match": "*" };
 }
 
 /**
  * @private
  */
-function createBucket(bucketName, options = {}) {
-  if (!bucketName) {
-    throw new Error("A bucket name is required.");
-  }
-  // Note that we simply ignore any "bucket" option passed here, as the one
-  // we're interested in is the one provided as a required argument.
-  const { headers, permissions, safe } = _extends({}, requestDefaults, options);
+function createRequest(path, { data, permissions }, options = {}) {
+  const { headers, safe } = _extends({}, requestDefaults, options);
   return {
-    method: "PUT",
-    path: (0, _endpoint2.default)("bucket", bucketName),
+    method: data && data.id ? "PUT" : "POST",
+    path,
     headers: _extends({}, headers, safeHeader(safe)),
     body: {
-      // XXX We can't pass the data option just yet, see Kinto/kinto/issues/239
-      permissions
-    }
-  };
-}
-
-/**
- * @private
- */
-function updateBucket(bucket, options = {}) {
-  if (typeof bucket !== "object") {
-    throw new Error("A bucket object is required.");
-  }
-  if (!bucket.id) {
-    throw new Error("A bucket id is required.");
-  }
-  const { headers, permissions, safe, patch, last_modified } = _extends({}, requestDefaults, options);
-  return {
-    method: patch ? "PATCH" : "PUT",
-    path: (0, _endpoint2.default)("bucket", bucket.id),
-    headers: _extends({}, headers, safeHeader(safe, last_modified || bucket.last_modified)),
-    body: {
-      data: bucket,
+      data,
       permissions
     }
   };
 }
 
 /**
  * @private
  */
-function deleteBucket(bucket, options = {}) {
-  if (typeof bucket !== "object") {
-    throw new Error("A bucket object is required.");
-  }
-  if (!bucket.id) {
-    throw new Error("A bucket id is required.");
-  }
-  const { headers, safe, last_modified } = _extends({}, requestDefaults, {
-    last_modified: bucket.last_modified
-  }, options);
-  if (safe && !last_modified) {
-    throw new Error("Safe concurrency check requires a last_modified value.");
-  }
-  return {
-    method: "DELETE",
-    path: (0, _endpoint2.default)("bucket", bucket.id),
-    headers: _extends({}, headers, safeHeader(safe, last_modified))
-  };
-}
-
-/**
- * @private
- */
-function deleteBuckets(options = {}) {
-  const { headers, safe, last_modified } = _extends({}, requestDefaults, options);
-  if (safe && !last_modified) {
-    throw new Error("Safe concurrency check requires a last_modified value.");
-  }
-  return {
-    method: "DELETE",
-    path: (0, _endpoint2.default)("buckets"),
-    headers: _extends({}, headers, safeHeader(safe, last_modified))
-  };
-}
+function updateRequest(path, { data, permissions }, options = {}) {
+  const {
+    headers,
+    safe,
+    patch
+  } = _extends({}, requestDefaults, options);
+  const { last_modified } = _extends({}, data, options);
 
-/**
- * @private
- */
-function createCollection(id, options = {}) {
-  const { bucket, headers, permissions, data, safe } = _extends({}, requestDefaults, options);
-  // XXX checks that provided data can't override schema when provided
-  const path = id ? (0, _endpoint2.default)("collection", bucket, id) : (0, _endpoint2.default)("collections", bucket);
-  return {
-    method: id ? "PUT" : "POST",
-    path,
-    headers: _extends({}, headers, safeHeader(safe)),
-    body: { data, permissions }
-  };
-}
+  if (Object.keys((0, _utils.omit)(data, "id", "last_modified")).length === 0) {
+    data = undefined;
+  }
 
-/**
- * @private
- */
-function updateCollection(collection, options = {}) {
-  if (typeof collection !== "object") {
-    throw new Error("A collection object is required.");
-  }
-  if (!collection.id) {
-    throw new Error("A collection id is required.");
-  }
-  const {
-    bucket,
-    headers,
-    permissions,
-    schema,
-    metadata,
-    safe,
-    patch,
-    last_modified
-  } = _extends({}, requestDefaults, options);
-  const collectionData = _extends({}, metadata, collection);
-  if (options.schema) {
-    collectionData.schema = schema;
-  }
   return {
     method: patch ? "PATCH" : "PUT",
-    path: (0, _endpoint2.default)("collection", bucket, collection.id),
-    headers: _extends({}, headers, safeHeader(safe, last_modified || collection.last_modified)),
+    path,
+    headers: _extends({}, headers, safeHeader(safe, last_modified)),
     body: {
-      data: collectionData,
+      data,
       permissions
     }
   };
 }
 
 /**
  * @private
  */
-function deleteCollection(collection, options = {}) {
-  if (typeof collection !== "object") {
-    throw new Error("A collection object is required.");
-  }
-  if (!collection.id) {
-    throw new Error("A collection id is required.");
-  }
-  const { bucket, headers, safe, last_modified } = _extends({}, requestDefaults, {
-    last_modified: collection.last_modified
-  }, options);
+function deleteRequest(path, options = {}) {
+  const { headers, safe, last_modified } = _extends({}, requestDefaults, options);
   if (safe && !last_modified) {
     throw new Error("Safe concurrency check requires a last_modified value.");
   }
   return {
     method: "DELETE",
-    path: (0, _endpoint2.default)("collection", bucket, collection.id),
+    path,
     headers: _extends({}, headers, safeHeader(safe, last_modified))
   };
 }
 
-/**
- * @private
- */
-function createRecord(collName, record, options = {}) {
-  if (!collName) {
-    throw new Error("A collection name is required.");
-  }
-  const { bucket, headers, permissions, safe } = _extends({}, requestDefaults, options);
-  return {
-    // Note: Safe POST using a record id would fail.
-    // see https://github.com/Kinto/kinto/issues/489
-    method: record.id ? "PUT" : "POST",
-    path: record.id ? (0, _endpoint2.default)("record", bucket, collName, record.id) : (0, _endpoint2.default)("records", bucket, collName),
-    headers: _extends({}, headers, safeHeader(safe)),
-    body: {
-      data: record,
-      permissions
-    }
-  };
-}
-
-/**
- * @private
- */
-function updateRecord(collName, record, options = {}) {
-  if (!collName) {
-    throw new Error("A collection name is required.");
-  }
-  if (!record.id) {
-    throw new Error("A record id is required.");
-  }
-  const { bucket, headers, permissions, safe, patch, last_modified } = _extends({}, requestDefaults, options);
-  return {
-    method: patch ? "PATCH" : "PUT",
-    path: (0, _endpoint2.default)("record", bucket, collName, record.id),
-    headers: _extends({}, headers, safeHeader(safe, last_modified || record.last_modified)),
-    body: {
-      data: record,
-      permissions
-    }
-  };
-}
-
-/**
- * @private
- */
-function deleteRecord(collName, record, options = {}) {
-  if (!collName) {
-    throw new Error("A collection name is required.");
-  }
-  if (typeof record !== "object") {
-    throw new Error("A record object is required.");
-  }
-  if (!record.id) {
-    throw new Error("A record id is required.");
-  }
-  const { bucket, headers, safe, last_modified } = _extends({}, requestDefaults, {
-    last_modified: record.last_modified
-  }, options);
-  if (safe && !last_modified) {
-    throw new Error("Safe concurrency check requires a last_modified value.");
-  }
-  return {
-    method: "DELETE",
-    path: (0, _endpoint2.default)("record", bucket, collName, record.id),
-    headers: _extends({}, headers, safeHeader(safe, last_modified))
-  };
-}
-
-},{"./endpoint":6}],10:[function(require,module,exports){
+},{"./utils":10}],10:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 exports.partition = partition;
 exports.pMap = pMap;
 exports.omit = omit;
 exports.toDataBody = toDataBody;
 exports.qsify = qsify;
 exports.checkVersion = checkVersion;
 exports.support = support;
+exports.capable = capable;
 exports.nobatch = nobatch;
+exports.isObject = isObject;
 /**
  * Chunks an array into n pieces.
  *
  * @private
  * @param  {Array}  array
  * @param  {Number} n
  * @return {Array}
  */
@@ -1742,27 +1724,27 @@ function omit(obj, ...keys) {
     return acc;
   }, {});
 }
 
 /**
  * Always returns a resource data object from the provided argument.
  *
  * @private
- * @param  {Object|String} value
+ * @param  {Object|String} resource
  * @return {Object}
  */
-function toDataBody(value) {
-  if (typeof value === "object") {
-    return value;
+function toDataBody(resource) {
+  if (isObject(resource)) {
+    return resource;
   }
-  if (typeof value === "string") {
-    return { id: value };
+  if (typeof resource === "string") {
+    return { id: resource };
   }
-  throw new Error("Invalid collection argument.");
+  throw new Error("Invalid argument.");
 }
 
 /**
  * Transforms an object into an URL query string, stripping out any undefined
  * values.
  *
  * @param  {Object} obj
  * @return {String}
@@ -1827,16 +1809,50 @@ function support(min, max) {
         });
         return wrappedMethod;
       }
     };
   };
 }
 
 /**
+ * Generates a decorator function ensuring that the specified capabilities are
+ * available on the server before executing it.
+ *
+ * @param  {Array<String>} capabilities The required capabilities.
+ * @return {Function}
+ */
+function capable(capabilities) {
+  return function (target, key, descriptor) {
+    const fn = descriptor.value;
+    return {
+      configurable: true,
+      get() {
+        const wrappedMethod = (...args) => {
+          // "this" is the current instance which its method is decorated.
+          const client = "client" in this ? this.client : this;
+          return client.fetchServerCapabilities().then(available => {
+            const missing = capabilities.filter(c => available.indexOf(c) < 0);
+            if (missing.length > 0) {
+              throw new Error(`Required capabilities ${ missing.join(", ") } ` + "not present on server");
+            }
+          }).then(Promise.resolve(fn.apply(this, args)));
+        };
+        Object.defineProperty(this, key, {
+          value: wrappedMethod,
+          configurable: true,
+          writable: true
+        });
+        return wrappedMethod;
+      }
+    };
+  };
+}
+
+/**
  * Generates a decorator function ensuring an operation is not performed from
  * within a batch request.
  *
  * @param  {String} message The error message to throw.
  * @return {Function}
  */
 function nobatch(message) {
   return function (target, key, descriptor) {
@@ -1857,10 +1873,19 @@ function nobatch(message) {
           writable: true
         });
         return wrappedMethod;
       }
     };
   };
 }
 
+/**
+ * Returns true if the specified value is an object (i.e. not an array nor null).
+ * @param  {Object} thing The value to inspect.
+ * @return {bool}
+ */
+function isObject(thing) {
+  return typeof thing === "object" && thing !== null && !Array.isArray(thing);
+}
+
 },{}]},{},[1])(1)
 });
\ No newline at end of file
--- a/services/common/kinto-offline-client.js
+++ b/services/common/kinto-offline-client.js
@@ -15,17 +15,17 @@
 
 /*
  * This file is generated from kinto.js - do not modify directly.
  */
 
 this.EXPORTED_SYMBOLS = ["loadKinto"];
 
 /*
- * Version 2.0.3 - 0faf45b
+ * Version 3.1.2 - 7fe074d
  */
 
 (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){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
@@ -47,16 +47,17 @@ function _interopRequireDefault(obj) { r
  *
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS,
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
 
+
 Components.utils.import("resource://gre/modules/Sqlite.jsm");
 Components.utils.import("resource://gre/modules/Task.jsm");
 
 const SQLITE_PATH = "kinto.sqlite";
 
 const statements = {
   "createCollectionData": `
     CREATE TABLE collection_data (
@@ -79,20 +80,18 @@ const statements = {
     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;`,
+    INSERT OR REPLACE INTO collection_data (collection_name, record_id, record)
+      VALUES (:collection_name, :record_id, :record);`,
 
   "deleteData": `
     DELETE FROM collection_data
       WHERE collection_name = :collection_name
       AND record_id = :record_id;`,
 
   "saveLastModified": `
     REPLACE INTO collection_metadata (collection_name, last_modified)
@@ -109,30 +108,48 @@ const statements = {
         WHERE collection_name = :collection_name
         AND record_id = :record_id;`,
 
   "listRecords": `
     SELECT record
       FROM collection_data
         WHERE collection_name = :collection_name;`,
 
+  // N.B. we have to have a dynamic number of placeholders, which you
+  // can't do without building your own statement. See `execute` for details
+  "listRecordsById": `
+    SELECT record_id, record
+      FROM collection_data
+        WHERE collection_name = ?
+          AND record_id IN `,
+
   "importData": `
     REPLACE INTO collection_data (collection_name, record_id, record)
       VALUES (:collection_name, :record_id, :record);`
 
 };
 
 const createStatements = ["createCollectionData", "createCollectionMetadata", "createCollectionDataRecordIdIndex"];
 
 const currentSchemaVersion = 1;
 
+/**
+ * Firefox adapter.
+ *
+ * Uses Sqlite as a backing store.
+ *
+ * Options:
+ *  - path: the filename/path for the Sqlite database. If absent, use SQLITE_PATH.
+ */
 class FirefoxAdapter extends _base2.default {
-  constructor(collection) {
+  constructor(collection, options = {}) {
     super();
     this.collection = collection;
+    this._connection = null;
+    this._options = options;
   }
 
   _init(connection) {
     return Task.spawn(function* () {
       yield connection.executeTransaction(function* doSetup() {
         const schema = yield connection.getSchemaVersion();
 
         if (schema == 0) {
@@ -155,17 +172,18 @@ class FirefoxAdapter extends _base2.defa
       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: SQLITE_PATH, sharedMemoryCache: false };
+      const path = self._options.path || SQLITE_PATH;
+      const opts = { path, sharedMemoryCache: false };
       if (!self._connection) {
         self._connection = yield Sqlite.openConnection(opts).then(self._init);
       }
     });
   }
 
   close() {
     if (this._connection) {
@@ -180,34 +198,41 @@ class FirefoxAdapter extends _base2.defa
     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 {
+    const conn = this._connection;
+    const collection = this.collection;
+
+    return conn.executeTransaction(function* doExecuteTransaction() {
+      // Preload specified records from DB, within transaction.
+      const parameters = [collection, ...options.preload];
+      const placeholders = options.preload.map(_ => "?");
+      const stmt = statements.listRecordsById + "(" + placeholders.join(",") + ");";
+      const rows = yield conn.execute(stmt, parameters);
+
+      const preloaded = rows.reduce((acc, row) => {
+        const record = JSON.parse(row.getResultByName("record"));
+        acc[row.getResultByName("record_id")] = record;
+        return acc;
+      }, {});
+
+      const proxy = transactionProxy(collection, preloaded);
       result = callback(proxy);
-    } catch (e) {
-      return Promise.reject(e);
-    }
-    const conn = this._connection;
-    return conn.executeTransaction(function* doExecuteTransaction() {
+
       for (let { statement, params } of proxy.operations) {
         yield conn.executeCached(statement, params);
       }
-    }).then(_ => result);
+    }, conn.TRANSACTION_EXCLUSIVE).then(_ => result);
   }
 
   get(id) {
     const params = {
       collection_name: this.collection,
       record_id: id
     };
     return this._executeStatement(statements.getRecord, params).then(result => {
@@ -259,17 +284,17 @@ class FirefoxAdapter extends _base2.defa
           };
           yield connection.execute(statements.importData, params);
         }
         const lastModified = Math.max(...records.map(record => record.last_modified));
         const params = {
           collection_name: collection_name
         };
         const previousLastModified = yield connection.execute(statements.getLastModified, params).then(result => {
-          return result.length > 0 ? result[0].getResultByName('last_modified') : -1;
+          return result.length > 0 ? result[0].getResultByName("last_modified") : -1;
         });
         if (lastModified > previousLastModified) {
           const params = {
             collection_name: collection_name,
             last_modified: lastModified
           };
           yield connection.execute(statements.saveLastModified, params);
         }
@@ -343,17 +368,17 @@ function transactionProxy(collection, pr
 
     get(id) {
       // Gecko JS engine outputs undesired warnings if id is not in preloaded.
       return id in preloaded ? preloaded[id] : undefined;
     }
   };
 }
 
-},{"../src/adapters/base":5,"../src/utils":7}],2:[function(require,module,exports){
+},{"../src/adapters/base":6,"../src/utils":8}],2:[function(require,module,exports){
 /*
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
  *
  *     http://www.apache.org/licenses/LICENSE-2.0
  *
@@ -364,16 +389,19 @@ function transactionProxy(collection, pr
  * limitations under the License.
  */
 
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
+
+var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
+
 exports.default = loadKinto;
 
 var _base = require("../src/adapters/base");
 
 var _base2 = _interopRequireDefault(_base);
 
 var _KintoBase = require("../src/KintoBase");
 
@@ -388,17 +416,17 @@ var _utils = require("../src/utils");
 function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
 
 const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
 
 function loadKinto() {
   const { EventEmitter } = Cu.import("resource://devtools/shared/event-emitter.js", {});
   const { generateUUID } = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
 
-  // Use standalone kinto-client module landed in FFx.
+  // Use standalone kinto-http module landed in FFx.
   const { KintoHttpClient } = Cu.import("resource://services-common/kinto-http-client.js");
 
   Cu.import("resource://gre/modules/Timer.jsm");
   Cu.importGlobalProperties(['fetch']);
 
   // Leverage Gecko service to generate UUIDs.
   function makeIDSchema() {
     return {
@@ -418,48 +446,51 @@ function loadKinto() {
     }
 
     constructor(options = {}) {
       const emitter = {};
       EventEmitter.decorate(emitter);
 
       const defaults = {
         events: emitter,
-        ApiClass: KintoHttpClient
+        ApiClass: KintoHttpClient,
+        adapter: _FirefoxStorage2.default
       };
 
-      const expandedOptions = Object.assign(defaults, options);
+      const expandedOptions = _extends({}, defaults, options);
       super(expandedOptions);
     }
 
     collection(collName, options = {}) {
       const idSchema = makeIDSchema();
-      const expandedOptions = Object.assign({ idSchema }, options);
+      const expandedOptions = _extends({ idSchema }, options);
       return super.collection(collName, expandedOptions);
     }
   }
 
   return KintoFX;
 }
 
 // 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":4,"../src/adapters/base":5,"../src/utils":7,"./FirefoxStorage":1}],3:[function(require,module,exports){
+},{"../src/KintoBase":4,"../src/adapters/base":6,"../src/utils":8,"./FirefoxStorage":1}],3:[function(require,module,exports){
 
 },{}],4:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 
+var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
+
 var _collection = require("./collection");
 
 var _collection2 = _interopRequireDefault(_collection);
 
 var _base = require("./adapters/base");
 
 var _base2 = _interopRequireDefault(_base);
 
@@ -496,40 +527,47 @@ class KintoBase {
   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.
+   * - `{String}`       `remote`         The server URL to use.
+   * - `{String}`       `bucket`         The collection bucket name.
+   * - `{EventEmitter}` `events`         Events handler.
+   * - `{BaseAdapter}`  `adapter`        The base DB adapter class.
+   * - `{Object}`       `adapterOptions` Options given to the adapter.
+   * - `{String}`       `dbPrefix`       The DB name prefix.
+   * - `{Object}`       `headers`        The HTTP headers to use.
+   * - `{String}`       `requestMode`    The HTTP CORS mode to use.
+   * - `{Number}`       `timeout`        The requests timeout in ms (default: `5000`).
    *
    * @param  {Object} options The options object.
    */
   constructor(options = {}) {
     const defaults = {
       bucket: DEFAULT_BUCKET_NAME,
       remote: DEFAULT_REMOTE
     };
-    this._options = Object.assign(defaults, options);
+    this._options = _extends({}, defaults, options);
     if (!this._options.adapter) {
       throw new Error("No adapter provided");
     }
 
-    const { remote, events, headers, requestMode, ApiClass } = this._options;
-    this._api = new ApiClass(remote, { events, headers, requestMode });
+    const { remote, events, headers, requestMode, timeout, ApiClass } = this._options;
 
     // public properties
+
+    /**
+     * The kinto HTTP client instance.
+     * @type {KintoClient}
+     */
+    this.api = new ApiClass(remote, { events, headers, requestMode, timeout });
     /**
      * The event emitter instance.
      * @type {EventEmitter}
      */
     this.events = this._options.events;
   }
 
   /**
@@ -542,29 +580,474 @@ class KintoBase {
    * @return {Collection}
    */
   collection(collName, options = {}) {
     if (!collName) {
       throw new Error("missing collection name");
     }
 
     const bucket = this._options.bucket;
-    return new _collection2.default(bucket, collName, this._api, {
+    return new _collection2.default(bucket, collName, this.api, {
       events: this._options.events,
       adapter: this._options.adapter,
+      adapterOptions: this._options.adapterOptions,
       dbPrefix: this._options.dbPrefix,
       idSchema: options.idSchema,
       remoteTransformers: options.remoteTransformers,
       hooks: options.hooks
     });
   }
 }
 exports.default = KintoBase;
 
-},{"./adapters/base":5,"./collection":6}],5:[function(require,module,exports){
+},{"./adapters/base":6,"./collection":7}],5:[function(require,module,exports){
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+  value: true
+});
+
+var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
+
+var _base = require("./base.js");
+
+var _base2 = _interopRequireDefault(_base);
+
+var _utils = require("../utils");
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+const INDEXED_FIELDS = ["id", "_status", "last_modified"];
+
+/**
+ * IDB cursor handlers.
+ * @type {Object}
+ */
+const cursorHandlers = {
+  all(done) {
+    const results = [];
+    return function (event) {
+      const cursor = event.target.result;
+      if (cursor) {
+        results.push(cursor.value);
+        cursor.continue();
+      } else {
+        done(results);
+      }
+    };
+  },
+
+  in(values, done) {
+    const sortedValues = [].slice.call(values).sort();
+    const results = [];
+    return function (event) {
+      const cursor = event.target.result;
+      if (!cursor) {
+        done(results);
+        return;
+      }
+      const { key, value } = cursor;
+      let i = 0;
+      while (key > sortedValues[i]) {
+        // The cursor has passed beyond this key. Check next.
+        ++i;
+        if (i === sortedValues.length) {
+          done(results); // There is no next. Stop searching.
+          return;
+        }
+      }
+      if (key === sortedValues[i]) {
+        results.push(value);
+        cursor.continue();
+      } else {
+        cursor.continue(sortedValues[i]);
+      }
+    };
+  }
+};
+
+/**
+ * Extract from filters definition the first indexed field. Since indexes were
+ * created on single-columns, extracting a single one makes sense.
+ *
+ * @param  {Object} filters The filters object.
+ * @return {String|undefined}
+ */
+function findIndexedField(filters) {
+  const filteredFields = Object.keys(filters);
+  const indexedFields = filteredFields.filter(field => {
+    return INDEXED_FIELDS.indexOf(field) !== -1;
+  });
+  return indexedFields[0];
+}
+
+/**
+ * Creates an IDB request and attach it the appropriate cursor event handler to
+ * perform a list query.
+ *
+ * Multiple matching values are handled by passing an array.
+ *
+ * @param  {IDBStore}         store      The IDB store.
+ * @param  {String|undefined} indexField The indexed field to query, if any.
+ * @param  {Any}              value      The value to filter, if any.
+ * @param  {Function}         done       The operation completion handler.
+ * @return {IDBRequest}
+ */
+function createListRequest(store, indexField, value, done) {
+  if (!indexField) {
+    // Get all records.
+    const request = store.openCursor();
+    request.onsuccess = cursorHandlers.all(done);
+    return request;
+  }
+
+  // WHERE IN equivalent clause
+  if (Array.isArray(value)) {
+    const request = store.index(indexField).openCursor();
+    request.onsuccess = cursorHandlers.in(value, done);
+    return request;
+  }
+
+  // WHERE field = value clause
+  const request = store.index(indexField).openCursor(IDBKeyRange.only(value));
+  request.onsuccess = cursorHandlers.all(done);
+  return request;
+}
+
+/**
+ * IndexedDB adapter.
+ *
+ * This adapter doesn't support any options.
+ */
+class IDB extends _base2.default {
+  /**
+   * Constructor.
+   *
+   * @param  {String} dbname The database nale.
+   */
+  constructor(dbname) {
+    super();
+    this._db = null;
+    // public properties
+    /**
+     * The database name.
+     * @type {String}
+     */
+    this.dbname = dbname;
+  }
+
+  _handleError(method) {
+    return err => {
+      const error = new Error(method + "() " + err.message);
+      error.stack = err.stack;
+      throw error;
+    };
+  }
+
+  /**
+   * Ensures a connection to the IndexedDB database has been opened.
+   *
+   * @override
+   * @return {Promise}
+   */
+  open() {
+    if (this._db) {
+      return Promise.resolve(this);
+    }
+    return new Promise((resolve, reject) => {
+      const request = indexedDB.open(this.dbname, 1);
+      request.onupgradeneeded = event => {
+        // DB object
+        const db = event.target.result;
+        // Main collection store
+        const collStore = db.createObjectStore(this.dbname, {
+          keyPath: "id"
+        });
+        // Primary key (generated by IdSchema, UUID by default)
+        collStore.createIndex("id", "id", { unique: true });
+        // Local record status ("synced", "created", "updated", "deleted")
+        collStore.createIndex("_status", "_status");
+        // Last modified field
+        collStore.createIndex("last_modified", "last_modified");
+
+        // Metadata store
+        const metaStore = db.createObjectStore("__meta__", {
+          keyPath: "name"
+        });
+        metaStore.createIndex("name", "name", { unique: true });
+      };
+      request.onerror = event => reject(event.target.error);
+      request.onsuccess = event => {
+        this._db = event.target.result;
+        resolve(this);
+      };
+    });
+  }
+
+  /**
+   * Closes current connection to the database.
+   *
+   * @override
+   * @return {Promise}
+   */
+  close() {
+    if (this._db) {
+      this._db.close(); // indexedDB.close is synchronous
+      this._db = null;
+    }
+    return super.close();
+  }
+
+  /**
+   * Returns a transaction and a store objects for this collection.
+   *
+   * To determine if a transaction has completed successfully, we should rather
+   * listen to the transaction’s complete event rather than the IDBObjectStore
+   * request’s success event, because the transaction may still fail after the
+   * success event fires.
+   *
+   * @param  {String}      mode  Transaction mode ("readwrite" or undefined)
+   * @param  {String|null} name  Store name (defaults to coll name)
+   * @return {Object}
+   */
+  prepare(mode = undefined, name = null) {
+    const storeName = name || this.dbname;
+    // On Safari, calling IDBDatabase.transaction with mode == undefined raises
+    // a TypeError.
+    const transaction = mode ? this._db.transaction([storeName], mode) : this._db.transaction([storeName]);
+    const store = transaction.objectStore(storeName);
+    return { transaction, store };
+  }
+
+  /**
+   * Deletes every records in the current collection.
+   *
+   * @override
+   * @return {Promise}
+   */
+  clear() {
+    return this.open().then(() => {
+      return new Promise((resolve, reject) => {
+        const { transaction, store } = this.prepare("readwrite");
+        store.clear();
+        transaction.onerror = event => reject(new Error(event.target.error));
+        transaction.oncomplete = () => resolve();
+      });
+    }).catch(this._handleError("clear"));
+  }
+
+  /**
+   * Executes the set of synchronous CRUD operations described in the provided
+   * callback within an IndexedDB transaction, for current db store.
+   *
+   * The callback will be provided an object exposing the following synchronous
+   * CRUD operation methods: get, create, update, delete.
+   *
+   * Important note: because limitations in IndexedDB implementations, no
+   * asynchronous code should be performed within the provided callback; the
+   * promise will therefore be rejected if the callback returns a Promise.
+   *
+   * Options:
+   * - {Array} preload: The list of record IDs to fetch and make available to
+   *   the transaction object get() method (default: [])
+   *
+   * @example
+   * const db = new IDB("example");
+   * db.execute(transaction => {
+   *   transaction.create({id: 1, title: "foo"});
+   *   transaction.update({id: 2, title: "bar"});
+   *   transaction.delete(3);
+   *   return "foo";
+   * })
+   *   .catch(console.error.bind(console));
+   *   .then(console.log.bind(console)); // => "foo"
+   *
+   * @param  {Function} callback The operation description callback.
+   * @param  {Object}   options  The options object.
+   * @return {Promise}
+   */
+  execute(callback, options = { preload: [] }) {
+    // Transactions in IndexedDB are autocommited when a callback does not
+    // perform any additional operation.
+    // The way Promises are implemented in Firefox (see https://bugzilla.mozilla.org/show_bug.cgi?id=1193394)
+    // prevents using within an opened transaction.
+    // To avoid managing asynchronocity in the specified `callback`, we preload
+    // a list of record in order to execute the `callback` synchronously.
+    // See also:
+    // - http://stackoverflow.com/a/28388805/330911
+    // - http://stackoverflow.com/a/10405196
+    // - https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
+    return this.open().then(_ => new Promise((resolve, reject) => {
+      // Start transaction.
+      const { transaction, store } = this.prepare("readwrite");
+      // Preload specified records using index.
+      const ids = options.preload;
+      store.index("id").openCursor().onsuccess = cursorHandlers.in(ids, records => {
+        // Store obtained records by id.
+        const preloaded = records.reduce((acc, record) => {
+          acc[record.id] = record;
+          return acc;
+        }, {});
+        // Expose a consistent API for every adapter instead of raw store methods.
+        const proxy = transactionProxy(store, preloaded);
+        // The callback is executed synchronously within the same transaction.
+        let result;
+        try {
+          result = callback(proxy);
+        } catch (e) {
+          transaction.abort();
+          reject(e);
+        }
+        if (result instanceof Promise) {
+          // XXX: investigate how to provide documentation details in error.
+          reject(new Error("execute() callback should not return a Promise."));
+        }
+        // XXX unsure if we should manually abort the transaction on error
+        transaction.onerror = event => reject(new Error(event.target.error));
+        transaction.oncomplete = event => resolve(result);
+      });
+    }));
+  }
+
+  /**
+   * Retrieve a record by its primary key from the IndexedDB database.
+   *
+   * @override
+   * @param  {String} id The record id.
+   * @return {Promise}
+   */
+  get(id) {
+    return this.open().then(() => {
+      return new Promise((resolve, reject) => {
+        const { transaction, store } = this.prepare();
+        const request = store.get(id);
+        transaction.onerror = event => reject(new Error(event.target.error));
+        transaction.oncomplete = () => resolve(request.result);
+      });
+    }).catch(this._handleError("get"));
+  }
+
+  /**
+   * Lists all records from the IndexedDB database.
+   *
+   * @override
+   * @return {Promise}
+   */
+  list(params = { filters: {} }) {
+    const { filters } = params;
+    const indexField = findIndexedField(filters);
+    const value = filters[indexField];
+    return this.open().then(() => {
+      return new Promise((resolve, reject) => {
+        let results = [];
+        const { transaction, store } = this.prepare();
+        createListRequest(store, indexField, value, _results => {
+          // we have received all requested records, parking them within
+          // current scope
+          results = _results;
+        });
+        transaction.onerror = event => reject(new Error(event.target.error));
+        transaction.oncomplete = event => resolve(results);
+      });
+    }).then(results => {
+      // The resulting list of records is filtered and sorted.
+      const remainingFilters = _extends({}, filters);
+      // If `indexField` was used already, don't filter again.
+      delete remainingFilters[indexField];
+      // XXX: with some efforts, this could be fully implemented using IDB API.
+      return (0, _utils.reduceRecords)(remainingFilters, params.order, results);
+    }).catch(this._handleError("list"));
+  }
+
+  /**
+   * Store the lastModified value into metadata store.
+   *
+   * @override
+   * @param  {Number}  lastModified
+   * @return {Promise}
+   */
+  saveLastModified(lastModified) {
+    const value = parseInt(lastModified, 10) || null;
+    return this.open().then(() => {
+      return new Promise((resolve, reject) => {
+        const { transaction, store } = this.prepare("readwrite", "__meta__");
+        store.put({ name: "lastModified", value: value });
+        transaction.onerror = event => reject(event.target.error);
+        transaction.oncomplete = event => resolve(value);
+      });
+    });
+  }
+
+  /**
+   * Retrieve saved lastModified value.
+   *
+   * @override
+   * @return {Promise}
+   */
+  getLastModified() {
+    return this.open().then(() => {
+      return new Promise((resolve, reject) => {
+        const { transaction, store } = this.prepare(undefined, "__meta__");
+        const request = store.get("lastModified");
+        transaction.onerror = event => reject(event.target.error);
+        transaction.oncomplete = event => {
+          resolve(request.result && request.result.value || null);
+        };
+      });
+    });
+  }
+
+  /**
+   * Load a dump of records exported from a server.
+   *
+   * @abstract
+   * @return {Promise}
+   */
+  loadDump(records) {
+    return this.execute(transaction => {
+      records.forEach(record => transaction.update(record));
+    }).then(() => this.getLastModified()).then(previousLastModified => {
+      const lastModified = Math.max(...records.map(record => record.last_modified));
+      if (lastModified > previousLastModified) {
+        return this.saveLastModified(lastModified);
+      }
+    }).then(() => records).catch(this._handleError("loadDump"));
+  }
+}
+
+exports.default = IDB; /**
+                        * IDB transaction proxy.
+                        *
+                        * @param  {IDBStore} store     The IndexedDB database store.
+                        * @param  {Array}    preloaded The list of records to make available to
+                        *                              get() (default: []).
+                        * @return {Object}
+                        */
+
+function transactionProxy(store, preloaded = []) {
+  return {
+    create(record) {
+      store.add(record);
+    },
+
+    update(record) {
+      store.put(record);
+    },
+
+    delete(id) {
+      store.delete(id);
+    },
+
+    get(id) {
+      return preloaded[id];
+    }
+  };
+}
+
+},{"../utils":8,"./base.js":6}],6:[function(require,module,exports){
 "use strict";
 
 /**
  * Base db adapter.
  *
  * @abstract
  */
 
@@ -664,52 +1147,56 @@ class BaseAdapter {
    * @return {Promise}
    */
   loadDump(records) {
     throw new Error("Not Implemented.");
   }
 }
 exports.default = BaseAdapter;
 
-},{}],6:[function(require,module,exports){
+},{}],7:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
-exports.SyncResultObject = undefined;
-exports.cleanRecord = cleanRecord;
+exports.CollectionTransaction = exports.SyncResultObject = undefined;
+
+var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
+
+exports.recordsEqual = recordsEqual;
 
 var _base = require("./adapters/base");
 
 var _base2 = _interopRequireDefault(_base);
 
+var _IDB = require("./adapters/IDB");
+
+var _IDB2 = _interopRequireDefault(_IDB);
+
 var _utils = require("./utils");
 
 var _uuid = require("uuid");
 
 function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
 
-const RECORD_FIELDS_TO_CLEAN = ["_status", "last_modified"];
+const RECORD_FIELDS_TO_CLEAN = ["_status"];
 const AVAILABLE_HOOKS = ["incoming-changes"];
 
 /**
- * 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.
+ * Compare two records omitting local fields and synchronization
+ * attributes (like _status and last_modified)
+ * @param {Object} a    A record to compare.
+ * @param {Object} b    A record to compare.
+ * @return {boolean}
  */
-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;
-  }, {});
+function recordsEqual(a, b, localFields = []) {
+  const fieldsToClean = RECORD_FIELDS_TO_CLEAN.concat(["last_modified"]).concat(localFields);
+  const cleanLocal = r => (0, _utils.omitKeys)(r, fieldsToClean);
+  return (0, _utils.deepEqual)(cleanLocal(a), cleanLocal(b));
 }
 
 /**
  * Synchronization result object.
  */
 class SyncResultObject {
   /**
    * Object default values.
@@ -781,74 +1268,89 @@ function createUUIDSchema() {
 
     validate(id) {
       return (0, _utils.isUUID)(id);
     }
   };
 }
 
 function markStatus(record, status) {
-  return Object.assign({}, record, { _status: status });
+  return _extends({}, 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.
+ * @param  {Array<String>}       localFields The list of fields that remain local.
  * @return {Object}
  */
-function importChange(transaction, remote) {
+function importChange(transaction, remote, localFields) {
   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.deepEqual)(cleanRecord(local), cleanRecord(remote));
+  // Compare local and remote, ignoring local fields.
+  const isIdentical = recordsEqual(local, remote, localFields);
+  // Apply remote changes on local record.
+  const synced = _extends({}, local, markSynced(remote));
+  // Detect or ignore conflicts if record has also been modified locally.
   if (local._status !== "synced") {
     // Locally deleted, unsynced: scheduled for remote deletion.
     if (local._status === "deleted") {
       return { type: "skipped", data: local };
     }
-    if (identical) {
+    if (isIdentical) {
       // 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, previous: local };
+      return { type: "updated", data: { old: local, new: synced } };
+    }
+    if (local.last_modified !== undefined && local.last_modified === remote.last_modified) {
+      // If our local version has the same last_modified as the remote
+      // one, this represents an object that corresponds to a resolved
+      // conflict. Our local version represents the final output, so
+      // we keep that one. (No transaction operation to do.)
+      // But if our last_modified is undefined,
+      // that means we've created the same object locally as one on
+      // the server, which *must* be a conflict.
+      return { type: "void" };
     }
     return {
       type: "conflicts",
       data: { type: "incoming", local: local, remote: remote }
     };
   }
+  // Local record was synced.
   if (remote.deleted) {
     transaction.delete(remote.id);
-    return { type: "deleted", data: { id: local.id } };
+    return { type: "deleted", data: local };
   }
-  const synced = markSynced(remote);
+  // Import locally.
   transaction.update(synced);
-  // if identical, simply exclude it from all lists
-  const type = identical ? "void" : "updated";
-  return { type, data: synced };
+  // if identical, simply exclude it from all SyncResultObject lists
+  const type = isIdentical ? "void" : "updated";
+  return { type, data: { old: local, new: synced } };
 }
 
 /**
  * Abstracts a collection of records stored in the local database, providing
  * CRUD operations and synchronization helpers.
  */
 class Collection {
   /**
@@ -863,37 +1365,36 @@ class Collection {
    * @param  {Api}    api     The Api instance.
    * @param  {Object} options The options object.
    */
   constructor(bucket, name, api, options = {}) {
     this._bucket = bucket;
     this._name = name;
     this._lastModified = null;
 
-    const DBAdapter = options.adapter;
+    const DBAdapter = options.adapter || _IDB2.default;
     if (!DBAdapter) {
       throw new Error("No adapter provided");
     }
     const dbPrefix = options.dbPrefix || "";
-    const db = new DBAdapter(`${ dbPrefix }${ bucket }/${ name }`);
+    const db = new DBAdapter(`${ dbPrefix }${ bucket }/${ name }`, options.adapterOptions);
     if (!(db instanceof _base2.default)) {
       throw new Error("Unsupported adapter.");
     }
     // public properties
     /**
      * The db adapter instance
      * @type {BaseAdapter}
      */
     this.db = db;
     /**
      * The Api instance.
      * @type {KintoClient}
      */
     this.api = api;
-    this._apiCollection = this.api.bucket(this.bucket).collection(this.name);
     /**
      * The event emitter instance.
      * @type {EventEmitter}
      */
     this.events = options.events;
     /**
      * The IdSchema instance.
      * @type {Object}
@@ -904,16 +1405,21 @@ class Collection {
      * @type {Array}
      */
     this.remoteTransformers = this._validateRemoteTransformers(options.remoteTransformers);
     /**
      * The list of hooks.
      * @type {Object}
      */
     this.hooks = this._validateHooks(options.hooks);
+    /**
+     * The list of fields names that will remain local.
+     * @type {Array}
+     */
+    this.localFields = options.localFields || [];
   }
 
   /**
    * The collection name.
    * @type {String}
    */
   get name() {
     return this._name;
@@ -1081,17 +1587,18 @@ class Collection {
       return Promise.resolve(record);
     }
     return (0, _utils.waterfall)(this[`${ type }Transformers`].reverse().map(transformer => {
       return record => transformer.decode(record);
     }), record);
   }
 
   /**
-   * Adds a record to the local database.
+   * Adds a record to the local database, asserting that none
+   * already exist with this ID.
    *
    * 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`).
@@ -1099,130 +1606,146 @@ class Collection {
    *                          instead of one that is generated automatically
    *                          (default: `false`).
    *
    * @param  {Object} record
    * @param  {Object} options
    * @return {Promise}
    */
   create(record, options = { useRecordId: false, synced: false }) {
+    // Validate the record and its ID (if any), even though this
+    // validation is also done in the CollectionTransaction method,
+    // because we need to pass the ID to preloadIds.
     const 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) {
+    if ((options.synced || options.useRecordId) && !record.hasOwnProperty("id")) {
       return reject("Missing required Id; synced and useRecordId options require one");
     }
-    if (!options.synced && !options.useRecordId && record.id) {
+    if (!options.synced && !options.useRecordId && record.hasOwnProperty("id")) {
       return reject("Extraneous Id; can't create a record having one set.");
     }
-    const newRecord = Object.assign({}, record, {
+    const newRecord = _extends({}, 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.execute(transaction => {
-      transaction.create(newRecord);
-      return { data: newRecord, permissions: {} };
-    }).catch(err => {
+    return this.execute(txn => txn.create(newRecord), { preloadIds: [newRecord.id] }).catch(err => {
       if (options.useRecordId) {
         throw new Error("Couldn't create record. It may have been virtually deleted.");
       }
       throw err;
     });
   }
 
   /**
-   * Updates a record from the local database.
+   * Like {@link CollectionTransaction#update}, but wrapped in its own transaction.
    *
    * 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 }) {
+    // Validate the record and its ID, even though this validation is
+    // also done in the CollectionTransaction method, because we need
+    // to pass the ID to preloadIds.
     if (typeof record !== "object") {
       return Promise.reject(new Error("Record is not an object."));
     }
-    if (!record.id) {
+    if (!record.hasOwnProperty("id")) {
       return Promise.reject(new Error("Cannot update a record missing id."));
     }
     if (!this.idSchema.validate(record.id)) {
       return Promise.reject(new Error(`Invalid Id: ${ record.id }`));
     }
-    return this.get(record.id).then(res => {
-      const existing = res.data;
-      const newStatus = options.synced ? "synced" : "updated";
-      return this.db.execute(transaction => {
-        const source = options.patch ? Object.assign({}, existing, record) : record;
-        const updated = markStatus(source, newStatus);
-        if (existing.last_modified && !updated.last_modified) {
-          updated.last_modified = existing.last_modified;
-        }
-        transaction.update(updated);
-        return { data: updated, permissions: {} };
-      });
-    });
+
+    return this.execute(txn => txn.update(record, options), { preloadIds: [record.id] });
   }
 
   /**
-   * Retrieve a record by its id from the local database.
+   * Like {@link CollectionTransaction#upsert}, but wrapped in its own transaction.
+   *
+   * @param  {Object} record
+   * @return {Promise}
+   */
+  upsert(record) {
+    // Validate the record and its ID, even though this validation is
+    // also done in the CollectionTransaction method, because we need
+    // to pass the ID to preloadIds.
+    if (typeof record !== "object") {
+      return Promise.reject(new Error("Record is not an object."));
+    }
+    if (!record.hasOwnProperty("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.execute(txn => txn.upsert(record), { preloadIds: [record.id] });
+  }
+
+  /**
+   * Like {@link CollectionTransaction#get}, but wrapped in its own transaction.
+   *
+   * Options:
+   * - {Boolean} includeDeleted: Include virtually deleted records.
    *
    * @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.execute(txn => txn.get(id, options), { preloadIds: [id] });
   }
 
   /**
-   * Deletes a record from the local database.
+   * Like {@link CollectionTransaction#getAny}, but wrapped in its own transaction.
+   *
+   * @param  {String} id
+   * @return {Promise}
+   */
+  getAny(id) {
+    return this.execute(txn => txn.getAny(id), { preloadIds: [id] });
+  }
+
+  /**
+   * Same as {@link Collection#delete}, but wrapped in its own transaction.
    *
    * 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 }`));
-    }
-    // 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 {
-          // Delete for real.
-          transaction.delete(id);
-        }
-        return { data: { id: id }, permissions: {} };
-      });
-    });
+    return this.execute(transaction => {
+      return transaction.delete(id, options);
+    }, { preloadIds: [id] });
+  }
+
+  /**
+   * The same as {@link CollectionTransaction#deleteAny}, but wrapped
+   * in its own transaction.
+   *
+   * @param  {String} id       The record's Id.
+   * @return {Promise}
+   */
+  deleteAny(id) {
+    return this.execute(txn => txn.deleteAny(id), { preloadIds: [id] });
   }
 
   /**
    * Lists records from the local database.
    *
    * Params:
    * - {Object} filters Filter the results (default: `{}`).
    * - {String} order   The order to apply   (default: `-last_modified`).
@@ -1230,17 +1753,17 @@ class Collection {
    * 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);
+    params = _extends({ order: "-last_modified", filters: {} }, params);
     return this.db.list(params).then(results => {
       let data = results;
       if (!options.includeDeleted) {
         data = results.filter(record => record._status !== "deleted");
       }
       return { data, permissions: {} };
     });
   }
@@ -1259,25 +1782,22 @@ class Collection {
       }
       return this._decodeRecord("remote", change);
     })).then(decodedChanges => {
       // No change, nothing to import.
       if (decodedChanges.length === 0) {
         return Promise.resolve(syncResultObject);
       }
       // Retrieve records matching change ids.
-      const remoteIds = decodedChanges.map(change => change.id);
-      return this.list({ filters: { id: remoteIds }, order: "" }, { includeDeleted: true }).then(res => ({ decodedChanges, existingRecords: res.data })).then(({ decodedChanges, existingRecords }) => {
-        return this.db.execute(transaction => {
-          return decodedChanges.map(remote => {
-            // Store remote change into local database.
-            return importChange(transaction, remote);
-          });
-        }, { preload: existingRecords });
-      }).catch(err => {
+      return this.db.execute(transaction => {
+        return decodedChanges.map(remote => {
+          // Store remote change into local database.
+          return importChange(transaction, remote, this.localFields);
+        });
+      }, { preload: decodedChanges.map(record => record.id) }).catch(err => {
         const data = {
           type: "incoming",
           message: err.message,
           stack: err.stack
         };
         // XXX one error of the whole transaction instead of per atomic op
         return [{ type: "errors", data }];
       }).then(imports => {
@@ -1298,16 +1818,51 @@ class Collection {
       return this.db.saveLastModified(syncResultObject.lastModified).then(lastModified => {
         this._lastModified = lastModified;
         return syncResultObject;
       });
     });
   }
 
   /**
+   * Execute a bunch of operations in a transaction.
+   *
+   * This transaction should be atomic -- either all of its operations
+   * will succeed, or none will.
+   *
+   * The argument to this function is itself a function which will be
+   * called with a {@link CollectionTransaction}. Collection methods
+   * are available on this transaction, but instead of returning
+   * promises, they are synchronous. execute() returns a Promise whose
+   * value will be the return value of the provided function.
+   *
+   * Most operations will require access to the record itself, which
+   * must be preloaded by passing its ID in the preloadIds option.
+   *
+   * Options:
+   * - {Array} preloadIds: list of IDs to fetch at the beginning of
+   *   the transaction
+   *
+   * @return {Promise} Resolves with the result of the given function
+   *    when the transaction commits.
+   */
+  execute(doOperations, { preloadIds = [] } = {}) {
+    for (let id of preloadIds) {
+      if (!this.idSchema.validate(id)) {
+        return Promise.reject(Error(`Invalid Id: ${ id }`));
+      }
+    }
+
+    return this.db.execute(transaction => {
+      const txn = new CollectionTransaction(this, transaction);
+      return doOperations(txn);
+    }, { preload: preloadIds });
+  }
+
+  /**
    * 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.
    */
@@ -1317,17 +1872,17 @@ class Collection {
       return this.db.execute(transaction => {
         _count = unsynced.data.length;
         unsynced.data.forEach(record => {
           if (record._status === "deleted") {
             // Garbage collect deleted records.
             transaction.delete(record.id);
           } else {
             // Records that were synced become «created».
-            transaction.update(Object.assign({}, record, {
+            transaction.update(_extends({}, record, {
               last_modified: undefined,
               _status: "created"
             }));
           }
         });
       });
     }).then(() => this.db.saveLastModified(null)).then(() => _count);
   }
@@ -1352,46 +1907,59 @@ class Collection {
   /**
    * 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
+   * @param  {KintoClient.Collection} client           Kinto client Collection instance.
+   * @param  {SyncResultObject}       syncResultObject The sync result object.
+   * @param  {Object}                 options
    * @return {Promise}
    */
-  pullChanges(syncResultObject, options = {}) {
+  pullChanges(client, syncResultObject, options = {}) {
     if (!syncResultObject.ok) {
       return Promise.resolve(syncResultObject);
     }
-    options = Object.assign({
-      strategy: Collection.strategy.MANUAL,
+    options = _extends({ strategy: Collection.strategy.MANUAL,
       lastModified: this.lastModified,
       headers: {}
     }, options);
+
+    // Optionally ignore some records when pulling for changes.
+    // (avoid redownloading our own changes on last step of #sync())
+    let filters;
+    if (options.exclude) {
+      // Limit the list of excluded records to the first 50 records in order
+      // to remain under de-facto URL size limit (~2000 chars).
+      // http://stackoverflow.com/questions/417142/what-is-the-maximum-length-of-a-url-in-different-browsers/417184#417184
+      const exclude_id = options.exclude.slice(0, 50).map(r => r.id).join(",");
+      filters = { exclude_id };
+    }
     // First fetch remote changes from the server
-    return this._apiCollection.listRecords({
-      since: options.lastModified || undefined,
-      headers: options.headers
+    return client.listRecords({
+      // Since should be ETag (see https://github.com/Kinto/kinto.js/issues/356)
+      since: options.lastModified ? `${ options.lastModified }` : undefined,
+      headers: options.headers,
+      filters
     }).then(({ data, last_modified }) => {
       // last_modified is the ETag header value (string).
       // For retro-compatibility with first kinto.js versions
       // parse it to integer.
-      const unquoted = last_modified ? parseInt(last_modified.replace(/"/g, ""), 10) : undefined;
+      const unquoted = last_modified ? parseInt(last_modified, 10) : undefined;
 
       // Check if server was flushed.
       // This is relevant for the Kinto demo server
       // (and thus for many new comers).
       const localSynced = options.lastModified;
       const serverChanged = unquoted > options.lastModified;
       const emptyCollection = data.length === 0;
-      if (localSynced && serverChanged && emptyCollection) {
+      if (!options.exclude && localSynced && serverChanged && emptyCollection) {
         throw Error("Server has been flushed.");
       }
 
       const payload = { lastModified: unquoted, changes: data };
       return this.applyHook("incoming-changes", payload);
     })
     // Reflect these changes locally
     .then(changes => this.importChanges(syncResultObject, changes))
@@ -1399,90 +1967,101 @@ class Collection {
     .then(result => this._handleConflicts(result, options.strategy));
   }
 
   applyHook(hookName, payload) {
     if (typeof this.hooks[hookName] == "undefined") {
       return Promise.resolve(payload);
     }
     return (0, _utils.waterfall)(this.hooks[hookName].map(hook => {
-      return record => hook(payload, this);
+      return record => {
+        const result = hook(payload, this);
+        const resultThenable = result && typeof result.then === "function";
+        const resultChanges = result && result.hasOwnProperty("changes");
+        if (!(resultThenable || resultChanges)) {
+          throw new Error(`Invalid return value for hook: ${ JSON.stringify(result) } has no 'then()' or 'changes' properties`);
+        }
+        return result;
+      };
     }), payload);
   }
 
   /**
    * 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.
+   * @param  {KintoClient.Collection} client           Kinto client Collection instance.
+   * @param  {SyncResultObject}       syncResultObject The sync result object.
+   * @param  {Object}                 options          The options object.
    * @return {Promise}
    */
-  pushChanges(syncResultObject, options = {}) {
+  pushChanges(client, syncResultObject, options = {}) {
     if (!syncResultObject.ok) {
       return Promise.resolve(syncResultObject);
     }
-    const safe = options.strategy === Collection.SERVER_WINS;
-    options = Object.assign({ safe }, options);
+    const safe = !options.strategy || options.strategy !== Collection.CLIENT_WINS;
 
     // Fetch local changes
     return this.gatherLocalChanges().then(({ toDelete, toSync }) => {
       // Send batch update requests
-      return this._apiCollection.batch(batch => {
+      return client.batch(batch => {
         toDelete.forEach(r => {
           // never published locally deleted records should not be pusblished
           if (r.last_modified) {
             batch.deleteRecord(r);
           }
         });
         toSync.forEach(r => {
-          const isCreated = r._status === "created";
-          // Do not store status on server.
-          // XXX: cleanRecord() removes last_modified, required by safe.
-          delete r._status;
-          if (isCreated) {
-            batch.createRecord(r);
+          // Clean local fields (like _status) before sending to server.
+          const published = this.cleanLocalFields(r);
+          if (r._status === "created") {
+            batch.createRecord(published);
           } else {
-            batch.updateRecord(r);
+            batch.updateRecord(published);
           }
         });
-      }, { headers: options.headers, safe: true, aggregate: true });
+      }, { headers: options.headers, safe, aggregate: true });
     })
     // Update published local records
     .then(synced => {
       // Merge outgoing errors into sync result object
       syncResultObject.add("errors", synced.errors.map(error => {
         error.type = "outgoing";
         return error;
       }));
 
       // The result of a batch returns data and permissions.
       // XXX: permissions are ignored currently.
       const conflicts = synced.conflicts.map(c => {
         return { type: c.type, local: c.local.data, remote: c.remote };
       });
+      // Merge outgoing conflicts into sync result object
+      syncResultObject.add("conflicts", conflicts);
+
+      // Reflect publication results locally using the response from
+      // the batch request.
+      // For created and updated records, the last_modified coming from server
+      // will be stored locally.
       const published = synced.published.map(c => c.data);
       const skipped = synced.skipped.map(c => c.data);
 
-      // 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 }));
+      // Records that must be deleted are either deletions that were pushed
+      // to server (published) or deleted records that were never pushed (skipped).
+      const missingRemotely = skipped.map(r => {
+        return _extends({}, r, { deleted: true });
+      });
       const toApplyLocally = published.concat(missingRemotely);
-      // Deleted records are distributed accross local and missing records
-      // XXX: When tackling the issue to avoid downloading our own changes
-      // from the server. `toDeleteLocally` should be obtained from local db.
-      // See https://github.com/Kinto/kinto.js/issues/144
+
       const toDeleteLocally = toApplyLocally.filter(r => r.deleted);
       const toUpdateLocally = toApplyLocally.filter(r => !r.deleted);
       // First, apply the decode transformers, if any
       return Promise.all(toUpdateLocally.map(record => {
         return this._decodeRecord("remote", record);
       }))
-      // Process everything within a single transaction
+      // 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 => {
@@ -1500,105 +2079,144 @@ class Collection {
     // 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 }));
+        return this.pushChanges(client, result, _extends({}, 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;
         });
       }
     });
   }
 
   /**
+   * Return a copy of the specified record without the local fields.
+   *
+   * @param  {Object} record  A record with potential local fields.
+   * @return {Object}
+   */
+  cleanLocalFields(record) {
+    const localKeys = RECORD_FIELDS_TO_CLEAN.concat(this.localFields);
+    return (0, _utils.omitKeys)(record, localKeys);
+  }
+
+  /**
    * 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, {
+    return this.db.execute(transaction => {
+      const updated = this._resolveRaw(conflict, resolution);
+      transaction.update(updated);
+      return { data: updated, permissions: {} };
+    });
+  }
+
+  /**
+   * @private
+   */
+  _resolveRaw(conflict, resolution) {
+    const resolved = _extends({}, resolution, {
       // Ensure local record has the latest authoritative timestamp
       last_modified: conflict.remote.last_modified
-    }));
+    });
+    // If the resolution object is strictly equal to the
+    // remote record, then we can mark it as synced locally.
+    // Otherwise, mark it as updated (so that the resolution is pushed).
+    const synced = (0, _utils.deepEqual)(resolved, conflict.remote);
+    return markStatus(resolved, synced ? "synced" : "updated");
   }
 
   /**
    * 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);
     }
-    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));
+    return this.db.execute(transaction => {
+      return result.conflicts.map(conflict => {
+        const resolution = strategy === Collection.strategy.CLIENT_WINS ? conflict.local : conflict.remote;
+        const updated = this._resolveRaw(conflict, resolution);
+        transaction.update(updated);
+        return updated;
+      });
+    }).then(imports => {
+      return result.reset("conflicts").add("resolved", imports);
     });
   }
 
   /**
    * 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} bucket: The remove bucket id to use (default: null)
+   * - {String} collection: The remove collection id to use (default: null)
    * - {String} remote The remote Kinto server endpoint to use (default: null).
    *
    * @param  {Object} options Options.
    * @return {Promise}
    * @throws {Error} If an invalid remote option is passed.
    */
   sync(options = {
     strategy: Collection.strategy.MANUAL,
     headers: {},
     ignoreBackoff: false,
+    bucket: null,
+    collection: null,
     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 asking clients to back off; retry in ${ seconds }s or use the ignoreBackoff option.`));
     }
+
+    const client = this.api.bucket(options.bucket || this.bucket).collection(options.collection || this.name);
     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 => {
+    const syncPromise = this.db.getLastModified().then(lastModified => this._lastModified = lastModified).then(_ => this.pullChanges(client, result, options)).then(result => this.pushChanges(client, 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);
+      // Avoid redownloading our own changes during the last pull.
+      const pullOpts = _extends({}, options, { exclude: result.published });
+      return this.pullChanges(client, result, pullOpts);
     });
     // 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.
    *
@@ -1610,17 +2228,17 @@ class Collection {
    */
   loadDump(records) {
     const reject = msg => Promise.reject(new Error(msg));
     if (!Array.isArray(records)) {
       return reject("Records is not an array.");
     }
 
     for (let record of records) {
-      if (!record.id || !this.idSchema.validate(record.id)) {
+      if (!record.hasOwnProperty("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));
       }
     }
 
@@ -1646,31 +2264,234 @@ class Collection {
         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;
+
+exports.default = Collection; /**
+                               * A Collection-oriented wrapper for an adapter's transaction.
+                               *
+                               * This defines the high-level functions available on a collection.
+                               * The collection itself offers functions of the same name. These will
+                               * perform just one operation in its own transaction.
+                               */
+
+class CollectionTransaction {
+  constructor(collection, adapterTransaction) {
+    this.collection = collection;
+    this.adapterTransaction = adapterTransaction;
+  }
+
+  /**
+   * Retrieve a record by its id from the local database, or
+   * undefined if none exists.
+   *
+   * This will also return virtually deleted records.
+   *
+   * @param  {String} id
+   * @return {Object}
+   */
+  getAny(id) {
+    const record = this.adapterTransaction.get(id);
+    return { data: record, permissions: {} };
+  }
+
+  /**
+   * Retrieve a record by its id from the local database.
+   *
+   * Options:
+   * - {Boolean} includeDeleted: Include virtually deleted records.
+   *
+   * @param  {String} id
+   * @param  {Object} options
+   * @return {Object}
+   */
+  get(id, options = { includeDeleted: false }) {
+    const res = this.getAny(id);
+    if (!res.data || !options.includeDeleted && res.data._status === "deleted") {
+      throw new Error(`Record with id=${ id } not found.`);
+    }
+
+    return res;
+  }
+
+  /**
+   * 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 {Object}
+   */
+  delete(id, options = { virtual: true }) {
+    // Ensure the record actually exists.
+    const existing = this.adapterTransaction.get(id);
+    const alreadyDeleted = existing && existing._status == "deleted";
+    if (!existing || alreadyDeleted && options.virtual) {
+      throw new Error(`Record with id=${ id } not found.`);
+    }
+    // Virtual updates status.
+    if (options.virtual) {
+      this.adapterTransaction.update(markDeleted(existing));
+    } else {
+      // Delete for real.
+      this.adapterTransaction.delete(id);
+    }
+    return { data: existing, permissions: {} };
+  }
+
+  /**
+   * Deletes a record from the local database, if any exists.
+   * Otherwise, do nothing.
+   *
+   * @param  {String} id       The record's Id.
+   * @return {Object}
+   */
+  deleteAny(id) {
+    const existing = this.adapterTransaction.get(id);
+    if (existing) {
+      this.adapterTransaction.update(markDeleted(existing));
+    }
+    return { data: _extends({ id }, existing), deleted: !!existing, permissions: {} };
+  }
 
-},{"./adapters/base":5,"./utils":7,"uuid":3}],7:[function(require,module,exports){
+  /**
+   * Adds a record to the local database, asserting that none
+   * already exist with this ID.
+   *
+   * @param  {Object} record, which must contain an ID
+   * @return {Object}
+   */
+  create(record) {
+    if (typeof record !== "object") {
+      throw new Error("Record is not an object.");
+    }
+    if (!record.hasOwnProperty("id")) {
+      throw new Error("Cannot create a record missing id");
+    }
+    if (!this.collection.idSchema.validate(record.id)) {
+      throw new Error(`Invalid Id: ${ record.id }`);
+    }
+
+    this.adapterTransaction.create(record);
+    return { data: record, permissions: {} };
+  }
+
+  /**
+   * 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 {Object}
+   */
+  update(record, options = { synced: false, patch: false }) {
+    if (typeof record !== "object") {
+      throw new Error("Record is not an object.");
+    }
+    if (!record.hasOwnProperty("id")) {
+      throw new Error("Cannot update a record missing id.");
+    }
+    if (!this.collection.idSchema.validate(record.id)) {
+      throw new Error(`Invalid Id: ${ record.id }`);
+    }
+
+    const oldRecord = this.adapterTransaction.get(record.id);
+    if (!oldRecord) {
+      throw new Error(`Record with id=${ record.id } not found.`);
+    }
+    const newRecord = options.patch ? _extends({}, oldRecord, record) : record;
+    const updated = this._updateRaw(oldRecord, newRecord, options);
+    this.adapterTransaction.update(updated);
+    return { data: updated, oldRecord: oldRecord, permissions: {} };
+  }
+
+  /**
+   * Lower-level primitive for updating a record while respecting
+   * _status and last_modified.
+   *
+   * @param  {Object} oldRecord: the record retrieved from the DB
+   * @param  {Object} newRecord: the record to replace it with
+   * @return {Object}
+   */
+  _updateRaw(oldRecord, newRecord, { synced = false } = {}) {
+    const updated = _extends({}, newRecord);
+    // Make sure to never loose the existing timestamp.
+    if (oldRecord && oldRecord.last_modified && !updated.last_modified) {
+      updated.last_modified = oldRecord.last_modified;
+    }
+    // If only local fields have changed, then keep record as synced.
+    // If status is created, keep record as created.
+    // If status is deleted, mark as updated.
+    const isIdentical = oldRecord && recordsEqual(oldRecord, updated, this.localFields);
+    const keepSynced = isIdentical && oldRecord._status == "synced";
+    const neverSynced = !oldRecord || oldRecord && oldRecord._status == "created";
+    const newStatus = keepSynced || synced ? "synced" : neverSynced ? "created" : "updated";
+    return markStatus(updated, newStatus);
+  }
+
+  /**
+   * Upsert a record into the local database.
+   *
+   * This record must have an ID.
+   *
+   * If a record with this ID already exists, it will be replaced.
+   * Otherwise, this record will be inserted.
+   *
+   * @param  {Object} record
+   * @return {Object}
+   */
+  upsert(record) {
+    if (typeof record !== "object") {
+      throw new Error("Record is not an object.");
+    }
+    if (!record.hasOwnProperty("id")) {
+      throw new Error("Cannot update a record missing id.");
+    }
+    if (!this.collection.idSchema.validate(record.id)) {
+      throw new Error(`Invalid Id: ${ record.id }`);
+    }
+    let oldRecord = this.adapterTransaction.get(record.id);
+    const updated = this._updateRaw(oldRecord, record);
+    this.adapterTransaction.update(updated);
+    // Don't return deleted records -- pretend they are gone
+    if (oldRecord && oldRecord._status == "deleted") {
+      oldRecord = undefined;
+    }
+
+    return { data: updated, oldRecord: oldRecord, permissions: {} };
+  }
+}
+exports.CollectionTransaction = CollectionTransaction;
+
+},{"./adapters/IDB":5,"./adapters/base":6,"./utils":8,"uuid":3}],8:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 exports.sortObjects = sortObjects;
 exports.filterObjects = filterObjects;
 exports.reduceRecords = reduceRecords;
 exports.isUUID = isUUID;
 exports.waterfall = waterfall;
 exports.pFinally = pFinally;
 exports.deepEqual = deepEqual;
+exports.omitKeys = omitKeys;
 const RE_UUID = exports.RE_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
 
 /**
  * Checks if a value is undefined.
  * @param  {Any}  value
  * @return {Boolean}
  */
 function _isUndefined(value) {
@@ -1799,10 +2620,26 @@ function deepEqual(a, b) {
   for (let k in a) {
     if (!deepEqual(a[k], b[k])) {
       return false;
     }
   }
   return true;
 }
 
+/**
+ * Return an object without the specified keys.
+ *
+ * @param  {Object} obj        The original object.
+ * @param  {Array}  keys       The list of keys to exclude.
+ * @return {Object}            A copy without the specified keys.
+ */
+function omitKeys(obj, keys = []) {
+  return Object.keys(obj).reduce((acc, key) => {
+    if (keys.indexOf(key) === -1) {
+      acc[key] = obj[key];
+    }
+    return acc;
+  }, {});
+}
+
 },{}]},{},[2])(2)
 });
\ No newline at end of file
--- a/services/common/tests/unit/test_kinto.js
+++ b/services/common/tests/unit/test_kinto.js
@@ -113,18 +113,19 @@ add_task(function* test_kinto_update() {
     do_check_eq(createResult.data._status, "created");
     // check we can update this OK
     let copiedRecord = Object.assign(createResult.data, {});
     deepEqual(createResult.data, copiedRecord);
     copiedRecord.foo = "wibble";
     let updateResult = yield collection.update(copiedRecord);
     // check the field was updated
     do_check_eq(updateResult.data.foo, copiedRecord.foo);
-    // check the status has changed
-    do_check_eq(updateResult.data._status, "updated");
+    // check the status is still "created", since we haven't synced
+    // the record
+    do_check_eq(updateResult.data._status, "created");
   } finally {
     yield collection.db.close();
   }
 });
 
 add_task(clear_collection);
 
 add_task(function* test_kinto_clear() {