/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; const {classes: Cc, interfaces: Ci, utils: Cu} = Components; // Make it possible to load LoopStorage.jsm in xpcshell tests try { Cu.importGlobalProperties(["indexedDB"]); } catch (ex) { // don't write this is out in xpcshell, since it's expected there if (typeof window !== 'undefined' && "console" in window) { console.log("Failed to import indexedDB; if this isn't a unit test," + " something is wrong", ex); } } Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); XPCOMUtils.defineLazyGetter(this, "eventEmitter", function() { const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js", {}); return new EventEmitter(); }); this.EXPORTED_SYMBOLS = ["LoopStorage"]; const kDatabasePrefix = "loop-"; const kDefaultDatabaseName = "default"; let gDatabaseName = kDatabasePrefix + kDefaultDatabaseName; const kDatabaseVersion = 1; let gWaitForOpenCallbacks = new Set(); let gDatabase = null; let gClosed = false; /** * Properly shut the database instance down. This is done on application shutdown. */ const closeDatabase = function() { Services.obs.removeObserver(closeDatabase, "quit-application"); if (!gDatabase) { return; } gDatabase.close(); gDatabase = null; gClosed = true; }; /** * Open a connection to the IndexedDB database. * This function is different than IndexedDBHelper.jsm provides, as it ensures * only one connection is open during the lifetime of this API. Callbacks are * queued when a connection attempt is in progress and are invoked once the * connection is established. * * @param {Function} onOpen Callback to be invoked once a database connection is * established. It takes an Error object as first argument * and the database connection object as second argument, * if successful. */ const ensureDatabaseOpen = function(onOpen) { if (gClosed) { onOpen(new Error("Database already closed")); return; } if (gDatabase) { onOpen(null, gDatabase); return; } if (!gWaitForOpenCallbacks.has(onOpen)) { gWaitForOpenCallbacks.add(onOpen); if (gWaitForOpenCallbacks.size !== 1) { return; } } let invokeCallbacks = err => { for (let callback of gWaitForOpenCallbacks) { callback(err, gDatabase); } gWaitForOpenCallbacks.clear(); }; let openRequest = indexedDB.open(gDatabaseName, kDatabaseVersion); openRequest.onblocked = function(event) { invokeCallbacks(new Error("Database cannot be upgraded cause in use: " + event.target.error)); }; openRequest.onerror = function(event) { // Try to delete the old database so that we can start this process over // next time. indexedDB.deleteDatabase(gDatabaseName); invokeCallbacks(new Error("Error while opening database: " + event.target.errorCode)); }; openRequest.onupgradeneeded = function(event) { let db = event.target.result; eventEmitter.emit("upgrade", db, event.oldVersion, kDatabaseVersion); }; openRequest.onsuccess = function(event) { gDatabase = event.target.result; invokeCallbacks(); // Close the database instance properly on application shutdown. Services.obs.addObserver(closeDatabase, "quit-application", false); }; }; /** * Switch to a database with a different name by closing the current connection * and making sure that the next connection attempt will be made using the updated * name. * * @param {String} name New name of the database to switch to. */ const switchDatabase = function(name) { if (!name) { name = kDefaultDatabaseName; } name = kDatabasePrefix + name; if (name == gDatabaseName) { // This is already the current database, so there's no need to switch. return; } gDatabaseName = name; if (gDatabase) { try { gDatabase.close(); } finally { gDatabase = null; } } }; /** * Start a transaction on the loop database and return it. * * @param {String} store Name of the object store to start a transaction on * @param {Function} callback Callback to be invoked once a database connection * is established and a transaction can be started. * It takes an Error object as first argument and the * transaction object as second argument. * @param {String} mode Mode of the transaction. May be 'readonly' or 'readwrite' * * @note we can't use a Promise here, as they are resolved after a spin of the * event loop; the transaction will have finished by then and no operations * are possible anymore, yielding an error. */ const getTransaction = function(store, callback, mode) { ensureDatabaseOpen((err, db) => { if (err) { callback(err); return; } let trans; try { trans = db.transaction(store, mode); } catch(ex) { callback(ex); return; } callback(null, trans); }); }; /** * Start a transaction on the loop database and return the requested store. * * @param {String} store Name of the object store to retrieve * @param {Function} callback Callback to be invoked once a database connection * is established and a transaction can be started. * It takes an Error object as first argument and the * store object as second argument. * @param {String} mode Mode of the transaction. May be 'readonly' or 'readwrite' * * @note we can't use a Promise here, as they are resolved after a spin of the * event loop; the transaction will have finished by then and no operations * are possible anymore, yielding an error. */ const getStore = function(store, callback, mode) { getTransaction(store, (err, trans) => { if (err) { callback(err); return; } callback(null, trans.objectStore(store)); }, mode); }; /** * Public Loop Storage API. * * Since IndexedDB transaction can not stand a spin of the event loop _before_ * using a IDBTransaction object, we can't use Promise.jsm promises. Therefore * LoopStorage provides two async helper functions, `asyncForEach` and `asyncParallel`. * * LoopStorage implements the EventEmitter interface by exposing two methods, `on` * and `off`, to subscribe to events. * At this point only the `upgrade` event will be emitted. This happens when the * database is loaded in memory and consumers will be able to change its structure. */ this.LoopStorage = Object.freeze({ /** * @var {String} databaseName The name of the database that is currently active, * WITHOUT the prefix */ get databaseName() { return gDatabaseName.substr(kDatabasePrefix.length); }, /** * Open a connection to the IndexedDB database and return the database object. * * @param {Function} callback Callback to be invoked once a database connection * is established. It takes an Error object as first * argument and the database connection object as * second argument, if successful. */ getSingleton: function(callback) { ensureDatabaseOpen(callback); }, /** * Switch to a database with a different name. * * @param {String} name New name of the database to switch to. Defaults to * `kDefaultDatabaseName` */ switchDatabase: function(name = kDefaultDatabaseName) { switchDatabase(name); }, /** * Start a transaction on the loop database and return it. * If only two arguments are passed, the default mode will be assumed and the * second argument is assumed to be a callback. * * @param {String} store Name of the object store to start a transaction on * @param {Function} callback Callback to be invoked once a database connection * is established and a transaction can be started. * It takes an Error object as first argument and the * transaction object as second argument. * @param {String} mode Mode of the transaction. May be 'readonly' or 'readwrite' * * @note we can't use a Promise here, as they are resolved after a spin of the * event loop; the transaction will have finished by then and no operations * are possible anymore, yielding an error. */ getTransaction: function(store, callback, mode = "readonly") { getTransaction(store, callback, mode); }, /** * Start a transaction on the loop database and return the requested store. * If only two arguments are passed, the default mode will be assumed and the * second argument is assumed to be a callback. * * @param {String} store Name of the object store to retrieve * @param {Function} callback Callback to be invoked once a database connection * is established and a transaction can be started. * It takes an Error object as first argument and the * store object as second argument. * @param {String} mode Mode of the transaction. May be 'readonly' or 'readwrite' * * @note we can't use a Promise here, as they are resolved after a spin of the * event loop; the transaction will have finished by then and no operations * are possible anymore, yielding an error. */ getStore: function(store, callback, mode = "readonly") { getStore(store, callback, mode); }, /** * Perform an async function in serial on each of the list items and call a * callback Function when all list items are done. * IMPORTANT: only use this iteration method if you are sure that the operations * performed in `onItem` are guaranteed to be async in the success case. * * @param {Array} list Non-empty list of items to iterate * @param {Function} onItem Callback to invoke for each item in the list. It * takes the item is first argument and a callback * function as second, which is to be invoked once * the consumer is done with its async operation. If * an error is passed as the first argument to this * callback function, the iteration will stop and * `onDone` callback will be invoked with that error. * @param {callback} onDone Callback to invoke when the list is completed or * on error. It takes an Error object as first * argument. */ asyncForEach: function(list, onItem, onDone) { let i = 0; let len = list.length; if (!len) { onDone(new Error("Argument error: empty list")); return; } onItem(list[i], function handler(err) { if (err) { onDone(err); return; } i++; if (i < len) { onItem(list[i], handler, i); } else { onDone(); } }, i); }, /** * Perform an async function in parallel on each of the list items and call a * callback Function when all list items are done. * IMPORTANT: only use this iteration method if you are sure that the operations * performed in `onItem` are guaranteed to be async in the success case. * * @param {Array} list Non-empty list of items to iterate * @param {Function} onItem Callback to invoke for each item in the list. It * takes the item is first argument and a callback * function as second, which is to be invoked once * the consumer is done with its async operation. If * an error is passed as the first argument to this * callback function, the iteration will stop and * `onDone` callback will be invoked with that error. * @param {callback} onDone Callback to invoke when the list is completed or * on error. It takes an Error object as first * argument. */ asyncParallel: function(list, onItem, onDone) { let i = 0; let done = 0; let callbackCalled = false; let len = list.length; if (!len) { onDone(new Error("Argument error: empty list")); return; } for (; i < len; ++i) { onItem(list[i], function handler(err) { if (callbackCalled) { return; } if (err) { onDone(err); callbackCalled = true; return; } if (++done === len) { onDone(); callbackCalled = true; } }, i); } }, on: (...params) => eventEmitter.on(...params), off: (...params) => eventEmitter.off(...params) });