UNPKG

kinto

Version:

An Offline-First JavaScript client for Kinto.

1,572 lines 61.5 kB
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.CollectionTransaction = exports.ServerWasFlushedError = exports.SyncResultObject = void 0;
exports.recordsEqual = recordsEqual;
exports.createKeyValueStoreIdSchema = createKeyValueStoreIdSchema;
const base_1 = __importDefault(require("./adapters/base"));
const IDB_1 = __importDefault(require("./adapters/IDB"));
const utils_1 = require("./utils");
const uuid_1 = require("uuid");
const utils_2 = require("./utils");
const RECORD_FIELDS_TO_CLEAN = ["_status"];
const AVAILABLE_HOOKS = ["incoming-changes"];
const IMPORT_CHUNK_SIZE = 200;
/**
 * Compare two records omitting local fields and synchronization
 * attributes (like _status and last_modified)
 * @param {Object} a    A record to compare.
 * @param {Object} b    A record to compare.
 * @param {Array} localFields Additional fields to ignore during the comparison
 * @return {boolean}
 */
function recordsEqual(a, b, localFields = []) {
    const fieldsToClean = [
        ...RECORD_FIELDS_TO_CLEAN,
        "last_modified",
        ...localFields,
    ];
    const cleanLocal = (r) => (0, utils_2.omitKeys)(r, fieldsToClean);
    return (0, utils_1.deepEqual)(cleanLocal(a), cleanLocal(b));
}
/**
 * Synchronization result object.
 */
class SyncResultObject {
    constructor() {
        /**
         * Current synchronization result status; becomes `false` when conflicts or
         * errors are registered.
         * @type {Boolean}
         */
        this.lastModified = null;
        this._lists = {
            errors: [],
            created: [],
            updated: [],
            deleted: [],
            published: [],
            conflicts: [],
            skipped: [],
            resolved: [],
            void: [],
        };
        this._cached = {};
    }
    /**
     * Adds entries for a given result type.
     *
     * @param {String} type    The result type.
     * @param {Array}  entries The result entries.
     * @return {SyncResultObject}
     */
    add(type, entries) {
        if (!Array.isArray(this._lists[type])) {
            console.warn(`Unknown type "${type}"`);
            return this;
        }
        if (!Array.isArray(entries)) {
            entries = [entries];
        }
        this._lists[type] = [...this._lists[type], ...entries];
        delete this._cached[type];
        return this;
    }
    get ok() {
        return this.errors.length + this.conflicts.length === 0;
    }
    get errors() {
        return this._lists.errors;
    }
    get conflicts() {
        return this._lists.conflicts;
    }
    get skipped() {
        return this._deduplicate("skipped");
    }
    get resolved() {
        return this._deduplicate("resolved");
    }
    get created() {
        return this._deduplicate("created");
    }
    get updated() {
        return this._deduplicate("updated");
    }
    get deleted() {
        return this._deduplicate("deleted");
    }
    get published() {
        return this._deduplicate("published");
    }
    _deduplicate(list) {
        if (!(list in this._cached)) {
            // Deduplicate entries by id. If the values don't have `id` attribute, just
            // keep all.
            const recordsWithoutId = new Set();
            const recordsById = new Map();
            this._lists[list].forEach((record) => {
                if (!record.id) {
                    recordsWithoutId.add(record);
                }
                else {
                    recordsById.set(record.id, record);
                }
            });
            this._cached[list] = Array.from(recordsById.values()).concat(Array.from(recordsWithoutId));
        }
        return this._cached[list];
    }
    /**
     * Reinitializes result entries for a given result type.
     *
     * @param  {String} type The result type.
     * @return {SyncResultObject}
     */
    reset(type) {
        this._lists[type] = [];
        delete this._cached[type];
        return this;
    }
    toObject() {
        // Only used in tests.
        return {
            ok: this.ok,
            lastModified: this.lastModified,
            errors: this.errors,
            created: this.created,
            updated: this.updated,
            deleted: this.deleted,
            skipped: this.skipped,
            published: this.published,
            conflicts: this.conflicts,
            resolved: this.resolved,
        };
    }
}
exports.SyncResultObject = SyncResultObject;
class ServerWasFlushedError extends Error {
    constructor(clientTimestamp, serverTimestamp, message) {
        super(message);
        if (Error.captureStackTrace) {
            Error.captureStackTrace(this, ServerWasFlushedError);
        }
        this.clientTimestamp = clientTimestamp;
        this.serverTimestamp = serverTimestamp;
    }
}
exports.ServerWasFlushedError = ServerWasFlushedError;
function createUUIDSchema() {
    return {
        generate() {
            return (0, uuid_1.v4)();
        },
        validate(id) {
            return typeof id === "string" && utils_2.RE_RECORD_ID.test(id);
        },
    };
}
/**
 * IDSchema for when using kinto.js as a key-value store.
 * Using this IDSchema requires you to set a property as the id.
 * This will be the property used to retrieve this record.
 *
 * @example
 * const exampleCollection = db.collection("example", { idSchema: createKeyValueStoreIdSchema() })
 * await exampleCollection.create({ title: "How to tie a tie", favoriteColor: "blue", id: "user123" }, { useRecordId: true })
 * await exampleCollection.getAny("user123")
 */
function createKeyValueStoreIdSchema() {
    return {
        generate() {
            throw new Error("createKeyValueStoreIdSchema() does not generate an id");
        },
        validate() {
            return true;
        },
    };
}
function markStatus(record, status) {
    return { ...record, _status: status };
}
function markDeleted(record) {
    return markStatus(record, "deleted");
}
function markSynced(record) {
    return markStatus(record, "synced");
}
/**
 * Import a remote change into the local database.
 *
 * @param  {IDBTransactionProxy} transaction The transaction handler.
 * @param  {Object}              remote      The remote change object to import.
 * @param  {Array<String>}       localFields The list of fields that remain local.
 * @param  {String}              strategy    The {@link Collection.strategy}.
 * @return {Object}
 */
