import PromiseSync from "../PromiseSync"
import RecordFetcher from "./RecordFetcher"
import DatabaseVersionMigrator from "./DatabaseVersionMigrator"
import DatabaseSchema from "../schema/DatabaseSchema"
import UpgradedDatabaseSchema from "../schema/UpgradedDatabaseSchema"

/**
 * Private field symbols.
 */
const FIELDS = Object.freeze({
  database: Symbol("database"),
  transaction: Symbol("transaction"),
  schemaDescriptors: Symbol("schemaDescriptors"),
  currentVersion: Symbol("currentVersion")
})

/**
 * Database migrator evaluates the provided database schemas to upgrade the
 * database to the greatest described version.
 */
export default class DatabaseMigrator {
  /**
   * Initializes the database migrator.
   *
   * @param {IDBDatabase} database The native Indexed DB database to upgrade.
   * @param {IDBTransaction} transaction The native {@code versionchange}
   *        transaction to use to manipulate the data.
   * @param {((DatabaseSchema|UpgradedDatabaseSchema)[]|Object[])} schemaDescriptors
   *        Descriptors of the database schema for all known database versions.
   * @param {number} currentVersion The current version of the database, as a
   *        positive integer, or set to {@code 0} if the database is being
   *        created.
   */
  constructor(database, transaction, schemaDescriptors, currentVersion) {
    if (!schemaDescriptors.length) {
      throw new Error("The list of schema descriptors cannot be empty")
    }
    let sortedSchemasCopy = schemaDescriptors.slice().sort((desc1, desc2) => {
      return desc1.version - desc2.version
    })
    checkSchemaDescriptorTypes(sortedSchemasCopy)

    let isVersionValid = (currentVersion >= 0) &&
        (parseInt(currentVersion, 10) === currentVersion)
    if (!isVersionValid) {
      throw new Error("The version number must be either a positive " +
          "integer, or 0 if the database is being created")
    }

    /**
     * The native Indexed DB database connection.
     *
     * @type {IDBDatabase}
     */
    this[FIELDS.database] = database

    /**
     * The native {@code versionchange} transaction to use to manipulate the
     * data.
     *
     * @type {IDBTransaction}
     */
    this[FIELDS.transaction] = transaction

    /**
     * Descriptors of the database schemas across the versions of the database,
     * sorting by the database version in ascending order.
     *
     * The first element is always a {@linkcode DatabaseSchema} instance, the
     * rest of the elements are instances of the
     * {@linkcode UpgradedDatabaseSchema} classes.
     *
     * @type {(DatabaseSchema|UpgradedDatabaseSchema)[]}
     */
    this[FIELDS.schemaDescriptors] = Object.freeze(sortedSchemasCopy)

    /**
     * The current version of the database, before the migration was started.
     * The version number is either a positive integer, or {@code 0} if the
     * database is being created.
     *
     * @type {number}
     */
    this[FIELDS.currentVersion] = currentVersion

    Object.freeze(this)
  }

  /**
   * Processes the schema descriptors and upgrades the database to the greatest
   * described version.
   *
   * @return {PromiseSync<undefined>} A promise that resolves when the schema is
   *         upgraded to the greatest version specified in the schema
   *         descriptors.
   */
  executeMigration() {
    return migrateDatabase(
      this[FIELDS.database],
      this[FIELDS.transaction],
      this[FIELDS.schemaDescriptors],
      this[FIELDS.currentVersion]
    )
  }
}

/**
 * Processes the schema descriptors to upgrade the database schema to the
 * greatest version specified.
 *
 * @param {IDBDatabase} nativeDatabase The native Indexed DB database being
 *        migrated to a higher version.
 * @param {IDBTransaction} nativeTransaction The native Indexed DB
 *        {@code versionchange} transaction to use to manipulate the data.
 * @param {(DatabaseSchema|UpgradedDatabaseSchema)[]} schemaDescriptors Schema
 *        descriptors of the database schemas for various versions, sorted in
 *        ascending order by the version number.
 * @param {number} currentVersion The current version of the database schema.
 * @return {PromiseSync<undefined>} A promise that resolves when the schema is
 *         upgraded to the greatest version specified in the schema
 *         descriptors.
 */
function migrateDatabase(nativeDatabase, nativeTransaction, schemaDescriptors,
    currentVersion) {
  let descriptorsToProcess = schemaDescriptors.filter((descriptor) => {
    return descriptor.version > currentVersion
  })

  if (!descriptorsToProcess.length) {
    return PromiseSync.resolve(undefined)
  }

  return migrateDatabaseVersion(
    nativeDatabase,
    nativeTransaction,
    descriptorsToProcess[0]
  ).then(() => {
    return migrateDatabase(
      nativeDatabase,
      nativeTransaction,
      descriptorsToProcess,
      descriptorsToProcess[0].version
    )
  })
}

/**
 * Performs a single-version database migration to the schema described by the
 * provided database schema descriptor.
 *
 * @param {IDBDatabase} nativeDatabase The native Indexed DB database being
 *        migrated to a higher version.
 * @param {IDBTransaction} nativeTransaction The native Indexed DB
 *        {@code versionchange} transaction to use to manipulate the data.
 * @param {(DatabaseSchema|UpgradedDatabaseSchema)} descriptor Schema
 *        descriptor of the version to which the database is to be upgraded.
 * @return {PromiseSync<undefined>} A promise that resolves once the database
 *         has been upgraded to the schema described by the provided schema
 *         descriptor.
 */
function migrateDatabaseVersion(nativeDatabase, nativeTransaction,
    descriptor) {
  let fetchPromise
  if (descriptor.fetchBefore && descriptor.fetchBefore.length) {
    let fetcher = new RecordFetcher()
    let objectStores = normalizeFetchBeforeObjectStores(descriptor.fetchBefore)
    fetchPromise = fetcher.fetchRecords(nativeTransaction, objectStores)
  } else {
    fetchPromise = PromiseSync.resolve({})
  }

  return fetchPromise.then((recordsMap) => {
    let versionMigrator = new DatabaseVersionMigrator(
      nativeDatabase,
      nativeTransaction,
      descriptor.objectStores
    )

    return versionMigrator.executeMigration(
      descriptor.after || (() => {}),
      recordsMap
    )
  })
}

/**
 * Normalizes the provided array of object store fetch descriptors to process
 * before upgrading the database schema.
 *
 * @param {(string|{objectStore: string, preprocessor: function(*, (number|string|Date|Array)): (*|UpgradedDatabaseSchema.SKIP_RECORD|UpgradedDatabaseSchema.DELETE_RECORD)=})[]} objectStores
 *        The names of object stores that should have their records fetch or
 *        (possibly partially filled) object store fetch descriptors, mixed in
 *        an array.
 * @return {{objectStore: string, preprocessor: function(*, (number|string|Date|Array)): (*|UpgradedDatabaseSchema.SKIP_RECORD|UpgradedDatabaseSchema.DELETE_RECORD)}[]}
 *         Normalized object store fetch descriptors.
 */
function normalizeFetchBeforeObjectStores(objectStores) {
  return objectStores.map((objectStore) => {
    if (typeof objectStore === "string") {
      return {
        objectStore,
        preprocessor: record => record
      }
    } else if (!objectStore.preprocessor) {
      return {
        objectStore: objectStore.objectStore,
        preprocessor: record => record
      }
    } else {
      return objectStore
    }
  })
}

/**
 * Validates the types of the provided schema descriptors.
 *
 * @param {((DatabaseSchema|UpgradedDatabaseSchema)[]|Object[])} schemaDescriptors
 *        The database schemas for database versions to validate, sorted by
 *        version number in the ascending order.
 * @throws {TypeError} Thrown if the schema descriptors are of invalid type.
 */
function checkSchemaDescriptorTypes(schemaDescriptors) {
  let onlyPlainObjects = schemaDescriptors.every((descriptor) => {
    return descriptor.constructor === Object
  })
  if (onlyPlainObjects) {
    return
  }

  if (!(schemaDescriptors[0] instanceof DatabaseSchema)) {
    throw new TypeError("The schema descriptor of the lowest described " +
        `database version (${schemaDescriptors[0].version}) must be a ` +
        "DatabaseSchema instance, or all schema descriptors must be plain " +
        "objects")
  }
  schemaDescriptors.slice(1).forEach((descriptor) => {
    if (!(descriptor instanceof UpgradedDatabaseSchema)) {
      throw new TypeError("The schema descriptors of the upgraded database " +
          "versions must be UpgradedDatabaseSchema instances, but the " +
          `provided descriptor of version ${descriptor.version} was not an ` +
          "UpgradedDatabaseSchema instance, or all schema descriptors must " +
          "be plain objects")
    }
  })
}


import PromiseSync from "../PromiseSync"
import AbstractBaseStorage from "./AbstractBaseStorage"
import CursorDirection from "./CursorDirection"
import KeyRange from "./KeyRange"
import RecordList from "./RecordList"
import {
  compileFieldRangeFilter,
  normalizeFilter,
  keyRangeToFieldRangeObject
} from "./utils"

/**
 * Private field symbols.
 */
const FIELDS = Object.freeze({
  storage: Symbol("storage"),
  unique: Symbol("unique"),
  storageFactory: Symbol("storageFactory")
})

/**
 * Abstract storage accessor providing high-level read-only API.
 *
 * @abstract
 */
export default class AbstractReadOnlyStorage extends AbstractBaseStorage {
  /**
   * Initializes the read-only storage. The overriding implementation should
   * freeze the instance object once it is fully initialized.
   *
   * @param {(IDBObjectStore|IDBIndex)} storage The native Indexed DB object
   *        store or index.
   * @param {function(new: ReadyOnlyCursor)} cursorConstructor Constructor of
   *        the cursor to use when traversing the storage records.
   * @param {function(): AbstractReadOnlyStorage} storageFactory A function
   *        that creates a new read-only transaction and returns a new storage
   *        accessor for this storage each time it is invoked.
   */
  constructor(storage, cursorConstructor, storageFactory) {
    super(storage, cursorConstructor)

    if (this.constructor === AbstractReadOnlyStorage) {
      throw new Error("The AbstractReadOnlyStorage class is abstract and " +
          "must be overridden")
    }

    /**
     * The native Indexed DB object store or index.
     *
     * @type {(IDBObjectStore|IDBIndex)}
     */
    this[FIELDS.storage] = storage

    /**
     * When {@code true}, the keys by which the records are organized in the
     * store are always unique for each record.
     *
     * @type {boolean}
     */
    this[FIELDS.unique] = storage instanceof IDBObjectStore || storage.unique

    /**
     * A function that creates a new read-only transaction and returns a new
     * storage accessor for this storage each time it is invoked.
     *
     * @type {function(): AbstractReadOnlyStorage}
     */
    this[FIELDS.storageFactory] = storageFactory
  }

