Bug 1282109: update kinto-offline-client.js, r?MattN draft
authorEthan Glasser-Camp <eglassercamp@mozilla.com>
Wed, 13 Jul 2016 15:09:42 -0400
changeset 387679 9c6362db4fbb34da481432110ff53680c254d89b
parent 383145 fdcee57b4e4f66a82831ab01e61500da98a858e8
child 525408 9142d2f4a7eb68de31444faf9fd90776468e7d6f
push id23027
push usereglassercamp@mozilla.com
push dateThu, 14 Jul 2016 13:56:07 +0000
reviewersMattN
bugs1282109
milestone50.0a1
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() {