function importChange(transaction, remote, localFields, strategy) {
    const local = transaction.get(remote.id);
    if (!local) {
        // Not found locally but remote change is marked as deleted; skip to
        // avoid recreation.
        if (remote.deleted) {
            return { type: "skipped", data: remote };
        }
        const synced = markSynced(remote);
        transaction.create(synced);
        return { type: "created", data: synced };
    }
    // Apply remote changes on local record.
    const synced = { ...local, ...markSynced(remote) };
    // With pull only, we don't need to compare records since we override them.
    if (strategy === Collection.strategy.PULL_ONLY) {
        if (remote.deleted) {
            transaction.delete(remote.id);
            return { type: "deleted", data: local };
        }
        transaction.update(synced);
        return { type: "updated", data: { old: local, new: synced } };
    }
    // With other sync strategies, we detect conflicts,
    // by comparing local and remote, ignoring local fields.
    const isIdentical = recordsEqual(local, remote, localFields);
    // Detect or ignore conflicts if record has also been modified locally.
    if (local._status !== "synced") {
        // Locally deleted, unsynced: scheduled for remote deletion.
        if (local._status === "deleted") {
            return { type: "skipped", data: local };
        }
        if (isIdentical) {
            // If records are identical, import anyway, so we bump the
            // local last_modified value from the server and set record
            // status to "synced".
            transaction.update(synced);
            return { type: "updated", data: { old: local, new: synced } };
        }
        if (local.last_modified !== undefined &&
            local.last_modified === remote.last_modified) {
            // If our local version has the same last_modified as the remote
            // one, this represents an object that corresponds to a resolved
            // conflict. Our local version represents the final output, so
            // we keep that one. (No transaction operation to do.)
            // But if our last_modified is undefined,
            // that means we've created the same object locally as one on
            // the server, which *must* be a conflict.
            return { type: "void" };
        }
        return {
            type: "conflicts",
            data: { type: "incoming", local, remote },
        };
    }
    // Local record was synced.
    if (remote.deleted) {
        transaction.delete(remote.id);
        return { type: "deleted", data: local };
    }
    // Import locally.
    transaction.update(synced);
    // if identical, simply exclude it from all SyncResultObject lists
    if (isIdentical) {
        return { type: "void" };
    }
    return { type: "updated", data: { old: local, new: synced } };
}
/**
 * Abstracts a collection of records stored in the local database, providing
 * CRUD operations and synchronization helpers.
 */
