/** * @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.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 * @implements {shaka.offline.IStorageEngine} */ shaka.offline.DBEngine = function() { goog.asserts.assert( shaka.offline.DBEngine.isSupported(), 'DBEngine should not be called when DBEngine is not supported'); /** @private {IDBDatabase} */ this.db_ = null; /** @private {!Array.} */ this.operations_ = []; /** @private {!Object.} */ this.currentIdMap_ = {}; }; /** * @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 {string} */ shaka.offline.DBEngine.DB_NAME_ = 'shaka_offline_db'; /** @private @const {number} */ shaka.offline.DBEngine.DB_VERSION_ = 1; /** * 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. * @return {!Promise} */ shaka.offline.DBEngine.deleteDatabase = function() { if (!window.indexedDB) return Promise.resolve(); var request = window.indexedDB.deleteDatabase(shaka.offline.DBEngine.DB_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; }; /** @override */ shaka.offline.DBEngine.prototype.initialized = function() { return this.db_ != null; }; /** @override */ shaka.offline.DBEngine.prototype.init = function(storeMap, opt_retryCount) { goog.asserts.assert(!this.db_, 'Already initialized'); return this.createConnection_(storeMap, opt_retryCount).then(function() { // For each store, get the next ID and store in the map. var stores = Object.keys(storeMap); return Promise.all(stores.map(function(store) { return this.getNextId_(store).then(function(id) { this.currentIdMap_[store] = id; }.bind(this)); }.bind(this))); }.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.get = function(storeName, key) { /** @type {!IDBRequest} */ var request; return this.createTransaction_(storeName, 'readonly', function(store) { request = store.get(key); }).then(function() { return request.result; }); }; /** @override */ shaka.offline.DBEngine.prototype.forEach = function(storeName, callback) { return this.createTransaction_(storeName, 'readonly', function(store) { var request = store.openCursor(); request.onsuccess = function(event) { var cursor = event.target.result; if (cursor) { callback(cursor.value); cursor.continue(); } }; }); }; /** @override */ shaka.offline.DBEngine.prototype.insert = function(storeName, value) { return this.createTransaction_(storeName, 'readwrite', function(store) { store.put(value); }); }; /** @override */ shaka.offline.DBEngine.prototype.remove = function(storeName, key) { return this.createTransaction_(storeName, 'readwrite', function(store) { store.delete(key); }); }; /** @override */ shaka.offline.DBEngine.prototype.removeKeys = function(storeName, keys, opt_onKeyRemoved) { return this.createTransaction_(storeName, 'readwrite', function(store) { for (var i = 0; i < keys.length; i++) { var request = store.delete(keys[i]); request.onsuccess = opt_onKeyRemoved || function(event) { }; } }); }; /** @override */ shaka.offline.DBEngine.prototype.reserveId = function(storeName) { goog.asserts.assert(storeName in this.currentIdMap_, 'Store name must be passed to init()'); return this.currentIdMap_[storeName]++; }; /** * Gets the ID to start at. * * @param {string} storeName * @return {!Promise.} * @private */ shaka.offline.DBEngine.prototype.getNextId_ = function(storeName) { var id = 0; return this.createTransaction_(storeName, 'readonly', function(store) { var request = store.openCursor(null, 'prev'); request.onsuccess = function(event) { var cursor = event.target.result; if (cursor) { id = cursor.key + 1; } }; }).then(function() { return id; }); }; /** * 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) { goog.asserts.assert(this.db_, 'DBEngine must not be destroyed'); goog.asserts.assert(type == 'readonly' || type == 'readwrite', 'Type must be "readonly" or "readwrite"'); var op = { transaction: this.db_.transaction([storeName], type), promise: new shaka.util.PublicPromise() }; op.transaction.oncomplete = (function(event) { this.closeOperation_(op); op.promise.resolve(); }.bind(this)); // 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) { this.closeOperation_(op); shaka.offline.DBEngine.onError_(op.transaction, op.promise, event); }.bind(this)); // 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(); }.bind(this)); var store = op.transaction.objectStore(storeName); action(store); this.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); }; /** * Creates a new connection to the database. * * On IE/Edge, it is possible for the database to not be deleted when the * success callback is fired. This means that when we delete the database and * immediately create a new connection, we will connect to the old database. * If this happens, we need to close the connection and retry. * * @see https://goo.gl/hOYJvN * * @param {!Object.} storeMap * @param {number=} opt_retryCount * @return {!Promise} * @private */ shaka.offline.DBEngine.prototype.createConnection_ = function( storeMap, opt_retryCount) { var DBEngine = shaka.offline.DBEngine; var indexedDB = window.indexedDB; var request = indexedDB.open(DBEngine.DB_NAME_, DBEngine.DB_VERSION_); var upgraded = false; var createPromise = new shaka.util.PublicPromise(); request.onupgradeneeded = function(event) { upgraded = true; var db = event.target.result; goog.asserts.assert(event.oldVersion == 0, 'Must be upgrading from version 0'); goog.asserts.assert(db.objectStoreNames.length == 0, 'Version 0 database should be empty'); for (var name in storeMap) { db.createObjectStore(name, {keyPath: storeMap[name]}); } }; request.onsuccess = (function(event) { if (opt_retryCount && !upgraded) { event.target.result.close(); shaka.log.info('Didn\'t get an upgrade event... trying again.'); setTimeout(function() { var p = this.createConnection_(storeMap, opt_retryCount - 1); p.then(createPromise.resolve, createPromise.reject); }.bind(this), 1000); return; } goog.asserts.assert(opt_retryCount == undefined || upgraded, 'Should get upgrade event'); this.db_ = event.target.result; createPromise.resolve(); }.bind(this)); request.onerror = DBEngine.onError_.bind(null, request, createPromise); return createPromise; }; /** * 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) { if (errorSource.error) { promise.reject(new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.STORAGE, shaka.util.Error.Code.INDEXED_DB_ERROR, errorSource.error)); } else { promise.reject(new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.STORAGE, shaka.util.Error.Code.OPERATION_ABORTED)); } // Firefox will raise an error which will cause a karma failure. event.preventDefault(); };