Bug 1842773 - Part 5: Add ArrayBuffer.prototype.{maxByteLength,resizable} getters...
[gecko.git] / services / common / kinto-offline-client.js
blob7b11347555db12de3fd9ccf82f618651949f5378
1 /*
2  *
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
6  *
7  *     http://www.apache.org/licenses/LICENSE-2.0
8  *
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.
14  */
15 "use strict";
18  * This file is generated from kinto.js - do not modify directly.
19  */
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
27 // present.
29 // See https://bugzilla.mozilla.org/show_bug.cgi?id=1394556#c3 for
30 // more details.
31 const global = this;
33 var EXPORTED_SYMBOLS = ["Kinto"];
36  * Version 13.0.0 - 7fbf95d
37  */
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';
45   /**
46    * Base db adapter.
47    *
48    * @abstract
49    */
50   class BaseAdapter {
51       /**
52        * Deletes every records present in the database.
53        *
54        * @abstract
55        * @return {Promise}
56        */
57       clear() {
58           throw new Error("Not Implemented.");
59       }
60       /**
61        * Executes a batch of operations within a single transaction.
62        *
63        * @abstract
64        * @param  {Function} callback The operation callback.
65        * @param  {Object}   options  The options object.
66        * @return {Promise}
67        */
68       execute(callback, options = { preload: [] }) {
69           throw new Error("Not Implemented.");
70       }
71       /**
72        * Retrieve a record by its primary key from the database.
73        *
74        * @abstract
75        * @param  {String} id The record id.
76        * @return {Promise}
77        */
78       get(id) {
79           throw new Error("Not Implemented.");
80       }
81       /**
82        * Lists all records from the database.
83        *
84        * @abstract
85        * @param  {Object} params  The filters and order to apply to the results.
86        * @return {Promise}
87        */
88       list(params = { filters: {}, order: "" }) {
89           throw new Error("Not Implemented.");
90       }
91       /**
92        * Store the lastModified value.
93        *
94        * @abstract
95        * @param  {Number}  lastModified
96        * @return {Promise}
97        */
98       saveLastModified(lastModified) {
99           throw new Error("Not Implemented.");
100       }
101       /**
102        * Retrieve saved lastModified value.
103        *
104        * @abstract
105        * @return {Promise}
106        */
107       getLastModified() {
108           throw new Error("Not Implemented.");
109       }
110       /**
111        * Load records in bulk that were exported from a server.
112        *
113        * @abstract
114        * @param  {Array} records The records to load.
115        * @return {Promise}
116        */
117       importBulk(records) {
118           throw new Error("Not Implemented.");
119       }
120       /**
121        * Load a dump of records exported from a server.
122        *
123        * @deprecated Use {@link importBulk} instead.
124        * @abstract
125        * @param  {Array} records The records to load.
126        * @return {Promise}
127        */
128       loadDump(records) {
129           throw new Error("Not Implemented.");
130       }
131       saveMetadata(metadata) {
132           throw new Error("Not Implemented.");
133       }
134       getMetadata() {
135           throw new Error("Not Implemented.");
136       }
137   }
139   const RE_RECORD_ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
140   /**
141    * Checks if a value is undefined.
142    * @param  {Any}  value
143    * @return {Boolean}
144    */
145   function _isUndefined(value) {
146       return typeof value === "undefined";
147   }
148   /**
149    * Sorts records in a list according to a given ordering.
150    *
151    * @param  {String} order The ordering, eg. `-last_modified`.
152    * @param  {Array}  list  The collection to order.
153    * @return {Array}
154    */
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])) {
161               return direction;
162           }
163           if (b[field] && _isUndefined(a[field])) {
164               return -direction;
165           }
166           if (_isUndefined(a[field]) && _isUndefined(b[field])) {
167               return 0;
168           }
169           return a[field] > b[field] ? direction : -direction;
170       });
171   }
172   /**
173    * Test if a single object matches all given filters.
174    *
175    * @param  {Object} filters  The filters object.
176    * @param  {Object} entry    The object to filter.
177    * @return {Boolean}
178    */
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]);
184           }
185           else if (typeof value === "object") {
186               return filterObject(value, entry[filter]);
187           }
188           else if (!Object.prototype.hasOwnProperty.call(entry, filter)) {
189               console.error(`The property ${filter} does not exist`);
190               return false;
191           }
192           return entry[filter] === value;
193       });
194   }
195   /**
196    * Resolves a list of functions sequentially, which can be sync or async; in
197    * case of async, functions must return a promise.
198    *
199    * @param  {Array} fns  The list of functions.
200    * @param  {Any}   init The initial value.
201    * @return {Promise}
202    */
203   function waterfall(fns, init) {
204       if (!fns.length) {
205           return Promise.resolve(init);
206       }
207       return fns.reduce((promise, nextFn) => {
208           return promise.then(nextFn);
209       }, Promise.resolve(init));
210   }
211   /**
212    * Simple deep object comparison function. This only supports comparison of
213    * serializable JavaScript objects.
214    *
215    * @param  {Object} a The source object.
216    * @param  {Object} b The compared object.
217    * @return {Boolean}
218    */
219   function deepEqual(a, b) {
220       if (a === b) {
221           return true;
222       }
223       if (typeof a !== typeof b) {
224           return false;
225       }
226       if (!(a && typeof a == "object") || !(b && typeof b == "object")) {
227           return false;
228       }
229       if (Object.keys(a).length !== Object.keys(b).length) {
230           return false;
231       }
232       for (const k in a) {
233           if (!deepEqual(a[k], b[k])) {
234               return false;
235           }
236       }
237       return true;
238   }
239   /**
240    * Return an object without the specified keys.
241    *
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.
245    */
246   function omitKeys(obj, keys = []) {
247       const result = Object.assign({}, obj);
248       for (const key of keys) {
249           delete result[key];
250       }
251       return result;
252   }
253   function arrayEqual(a, b) {
254       if (a.length !== b.length) {
255           return false;
256       }
257       for (let i = a.length; i--;) {
258           if (a[i] !== b[i]) {
259               return false;
260           }
261       }
262       return true;
263   }
264   function makeNestedObjectFromArr(arr, val, nestedFiltersObj) {
265       const last = arr.length - 1;
266       return arr.reduce((acc, cv, i) => {
267           if (i === last) {
268               return (acc[cv] = val);
269           }
270           else if (Object.prototype.hasOwnProperty.call(acc, cv)) {
271               return acc[cv];
272           }
273           else {
274               return (acc[cv] = {});
275           }
276       }, nestedFiltersObj);
277   }
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);
284       }
285       return transformedFilters;
286   }
288   const INDEXED_FIELDS = ["id", "_status", "last_modified"];
289   /**
290    * Small helper that wraps the opening of an IndexedDB into a Promise.
291    *
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>}
297    */
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 ||
308                       transaction.error ||
309                       new DOMException("The operation has been aborted", "AbortError");
310                   reject(error);
311               };
312               // Callback for store creation etc.
313               return onupgradeneeded(event);
314           };
315           request.onerror = event => {
316               reject(event.target.error);
317           };
318           request.onsuccess = event => {
319               const db = event.target.result;
320               resolve(db);
321           };
322       });
323   }
324   /**
325    * Helper to run the specified callback in a single transaction on the
326    * specified store.
327    * The helper focuses on transaction wrapping into a promise.
328    *
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.
335    */
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
340           // a TypeError.
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.
346           const abort = e => {
347               transaction.abort();
348               reject(e);
349           };
350           // Execute the specified callback **synchronously**.
351           let result;
352           try {
353               result = callback(store, abort);
354           }
355           catch (e) {
356               abort(e);
357           }
358           transaction.onerror = event => reject(event.target.error);
359           transaction.oncomplete = event => resolve(result);
360           transaction.onabort = event => {
361               const error = event.target.error ||
362                   transaction.error ||
363                   new DOMException("The operation has been aborted", "AbortError");
364               reject(error);
365           };
366       });
367   }
368   /**
369    * Helper to wrap the deletion of an IndexedDB database into a promise.
370    *
371    * @param dbName {String} the database to delete
372    * @return {Promise}
373    */
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);
379       });
380   }
381   /**
382    * IDB cursor handlers.
383    * @type {Object}
384    */
385   const cursorHandlers = {
386       all(filters, done) {
387           const results = [];
388           return event => {
389               const cursor = event.target.result;
390               if (cursor) {
391                   const { value } = cursor;
392                   if (filterObject(filters, value)) {
393                       results.push(value);
394                   }
395                   cursor.continue();
396               }
397               else {
398                   done(results);
399               }
400           };
401       },
402       in(values, filters, done) {
403           const results = [];
404           let i = 0;
405           return function (event) {
406               const cursor = event.target.result;
407               if (!cursor) {
408                   done(results);
409                   return;
410               }
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.
417                   ++i;
418                   if (i === values.length) {
419                       done(results); // There is no next. Stop searching.
420                       return;
421                   }
422               }
423               const isEqual = Array.isArray(key)
424                   ? arrayEqual(key, values[i])
425                   : key === values[i];
426               if (isEqual) {
427                   if (filterObject(filters, value)) {
428                       results.push(value);
429                   }
430                   cursor.continue();
431               }
432               else {
433                   cursor.continue(values[i]);
434               }
435           };
436       },
437   };
438   /**
439    * Creates an IDB request and attach it the appropriate cursor event handler to
440    * perform a list query.
441    *
442    * Multiple matching values are handled by passing an array.
443    *
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}
449    */
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);
456           return request;
457       }
458       // Introspect filters and check if they leverage an indexed field.
459       const indexField = filterFields.find(field => {
460           return INDEXED_FIELDS.includes(field);
461       });
462       if (!indexField) {
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"})
465           if (isSubQuery) {
466               const newFilter = transformSubObjectFilters(filters);
467               const request = store.index("cid").openCursor(IDBKeyRange.only(cid));
468               request.onsuccess = cursorHandlers.all(newFilter, done);
469               return request;
470           }
471           const request = store.index("cid").openCursor(IDBKeyRange.only(cid));
472           request.onsuccess = cursorHandlers.all(filters, done);
473           return request;
474       }
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) {
484               return done([]);
485           }
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);
490           return request;
491       }
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);
496           return request;
497       }
498       // WHERE field = value clause
499       const request = indexStore.openCursor(IDBKeyRange.only([cid, value]));
500       request.onsuccess = cursorHandlers.all(remainingFilters, done);
501       return request;
502   }
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;
508       }
509   }
510   /**
511    * IndexedDB adapter.
512    *
513    * This adapter doesn't support any options.
514    */
515   class IDB extends BaseAdapter {
516       /* Expose the IDBError class publicly */
517       static get IDBError() {
518           return IDBError;
519       }
520       /**
521        * Constructor.
522        *
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`)
527        */
528       constructor(cid, options = {}) {
529           super();
530           this.cid = cid;
531           this.dbName = options.dbName || "KintoDB";
532           this._options = options;
533           this._db = null;
534       }
535       _handleError(method, err) {
536           throw new IDBError(method, err);
537       }
538       /**
539        * Ensures a connection to the IndexedDB database has been opened.
540        *
541        * @override
542        * @return {Promise}
543        */
544       async open() {
545           if (this._db) {
546               return this;
547           }
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)
554               : null;
555           this._db = await open(this.dbName, {
556               version: 2,
557               onupgradeneeded: event => {
558                   const db = event.target.result;
559                   if (event.oldVersion < 1) {
560                       // Records store
561                       const recordsStore = db.createObjectStore("records", {
562                           keyPath: ["_cid", "id"],
563                       });
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"]);
571                       // Timestamps store
572                       db.createObjectStore("timestamps", {
573                           keyPath: "cid",
574                       });
575                   }
576                   if (event.oldVersion < 2) {
577                       // Collections store
578                       db.createObjectStore("collections", {
579                           keyPath: "cid",
580                       });
581                   }
582               },
583           });
584           if (dataToMigrate) {
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.`);
592           }
593           return this;
594       }
595       /**
596        * Closes current connection to the database.
597        *
598        * @override
599        * @return {Promise}
600        */
601       close() {
602           if (this._db) {
603               this._db.close(); // indexedDB.close is synchronous
604               this._db = null;
605           }
606           return Promise.resolve();
607       }
608       /**
609        * Returns a transaction and an object store for a store name.
610        *
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.
615        *
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)
620        * @return {Object}
621        */
622       async prepare(name, callback, options) {
623           await this.open();
624           await execute(this._db, name, callback, options);
625       }
626       /**
627        * Deletes every records in the current collection.
628        *
629        * @override
630        * @return {Promise}
631        */
632       async clear() {
633           try {
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;
639                       if (cursor) {
640                           store.delete(cursor.primaryKey);
641                           cursor.continue();
642                       }
643                   };
644                   return request;
645               }, { mode: "readwrite" });
646           }
647           catch (e) {
648               this._handleError("clear", e);
649           }
650       }
651       /**
652        * Executes the set of synchronous CRUD operations described in the provided
653        * callback within an IndexedDB transaction, for current db store.
654        *
655        * The callback will be provided an object exposing the following synchronous
656        * CRUD operation methods: get, create, update, delete.
657        *
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.
661        *
662        * Options:
663        * - {Array} preload: The list of record IDs to fetch and make available to
664        *   the transaction object get() method (default: [])
665        *
666        * @example
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);
672        *   return "foo";
673        * });
674        *
675        * @override
676        * @param  {Function} callback The operation description callback.
677        * @param  {Object}   options  The options object.
678        * @return {Promise}
679        */
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.
687           // See also:
688           // - http://stackoverflow.com/a/28388805/330911
689           // - http://stackoverflow.com/a/10405196
690           // - https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
691           let result;
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.
697                   try {
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.");
702                       }
703                       // Bring to scope that will be returned (once promise awaited).
704                       result = returned;
705                   }
706                   catch (e) {
707                       // The callback has thrown an error explicitly. Abort transaction cleanly.
708                       abort(e);
709                   }
710               };
711               // No option to preload records, go straight to `callback`.
712               if (!options.preload.length) {
713                   return runCallback();
714               }
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;
723                   }
724                   runCallback(preloaded);
725               });
726           }, { mode: "readwrite" });
727           return result;
728       }
729       /**
730        * Retrieve a record by its primary key from the IndexedDB database.
731        *
732        * @override
733        * @param  {String} id The record id.
734        * @return {Promise}
735        */
736       async get(id) {
737           try {
738               let record;
739               await this.prepare("records", store => {
740                   store.get([this.cid, id]).onsuccess = e => (record = e.target.result);
741               });
742               return record;
743           }
744           catch (e) {
745               this._handleError("get", e);
746           }
747       }
748       /**
749        * Lists all records from the IndexedDB database.
750        *
751        * @override
752        * @param  {Object} params  The filters and order to apply to the results.
753        * @return {Promise}
754        */
755       async list(params = { filters: {} }) {
756           const { filters } = params;
757           try {
758               let results = [];
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"];
765                       }
766                       results = _results;
767                   });
768               });
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;
772           }
773           catch (e) {
774               this._handleError("list", e);
775           }
776       }
777       /**
778        * Store the lastModified value into metadata store.
779        *
780        * @override
781        * @param  {Number}  lastModified
782        * @return {Promise}
783        */
784       async saveLastModified(lastModified) {
785           const value = parseInt(lastModified, 10) || null;
786           try {
787               await this.prepare("timestamps", store => {
788                   if (value === null) {
789                       store.delete(this.cid);
790                   }
791                   else {
792                       store.put({ cid: this.cid, value });
793                   }
794               }, { mode: "readwrite" });
795               return value;
796           }
797           catch (e) {
798               this._handleError("saveLastModified", e);
799           }
800       }
801       /**
802        * Retrieve saved lastModified value.
803        *
804        * @override
805        * @return {Promise}
806        */
807       async getLastModified() {
808           try {
809               let entry = null;
810               await this.prepare("timestamps", store => {
811                   store.get(this.cid).onsuccess = e => (entry = e.target.result);
812               });
813               return entry ? entry.value : null;
814           }
815           catch (e) {
816               this._handleError("getLastModified", e);
817           }
818       }
819       /**
820        * Load a dump of records exported from a server.
821        *
822        * @deprecated Use {@link importBulk} instead.
823        * @abstract
824        * @param  {Array} records The records to load.
825        * @return {Promise}
826        */
827       async loadDump(records) {
828           return this.importBulk(records);
829       }
830       /**
831        * Load records in bulk that were exported from a server.
832        *
833        * @abstract
834        * @param  {Array} records The records to load.
835        * @return {Promise}
836        */
837       async importBulk(records) {
838           try {
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())
843                   let i = 0;
844                   putNext();
845                   function putNext() {
846                       if (i == records.length) {
847                           return;
848                       }
849                       // On error, `transaction.onerror` is called.
850                       transaction.update(records[i]).onsuccess = putNext;
851                       ++i;
852                   }
853               });
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);
858               }
859               return records;
860           }
861           catch (e) {
862               this._handleError("importBulk", e);
863           }
864       }
865       async saveMetadata(metadata) {
866           try {
867               await this.prepare("collections", store => store.put({ cid: this.cid, metadata }), { mode: "readwrite" });
868               return metadata;
869           }
870           catch (e) {
871               this._handleError("saveMetadata", e);
872           }
873       }
874       async getMetadata() {
875           try {
876               let entry = null;
877               await this.prepare("collections", store => {
878                   store.get(this.cid).onsuccess = e => (entry = e.target.result);
879               });
880               return entry ? entry.metadata : null;
881           }
882           catch (e) {
883               this._handleError("getMetadata", e);
884           }
885       }
886   }
887   /**
888    * IDB transaction proxy.
889    *
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: []).
894    * @return {Object}
895    */
896   function transactionProxy(adapter, store, preloaded = []) {
897       const _cid = adapter.cid;
898       return {
899           create(record) {
900               store.add(Object.assign(Object.assign({}, record), { _cid }));
901           },
902           update(record) {
903               return store.put(Object.assign(Object.assign({}, record), { _cid }));
904           },
905           delete(id) {
906               store.delete([_cid, id]);
907           },
908           get(id) {
909               return preloaded[id];
910           },
911       };
912   }
913   /**
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.
917    */
918   async function migrationRequired(dbName) {
919       let exists = true;
920       const db = await open(dbName, {
921           version: 1,
922           onupgradeneeded: event => {
923               exists = false;
924           },
925       });
926       // Check that the DB we're looking at is really a legacy one,
927       // and not some remainder of the open() operation above.
928       exists &=
929           db.objectStoreNames.contains("__meta__") &&
930               db.objectStoreNames.contains(dbName);
931       if (!exists) {
932           db.close();
933           // Testing the existence creates it, so delete it :)
934           await deleteDatabase(dbName);
935           return null;
936       }
937       console.warn(`${dbName}: old IndexedDB database found.`);
938       try {
939           // Scan all records.
940           let records;
941           await execute(db, dbName, store => {
942               store.openCursor().onsuccess = cursorHandlers.all({}, res => (records = res));
943           });
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;
950               };
951           });
952           // Some previous versions, also used to store the timestamps without prefix.
953           if (!timestamp) {
954               await execute(db, "__meta__", store => {
955                   store.get("lastModified").onsuccess = e => {
956                       timestamp = e.target.result ? e.target.result.value : null;
957                   };
958               });
959           }
960           console.log(`${dbName}: ${timestamp ? "found" : "no"} timestamp.`);
961           // Those will be inserted in the new database/schema.
962           return { records, timestamp };
963       }
964       catch (e) {
965           console.error("Error occured during migration", e);
966           return null;
967       }
968       finally {
969           db.close();
970       }
971   }
973   var uuid4 = {};
975   const RECORD_FIELDS_TO_CLEAN = ["_status"];
976   const AVAILABLE_HOOKS = ["incoming-changes"];
977   const IMPORT_CHUNK_SIZE = 200;
978   /**
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
984    * @return {boolean}
985    */
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));
990   }
991   /**
992    * Synchronization result object.
993    */
994   class SyncResultObject {
995       /**
996        * Public constructor.
997        */
998       constructor() {
999           /**
1000            * Current synchronization result status; becomes `false` when conflicts or
1001            * errors are registered.
1002            * @type {Boolean}
1003            */
1004           this.lastModified = null;
1005           this._lists = {};
1006           [
1007               "errors",
1008               "created",
1009               "updated",
1010               "deleted",
1011               "published",
1012               "conflicts",
1013               "skipped",
1014               "resolved",
1015               "void",
1016           ].forEach(l => (this._lists[l] = []));
1017           this._cached = {};
1018       }
1019       /**
1020        * Adds entries for a given result type.
1021        *
1022        * @param {String} type    The result type.
1023        * @param {Array}  entries The result entries.
1024        * @return {SyncResultObject}
1025        */
1026       add(type, entries) {
1027           if (!Array.isArray(this._lists[type])) {
1028               console.warn(`Unknown type "${type}"`);
1029               return;
1030           }
1031           if (!Array.isArray(entries)) {
1032               entries = [entries];
1033           }
1034           this._lists[type] = this._lists[type].concat(entries);
1035           delete this._cached[type];
1036           return this;
1037       }
1038       get ok() {
1039           return this.errors.length + this.conflicts.length === 0;
1040       }
1041       get errors() {
1042           return this._lists["errors"];
1043       }
1044       get conflicts() {
1045           return this._lists["conflicts"];
1046       }
1047       get skipped() {
1048           return this._deduplicate("skipped");
1049       }
1050       get resolved() {
1051           return this._deduplicate("resolved");
1052       }
1053       get created() {
1054           return this._deduplicate("created");
1055       }
1056       get updated() {
1057           return this._deduplicate("updated");
1058       }
1059       get deleted() {
1060           return this._deduplicate("deleted");
1061       }
1062       get published() {
1063           return this._deduplicate("published");
1064       }
1065       _deduplicate(list) {
1066           if (!(list in this._cached)) {
1067               // Deduplicate entries by id. If the values don't have `id` attribute, just
1068               // keep all.
1069               const recordsWithoutId = new Set();
1070               const recordsById = new Map();
1071               this._lists[list].forEach(record => {
1072                   if (!record.id) {
1073                       recordsWithoutId.add(record);
1074                   }
1075                   else {
1076                       recordsById.set(record.id, record);
1077                   }
1078               });
1079               this._cached[list] = Array.from(recordsById.values()).concat(Array.from(recordsWithoutId));
1080           }
1081           return this._cached[list];
1082       }
1083       /**
1084        * Reinitializes result entries for a given result type.
1085        *
1086        * @param  {String} type The result type.
1087        * @return {SyncResultObject}
1088        */
1089       reset(type) {
1090           this._lists[type] = [];
1091           delete this._cached[type];
1092           return this;
1093       }
1094       toObject() {
1095           // Only used in tests.
1096           return {
1097               ok: this.ok,
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,
1107           };
1108       }
1109   }
1110   class ServerWasFlushedError extends Error {
1111       constructor(clientTimestamp, serverTimestamp, message) {
1112           super(message);
1113           if (Error.captureStackTrace) {
1114               Error.captureStackTrace(this, ServerWasFlushedError);
1115           }
1116           this.clientTimestamp = clientTimestamp;
1117           this.serverTimestamp = serverTimestamp;
1118       }
1119   }
1120   function createUUIDSchema() {
1121       return {
1122           generate() {
1123               return uuid4();
1124           },
1125           validate(id) {
1126               return typeof id == "string" && RE_RECORD_ID.test(id);
1127           },
1128       };
1129   }
1130   function markStatus(record, status) {
1131       return Object.assign(Object.assign({}, record), { _status: status });
1132   }
1133   function markDeleted(record) {
1134       return markStatus(record, "deleted");
1135   }
1136   function markSynced(record) {
1137       return markStatus(record, "synced");
1138   }
1139   /**
1140    * Import a remote change into the local database.
1141    *
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}.
1146    * @return {Object}
1147    */
1148   function importChange(transaction, remote, localFields, strategy) {
1149       const local = transaction.get(remote.id);
1150       if (!local) {
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 };
1155           }
1156           const synced = markSynced(remote);
1157           transaction.create(synced);
1158           return { type: "created", data: synced };
1159       }
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 };
1167           }
1168           transaction.update(synced);
1169           return { type: "updated", data: { old: local, new: synced } };
1170       }
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 };
1179           }
1180           if (isIdentical) {
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 } };
1186           }
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" };
1197           }
1198           return {
1199               type: "conflicts",
1200               data: { type: "incoming", local: local, remote: remote },
1201           };
1202       }
1203       // Local record was synced.
1204       if (remote.deleted) {
1205           transaction.delete(remote.id);
1206           return { type: "deleted", data: local };
1207       }
1208       // Import locally.
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 } };
1213   }
1214   /**
1215    * Abstracts a collection of records stored in the local database, providing
1216    * CRUD operations and synchronization helpers.
1217    */
1218   class Collection {
1219       /**
1220        * Constructor.
1221        *
1222        * Options:
1223        * - `{BaseAdapter} adapter` The DB adapter (default: `IDB`)
1224        *
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.
1229        */
1230       constructor(bucket, name, kinto, options = {}) {
1231           this._bucket = bucket;
1232           this._name = name;
1233           this._lastModified = null;
1234           const DBAdapter = options.adapter || IDB;
1235           if (!DBAdapter) {
1236               throw new Error("No adapter provided");
1237           }
1238           const db = new DBAdapter(`${bucket}/${name}`, options.adapterOptions);
1239           if (!(db instanceof BaseAdapter)) {
1240               throw new Error("Unsupported adapter.");
1241           }
1242           // public properties
1243           /**
1244            * The db adapter instance
1245            * @type {BaseAdapter}
1246            */
1247           this.db = db;
1248           /**
1249            * The KintoBase instance.
1250            * @type {KintoBase}
1251            */
1252           this.kinto = kinto;
1253           /**
1254            * The event emitter instance.
1255            * @type {EventEmitter}
1256            */
1257           this.events = options.events;
1258           /**
1259            * The IdSchema instance.
1260            * @type {Object}
1261            */
1262           this.idSchema = this._validateIdSchema(options.idSchema);
1263           /**
1264            * The list of remote transformers.
1265            * @type {Array}
1266            */
1267           this.remoteTransformers = this._validateRemoteTransformers(options.remoteTransformers);
1268           /**
1269            * The list of hooks.
1270            * @type {Object}
1271            */
1272           this.hooks = this._validateHooks(options.hooks);
1273           /**
1274            * The list of fields names that will remain local.
1275            * @type {Array}
1276            */
1277           this.localFields = options.localFields || [];
1278       }
1279       /**
1280        * The HTTP client.
1281        * @type {KintoClient}
1282        */
1283       get api() {
1284           return this.kinto.api;
1285       }
1286       /**
1287        * The collection name.
1288        * @type {String}
1289        */
1290       get name() {
1291           return this._name;
1292       }
1293       /**
1294        * The bucket name.
1295        * @type {String}
1296        */
1297       get bucket() {
1298           return this._bucket;
1299       }
1300       /**
1301        * The last modified timestamp.
1302        * @type {Number}
1303        */
1304       get lastModified() {
1305           return this._lastModified;
1306       }
1307       /**
1308        * Synchronization strategies. Available strategies are:
1309        *
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.
1313        *
1314        * @type {Object}
1315        */
1316       static get strategy() {
1317           return {
1318               CLIENT_WINS: "client_wins",
1319               SERVER_WINS: "server_wins",
1320               PULL_ONLY: "pull_only",
1321               MANUAL: "manual",
1322           };
1323       }
1324       /**
1325        * Validates an idSchema.
1326        *
1327        * @param  {Object|undefined} idSchema
1328        * @return {Object}
1329        */
1330       _validateIdSchema(idSchema) {
1331           if (typeof idSchema === "undefined") {
1332               return createUUIDSchema();
1333           }
1334           if (typeof idSchema !== "object") {
1335               throw new Error("idSchema must be an object.");
1336           }
1337           else if (typeof idSchema.generate !== "function") {
1338               throw new Error("idSchema must provide a generate function.");
1339           }
1340           else if (typeof idSchema.validate !== "function") {
1341               throw new Error("idSchema must provide a validate function.");
1342           }
1343           return idSchema;
1344       }
1345       /**
1346        * Validates a list of remote transformers.
1347        *
1348        * @param  {Array|undefined} remoteTransformers
1349        * @return {Array}
1350        */
1351       _validateRemoteTransformers(remoteTransformers) {
1352           if (typeof remoteTransformers === "undefined") {
1353               return [];
1354           }
1355           if (!Array.isArray(remoteTransformers)) {
1356               throw new Error("remoteTransformers should be an array.");
1357           }
1358           return remoteTransformers.map(transformer => {
1359               if (typeof transformer !== "object") {
1360                   throw new Error("A transformer must be an object.");
1361               }
1362               else if (typeof transformer.encode !== "function") {
1363                   throw new Error("A transformer must provide an encode function.");
1364               }
1365               else if (typeof transformer.decode !== "function") {
1366                   throw new Error("A transformer must provide a decode function.");
1367               }
1368               return transformer;
1369           });
1370       }
1371       /**
1372        * Validate the passed hook is correct.
1373        *
1374        * @param {Array|undefined} hook.
1375        * @return {Array}
1376        **/
1377       _validateHook(hook) {
1378           if (!Array.isArray(hook)) {
1379               throw new Error("A hook definition should be an array of functions.");
1380           }
1381           return hook.map(fn => {
1382               if (typeof fn !== "function") {
1383                   throw new Error("A hook definition should be an array of functions.");
1384               }
1385               return fn;
1386           });
1387       }
1388       /**
1389        * Validates a list of hooks.
1390        *
1391        * @param  {Object|undefined} hooks
1392        * @return {Object}
1393        */
1394       _validateHooks(hooks) {
1395           if (typeof hooks === "undefined") {
1396               return {};
1397           }
1398           if (Array.isArray(hooks)) {
1399               throw new Error("hooks should be an object, not an array.");
1400           }
1401           if (typeof hooks !== "object") {
1402               throw new Error("hooks should be an object.");
1403           }
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(", "));
1408               }
1409               validatedHooks[hook] = this._validateHook(hooks[hook]);
1410           }
1411           return validatedHooks;
1412       }
1413       /**
1414        * Deletes every records in the current collection and marks the collection as
1415        * never synced.
1416        *
1417        * @return {Promise}
1418        */
1419       async clear() {
1420           await this.db.clear();
1421           await this.db.saveMetadata(null);
1422           await this.db.saveLastModified(null);
1423           return { data: [], permissions: {} };
1424       }
1425       /**
1426        * Encodes a record.
1427        *
1428        * @param  {String} type   Either "remote" or "local".
1429        * @param  {Object} record The record object to encode.
1430        * @return {Promise}
1431        */
1432       _encodeRecord(type, record) {
1433           if (!this[`${type}Transformers`].length) {
1434               return Promise.resolve(record);
1435           }
1436           return waterfall(this[`${type}Transformers`].map(transformer => {
1437               return record => transformer.encode(record);
1438           }), record);
1439       }
1440       /**
1441        * Decodes a record.
1442        *
1443        * @param  {String} type   Either "remote" or "local".
1444        * @param  {Object} record The record object to decode.
1445        * @return {Promise}
1446        */
1447       _decodeRecord(type, record) {
1448           if (!this[`${type}Transformers`].length) {
1449               return Promise.resolve(record);
1450           }
1451           return waterfall(this[`${type}Transformers`].reverse().map(transformer => {
1452               return record => transformer.decode(record);
1453           }), record);
1454       }
1455       /**
1456        * Adds a record to the local database, asserting that none
1457        * already exist with this ID.
1458        *
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.
1463        *
1464        * Options:
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`).
1469        *
1470        * @param  {Object} record
1471        * @param  {Object} options
1472        * @return {Promise}
1473        */
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.");
1481           }
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");
1485           }
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.");
1490           }
1491           const newRecord = Object.assign(Object.assign({}, record), { id: options.synced || options.useRecordId
1492                   ? record.id
1493                   : this.idSchema.generate(record), _status: options.synced ? "synced" : "created" });
1494           if (!this.idSchema.validate(newRecord.id)) {
1495               return reject(`Invalid Id: ${newRecord.id}`);
1496           }
1497           return this.execute(txn => txn.create(newRecord), {
1498               preloadIds: [newRecord.id],
1499           }).catch(err => {
1500               if (options.useRecordId) {
1501                   throw new Error("Couldn't create record. It may have been virtually deleted.");
1502               }
1503               throw err;
1504           });
1505       }
1506       /**
1507        * Like {@link CollectionTransaction#update}, but wrapped in its own transaction.
1508        *
1509        * Options:
1510        * - {Boolean} synced: Sets record status to "synced" (default: false)
1511        * - {Boolean} patch:  Extends the existing record instead of overwriting it
1512        *   (default: false)
1513        *
1514        * @param  {Object} record
1515        * @param  {Object} options
1516        * @return {Promise}
1517        */
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."));
1524           }
1525           if (!Object.prototype.hasOwnProperty.call(record, "id")) {
1526               return Promise.reject(new Error("Cannot update a record missing id."));
1527           }
1528           if (!this.idSchema.validate(record.id)) {
1529               return Promise.reject(new Error(`Invalid Id: ${record.id}`));
1530           }
1531           return this.execute(txn => txn.update(record, options), {
1532               preloadIds: [record.id],
1533           });
1534       }
1535       /**
1536        * Like {@link CollectionTransaction#upsert}, but wrapped in its own transaction.
1537        *
1538        * @param  {Object} record
1539        * @return {Promise}
1540        */
1541       upsert(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."));
1547           }
1548           if (!Object.prototype.hasOwnProperty.call(record, "id")) {
1549               return Promise.reject(new Error("Cannot update a record missing id."));
1550           }
1551           if (!this.idSchema.validate(record.id)) {
1552               return Promise.reject(new Error(`Invalid Id: ${record.id}`));
1553           }
1554           return this.execute(txn => txn.upsert(record), { preloadIds: [record.id] });
1555       }
1556       /**
1557        * Like {@link CollectionTransaction#get}, but wrapped in its own transaction.
1558        *
1559        * Options:
1560        * - {Boolean} includeDeleted: Include virtually deleted records.
1561        *
1562        * @param  {String} id
1563        * @param  {Object} options
1564        * @return {Promise}
1565        */
1566       get(id, options = { includeDeleted: false }) {
1567           return this.execute(txn => txn.get(id, options), { preloadIds: [id] });
1568       }
1569       /**
1570        * Like {@link CollectionTransaction#getAny}, but wrapped in its own transaction.
1571        *
1572        * @param  {String} id
1573        * @return {Promise}
1574        */
1575       getAny(id) {
1576           return this.execute(txn => txn.getAny(id), { preloadIds: [id] });
1577       }
1578       /**
1579        * Same as {@link Collection#delete}, but wrapped in its own transaction.
1580        *
1581        * Options:
1582        * - {Boolean} virtual: When set to `true`, doesn't actually delete the record,
1583        *   update its `_status` attribute to `deleted` instead (default: true)
1584        *
1585        * @param  {String} id       The record's Id.
1586        * @param  {Object} options  The options object.
1587        * @return {Promise}
1588        */
1589       delete(id, options = { virtual: true }) {
1590           return this.execute(transaction => {
1591               return transaction.delete(id, options);
1592           }, { preloadIds: [id] });
1593       }
1594       /**
1595        * Same as {@link Collection#deleteAll}, but wrapped in its own transaction, execulding the parameter.
1596        *
1597        * @return {Promise}
1598        */
1599       async deleteAll() {
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 });
1605       }
1606       /**
1607        * The same as {@link CollectionTransaction#deleteAny}, but wrapped
1608        * in its own transaction.
1609        *
1610        * @param  {String} id       The record's Id.
1611        * @return {Promise}
1612        */
1613       deleteAny(id) {
1614           return this.execute(txn => txn.deleteAny(id), { preloadIds: [id] });
1615       }
1616       /**
1617        * Lists records from the local database.
1618        *
1619        * Params:
1620        * - {Object} filters Filter the results (default: `{}`).
1621        * - {String} order   The order to apply   (default: `-last_modified`).
1622        *
1623        * Options:
1624        * - {Boolean} includeDeleted: Include virtually deleted records.
1625        *
1626        * @param  {Object} params  The filters and order to apply to the results.
1627        * @param  {Object} options The options object.
1628        * @return {Promise}
1629        */
1630       async list(params = {}, options = { includeDeleted: false }) {
1631           params = Object.assign({ order: "-last_modified", filters: {} }, params);
1632           const results = await this.db.list(params);
1633           let data = results;
1634           if (!options.includeDeleted) {
1635               data = results.filter(record => record._status !== "deleted");
1636           }
1637           return { data, permissions: {} };
1638       }
1639       /**
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)
1646        * @return {Promise}
1647        */
1648       async importChanges(syncResultObject, decodedChanges, strategy = Collection.strategy.MANUAL) {
1649           // Retrieve records matching change ids.
1650           try {
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);
1657                       });
1658                       const conflicts = imports
1659                           .filter(i => i.type === "conflicts")
1660                           .map(i => i.data);
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);
1669                   }
1670               }
1671           }
1672           catch (err) {
1673               const data = {
1674                   type: "incoming",
1675                   message: err.message,
1676                   stack: err.stack,
1677               };
1678               // XXX one error of the whole transaction instead of per atomic op
1679               syncResultObject.add("errors", data);
1680           }
1681           return syncResultObject;
1682       }
1683       /**
1684        * Imports the responses of pushed changes into the local database.
1685        * Basically it stores the timestamp assigned by the server into the local
1686        * database.
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}.
1691        * @return {Promise}
1692        */
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);
1700                   return synced;
1701               });
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 };
1706               });
1707               const published = updated.concat(deleted);
1708               // Handle conflicts, if any
1709               const resolved = this._handleConflicts(transaction, conflicts, strategy);
1710               return { published, resolved };
1711           });
1712           syncResultObject.add("published", published);
1713           if (resolved.length > 0) {
1714               syncResultObject
1715                   .reset("conflicts")
1716                   .reset("resolved")
1717                   .add("resolved", resolved);
1718           }
1719           return syncResultObject;
1720       }
1721       /**
1722        * Handles synchronization conflicts according to specified strategy.
1723        *
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
1728        */
1729       _handleConflicts(transaction, conflicts, strategy) {
1730           if (strategy === Collection.strategy.MANUAL) {
1731               return [];
1732           }
1733           return conflicts.map(conflict => {
1734               const resolution = strategy === Collection.strategy.CLIENT_WINS
1735                   ? conflict.local
1736                   : conflict.remote;
1737               const rejected = strategy === Collection.strategy.CLIENT_WINS
1738                   ? conflict.remote
1739                   : conflict.local;
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);
1749                   accepted = null;
1750                   // The record was deleted, but that status is "synced" with
1751                   // the server, so we don't need to push the change.
1752                   status = "synced";
1753                   id = conflict.local.id;
1754               }
1755               else {
1756                   const updated = this._resolveRaw(conflict, resolution);
1757                   transaction.update(updated);
1758                   accepted = updated;
1759                   status = updated._status;
1760                   id = updated.id;
1761               }
1762               return { rejected, accepted, id, _status: status };
1763           });
1764       }
1765       /**
1766        * Execute a bunch of operations in a transaction.
1767        *
1768        * This transaction should be atomic -- either all of its operations
1769        * will succeed, or none will.
1770        *
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.
1776        *
1777        * Most operations will require access to the record itself, which
1778        * must be preloaded by passing its ID in the preloadIds option.
1779        *
1780        * Options:
1781        * - {Array} preloadIds: list of IDs to fetch at the beginning of
1782        *   the transaction
1783        *
1784        * @return {Promise} Resolves with the result of the given function
1785        *    when the transaction commits.
1786        */
1787       execute(doOperations, { preloadIds = [] } = {}) {
1788           for (const id of preloadIds) {
1789               if (!this.idSchema.validate(id)) {
1790                   return Promise.reject(Error(`Invalid Id: ${id}`));
1791               }
1792           }
1793           return this.db.execute(transaction => {
1794               const txn = new CollectionTransaction(this, transaction);
1795               const result = doOperations(txn);
1796               txn.emitEvents();
1797               return result;
1798           }, { preload: preloadIds });
1799       }
1800       /**
1801        * Resets the local records as if they were never synced; existing records are
1802        * marked as newly created, deleted records are dropped.
1803        *
1804        * A next call to {@link Collection.sync} will thus republish the whole
1805        * content of the local collection to the server.
1806        *
1807        * @return {Promise} Resolves with the number of processed records.
1808        */
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);
1816                   }
1817                   else {
1818                       // Records that were synced become Â«created».
1819                       transaction.update(Object.assign(Object.assign({}, record), { last_modified: undefined, _status: "created" }));
1820                   }
1821               });
1822           });
1823           this._lastModified = null;
1824           await this.db.saveLastModified(null);
1825           return unsynced.data.length;
1826       }
1827       /**
1828        * Returns an object containing two lists:
1829        *
1830        * - `toDelete`: unsynced deleted records we can safely delete;
1831        * - `toSync`: local updates to send to the server.
1832        *
1833        * @return {Promise}
1834        */
1835       async gatherLocalChanges() {
1836           const unsynced = await this.list({
1837               filters: { _status: ["created", "updated"] },
1838               order: "",
1839           });
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")));
1844       }
1845       /**
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.
1849        *
1850        * Options:
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.
1857        *
1858        * @param  {KintoClient.Collection} client           Kinto client Collection instance.
1859        * @param  {SyncResultObject}       syncResultObject The sync result object.
1860        * @param  {Object}                 options          The options object.
1861        * @return {Promise}
1862        */
1863       async pullChanges(client, syncResultObject, options = {}) {
1864           if (!syncResultObject.ok) {
1865               return syncResultObject;
1866           }
1867           const since = this.lastModified
1868               ? 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())
1873           let filters;
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
1879                   .slice(0, 50)
1880                   .map(r => r.id)
1881                   .join(",");
1882               filters = { exclude_id };
1883           }
1884           if (options.expectedTimestamp) {
1885               filters = Object.assign(Object.assign({}, filters), { _expected: options.expectedTimestamp });
1886           }
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)
1894               pages: Infinity,
1895               filters,
1896           });
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: " +
1909                   localSynced +
1910                   " Server Side Timestamp: " +
1911                   unquoted);
1912               throw e;
1913           }
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);
1921           }));
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);
1929           }
1930           return syncResultObject;
1931       }
1932       applyHook(hookName, payload) {
1933           if (typeof this.hooks[hookName] == "undefined") {
1934               return Promise.resolve(payload);
1935           }
1936           return waterfall(this.hooks[hookName].map(hook => {
1937               return record => {
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`);
1943                   }
1944                   return result;
1945               };
1946           }), payload);
1947       }
1948       /**
1949        * Publish local changes to the remote server and updates the passed
1950        * {@link SyncResultObject} with publication results.
1951        *
1952        * Options:
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.
1956        *
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.
1963        * @return {Promise}
1964        */
1965       async pushChanges(client, changes, syncResultObject, options = {}) {
1966           if (!syncResultObject.ok) {
1967               return syncResultObject;
1968           }
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);
1978                   }
1979               });
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);
1985                   }
1986                   else {
1987                       batch.updateRecord(published);
1988                   }
1989               });
1990           }, {
1991               headers: options.headers,
1992               retry: options.retry,
1993               safe,
1994               aggregate: true,
1995           });
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);
2011           }
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);
2025           }));
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);
2030           }
2031           return syncResultObject;
2032       }
2033       /**
2034        * Return a copy of the specified record without the local fields.
2035        *
2036        * @param  {Object} record  A record with potential local fields.
2037        * @return {Object}
2038        */
2039       cleanLocalFields(record) {
2040           const localKeys = RECORD_FIELDS_TO_CLEAN.concat(this.localFields);
2041           return omitKeys(record, localKeys);
2042       }
2043       /**
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.
2047        *
2048        * @param  {Object} conflict   The conflict object.
2049        * @param  {Object} resolution The proposed record.
2050        * @return {Promise}
2051        */
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: {} };
2057           });
2058       }
2059       /**
2060        * @private
2061        */
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");
2071       }
2072       /**
2073        * Synchronize remote and local data. The promise will resolve with a
2074        * {@link SyncResultObject}, though will reject:
2075        *
2076        * - if the server is currently backed off;
2077        * - if the server has been detected flushed.
2078        *
2079        * Options:
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
2085        *   backed off.
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).
2089        *
2090        * @param  {Object} options Options.
2091        * @return {Promise}
2092        * @throws {Error} If an invalid remote option is passed.
2093        */
2094       async sync(options = {
2095           strategy: Collection.strategy.MANUAL,
2096           headers: {},
2097           retry: 1,
2098           ignoreBackoff: false,
2099           bucket: null,
2100           collection: null,
2101           remote: null,
2102           expectedTimestamp: null,
2103       }) {
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;
2109           }
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.`));
2113           }
2114           const client = this.api
2115               .bucket(options.bucket)
2116               .collection(options.collection);
2117           const result = new SyncResultObject();
2118           try {
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 };
2136                           }
2137                           return this._encodeRecord("remote", record);
2138                       }));
2139                       await this.pushChanges(client, resolvedEncoded, result, options);
2140                   }
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);
2147                   }
2148               }
2149               // Don't persist lastModified value if any conflict or error occured
2150               if (result.ok) {
2151                   // No conflict occured, persist collection's lastModified value
2152                   this._lastModified = await this.db.saveLastModified(result.lastModified);
2153               }
2154           }
2155           catch (e) {
2156               this.events.emit("sync:error", Object.assign(Object.assign({}, options), { error: e }));
2157               throw e;
2158           }
2159           finally {
2160               // Ensure API default remote is reverted if a custom one's been used
2161               this.api.remote = previousRemote;
2162           }
2163           this.events.emit("sync:success", Object.assign(Object.assign({}, options), { result }));
2164           return result;
2165       }
2166       /**
2167        * Load a list of records already synced with the remote server.
2168        *
2169        * The local records which are unsynced or whose timestamp is either missing
2170        * or superior to those being loaded will be ignored.
2171        *
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.
2175        */
2176       async loadDump(records) {
2177           return this.importBulk(records);
2178       }
2179       /**
2180        * Load a list of records already synced with the remote server.
2181        *
2182        * The local records which are unsynced or whose timestamp is either missing
2183        * or superior to those being loaded will be ignored.
2184        *
2185        * @param  {Array} records The previously exported list of records to load.
2186        * @return {Promise} with the effectively imported records.
2187        */
2188       async importBulk(records) {
2189           if (!Array.isArray(records)) {
2190               throw new Error("Records is not an array.");
2191           }
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));
2196               }
2197               if (!record.last_modified) {
2198                   throw new Error("Record has no last_modified value: " + JSON.stringify(record));
2199               }
2200           }
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;
2207               return acc;
2208           }, {});
2209           const newRecords = records.filter(record => {
2210               const localRecord = existingById[record.id];
2211               const shouldKeep =
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);
2220               return shouldKeep;
2221           });
2222           return await this.db.importBulk(newRecords.map(markSynced));
2223       }
2224       async pullMetadata(client, options = {}) {
2225           const { expectedTimestamp, headers } = options;
2226           const query = expectedTimestamp
2227               ? { query: { _expected: expectedTimestamp } }
2228               : undefined;
2229           const metadata = await client.getData(Object.assign(Object.assign({}, query), { headers }));
2230           return this.db.saveMetadata(metadata);
2231       }
2232       async metadata() {
2233           return this.db.getMetadata();
2234       }
2235   }
2236   /**
2237    * A Collection-oriented wrapper for an adapter's transaction.
2238    *
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.
2242    */
2243   class CollectionTransaction {
2244       constructor(collection, adapterTransaction) {
2245           this.collection = collection;
2246           this.adapterTransaction = adapterTransaction;
2247           this._events = [];
2248       }
2249       _queueEvent(action, payload) {
2250           this._events.push({ action, payload });
2251       }
2252       /**
2253        * Emit queued events, to be called once every transaction operations have
2254        * been executed successfully.
2255        */
2256       emitEvents() {
2257           for (const { action, payload } of this._events) {
2258               this.collection.events.emit(action, payload);
2259           }
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 });
2263           }
2264           this._events = [];
2265       }
2266       /**
2267        * Retrieve a record by its id from the local database, or
2268        * undefined if none exists.
2269        *
2270        * This will also return virtually deleted records.
2271        *
2272        * @param  {String} id
2273        * @return {Object}
2274        */
2275       getAny(id) {
2276           const record = this.adapterTransaction.get(id);
2277           return { data: record, permissions: {} };
2278       }
2279       /**
2280        * Retrieve a record by its id from the local database.
2281        *
2282        * Options:
2283        * - {Boolean} includeDeleted: Include virtually deleted records.
2284        *
2285        * @param  {String} id
2286        * @param  {Object} options
2287        * @return {Object}
2288        */
2289       get(id, options = { includeDeleted: false }) {
2290           const res = this.getAny(id);
2291           if (!res.data ||
2292               (!options.includeDeleted && res.data._status === "deleted")) {
2293               throw new Error(`Record with id=${id} not found.`);
2294           }
2295           return res;
2296       }
2297       /**
2298        * Deletes a record from the local database.
2299        *
2300        * Options:
2301        * - {Boolean} virtual: When set to `true`, doesn't actually delete the record,
2302        *   update its `_status` attribute to `deleted` instead (default: true)
2303        *
2304        * @param  {String} id       The record's Id.
2305        * @param  {Object} options  The options object.
2306        * @return {Object}
2307        */
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.`);
2314           }
2315           // Virtual updates status.
2316           if (options.virtual) {
2317               this.adapterTransaction.update(markDeleted(existing));
2318           }
2319           else {
2320               // Delete for real.
2321               this.adapterTransaction.delete(id);
2322           }
2323           this._queueEvent("delete", { data: existing });
2324           return { data: existing, permissions: {} };
2325       }
2326       /**
2327        * Soft delete all records from the local database.
2328        *
2329        * @param  {Array} ids        Array of non-deleted Record Ids.
2330        * @return {Object}
2331        */
2332       deleteAll(ids) {
2333           const existingRecords = [];
2334           ids.forEach(id => {
2335               existingRecords.push(this.adapterTransaction.get(id));
2336               this.delete(id);
2337           });
2338           this._queueEvent("deleteAll", { data: existingRecords });
2339           return { data: existingRecords, permissions: {} };
2340       }
2341       /**
2342        * Deletes a record from the local database, if any exists.
2343        * Otherwise, do nothing.
2344        *
2345        * @param  {String} id       The record's Id.
2346        * @return {Object}
2347        */
2348       deleteAny(id) {
2349           const existing = this.adapterTransaction.get(id);
2350           if (existing) {
2351               this.adapterTransaction.update(markDeleted(existing));
2352               this._queueEvent("delete", { data: existing });
2353           }
2354           return { data: Object.assign({ id }, existing), deleted: !!existing, permissions: {} };
2355       }
2356       /**
2357        * Adds a record to the local database, asserting that none
2358        * already exist with this ID.
2359        *
2360        * @param  {Object} record, which must contain an ID
2361        * @return {Object}
2362        */
2363       create(record) {
2364           if (typeof record !== "object") {
2365               throw new Error("Record is not an object.");
2366           }
2367           if (!Object.prototype.hasOwnProperty.call(record, "id")) {
2368               throw new Error("Cannot create a record missing id");
2369           }
2370           if (!this.collection.idSchema.validate(record.id)) {
2371               throw new Error(`Invalid Id: ${record.id}`);
2372           }
2373           this.adapterTransaction.create(record);
2374           this._queueEvent("create", { data: record });
2375           return { data: record, permissions: {} };
2376       }
2377       /**
2378        * Updates a record from the local database.
2379        *
2380        * Options:
2381        * - {Boolean} synced: Sets record status to "synced" (default: false)
2382        * - {Boolean} patch:  Extends the existing record instead of overwriting it
2383        *   (default: false)
2384        *
2385        * @param  {Object} record
2386        * @param  {Object} options
2387        * @return {Object}
2388        */
2389       update(record, options = { synced: false, patch: false }) {
2390           if (typeof record !== "object") {
2391               throw new Error("Record is not an object.");
2392           }
2393           if (!Object.prototype.hasOwnProperty.call(record, "id")) {
2394               throw new Error("Cannot update a record missing id.");
2395           }
2396           if (!this.collection.idSchema.validate(record.id)) {
2397               throw new Error(`Invalid Id: ${record.id}`);
2398           }
2399           const oldRecord = this.adapterTransaction.get(record.id);
2400           if (!oldRecord) {
2401               throw new Error(`Record with id=${record.id} not found.`);
2402           }
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: {} };
2408       }
2409       /**
2410        * Lower-level primitive for updating a record while respecting
2411        * _status and last_modified.
2412        *
2413        * @param  {Object} oldRecord: the record retrieved from the DB
2414        * @param  {Object} newRecord: the record to replace it with
2415        * @return {Object}
2416        */
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;
2422           }
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);
2432       }
2433       /**
2434        * Upsert a record into the local database.
2435        *
2436        * This record must have an ID.
2437        *
2438        * If a record with this ID already exists, it will be replaced.
2439        * Otherwise, this record will be inserted.
2440        *
2441        * @param  {Object} record
2442        * @return {Object}
2443        */
2444       upsert(record) {
2445           if (typeof record !== "object") {
2446               throw new Error("Record is not an object.");
2447           }
2448           if (!Object.prototype.hasOwnProperty.call(record, "id")) {
2449               throw new Error("Cannot update a record missing id.");
2450           }
2451           if (!this.collection.idSchema.validate(record.id)) {
2452               throw new Error(`Invalid Id: ${record.id}`);
2453           }
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;
2460           }
2461           if (oldRecord) {
2462               this._queueEvent("update", { data: updated, oldRecord });
2463           }
2464           else {
2465               this._queueEvent("create", { data: updated });
2466           }
2467           return { data: updated, oldRecord, permissions: {} };
2468       }
2469   }
2471   const DEFAULT_BUCKET_NAME = "default";
2472   const DEFAULT_REMOTE = "http://localhost:8888/v1";
2473   const DEFAULT_RETRY = 1;
2474   /**
2475    * KintoBase class.
2476    */
2477   class KintoBase {
2478       /**
2479        * Provides a public access to the base adapter class. Users can create a
2480        * custom DB adapter by extending {@link BaseAdapter}.
2481        *
2482        * @type {Object}
2483        */
2484       static get adapters() {
2485           return {
2486               BaseAdapter: BaseAdapter,
2487           };
2488       }
2489       /**
2490        * Synchronization strategies. Available strategies are:
2491        *
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.
2495        *
2496        * @type {Object}
2497        */
2498       static get syncStrategy() {
2499           return Collection.strategy;
2500       }
2501       /**
2502        * Constructor.
2503        *
2504        * Options:
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`).
2514        *
2515        * @param  {Object} options The options object.
2516        */
2517       constructor(options = {}) {
2518           const defaults = {
2519               bucket: DEFAULT_BUCKET_NAME,
2520               remote: DEFAULT_REMOTE,
2521               retry: DEFAULT_RETRY,
2522           };
2523           this._options = Object.assign(Object.assign({}, defaults), options);
2524           if (!this._options.adapter) {
2525               throw new Error("No adapter provided");
2526           }
2527           this._api = null;
2528           /**
2529            * The event emitter instance.
2530            * @type {EventEmitter}
2531            */
2532           this.events = this._options.events;
2533       }
2534       /**
2535        * The kinto HTTP client instance.
2536        * @type {KintoClient}
2537        */
2538       get api() {
2539           const { events, headers, remote, requestMode, retry, timeout, } = this._options;
2540           if (!this._api) {
2541               this._api = new this.ApiClass(remote, {
2542                   events,
2543                   headers,
2544                   requestMode,
2545                   retry,
2546                   timeout,
2547               });
2548           }
2549           return this._api;
2550       }
2551       /**
2552        * Creates a {@link Collection} instance. The second (optional) parameter
2553        * will set collection-level options like e.g. `remoteTransformers`.
2554        *
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}
2562        */
2563       collection(collName, options = {}) {
2564           if (!collName) {
2565               throw new Error("missing collection name");
2566           }
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, {
2570               events,
2571               adapter,
2572               adapterOptions,
2573               idSchema,
2574               remoteTransformers,
2575               hooks,
2576               localFields,
2577           });
2578       }
2579   }
2581   /*
2582    *
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
2586    *
2587    *     http://www.apache.org/licenses/LICENSE-2.0
2588    *
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.
2594    */
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"
2602   });
2603   ChromeUtils.defineLazyGetter(global, "generateUUID", () => {
2604       const { generateUUID } = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
2605       return generateUUID;
2606   });
2607   class Kinto extends KintoBase {
2608       static get adapters() {
2609           return {
2610               BaseAdapter,
2611               IDB,
2612           };
2613       }
2614       get ApiClass() {
2615           return KintoHttpClient;
2616       }
2617       constructor(options = {}) {
2618           const events = {};
2619           EventEmitter.decorate(events);
2620           const defaults = {
2621               adapter: IDB,
2622               events,
2623           };
2624           super(Object.assign(Object.assign({}, defaults), options));
2625       }
2626       collection(collName, options = {}) {
2627           const idSchema = {
2628               validate(id) {
2629                   return typeof id == "string" && RE_RECORD_ID.test(id);
2630               },
2631               generate() {
2632                   return generateUUID()
2633                       .toString()
2634                       .replace(/[{}]/g, "");
2635               },
2636           };
2637           return super.collection(collName, Object.assign({ idSchema }, options));
2638       }
2639   }
2641   return Kinto;
2643 })));