2016-04-14 23:07:31 +00:00
|
|
|
|
/*
|
|
|
|
|
*
|
|
|
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
|
* you may not use this file except in compliance with the License.
|
|
|
|
|
* You may obtain a copy of the License at
|
|
|
|
|
*
|
|
|
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
*
|
|
|
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
|
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
|
* See the License for the specific language governing permissions and
|
|
|
|
|
* limitations under the License.
|
|
|
|
|
*/
|
2017-08-30 08:50:27 +00:00
|
|
|
|
"use strict";
|
2016-04-14 23:07:31 +00:00
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* This file is generated from kinto.js - do not modify directly.
|
|
|
|
|
*/
|
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
// This is required because with Babel compiles ES2015 modules into a
|
|
|
|
|
// require() form that tries to keep its modules on "this", but
|
|
|
|
|
// doesn't specify "this", leaving it to default to the global
|
|
|
|
|
// object. However, in strict mode, "this" no longer defaults to the
|
|
|
|
|
// global object, so expose the global object explicitly. Babel's
|
|
|
|
|
// compiled output will use a variable called "global" if one is
|
|
|
|
|
// present.
|
|
|
|
|
//
|
|
|
|
|
// See https://bugzilla.mozilla.org/show_bug.cgi?id=1394556#c3 for
|
|
|
|
|
// more details.
|
2017-08-30 08:50:27 +00:00
|
|
|
|
const global = this;
|
|
|
|
|
|
2018-02-23 19:50:01 +00:00
|
|
|
|
var EXPORTED_SYMBOLS = ["Kinto"];
|
2016-04-14 23:07:31 +00:00
|
|
|
|
|
|
|
|
|
/*
|
2018-10-18 17:02:24 +00:00
|
|
|
|
* Version 12.2.0 - 266e100
|
2016-04-14 23:07:31 +00:00
|
|
|
|
*/
|
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
(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.Kinto = f()}})(function(){var define,module,exports;return (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){
|
2016-04-14 23:07:31 +00:00
|
|
|
|
/*
|
|
|
|
|
*
|
|
|
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
|
* you may not use this file except in compliance with the License.
|
|
|
|
|
* You may obtain a copy of the License at
|
|
|
|
|
*
|
|
|
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
*
|
|
|
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
|
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
|
* See the License for the specific language governing permissions and
|
|
|
|
|
* limitations under the License.
|
|
|
|
|
*/
|
|
|
|
|
"use strict";
|
|
|
|
|
|
|
|
|
|
Object.defineProperty(exports, "__esModule", {
|
|
|
|
|
value: true
|
|
|
|
|
});
|
2018-09-19 10:16:41 +00:00
|
|
|
|
exports.default = void 0;
|
2016-07-13 19:09:42 +00:00
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
var _KintoBase = _interopRequireDefault(require("../src/KintoBase"));
|
2018-03-20 18:29:50 +00:00
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
var _base = _interopRequireDefault(require("../src/adapters/base"));
|
2018-03-20 18:29:50 +00:00
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
var _IDB = _interopRequireDefault(require("../src/adapters/IDB"));
|
2018-03-20 18:29:50 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
var _utils = require("../src/utils");
|
|
|
|
|
|
|
|
|
|
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
|
|
|
|
|
|
2018-01-29 23:20:18 +00:00
|
|
|
|
ChromeUtils.import("resource://gre/modules/Timer.jsm");
|
2018-05-26 00:02:29 +00:00
|
|
|
|
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
|
|
|
|
|
XPCOMUtils.defineLazyGlobalGetters(global, ["fetch", "indexedDB"]);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
const {
|
|
|
|
|
EventEmitter
|
|
|
|
|
} = ChromeUtils.import("resource://gre/modules/EventEmitter.jsm", {});
|
|
|
|
|
const {
|
|
|
|
|
generateUUID
|
|
|
|
|
} = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator); // Use standalone kinto-http module landed in FFx.
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
KintoHttpClient
|
|
|
|
|
} = ChromeUtils.import("resource://services-common/kinto-http-client.js");
|
|
|
|
|
|
|
|
|
|
class Kinto extends _KintoBase.default {
|
2018-03-20 18:29:50 +00:00
|
|
|
|
static get adapters() {
|
|
|
|
|
return {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
BaseAdapter: _base.default,
|
|
|
|
|
IDB: _IDB.default
|
2018-03-20 18:29:50 +00:00
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2016-11-16 00:38:53 +00:00
|
|
|
|
constructor(options = {}) {
|
|
|
|
|
const events = {};
|
|
|
|
|
EventEmitter.decorate(events);
|
|
|
|
|
const defaults = {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
adapter: _IDB.default,
|
2016-11-16 00:38:53 +00:00
|
|
|
|
events,
|
|
|
|
|
ApiClass: KintoHttpClient
|
|
|
|
|
};
|
2018-09-19 10:16:41 +00:00
|
|
|
|
super({ ...defaults,
|
|
|
|
|
...options
|
|
|
|
|
});
|
2016-11-16 00:38:53 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
collection(collName, options = {}) {
|
|
|
|
|
const idSchema = {
|
2018-07-11 12:50:47 +00:00
|
|
|
|
validate(id) {
|
|
|
|
|
return typeof id == "string" && _utils.RE_RECORD_ID.test(id);
|
|
|
|
|
},
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-07-11 12:50:47 +00:00
|
|
|
|
generate() {
|
2016-04-14 23:07:31 +00:00
|
|
|
|
return generateUUID().toString().replace(/[{}]/g, "");
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
};
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return super.collection(collName, {
|
|
|
|
|
idSchema,
|
|
|
|
|
...options
|
|
|
|
|
});
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
} // This fixes compatibility with CommonJS required by browserify.
|
2016-04-14 23:07:31 +00:00
|
|
|
|
// See http://stackoverflow.com/questions/33505992/babel-6-changes-how-it-exports-default/33683495#33683495
|
2016-10-07 14:27:48 +00:00
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
exports.default = Kinto;
|
|
|
|
|
|
2016-11-16 00:38:53 +00:00
|
|
|
|
if (typeof module === "object") {
|
|
|
|
|
module.exports = Kinto;
|
2016-10-07 14:27:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
2018-03-20 18:29:50 +00:00
|
|
|
|
},{"../src/KintoBase":3,"../src/adapters/IDB":4,"../src/adapters/base":5,"../src/utils":7}],2:[function(require,module,exports){
|
2016-10-07 14:27:48 +00:00
|
|
|
|
|
2016-11-16 00:38:53 +00:00
|
|
|
|
},{}],3:[function(require,module,exports){
|
2016-04-14 23:07:31 +00:00
|
|
|
|
"use strict";
|
|
|
|
|
|
|
|
|
|
Object.defineProperty(exports, "__esModule", {
|
|
|
|
|
value: true
|
|
|
|
|
});
|
2018-09-19 10:16:41 +00:00
|
|
|
|
exports.default = void 0;
|
2016-04-14 23:07:31 +00:00
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
var _collection = _interopRequireDefault(require("./collection"));
|
2016-04-14 23:07:31 +00:00
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
var _base = _interopRequireDefault(require("./adapters/base"));
|
2016-04-14 23:07:31 +00:00
|
|
|
|
|
|
|
|
|
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
|
|
|
|
|
|
|
|
|
|
const DEFAULT_BUCKET_NAME = "default";
|
|
|
|
|
const DEFAULT_REMOTE = "http://localhost:8888/v1";
|
2017-01-18 13:53:52 +00:00
|
|
|
|
const DEFAULT_RETRY = 1;
|
2016-04-14 23:07:31 +00:00
|
|
|
|
/**
|
|
|
|
|
* KintoBase class.
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
class KintoBase {
|
|
|
|
|
/**
|
|
|
|
|
* Provides a public access to the base adapter class. Users can create a
|
|
|
|
|
* custom DB adapter by extending {@link BaseAdapter}.
|
|
|
|
|
*
|
|
|
|
|
* @type {Object}
|
|
|
|
|
*/
|
|
|
|
|
static get adapters() {
|
|
|
|
|
return {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
BaseAdapter: _base.default
|
2016-04-14 23:07:31 +00:00
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Synchronization strategies. Available strategies are:
|
|
|
|
|
*
|
|
|
|
|
* - `MANUAL`: Conflicts will be reported in a dedicated array.
|
|
|
|
|
* - `SERVER_WINS`: Conflicts are resolved using remote data.
|
|
|
|
|
* - `CLIENT_WINS`: Conflicts are resolved using local data.
|
|
|
|
|
*
|
|
|
|
|
* @type {Object}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
static get syncStrategy() {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return _collection.default.strategy;
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Constructor.
|
|
|
|
|
*
|
|
|
|
|
* Options:
|
2016-07-13 19:09:42 +00:00
|
|
|
|
* - `{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.
|
|
|
|
|
* - `{Object}` `headers` The HTTP headers to use.
|
2017-01-18 13:53:52 +00:00
|
|
|
|
* - `{Object}` `retry` Number of retries when the server fails to process the request (default: `1`)
|
2016-07-13 19:09:42 +00:00
|
|
|
|
* - `{String}` `requestMode` The HTTP CORS mode to use.
|
|
|
|
|
* - `{Number}` `timeout` The requests timeout in ms (default: `5000`).
|
2016-04-14 23:07:31 +00:00
|
|
|
|
*
|
|
|
|
|
* @param {Object} options The options object.
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
constructor(options = {}) {
|
|
|
|
|
const defaults = {
|
|
|
|
|
bucket: DEFAULT_BUCKET_NAME,
|
2017-01-18 13:53:52 +00:00
|
|
|
|
remote: DEFAULT_REMOTE,
|
|
|
|
|
retry: DEFAULT_RETRY
|
2016-04-14 23:07:31 +00:00
|
|
|
|
};
|
2018-09-19 10:16:41 +00:00
|
|
|
|
this._options = { ...defaults,
|
|
|
|
|
...options
|
|
|
|
|
};
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
if (!this._options.adapter) {
|
|
|
|
|
throw new Error("No adapter provided");
|
|
|
|
|
}
|
|
|
|
|
|
2017-05-11 17:15:20 +00:00
|
|
|
|
const {
|
2018-02-16 20:08:18 +00:00
|
|
|
|
ApiClass,
|
2017-05-11 17:15:20 +00:00
|
|
|
|
events,
|
|
|
|
|
headers,
|
2018-02-16 20:08:18 +00:00
|
|
|
|
remote,
|
2017-05-11 17:15:20 +00:00
|
|
|
|
requestMode,
|
2018-02-16 20:08:18 +00:00
|
|
|
|
retry,
|
|
|
|
|
timeout
|
2018-09-19 10:16:41 +00:00
|
|
|
|
} = this._options; // public properties
|
2016-07-13 19:09:42 +00:00
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The kinto HTTP client instance.
|
|
|
|
|
* @type {KintoClient}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2017-05-11 17:15:20 +00:00
|
|
|
|
this.api = new ApiClass(remote, {
|
|
|
|
|
events,
|
|
|
|
|
headers,
|
|
|
|
|
requestMode,
|
2018-02-16 20:08:18 +00:00
|
|
|
|
retry,
|
2017-05-11 17:15:20 +00:00
|
|
|
|
timeout
|
|
|
|
|
});
|
2016-04-14 23:07:31 +00:00
|
|
|
|
/**
|
|
|
|
|
* The event emitter instance.
|
|
|
|
|
* @type {EventEmitter}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
this.events = this._options.events;
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Creates a {@link Collection} instance. The second (optional) parameter
|
|
|
|
|
* will set collection-level options like e.g. `remoteTransformers`.
|
|
|
|
|
*
|
|
|
|
|
* @param {String} collName The collection name.
|
2017-01-18 13:53:52 +00:00
|
|
|
|
* @param {Object} [options={}] Extra options or override client's options.
|
|
|
|
|
* @param {Object} [options.idSchema] IdSchema instance (default: UUID)
|
|
|
|
|
* @param {Object} [options.remoteTransformers] Array<RemoteTransformer> (default: `[]`])
|
|
|
|
|
* @param {Object} [options.hooks] Array<Hook> (default: `[]`])
|
2018-02-16 20:08:18 +00:00
|
|
|
|
* @param {Object} [options.localFields] Array<Field> (default: `[]`])
|
2016-04-14 23:07:31 +00:00
|
|
|
|
* @return {Collection}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
collection(collName, options = {}) {
|
|
|
|
|
if (!collName) {
|
|
|
|
|
throw new Error("missing collection name");
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
bucket,
|
|
|
|
|
events,
|
|
|
|
|
adapter,
|
|
|
|
|
adapterOptions
|
|
|
|
|
} = { ...this._options,
|
2018-02-16 20:08:18 +00:00
|
|
|
|
...options
|
|
|
|
|
};
|
2018-09-19 10:16:41 +00:00
|
|
|
|
const {
|
|
|
|
|
idSchema,
|
|
|
|
|
remoteTransformers,
|
|
|
|
|
hooks,
|
|
|
|
|
localFields
|
|
|
|
|
} = options;
|
|
|
|
|
return new _collection.default(bucket, collName, this.api, {
|
2017-01-18 13:53:52 +00:00
|
|
|
|
events,
|
|
|
|
|
adapter,
|
|
|
|
|
adapterOptions,
|
|
|
|
|
idSchema,
|
|
|
|
|
remoteTransformers,
|
2018-02-16 20:08:18 +00:00
|
|
|
|
hooks,
|
|
|
|
|
localFields
|
2016-04-14 23:07:31 +00:00
|
|
|
|
});
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
exports.default = KintoBase;
|
|
|
|
|
|
2016-11-16 00:38:53 +00:00
|
|
|
|
},{"./adapters/base":5,"./collection":6}],4:[function(require,module,exports){
|
2016-07-13 19:09:42 +00:00
|
|
|
|
"use strict";
|
|
|
|
|
|
|
|
|
|
Object.defineProperty(exports, "__esModule", {
|
|
|
|
|
value: true
|
|
|
|
|
});
|
2018-09-19 10:16:41 +00:00
|
|
|
|
exports.open = open;
|
|
|
|
|
exports.execute = execute;
|
|
|
|
|
exports.default = void 0;
|
2016-07-13 19:09:42 +00:00
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
var _base = _interopRequireDefault(require("./base.js"));
|
2016-07-13 19:09:42 +00:00
|
|
|
|
|
|
|
|
|
var _utils = require("../utils");
|
|
|
|
|
|
|
|
|
|
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
|
|
|
|
|
|
|
|
|
|
const INDEXED_FIELDS = ["id", "_status", "last_modified"];
|
2018-09-19 10:16:41 +00:00
|
|
|
|
/**
|
|
|
|
|
* Small helper that wraps the opening of an IndexedDB into a Promise.
|
|
|
|
|
*
|
|
|
|
|
* @param dbname {String} The database name.
|
|
|
|
|
* @param version {Integer} Schema version
|
|
|
|
|
* @param onupgradeneeded {Function} The callback to execute if schema is
|
|
|
|
|
* missing or different.
|
|
|
|
|
* @return {Promise<IDBDatabase>}
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
async function open(dbname, {
|
|
|
|
|
version,
|
|
|
|
|
onupgradeneeded
|
|
|
|
|
}) {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const request = indexedDB.open(dbname, version);
|
|
|
|
|
|
|
|
|
|
request.onupgradeneeded = event => {
|
|
|
|
|
const db = event.target.result;
|
|
|
|
|
|
|
|
|
|
db.onerror = event => reject(event.target.error);
|
2016-07-13 19:09:42 +00:00
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return onupgradeneeded(event);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
request.onerror = event => {
|
|
|
|
|
reject(event.target.error);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
request.onsuccess = event => {
|
|
|
|
|
const db = event.target.result;
|
|
|
|
|
resolve(db);
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Helper to run the specified callback in a single transaction on the
|
|
|
|
|
* specified store.
|
|
|
|
|
* The helper focuses on transaction wrapping into a promise.
|
|
|
|
|
*
|
|
|
|
|
* @param db {IDBDatabase} The database instance.
|
|
|
|
|
* @param name {String} The store name.
|
|
|
|
|
* @param callback {Function} The piece of code to execute in the transaction.
|
|
|
|
|
* @param options {Object} Options.
|
|
|
|
|
* @param options.mode {String} Transaction mode (default: read).
|
|
|
|
|
* @return {Promise} any value returned by the callback.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function execute(db, name, callback, options = {}) {
|
|
|
|
|
const {
|
|
|
|
|
mode
|
|
|
|
|
} = options;
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
// On Safari, calling IDBDatabase.transaction with mode == undefined raises
|
|
|
|
|
// a TypeError.
|
|
|
|
|
const transaction = mode ? db.transaction([name], mode) : db.transaction([name]);
|
|
|
|
|
const store = transaction.objectStore(name); // Let the callback abort this transaction.
|
|
|
|
|
|
|
|
|
|
const abort = e => {
|
|
|
|
|
transaction.abort();
|
|
|
|
|
reject(e);
|
|
|
|
|
}; // Execute the specified callback **synchronously**.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let result;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
result = callback(store, abort);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
abort(e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
transaction.onerror = event => reject(event.target.error);
|
|
|
|
|
|
|
|
|
|
transaction.oncomplete = event => resolve(result);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Helper to wrap the deletion of an IndexedDB database into a promise.
|
|
|
|
|
*
|
|
|
|
|
* @param dbName {String} the database to delete
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function deleteDatabase(dbName) {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const request = indexedDB.deleteDatabase(dbName);
|
|
|
|
|
|
|
|
|
|
request.onsuccess = event => resolve(event.target);
|
|
|
|
|
|
|
|
|
|
request.onerror = event => reject(event.target.error);
|
|
|
|
|
});
|
|
|
|
|
}
|
2016-07-13 19:09:42 +00:00
|
|
|
|
/**
|
|
|
|
|
* IDB cursor handlers.
|
|
|
|
|
* @type {Object}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
const cursorHandlers = {
|
2016-10-07 14:27:48 +00:00
|
|
|
|
all(filters, done) {
|
2016-07-13 19:09:42 +00:00
|
|
|
|
const results = [];
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return event => {
|
2016-07-13 19:09:42 +00:00
|
|
|
|
const cursor = event.target.result;
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (cursor) {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
const {
|
|
|
|
|
value
|
|
|
|
|
} = cursor;
|
|
|
|
|
|
|
|
|
|
if ((0, _utils.filterObject)(filters, value)) {
|
|
|
|
|
results.push(value);
|
2016-10-07 14:27:48 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
cursor.continue();
|
|
|
|
|
} else {
|
|
|
|
|
done(results);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
in(values, filters, done) {
|
2016-07-13 19:09:42 +00:00
|
|
|
|
const results = [];
|
|
|
|
|
return function (event) {
|
|
|
|
|
const cursor = event.target.result;
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (!cursor) {
|
|
|
|
|
done(results);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
key,
|
|
|
|
|
value
|
|
|
|
|
} = cursor; // `key` can be an array of two values (see `keyPath` in indices definitions).
|
|
|
|
|
|
|
|
|
|
let i = 0; // `values` can be an array of arrays if we filter using an index whose key path
|
|
|
|
|
// is an array (eg. `cursorHandlers.in([["bid/cid", 42], ["bid/cid", 43]], ...)`)
|
|
|
|
|
|
|
|
|
|
while (key > values[i]) {
|
2016-07-13 19:09:42 +00:00
|
|
|
|
// The cursor has passed beyond this key. Check next.
|
|
|
|
|
++i;
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
if (i === values.length) {
|
2016-07-13 19:09:42 +00:00
|
|
|
|
done(results); // There is no next. Stop searching.
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
const isEqual = Array.isArray(key) ? (0, _utils.arrayEqual)(key, values[i]) : key === values[i];
|
|
|
|
|
|
|
|
|
|
if (isEqual) {
|
|
|
|
|
if ((0, _utils.filterObject)(filters, value)) {
|
|
|
|
|
results.push(value);
|
|
|
|
|
}
|
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
cursor.continue();
|
|
|
|
|
} else {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
cursor.continue(values[i]);
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
};
|
2016-07-13 19:09:42 +00:00
|
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*
|
2018-09-19 10:16:41 +00:00
|
|
|
|
* @param {String} cid The collection id (ie. `{bid}/{cid}`)
|
2016-07-13 19:09:42 +00:00
|
|
|
|
* @param {IDBStore} store The IDB store.
|
2018-09-19 10:16:41 +00:00
|
|
|
|
* @param {Object} filters Filter the records by field.
|
2016-07-13 19:09:42 +00:00
|
|
|
|
* @param {Function} done The operation completion handler.
|
|
|
|
|
* @return {IDBRequest}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
function createListRequest(cid, store, filters, done) {
|
2018-10-18 17:02:24 +00:00
|
|
|
|
const filterFields = Object.keys(filters); // If no filters, get all results in one bulk.
|
|
|
|
|
|
|
|
|
|
if (filterFields.length == 0) {
|
|
|
|
|
const request = store.index("cid").getAll(IDBKeyRange.only(cid));
|
|
|
|
|
request.onsuccess = event => done(event.target.result);
|
|
|
|
|
return request;
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
// Introspect filters and check if they leverage an indexed field.
|
2018-10-18 17:02:24 +00:00
|
|
|
|
const indexField = filterFields.find(field => {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return INDEXED_FIELDS.includes(field);
|
|
|
|
|
});
|
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (!indexField) {
|
2018-10-18 17:02:24 +00:00
|
|
|
|
// Iterate on all records for this collection (ie. cid)
|
2018-09-19 10:16:41 +00:00
|
|
|
|
const request = store.index("cid").openCursor(IDBKeyRange.only(cid));
|
2016-10-07 14:27:48 +00:00
|
|
|
|
request.onsuccess = cursorHandlers.all(filters, done);
|
2016-07-13 19:09:42 +00:00
|
|
|
|
return request;
|
2018-09-19 10:16:41 +00:00
|
|
|
|
} // If `indexField` was used already, don't filter again.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const remainingFilters = (0, _utils.omitKeys)(filters, indexField); // value specified in the filter (eg. `filters: { _status: ["created", "updated"] }`)
|
|
|
|
|
|
2018-10-18 17:02:24 +00:00
|
|
|
|
const value = filters[indexField];
|
|
|
|
|
// For the "id" field, use the primary key.
|
|
|
|
|
const indexStore = indexField == "id" ? store : store.index(indexField);
|
2016-07-13 19:09:42 +00:00
|
|
|
|
|
2018-10-18 17:02:24 +00:00
|
|
|
|
// WHERE IN equivalent clause
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (Array.isArray(value)) {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
if (value.length === 0) {
|
|
|
|
|
return done([]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const values = value.map(i => [cid, i]).sort();
|
|
|
|
|
const range = IDBKeyRange.bound(values[0], values[values.length - 1]);
|
2018-10-18 17:02:24 +00:00
|
|
|
|
const request = indexStore.openCursor(range);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
request.onsuccess = cursorHandlers.in(values, remainingFilters, done);
|
2016-07-13 19:09:42 +00:00
|
|
|
|
return request;
|
2018-10-18 17:02:24 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If no filters on custom attribute, get all results in one bulk.
|
|
|
|
|
if (remainingFilters.length == 0) {
|
|
|
|
|
const request = indexStore.getAll(IDBKeyRange.only([cid, value]));
|
|
|
|
|
request.onsuccess = event => done(event.target.result);
|
|
|
|
|
return request;
|
2018-09-19 10:16:41 +00:00
|
|
|
|
} // WHERE field = value clause
|
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
|
2018-10-18 17:02:24 +00:00
|
|
|
|
const request = indexStore.openCursor(IDBKeyRange.only([cid, value]));
|
2018-09-19 10:16:41 +00:00
|
|
|
|
request.onsuccess = cursorHandlers.all(remainingFilters, done);
|
2016-07-13 19:09:42 +00:00
|
|
|
|
return request;
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* IndexedDB adapter.
|
|
|
|
|
*
|
|
|
|
|
* This adapter doesn't support any options.
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class IDB extends _base.default {
|
2016-07-13 19:09:42 +00:00
|
|
|
|
/**
|
|
|
|
|
* Constructor.
|
|
|
|
|
*
|
2018-09-19 10:16:41 +00:00
|
|
|
|
* @param {String} cid The key base for this collection (eg. `bid/cid`)
|
|
|
|
|
* @param {Object} options
|
|
|
|
|
* @param {String} options.dbName The IndexedDB name (default: `"KintoDB"`)
|
|
|
|
|
* @param {String} options.migrateOldData Whether old database data should be migrated (default: `false`)
|
2016-07-13 19:09:42 +00:00
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
constructor(cid, options = {}) {
|
2016-07-13 19:09:42 +00:00
|
|
|
|
super();
|
2018-09-19 10:16:41 +00:00
|
|
|
|
this.cid = cid;
|
|
|
|
|
this.dbName = options.dbName || "KintoDB";
|
|
|
|
|
this._options = options;
|
2016-07-13 19:09:42 +00:00
|
|
|
|
this._db = null;
|
|
|
|
|
}
|
|
|
|
|
|
2016-10-07 14:27:48 +00:00
|
|
|
|
_handleError(method, err) {
|
|
|
|
|
const error = new Error(method + "() " + err.message);
|
|
|
|
|
error.stack = err.stack;
|
|
|
|
|
throw error;
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Ensures a connection to the IndexedDB database has been opened.
|
|
|
|
|
*
|
|
|
|
|
* @override
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async open() {
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (this._db) {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return this;
|
|
|
|
|
} // In previous versions, we used to have a database with name `${bid}/${cid}`.
|
|
|
|
|
// Check if it exists, and migrate data once new schema is in place.
|
|
|
|
|
// Note: the built-in migrations from IndexedDB can only be used if the
|
|
|
|
|
// database name does not change.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const dataToMigrate = this._options.migrateOldData ? await migrationRequired(this.cid) : null;
|
|
|
|
|
this._db = await open(this.dbName, {
|
|
|
|
|
version: 1,
|
|
|
|
|
onupgradeneeded: event => {
|
|
|
|
|
const db = event.target.result; // Records store
|
|
|
|
|
|
|
|
|
|
const recordsStore = db.createObjectStore("records", {
|
|
|
|
|
keyPath: ["_cid", "id"]
|
|
|
|
|
}); // An index to obtain all the records in a collection.
|
|
|
|
|
|
|
|
|
|
recordsStore.createIndex("cid", "_cid"); // Here we create indices for every known field in records by collection.
|
2018-10-18 17:02:24 +00:00
|
|
|
|
// Local record status ("synced", "created", "updated", "deleted")
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
recordsStore.createIndex("_status", ["_cid", "_status"]); // Last modified field
|
|
|
|
|
|
|
|
|
|
recordsStore.createIndex("last_modified", ["_cid", "last_modified"]); // Timestamps store
|
|
|
|
|
|
|
|
|
|
db.createObjectStore("timestamps", {
|
|
|
|
|
keyPath: "cid"
|
2016-07-13 19:09:42 +00:00
|
|
|
|
});
|
2018-09-19 10:16:41 +00:00
|
|
|
|
}
|
2016-07-13 19:09:42 +00:00
|
|
|
|
});
|
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
if (dataToMigrate) {
|
|
|
|
|
const {
|
|
|
|
|
records,
|
|
|
|
|
timestamp
|
|
|
|
|
} = dataToMigrate;
|
|
|
|
|
await this.loadDump(records);
|
|
|
|
|
await this.saveLastModified(timestamp);
|
|
|
|
|
console.log(`${this.cid}: data was migrated successfully.`); // Delete the old database.
|
|
|
|
|
|
|
|
|
|
await deleteDatabase(this.cid);
|
|
|
|
|
console.warn(`${this.cid}: old database was deleted.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this;
|
|
|
|
|
}
|
2016-07-13 19:09:42 +00:00
|
|
|
|
/**
|
|
|
|
|
* Closes current connection to the database.
|
|
|
|
|
*
|
|
|
|
|
* @override
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
close() {
|
|
|
|
|
if (this._db) {
|
|
|
|
|
this._db.close(); // indexedDB.close is synchronous
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
this._db = null;
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2017-01-18 13:53:52 +00:00
|
|
|
|
return Promise.resolve();
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
2018-09-19 10:16:41 +00:00
|
|
|
|
* Returns a transaction and an object store for a store name.
|
2016-07-13 19:09:42 +00:00
|
|
|
|
*
|
|
|
|
|
* 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.
|
|
|
|
|
*
|
2018-09-19 10:16:41 +00:00
|
|
|
|
* @param {String} name Store name
|
|
|
|
|
* @param {Function} callback to execute
|
|
|
|
|
* @param {Object} options Options
|
|
|
|
|
* @param {String} options.mode Transaction mode ("readwrite" or undefined)
|
2016-07-13 19:09:42 +00:00
|
|
|
|
* @return {Object}
|
|
|
|
|
*/
|
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
async prepare(name, callback, options) {
|
|
|
|
|
await this.open();
|
|
|
|
|
await execute(this._db, name, callback, options);
|
|
|
|
|
}
|
2016-07-13 19:09:42 +00:00
|
|
|
|
/**
|
|
|
|
|
* Deletes every records in the current collection.
|
|
|
|
|
*
|
|
|
|
|
* @override
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
async clear() {
|
|
|
|
|
try {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
await this.prepare("records", store => {
|
|
|
|
|
const range = IDBKeyRange.only(this.cid);
|
|
|
|
|
const request = store.index("cid").openKeyCursor(range);
|
|
|
|
|
|
|
|
|
|
request.onsuccess = event => {
|
|
|
|
|
const cursor = event.target.result;
|
|
|
|
|
|
|
|
|
|
if (cursor) {
|
|
|
|
|
store.delete(cursor.primaryKey);
|
|
|
|
|
cursor.continue();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return request;
|
|
|
|
|
}, {
|
|
|
|
|
mode: "readwrite"
|
2018-02-16 20:08:18 +00:00
|
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this._handleError("clear", e);
|
|
|
|
|
}
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* 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");
|
2018-09-19 10:16:41 +00:00
|
|
|
|
* const result = await db.execute(transaction => {
|
2016-07-13 19:09:42 +00:00
|
|
|
|
* transaction.create({id: 1, title: "foo"});
|
|
|
|
|
* transaction.update({id: 2, title: "bar"});
|
|
|
|
|
* transaction.delete(3);
|
|
|
|
|
* return "foo";
|
2018-09-19 10:16:41 +00:00
|
|
|
|
* });
|
2016-07-13 19:09:42 +00:00
|
|
|
|
*
|
2018-02-16 20:08:18 +00:00
|
|
|
|
* @override
|
2016-07-13 19:09:42 +00:00
|
|
|
|
* @param {Function} callback The operation description callback.
|
|
|
|
|
* @param {Object} options The options object.
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async execute(callback, options = {
|
|
|
|
|
preload: []
|
|
|
|
|
}) {
|
2018-02-16 20:08:18 +00:00
|
|
|
|
// 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/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
let result;
|
|
|
|
|
await this.prepare("records", (store, abort) => {
|
|
|
|
|
const runCallback = (preloaded = []) => {
|
2018-02-16 20:08:18 +00:00
|
|
|
|
// Expose a consistent API for every adapter instead of raw store methods.
|
2018-09-19 10:16:41 +00:00
|
|
|
|
const proxy = transactionProxy(this, store, preloaded); // The callback is executed synchronously within the same transaction.
|
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
try {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
const returned = callback(proxy);
|
|
|
|
|
|
|
|
|
|
if (returned instanceof Promise) {
|
|
|
|
|
// XXX: investigate how to provide documentation details in error.
|
|
|
|
|
throw new Error("execute() callback should not return a Promise.");
|
|
|
|
|
} // Bring to scope that will be returned (once promise awaited).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
result = returned;
|
2018-02-16 20:08:18 +00:00
|
|
|
|
} catch (e) {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
// The callback has thrown an error explicitly. Abort transaction cleanly.
|
|
|
|
|
abort(e);
|
2018-02-16 20:08:18 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
}; // No option to preload records, go straight to `callback`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!options.preload.length) {
|
|
|
|
|
return runCallback();
|
|
|
|
|
} // Preload specified records using a list request.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const filters = {
|
|
|
|
|
id: options.preload
|
|
|
|
|
};
|
|
|
|
|
createListRequest(this.cid, store, filters, records => {
|
|
|
|
|
// Store obtained records by id.
|
|
|
|
|
const preloaded = records.reduce((acc, record) => {
|
|
|
|
|
acc[record.id] = (0, _utils.omitKeys)(record, ["_cid"]);
|
|
|
|
|
return acc;
|
|
|
|
|
}, {});
|
|
|
|
|
runCallback(preloaded);
|
2016-07-13 19:09:42 +00:00
|
|
|
|
});
|
2018-09-19 10:16:41 +00:00
|
|
|
|
}, {
|
|
|
|
|
mode: "readwrite"
|
2018-02-16 20:08:18 +00:00
|
|
|
|
});
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return result;
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Retrieve a record by its primary key from the IndexedDB database.
|
|
|
|
|
*
|
|
|
|
|
* @override
|
|
|
|
|
* @param {String} id The record id.
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
async get(id) {
|
|
|
|
|
try {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
let record;
|
|
|
|
|
await this.prepare("records", store => {
|
|
|
|
|
store.get([this.cid, id]).onsuccess = e => record = e.target.result;
|
2018-02-16 20:08:18 +00:00
|
|
|
|
});
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return record;
|
2018-02-16 20:08:18 +00:00
|
|
|
|
} catch (e) {
|
|
|
|
|
this._handleError("get", e);
|
|
|
|
|
}
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Lists all records from the IndexedDB database.
|
|
|
|
|
*
|
|
|
|
|
* @override
|
2018-02-16 20:08:18 +00:00
|
|
|
|
* @param {Object} params The filters and order to apply to the results.
|
2016-07-13 19:09:42 +00:00
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async list(params = {
|
|
|
|
|
filters: {}
|
|
|
|
|
}) {
|
|
|
|
|
const {
|
|
|
|
|
filters
|
|
|
|
|
} = params;
|
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
try {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
let results = [];
|
|
|
|
|
await this.prepare("records", store => {
|
|
|
|
|
createListRequest(this.cid, store, filters, _results => {
|
|
|
|
|
// we have received all requested records that match the filters,
|
|
|
|
|
// we now park them within current scope and hide the `_cid` attribute.
|
|
|
|
|
results = _results.map(r => (0, _utils.omitKeys)(r, ["_cid"]));
|
2016-07-13 19:09:42 +00:00
|
|
|
|
});
|
2018-09-19 10:16:41 +00:00
|
|
|
|
}); // The resulting list of records is sorted.
|
2018-02-16 20:08:18 +00:00
|
|
|
|
// XXX: with some efforts, this could be fully implemented using IDB API.
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
return params.order ? (0, _utils.sortObjects)(params.order, results) : results;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this._handleError("list", e);
|
|
|
|
|
}
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Store the lastModified value into metadata store.
|
|
|
|
|
*
|
|
|
|
|
* @override
|
|
|
|
|
* @param {Number} lastModified
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
async saveLastModified(lastModified) {
|
|
|
|
|
const value = parseInt(lastModified, 10) || null;
|
2016-07-13 19:09:42 +00:00
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
try {
|
|
|
|
|
await this.prepare("timestamps", store => store.put({
|
|
|
|
|
cid: this.cid,
|
|
|
|
|
value
|
|
|
|
|
}), {
|
|
|
|
|
mode: "readwrite"
|
|
|
|
|
});
|
|
|
|
|
return value;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this._handleError("saveLastModified", e);
|
|
|
|
|
}
|
|
|
|
|
}
|
2016-07-13 19:09:42 +00:00
|
|
|
|
/**
|
|
|
|
|
* Retrieve saved lastModified value.
|
|
|
|
|
*
|
|
|
|
|
* @override
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
async getLastModified() {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
try {
|
|
|
|
|
let entry = null;
|
|
|
|
|
await this.prepare("timestamps", store => {
|
|
|
|
|
store.get(this.cid).onsuccess = e => entry = e.target.result;
|
|
|
|
|
});
|
|
|
|
|
return entry ? entry.value : null;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this._handleError("getLastModified", e);
|
|
|
|
|
}
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Load a dump of records exported from a server.
|
|
|
|
|
*
|
|
|
|
|
* @abstract
|
2018-02-16 20:08:18 +00:00
|
|
|
|
* @param {Array} records The records to load.
|
2016-07-13 19:09:42 +00:00
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
async loadDump(records) {
|
|
|
|
|
try {
|
|
|
|
|
await this.execute(transaction => {
|
|
|
|
|
records.forEach(record => transaction.update(record));
|
|
|
|
|
});
|
|
|
|
|
const previousLastModified = await this.getLastModified();
|
|
|
|
|
const lastModified = Math.max(...records.map(record => record.last_modified));
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
if (lastModified > previousLastModified) {
|
|
|
|
|
await this.saveLastModified(lastModified);
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
return records;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this._handleError("loadDump", e);
|
|
|
|
|
}
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
/**
|
|
|
|
|
* IDB transaction proxy.
|
|
|
|
|
*
|
|
|
|
|
* @param {IDB} adapter The call IDB adapter
|
|
|
|
|
* @param {IDBStore} store The IndexedDB database store.
|
|
|
|
|
* @param {Array} preloaded The list of records to make available to
|
|
|
|
|
* get() (default: []).
|
|
|
|
|
* @return {Object}
|
|
|
|
|
*/
|
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
exports.default = IDB;
|
2016-07-13 19:09:42 +00:00
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
function transactionProxy(adapter, store, preloaded = []) {
|
|
|
|
|
const _cid = adapter.cid;
|
2016-07-13 19:09:42 +00:00
|
|
|
|
return {
|
|
|
|
|
create(record) {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
store.add({ ...record,
|
|
|
|
|
_cid
|
|
|
|
|
});
|
2016-07-13 19:09:42 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
update(record) {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
store.put({ ...record,
|
|
|
|
|
_cid
|
|
|
|
|
});
|
2016-07-13 19:09:42 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
delete(id) {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
store.delete([_cid, id]);
|
2016-07-13 19:09:42 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
get(id) {
|
|
|
|
|
return preloaded[id];
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
};
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
/**
|
|
|
|
|
* Up to version 10.X of kinto.js, each collection had its own collection.
|
|
|
|
|
* The database name was `${bid}/${cid}` (eg. `"blocklists/certificates"`)
|
|
|
|
|
* and contained only one store with the same name.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function migrationRequired(dbName) {
|
|
|
|
|
let exists = true;
|
|
|
|
|
const db = await open(dbName, {
|
|
|
|
|
version: 1,
|
|
|
|
|
onupgradeneeded: event => {
|
|
|
|
|
exists = false;
|
|
|
|
|
}
|
|
|
|
|
}); // Check that the DB we're looking at is really a legacy one,
|
|
|
|
|
// and not some remainder of the open() operation above.
|
|
|
|
|
|
|
|
|
|
exists &= db.objectStoreNames.contains("__meta__") && db.objectStoreNames.contains(dbName);
|
|
|
|
|
|
|
|
|
|
if (!exists) {
|
|
|
|
|
db.close(); // Testing the existence creates it, so delete it :)
|
|
|
|
|
|
|
|
|
|
await deleteDatabase(dbName);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.warn(`${dbName}: old IndexedDB database found.`);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Scan all records.
|
|
|
|
|
let records;
|
|
|
|
|
await execute(db, dbName, store => {
|
|
|
|
|
store.openCursor().onsuccess = cursorHandlers.all({}, res => records = res);
|
|
|
|
|
});
|
|
|
|
|
console.log(`${dbName}: found ${records.length} records.`); // Check if there's a entry for this.
|
|
|
|
|
|
|
|
|
|
let timestamp = null;
|
|
|
|
|
await execute(db, "__meta__", store => {
|
|
|
|
|
store.get(`${dbName}-lastModified`).onsuccess = e => {
|
|
|
|
|
timestamp = e.target.result ? e.target.result.value : null;
|
|
|
|
|
};
|
|
|
|
|
}); // Some previous versions, also used to store the timestamps without prefix.
|
|
|
|
|
|
|
|
|
|
if (!timestamp) {
|
|
|
|
|
await execute(db, "__meta__", store => {
|
|
|
|
|
store.get("lastModified").onsuccess = e => {
|
|
|
|
|
timestamp = e.target.result ? e.target.result.value : null;
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`${dbName}: ${timestamp ? "found" : "no"} timestamp.`); // Those will be inserted in the new database/schema.
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
records,
|
|
|
|
|
timestamp
|
|
|
|
|
};
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error("Error occured during migration", e);
|
|
|
|
|
return null;
|
|
|
|
|
} finally {
|
|
|
|
|
db.close();
|
|
|
|
|
}
|
|
|
|
|
}
|
2016-07-13 19:09:42 +00:00
|
|
|
|
|
2016-11-16 00:38:53 +00:00
|
|
|
|
},{"../utils":7,"./base.js":5}],5:[function(require,module,exports){
|
2016-04-14 23:07:31 +00:00
|
|
|
|
"use strict";
|
|
|
|
|
/**
|
|
|
|
|
* Base db adapter.
|
|
|
|
|
*
|
|
|
|
|
* @abstract
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
Object.defineProperty(exports, "__esModule", {
|
|
|
|
|
value: true
|
|
|
|
|
});
|
2018-09-19 10:16:41 +00:00
|
|
|
|
exports.default = void 0;
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
class BaseAdapter {
|
|
|
|
|
/**
|
|
|
|
|
* Deletes every records present in the database.
|
|
|
|
|
*
|
|
|
|
|
* @abstract
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
|
|
|
|
clear() {
|
|
|
|
|
throw new Error("Not Implemented.");
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Executes a batch of operations within a single transaction.
|
|
|
|
|
*
|
|
|
|
|
* @abstract
|
|
|
|
|
* @param {Function} callback The operation callback.
|
|
|
|
|
* @param {Object} options The options object.
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
execute(callback, options = {
|
|
|
|
|
preload: []
|
|
|
|
|
}) {
|
2016-04-14 23:07:31 +00:00
|
|
|
|
throw new Error("Not Implemented.");
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Retrieve a record by its primary key from the database.
|
|
|
|
|
*
|
|
|
|
|
* @abstract
|
|
|
|
|
* @param {String} id The record id.
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
get(id) {
|
|
|
|
|
throw new Error("Not Implemented.");
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Lists all records from the database.
|
|
|
|
|
*
|
|
|
|
|
* @abstract
|
|
|
|
|
* @param {Object} params The filters and order to apply to the results.
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
list(params = {
|
|
|
|
|
filters: {},
|
|
|
|
|
order: ""
|
|
|
|
|
}) {
|
2016-04-14 23:07:31 +00:00
|
|
|
|
throw new Error("Not Implemented.");
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Store the lastModified value.
|
|
|
|
|
*
|
|
|
|
|
* @abstract
|
|
|
|
|
* @param {Number} lastModified
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
saveLastModified(lastModified) {
|
|
|
|
|
throw new Error("Not Implemented.");
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Retrieve saved lastModified value.
|
|
|
|
|
*
|
|
|
|
|
* @abstract
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
getLastModified() {
|
|
|
|
|
throw new Error("Not Implemented.");
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Load a dump of records exported from a server.
|
|
|
|
|
*
|
|
|
|
|
* @abstract
|
2018-02-16 20:08:18 +00:00
|
|
|
|
* @param {Array} records The records to load.
|
2016-04-14 23:07:31 +00:00
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
loadDump(records) {
|
|
|
|
|
throw new Error("Not Implemented.");
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
exports.default = BaseAdapter;
|
|
|
|
|
|
2016-11-16 00:38:53 +00:00
|
|
|
|
},{}],6:[function(require,module,exports){
|
2016-04-14 23:07:31 +00:00
|
|
|
|
"use strict";
|
|
|
|
|
|
|
|
|
|
Object.defineProperty(exports, "__esModule", {
|
|
|
|
|
value: true
|
|
|
|
|
});
|
2016-07-13 19:09:42 +00:00
|
|
|
|
exports.recordsEqual = recordsEqual;
|
2018-09-19 10:16:41 +00:00
|
|
|
|
exports.CollectionTransaction = exports.default = exports.SyncResultObject = void 0;
|
2016-04-14 23:07:31 +00:00
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
var _base = _interopRequireDefault(require("./adapters/base"));
|
2016-04-14 23:07:31 +00:00
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
var _IDB = _interopRequireDefault(require("./adapters/IDB"));
|
2016-07-13 19:09:42 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
var _utils = require("./utils");
|
|
|
|
|
|
|
|
|
|
var _uuid = require("uuid");
|
|
|
|
|
|
|
|
|
|
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
|
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
const RECORD_FIELDS_TO_CLEAN = ["_status"];
|
2016-04-14 23:07:31 +00:00
|
|
|
|
const AVAILABLE_HOOKS = ["incoming-changes"];
|
|
|
|
|
/**
|
2016-07-13 19:09:42 +00:00
|
|
|
|
* 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.
|
2018-02-16 20:08:18 +00:00
|
|
|
|
* @param {Array} localFields Additional fields to ignore during the comparison
|
2016-07-13 19:09:42 +00:00
|
|
|
|
* @return {boolean}
|
2016-04-14 23:07:31 +00:00
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
function recordsEqual(a, b, localFields = []) {
|
|
|
|
|
const fieldsToClean = RECORD_FIELDS_TO_CLEAN.concat(["last_modified"]).concat(localFields);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
const cleanLocal = r => (0, _utils.omitKeys)(r, fieldsToClean);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
return (0, _utils.deepEqual)(cleanLocal(a), cleanLocal(b));
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Synchronization result object.
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
class SyncResultObject {
|
|
|
|
|
/**
|
|
|
|
|
* Object default values.
|
|
|
|
|
* @type {Object}
|
|
|
|
|
*/
|
|
|
|
|
static get defaults() {
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
lastModified: null,
|
|
|
|
|
errors: [],
|
|
|
|
|
created: [],
|
|
|
|
|
updated: [],
|
|
|
|
|
deleted: [],
|
|
|
|
|
published: [],
|
|
|
|
|
conflicts: [],
|
|
|
|
|
skipped: [],
|
|
|
|
|
resolved: []
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Public constructor.
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
constructor() {
|
|
|
|
|
/**
|
|
|
|
|
* Current synchronization result status; becomes `false` when conflicts or
|
|
|
|
|
* errors are registered.
|
|
|
|
|
* @type {Boolean}
|
|
|
|
|
*/
|
|
|
|
|
this.ok = true;
|
2016-11-16 00:38:53 +00:00
|
|
|
|
Object.assign(this, SyncResultObject.defaults);
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Adds entries for a given result type.
|
|
|
|
|
*
|
|
|
|
|
* @param {String} type The result type.
|
|
|
|
|
* @param {Array} entries The result entries.
|
|
|
|
|
* @return {SyncResultObject}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
add(type, entries) {
|
|
|
|
|
if (!Array.isArray(this[type])) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-04-20 02:35:22 +00:00
|
|
|
|
if (!Array.isArray(entries)) {
|
|
|
|
|
entries = [entries];
|
2018-09-19 10:16:41 +00:00
|
|
|
|
} // Deduplicate entries by id. If the values don't have `id` attribute, just
|
2016-10-07 14:27:48 +00:00
|
|
|
|
// keep all.
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2018-04-20 02:35:22 +00:00
|
|
|
|
const recordsWithoutId = new Set();
|
|
|
|
|
const recordsById = new Map();
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-04-20 02:35:22 +00:00
|
|
|
|
function addOneRecord(record) {
|
|
|
|
|
if (!record.id) {
|
|
|
|
|
recordsWithoutId.add(record);
|
|
|
|
|
} else {
|
|
|
|
|
recordsById.set(record.id, record);
|
|
|
|
|
}
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-04-20 02:35:22 +00:00
|
|
|
|
this[type].forEach(addOneRecord);
|
|
|
|
|
entries.forEach(addOneRecord);
|
|
|
|
|
this[type] = Array.from(recordsById.values()).concat(Array.from(recordsWithoutId));
|
2016-04-14 23:07:31 +00:00
|
|
|
|
this.ok = this.errors.length + this.conflicts.length === 0;
|
|
|
|
|
return this;
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Reinitializes result entries for a given result type.
|
|
|
|
|
*
|
|
|
|
|
* @param {String} type The result type.
|
|
|
|
|
* @return {SyncResultObject}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
reset(type) {
|
|
|
|
|
this[type] = SyncResultObject.defaults[type];
|
|
|
|
|
this.ok = this.errors.length + this.conflicts.length === 0;
|
|
|
|
|
return this;
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
exports.SyncResultObject = SyncResultObject;
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
function createUUIDSchema() {
|
|
|
|
|
return {
|
|
|
|
|
generate() {
|
|
|
|
|
return (0, _uuid.v4)();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
validate(id) {
|
2018-07-11 12:50:47 +00:00
|
|
|
|
return typeof id == "string" && _utils.RE_RECORD_ID.test(id);
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function markStatus(record, status) {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return { ...record,
|
|
|
|
|
_status: status
|
|
|
|
|
};
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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.
|
2016-07-13 19:09:42 +00:00
|
|
|
|
* @param {Array<String>} localFields The list of fields that remain local.
|
2016-04-14 23:07:31 +00:00
|
|
|
|
* @return {Object}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
function importChange(transaction, remote, localFields) {
|
2016-04-14 23:07:31 +00:00
|
|
|
|
const local = transaction.get(remote.id);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
if (!local) {
|
|
|
|
|
// Not found locally but remote change is marked as deleted; skip to
|
|
|
|
|
// avoid recreation.
|
|
|
|
|
if (remote.deleted) {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return {
|
|
|
|
|
type: "skipped",
|
|
|
|
|
data: remote
|
|
|
|
|
};
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
const synced = markSynced(remote);
|
|
|
|
|
transaction.create(synced);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return {
|
|
|
|
|
type: "created",
|
|
|
|
|
data: synced
|
|
|
|
|
};
|
|
|
|
|
} // Compare local and remote, ignoring local fields.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const isIdentical = recordsEqual(local, remote, localFields); // Apply remote changes on local record.
|
|
|
|
|
|
|
|
|
|
const synced = { ...local,
|
|
|
|
|
...markSynced(remote)
|
|
|
|
|
}; // Detect or ignore conflicts if record has also been modified locally.
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
if (local._status !== "synced") {
|
|
|
|
|
// Locally deleted, unsynced: scheduled for remote deletion.
|
|
|
|
|
if (local._status === "deleted") {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return {
|
|
|
|
|
type: "skipped",
|
|
|
|
|
data: local
|
|
|
|
|
};
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (isIdentical) {
|
2016-04-14 23:07:31 +00:00
|
|
|
|
// If records are identical, import anyway, so we bump the
|
|
|
|
|
// local last_modified value from the server and set record
|
|
|
|
|
// status to "synced".
|
|
|
|
|
transaction.update(synced);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return {
|
|
|
|
|
type: "updated",
|
|
|
|
|
data: {
|
|
|
|
|
old: local,
|
|
|
|
|
new: synced
|
|
|
|
|
}
|
|
|
|
|
};
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
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.
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return {
|
|
|
|
|
type: "void"
|
|
|
|
|
};
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
return {
|
|
|
|
|
type: "conflicts",
|
2018-09-19 10:16:41 +00:00
|
|
|
|
data: {
|
|
|
|
|
type: "incoming",
|
|
|
|
|
local: local,
|
|
|
|
|
remote: remote
|
|
|
|
|
}
|
2016-04-14 23:07:31 +00:00
|
|
|
|
};
|
2018-09-19 10:16:41 +00:00
|
|
|
|
} // Local record was synced.
|
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
if (remote.deleted) {
|
|
|
|
|
transaction.delete(remote.id);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return {
|
|
|
|
|
type: "deleted",
|
|
|
|
|
data: local
|
|
|
|
|
};
|
|
|
|
|
} // Import locally.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
transaction.update(synced); // if identical, simply exclude it from all SyncResultObject lists
|
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
const type = isIdentical ? "void" : "updated";
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return {
|
|
|
|
|
type,
|
|
|
|
|
data: {
|
|
|
|
|
old: local,
|
|
|
|
|
new: synced
|
|
|
|
|
}
|
|
|
|
|
};
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Abstracts a collection of records stored in the local database, providing
|
|
|
|
|
* CRUD operations and synchronization helpers.
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
class Collection {
|
|
|
|
|
/**
|
|
|
|
|
* Constructor.
|
|
|
|
|
*
|
|
|
|
|
* Options:
|
|
|
|
|
* - `{BaseAdapter} adapter` The DB adapter (default: `IDB`)
|
|
|
|
|
*
|
|
|
|
|
* @param {String} bucket The bucket identifier.
|
|
|
|
|
* @param {String} name The collection name.
|
|
|
|
|
* @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;
|
2018-09-19 10:16:41 +00:00
|
|
|
|
const DBAdapter = options.adapter || _IDB.default;
|
2016-04-14 23:07:31 +00:00
|
|
|
|
|
|
|
|
|
if (!DBAdapter) {
|
|
|
|
|
throw new Error("No adapter provided");
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
const db = new DBAdapter(`${bucket}/${name}`, options.adapterOptions);
|
|
|
|
|
|
|
|
|
|
if (!(db instanceof _base.default)) {
|
2016-04-14 23:07:31 +00:00
|
|
|
|
throw new Error("Unsupported adapter.");
|
2018-09-19 10:16:41 +00:00
|
|
|
|
} // public properties
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
/**
|
|
|
|
|
* The db adapter instance
|
|
|
|
|
* @type {BaseAdapter}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
this.db = db;
|
|
|
|
|
/**
|
|
|
|
|
* The Api instance.
|
|
|
|
|
* @type {KintoClient}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
this.api = api;
|
|
|
|
|
/**
|
|
|
|
|
* The event emitter instance.
|
|
|
|
|
* @type {EventEmitter}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
this.events = options.events;
|
|
|
|
|
/**
|
|
|
|
|
* The IdSchema instance.
|
|
|
|
|
* @type {Object}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
this.idSchema = this._validateIdSchema(options.idSchema);
|
|
|
|
|
/**
|
|
|
|
|
* The list of remote transformers.
|
|
|
|
|
* @type {Array}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
this.remoteTransformers = this._validateRemoteTransformers(options.remoteTransformers);
|
|
|
|
|
/**
|
|
|
|
|
* The list of hooks.
|
|
|
|
|
* @type {Object}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
this.hooks = this._validateHooks(options.hooks);
|
2016-07-13 19:09:42 +00:00
|
|
|
|
/**
|
|
|
|
|
* The list of fields names that will remain local.
|
|
|
|
|
* @type {Array}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
this.localFields = options.localFields || [];
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* The collection name.
|
|
|
|
|
* @type {String}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
get name() {
|
|
|
|
|
return this._name;
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* The bucket name.
|
|
|
|
|
* @type {String}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
get bucket() {
|
|
|
|
|
return this._bucket;
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* The last modified timestamp.
|
|
|
|
|
* @type {Number}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
get lastModified() {
|
|
|
|
|
return this._lastModified;
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Synchronization strategies. Available strategies are:
|
|
|
|
|
*
|
|
|
|
|
* - `MANUAL`: Conflicts will be reported in a dedicated array.
|
|
|
|
|
* - `SERVER_WINS`: Conflicts are resolved using remote data.
|
|
|
|
|
* - `CLIENT_WINS`: Conflicts are resolved using local data.
|
|
|
|
|
*
|
|
|
|
|
* @type {Object}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
static get strategy() {
|
|
|
|
|
return {
|
|
|
|
|
CLIENT_WINS: "client_wins",
|
|
|
|
|
SERVER_WINS: "server_wins",
|
|
|
|
|
MANUAL: "manual"
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Validates an idSchema.
|
|
|
|
|
*
|
|
|
|
|
* @param {Object|undefined} idSchema
|
|
|
|
|
* @return {Object}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
_validateIdSchema(idSchema) {
|
|
|
|
|
if (typeof idSchema === "undefined") {
|
|
|
|
|
return createUUIDSchema();
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
if (typeof idSchema !== "object") {
|
|
|
|
|
throw new Error("idSchema must be an object.");
|
|
|
|
|
} else if (typeof idSchema.generate !== "function") {
|
|
|
|
|
throw new Error("idSchema must provide a generate function.");
|
|
|
|
|
} else if (typeof idSchema.validate !== "function") {
|
|
|
|
|
throw new Error("idSchema must provide a validate function.");
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
return idSchema;
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Validates a list of remote transformers.
|
|
|
|
|
*
|
|
|
|
|
* @param {Array|undefined} remoteTransformers
|
|
|
|
|
* @return {Array}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
_validateRemoteTransformers(remoteTransformers) {
|
|
|
|
|
if (typeof remoteTransformers === "undefined") {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
if (!Array.isArray(remoteTransformers)) {
|
|
|
|
|
throw new Error("remoteTransformers should be an array.");
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
return remoteTransformers.map(transformer => {
|
|
|
|
|
if (typeof transformer !== "object") {
|
|
|
|
|
throw new Error("A transformer must be an object.");
|
|
|
|
|
} else if (typeof transformer.encode !== "function") {
|
|
|
|
|
throw new Error("A transformer must provide an encode function.");
|
|
|
|
|
} else if (typeof transformer.decode !== "function") {
|
|
|
|
|
throw new Error("A transformer must provide a decode function.");
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
return transformer;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Validate the passed hook is correct.
|
|
|
|
|
*
|
|
|
|
|
* @param {Array|undefined} hook.
|
|
|
|
|
* @return {Array}
|
|
|
|
|
**/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
_validateHook(hook) {
|
|
|
|
|
if (!Array.isArray(hook)) {
|
|
|
|
|
throw new Error("A hook definition should be an array of functions.");
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
return hook.map(fn => {
|
|
|
|
|
if (typeof fn !== "function") {
|
|
|
|
|
throw new Error("A hook definition should be an array of functions.");
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
return fn;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Validates a list of hooks.
|
|
|
|
|
*
|
|
|
|
|
* @param {Object|undefined} hooks
|
|
|
|
|
* @return {Object}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
_validateHooks(hooks) {
|
|
|
|
|
if (typeof hooks === "undefined") {
|
|
|
|
|
return {};
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
if (Array.isArray(hooks)) {
|
|
|
|
|
throw new Error("hooks should be an object, not an array.");
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
if (typeof hooks !== "object") {
|
|
|
|
|
throw new Error("hooks should be an object.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const validatedHooks = {};
|
|
|
|
|
|
2017-05-11 17:15:20 +00:00
|
|
|
|
for (const hook in hooks) {
|
2018-02-01 19:45:22 +00:00
|
|
|
|
if (!AVAILABLE_HOOKS.includes(hook)) {
|
2016-04-14 23:07:31 +00:00
|
|
|
|
throw new Error("The hook should be one of " + AVAILABLE_HOOKS.join(", "));
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
validatedHooks[hook] = this._validateHook(hooks[hook]);
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
return validatedHooks;
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Deletes every records in the current collection and marks the collection as
|
|
|
|
|
* never synced.
|
|
|
|
|
*
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
async clear() {
|
|
|
|
|
await this.db.clear();
|
|
|
|
|
await this.db.saveLastModified(null);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return {
|
|
|
|
|
data: [],
|
|
|
|
|
permissions: {}
|
|
|
|
|
};
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Encodes a record.
|
|
|
|
|
*
|
|
|
|
|
* @param {String} type Either "remote" or "local".
|
|
|
|
|
* @param {Object} record The record object to encode.
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
_encodeRecord(type, record) {
|
2017-01-28 00:23:05 +00:00
|
|
|
|
if (!this[`${type}Transformers`].length) {
|
2016-11-16 00:38:53 +00:00
|
|
|
|
return Promise.resolve(record);
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2017-01-28 00:23:05 +00:00
|
|
|
|
return (0, _utils.waterfall)(this[`${type}Transformers`].map(transformer => {
|
2016-04-14 23:07:31 +00:00
|
|
|
|
return record => transformer.encode(record);
|
|
|
|
|
}), record);
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Decodes a record.
|
|
|
|
|
*
|
|
|
|
|
* @param {String} type Either "remote" or "local".
|
|
|
|
|
* @param {Object} record The record object to decode.
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
_decodeRecord(type, record) {
|
2017-01-28 00:23:05 +00:00
|
|
|
|
if (!this[`${type}Transformers`].length) {
|
2016-11-16 00:38:53 +00:00
|
|
|
|
return Promise.resolve(record);
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2017-01-28 00:23:05 +00:00
|
|
|
|
return (0, _utils.waterfall)(this[`${type}Transformers`].reverse().map(transformer => {
|
2016-04-14 23:07:31 +00:00
|
|
|
|
return record => transformer.decode(record);
|
|
|
|
|
}), record);
|
|
|
|
|
}
|
|
|
|
|
/**
|
2016-07-13 19:09:42 +00:00
|
|
|
|
* Adds a record to the local database, asserting that none
|
|
|
|
|
* already exist with this ID.
|
2016-04-14 23:07:31 +00:00
|
|
|
|
*
|
|
|
|
|
* Note: If either the `useRecordId` or `synced` options are true, then the
|
|
|
|
|
* record object must contain the id field to be validated. If none of these
|
|
|
|
|
* options are true, an id is generated using the current IdSchema; in this
|
|
|
|
|
* case, the record passed must not have an id.
|
|
|
|
|
*
|
|
|
|
|
* Options:
|
|
|
|
|
* - {Boolean} synced Sets record status to "synced" (default: `false`).
|
|
|
|
|
* - {Boolean} useRecordId Forces the `id` field from the record to be used,
|
|
|
|
|
* instead of one that is generated automatically
|
|
|
|
|
* (default: `false`).
|
|
|
|
|
*
|
|
|
|
|
* @param {Object} record
|
|
|
|
|
* @param {Object} options
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
create(record, options = {
|
|
|
|
|
useRecordId: false,
|
|
|
|
|
synced: false
|
|
|
|
|
}) {
|
2016-07-13 19:09:42 +00:00
|
|
|
|
// 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.
|
2016-11-16 00:38:53 +00:00
|
|
|
|
const reject = msg => Promise.reject(new Error(msg));
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
if (typeof record !== "object") {
|
|
|
|
|
return reject("Record is not an object.");
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if ((options.synced || options.useRecordId) && !record.hasOwnProperty("id")) {
|
2016-04-14 23:07:31 +00:00
|
|
|
|
return reject("Missing required Id; synced and useRecordId options require one");
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (!options.synced && !options.useRecordId && record.hasOwnProperty("id")) {
|
2016-04-14 23:07:31 +00:00
|
|
|
|
return reject("Extraneous Id; can't create a record having one set.");
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
const newRecord = { ...record,
|
2018-02-16 20:08:18 +00:00
|
|
|
|
id: options.synced || options.useRecordId ? record.id : this.idSchema.generate(record),
|
2016-04-14 23:07:31 +00:00
|
|
|
|
_status: options.synced ? "synced" : "created"
|
2018-02-16 20:08:18 +00:00
|
|
|
|
};
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
if (!this.idSchema.validate(newRecord.id)) {
|
2017-01-28 00:23:05 +00:00
|
|
|
|
return reject(`Invalid Id: ${newRecord.id}`);
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2017-05-11 17:15:20 +00:00
|
|
|
|
return this.execute(txn => txn.create(newRecord), {
|
|
|
|
|
preloadIds: [newRecord.id]
|
|
|
|
|
}).catch(err => {
|
2016-04-14 23:07:31 +00:00
|
|
|
|
if (options.useRecordId) {
|
|
|
|
|
throw new Error("Couldn't create record. It may have been virtually deleted.");
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
throw err;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
/**
|
2016-07-13 19:09:42 +00:00
|
|
|
|
* Like {@link CollectionTransaction#update}, but wrapped in its own transaction.
|
2016-04-14 23:07:31 +00:00
|
|
|
|
*
|
|
|
|
|
* 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}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
update(record, options = {
|
|
|
|
|
synced: false,
|
|
|
|
|
patch: false
|
|
|
|
|
}) {
|
2016-07-13 19:09:42 +00:00
|
|
|
|
// 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.
|
2016-04-14 23:07:31 +00:00
|
|
|
|
if (typeof record !== "object") {
|
2016-11-16 00:38:53 +00:00
|
|
|
|
return Promise.reject(new Error("Record is not an object."));
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (!record.hasOwnProperty("id")) {
|
2016-11-16 00:38:53 +00:00
|
|
|
|
return Promise.reject(new Error("Cannot update a record missing id."));
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
if (!this.idSchema.validate(record.id)) {
|
2017-01-28 00:23:05 +00:00
|
|
|
|
return Promise.reject(new Error(`Invalid Id: ${record.id}`));
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
2016-07-13 19:09:42 +00:00
|
|
|
|
|
2017-05-11 17:15:20 +00:00
|
|
|
|
return this.execute(txn => txn.update(record, options), {
|
|
|
|
|
preloadIds: [record.id]
|
|
|
|
|
});
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
2016-07-13 19:09:42 +00:00
|
|
|
|
* Like {@link CollectionTransaction#upsert}, but wrapped in its own transaction.
|
|
|
|
|
*
|
|
|
|
|
* @param {Object} record
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
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") {
|
2016-11-16 00:38:53 +00:00
|
|
|
|
return Promise.reject(new Error("Record is not an object."));
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (!record.hasOwnProperty("id")) {
|
2016-11-16 00:38:53 +00:00
|
|
|
|
return Promise.reject(new Error("Cannot update a record missing id."));
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (!this.idSchema.validate(record.id)) {
|
2017-01-28 00:23:05 +00:00
|
|
|
|
return Promise.reject(new Error(`Invalid Id: ${record.id}`));
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return this.execute(txn => txn.upsert(record), {
|
|
|
|
|
preloadIds: [record.id]
|
|
|
|
|
});
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Like {@link CollectionTransaction#get}, but wrapped in its own transaction.
|
|
|
|
|
*
|
|
|
|
|
* Options:
|
|
|
|
|
* - {Boolean} includeDeleted: Include virtually deleted records.
|
2016-04-14 23:07:31 +00:00
|
|
|
|
*
|
|
|
|
|
* @param {String} id
|
|
|
|
|
* @param {Object} options
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
get(id, options = {
|
|
|
|
|
includeDeleted: false
|
|
|
|
|
}) {
|
|
|
|
|
return this.execute(txn => txn.get(id, options), {
|
|
|
|
|
preloadIds: [id]
|
|
|
|
|
});
|
|
|
|
|
}
|
2016-04-14 23:07:31 +00:00
|
|
|
|
/**
|
2016-07-13 19:09:42 +00:00
|
|
|
|
* Like {@link CollectionTransaction#getAny}, but wrapped in its own transaction.
|
|
|
|
|
*
|
|
|
|
|
* @param {String} id
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
getAny(id) {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return this.execute(txn => txn.getAny(id), {
|
|
|
|
|
preloadIds: [id]
|
|
|
|
|
});
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Same as {@link Collection#delete}, but wrapped in its own transaction.
|
2016-04-14 23:07:31 +00:00
|
|
|
|
*
|
|
|
|
|
* 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}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
delete(id, options = {
|
|
|
|
|
virtual: true
|
|
|
|
|
}) {
|
2016-07-13 19:09:42 +00:00
|
|
|
|
return this.execute(transaction => {
|
|
|
|
|
return transaction.delete(id, options);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
}, {
|
|
|
|
|
preloadIds: [id]
|
|
|
|
|
});
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
2018-03-20 18:29:50 +00:00
|
|
|
|
/**
|
|
|
|
|
* Same as {@link Collection#deleteAll}, but wrapped in its own transaction, execulding the parameter.
|
|
|
|
|
*
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2018-03-20 18:29:50 +00:00
|
|
|
|
async deleteAll() {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
const {
|
|
|
|
|
data
|
|
|
|
|
} = await this.list({}, {
|
|
|
|
|
includeDeleted: false
|
|
|
|
|
});
|
2018-03-20 18:29:50 +00:00
|
|
|
|
const recordIds = data.map(record => record.id);
|
|
|
|
|
return this.execute(transaction => {
|
|
|
|
|
return transaction.deleteAll(recordIds);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
}, {
|
|
|
|
|
preloadIds: recordIds
|
|
|
|
|
});
|
2018-03-20 18:29:50 +00:00
|
|
|
|
}
|
2016-07-13 19:09:42 +00:00
|
|
|
|
/**
|
|
|
|
|
* The same as {@link CollectionTransaction#deleteAny}, but wrapped
|
|
|
|
|
* in its own transaction.
|
|
|
|
|
*
|
|
|
|
|
* @param {String} id The record's Id.
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
deleteAny(id) {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return this.execute(txn => txn.deleteAny(id), {
|
|
|
|
|
preloadIds: [id]
|
|
|
|
|
});
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Lists records from the local database.
|
|
|
|
|
*
|
|
|
|
|
* Params:
|
|
|
|
|
* - {Object} filters Filter the results (default: `{}`).
|
|
|
|
|
* - {String} order The order to apply (default: `-last_modified`).
|
|
|
|
|
*
|
|
|
|
|
* Options:
|
|
|
|
|
* - {Boolean} includeDeleted: Include virtually deleted records.
|
|
|
|
|
*
|
|
|
|
|
* @param {Object} params The filters and order to apply to the results.
|
|
|
|
|
* @param {Object} options The options object.
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async list(params = {}, options = {
|
|
|
|
|
includeDeleted: false
|
|
|
|
|
}) {
|
|
|
|
|
params = {
|
|
|
|
|
order: "-last_modified",
|
|
|
|
|
filters: {},
|
|
|
|
|
...params
|
|
|
|
|
};
|
2018-02-16 20:08:18 +00:00
|
|
|
|
const results = await this.db.list(params);
|
|
|
|
|
let data = results;
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
if (!options.includeDeleted) {
|
|
|
|
|
data = results.filter(record => record._status !== "deleted");
|
|
|
|
|
}
|
2016-04-14 23:07:31 +00:00
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return {
|
|
|
|
|
data,
|
|
|
|
|
permissions: {}
|
|
|
|
|
};
|
|
|
|
|
}
|
2016-04-14 23:07:31 +00:00
|
|
|
|
/**
|
2016-10-07 14:27:48 +00:00
|
|
|
|
* Imports remote changes into the local database.
|
|
|
|
|
* This method is in charge of detecting the conflicts, and resolve them
|
|
|
|
|
* according to the specified strategy.
|
2016-04-14 23:07:31 +00:00
|
|
|
|
* @param {SyncResultObject} syncResultObject The sync result object.
|
2016-10-07 14:27:48 +00:00
|
|
|
|
* @param {Array} decodedChanges The list of changes to import in the local database.
|
|
|
|
|
* @param {String} strategy The {@link Collection.strategy} (default: MANUAL)
|
2016-04-14 23:07:31 +00:00
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
async importChanges(syncResultObject, decodedChanges, strategy = Collection.strategy.MANUAL) {
|
|
|
|
|
// Retrieve records matching change ids.
|
|
|
|
|
try {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
const {
|
|
|
|
|
imports,
|
|
|
|
|
resolved
|
|
|
|
|
} = await this.db.execute(transaction => {
|
2018-02-16 20:08:18 +00:00
|
|
|
|
const imports = decodedChanges.map(remote => {
|
|
|
|
|
// Store remote change into local database.
|
|
|
|
|
return importChange(transaction, remote, this.localFields);
|
2016-07-13 19:09:42 +00:00
|
|
|
|
});
|
2018-02-16 20:08:18 +00:00
|
|
|
|
const conflicts = imports.filter(i => i.type === "conflicts").map(i => i.data);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
const resolved = this._handleConflicts(transaction, conflicts, strategy);
|
2016-10-07 14:27:48 +00:00
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return {
|
|
|
|
|
imports,
|
|
|
|
|
resolved
|
|
|
|
|
};
|
|
|
|
|
}, {
|
|
|
|
|
preload: decodedChanges.map(record => record.id)
|
|
|
|
|
}); // Lists of created/updated/deleted records
|
|
|
|
|
|
|
|
|
|
imports.forEach(({
|
|
|
|
|
type,
|
|
|
|
|
data
|
|
|
|
|
}) => syncResultObject.add(type, data)); // Automatically resolved conflicts (if not manual)
|
2018-02-16 20:08:18 +00:00
|
|
|
|
|
|
|
|
|
if (resolved.length > 0) {
|
|
|
|
|
syncResultObject.reset("conflicts").add("resolved", resolved);
|
2016-10-07 14:27:48 +00:00
|
|
|
|
}
|
2018-02-16 20:08:18 +00:00
|
|
|
|
} catch (err) {
|
|
|
|
|
const data = {
|
|
|
|
|
type: "incoming",
|
|
|
|
|
message: err.message,
|
|
|
|
|
stack: err.stack
|
2018-09-19 10:16:41 +00:00
|
|
|
|
}; // XXX one error of the whole transaction instead of per atomic op
|
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
syncResultObject.add("errors", data);
|
|
|
|
|
}
|
2016-10-07 14:27:48 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
return syncResultObject;
|
2016-10-07 14:27:48 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Imports the responses of pushed changes into the local database.
|
|
|
|
|
* Basically it stores the timestamp assigned by the server into the local
|
|
|
|
|
* database.
|
|
|
|
|
* @param {SyncResultObject} syncResultObject The sync result object.
|
|
|
|
|
* @param {Array} toApplyLocally The list of changes to import in the local database.
|
|
|
|
|
* @param {Array} conflicts The list of conflicts that have to be resolved.
|
|
|
|
|
* @param {String} strategy The {@link Collection.strategy}.
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
async _applyPushedResults(syncResultObject, toApplyLocally, conflicts, strategy = Collection.strategy.MANUAL) {
|
|
|
|
|
const toDeleteLocally = toApplyLocally.filter(r => r.deleted);
|
|
|
|
|
const toUpdateLocally = toApplyLocally.filter(r => !r.deleted);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
const {
|
|
|
|
|
published,
|
|
|
|
|
resolved
|
|
|
|
|
} = await this.db.execute(transaction => {
|
2018-02-16 20:08:18 +00:00
|
|
|
|
const updated = toUpdateLocally.map(record => {
|
|
|
|
|
const synced = markSynced(record);
|
|
|
|
|
transaction.update(synced);
|
|
|
|
|
return synced;
|
2016-10-07 14:27:48 +00:00
|
|
|
|
});
|
2018-02-16 20:08:18 +00:00
|
|
|
|
const deleted = toDeleteLocally.map(record => {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
transaction.delete(record.id); // Amend result data with the deleted attribute set
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: record.id,
|
|
|
|
|
deleted: true
|
|
|
|
|
};
|
2016-10-07 14:27:48 +00:00
|
|
|
|
});
|
2018-09-19 10:16:41 +00:00
|
|
|
|
const published = updated.concat(deleted); // Handle conflicts, if any
|
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
const resolved = this._handleConflicts(transaction, conflicts, strategy);
|
2016-10-07 14:27:48 +00:00
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return {
|
|
|
|
|
published,
|
|
|
|
|
resolved
|
|
|
|
|
};
|
|
|
|
|
});
|
2018-02-16 20:08:18 +00:00
|
|
|
|
syncResultObject.add("published", published);
|
2016-10-07 14:27:48 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
if (resolved.length > 0) {
|
|
|
|
|
syncResultObject.reset("conflicts").reset("resolved").add("resolved", resolved);
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
return syncResultObject;
|
2016-10-07 14:27:48 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Handles synchronization conflicts according to specified strategy.
|
|
|
|
|
*
|
|
|
|
|
* @param {SyncResultObject} result The sync result object.
|
|
|
|
|
* @param {String} strategy The {@link Collection.strategy}.
|
2017-05-11 17:15:20 +00:00
|
|
|
|
* @return {Promise<Array<Object>>} The resolved conflicts, as an
|
|
|
|
|
* array of {accepted, rejected} objects
|
2016-10-07 14:27:48 +00:00
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-10-07 14:27:48 +00:00
|
|
|
|
_handleConflicts(transaction, conflicts, strategy) {
|
|
|
|
|
if (strategy === Collection.strategy.MANUAL) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-10-07 14:27:48 +00:00
|
|
|
|
return conflicts.map(conflict => {
|
|
|
|
|
const resolution = strategy === Collection.strategy.CLIENT_WINS ? conflict.local : conflict.remote;
|
2017-05-11 17:15:20 +00:00
|
|
|
|
const rejected = strategy === Collection.strategy.CLIENT_WINS ? conflict.remote : conflict.local;
|
|
|
|
|
let accepted, status, id;
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2017-05-11 17:15:20 +00:00
|
|
|
|
if (resolution === null) {
|
|
|
|
|
// We "resolved" with the server-side deletion. Delete locally.
|
|
|
|
|
// This only happens during SERVER_WINS because the local
|
|
|
|
|
// version of a record can never be null.
|
|
|
|
|
// We can get "null" from the remote side if we got a conflict
|
|
|
|
|
// and there is no remote version available; see kinto-http.js
|
|
|
|
|
// batch.js:aggregate.
|
|
|
|
|
transaction.delete(conflict.local.id);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
accepted = null; // The record was deleted, but that status is "synced" with
|
2017-05-11 17:15:20 +00:00
|
|
|
|
// the server, so we don't need to push the change.
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2017-05-11 17:15:20 +00:00
|
|
|
|
status = "synced";
|
|
|
|
|
id = conflict.local.id;
|
|
|
|
|
} else {
|
|
|
|
|
const updated = this._resolveRaw(conflict, resolution);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2017-05-11 17:15:20 +00:00
|
|
|
|
transaction.update(updated);
|
|
|
|
|
accepted = updated;
|
|
|
|
|
status = updated._status;
|
|
|
|
|
id = updated.id;
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
rejected,
|
|
|
|
|
accepted,
|
|
|
|
|
id,
|
|
|
|
|
_status: status
|
|
|
|
|
};
|
2016-04-14 23:07:31 +00:00
|
|
|
|
});
|
|
|
|
|
}
|
2016-07-13 19:09:42 +00:00
|
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
execute(doOperations, {
|
|
|
|
|
preloadIds = []
|
|
|
|
|
} = {}) {
|
2017-05-11 17:15:20 +00:00
|
|
|
|
for (const id of preloadIds) {
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (!this.idSchema.validate(id)) {
|
2017-01-28 00:23:05 +00:00
|
|
|
|
return Promise.reject(Error(`Invalid Id: ${id}`));
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.db.execute(transaction => {
|
|
|
|
|
const txn = new CollectionTransaction(this, transaction);
|
2016-08-25 18:13:15 +00:00
|
|
|
|
const result = doOperations(txn);
|
|
|
|
|
txn.emitEvents();
|
|
|
|
|
return result;
|
2018-09-19 10:16:41 +00:00
|
|
|
|
}, {
|
|
|
|
|
preload: preloadIds
|
|
|
|
|
});
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
2016-04-14 23:07:31 +00:00
|
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
async resetSyncStatus() {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
const unsynced = await this.list({
|
|
|
|
|
filters: {
|
|
|
|
|
_status: ["deleted", "synced"]
|
|
|
|
|
},
|
|
|
|
|
order: ""
|
|
|
|
|
}, {
|
|
|
|
|
includeDeleted: true
|
|
|
|
|
});
|
2018-02-16 20:08:18 +00:00
|
|
|
|
await this.db.execute(transaction => {
|
|
|
|
|
unsynced.data.forEach(record => {
|
|
|
|
|
if (record._status === "deleted") {
|
|
|
|
|
// Garbage collect deleted records.
|
|
|
|
|
transaction.delete(record.id);
|
|
|
|
|
} else {
|
|
|
|
|
// Records that were synced become «created».
|
2018-09-19 10:16:41 +00:00
|
|
|
|
transaction.update({ ...record,
|
2018-02-16 20:08:18 +00:00
|
|
|
|
last_modified: undefined,
|
|
|
|
|
_status: "created"
|
|
|
|
|
});
|
|
|
|
|
}
|
2016-04-14 23:07:31 +00:00
|
|
|
|
});
|
2018-02-16 20:08:18 +00:00
|
|
|
|
});
|
|
|
|
|
this._lastModified = null;
|
|
|
|
|
await this.db.saveLastModified(null);
|
|
|
|
|
return unsynced.data.length;
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Returns an object containing two lists:
|
|
|
|
|
*
|
|
|
|
|
* - `toDelete`: unsynced deleted records we can safely delete;
|
|
|
|
|
* - `toSync`: local updates to send to the server.
|
|
|
|
|
*
|
2016-08-25 18:13:15 +00:00
|
|
|
|
* @return {Promise}
|
2016-04-14 23:07:31 +00:00
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
async gatherLocalChanges() {
|
|
|
|
|
const unsynced = await this.list({
|
2018-09-19 10:16:41 +00:00
|
|
|
|
filters: {
|
|
|
|
|
_status: ["created", "updated"]
|
|
|
|
|
},
|
2018-02-16 20:08:18 +00:00
|
|
|
|
order: ""
|
|
|
|
|
});
|
2018-09-19 10:16:41 +00:00
|
|
|
|
const deleted = await this.list({
|
|
|
|
|
filters: {
|
|
|
|
|
_status: "deleted"
|
|
|
|
|
},
|
|
|
|
|
order: ""
|
|
|
|
|
}, {
|
|
|
|
|
includeDeleted: true
|
|
|
|
|
});
|
2018-02-16 20:08:18 +00:00
|
|
|
|
return await Promise.all(unsynced.data.concat(deleted.data).map(this._encodeRecord.bind(this, "remote")));
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* 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.
|
2018-10-09 14:05:01 +00:00
|
|
|
|
* - {String} expectedTimestamp: A timestamp to use as a "cache busting" query parameter.
|
|
|
|
|
* - {Array<String>} exclude: A list of record ids to exclude from pull.
|
|
|
|
|
* - {Object} headers: The HTTP headers to use in the request.
|
|
|
|
|
* - {int} retry: The number of retries to do if the HTTP request fails.
|
|
|
|
|
* - {int} lastModified: The timestamp to use in `?_since` query.
|
2016-04-14 23:07:31 +00:00
|
|
|
|
*
|
2016-07-13 19:09:42 +00:00
|
|
|
|
* @param {KintoClient.Collection} client Kinto client Collection instance.
|
|
|
|
|
* @param {SyncResultObject} syncResultObject The sync result object.
|
2018-10-09 14:05:01 +00:00
|
|
|
|
* @param {Object} options The options object.
|
2016-04-14 23:07:31 +00:00
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
async pullChanges(client, syncResultObject, options = {}) {
|
|
|
|
|
if (!syncResultObject.ok) {
|
|
|
|
|
return syncResultObject;
|
|
|
|
|
}
|
2016-10-07 14:27:48 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
const since = this.lastModified ? this.lastModified : await this.db.getLastModified();
|
|
|
|
|
options = {
|
|
|
|
|
strategy: Collection.strategy.MANUAL,
|
|
|
|
|
lastModified: since,
|
|
|
|
|
headers: {},
|
|
|
|
|
...options
|
2018-09-19 10:16:41 +00:00
|
|
|
|
}; // Optionally ignore some records when pulling for changes.
|
2018-02-16 20:08:18 +00:00
|
|
|
|
// (avoid redownloading our own changes on last step of #sync())
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
let filters;
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
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(",");
|
2018-09-19 10:16:41 +00:00
|
|
|
|
filters = {
|
|
|
|
|
exclude_id
|
|
|
|
|
};
|
2018-10-09 14:05:01 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (options.expectedTimestamp) {
|
|
|
|
|
filters = { ...filters,
|
|
|
|
|
_expected: options.expectedTimestamp
|
|
|
|
|
};
|
2018-09-19 10:16:41 +00:00
|
|
|
|
} // First fetch remote changes from the server
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
data,
|
|
|
|
|
last_modified
|
|
|
|
|
} = await client.listRecords({
|
2018-02-16 20:08:18 +00:00
|
|
|
|
// Since should be ETag (see https://github.com/Kinto/kinto.js/issues/356)
|
|
|
|
|
since: options.lastModified ? `${options.lastModified}` : undefined,
|
|
|
|
|
headers: options.headers,
|
|
|
|
|
retry: options.retry,
|
2018-03-20 18:29:50 +00:00
|
|
|
|
// Fetch every page by default (FIXME: option to limit pages, see #277)
|
|
|
|
|
pages: Infinity,
|
2018-02-16 20:08:18 +00:00
|
|
|
|
filters
|
2018-09-19 10:16:41 +00:00
|
|
|
|
}); // last_modified is the ETag header value (string).
|
2018-02-16 20:08:18 +00:00
|
|
|
|
// For retro-compatibility with first kinto.js versions
|
|
|
|
|
// parse it to integer.
|
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
const unquoted = last_modified ? parseInt(last_modified, 10) : undefined; // Check if server was flushed.
|
2018-02-16 20:08:18 +00:00
|
|
|
|
// This is relevant for the Kinto demo server
|
|
|
|
|
// (and thus for many new comers).
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
const localSynced = options.lastModified;
|
|
|
|
|
const serverChanged = unquoted > options.lastModified;
|
|
|
|
|
const emptyCollection = data.length === 0;
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
if (!options.exclude && localSynced && serverChanged && emptyCollection) {
|
|
|
|
|
throw Error("Server has been flushed.");
|
|
|
|
|
}
|
2016-10-07 14:27:48 +00:00
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
syncResultObject.lastModified = unquoted; // Decode incoming changes.
|
2018-02-16 20:08:18 +00:00
|
|
|
|
|
|
|
|
|
const decodedChanges = await Promise.all(data.map(change => {
|
|
|
|
|
return this._decodeRecord("remote", change);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
})); // Hook receives decoded records.
|
|
|
|
|
|
|
|
|
|
const payload = {
|
|
|
|
|
lastModified: unquoted,
|
|
|
|
|
changes: decodedChanges
|
|
|
|
|
};
|
|
|
|
|
const afterHooks = await this.applyHook("incoming-changes", payload); // No change, nothing to import.
|
2018-02-16 20:08:18 +00:00
|
|
|
|
|
|
|
|
|
if (afterHooks.changes.length > 0) {
|
|
|
|
|
// Reflect these changes locally
|
|
|
|
|
await this.importChanges(syncResultObject, afterHooks.changes, options.strategy);
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
return syncResultObject;
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
applyHook(hookName, payload) {
|
|
|
|
|
if (typeof this.hooks[hookName] == "undefined") {
|
2016-11-16 00:38:53 +00:00
|
|
|
|
return Promise.resolve(payload);
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
return (0, _utils.waterfall)(this.hooks[hookName].map(hook => {
|
2016-07-13 19:09:42 +00:00
|
|
|
|
return record => {
|
|
|
|
|
const result = hook(payload, this);
|
|
|
|
|
const resultThenable = result && typeof result.then === "function";
|
|
|
|
|
const resultChanges = result && result.hasOwnProperty("changes");
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (!(resultThenable || resultChanges)) {
|
2017-01-28 00:23:05 +00:00
|
|
|
|
throw new Error(`Invalid return value for hook: ${JSON.stringify(result)} has no 'then()' or 'changes' properties`);
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
return result;
|
|
|
|
|
};
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}), payload);
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Publish local changes to the remote server and updates the passed
|
|
|
|
|
* {@link SyncResultObject} with publication results.
|
|
|
|
|
*
|
2018-10-09 14:05:01 +00:00
|
|
|
|
* Options:
|
|
|
|
|
* - {String} strategy: The selected sync strategy.
|
|
|
|
|
* - {Object} headers: The HTTP headers to use in the request.
|
|
|
|
|
* - {int} retry: The number of retries to do if the HTTP request fails.
|
|
|
|
|
*
|
2016-07-13 19:09:42 +00:00
|
|
|
|
* @param {KintoClient.Collection} client Kinto client Collection instance.
|
|
|
|
|
* @param {SyncResultObject} syncResultObject The sync result object.
|
2016-10-07 14:27:48 +00:00
|
|
|
|
* @param {Object} changes The change object.
|
|
|
|
|
* @param {Array} changes.toDelete The list of records to delete.
|
|
|
|
|
* @param {Array} changes.toSync The list of records to create/update.
|
2016-07-13 19:09:42 +00:00
|
|
|
|
* @param {Object} options The options object.
|
2016-04-14 23:07:31 +00:00
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
async pushChanges(client, changes, syncResultObject, options = {}) {
|
|
|
|
|
if (!syncResultObject.ok) {
|
|
|
|
|
return syncResultObject;
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
const safe = !options.strategy || options.strategy !== Collection.CLIENT_WINS;
|
|
|
|
|
const toDelete = changes.filter(r => r._status == "deleted");
|
2018-09-19 10:16:41 +00:00
|
|
|
|
const toSync = changes.filter(r => r._status != "deleted"); // Perform a batch request with every changes.
|
2018-02-16 20:08:18 +00:00
|
|
|
|
|
|
|
|
|
const synced = await client.batch(batch => {
|
|
|
|
|
toDelete.forEach(r => {
|
|
|
|
|
// never published locally deleted records should not be pusblished
|
|
|
|
|
if (r.last_modified) {
|
|
|
|
|
batch.deleteRecord(r);
|
|
|
|
|
}
|
2016-07-13 19:09:42 +00:00
|
|
|
|
});
|
2018-02-16 20:08:18 +00:00
|
|
|
|
toSync.forEach(r => {
|
|
|
|
|
// Clean local fields (like _status) before sending to server.
|
|
|
|
|
const published = this.cleanLocalFields(r);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
if (r._status === "created") {
|
|
|
|
|
batch.createRecord(published);
|
|
|
|
|
} else {
|
|
|
|
|
batch.updateRecord(published);
|
|
|
|
|
}
|
2016-04-14 23:07:31 +00:00
|
|
|
|
});
|
2018-02-16 20:08:18 +00:00
|
|
|
|
}, {
|
|
|
|
|
headers: options.headers,
|
|
|
|
|
retry: options.retry,
|
|
|
|
|
safe,
|
|
|
|
|
aggregate: true
|
2018-09-19 10:16:41 +00:00
|
|
|
|
}); // Store outgoing errors into sync result object
|
2016-10-07 14:27:48 +00:00
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
syncResultObject.add("errors", synced.errors.map(e => ({ ...e,
|
|
|
|
|
type: "outgoing"
|
|
|
|
|
}))); // Store outgoing conflicts into sync result object
|
2018-02-16 20:08:18 +00:00
|
|
|
|
|
|
|
|
|
const conflicts = [];
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
for (const {
|
|
|
|
|
type,
|
|
|
|
|
local,
|
|
|
|
|
remote
|
|
|
|
|
} of synced.conflicts) {
|
2018-02-16 20:08:18 +00:00
|
|
|
|
// Note: we ensure that local data are actually available, as they may
|
|
|
|
|
// be missing in the case of a published deletion.
|
2018-09-19 10:16:41 +00:00
|
|
|
|
const safeLocal = local && local.data || {
|
|
|
|
|
id: remote.id
|
|
|
|
|
};
|
|
|
|
|
const realLocal = await this._decodeRecord("remote", safeLocal); // We can get "null" from the remote side if we got a conflict
|
2018-02-16 20:08:18 +00:00
|
|
|
|
// and there is no remote version available; see kinto-http.js
|
|
|
|
|
// batch.js:aggregate.
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
const realRemote = remote && (await this._decodeRecord("remote", remote));
|
2018-09-19 10:16:41 +00:00
|
|
|
|
const conflict = {
|
|
|
|
|
type,
|
|
|
|
|
local: realLocal,
|
|
|
|
|
remote: realRemote
|
|
|
|
|
};
|
2018-02-16 20:08:18 +00:00
|
|
|
|
conflicts.push(conflict);
|
|
|
|
|
}
|
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
syncResultObject.add("conflicts", conflicts); // Records that must be deleted are either deletions that were pushed
|
2018-02-16 20:08:18 +00:00
|
|
|
|
// to server (published) or deleted records that were never pushed (skipped).
|
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
const missingRemotely = synced.skipped.map(r => ({ ...r,
|
|
|
|
|
deleted: true
|
|
|
|
|
})); // For created and updated records, the last_modified coming from server
|
2018-02-16 20:08:18 +00:00
|
|
|
|
// will be stored locally.
|
|
|
|
|
// Reflect publication results locally using the response from
|
|
|
|
|
// the batch request.
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
const published = synced.published.map(c => c.data);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
const toApplyLocally = published.concat(missingRemotely); // Apply the decode transformers, if any
|
2018-02-16 20:08:18 +00:00
|
|
|
|
|
|
|
|
|
const decoded = await Promise.all(toApplyLocally.map(record => {
|
|
|
|
|
return this._decodeRecord("remote", record);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
})); // We have to update the local records with the responses of the server
|
2018-02-16 20:08:18 +00:00
|
|
|
|
// (eg. last_modified values etc.).
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
if (decoded.length > 0 || conflicts.length > 0) {
|
|
|
|
|
await this._applyPushedResults(syncResultObject, decoded, conflicts, options.strategy);
|
|
|
|
|
}
|
2016-10-07 14:27:48 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
return syncResultObject;
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
2016-07-13 19:09:42 +00:00
|
|
|
|
/**
|
|
|
|
|
* Return a copy of the specified record without the local fields.
|
|
|
|
|
*
|
|
|
|
|
* @param {Object} record A record with potential local fields.
|
|
|
|
|
* @return {Object}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
cleanLocalFields(record) {
|
|
|
|
|
const localKeys = RECORD_FIELDS_TO_CLEAN.concat(this.localFields);
|
|
|
|
|
return (0, _utils.omitKeys)(record, localKeys);
|
|
|
|
|
}
|
2016-04-14 23:07:31 +00:00
|
|
|
|
/**
|
|
|
|
|
* 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}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
resolve(conflict, resolution) {
|
2016-07-13 19:09:42 +00:00
|
|
|
|
return this.db.execute(transaction => {
|
|
|
|
|
const updated = this._resolveRaw(conflict, resolution);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
transaction.update(updated);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return {
|
|
|
|
|
data: updated,
|
|
|
|
|
permissions: {}
|
|
|
|
|
};
|
2016-07-13 19:09:42 +00:00
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* @private
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
_resolveRaw(conflict, resolution) {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
const resolved = { ...resolution,
|
2016-04-14 23:07:31 +00:00
|
|
|
|
// Ensure local record has the latest authoritative timestamp
|
2017-05-11 17:15:20 +00:00
|
|
|
|
last_modified: conflict.remote && conflict.remote.last_modified
|
2018-09-19 10:16:41 +00:00
|
|
|
|
}; // If the resolution object is strictly equal to the
|
2016-07-13 19:09:42 +00:00
|
|
|
|
// remote record, then we can mark it as synced locally.
|
|
|
|
|
// Otherwise, mark it as updated (so that the resolution is pushed).
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
const synced = (0, _utils.deepEqual)(resolved, conflict.remote);
|
|
|
|
|
return markStatus(resolved, synced ? "synced" : "updated");
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* 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.
|
2018-10-09 14:05:01 +00:00
|
|
|
|
* - {String} expectedTimestamp: A timestamp to use as a "cache busting" query parameter.
|
2017-01-18 13:53:52 +00:00
|
|
|
|
* - {Number} retry: Number of retries when server fails to process the request (default: 1).
|
2016-04-14 23:07:31 +00:00
|
|
|
|
* - {Collection.strategy} strategy: See {@link Collection.strategy}.
|
|
|
|
|
* - {Boolean} ignoreBackoff: Force synchronization even if server is currently
|
|
|
|
|
* backed off.
|
2016-07-13 19:09:42 +00:00
|
|
|
|
* - {String} bucket: The remove bucket id to use (default: null)
|
|
|
|
|
* - {String} collection: The remove collection id to use (default: null)
|
2016-04-14 23:07:31 +00:00
|
|
|
|
* - {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.
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
async sync(options = {
|
2016-04-14 23:07:31 +00:00
|
|
|
|
strategy: Collection.strategy.MANUAL,
|
|
|
|
|
headers: {},
|
2017-01-18 13:53:52 +00:00
|
|
|
|
retry: 1,
|
2016-04-14 23:07:31 +00:00
|
|
|
|
ignoreBackoff: false,
|
2016-07-13 19:09:42 +00:00
|
|
|
|
bucket: null,
|
|
|
|
|
collection: null,
|
2018-10-09 14:05:01 +00:00
|
|
|
|
remote: null,
|
|
|
|
|
expectedTimestamp: null
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}) {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
options = { ...options,
|
2018-02-16 20:08:18 +00:00
|
|
|
|
bucket: options.bucket || this.bucket,
|
|
|
|
|
collection: options.collection || this.name
|
|
|
|
|
};
|
|
|
|
|
const previousRemote = this.api.remote;
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
if (options.remote) {
|
|
|
|
|
// Note: setting the remote ensures it's valid, throws when invalid.
|
|
|
|
|
this.api.remote = options.remote;
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
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.`));
|
|
|
|
|
}
|
2016-09-05 17:18:25 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
const client = this.api.bucket(options.bucket).collection(options.collection);
|
|
|
|
|
const result = new SyncResultObject();
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
try {
|
|
|
|
|
// Fetch last changes from the server.
|
|
|
|
|
await this.pullChanges(client, result, options);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
const {
|
|
|
|
|
lastModified
|
|
|
|
|
} = result; // Fetch local changes
|
2016-10-07 14:27:48 +00:00
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
const toSync = await this.gatherLocalChanges(); // Publish local changes and pull local resolutions
|
2016-10-07 14:27:48 +00:00
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
await this.pushChanges(client, toSync, result, options); // Publish local resolution of push conflicts to server (on CLIENT_WINS)
|
2016-10-07 14:27:48 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
const resolvedUnsynced = result.resolved.filter(r => r._status !== "synced");
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
if (resolvedUnsynced.length > 0) {
|
|
|
|
|
const resolvedEncoded = await Promise.all(resolvedUnsynced.map(resolution => {
|
|
|
|
|
let record = resolution.accepted;
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
if (record === null) {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
record = {
|
|
|
|
|
id: resolution.id,
|
|
|
|
|
_status: resolution._status
|
|
|
|
|
};
|
2018-02-16 20:08:18 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
return this._encodeRecord("remote", record);
|
|
|
|
|
}));
|
|
|
|
|
await this.pushChanges(client, resolvedEncoded, result, options);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
} // Perform a last pull to catch changes that occured after the last pull,
|
2018-02-16 20:08:18 +00:00
|
|
|
|
// while local changes were pushed. Do not do it nothing was pushed.
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
if (result.published.length > 0) {
|
|
|
|
|
// Avoid redownloading our own changes during the last pull.
|
2018-09-19 10:16:41 +00:00
|
|
|
|
const pullOpts = { ...options,
|
2018-02-16 20:08:18 +00:00
|
|
|
|
lastModified,
|
|
|
|
|
exclude: result.published
|
|
|
|
|
};
|
|
|
|
|
await this.pullChanges(client, result, pullOpts);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
} // Don't persist lastModified value if any conflict or error occured
|
|
|
|
|
|
2016-10-07 14:27:48 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
if (result.ok) {
|
|
|
|
|
// No conflict occured, persist collection's lastModified value
|
|
|
|
|
this._lastModified = await this.db.saveLastModified(result.lastModified);
|
2016-10-07 14:27:48 +00:00
|
|
|
|
}
|
2018-02-16 20:08:18 +00:00
|
|
|
|
} catch (e) {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
this.events.emit("sync:error", { ...options,
|
|
|
|
|
error: e
|
|
|
|
|
});
|
2018-02-16 20:08:18 +00:00
|
|
|
|
throw e;
|
|
|
|
|
} finally {
|
|
|
|
|
// Ensure API default remote is reverted if a custom one's been used
|
|
|
|
|
this.api.remote = previousRemote;
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
this.events.emit("sync:success", { ...options,
|
|
|
|
|
result
|
|
|
|
|
});
|
2018-02-16 20:08:18 +00:00
|
|
|
|
return result;
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Load a list of records already synced with the remote server.
|
|
|
|
|
*
|
|
|
|
|
* The local records which are unsynced or whose timestamp is either missing
|
|
|
|
|
* or superior to those being loaded will be ignored.
|
|
|
|
|
*
|
|
|
|
|
* @param {Array} records The previously exported list of records to load.
|
|
|
|
|
* @return {Promise} with the effectively imported records.
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
async loadDump(records) {
|
|
|
|
|
if (!Array.isArray(records)) {
|
|
|
|
|
throw new Error("Records is not an array.");
|
|
|
|
|
}
|
2016-04-14 23:07:31 +00:00
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
for (const record of records) {
|
|
|
|
|
if (!record.hasOwnProperty("id") || !this.idSchema.validate(record.id)) {
|
|
|
|
|
throw new Error("Record has invalid ID: " + JSON.stringify(record));
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
|
|
|
|
|
2018-02-16 20:08:18 +00:00
|
|
|
|
if (!record.last_modified) {
|
|
|
|
|
throw new Error("Record has no last_modified value: " + JSON.stringify(record));
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
} // Fetch all existing records from local database,
|
2018-02-16 20:08:18 +00:00
|
|
|
|
// and skip those who are newer or not marked as synced.
|
|
|
|
|
// XXX filter by status / ids in records
|
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
data
|
|
|
|
|
} = await this.list({}, {
|
|
|
|
|
includeDeleted: true
|
|
|
|
|
});
|
2018-02-16 20:08:18 +00:00
|
|
|
|
const existingById = data.reduce((acc, record) => {
|
|
|
|
|
acc[record.id] = record;
|
|
|
|
|
return acc;
|
|
|
|
|
}, {});
|
|
|
|
|
const newRecords = records.filter(record => {
|
|
|
|
|
const localRecord = existingById[record.id];
|
2018-09-19 10:16:41 +00:00
|
|
|
|
const shouldKeep = // No local record with this id.
|
|
|
|
|
localRecord === undefined || // Or local record is synced
|
|
|
|
|
localRecord._status === "synced" && // And was synced from server
|
|
|
|
|
localRecord.last_modified !== undefined && // And is older than imported one.
|
2018-02-16 20:08:18 +00:00
|
|
|
|
record.last_modified > localRecord.last_modified;
|
|
|
|
|
return shouldKeep;
|
|
|
|
|
});
|
|
|
|
|
return await this.db.loadDump(newRecords.map(markSynced));
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
exports.default = Collection;
|
2016-07-13 19:09:42 +00:00
|
|
|
|
|
|
|
|
|
class CollectionTransaction {
|
|
|
|
|
constructor(collection, adapterTransaction) {
|
|
|
|
|
this.collection = collection;
|
|
|
|
|
this.adapterTransaction = adapterTransaction;
|
2016-08-25 18:13:15 +00:00
|
|
|
|
this._events = [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_queueEvent(action, payload) {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
this._events.push({
|
|
|
|
|
action,
|
|
|
|
|
payload
|
|
|
|
|
});
|
2016-08-25 18:13:15 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Emit queued events, to be called once every transaction operations have
|
|
|
|
|
* been executed successfully.
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-08-25 18:13:15 +00:00
|
|
|
|
emitEvents() {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
for (const {
|
|
|
|
|
action,
|
|
|
|
|
payload
|
|
|
|
|
} of this._events) {
|
2016-08-25 18:13:15 +00:00
|
|
|
|
this.collection.events.emit(action, payload);
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-08-25 18:13:15 +00:00
|
|
|
|
if (this._events.length > 0) {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
const targets = this._events.map(({
|
|
|
|
|
action,
|
|
|
|
|
payload
|
|
|
|
|
}) => ({
|
2018-02-16 20:08:18 +00:00
|
|
|
|
action,
|
|
|
|
|
...payload
|
|
|
|
|
}));
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
this.collection.events.emit("change", {
|
|
|
|
|
targets
|
|
|
|
|
});
|
2016-08-25 18:13:15 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-08-25 18:13:15 +00:00
|
|
|
|
this._events = [];
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* 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}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
getAny(id) {
|
|
|
|
|
const record = this.adapterTransaction.get(id);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return {
|
|
|
|
|
data: record,
|
|
|
|
|
permissions: {}
|
|
|
|
|
};
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* 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}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
get(id, options = {
|
|
|
|
|
includeDeleted: false
|
|
|
|
|
}) {
|
2016-07-13 19:09:42 +00:00
|
|
|
|
const res = this.getAny(id);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (!res.data || !options.includeDeleted && res.data._status === "deleted") {
|
2017-01-28 00:23:05 +00:00
|
|
|
|
throw new Error(`Record with id=${id} not found.`);
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
delete(id, options = {
|
|
|
|
|
virtual: true
|
|
|
|
|
}) {
|
2016-07-13 19:09:42 +00:00
|
|
|
|
// Ensure the record actually exists.
|
|
|
|
|
const existing = this.adapterTransaction.get(id);
|
|
|
|
|
const alreadyDeleted = existing && existing._status == "deleted";
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (!existing || alreadyDeleted && options.virtual) {
|
2017-01-28 00:23:05 +00:00
|
|
|
|
throw new Error(`Record with id=${id} not found.`);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
} // Virtual updates status.
|
|
|
|
|
|
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (options.virtual) {
|
|
|
|
|
this.adapterTransaction.update(markDeleted(existing));
|
|
|
|
|
} else {
|
|
|
|
|
// Delete for real.
|
|
|
|
|
this.adapterTransaction.delete(id);
|
|
|
|
|
}
|
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
this._queueEvent("delete", {
|
|
|
|
|
data: existing
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
data: existing,
|
|
|
|
|
permissions: {}
|
|
|
|
|
};
|
|
|
|
|
}
|
2018-03-20 18:29:50 +00:00
|
|
|
|
/**
|
|
|
|
|
* Soft delete all records from the local database.
|
|
|
|
|
*
|
|
|
|
|
* @param {Array} ids Array of non-deleted Record Ids.
|
|
|
|
|
* @return {Object}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2018-03-20 18:29:50 +00:00
|
|
|
|
deleteAll(ids) {
|
|
|
|
|
const existingRecords = [];
|
|
|
|
|
ids.forEach(id => {
|
|
|
|
|
existingRecords.push(this.adapterTransaction.get(id));
|
|
|
|
|
this.delete(id);
|
|
|
|
|
});
|
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
this._queueEvent("deleteAll", {
|
|
|
|
|
data: existingRecords
|
|
|
|
|
});
|
2018-03-20 18:29:50 +00:00
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return {
|
|
|
|
|
data: existingRecords,
|
|
|
|
|
permissions: {}
|
|
|
|
|
};
|
|
|
|
|
}
|
2016-07-13 19:09:42 +00:00
|
|
|
|
/**
|
|
|
|
|
* Deletes a record from the local database, if any exists.
|
|
|
|
|
* Otherwise, do nothing.
|
|
|
|
|
*
|
|
|
|
|
* @param {String} id The record's Id.
|
|
|
|
|
* @return {Object}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
deleteAny(id) {
|
|
|
|
|
const existing = this.adapterTransaction.get(id);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (existing) {
|
|
|
|
|
this.adapterTransaction.update(markDeleted(existing));
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
this._queueEvent("delete", {
|
|
|
|
|
data: existing
|
|
|
|
|
});
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
return {
|
|
|
|
|
data: {
|
|
|
|
|
id,
|
|
|
|
|
...existing
|
|
|
|
|
},
|
|
|
|
|
deleted: !!existing,
|
|
|
|
|
permissions: {}
|
|
|
|
|
};
|
|
|
|
|
}
|
2016-07-13 19:09:42 +00:00
|
|
|
|
/**
|
|
|
|
|
* 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}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
create(record) {
|
|
|
|
|
if (typeof record !== "object") {
|
|
|
|
|
throw new Error("Record is not an object.");
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (!record.hasOwnProperty("id")) {
|
|
|
|
|
throw new Error("Cannot create a record missing id");
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (!this.collection.idSchema.validate(record.id)) {
|
2017-01-28 00:23:05 +00:00
|
|
|
|
throw new Error(`Invalid Id: ${record.id}`);
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.adapterTransaction.create(record);
|
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
this._queueEvent("create", {
|
|
|
|
|
data: record
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
data: record,
|
|
|
|
|
permissions: {}
|
|
|
|
|
};
|
|
|
|
|
}
|
2016-07-13 19:09:42 +00:00
|
|
|
|
/**
|
|
|
|
|
* 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}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
update(record, options = {
|
|
|
|
|
synced: false,
|
|
|
|
|
patch: false
|
|
|
|
|
}) {
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (typeof record !== "object") {
|
|
|
|
|
throw new Error("Record is not an object.");
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (!record.hasOwnProperty("id")) {
|
|
|
|
|
throw new Error("Cannot update a record missing id.");
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (!this.collection.idSchema.validate(record.id)) {
|
2017-01-28 00:23:05 +00:00
|
|
|
|
throw new Error(`Invalid Id: ${record.id}`);
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const oldRecord = this.adapterTransaction.get(record.id);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (!oldRecord) {
|
2017-01-28 00:23:05 +00:00
|
|
|
|
throw new Error(`Record with id=${record.id} not found.`);
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
const newRecord = options.patch ? { ...oldRecord,
|
|
|
|
|
...record
|
|
|
|
|
} : record;
|
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
const updated = this._updateRaw(oldRecord, newRecord, options);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
this.adapterTransaction.update(updated);
|
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
this._queueEvent("update", {
|
|
|
|
|
data: updated,
|
|
|
|
|
oldRecord
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
data: updated,
|
|
|
|
|
oldRecord,
|
|
|
|
|
permissions: {}
|
|
|
|
|
};
|
|
|
|
|
}
|
2016-07-13 19:09:42 +00:00
|
|
|
|
/**
|
|
|
|
|
* 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}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_updateRaw(oldRecord, newRecord, {
|
|
|
|
|
synced = false
|
|
|
|
|
} = {}) {
|
|
|
|
|
const updated = { ...newRecord
|
|
|
|
|
}; // Make sure to never loose the existing timestamp.
|
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (oldRecord && oldRecord.last_modified && !updated.last_modified) {
|
|
|
|
|
updated.last_modified = oldRecord.last_modified;
|
2018-09-19 10:16:41 +00:00
|
|
|
|
} // If only local fields have changed, then keep record as synced.
|
2016-07-13 19:09:42 +00:00
|
|
|
|
// If status is created, keep record as created.
|
|
|
|
|
// If status is deleted, mark as updated.
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
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}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
upsert(record) {
|
|
|
|
|
if (typeof record !== "object") {
|
|
|
|
|
throw new Error("Record is not an object.");
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (!record.hasOwnProperty("id")) {
|
|
|
|
|
throw new Error("Cannot update a record missing id.");
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (!this.collection.idSchema.validate(record.id)) {
|
2017-01-28 00:23:05 +00:00
|
|
|
|
throw new Error(`Invalid Id: ${record.id}`);
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
let oldRecord = this.adapterTransaction.get(record.id);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
const updated = this._updateRaw(oldRecord, record);
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
this.adapterTransaction.update(updated); // Don't return deleted records -- pretend they are gone
|
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
if (oldRecord && oldRecord._status == "deleted") {
|
|
|
|
|
oldRecord = undefined;
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-08-25 18:13:15 +00:00
|
|
|
|
if (oldRecord) {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
this._queueEvent("update", {
|
|
|
|
|
data: updated,
|
|
|
|
|
oldRecord
|
|
|
|
|
});
|
2016-08-25 18:13:15 +00:00
|
|
|
|
} else {
|
2018-09-19 10:16:41 +00:00
|
|
|
|
this._queueEvent("create", {
|
|
|
|
|
data: updated
|
|
|
|
|
});
|
2016-08-25 18:13:15 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
data: updated,
|
|
|
|
|
oldRecord,
|
|
|
|
|
permissions: {}
|
|
|
|
|
};
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
exports.CollectionTransaction = CollectionTransaction;
|
|
|
|
|
|
2016-11-16 00:38:53 +00:00
|
|
|
|
},{"./adapters/IDB":4,"./adapters/base":5,"./utils":7,"uuid":2}],7:[function(require,module,exports){
|
2016-04-14 23:07:31 +00:00
|
|
|
|
"use strict";
|
|
|
|
|
|
|
|
|
|
Object.defineProperty(exports, "__esModule", {
|
|
|
|
|
value: true
|
|
|
|
|
});
|
|
|
|
|
exports.sortObjects = sortObjects;
|
2016-10-07 14:27:48 +00:00
|
|
|
|
exports.filterObject = filterObject;
|
2016-04-14 23:07:31 +00:00
|
|
|
|
exports.filterObjects = filterObjects;
|
|
|
|
|
exports.waterfall = waterfall;
|
2016-05-02 10:45:50 +00:00
|
|
|
|
exports.deepEqual = deepEqual;
|
2016-07-13 19:09:42 +00:00
|
|
|
|
exports.omitKeys = omitKeys;
|
2018-09-19 10:16:41 +00:00
|
|
|
|
exports.arrayEqual = arrayEqual;
|
|
|
|
|
exports.RE_RECORD_ID = void 0;
|
|
|
|
|
const RE_RECORD_ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
2016-04-14 23:07:31 +00:00
|
|
|
|
/**
|
|
|
|
|
* Checks if a value is undefined.
|
|
|
|
|
* @param {Any} value
|
|
|
|
|
* @return {Boolean}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
exports.RE_RECORD_ID = RE_RECORD_ID;
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
function _isUndefined(value) {
|
|
|
|
|
return typeof value === "undefined";
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Sorts records in a list according to a given ordering.
|
|
|
|
|
*
|
|
|
|
|
* @param {String} order The ordering, eg. `-last_modified`.
|
|
|
|
|
* @param {Array} list The collection to order.
|
|
|
|
|
* @return {Array}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
function sortObjects(order, list) {
|
|
|
|
|
const hasDash = order[0] === "-";
|
|
|
|
|
const field = hasDash ? order.slice(1) : order;
|
|
|
|
|
const direction = hasDash ? -1 : 1;
|
|
|
|
|
return list.slice().sort((a, b) => {
|
|
|
|
|
if (a[field] && _isUndefined(b[field])) {
|
|
|
|
|
return direction;
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
if (b[field] && _isUndefined(a[field])) {
|
|
|
|
|
return -direction;
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
if (_isUndefined(a[field]) && _isUndefined(b[field])) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
return a[field] > b[field] ? direction : -direction;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
/**
|
2016-10-07 14:27:48 +00:00
|
|
|
|
* Test if a single object matches all given filters.
|
2016-04-14 23:07:31 +00:00
|
|
|
|
*
|
2016-10-07 14:27:48 +00:00
|
|
|
|
* @param {Object} filters The filters object.
|
|
|
|
|
* @param {Object} entry The object to filter.
|
2018-02-16 20:08:18 +00:00
|
|
|
|
* @return {Boolean}
|
2016-04-14 23:07:31 +00:00
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-10-07 14:27:48 +00:00
|
|
|
|
function filterObject(filters, entry) {
|
2016-11-16 00:38:53 +00:00
|
|
|
|
return Object.keys(filters).every(filter => {
|
2016-10-07 14:27:48 +00:00
|
|
|
|
const value = filters[filter];
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-10-07 14:27:48 +00:00
|
|
|
|
if (Array.isArray(value)) {
|
|
|
|
|
return value.some(candidate => candidate === entry[filter]);
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-10-07 14:27:48 +00:00
|
|
|
|
return entry[filter] === value;
|
2016-04-14 23:07:31 +00:00
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
/**
|
2016-10-07 14:27:48 +00:00
|
|
|
|
* Filters records in a list matching all given filters.
|
2016-04-14 23:07:31 +00:00
|
|
|
|
*
|
2016-10-07 14:27:48 +00:00
|
|
|
|
* @param {Object} filters The filters object.
|
|
|
|
|
* @param {Array} list The collection to filter.
|
2016-04-14 23:07:31 +00:00
|
|
|
|
* @return {Array}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-10-07 14:27:48 +00:00
|
|
|
|
function filterObjects(filters, list) {
|
|
|
|
|
return list.filter(entry => {
|
|
|
|
|
return filterObject(filters, entry);
|
|
|
|
|
});
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Resolves a list of functions sequentially, which can be sync or async; in
|
|
|
|
|
* case of async, functions must return a promise.
|
|
|
|
|
*
|
|
|
|
|
* @param {Array} fns The list of functions.
|
|
|
|
|
* @param {Any} init The initial value.
|
|
|
|
|
* @return {Promise}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
function waterfall(fns, init) {
|
|
|
|
|
if (!fns.length) {
|
2016-11-16 00:38:53 +00:00
|
|
|
|
return Promise.resolve(init);
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-04-14 23:07:31 +00:00
|
|
|
|
return fns.reduce((promise, nextFn) => {
|
|
|
|
|
return promise.then(nextFn);
|
2016-11-16 00:38:53 +00:00
|
|
|
|
}, Promise.resolve(init));
|
2016-04-14 23:07:31 +00:00
|
|
|
|
}
|
2016-05-02 10:45:50 +00:00
|
|
|
|
/**
|
|
|
|
|
* Simple deep object comparison function. This only supports comparison of
|
|
|
|
|
* serializable JavaScript objects.
|
|
|
|
|
*
|
|
|
|
|
* @param {Object} a The source object.
|
|
|
|
|
* @param {Object} b The compared object.
|
|
|
|
|
* @return {Boolean}
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-05-02 10:45:50 +00:00
|
|
|
|
function deepEqual(a, b) {
|
|
|
|
|
if (a === b) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-05-02 10:45:50 +00:00
|
|
|
|
if (typeof a !== typeof b) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-08-30 16:19:19 +00:00
|
|
|
|
if (!(a && typeof a == "object") || !(b && typeof b == "object")) {
|
2016-05-02 10:45:50 +00:00
|
|
|
|
return false;
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-11-16 00:38:53 +00:00
|
|
|
|
if (Object.keys(a).length !== Object.keys(b).length) {
|
2016-05-02 10:45:50 +00:00
|
|
|
|
return false;
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2018-03-20 18:29:50 +00:00
|
|
|
|
for (const k in a) {
|
2016-05-02 10:45:50 +00:00
|
|
|
|
if (!deepEqual(a[k], b[k])) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-05-02 10:45:50 +00:00
|
|
|
|
return true;
|
|
|
|
|
}
|
2016-07-13 19:09:42 +00:00
|
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
function omitKeys(obj, keys = []) {
|
2016-11-16 00:38:53 +00:00
|
|
|
|
return Object.keys(obj).reduce((acc, key) => {
|
2018-02-01 19:45:22 +00:00
|
|
|
|
if (!keys.includes(key)) {
|
2016-07-13 19:09:42 +00:00
|
|
|
|
acc[key] = obj[key];
|
|
|
|
|
}
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|
2016-07-13 19:09:42 +00:00
|
|
|
|
return acc;
|
|
|
|
|
}, {});
|
|
|
|
|
}
|
|
|
|
|
|
2018-09-19 10:16:41 +00:00
|
|
|
|
function arrayEqual(a, b) {
|
|
|
|
|
if (a.length !== b.length) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (let i = a.length; i--;) {
|
|
|
|
|
if (a[i] !== b[i]) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2016-11-16 00:38:53 +00:00
|
|
|
|
},{}]},{},[1])(1)
|
2017-08-30 08:50:27 +00:00
|
|
|
|
});
|
2018-09-19 10:16:41 +00:00
|
|
|
|
|