  /**
   * Tests whether a record matching the specified filter exists in this
   * storage.
   *
   * @param {(number|string|Date|Array|IDBKeyRange|Object<string, (number|string|Date|Array|IDBKeyRange)>|function(*, (number|string|Date|Array), (number|string|Date|Array)): boolean)} filter
   *        The filter restricting on which records the callback will be
   *        executed. The first argument will be set to the record, the second
   *        argument will be set to the primary key of the record, and the
   *        third argument will be set to the key referencing the record (the
   *        primary key if traversing an object store).
   * @return {PromiseSync<boolean>} A promise that resolves to {@code true} if
   *         there is a record matching the provided filter.
   */
  exists(filter) {
    filter = normalizeFilter(filter, this.keyPath)

    let keyRange
    if (filter instanceof Function) {
      keyRange = undefined
    } else {
      keyRange = filter
      filter = null
    }

    let cursorFactory = this.createCursorFactory(keyRange)

    return new PromiseSync((resolve, reject) => {
      cursorFactory((cursor) => {
        if (filter) {
          if (filter(cursor.record, cursor.primaryKey, cursor.key)) {
            resolve(true)
            return
          }

          cursor.continue()
          return
        }

        resolve(true)
      }).then(() => resolve(false)).catch(reject)
    })
  }

  /**
   * Calculates the number of records matching the specified filter.
   *
   * @param {?(undefined|number|string|Date|Array|IDBKeyRange|Object<string, (number|string|Date|Array|IDBKeyRange)>|function(*, (number|string|Date|Array), (number|string|Date|Array)): boolean)=} filter
   *        The filter restricting on which records the callback will be
   *        executed. The first argument will be set to the record, the second
   *        argument will be set to the primary key of the record, and the
   *        third argument will be set to the key referencing the record (the
   *        primary key if traversing an object store).
   * @return {PromiseSync<number>} A promise that resolves to the number of
   *         records satisfying the filter.
   */
  count(filter = undefined) {
    filter = normalizeFilter(filter, this.keyPath)

    if (filter instanceof Function) {
      return this.forEach(filter, CursorDirection.NEXT, () => {})
    }

    let request = this[FIELDS.storage].count(filter)
    return PromiseSync.resolve(request)
  }

  /**
   * Executes the provided callback on the records in this storage that match
   * the specified filter.
   *
   * @param {?(undefined|number|string|Date|Array|IDBKeyRange|Object<string, (number|string|Date|Array|IDBKeyRange)>|function(*, (number|string|Date|Array), (number|string|Date|Array)): boolean)} filter
   *        The filter restricting on which records the callback will be
   *        executed. If a function is provided, the first argument will be set
   *        to the record, the second argument will be set to the primary key
   *        of the record, and the third argument will be set to the key
   *        referencing the record (the primary key if traversing an object
   *        store).
   * @param {(CursorDirection|string)} direction The direction in which the
   *        records should be traversed. Use either the
   *        {@code CursorDirection.*} constants, or strings {@code "NEXT"} and
   *        {@code "PREVIOUS"} (or {@code "PREV"} for short). The letter case
   *        used in the strings does not matter. Defaults to
   *        {@code CursorDirection.NEXT}.
   * @param {function(*, (number|string|Date|Array), (number|string|Date|Array))} callback
   *        The callback to execute on the records matching the filter. The
   *        first argument will be set to the record, the second argument will
   *        be set to the primary key of the record, and the third argument
   *        will be set to the key referencing the record (the primary key if
   *        traversing an object store).
   * @return {PromiseSync<number>} A promise that resolves to the number of
   *         records satisfying the filter.
   */
  forEach(filter, direction, callback) {
    filter = normalizeFilter(filter, this.keyPath)

    let keyRange
    if (filter instanceof Function) {
      keyRange = undefined
    } else {
      keyRange = filter
      filter = null
    }

    let recordCount = 0

    return this.createCursorFactory(keyRange, direction)((cursor) => {
      if (!filter || filter(cursor.record, cursor.primaryKey, cursor.key)) {
        callback(cursor.record, cursor.primaryKey, cursor.key)
        recordCount++
      }

      cursor.continue()
    }).then(() => recordCount)
  }

  /**
   * Retrieves all records from this object store that match the specified
   * filter. The records will be listed in the specified order.
   *
   * @param {?(undefined|number|string|Date|Array|IDBKeyRange|Object<string, (number|string|Date|Array|IDBKeyRange)>|function(*, (number|string|Date|Array), (number|string|Date|Array)): boolean)=} filter
   *        The filter, restricting the records returned by this method. If a
   *        function is provided, the first argument will be set to the record,
   *        the second argument will be set to the primary key of the record,
   *        and the third argument will be set to the key referencing the
   *        record (the primary key if traversing an object store).
   * @param {CursorDirection} direction The direction in which the records are
   *        to be listed. Use either the {@code CursorDirection.*} constants,
   *        or strings {@code "NEXT"} and {@code "PREVIOUS"} (or {@code "PREV"}
   *        for short). The letter case used in the strings does not matter.
   *        Defaults to {@code CursorDirection.NEXT}.
   * @return {PromiseSync<Array<*>>} A promise that resolves to an array of all
   *         records matching the filter, listed in the specified order.
   */
  getAll(filter = undefined, direction = CursorDirection.NEXT) {
    return new PromiseSync((resolve, reject) => {
      let records = []

      this.forEach(filter, direction, (record) => {
        records.push(record)
      }).then(() => resolve(records)).
          catch(reject)
    })
  }

  /**
   * Lists the records in this storage in pages of specified size.
   *
   * The records will be returned in a {@linkcode RecordStore}, which is an
   * augmented array that can be used to fetch the next page of this listing of
   * records.
   *
   * The {@linkcode RecordStore} is not dependent on the current transaction,
   * and therefore the next pages can be fetched even after an arbitrary delay
   * after the current transaction has ended.
   *
   * Fetching the next pages of records will not be affected by read-write
   * operations. Note that new records with primary key of previous value
   * (depending on the used cursor direction) to the last internally traversed
   * record will not be included in the next pages, as the record list always
   * fetches the next page by fetching the records since the primary key of the
   * last internally traversed record.
   *
   * Deleting all records after the last fetched record and fetching the next
   * page will result in fetching an empty page of records, that will be marked
   * as the last page.
   *
   * Finally, this method has a slight overhead, because the record list needs
   * to look ahead for one record matching the filter after the last returned
   * record to determine whether additional pages of records are available.
   *
   * @param {?(undefined|number|string|Date|Array|IDBKeyRange|Object<string, (number|string|Date|Array|IDBKeyRange)>|function(*, (number|string|Date|Array), (number|string|Date|Array)): boolean)=} filter
   *        The filter, restricting the records returned by this method. If a
   *        function is provided, the first argument will be set to the record,
   *        the second argument will be set to the primary key of the record,
   *        and the third argument will be set to the key referencing the
   *        record (the primary key if traversing an object store).
   * @param {(CursorDirection|string)} direction The direction in which the
   *        records are to be listed. Use either the {@code CursorDirection.*}
   *        constants, or strings {@code "NEXT"} and {@code "PREVIOUS"} (or
   *        {@code "PREV"} for short). The letter case used in the strings does
   *        not matter.
   *        Defaults to {@code CursorDirection.NEXT}.
   * @param {number} pageSize The number of records per page.
   * @return {Promise<RecordList<*>>} A promise that resolves to a record list
   *         of the fetched records matching the filter.
   */
  list(filter = undefined, direction = CursorDirection.NEXT, pageSize = 50) {
    if (!/^[1-9]\d*$/.test(`${pageSize}`)) {
      throw new Error("The page size must be a positive integer, " +
          `${pageSize} provided`)
    }

    // convert the filter to a filter function - we need to always set our key
    // range ourselves to have a high-performance paging
    filter = normalizeFilter(filter, this.keyPath)
    let keyRange = undefined
    if (filter instanceof IDBKeyRange) {
      keyRange = filter
      if (this.keyPath) {
        filter = keyRangeToFieldRangeObject(filter, this.keyPath)
        filter = compileFieldRangeFilter(filter)
      } else {
        let primaryKeyFilter = compileFieldRangeFilter({
          primaryKey: filter
        })
        filter = (record, primaryKey) => {
          return primaryKeyFilter({
            primaryKey: primaryKey
          })
        }
      }
    }

    // fetch the first page of records and create a record list
    let unique = this[FIELDS.unique]
    let storageFactory = this[FIELDS.storageFactory]
    return list(this, keyRange, filter, direction, unique, pageSize,
        storageFactory)
  }
}

/**
 * Creates a promise that resolves to a record list containing the first page
 * of records matching the provided filter.
 *
 * @param {AbstractReadOnlyStorage} storage The current storage accessor - will
 *        be used to fetch the first page of records.
 * @param {(undefined|IDBKeyRange)} keyRange The key range to use for the first
 *        page or records.
 * @param {(undefined|function(*, (number|string|Date|Array), (number|string|Date|Array)): boolean)} filter
 *        The filter function restricting the records that will be listed.
 *        If a function is provided, the first argument will be set to the
 *        record, the second argument will be set to the primary key of the
 *        record, and the third argument will be set to the key referencing the
 *        record (the primary key if traversing an object store).
 * @param {CursorDirection} direction The direction in which the records in the
 *        storage should be listed.
 * @param {boolean} unique When {@code true}, the keys by which the records are
 *        organized in the store are always unique for each record.
 * @param {number} pageSize The maximum number of records per page. Must be a
 *        positive integer.
 * @param {function(): AbstractReadOnlyStorage} storageFactory A function that
 *        creates a new read-only transaction and returns this storage accessor
 *        each time it is invoked.
 * @return {Promise<RecordList<*>>} A promise that resolves to a record list of
 *         the fetched records matching the filter.
 */
