Source: lib/offline/db_engine.js

/**
 * @license
 * Copyright 2016 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

goog.provide('shaka.offline.DBEngine');

goog.require('goog.asserts');
goog.require('shaka.log');
goog.require('shaka.offline.DBUpgrade');
goog.require('shaka.offline.DBUpgradeFromVersion0');
goog.require('shaka.offline.DBUpgradeFromVersion1');
goog.require('shaka.offline.DBUtils');
goog.require('shaka.offline.IStorageEngine');
goog.require('shaka.util.Error');
goog.require('shaka.util.Functional');
goog.require('shaka.util.PublicPromise');



/**
 * This manages all operations on an IndexedDB.  This wraps all operations
 * in Promises.  All Promises will resolve once the transaction has completed.
 * Depending on the browser, this may or may not be after the data is flushed
 * to disk.  https://goo.gl/zMOeJc
 *
 * @struct
 * @constructor
 * @param {string} name
 * @implements {shaka.offline.IStorageEngine}
 */
shaka.offline.DBEngine = function(name) {
  goog.asserts.assert(
      shaka.offline.DBEngine.isSupported(),
      'DBEngine should not be called when DBEngine is not supported');

  /** @private {string} */
  this.name_ = name;

  /** @private {IDBDatabase} */
  this.db_ = null;

  /** @private {!Array.<shaka.offline.DBEngine.Operation>} */
  this.operations_ = [];
};


/**
 * @typedef {{
 *   transaction: !IDBTransaction,
 *   promise: !shaka.util.PublicPromise
 * }}
 *
 * @property {!IDBTransaction} transaction
 *   The transaction that this operation is using.
 * @property {!shaka.util.PublicPromise} promise
 *   The promise associated with the operation.
 */
shaka.offline.DBEngine.Operation;


/** @private @const {number} */
shaka.offline.DBEngine.DB_VERSION_ = 2;


/**
 * Determines if the browsers supports IndexedDB.
 * @return {boolean}
 */
shaka.offline.DBEngine.isSupported = function() {
  return window.indexedDB != null;
};


/**
 * Delete the database.  There must be no open connections to the database.
 * @param {string} name
 * @return {!Promise}
 */
shaka.offline.DBEngine.deleteDatabase = function(name) {
  if (!window.indexedDB)
    return Promise.resolve();
  var request = window.indexedDB.deleteDatabase(name);

  var p = new shaka.util.PublicPromise();
  request.onsuccess = function(event) {
    goog.asserts.assert(event.newVersion == null, 'Unexpected database update');
    p.resolve();
  };
  request.onerror = shaka.offline.DBEngine.onError_.bind(null, request, p);
  return p;
};


/**
 * @param {number=} opt_updateRetries The number of times to init the database
 *                                    expecting an upgrade. If an upgrade does
 *                                    not happen, the init will fail.
 * @return {!Promise}
 */
shaka.offline.DBEngine.prototype.init = function(opt_updateRetries) {
  var name = this.name_;

  return Promise.resolve().then(function() {
    return shaka.offline.DBUtils.open(
        name,
        shaka.offline.DBEngine.DB_VERSION_,
        shaka.offline.DBEngine.onUpgrade,
        opt_updateRetries);
  }).then(function(db) {
    this.db_ = db;
  }.bind(this));
};


/** @override */
shaka.offline.DBEngine.prototype.destroy = function() {
  return Promise.all(this.operations_.map(function(op) {
    try {
      // If the transaction is considered finished but has not called the
      // callbacks yet, it will still be in the list and this call will fail.
      // Simply ignore errors.
      op.transaction.abort();
    } catch (e) {}

    var Functional = shaka.util.Functional;
    return op.promise.catch(Functional.noop);
  })).then(function() {
    goog.asserts.assert(this.operations_.length == 0,
                        'All operations should have been closed');
    if (this.db_) {
      this.db_.close();
      this.db_ = null;
    }
  }.bind(this));
};


/** @override */
shaka.offline.DBEngine.prototype.getManifest = function(key) {
  return this.get_(
      shaka.offline.DBUtils.StoreV2.MANIFEST,
      key);
};


/** @override */
shaka.offline.DBEngine.prototype.forEachManifest = function(each) {
  return this.forEach_(
      shaka.offline.DBUtils.StoreV2.MANIFEST,
      each);
};


