author | Ethan Glasser-Camp <eglassercamp@mozilla.com> |
Wed, 13 Jul 2016 15:09:42 -0400 | |
changeset 305517 | 80b9e593a3aeddb2fade68ad88619b9b31d599d2 |
parent 305516 | 7bfa8f091fcc2b1755b5a8eaae50f4cf59870a55 |
child 305518 | 92442d706b644c71044249e8a1cb4eb4437f1dbc |
push id | 79610 |
push user | mgoodwin@mozilla.com |
push date | Tue, 19 Jul 2016 13:26:11 +0000 |
treeherder | mozilla-inbound@92442d706b64 [default view] [failures only] |
perfherder | [talos] [build metrics] [platform microbench] (compared to previous push) |
reviewers | MattN |
bugs | 1282109 |
milestone | 50.0a1 |
first release with | nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
|
last release without | nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
|
--- 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() {