function list(storage, keyRange, filter, direction, unique, pageSize,
    storageFactory) {
  return new Promise((resolve, reject) => {
    let items = []

    storage.createCursorFactory(keyRange, direction)((cursor) => {
      if (!filter || filter(cursor.record, cursor.primaryKey, cursor.key)) {
        if (items.length === pageSize) {
          finalize(true, cursor.key, cursor.primaryKey)
          return
        } else {
          items.push(cursor.record)
        }
      }

      cursor.continue()
    }).then(() => finalize(false, null, null)).catch(error => reject(error))

    function finalize(hasNextPage, nextKey, nextPrimaryKey) {
      resolve(new RecordList(items, storageFactory, nextKey, nextPrimaryKey,
          direction, unique, filter, pageSize, hasNextPage))
    }
  })
}

/**
 * Normalizes the provided compound key represented as an object into a
 * compound key representation compatible with the Indexed DB.
 *
 * @param {string[]} keyPaths The key paths of this storage.
 * @param {Object} key The compound key to normalize for use with the Indexed
 *        DB.
 * @return {Array<(number|string|Date|Array)>} Normalized compound key.
 */
function normalizeCompoundObjectKey(keyPaths, key) {
  let normalizedKey = []

  keyPaths.forEach((keyPath) => {
    let keyValue = key

    keyPath.split(".").forEach((fieldName) => {
      if (!keyValue.hasOwnProperty(fieldName)) {
        throw new Error(`The ${keyPath} key path is not defined in the ` +
            "provided compound key")
      }

      keyValue = keyValue[fieldName]
    })

    normalizedKey.push(keyValue)
  })

  return normalizedKey
}


import PromiseSync from "../PromiseSync"
import CursorDirection from "./CursorDirection"

/**
 * Values allowed as cursor directions.
 *
 * @type {(CursorDirection|string)[]}
 */
const CURSOR_DIRECTIONS = [
  CursorDirection.NEXT,
  CursorDirection.PREVIOUS,
  "NEXT",
  "PREVIOUS",
  "PREV"
]

/**
 * Private field symbols.
 */
const FIELDS = Object.freeze({
  storage: Symbol("storage"),
  cursorConstructor: Symbol("cursorConstructor")
})

/**
 * Common base class providing the basic read-only functionality of object
 * stores and indexes.
 *
 * @abstract
 */
export default class AbstractBaseStorage {
  /**
   * Initializes the storage. The overriding implementation should freeze the
   * instance object once it is fully initialized.
   *
   * @param {(IDBObjectStore|IDBIndex)} storage The native Indexed DB object
   *        store or index.
   * @param {function(new: ReadOnlyCursor, IDBRequest, function(), function(IDBRequest): PromiseSync)} cursorConstructor
   *        Constructor of the cursor to use when traversing the storage
   *        records.
   */
  constructor(storage, cursorConstructor) {
    if (this.constructor === AbstractBaseStorage) {
      throw new Error("THe AbstractBaseStorage class is abstract and must " +
          "be overridden")
    }

    let keyPath = storage.keyPath
    if (keyPath && (typeof keyPath !== "string")) {
      keyPath = Object.freeze(Array.from(keyPath))
    }

    /**
     * The key path of this object store or index, specified as a sequence of
     * field names joined by dots (if the object store uses in-line keys), or
     * an array of field names if the object store uses a compound key, or
     * {@code null} if this is an object store that uses out-of-line keys.
     *
     * @type {?(string|string[])}
     */
    this.keyPath = keyPath || null

    /**
     * The name of this object store.
     *
     * @type {string}
     */
    this.name = storage.name

    /**
     * The native IndexedDB storage access object - an object store or an
     * index.
     *
     * @type {(IDBObjectStore|IDBIndex)}
     */
    this[FIELDS.storage] = storage

    /**
     * The constructor function of the cursor to use to create cursor
     * instances.
     *
     * @type {function(new: ReadOnlyCursor, IDBRequest, function(), function(IDBRequest): PromiseSync)}
     */
    this[FIELDS.cursorConstructor] = cursorConstructor
  }

  /**
   * Retrieves a single record identified by the specified key value.
   *
   * If the key is an {@linkcode IDBKeyRange} instance, or the key value
   * matches multiple records, the method retrieves the first record matching
   * the key / key range.
   *
   * There are the following ways of specifying a compound key:
   * - An array of primary key field values. The values must be specified in
   *   the same order as the key paths of this object store.
   * - An {@code Object<string, (number|string|Date|Array)>} object specifying
   *   only the primary key field values.
   *
   * @param {(number|string|Date|Array|Object|IDBKeyRange)} key The key value
   *        identifying the record.
   * @return {PromiseSync<*>} A promise that resolves to the record, or
   *         {@code undefined} if the record does not exist. The also promise
   *         resolves to {@code undefined} if the record exists, but it is the
   *         {@code undefined} value.
   */
  get(key) {
    let isCompoundKeyObject = (key instanceof Object) &&
        !(key instanceof Date) &&
        !(key instanceof IDBKeyRange)
    if (isCompoundKeyObject) {
      if (!(this.keyPath instanceof Array)) {
        throw new Error("This storage does not use a compound key, but one " +
            "was provided")
      }
      key = normalizeCompoundObjectKey(this.keyPath, key)
    }

    let request = this[FIELDS.storage].get(key)
    return PromiseSync.resolve(request)
  }

  /**
   * Opens a read-only cursor that traverses the records of this storage,
   * resolving to the traversed records.
   *
   * The returned promise resolves once the record callback does not invoke
   * the {@code continue} nor the {@code advance} method synchronously or the
   * cursor reaches the end of available records.
   *
   * @param {?(number|string|Date|Array|IDBKeyRange)} keyRange A key range to
   *        use to filter the records by matching the values of their primary
   *        keys against this key range.
   * @param {(CursorDirection|string)} direction The direction in which the
   *        cursor will traverse the records. Use either the
   *        {@code CursorDirection.*} constants, or strings {@code "NEXT"} and
   *        {@code "PREVIOUS"} (or {@code "PREV"} for short). The letter case
   *        used in the strings does not matter.
   * @param {function(ReadOnlyCursor)} recordCallback A callback executed every
   *        time the cursor traverses to a record.
   * @return {PromiseSync<number>} A promise that resolves to the number of
   *         records the cursor traversed.
   */
  openCursor(keyRange, direction, recordCallback) {
    return this.createCursorFactory(keyRange, direction)(recordCallback)
  }

  /**
   * Creates a factory function for opening cursors on this storage with the
   * specified configuration for the duration of the current transaction.
   *
   * @param {?(undefined|number|string|Date|Array|IDBKeyRange)=} keyRange A key
   *        range to use to filter the records by matching the values of their
   *        primary keys against this key range.
   * @param {(CursorDirection|string)=} direction The direction in which the
   *        cursor will traverse the records. Use either the
   *        {@code CursorDirection.*} constants, or strings {@code "NEXT"} and
   *        {@code "PREVIOUS"} (or {@code "PREV"} for short). The letter case
   *        used in the strings does not matter.
   * @return {function(function(ReadOnlyCursor)): PromiseSync<number>} A cursor
   *         factory. The factory accepts a callback to execute on every record
   *         the cursor iterates over. The promise returned by the factory
   *         resolves once the record callback does not invoke the
   *         {@code continue} nor the {@code advance} method synchronously or
   *         the cursor reaches the end of available records.
   */
  createCursorFactory(keyRange = undefined, direction = CursorDirection.NEXT) {
    if (keyRange === null) {
      keyRange = undefined
    }

    let cursorConstructor = this[FIELDS.cursorConstructor]

    if (typeof direction === "string") {
      if (CURSOR_DIRECTIONS.indexOf(direction.toUpperCase()) === -1) {
        throw new Error("When using a string as cursor direction, use NEXT " +
            `or PREVIOUS, ${direction} provided`);
      }
    } else {
      direction = direction.value
    }

    let cursorDirection = direction.toLowerCase().substring(0, 4)

    return (recordCallback) => {
      let request = this[FIELDS.storage].openCursor(keyRange, cursorDirection)
      return iterateCursor(request, cursorConstructor, recordCallback)
    }
  }
}

/**
 * Iterates the cursor to which the provided Indexed DB request resolves. The
 * method will iterate the cursor over the records in this storage within the
 * range specified when the cursor was opened until the provided callback does
 * not request iterating to the next record or the last matching record is
 * reached.
 *
 * @param {IDBRequest} request Indexed DB request that resolves to a cursor
 *        every time the cursor iterates to a record.
 * @param {function(new: ReadOnlyCursor, IDBRequest, function(), function(IDBRequest): PromiseSync)} cursorConstructor
 *        Constructor of the cursor class to use to wrap the native IDBRequest
 *        producing the native IndexedDB cursor.
 * @param {function(ReadOnlyCursor)} recordCallback The callback to execute,
 *        passing a high-level cursor instance pointing to the current record
 *        in each iteration of the cursor.
 * @return {PromiseSync<number>} A promise that resolves to the number of
 *         records the cursor traversed.
 */
function iterateCursor(request, cursorConstructor, recordCallback) {
  return new PromiseSync((resolve, reject) => {
    let traversedRecords = 0
    let canIterate = true

    request.onerror = () => reject(request.error)
    request.onsuccess = () => {
      if (!canIterate) {
        console.warn("Cursor iteration was requested asynchronously, " +
            "ignoring the new cursor position")
        return
      }

      if (!request.result) {
        resolve(traversedRecords)
        return
      }

      traversedRecords++

      let iterationRequested = handleCursorIteration(
        request,
        cursorConstructor,
        recordCallback,
        reject
      )

      if (!iterationRequested) {
        canIterate = false
        resolve(traversedRecords)
      }
    }
  })
}

/**
 * Handles a single iteration of a Indexed DB cursor iterating the records in
 * the storage.
 *
 * @param {IDBRequest} request Indexed DB request resolved to a cursor.
 * @param {function(new: ReadOnlyCursor, IDBRequest, function(), function(IDBRequest): PromiseSync)} cursorConstructor
 *        Constructor of the high-level cursor implementation to use.
 * @param {function(ReadOnlyCursor)} recordCallback The callback to execute,
 *        passing a high-level cursor instance pointing to the current record.
 * @param {function(Error)} reject Callback to call if any sub-operation
 *        triggered by the callback results in an error.
 * @return {boolean} {@code true} if the cursor should iterate to the next
 *         record.
 */
