3 * Licensed under the Apache License, Version 2.0 (the "License");
4 * you may not use this file except in compliance with the License.
5 * You may obtain a copy of the License at
7 * http://www.apache.org/licenses/LICENSE-2.0
9 * Unless required by applicable law or agreed to in writing, software
10 * distributed under the License is distributed on an "AS IS" BASIS,
11 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 * See the License for the specific language governing permissions and
13 * limitations under the License.
18 * This file is generated from kinto.js - do not modify directly.
21 // This is required because with Babel compiles ES2015 modules into a
22 // require() form that tries to keep its modules on "this", but
23 // doesn't specify "this", leaving it to default to the global
24 // object. However, in strict mode, "this" no longer defaults to the
25 // global object, so expose the global object explicitly. Babel's
26 // compiled output will use a variable called "global" if one is
29 // See https://bugzilla.mozilla.org/show_bug.cgi?id=1394556#c3 for
33 var EXPORTED_SYMBOLS = ["Kinto"];
36 * Version 13.0.0 - 7fbf95d
39 (function (global, factory) {
40 typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
41 typeof define === 'function' && define.amd ? define(factory) :
42 (global = global || self, global.Kinto = factory());
43 }(this, (function () { 'use strict';
52 * Deletes every records present in the database.
58 throw new Error("Not Implemented.");
61 * Executes a batch of operations within a single transaction.
64 * @param {Function} callback The operation callback.
65 * @param {Object} options The options object.
68 execute(callback, options = { preload: [] }) {
69 throw new Error("Not Implemented.");
72 * Retrieve a record by its primary key from the database.
75 * @param {String} id The record id.
79 throw new Error("Not Implemented.");
82 * Lists all records from the database.
85 * @param {Object} params The filters and order to apply to the results.
88 list(params = { filters: {}, order: "" }) {
89 throw new Error("Not Implemented.");
92 * Store the lastModified value.
95 * @param {Number} lastModified
98 saveLastModified(lastModified) {
99 throw new Error("Not Implemented.");
102 * Retrieve saved lastModified value.
108 throw new Error("Not Implemented.");
111 * Load records in bulk that were exported from a server.
114 * @param {Array} records The records to load.
117 importBulk(records) {
118 throw new Error("Not Implemented.");
121 * Load a dump of records exported from a server.
123 * @deprecated Use {@link importBulk} instead.
125 * @param {Array} records The records to load.
129 throw new Error("Not Implemented.");
131 saveMetadata(metadata) {
132 throw new Error("Not Implemented.");
135 throw new Error("Not Implemented.");
139 const RE_RECORD_ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
141 * Checks if a value is undefined.
145 function _isUndefined(value) {
146 return typeof value === "undefined";
149 * Sorts records in a list according to a given ordering.
151 * @param {String} order The ordering, eg. `-last_modified`.
152 * @param {Array} list The collection to order.
155 function sortObjects(order, list) {
156 const hasDash = order[0] === "-";
157 const field = hasDash ? order.slice(1) : order;
158 const direction = hasDash ? -1 : 1;
159 return list.slice().sort((a, b) => {
160 if (a[field] && _isUndefined(b[field])) {
163 if (b[field] && _isUndefined(a[field])) {
166 if (_isUndefined(a[field]) && _isUndefined(b[field])) {
169 return a[field] > b[field] ? direction : -direction;
173 * Test if a single object matches all given filters.
175 * @param {Object} filters The filters object.
176 * @param {Object} entry The object to filter.
179 function filterObject(filters, entry) {
180 return Object.keys(filters).every(filter => {
181 const value = filters[filter];
182 if (Array.isArray(value)) {
183 return value.some(candidate => candidate === entry[filter]);
185 else if (typeof value === "object") {
186 return filterObject(value, entry[filter]);
188 else if (!Object.prototype.hasOwnProperty.call(entry, filter)) {
189 console.error(`The property ${filter} does not exist`);
192 return entry[filter] === value;
196 * Resolves a list of functions sequentially, which can be sync or async; in
197 * case of async, functions must return a promise.
199 * @param {Array} fns The list of functions.
200 * @param {Any} init The initial value.
203 function waterfall(fns, init) {
205 return Promise.resolve(init);
207 return fns.reduce((promise, nextFn) => {
208 return promise.then(nextFn);
209 }, Promise.resolve(init));
212 * Simple deep object comparison function. This only supports comparison of
213 * serializable JavaScript objects.
215 * @param {Object} a The source object.
216 * @param {Object} b The compared object.
219 function deepEqual(a, b) {
223 if (typeof a !== typeof b) {
226 if (!(a && typeof a == "object") || !(b && typeof b == "object")) {
229 if (Object.keys(a).length !== Object.keys(b).length) {
233 if (!deepEqual(a[k], b[k])) {
240 * Return an object without the specified keys.
242 * @param {Object} obj The original object.
243 * @param {Array} keys The list of keys to exclude.
244 * @return {Object} A copy without the specified keys.
246 function omitKeys(obj, keys = []) {
247 const result = Object.assign({}, obj);
248 for (const key of keys) {
253 function arrayEqual(a, b) {
254 if (a.length !== b.length) {
257 for (let i = a.length; i--;) {
264 function makeNestedObjectFromArr(arr, val, nestedFiltersObj) {
265 const last = arr.length - 1;
266 return arr.reduce((acc, cv, i) => {
268 return (acc[cv] = val);
270 else if (Object.prototype.hasOwnProperty.call(acc, cv)) {
274 return (acc[cv] = {});
276 }, nestedFiltersObj);
278 function transformSubObjectFilters(filtersObj) {
279 const transformedFilters = {};
280 for (const key in filtersObj) {
281 const keysArr = key.split(".");
282 const val = filtersObj[key];
283 makeNestedObjectFromArr(keysArr, val, transformedFilters);
285 return transformedFilters;
288 const INDEXED_FIELDS = ["id", "_status", "last_modified"];
290 * Small helper that wraps the opening of an IndexedDB into a Promise.
292 * @param dbname {String} The database name.
293 * @param version {Integer} Schema version
294 * @param onupgradeneeded {Function} The callback to execute if schema is
295 * missing or different.
296 * @return {Promise<IDBDatabase>}
298 async function open(dbname, { version, onupgradeneeded }) {
299 return new Promise((resolve, reject) => {
300 const request = indexedDB.open(dbname, version);
301 request.onupgradeneeded = event => {
302 const db = event.target.result;
303 db.onerror = event => reject(event.target.error);
304 // When an upgrade is needed, a transaction is started.
305 const transaction = event.target.transaction;
306 transaction.onabort = event => {
307 const error = event.target.error ||
309 new DOMException("The operation has been aborted", "AbortError");
312 // Callback for store creation etc.
313 return onupgradeneeded(event);
315 request.onerror = event => {
316 reject(event.target.error);
318 request.onsuccess = event => {
319 const db = event.target.result;
325 * Helper to run the specified callback in a single transaction on the
327 * The helper focuses on transaction wrapping into a promise.
329 * @param db {IDBDatabase} The database instance.
330 * @param name {String} The store name.
331 * @param callback {Function} The piece of code to execute in the transaction.
332 * @param options {Object} Options.
333 * @param options.mode {String} Transaction mode (default: read).
334 * @return {Promise} any value returned by the callback.
336 async function execute(db, name, callback, options = {}) {
337 const { mode } = options;
338 return new Promise((resolve, reject) => {
339 // On Safari, calling IDBDatabase.transaction with mode == undefined raises
341 const transaction = mode
342 ? db.transaction([name], mode)
343 : db.transaction([name]);
344 const store = transaction.objectStore(name);
345 // Let the callback abort this transaction.
350 // Execute the specified callback **synchronously**.
353 result = callback(store, abort);
358 transaction.onerror = event => reject(event.target.error);
359 transaction.oncomplete = event => resolve(result);
360 transaction.onabort = event => {
361 const error = event.target.error ||
363 new DOMException("The operation has been aborted", "AbortError");
369 * Helper to wrap the deletion of an IndexedDB database into a promise.
371 * @param dbName {String} the database to delete
374 async function deleteDatabase(dbName) {
375 return new Promise((resolve, reject) => {
376 const request = indexedDB.deleteDatabase(dbName);
377 request.onsuccess = event => resolve(event.target);
378 request.onerror = event => reject(event.target.error);
382 * IDB cursor handlers.
385 const cursorHandlers = {
389 const cursor = event.target.result;
391 const { value } = cursor;
392 if (filterObject(filters, value)) {
402 in(values, filters, done) {
405 return function (event) {
406 const cursor = event.target.result;
411 const { key, value } = cursor;
412 // `key` can be an array of two values (see `keyPath` in indices definitions).
413 // `values` can be an array of arrays if we filter using an index whose key path
414 // is an array (eg. `cursorHandlers.in([["bid/cid", 42], ["bid/cid", 43]], ...)`)
415 while (key > values[i]) {
416 // The cursor has passed beyond this key. Check next.
418 if (i === values.length) {
419 done(results); // There is no next. Stop searching.
423 const isEqual = Array.isArray(key)
424 ? arrayEqual(key, values[i])
427 if (filterObject(filters, value)) {
433 cursor.continue(values[i]);
439 * Creates an IDB request and attach it the appropriate cursor event handler to
440 * perform a list query.
442 * Multiple matching values are handled by passing an array.
444 * @param {String} cid The collection id (ie. `{bid}/{cid}`)
445 * @param {IDBStore} store The IDB store.
446 * @param {Object} filters Filter the records by field.
447 * @param {Function} done The operation completion handler.
448 * @return {IDBRequest}
450 function createListRequest(cid, store, filters, done) {
451 const filterFields = Object.keys(filters);
452 // If no filters, get all results in one bulk.
453 if (filterFields.length == 0) {
454 const request = store.index("cid").getAll(IDBKeyRange.only(cid));
455 request.onsuccess = event => done(event.target.result);
458 // Introspect filters and check if they leverage an indexed field.
459 const indexField = filterFields.find(field => {
460 return INDEXED_FIELDS.includes(field);
463 // Iterate on all records for this collection (ie. cid)
464 const isSubQuery = Object.keys(filters).some(key => key.includes(".")); // (ie. filters: {"article.title": "hello"})
466 const newFilter = transformSubObjectFilters(filters);
467 const request = store.index("cid").openCursor(IDBKeyRange.only(cid));
468 request.onsuccess = cursorHandlers.all(newFilter, done);
471 const request = store.index("cid").openCursor(IDBKeyRange.only(cid));
472 request.onsuccess = cursorHandlers.all(filters, done);
475 // If `indexField` was used already, don't filter again.
476 const remainingFilters = omitKeys(filters, [indexField]);
477 // value specified in the filter (eg. `filters: { _status: ["created", "updated"] }`)
478 const value = filters[indexField];
479 // For the "id" field, use the primary key.
480 const indexStore = indexField == "id" ? store : store.index(indexField);
481 // WHERE IN equivalent clause
482 if (Array.isArray(value)) {
483 if (value.length === 0) {
486 const values = value.map(i => [cid, i]).sort();
487 const range = IDBKeyRange.bound(values[0], values[values.length - 1]);
488 const request = indexStore.openCursor(range);
489 request.onsuccess = cursorHandlers.in(values, remainingFilters, done);
492 // If no filters on custom attribute, get all results in one bulk.
493 if (remainingFilters.length == 0) {
494 const request = indexStore.getAll(IDBKeyRange.only([cid, value]));
495 request.onsuccess = event => done(event.target.result);
498 // WHERE field = value clause
499 const request = indexStore.openCursor(IDBKeyRange.only([cid, value]));
500 request.onsuccess = cursorHandlers.all(remainingFilters, done);
503 class IDBError extends Error {
504 constructor(method, err) {
505 super(`IndexedDB ${method}() ${err.message}`);
506 this.name = err.name;
507 this.stack = err.stack;
513 * This adapter doesn't support any options.
515 class IDB extends BaseAdapter {
516 /* Expose the IDBError class publicly */
517 static get IDBError() {
523 * @param {String} cid The key base for this collection (eg. `bid/cid`)
524 * @param {Object} options
525 * @param {String} options.dbName The IndexedDB name (default: `"KintoDB"`)
526 * @param {String} options.migrateOldData Whether old database data should be migrated (default: `false`)
528 constructor(cid, options = {}) {
531 this.dbName = options.dbName || "KintoDB";
532 this._options = options;
535 _handleError(method, err) {
536 throw new IDBError(method, err);
539 * Ensures a connection to the IndexedDB database has been opened.
548 // In previous versions, we used to have a database with name `${bid}/${cid}`.
549 // Check if it exists, and migrate data once new schema is in place.
550 // Note: the built-in migrations from IndexedDB can only be used if the
551 // database name does not change.
552 const dataToMigrate = this._options.migrateOldData
553 ? await migrationRequired(this.cid)
555 this._db = await open(this.dbName, {
557 onupgradeneeded: event => {
558 const db = event.target.result;
559 if (event.oldVersion < 1) {
561 const recordsStore = db.createObjectStore("records", {
562 keyPath: ["_cid", "id"],
564 // An index to obtain all the records in a collection.
565 recordsStore.createIndex("cid", "_cid");
566 // Here we create indices for every known field in records by collection.
567 // Local record status ("synced", "created", "updated", "deleted")
568 recordsStore.createIndex("_status", ["_cid", "_status"]);
569 // Last modified field
570 recordsStore.createIndex("last_modified", ["_cid", "last_modified"]);
572 db.createObjectStore("timestamps", {
576 if (event.oldVersion < 2) {
578 db.createObjectStore("collections", {
585 const { records, timestamp } = dataToMigrate;
586 await this.importBulk(records);
587 await this.saveLastModified(timestamp);
588 console.log(`${this.cid}: data was migrated successfully.`);
589 // Delete the old database.
590 await deleteDatabase(this.cid);
591 console.warn(`${this.cid}: old database was deleted.`);
596 * Closes current connection to the database.
603 this._db.close(); // indexedDB.close is synchronous
606 return Promise.resolve();
609 * Returns a transaction and an object store for a store name.
611 * To determine if a transaction has completed successfully, we should rather
612 * listen to the transaction’s complete event rather than the IDBObjectStore
613 * request’s success event, because the transaction may still fail after the
614 * success event fires.
616 * @param {String} name Store name
617 * @param {Function} callback to execute
618 * @param {Object} options Options
619 * @param {String} options.mode Transaction mode ("readwrite" or undefined)
622 async prepare(name, callback, options) {
624 await execute(this._db, name, callback, options);
627 * Deletes every records in the current collection.
634 await this.prepare("records", store => {
635 const range = IDBKeyRange.only(this.cid);
636 const request = store.index("cid").openKeyCursor(range);
637 request.onsuccess = event => {
638 const cursor = event.target.result;
640 store.delete(cursor.primaryKey);
645 }, { mode: "readwrite" });
648 this._handleError("clear", e);
652 * Executes the set of synchronous CRUD operations described in the provided
653 * callback within an IndexedDB transaction, for current db store.
655 * The callback will be provided an object exposing the following synchronous
656 * CRUD operation methods: get, create, update, delete.
658 * Important note: because limitations in IndexedDB implementations, no
659 * asynchronous code should be performed within the provided callback; the
660 * promise will therefore be rejected if the callback returns a Promise.
663 * - {Array} preload: The list of record IDs to fetch and make available to
664 * the transaction object get() method (default: [])
667 * const db = new IDB("example");
668 * const result = await db.execute(transaction => {
669 * transaction.create({id: 1, title: "foo"});
670 * transaction.update({id: 2, title: "bar"});
671 * transaction.delete(3);
676 * @param {Function} callback The operation description callback.
677 * @param {Object} options The options object.
680 async execute(callback, options = { preload: [] }) {
681 // Transactions in IndexedDB are autocommited when a callback does not
682 // perform any additional operation.
683 // The way Promises are implemented in Firefox (see https://bugzilla.mozilla.org/show_bug.cgi?id=1193394)
684 // prevents using within an opened transaction.
685 // To avoid managing asynchronocity in the specified `callback`, we preload
686 // a list of record in order to execute the `callback` synchronously.
688 // - http://stackoverflow.com/a/28388805/330911
689 // - http://stackoverflow.com/a/10405196
690 // - https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
692 await this.prepare("records", (store, abort) => {
693 const runCallback = (preloaded = []) => {
694 // Expose a consistent API for every adapter instead of raw store methods.
695 const proxy = transactionProxy(this, store, preloaded);
696 // The callback is executed synchronously within the same transaction.
698 const returned = callback(proxy);
699 if (returned instanceof Promise) {
700 // XXX: investigate how to provide documentation details in error.
701 throw new Error("execute() callback should not return a Promise.");
703 // Bring to scope that will be returned (once promise awaited).
707 // The callback has thrown an error explicitly. Abort transaction cleanly.
711 // No option to preload records, go straight to `callback`.
712 if (!options.preload.length) {
713 return runCallback();
715 // Preload specified records using a list request.
716 const filters = { id: options.preload };
717 createListRequest(this.cid, store, filters, records => {
718 // Store obtained records by id.
719 const preloaded = {};
720 for (const record of records) {
721 delete record["_cid"];
722 preloaded[record.id] = record;
724 runCallback(preloaded);
726 }, { mode: "readwrite" });
730 * Retrieve a record by its primary key from the IndexedDB database.
733 * @param {String} id The record id.
739 await this.prepare("records", store => {
740 store.get([this.cid, id]).onsuccess = e => (record = e.target.result);
745 this._handleError("get", e);
749 * Lists all records from the IndexedDB database.
752 * @param {Object} params The filters and order to apply to the results.
755 async list(params = { filters: {} }) {
756 const { filters } = params;
759 await this.prepare("records", store => {
760 createListRequest(this.cid, store, filters, _results => {
761 // we have received all requested records that match the filters,
762 // we now park them within current scope and hide the `_cid` attribute.
763 for (const result of _results) {
764 delete result["_cid"];
769 // The resulting list of records is sorted.
770 // XXX: with some efforts, this could be fully implemented using IDB API.
771 return params.order ? sortObjects(params.order, results) : results;
774 this._handleError("list", e);
778 * Store the lastModified value into metadata store.
781 * @param {Number} lastModified
784 async saveLastModified(lastModified) {
785 const value = parseInt(lastModified, 10) || null;
787 await this.prepare("timestamps", store => {
788 if (value === null) {
789 store.delete(this.cid);
792 store.put({ cid: this.cid, value });
794 }, { mode: "readwrite" });
798 this._handleError("saveLastModified", e);
802 * Retrieve saved lastModified value.
807 async getLastModified() {
810 await this.prepare("timestamps", store => {
811 store.get(this.cid).onsuccess = e => (entry = e.target.result);
813 return entry ? entry.value : null;
816 this._handleError("getLastModified", e);
820 * Load a dump of records exported from a server.
822 * @deprecated Use {@link importBulk} instead.
824 * @param {Array} records The records to load.
827 async loadDump(records) {
828 return this.importBulk(records);
831 * Load records in bulk that were exported from a server.
834 * @param {Array} records The records to load.
837 async importBulk(records) {
839 await this.execute(transaction => {
840 // Since the put operations are asynchronous, we chain
841 // them together. The last one will be waited for the
842 // `transaction.oncomplete` callback. (see #execute())
846 if (i == records.length) {
849 // On error, `transaction.onerror` is called.
850 transaction.update(records[i]).onsuccess = putNext;
854 const previousLastModified = await this.getLastModified();
855 const lastModified = Math.max(...records.map(record => record.last_modified));
856 if (lastModified > previousLastModified) {
857 await this.saveLastModified(lastModified);
862 this._handleError("importBulk", e);
865 async saveMetadata(metadata) {
867 await this.prepare("collections", store => store.put({ cid: this.cid, metadata }), { mode: "readwrite" });
871 this._handleError("saveMetadata", e);
874 async getMetadata() {
877 await this.prepare("collections", store => {
878 store.get(this.cid).onsuccess = e => (entry = e.target.result);
880 return entry ? entry.metadata : null;
883 this._handleError("getMetadata", e);
888 * IDB transaction proxy.
890 * @param {IDB} adapter The call IDB adapter
891 * @param {IDBStore} store The IndexedDB database store.
892 * @param {Array} preloaded The list of records to make available to
893 * get() (default: []).
896 function transactionProxy(adapter, store, preloaded = []) {
897 const _cid = adapter.cid;
900 store.add(Object.assign(Object.assign({}, record), { _cid }));
903 return store.put(Object.assign(Object.assign({}, record), { _cid }));
906 store.delete([_cid, id]);
909 return preloaded[id];
914 * Up to version 10.X of kinto.js, each collection had its own collection.
915 * The database name was `${bid}/${cid}` (eg. `"blocklists/certificates"`)
916 * and contained only one store with the same name.
918 async function migrationRequired(dbName) {
920 const db = await open(dbName, {
922 onupgradeneeded: event => {
926 // Check that the DB we're looking at is really a legacy one,
927 // and not some remainder of the open() operation above.
929 db.objectStoreNames.contains("__meta__") &&
930 db.objectStoreNames.contains(dbName);
933 // Testing the existence creates it, so delete it :)
934 await deleteDatabase(dbName);
937 console.warn(`${dbName}: old IndexedDB database found.`);
941 await execute(db, dbName, store => {
942 store.openCursor().onsuccess = cursorHandlers.all({}, res => (records = res));
944 console.log(`${dbName}: found ${records.length} records.`);
945 // Check if there's a entry for this.
946 let timestamp = null;
947 await execute(db, "__meta__", store => {
948 store.get(`${dbName}-lastModified`).onsuccess = e => {
949 timestamp = e.target.result ? e.target.result.value : null;
952 // Some previous versions, also used to store the timestamps without prefix.
954 await execute(db, "__meta__", store => {
955 store.get("lastModified").onsuccess = e => {
956 timestamp = e.target.result ? e.target.result.value : null;
960 console.log(`${dbName}: ${timestamp ? "found" : "no"} timestamp.`);
961 // Those will be inserted in the new database/schema.
962 return { records, timestamp };
965 console.error("Error occured during migration", e);
975 const RECORD_FIELDS_TO_CLEAN = ["_status"];
976 const AVAILABLE_HOOKS = ["incoming-changes"];
977 const IMPORT_CHUNK_SIZE = 200;
979 * Compare two records omitting local fields and synchronization
980 * attributes (like _status and last_modified)
981 * @param {Object} a A record to compare.
982 * @param {Object} b A record to compare.
983 * @param {Array} localFields Additional fields to ignore during the comparison
986 function recordsEqual(a, b, localFields = []) {
987 const fieldsToClean = RECORD_FIELDS_TO_CLEAN.concat(["last_modified"]).concat(localFields);
988 const cleanLocal = r => omitKeys(r, fieldsToClean);
989 return deepEqual(cleanLocal(a), cleanLocal(b));
992 * Synchronization result object.
994 class SyncResultObject {
996 * Public constructor.
1000 * Current synchronization result status; becomes `false` when conflicts or
1001 * errors are registered.
1004 this.lastModified = null;
1016 ].forEach(l => (this._lists[l] = []));
1020 * Adds entries for a given result type.
1022 * @param {String} type The result type.
1023 * @param {Array} entries The result entries.
1024 * @return {SyncResultObject}
1026 add(type, entries) {
1027 if (!Array.isArray(this._lists[type])) {
1028 console.warn(`Unknown type "${type}"`);
1031 if (!Array.isArray(entries)) {
1032 entries = [entries];
1034 this._lists[type] = this._lists[type].concat(entries);
1035 delete this._cached[type];
1039 return this.errors.length + this.conflicts.length === 0;
1042 return this._lists["errors"];
1045 return this._lists["conflicts"];
1048 return this._deduplicate("skipped");
1051 return this._deduplicate("resolved");
1054 return this._deduplicate("created");
1057 return this._deduplicate("updated");
1060 return this._deduplicate("deleted");
1063 return this._deduplicate("published");
1065 _deduplicate(list) {
1066 if (!(list in this._cached)) {
1067 // Deduplicate entries by id. If the values don't have `id` attribute, just
1069 const recordsWithoutId = new Set();
1070 const recordsById = new Map();
1071 this._lists[list].forEach(record => {
1073 recordsWithoutId.add(record);
1076 recordsById.set(record.id, record);
1079 this._cached[list] = Array.from(recordsById.values()).concat(Array.from(recordsWithoutId));
1081 return this._cached[list];
1084 * Reinitializes result entries for a given result type.
1086 * @param {String} type The result type.
1087 * @return {SyncResultObject}
1090 this._lists[type] = [];
1091 delete this._cached[type];
1095 // Only used in tests.
1098 lastModified: this.lastModified,
1099 errors: this.errors,
1100 created: this.created,
1101 updated: this.updated,
1102 deleted: this.deleted,
1103 skipped: this.skipped,
1104 published: this.published,
1105 conflicts: this.conflicts,
1106 resolved: this.resolved,
1110 class ServerWasFlushedError extends Error {
1111 constructor(clientTimestamp, serverTimestamp, message) {
1113 if (Error.captureStackTrace) {
1114 Error.captureStackTrace(this, ServerWasFlushedError);
1116 this.clientTimestamp = clientTimestamp;
1117 this.serverTimestamp = serverTimestamp;
1120 function createUUIDSchema() {
1126 return typeof id == "string" && RE_RECORD_ID.test(id);
1130 function markStatus(record, status) {
1131 return Object.assign(Object.assign({}, record), { _status: status });
1133 function markDeleted(record) {
1134 return markStatus(record, "deleted");
1136 function markSynced(record) {
1137 return markStatus(record, "synced");
1140 * Import a remote change into the local database.
1142 * @param {IDBTransactionProxy} transaction The transaction handler.
1143 * @param {Object} remote The remote change object to import.
1144 * @param {Array<String>} localFields The list of fields that remain local.
1145 * @param {String} strategy The {@link Collection.strategy}.
1148 function importChange(transaction, remote, localFields, strategy) {
1149 const local = transaction.get(remote.id);
1151 // Not found locally but remote change is marked as deleted; skip to
1152 // avoid recreation.
1153 if (remote.deleted) {
1154 return { type: "skipped", data: remote };
1156 const synced = markSynced(remote);
1157 transaction.create(synced);
1158 return { type: "created", data: synced };
1160 // Apply remote changes on local record.
1161 const synced = Object.assign(Object.assign({}, local), markSynced(remote));
1162 // With pull only, we don't need to compare records since we override them.
1163 if (strategy === Collection.strategy.PULL_ONLY) {
1164 if (remote.deleted) {
1165 transaction.delete(remote.id);
1166 return { type: "deleted", data: local };
1168 transaction.update(synced);
1169 return { type: "updated", data: { old: local, new: synced } };
1171 // With other sync strategies, we detect conflicts,
1172 // by comparing local and remote, ignoring local fields.
1173 const isIdentical = recordsEqual(local, remote, localFields);
1174 // Detect or ignore conflicts if record has also been modified locally.
1175 if (local._status !== "synced") {
1176 // Locally deleted, unsynced: scheduled for remote deletion.
1177 if (local._status === "deleted") {
1178 return { type: "skipped", data: local };
1181 // If records are identical, import anyway, so we bump the
1182 // local last_modified value from the server and set record
1183 // status to "synced".
1184 transaction.update(synced);
1185 return { type: "updated", data: { old: local, new: synced } };
1187 if (local.last_modified !== undefined &&
1188 local.last_modified === remote.last_modified) {
1189 // If our local version has the same last_modified as the remote
1190 // one, this represents an object that corresponds to a resolved
1191 // conflict. Our local version represents the final output, so
1192 // we keep that one. (No transaction operation to do.)
1193 // But if our last_modified is undefined,
1194 // that means we've created the same object locally as one on
1195 // the server, which *must* be a conflict.
1196 return { type: "void" };
1200 data: { type: "incoming", local: local, remote: remote },
1203 // Local record was synced.
1204 if (remote.deleted) {
1205 transaction.delete(remote.id);
1206 return { type: "deleted", data: local };
1209 transaction.update(synced);
1210 // if identical, simply exclude it from all SyncResultObject lists
1211 const type = isIdentical ? "void" : "updated";
1212 return { type, data: { old: local, new: synced } };
1215 * Abstracts a collection of records stored in the local database, providing
1216 * CRUD operations and synchronization helpers.
1223 * - `{BaseAdapter} adapter` The DB adapter (default: `IDB`)
1225 * @param {String} bucket The bucket identifier.
1226 * @param {String} name The collection name.
1227 * @param {KintoBase} kinto The Kinto instance.
1228 * @param {Object} options The options object.
1230 constructor(bucket, name, kinto, options = {}) {
1231 this._bucket = bucket;
1233 this._lastModified = null;
1234 const DBAdapter = options.adapter || IDB;
1236 throw new Error("No adapter provided");
1238 const db = new DBAdapter(`${bucket}/${name}`, options.adapterOptions);
1239 if (!(db instanceof BaseAdapter)) {
1240 throw new Error("Unsupported adapter.");
1242 // public properties
1244 * The db adapter instance
1245 * @type {BaseAdapter}
1249 * The KintoBase instance.
1254 * The event emitter instance.
1255 * @type {EventEmitter}
1257 this.events = options.events;
1259 * The IdSchema instance.
1262 this.idSchema = this._validateIdSchema(options.idSchema);
1264 * The list of remote transformers.
1267 this.remoteTransformers = this._validateRemoteTransformers(options.remoteTransformers);
1269 * The list of hooks.
1272 this.hooks = this._validateHooks(options.hooks);
1274 * The list of fields names that will remain local.
1277 this.localFields = options.localFields || [];
1281 * @type {KintoClient}
1284 return this.kinto.api;
1287 * The collection name.
1298 return this._bucket;
1301 * The last modified timestamp.
1304 get lastModified() {
1305 return this._lastModified;
1308 * Synchronization strategies. Available strategies are:
1310 * - `MANUAL`: Conflicts will be reported in a dedicated array.
1311 * - `SERVER_WINS`: Conflicts are resolved using remote data.
1312 * - `CLIENT_WINS`: Conflicts are resolved using local data.
1316 static get strategy() {
1318 CLIENT_WINS: "client_wins",
1319 SERVER_WINS: "server_wins",
1320 PULL_ONLY: "pull_only",
1325 * Validates an idSchema.
1327 * @param {Object|undefined} idSchema
1330 _validateIdSchema(idSchema) {
1331 if (typeof idSchema === "undefined") {
1332 return createUUIDSchema();
1334 if (typeof idSchema !== "object") {
1335 throw new Error("idSchema must be an object.");
1337 else if (typeof idSchema.generate !== "function") {
1338 throw new Error("idSchema must provide a generate function.");
1340 else if (typeof idSchema.validate !== "function") {
1341 throw new Error("idSchema must provide a validate function.");
1346 * Validates a list of remote transformers.
1348 * @param {Array|undefined} remoteTransformers
1351 _validateRemoteTransformers(remoteTransformers) {
1352 if (typeof remoteTransformers === "undefined") {
1355 if (!Array.isArray(remoteTransformers)) {
1356 throw new Error("remoteTransformers should be an array.");
1358 return remoteTransformers.map(transformer => {
1359 if (typeof transformer !== "object") {
1360 throw new Error("A transformer must be an object.");
1362 else if (typeof transformer.encode !== "function") {
1363 throw new Error("A transformer must provide an encode function.");
1365 else if (typeof transformer.decode !== "function") {
1366 throw new Error("A transformer must provide a decode function.");
1372 * Validate the passed hook is correct.
1374 * @param {Array|undefined} hook.
1377 _validateHook(hook) {
1378 if (!Array.isArray(hook)) {
1379 throw new Error("A hook definition should be an array of functions.");
1381 return hook.map(fn => {
1382 if (typeof fn !== "function") {
1383 throw new Error("A hook definition should be an array of functions.");
1389 * Validates a list of hooks.
1391 * @param {Object|undefined} hooks
1394 _validateHooks(hooks) {
1395 if (typeof hooks === "undefined") {
1398 if (Array.isArray(hooks)) {
1399 throw new Error("hooks should be an object, not an array.");
1401 if (typeof hooks !== "object") {
1402 throw new Error("hooks should be an object.");
1404 const validatedHooks = {};
1405 for (const hook in hooks) {
1406 if (!AVAILABLE_HOOKS.includes(hook)) {
1407 throw new Error("The hook should be one of " + AVAILABLE_HOOKS.join(", "));
1409 validatedHooks[hook] = this._validateHook(hooks[hook]);
1411 return validatedHooks;
1414 * Deletes every records in the current collection and marks the collection as
1420 await this.db.clear();
1421 await this.db.saveMetadata(null);
1422 await this.db.saveLastModified(null);
1423 return { data: [], permissions: {} };
1428 * @param {String} type Either "remote" or "local".
1429 * @param {Object} record The record object to encode.
1432 _encodeRecord(type, record) {
1433 if (!this[`${type}Transformers`].length) {
1434 return Promise.resolve(record);
1436 return waterfall(this[`${type}Transformers`].map(transformer => {
1437 return record => transformer.encode(record);
1443 * @param {String} type Either "remote" or "local".
1444 * @param {Object} record The record object to decode.
1447 _decodeRecord(type, record) {
1448 if (!this[`${type}Transformers`].length) {
1449 return Promise.resolve(record);
1451 return waterfall(this[`${type}Transformers`].reverse().map(transformer => {
1452 return record => transformer.decode(record);
1456 * Adds a record to the local database, asserting that none
1457 * already exist with this ID.
1459 * Note: If either the `useRecordId` or `synced` options are true, then the
1460 * record object must contain the id field to be validated. If none of these
1461 * options are true, an id is generated using the current IdSchema; in this
1462 * case, the record passed must not have an id.
1465 * - {Boolean} synced Sets record status to "synced" (default: `false`).
1466 * - {Boolean} useRecordId Forces the `id` field from the record to be used,
1467 * instead of one that is generated automatically
1468 * (default: `false`).
1470 * @param {Object} record
1471 * @param {Object} options
1474 create(record, options = { useRecordId: false, synced: false }) {
1475 // Validate the record and its ID (if any), even though this
1476 // validation is also done in the CollectionTransaction method,
1477 // because we need to pass the ID to preloadIds.
1478 const reject = msg => Promise.reject(new Error(msg));
1479 if (typeof record !== "object") {
1480 return reject("Record is not an object.");
1482 if ((options.synced || options.useRecordId) &&
1483 !Object.prototype.hasOwnProperty.call(record, "id")) {
1484 return reject("Missing required Id; synced and useRecordId options require one");
1486 if (!options.synced &&
1487 !options.useRecordId &&
1488 Object.prototype.hasOwnProperty.call(record, "id")) {
1489 return reject("Extraneous Id; can't create a record having one set.");
1491 const newRecord = Object.assign(Object.assign({}, record), { id: options.synced || options.useRecordId
1493 : this.idSchema.generate(record), _status: options.synced ? "synced" : "created" });
1494 if (!this.idSchema.validate(newRecord.id)) {
1495 return reject(`Invalid Id: ${newRecord.id}`);
1497 return this.execute(txn => txn.create(newRecord), {
1498 preloadIds: [newRecord.id],
1500 if (options.useRecordId) {
1501 throw new Error("Couldn't create record. It may have been virtually deleted.");
1507 * Like {@link CollectionTransaction#update}, but wrapped in its own transaction.
1510 * - {Boolean} synced: Sets record status to "synced" (default: false)
1511 * - {Boolean} patch: Extends the existing record instead of overwriting it
1514 * @param {Object} record
1515 * @param {Object} options
1518 update(record, options = { synced: false, patch: false }) {
1519 // Validate the record and its ID, even though this validation is
1520 // also done in the CollectionTransaction method, because we need
1521 // to pass the ID to preloadIds.
1522 if (typeof record !== "object") {
1523 return Promise.reject(new Error("Record is not an object."));
1525 if (!Object.prototype.hasOwnProperty.call(record, "id")) {
1526 return Promise.reject(new Error("Cannot update a record missing id."));
1528 if (!this.idSchema.validate(record.id)) {
1529 return Promise.reject(new Error(`Invalid Id: ${record.id}`));
1531 return this.execute(txn => txn.update(record, options), {
1532 preloadIds: [record.id],
1536 * Like {@link CollectionTransaction#upsert}, but wrapped in its own transaction.
1538 * @param {Object} record
1542 // Validate the record and its ID, even though this validation is
1543 // also done in the CollectionTransaction method, because we need
1544 // to pass the ID to preloadIds.
1545 if (typeof record !== "object") {
1546 return Promise.reject(new Error("Record is not an object."));
1548 if (!Object.prototype.hasOwnProperty.call(record, "id")) {
1549 return Promise.reject(new Error("Cannot update a record missing id."));
1551 if (!this.idSchema.validate(record.id)) {
1552 return Promise.reject(new Error(`Invalid Id: ${record.id}`));
1554 return this.execute(txn => txn.upsert(record), { preloadIds: [record.id] });
1557 * Like {@link CollectionTransaction#get}, but wrapped in its own transaction.
1560 * - {Boolean} includeDeleted: Include virtually deleted records.
1562 * @param {String} id
1563 * @param {Object} options
1566 get(id, options = { includeDeleted: false }) {
1567 return this.execute(txn => txn.get(id, options), { preloadIds: [id] });
1570 * Like {@link CollectionTransaction#getAny}, but wrapped in its own transaction.
1572 * @param {String} id
1576 return this.execute(txn => txn.getAny(id), { preloadIds: [id] });
1579 * Same as {@link Collection#delete}, but wrapped in its own transaction.
1582 * - {Boolean} virtual: When set to `true`, doesn't actually delete the record,
1583 * update its `_status` attribute to `deleted` instead (default: true)
1585 * @param {String} id The record's Id.
1586 * @param {Object} options The options object.
1589 delete(id, options = { virtual: true }) {
1590 return this.execute(transaction => {
1591 return transaction.delete(id, options);
1592 }, { preloadIds: [id] });
1595 * Same as {@link Collection#deleteAll}, but wrapped in its own transaction, execulding the parameter.
1600 const { data } = await this.list({}, { includeDeleted: false });
1601 const recordIds = data.map(record => record.id);
1602 return this.execute(transaction => {
1603 return transaction.deleteAll(recordIds);
1604 }, { preloadIds: recordIds });
1607 * The same as {@link CollectionTransaction#deleteAny}, but wrapped
1608 * in its own transaction.
1610 * @param {String} id The record's Id.
1614 return this.execute(txn => txn.deleteAny(id), { preloadIds: [id] });
1617 * Lists records from the local database.
1620 * - {Object} filters Filter the results (default: `{}`).
1621 * - {String} order The order to apply (default: `-last_modified`).
1624 * - {Boolean} includeDeleted: Include virtually deleted records.
1626 * @param {Object} params The filters and order to apply to the results.
1627 * @param {Object} options The options object.
1630 async list(params = {}, options = { includeDeleted: false }) {
1631 params = Object.assign({ order: "-last_modified", filters: {} }, params);
1632 const results = await this.db.list(params);
1634 if (!options.includeDeleted) {
1635 data = results.filter(record => record._status !== "deleted");
1637 return { data, permissions: {} };
1640 * Imports remote changes into the local database.
1641 * This method is in charge of detecting the conflicts, and resolve them
1642 * according to the specified strategy.
1643 * @param {SyncResultObject} syncResultObject The sync result object.
1644 * @param {Array} decodedChanges The list of changes to import in the local database.
1645 * @param {String} strategy The {@link Collection.strategy} (default: MANUAL)
1648 async importChanges(syncResultObject, decodedChanges, strategy = Collection.strategy.MANUAL) {
1649 // Retrieve records matching change ids.
1651 for (let i = 0; i < decodedChanges.length; i += IMPORT_CHUNK_SIZE) {
1652 const slice = decodedChanges.slice(i, i + IMPORT_CHUNK_SIZE);
1653 const { imports, resolved } = await this.db.execute(transaction => {
1654 const imports = slice.map(remote => {
1655 // Store remote change into local database.
1656 return importChange(transaction, remote, this.localFields, strategy);
1658 const conflicts = imports
1659 .filter(i => i.type === "conflicts")
1661 const resolved = this._handleConflicts(transaction, conflicts, strategy);
1662 return { imports, resolved };
1663 }, { preload: slice.map(record => record.id) });
1664 // Lists of created/updated/deleted records
1665 imports.forEach(({ type, data }) => syncResultObject.add(type, data));
1666 // Automatically resolved conflicts (if not manual)
1667 if (resolved.length > 0) {
1668 syncResultObject.reset("conflicts").add("resolved", resolved);
1675 message: err.message,
1678 // XXX one error of the whole transaction instead of per atomic op
1679 syncResultObject.add("errors", data);
1681 return syncResultObject;
1684 * Imports the responses of pushed changes into the local database.
1685 * Basically it stores the timestamp assigned by the server into the local
1687 * @param {SyncResultObject} syncResultObject The sync result object.
1688 * @param {Array} toApplyLocally The list of changes to import in the local database.
1689 * @param {Array} conflicts The list of conflicts that have to be resolved.
1690 * @param {String} strategy The {@link Collection.strategy}.
1693 async _applyPushedResults(syncResultObject, toApplyLocally, conflicts, strategy = Collection.strategy.MANUAL) {
1694 const toDeleteLocally = toApplyLocally.filter(r => r.deleted);
1695 const toUpdateLocally = toApplyLocally.filter(r => !r.deleted);
1696 const { published, resolved } = await this.db.execute(transaction => {
1697 const updated = toUpdateLocally.map(record => {
1698 const synced = markSynced(record);
1699 transaction.update(synced);
1702 const deleted = toDeleteLocally.map(record => {
1703 transaction.delete(record.id);
1704 // Amend result data with the deleted attribute set
1705 return { id: record.id, deleted: true };
1707 const published = updated.concat(deleted);
1708 // Handle conflicts, if any
1709 const resolved = this._handleConflicts(transaction, conflicts, strategy);
1710 return { published, resolved };
1712 syncResultObject.add("published", published);
1713 if (resolved.length > 0) {
1717 .add("resolved", resolved);
1719 return syncResultObject;
1722 * Handles synchronization conflicts according to specified strategy.
1724 * @param {SyncResultObject} result The sync result object.
1725 * @param {String} strategy The {@link Collection.strategy}.
1726 * @return {Promise<Array<Object>>} The resolved conflicts, as an
1727 * array of {accepted, rejected} objects
1729 _handleConflicts(transaction, conflicts, strategy) {
1730 if (strategy === Collection.strategy.MANUAL) {
1733 return conflicts.map(conflict => {
1734 const resolution = strategy === Collection.strategy.CLIENT_WINS
1737 const rejected = strategy === Collection.strategy.CLIENT_WINS
1740 let accepted, status, id;
1741 if (resolution === null) {
1742 // We "resolved" with the server-side deletion. Delete locally.
1743 // This only happens during SERVER_WINS because the local
1744 // version of a record can never be null.
1745 // We can get "null" from the remote side if we got a conflict
1746 // and there is no remote version available; see kinto-http.js
1747 // batch.js:aggregate.
1748 transaction.delete(conflict.local.id);
1750 // The record was deleted, but that status is "synced" with
1751 // the server, so we don't need to push the change.
1753 id = conflict.local.id;
1756 const updated = this._resolveRaw(conflict, resolution);
1757 transaction.update(updated);
1759 status = updated._status;
1762 return { rejected, accepted, id, _status: status };
1766 * Execute a bunch of operations in a transaction.
1768 * This transaction should be atomic -- either all of its operations
1769 * will succeed, or none will.
1771 * The argument to this function is itself a function which will be
1772 * called with a {@link CollectionTransaction}. Collection methods
1773 * are available on this transaction, but instead of returning
1774 * promises, they are synchronous. execute() returns a Promise whose
1775 * value will be the return value of the provided function.
1777 * Most operations will require access to the record itself, which
1778 * must be preloaded by passing its ID in the preloadIds option.
1781 * - {Array} preloadIds: list of IDs to fetch at the beginning of
1784 * @return {Promise} Resolves with the result of the given function
1785 * when the transaction commits.
1787 execute(doOperations, { preloadIds = [] } = {}) {
1788 for (const id of preloadIds) {
1789 if (!this.idSchema.validate(id)) {
1790 return Promise.reject(Error(`Invalid Id: ${id}`));
1793 return this.db.execute(transaction => {
1794 const txn = new CollectionTransaction(this, transaction);
1795 const result = doOperations(txn);
1798 }, { preload: preloadIds });
1801 * Resets the local records as if they were never synced; existing records are
1802 * marked as newly created, deleted records are dropped.
1804 * A next call to {@link Collection.sync} will thus republish the whole
1805 * content of the local collection to the server.
1807 * @return {Promise} Resolves with the number of processed records.
1809 async resetSyncStatus() {
1810 const unsynced = await this.list({ filters: { _status: ["deleted", "synced"] }, order: "" }, { includeDeleted: true });
1811 await this.db.execute(transaction => {
1812 unsynced.data.forEach(record => {
1813 if (record._status === "deleted") {
1814 // Garbage collect deleted records.
1815 transaction.delete(record.id);
1818 // Records that were synced become «created».
1819 transaction.update(Object.assign(Object.assign({}, record), { last_modified: undefined, _status: "created" }));
1823 this._lastModified = null;
1824 await this.db.saveLastModified(null);
1825 return unsynced.data.length;
1828 * Returns an object containing two lists:
1830 * - `toDelete`: unsynced deleted records we can safely delete;
1831 * - `toSync`: local updates to send to the server.
1835 async gatherLocalChanges() {
1836 const unsynced = await this.list({
1837 filters: { _status: ["created", "updated"] },
1840 const deleted = await this.list({ filters: { _status: "deleted" }, order: "" }, { includeDeleted: true });
1841 return await Promise.all(unsynced.data
1842 .concat(deleted.data)
1843 .map(this._encodeRecord.bind(this, "remote")));
1846 * Fetch remote changes, import them to the local database, and handle
1847 * conflicts according to `options.strategy`. Then, updates the passed
1848 * {@link SyncResultObject} with import results.
1851 * - {String} strategy: The selected sync strategy.
1852 * - {String} expectedTimestamp: A timestamp to use as a "cache busting" query parameter.
1853 * - {Array<String>} exclude: A list of record ids to exclude from pull.
1854 * - {Object} headers: The HTTP headers to use in the request.
1855 * - {int} retry: The number of retries to do if the HTTP request fails.
1856 * - {int} lastModified: The timestamp to use in `?_since` query.
1858 * @param {KintoClient.Collection} client Kinto client Collection instance.
1859 * @param {SyncResultObject} syncResultObject The sync result object.
1860 * @param {Object} options The options object.
1863 async pullChanges(client, syncResultObject, options = {}) {
1864 if (!syncResultObject.ok) {
1865 return syncResultObject;
1867 const since = this.lastModified
1869 : await this.db.getLastModified();
1870 options = Object.assign({ strategy: Collection.strategy.MANUAL, lastModified: since, headers: {} }, options);
1871 // Optionally ignore some records when pulling for changes.
1872 // (avoid redownloading our own changes on last step of #sync())
1874 if (options.exclude) {
1875 // Limit the list of excluded records to the first 50 records in order
1876 // to remain under de-facto URL size limit (~2000 chars).
1877 // http://stackoverflow.com/questions/417142/what-is-the-maximum-length-of-a-url-in-different-browsers/417184#417184
1878 const exclude_id = options.exclude
1882 filters = { exclude_id };
1884 if (options.expectedTimestamp) {
1885 filters = Object.assign(Object.assign({}, filters), { _expected: options.expectedTimestamp });
1887 // First fetch remote changes from the server
1888 const { data, last_modified } = await client.listRecords({
1889 // Since should be ETag (see https://github.com/Kinto/kinto.js/issues/356)
1890 since: options.lastModified ? `${options.lastModified}` : undefined,
1891 headers: options.headers,
1892 retry: options.retry,
1893 // Fetch every page by default (FIXME: option to limit pages, see #277)
1897 // last_modified is the ETag header value (string).
1898 // For retro-compatibility with first kinto.js versions
1899 // parse it to integer.
1900 const unquoted = last_modified ? parseInt(last_modified, 10) : undefined;
1901 // Check if server was flushed.
1902 // This is relevant for the Kinto demo server
1903 // (and thus for many new comers).
1904 const localSynced = options.lastModified;
1905 const serverChanged = unquoted > options.lastModified;
1906 const emptyCollection = data.length === 0;
1907 if (!options.exclude && localSynced && serverChanged && emptyCollection) {
1908 const e = new ServerWasFlushedError(localSynced, unquoted, "Server has been flushed. Client Side Timestamp: " +
1910 " Server Side Timestamp: " +
1914 // Atomic updates are not sensible here because unquoted is not
1915 // computed as a function of syncResultObject.lastModified.
1916 // eslint-disable-next-line require-atomic-updates
1917 syncResultObject.lastModified = unquoted;
1918 // Decode incoming changes.
1919 const decodedChanges = await Promise.all(data.map(change => {
1920 return this._decodeRecord("remote", change);
1922 // Hook receives decoded records.
1923 const payload = { lastModified: unquoted, changes: decodedChanges };
1924 const afterHooks = await this.applyHook("incoming-changes", payload);
1925 // No change, nothing to import.
1926 if (afterHooks.changes.length > 0) {
1927 // Reflect these changes locally
1928 await this.importChanges(syncResultObject, afterHooks.changes, options.strategy);
1930 return syncResultObject;
1932 applyHook(hookName, payload) {
1933 if (typeof this.hooks[hookName] == "undefined") {
1934 return Promise.resolve(payload);
1936 return waterfall(this.hooks[hookName].map(hook => {
1938 const result = hook(payload, this);
1939 const resultThenable = result && typeof result.then === "function";
1940 const resultChanges = result && Object.prototype.hasOwnProperty.call(result, "changes");
1941 if (!(resultThenable || resultChanges)) {
1942 throw new Error(`Invalid return value for hook: ${JSON.stringify(result)} has no 'then()' or 'changes' properties`);
1949 * Publish local changes to the remote server and updates the passed
1950 * {@link SyncResultObject} with publication results.
1953 * - {String} strategy: The selected sync strategy.
1954 * - {Object} headers: The HTTP headers to use in the request.
1955 * - {int} retry: The number of retries to do if the HTTP request fails.
1957 * @param {KintoClient.Collection} client Kinto client Collection instance.
1958 * @param {SyncResultObject} syncResultObject The sync result object.
1959 * @param {Object} changes The change object.
1960 * @param {Array} changes.toDelete The list of records to delete.
1961 * @param {Array} changes.toSync The list of records to create/update.
1962 * @param {Object} options The options object.
1965 async pushChanges(client, changes, syncResultObject, options = {}) {
1966 if (!syncResultObject.ok) {
1967 return syncResultObject;
1969 const safe = !options.strategy || options.strategy !== Collection.CLIENT_WINS;
1970 const toDelete = changes.filter(r => r._status == "deleted");
1971 const toSync = changes.filter(r => r._status != "deleted");
1972 // Perform a batch request with every changes.
1973 const synced = await client.batch(batch => {
1974 toDelete.forEach(r => {
1975 // never published locally deleted records should not be pusblished
1976 if (r.last_modified) {
1977 batch.deleteRecord(r);
1980 toSync.forEach(r => {
1981 // Clean local fields (like _status) before sending to server.
1982 const published = this.cleanLocalFields(r);
1983 if (r._status === "created") {
1984 batch.createRecord(published);
1987 batch.updateRecord(published);
1991 headers: options.headers,
1992 retry: options.retry,
1996 // Store outgoing errors into sync result object
1997 syncResultObject.add("errors", synced.errors.map(e => (Object.assign(Object.assign({}, e), { type: "outgoing" }))));
1998 // Store outgoing conflicts into sync result object
1999 const conflicts = [];
2000 for (const { type, local, remote } of synced.conflicts) {
2001 // Note: we ensure that local data are actually available, as they may
2002 // be missing in the case of a published deletion.
2003 const safeLocal = (local && local.data) || { id: remote.id };
2004 const realLocal = await this._decodeRecord("remote", safeLocal);
2005 // We can get "null" from the remote side if we got a conflict
2006 // and there is no remote version available; see kinto-http.js
2007 // batch.js:aggregate.
2008 const realRemote = remote && (await this._decodeRecord("remote", remote));
2009 const conflict = { type, local: realLocal, remote: realRemote };
2010 conflicts.push(conflict);
2012 syncResultObject.add("conflicts", conflicts);
2013 // Records that must be deleted are either deletions that were pushed
2014 // to server (published) or deleted records that were never pushed (skipped).
2015 const missingRemotely = synced.skipped.map(r => (Object.assign(Object.assign({}, r), { deleted: true })));
2016 // For created and updated records, the last_modified coming from server
2017 // will be stored locally.
2018 // Reflect publication results locally using the response from
2019 // the batch request.
2020 const published = synced.published.map(c => c.data);
2021 const toApplyLocally = published.concat(missingRemotely);
2022 // Apply the decode transformers, if any
2023 const decoded = await Promise.all(toApplyLocally.map(record => {
2024 return this._decodeRecord("remote", record);
2026 // We have to update the local records with the responses of the server
2027 // (eg. last_modified values etc.).
2028 if (decoded.length > 0 || conflicts.length > 0) {
2029 await this._applyPushedResults(syncResultObject, decoded, conflicts, options.strategy);
2031 return syncResultObject;
2034 * Return a copy of the specified record without the local fields.
2036 * @param {Object} record A record with potential local fields.
2039 cleanLocalFields(record) {
2040 const localKeys = RECORD_FIELDS_TO_CLEAN.concat(this.localFields);
2041 return omitKeys(record, localKeys);
2044 * Resolves a conflict, updating local record according to proposed
2045 * resolution — keeping remote record `last_modified` value as a reference for
2046 * further batch sending.
2048 * @param {Object} conflict The conflict object.
2049 * @param {Object} resolution The proposed record.
2052 resolve(conflict, resolution) {
2053 return this.db.execute(transaction => {
2054 const updated = this._resolveRaw(conflict, resolution);
2055 transaction.update(updated);
2056 return { data: updated, permissions: {} };
2062 _resolveRaw(conflict, resolution) {
2063 const resolved = Object.assign(Object.assign({}, resolution), {
2064 // Ensure local record has the latest authoritative timestamp
2065 last_modified: conflict.remote && conflict.remote.last_modified });
2066 // If the resolution object is strictly equal to the
2067 // remote record, then we can mark it as synced locally.
2068 // Otherwise, mark it as updated (so that the resolution is pushed).
2069 const synced = deepEqual(resolved, conflict.remote);
2070 return markStatus(resolved, synced ? "synced" : "updated");
2073 * Synchronize remote and local data. The promise will resolve with a
2074 * {@link SyncResultObject}, though will reject:
2076 * - if the server is currently backed off;
2077 * - if the server has been detected flushed.
2080 * - {Object} headers: HTTP headers to attach to outgoing requests.
2081 * - {String} expectedTimestamp: A timestamp to use as a "cache busting" query parameter.
2082 * - {Number} retry: Number of retries when server fails to process the request (default: 1).
2083 * - {Collection.strategy} strategy: See {@link Collection.strategy}.
2084 * - {Boolean} ignoreBackoff: Force synchronization even if server is currently
2086 * - {String} bucket: The remove bucket id to use (default: null)
2087 * - {String} collection: The remove collection id to use (default: null)
2088 * - {String} remote The remote Kinto server endpoint to use (default: null).
2090 * @param {Object} options Options.
2092 * @throws {Error} If an invalid remote option is passed.
2094 async sync(options = {
2095 strategy: Collection.strategy.MANUAL,
2098 ignoreBackoff: false,
2102 expectedTimestamp: null,
2104 options = Object.assign(Object.assign({}, options), { bucket: options.bucket || this.bucket, collection: options.collection || this.name });
2105 const previousRemote = this.api.remote;
2106 if (options.remote) {
2107 // Note: setting the remote ensures it's valid, throws when invalid.
2108 this.api.remote = options.remote;
2110 if (!options.ignoreBackoff && this.api.backoff > 0) {
2111 const seconds = Math.ceil(this.api.backoff / 1000);
2112 return Promise.reject(new Error(`Server is asking clients to back off; retry in ${seconds}s or use the ignoreBackoff option.`));
2114 const client = this.api
2115 .bucket(options.bucket)
2116 .collection(options.collection);
2117 const result = new SyncResultObject();
2119 // Fetch collection metadata.
2120 await this.pullMetadata(client, options);
2121 // Fetch last changes from the server.
2122 await this.pullChanges(client, result, options);
2123 const { lastModified } = result;
2124 if (options.strategy != Collection.strategy.PULL_ONLY) {
2125 // Fetch local changes
2126 const toSync = await this.gatherLocalChanges();
2127 // Publish local changes and pull local resolutions
2128 await this.pushChanges(client, toSync, result, options);
2129 // Publish local resolution of push conflicts to server (on CLIENT_WINS)
2130 const resolvedUnsynced = result.resolved.filter(r => r._status !== "synced");
2131 if (resolvedUnsynced.length > 0) {
2132 const resolvedEncoded = await Promise.all(resolvedUnsynced.map(resolution => {
2133 let record = resolution.accepted;
2134 if (record === null) {
2135 record = { id: resolution.id, _status: resolution._status };
2137 return this._encodeRecord("remote", record);
2139 await this.pushChanges(client, resolvedEncoded, result, options);
2141 // Perform a last pull to catch changes that occured after the last pull,
2142 // while local changes were pushed. Do not do it nothing was pushed.
2143 if (result.published.length > 0) {
2144 // Avoid redownloading our own changes during the last pull.
2145 const pullOpts = Object.assign(Object.assign({}, options), { lastModified, exclude: result.published });
2146 await this.pullChanges(client, result, pullOpts);
2149 // Don't persist lastModified value if any conflict or error occured
2151 // No conflict occured, persist collection's lastModified value
2152 this._lastModified = await this.db.saveLastModified(result.lastModified);
2156 this.events.emit("sync:error", Object.assign(Object.assign({}, options), { error: e }));
2160 // Ensure API default remote is reverted if a custom one's been used
2161 this.api.remote = previousRemote;
2163 this.events.emit("sync:success", Object.assign(Object.assign({}, options), { result }));
2167 * Load a list of records already synced with the remote server.
2169 * The local records which are unsynced or whose timestamp is either missing
2170 * or superior to those being loaded will be ignored.
2172 * @deprecated Use {@link importBulk} instead.
2173 * @param {Array} records The previously exported list of records to load.
2174 * @return {Promise} with the effectively imported records.
2176 async loadDump(records) {
2177 return this.importBulk(records);
2180 * Load a list of records already synced with the remote server.
2182 * The local records which are unsynced or whose timestamp is either missing
2183 * or superior to those being loaded will be ignored.
2185 * @param {Array} records The previously exported list of records to load.
2186 * @return {Promise} with the effectively imported records.
2188 async importBulk(records) {
2189 if (!Array.isArray(records)) {
2190 throw new Error("Records is not an array.");
2192 for (const record of records) {
2193 if (!Object.prototype.hasOwnProperty.call(record, "id") ||
2194 !this.idSchema.validate(record.id)) {
2195 throw new Error("Record has invalid ID: " + JSON.stringify(record));
2197 if (!record.last_modified) {
2198 throw new Error("Record has no last_modified value: " + JSON.stringify(record));
2201 // Fetch all existing records from local database,
2202 // and skip those who are newer or not marked as synced.
2203 // XXX filter by status / ids in records
2204 const { data } = await this.list({}, { includeDeleted: true });
2205 const existingById = data.reduce((acc, record) => {
2206 acc[record.id] = record;
2209 const newRecords = records.filter(record => {
2210 const localRecord = existingById[record.id];
2212 // No local record with this id.
2213 localRecord === undefined ||
2214 // Or local record is synced
2215 (localRecord._status === "synced" &&
2216 // And was synced from server
2217 localRecord.last_modified !== undefined &&
2218 // And is older than imported one.
2219 record.last_modified > localRecord.last_modified);
2222 return await this.db.importBulk(newRecords.map(markSynced));
2224 async pullMetadata(client, options = {}) {
2225 const { expectedTimestamp, headers } = options;
2226 const query = expectedTimestamp
2227 ? { query: { _expected: expectedTimestamp } }
2229 const metadata = await client.getData(Object.assign(Object.assign({}, query), { headers }));
2230 return this.db.saveMetadata(metadata);
2233 return this.db.getMetadata();
2237 * A Collection-oriented wrapper for an adapter's transaction.
2239 * This defines the high-level functions available on a collection.
2240 * The collection itself offers functions of the same name. These will
2241 * perform just one operation in its own transaction.
2243 class CollectionTransaction {
2244 constructor(collection, adapterTransaction) {
2245 this.collection = collection;
2246 this.adapterTransaction = adapterTransaction;
2249 _queueEvent(action, payload) {
2250 this._events.push({ action, payload });
2253 * Emit queued events, to be called once every transaction operations have
2254 * been executed successfully.
2257 for (const { action, payload } of this._events) {
2258 this.collection.events.emit(action, payload);
2260 if (this._events.length > 0) {
2261 const targets = this._events.map(({ action, payload }) => (Object.assign({ action }, payload)));
2262 this.collection.events.emit("change", { targets });
2267 * Retrieve a record by its id from the local database, or
2268 * undefined if none exists.
2270 * This will also return virtually deleted records.
2272 * @param {String} id
2276 const record = this.adapterTransaction.get(id);
2277 return { data: record, permissions: {} };
2280 * Retrieve a record by its id from the local database.
2283 * - {Boolean} includeDeleted: Include virtually deleted records.
2285 * @param {String} id
2286 * @param {Object} options
2289 get(id, options = { includeDeleted: false }) {
2290 const res = this.getAny(id);
2292 (!options.includeDeleted && res.data._status === "deleted")) {
2293 throw new Error(`Record with id=${id} not found.`);
2298 * Deletes a record from the local database.
2301 * - {Boolean} virtual: When set to `true`, doesn't actually delete the record,
2302 * update its `_status` attribute to `deleted` instead (default: true)
2304 * @param {String} id The record's Id.
2305 * @param {Object} options The options object.
2308 delete(id, options = { virtual: true }) {
2309 // Ensure the record actually exists.
2310 const existing = this.adapterTransaction.get(id);
2311 const alreadyDeleted = existing && existing._status == "deleted";
2312 if (!existing || (alreadyDeleted && options.virtual)) {
2313 throw new Error(`Record with id=${id} not found.`);
2315 // Virtual updates status.
2316 if (options.virtual) {
2317 this.adapterTransaction.update(markDeleted(existing));
2321 this.adapterTransaction.delete(id);
2323 this._queueEvent("delete", { data: existing });
2324 return { data: existing, permissions: {} };
2327 * Soft delete all records from the local database.
2329 * @param {Array} ids Array of non-deleted Record Ids.
2333 const existingRecords = [];
2335 existingRecords.push(this.adapterTransaction.get(id));
2338 this._queueEvent("deleteAll", { data: existingRecords });
2339 return { data: existingRecords, permissions: {} };
2342 * Deletes a record from the local database, if any exists.
2343 * Otherwise, do nothing.
2345 * @param {String} id The record's Id.
2349 const existing = this.adapterTransaction.get(id);
2351 this.adapterTransaction.update(markDeleted(existing));
2352 this._queueEvent("delete", { data: existing });
2354 return { data: Object.assign({ id }, existing), deleted: !!existing, permissions: {} };
2357 * Adds a record to the local database, asserting that none
2358 * already exist with this ID.
2360 * @param {Object} record, which must contain an ID
2364 if (typeof record !== "object") {
2365 throw new Error("Record is not an object.");
2367 if (!Object.prototype.hasOwnProperty.call(record, "id")) {
2368 throw new Error("Cannot create a record missing id");
2370 if (!this.collection.idSchema.validate(record.id)) {
2371 throw new Error(`Invalid Id: ${record.id}`);
2373 this.adapterTransaction.create(record);
2374 this._queueEvent("create", { data: record });
2375 return { data: record, permissions: {} };
2378 * Updates a record from the local database.
2381 * - {Boolean} synced: Sets record status to "synced" (default: false)
2382 * - {Boolean} patch: Extends the existing record instead of overwriting it
2385 * @param {Object} record
2386 * @param {Object} options
2389 update(record, options = { synced: false, patch: false }) {
2390 if (typeof record !== "object") {
2391 throw new Error("Record is not an object.");
2393 if (!Object.prototype.hasOwnProperty.call(record, "id")) {
2394 throw new Error("Cannot update a record missing id.");
2396 if (!this.collection.idSchema.validate(record.id)) {
2397 throw new Error(`Invalid Id: ${record.id}`);
2399 const oldRecord = this.adapterTransaction.get(record.id);
2401 throw new Error(`Record with id=${record.id} not found.`);
2403 const newRecord = options.patch ? Object.assign(Object.assign({}, oldRecord), record) : record;
2404 const updated = this._updateRaw(oldRecord, newRecord, options);
2405 this.adapterTransaction.update(updated);
2406 this._queueEvent("update", { data: updated, oldRecord });
2407 return { data: updated, oldRecord, permissions: {} };
2410 * Lower-level primitive for updating a record while respecting
2411 * _status and last_modified.
2413 * @param {Object} oldRecord: the record retrieved from the DB
2414 * @param {Object} newRecord: the record to replace it with
2417 _updateRaw(oldRecord, newRecord, { synced = false } = {}) {
2418 const updated = Object.assign({}, newRecord);
2419 // Make sure to never loose the existing timestamp.
2420 if (oldRecord && oldRecord.last_modified && !updated.last_modified) {
2421 updated.last_modified = oldRecord.last_modified;
2423 // If only local fields have changed, then keep record as synced.
2424 // If status is created, keep record as created.
2425 // If status is deleted, mark as updated.
2426 const isIdentical = oldRecord &&
2427 recordsEqual(oldRecord, updated, this.collection.localFields);
2428 const keepSynced = isIdentical && oldRecord._status == "synced";
2429 const neverSynced = !oldRecord || (oldRecord && oldRecord._status == "created");
2430 const newStatus = keepSynced || synced ? "synced" : neverSynced ? "created" : "updated";
2431 return markStatus(updated, newStatus);
2434 * Upsert a record into the local database.
2436 * This record must have an ID.
2438 * If a record with this ID already exists, it will be replaced.
2439 * Otherwise, this record will be inserted.
2441 * @param {Object} record
2445 if (typeof record !== "object") {
2446 throw new Error("Record is not an object.");
2448 if (!Object.prototype.hasOwnProperty.call(record, "id")) {
2449 throw new Error("Cannot update a record missing id.");
2451 if (!this.collection.idSchema.validate(record.id)) {
2452 throw new Error(`Invalid Id: ${record.id}`);
2454 let oldRecord = this.adapterTransaction.get(record.id);
2455 const updated = this._updateRaw(oldRecord, record);
2456 this.adapterTransaction.update(updated);
2457 // Don't return deleted records -- pretend they are gone
2458 if (oldRecord && oldRecord._status == "deleted") {
2459 oldRecord = undefined;
2462 this._queueEvent("update", { data: updated, oldRecord });
2465 this._queueEvent("create", { data: updated });
2467 return { data: updated, oldRecord, permissions: {} };
2471 const DEFAULT_BUCKET_NAME = "default";
2472 const DEFAULT_REMOTE = "http://localhost:8888/v1";
2473 const DEFAULT_RETRY = 1;
2479 * Provides a public access to the base adapter class. Users can create a
2480 * custom DB adapter by extending {@link BaseAdapter}.
2484 static get adapters() {
2486 BaseAdapter: BaseAdapter,
2490 * Synchronization strategies. Available strategies are:
2492 * - `MANUAL`: Conflicts will be reported in a dedicated array.
2493 * - `SERVER_WINS`: Conflicts are resolved using remote data.
2494 * - `CLIENT_WINS`: Conflicts are resolved using local data.
2498 static get syncStrategy() {
2499 return Collection.strategy;
2505 * - `{String}` `remote` The server URL to use.
2506 * - `{String}` `bucket` The collection bucket name.
2507 * - `{EventEmitter}` `events` Events handler.
2508 * - `{BaseAdapter}` `adapter` The base DB adapter class.
2509 * - `{Object}` `adapterOptions` Options given to the adapter.
2510 * - `{Object}` `headers` The HTTP headers to use.
2511 * - `{Object}` `retry` Number of retries when the server fails to process the request (default: `1`)
2512 * - `{String}` `requestMode` The HTTP CORS mode to use.
2513 * - `{Number}` `timeout` The requests timeout in ms (default: `5000`).
2515 * @param {Object} options The options object.
2517 constructor(options = {}) {
2519 bucket: DEFAULT_BUCKET_NAME,
2520 remote: DEFAULT_REMOTE,
2521 retry: DEFAULT_RETRY,
2523 this._options = Object.assign(Object.assign({}, defaults), options);
2524 if (!this._options.adapter) {
2525 throw new Error("No adapter provided");
2529 * The event emitter instance.
2530 * @type {EventEmitter}
2532 this.events = this._options.events;
2535 * The kinto HTTP client instance.
2536 * @type {KintoClient}
2539 const { events, headers, remote, requestMode, retry, timeout, } = this._options;
2541 this._api = new this.ApiClass(remote, {
2552 * Creates a {@link Collection} instance. The second (optional) parameter
2553 * will set collection-level options like e.g. `remoteTransformers`.
2555 * @param {String} collName The collection name.
2556 * @param {Object} [options={}] Extra options or override client's options.
2557 * @param {Object} [options.idSchema] IdSchema instance (default: UUID)
2558 * @param {Object} [options.remoteTransformers] Array<RemoteTransformer> (default: `[]`])
2559 * @param {Object} [options.hooks] Array<Hook> (default: `[]`])
2560 * @param {Object} [options.localFields] Array<Field> (default: `[]`])
2561 * @return {Collection}
2563 collection(collName, options = {}) {
2565 throw new Error("missing collection name");
2567 const { bucket, events, adapter, adapterOptions } = Object.assign(Object.assign({}, this._options), options);
2568 const { idSchema, remoteTransformers, hooks, localFields } = options;
2569 return new Collection(bucket, collName, this, {
2583 * Licensed under the Apache License, Version 2.0 (the "License");
2584 * you may not use this file except in compliance with the License.
2585 * You may obtain a copy of the License at
2587 * http://www.apache.org/licenses/LICENSE-2.0
2589 * Unless required by applicable law or agreed to in writing, software
2590 * distributed under the License is distributed on an "AS IS" BASIS,
2591 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
2592 * See the License for the specific language governing permissions and
2593 * limitations under the License.
2595 const { setTimeout, clearTimeout } = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs");
2596 const { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
2597 XPCOMUtils.defineLazyGlobalGetters(global, ["fetch", "indexedDB"]);
2598 ChromeUtils.defineESModuleGetters(global, {
2599 EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
2600 // Use standalone kinto-http module landed in FFx.
2601 KintoHttpClient: "resource://services-common/kinto-http-client.sys.mjs"
2603 ChromeUtils.defineLazyGetter(global, "generateUUID", () => {
2604 const { generateUUID } = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
2605 return generateUUID;
2607 class Kinto extends KintoBase {
2608 static get adapters() {
2615 return KintoHttpClient;
2617 constructor(options = {}) {
2619 EventEmitter.decorate(events);
2624 super(Object.assign(Object.assign({}, defaults), options));
2626 collection(collName, options = {}) {
2629 return typeof id == "string" && RE_RECORD_ID.test(id);
2632 return generateUUID()
2634 .replace(/[{}]/g, "");
2637 return super.collection(collName, Object.assign({ idSchema }, options));