class Collection {
    constructor(bucket, name, kinto, options = {}) {
        this._bucket = bucket;
        this._name = name;
        this._lastModified = null;
        const db = options.adapter
            ? options.adapter(`${bucket}/${name}`, options.adapterOptions)
            : new IDB_1.default(`${bucket}/${name}`, options.adapterOptions);
        if (!(db instanceof base_1.default)) {
            throw new Error("Unsupported adapter.");
        }
        // public properties
        this.db = db;
        /**
         * The KintoBase instance.
         * @type {KintoBase}
         */
        this.kinto = kinto;
        /**
         * The event emitter instance.
         * @type {EventEmitter}
         */
        this.events = options.events;
        /**
         * The IdSchema instance.
         * @type {Object}
         */
        this.idSchema = this._validateIdSchema(options.idSchema);
        /**
         * The list of remote transformers.
         * @type {Array}
         */
        this.remoteTransformers = this._validateRemoteTransformers(options.remoteTransformers);
        /**
         * The list of hooks.
         * @type {Object}
         */
        this.hooks = this._validateHooks(options.hooks);
        /**
         * The list of fields names that will remain local.
         * @type {Array}
         */
        this.localFields = options.localFields || [];
    }
    /**
     * The HTTP client.
     * @type {KintoClient}
     */
    get api() {
        return this.kinto.api;
    }
    /**
     * The collection name.
     * @type {String}
     */
    get name() {
        return this._name;
    }
    /**
     * The bucket name.
     * @type {String}
     */
    get bucket() {
        return this._bucket;
    }
    /**
     * The last modified timestamp.
     * @type {Number}
     */
    get lastModified() {
        return this._lastModified;
    }
    /**
     * Synchronization strategies. Available strategies are:
     *
     * - `MANUAL`: Conflicts will be reported in a dedicated array.
     * - `SERVER_WINS`: Conflicts are resolved using remote data.
     * - `CLIENT_WINS`: Conflicts are resolved using local data.
     *
     * @type {Object}
     */
    static get strategy() {
        return {
            CLIENT_WINS: "client_wins",
            SERVER_WINS: "server_wins",
            PULL_ONLY: "pull_only",
            MANUAL: "manual",
        };
    }
    /**
     * Validates an idSchema.
     *
     * @param  {Object|undefined} idSchema
     * @return {Object}
     */
    _validateIdSchema(idSchema) {
        if (typeof idSchema === "undefined") {
            return createUUIDSchema();
        }
        if (typeof idSchema !== "object") {
            throw new Error("idSchema must be an object.");
        }
        else if (typeof idSchema.generate !== "function") {
            throw new Error("idSchema must provide a generate function.");
        }
        else if (typeof idSchema.validate !== "function") {
            throw new Error("idSchema must provide a validate function.");
        }
        return idSchema;
    }
    /**
     * Validates a list of remote transformers.
     *
     * @param  {Array|undefined} remoteTransformers
     * @return {Array}
     */
    _validateRemoteTransformers(remoteTransformers) {
        if (typeof remoteTransformers === "undefined") {
            return [];
        }
        if (!Array.isArray(remoteTransformers)) {
            throw new Error("remoteTransformers should be an array.");
        }
        return remoteTransformers.map((transformer) => {
            if (typeof transformer !== "object") {
                throw new Error("A transformer must be an object.");
            }
            else if (typeof transformer.encode !== "function") {
                throw new Error("A transformer must provide an encode function.");
            }
            else if (typeof transformer.decode !== "function") {
                throw new Error("A transformer must provide a decode function.");
            }
            return transformer;
        });
    }
    /**
     * Validate the passed hook is correct.
     *
     * @param {Array|undefined} hook.
     * @return {Array}
     **/
    _validateHook(hook) {
        if (!Array.isArray(hook)) {
            throw new Error("A hook definition should be an array of functions.");
        }
        return hook.map((fn) => {
            if (typeof fn !== "function") {
                throw new Error("A hook definition should be an array of functions.");
            }
            return fn;
        });
    }
    /**
     * Validates a list of hooks.
     *
     * @param  {Object|undefined} hooks
     * @return {Object}
     */
    _validateHooks(hooks) {
        if (typeof hooks === "undefined") {
            return {};
        }
        if (Array.isArray(hooks)) {
            throw new Error("hooks should be an object, not an array.");
        }
        if (typeof hooks !== "object") {
            throw new Error("hooks should be an object.");
        }
        const validatedHooks = {};
        for (const hook in hooks) {
            if (!AVAILABLE_HOOKS.includes(hook)) {
                throw new Error("The hook should be one of " + AVAILABLE_HOOKS.join(", "));
            }
            validatedHooks[hook] = this._validateHook(hooks[hook]);
        }
        return validatedHooks;
    }
    /**
     * Deletes every records in the current collection and marks the collection as
     * never synced.
     *
     * @return {Promise}
     */
    async clear() {
        await this.db.clear();
        await this.db.saveMetadata(null);
        await this.db.saveLastModified(null);
        return { data: [], permissions: {} };
    }
    /**
     * Encodes a record.
     *
     * @param  {String} type   Either "remote" or "local".
     * @param  {Object} record The record object to encode.
     * @return {Promise}
     */
    _encodeRecord(type, record) {
        const transformers = type === "remote" ? this.remoteTransformers : [];
        if (!transformers.length) {
            return Promise.resolve(record);
        }
        return (0, utils_1.waterfall)(transformers.map((transformer) => {
            return (record) => transformer.encode(record);
        }), record);
    }
    /**
     * Decodes a record.
     *
     * @param  {String} type   Either "remote" or "local".
     * @param  {Object} record The record object to decode.
     * @return {Promise}
     */
    _decodeRecord(type, record) {
        const transformers = type === "remote" ? this.remoteTransformers : [];
        if (!transformers.length) {
            return Promise.resolve(record);
        }
        return (0, utils_1.waterfall)(transformers.reverse().map((transformer) => {
            return (record) => transformer.decode(record);
        }), record);
    }
    /**
     * Adds a record to the local database, asserting that none
     * already exist with this ID.
     *
     * Note: If either the `useRecordId` or `synced` options are true, then the
     * record object must contain the id field to be validated. If none of these
     * options are true, an id is generated using the current IdSchema; in this
     * case, the record passed must not have an id.
     *
     * Options:
     * - {Boolean} synced       Sets record status to "synced" (default: `false`).
     * - {Boolean} useRecordId  Forces the `id` field from the record to be used,
     *                          instead of one that is generated automatically
     *                          (default: `false`).
     *
     * @param  {Object} record
     * @param  {Object} options
     * @return {Promise}
     */
    create(record, options = {
        useRecordId: false,
        synced: false,
    }) {
        // Validate the record and its ID (if any), even though this
        // validation is also done in the CollectionTransaction method,
        // because we need to pass the ID to preloadIds.
        const reject = (msg) => Promise.reject(new Error(msg));
        if (typeof record !== "object") {
            return reject("Record is not an object.");
        }
        if ((options.synced || options.useRecordId) &&
            !Object.prototype.hasOwnProperty.call(record, "id")) {
            return reject("Missing required Id; synced and useRecordId options require one");
        }
        if (!options.synced &&
            !options.useRecordId &&
            Object.prototype.hasOwnProperty.call(record, "id")) {
            return reject("Extraneous Id; can't create a record having one set.");
        }
        const newRecord = {
            ...record,
            id: options.synced || options.useRecordId
                ? record.id
                : this.idSchema.generate(record),
            _status: options.synced ? "synced" : "created",
        };
        if (!this.idSchema.validate(newRecord.id)) {
            return reject(`Invalid Id: ${newRecord.id}`);
        }
        return this.execute((txn) => txn.create(newRecord), {
            preloadIds: [newRecord.id],
        }).catch((err) => {
            if (options.useRecordId) {
                throw new Error("Couldn't create record. It may have been virtually deleted.");
            }
            throw err;
        });
    }
    /**
     * Like {@link CollectionTransaction#update}, but wrapped in its own transaction.
     *
     * Options:
     * - {Boolean} synced: Sets record status to "synced" (default: false)
     * - {Boolean} patch:  Extends the existing record instead of overwriting it
     *   (default: false)
     *
     * @param  {Object} record
     * @param  {Object} options
     * @return {Promise}
     */
    update(record, options = {
        synced: false,
        patch: false,
    }) {
        // Validate the record and its ID, even though this validation is
        // also done in the CollectionTransaction method, because we need
        // to pass the ID to preloadIds.
        if (typeof record !== "object") {
            return Promise.reject(new Error("Record is not an object."));
        }
        if (!Object.prototype.hasOwnProperty.call(record, "id")) {
            return Promise.reject(new Error("Cannot update a record missing id."));
        }
        if (!this.idSchema.validate(record.id)) {
            return Promise.reject(new Error(`Invalid Id: ${record.id}`));
        }
        return this.execute((txn) => txn.update(record, {
            synced: options.synced ?? false,
            patch: options.patch ?? false,
        }), {
            preloadIds: [record.id],
        });
    }
    /**
     * Like {@link CollectionTransaction#upsert}, but wrapped in its own transaction.
     *
     * @param  {Object} record
     * @return {Promise}
     */
    upsert(record) {
        // Validate the record and its ID, even though this validation is
        // also done in the CollectionTransaction method, because we need
        // to pass the ID to preloadIds.
        if (typeof record !== "object") {
            return Promise.reject(new Error("Record is not an object."));
        }
        if (!Object.prototype.hasOwnProperty.call(record, "id")) {
            return Promise.reject(new Error("Cannot update a record missing id."));
        }
        if (!this.idSchema.validate(record.id)) {
            return Promise.reject(new Error(`Invalid Id: ${record.id}`));
        }
        return this.execute((txn) => txn.upsert(record), {
            preloadIds: [record.id],
        });
    }
    /**
     * Like {@link CollectionTransaction#get}, but wrapped in its own transaction.
     *
     * Options:
     * - {Boolean} includeDeleted: Include virtually deleted records.
     *
     * @param  {String} id
     * @param  {Object} options
     * @return {Promise}
     */
    get(id, options = { includeDeleted: false }) {
        return this.execute((txn) => txn.get(id, options), { preloadIds: [id] });
    }
    /**
     * Like {@link CollectionTransaction#getAny}, but wrapped in its own transaction.
     *
     * @param  {String} id
     * @return {Promise}
     */
    getAny(id) {
        return this.execute((txn) => txn.getAny(id), { preloadIds: [id] });
    }
    /**
     * Same as {@link Collection#delete}, but wrapped in its own transaction.
     *
     * Options:
     * - {Boolean} virtual: When set to `true`, doesn't actually delete the record,
     *   update its `_status` attribute to `deleted` instead (default: true)
     *
     * @param  {String} id       The record's Id.
     * @param  {Object} options  The options object.
     * @return {Promise}
     */
    delete(id, options = { virtual: true }) {
        return this.execute((transaction) => {
            return transaction.delete(id, options);
        }, { preloadIds: [id] });
    }
    /**
     * Same as {@link Collection#deleteAll}, but wrapped in its own transaction, execulding the parameter.
     *
     * @return {Promise}
     */
    async deleteAll() {
        const { data } = await this.list({}, { includeDeleted: false });
        const recordIds = data.map((record) => record.id);
        return this.execute((transaction) => {
            return transaction.deleteAll(recordIds);
        }, { preloadIds: recordIds });
    }
    /**
     * The same as {@link CollectionTransaction#deleteAny}, but wrapped
     * in its own transaction.
     *
     * @param  {String} id       The record's Id.
     * @return {Promise}
     */
    deleteAny(id) {
        return this.execute((txn) => txn.deleteAny(id), { preloadIds: [id] });
    }
    /**
     * Lists records from the local database.
     *
     * Params:
     * - {Object} filters Filter the results (default: `{}`).
     * - {String} order   The order to apply   (default: `-last_modified`).
     *
     * Options:
     * - {Boolean} includeDeleted: Include virtually deleted records.
     *
     * @param  {Object} params  The filters and order to apply to the results.
     * @param  {Object} options The options object.
     * @return {Promise}
     */
    async list(params = {}, options = { includeDeleted: false }) {
        params = { order: "-last_modified", filters: {}, ...params };
        const results = await this.db.list(params);
        let data = results;
        if (!options.includeDeleted) {
            data = results.filter((record) => record._status !== "deleted");
        }
        return { data, permissions: {} };
    }
    /**
     * Imports remote changes into the local database.
     * This method is in charge of detecting the conflicts, and resolve them
     * according to the specified strategy.
     * @param  {SyncResultObject} syncResultObject The sync result object.
     * @param  {Array}            decodedChanges   The list of changes to import in the local database.
     * @param  {String}           strategy         The {@link Collection.strategy} (default: MANUAL)
     * @return {Promise}
     */
    async importChanges(syncResultObject, decodedChanges, strategy = Collection.strategy.MANUAL) {
        // Retrieve records matching change ids.
        try {
            for (let i = 0; i < decodedChanges.length; i += IMPORT_CHUNK_SIZE) {
                const slice = decodedChanges.slice(i, i + IMPORT_CHUNK_SIZE);
                const { imports, resolved } = await this.db.execute((transaction) => {
                    const imports = slice.map((remote) => {
                        // Store remote change into local database.
                        return importChange(transaction, remote, this.localFields, strategy);
                    });
                    const conflicts = imports.filter((i) => i.type === "conflicts").map((i) => i.data);
                    const resolved = this._handleConflicts(transaction, conflicts, strategy);
                    return { imports, resolved };
                }, { preload: slice.map((record) => record.id) });
                // Lists of created/updated/deleted records
                imports.forEach(({ type, data }) => syncResultObject.add(type, data));
                // Automatically resolved conflicts (if not manual)
                if (resolved.length > 0) {
                    syncResultObject.reset("conflicts").add("resolved", resolved);
                }
            }
        }
        catch (err) {
            const data = {
                type: "incoming",
                message: err.message,
                stack: err.stack,
            };
            // XXX one error of the whole transaction instead of per atomic op
            syncResultObject.add("errors", data);
        }
        return syncResultObject;
    }
    /**
     * Imports the responses of pushed changes into the local database.
     * Basically it stores the timestamp assigned by the server into the local
     * database.
     * @param  {SyncResultObject} syncResultObject The sync result object.
     * @param  {Array}            toApplyLocally   The list of changes to import in the local database.
     * @param  {Array}            conflicts        The list of conflicts that have to be resolved.
     * @param  {String}           strategy         The {@link Collection.strategy}.
     * @return {Promise}
     */
    async _applyPushedResults(syncResultObject, toApplyLocally, conflicts, strategy = Collection.strategy.MANUAL) {
        const toDeleteLocally = toApplyLocally.filter((r) => r.deleted);
        const toUpdateLocally = toApplyLocally.filter((r) => !r.deleted);
        const { published, resolved } = await this.db.execute((transaction) => {
            const updated = toUpdateLocally.map((record) => {
                const synced = markSynced(record);
                transaction.update(synced);
                return synced;
            });
            const deleted = toDeleteLocally.map((record) => {
                transaction.delete(record.id);
                // Amend result data with the deleted attribute set
                return { id: record.id, deleted: true };
            });
            const published = updated.concat(deleted);
            // Handle conflicts, if any
            const resolved = this._handleConflicts(transaction, conflicts, strategy);
            return { published, resolved };
        });
        syncResultObject.add("published", published);
        if (resolved.length > 0) {
            syncResultObject
                .reset("conflicts")
                .reset("resolved")
                .add("resolved", resolved);
        }
        return syncResultObject;
    }
    /**
     * Handles synchronization conflicts according to specified strategy.
     *
     * @param  {SyncResultObject} result    The sync result object.
     * @param  {String}           strategy  The {@link Collection.strategy}.
     * @return {Promise<Array<Object>>} The resolved conflicts, as an
     *    array of {accepted, rejected} objects
     */
    _handleConflicts(transaction, conflicts, strategy) {
        if (strategy === Collection.strategy.MANUAL) {
            return [];
        }
        return conflicts.map((conflict) => {
            const resolution = strategy === Collection.strategy.CLIENT_WINS
                ? conflict.local
                : conflict.remote;
            const rejected = strategy === Collection.strategy.CLIENT_WINS
                ? conflict.remote
                : conflict.local;
            let accepted, status, id;
            if (resolution === null) {
                // We "resolved" with the server-side deletion. Delete locally.
                // This only happens during SERVER_WINS because the local
                // version of a record can never be null.
                // We can get "null" from the remote side if we got a conflict
                // and there is no remote version available; see src/http
                // batch.js:aggregate.
                transaction.delete(conflict.local.id);
                accepted = null;
                // The record was deleted, but that status is "synced" with
                // the server, so we don't need to push the change.
                status = "synced";
                id = conflict.local.id;
            }
            else {
                const updated = this._resolveRaw(conflict, resolution);
                transaction.update(updated);
                accepted = updated;
                status = updated._status;
                id = updated.id;
            }
            return { rejected, accepted, id, _status: status };
        });
    }
    /**
     * Execute a bunch of operations in a transaction.
     *
     * This transaction should be atomic -- either all of its operations
     * will succeed, or none will.
     *
     * The argument to this function is itself a function which will be
     * called with a {@link CollectionTransaction}. Collection methods
     * are available on this transaction, but instead of returning
     * promises, they are synchronous. execute() returns a Promise whose
     * value will be the return value of the provided function.
     *
     * Most operations will require access to the record itself, which
     * must be preloaded by passing its ID in the preloadIds option.
     *
     * Options:
     * - {Array} preloadIds: list of IDs to fetch at the beginning of
     *   the transaction
     *
     * @return {Promise} Resolves with the result of the given function
     *    when the transaction commits.
     */
    execute(doOperations, { preloadIds = [] } = {}) {
        for (const id of preloadIds) {
            if (!this.idSchema.validate(id)) {
                return Promise.reject(Error(`Invalid Id: ${id}`));
            }
        }
        return this.db.execute((transaction) => {
            const txn = new CollectionTransaction(this, transaction);
            const result = doOperations(txn);
            txn.emitEvents();
            return result;
        }, { preload: preloadIds });
    }
    /**
     * Resets the local records as if they were never synced; existing records are
     * marked as newly created, deleted records are dropped.
     *
     * A next call to {@link Collection.sync} will thus republish the whole
     * content of the local collection to the server.
     *
     * @return {Promise} Resolves with the number of processed records.
     */
    async resetSyncStatus() {
        const unsynced = await this.list({ filters: { _status: ["deleted", "synced"] }, order: "" }, { includeDeleted: true });
        await this.db.execute((transaction) => {
            unsynced.data.forEach((record) => {
                if (record._status === "deleted") {
                    // Garbage collect deleted records.
                    transaction.delete(record.id);
                }
                else {
                    // Records that were synced become «created».
                    transaction.update({
                        ...record,
                        last_modified: undefined,
                        _status: "created",
                    });
                }
            });
        });
        this._lastModified = null;
        await this.db.saveLastModified(null);
        return unsynced.data.length;
    }
    /**
     * Returns an object containing two lists:
     *
     * - `toDelete`: unsynced deleted records we can safely delete;
     * - `toSync`: local updates to send to the server.
     *
     * @return {Promise}
     */
    async gatherLocalChanges() {
        const unsynced = await this.list({
            filters: { _status: ["created", "updated"] },
            order: "",
        });
        const deleted = await this.list({ filters: { _status: "deleted" }, order: "" }, { includeDeleted: true });
        return await Promise.all(unsynced.data
            .concat(deleted.data)
            .map(this._encodeRecord.bind(this, "remote")));
    }
    /**
     * Fetch remote changes, import them to the local database, and handle
     * conflicts according to `options.strategy`. Then, updates the passed
     * {@link SyncResultObject} with import results.
     *
     * Options:
     * - {String} strategy: The selected sync strategy.
     * - {String} expectedTimestamp: A timestamp to use as a "cache busting" query parameter.
     * - {Array<String>} exclude: A list of record ids to exclude from pull.
     * - {Object} headers: The HTTP headers to use in the request.
     * - {int} retry: The number of retries to do if the HTTP request fails.
     * - {int} lastModified: The timestamp to use in `?_since` query.
     *
     * @param  {KintoClient.Collection} client           Kinto client Collection instance.
     * @param  {SyncResultObject}       syncResultObject The sync result object.
     * @param  {Object}                 options          The options object.
     * @return {Promise}
     */
    async pullChanges(client, syncResultObject, options = {}) {
        if (!syncResultObject.ok) {
            return syncResultObject;
        }
        const since = this.lastModified
            ? this.lastModified
            : await this.db.getLastModified();
        options = {
            strategy: Collection.strategy.MANUAL,
            lastModified: since,
            headers: {},
            ...options,
        };
        // Optionally ignore some records when pulling for changes.
        // (avoid redownloading our own changes on last step of #sync())
        let filters;
        if (options.exclude) {
            // Limit the list of excluded records to the first 50 records in order
            // to remain under de-facto URL size limit (~2000 chars).
            // http://stackoverflow.com/questions/417142/what-is-the-maximum-length-of-a-url-in-different-browsers/417184#417184
            const exclude_id = options.exclude
                .slice(0, 50)
                .map((r) => r.id)
                .join(",");
            filters = { exclude_id };
        }
        if (options.expectedTimestamp) {
            filters = {
                ...filters,
                _expected: options.expectedTimestamp,
            };
        }
        // First fetch remote changes from the server
        const { data, last_modified } = await client.listRecords({
            // Since should be ETag (see https://github.com/Kinto/kinto.js/issues/356)
            since: options.lastModified ? `${options.lastModified}` : undefined,
            headers: options.headers,
            retry: options.retry,
            // Fetch every page by default (FIXME: option to limit pages, see #277)
            pages: Infinity,
            filters,
        });
        // last_modified is the ETag header value (string).
        // For retro-compatibility with first kinto.js versions
        // parse it to integer.
        const unquoted = last_modified ? parseInt(last_modified, 10) : undefined;
        // Check if server was flushed.
        // This is relevant for the Kinto demo server
        // (and thus for many new comers).
        const localSynced = options.lastModified;
        const serverChanged = unquoted && unquoted > options.lastModified;
        const emptyCollection = data.length === 0;
        if (!options.exclude && localSynced && serverChanged && emptyCollection) {
            const e = new ServerWasFlushedError(localSynced, unquoted, "Server has been flushed. Client Side Timestamp: " +
                localSynced +
                " Server Side Timestamp: " +
                unquoted);
            throw e;
        }
        // Atomic updates are not sensible here because unquoted is not
        // computed as a function of syncResultObject.lastModified.
        // eslint-disable-next-line require-atomic-updates
        syncResultObject.lastModified = unquoted;
        // Decode incoming changes.
        const decodedChanges = await Promise.all(data.map((change) => {
            return this._decodeRecord("remote", change);
        }));
        // Hook receives decoded records.
        const payload = { lastModified: unquoted, changes: decodedChanges };
        const afterHooks = await this.applyHook("incoming-changes", payload);
        // No change, nothing to import.
        if (afterHooks.changes.length > 0) {
            // Reflect these changes locally
            await this.importChanges(syncResultObject, afterHooks.changes, options.strategy);
        }
        return syncResultObject;
    }
    applyHook(hookName, payload) {
        if (typeof this.hooks[hookName] === "undefined") {
            return Promise.resolve(payload);
        }
        return (0, utils_1.waterfall)(this.hooks[hookName].map((hook) => {
            return (record) => {
                const result = hook(payload, this);
                const resultThenable = result && typeof result.then === "function";
                const resultChanges = result && Object.prototype.hasOwnProperty.call(result, "changes");
                if (!(resultThenable || resultChanges)) {
                    throw new Error(`Invalid return value for hook: ${JSON.stringify(result)} has no 'then()' or 'changes' properties`);
                }
                return result;
            };
        }), payload);
    }
    /**
     * Publish local changes to the remote server and updates the passed
     * {@link SyncResultObject} with publication results.
     *
     * Options:
     * - {String} strategy: The selected sync strategy.
     * - {Object} headers: The HTTP headers to use in the request.
     * - {int} retry: The number of retries to do if the HTTP request fails.
     *
     * @param  {KintoClient.Collection} client           Kinto client Collection instance.
     * @param  {SyncResultObject}       syncResultObject The sync result object.
     * @param  {Object}                 changes          The change object.
     * @param  {Array}                  changes.toDelete The list of records to delete.
     * @param  {Array}                  changes.toSync   The list of records to create/update.
     * @param  {Object}                 options          The options object.
     * @return {Promise}
     */
    async pushChanges(client, changes, syncResultObject, options = {}) {
        if (!syncResultObject.ok) {
            return syncResultObject;
        }
        // FIXME: replacing `undefined` with Collection.strategy.CLIENT_WINS breaks tests
        const safe = !options.strategy || options.strategy !== undefined;
        const toDelete = changes.filter((r) => r._status === "deleted");
        const toSync = changes.filter((r) => r._status != "deleted");
        // Perform a batch request with every changes.
        const synced = (await client.batch((batch) => {
            toDelete.forEach((r) => {
                // never published locally deleted records should not be pusblished
                if (r.last_modified) {
                    batch.deleteRecord(r);
                }
            });
            toSync.forEach((r) => {
                // Clean local fields (like _status) before sending to server.
                const published = this.cleanLocalFields(r);
                if (r._status === "created") {
                    batch.createRecord(published);
                }
                else {
                    batch.updateRecord(published);
                }
            });
        }, {
            headers: options.headers,
            retry: options.retry,
            safe,
            aggregate: true,
        }));
        // Store outgoing errors into sync result object
        syncResultObject.add("errors", synced.errors.map((e) => ({ ...e, type: "outgoing" })));
        // Store outgoing conflicts into sync result object
        const conflicts = [];
        for (const { type, local, remote } of synced.conflicts) {
            // Note: we ensure that local data are actually available, as they may
            // be missing in the case of a published deletion.
            const safeLocal = (local && local.data) || { id: remote.id };
            const realLocal = await this._decodeRecord("remote", safeLocal);
            // We can get "null" from the remote side if we got a conflict
            // and there is no remote version available; see src/http
            // batch.js:aggregate.
            const realRemote = remote && (await this._decodeRecord("remote", remote));
            const conflict = { type, local: realLocal, remote: realRemote };
            conflicts.push(conflict);
        }
        syncResultObject.add("conflicts", conflicts);
        // Records that must be deleted are either deletions that were pushed
        // to server (published) or deleted records that were never pushed (skipped).
        const missingRemotely = synced.skipped.map((r) => ({
            ...r,
            deleted: true,
        }));
        // For created and updated records, the last_modified coming from server
        // will be stored locally.
        // Reflect publication results locally using the response from
        // the batch request.
        const published = synced.published.map((c) => c.data);
        const toApplyLocally = published.concat(missingRemotely);
        // Apply the decode transformers, if any
        const decoded = await Promise.all(toApplyLocally.map((record) => {
            return this._decodeRecord("remote", record);
        }));
        // We have to update the local records with the responses of the server
        // (eg. last_modified values etc.).
        if (decoded.length > 0 || conflicts.length > 0) {
            await this._applyPushedResults(syncResultObject, decoded, conflicts, options.strategy);
        }
        return syncResultObject;
    }
    /**
     * Return a copy of the specified record without the local fields.
     *
     * @param  {Object} record  A record with potential local fields.
     * @return {Object}
     */
    cleanLocalFields(record) {
        const localKeys = [...RECORD_FIELDS_TO_CLEAN, ...this.localFields];
        return (0, utils_2.omitKeys)(record, localKeys);
    }
    /**
     * Resolves a conflict, updating local record according to proposed
     * resolution — keeping remote record `last_modified` value as a reference for
     * further batch sending.
     *
     * @param  {Object} conflict   The conflict object.
     * @param  {Object} resolution The proposed record.
     * @return {Promise}
     */
    resolve(conflict, resolution) {
        return this.db.execute((transaction) => {
            const updated = this._resolveRaw(conflict, resolution);
            transaction.update(updated);
            return { data: updated, permissions: {} };
        });
    }
    /**
     * @private
     */
    _resolveRaw(conflict, resolution) {
        const resolved = {
            ...resolution,
            // Ensure local record has the latest authoritative timestamp
            last_modified: conflict.remote && conflict.remote.last_modified,
        };
        // If the resolution object is strictly equal to the
        // remote record, then we can mark it as synced locally.
        // Otherwise, mark it as updated (so that the resolution is pushed).
        const synced = (0, utils_1.deepEqual)(resolved, conflict.remote);
        return markStatus(resolved, synced ? "synced" : "updated");
    }
    /**
     * Synchronize remote and local data. The promise will resolve with a
     * {@link SyncResultObject}, though will reject:
     *
     * - if the server is currently backed off;
     * - if the server has been detected flushed.
     *
     * Options:
     * - {Object} headers: HTTP headers to attach to outgoing requests.
     * - {String} expectedTimestamp: A timestamp to use as a "cache busting" query parameter.
     * - {Number} retry: Number of retries when server fails to process the request (default: 1).
     * - {Collection.strategy} strategy: See {@link Collection.strategy}.
     * - {Boolean} ignoreBackoff: Force synchronization even if server is currently
     *   backed off.
     * - {String} bucket: The remove bucket id to use (default: null)
     * - {String} collection: The remove collection id to use (default: null)
     * - {String} remote The remote Kinto server endpoint to use (default: null).
     *
     * @param  {Object} options Options.
     * @return {Promise}
     * @throws {Error} If an invalid remote option is passed.
     */
    async sync(options = {
        strategy: Collection.strategy.MANUAL,
        headers: {},
        retry: 1,
        ignoreBackoff: false,
        bucket: null,
        collection: null,
        remote: null,
        expectedTimestamp: null,
    }) {
        options = {
            ...options,
            bucket: options.bucket || this.bucket,
            collection: options.collection || this.name,
        };
        const previousRemote = this.api.remote;
        if (options.remote) {
            // Note: setting the remote ensures it's valid, throws when invalid.
            this.api.remote = options.remote;
        }
        if (!options.ignoreBackoff && this.api.backoff > 0) {
            const seconds = Math.ceil(this.api.backoff / 1000);
            return Promise.reject(new Error(`Server is asking clients to back off; retry in ${seconds}s or use the ignoreBackoff option.`));
        }
        const client = this.api
            .bucket(options.bucket)
            .collection(options.collection);
        const result = new SyncResultObject();
        try {
            // Fetch collection metadata.
            await this.pullMetadata(client, options);
            // Fetch last changes from the server.
            await this.pullChanges(client, result, options);
            const { lastModified } = result;
            if (options.strategy !== Collection.strategy.PULL_ONLY) {
                // Fetch local changes
                const toSync = await this.gatherLocalChanges();
                // Publish local changes and pull local resolutions
                await this.pushChanges(client, toSync, result, options);
                // Publish local resolution of push conflicts to server (on CLIENT_WINS)
                const resolvedUnsynced = result.resolved.filter((r) => r._status !== "synced");
                if (resolvedUnsynced.length > 0) {
                    const resolvedEncoded = await Promise.all(resolvedUnsynced.map((resolution) => {
                        let record = resolution.accepted;
                        if (record === null) {
                            record = { id: resolution.id, _status: resolution._status };
                        }
                        return this._encodeRecord("remote", record);
                    }));
                    await this.pushChanges(client, resolvedEncoded, result, options);
                }
                // Perform a last pull to catch changes that occured after the last pull,
                // while local changes were pushed. Do not do it nothing was pushed.
                if (result.published.length > 0) {
                    // Avoid redownloading our own changes during the last pull.
                    const pullOpts = {
                        ...options,
                        lastModified,
                        exclude: result.published,
                    };
                    await this.pullChanges(client, result, pullOpts);
                }
            }
            // Don't persist lastModified value if any conflict or error occured
            if (result.ok) {
                // No conflict occured, persist collection's lastModified value
                this._lastModified = await this.db.saveLastModified(result.lastModified);
            }
        }
        catch (e) {
            this.events?.emit("sync:error", { ...options, error: e });
            throw e;
        }
        finally {
            // Ensure API default remote is reverted if a custom one's been used
            this.api.remote = previousRemote;
        }
        this.events?.emit("sync:success", { ...options, result });
        return result;
    }
    /**
     * Load a list of records already synced with the remote server.
     *
     * The local records which are unsynced or whose timestamp is either missing
     * or superior to those being loaded will be ignored.
     *
     * @deprecated Use {@link importBulk} instead.
     * @param  {Array} records The previously exported list of records to load.
     * @return {Promise} with the effectively imported records.
     */
    async loadDump(records) {
        return this.importBulk(records);
    }
    /**
     * Load a list of records already synced with the remote server.
     *
     * The local records which are unsynced or whose timestamp is either missing
     * or superior to those being loaded will be ignored.
     *
     * @param  {Array} records The previously exported list of records to load.
     * @return {Promise} with the effectively imported records.
     */
    async importBulk(records) {
        if (!Array.isArray(records)) {
            throw new Error("Records is not an array.");
        }
        for (const record of records) {
            if (!Object.prototype.hasOwnProperty.call(record, "id") ||
                !this.idSchema.validate(record.id)) {
                throw new Error("Record has invalid ID: " + JSON.stringify(record));
            }
            if (!record.last_modified) {
                throw new Error("Record has no last_modified value: " + JSON.stringify(record));
            }
        }
        // Fetch all existing records from local database,
        // and skip those who are newer or not marked as synced.
        // XXX filter by status / ids in records
        const { data } = await this.list({}, { includeDeleted: true });
        const existingById = data.reduce((acc, record) => {
            acc[record.id] = record;
            return acc;
        }, {});
        const newRecords = records.filter((record) => {
            const localRecord = existingById[record.id];
            const shouldKeep = 
            // No local record with this id.
            localRecord === undefined ||
                // Or local record is synced
                (localRecord._status === "synced" &&
                    // And was synced from server
                    localRecord.last_modified !== undefined &&
                    // And is older than imported one.
                    record.last_modified > localRecord.last_modified);
            return shouldKeep;
        });
        return await this.db.importBulk(newRecords.map(markSynced));
    }
    async pullMetadata(client, options = {}) {
        const { expectedTimestamp, headers } = options;
        const query = expectedTimestamp
            ? { query: { _expected: expectedTimestamp.toString() } }
            : undefined;
        const metadata = await client.getData({
            ...query,
            headers,
        });
        return this.db.saveMetadata(metadata);
    }
    async metadata() {
        return this.db.getMetadata();
    }
}
exports.default = Collection;
/**
 * A Collection-oriented wrapper for an adapter's transaction.
 *
 * This defines the high-level functions available on a collection.
 * The collection itself offers functions of the same name. These will
 * perform just one operation in its own transaction.
 */