function handleCursorIteration(request, cursorConstructor, recordCallback,
    reject) {
  let iterationRequested = false
  let cursor = new cursorConstructor(request, () => {
    iterationRequested = true
  }, (subRequest) => {
    return PromiseSync.resolve(subRequest).catch((error) => {
      reject(error)
      throw error
    })
  })

  try {
    recordCallback(cursor)
  } catch (error) {
    iterationRequested = false
    reject(error)
  }

  return iterationRequested
}

/**
 * Normalizes the provided compound key represented as an object into a
 * compound key representation compatible with the Indexed DB.
 *
 * @param {string[]} keyPaths The key paths of this storage.
 * @param {Object} key The compound key to normalize for use with the Indexed
 *        DB.
 * @return {Array<(number|string|Date|Array)>} Normalized compound key.
 */
function normalizeCompoundObjectKey(keyPaths, key) {
  let normalizedKey = []

  keyPaths.forEach((keyPath) => {
    let keyValue = key

    keyPath.split(".").forEach((fieldName) => {
      if (!keyValue.hasOwnProperty(fieldName)) {
        throw new Error(`The ${keyPath} key path is not defined in the ` +
            "provided compound key")
      }

      keyValue = keyValue[fieldName]
    })

    normalizedKey.push(keyValue)
  })

  return normalizedKey
}


import PromiseSync from "../PromiseSync"
import CursorDirection from "../object-store/CursorDirection"
import UpgradedDatabaseSchema from "../schema/UpgradedDatabaseSchema"
import Transaction from "../transaction/Transaction"

/**
 * Utility for fetching (and deleting) records from a database for the purpose
 * of database data migration between schema version upgrades.
 */
export default class RecordFetcher {
  /**
   * Uses the provided transaction, fetches all records from the specified
   * object stores and returns the fetched records.
   *
   * @param {IDBTransaction} nativeTransaction The Indexed DB transaction to
   *        use to fetch the records.
   * @param {{objectStore: string, preprocessor: function(*, (number|string|Date|Array)): (*|UpgradedDatabaseSchema.SKIP_RECORD|UpgradedDatabaseSchema.DELETE_RECORD)}[]} objectStores
   *        Names names of object stores from which all records should be
   *        fetched and a map/filter callback function executed to preprocess
   *        the records.
   *        The callback function will executed with the following arguments:
   *        - the record
   *        - the primary key of the record (will be frozen to prevent any
   *          modifications)
   *
   *        The callback may return either the preprocessed record, or
   *        {@linkcode UpgradedDatabaseSchema.SKIP_RECORD} to indicate that the
   *        record should be omitted from the records passed to the
   *        after-migration callback, or
   *        {@linkcode UpgradedDatabaseSchema.DELETE_RECORD} to both omit the
   *        record from the records passed to the after-migration callback and
   *        delete the record.
   * @return {PromiseSync<Object<string, {key: (number|string|Date|Array), record: *}[]>>}
   *         A promise that resolves to a map of object store names to the
   *         records fetched from the object store, except for the records
   *         marked for skipping or deletion.
   */
  fetchRecords(nativeTransaction, objectStores) {
    if (!objectStores.length) {
      throw new Error("The object stores array cannot be empty")
    }

    let transaction = new Transaction(nativeTransaction, () => transaction)

    return fetchAllRecords(transaction, objectStores)
  }
}

/**
 * Fetches all records from the specified object stores using the provided
 * read-write transaction.
 *
 * @param {Transaction} transaction The read-write transaction to use to access
 *        the object stores.
 * @param {(string|{objectStore: string, preprocessor: function(*, (number|string|Date|Array)): (*|UpgradedDatabaseSchema.SKIP_RECORD|UpgradedDatabaseSchema.DELETE_RECORD)=})[]} objectStores
 *        The names of object stores that should have their records fetch or
 *        (possibly partially filled) object store fetch descriptors, mixed in
 *        an array.
 * @return {PromiseSync<Object<string, {key: (number|string|Date|Array), record: *}[]>>}
 *         A promise that resolves once all the records have been fetched and
 *         the records marked for deletion were deleted.
 *         The promise resolves to a map of object store names to the records
 *         fetched from the object store, except for the records marked for
 *         skipping or deletion.
 */
function fetchAllRecords(transaction, objectStores) {
  return PromiseSync.all(objectStores.map((descriptor) => {
    return fetchRecords(
      transaction.getObjectStore(descriptor.objectStore),
      descriptor.preprocessor
    )
  })).then((fetchedRecords) => {
    let recordsMap = {}

    for (let i = 0; i < objectStores.length; i++) {
      recordsMap[objectStores[i].objectStore] = fetchedRecords[i]
    }

    return recordsMap
  })
}

/**
 * Extracts all records from the provided object store and preprocess them
 * using the provided preprocessor.
 *
 * The method traverses the records of the object store in ascending order of
 * their primary keys, deleting the records for which the preprocessor returns
 * the {@linkcode UpgradedDatabaseSchema.DELETE_RECORD} before traversing to
 * the next record.
 *
 * @param {ObjectStore} objectStore The read-write accessor the object store
 *        from which the records should be read.
 * @param {function(*, (number|string|Date|Array)): (*|UpgradedDatabaseSchema.SKIP_RECORD|UpgradedDatabaseSchema.DELETE_RECORD)} preprocessor
 *        The callback to call on each record. The value returned by it will be
 *        stored in the resulting record array instead of the original record.
 *        The record will not be included in the resulting record array if the
 *        preprocessor returns {@linkcode UpgradedDatabaseSchema.SKIP_RECORD}
 *        or {@linkcode UpgradedDatabaseSchema.DELETE_RECORD}.
 * @return {PromiseSync<{key: (number|string|Date|Array), record: *}[]>} A
 *         promise that resolves once all records in the object store have been
 *         traversed. The promise will resolve to an array of the records
 *         processed by the provided record preprocessor, in the order they
 *         were traversed, and not containing the records that the preprocessor
 *         marked as to be skipped or deleted.
 */
function fetchRecords(objectStore, preprocessor) {
  return new PromiseSync((resolve, reject) => {
    let records = []

    objectStore.openCursor(null, CursorDirection.NEXT, (cursor) => {
      let primaryKey = cursor.primaryKey
      if (primaryKey instanceof Object) {
        Object.freeze(primaryKey)
      }

      let preprocessedRecord = preprocessor(cursor.record, primaryKey)
      if (preprocessedRecord === UpgradedDatabaseSchema.DELETE_RECORD) {
        cursor.delete()
        cursor.continue()
        return
      } else if (preprocessedRecord !== UpgradedDatabaseSchema.SKIP_RECORD) {
        records.push({
          key: primaryKey,
          record: preprocessedRecord
        })
      } else {
        // SKIP_RECORD returned, do nothing
      }

      cursor.continue()
    }).then(() => resolve(records)).catch(error => reject(error))
  })
}


"use strict";

const mkdirp = require("mkdirp")
    , fs = require("fs")
    , abs = require("abs")
    , typpy = require("typpy")
    , path = require("path")
    ;

/**
 * writeFileP
 * Create the directory structure and then create the file.
 *
 * @name writeFileP
 * @function
 * @param {String} outputPath The path to the file you want to create.
 * @param {String|Object} data The file content. If an Array/Object is provided, it will be stringified.
 * @param {Function} cb The callback function.
 */
function writeFileP (outputPath, data, cb) {
    outputPath = abs(outputPath);
    let dirname = path.dirname(outputPath);
    mkdirp(dirname, err => {
        if (err) { return cb(err); }
        let str = data;
        if (typpy(data, Array) || typpy(data, Object)) {
            str = JSON.stringify(data, null, 2);
        }
        fs.writeFile(outputPath, str, err => cb(err, data));
    });
}

/**
 * writeFileP.sync
 * The sync version of the function.
 *
 * @name writeFileP.sync
 * @function
 * @param {String} outputPath The path to the file you want to create.
 * @param {String|Object} data The file content. If an Array/Object is provided, it will be stringified.
 * @returns {String|Object} The content written in the file. If an object was provided, the stringified version will *not* be returned but the raw value.
 */
writeFileP.sync = (outputPath, data) => {
    outputPath = abs(outputPath);
    let dirname = path.dirname(outputPath);
    mkdirp.sync(dirname);
    let str = data;
    if (typpy(data, Array) || typpy(data, Object)) {
        str = JSON.stringify(data, null, 2);
    }
    fs.writeFileSync(outputPath, str);
    return data;
};

module.exports = writeFileP;


import ReadOnlyTransaction from "./transaction/ReadOnlyTransaction"
import Transaction from "./transaction/Transaction"

/**
 * Constants representing the available transaction modes.
 *
 * @type {Object<string, string>}
 */
const TRANSACTION_MODES = Object.freeze({
  READ_ONLY: "readonly",
  READ_WRITE: "readwrite"
})

/**
 * Private field symbols.
 */
const FIELDS = Object.freeze({
  database: Symbol("database"),
  versionChangeListeners: Symbol("versionChangeListeners"),
  activeTransactions: Symbol("activeTransactions")
})

/**
 * Database connection, used to create transactions for accessing and
 * manipulating the data in the database.
 */
export default class Database {
  /**
   * Initializes the database connection.
   *
   * @param {IDBDatabase} database The native connection to the Indexed DB
   *        database.
   */
  constructor(database) {
    /**
     * The name of the database.
     *
     * @type {string}
     */
    this.name = database.name

    /**
     * The current version of the database, specified as a positive integer.
     *
     * @type {number}
     */
    this.version = database.version

    /**
     * The names of the object stores available in the database. The names are
     * sorted in the ascending order.
     *
     * @type {string[]}
     */
    this.objectStoreNames =
        Object.freeze(Array.from(database.objectStoreNames))

    /**
     * The native connection to the Indexed DB database.
     *
     * @type {IDBDatabase}
     */
    this[FIELDS.database] = database

    /**
     * Listeners to be executed on the {@code versionchange} event.
     *
     * @type {Set<function(number)>}
     */
    this[FIELDS.versionChangeListeners] = new Set()

    /**
     * The transactions that currently are in progress.
     *
     * @type {Set<ReadOnlyTransaction>}
     */
    this[FIELDS.activeTransactions] = new Set()

    database.onversionchange = (event) => {
      let newVersion = event.newVersion

      this[FIELDS.versionChangeListeners].forEach((listener) => {
        try {
          listener(newVersion)
        } catch (error) {
          console.error("An event listener threw an error", error)
        }
      })
    }

    this.addVersionChangeListener(() => {
      if (this[FIELDS.versionChangeListeners].size !== 1) {
        return
      }

      console.warn("The database just received a versionchange event, but " +
          "no custom event listener has been registered for this event. " +
          "The connection to the database will therefore remain open and " +
          "the database upgrade will be blocked")
    })
  }