/** @override */
shaka.offline.DBEngine.prototype.addManifest = function(value) {
  return this.add_(
      shaka.offline.DBUtils.StoreV2.MANIFEST,
      value);
};


/** @override */
shaka.offline.DBEngine.prototype.updateManifest = function(key, value) {
  return this.update_(
      shaka.offline.DBUtils.StoreV2.MANIFEST,
      key,
      value);
};


/** @override */
shaka.offline.DBEngine.prototype.removeManifests =
    function(keys, onKeyRemoved) {
  return this.remove_(
      shaka.offline.DBUtils.StoreV2.MANIFEST,
      keys,
      onKeyRemoved);
};


/** @override */
shaka.offline.DBEngine.prototype.getSegment = function(key) {
  return this.get_(
      shaka.offline.DBUtils.StoreV2.SEGMENT,
      key);
};


/** @override */
shaka.offline.DBEngine.prototype.forEachSegment = function(each) {
  return this.forEach_(
      shaka.offline.DBUtils.StoreV2.SEGMENT,
      each);
};


/** @override */
shaka.offline.DBEngine.prototype.addSegment = function(value) {
  return this.add_(
      shaka.offline.DBUtils.StoreV2.SEGMENT,
      value);
};


/** @override */
shaka.offline.DBEngine.prototype.removeSegments =
    function(keys, onKeyRemoved) {
  return this.remove_(
      shaka.offline.DBUtils.StoreV2.SEGMENT,
      keys,
      onKeyRemoved);
};


/**
 * @param {shaka.offline.DBUtils.StoreV2} store
 * @param {number} key
 * @return {!Promise<T>}
 * @template T
 * @private
 */
shaka.offline.DBEngine.prototype.get_ = function(store, key) {
  /** @const */
  var READ_ONLY = shaka.offline.DBUtils.Mode.READ_ONLY;

  /** @type {IDBRequest} */
  var request;
  return this.createTransaction_(store, READ_ONLY, function(store) {
    request = store.get(key);
  }).then(function() { return request.result; });
};


/**
 * @param {shaka.offline.DBUtils.StoreV2} store
 * @param {function(number, T)} each
 * @return {!Promise}
 * @template T
 * @private
 */
shaka.offline.DBEngine.prototype.forEach_ = function(store, each) {
  /** @const */
  var READ_ONLY = shaka.offline.DBUtils.Mode.READ_ONLY;

  /** @const */
  var noop = function() {};

  return this.createTransaction_(store, READ_ONLY, function(store) {
    shaka.offline.DBUtils.forEach(store, function(key, value, next) {
      each(key, value);
      next();
    }, noop);
  });
};


/**
 * @param {shaka.offline.DBUtils.StoreV2} store
 * @param {number} key
 * @param {T} value
 * @return {!Promise}
 * @template T
 * @private
 */
shaka.offline.DBEngine.prototype.update_ = function(store, key, value) {
  /** @const */
  var READ_WRITE = shaka.offline.DBUtils.Mode.READ_WRITE;

  return this.createTransaction_(store, READ_WRITE, function(store) {
    store.put(value, key);
  });
};


/**
 * @param {shaka.offline.DBUtils.StoreV2} store
 * @param {T} value
 * @return {!Promise<number>}
 * @template T
 * @private
 */
shaka.offline.DBEngine.prototype.add_ = function(store, value) {
  /** @const */
  var READ_WRITE = shaka.offline.DBUtils.Mode.READ_WRITE;

  /** @type {number} */
  var key;

  return this.createTransaction_(store, READ_WRITE, function(store) {
    var request = store.add(value);
    request.onsuccess = function(event) {
      key = event.target.result;
    };
  }).then(function() { return key; });
};


/**
 * @param {shaka.offline.DBUtils.StoreV2} store
 * @param {!Array<number>} keys
 * @param {?function(number)} onKeyRemoved
 * @return {!Promise}
 * @template T
 * @private
 */
shaka.offline.DBEngine.prototype.remove_ = function(store, keys, onKeyRemoved) {
  /** @const */
  var READ_WRITE = shaka.offline.DBUtils.Mode.READ_WRITE;

  return this.createTransaction_(store, READ_WRITE, function(store) {
    keys.forEach(function(key) {
      /** @type {IDBRequest} */
      var request = store.delete(key);
      request.onsuccess = function() {
        if (onKeyRemoved) {
          onKeyRemoved(key);
        }
      };
    });
  });
};


