summaryrefslogtreecommitdiffstats
path: root/toolkit/components/satchel/FormHistory.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/satchel/FormHistory.sys.mjs')
-rw-r--r--toolkit/components/satchel/FormHistory.sys.mjs1303
1 files changed, 1303 insertions, 0 deletions
diff --git a/toolkit/components/satchel/FormHistory.sys.mjs b/toolkit/components/satchel/FormHistory.sys.mjs
new file mode 100644
index 0000000000..3928ea19b7
--- /dev/null
+++ b/toolkit/components/satchel/FormHistory.sys.mjs
@@ -0,0 +1,1303 @@
+/* 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/. */
+
+/**
+ * FormHistory
+ *
+ * Used to store values that have been entered into forms which may later
+ * be used to automatically fill in the values when the form is visited again.
+ *
+ * async search(terms, queryData)
+ * Look up values that have been previously stored.
+ * terms - array of terms to return data for
+ * queryData - object that contains the query terms
+ * The query object contains properties for each search criteria to match, where the value
+ * of the property specifies the value that term must have. For example,
+ * { term1: value1, term2: value2 }
+ * Resolves to an array containing the found results. Each element in
+ * the array is an object containing a property for each search term
+ * specified by 'terms'.
+ * Rejects in case of errors.
+ * async count(queryData)
+ * Find the number of stored entries that match the given criteria.
+ * queryData - array of objects that indicate the query. See the search method for details.
+ * Resolves to the number of found entries.
+ * Rejects in case of errors.
+ * async update(changes)
+ * Write data to form history storage.
+ * changes - an array of changes to be made. If only one change is to be made, it
+ * may be passed as an object rather than a one-element array.
+ * Each change object is of the form:
+ * { op: operation, term1: value1, term2: value2, ... }
+ * Valid operations are:
+ * add - add a new entry
+ * update - update an existing entry
+ * remove - remove an entry
+ * bump - update the last accessed time on an entry
+ * The terms specified allow matching of one or more specific entries. If no terms
+ * are specified then all entries are matched. This means that { op: "remove" } is
+ * used to remove all entries and clear the form history.
+ * Resolves once the operation is complete.
+ * Rejects in case of errors.
+ * async getAutoCompeteResults(searchString, params, callback)
+ * Retrieve an array of form history values suitable for display in an autocomplete list.
+ * searchString - the string to search for, typically the entered value of a textbox
+ * params - zero or more filter arguments:
+ * fieldname - form field name
+ * agedWeight
+ * bucketSize
+ * expiryDate
+ * maxTimeGroundings
+ * timeGroupingSize
+ * prefixWeight
+ * boundaryWeight
+ * source
+ * callback - callback that is invoked for each result, the second argument
+ * is a function that can be used to cancel the operation.
+ * Each result is an object with four properties:
+ * text, textLowerCase, frecency, totalScore
+ * Resolves with an array of results, once the operation is complete.
+ * Rejects in case of errors.
+ *
+ * schemaVersion
+ * This property holds the version of the database schema
+ *
+ * Terms:
+ * guid - entry identifier. For 'add', a guid will be generated.
+ * fieldname - form field name
+ * value - form value
+ * timesUsed - the number of times the entry has been accessed
+ * firstUsed - the time the the entry was first created
+ * lastUsed - the time the entry was last accessed
+ * firstUsedStart - search for entries created after or at this time
+ * firstUsedEnd - search for entries created before or at this time
+ * lastUsedStart - search for entries last accessed after or at this time
+ * lastUsedEnd - search for entries last accessed before or at this time
+ * newGuid - a special case valid only for 'update' and allows the guid for
+ * an existing record to be updated. The 'guid' term is the only
+ * other term which can be used (ie, you can not also specify a
+ * fieldname, value etc) and indicates the guid of the existing
+ * record that should be updated.
+ */
+
+export let FormHistory;
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Sqlite: "resource://gre/modules/Sqlite.sys.mjs",
+});
+
+const DB_SCHEMA_VERSION = 5;
+const DAY_IN_MS = 86400000; // 1 day in milliseconds
+const MAX_SEARCH_TOKENS = 10;
+const DB_FILENAME = "formhistory.sqlite";
+
+var supportsDeletedTable = AppConstants.platform == "android";
+
+var Prefs = {
+ _initialized: false,
+
+ get(name) {
+ this.ensureInitialized();
+ return this[`_${name}`];
+ },
+
+ ensureInitialized() {
+ if (this._initialized) {
+ return;
+ }
+
+ this._initialized = true;
+
+ this._prefBranch = Services.prefs.getBranch("browser.formfill.");
+ this._prefBranch.addObserver("", this, true);
+
+ this._agedWeight = this._prefBranch.getIntPref("agedWeight");
+ this._boundaryWeight = this._prefBranch.getIntPref("boundaryWeight");
+ this._bucketSize = this._prefBranch.getIntPref("bucketSize");
+ this._debug = this._prefBranch.getBoolPref("debug");
+ this._enabled = this._prefBranch.getBoolPref("enable");
+ this._expireDays = this._prefBranch.getIntPref("expire_days");
+ this._maxTimeGroupings = this._prefBranch.getIntPref("maxTimeGroupings");
+ this._prefixWeight = this._prefBranch.getIntPref("prefixWeight");
+ this._timeGroupingSize =
+ this._prefBranch.getIntPref("timeGroupingSize") * 1000 * 1000;
+ },
+
+ observe(_subject, topic, data) {
+ if (topic == "nsPref:changed") {
+ let prefName = data;
+ log(`got change to ${prefName} preference`);
+
+ switch (prefName) {
+ case "agedWeight":
+ this._agedWeight = this._prefBranch.getIntPref(prefName);
+ break;
+ case "boundaryWeight":
+ this._boundaryWeight = this._prefBranch.getIntPref(prefName);
+ break;
+ case "bucketSize":
+ this._bucketSize = this._prefBranch.getIntPref(prefName);
+ break;
+ case "debug":
+ this._debug = this._prefBranch.getBoolPref(prefName);
+ break;
+ case "enable":
+ this._enabled = this._prefBranch.getBoolPref(prefName);
+ break;
+ case "expire_days":
+ this._expireDays = this._prefBranch.getIntPref("expire_days");
+ break;
+ case "maxTimeGroupings":
+ this._maxTimeGroupings = this._prefBranch.getIntPref(prefName);
+ break;
+ case "prefixWeight":
+ this._prefixWeight = this._prefBranch.getIntPref(prefName);
+ break;
+ case "timeGroupingSize":
+ this._timeGroupingSize =
+ this._prefBranch.getIntPref(prefName) * 1000 * 1000;
+ break;
+ default:
+ log(`Oops! Pref ${prefName} not handled, change ignored.`);
+ break;
+ }
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+};
+
+function log(aMessage) {
+ if (Prefs.get("debug")) {
+ Services.console.logStringMessage("FormHistory: " + aMessage);
+ }
+}
+
+function sendNotification(aType, aData) {
+ if (typeof aData == "string") {
+ const strWrapper = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ strWrapper.data = aData;
+ aData = strWrapper;
+ } else if (typeof aData == "number") {
+ const intWrapper = Cc["@mozilla.org/supports-PRInt64;1"].createInstance(
+ Ci.nsISupportsPRInt64
+ );
+ intWrapper.data = aData;
+ aData = intWrapper;
+ } else if (aData) {
+ throw Components.Exception(
+ `Invalid type ${typeof aType} passed to sendNotification`,
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+
+ Services.obs.notifyObservers(aData, "satchel-storage-changed", aType);
+}
+
+/**
+ * Current database schema
+ */
+
+const dbSchema = {
+ tables: {
+ moz_formhistory: {
+ id: "INTEGER PRIMARY KEY",
+ fieldname: "TEXT NOT NULL",
+ value: "TEXT NOT NULL",
+ timesUsed: "INTEGER",
+ firstUsed: "INTEGER",
+ lastUsed: "INTEGER",
+ guid: "TEXT",
+ },
+ moz_deleted_formhistory: {
+ id: "INTEGER PRIMARY KEY",
+ timeDeleted: "INTEGER",
+ guid: "TEXT",
+ },
+ moz_sources: {
+ id: "INTEGER PRIMARY KEY",
+ source: "TEXT NOT NULL",
+ },
+ moz_history_to_sources: {
+ history_id: "INTEGER",
+ source_id: "INTEGER",
+ SQL: `
+ PRIMARY KEY (history_id, source_id),
+ FOREIGN KEY (history_id) REFERENCES moz_formhistory(id) ON DELETE CASCADE,
+ FOREIGN KEY (source_id) REFERENCES moz_sources(id) ON DELETE CASCADE
+ `,
+ },
+ },
+ indices: {
+ moz_formhistory_index: {
+ table: "moz_formhistory",
+ columns: ["fieldname"],
+ },
+ moz_formhistory_lastused_index: {
+ table: "moz_formhistory",
+ columns: ["lastUsed"],
+ },
+ moz_formhistory_guid_index: {
+ table: "moz_formhistory",
+ columns: ["guid"],
+ },
+ },
+};
+
+/**
+ * Validating and processing API querying data
+ */
+
+const validFields = [
+ "fieldname",
+ "firstUsed",
+ "guid",
+ "lastUsed",
+ "source",
+ "timesUsed",
+ "value",
+];
+
+const searchFilters = [
+ "firstUsedStart",
+ "firstUsedEnd",
+ "lastUsedStart",
+ "lastUsedEnd",
+ "source",
+];
+
+function validateOpData(aData, aDataType) {
+ let thisValidFields = validFields;
+ // A special case to update the GUID - in this case there can be a 'newGuid'
+ // field and of the normally valid fields, only 'guid' is accepted.
+ if (aDataType == "Update" && "newGuid" in aData) {
+ thisValidFields = ["guid", "newGuid"];
+ }
+ for (const field in aData) {
+ if (field != "op" && !thisValidFields.includes(field)) {
+ throw Components.Exception(
+ `${aDataType} query contains an unrecognized field: ${field}`,
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ }
+ return aData;
+}
+
+function validateSearchData(aData, aDataType) {
+ for (const field in aData) {
+ if (
+ field != "op" &&
+ !validFields.includes(field) &&
+ !searchFilters.includes(field)
+ ) {
+ throw Components.Exception(
+ `${aDataType} query contains an unrecognized field: ${field}`,
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ }
+}
+
+function makeQueryPredicates(aQueryData, delimiter = " AND ") {
+ const params = {};
+ const queryTerms = Object.keys(aQueryData)
+ .filter(field => aQueryData[field] !== undefined)
+ .map(field => {
+ params[field] = aQueryData[field];
+ switch (field) {
+ case "firstUsedStart":
+ return "firstUsed >= :" + field;
+ case "firstUsedEnd":
+ return "firstUsed <= :" + field;
+ case "lastUsedStart":
+ return "lastUsed >= :" + field;
+ case "lastUsedEnd":
+ return "lastUsed <= :" + field;
+ case "source":
+ return `EXISTS(
+ SELECT 1 FROM moz_history_to_sources
+ JOIN moz_sources s ON s.id = source_id
+ WHERE source = :${field}
+ AND history_id = moz_formhistory.id
+ )`;
+ }
+ return field + " = :" + field;
+ })
+ .join(delimiter);
+ return { queryTerms, params };
+}
+
+function generateGUID() {
+ // string like: "{f60d9eac-9421-4abc-8491-8e8322b063d4}"
+ const uuid = Services.uuid.generateUUID().toString();
+ let raw = ""; // A string with the low bytes set to random values
+ let bytes = 0;
+ for (let i = 1; bytes < 12; i += 2) {
+ // Skip dashes
+ if (uuid[i] == "-") {
+ i++;
+ }
+ const hexVal = parseInt(uuid[i] + uuid[i + 1], 16);
+ raw += String.fromCharCode(hexVal);
+ bytes++;
+ }
+ return btoa(raw);
+}
+
+var Migrators = {
+ // Bug 506402 - Adds deleted form history table.
+ async dbAsyncMigrateToVersion4(conn) {
+ const tableName = "moz_deleted_formhistory";
+ const tableExists = await conn.tableExists(tableName);
+ if (!tableExists) {
+ await createTable(conn, tableName);
+ }
+ },
+
+ // Bug 1654862 - Adds sources and moz_history_to_sources tables.
+ async dbAsyncMigrateToVersion5(conn) {
+ if (!(await conn.tableExists("moz_sources"))) {
+ for (const tableName of ["moz_history_to_sources", "moz_sources"]) {
+ await createTable(conn, tableName);
+ }
+ }
+ },
+};
+
+/**
+ * @typedef {object} InsertQueryData
+ * @property {object} updatedChange
+ * A change requested by FormHistory.
+ * @property {string} query
+ * The insert query string.
+ */
+
+/**
+ * Prepares a query and some default parameters when inserting an entry
+ * to the database.
+ *
+ * @param {object} change
+ * The change requested by FormHistory.
+ * @param {number} now
+ * The current timestamp in microseconds.
+ * @returns {InsertQueryData}
+ * The query information needed to pass along to the database.
+ */
+function prepareInsertQuery(change, now) {
+ const params = {};
+ for (const key of new Set([
+ ...Object.keys(change),
+ // These must always be NOT NULL.
+ "firstUsed",
+ "lastUsed",
+ "timesUsed",
+ ])) {
+ switch (key) {
+ case "fieldname":
+ case "guid":
+ case "value":
+ params[key] = change[key];
+ break;
+ case "firstUsed":
+ case "lastUsed":
+ params[key] = change[key] || now;
+ break;
+ case "timesUsed":
+ params[key] = change[key] || 1;
+ break;
+ default:
+ // Skip unnecessary properties.
+ }
+ }
+
+ return {
+ query: `
+ INSERT INTO moz_formhistory
+ (fieldname, value, timesUsed, firstUsed, lastUsed, guid)
+ VALUES (:fieldname, :value, :timesUsed, :firstUsed, :lastUsed, :guid)`,
+ params,
+ };
+}
+
+// There is a fieldname / value uniqueness constraint that's at this time
+// only enforced at this level. This Map maps fieldnames => values that
+// are in the process of being inserted into the database so that we know
+// not to try to insert the same ones on top. Attempts to do so will be
+// ignored.
+var InProgressInserts = {
+ _inProgress: new Map(),
+
+ add(fieldname, value) {
+ const fieldnameSet = this._inProgress.get(fieldname);
+ if (!fieldnameSet) {
+ this._inProgress.set(fieldname, new Set([value]));
+ return true;
+ }
+
+ if (!fieldnameSet.has(value)) {
+ fieldnameSet.add(value);
+ return true;
+ }
+
+ return false;
+ },
+
+ clear(fieldnamesAndValues) {
+ for (const [fieldname, value] of fieldnamesAndValues) {
+ const fieldnameSet = this._inProgress.get(fieldname);
+ if (fieldnameSet?.delete(value) && fieldnameSet.size == 0) {
+ this._inProgress.delete(fieldname);
+ }
+ }
+ },
+};
+
+function getAddSourceToGuidQueries(source, guid) {
+ return [
+ {
+ query: `INSERT OR IGNORE INTO moz_sources (source) VALUES (:source)`,
+ params: { source },
+ },
+ {
+ query: `
+ INSERT OR IGNORE INTO moz_history_to_sources (history_id, source_id)
+ VALUES(
+ (SELECT id FROM moz_formhistory WHERE guid = :guid),
+ (SELECT id FROM moz_sources WHERE source = :source)
+ )
+ `,
+ params: { guid, source },
+ },
+ ];
+}
+
+/**
+ * Constructs and executes database statements from a pre-processed list of
+ * inputted changes.
+ *
+ * @param {Array.<object>} aChanges changes to form history
+ */
+// XXX This should be split up and the complexity reduced.
+// eslint-disable-next-line complexity
+async function updateFormHistoryWrite(aChanges) {
+ log("updateFormHistoryWrite " + aChanges.length);
+
+ // pass 'now' down so that every entry in the batch has the same timestamp
+ const now = Date.now() * 1000;
+ let queries = [];
+ const notifications = [];
+ const adds = [];
+ const conn = await FormHistory.db;
+
+ for (const change of aChanges) {
+ const operation = change.op;
+ delete change.op;
+ switch (operation) {
+ case "remove": {
+ log("Remove from form history " + change);
+ const { queryTerms, params } = makeQueryPredicates(change);
+
+ // If source is defined, we only remove the source relation, if the
+ // consumer intends to remove the value from everywhere, then they
+ // should not pass source. This gives full control to the caller.
+ if (change.source) {
+ await conn.executeCached(
+ `DELETE FROM moz_history_to_sources
+ WHERE source_id = (
+ SELECT id FROM moz_sources WHERE source = :source
+ )
+ AND history_id = (
+ SELECT id FROM moz_formhistory WHERE ${queryTerms}
+ )
+ `,
+ params
+ );
+ break;
+ }
+
+ // Fetch the GUIDs we are going to delete.
+ try {
+ let query = "SELECT guid FROM moz_formhistory";
+ if (queryTerms) {
+ query += " WHERE " + queryTerms;
+ }
+
+ await conn.executeCached(query, params, row => {
+ notifications.push([
+ "formhistory-remove",
+ row.getResultByName("guid"),
+ ]);
+ });
+ } catch (e) {
+ log("Error getting guids from moz_formhistory: " + e);
+ }
+
+ if (supportsDeletedTable) {
+ log("Moving to deleted table " + change);
+ let query = "INSERT INTO moz_deleted_formhistory (guid, timeDeleted)";
+
+ // TODO: Add these items to the deleted items table once we've sorted
+ // out the issues from bug 756701
+ if (change.guid || queryTerms) {
+ query += change.guid
+ ? " VALUES (:guid, :timeDeleted)"
+ : " SELECT guid, :timeDeleted FROM moz_formhistory WHERE " +
+ queryTerms;
+ queries.push({
+ query,
+ params: Object.assign({ timeDeleted: now }, params),
+ });
+ }
+ }
+
+ let query = "DELETE FROM moz_formhistory";
+ if (queryTerms) {
+ log("removeEntries");
+ query += " WHERE " + queryTerms;
+ } else {
+ log("removeAllEntries");
+ // Not specifying any fields means we should remove all entries. We
+ // won't need to modify the query in this case.
+ }
+
+ queries.push({ query, params });
+ // Expire orphan sources.
+ queries.push({
+ query: `
+ DELETE FROM moz_sources WHERE id NOT IN (
+ SELECT DISTINCT source_id FROM moz_history_to_sources
+ )`,
+ });
+ break;
+ }
+ case "update": {
+ log("Update form history " + change);
+ const guid = change.guid;
+ delete change.guid;
+ // a special case for updating the GUID - the new value can be
+ // specified in newGuid.
+ if (change.newGuid) {
+ change.guid = change.newGuid;
+ delete change.newGuid;
+ }
+
+ let query = "UPDATE moz_formhistory SET ";
+ let { queryTerms, params } = makeQueryPredicates(change, ", ");
+ if (!queryTerms) {
+ throw Components.Exception(
+ "Update query must define fields to modify.",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ query += queryTerms + " WHERE guid = :existing_guid";
+ queries.push({
+ query,
+ params: Object.assign({ existing_guid: guid }, params),
+ });
+
+ notifications.push(["formhistory-update", guid]);
+
+ // Source is ignored for "update" operations, since it's not really
+ // common to change the source of a value, and anyway currently this is
+ // mostly used to update guids.
+ break;
+ }
+ case "bump": {
+ log("Bump form history " + change);
+ if (change.guid) {
+ const query =
+ "UPDATE moz_formhistory " +
+ "SET timesUsed = timesUsed + 1, lastUsed = :lastUsed WHERE guid = :guid";
+ const queryParams = {
+ lastUsed: now,
+ guid: change.guid,
+ };
+
+ queries.push({ query, params: queryParams });
+ notifications.push(["formhistory-update", change.guid]);
+ } else {
+ if (!InProgressInserts.add(change.fieldname, change.value)) {
+ // This updateFormHistoryWrite call, or a previous one, is already
+ // going to add this fieldname / value pair, so we can ignore this.
+ continue;
+ }
+ adds.push([change.fieldname, change.value]);
+ change.guid = generateGUID();
+ const { query, params } = prepareInsertQuery(change, now);
+ queries.push({ query, params });
+ notifications.push(["formhistory-add", params.guid]);
+ }
+
+ if (change.source) {
+ queries = queries.concat(
+ getAddSourceToGuidQueries(change.source, change.guid)
+ );
+ }
+ break;
+ }
+ case "add": {
+ if (!InProgressInserts.add(change.fieldname, change.value)) {
+ // This updateFormHistoryWrite call, or a previous one, is already
+ // going to add this fieldname / value pair, so we can ignore this.
+ continue;
+ }
+ adds.push([change.fieldname, change.value]);
+
+ log("Add to form history " + change);
+ if (!change.guid) {
+ change.guid = generateGUID();
+ }
+
+ const { query, params } = prepareInsertQuery(change, now);
+ queries.push({ query, params });
+
+ notifications.push(["formhistory-add", params.guid]);
+
+ if (change.source) {
+ queries = queries.concat(
+ getAddSourceToGuidQueries(change.source, change.guid)
+ );
+ }
+ break;
+ }
+ default: {
+ // We should've already guaranteed that change.op is one of the above
+ throw Components.Exception(
+ "Invalid operation " + operation,
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ }
+ }
+
+ try {
+ await conn.executeTransaction(async () => {
+ for (const { query, params } of queries) {
+ await conn.executeCached(query, params);
+ }
+ });
+ for (const [notification, param] of notifications) {
+ // We're either sending a GUID or nothing at all.
+ sendNotification(notification, param);
+ }
+ } finally {
+ InProgressInserts.clear(adds);
+ }
+}
+
+/**
+ * Functions that expire entries in form history and shrinks database
+ * afterwards as necessary initiated by expireOldEntries.
+ */
+
+/**
+ * Removes entries from database.
+ *
+ * @param {number} aExpireTime expiration timestamp
+ * @param {number} aBeginningCount numer of entries at first
+ * @returns {Promise} resolved once the work is complete
+ */
+async function expireOldEntriesDeletion(aExpireTime, aBeginningCount) {
+ log(`expireOldEntriesDeletion(${aExpireTime},${aBeginningCount})`);
+
+ await FormHistory.update([
+ {
+ op: "remove",
+ lastUsedEnd: aExpireTime,
+ },
+ ]);
+ await expireOldEntriesVacuum(aExpireTime, aBeginningCount);
+}
+
+/**
+ * Counts number of entries removed and shrinks database as necessary.
+ *
+ * @param {number} aExpireTime expiration timestamp
+ * @param {number} aBeginningCount number of entries at first
+ */
+async function expireOldEntriesVacuum(aExpireTime, aBeginningCount) {
+ const count = await FormHistory.count({});
+ if (aBeginningCount - count > 500) {
+ log("expireOldEntriesVacuum");
+ const conn = await FormHistory.db;
+ await conn.executeCached("VACUUM");
+ }
+ sendNotification("formhistory-expireoldentries", aExpireTime);
+}
+
+async function createTable(conn, tableName) {
+ const table = dbSchema.tables[tableName];
+ const columns = Object.keys(table)
+ .filter(col => col != "SQL")
+ .map(col => [col, table[col]].join(" "))
+ .join(", ");
+ const no_rowid = Object.keys(table).includes("id") ? "" : "WITHOUT ROWID";
+ log(`Creating table ${tableName} with ${columns}`);
+ await conn.execute(
+ `CREATE TABLE ${tableName} (
+ ${columns}
+ ${table.SQL ? "," + table.SQL : ""}
+ ) ${no_rowid}`
+ );
+}
+
+/**
+ * Database creation and access. Used by FormHistory and some of the
+ * utility functions, but is not exposed to the outside world.
+ *
+ * @class
+ */
+var DB = {
+ // Once we establish a database connection, we have to hold a reference
+ // to it so that it won't get GC'd.
+ _instance: null,
+ // MAX_ATTEMPTS is how many times we'll try to establish a connection
+ // or migrate a database before giving up.
+ MAX_ATTEMPTS: 2,
+
+ /** String representing where the FormHistory database is on the filesystem */
+ get path() {
+ return PathUtils.join(PathUtils.profileDir, DB_FILENAME);
+ },
+
+ /**
+ * Sets up and returns a connection to the FormHistory database. The
+ * connection also registers itself with AsyncShutdown so that the
+ * connection is closed on when the profile-before-change observer
+ * notification is fired.
+ *
+ * @returns {Promise<OpenedConnection>}
+ * A {@link toolkit/modules/Sqlite.sys.mjs} connection to the database.
+ * @throws
+ * If connecting to the database, or migrating the database
+ * failed after MAX_ATTEMPTS attempts (where each attempt
+ * backs up and deletes the old database), this will reject
+ * with the Sqlite.sys.mjs error.
+ */
+ get conn() {
+ delete this.conn;
+ const conn = (async () => {
+ try {
+ this._instance = await this._establishConn();
+ } catch (e) {
+ log("Failed to establish database connection: " + e);
+ throw e;
+ }
+
+ return this._instance;
+ })();
+
+ return (this.conn = conn);
+ },
+
+ // Private functions
+
+ /**
+ * Tries to connect to the Sqlite database at this.path, and then
+ * migrates the database as necessary. If any of the steps to do this
+ * fail, this function should re-enter itself with an incremented
+ * attemptNum so that another attempt can be made after backing up
+ * and deleting the old database.
+ *
+ * @async
+ * @param {number} attemptNum
+ * The optional number of the attempt that is being made to connect
+ * to the database. Defaults to 0.
+ * @returns {Promise<OpenedConnection>}
+ * A {@link toolkit/modules/Sqlite.sys.mjs} connection to the database.
+ * @throws
+ * If connecting to the database, or migrating the database
+ * failed after MAX_ATTEMPTS attempts (where each attempt
+ * backs up and deletes the old database), this will reject
+ * with the Sqlite.sys.mjs error.
+ */
+ async _establishConn(attemptNum = 0) {
+ log(`Establishing database connection - attempt # ${attemptNum}`);
+ let conn;
+ try {
+ conn = await lazy.Sqlite.openConnection({ path: this.path });
+ lazy.Sqlite.shutdown.addBlocker("Closing FormHistory database.", () =>
+ conn.close()
+ );
+ } catch (e) {
+ // Bug 1423729 - We should check the reason for the connection failure,
+ // in case this is due to the disk being full or the database file being
+ // inaccessible due to third-party software (like anti-virus software).
+ // In that case, we should probably fail right away.
+ if (attemptNum < this.MAX_ATTEMPTS) {
+ log("Establishing connection failed.");
+ await this._failover(conn);
+ return this._establishConn(++attemptNum);
+ }
+
+ if (conn) {
+ await conn.close();
+ }
+ log("Establishing connection failed too many times. Giving up.");
+ throw e;
+ }
+
+ try {
+ // Enable foreign keys support.
+ await conn.execute("PRAGMA foreign_keys = ON");
+
+ const dbVersion = parseInt(await conn.getSchemaVersion(), 10);
+
+ // Case 1: Database is up to date and we're ready to go.
+ if (dbVersion == DB_SCHEMA_VERSION) {
+ return conn;
+ }
+
+ // Case 2: Downgrade
+ if (dbVersion > DB_SCHEMA_VERSION) {
+ log("Downgrading to version " + DB_SCHEMA_VERSION);
+ // User's DB is newer. Sanity check that our expected columns are
+ // present, and if so mark the lower version and merrily continue
+ // on. If the columns are borked, something is wrong so blow away
+ // the DB and start from scratch. [Future incompatible upgrades
+ // should switch to a different table or file.]
+ if (!(await this._expectedColumnsPresent(conn))) {
+ throw Components.Exception(
+ "DB is missing expected columns",
+ Cr.NS_ERROR_FILE_CORRUPTED
+ );
+ }
+
+ // Change the stored version to the current version. If the user
+ // runs the newer code again, it will see the lower version number
+ // and re-upgrade (to fixup any entries the old code added).
+ await conn.setSchemaVersion(DB_SCHEMA_VERSION);
+ return conn;
+ }
+
+ // Case 3: Very old database that cannot be migrated.
+ //
+ // When FormHistory is released, we will no longer support the various
+ // schema versions prior to this release that nsIFormHistory2 once did.
+ // We'll throw an NS_ERROR_FILE_CORRUPTED, which should cause us to wipe
+ // out this DB and create a new one (unless this is our MAX_ATTEMPTS
+ // attempt).
+ if (dbVersion > 0 && dbVersion < 3) {
+ throw Components.Exception(
+ "DB version is unsupported.",
+ Cr.NS_ERROR_FILE_CORRUPTED
+ );
+ }
+
+ if (dbVersion == 0) {
+ // Case 4: New database
+ await conn.executeTransaction(async () => {
+ log("Creating DB -- tables");
+ for (const name in dbSchema.tables) {
+ await createTable(conn, name);
+ }
+
+ log("Creating DB -- indices");
+ for (const name in dbSchema.indices) {
+ const index = dbSchema.indices[name];
+ const statement = `CREATE INDEX IF NOT EXISTS ${name} ON ${
+ index.table
+ }(${index.columns.join(", ")})`;
+ await conn.execute(statement);
+ }
+ });
+ } else {
+ // Case 5: Old database requiring a migration
+ await conn.executeTransaction(async () => {
+ for (let v = dbVersion + 1; v <= DB_SCHEMA_VERSION; v++) {
+ log(`Upgrading to version ${v}...`);
+ await Migrators["dbAsyncMigrateToVersion" + v](conn);
+ }
+ });
+ }
+
+ await conn.setSchemaVersion(DB_SCHEMA_VERSION);
+
+ return conn;
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_FILE_CORRUPTED) {
+ throw e;
+ }
+
+ if (attemptNum < this.MAX_ATTEMPTS) {
+ log("Setting up database failed.");
+ await this._failover(conn);
+ return this._establishConn(++attemptNum);
+ }
+
+ if (conn) {
+ await conn.close();
+ }
+
+ log("Setting up database failed too many times. Giving up.");
+
+ throw e;
+ }
+ },
+
+ /**
+ * Closes a connection to the database, then backs up the database before
+ * deleting it.
+ *
+ * @async
+ * @param {SqliteConnection | null} conn
+ * The connection to the database that we failed to establish or
+ * migrate.
+ * @throws If any file operations fail.
+ */
+ async _failover(conn) {
+ log("Cleaning up DB file - close & remove & backup.");
+ if (conn) {
+ await conn.close();
+ }
+ const backupFile = this.path + ".corrupt";
+ const uniquePath = await IOUtils.createUniqueFile(
+ PathUtils.parent(backupFile),
+ PathUtils.filename(backupFile),
+ 0o600
+ );
+ await IOUtils.copy(this.path, uniquePath);
+ await IOUtils.remove(this.path);
+ log("Completed DB cleanup.");
+ },
+
+ /**
+ * Tests that a database connection contains the tables that we expect.
+ *
+ * @async
+ * @param {SqliteConnection | null} conn
+ * The connection to the database that we're testing.
+ * @returns {Promise<boolean>} true if all expected columns are present.
+ */
+ async _expectedColumnsPresent(conn) {
+ for (const name in dbSchema.tables) {
+ const table = dbSchema.tables[name];
+ const columns = Object.keys(table).filter(col => col != "SQL");
+ const query = `SELECT ${columns.join(", ")} FROM ${name}`;
+ try {
+ await conn.execute(query, null, (_row, cancel) => {
+ // One row is enough to let us know this worked.
+ cancel();
+ });
+ } catch (e) {
+ return false;
+ }
+ }
+
+ log("Verified that expected columns are present in DB.");
+ return true;
+ },
+};
+
+FormHistory = {
+ get db() {
+ return DB.conn;
+ },
+
+ get enabled() {
+ return Prefs.get("enabled");
+ },
+
+ async search(aSelectTerms, aSearchData, aRowFunc) {
+ // if no terms selected, select everything
+ if (!aSelectTerms) {
+ // Source is not a valid column in moz_formhistory.
+ aSelectTerms = validFields.filter(f => f != "source");
+ }
+
+ validateSearchData(aSearchData, "Search");
+
+ let query = `SELECT ${aSelectTerms.join(", ")} FROM moz_formhistory`;
+ const { queryTerms, params } = makeQueryPredicates(aSearchData);
+ if (queryTerms) {
+ query += " WHERE " + queryTerms;
+ }
+
+ const allResults = [];
+
+ const conn = await this.db;
+ await conn.executeCached(query, params, row => {
+ const result = {};
+ for (const field of aSelectTerms) {
+ result[field] = row.getResultByName(field);
+ }
+ aRowFunc?.(result);
+ allResults.push(result);
+ });
+
+ return allResults;
+ },
+
+ async count(aSearchData) {
+ validateSearchData(aSearchData, "Count");
+
+ let query = "SELECT COUNT(*) AS numEntries FROM moz_formhistory";
+ const { queryTerms, params } = makeQueryPredicates(aSearchData);
+ if (queryTerms) {
+ query += " WHERE " + queryTerms;
+ }
+
+ const conn = await this.db;
+ const rows = await conn.executeCached(query, params);
+ return rows[0].getResultByName("numEntries");
+ },
+
+ async update(aChanges) {
+ function validIdentifier(change) {
+ // The identifier is only valid if one of either the guid
+ // or the (fieldname/value) are set (so an X-OR)
+ return Boolean(change.guid) != Boolean(change.fieldname && change.value);
+ }
+
+ if (!("length" in aChanges)) {
+ aChanges = [aChanges];
+ }
+
+ const isRemoveOperation = aChanges.every(change => change?.op == "remove");
+ if (!this.enabled && !isRemoveOperation) {
+ throw new Error(
+ "Form history is disabled, only remove operations are allowed"
+ );
+ }
+
+ for (const change of aChanges) {
+ switch (change.op) {
+ case "remove":
+ validateSearchData(change, "Remove");
+ continue;
+ case "update":
+ if (validIdentifier(change)) {
+ validateOpData(change, "Update");
+ if (change.guid) {
+ continue;
+ }
+ } else {
+ throw Components.Exception(
+ "update op='update' does not correctly reference a entry.",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ break;
+ case "bump":
+ if (validIdentifier(change)) {
+ validateOpData(change, "Bump");
+ if (change.guid) {
+ continue;
+ }
+ } else {
+ throw Components.Exception(
+ "update op='bump' does not correctly reference a entry.",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ break;
+ case "add":
+ if (change.fieldname && change.value) {
+ validateOpData(change, "Add");
+ } else {
+ throw Components.Exception(
+ "update op='add' must have a fieldname and a value.",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ break;
+ default:
+ throw Components.Exception(
+ "update does not recognize op='" + change.op + "'",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+
+ const results = await FormHistory.search(["guid"], {
+ fieldname: change.fieldname,
+ value: change.value,
+ });
+ if (results.length > 1) {
+ const error =
+ "Database contains multiple entries with the same fieldname/value pair.";
+ log(error);
+ throw new Error(error);
+ }
+ change.guid = results[0]?.guid;
+ }
+
+ await updateFormHistoryWrite(aChanges);
+ },
+
+ /**
+ * Gets results for the autocomplete widget.
+ *
+ * @param {string} searchString The string to search for.
+ * @param {object} params zero or more filter properties:
+ * - fieldname
+ * - source
+ * @param {Function} [callback] if provided, it is invoked for each result.
+ * the first argument is the result with four properties (text,
+ * textLowerCase, frequency, totalScore), the second argument is a cancel
+ * function that can be invoked to cancel the search.
+ * @returns {Promise<Array>}
+ * An array of results. If the search was canceled it will be an empty array.
+ */
+ async getAutoCompleteResults(searchString, params, callback) {
+ // only do substring matching when the search string contains more than one character
+ let searchTokens;
+ let where = "";
+ let boundaryCalc = "";
+
+ params = {
+ agedWeight: Prefs.get("agedWeight"),
+ bucketSize: Prefs.get("bucketSize"),
+ expiryDate:
+ 1000 * (Date.now() - Prefs.get("expireDays") * 24 * 60 * 60 * 1000),
+ maxTimeGroupings: Prefs.get("maxTimeGroupings"),
+ timeGroupingSize: Prefs.get("timeGroupingSize"),
+ prefixWeight: Prefs.get("prefixWeight"),
+ boundaryWeight: Prefs.get("boundaryWeight"),
+ ...params,
+ };
+
+ if (searchString.length >= 1) {
+ params.valuePrefix = searchString.replaceAll("/", "//") + "%";
+ }
+
+ if (searchString.length > 1) {
+ searchTokens = searchString.split(/\s+/);
+
+ // build up the word boundary and prefix match bonus calculation
+ boundaryCalc =
+ "MAX(1, :prefixWeight * (value LIKE :valuePrefix ESCAPE '/') + (";
+ // for each word, calculate word boundary weights for the SELECT clause and
+ // add word to the WHERE clause of the query
+ let tokenCalc = [];
+ let searchTokenCount = Math.min(searchTokens.length, MAX_SEARCH_TOKENS);
+ for (let i = 0; i < searchTokenCount; i++) {
+ let escapedToken = searchTokens[i].replaceAll("/", "//");
+ params["tokenBegin" + i] = escapedToken + "%";
+ params["tokenBoundary" + i] = "% " + escapedToken + "%";
+ params["tokenContains" + i] = "%" + escapedToken + "%";
+
+ tokenCalc.push(
+ `(value LIKE :tokenBegin${i} ESCAPE '/') + (value LIKE :tokenBoundary${i} ESCAPE '/')`
+ );
+ where += `AND (value LIKE :tokenContains${i} ESCAPE '/') `;
+ }
+ // add more weight if we have a traditional prefix match and
+ // multiply boundary bonuses by boundary weight
+ boundaryCalc += tokenCalc.join(" + ") + ") * :boundaryWeight)";
+ } else if (searchString.length == 1) {
+ where = "AND (value LIKE :valuePrefix ESCAPE '/') ";
+ boundaryCalc = "1";
+ delete params.prefixWeight;
+ delete params.boundaryWeight;
+ } else {
+ where = "";
+ boundaryCalc = "1";
+ delete params.prefixWeight;
+ delete params.boundaryWeight;
+ }
+
+ params.now = Date.now() * 1000; // convert from ms to microseconds
+
+ if (params.source) {
+ where += `AND EXISTS(
+ SELECT 1 FROM moz_history_to_sources
+ JOIN moz_sources s ON s.id = source_id
+ WHERE source = :source
+ AND history_id = moz_formhistory.id
+ )`;
+ }
+
+ /* Three factors in the frecency calculation for an entry (in order of use in calculation):
+ * 1) average number of times used - items used more are ranked higher
+ * 2) how recently it was last used - items used recently are ranked higher
+ * 3) additional weight for aged entries surviving expiry - these entries are relevant
+ * since they have been used multiple times over a large time span so rank them higher
+ * The score is then divided by the bucket size and we round the result so that entries
+ * with a very similar frecency are bucketed together with an alphabetical sort. This is
+ * to reduce the amount of moving around by entries while typing.
+ */
+
+ const query =
+ "/* do not warn (bug 496471): can't use an index */ " +
+ "SELECT value, guid, " +
+ "ROUND( " +
+ "timesUsed / MAX(1.0, (lastUsed - firstUsed) / :timeGroupingSize) * " +
+ "MAX(1.0, :maxTimeGroupings - (:now - lastUsed) / :timeGroupingSize) * " +
+ "MAX(1.0, :agedWeight * (firstUsed < :expiryDate)) / " +
+ ":bucketSize " +
+ ", 3) AS frecency, " +
+ boundaryCalc +
+ " AS boundaryBonuses " +
+ "FROM moz_formhistory " +
+ "WHERE fieldname=:fieldname " +
+ where +
+ "ORDER BY ROUND(frecency * boundaryBonuses) DESC, UPPER(value) ASC";
+
+ const results = [];
+ let cancelled = false;
+ function cancelFn() {
+ cancelled = true;
+ }
+
+ const conn = await this.db;
+ await conn.executeCached(query, params, (row, cancel) => {
+ if (cancelled) {
+ cancel();
+ return;
+ }
+
+ const value = row.getResultByName("value");
+ const guid = row.getResultByName("guid");
+ const frecency = row.getResultByName("frecency");
+ const entry = {
+ text: value,
+ guid,
+ textLowerCase: value.toLowerCase(),
+ frecency,
+ totalScore: Math.round(
+ frecency * row.getResultByName("boundaryBonuses")
+ ),
+ };
+ callback?.(entry, cancelFn);
+ results.push(entry);
+ });
+ return cancelled ? [] : results;
+ },
+
+ // This is used only so that the test can verify deleted table support.
+ get _supportsDeletedTable() {
+ return supportsDeletedTable;
+ },
+ set _supportsDeletedTable(val) {
+ supportsDeletedTable = val;
+ },
+
+ // The remaining methods are called by FormHistoryStartup.js
+ async expireOldEntries() {
+ log("expireOldEntries");
+
+ // Determine how many days of history we're supposed to keep.
+ // Calculate expireTime in microseconds
+ const expireTime =
+ (Date.now() - Prefs.get("expireDays") * DAY_IN_MS) * 1000;
+
+ sendNotification("formhistory-beforeexpireoldentries", expireTime);
+
+ const count = await FormHistory.count({});
+ await expireOldEntriesDeletion(expireTime, count);
+ },
+};
+
+// Prevent add-ons from redefining this API
+Object.freeze(FormHistory);