  /**
   * Registers the provided listener to be executed on the
   * {@code versionchange} event. The event listener will be executed with the
   * version number to which the database is being upgraded.
   *
   * It is generally recommended to close the database connection from an event
   * listener registered using this method, in order to allow the database to
   * be upgraded, and then request the user to reload the page, or perform the
   * reload automatically (while, preferably, preserving any user data).
   *
   * @param {function(number)} listener The listener to register.
   */
  addVersionChangeListener(listener) {
    this[FIELDS.versionChangeListeners].add(listener)
  }

  /**
   * Starts a new read-write transaction with the access to the specified
   * object stores.
   *
   * The created transaction will have an exclusive lock on the specified
   * object store, allowing no other, read-only or read-write, transaction to
   * access any of them until this transaction is finished. Any transaction
   * created after this one with access to any of the same object stores will
   * queue its operations and wait for this transaction to finish.
   *
   * A transaction is considered finished once it has been aborted, failed due
   * to an error, or committed. The transaction is committed automatically once
   * there are no pending requests to be fulfilled.
   *
   * The details of the transaction life cycle are available at
   * http://www.w3.org/TR/IndexedDB/#dfn-transaction-lifetime.
   *
   * @param {(...string|string[])} objectStoreNames The names of the object
   *        stores the created transaction should have access to.
   * @return {Transaction} The created transaction.
   * @see http://w3c.github.io/IndexedDB/#dfn-transaction-lifetime
   */
  startTransaction(...objectStoreNames) {
    if (objectStoreNames[0] instanceof Array) {
      objectStoreNames = objectStoreNames[0]
    }

    let nativeTransaction = this[FIELDS.database].transaction(
      objectStoreNames,
      TRANSACTION_MODES.READ_WRITE
    )

    let transaction = new Transaction(nativeTransaction, (objectStoreName) => {
      return this.startReadOnlyTransaction(objectStoreName)
    })

    this[FIELDS.activeTransactions].add(transaction)
    transaction.completionPromise.catch(() => {}).then(() => {
      this[FIELDS.activeTransactions].delete(transaction)
    })

    return transaction
  }

  /**
   * Starts a new read-only transaction with the access to the specified object
   * stores.
   *
   * The created transaction will have a shared lock on the specified object
   * store, allowing other read-only transaction to access the same object
   * stores simultaneously, but blocking any ready-write transaction with
   * access to any object store until this transaction is finished.
   *
   * A transaction is considered finished once it has been aborted, failed due
   * to an error, or ended. The transaction is ended automatically once there
   * are no pending requests to be fulfilled.
   *
   * The details of the transaction life cycle are available at
   * http://www.w3.org/TR/IndexedDB/#dfn-transaction-lifetime.
   *
   * @param {(...string|string[])} objectStoreNames The names of the object
   *        stores the created transaction should have access to.
   * @return {ReadOnlyTransaction} The created transaction.
   * @see http://w3c.github.io/IndexedDB/#dfn-transaction-lifetime
   */
  startReadOnlyTransaction(...objectStoreNames) {
    if (objectStoreNames[0] instanceof Array) {
      objectStoreNames = objectStoreNames[0]
    }

    let nativeTransaction = this[FIELDS.database].transaction(
      objectStoreNames,
      TRANSACTION_MODES.READ_ONLY
    )

    let transaction = new ReadOnlyTransaction(nativeTransaction,
        (objectStoreName) => {
      return this.startReadOnlyTransaction(objectStoreName)
    })

    this[FIELDS.activeTransactions].add(transaction)
    transaction.completionPromise.catch(() => {}).then(() => {
      this[FIELDS.activeTransactions].delete(transaction)
    })

    return transaction
  }

  /**
   * Creates a new read-only transaction with access only to the specified
   * object store and returns the object store access object.
   *
   * The object store accessor will be available for use only as long as the
   * created transaction is active.
   *
   * @param {string} objectStoreName The name of the object store.
   * @return {ReadOnlyObjectStore} A read-only access to the specified object
   *        store using a new read-only transaction.
   * @see startReadOnlyTransaction(...objectStoreNames)
   */
  getObjectStore(objectStoreName) {
    let transaction = this.startReadOnlyTransaction(objectStoreName)
    return transaction.getObjectStore(objectStoreName)
  }

  /**
   * Runs the provided transaction operations on the specified object stores
   * in a new read-write transaction.
   *
   * The created transaction will have an exclusive lock on the specified
   * object store, allowing no other, read-only or read-write, transaction to
   * access any of them until this transaction is finished. Any transaction
   * created after this one with access to any of the same object stores will
   * queue its operations and wait for this transaction to finish.
   *
   * The method returns a promise resolved when the transaction completes.
   *
   * @param {string|string[]} objectStoreNames The name(s) of the object stores
   *        to pass to the {@linkcode transactionOperations} callback, or an
   *        array containing a single item - the array of object store names.
   *        It is possible to use a string if only a single object store is
   *        needed.
   * @param {function(...ObjectStore, function()): PromiseSync<*>} transactionOperations
   *        The callback containing the operations on the object stores in the
   *        transaction.
   *        The callback will receive the requested object stores as arguments,
   *        followed by a callback for aborting the transaction.
   *        The callback should return a synchronous promise (a return value of
   *        one of the data-related methods of object stores and their
   *        indexes), the value to which the returned promise resolves will be
   *        the result value of the promise returned by this function.
   * @return {Promise<*>} A promise that resolves when the transaction is
   *         completed. The promise will resolve to the value to which resolved
   *         the promise returned by the {@linkcode transactionOperations}
   *         callback.
   */
  runTransaction(objectStoreNames, transactionOperations) {
    if (typeof objectStoreNames === "string") {
      objectStoreNames = [objectStoreNames]
    }

    let transaction = this.startTransaction(...objectStoreNames)
    return runTransaction(transaction, objectStoreNames, transactionOperations)
  }

  /**
   * Runs the provided transaction operations on the specified object stores
   * in a new read-only transaction.
   *
   * The created transaction will have a shared lock on the specified object
   * store, allowing other read-only transaction to access the same object
   * stores simultaneously, but blocking any ready-write transaction with
   * access to any object store until this transaction is finished.
   *
   * The method returns a promise resolved when the transaction completes.
   *
   * @param {string|string[]} objectStoreNames The name(s) of the object stores
   *        to pass to the {@linkcode transactionOperations} callback, or an
   *        array containing a single item - the array of object store names.
   *        It is possible to use a string if only a single object store is
   *        needed.
   * @param {function(...ReadOnlyObjectStore, function()): PromiseSync<*>} transactionOperations
   *        The callback containing the operations on the object stores in the
   *        transaction.
   *        The callback will receive the requested object stores as arguments,
   *        followed by a callback for aborting the transaction.
   *        The callback should return a synchronous promise (a return value of
   *        one of the data-related methods of object stores and their
   *        indexes), the value to which the returned promise resolves will be
   *        the result value of the promise returned by this function.
   * @return {Promise<*>} A promise that resolves when the transaction is
   *         completed. The promise will resolve to the value to which resolved
   *         the promise returned by the {@linkcode transactionOperations}
   *         callback.
   */
  runReadOnlyTransaction(objectStoreNames, transactionOperations) {
    if (typeof objectStoreNames === "string") {
      objectStoreNames = [objectStoreNames]
    }

    let transaction = this.startReadOnlyTransaction(...objectStoreNames)
    return runTransaction(transaction, objectStoreNames, transactionOperations)
  }

  /**
   * Closes this connection to the database.
   *
   * The connection is closed asynchronously, but no more operations can be
   * made using this connection once this method is called, even using the
   * still active transactions.
   *
   * The promise returned by this method resolves once all pending transactions
   * started from this connection are completed (the connection is not actually
   * closed until all pending transactions are completed).
   *
   * Note that any pending {@code versionchange} transactions (database schema
   * upgrades or database deletions) will not happen until the connection is
   * actually closed (after the returned promise returns).
   *
   * @return {Promise<undefined>} A promise resolved when all pending
   *         transactions are completed.
   */
  close() {
    this[FIELDS.database].close()

    let transactions = Array.from(this[FIELDS.activeTransactions])
    return Promise.all(transactions.map((transaction) => {
      return transaction.completionPromise
    })).catch(() => {}).then(() => {})
  }
}

/**
 * Runs the provided transaction operations on the specified object stores
 * obtained from the provided transaction.
 *
 * The function returns a promise resolved when the transaction completes.
 *
 * @param {ReadOnlyTransaction} transaction The transaction from which the
 *        object stores will be retrieved. The returned promise will resolve
 *        when this transaction is completed.
 * @param {string[]} objectStoreNames The names of the object stores to pass to the
 *        {@linkcode transactionOperations} callback.
 * @param {function(...ReadOnlyObjectStore, function()): PromiseSync<*>} transactionOperations
 *        The callback containing the operations on the object stores in the
 *        transaction.
 *        The callback will receive the requested object stores as arguments,
 *        followed by a callback for aborting the transaction.
 *        The callback should return a synchronous promise (a return value of
 *        one of the data-related methods of object stores and their indexes),
 *        the value to which the returned promise resolves will be the result
 *        value of the promise returned by this function.
 * @return {Promise<*>} A promise that resolves when the transaction is
 *         completed. The promise will resolve to the value to which resolved
 *         the promise returned by the {@linkcode transactionOperations}
 *         callback.
 */
function runTransaction(transaction, objectStoreNames, transactionOperations) {
  let callbackArguments = objectStoreNames.map((objectStoreName) => {
    return transaction.getObjectStore(objectStoreName)
  })
  callbackArguments.push(() => transaction.abort())

  let resultPromise = transactionOperations(...callbackArguments)
  return Promise.resolve(resultPromise).then((result) => {
    return transaction.completionPromise.then(() => result)
  })
}


import PromiseSync from "../PromiseSync"
import AbstractReadOnlyStorage from "./AbstractReadOnlyStorage"
import CursorDirection from "./CursorDirection"
import ReadOnlyCursor from "./ReadOnlyCursor"

/**
 * Values allowed as cursor directions.
 *
 * @type {(CursorDirection|string)[]}
 */
