--- 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