class CollectionTransaction {
    constructor(collection, adapterTransaction) {
        this.collection = collection;
        this.adapterTransaction = adapterTransaction;
        this._events = [];
    }
    _queueEvent(action, payload) {
        this._events.push({ action, payload });
    }
    /**
     * Emit queued events, to be called once every transaction operations have
     * been executed successfully.
     */
    emitEvents() {
        for (const { action, payload } of this._events) {
            this.collection.events?.emit(action, payload);
        }
        if (this._events.length > 0) {
            const targets = this._events.map(({ action, payload }) => ({
                action,
                ...payload,
            }));
            this.collection.events?.emit("change", { targets });
        }
        this._events = [];
    }
    /**
     * Retrieve a record by its id from the local database, or
     * undefined if none exists.
     *
     * This will also return virtually deleted records.
     *
     * @param  {String} id
     * @return {Object}
     */
    getAny(id) {
        const record = this.adapterTransaction.get(id);
        return { data: record, permissions: {} };
    }
    /**
     * Retrieve a record by its id from the local database.
     *
     * Options:
     * - {Boolean} includeDeleted: Include virtually deleted records.
     *
     * @param  {String} id
     * @param  {Object} options
     * @return {Object}
     */
    get(id, options = { includeDeleted: false }) {
        const res = this.getAny(id);
        if (!res.data ||
            (!options.includeDeleted && res.data._status === "deleted")) {
            throw new Error(`Record with id=${id} not found.`);
        }
        return res;
    }
    /**
     * Deletes a record from the local database.
     *
     * Options:
     * - {Boolean} virtual: When set to `true`, doesn't actually delete the record,
     *   update its `_status` attribute to `deleted` instead (default: true)
     *
     * @param  {String} id       The record's Id.
     * @param  {Object} options  The options object.
     * @return {Object}
     */
    delete(id, options = { virtual: true }) {
        // Ensure the record actually exists.
        const existing = this.adapterTransaction.get(id);
        const alreadyDeleted = existing && existing._status === "deleted";
        if (!existing || (alreadyDeleted && options.virtual)) {
            throw new Error(`Record with id=${id} not found.`);
        }
        // Virtual updates status.
        if (options.virtual) {
            this.adapterTransaction.update(markDeleted(existing));
        }
        else {
            // Delete for real.
            this.adapterTransaction.delete(id);
        }
        this._queueEvent("delete", { data: existing });
        return { data: existing, permissions: {} };
    }
    /**
     * Soft delete all records from the local database.
     *
     * @param  {Array} ids        Array of non-deleted Record Ids.
     * @return {Object}
     */
    deleteAll(ids) {
        const existingRecords = [];
        ids.forEach((id) => {
            existingRecords.push(this.adapterTransaction.get(id));
            this.delete(id);
        });
        this._queueEvent("deleteAll", { data: existingRecords });
        return { data: existingRecords, permissions: {} };
    }
    /**
     * Deletes a record from the local database, if any exists.
     * Otherwise, do nothing.
     *
     * @param  {String} id       The record's Id.
     * @return {Object}
     */
    deleteAny(id) {
        const existing = this.adapterTransaction.get(id);
        if (existing) {
            this.adapterTransaction.update(markDeleted(existing));
            this._queueEvent("delete", { data: existing });
        }
        return {
            data: { id, ...existing },
            deleted: !!existing,
            permissions: {},
        };
    }
    /**
     * Adds a record to the local database, asserting that none
     * already exist with this ID.
     *
     * @param  {Object} record, which must contain an ID
     * @return {Object}
     */
    create(record) {
        if (typeof record !== "object") {
            throw new Error("Record is not an object.");
        }
        if (!Object.prototype.hasOwnProperty.call(record, "id")) {
            throw new Error("Cannot create a record missing id");
        }
        if (!this.collection.idSchema.validate(record.id)) {
            throw new Error(`Invalid Id: ${record.id}`);
        }
        this.adapterTransaction.create(record);
        this._queueEvent("create", { data: record });
        return { data: record, permissions: {} };
    }
    /**
     * Updates a record from the local database.
     *
     * Options:
     * - {Boolean} synced: Sets record status to "synced" (default: false)
     * - {Boolean} patch:  Extends the existing record instead of overwriting it
     *   (default: false)
     *
     * @param  {Object} record
     * @param  {Object} options
     * @return {Object}
     */
    update(record, options = {
        synced: false,
        patch: false,
    }) {
        if (typeof record !== "object") {
            throw new Error("Record is not an object.");
        }
        if (!Object.prototype.hasOwnProperty.call(record, "id")) {
            throw new Error("Cannot update a record missing id.");
        }
        if (!this.collection.idSchema.validate(record.id)) {
            throw new Error(`Invalid Id: ${record.id}`);
        }
        const oldRecord = this.adapterTransaction.get(record.id);
        if (!oldRecord) {
            throw new Error(`Record with id=${record.id} not found.`);
        }
        const newRecord = options.patch ? { ...oldRecord, ...record } : record;
        const updated = this._updateRaw(oldRecord, newRecord, options);
        this.adapterTransaction.update(updated);
        this._queueEvent("update", { data: updated, oldRecord });
        return { data: updated, oldRecord, permissions: {} };
    }
    /**
     * Lower-level primitive for updating a record while respecting
     * _status and last_modified.
     *
     * @param  {Object} oldRecord: the record retrieved from the DB
     * @param  {Object} newRecord: the record to replace it with
     * @return {Object}
     */
    _updateRaw(oldRecord, newRecord, { synced = false } = {}) {
        const updated = { ...newRecord };
        // Make sure to never loose the existing timestamp.
        if (oldRecord && oldRecord.last_modified && !updated.last_modified) {
            updated.last_modified = oldRecord.last_modified;
        }
        // If only local fields have changed, then keep record as synced.
        // If status is created, keep record as created.
        // If status is deleted, mark as updated.
        const isIdentical = oldRecord &&
            recordsEqual(oldRecord, updated, this.collection.localFields);
        const keepSynced = isIdentical && oldRecord._status === "synced";
        const neverSynced = !oldRecord || (oldRecord && oldRecord._status === "created");
        const newStatus = keepSynced || synced ? "synced" : neverSynced ? "created" : "updated";
        return markStatus(updated, newStatus);
    }
    /**
     * Upsert a record into the local database.
     *
     * This record must have an ID.
     *
     * If a record with this ID already exists, it will be replaced.
     * Otherwise, this record will be inserted.
     *
     * @param  {Object} record
     * @return {Object}
     */
    upsert(record) {
        if (typeof record !== "object") {
            throw new Error("Record is not an object.");
        }
        if (!Object.prototype.hasOwnProperty.call(record, "id")) {
            throw new Error("Cannot update a record missing id.");
        }
        if (!this.collection.idSchema.validate(record.id)) {
            throw new Error(`Invalid Id: ${record.id}`);
        }
        let oldRecord = this.adapterTransaction.get(record.id);
        const updated = this._updateRaw(oldRecord, record);
        this.adapterTransaction.update(updated);
        // Don't return deleted records -- pretend they are gone
        if (oldRecord && oldRecord._status === "deleted") {
            oldRecord = undefined;
        }
        if (oldRecord) {
            this._queueEvent("update", { data: updated, oldRecord });
        }
        else {
            this._queueEvent("create", { data: updated });
        }
        return { data: updated, oldRecord, permissions: {} };
    }
}
exports.CollectionTransaction = CollectionTransaction;