const CURSOR_DIRECTIONS = [
  CursorDirection.NEXT,
  CursorDirection.PREVIOUS,
  "NEXT",
  "PREVIOUS",
  "PREV"
]

/**
 * Private field symbols.
 */
const FIELDS = Object.freeze({
  storage: Symbol("storage"),
  cursorConstructor: Symbol("cursorConstructor")
})

/**
 * Read-only accessor for an index.
 */
export default class ReadOnlyIndex extends AbstractReadOnlyStorage {
  /**
   * Initializes the read-only index.
   *
   * @param {IDBIndex} storage The native Indexed DB index.
   * @param {function(new: ReadyOnlyCursor)} cursorConstructor Constructor of
   *        the cursor to use when traversing the storage records.
   * @param {function(): ReadOnlyTransaction} transactionFactory A function
   *        that creates and returns a new read-only transaction each time it
   *        is invoked.
   */
  constructor(storage, cursorConstructor, transactionFactory) {
    let storageFactory = () => {
      let transaction = transactionFactory()
      let objectStore = transaction.getObjectStore(storage.objectStore.name)
      return objectStore.index(storage.name)
    }
    super(storage, cursorConstructor, storageFactory)

    /**
     * When {@code true}, and a record's index key path evaluates to an array,
     * the index stores an index key value for each element of the evaluated
     * array.
     *
     * @type {boolean}
     */
    this.multiEntry = storage.multiEntry

    /**
     * When {@code true}, the index enforces that no records may share the same
     * index key value.
     *
     * @type {boolean}
     */
    this.unique = storage.unique

    /**
     * The native Indexed DB index.
     *
     * @type {IDBIndex}
     */
    this[FIELDS.storage] = storage

    /**
     * The constructor function of the cursor to use to create cursor
     * instances.
     *
     * @type {function(new: ReadyOnlyCursor)}
     */
    this[FIELDS.cursorConstructor] = cursorConstructor

    if (this.constructor === ReadOnlyIndex) {
      Object.freeze(this)
    }
  }

  /**
   * Retrieves the primary key of the first record matching the specified key
   * value or key range.
   *
   * @param {(number|string|Date|Array|IDBKeyRange)} key The index key or key
   *        range for which a record primary key should be retrieved.
   * @return {PromiseSync<(undefined|number|string|Date|Array)>} A promise that
   *         resolves to the primary key of the first record matching the
   *         specified index key or key range. The promise resolves to
   *         {@code undefined} if no record is found.
   */
  getPrimaryKey(key) {
    let request = this[FIELDS.storage].getKey(key)
    return PromiseSync.resolve(request)
  }

  /**
   * Traverses the keys in this index in the ascending order and resolves into
   * the primary keys of all traversed records.
   *
   * @return {PromiseSync<(number|string|Date|Array)[]>} A promise that
   *         resolves to a list of all record primary keys obtained by getting
   *         the primary of records traversed by traversing the key of this
   *         index in the ascending order.
   */
  getAllPrimaryKeys() {
    let primaryKeys = [];

    return this.openKeyCursor(null, CursorDirection.NEXT, false, (cursor) => {
      primaryKeys.push(cursor.primaryKey)
      cursor.continue()
    }).then(() => primaryKeys)
  }

  /**
   * Opens a read-only cursor that traverses the records of this index,
   * resolving to the traversed records.
   *
   * The returned promise resolves once the record callback does not invoke
   * the {@code continue} nor the {@code advance} method synchronously or the
   * cursor reaches the end of available records.
   *
   * @override
   * @param {?(IDBKeyRange)} keyRange A key range to use to filter the records
   *        by matching the values of their primary keys against this key
   *        range.
   * @param {(CursorDirection|string)} direction The direction in which the
   *        cursor will traverse the records. Use either the
   *        {@code CursorDirection.*} constants, or strings {@code "NEXT"} and
   *        {@code "PREVIOUS"} (or {@code "PREV"} for short). The letter case
   *        used in the strings does not matter.
   * @param {boolean} unique When {@code true}, it cursor will skip over the
   *        records stored with the same index key value.
   * @param {function(ReadOnlyCursor)} recordCallback A callback executed every
   *        time the cursor traverses to a record.
   * @return {PromiseSync<number>} A promise that resolves to the number of
   *         records the cursor traversed.
   */
  openCursor(keyRange, direction, unique, recordCallback) {
    let factory = this.createCursorFactory(keyRange, direction, unique)
    return factory(recordCallback)
  }

  /**
   * Opens a read-only cursor that traverses the records of this index,
   * resolving only the primary keys of the records.
   *
   * The returned promise resolves once the record callback does not invoke
   * the {@code continue} nor the {@code advance} method synchronously or the
   * cursor reaches the end of available records.
   *
   * The {@code record} field of the cursor will always be {@code null}.
   *
   * @param {?(IDBKeyRange)} keyRange A key range to use to filter the records
   *        by matching the values of their primary keys against this key
   *        range.
   * @param {(CursorDirection|string)} direction The direction in which the
   *        cursor will traverse the records. Use either the
   *        {@code CursorDirection.*} constants, or strings {@code "NEXT"} and
   *        {@code "PREVIOUS"} (or {@code "PREV"} for short). The letter case
   *        used in the strings does not matter.
   * @param {boolean} unique When {@code true}, it cursor will skip over the
   *        records stored with the same index key value.
   * @param {function(ReadOnlyCursor)} recordCallback A callback executed every
   *        time the cursor traverses to a record.
   * @return {PromiseSync<number>} A promise that resolves to the number of
   *         iterations the cursor has made (this may be larger than the number
   *         of records traversed if the index has its {@code multiEntry} flag
   *         set and some records repeatedly appear).
   */
  openKeyCursor(keyRange, direction, unique, recordCallback) {
    let factory = this.createKeyCursorFactory(keyRange, direction, unique)
    return factory(recordCallback)
  }

  /**
   * Creates a factory function for opening cursors on this storage with the
   * specified configuration for the duration of the current transaction.
   *
   * @override
   * @param {?(undefined|number|string|Date|Array|IDBKeyRange)=} keyRange A key
   *        range to use to filter the records by matching the values of their
   *        primary keys against this key range.
   * @param {(CursorDirection|string)=} direction The direction in which the
   *        cursor will traverse the records. Use either the
   *        {@code CursorDirection.*} constants, or strings {@code "NEXT"} and
   *        {@code "PREVIOUS"} (or {@code "PREV"} for short). The letter case
   *        used in the strings does not matter.
   * @param {boolean=} unique When {@code true}, it cursor will skip over the
   *        records stored with the same index key value.
   * @return {function(function(ReadOnlyCursor)): PromiseSync<number>} A cursor
   *         factory. The factory accepts a callback to execute on every record
   *         the cursor iterates over. The promise returned by the factory
   *         resolves once the record callback does not invoke the
   *         {@code continue} nor the {@code advance} method synchronously or
   *         the cursor reaches the end of available records.
   */
  createCursorFactory(keyRange = undefined, direction = CursorDirection.NEXT,
      unique = false) {
    if (keyRange === null) {
      keyRange = undefined
    }

    let cursorConstructor = this[FIELDS.cursorConstructor]
    let cursorDirection = toNativeCursorDirection(direction, unique)

    return (recordCallback) => {
      let request = this[FIELDS.storage].openCursor(keyRange, cursorDirection)
      return iterateCursor(request, cursorConstructor, recordCallback)
    }
  }

  /**
   * Creates a factory function for opening cursors on this storage with the
   * specified configuration for the duration of the current transaction.
   *
   * @param {?(undefined|number|string|Date|Array|IDBKeyRange)=} keyRange A key
   *        range to use to filter the records by matching the values of their
   *        primary keys against this key range.
   * @param {(CursorDirection|string)=} direction The direction in which the
   *        cursor will traverse the records. Use either the
   *        {@code CursorDirection.*} constants, or strings {@code "NEXT"} and
   *        {@code "PREVIOUS"} (or {@code "PREV"} for short). The letter case
   *        used in the strings does not matter.
   * @param {boolean=} unique When {@code true}, it cursor will skip over the
   *        records stored with the same index key value.
   * @return {function(function(ReadOnlyCursor)): PromiseSync<number>} A cursor
   *         factory. The factory accepts a callback to execute on every record
   *         the cursor iterates over. The promise returned by the factory
   *         resolves once the record callback does not invoke the
   *         {@code continue} nor the {@code advance} method synchronously or
   *         the cursor reaches the end of available records.
   */
  createKeyCursorFactory(keyRange = undefined,
      direction = CursorDirection.NEXT, unique = false) {
    if (keyRange === null) {
      keyRange = undefined
    }

    let cursorDirection = toNativeCursorDirection(direction, unique)

    return (recordCallback) => {
      let request;
      request = this[FIELDS.storage].openKeyCursor(keyRange, cursorDirection)
      return iterateCursor(request, ReadOnlyCursor, recordCallback)
    }
  }
}

/**
 * Iterates the cursor to which the provided Indexed DB request resolves. The
 * method will iterate the cursor over the records in this storage within the
 * range specified when the cursor was opened until the provided callback does
 * not request iterating to the next record or the last matching record is
 * reached.
 *
 * @param {IDBRequest} request Indexed DB request that resolves to a cursor
 *        every time the cursor iterates to a record.
 * @param {function(new: ReadOnlyCursor, IDBRequest, function(), function(IDBRequest): PromiseSync)} cursorConstructor
 *        Constructor of the cursor class to use to wrap the native IDBRequest
 *        producing the native IndexedDB cursor.
 * @param {function(ReadOnlyCursor)} recordCallback The callback to execute,
 *        passing a high-level cursor instance pointing to the current record
 *        in each iteration of the cursor.
 * @return {PromiseSync<number>} A promise that resolves to the number of
 *         records the cursor traversed.
 */
function iterateCursor(request, cursorConstructor, recordCallback) {
  return new PromiseSync((resolve, reject) => {
    let traversedRecords = 0
    let canIterate = true

    request.onerror = () => reject(request.error)
    request.onsuccess = () => {
      if (!canIterate) {
        console.warn("Cursor iteration was requested asynchronously, " +
            "ignoring the new cursor position")
      }

      if (!request.result) {
        resolve(traversedRecords)
        return
      }

      traversedRecords++

      let iterationRequested = handleCursorIteration(
        request,
        cursorConstructor,
        recordCallback,
        reject
      )

      if (!iterationRequested) {
        canIterate = false
        resolve(traversedRecords)
      }
    }
  })
}

