summaryrefslogtreecommitdiffstats
path: root/toolkit/modules/Sqlite.sys.mjs
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /toolkit/modules/Sqlite.sys.mjs
parentInitial commit. (diff)
downloadthunderbird-upstream.tar.xz
thunderbird-upstream.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r--toolkit/modules/Sqlite.sys.mjs1966
1 files changed, 1966 insertions, 0 deletions
diff --git a/toolkit/modules/Sqlite.sys.mjs b/toolkit/modules/Sqlite.sys.mjs
new file mode 100644
index 0000000000..991105dd76
--- /dev/null
+++ b/toolkit/modules/Sqlite.sys.mjs
@@ -0,0 +1,1966 @@
+/* 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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ Log: "resource://gre/modules/Log.sys.mjs",
+ PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "FinalizationWitnessService",
+ "@mozilla.org/toolkit/finalizationwitness;1",
+ "nsIFinalizationWitnessService"
+);
+
+// Regular expression used by isInvalidBoundLikeQuery
+var likeSqlRegex = /\bLIKE\b\s(?![@:?])/i;
+
+// Counts the number of created connections per database basename(). This is
+// used for logging to distinguish connection instances.
+var connectionCounters = new Map();
+
+// Tracks identifiers of wrapped connections, that are Storage connections
+// opened through mozStorage and then wrapped by Sqlite.sys.mjs to use its syntactic
+// sugar API. Since these connections have an unknown origin, we use this set
+// to differentiate their behavior.
+var wrappedConnections = new Set();
+
+/**
+ * Once `true`, reject any attempt to open or close a database.
+ */
+function isClosed() {
+ // If Barriers have not been initialized yet, just trust AppStartup.
+ if (
+ typeof Object.getOwnPropertyDescriptor(lazy, "Barriers").get == "function"
+ ) {
+ // It's still possible to open new connections at profile-before-change, so
+ // use the next phase here, as a fallback.
+ return Services.startup.isInOrBeyondShutdownPhase(
+ Ci.nsIAppStartup.SHUTDOWN_PHASE_XPCOMWILLSHUTDOWN
+ );
+ }
+ return lazy.Barriers.shutdown.client.isClosed;
+}
+
+var Debugging = {
+ // Tests should fail if a connection auto closes. The exception is
+ // when finalization itself is tested, in which case this flag
+ // should be set to false.
+ failTestsOnAutoClose: true,
+};
+
+/**
+ * Helper function to check whether LIKE is implemented using proper bindings.
+ *
+ * @param sql
+ * (string) The SQL query to be verified.
+ * @return boolean value telling us whether query was correct or not
+ */
+function isInvalidBoundLikeQuery(sql) {
+ return likeSqlRegex.test(sql);
+}
+
+// Displays a script error message
+function logScriptError(message) {
+ let consoleMessage = Cc["@mozilla.org/scripterror;1"].createInstance(
+ Ci.nsIScriptError
+ );
+ let stack = new Error();
+ consoleMessage.init(
+ message,
+ stack.fileName,
+ null,
+ stack.lineNumber,
+ 0,
+ Ci.nsIScriptError.errorFlag,
+ "component javascript"
+ );
+ Services.console.logMessage(consoleMessage);
+
+ // This `Promise.reject` will cause tests to fail. The debugging
+ // flag can be used to suppress this for tests that explicitly
+ // test auto closes.
+ if (Debugging.failTestsOnAutoClose) {
+ Promise.reject(new Error(message));
+ }
+}
+
+/**
+ * Gets connection identifier from its database file name.
+ *
+ * @param fileName
+ * A database file string name.
+ * @return the connection identifier.
+ */
+function getIdentifierByFileName(fileName) {
+ let number = connectionCounters.get(fileName) || 0;
+ connectionCounters.set(fileName, number + 1);
+ return fileName + "#" + number;
+}
+
+/**
+ * Convert mozIStorageError to common NS_ERROR_*
+ * The conversion is mostly based on the one in
+ * mozStoragePrivateHelpers::ConvertResultCode, plus a few additions.
+ *
+ * @param {integer} result a mozIStorageError result code.
+ * @returns {integer} an NS_ERROR_* result code.
+ */
+function convertStorageErrorResult(result) {
+ switch (result) {
+ case Ci.mozIStorageError.PERM:
+ case Ci.mozIStorageError.AUTH:
+ case Ci.mozIStorageError.CANTOPEN:
+ return Cr.NS_ERROR_FILE_ACCESS_DENIED;
+ case Ci.mozIStorageError.LOCKED:
+ return Cr.NS_ERROR_FILE_IS_LOCKED;
+ case Ci.mozIStorageError.READONLY:
+ return Cr.NS_ERROR_FILE_READ_ONLY;
+ case Ci.mozIStorageError.ABORT:
+ case Ci.mozIStorageError.INTERRUPT:
+ return Cr.NS_ERROR_ABORT;
+ case Ci.mozIStorageError.TOOBIG:
+ case Ci.mozIStorageError.FULL:
+ return Cr.NS_ERROR_FILE_NO_DEVICE_SPACE;
+ case Ci.mozIStorageError.NOMEM:
+ return Cr.NS_ERROR_OUT_OF_MEMORY;
+ case Ci.mozIStorageError.BUSY:
+ return Cr.NS_ERROR_STORAGE_BUSY;
+ case Ci.mozIStorageError.CONSTRAINT:
+ return Cr.NS_ERROR_STORAGE_CONSTRAINT;
+ case Ci.mozIStorageError.NOLFS:
+ case Ci.mozIStorageError.IOERR:
+ return Cr.NS_ERROR_STORAGE_IOERR;
+ case Ci.mozIStorageError.SCHEMA:
+ case Ci.mozIStorageError.MISMATCH:
+ case Ci.mozIStorageError.MISUSE:
+ case Ci.mozIStorageError.RANGE:
+ return Ci.NS_ERROR_UNEXPECTED;
+ case Ci.mozIStorageError.CORRUPT:
+ case Ci.mozIStorageError.EMPTY:
+ case Ci.mozIStorageError.FORMAT:
+ case Ci.mozIStorageError.NOTADB:
+ return Cr.NS_ERROR_FILE_CORRUPTED;
+ default:
+ return Cr.NS_ERROR_FAILURE;
+ }
+}
+/**
+ * Barriers used to ensure that Sqlite.sys.mjs is shutdown after all
+ * its clients.
+ */
+XPCOMUtils.defineLazyGetter(lazy, "Barriers", () => {
+ let Barriers = {
+ /**
+ * Public barrier that clients may use to add blockers to the
+ * shutdown of Sqlite.sys.mjs. Triggered by profile-before-change.
+ * Once all blockers of this barrier are lifted, we close the
+ * ability to open new connections.
+ */
+ shutdown: new lazy.AsyncShutdown.Barrier(
+ "Sqlite.sys.mjs: wait until all clients have completed their task"
+ ),
+
+ /**
+ * Private barrier blocked by connections that are still open.
+ * Triggered after Barriers.shutdown is lifted and `isClosed()` returns
+ * `true`.
+ */
+ connections: new lazy.AsyncShutdown.Barrier(
+ "Sqlite.sys.mjs: wait until all connections are closed"
+ ),
+ };
+
+ /**
+ * Observer for the event which is broadcasted when the finalization
+ * witness `_witness` of `OpenedConnection` is garbage collected.
+ *
+ * The observer is passed the connection identifier of the database
+ * connection that is being finalized.
+ */
+ let finalizationObserver = function (subject, topic, identifier) {
+ let connectionData = ConnectionData.byId.get(identifier);
+
+ if (connectionData === undefined) {
+ logScriptError(
+ "Error: Attempt to finalize unknown Sqlite connection: " +
+ identifier +
+ "\n"
+ );
+ return;
+ }
+
+ ConnectionData.byId.delete(identifier);
+ logScriptError(
+ "Warning: Sqlite connection '" +
+ identifier +
+ "' was not properly closed. Auto-close triggered by garbage collection.\n"
+ );
+ connectionData.close();
+ };
+ Services.obs.addObserver(finalizationObserver, "sqlite-finalization-witness");
+
+ /**
+ * Ensure that Sqlite.sys.mjs:
+ * - informs its clients before shutting down;
+ * - lets clients open connections during shutdown, if necessary;
+ * - waits for all connections to be closed before shutdown.
+ */
+ lazy.AsyncShutdown.profileBeforeChange.addBlocker(
+ "Sqlite.sys.mjs shutdown blocker",
+ async function () {
+ await Barriers.shutdown.wait();
+ // At this stage, all clients have had a chance to open (and close)
+ // their databases. Some previous close operations may still be pending,
+ // so we need to wait until they are complete before proceeding.
+ await Barriers.connections.wait();
+
+ // Everything closed, no finalization events to catch
+ Services.obs.removeObserver(
+ finalizationObserver,
+ "sqlite-finalization-witness"
+ );
+ },
+
+ function status() {
+ if (isClosed()) {
+ // We are waiting for the connections to close. The interesting
+ // status is therefore the list of connections still pending.
+ return {
+ description: "Waiting for connections to close",
+ state: Barriers.connections.state,
+ };
+ }
+
+ // We are still in the first stage: waiting for the barrier
+ // to be lifted. The interesting status is therefore that of
+ // the barrier.
+ return {
+ description: "Waiting for the barrier to be lifted",
+ state: Barriers.shutdown.state,
+ };
+ }
+ );
+
+ return Barriers;
+});
+
+const VACUUM_CATEGORY = "vacuum-participant";
+const VACUUM_CONTRACTID = "@sqlite.module.js/vacuum-participant;";
+var registeredVacuumParticipants = new Map();
+
+function registerVacuumParticipant(connectionData) {
+ let contractId = VACUUM_CONTRACTID + connectionData._identifier;
+ let factory = {
+ createInstance(iid) {
+ return connectionData.QueryInterface(iid);
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIFactory"]),
+ };
+ let cid = Services.uuid.generateUUID();
+ Components.manager
+ .QueryInterface(Ci.nsIComponentRegistrar)
+ .registerFactory(cid, contractId, contractId, factory);
+ Services.catMan.addCategoryEntry(
+ VACUUM_CATEGORY,
+ contractId,
+ contractId,
+ false,
+ false
+ );
+ registeredVacuumParticipants.set(contractId, { cid, factory });
+}
+
+function unregisterVacuumParticipant(connectionData) {
+ let contractId = VACUUM_CONTRACTID + connectionData._identifier;
+ let component = registeredVacuumParticipants.get(contractId);
+ if (component) {
+ Components.manager
+ .QueryInterface(Ci.nsIComponentRegistrar)
+ .unregisterFactory(component.cid, component.factory);
+ Services.catMan.deleteCategoryEntry(VACUUM_CATEGORY, contractId, false);
+ }
+}
+
+/**
+ * Connection data with methods necessary for closing the connection.
+ *
+ * To support auto-closing in the event of garbage collection, this
+ * data structure contains all the connection data of an opened
+ * connection and all of the methods needed for sucessfully closing
+ * it.
+ *
+ * By putting this information in its own separate object, it is
+ * possible to store an additional reference to it without preventing
+ * a garbage collection of a finalization witness in
+ * OpenedConnection. When the witness detects a garbage collection,
+ * this object can be used to close the connection.
+ *
+ * This object contains more methods than just `close`. When
+ * OpenedConnection needs to use the methods in this object, it will
+ * dispatch its method calls here.
+ */
+function ConnectionData(connection, identifier, options = {}) {
+ this._log = lazy.Log.repository.getLoggerWithMessagePrefix(
+ "Sqlite.sys.mjs",
+ `Connection ${identifier}: `
+ );
+ this._log.manageLevelFromPref("toolkit.sqlitejsm.loglevel");
+ this._log.debug("Opened");
+
+ this._dbConn = connection;
+
+ // This is a unique identifier for the connection, generated through
+ // getIdentifierByFileName. It may be used for logging or as a key in Maps.
+ this._identifier = identifier;
+
+ this._open = true;
+
+ this._cachedStatements = new Map();
+ this._anonymousStatements = new Map();
+ this._anonymousCounter = 0;
+
+ // A map from statement index to mozIStoragePendingStatement, to allow for
+ // canceling prior to finalizing the mozIStorageStatements.
+ this._pendingStatements = new Map();
+
+ // Increments for each executed statement for the life of the connection.
+ this._statementCounter = 0;
+
+ // Increments whenever we request a unique operation id.
+ this._operationsCounter = 0;
+
+ if ("defaultTransactionType" in options) {
+ this.defaultTransactionType = options.defaultTransactionType;
+ } else {
+ this.defaultTransactionType = convertStorageTransactionType(
+ this._dbConn.defaultTransactionType
+ );
+ }
+ // Tracks whether this instance initiated a transaction.
+ this._initiatedTransaction = false;
+ // Manages a chain of transactions promises, so that new transactions
+ // always happen in queue to the previous ones. It never rejects.
+ this._transactionQueue = Promise.resolve();
+
+ this._idleShrinkMS = options.shrinkMemoryOnConnectionIdleMS;
+ if (this._idleShrinkMS) {
+ this._idleShrinkTimer = Cc["@mozilla.org/timer;1"].createInstance(
+ Ci.nsITimer
+ );
+ // We wait for the first statement execute to start the timer because
+ // shrinking now would not do anything.
+ }
+
+ // Deferred whose promise is resolved when the connection closing procedure
+ // is complete.
+ this._deferredClose = lazy.PromiseUtils.defer();
+ this._closeRequested = false;
+
+ // An AsyncShutdown barrier used to make sure that we wait until clients
+ // are done before shutting down the connection.
+ this._barrier = new lazy.AsyncShutdown.Barrier(
+ `${this._identifier}: waiting for clients`
+ );
+
+ lazy.Barriers.connections.client.addBlocker(
+ this._identifier + ": waiting for shutdown",
+ this._deferredClose.promise,
+ () => ({
+ identifier: this._identifier,
+ isCloseRequested: this._closeRequested,
+ hasDbConn: !!this._dbConn,
+ initiatedTransaction: this._initiatedTransaction,
+ pendingStatements: this._pendingStatements.size,
+ statementCounter: this._statementCounter,
+ })
+ );
+
+ // We avoid creating a timer for every transaction, because in most cases they
+ // are not canceled and they are only used as a timeout.
+ // Instead the timer is reused when it's sufficiently close to the previous
+ // creation time (see `_getTimeoutPromise` for more info).
+ this._timeoutPromise = null;
+ // The last timestamp when we should consider using `this._timeoutPromise`.
+ this._timeoutPromiseExpires = 0;
+
+ this._useIncrementalVacuum = !!options.incrementalVacuum;
+ if (this._useIncrementalVacuum) {
+ this._log.debug("Set auto_vacuum INCREMENTAL");
+ this.execute("PRAGMA auto_vacuum = 2").catch(ex => {
+ this._log.error("Setting auto_vacuum to INCREMENTAL failed.");
+ console.error(ex);
+ });
+ }
+
+ this._expectedPageSize = options.pageSize ?? 0;
+ if (this._expectedPageSize) {
+ this._log.debug("Set page_size to " + this._expectedPageSize);
+ this.execute("PRAGMA page_size = " + this._expectedPageSize).catch(ex => {
+ this._log.error(`Setting page_size to ${this._expectedPageSize} failed.`);
+ console.error(ex);
+ });
+ }
+
+ this._vacuumOnIdle = options.vacuumOnIdle;
+ if (this._vacuumOnIdle) {
+ this._log.debug("Register as vacuum participant");
+ this.QueryInterface = ChromeUtils.generateQI([
+ Ci.mozIStorageVacuumParticipant,
+ ]);
+ registerVacuumParticipant(this);
+ }
+}
+
+/**
+ * Map of connection identifiers to ConnectionData objects
+ *
+ * The connection identifier is a human-readable name of the
+ * database. Used by finalization witnesses to be able to close opened
+ * connections on garbage collection.
+ *
+ * Key: _identifier of ConnectionData
+ * Value: ConnectionData object
+ */
+ConnectionData.byId = new Map();
+
+ConnectionData.prototype = Object.freeze({
+ get expectedDatabasePageSize() {
+ return this._expectedPageSize;
+ },
+
+ get useIncrementalVacuum() {
+ return this._useIncrementalVacuum;
+ },
+
+ /**
+ * This should only be used by the VacuumManager component.
+ * @see unsafeRawConnection for an official (but still unsafe) API.
+ */
+ get databaseConnection() {
+ if (this._vacuumOnIdle) {
+ return this._dbConn;
+ }
+ return null;
+ },
+
+ onBeginVacuum() {
+ let granted = !this.transactionInProgress;
+ this._log.debug("Begin Vacuum - " + granted ? "granted" : "denied");
+ return granted;
+ },
+
+ onEndVacuum(succeeded) {
+ this._log.debug("End Vacuum - " + succeeded ? "success" : "failure");
+ },
+
+ /**
+ * Run a task, ensuring that its execution will not be interrupted by shutdown.
+ *
+ * As the operations of this module are asynchronous, a sequence of operations,
+ * or even an individual operation, can still be pending when the process shuts
+ * down. If any of this operations is a write, this can cause data loss, simply
+ * because the write has not been completed (or even started) by shutdown.
+ *
+ * To avoid this risk, clients are encouraged to use `executeBeforeShutdown` for
+ * any write operation, as follows:
+ *
+ * myConnection.executeBeforeShutdown("Bookmarks: Removing a bookmark",
+ * async function(db) {
+ * // The connection will not be closed and shutdown will not proceed
+ * // until this task has completed.
+ *
+ * // `db` exposes the same API as `myConnection` but provides additional
+ * // logging support to help debug hard-to-catch shutdown timeouts.
+ *
+ * await db.execute(...);
+ * }));
+ *
+ * @param {string} name A human-readable name for the ongoing operation, used
+ * for logging and debugging purposes.
+ * @param {function(db)} task A function that takes as argument a Sqlite.sys.mjs
+ * db and returns a Promise.
+ */
+ executeBeforeShutdown(parent, name, task) {
+ if (!name) {
+ throw new TypeError("Expected a human-readable name as first argument");
+ }
+ if (typeof task != "function") {
+ throw new TypeError("Expected a function as second argument");
+ }
+ if (this._closeRequested) {
+ throw new Error(
+ `${this._identifier}: cannot execute operation ${name}, the connection is already closing`
+ );
+ }
+
+ // Status, used for AsyncShutdown crash reports.
+ let status = {
+ // The latest command started by `task`, either as a
+ // sql string, or as one of "<not started>" or "<closing>".
+ command: "<not started>",
+
+ // `true` if `command` was started but not completed yet.
+ isPending: false,
+ };
+
+ // An object with the same API as `this` but with
+ // additional logging. To keep logging simple, we
+ // assume that `task` is not running several queries
+ // concurrently.
+ let loggedDb = Object.create(parent, {
+ execute: {
+ value: async (sql, ...rest) => {
+ status.isPending = true;
+ status.command = sql;
+ try {
+ return await this.execute(sql, ...rest);
+ } finally {
+ status.isPending = false;
+ }
+ },
+ },
+ close: {
+ value: async () => {
+ status.isPending = true;
+ status.command = "<close>";
+ try {
+ return await this.close();
+ } finally {
+ status.isPending = false;
+ }
+ },
+ },
+ executeCached: {
+ value: async (sql, ...rest) => {
+ status.isPending = true;
+ status.command = "cached: " + sql;
+ try {
+ return await this.executeCached(sql, ...rest);
+ } finally {
+ status.isPending = false;
+ }
+ },
+ },
+ });
+
+ let promiseResult = task(loggedDb);
+ if (
+ !promiseResult ||
+ typeof promiseResult != "object" ||
+ !("then" in promiseResult)
+ ) {
+ throw new TypeError("Expected a Promise");
+ }
+ let key = `${this._identifier}: ${name} (${this._getOperationId()})`;
+ let promiseComplete = promiseResult.catch(() => {});
+ this._barrier.client.addBlocker(key, promiseComplete, {
+ fetchState: () => status,
+ });
+
+ return (async () => {
+ try {
+ return await promiseResult;
+ } finally {
+ this._barrier.client.removeBlocker(key, promiseComplete);
+ }
+ })();
+ },
+ close() {
+ this._closeRequested = true;
+
+ if (!this._dbConn) {
+ return this._deferredClose.promise;
+ }
+
+ this._log.debug("Request to close connection.");
+ this._clearIdleShrinkTimer();
+
+ if (this._vacuumOnIdle) {
+ this._log.debug("Unregister as vacuum participant");
+ unregisterVacuumParticipant(this);
+ }
+
+ return this._barrier.wait().then(() => {
+ if (!this._dbConn) {
+ return undefined;
+ }
+ return this._finalize();
+ });
+ },
+
+ clone(readOnly = false) {
+ this.ensureOpen();
+
+ this._log.debug("Request to clone connection.");
+
+ let options = {
+ connection: this._dbConn,
+ readOnly,
+ };
+ if (this._idleShrinkMS) {
+ options.shrinkMemoryOnConnectionIdleMS = this._idleShrinkMS;
+ }
+
+ return cloneStorageConnection(options);
+ },
+ _getOperationId() {
+ return this._operationsCounter++;
+ },
+ _finalize() {
+ this._log.debug("Finalizing connection.");
+ // Cancel any pending statements.
+ for (let [, /* k */ statement] of this._pendingStatements) {
+ statement.cancel();
+ }
+ this._pendingStatements.clear();
+
+ // We no longer need to track these.
+ this._statementCounter = 0;
+
+ // Next we finalize all active statements.
+ for (let [, /* k */ statement] of this._anonymousStatements) {
+ statement.finalize();
+ }
+ this._anonymousStatements.clear();
+
+ for (let [, /* k */ statement] of this._cachedStatements) {
+ statement.finalize();
+ }
+ this._cachedStatements.clear();
+
+ // This guards against operations performed between the call to this
+ // function and asyncClose() finishing. See also bug 726990.
+ this._open = false;
+
+ // We must always close the connection at the Sqlite.sys.mjs-level, not
+ // necessarily at the mozStorage-level.
+ let markAsClosed = () => {
+ this._log.debug("Closed");
+ // Now that the connection is closed, no need to keep
+ // a blocker for Barriers.connections.
+ lazy.Barriers.connections.client.removeBlocker(
+ this._deferredClose.promise
+ );
+ this._deferredClose.resolve();
+ };
+ if (wrappedConnections.has(this._identifier)) {
+ wrappedConnections.delete(this._identifier);
+ this._dbConn = null;
+ markAsClosed();
+ } else {
+ this._log.debug("Calling asyncClose().");
+ try {
+ this._dbConn.asyncClose(markAsClosed);
+ } catch (ex) {
+ // If for any reason asyncClose fails, we must still remove the
+ // shutdown blockers and resolve _deferredClose.
+ markAsClosed();
+ } finally {
+ this._dbConn = null;
+ }
+ }
+ return this._deferredClose.promise;
+ },
+
+ executeCached(sql, params = null, onRow = null) {
+ this.ensureOpen();
+
+ if (!sql) {
+ throw new Error("sql argument is empty.");
+ }
+
+ let statement = this._cachedStatements.get(sql);
+ if (!statement) {
+ statement = this._dbConn.createAsyncStatement(sql);
+ this._cachedStatements.set(sql, statement);
+ }
+
+ this._clearIdleShrinkTimer();
+
+ return new Promise((resolve, reject) => {
+ try {
+ this._executeStatement(sql, statement, params, onRow).then(
+ result => {
+ this._startIdleShrinkTimer();
+ resolve(result);
+ },
+ error => {
+ this._startIdleShrinkTimer();
+ reject(error);
+ }
+ );
+ } catch (ex) {
+ this._startIdleShrinkTimer();
+ throw ex;
+ }
+ });
+ },
+
+ execute(sql, params = null, onRow = null) {
+ if (typeof sql != "string") {
+ throw new Error("Must define SQL to execute as a string: " + sql);
+ }
+
+ this.ensureOpen();
+
+ let statement = this._dbConn.createAsyncStatement(sql);
+ let index = this._anonymousCounter++;
+
+ this._anonymousStatements.set(index, statement);
+ this._clearIdleShrinkTimer();
+
+ let onFinished = () => {
+ this._anonymousStatements.delete(index);
+ statement.finalize();
+ this._startIdleShrinkTimer();
+ };
+
+ return new Promise((resolve, reject) => {
+ try {
+ this._executeStatement(sql, statement, params, onRow).then(
+ rows => {
+ onFinished();
+ resolve(rows);
+ },
+ error => {
+ onFinished();
+ reject(error);
+ }
+ );
+ } catch (ex) {
+ onFinished();
+ throw ex;
+ }
+ });
+ },
+
+ get transactionInProgress() {
+ return this._open && this._dbConn.transactionInProgress;
+ },
+
+ executeTransaction(func, type) {
+ // Identify the caller for debugging purposes.
+ let caller = new Error().stack
+ .split("\n", 3)
+ .pop()
+ .match(/^([^@]*@).*\/([^\/:]+)[:0-9]*$/);
+ caller = caller[1] + caller[2];
+ this._log.debug(`Transaction (type ${type}) requested by: ${caller}`);
+
+ if (type == OpenedConnection.prototype.TRANSACTION_DEFAULT) {
+ type = this.defaultTransactionType;
+ } else if (!OpenedConnection.TRANSACTION_TYPES.includes(type)) {
+ throw new Error("Unknown transaction type: " + type);
+ }
+ this.ensureOpen();
+
+ // If a transaction yields on a never resolved promise, or is mistakenly
+ // nested, it could hang the transactions queue forever. Thus we timeout
+ // the execution after a meaningful amount of time, to ensure in any case
+ // we'll proceed after a while.
+ let timeoutPromise = this._getTimeoutPromise();
+
+ let promise = this._transactionQueue.then(() => {
+ if (this._closeRequested) {
+ throw new Error("Transaction canceled due to a closed connection.");
+ }
+
+ let transactionPromise = (async () => {
+ // At this point we should never have an in progress transaction, since
+ // they are enqueued.
+ if (this._initiatedTransaction) {
+ this._log.error(
+ "Unexpected transaction in progress when trying to start a new one."
+ );
+ }
+ try {
+ // We catch errors in statement execution to detect nested transactions.
+ try {
+ await this.execute("BEGIN " + type + " TRANSACTION");
+ this._log.debug(`Begin transaction`);
+ this._initiatedTransaction = true;
+ } catch (ex) {
+ // Unfortunately, if we are wrapping an existing connection, a
+ // transaction could have been started by a client of the same
+ // connection that doesn't use Sqlite.sys.mjs (e.g. C++ consumer).
+ // The best we can do is proceed without a transaction and hope
+ // things won't break.
+ if (wrappedConnections.has(this._identifier)) {
+ this._log.warn(
+ "A new transaction could not be started cause the wrapped connection had one in progress",
+ ex
+ );
+ } else {
+ this._log.warn(
+ "A transaction was already in progress, likely a nested transaction",
+ ex
+ );
+ throw ex;
+ }
+ }
+
+ let result;
+ try {
+ result = await Promise.race([func(), timeoutPromise]);
+ } catch (ex) {
+ // It's possible that the exception has been caused by trying to
+ // close the connection in the middle of a transaction.
+ if (this._closeRequested) {
+ this._log.warn(
+ "Connection closed while performing a transaction",
+ ex
+ );
+ } else {
+ // Otherwise the function didn't resolve before the timeout, or
+ // generated an unexpected error. Then we rollback.
+ if (ex.becauseTimedOut) {
+ let caller_module = caller.split(":", 1)[0];
+ Services.telemetry.keyedScalarAdd(
+ "mozstorage.sqlitejsm_transaction_timeout",
+ caller_module,
+ 1
+ );
+ this._log.error(
+ `The transaction requested by ${caller} timed out. Rolling back`,
+ ex
+ );
+ } else {
+ this._log.error(
+ `Error during transaction requested by ${caller}. Rolling back`,
+ ex
+ );
+ }
+ // If we began a transaction, we must rollback it.
+ if (this._initiatedTransaction) {
+ try {
+ await this.execute("ROLLBACK TRANSACTION");
+ this._initiatedTransaction = false;
+ this._log.debug(`Roll back transaction`);
+ } catch (inner) {
+ this._log.error("Could not roll back transaction", inner);
+ }
+ }
+ }
+ // Rethrow the exception.
+ throw ex;
+ }
+
+ // See comment above about connection being closed during transaction.
+ if (this._closeRequested) {
+ this._log.warn(
+ "Connection closed before committing the transaction."
+ );
+ throw new Error(
+ "Connection closed before committing the transaction."
+ );
+ }
+
+ // If we began a transaction, we must commit it.
+ if (this._initiatedTransaction) {
+ try {
+ await this.execute("COMMIT TRANSACTION");
+ this._log.debug(`Commit transaction`);
+ } catch (ex) {
+ this._log.warn("Error committing transaction", ex);
+ throw ex;
+ }
+ }
+
+ return result;
+ } finally {
+ this._initiatedTransaction = false;
+ }
+ })();
+
+ return Promise.race([transactionPromise, timeoutPromise]);
+ });
+ // Atomically update the queue before anyone else has a chance to enqueue
+ // further transactions.
+ this._transactionQueue = promise.catch(ex => {
+ this._log.error(ex);
+ });
+
+ // Make sure that we do not shutdown the connection during a transaction.
+ this._barrier.client.addBlocker(
+ `Transaction (${this._getOperationId()})`,
+ this._transactionQueue
+ );
+ return promise;
+ },
+
+ shrinkMemory() {
+ this._log.debug("Shrinking memory usage.");
+ return this.execute("PRAGMA shrink_memory").finally(() => {
+ this._clearIdleShrinkTimer();
+ });
+ },
+
+ discardCachedStatements() {
+ let count = 0;
+ for (let [, /* k */ statement] of this._cachedStatements) {
+ ++count;
+ statement.finalize();
+ }
+ this._cachedStatements.clear();
+ this._log.debug("Discarded " + count + " cached statements.");
+ return count;
+ },
+
+ interrupt() {
+ this._log.debug("Trying to interrupt.");
+ this.ensureOpen();
+ this._dbConn.interrupt();
+ },
+
+ /**
+ * Helper method to bind parameters of various kinds through
+ * reflection.
+ */
+ _bindParameters(statement, params) {
+ if (!params) {
+ return;
+ }
+
+ function bindParam(obj, key, val) {
+ let isBlob =
+ val && typeof val == "object" && val.constructor.name == "Uint8Array";
+ let args = [key, val];
+ if (isBlob) {
+ args.push(val.length);
+ }
+ let methodName = `bind${isBlob ? "Blob" : ""}By${
+ typeof key == "number" ? "Index" : "Name"
+ }`;
+ obj[methodName](...args);
+ }
+
+ if (Array.isArray(params)) {
+ // It's an array of separate params.
+ if (params.length && typeof params[0] == "object" && params[0] !== null) {
+ let paramsArray = statement.newBindingParamsArray();
+ for (let p of params) {
+ let bindings = paramsArray.newBindingParams();
+ for (let [key, value] of Object.entries(p)) {
+ bindParam(bindings, key, value);
+ }
+ paramsArray.addParams(bindings);
+ }
+
+ statement.bindParameters(paramsArray);
+ return;
+ }
+
+ // Indexed params.
+ for (let i = 0; i < params.length; i++) {
+ bindParam(statement, i, params[i]);
+ }
+ return;
+ }
+
+ // Named params.
+ if (params && typeof params == "object") {
+ for (let k in params) {
+ bindParam(statement, k, params[k]);
+ }
+ return;
+ }
+
+ throw new Error(
+ "Invalid type for bound parameters. Expected Array or " +
+ "object. Got: " +
+ params
+ );
+ },
+
+ _executeStatement(sql, statement, params, onRow) {
+ if (statement.state != statement.MOZ_STORAGE_STATEMENT_READY) {
+ throw new Error("Statement is not ready for execution.");
+ }
+
+ if (onRow && typeof onRow != "function") {
+ throw new Error("onRow must be a function. Got: " + onRow);
+ }
+
+ this._bindParameters(statement, params);
+
+ let index = this._statementCounter++;
+
+ let deferred = lazy.PromiseUtils.defer();
+ let userCancelled = false;
+ let errors = [];
+ let rows = [];
+ let handledRow = false;
+
+ // Don't incur overhead for serializing params unless the messages go
+ // somewhere.
+ if (this._log.level <= lazy.Log.Level.Trace) {
+ let msg = "Stmt #" + index + " " + sql;
+
+ if (params) {
+ msg += " - " + JSON.stringify(params);
+ }
+ this._log.trace(msg);
+ } else {
+ this._log.debug("Stmt #" + index + " starting");
+ }
+
+ let self = this;
+ let pending = statement.executeAsync({
+ handleResult(resultSet) {
+ // .cancel() may not be immediate and handleResult() could be called
+ // after a .cancel().
+ for (
+ let row = resultSet.getNextRow();
+ row && !userCancelled;
+ row = resultSet.getNextRow()
+ ) {
+ if (!onRow) {
+ rows.push(row);
+ continue;
+ }
+
+ handledRow = true;
+
+ try {
+ onRow(row, () => {
+ userCancelled = true;
+ pending.cancel();
+ });
+ } catch (e) {
+ self._log.warn("Exception when calling onRow callback", e);
+ }
+ }
+ },
+
+ handleError(error) {
+ self._log.warn(
+ "Error when executing SQL (" + error.result + "): " + error.message
+ );
+ errors.push(error);
+ },
+
+ handleCompletion(reason) {
+ self._log.debug("Stmt #" + index + " finished.");
+ self._pendingStatements.delete(index);
+
+ switch (reason) {
+ case Ci.mozIStorageStatementCallback.REASON_FINISHED:
+ case Ci.mozIStorageStatementCallback.REASON_CANCELED:
+ // If there is an onRow handler, we always instead resolve to a
+ // boolean indicating whether the onRow handler was called or not.
+ let result = onRow ? handledRow : rows;
+ deferred.resolve(result);
+ break;
+
+ case Ci.mozIStorageStatementCallback.REASON_ERROR:
+ let error = new Error(
+ "Error(s) encountered during statement execution: " +
+ errors.map(e => e.message).join(", ")
+ );
+ error.errors = errors;
+
+ // Forward the error result.
+ // Corruption is the most critical one so it's handled apart.
+ if (errors.some(e => e.result == Ci.mozIStorageError.CORRUPT)) {
+ error.result = Cr.NS_ERROR_FILE_CORRUPTED;
+ } else {
+ // Just use the first error result in the other cases.
+ error.result = convertStorageErrorResult(errors[0]?.result);
+ }
+
+ deferred.reject(error);
+ break;
+
+ default:
+ deferred.reject(
+ new Error("Unknown completion reason code: " + reason)
+ );
+ break;
+ }
+ },
+ });
+
+ this._pendingStatements.set(index, pending);
+ return deferred.promise;
+ },
+
+ ensureOpen() {
+ if (!this._open) {
+ throw new Error("Connection is not open.");
+ }
+ },
+
+ _clearIdleShrinkTimer() {
+ if (!this._idleShrinkTimer) {
+ return;
+ }
+
+ this._idleShrinkTimer.cancel();
+ },
+
+ _startIdleShrinkTimer() {
+ if (!this._idleShrinkTimer) {
+ return;
+ }
+
+ this._idleShrinkTimer.initWithCallback(
+ this.shrinkMemory.bind(this),
+ this._idleShrinkMS,
+ this._idleShrinkTimer.TYPE_ONE_SHOT
+ );
+ },
+
+ /**
+ * Returns a promise that will resolve after a time comprised between 80% of
+ * `TRANSACTIONS_TIMEOUT_MS` and `TRANSACTIONS_TIMEOUT_MS`. Use
+ * this method instead of creating several individual timers that may survive
+ * longer than necessary.
+ */
+ _getTimeoutPromise() {
+ if (this._timeoutPromise && Cu.now() <= this._timeoutPromiseExpires) {
+ return this._timeoutPromise;
+ }
+ let timeoutPromise = new Promise((resolve, reject) => {
+ setTimeout(() => {
+ // Clear out this._timeoutPromise if it hasn't changed since we set it.
+ if (this._timeoutPromise == timeoutPromise) {
+ this._timeoutPromise = null;
+ }
+ let e = new Error(
+ "Transaction timeout, most likely caused by unresolved pending work."
+ );
+ e.becauseTimedOut = true;
+ reject(e);
+ }, Sqlite.TRANSACTIONS_TIMEOUT_MS);
+ });
+ this._timeoutPromise = timeoutPromise;
+ this._timeoutPromiseExpires =
+ Cu.now() + Sqlite.TRANSACTIONS_TIMEOUT_MS * 0.2;
+ return this._timeoutPromise;
+ },
+});
+
+/**
+ * Opens a connection to a SQLite database.
+ *
+ * The following parameters can control the connection:
+ *
+ * path -- (string) The filesystem path of the database file to open. If the
+ * file does not exist, a new database will be created.
+ *
+ * sharedMemoryCache -- (bool) Whether multiple connections to the database
+ * share the same memory cache. Sharing the memory cache likely results
+ * in less memory utilization. However, sharing also requires connections
+ * to obtain a lock, possibly making database access slower. Defaults to
+ * true.
+ *
+ * shrinkMemoryOnConnectionIdleMS -- (integer) If defined, the connection
+ * will attempt to minimize its memory usage after this many
+ * milliseconds of connection idle. The connection is idle when no
+ * statements are executing. There is no default value which means no
+ * automatic memory minimization will occur. Please note that this is
+ * *not* a timer on the idle service and this could fire while the
+ * application is active.
+ *
+ * readOnly -- (bool) Whether to open the database with SQLITE_OPEN_READONLY
+ * set. If used, writing to the database will fail. Defaults to false.
+ *
+ * ignoreLockingMode -- (bool) Whether to ignore locks on the database held
+ * by other connections. If used, implies readOnly. Defaults to false.
+ * USE WITH EXTREME CAUTION. This mode WILL produce incorrect results or
+ * return "false positive" corruption errors if other connections write
+ * to the DB at the same time.
+ *
+ * vacuumOnIdle -- (bool) Whether to register this connection to be vacuumed
+ * on idle by the VacuumManager component.
+ * If you're vacuum-ing an incremental vacuum database, ensure to also
+ * set incrementalVacuum to true, otherwise this will try to change it
+ * to full vacuum mode.
+ *
+ * incrementalVacuum -- (bool) if set to true auto_vacuum = INCREMENTAL will
+ * be enabled for the database.
+ * Changing auto vacuum of an already populated database requires a full
+ * VACUUM. You can evaluate to enable vacuumOnIdle for that.
+ *
+ * pageSize -- (integer) This allows to set a custom page size for the
+ * database. It is usually not necessary to set it, since the default
+ * value should be good for most consumers.
+ * Changing the page size of an already populated database requires a full
+ * VACUUM. You can evaluate to enable vacuumOnIdle for that.
+ *
+ * testDelayedOpenPromise -- (promise) Used by tests to delay the open
+ * callback handling and execute code between asyncOpen and its callback.
+ *
+ * FUTURE options to control:
+ *
+ * special named databases
+ * pragma TEMP STORE = MEMORY
+ * TRUNCATE JOURNAL
+ * SYNCHRONOUS = full
+ *
+ * @param options
+ * (Object) Parameters to control connection and open options.
+ *
+ * @return Promise<OpenedConnection>
+ */
+function openConnection(options) {
+ let log = lazy.Log.repository.getLoggerWithMessagePrefix(
+ "Sqlite.sys.mjs",
+ `ConnectionOpener: `
+ );
+ log.manageLevelFromPref("toolkit.sqlitejsm.loglevel");
+
+ if (!options.path) {
+ throw new Error("path not specified in connection options.");
+ }
+
+ if (isClosed()) {
+ throw new Error(
+ "Sqlite.sys.mjs has been shutdown. Cannot open connection to: " +
+ options.path
+ );
+ }
+
+ // Retains absolute paths and normalizes relative as relative to profile.
+ let path = options.path;
+ let file;
+ try {
+ file = lazy.FileUtils.File(path);
+ } catch (ex) {
+ // For relative paths, we will get an exception from trying to initialize
+ // the file. We must then join this path to the profile directory.
+ if (ex.result == Cr.NS_ERROR_FILE_UNRECOGNIZED_PATH) {
+ path = PathUtils.joinRelative(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ options.path
+ );
+ file = lazy.FileUtils.File(path);
+ } else {
+ throw ex;
+ }
+ }
+
+ let sharedMemoryCache =
+ "sharedMemoryCache" in options ? options.sharedMemoryCache : true;
+
+ let openedOptions = {};
+
+ if ("shrinkMemoryOnConnectionIdleMS" in options) {
+ if (!Number.isInteger(options.shrinkMemoryOnConnectionIdleMS)) {
+ throw new Error(
+ "shrinkMemoryOnConnectionIdleMS must be an integer. " +
+ "Got: " +
+ options.shrinkMemoryOnConnectionIdleMS
+ );
+ }
+
+ openedOptions.shrinkMemoryOnConnectionIdleMS =
+ options.shrinkMemoryOnConnectionIdleMS;
+ }
+
+ if ("defaultTransactionType" in options) {
+ let defaultTransactionType = options.defaultTransactionType;
+ if (!OpenedConnection.TRANSACTION_TYPES.includes(defaultTransactionType)) {
+ throw new Error(
+ "Unknown default transaction type: " + defaultTransactionType
+ );
+ }
+
+ openedOptions.defaultTransactionType = defaultTransactionType;
+ }
+
+ if ("vacuumOnIdle" in options) {
+ if (typeof options.vacuumOnIdle != "boolean") {
+ throw new Error("Invalid vacuumOnIdle: " + options.vacuumOnIdle);
+ }
+ openedOptions.vacuumOnIdle = options.vacuumOnIdle;
+ }
+
+ if ("incrementalVacuum" in options) {
+ if (typeof options.incrementalVacuum != "boolean") {
+ throw new Error(
+ "Invalid incrementalVacuum: " + options.incrementalVacuum
+ );
+ }
+ openedOptions.incrementalVacuum = options.incrementalVacuum;
+ }
+
+ if ("pageSize" in options) {
+ if (
+ ![512, 1024, 2048, 4096, 8192, 16384, 32768, 65536].includes(
+ options.pageSize
+ )
+ ) {
+ throw new Error("Invalid pageSize: " + options.pageSize);
+ }
+ openedOptions.pageSize = options.pageSize;
+ }
+
+ let identifier = getIdentifierByFileName(PathUtils.filename(path));
+
+ log.debug("Opening database: " + path + " (" + identifier + ")");
+
+ return new Promise((resolve, reject) => {
+ let dbOpenOptions = Ci.mozIStorageService.OPEN_DEFAULT;
+ if (sharedMemoryCache) {
+ dbOpenOptions |= Ci.mozIStorageService.OPEN_SHARED;
+ }
+ if (options.readOnly) {
+ dbOpenOptions |= Ci.mozIStorageService.OPEN_READONLY;
+ }
+ if (options.ignoreLockingMode) {
+ dbOpenOptions |= Ci.mozIStorageService.OPEN_IGNORE_LOCKING_MODE;
+ dbOpenOptions |= Ci.mozIStorageService.OPEN_READONLY;
+ }
+
+ let dbConnectionOptions = Ci.mozIStorageService.CONNECTION_DEFAULT;
+
+ Services.storage.openAsyncDatabase(
+ file,
+ dbOpenOptions,
+ dbConnectionOptions,
+ async (status, connection) => {
+ if (!connection) {
+ log.error(`Could not open connection to ${path}: ${status}`);
+ let error = new Components.Exception(
+ `Could not open connection to ${path}: ${status}`,
+ status
+ );
+ reject(error);
+ return;
+ }
+ log.debug("Connection opened");
+
+ if (options.testDelayedOpenPromise) {
+ await options.testDelayedOpenPromise;
+ }
+
+ if (isClosed()) {
+ connection.QueryInterface(Ci.mozIStorageAsyncConnection).asyncClose();
+ reject(
+ new Error(
+ "Sqlite.sys.mjs has been shutdown. Cannot open connection to: " +
+ options.path
+ )
+ );
+ return;
+ }
+
+ try {
+ resolve(
+ new OpenedConnection(
+ connection.QueryInterface(Ci.mozIStorageAsyncConnection),
+ identifier,
+ openedOptions
+ )
+ );
+ } catch (ex) {
+ log.error("Could not open database", ex);
+ connection.asyncClose();
+ reject(ex);
+ }
+ }
+ );
+ });
+}
+
+/**
+ * Creates a clone of an existing and open Storage connection. The clone has
+ * the same underlying characteristics of the original connection and is
+ * returned in form of an OpenedConnection handle.
+ *
+ * The following parameters can control the cloned connection:
+ *
+ * connection -- (mozIStorageAsyncConnection) The original Storage connection
+ * to clone. It's not possible to clone connections to memory databases.
+ *
+ * readOnly -- (boolean) - If true the clone will be read-only. If the
+ * original connection is already read-only, the clone will be, regardless
+ * of this option. If the original connection is using the shared cache,
+ * this parameter will be ignored and the clone will be as privileged as
+ * the original connection.
+ * shrinkMemoryOnConnectionIdleMS -- (integer) If defined, the connection
+ * will attempt to minimize its memory usage after this many
+ * milliseconds of connection idle. The connection is idle when no
+ * statements are executing. There is no default value which means no
+ * automatic memory minimization will occur. Please note that this is
+ * *not* a timer on the idle service and this could fire while the
+ * application is active.
+ *
+ *
+ * @param options
+ * (Object) Parameters to control connection and clone options.
+ *
+ * @return Promise<OpenedConnection>
+ */
+function cloneStorageConnection(options) {
+ let log = lazy.Log.repository.getLoggerWithMessagePrefix(
+ "Sqlite.sys.mjs",
+ `ConnectionCloner: `
+ );
+ log.manageLevelFromPref("toolkit.sqlitejsm.loglevel");
+
+ let source = options && options.connection;
+ if (!source) {
+ throw new TypeError("connection not specified in clone options.");
+ }
+ if (!(source instanceof Ci.mozIStorageAsyncConnection)) {
+ throw new TypeError("Connection must be a valid Storage connection.");
+ }
+
+ if (isClosed()) {
+ throw new Error(
+ "Sqlite.sys.mjs has been shutdown. Cannot clone connection to: " +
+ source.databaseFile.path
+ );
+ }
+
+ let openedOptions = {};
+
+ if ("shrinkMemoryOnConnectionIdleMS" in options) {
+ if (!Number.isInteger(options.shrinkMemoryOnConnectionIdleMS)) {
+ throw new TypeError(
+ "shrinkMemoryOnConnectionIdleMS must be an integer. " +
+ "Got: " +
+ options.shrinkMemoryOnConnectionIdleMS
+ );
+ }
+ openedOptions.shrinkMemoryOnConnectionIdleMS =
+ options.shrinkMemoryOnConnectionIdleMS;
+ }
+
+ let path = source.databaseFile.path;
+ let identifier = getIdentifierByFileName(PathUtils.filename(path));
+
+ log.debug("Cloning database: " + path + " (" + identifier + ")");
+
+ return new Promise((resolve, reject) => {
+ source.asyncClone(!!options.readOnly, (status, connection) => {
+ if (!connection) {
+ log.error("Could not clone connection: " + status);
+ reject(new Error("Could not clone connection: " + status));
+ return;
+ }
+ log.debug("Connection cloned");
+
+ if (isClosed()) {
+ connection.QueryInterface(Ci.mozIStorageAsyncConnection).asyncClose();
+ reject(
+ new Error(
+ "Sqlite.sys.mjs has been shutdown. Cannot open connection to: " +
+ options.path
+ )
+ );
+ return;
+ }
+
+ try {
+ let conn = connection.QueryInterface(Ci.mozIStorageAsyncConnection);
+ resolve(new OpenedConnection(conn, identifier, openedOptions));
+ } catch (ex) {
+ log.error("Could not clone database", ex);
+ connection.asyncClose();
+ reject(ex);
+ }
+ });
+ });
+}
+
+/**
+ * Wraps an existing and open Storage connection with Sqlite.sys.mjs API. The
+ * wrapped connection clone has the same underlying characteristics of the
+ * original connection and is returned in form of an OpenedConnection handle.
+ *
+ * Clients are responsible for closing both the Sqlite.sys.mjs wrapper and the
+ * underlying mozStorage connection.
+ *
+ * The following parameters can control the wrapped connection:
+ *
+ * connection -- (mozIStorageAsyncConnection) The original Storage connection
+ * to wrap.
+ *
+ * @param options
+ * (Object) Parameters to control connection and wrap options.
+ *
+ * @return Promise<OpenedConnection>
+ */
+function wrapStorageConnection(options) {
+ let log = lazy.Log.repository.getLoggerWithMessagePrefix(
+ "Sqlite.sys.mjs",
+ `ConnectionCloner: `
+ );
+ log.manageLevelFromPref("toolkit.sqlitejsm.loglevel");
+
+ let connection = options && options.connection;
+ if (!connection || !(connection instanceof Ci.mozIStorageAsyncConnection)) {
+ throw new TypeError("connection not specified or invalid.");
+ }
+
+ if (isClosed()) {
+ throw new Error(
+ "Sqlite.sys.mjs has been shutdown. Cannot wrap connection to: " +
+ connection.databaseFile.path
+ );
+ }
+
+ let identifier = getIdentifierByFileName(connection.databaseFile.leafName);
+
+ log.debug("Wrapping database: " + identifier);
+ return new Promise(resolve => {
+ try {
+ let conn = connection.QueryInterface(Ci.mozIStorageAsyncConnection);
+ let wrapper = new OpenedConnection(conn, identifier);
+ // We must not handle shutdown of a wrapped connection, since that is
+ // already handled by the opener.
+ wrappedConnections.add(identifier);
+ resolve(wrapper);
+ } catch (ex) {
+ log.error("Could not wrap database", ex);
+ throw ex;
+ }
+ });
+}
+
+/**
+ * Handle on an opened SQLite database.
+ *
+ * This is essentially a glorified wrapper around mozIStorageConnection.
+ * However, it offers some compelling advantages.
+ *
+ * The main functions on this type are `execute` and `executeCached`. These are
+ * ultimately how all SQL statements are executed. It's worth explaining their
+ * differences.
+ *
+ * `execute` is used to execute one-shot SQL statements. These are SQL
+ * statements that are executed one time and then thrown away. They are useful
+ * for dynamically generated SQL statements and clients who don't care about
+ * performance (either their own or wasting resources in the overall
+ * application). Because of the performance considerations, it is recommended
+ * to avoid `execute` unless the statement you are executing will only be
+ * executed once or seldomly.
+ *
+ * `executeCached` is used to execute a statement that will presumably be
+ * executed multiple times. The statement is parsed once and stuffed away
+ * inside the connection instance. Subsequent calls to `executeCached` will not
+ * incur the overhead of creating a new statement object. This should be used
+ * in preference to `execute` when a specific SQL statement will be executed
+ * multiple times.
+ *
+ * Instances of this type are not meant to be created outside of this file.
+ * Instead, first open an instance of `UnopenedSqliteConnection` and obtain
+ * an instance of this type by calling `open`.
+ *
+ * FUTURE IMPROVEMENTS
+ *
+ * Ability to enqueue operations. Currently there can be race conditions,
+ * especially as far as transactions are concerned. It would be nice to have
+ * an enqueueOperation(func) API that serially executes passed functions.
+ *
+ * Support for SAVEPOINT (named/nested transactions) might be useful.
+ *
+ * @param connection
+ * (mozIStorageConnection) Underlying SQLite connection.
+ * @param identifier
+ * (string) The unique identifier of this database. It may be used for
+ * logging or as a key in Maps.
+ * @param options [optional]
+ * (object) Options to control behavior of connection. See
+ * `openConnection`.
+ */
+function OpenedConnection(connection, identifier, options = {}) {
+ // Store all connection data in a field distinct from the
+ // witness. This enables us to store an additional reference to this
+ // field without preventing garbage collection of
+ // OpenedConnection. On garbage collection, we will still be able to
+ // close the database using this extra reference.
+ this._connectionData = new ConnectionData(connection, identifier, options);
+
+ // Store the extra reference in a map with connection identifier as
+ // key.
+ ConnectionData.byId.set(
+ this._connectionData._identifier,
+ this._connectionData
+ );
+
+ // Make a finalization witness. If this object is garbage collected
+ // before its `forget` method has been called, an event with topic
+ // "sqlite-finalization-witness" is broadcasted along with the
+ // connection identifier string of the database.
+ this._witness = lazy.FinalizationWitnessService.make(
+ "sqlite-finalization-witness",
+ this._connectionData._identifier
+ );
+}
+
+OpenedConnection.TRANSACTION_TYPES = ["DEFERRED", "IMMEDIATE", "EXCLUSIVE"];
+
+// Converts a `mozIStorageAsyncConnection::TRANSACTION_*` constant into the
+// corresponding `OpenedConnection.TRANSACTION_TYPES` constant.
+function convertStorageTransactionType(type) {
+ if (!(type in OpenedConnection.TRANSACTION_TYPES)) {
+ throw new Error("Unknown storage transaction type: " + type);
+ }
+ return OpenedConnection.TRANSACTION_TYPES[type];
+}
+
+OpenedConnection.prototype = Object.freeze({
+ TRANSACTION_DEFAULT: "DEFAULT",
+ TRANSACTION_DEFERRED: "DEFERRED",
+ TRANSACTION_IMMEDIATE: "IMMEDIATE",
+ TRANSACTION_EXCLUSIVE: "EXCLUSIVE",
+
+ /**
+ * Returns a handle to the underlying `mozIStorageAsyncConnection`. This is
+ * ⚠️ **extremely unsafe** ⚠️ because `Sqlite.sys.mjs` continues to manage the
+ * connection's lifecycle, including transactions and shutdown blockers.
+ * Misusing the raw connection can easily lead to data loss, memory leaks,
+ * and errors.
+ *
+ * Consumers of the raw connection **must not** close or re-wrap it,
+ * and should not run statements concurrently with `Sqlite.sys.mjs`.
+ *
+ * It's _much_ safer to open a `mozIStorage{Async}Connection` yourself,
+ * and access it from JavaScript via `Sqlite.wrapStorageConnection`.
+ * `unsafeRawConnection` is an escape hatch for cases where you can't
+ * do that.
+ *
+ * Please do _not_ add new uses of `unsafeRawConnection` without review
+ * from a storage peer.
+ */
+ get unsafeRawConnection() {
+ return this._connectionData._dbConn;
+ },
+
+ /**
+ * Returns the maximum number of bound parameters for statements executed
+ * on this connection.
+ *
+ * @type {number}
+ */
+ get variableLimit() {
+ return this.unsafeRawConnection.variableLimit;
+ },
+
+ /**
+ * The integer schema version of the database.
+ *
+ * This is 0 if not schema version has been set.
+ *
+ * @return Promise<int>
+ */
+ getSchemaVersion(schemaName = "main") {
+ return this.execute(`PRAGMA ${schemaName}.user_version`).then(result =>
+ result[0].getInt32(0)
+ );
+ },
+
+ setSchemaVersion(value, schemaName = "main") {
+ if (!Number.isInteger(value)) {
+ // Guarding against accidental SQLi
+ throw new TypeError("Schema version must be an integer. Got " + value);
+ }
+ this._connectionData.ensureOpen();
+ return this.execute(`PRAGMA ${schemaName}.user_version = ${value}`);
+ },
+
+ /**
+ * Close the database connection.
+ *
+ * This must be performed when you are finished with the database.
+ *
+ * Closing the database connection has the side effect of forcefully
+ * cancelling all active statements. Therefore, callers should ensure that
+ * all active statements have completed before closing the connection, if
+ * possible.
+ *
+ * The returned promise will be resolved once the connection is closed.
+ * Successive calls to close() return the same promise.
+ *
+ * IMPROVEMENT: Resolve the promise to a closed connection which can be
+ * reopened.
+ *
+ * @return Promise<>
+ */
+ close() {
+ // Unless cleanup has already been done by a previous call to
+ // `close`, delete the database entry from map and tell the
+ // finalization witness to forget.
+ if (ConnectionData.byId.has(this._connectionData._identifier)) {
+ ConnectionData.byId.delete(this._connectionData._identifier);
+ this._witness.forget();
+ }
+ return this._connectionData.close();
+ },
+
+ /**
+ * Clones this connection to a new Sqlite one.
+ *
+ * The following parameters can control the cloned connection:
+ *
+ * @param readOnly
+ * (boolean) - If true the clone will be read-only. If the original
+ * connection is already read-only, the clone will be, regardless of
+ * this option. If the original connection is using the shared cache,
+ * this parameter will be ignored and the clone will be as privileged as
+ * the original connection.
+ *
+ * @return Promise<OpenedConnection>
+ */
+ clone(readOnly = false) {
+ return this._connectionData.clone(readOnly);
+ },
+
+ executeBeforeShutdown(name, task) {
+ return this._connectionData.executeBeforeShutdown(this, name, task);
+ },
+
+ /**
+ * Execute a SQL statement and cache the underlying statement object.
+ *
+ * This function executes a SQL statement and also caches the underlying
+ * derived statement object so subsequent executions are faster and use
+ * less resources.
+ *
+ * This function optionally binds parameters to the statement as well as
+ * optionally invokes a callback for every row retrieved.
+ *
+ * By default, no parameters are bound and no callback will be invoked for
+ * every row.
+ *
+ * Bound parameters can be defined as an Array of positional arguments or
+ * an object mapping named parameters to their values. If there are no bound
+ * parameters, the caller can pass nothing or null for this argument.
+ *
+ * Callers are encouraged to pass objects rather than Arrays for bound
+ * parameters because they prevent foot guns. With positional arguments, it
+ * is simple to modify the parameter count or positions without fixing all
+ * users of the statement. Objects/named parameters are a little safer
+ * because changes in order alone won't result in bad things happening.
+ *
+ * When `onRow` is not specified, all returned rows are buffered before the
+ * returned promise is resolved. For INSERT or UPDATE statements, this has
+ * no effect because no rows are returned from these. However, it has
+ * implications for SELECT statements.
+ *
+ * If your SELECT statement could return many rows or rows with large amounts
+ * of data, for performance reasons it is recommended to pass an `onRow`
+ * handler. Otherwise, the buffering may consume unacceptable amounts of
+ * resources.
+ *
+ * If the second parameter of an `onRow` handler is called during execution
+ * of the `onRow` handler, the execution of the statement is immediately
+ * cancelled. Subsequent rows will not be processed and no more `onRow`
+ * invocations will be made. The promise is resolved immediately.
+ *
+ * If an exception is thrown by the `onRow` handler, the exception is logged
+ * and processing of subsequent rows occurs as if nothing happened. The
+ * promise is still resolved (not rejected).
+ *
+ * The return value is a promise that will be resolved when the statement
+ * has completed fully.
+ *
+ * The promise will be rejected with an `Error` instance if the statement
+ * did not finish execution fully. The `Error` may have an `errors` property.
+ * If defined, it will be an Array of objects describing individual errors.
+ * Each object has the properties `result` and `message`. `result` is a
+ * numeric error code and `message` is a string description of the problem.
+ *
+ * @param name
+ * (string) The name of the registered statement to execute.
+ * @param params optional
+ * (Array or object) Parameters to bind.
+ * @param onRow optional
+ * (function) Callback to receive each row from result.
+ */
+ executeCached(sql, params = null, onRow = null) {
+ if (isInvalidBoundLikeQuery(sql)) {
+ throw new Error("Please enter a LIKE clause with bindings");
+ }
+ return this._connectionData.executeCached(sql, params, onRow);
+ },
+
+ /**
+ * Execute a one-shot SQL statement.
+ *
+ * If you find yourself feeding the same SQL string in this function, you
+ * should *not* use this function and instead use `executeCached`.
+ *
+ * See `executeCached` for the meaning of the arguments and extended usage info.
+ *
+ * @param sql
+ * (string) SQL to execute.
+ * @param params optional
+ * (Array or Object) Parameters to bind to the statement.
+ * @param onRow optional
+ * (function) Callback to receive result of a single row.
+ */
+ execute(sql, params = null, onRow = null) {
+ if (isInvalidBoundLikeQuery(sql)) {
+ throw new Error("Please enter a LIKE clause with bindings");
+ }
+ return this._connectionData.execute(sql, params, onRow);
+ },
+
+ /**
+ * The default behavior for transactions run on this connection.
+ */
+ get defaultTransactionType() {
+ return this._connectionData.defaultTransactionType;
+ },
+
+ /**
+ * Whether a transaction is currently in progress.
+ *
+ * Note that this is true if a transaction is active on the connection,
+ * regardless of whether it was started by `Sqlite.sys.mjs` or another consumer.
+ * See the explanation above `mozIStorageConnection.transactionInProgress` for
+ * why this distinction matters.
+ */
+ get transactionInProgress() {
+ return this._connectionData.transactionInProgress;
+ },
+
+ /**
+ * Perform a transaction.
+ *
+ * *****************************************************************************
+ * YOU SHOULD _NEVER_ NEST executeTransaction CALLS FOR ANY REASON, NOR
+ * DIRECTLY, NOR THROUGH OTHER PROMISES.
+ * FOR EXAMPLE, NEVER DO SOMETHING LIKE:
+ * await executeTransaction(async function () {
+ * ...some_code...
+ * await executeTransaction(async function () { // WRONG!
+ * ...some_code...
+ * })
+ * await someCodeThatExecuteTransaction(); // WRONG!
+ * await neverResolvedPromise; // WRONG!
+ * });
+ * NESTING CALLS WILL BLOCK ANY FUTURE TRANSACTION UNTIL A TIMEOUT KICKS IN.
+ * *****************************************************************************
+ *
+ * A transaction is specified by a user-supplied function that is an
+ * async function. The function receives this connection instance as its argument.
+ *
+ * The supplied function is expected to return promises. These are often
+ * promises created by calling `execute` and `executeCached`. If the
+ * generator is exhausted without any errors being thrown, the
+ * transaction is committed. If an error occurs, the transaction is
+ * rolled back.
+ *
+ * The returned value from this function is a promise that will be resolved
+ * once the transaction has been committed or rolled back. The promise will
+ * be resolved to whatever value the supplied function resolves to. If
+ * the transaction is rolled back, the promise is rejected.
+ *
+ * @param func
+ * (function) What to perform as part of the transaction.
+ * @param type optional
+ * One of the TRANSACTION_* constants attached to this type.
+ */
+ executeTransaction(func, type = this.TRANSACTION_DEFAULT) {
+ return this._connectionData.executeTransaction(() => func(this), type);
+ },
+
+ /**
+ * Whether a table exists in the database (both persistent and temporary tables).
+ *
+ * @param name
+ * (string) Name of the table.
+ *
+ * @return Promise<bool>
+ */
+ tableExists(name) {
+ return this.execute(
+ "SELECT name FROM (SELECT * FROM sqlite_master UNION ALL " +
+ "SELECT * FROM sqlite_temp_master) " +
+ "WHERE type = 'table' AND name=?",
+ [name]
+ ).then(function onResult(rows) {
+ return Promise.resolve(!!rows.length);
+ });
+ },
+
+ /**
+ * Whether a named index exists (both persistent and temporary tables).
+ *
+ * @param name
+ * (string) Name of the index.
+ *
+ * @return Promise<bool>
+ */
+ indexExists(name) {
+ return this.execute(
+ "SELECT name FROM (SELECT * FROM sqlite_master UNION ALL " +
+ "SELECT * FROM sqlite_temp_master) " +
+ "WHERE type = 'index' AND name=?",
+ [name]
+ ).then(function onResult(rows) {
+ return Promise.resolve(!!rows.length);
+ });
+ },
+
+ /**
+ * Free up as much memory from the underlying database connection as possible.
+ *
+ * @return Promise<>
+ */
+ shrinkMemory() {
+ return this._connectionData.shrinkMemory();
+ },
+
+ /**
+ * Discard all cached statements.
+ *
+ * Note that this relies on us being non-interruptible between
+ * the insertion or retrieval of a statement in the cache and its
+ * execution: we finalize all statements, which is only safe if
+ * they will not be executed again.
+ *
+ * @return (integer) the number of statements discarded.
+ */
+ discardCachedStatements() {
+ return this._connectionData.discardCachedStatements();
+ },
+
+ /**
+ * Interrupts pending database operations returning at the first opportunity.
+ * Statement execution will throw an NS_ERROR_ABORT failure.
+ * Can only be used on read-only connections.
+ */
+ interrupt() {
+ this._connectionData.interrupt();
+ },
+});
+
+export var Sqlite = {
+ // The maximum time to wait before considering a transaction stuck and
+ // issuing a ROLLBACK, see `executeTransaction`. Could be modified by tests.
+ TRANSACTIONS_TIMEOUT_MS: 300000, // 5 minutes
+
+ openConnection,
+ cloneStorageConnection,
+ wrapStorageConnection,
+ /**
+ * Shutdown barrier client. May be used by clients to perform last-minute
+ * cleanup prior to the shutdown of this module.
+ *
+ * See the documentation of AsyncShutdown.Barrier.prototype.client.
+ */
+ get shutdown() {
+ return lazy.Barriers.shutdown.client;
+ },
+ failTestsOnAutoClose(enabled) {
+ Debugging.failTestsOnAutoClose = enabled;
+ },
+};