/**
 * Creates a new transaction for the given store name and calls |action| to
 * modify the store. The transaction will resolve or reject the promise
 * returned by this function.
 *
 * @param {string} storeName
 * @param {string} type
 * @param {!function(IDBObjectStore)} action
 *
 * @return {!Promise}
 * @private
 */
shaka.offline.DBEngine.prototype.createTransaction_ = function(storeName,
                                                               type,
                                                               action) {
  /** @const */
  var READ_ONLY = shaka.offline.DBUtils.Mode.READ_ONLY;
  /** @const */
  var READ_WRITE = shaka.offline.DBUtils.Mode.READ_WRITE;

  /** @type {!shaka.offline.DBEngine} */
  var self = this;

  goog.asserts.assert(self.db_, 'DBEngine must not be destroyed');
  goog.asserts.assert(type == READ_ONLY || type == READ_WRITE,
                      'Unexpected transaction type.');

  var op = {
    transaction: self.db_.transaction([storeName], type),
    promise: new shaka.util.PublicPromise()
  };

  op.transaction.oncomplete = function(event) {
    self.closeOperation_(op);
    op.promise.resolve();
  };

  // We will see an onabort call via:
  //   1. request error -> transaction error -> transaction abort
  //   2. transaction commit fail -> transaction abort
  // As any transaction error will result in an abort, it is better to listen
  // for an abort so that we will catch all failed transaction operations.
  op.transaction.onabort = function(event) {
    self.closeOperation_(op);
    shaka.offline.DBEngine.onError_(op.transaction, op.promise, event);
  };

  // We need to prevent default on the onerror event or else Firefox will
  // raise an error which will cause a karma failure. This will not stop the
  // onabort callback from firing.
  op.transaction.onerror = function(event) {
    event.preventDefault();
  };

  var store = op.transaction.objectStore(storeName);
  action(store);

  self.operations_.push(op);

  return op.promise;
};


/**
 * Close an open operation.
 *
 * @param {!shaka.offline.DBEngine.Operation} op
 * @private
 */
shaka.offline.DBEngine.prototype.closeOperation_ = function(op) {
  var i = this.operations_.indexOf(op);
  goog.asserts.assert(i >= 0, 'Operation must be in the list.');
  this.operations_.splice(i, 1);
};


/**
 * @param {number} oldVersion
 * @param {!IDBDatabase} db
 * @param {!IDBTransaction} transaction
 */
shaka.offline.DBEngine.onUpgrade = function(oldVersion, db, transaction) {
  /** @const {number} */
  var currentVersion = shaka.offline.DBEngine.DB_VERSION_;

  shaka.log.v1('Upgrading database from version ' + oldVersion +
      ' to version ' + currentVersion);

  /** @type {!Object.<number, !shaka.offline.DBUpgrade>} */
  var upgraders = {
    0: new shaka.offline.DBUpgradeFromVersion0(),
    1: new shaka.offline.DBUpgradeFromVersion1()
  };

  /** @type {shaka.offline.DBUpgrade} */
  var upgrader = upgraders[oldVersion];

  if (upgrader) {
    upgrader.upgrade(db, transaction);
  } else {
    var failureMessage = 'Attemping to upgrade from version ' + oldVersion +
                         ' which is not supported. To use offline, please' +
                         ' delete the offline storage.';
    throw new shaka.util.Error(
        shaka.util.Error.Severity.RECOVERABLE,
        shaka.util.Error.Category.STORAGE,
        shaka.util.Error.Code.INDEXED_DB_ERROR,
        failureMessage);
  }
};


/**
 * Rejects the given Promise using the error fromt the transaction.
 *
 * @param {!IDBTransaction|!IDBRequest} errorSource
 * @param {!shaka.util.PublicPromise} promise
 * @param {!Event} event
 * @private
 */
shaka.offline.DBEngine.onError_ = function(errorSource, promise, event) {

  var error;

  if (errorSource.error) {
    error = new shaka.util.Error(
        shaka.util.Error.Severity.CRITICAL,
        shaka.util.Error.Category.STORAGE,
        shaka.util.Error.Code.INDEXED_DB_ERROR,
        errorSource.error);
  } else {
    error = new shaka.util.Error(
        shaka.util.Error.Severity.CRITICAL,
        shaka.util.Error.Category.STORAGE,
        shaka.util.Error.Code.OPERATION_ABORTED);
  }

  promise.reject(error);

  // Firefox will raise an error which will cause a karma failure.
  event.preventDefault();
};