/**
 * Handles a single iteration of a Indexed DB cursor iterating the records in
 * the storage.
 *
 * @param {IDBRequest} request Indexed DB request resolved to a cursor.
 * @param {function(new: ReadOnlyCursor, IDBRequest, function(), function(IDBRequest): PromiseSync)} cursorConstructor
 *        Constructor of the high-level cursor implementation to use.
 * @param {function(ReadOnlyCursor)} recordCallback The callback to execute,
 *        passing a high-level cursor instance pointing to the current record.
 * @param {function(Error)} reject Callback to call if any sub-operation
 *        triggered by the callback results in an error.
 * @return {boolean} {@code true} if the cursor should iterate to the next
 *         record.
 */
function handleCursorIteration(request, cursorConstructor, recordCallback,
    reject) {
  let iterationRequested = false
  let cursor = new cursorConstructor(request, () => {
    iterationRequested = true
  }, (subRequest) => {
    return PromiseSync.resolve(subRequest).catch((error) => {
      reject(error)
      throw error
    })
  })

  try {
    recordCallback(cursor)
  } catch (error) {
    iterationRequested = false
    reject(error)
  }

  return iterationRequested
}

/**
 * Returns the cursor direction to use with the native Indexed DB API.
 *
 * @param {(CursorDirection|string)=} direction The direction in which the
 *        cursor will traverse the records. Use either the
 *        {@code CursorDirection.*} constants, or strings {@code "NEXT"} and
 *        {@code "PREVIOUS"} (or {@code "PREV"} for short). The letter case
 *        used in the strings does not matter.
 * @param {boolean=} unique When {@code true}, it cursor will skip over the
 *        records stored with the same index key value.
 * @return {string} The cursor direction compatible with the native Indexed DB
 *         API.
 */
function toNativeCursorDirection(direction, unique) {
  if (typeof direction === "string") {
    if (CURSOR_DIRECTIONS.indexOf(direction.toUpperCase()) === -1) {
      throw new Error("When using a string as cursor direction, use NEXT " +
          `or PREVIOUS, ${direction} provided`);
    }
  } else {
    direction = direction.value
  }

  let cursorDirection = direction.toLowerCase().substring(0, 4)
  if (unique) {
    cursorDirection += "unique"
  }

  return cursorDirection
}


/**
 * Private field symbols.
 */
const FIELDS = Object.freeze({
  database: Symbol("database"),
  objectStore: Symbol("objectStore"),
  schema: Symbol("schema")
})

/**
 * Migrator of object store schemas.
 */
export default class ObjectStoreMigrator {
  /**
   * Initializes the object store migrator.
   *
   * @param {IDBDatabase} database The native Indexed DB database being
   *        migrated.
   * @param {?IDBObjectStore} nativeObjectStore The native Indexed DB object
   *        store being migrated. Set to {@code null} if the object store does
   *        not exist yet.
   * @param {ObjectStoreSchema} schema Schema descriptor of the version to
   *        which the database is to be upgraded.
   */
  constructor(database, nativeObjectStore, schema) {
    /**
     * The native Indexed DB database being migrated.
     *
     * @type {IDBDatabase}
     */
    this[FIELDS.database] = database

    /**
     * The native Indexed DB object store being migrated, or {@code null} if
     * the object store does not exist yet.
     *
     * @type {?IDBObjectStore}
     */
    this[FIELDS.objectStore] = nativeObjectStore

    /**
     * The schema to which the object store should be migrated.
     *
     * @type {ObjectStoreSchema}
     */
    this[FIELDS.schema] = schema

    Object.freeze(this)
  }

  /**
   * Processes the schema descriptor and migrates the object store. The object
   * store will be created if it does not already exist.
   */
  executeMigration() {
    let schema = this[FIELDS.schema]
    let objectStore = this[FIELDS.objectStore]
    if (!objectStore) {
      objectStore = this[FIELDS.database].createObjectStore(schema.name, {
        keyPath: schema.keyPath || null,
        autoIncrement: schema.autoIncrement
      })
    }

    let indexNames = Array.from(objectStore.indexNames)
    indexNames.forEach((indexName) => {
      if (shouldDeleteIndex(objectStore, schema, indexName)) {
        objectStore.deleteIndex(indexName)
      }
    })

    let schemaIndexes = schema.indexes || []
    schemaIndexes.forEach((indexSchema) => {
      createIndex(objectStore, indexSchema)
    })
  }
}

/**
 * Returns {@code true} if the index should be deleted from the object store,
 * whether because it is no longer present in the schema or its properties have
 * been updated in the schema.
 *
 * @param {IDBObjectStore} objectStore The native Indexed DB object store.
 * @param {ObjectStoreSchema} schema The schema of the object store.
 * @param {string} indexName The name of the index being tested whether it
 *        should be deleted.
 * @return {@code true} if the index should be deleted.
 */
function shouldDeleteIndex(objectStore, schema, indexName) {
  let schemaIndexes = schema.indexes || []
  let newIndexNames = schemaIndexes.map(indexSchema => indexSchema.name)

  if (newIndexNames.indexOf(indexName) === -1) {
    return true
  }

  let index = objectStore.index(indexName)
  let indexKeyPath = index.keyPath;
  if (indexKeyPath && (typeof indexKeyPath !== "string")) {
    indexKeyPath = Array.from(indexKeyPath)
  }
  let serializedIndexKeyPath = JSON.stringify(indexKeyPath)

  let indexSchema = schemaIndexes.filter((indexSchema) => {
    return indexSchema.name === index.name
  })[0]

  return (index.unique !== indexSchema.unique) ||
      (index.multiEntry !== indexSchema.multiEntry) ||
      (serializedIndexKeyPath !== JSON.stringify(indexSchema.keyPaths))
}

/**
 * Creates a new index in the provided object store according to the provided
 * index schema.
 *
 * @param {IDBObjectStore} objectStore The native Indexed DB object store.
 * @param {IndexSchema} indexSchema The schema of the index to create.
 */
function createIndex(objectStore, indexSchema) {
  let indexNames = Array.from(objectStore.indexNames)

  if (indexNames.indexOf(indexSchema.name) !== -1) {
    return
  }

  objectStore.createIndex(indexSchema.name, indexSchema.keyPath, {
    unique: indexSchema.unique,
    multiEntry: indexSchema.multiEntry
  })
}


import {idbProvider} from "../NativeDBAccessor"
import KeyRange from "./KeyRange"
import CursorDirection from "./CursorDirection"

/**
 * Private field symbols.
 */
const FIELDS = Object.freeze({
  storageFactory: Symbol("storageFactory"),
  nextKey: Symbol("nextKey"),
  firstPrimaryKey: Symbol("firstPrimaryKey"),
  cursorDirection: Symbol("cursorDirection"),
  unique: Symbol("unique"),
  filter: Symbol("filter"),
  pageSize: Symbol("pageSize"),
  hasNextPage: Symbol("hasNextPage")
})

/**
 * The record list is an array of records, representing a single page of record
 * listing.
 *
 * The record list provides API for easy fetching of the next page of records,
 * while not being dependent on the current transaction.
 */
export default class RecordList extends Array {
  /**
   * Initializes the record list.
   *
   * @param {*[]} items The records of the record list.
   * @param {function(): AbstractReadOnlyStorage} storageFactory A function
   *        that creates a new read-only transaction and returns this storage
   *        accessor each time it is invoked.
   * @param {(number|string|Date|Array)} nextKey The storage key of the first
   *        record to include in the next page.
   * @param {(number|string|Date|Array)} firstPrimaryKey The primary key of the
   *        first record to include in the next page.
   * @param {CursorDirection} cursorDirection The direction in which the
   *        records of the storage are traversed.
   * @param {boolean} unique Set to {@code true} if the key used by the storage
   *        to organize records has a unique value for each record.
   * @param {(undefined|function(*, (number|string|Date|Array), (number|string|Date|Array)): boolean)} filter
   *        The filter function restricting the records that will be listed.
   * @param {number} pageSize The number of records per page specified as a
   *        positive integer.
   * @param {boolean} hasNextPage Set to {@code true} if more records were
   *        available when this record list was initialized.
   */
  constructor(items, storageFactory, nextKey, firstPrimaryKey, cursorDirection,
      unique, filter, pageSize, hasNextPage) {
    super()

    if (items.length > pageSize) {
      throw new Error("The record list cannot be longer than the page size")
    }

    /**
     * A function that creates a new read-only transaction and returns this
     * storage accessor each time it is invoked.
     *
     * @type {function(): AbstractReadOnlyStorage}
     */
    this[FIELDS.storageFactory] = storageFactory

    /**
     * The storage key of the first record to include in the next page.
     *
     * @type {(number|string|Date|Array)}
     */
    this[FIELDS.nextKey] = nextKey

    /**
     * The primary key of the first record to include in the next page.
     *
     * @type {(number|string|Date|Array)}
     */
    this[FIELDS.firstPrimaryKey] = firstPrimaryKey

    /**
     * The direction in which the records of the storage are traversed.
     *
     * @type {CursorDirection}
     */
    this[FIELDS.cursorDirection] = cursorDirection

    /**
     * Set to {@code true} if the key used by the storage to organize records
     * has a unique value for each record.
     *
     * @type {boolean}
     */
    this[FIELDS.unique] = unique

    /**
     * The filter function restricting the records that will be listed.
     *
     * @type {(undefined|function(*, (number|string|Date|Array), (number|string|Date|Array)): boolean)}
     */
    this[FIELDS.filter] = filter

    /**
     * The number of records per page specified as a positive integer.
     *
     * @type {number}
     */
    this[FIELDS.pageSize] = pageSize

    /**
     * Set to {@code true} if more records were available when this record list
     * was initialized.
     *
     * @type {boolean}
     */
    this[FIELDS.hasNextPage] = hasNextPage

    this.push.apply(this, items)
  }

  /**
   * Returns {@code true} if a next page of records is available. The method
   * may return {@code true} even if there are no more records available
   * because they were deleted since this record list was initialized.
   *
   * @rerurn {@code true} if a next page of records is available.
   */
  get hasNextPage() {
    return this[FIELDS.hasNextPage]
  }

