tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

kinto-offline-client.sys.mjs (97859B)


      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 
     16 /*
     17 * This file is generated from kinto.js - do not modify directly.
     18 */
     19 
     20 /*
     21 * Version 13.0.0 - 7fbf95d
     22 */
     23 
     24 /**
     25 * Base db adapter.
     26 *
     27 * @abstract
     28 */
     29 class BaseAdapter {
     30    /**
     31     * Deletes every records present in the database.
     32     *
     33     * @abstract
     34     * @return {Promise}
     35     */
     36    clear() {
     37        throw new Error("Not Implemented.");
     38    }
     39    /**
     40     * Executes a batch of operations within a single transaction.
     41     *
     42     * @abstract
     43     * @param  {Function} callback The operation callback.
     44     * @param  {Object}   options  The options object.
     45     * @return {Promise}
     46     */
     47    execute(callback, options = { preload: [] }) {
     48        throw new Error("Not Implemented.");
     49    }
     50    /**
     51     * Retrieve a record by its primary key from the database.
     52     *
     53     * @abstract
     54     * @param  {String} id The record id.
     55     * @return {Promise}
     56     */
     57    get(id) {
     58        throw new Error("Not Implemented.");
     59    }
     60    /**
     61     * Lists all records from the database.
     62     *
     63     * @abstract
     64     * @param  {Object} params  The filters and order to apply to the results.
     65     * @return {Promise}
     66     */
     67    list(params = { filters: {}, order: "" }) {
     68        throw new Error("Not Implemented.");
     69    }
     70    /**
     71     * Store the lastModified value.
     72     *
     73     * @abstract
     74     * @param  {Number}  lastModified
     75     * @return {Promise}
     76     */
     77    saveLastModified(lastModified) {
     78        throw new Error("Not Implemented.");
     79    }
     80    /**
     81     * Retrieve saved lastModified value.
     82     *
     83     * @abstract
     84     * @return {Promise}
     85     */
     86    getLastModified() {
     87        throw new Error("Not Implemented.");
     88    }
     89    /**
     90     * Load records in bulk that were exported from a server.
     91     *
     92     * @abstract
     93     * @param  {Array} records The records to load.
     94     * @return {Promise}
     95     */
     96    importBulk(records) {
     97        throw new Error("Not Implemented.");
     98    }
     99    /**
    100     * Load a dump of records exported from a server.
    101     *
    102     * @deprecated Use {@link importBulk} instead.
    103     * @abstract
    104     * @param  {Array} records The records to load.
    105     * @return {Promise}
    106     */
    107    loadDump(records) {
    108        throw new Error("Not Implemented.");
    109    }
    110    saveMetadata(metadata) {
    111        throw new Error("Not Implemented.");
    112    }
    113    getMetadata() {
    114        throw new Error("Not Implemented.");
    115    }
    116 }
    117 
    118 const RE_RECORD_ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
    119 /**
    120 * Checks if a value is undefined.
    121 * @param  {Any}  value
    122 * @return {Boolean}
    123 */
    124 function _isUndefined(value) {
    125    return typeof value === "undefined";
    126 }
    127 /**
    128 * Sorts records in a list according to a given ordering.
    129 *
    130 * @param  {String} order The ordering, eg. `-last_modified`.
    131 * @param  {Array}  list  The collection to order.
    132 * @return {Array}
    133 */
    134 function sortObjects(order, list) {
    135    const hasDash = order[0] === "-";
    136    const field = hasDash ? order.slice(1) : order;
    137    const direction = hasDash ? -1 : 1;
    138    return list.slice().sort((a, b) => {
    139        if (a[field] && _isUndefined(b[field])) {
    140            return direction;
    141        }
    142        if (b[field] && _isUndefined(a[field])) {
    143            return -direction;
    144        }
    145        if (_isUndefined(a[field]) && _isUndefined(b[field])) {
    146            return 0;
    147        }
    148        return a[field] > b[field] ? direction : -direction;
    149    });
    150 }
    151 /**
    152 * Test if a single object matches all given filters.
    153 *
    154 * @param  {Object} filters  The filters object.
    155 * @param  {Object} entry    The object to filter.
    156 * @return {Boolean}
    157 */
    158 function filterObject(filters, entry) {
    159    return Object.keys(filters).every(filter => {
    160        const value = filters[filter];
    161        if (Array.isArray(value)) {
    162            return value.some(candidate => candidate === entry[filter]);
    163        }
    164        else if (typeof value === "object") {
    165            return filterObject(value, entry[filter]);
    166        }
    167        else if (!Object.prototype.hasOwnProperty.call(entry, filter)) {
    168            console.error(`The property ${filter} does not exist`);
    169            return false;
    170        }
    171        return entry[filter] === value;
    172    });
    173 }
    174 /**
    175 * Resolves a list of functions sequentially, which can be sync or async; in
    176 * case of async, functions must return a promise.
    177 *
    178 * @param  {Array} fns  The list of functions.
    179 * @param  {Any}   init The initial value.
    180 * @return {Promise}
    181 */
    182 function waterfall(fns, init) {
    183    if (!fns.length) {
    184        return Promise.resolve(init);
    185    }
    186    return fns.reduce((promise, nextFn) => {
    187        return promise.then(nextFn);
    188    }, Promise.resolve(init));
    189 }
    190 /**
    191 * Simple deep object comparison function. This only supports comparison of
    192 * serializable JavaScript objects.
    193 *
    194 * @param  {Object} a The source object.
    195 * @param  {Object} b The compared object.
    196 * @return {Boolean}
    197 */
    198 function deepEqual(a, b) {
    199    if (a === b) {
    200        return true;
    201    }
    202    if (typeof a !== typeof b) {
    203        return false;
    204    }
    205    if (!(a && typeof a == "object") || !(b && typeof b == "object")) {
    206        return false;
    207    }
    208    if (Object.keys(a).length !== Object.keys(b).length) {
    209        return false;
    210    }
    211    for (const k in a) {
    212        if (!deepEqual(a[k], b[k])) {
    213            return false;
    214        }
    215    }
    216    return true;
    217 }
    218 /**
    219 * Return an object without the specified keys.
    220 *
    221 * @param  {Object} obj        The original object.
    222 * @param  {Array}  keys       The list of keys to exclude.
    223 * @return {Object}            A copy without the specified keys.
    224 */
    225 function omitKeys(obj, keys = []) {
    226    const result = Object.assign({}, obj);
    227    for (const key of keys) {
    228        delete result[key];
    229    }
    230    return result;
    231 }
    232 function arrayEqual(a, b) {
    233    if (a.length !== b.length) {
    234        return false;
    235    }
    236    for (let i = a.length; i--;) {
    237        if (a[i] !== b[i]) {
    238            return false;
    239        }
    240    }
    241    return true;
    242 }
    243 function makeNestedObjectFromArr(arr, val, nestedFiltersObj) {
    244    const last = arr.length - 1;
    245    return arr.reduce((acc, cv, i) => {
    246        if (i === last) {
    247            return (acc[cv] = val);
    248        }
    249        else if (Object.prototype.hasOwnProperty.call(acc, cv)) {
    250            return acc[cv];
    251        }
    252        else {
    253            return (acc[cv] = {});
    254        }
    255    }, nestedFiltersObj);
    256 }
    257 function transformSubObjectFilters(filtersObj) {
    258    const transformedFilters = {};
    259    for (const key in filtersObj) {
    260        const keysArr = key.split(".");
    261        const val = filtersObj[key];
    262        makeNestedObjectFromArr(keysArr, val, transformedFilters);
    263    }
    264    return transformedFilters;
    265 }
    266 
    267 const INDEXED_FIELDS = ["id", "_status", "last_modified"];
    268 /**
    269 * Small helper that wraps the opening of an IndexedDB into a Promise.
    270 *
    271 * @param dbname          {String}   The database name.
    272 * @param version         {Integer}  Schema version
    273 * @param onupgradeneeded {Function} The callback to execute if schema is
    274 *                                   missing or different.
    275 * @return {Promise<IDBDatabase>}
    276 */
    277 async function open(dbname, { version, onupgradeneeded }) {
    278    return new Promise((resolve, reject) => {
    279        const request = indexedDB.open(dbname, version);
    280        request.onupgradeneeded = event => {
    281            const db = event.target.result;
    282            db.onerror = event => reject(event.target.error);
    283            // When an upgrade is needed, a transaction is started.
    284            const transaction = event.target.transaction;
    285            transaction.onabort = event => {
    286                const error = event.target.error ||
    287                    transaction.error ||
    288                    new DOMException("The operation has been aborted", "AbortError");
    289                reject(error);
    290            };
    291            // Callback for store creation etc.
    292            return onupgradeneeded(event);
    293        };
    294        request.onerror = event => {
    295            reject(event.target.error);
    296        };
    297        request.onsuccess = event => {
    298            const db = event.target.result;
    299            resolve(db);
    300        };
    301    });
    302 }
    303 /**
    304 * Helper to run the specified callback in a single transaction on the
    305 * specified store.
    306 * The helper focuses on transaction wrapping into a promise.
    307 *
    308 * @param db           {IDBDatabase} The database instance.
    309 * @param name         {String}      The store name.
    310 * @param callback     {Function}    The piece of code to execute in the transaction.
    311 * @param options      {Object}      Options.
    312 * @param options.mode {String}      Transaction mode (default: read).
    313 * @return {Promise} any value returned by the callback.
    314 */
    315 async function execute(db, name, callback, options = {}) {
    316    const { mode } = options;
    317    return new Promise((resolve, reject) => {
    318        // On Safari, calling IDBDatabase.transaction with mode == undefined raises
    319        // a TypeError.
    320        const transaction = mode
    321            ? db.transaction([name], mode)
    322            : db.transaction([name]);
    323        const store = transaction.objectStore(name);
    324        // Let the callback abort this transaction.
    325        const abort = e => {
    326            transaction.abort();
    327            reject(e);
    328        };
    329        // Execute the specified callback **synchronously**.
    330        let result;
    331        try {
    332            result = callback(store, abort);
    333        }
    334        catch (e) {
    335            abort(e);
    336        }
    337        transaction.onerror = event => reject(event.target.error);
    338        transaction.oncomplete = event => resolve(result);
    339        transaction.onabort = event => {
    340            const error = event.target.error ||
    341                transaction.error ||
    342                new DOMException("The operation has been aborted", "AbortError");
    343            reject(error);
    344        };
    345    });
    346 }
    347 /**
    348 * Helper to wrap the deletion of an IndexedDB database into a promise.
    349 *
    350 * @param dbName {String} the database to delete
    351 * @return {Promise}
    352 */
    353 async function deleteDatabase(dbName) {
    354    return new Promise((resolve, reject) => {
    355        const request = indexedDB.deleteDatabase(dbName);
    356        request.onsuccess = event => resolve(event.target);
    357        request.onerror = event => reject(event.target.error);
    358    });
    359 }
    360 /**
    361 * IDB cursor handlers.
    362 * @type {Object}
    363 */
    364 const cursorHandlers = {
    365    all(filters, done) {
    366        const results = [];
    367        return event => {
    368            const cursor = event.target.result;
    369            if (cursor) {
    370                const { value } = cursor;
    371                if (filterObject(filters, value)) {
    372                    results.push(value);
    373                }
    374                cursor.continue();
    375            }
    376            else {
    377                done(results);
    378            }
    379        };
    380    },
    381    in(values, filters, done) {
    382        const results = [];
    383        let i = 0;
    384        return function (event) {
    385            const cursor = event.target.result;
    386            if (!cursor) {
    387                done(results);
    388                return;
    389            }
    390            const { key, value } = cursor;
    391            // `key` can be an array of two values (see `keyPath` in indices definitions).
    392            // `values` can be an array of arrays if we filter using an index whose key path
    393            // is an array (eg. `cursorHandlers.in([["bid/cid", 42], ["bid/cid", 43]], ...)`)
    394            while (key > values[i]) {
    395                // The cursor has passed beyond this key. Check next.
    396                ++i;
    397                if (i === values.length) {
    398                    done(results); // There is no next. Stop searching.
    399                    return;
    400                }
    401            }
    402            const isEqual = Array.isArray(key)
    403                ? arrayEqual(key, values[i])
    404                : key === values[i];
    405            if (isEqual) {
    406                if (filterObject(filters, value)) {
    407                    results.push(value);
    408                }
    409                cursor.continue();
    410            }
    411            else {
    412                cursor.continue(values[i]);
    413            }
    414        };
    415    },
    416 };
    417 /**
    418 * Creates an IDB request and attach it the appropriate cursor event handler to
    419 * perform a list query.
    420 *
    421 * Multiple matching values are handled by passing an array.
    422 *
    423 * @param  {String}           cid        The collection id (ie. `{bid}/{cid}`)
    424 * @param  {IDBStore}         store      The IDB store.
    425 * @param  {Object}           filters    Filter the records by field.
    426 * @param  {Function}         done       The operation completion handler.
    427 * @return {IDBRequest}
    428 */
    429 function createListRequest(cid, store, filters, done) {
    430    const filterFields = Object.keys(filters);
    431    // If no filters, get all results in one bulk.
    432    if (filterFields.length == 0) {
    433        const request = store.index("cid").getAll(IDBKeyRange.only(cid));
    434        request.onsuccess = event => done(event.target.result);
    435        return request;
    436    }
    437    // Introspect filters and check if they leverage an indexed field.
    438    const indexField = filterFields.find(field => {
    439        return INDEXED_FIELDS.includes(field);
    440    });
    441    if (!indexField) {
    442        // Iterate on all records for this collection (ie. cid)
    443        const isSubQuery = Object.keys(filters).some(key => key.includes(".")); // (ie. filters: {"article.title": "hello"})
    444        if (isSubQuery) {
    445            const newFilter = transformSubObjectFilters(filters);
    446            const request = store.index("cid").openCursor(IDBKeyRange.only(cid));
    447            request.onsuccess = cursorHandlers.all(newFilter, done);
    448            return request;
    449        }
    450        const request = store.index("cid").openCursor(IDBKeyRange.only(cid));
    451        request.onsuccess = cursorHandlers.all(filters, done);
    452        return request;
    453    }
    454    // If `indexField` was used already, don't filter again.
    455    const remainingFilters = omitKeys(filters, [indexField]);
    456    // value specified in the filter (eg. `filters: { _status: ["created", "updated"] }`)
    457    const value = filters[indexField];
    458    // For the "id" field, use the primary key.
    459    const indexStore = indexField == "id" ? store : store.index(indexField);
    460    // WHERE IN equivalent clause
    461    if (Array.isArray(value)) {
    462        if (value.length === 0) {
    463            return done([]);
    464        }
    465        const values = value.map(i => [cid, i]).sort();
    466        const range = IDBKeyRange.bound(values[0], values[values.length - 1]);
    467        const request = indexStore.openCursor(range);
    468        request.onsuccess = cursorHandlers.in(values, remainingFilters, done);
    469        return request;
    470    }
    471    // If no filters on custom attribute, get all results in one bulk.
    472    if (remainingFilters.length == 0) {
    473        const request = indexStore.getAll(IDBKeyRange.only([cid, value]));
    474        request.onsuccess = event => done(event.target.result);
    475        return request;
    476    }
    477    // WHERE field = value clause
    478    const request = indexStore.openCursor(IDBKeyRange.only([cid, value]));
    479    request.onsuccess = cursorHandlers.all(remainingFilters, done);
    480    return request;
    481 }
    482 class IDBError extends Error {
    483    constructor(method, err) {
    484        super(`IndexedDB ${method}() ${err.message}`);
    485        this.name = err.name;
    486        this.stack = err.stack;
    487    }
    488 }
    489 /**
    490 * IndexedDB adapter.
    491 *
    492 * This adapter doesn't support any options.
    493 */
    494 class IDB extends BaseAdapter {
    495    /* Expose the IDBError class publicly */
    496    static get IDBError() {
    497        return IDBError;
    498    }
    499    /**
    500     * Constructor.
    501     *
    502     * @param  {String} cid  The key base for this collection (eg. `bid/cid`)
    503     * @param  {Object} options
    504     * @param  {String} options.dbName         The IndexedDB name (default: `"KintoDB"`)
    505     * @param  {String} options.migrateOldData Whether old database data should be migrated (default: `false`)
    506     */
    507    constructor(cid, options = {}) {
    508        super();
    509        this.cid = cid;
    510        this.dbName = options.dbName || "KintoDB";
    511        this._options = options;
    512        this._db = null;
    513    }
    514    _handleError(method, err) {
    515        throw new IDBError(method, err);
    516    }
    517    /**
    518     * Ensures a connection to the IndexedDB database has been opened.
    519     *
    520     * @override
    521     * @return {Promise}
    522     */
    523    async open() {
    524        if (this._db) {
    525            return this;
    526        }
    527        // In previous versions, we used to have a database with name `${bid}/${cid}`.
    528        // Check if it exists, and migrate data once new schema is in place.
    529        // Note: the built-in migrations from IndexedDB can only be used if the
    530        // database name does not change.
    531        const dataToMigrate = this._options.migrateOldData
    532            ? await migrationRequired(this.cid)
    533            : null;
    534        this._db = await open(this.dbName, {
    535            version: 2,
    536            onupgradeneeded: event => {
    537                const db = event.target.result;
    538                if (event.oldVersion < 1) {
    539                    // Records store
    540                    const recordsStore = db.createObjectStore("records", {
    541                        keyPath: ["_cid", "id"],
    542                    });
    543                    // An index to obtain all the records in a collection.
    544                    recordsStore.createIndex("cid", "_cid");
    545                    // Here we create indices for every known field in records by collection.
    546                    // Local record status ("synced", "created", "updated", "deleted")
    547                    recordsStore.createIndex("_status", ["_cid", "_status"]);
    548                    // Last modified field
    549                    recordsStore.createIndex("last_modified", ["_cid", "last_modified"]);
    550                    // Timestamps store
    551                    db.createObjectStore("timestamps", {
    552                        keyPath: "cid",
    553                    });
    554                }
    555                if (event.oldVersion < 2) {
    556                    // Collections store
    557                    db.createObjectStore("collections", {
    558                        keyPath: "cid",
    559                    });
    560                }
    561            },
    562        });
    563        if (dataToMigrate) {
    564            const { records, timestamp } = dataToMigrate;
    565            await this.importBulk(records);
    566            await this.saveLastModified(timestamp);
    567            console.log(`${this.cid}: data was migrated successfully.`);
    568            // Delete the old database.
    569            await deleteDatabase(this.cid);
    570            console.warn(`${this.cid}: old database was deleted.`);
    571        }
    572        return this;
    573    }
    574    /**
    575     * Closes current connection to the database.
    576     *
    577     * @override
    578     * @return {Promise}
    579     */
    580    close() {
    581        if (this._db) {
    582            this._db.close(); // indexedDB.close is synchronous
    583            this._db = null;
    584        }
    585        return Promise.resolve();
    586    }
    587    /**
    588     * Returns a transaction and an object store for a store name.
    589     *
    590     * To determine if a transaction has completed successfully, we should rather
    591     * listen to the transaction’s complete event rather than the IDBObjectStore
    592     * request’s success event, because the transaction may still fail after the
    593     * success event fires.
    594     *
    595     * @param  {String}      name  Store name
    596     * @param  {Function}    callback to execute
    597     * @param  {Object}      options Options
    598     * @param  {String}      options.mode  Transaction mode ("readwrite" or undefined)
    599     * @return {Object}
    600     */
    601    async prepare(name, callback, options) {
    602        await this.open();
    603        await execute(this._db, name, callback, options);
    604    }
    605    /**
    606     * Deletes every records in the current collection.
    607     *
    608     * @override
    609     * @return {Promise}
    610     */
    611    async clear() {
    612        try {
    613            await this.prepare("records", store => {
    614                const range = IDBKeyRange.only(this.cid);
    615                const request = store.index("cid").openKeyCursor(range);
    616                request.onsuccess = event => {
    617                    const cursor = event.target.result;
    618                    if (cursor) {
    619                        store.delete(cursor.primaryKey);
    620                        cursor.continue();
    621                    }
    622                };
    623                return request;
    624            }, { mode: "readwrite" });
    625        }
    626        catch (e) {
    627            this._handleError("clear", e);
    628        }
    629    }
    630    /**
    631     * Executes the set of synchronous CRUD operations described in the provided
    632     * callback within an IndexedDB transaction, for current db store.
    633     *
    634     * The callback will be provided an object exposing the following synchronous
    635     * CRUD operation methods: get, create, update, delete.
    636     *
    637     * Important note: because limitations in IndexedDB implementations, no
    638     * asynchronous code should be performed within the provided callback; the
    639     * promise will therefore be rejected if the callback returns a Promise.
    640     *
    641     * Options:
    642     * - {Array} preload: The list of record IDs to fetch and make available to
    643     *   the transaction object get() method (default: [])
    644     *
    645     * @example
    646     * const db = new IDB("example");
    647     * const result = await db.execute(transaction => {
    648     *   transaction.create({id: 1, title: "foo"});
    649     *   transaction.update({id: 2, title: "bar"});
    650     *   transaction.delete(3);
    651     *   return "foo";
    652     * });
    653     *
    654     * @override
    655     * @param  {Function} callback The operation description callback.
    656     * @param  {Object}   options  The options object.
    657     * @return {Promise}
    658     */
    659    async execute(callback, options = { preload: [] }) {
    660        // Transactions in IndexedDB are autocommited when a callback does not
    661        // perform any additional operation.
    662        // The way Promises are implemented in Firefox (see https://bugzilla.mozilla.org/show_bug.cgi?id=1193394)
    663        // prevents using within an opened transaction.
    664        // To avoid managing asynchronocity in the specified `callback`, we preload
    665        // a list of record in order to execute the `callback` synchronously.
    666        // See also:
    667        // - http://stackoverflow.com/a/28388805/330911
    668        // - http://stackoverflow.com/a/10405196
    669        // - https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
    670        let result;
    671        await this.prepare("records", (store, abort) => {
    672            const runCallback = (preloaded = []) => {
    673                // Expose a consistent API for every adapter instead of raw store methods.
    674                const proxy = transactionProxy(this, store, preloaded);
    675                // The callback is executed synchronously within the same transaction.
    676                try {
    677                    const returned = callback(proxy);
    678                    if (returned instanceof Promise) {
    679                        // XXX: investigate how to provide documentation details in error.
    680                        throw new Error("execute() callback should not return a Promise.");
    681                    }
    682                    // Bring to scope that will be returned (once promise awaited).
    683                    result = returned;
    684                }
    685                catch (e) {
    686                    // The callback has thrown an error explicitly. Abort transaction cleanly.
    687                    abort(e);
    688                }
    689            };
    690            // No option to preload records, go straight to `callback`.
    691            if (!options.preload.length) {
    692                return runCallback();
    693            }
    694            // Preload specified records using a list request.
    695            const filters = { id: options.preload };
    696            createListRequest(this.cid, store, filters, records => {
    697                // Store obtained records by id.
    698                const preloaded = {};
    699                for (const record of records) {
    700                    delete record["_cid"];
    701                    preloaded[record.id] = record;
    702                }
    703                runCallback(preloaded);
    704            });
    705        }, { mode: "readwrite" });
    706        return result;
    707    }
    708    /**
    709     * Retrieve a record by its primary key from the IndexedDB database.
    710     *
    711     * @override
    712     * @param  {String} id The record id.
    713     * @return {Promise}
    714     */
    715    async get(id) {
    716        try {
    717            let record;
    718            await this.prepare("records", store => {
    719                store.get([this.cid, id]).onsuccess = e => (record = e.target.result);
    720            });
    721            return record;
    722        }
    723        catch (e) {
    724            this._handleError("get", e);
    725        }
    726    }
    727    /**
    728     * Lists all records from the IndexedDB database.
    729     *
    730     * @override
    731     * @param  {Object} params  The filters and order to apply to the results.
    732     * @return {Promise}
    733     */
    734    async list(params = { filters: {} }) {
    735        const { filters } = params;
    736        try {
    737            let results = [];
    738            await this.prepare("records", store => {
    739                createListRequest(this.cid, store, filters, _results => {
    740                    // we have received all requested records that match the filters,
    741                    // we now park them within current scope and hide the `_cid` attribute.
    742                    for (const result of _results) {
    743                        delete result["_cid"];
    744                    }
    745                    results = _results;
    746                });
    747            });
    748            // The resulting list of records is sorted.
    749            // XXX: with some efforts, this could be fully implemented using IDB API.
    750            return params.order ? sortObjects(params.order, results) : results;
    751        }
    752        catch (e) {
    753            this._handleError("list", e);
    754        }
    755    }
    756    /**
    757     * Store the lastModified value into metadata store.
    758     *
    759     * @override
    760     * @param  {Number}  lastModified
    761     * @return {Promise}
    762     */
    763    async saveLastModified(lastModified) {
    764        const value = parseInt(lastModified, 10) || null;
    765        try {
    766            await this.prepare("timestamps", store => {
    767                if (value === null) {
    768                    store.delete(this.cid);
    769                }
    770                else {
    771                    store.put({ cid: this.cid, value });
    772                }
    773            }, { mode: "readwrite" });
    774            return value;
    775        }
    776        catch (e) {
    777            this._handleError("saveLastModified", e);
    778        }
    779    }
    780    /**
    781     * Retrieve saved lastModified value.
    782     *
    783     * @override
    784     * @return {Promise}
    785     */
    786    async getLastModified() {
    787        try {
    788            let entry = null;
    789            await this.prepare("timestamps", store => {
    790                store.get(this.cid).onsuccess = e => (entry = e.target.result);
    791            });
    792            return entry ? entry.value : null;
    793        }
    794        catch (e) {
    795            this._handleError("getLastModified", e);
    796        }
    797    }
    798    /**
    799     * Load a dump of records exported from a server.
    800     *
    801     * @deprecated Use {@link importBulk} instead.
    802     * @abstract
    803     * @param  {Array} records The records to load.
    804     * @return {Promise}
    805     */
    806    async loadDump(records) {
    807        return this.importBulk(records);
    808    }
    809    /**
    810     * Load records in bulk that were exported from a server.
    811     *
    812     * @abstract
    813     * @param  {Array} records The records to load.
    814     * @return {Promise}
    815     */
    816    async importBulk(records) {
    817        try {
    818            await this.execute(transaction => {
    819                // Since the put operations are asynchronous, we chain
    820                // them together. The last one will be waited for the
    821                // `transaction.oncomplete` callback. (see #execute())
    822                let i = 0;
    823                putNext();
    824                function putNext() {
    825                    if (i == records.length) {
    826                        return;
    827                    }
    828                    // On error, `transaction.onerror` is called.
    829                    transaction.update(records[i]).onsuccess = putNext;
    830                    ++i;
    831                }
    832            });
    833            const previousLastModified = await this.getLastModified();
    834            const lastModified = Math.max(...records.map(record => record.last_modified));
    835            if (lastModified > previousLastModified) {
    836                await this.saveLastModified(lastModified);
    837            }
    838            return records;
    839        }
    840        catch (e) {
    841            this._handleError("importBulk", e);
    842        }
    843    }
    844    async saveMetadata(metadata) {
    845        try {
    846            await this.prepare("collections", store => store.put({ cid: this.cid, metadata }), { mode: "readwrite" });
    847            return metadata;
    848        }
    849        catch (e) {
    850            this._handleError("saveMetadata", e);
    851        }
    852    }
    853    async getMetadata() {
    854        try {
    855            let entry = null;
    856            await this.prepare("collections", store => {
    857                store.get(this.cid).onsuccess = e => (entry = e.target.result);
    858            });
    859            return entry ? entry.metadata : null;
    860        }
    861        catch (e) {
    862            this._handleError("getMetadata", e);
    863        }
    864    }
    865 }
    866 /**
    867 * IDB transaction proxy.
    868 *
    869 * @param  {IDB} adapter        The call IDB adapter
    870 * @param  {IDBStore} store     The IndexedDB database store.
    871 * @param  {Array}    preloaded The list of records to make available to
    872 *                              get() (default: []).
    873 * @return {Object}
    874 */
    875 function transactionProxy(adapter, store, preloaded = []) {
    876    const _cid = adapter.cid;
    877    return {
    878        create(record) {
    879            store.add(Object.assign(Object.assign({}, record), { _cid }));
    880        },
    881        update(record) {
    882            return store.put(Object.assign(Object.assign({}, record), { _cid }));
    883        },
    884        delete(id) {
    885            store.delete([_cid, id]);
    886        },
    887        get(id) {
    888            return preloaded[id];
    889        },
    890    };
    891 }
    892 /**
    893 * Up to version 10.X of kinto.js, each collection had its own collection.
    894 * The database name was `${bid}/${cid}` (eg. `"blocklists/certificates"`)
    895 * and contained only one store with the same name.
    896 */
    897 async function migrationRequired(dbName) {
    898    let exists = true;
    899    const db = await open(dbName, {
    900        version: 1,
    901        onupgradeneeded: event => {
    902            exists = false;
    903        },
    904    });
    905    // Check that the DB we're looking at is really a legacy one,
    906    // and not some remainder of the open() operation above.
    907    exists &=
    908        db.objectStoreNames.contains("__meta__") &&
    909            db.objectStoreNames.contains(dbName);
    910    if (!exists) {
    911        db.close();
    912        // Testing the existence creates it, so delete it :)
    913        await deleteDatabase(dbName);
    914        return null;
    915    }
    916    console.warn(`${dbName}: old IndexedDB database found.`);
    917    try {
    918        // Scan all records.
    919        let records;
    920        await execute(db, dbName, store => {
    921            store.openCursor().onsuccess = cursorHandlers.all({}, res => (records = res));
    922        });
    923        console.log(`${dbName}: found ${records.length} records.`);
    924        // Check if there's a entry for this.
    925        let timestamp = null;
    926        await execute(db, "__meta__", store => {
    927            store.get(`${dbName}-lastModified`).onsuccess = e => {
    928                timestamp = e.target.result ? e.target.result.value : null;
    929            };
    930        });
    931        // Some previous versions, also used to store the timestamps without prefix.
    932        if (!timestamp) {
    933            await execute(db, "__meta__", store => {
    934                store.get("lastModified").onsuccess = e => {
    935                    timestamp = e.target.result ? e.target.result.value : null;
    936                };
    937            });
    938        }
    939        console.log(`${dbName}: ${timestamp ? "found" : "no"} timestamp.`);
    940        // Those will be inserted in the new database/schema.
    941        return { records, timestamp };
    942    }
    943    catch (e) {
    944        console.error("Error occured during migration", e);
    945        return null;
    946    }
    947    finally {
    948        db.close();
    949    }
    950 }
    951 
    952 var uuid4 = {};
    953 
    954 const RECORD_FIELDS_TO_CLEAN = ["_status"];
    955 const AVAILABLE_HOOKS = ["incoming-changes"];
    956 const IMPORT_CHUNK_SIZE = 200;
    957 /**
    958 * Compare two records omitting local fields and synchronization
    959 * attributes (like _status and last_modified)
    960 * @param {Object} a    A record to compare.
    961 * @param {Object} b    A record to compare.
    962 * @param {Array} localFields Additional fields to ignore during the comparison
    963 * @return {boolean}
    964 */
    965 function recordsEqual(a, b, localFields = []) {
    966    const fieldsToClean = RECORD_FIELDS_TO_CLEAN.concat(["last_modified"]).concat(localFields);
    967    const cleanLocal = r => omitKeys(r, fieldsToClean);
    968    return deepEqual(cleanLocal(a), cleanLocal(b));
    969 }
    970 /**
    971 * Synchronization result object.
    972 */
    973 class SyncResultObject {
    974    /**
    975     * Public constructor.
    976     */
    977    constructor() {
    978        /**
    979         * Current synchronization result status; becomes `false` when conflicts or
    980         * errors are registered.
    981         * @type {Boolean}
    982         */
    983        this.lastModified = null;
    984        this._lists = {};
    985        [
    986            "errors",
    987            "created",
    988            "updated",
    989            "deleted",
    990            "published",
    991            "conflicts",
    992            "skipped",
    993            "resolved",
    994            "void",
    995        ].forEach(l => (this._lists[l] = []));
    996        this._cached = {};
    997    }
    998    /**
    999     * Adds entries for a given result type.
   1000     *
   1001     * @param {String} type    The result type.
   1002     * @param {Array}  entries The result entries.
   1003     * @return {SyncResultObject}
   1004     */
   1005    add(type, entries) {
   1006        if (!Array.isArray(this._lists[type])) {
   1007            console.warn(`Unknown type "${type}"`);
   1008            return;
   1009        }
   1010        if (!Array.isArray(entries)) {
   1011            entries = [entries];
   1012        }
   1013        this._lists[type] = this._lists[type].concat(entries);
   1014        delete this._cached[type];
   1015        return this;
   1016    }
   1017    get ok() {
   1018        return this.errors.length + this.conflicts.length === 0;
   1019    }
   1020    get errors() {
   1021        return this._lists["errors"];
   1022    }
   1023    get conflicts() {
   1024        return this._lists["conflicts"];
   1025    }
   1026    get skipped() {
   1027        return this._deduplicate("skipped");
   1028    }
   1029    get resolved() {
   1030        return this._deduplicate("resolved");
   1031    }
   1032    get created() {
   1033        return this._deduplicate("created");
   1034    }
   1035    get updated() {
   1036        return this._deduplicate("updated");
   1037    }
   1038    get deleted() {
   1039        return this._deduplicate("deleted");
   1040    }
   1041    get published() {
   1042        return this._deduplicate("published");
   1043    }
   1044    _deduplicate(list) {
   1045        if (!(list in this._cached)) {
   1046            // Deduplicate entries by id. If the values don't have `id` attribute, just
   1047            // keep all.
   1048            const recordsWithoutId = new Set();
   1049            const recordsById = new Map();
   1050            this._lists[list].forEach(record => {
   1051                if (!record.id) {
   1052                    recordsWithoutId.add(record);
   1053                }
   1054                else {
   1055                    recordsById.set(record.id, record);
   1056                }
   1057            });
   1058            this._cached[list] = Array.from(recordsById.values()).concat(Array.from(recordsWithoutId));
   1059        }
   1060        return this._cached[list];
   1061    }
   1062    /**
   1063     * Reinitializes result entries for a given result type.
   1064     *
   1065     * @param  {String} type The result type.
   1066     * @return {SyncResultObject}
   1067     */
   1068    reset(type) {
   1069        this._lists[type] = [];
   1070        delete this._cached[type];
   1071        return this;
   1072    }
   1073    toObject() {
   1074        // Only used in tests.
   1075        return {
   1076            ok: this.ok,
   1077            lastModified: this.lastModified,
   1078            errors: this.errors,
   1079            created: this.created,
   1080            updated: this.updated,
   1081            deleted: this.deleted,
   1082            skipped: this.skipped,
   1083            published: this.published,
   1084            conflicts: this.conflicts,
   1085            resolved: this.resolved,
   1086        };
   1087    }
   1088 }
   1089 class ServerWasFlushedError extends Error {
   1090    constructor(clientTimestamp, serverTimestamp, message) {
   1091        super(message);
   1092        if (Error.captureStackTrace) {
   1093            Error.captureStackTrace(this, ServerWasFlushedError);
   1094        }
   1095        this.clientTimestamp = clientTimestamp;
   1096        this.serverTimestamp = serverTimestamp;
   1097    }
   1098 }
   1099 function createUUIDSchema() {
   1100    return {
   1101        generate() {
   1102            return uuid4();
   1103        },
   1104        validate(id) {
   1105            return typeof id == "string" && RE_RECORD_ID.test(id);
   1106        },
   1107    };
   1108 }
   1109 function markStatus(record, status) {
   1110    return Object.assign(Object.assign({}, record), { _status: status });
   1111 }
   1112 function markDeleted(record) {
   1113    return markStatus(record, "deleted");
   1114 }
   1115 function markSynced(record) {
   1116    return markStatus(record, "synced");
   1117 }
   1118 /**
   1119 * Import a remote change into the local database.
   1120 *
   1121 * @param  {IDBTransactionProxy} transaction The transaction handler.
   1122 * @param  {Object}              remote      The remote change object to import.
   1123 * @param  {Array<String>}       localFields The list of fields that remain local.
   1124 * @param  {String}              strategy    The {@link Collection.strategy}.
   1125 * @return {Object}
   1126 */
   1127 function importChange(transaction, remote, localFields, strategy) {
   1128    const local = transaction.get(remote.id);
   1129    if (!local) {
   1130        // Not found locally but remote change is marked as deleted; skip to
   1131        // avoid recreation.
   1132        if (remote.deleted) {
   1133            return { type: "skipped", data: remote };
   1134        }
   1135        const synced = markSynced(remote);
   1136        transaction.create(synced);
   1137        return { type: "created", data: synced };
   1138    }
   1139    // Apply remote changes on local record.
   1140    const synced = Object.assign(Object.assign({}, local), markSynced(remote));
   1141    // With pull only, we don't need to compare records since we override them.
   1142    if (strategy === Collection.strategy.PULL_ONLY) {
   1143        if (remote.deleted) {
   1144            transaction.delete(remote.id);
   1145            return { type: "deleted", data: local };
   1146        }
   1147        transaction.update(synced);
   1148        return { type: "updated", data: { old: local, new: synced } };
   1149    }
   1150    // With other sync strategies, we detect conflicts,
   1151    // by comparing local and remote, ignoring local fields.
   1152    const isIdentical = recordsEqual(local, remote, localFields);
   1153    // Detect or ignore conflicts if record has also been modified locally.
   1154    if (local._status !== "synced") {
   1155        // Locally deleted, unsynced: scheduled for remote deletion.
   1156        if (local._status === "deleted") {
   1157            return { type: "skipped", data: local };
   1158        }
   1159        if (isIdentical) {
   1160            // If records are identical, import anyway, so we bump the
   1161            // local last_modified value from the server and set record
   1162            // status to "synced".
   1163            transaction.update(synced);
   1164            return { type: "updated", data: { old: local, new: synced } };
   1165        }
   1166        if (local.last_modified !== undefined &&
   1167            local.last_modified === remote.last_modified) {
   1168            // If our local version has the same last_modified as the remote
   1169            // one, this represents an object that corresponds to a resolved
   1170            // conflict. Our local version represents the final output, so
   1171            // we keep that one. (No transaction operation to do.)
   1172            // But if our last_modified is undefined,
   1173            // that means we've created the same object locally as one on
   1174            // the server, which *must* be a conflict.
   1175            return { type: "void" };
   1176        }
   1177        return {
   1178            type: "conflicts",
   1179            data: { type: "incoming", local: local, remote: remote },
   1180        };
   1181    }
   1182    // Local record was synced.
   1183    if (remote.deleted) {
   1184        transaction.delete(remote.id);
   1185        return { type: "deleted", data: local };
   1186    }
   1187    // Import locally.
   1188    transaction.update(synced);
   1189    // if identical, simply exclude it from all SyncResultObject lists
   1190    const type = isIdentical ? "void" : "updated";
   1191    return { type, data: { old: local, new: synced } };
   1192 }
   1193 /**
   1194 * Abstracts a collection of records stored in the local database, providing
   1195 * CRUD operations and synchronization helpers.
   1196 */
   1197 class Collection {
   1198    /**
   1199     * Constructor.
   1200     *
   1201     * Options:
   1202     * - `{BaseAdapter} adapter` The DB adapter (default: `IDB`)
   1203     *
   1204     * @param  {String}    bucket  The bucket identifier.
   1205     * @param  {String}    name    The collection name.
   1206     * @param  {KintoBase} kinto   The Kinto instance.
   1207     * @param  {Object}    options The options object.
   1208     */
   1209    constructor(bucket, name, kinto, options = {}) {
   1210        this._bucket = bucket;
   1211        this._name = name;
   1212        this._lastModified = null;
   1213        const DBAdapter = options.adapter || IDB;
   1214        if (!DBAdapter) {
   1215            throw new Error("No adapter provided");
   1216        }
   1217        const db = new DBAdapter(`${bucket}/${name}`, options.adapterOptions);
   1218        if (!(db instanceof BaseAdapter)) {
   1219            throw new Error("Unsupported adapter.");
   1220        }
   1221        // public properties
   1222        /**
   1223         * The db adapter instance
   1224         * @type {BaseAdapter}
   1225         */
   1226        this.db = db;
   1227        /**
   1228         * The KintoBase instance.
   1229         * @type {KintoBase}
   1230         */
   1231        this.kinto = kinto;
   1232        /**
   1233         * The event emitter instance.
   1234         * @type {EventEmitter}
   1235         */
   1236        this.events = options.events;
   1237        /**
   1238         * The IdSchema instance.
   1239         * @type {Object}
   1240         */
   1241        this.idSchema = this._validateIdSchema(options.idSchema);
   1242        /**
   1243         * The list of remote transformers.
   1244         * @type {Array}
   1245         */
   1246        this.remoteTransformers = this._validateRemoteTransformers(options.remoteTransformers);
   1247        /**
   1248         * The list of hooks.
   1249         * @type {Object}
   1250         */
   1251        this.hooks = this._validateHooks(options.hooks);
   1252        /**
   1253         * The list of fields names that will remain local.
   1254         * @type {Array}
   1255         */
   1256        this.localFields = options.localFields || [];
   1257    }
   1258    /**
   1259     * The HTTP client.
   1260     * @type {KintoClient}
   1261     */
   1262    get api() {
   1263        return this.kinto.api;
   1264    }
   1265    /**
   1266     * The collection name.
   1267     * @type {String}
   1268     */
   1269    get name() {
   1270        return this._name;
   1271    }
   1272    /**
   1273     * The bucket name.
   1274     * @type {String}
   1275     */
   1276    get bucket() {
   1277        return this._bucket;
   1278    }
   1279    /**
   1280     * The last modified timestamp.
   1281     * @type {Number}
   1282     */
   1283    get lastModified() {
   1284        return this._lastModified;
   1285    }
   1286    /**
   1287     * Synchronization strategies. Available strategies are:
   1288     *
   1289     * - `MANUAL`: Conflicts will be reported in a dedicated array.
   1290     * - `SERVER_WINS`: Conflicts are resolved using remote data.
   1291     * - `CLIENT_WINS`: Conflicts are resolved using local data.
   1292     *
   1293     * @type {Object}
   1294     */
   1295    static get strategy() {
   1296        return {
   1297            CLIENT_WINS: "client_wins",
   1298            SERVER_WINS: "server_wins",
   1299            PULL_ONLY: "pull_only",
   1300            MANUAL: "manual",
   1301        };
   1302    }
   1303    /**
   1304     * Validates an idSchema.
   1305     *
   1306     * @param  {Object|undefined} idSchema
   1307     * @return {Object}
   1308     */
   1309    _validateIdSchema(idSchema) {
   1310        if (typeof idSchema === "undefined") {
   1311            return createUUIDSchema();
   1312        }
   1313        if (typeof idSchema !== "object") {
   1314            throw new Error("idSchema must be an object.");
   1315        }
   1316        else if (typeof idSchema.generate !== "function") {
   1317            throw new Error("idSchema must provide a generate function.");
   1318        }
   1319        else if (typeof idSchema.validate !== "function") {
   1320            throw new Error("idSchema must provide a validate function.");
   1321        }
   1322        return idSchema;
   1323    }
   1324    /**
   1325     * Validates a list of remote transformers.
   1326     *
   1327     * @param  {Array|undefined} remoteTransformers
   1328     * @return {Array}
   1329     */
   1330    _validateRemoteTransformers(remoteTransformers) {
   1331        if (typeof remoteTransformers === "undefined") {
   1332            return [];
   1333        }
   1334        if (!Array.isArray(remoteTransformers)) {
   1335            throw new Error("remoteTransformers should be an array.");
   1336        }
   1337        return remoteTransformers.map(transformer => {
   1338            if (typeof transformer !== "object") {
   1339                throw new Error("A transformer must be an object.");
   1340            }
   1341            else if (typeof transformer.encode !== "function") {
   1342                throw new Error("A transformer must provide an encode function.");
   1343            }
   1344            else if (typeof transformer.decode !== "function") {
   1345                throw new Error("A transformer must provide a decode function.");
   1346            }
   1347            return transformer;
   1348        });
   1349    }
   1350    /**
   1351     * Validate the passed hook is correct.
   1352     *
   1353     * @param {Array|undefined} hook.
   1354     * @return {Array}
   1355     **/
   1356    _validateHook(hook) {
   1357        if (!Array.isArray(hook)) {
   1358            throw new Error("A hook definition should be an array of functions.");
   1359        }
   1360        return hook.map(fn => {
   1361            if (typeof fn !== "function") {
   1362                throw new Error("A hook definition should be an array of functions.");
   1363            }
   1364            return fn;
   1365        });
   1366    }
   1367    /**
   1368     * Validates a list of hooks.
   1369     *
   1370     * @param  {Object|undefined} hooks
   1371     * @return {Object}
   1372     */
   1373    _validateHooks(hooks) {
   1374        if (typeof hooks === "undefined") {
   1375            return {};
   1376        }
   1377        if (Array.isArray(hooks)) {
   1378            throw new Error("hooks should be an object, not an array.");
   1379        }
   1380        if (typeof hooks !== "object") {
   1381            throw new Error("hooks should be an object.");
   1382        }
   1383        const validatedHooks = {};
   1384        for (const hook in hooks) {
   1385            if (!AVAILABLE_HOOKS.includes(hook)) {
   1386                throw new Error("The hook should be one of " + AVAILABLE_HOOKS.join(", "));
   1387            }
   1388            validatedHooks[hook] = this._validateHook(hooks[hook]);
   1389        }
   1390        return validatedHooks;
   1391    }
   1392    /**
   1393     * Deletes every records in the current collection and marks the collection as
   1394     * never synced.
   1395     *
   1396     * @return {Promise}
   1397     */
   1398    async clear() {
   1399        await this.db.clear();
   1400        await this.db.saveMetadata(null);
   1401        await this.db.saveLastModified(null);
   1402        return { data: [], permissions: {} };
   1403    }
   1404    /**
   1405     * Encodes a record.
   1406     *
   1407     * @param  {String} type   Either "remote" or "local".
   1408     * @param  {Object} record The record object to encode.
   1409     * @return {Promise}
   1410     */
   1411    _encodeRecord(type, record) {
   1412        if (!this[`${type}Transformers`].length) {
   1413            return Promise.resolve(record);
   1414        }
   1415        return waterfall(this[`${type}Transformers`].map(transformer => {
   1416            return record => transformer.encode(record);
   1417        }), record);
   1418    }
   1419    /**
   1420     * Decodes a record.
   1421     *
   1422     * @param  {String} type   Either "remote" or "local".
   1423     * @param  {Object} record The record object to decode.
   1424     * @return {Promise}
   1425     */
   1426    _decodeRecord(type, record) {
   1427        if (!this[`${type}Transformers`].length) {
   1428            return Promise.resolve(record);
   1429        }
   1430        return waterfall(this[`${type}Transformers`].reverse().map(transformer => {
   1431            return record => transformer.decode(record);
   1432        }), record);
   1433    }
   1434    /**
   1435     * Adds a record to the local database, asserting that none
   1436     * already exist with this ID.
   1437     *
   1438     * Note: If either the `useRecordId` or `synced` options are true, then the
   1439     * record object must contain the id field to be validated. If none of these
   1440     * options are true, an id is generated using the current IdSchema; in this
   1441     * case, the record passed must not have an id.
   1442     *
   1443     * Options:
   1444     * - {Boolean} synced       Sets record status to "synced" (default: `false`).
   1445     * - {Boolean} useRecordId  Forces the `id` field from the record to be used,
   1446     *                          instead of one that is generated automatically
   1447     *                          (default: `false`).
   1448     *
   1449     * @param  {Object} record
   1450     * @param  {Object} options
   1451     * @return {Promise}
   1452     */
   1453    create(record, options = { useRecordId: false, synced: false }) {
   1454        // Validate the record and its ID (if any), even though this
   1455        // validation is also done in the CollectionTransaction method,
   1456        // because we need to pass the ID to preloadIds.
   1457        const reject = msg => Promise.reject(new Error(msg));
   1458        if (typeof record !== "object") {
   1459            return reject("Record is not an object.");
   1460        }
   1461        if ((options.synced || options.useRecordId) &&
   1462            !Object.prototype.hasOwnProperty.call(record, "id")) {
   1463            return reject("Missing required Id; synced and useRecordId options require one");
   1464        }
   1465        if (!options.synced &&
   1466            !options.useRecordId &&
   1467            Object.prototype.hasOwnProperty.call(record, "id")) {
   1468            return reject("Extraneous Id; can't create a record having one set.");
   1469        }
   1470        const newRecord = Object.assign(Object.assign({}, record), { id: options.synced || options.useRecordId
   1471                ? record.id
   1472                : this.idSchema.generate(record), _status: options.synced ? "synced" : "created" });
   1473        if (!this.idSchema.validate(newRecord.id)) {
   1474            return reject(`Invalid Id: ${newRecord.id}`);
   1475        }
   1476        return this.execute(txn => txn.create(newRecord), {
   1477            preloadIds: [newRecord.id],
   1478        }).catch(err => {
   1479            if (options.useRecordId) {
   1480                throw new Error("Couldn't create record. It may have been virtually deleted.");
   1481            }
   1482            throw err;
   1483        });
   1484    }
   1485    /**
   1486     * Like {@link CollectionTransaction#update}, but wrapped in its own transaction.
   1487     *
   1488     * Options:
   1489     * - {Boolean} synced: Sets record status to "synced" (default: false)
   1490     * - {Boolean} patch:  Extends the existing record instead of overwriting it
   1491     *   (default: false)
   1492     *
   1493     * @param  {Object} record
   1494     * @param  {Object} options
   1495     * @return {Promise}
   1496     */
   1497    update(record, options = { synced: false, patch: false }) {
   1498        // Validate the record and its ID, even though this validation is
   1499        // also done in the CollectionTransaction method, because we need
   1500        // to pass the ID to preloadIds.
   1501        if (typeof record !== "object") {
   1502            return Promise.reject(new Error("Record is not an object."));
   1503        }
   1504        if (!Object.prototype.hasOwnProperty.call(record, "id")) {
   1505            return Promise.reject(new Error("Cannot update a record missing id."));
   1506        }
   1507        if (!this.idSchema.validate(record.id)) {
   1508            return Promise.reject(new Error(`Invalid Id: ${record.id}`));
   1509        }
   1510        return this.execute(txn => txn.update(record, options), {
   1511            preloadIds: [record.id],
   1512        });
   1513    }
   1514    /**
   1515     * Like {@link CollectionTransaction#upsert}, but wrapped in its own transaction.
   1516     *
   1517     * @param  {Object} record
   1518     * @return {Promise}
   1519     */
   1520    upsert(record) {
   1521        // Validate the record and its ID, even though this validation is
   1522        // also done in the CollectionTransaction method, because we need
   1523        // to pass the ID to preloadIds.
   1524        if (typeof record !== "object") {
   1525            return Promise.reject(new Error("Record is not an object."));
   1526        }
   1527        if (!Object.prototype.hasOwnProperty.call(record, "id")) {
   1528            return Promise.reject(new Error("Cannot update a record missing id."));
   1529        }
   1530        if (!this.idSchema.validate(record.id)) {
   1531            return Promise.reject(new Error(`Invalid Id: ${record.id}`));
   1532        }
   1533        return this.execute(txn => txn.upsert(record), { preloadIds: [record.id] });
   1534    }
   1535    /**
   1536     * Like {@link CollectionTransaction#get}, but wrapped in its own transaction.
   1537     *
   1538     * Options:
   1539     * - {Boolean} includeDeleted: Include virtually deleted records.
   1540     *
   1541     * @param  {String} id
   1542     * @param  {Object} options
   1543     * @return {Promise}
   1544     */
   1545    get(id, options = { includeDeleted: false }) {
   1546        return this.execute(txn => txn.get(id, options), { preloadIds: [id] });
   1547    }
   1548    /**
   1549     * Like {@link CollectionTransaction#getAny}, but wrapped in its own transaction.
   1550     *
   1551     * @param  {String} id
   1552     * @return {Promise}
   1553     */
   1554    getAny(id) {
   1555        return this.execute(txn => txn.getAny(id), { preloadIds: [id] });
   1556    }
   1557    /**
   1558     * Same as {@link Collection#delete}, but wrapped in its own transaction.
   1559     *
   1560     * Options:
   1561     * - {Boolean} virtual: When set to `true`, doesn't actually delete the record,
   1562     *   update its `_status` attribute to `deleted` instead (default: true)
   1563     *
   1564     * @param  {String} id       The record's Id.
   1565     * @param  {Object} options  The options object.
   1566     * @return {Promise}
   1567     */
   1568    delete(id, options = { virtual: true }) {
   1569        return this.execute(transaction => {
   1570            return transaction.delete(id, options);
   1571        }, { preloadIds: [id] });
   1572    }
   1573    /**
   1574     * Same as {@link Collection#deleteAll}, but wrapped in its own transaction, execulding the parameter.
   1575     *
   1576     * @return {Promise}
   1577     */
   1578    async deleteAll() {
   1579        const { data } = await this.list({}, { includeDeleted: false });
   1580        const recordIds = data.map(record => record.id);
   1581        return this.execute(transaction => {
   1582            return transaction.deleteAll(recordIds);
   1583        }, { preloadIds: recordIds });
   1584    }
   1585    /**
   1586     * The same as {@link CollectionTransaction#deleteAny}, but wrapped
   1587     * in its own transaction.
   1588     *
   1589     * @param  {String} id       The record's Id.
   1590     * @return {Promise}
   1591     */
   1592    deleteAny(id) {
   1593        return this.execute(txn => txn.deleteAny(id), { preloadIds: [id] });
   1594    }
   1595    /**
   1596     * Lists records from the local database.
   1597     *
   1598     * Params:
   1599     * - {Object} filters Filter the results (default: `{}`).
   1600     * - {String} order   The order to apply   (default: `-last_modified`).
   1601     *
   1602     * Options:
   1603     * - {Boolean} includeDeleted: Include virtually deleted records.
   1604     *
   1605     * @param  {Object} params  The filters and order to apply to the results.
   1606     * @param  {Object} options The options object.
   1607     * @return {Promise}
   1608     */
   1609    async list(params = {}, options = { includeDeleted: false }) {
   1610        params = Object.assign({ order: "-last_modified", filters: {} }, params);
   1611        const results = await this.db.list(params);
   1612        let data = results;
   1613        if (!options.includeDeleted) {
   1614            data = results.filter(record => record._status !== "deleted");
   1615        }
   1616        return { data, permissions: {} };
   1617    }
   1618    /**
   1619     * Imports remote changes into the local database.
   1620     * This method is in charge of detecting the conflicts, and resolve them
   1621     * according to the specified strategy.
   1622     * @param  {SyncResultObject} syncResultObject The sync result object.
   1623     * @param  {Array}            decodedChanges   The list of changes to import in the local database.
   1624     * @param  {String}           strategy         The {@link Collection.strategy} (default: MANUAL)
   1625     * @return {Promise}
   1626     */
   1627    async importChanges(syncResultObject, decodedChanges, strategy = Collection.strategy.MANUAL) {
   1628        // Retrieve records matching change ids.
   1629        try {
   1630            for (let i = 0; i < decodedChanges.length; i += IMPORT_CHUNK_SIZE) {
   1631                const slice = decodedChanges.slice(i, i + IMPORT_CHUNK_SIZE);
   1632                const { imports, resolved } = await this.db.execute(transaction => {
   1633                    const imports = slice.map(remote => {
   1634                        // Store remote change into local database.
   1635                        return importChange(transaction, remote, this.localFields, strategy);
   1636                    });
   1637                    const conflicts = imports
   1638                        .filter(i => i.type === "conflicts")
   1639                        .map(i => i.data);
   1640                    const resolved = this._handleConflicts(transaction, conflicts, strategy);
   1641                    return { imports, resolved };
   1642                }, { preload: slice.map(record => record.id) });
   1643                // Lists of created/updated/deleted records
   1644                imports.forEach(({ type, data }) => syncResultObject.add(type, data));
   1645                // Automatically resolved conflicts (if not manual)
   1646                if (resolved.length > 0) {
   1647                    syncResultObject.reset("conflicts").add("resolved", resolved);
   1648                }
   1649            }
   1650        }
   1651        catch (err) {
   1652            const data = {
   1653                type: "incoming",
   1654                message: err.message,
   1655                stack: err.stack,
   1656            };
   1657            // XXX one error of the whole transaction instead of per atomic op
   1658            syncResultObject.add("errors", data);
   1659        }
   1660        return syncResultObject;
   1661    }
   1662    /**
   1663     * Imports the responses of pushed changes into the local database.
   1664     * Basically it stores the timestamp assigned by the server into the local
   1665     * database.
   1666     * @param  {SyncResultObject} syncResultObject The sync result object.
   1667     * @param  {Array}            toApplyLocally   The list of changes to import in the local database.
   1668     * @param  {Array}            conflicts        The list of conflicts that have to be resolved.
   1669     * @param  {String}           strategy         The {@link Collection.strategy}.
   1670     * @return {Promise}
   1671     */
   1672    async _applyPushedResults(syncResultObject, toApplyLocally, conflicts, strategy = Collection.strategy.MANUAL) {
   1673        const toDeleteLocally = toApplyLocally.filter(r => r.deleted);
   1674        const toUpdateLocally = toApplyLocally.filter(r => !r.deleted);
   1675        const { published, resolved } = await this.db.execute(transaction => {
   1676            const updated = toUpdateLocally.map(record => {
   1677                const synced = markSynced(record);
   1678                transaction.update(synced);
   1679                return synced;
   1680            });
   1681            const deleted = toDeleteLocally.map(record => {
   1682                transaction.delete(record.id);
   1683                // Amend result data with the deleted attribute set
   1684                return { id: record.id, deleted: true };
   1685            });
   1686            const published = updated.concat(deleted);
   1687            // Handle conflicts, if any
   1688            const resolved = this._handleConflicts(transaction, conflicts, strategy);
   1689            return { published, resolved };
   1690        });
   1691        syncResultObject.add("published", published);
   1692        if (resolved.length > 0) {
   1693            syncResultObject
   1694                .reset("conflicts")
   1695                .reset("resolved")
   1696                .add("resolved", resolved);
   1697        }
   1698        return syncResultObject;
   1699    }
   1700    /**
   1701     * Handles synchronization conflicts according to specified strategy.
   1702     *
   1703     * @param  {SyncResultObject} result    The sync result object.
   1704     * @param  {String}           strategy  The {@link Collection.strategy}.
   1705     * @return {Promise<Array<Object>>} The resolved conflicts, as an
   1706     *    array of {accepted, rejected} objects
   1707     */
   1708    _handleConflicts(transaction, conflicts, strategy) {
   1709        if (strategy === Collection.strategy.MANUAL) {
   1710            return [];
   1711        }
   1712        return conflicts.map(conflict => {
   1713            const resolution = strategy === Collection.strategy.CLIENT_WINS
   1714                ? conflict.local
   1715                : conflict.remote;
   1716            const rejected = strategy === Collection.strategy.CLIENT_WINS
   1717                ? conflict.remote
   1718                : conflict.local;
   1719            let accepted, status, id;
   1720            if (resolution === null) {
   1721                // We "resolved" with the server-side deletion. Delete locally.
   1722                // This only happens during SERVER_WINS because the local
   1723                // version of a record can never be null.
   1724                // We can get "null" from the remote side if we got a conflict
   1725                // and there is no remote version available; see kinto-http.js
   1726                // batch.js:aggregate.
   1727                transaction.delete(conflict.local.id);
   1728                accepted = null;
   1729                // The record was deleted, but that status is "synced" with
   1730                // the server, so we don't need to push the change.
   1731                status = "synced";
   1732                id = conflict.local.id;
   1733            }
   1734            else {
   1735                const updated = this._resolveRaw(conflict, resolution);
   1736                transaction.update(updated);
   1737                accepted = updated;
   1738                status = updated._status;
   1739                id = updated.id;
   1740            }
   1741            return { rejected, accepted, id, _status: status };
   1742        });
   1743    }
   1744    /**
   1745     * Execute a bunch of operations in a transaction.
   1746     *
   1747     * This transaction should be atomic -- either all of its operations
   1748     * will succeed, or none will.
   1749     *
   1750     * The argument to this function is itself a function which will be
   1751     * called with a {@link CollectionTransaction}. Collection methods
   1752     * are available on this transaction, but instead of returning
   1753     * promises, they are synchronous. execute() returns a Promise whose
   1754     * value will be the return value of the provided function.
   1755     *
   1756     * Most operations will require access to the record itself, which
   1757     * must be preloaded by passing its ID in the preloadIds option.
   1758     *
   1759     * Options:
   1760     * - {Array} preloadIds: list of IDs to fetch at the beginning of
   1761     *   the transaction
   1762     *
   1763     * @return {Promise} Resolves with the result of the given function
   1764     *    when the transaction commits.
   1765     */
   1766    execute(doOperations, { preloadIds = [] } = {}) {
   1767        for (const id of preloadIds) {
   1768            if (!this.idSchema.validate(id)) {
   1769                return Promise.reject(Error(`Invalid Id: ${id}`));
   1770            }
   1771        }
   1772        return this.db.execute(transaction => {
   1773            const txn = new CollectionTransaction(this, transaction);
   1774            const result = doOperations(txn);
   1775            txn.emitEvents();
   1776            return result;
   1777        }, { preload: preloadIds });
   1778    }
   1779    /**
   1780     * Resets the local records as if they were never synced; existing records are
   1781     * marked as newly created, deleted records are dropped.
   1782     *
   1783     * A next call to {@link Collection.sync} will thus republish the whole
   1784     * content of the local collection to the server.
   1785     *
   1786     * @return {Promise} Resolves with the number of processed records.
   1787     */
   1788    async resetSyncStatus() {
   1789        const unsynced = await this.list({ filters: { _status: ["deleted", "synced"] }, order: "" }, { includeDeleted: true });
   1790        await this.db.execute(transaction => {
   1791            unsynced.data.forEach(record => {
   1792                if (record._status === "deleted") {
   1793                    // Garbage collect deleted records.
   1794                    transaction.delete(record.id);
   1795                }
   1796                else {
   1797                    // Records that were synced become «created».
   1798                    transaction.update(Object.assign(Object.assign({}, record), { last_modified: undefined, _status: "created" }));
   1799                }
   1800            });
   1801        });
   1802        this._lastModified = null;
   1803        await this.db.saveLastModified(null);
   1804        return unsynced.data.length;
   1805    }
   1806    /**
   1807     * Returns an object containing two lists:
   1808     *
   1809     * - `toDelete`: unsynced deleted records we can safely delete;
   1810     * - `toSync`: local updates to send to the server.
   1811     *
   1812     * @return {Promise}
   1813     */
   1814    async gatherLocalChanges() {
   1815        const unsynced = await this.list({
   1816            filters: { _status: ["created", "updated"] },
   1817            order: "",
   1818        });
   1819        const deleted = await this.list({ filters: { _status: "deleted" }, order: "" }, { includeDeleted: true });
   1820        return await Promise.all(unsynced.data
   1821            .concat(deleted.data)
   1822            .map(this._encodeRecord.bind(this, "remote")));
   1823    }
   1824    /**
   1825     * Fetch remote changes, import them to the local database, and handle
   1826     * conflicts according to `options.strategy`. Then, updates the passed
   1827     * {@link SyncResultObject} with import results.
   1828     *
   1829     * Options:
   1830     * - {String} strategy: The selected sync strategy.
   1831     * - {String} expectedTimestamp: A timestamp to use as a "cache busting" query parameter.
   1832     * - {Array<String>} exclude: A list of record ids to exclude from pull.
   1833     * - {Object} headers: The HTTP headers to use in the request.
   1834     * - {int} retry: The number of retries to do if the HTTP request fails.
   1835     * - {int} lastModified: The timestamp to use in `?_since` query.
   1836     *
   1837     * @param  {KintoClient.Collection} client           Kinto client Collection instance.
   1838     * @param  {SyncResultObject}       syncResultObject The sync result object.
   1839     * @param  {Object}                 options          The options object.
   1840     * @return {Promise}
   1841     */
   1842    async pullChanges(client, syncResultObject, options = {}) {
   1843        if (!syncResultObject.ok) {
   1844            return syncResultObject;
   1845        }
   1846        const since = this.lastModified
   1847            ? this.lastModified
   1848            : await this.db.getLastModified();
   1849        options = Object.assign({ strategy: Collection.strategy.MANUAL, lastModified: since, headers: {} }, options);
   1850        // Optionally ignore some records when pulling for changes.
   1851        // (avoid redownloading our own changes on last step of #sync())
   1852        let filters;
   1853        if (options.exclude) {
   1854            // Limit the list of excluded records to the first 50 records in order
   1855            // to remain under de-facto URL size limit (~2000 chars).
   1856            // http://stackoverflow.com/questions/417142/what-is-the-maximum-length-of-a-url-in-different-browsers/417184#417184
   1857            const exclude_id = options.exclude
   1858                .slice(0, 50)
   1859                .map(r => r.id)
   1860                .join(",");
   1861            filters = { exclude_id };
   1862        }
   1863        if (options.expectedTimestamp) {
   1864            filters = Object.assign(Object.assign({}, filters), { _expected: options.expectedTimestamp });
   1865        }
   1866        // First fetch remote changes from the server
   1867        const { data, last_modified } = await client.listRecords({
   1868            // Since should be ETag (see https://github.com/Kinto/kinto.js/issues/356)
   1869            since: options.lastModified ? `${options.lastModified}` : undefined,
   1870            headers: options.headers,
   1871            retry: options.retry,
   1872            // Fetch every page by default (FIXME: option to limit pages, see #277)
   1873            pages: Infinity,
   1874            filters,
   1875        });
   1876        // last_modified is the ETag header value (string).
   1877        // For retro-compatibility with first kinto.js versions
   1878        // parse it to integer.
   1879        const unquoted = last_modified ? parseInt(last_modified, 10) : undefined;
   1880        // Check if server was flushed.
   1881        // This is relevant for the Kinto demo server
   1882        // (and thus for many new comers).
   1883        const localSynced = options.lastModified;
   1884        const serverChanged = unquoted > options.lastModified;
   1885        const emptyCollection = data.length === 0;
   1886        if (!options.exclude && localSynced && serverChanged && emptyCollection) {
   1887            const e = new ServerWasFlushedError(localSynced, unquoted, "Server has been flushed. Client Side Timestamp: " +
   1888                localSynced +
   1889                " Server Side Timestamp: " +
   1890                unquoted);
   1891            throw e;
   1892        }
   1893        // Atomic updates are not sensible here because unquoted is not
   1894        // computed as a function of syncResultObject.lastModified.
   1895        // eslint-disable-next-line require-atomic-updates
   1896        syncResultObject.lastModified = unquoted;
   1897        // Decode incoming changes.
   1898        const decodedChanges = await Promise.all(data.map(change => {
   1899            return this._decodeRecord("remote", change);
   1900        }));
   1901        // Hook receives decoded records.
   1902        const payload = { lastModified: unquoted, changes: decodedChanges };
   1903        const afterHooks = await this.applyHook("incoming-changes", payload);
   1904        // No change, nothing to import.
   1905        if (afterHooks.changes.length > 0) {
   1906            // Reflect these changes locally
   1907            await this.importChanges(syncResultObject, afterHooks.changes, options.strategy);
   1908        }
   1909        return syncResultObject;
   1910    }
   1911    applyHook(hookName, payload) {
   1912        if (typeof this.hooks[hookName] == "undefined") {
   1913            return Promise.resolve(payload);
   1914        }
   1915        return waterfall(this.hooks[hookName].map(hook => {
   1916            return record => {
   1917                const result = hook(payload, this);
   1918                const resultThenable = result && typeof result.then === "function";
   1919                const resultChanges = result && Object.prototype.hasOwnProperty.call(result, "changes");
   1920                if (!(resultThenable || resultChanges)) {
   1921                    throw new Error(`Invalid return value for hook: ${JSON.stringify(result)} has no 'then()' or 'changes' properties`);
   1922                }
   1923                return result;
   1924            };
   1925        }), payload);
   1926    }
   1927    /**
   1928     * Publish local changes to the remote server and updates the passed
   1929     * {@link SyncResultObject} with publication results.
   1930     *
   1931     * Options:
   1932     * - {String} strategy: The selected sync strategy.
   1933     * - {Object} headers: The HTTP headers to use in the request.
   1934     * - {int} retry: The number of retries to do if the HTTP request fails.
   1935     *
   1936     * @param  {KintoClient.Collection} client           Kinto client Collection instance.
   1937     * @param  {SyncResultObject}       syncResultObject The sync result object.
   1938     * @param  {Object}                 changes          The change object.
   1939     * @param  {Array}                  changes.toDelete The list of records to delete.
   1940     * @param  {Array}                  changes.toSync   The list of records to create/update.
   1941     * @param  {Object}                 options          The options object.
   1942     * @return {Promise}
   1943     */
   1944    async pushChanges(client, changes, syncResultObject, options = {}) {
   1945        if (!syncResultObject.ok) {
   1946            return syncResultObject;
   1947        }
   1948        const safe = !options.strategy || options.strategy !== Collection.CLIENT_WINS;
   1949        const toDelete = changes.filter(r => r._status == "deleted");
   1950        const toSync = changes.filter(r => r._status != "deleted");
   1951        // Perform a batch request with every changes.
   1952        const synced = await client.batch(batch => {
   1953            toDelete.forEach(r => {
   1954                // never published locally deleted records should not be pusblished
   1955                if (r.last_modified) {
   1956                    batch.deleteRecord(r);
   1957                }
   1958            });
   1959            toSync.forEach(r => {
   1960                // Clean local fields (like _status) before sending to server.
   1961                const published = this.cleanLocalFields(r);
   1962                if (r._status === "created") {
   1963                    batch.createRecord(published);
   1964                }
   1965                else {
   1966                    batch.updateRecord(published);
   1967                }
   1968            });
   1969        }, {
   1970            headers: options.headers,
   1971            retry: options.retry,
   1972            safe,
   1973            aggregate: true,
   1974        });
   1975        // Store outgoing errors into sync result object
   1976        syncResultObject.add("errors", synced.errors.map(e => (Object.assign(Object.assign({}, e), { type: "outgoing" }))));
   1977        // Store outgoing conflicts into sync result object
   1978        const conflicts = [];
   1979        for (const { type, local, remote } of synced.conflicts) {
   1980            // Note: we ensure that local data are actually available, as they may
   1981            // be missing in the case of a published deletion.
   1982            const safeLocal = (local && local.data) || { id: remote.id };
   1983            const realLocal = await this._decodeRecord("remote", safeLocal);
   1984            // We can get "null" from the remote side if we got a conflict
   1985            // and there is no remote version available; see kinto-http.js
   1986            // batch.js:aggregate.
   1987            const realRemote = remote && (await this._decodeRecord("remote", remote));
   1988            const conflict = { type, local: realLocal, remote: realRemote };
   1989            conflicts.push(conflict);
   1990        }
   1991        syncResultObject.add("conflicts", conflicts);
   1992        // Records that must be deleted are either deletions that were pushed
   1993        // to server (published) or deleted records that were never pushed (skipped).
   1994        const missingRemotely = synced.skipped.map(r => (Object.assign(Object.assign({}, r), { deleted: true })));
   1995        // For created and updated records, the last_modified coming from server
   1996        // will be stored locally.
   1997        // Reflect publication results locally using the response from
   1998        // the batch request.
   1999        const published = synced.published.map(c => c.data);
   2000        const toApplyLocally = published.concat(missingRemotely);
   2001        // Apply the decode transformers, if any
   2002        const decoded = await Promise.all(toApplyLocally.map(record => {
   2003            return this._decodeRecord("remote", record);
   2004        }));
   2005        // We have to update the local records with the responses of the server
   2006        // (eg. last_modified values etc.).
   2007        if (decoded.length > 0 || conflicts.length > 0) {
   2008            await this._applyPushedResults(syncResultObject, decoded, conflicts, options.strategy);
   2009        }
   2010        return syncResultObject;
   2011    }
   2012    /**
   2013     * Return a copy of the specified record without the local fields.
   2014     *
   2015     * @param  {Object} record  A record with potential local fields.
   2016     * @return {Object}
   2017     */
   2018    cleanLocalFields(record) {
   2019        const localKeys = RECORD_FIELDS_TO_CLEAN.concat(this.localFields);
   2020        return omitKeys(record, localKeys);
   2021    }
   2022    /**
   2023     * Resolves a conflict, updating local record according to proposed
   2024     * resolution — keeping remote record `last_modified` value as a reference for
   2025     * further batch sending.
   2026     *
   2027     * @param  {Object} conflict   The conflict object.
   2028     * @param  {Object} resolution The proposed record.
   2029     * @return {Promise}
   2030     */
   2031    resolve(conflict, resolution) {
   2032        return this.db.execute(transaction => {
   2033            const updated = this._resolveRaw(conflict, resolution);
   2034            transaction.update(updated);
   2035            return { data: updated, permissions: {} };
   2036        });
   2037    }
   2038    /**
   2039     * @private
   2040     */
   2041    _resolveRaw(conflict, resolution) {
   2042        const resolved = Object.assign(Object.assign({}, resolution), {
   2043            // Ensure local record has the latest authoritative timestamp
   2044            last_modified: conflict.remote && conflict.remote.last_modified });
   2045        // If the resolution object is strictly equal to the
   2046        // remote record, then we can mark it as synced locally.
   2047        // Otherwise, mark it as updated (so that the resolution is pushed).
   2048        const synced = deepEqual(resolved, conflict.remote);
   2049        return markStatus(resolved, synced ? "synced" : "updated");
   2050    }
   2051    /**
   2052     * Synchronize remote and local data. The promise will resolve with a
   2053     * {@link SyncResultObject}, though will reject:
   2054     *
   2055     * - if the server is currently backed off;
   2056     * - if the server has been detected flushed.
   2057     *
   2058     * Options:
   2059     * - {Object} headers: HTTP headers to attach to outgoing requests.
   2060     * - {String} expectedTimestamp: A timestamp to use as a "cache busting" query parameter.
   2061     * - {Number} retry: Number of retries when server fails to process the request (default: 1).
   2062     * - {Collection.strategy} strategy: See {@link Collection.strategy}.
   2063     * - {Boolean} ignoreBackoff: Force synchronization even if server is currently
   2064     *   backed off.
   2065     * - {String} bucket: The remove bucket id to use (default: null)
   2066     * - {String} collection: The remove collection id to use (default: null)
   2067     * - {String} remote The remote Kinto server endpoint to use (default: null).
   2068     *
   2069     * @param  {Object} options Options.
   2070     * @return {Promise}
   2071     * @throws {Error} If an invalid remote option is passed.
   2072     */
   2073    async sync(options = {
   2074        strategy: Collection.strategy.MANUAL,
   2075        headers: {},
   2076        retry: 1,
   2077        ignoreBackoff: false,
   2078        bucket: null,
   2079        collection: null,
   2080        remote: null,
   2081        expectedTimestamp: null,
   2082    }) {
   2083        options = Object.assign(Object.assign({}, options), { bucket: options.bucket || this.bucket, collection: options.collection || this.name });
   2084        const previousRemote = this.api.remote;
   2085        if (options.remote) {
   2086            // Note: setting the remote ensures it's valid, throws when invalid.
   2087            this.api.remote = options.remote;
   2088        }
   2089        if (!options.ignoreBackoff && this.api.backoff > 0) {
   2090            const seconds = Math.ceil(this.api.backoff / 1000);
   2091            return Promise.reject(new Error(`Server is asking clients to back off; retry in ${seconds}s or use the ignoreBackoff option.`));
   2092        }
   2093        const client = this.api
   2094            .bucket(options.bucket)
   2095            .collection(options.collection);
   2096        const result = new SyncResultObject();
   2097        try {
   2098            // Fetch collection metadata.
   2099            await this.pullMetadata(client, options);
   2100            // Fetch last changes from the server.
   2101            await this.pullChanges(client, result, options);
   2102            const { lastModified } = result;
   2103            if (options.strategy != Collection.strategy.PULL_ONLY) {
   2104                // Fetch local changes
   2105                const toSync = await this.gatherLocalChanges();
   2106                // Publish local changes and pull local resolutions
   2107                await this.pushChanges(client, toSync, result, options);
   2108                // Publish local resolution of push conflicts to server (on CLIENT_WINS)
   2109                const resolvedUnsynced = result.resolved.filter(r => r._status !== "synced");
   2110                if (resolvedUnsynced.length > 0) {
   2111                    const resolvedEncoded = await Promise.all(resolvedUnsynced.map(resolution => {
   2112                        let record = resolution.accepted;
   2113                        if (record === null) {
   2114                            record = { id: resolution.id, _status: resolution._status };
   2115                        }
   2116                        return this._encodeRecord("remote", record);
   2117                    }));
   2118                    await this.pushChanges(client, resolvedEncoded, result, options);
   2119                }
   2120                // Perform a last pull to catch changes that occured after the last pull,
   2121                // while local changes were pushed. Do not do it nothing was pushed.
   2122                if (result.published.length > 0) {
   2123                    // Avoid redownloading our own changes during the last pull.
   2124                    const pullOpts = Object.assign(Object.assign({}, options), { lastModified, exclude: result.published });
   2125                    await this.pullChanges(client, result, pullOpts);
   2126                }
   2127            }
   2128            // Don't persist lastModified value if any conflict or error occured
   2129            if (result.ok) {
   2130                // No conflict occured, persist collection's lastModified value
   2131                this._lastModified = await this.db.saveLastModified(result.lastModified);
   2132            }
   2133        }
   2134        catch (e) {
   2135            this.events.emit("sync:error", Object.assign(Object.assign({}, options), { error: e }));
   2136            throw e;
   2137        }
   2138        finally {
   2139            // Ensure API default remote is reverted if a custom one's been used
   2140            this.api.remote = previousRemote;
   2141        }
   2142        this.events.emit("sync:success", Object.assign(Object.assign({}, options), { result }));
   2143        return result;
   2144    }
   2145    /**
   2146     * Load a list of records already synced with the remote server.
   2147     *
   2148     * The local records which are unsynced or whose timestamp is either missing
   2149     * or superior to those being loaded will be ignored.
   2150     *
   2151     * @deprecated Use {@link importBulk} instead.
   2152     * @param  {Array} records The previously exported list of records to load.
   2153     * @return {Promise} with the effectively imported records.
   2154     */
   2155    async loadDump(records) {
   2156        return this.importBulk(records);
   2157    }
   2158    /**
   2159     * Load a list of records already synced with the remote server.
   2160     *
   2161     * The local records which are unsynced or whose timestamp is either missing
   2162     * or superior to those being loaded will be ignored.
   2163     *
   2164     * @param  {Array} records The previously exported list of records to load.
   2165     * @return {Promise} with the effectively imported records.
   2166     */
   2167    async importBulk(records) {
   2168        if (!Array.isArray(records)) {
   2169            throw new Error("Records is not an array.");
   2170        }
   2171        for (const record of records) {
   2172            if (!Object.prototype.hasOwnProperty.call(record, "id") ||
   2173                !this.idSchema.validate(record.id)) {
   2174                throw new Error("Record has invalid ID: " + JSON.stringify(record));
   2175            }
   2176            if (!record.last_modified) {
   2177                throw new Error("Record has no last_modified value: " + JSON.stringify(record));
   2178            }
   2179        }
   2180        // Fetch all existing records from local database,
   2181        // and skip those who are newer or not marked as synced.
   2182        // XXX filter by status / ids in records
   2183        const { data } = await this.list({}, { includeDeleted: true });
   2184        const existingById = data.reduce((acc, record) => {
   2185            acc[record.id] = record;
   2186            return acc;
   2187        }, {});
   2188        const newRecords = records.filter(record => {
   2189            const localRecord = existingById[record.id];
   2190            const shouldKeep =
   2191            // No local record with this id.
   2192            localRecord === undefined ||
   2193                // Or local record is synced
   2194                (localRecord._status === "synced" &&
   2195                    // And was synced from server
   2196                    localRecord.last_modified !== undefined &&
   2197                    // And is older than imported one.
   2198                    record.last_modified > localRecord.last_modified);
   2199            return shouldKeep;
   2200        });
   2201        return await this.db.importBulk(newRecords.map(markSynced));
   2202    }
   2203    async pullMetadata(client, options = {}) {
   2204        const { expectedTimestamp, headers } = options;
   2205        const query = expectedTimestamp
   2206            ? { query: { _expected: expectedTimestamp } }
   2207            : undefined;
   2208        const metadata = await client.getData(Object.assign(Object.assign({}, query), { headers }));
   2209        return this.db.saveMetadata(metadata);
   2210    }
   2211    async metadata() {
   2212        return this.db.getMetadata();
   2213    }
   2214 }
   2215 /**
   2216 * A Collection-oriented wrapper for an adapter's transaction.
   2217 *
   2218 * This defines the high-level functions available on a collection.
   2219 * The collection itself offers functions of the same name. These will
   2220 * perform just one operation in its own transaction.
   2221 */
   2222 class CollectionTransaction {
   2223    constructor(collection, adapterTransaction) {
   2224        this.collection = collection;
   2225        this.adapterTransaction = adapterTransaction;
   2226        this._events = [];
   2227    }
   2228    _queueEvent(action, payload) {
   2229        this._events.push({ action, payload });
   2230    }
   2231    /**
   2232     * Emit queued events, to be called once every transaction operations have
   2233     * been executed successfully.
   2234     */
   2235    emitEvents() {
   2236        for (const { action, payload } of this._events) {
   2237            this.collection.events.emit(action, payload);
   2238        }
   2239        if (this._events.length > 0) {
   2240            const targets = this._events.map(({ action, payload }) => (Object.assign({ action }, payload)));
   2241            this.collection.events.emit("change", { targets });
   2242        }
   2243        this._events = [];
   2244    }
   2245    /**
   2246     * Retrieve a record by its id from the local database, or
   2247     * undefined if none exists.
   2248     *
   2249     * This will also return virtually deleted records.
   2250     *
   2251     * @param  {String} id
   2252     * @return {Object}
   2253     */
   2254    getAny(id) {
   2255        const record = this.adapterTransaction.get(id);
   2256        return { data: record, permissions: {} };
   2257    }
   2258    /**
   2259     * Retrieve a record by its id from the local database.
   2260     *
   2261     * Options:
   2262     * - {Boolean} includeDeleted: Include virtually deleted records.
   2263     *
   2264     * @param  {String} id
   2265     * @param  {Object} options
   2266     * @return {Object}
   2267     */
   2268    get(id, options = { includeDeleted: false }) {
   2269        const res = this.getAny(id);
   2270        if (!res.data ||
   2271            (!options.includeDeleted && res.data._status === "deleted")) {
   2272            throw new Error(`Record with id=${id} not found.`);
   2273        }
   2274        return res;
   2275    }
   2276    /**
   2277     * Deletes a record from the local database.
   2278     *
   2279     * Options:
   2280     * - {Boolean} virtual: When set to `true`, doesn't actually delete the record,
   2281     *   update its `_status` attribute to `deleted` instead (default: true)
   2282     *
   2283     * @param  {String} id       The record's Id.
   2284     * @param  {Object} options  The options object.
   2285     * @return {Object}
   2286     */
   2287    delete(id, options = { virtual: true }) {
   2288        // Ensure the record actually exists.
   2289        const existing = this.adapterTransaction.get(id);
   2290        const alreadyDeleted = existing && existing._status == "deleted";
   2291        if (!existing || (alreadyDeleted && options.virtual)) {
   2292            throw new Error(`Record with id=${id} not found.`);
   2293        }
   2294        // Virtual updates status.
   2295        if (options.virtual) {
   2296            this.adapterTransaction.update(markDeleted(existing));
   2297        }
   2298        else {
   2299            // Delete for real.
   2300            this.adapterTransaction.delete(id);
   2301        }
   2302        this._queueEvent("delete", { data: existing });
   2303        return { data: existing, permissions: {} };
   2304    }
   2305    /**
   2306     * Soft delete all records from the local database.
   2307     *
   2308     * @param  {Array} ids        Array of non-deleted Record Ids.
   2309     * @return {Object}
   2310     */
   2311    deleteAll(ids) {
   2312        const existingRecords = [];
   2313        ids.forEach(id => {
   2314            existingRecords.push(this.adapterTransaction.get(id));
   2315            this.delete(id);
   2316        });
   2317        this._queueEvent("deleteAll", { data: existingRecords });
   2318        return { data: existingRecords, permissions: {} };
   2319    }
   2320    /**
   2321     * Deletes a record from the local database, if any exists.
   2322     * Otherwise, do nothing.
   2323     *
   2324     * @param  {String} id       The record's Id.
   2325     * @return {Object}
   2326     */
   2327    deleteAny(id) {
   2328        const existing = this.adapterTransaction.get(id);
   2329        if (existing) {
   2330            this.adapterTransaction.update(markDeleted(existing));
   2331            this._queueEvent("delete", { data: existing });
   2332        }
   2333        return { data: Object.assign({ id }, existing), deleted: !!existing, permissions: {} };
   2334    }
   2335    /**
   2336     * Adds a record to the local database, asserting that none
   2337     * already exist with this ID.
   2338     *
   2339     * @param  {Object} record, which must contain an ID
   2340     * @return {Object}
   2341     */
   2342    create(record) {
   2343        if (typeof record !== "object") {
   2344            throw new Error("Record is not an object.");
   2345        }
   2346        if (!Object.prototype.hasOwnProperty.call(record, "id")) {
   2347            throw new Error("Cannot create a record missing id");
   2348        }
   2349        if (!this.collection.idSchema.validate(record.id)) {
   2350            throw new Error(`Invalid Id: ${record.id}`);
   2351        }
   2352        this.adapterTransaction.create(record);
   2353        this._queueEvent("create", { data: record });
   2354        return { data: record, permissions: {} };
   2355    }
   2356    /**
   2357     * Updates a record from the local database.
   2358     *
   2359     * Options:
   2360     * - {Boolean} synced: Sets record status to "synced" (default: false)
   2361     * - {Boolean} patch:  Extends the existing record instead of overwriting it
   2362     *   (default: false)
   2363     *
   2364     * @param  {Object} record
   2365     * @param  {Object} options
   2366     * @return {Object}
   2367     */
   2368    update(record, options = { synced: false, patch: false }) {
   2369        if (typeof record !== "object") {
   2370            throw new Error("Record is not an object.");
   2371        }
   2372        if (!Object.prototype.hasOwnProperty.call(record, "id")) {
   2373            throw new Error("Cannot update a record missing id.");
   2374        }
   2375        if (!this.collection.idSchema.validate(record.id)) {
   2376            throw new Error(`Invalid Id: ${record.id}`);
   2377        }
   2378        const oldRecord = this.adapterTransaction.get(record.id);
   2379        if (!oldRecord) {
   2380            throw new Error(`Record with id=${record.id} not found.`);
   2381        }
   2382        const newRecord = options.patch ? Object.assign(Object.assign({}, oldRecord), record) : record;
   2383        const updated = this._updateRaw(oldRecord, newRecord, options);
   2384        this.adapterTransaction.update(updated);
   2385        this._queueEvent("update", { data: updated, oldRecord });
   2386        return { data: updated, oldRecord, permissions: {} };
   2387    }
   2388    /**
   2389     * Lower-level primitive for updating a record while respecting
   2390     * _status and last_modified.
   2391     *
   2392     * @param  {Object} oldRecord: the record retrieved from the DB
   2393     * @param  {Object} newRecord: the record to replace it with
   2394     * @return {Object}
   2395     */
   2396    _updateRaw(oldRecord, newRecord, { synced = false } = {}) {
   2397        const updated = Object.assign({}, newRecord);
   2398        // Make sure to never loose the existing timestamp.
   2399        if (oldRecord && oldRecord.last_modified && !updated.last_modified) {
   2400            updated.last_modified = oldRecord.last_modified;
   2401        }
   2402        // If only local fields have changed, then keep record as synced.
   2403        // If status is created, keep record as created.
   2404        // If status is deleted, mark as updated.
   2405        const isIdentical = oldRecord &&
   2406            recordsEqual(oldRecord, updated, this.collection.localFields);
   2407        const keepSynced = isIdentical && oldRecord._status == "synced";
   2408        const neverSynced = !oldRecord || (oldRecord && oldRecord._status == "created");
   2409        const newStatus = keepSynced || synced ? "synced" : neverSynced ? "created" : "updated";
   2410        return markStatus(updated, newStatus);
   2411    }
   2412    /**
   2413     * Upsert a record into the local database.
   2414     *
   2415     * This record must have an ID.
   2416     *
   2417     * If a record with this ID already exists, it will be replaced.
   2418     * Otherwise, this record will be inserted.
   2419     *
   2420     * @param  {Object} record
   2421     * @return {Object}
   2422     */
   2423    upsert(record) {
   2424        if (typeof record !== "object") {
   2425            throw new Error("Record is not an object.");
   2426        }
   2427        if (!Object.prototype.hasOwnProperty.call(record, "id")) {
   2428            throw new Error("Cannot update a record missing id.");
   2429        }
   2430        if (!this.collection.idSchema.validate(record.id)) {
   2431            throw new Error(`Invalid Id: ${record.id}`);
   2432        }
   2433        let oldRecord = this.adapterTransaction.get(record.id);
   2434        const updated = this._updateRaw(oldRecord, record);
   2435        this.adapterTransaction.update(updated);
   2436        // Don't return deleted records -- pretend they are gone
   2437        if (oldRecord && oldRecord._status == "deleted") {
   2438            oldRecord = undefined;
   2439        }
   2440        if (oldRecord) {
   2441            this._queueEvent("update", { data: updated, oldRecord });
   2442        }
   2443        else {
   2444            this._queueEvent("create", { data: updated });
   2445        }
   2446        return { data: updated, oldRecord, permissions: {} };
   2447    }
   2448 }
   2449 
   2450 const DEFAULT_BUCKET_NAME = "default";
   2451 const DEFAULT_REMOTE = "http://localhost:8888/v1";
   2452 const DEFAULT_RETRY = 1;
   2453 /**
   2454 * KintoBase class.
   2455 */
   2456 class KintoBase {
   2457    /**
   2458     * Provides a public access to the base adapter class. Users can create a
   2459     * custom DB adapter by extending {@link BaseAdapter}.
   2460     *
   2461     * @type {Object}
   2462     */
   2463    static get adapters() {
   2464        return {
   2465            BaseAdapter: BaseAdapter,
   2466        };
   2467    }
   2468    /**
   2469     * Synchronization strategies. Available strategies are:
   2470     *
   2471     * - `MANUAL`: Conflicts will be reported in a dedicated array.
   2472     * - `SERVER_WINS`: Conflicts are resolved using remote data.
   2473     * - `CLIENT_WINS`: Conflicts are resolved using local data.
   2474     *
   2475     * @type {Object}
   2476     */
   2477    static get syncStrategy() {
   2478        return Collection.strategy;
   2479    }
   2480    /**
   2481     * Constructor.
   2482     *
   2483     * Options:
   2484     * - `{String}`       `remote`         The server URL to use.
   2485     * - `{String}`       `bucket`         The collection bucket name.
   2486     * - `{EventEmitter}` `events`         Events handler.
   2487     * - `{BaseAdapter}`  `adapter`        The base DB adapter class.
   2488     * - `{Object}`       `adapterOptions` Options given to the adapter.
   2489     * - `{Object}`       `headers`        The HTTP headers to use.
   2490     * - `{Object}`       `retry`          Number of retries when the server fails to process the request (default: `1`)
   2491     * - `{String}`       `requestMode`    The HTTP CORS mode to use.
   2492     * - `{Number}`       `timeout`        The requests timeout in ms (default: `5000`).
   2493     *
   2494     * @param  {Object} options The options object.
   2495     */
   2496    constructor(options = {}) {
   2497        const defaults = {
   2498            bucket: DEFAULT_BUCKET_NAME,
   2499            remote: DEFAULT_REMOTE,
   2500            retry: DEFAULT_RETRY,
   2501        };
   2502        this._options = Object.assign(Object.assign({}, defaults), options);
   2503        if (!this._options.adapter) {
   2504            throw new Error("No adapter provided");
   2505        }
   2506        this._api = null;
   2507        /**
   2508         * The event emitter instance.
   2509         * @type {EventEmitter}
   2510         */
   2511        this.events = this._options.events;
   2512    }
   2513    /**
   2514     * The kinto HTTP client instance.
   2515     * @type {KintoClient}
   2516     */
   2517    get api() {
   2518        const { events, headers, remote, requestMode, retry, timeout, } = this._options;
   2519        if (!this._api) {
   2520            this._api = new this.ApiClass(remote, {
   2521                events,
   2522                headers,
   2523                requestMode,
   2524                retry,
   2525                timeout,
   2526            });
   2527        }
   2528        return this._api;
   2529    }
   2530    /**
   2531     * Creates a {@link Collection} instance. The second (optional) parameter
   2532     * will set collection-level options like e.g. `remoteTransformers`.
   2533     *
   2534     * @param  {String} collName The collection name.
   2535     * @param  {Object} [options={}]                 Extra options or override client's options.
   2536     * @param  {Object} [options.idSchema]           IdSchema instance (default: UUID)
   2537     * @param  {Object} [options.remoteTransformers] Array<RemoteTransformer> (default: `[]`])
   2538     * @param  {Object} [options.hooks]              Array<Hook> (default: `[]`])
   2539     * @param  {Object} [options.localFields]        Array<Field> (default: `[]`])
   2540     * @return {Collection}
   2541     */
   2542    collection(collName, options = {}) {
   2543        if (!collName) {
   2544            throw new Error("missing collection name");
   2545        }
   2546        const { bucket, events, adapter, adapterOptions } = Object.assign(Object.assign({}, this._options), options);
   2547        const { idSchema, remoteTransformers, hooks, localFields } = options;
   2548        return new Collection(bucket, collName, this, {
   2549            events,
   2550            adapter,
   2551            adapterOptions,
   2552            idSchema,
   2553            remoteTransformers,
   2554            hooks,
   2555            localFields,
   2556        });
   2557    }
   2558 }
   2559 
   2560 /*
   2561 *
   2562 * Licensed under the Apache License, Version 2.0 (the "License");
   2563 * you may not use this file except in compliance with the License.
   2564 * You may obtain a copy of the License at
   2565 *
   2566 *     http://www.apache.org/licenses/LICENSE-2.0
   2567 *
   2568 * Unless required by applicable law or agreed to in writing, software
   2569 * distributed under the License is distributed on an "AS IS" BASIS,
   2570 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   2571 * See the License for the specific language governing permissions and
   2572 * limitations under the License.
   2573 */
   2574 const lazy = {};
   2575 ChromeUtils.defineESModuleGetters(lazy, {
   2576    EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
   2577    // Use standalone kinto-http module landed in FFx.
   2578    KintoHttpClient: "resource://services-common/kinto-http-client.sys.mjs"
   2579 });
   2580 export class Kinto extends KintoBase {
   2581    static get adapters() {
   2582        return {
   2583            BaseAdapter,
   2584            IDB,
   2585        };
   2586    }
   2587    get ApiClass() {
   2588        return lazy.KintoHttpClient;
   2589    }
   2590    constructor(options = {}) {
   2591        const events = {};
   2592        lazy.EventEmitter.decorate(events);
   2593        const defaults = {
   2594            adapter: IDB,
   2595            events,
   2596        };
   2597        super(Object.assign(Object.assign({}, defaults), options));
   2598    }
   2599    collection(collName, options = {}) {
   2600        const idSchema = {
   2601            validate(id) {
   2602                return typeof id == "string" && RE_RECORD_ID.test(id);
   2603            },
   2604            generate() {
   2605                return Services.uuid.generateUUID()
   2606                    .toString()
   2607                    .replace(/[{}]/g, "");
   2608            },
   2609        };
   2610        return super.collection(collName, Object.assign({ idSchema }, options));
   2611    }
   2612 }