/* Copyright 2018 Google LLC Use of this source code is governed by an MIT-style license that can be found in the LICENSE file or at https://opensource.org/licenses/MIT. */ import '../_version.js'; /** * A class that wraps common IndexedDB functionality in a promise-based API. * It exposes all the underlying power and functionality of IndexedDB, but * wraps the most commonly used features in a way that's much simpler to use. * * @private */ export class DBWrapper { /** * @param {string} name * @param {number} version * @param {Object=} [callback] * @param {!Function} [callbacks.onupgradeneeded] * @param {!Function} [callbacks.onversionchange] Defaults to * DBWrapper.prototype._onversionchange when not specified. * @private */ constructor(name, version, { onupgradeneeded, onversionchange, } = {}) { this._db = null; this._name = name; this._version = version; this._onupgradeneeded = onupgradeneeded; this._onversionchange = onversionchange || (() => this.close()); } /** * Returns the IDBDatabase instance (not normally needed). * @return {IDBDatabase|undefined} * * @private */ get db() { return this._db; } /** * Opens a connected to an IDBDatabase, invokes any onupgradedneeded * callback, and added an onversionchange callback to the database. * * @return {IDBDatabase} * @private */ async open() { if (this._db) return; this._db = await new Promise((resolve, reject) => { // This flag is flipped to true if the timeout callback runs prior // to the request failing or succeeding. Note: we use a timeout instead // of an onblocked handler since there are cases where onblocked will // never never run. A timeout better handles all possible scenarios: // https://github.com/w3c/IndexedDB/issues/223 let openRequestTimedOut = false; setTimeout(() => { openRequestTimedOut = true; reject(new Error('The open request was blocked and timed out')); }, this.OPEN_TIMEOUT); const openRequest = indexedDB.open(this._name, this._version); openRequest.onerror = () => reject(openRequest.error); openRequest.onupgradeneeded = (evt) => { if (openRequestTimedOut) { openRequest.transaction.abort(); openRequest.result.close(); } else if (typeof this._onupgradeneeded === 'function') { this._onupgradeneeded(evt); } }; openRequest.onsuccess = () => { const db = openRequest.result; if (openRequestTimedOut) { db.close(); } else { db.onversionchange = this._onversionchange.bind(this); resolve(db); } }; }); return this; } /** * Polyfills the native `getKey()` method. Note, this is overridden at * runtime if the browser supports the native method. * * @param {string} storeName * @param {*} query * @return {Array} * @private */ async getKey(storeName, query) { return (await this.getAllKeys(storeName, query, 1))[0]; } /** * Polyfills the native `getAll()` method. Note, this is overridden at * runtime if the browser supports the native method. * * @param {string} storeName * @param {*} query * @param {number} count * @return {Array} * @private */ async getAll(storeName, query, count) { return await this.getAllMatching(storeName, { query, count }); } /** * Polyfills the native `getAllKeys()` method. Note, this is overridden at * runtime if the browser supports the native method. * * @param {string} storeName * @param {*} query * @param {number} count * @return {Array} * @private */ async getAllKeys(storeName, query, count) { const entries = await this.getAllMatching(storeName, { query, count, includeKeys: true }); return entries.map((entry) => entry.key); } /** * Supports flexible lookup in an object store by specifying an index, * query, direction, and count. This method returns an array of objects * with the signature . * * @param {string} storeName * @param {Object} [opts] * @param {string} [opts.index] The index to use (if specified). * @param {*} [opts.query] * @param {IDBCursorDirection} [opts.direction] * @param {number} [opts.count] The max number of results to return. * @param {boolean} [opts.includeKeys] When true, the structure of the * returned objects is changed from an array of values to an array of * objects in the form {key, primaryKey, value}. * @return {Array} * @private */ async getAllMatching(storeName, { index, query = null, // IE/Edge errors if query === `undefined`. direction = 'next', count, includeKeys = false, } = {}) { return await this.transaction([storeName], 'readonly', (txn, done) => { const store = txn.objectStore(storeName); const target = index ? store.index(index) : store; const results = []; const request = target.openCursor(query, direction); request.onsuccess = () => { const cursor = request.result; if (cursor) { results.push(includeKeys ? cursor : cursor.value); if (count && results.length >= count) { done(results); } else { cursor.continue(); } } else { done(results); } }; }); } /** * Accepts a list of stores, a transaction type, and a callback and * performs a transaction. A promise is returned that resolves to whatever * value the callback chooses. The callback holds all the transaction logic * and is invoked with two arguments: * 1. The IDBTransaction object * 2. A `done` function, that's used to resolve the promise when * when the transaction is done, if passed a value, the promise is * resolved to that value. * * @param {Array} storeNames An array of object store names * involved in the transaction. * @param {string} type Can be `readonly` or `readwrite`. * @param {!Function} callback * @return {*} The result of the transaction ran by the callback. * @private */ async transaction(storeNames, type, callback) { await this.open(); return await new Promise((resolve, reject) => { const txn = this._db.transaction(storeNames, type); txn.onabort = () => reject(txn.error); txn.oncomplete = () => resolve(); callback(txn, (value) => resolve(value)); }); } /** * Delegates async to a native IDBObjectStore method. * * @param {string} method The method name. * @param {string} storeName The object store name. * @param {string} type Can be `readonly` or `readwrite`. * @param {...*} args The list of args to pass to the native method. * @return {*} The result of the transaction. * @private */ async _call(method, storeName, type, ...args) { const callback = (txn, done) => { const objStore = txn.objectStore(storeName); // TODO(philipwalton): Fix this underlying TS2684 error. // @ts-ignore const request = objStore[method].apply(objStore, args); request.onsuccess = () => done(request.result); }; return await this.transaction([storeName], type, callback); } /** * Closes the connection opened by `DBWrapper.open()`. Generally this method * doesn't need to be called since: * 1. It's usually better to keep a connection open since opening * a new connection is somewhat slow. * 2. Connections are automatically closed when the reference is * garbage collected. * The primary use case for needing to close a connection is when another * reference (typically in another tab) needs to upgrade it and would be * blocked by the current, open connection. * * @private */ close() { if (this._db) { this._db.close(); this._db = null; } } } // Exposed on the prototype to let users modify the default timeout on a // per-instance or global basis. DBWrapper.prototype.OPEN_TIMEOUT = 2000; // Wrap native IDBObjectStore methods according to their mode. const methodsToWrap = { readonly: ['get', 'count', 'getKey', 'getAll', 'getAllKeys'], readwrite: ['add', 'put', 'clear', 'delete'], }; for (const [mode, methods] of Object.entries(methodsToWrap)) { for (const method of methods) { if (method in IDBObjectStore.prototype) { // Don't use arrow functions here since we're outside of the class. DBWrapper.prototype[method] = async function (storeName, ...args) { return await this._call(method, storeName, mode, ...args); }; } } }