  /**
   * Fetches the next page of records. The records will be fetched in a new
   * read-only transaction.
   *
   * @return {Promise<RecordList<*>>} A promise that resolves to the records
   *         list containing the next page of records.
   */
  fetchNextPage() {
    if (!this.hasNextPage) {
      throw new Error("There are no more pages of records to fetch")
    }

    let storageFactory = this[FIELDS.storageFactory]
    let cursorDirection = this[FIELDS.cursorDirection]
    let unique = this[FIELDS.unique]
    let keyRange
    if (cursorDirection === CursorDirection.NEXT) {
      keyRange = KeyRange.lowerBound(this[FIELDS.nextKey])
    } else {
      keyRange = KeyRange.upperBound(this[FIELDS.nextKey])
    }
    let pageSize = this[FIELDS.pageSize]

    return fetchNextPage(storageFactory, keyRange, cursorDirection, unique,
        this[FIELDS.firstPrimaryKey], this[FIELDS.filter], pageSize)
  }
}

/**
 * Fetches the next page of records in a new ready-only transaction and
 * resolves into a record list containing the fetched records.
 *
 * @param {function(): AbstractReadOnlyStorage} storageFactory A function that
 *        creates a new read-only transaction and returns this storage accessor
 *        each time it is invoked.
 * @param {IDBKeyRange} keyRange The key range to use when opening the cursor
 *        in order to skip most of the records traversed previously.
 * @param {CursorDirection} cursorDirection The direction in which the records
 *        of the storage are traversed.
 * @param {boolean} unique Set to {@code true} if the key used by the storage
 *        to organize records has a unique value for each record.
 * @param {(number|string|Date|Array)} firstPrimaryKey The primary key of the
 *        first record to include to the result.
 * @param {(undefined|function(*, (number|string|Date|Array), (number|string|Date|Array)): boolean)} filter
 *        The filter function restricting the records that will be listed.
 *        If a function is provided, the first argument will be set to the
 *        record, the second argument will be set to the primary key of the
 *        record, and the third argument will be set to the key referencing the
 *        record (the primary key if traversing an object store).
 * @param {number} pageSize The maximum number of records per page. Must be a
 *        positive integer.
 * @return {Promise<RecordList<*>>} A promise that resolves to the next page of
 *         records.
 */
function fetchNextPage(storageFactory, keyRange, cursorDirection, unique,
    firstPrimaryKey, filter, pageSize) {
  let storage = storageFactory()

  let nextItems = []

  return new Promise((resolve, reject) => {
    let idb = idbProvider()
    let cursorFactory = storage.createCursorFactory(
      keyRange,
      cursorDirection,
      unique
    )

    cursorFactory((cursor) => {
      if (!unique) {
        let shouldSkip =
          (
            (cursorDirection === CursorDirection.NEXT) &&
            (idb.cmp(firstPrimaryKey, cursor.primaryKey) > 0)
          ) || (
            (cursorDirection === CursorDirection.PREVIOUS) &&
            (idb.cmp(firstPrimaryKey, cursor.primaryKey) < 0)
          )

        if (shouldSkip) {
          cursor.continue()
          return
        }
      }

      if (!filter || filter(cursor.record, cursor.primaryKey, cursor.key)) {
        if (nextItems.length === pageSize) {
          finalize(true, cursor.key, cursor.primaryKey)
          return
        } else {
          nextItems.push(cursor.record)
        }
      }

      cursor.continue()
    }).then(() => finalize(false, null, null)).catch(error => reject(error))

    function finalize(hasNextPage, nextKey, nextPrimaryKey) {
      resolve(new RecordList(nextItems, storageFactory, nextKey,
          nextPrimaryKey, cursorDirection, unique, filter, pageSize,
          hasNextPage))
    }
  })
}


import ReadOnlyObjectStore from "../object-store/ReadOnlyObjectStore"
import ReadOnlyCursor from "../object-store/ReadOnlyCursor"

/**
 * Private field symbols.
 */
const FIELDS = Object.freeze({
  transaction: Symbol("transaction"),
  transactionFactory: Symbol("transactionFactory"),
  objectStores: Symbol("objectStores"),
  completeListeners: Symbol("completeListeners"),
  abortListeners: Symbol("abortListeners"),
  errorListeners: Symbol("errorListeners"),
  flags: Symbol("flags")
})

/**
 * A transaction with only a read-only access to the selected object stores.
 */
export default class ReadOnlyTransaction {
  /**
   * Initializes the read-only transaction.
   *
   * @param {IDBTransaction} transaction The IndexedDB native transaction.
   * @param {function(string): ReadOnlyTransaction} transactionFactory The
   *        factory function that creates a new read-only transaction with
   *        access only the to the object store specified by the provided
   *        argument every time the function is invoked.
   */
  constructor(transaction, transactionFactory) {
    /**
     * The native IndexedDB transaction object.
     *
     * @type {IDBTransaction}
     */
    this[FIELDS.transaction] = transaction

    /**
     * The factory function that creates a new read-only transaction with
     * access only the to the object store specified by the provided argument
     * every time the function is invoked.
     *
     * @type {function(string): ReadOnlyTransaction}
     */
    this[FIELDS.transactionFactory] = transactionFactory

    /**
     * Cache of created object store instances. The keys are the names of the
     * object stores.
     *
     * @type {Map<string, ReadOnlyObjectStore>}
     */
    this[FIELDS.objectStores] = new Map()

    /**
     * Event listeners for the {@code complete} event.
     *
     * @type {Set<function()>}
     */
    this[FIELDS.completeListeners] = new Set()

    /**
     * Event listeners for the {@code abort} event.
     *
     * @type {Set<function()>}
     */
    this[FIELDS.abortListeners] = new Set()

    /**
     * Event listeners of the {@code error} event.
     *
     * @type {Set<function(Error)>}
     */
    this[FIELDS.errorListeners] = new Set()

    /**
     * Flags used for tracking the state of the transaction.
     *
     * @type {{aborted: boolean}}
     */
    this[FIELDS.flags] = {
      aborted: false
    }

    /**
     * A promise that resolves when the transaction is completed, and rejects
     * when it is aborted on encounters an unexpected error.
     *
     * @type {Promise<undefined>}
     */
    this.completionPromise = new Promise((resolve, reject) => {
      this.addCompleteListener(resolve)
      this.addAbortListener(() => {
        let abortError = new Error("The transaction has been aborted")
        abortError.name = "AbortError"
        reject(abortError)
      })
      this.addErrorListener((error) => {
        if (this[FIELDS.flags].aborted) {
          return // the transaction has been manually aborted
        }
        reject(error)
      })
    })

    transaction.oncomplete = () => {
      executeEventListeners(this[FIELDS.completeListeners])
    }

    transaction.onabort = () => {
      executeEventListeners(this[FIELDS.abortListeners])
    }

    transaction.onerror = (event) => {
      executeEventListeners(this[FIELDS.errorListeners], transaction.error)
      event.preventDefault()
    }

    this.addErrorListener((error) => {
      if (this[FIELDS.errorListeners].size < 2) {
        console.error("Encountered an uncaptured transaction-level error " +
            "while no error listeners were registered", error);
      }
    })

    if (this.constructor === ReadOnlyTransaction) {
      Object.freeze(this)
    }
  }

  /**
   * Registers the provided listener to be executed when the transaction is
   * completed.
   *
   * The order in which the event listeners will be executed is undefined and
   * should not be relied upon.
   *
   * This method provides a more low-level access to the transaction lifecycle
   * which can be useful in certain situations, however, it is recommended to
   * use the {@linkcode completionPromise} instead as it makes promise chaining
   * easier.
   *
   * @param {function()} listener The listener to register.
   */
  addCompleteListener(listener) {
    this[FIELDS.completeListeners].add(listener)
  }

  /**
   * Registers the provided listener to be executed when the transaction is
   * aborted by calling the {@linkcode abort()} method, or due to an error.
   *
   * The order in which the event listeners will be executed is undefined and
   * should not be relied upon.
   *
   * This method provides a more low-level access to the transaction lifecycle
   * which can be useful in certain situations, however, it is recommended to
   * use the {@linkcode completionPromise} instead as it makes promise chaining
   * easier.
   *
   * @param {function()} listener The listener to register.
   */
  addAbortListener(listener) {
    this[FIELDS.abortListeners].add(listener)
  }

  /**
   * Registers the provided listener to be executed when an error is
   * encountered during the manipulation of the database from this transaction,
   * and the error was not somehow captured during the processing of the
   * operation that caused the error.
   *
   * The order in which the event listeners will be executed is undefined and
   * should not be relied upon.
   *
   * This method provides a more low-level access to the transaction lifecycle
   * which can be useful in certain situations, however, it is recommended to
   * use the {@linkcode completionPromise} instead as it makes promise chaining
   * easier.
   *
   * @param {function(Error)} listener The listener to register.
   */
  addErrorListener(listener) {
    this[FIELDS.errorListeners].add(listener)
  }

  /**
   * Aborts this transaction. Calling this method will lead to the execution of
   * the abort listeners registered on this transaction.
   */
  abort() {
    this[FIELDS.flags].aborted = true
    Object.freeze(this[FIELDS.flags])
    this[FIELDS.transaction].abort()
  }

  /**
   * Returns the read-only object store of the specified name. The method
   * returns the same object store if called repeatedly with the same argument.
   *
   * @param {string} objectStoreName The name of the object store to retrieve.
   * @return {ReadOnlyObjectStore} The object store.
   */
  getObjectStore(objectStoreName) {
    if (this[FIELDS.objectStores].has(objectStoreName)) {
      return this[FIELDS.objectStores].get(objectStoreName)
    }

    let transactionFactory = () => {
      return this[FIELDS.transactionFactory](objectStoreName)
    }

    let idbObjectStore = this[FIELDS.transaction].objectStore(objectStoreName)
    let objectStore = new ReadOnlyObjectStore(
      idbObjectStore,
      ReadOnlyCursor,
      transactionFactory
    )
    this[FIELDS.objectStores].set(objectStoreName, objectStore)

    return objectStore
  }
}

/**
 * Executes the provided event listeners with the provided parameters. Any
 * errors thrown by the executed event listeners will be caught and logged to
 * the console, and then the remaining event listeners will be executed.
 *
 * @param {function(...*)[]} listeners The event listeners to execute.
 * @param {...*} parameters The parameters to pass to the event listeners as
 *        arguments.
 */
function executeEventListeners(listeners, ...parameters) {
  listeners.forEach((listener) => {
    try {
      listener.apply(null, parameters)
    } catch (error) {
      console.error("An event listener threw an error", error)
    }
  })
}