diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /toolkit/modules/Sqlite.sys.mjs | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/modules/Sqlite.sys.mjs')
-rw-r--r-- | toolkit/modules/Sqlite.sys.mjs | 2020 |
1 files changed, 2020 insertions, 0 deletions
diff --git a/toolkit/modules/Sqlite.sys.mjs b/toolkit/modules/Sqlite.sys.mjs new file mode 100644 index 0000000000..2c9de0f438 --- /dev/null +++ b/toolkit/modules/Sqlite.sys.mjs @@ -0,0 +1,2020 @@ +/* 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/. */ + +/** + * PRIVACY WARNING + * =============== + * + * Database file names can be exposed through telemetry and in crash reports on + * the https://crash-stats.mozilla.org site, to allow recognizing the affected + * database. + * if your database name may contain privacy sensitive information, e.g. an + * URL origin, you should use openDatabaseWithFileURL and pass an explicit + * TelemetryFilename to it. That name will be used both for telemetry and for + * thread names in crash reports. + * If you have different needs (e.g. using the javascript module or an async + * connection from the main thread) please coordinate with the mozStorage peers. + */ + +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", +}); + +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. + */ +ChromeUtils.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 = Promise.withResolvers(); + 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 = Promise.withResolvers(); + 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; + }, + + /** + * Asynchronously makes a copy of the SQLite database while there may still be + * open connections on it. + * + * @param {string} destFilePath + * The path on the local filesystem to write the database copy. Any existing + * file at this path will be overwritten. + * @return Promise<undefined, nsresult> + */ + async backupToFile(destFilePath) { + if (!this._dbConn) { + return Promise.reject( + new Error("No opened database connection to create a backup from.") + ); + } + let destFile = await IOUtils.getFile(destFilePath); + return new Promise((resolve, reject) => { + this._dbConn.backupToFileAsync(destFile, result => { + if (Components.isSuccessCode(result)) { + resolve(); + } else { + reject(result); + } + }); + }); + }, +}); + +/** + * 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(); + }, + + /** + * Asynchronously makes a copy of the SQLite database while there may still be + * open connections on it. + * + * @param {string} destFilePath + * The path on the local filesystem to write the database copy. Any existing + * file at this path will be overwritten. + * @return Promise<undefined, nsresult> + */ + backup(destFilePath) { + return this._connectionData.backupToFile(destFilePath); + }, +}); + +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; + }, +}; |