tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

ChatStore.sys.mjs (23676B)


      1 /*
      2 This Source Code Form is subject to the terms of the Mozilla Public
      3 * License, v. 2.0. If a copy of the MPL was not distributed with this
      4 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
      5 
      6 const lazy = {};
      7 
      8 ChromeUtils.defineESModuleGetters(lazy, {
      9  Sqlite: "resource://gre/modules/Sqlite.sys.mjs",
     10 });
     11 
     12 ChromeUtils.defineLazyGetter(lazy, "log", function () {
     13  return console.createInstance({
     14    prefix: "ChatStore",
     15    maxLogLevelPref: "browser.aiwindow.chatStore.loglevel",
     16  });
     17 });
     18 
     19 import {
     20  CONVERSATION_TABLE,
     21  CONVERSATION_UPDATED_DATE_INDEX,
     22  CONVERSATION_INSERT,
     23  MESSAGE_TABLE,
     24  MESSAGE_ORDINAL_INDEX,
     25  MESSAGE_URL_INDEX,
     26  MESSAGE_CREATED_DATE_INDEX,
     27  MESSAGE_CONV_ID_INDEX,
     28  MESSAGE_INSERT,
     29  CONVERSATIONS_MOST_RECENT,
     30  CONVERSATION_BY_ID,
     31  CONVERSATIONS_BY_DATE,
     32  CONVERSATIONS_BY_URL,
     33  CONVERSATIONS_CONTENT_SEARCH,
     34  CONVERSATIONS_CONTENT_SEARCH_BY_ROLE,
     35  CONVERSATIONS_HISTORY_SEARCH,
     36  MESSAGES_BY_DATE,
     37  MESSAGES_BY_DATE_AND_ROLE,
     38  DELETE_CONVERSATION_BY_ID,
     39  CONVERSATIONS_OLDEST,
     40  CONVERSATION_HISTORY,
     41  ESCAPE_CHAR,
     42  getConversationMessagesSql,
     43 } from "./ChatSql.sys.mjs";
     44 
     45 import { ChatMinimal } from "./ChatMessage.sys.mjs";
     46 
     47 export { ChatConversation } from "./ChatConversation.sys.mjs";
     48 export { ChatMessage, ChatMinimal } from "./ChatMessage.sys.mjs";
     49 export {
     50  CONVERSATION_STATUS,
     51  MESSAGE_ROLE,
     52  INSIGHTS_FLAG_SOURCE,
     53 } from "./ChatConstants.sys.mjs";
     54 
     55 import {
     56  CURRENT_SCHEMA_VERSION,
     57  DB_FOLDER_PATH,
     58  DB_FILE_NAME,
     59  PREF_BRANCH,
     60  CONVERSATION_STATUS,
     61 } from "./ChatConstants.sys.mjs";
     62 
     63 import {
     64  parseConversationRow,
     65  parseMessageRows,
     66  parseChatHistoryViewRows,
     67  toJSONOrNull,
     68 } from "./ChatUtils.sys.mjs";
     69 
     70 // NOTE: Reference to migrations file, migrations.mjs has an example
     71 // migration function set up for a migration, and the eslint-disable-next-line
     72 // should be removed once we create the first migration.
     73 //
     74 // eslint-disable-next-line no-unused-vars
     75 import { migrations } from "./ChatMigrations.sys.mjs";
     76 
     77 const MAX_DB_SIZE_BYTES = 75 * 1024 * 1024;
     78 const SORTS = ["ASC", "DESC"];
     79 
     80 /**
     81 * Simple interface to store and retrieve chat conversations and messages.
     82 *
     83 * @todo Bug 2005409
     84 * Move this documentation to Firefox source docs
     85 *
     86 * See: https://docs.google.com/document/d/1VlwmGbMhPIe-tmeKWinHuPh50VC9QrWEeQQ5V-UvEso/edit?tab=t.klqqibndv3zk
     87 *
     88 * @example
     89 * let { ChatStore, ChatConversation, ChatMessage, MESSAGE_ROLE } =
     90 *   ChromeUtils.importESModule("resource:///modules/aiwindow/ui/modules/ChatStore.sys.mjs");
     91 * const chatStore = new ChatStore();
     92 * const conversation = new ChatConversation({
     93 *   title: "title",
     94 *   description: "description",
     95 *   pageUrl: new URL("https://mozilla.com/"),
     96 *   pageMeta: { one: 1, two: 2 },
     97 * });
     98 * const msg1 = new ChatMessage({
     99 *   ordinal: 0,
    100 *   role: MESSAGE_ROLE.USER,
    101 *   modelId: "test",
    102 *   params: { one: "one" },
    103 *   usage: { two: "two", content: "some content" },
    104 * });
    105 * const msg2 = new ChatMessage({
    106 *   ordinal: 1,
    107 *   role: MESSAGE_ROLE.ASSISTANT,
    108 *   modelId: "test",
    109 *   params: { one: "one" },
    110 *   usage: { two: "two", content: "some content 2" },
    111 * });
    112 * conversation.messages = [msg1, msg2];
    113 * await chatStore.updateConversation(conversation);
    114 * // Or findConversationsByDate, findConversationsByURL.
    115 * const foundConversation =
    116 *   await chatStore.findConversationById(conversation.id);
    117 *
    118 * @typedef {object} ChatStore
    119 *
    120 * @property {*} x ?
    121 */
    122 export class ChatStore {
    123  #asyncShutdownBlocker;
    124  #conn;
    125  #promiseConn;
    126 
    127  constructor() {
    128    this.#asyncShutdownBlocker = async () => {
    129      await this.#closeConnection();
    130    };
    131  }
    132 
    133  /**
    134   * Updates a conversation's saved state in the SQLite db
    135   *
    136   * @param {ChatConversation} conversation
    137   */
    138  async updateConversation(conversation) {
    139    await this.#ensureDatabase().catch(e => {
    140      lazy.log.error("Could not ensure a database connection.");
    141      throw e;
    142    });
    143 
    144    const pageUrl = URL.parse(conversation.pageUrl);
    145 
    146    await this.#conn
    147      .executeTransaction(async () => {
    148        await this.#conn.executeCached(CONVERSATION_INSERT, {
    149          conv_id: conversation.id,
    150          title: conversation.title,
    151          description: conversation.description,
    152          page_url: pageUrl?.href ?? null,
    153          page_meta: toJSONOrNull(conversation.pageMeta),
    154          created_date: conversation.createdDate,
    155          updated_date: conversation.updatedDate,
    156          status: conversation.status,
    157          active_branch_tip_message_id: conversation.activeBranchTipMessageId,
    158        });
    159 
    160        const messages = conversation.messages.map(m => ({
    161          message_id: m.id,
    162          conv_id: conversation.id,
    163          created_date: m.createdDate,
    164          parent_message_id: m.parentMessageId,
    165          revision_root_message_id: m.revisionRootMessageId,
    166          ordinal: m.ordinal,
    167          is_active_branch: m.isActiveBranch ? 1 : 0,
    168          role: m.role,
    169          model_id: m.modelId,
    170          params: toJSONOrNull(m.params),
    171          content: toJSONOrNull(m.content),
    172          usage: toJSONOrNull(m.usage),
    173          page_url: m.pageUrl?.href || "",
    174          turn_index: m.turnIndex,
    175          insights_enabled: m.insightsEnabled,
    176          insights_flag_source: m.insightsFlagSource,
    177          insights_applied_jsonb: toJSONOrNull(m.insightsApplied),
    178          web_search_queries_jsonb: toJSONOrNull(m.webSearchQueries),
    179        }));
    180        await this.#conn.executeCached(MESSAGE_INSERT, messages);
    181      })
    182      .catch(e => {
    183        lazy.log.error("Transaction failed to execute");
    184        throw e;
    185      });
    186  }
    187 
    188  /**
    189   * Gets a list of oldest conversations
    190   *
    191   * @param {number} numberOfConversations - How many conversations to retrieve
    192   * @returns {Array<ChatMinimal>} - List of ChatMinimal items
    193   */
    194  async findOldestConversations(numberOfConversations) {
    195    await this.#ensureDatabase().catch(e => {
    196      lazy.log.error("Could not ensure a database connection.");
    197      throw e;
    198    });
    199 
    200    const rows = await this.#conn
    201      .executeCached(CONVERSATIONS_OLDEST, {
    202        limit: numberOfConversations,
    203      })
    204      .catch(e => {
    205        lazy.log.error("Could not retrieve oldest conversations.");
    206        throw e;
    207      });
    208 
    209    return rows.map(row => {
    210      return new ChatMinimal({
    211        convId: row.getResultByName("conv_id"),
    212        title: row.getResultByName("title"),
    213      });
    214    });
    215  }
    216 
    217  /**
    218   * Gets a list of most recent conversations
    219   *
    220   * @param {number} numberOfConversations - How many conversations to retrieve
    221   * @returns {Array<ChatMinimal>} - List of ChatMinimal items
    222   */
    223  async findRecentConversations(numberOfConversations) {
    224    await this.#ensureDatabase().catch(e => {
    225      lazy.log.error("Could not ensure a database connection.");
    226      throw e;
    227    });
    228 
    229    const rows = await this.#conn
    230      .executeCached(CONVERSATIONS_MOST_RECENT, {
    231        limit: numberOfConversations,
    232      })
    233      .catch(e => {
    234        lazy.log.error("Could not retrieve most recent conversations.");
    235        throw e;
    236      });
    237 
    238    return rows.map(row => {
    239      return new ChatMinimal({
    240        convId: row.getResultByName("conv_id"),
    241        title: row.getResultByName("title"),
    242      });
    243    });
    244  }
    245 
    246  /**
    247   * Gets a Conversation using it's id
    248   *
    249   * @param {string} conversationId - The ID of the conversation to retrieve
    250   *
    251   * @returns {ChatConversation} - The conversation and its messages
    252   */
    253  async findConversationById(conversationId) {
    254    const conversations = await this.#findConversationsWithMessages(
    255      CONVERSATION_BY_ID,
    256      {
    257        conv_id: conversationId,
    258      }
    259    );
    260 
    261    return conversations[0] ?? null;
    262  }
    263 
    264  /**
    265   * Finds conversations between a specified start and end date
    266   *
    267   * @param {number} startDate - Start time epoch format
    268   * @param {number} endDate - End time epoch format
    269   *
    270   * @returns {Array<ChatConversation>} - The conversations and their messages
    271   */
    272  async findConversationsByDate(startDate, endDate) {
    273    return this.#findConversationsWithMessages(CONVERSATIONS_BY_DATE, {
    274      start_date: startDate,
    275      end_date: endDate,
    276    });
    277  }
    278 
    279  /**
    280   * Finds conversations between a specified start and end date
    281   *
    282   * @param {URL} pageUrl - The URL to find conversations for
    283   *
    284   * @returns {Array<ChatConversation>} - The conversations and their messages
    285   */
    286  async findConversationsByURL(pageUrl) {
    287    return this.#findConversationsWithMessages(CONVERSATIONS_BY_URL, {
    288      page_url: pageUrl.href,
    289    });
    290  }
    291 
    292  /**
    293   * Search for messages that happened between the specified start
    294   * and end dates, optionally, filter the messages by a specific
    295   * message role type.
    296   *
    297   * @param {Date} startDate - The start date, inclusive
    298   * @param {Date} [endDate=new Date()] - The end date, inclusive
    299   * @param {MessageRole} [role=-1] - The message role type to filter by, one of 0|1|2|3
    300   * as defined by the constant MESSAGE_ROLE
    301   * @param {number} [limit=-1] - The max number of messages to retrieve
    302   * @param {number} [offset=-1] - The number or messages to skip from the result set
    303   *
    304   * @returns {Array<ChatMessage>} - An array of ChatMessage entries
    305   */
    306  async findMessagesByDate(
    307    startDate,
    308    endDate = new Date(),
    309    role = -1,
    310    limit = -1,
    311    offset = -1
    312  ) {
    313    const params = {
    314      start_date: startDate.getTime(),
    315      end_date: endDate.getTime(),
    316      limit,
    317      offset,
    318    };
    319 
    320    let sql = MESSAGES_BY_DATE;
    321    if (role > -1) {
    322      sql = MESSAGES_BY_DATE_AND_ROLE;
    323      params.role = role;
    324    }
    325 
    326    let rows = await this.#conn.executeCached(sql, params);
    327 
    328    return parseMessageRows(rows);
    329  }
    330 
    331  #escapeForLike(searchString) {
    332    return searchString
    333      .replaceAll(ESCAPE_CHAR, `${ESCAPE_CHAR}${ESCAPE_CHAR}`)
    334      .replaceAll("%", `${ESCAPE_CHAR}%`)
    335      .replaceAll("_", `${ESCAPE_CHAR}_`);
    336  }
    337 
    338  /**
    339   * Searches through the message.content JSON object to find a particular
    340   * object path that contains a partial string match of a value.
    341   *
    342   * @param {string} keyChain - The object key chain to look through,
    343   * like obj.field1.field2
    344   * @param {MessageRole} [role=-1] - A message role to search for
    345   *
    346   * @returns {Array<ChatConversation>} - An array of conversations with messages
    347   * that contain a message that matches the search string at the given content
    348   * object path
    349   */
    350  async searchContent(keyChain, role = -1) {
    351    const path = `$.${keyChain}`;
    352 
    353    const query =
    354      role > -1
    355        ? CONVERSATIONS_CONTENT_SEARCH_BY_ROLE
    356        : CONVERSATIONS_CONTENT_SEARCH;
    357 
    358    const params = { path };
    359 
    360    if (role > -1) {
    361      params.role = role;
    362    }
    363 
    364    const rows = await this.#conn.executeCached(query, params);
    365 
    366    if (!rows.length) {
    367      return [];
    368    }
    369 
    370    const conversations = rows.map(parseConversationRow);
    371 
    372    return await this.#getMessagesForConversations(conversations);
    373  }
    374 
    375  /**
    376   * Searches for conversations where the conversation title, or the conversation
    377   * contains a user message where the search string contains a partial match
    378   * in the message.content.body field
    379   *
    380   * @param {string} searchString - The string to search with for conversations
    381   *
    382   * @returns {Array<ChatConversation>} - An array of conversations with messages
    383   * that contain a message that matches the search string in the conversation
    384   * titles
    385   */
    386  async search(searchString) {
    387    const path = `$.body`;
    388    const pattern = `%${this.#escapeForLike(searchString)}%`;
    389 
    390    const rows = await this.#conn.executeCached(CONVERSATIONS_HISTORY_SEARCH, {
    391      path,
    392      pattern,
    393    });
    394 
    395    if (!rows.length) {
    396      return [];
    397    }
    398 
    399    const conversations = rows.map(parseConversationRow);
    400 
    401    return await this.#getMessagesForConversations(conversations);
    402  }
    403 
    404  /**
    405   * Gets a list of chat history items to display in Chat History view.
    406   *
    407   * @param {number} [pageNumber=1] - The page number to get, 1 based indexing
    408   * @param {number} [pageSize=20] - Number of items to get per page
    409   * @param {string} [sort="desc"] - desc|asc The sorting order based on updated_date for conversations
    410   */
    411  async chatHistoryView(pageNumber = 1, pageSize = 20, sort = "desc") {
    412    const sorting = SORTS.find(item => item === sort.toUpperCase()) ?? "DESC";
    413    const offset = pageSize * (pageNumber - 1);
    414    const limit = pageSize;
    415    const params = {
    416      limit,
    417      offset,
    418    };
    419 
    420    const rows = await this.#conn.executeCached(
    421      CONVERSATION_HISTORY.replace("{sort}", sorting),
    422      params
    423    );
    424 
    425    return parseChatHistoryViewRows(rows);
    426  }
    427 
    428  /**
    429   * Prunes the database of old conversations in order to get the
    430   * database file size to the specified maximum size.
    431   *
    432   * @todo Bug 2005411
    433   * Review the requirements for db pruning and set up invocation schedule, and refactor
    434   * to use dbstat
    435   *
    436   * @param {number} [reduceByPercentage=0.05] - Percentage to reduce db file size by
    437   * @param {number} [maxDbSizeBytes=MAX_DB_SIZE_BYTES] - Db max file size
    438   */
    439  async pruneDatabase(
    440    reduceByPercentage = 0.05,
    441    maxDbSizeBytes = MAX_DB_SIZE_BYTES
    442  ) {
    443    if (!IOUtils.exists(this.databaseFilePath)) {
    444      return;
    445    }
    446 
    447    const DELETE_BATCH_SIZE = 50;
    448 
    449    const getPragmaInt = async name => {
    450      const result = await this.#conn.execute(`PRAGMA ${name}`);
    451      return result[0].getInt32(0);
    452    };
    453 
    454    // compute the logical DB size in bytes using SQLite's page_size,
    455    // page_count, and freelist_count
    456    const getLogicalDbSizeBytes = async () => {
    457      const pageSize = await getPragmaInt("page_size");
    458      const pageCount = await getPragmaInt("page_count");
    459      const freelistCount = await getPragmaInt("freelist_count");
    460 
    461      // Logical used pages = total pages - free pages
    462      const usedPages = pageCount - freelistCount;
    463      const lSize = usedPages * pageSize;
    464 
    465      return lSize;
    466    };
    467 
    468    let logicalSize = await getLogicalDbSizeBytes();
    469    if (logicalSize < maxDbSizeBytes) {
    470      return;
    471    }
    472 
    473    const targetLogicalSize = Math.max(
    474      0,
    475      logicalSize * (1 - reduceByPercentage)
    476    );
    477 
    478    const MAX_ITERATIONS = 100;
    479    // how many "no file size change" batches we tolerate
    480    const MAX_STAGNANT = 5;
    481    let iterations = 0;
    482    let stagnantIterations = 0;
    483 
    484    while (
    485      logicalSize > targetLogicalSize &&
    486      iterations < MAX_ITERATIONS &&
    487      stagnantIterations < MAX_STAGNANT
    488    ) {
    489      iterations++;
    490 
    491      const recentChats = await this.findOldestConversations(DELETE_BATCH_SIZE);
    492 
    493      if (!recentChats.length) {
    494        break;
    495      }
    496 
    497      for (const chat of recentChats) {
    498        await this.deleteConversationById(chat.id);
    499      }
    500 
    501      const newLogicalSize = await getLogicalDbSizeBytes();
    502      if (newLogicalSize >= logicalSize) {
    503        stagnantIterations++;
    504      } else {
    505        stagnantIterations = 0;
    506      }
    507 
    508      logicalSize = newLogicalSize;
    509    }
    510 
    511    // Actually reclaim disk space.
    512    await this.#conn.execute("PRAGMA incremental_vacuum;");
    513  }
    514 
    515  /**
    516   * Returns the file size of the database.
    517   * Establishes a connection first to make sure the
    518   * database exists.
    519   *
    520   * @returns {number} - The file size in bytes
    521   */
    522  async getDatabaseSize() {
    523    await this.#ensureDatabase();
    524 
    525    const stats = await IOUtils.stat(this.databaseFilePath);
    526    return stats.size;
    527  }
    528 
    529  /**
    530   * Deletes a particular conversation using it's id
    531   *
    532   * @param {string} id - The conv_id of a conversation row to delete
    533   */
    534  async deleteConversationById(id) {
    535    await this.#ensureDatabase();
    536 
    537    await this.#conn.execute(DELETE_CONVERSATION_BY_ID, {
    538      conv_id: id,
    539    });
    540  }
    541 
    542  /**
    543   * This method is meant to only be used for testing cleanup
    544   */
    545  async destroyDatabase() {
    546    await this.#removeDatabaseFiles();
    547  }
    548 
    549  /**
    550   * Gets the version of the schema currently set in the database.
    551   *
    552   * @returns {number}
    553   */
    554  async getDatabaseSchemaVersion() {
    555    if (!this.#conn) {
    556      await this.#ensureDatabase();
    557    }
    558 
    559    return this.#conn.getSchemaVersion();
    560  }
    561 
    562  async #getMessagesForConversations(conversations) {
    563    const convs = conversations.reduce((convMap, conv) => {
    564      convMap[conv.id] = conv;
    565 
    566      return convMap;
    567    }, {});
    568 
    569    // Find all the messages for all the conversations.
    570    const rows = await this.#conn
    571      .executeCached(
    572        getConversationMessagesSql(conversations.length),
    573        conversations.map(c => c.id)
    574      )
    575      .catch(e => {
    576        lazy.log.error("Could not retrieve messages for conversatons");
    577        lazy.log.error(`${e.message}\n${e.stack}`);
    578 
    579        return [];
    580      });
    581 
    582    // TODO: retrieve TTL content.
    583 
    584    parseMessageRows(rows).forEach(message => {
    585      const conversation = convs[message.convId];
    586      if (conversation) {
    587        conversation.messages.push(message);
    588      }
    589    });
    590 
    591    return conversations;
    592  }
    593 
    594  async #openConnection() {
    595    lazy.log.debug("Opening new connection");
    596 
    597    try {
    598      const confConfig = { path: this.databaseFilePath };
    599      this.#conn = await lazy.Sqlite.openConnection(confConfig);
    600    } catch (e) {
    601      lazy.log.error("openConnection() could not open db:", e.message);
    602      throw e;
    603    }
    604 
    605    lazy.Sqlite.shutdown.addBlocker(
    606      "ChatStore: Shutdown",
    607      this.#asyncShutdownBlocker
    608    );
    609 
    610    try {
    611      // TODO: remove this after switching pruneDatabase() to use dbstat
    612      await this.#conn.execute("PRAGMA page_size = 4096;");
    613      // Setup WAL journaling, as it is generally faster.
    614      await this.#conn.execute("PRAGMA journal_mode = WAL;");
    615      await this.#conn.execute("PRAGMA wal_autocheckpoint = 16;");
    616 
    617      // Store VACUUM information to be used by the VacuumManager.
    618      await this.#conn.execute("PRAGMA auto_vacuum = INCREMENTAL;");
    619      await this.#conn.execute("PRAGMA foreign_keys = ON;");
    620    } catch (e) {
    621      lazy.log.warn("Configuring SQLite PRAGMA settings: ", e.message);
    622    }
    623  }
    624 
    625  async #closeConnection() {
    626    if (!this.#conn) {
    627      return;
    628    }
    629 
    630    lazy.log.debug("Closing connection");
    631    lazy.Sqlite.shutdown.removeBlocker(this.#asyncShutdownBlocker);
    632    try {
    633      await this.#conn.close();
    634    } catch (e) {
    635      lazy.log.warn(`Error closing connection: ${e.message}`);
    636    }
    637    this.#conn = null;
    638  }
    639 
    640  /**
    641   * @todo Bug 2005412
    642   * Discuss implications of multiple instances of ChatStore
    643   * and the potential issues with migrations/schemas.
    644   */
    645  async #ensureDatabase() {
    646    if (this.#promiseConn) {
    647      return this.#promiseConn;
    648    }
    649 
    650    let deferred = Promise.withResolvers();
    651    this.#promiseConn = deferred.promise;
    652    if (this.#removeDatabaseOnStartup) {
    653      lazy.log.debug("Removing database on startup");
    654      try {
    655        await this.#removeDatabaseFiles();
    656      } catch (e) {
    657        deferred.reject(new Error("Could not remove the database files"));
    658        return deferred.promise;
    659      }
    660    }
    661 
    662    try {
    663      await this.#openConnection();
    664    } catch (e) {
    665      if (
    666        e.result == Cr.NS_ERROR_FILE_CORRUPTED ||
    667        e.errors?.some(error => error.result == Ci.mozIStorageError.NOTADB)
    668      ) {
    669        lazy.log.warn("Invalid database detected, removing it.", e);
    670        await this.#removeDatabaseFiles();
    671      }
    672    }
    673 
    674    if (!this.#conn) {
    675      try {
    676        await this.#openConnection();
    677      } catch (e) {
    678        lazy.log.error("Could not open the database connection.", e);
    679        deferred.reject(new Error("Could not open the database connection"));
    680        return deferred.promise;
    681      }
    682    }
    683 
    684    try {
    685      await this.#initializeSchema();
    686    } catch (e) {
    687      lazy.log.warn(
    688        "Failed to initialize the database schema, recreating the database.",
    689        e
    690      );
    691      // If the schema cannot be initialized try to create a new database file.
    692      await this.#removeDatabaseFiles();
    693    }
    694 
    695    deferred.resolve(this.#conn);
    696    return this.#promiseConn;
    697  }
    698 
    699  async setSchemaVersion(version) {
    700    await this.#conn.setSchemaVersion(version);
    701  }
    702 
    703  async #initializeSchema() {
    704    const version = await this.getDatabaseSchemaVersion();
    705 
    706    if (version == this.CURRENT_SCHEMA_VERSION) {
    707      return;
    708    }
    709 
    710    if (version > this.CURRENT_SCHEMA_VERSION) {
    711      await this.setSchemaVersion(this.CURRENT_SCHEMA_VERSION);
    712      return;
    713    }
    714 
    715    // Must migrate the schema.
    716    await this.#conn.executeTransaction(async () => {
    717      if (version == 0) {
    718        // This is a newly created database, just create the entities.
    719        await this.#createDatabaseEntities();
    720        await this.#conn.setSchemaVersion(this.CURRENT_SCHEMA_VERSION);
    721        // eslint-disable-next-line no-useless-return
    722        return;
    723      }
    724 
    725      await this.applyMigrations();
    726      await this.setSchemaVersion(this.CURRENT_SCHEMA_VERSION);
    727    });
    728  }
    729 
    730  async applyMigrations() {
    731    for (const migration of migrations) {
    732      if (typeof migration !== "function") {
    733        continue;
    734      }
    735 
    736      await migration(this.#conn, this.CURRENT_SCHEMA_VERSION);
    737    }
    738  }
    739 
    740  async #removeDatabaseFiles() {
    741    lazy.log.debug("Removing database files");
    742    await this.#closeConnection();
    743    try {
    744      for (let file of [
    745        this.databaseFilePath,
    746        PathUtils.join(DB_FOLDER_PATH, this.databaseFileName + "-wal"),
    747        PathUtils.join(DB_FOLDER_PATH, this.databaseFileName + "-shm"),
    748      ]) {
    749        lazy.log.debug(`Removing ${file}`);
    750        await IOUtils.remove(file, {
    751          retryReadonly: true,
    752          recursive: true,
    753          ignoreAbsent: true,
    754        });
    755      }
    756      this.#removeDatabaseOnStartup = false;
    757    } catch (e) {
    758      lazy.log.warn("Failed to remove database files", e);
    759      // Try to clear on next startup.
    760      this.#removeDatabaseOnStartup = true;
    761      // Re-throw the exception for the caller.
    762      throw e;
    763    }
    764  }
    765 
    766  async #findConversationsWithMessages(sql, queryParams) {
    767    await this.#ensureDatabase().catch(e => {
    768      lazy.log.error("Could not ensure a database connection.");
    769      lazy.log.error(`${e.message}\n${e.stack}`);
    770 
    771      return [];
    772    });
    773 
    774    // @todo Bug 2005414
    775    // Check summary first, find the one with the largest end_ordinal.
    776    // If not found retrieve all messages.
    777    // If found compare end_ordinal of the summary with active branch ordinal
    778    // to determine if extra messages must be retrieved.
    779    let rows = await this.#conn.executeCached(sql, queryParams);
    780 
    781    const conversations = rows.map(parseConversationRow);
    782 
    783    return await this.#getMessagesForConversations(conversations);
    784  }
    785 
    786  async #createDatabaseEntities() {
    787    await this.#conn.execute(CONVERSATION_TABLE);
    788    await this.#conn.execute(CONVERSATION_UPDATED_DATE_INDEX);
    789    await this.#conn.execute(MESSAGE_TABLE);
    790    await this.#conn.execute(MESSAGE_ORDINAL_INDEX);
    791    await this.#conn.execute(MESSAGE_URL_INDEX);
    792    await this.#conn.execute(MESSAGE_CREATED_DATE_INDEX);
    793    await this.#conn.execute(MESSAGE_CONV_ID_INDEX);
    794  }
    795 
    796  get #removeDatabaseOnStartup() {
    797    return Services.prefs.getBoolPref(
    798      `${PREF_BRANCH}.removeDatabaseOnStartup`,
    799      false
    800    );
    801  }
    802 
    803  set #removeDatabaseOnStartup(value) {
    804    lazy.log.debug(`Setting removeDatabaseOnStartup to ${value}`);
    805    Services.prefs.setBoolPref(`${PREF_BRANCH}.removeDatabaseOnStartup`, value);
    806  }
    807 
    808  static get CONVERSATION_STATUS() {
    809    return CONVERSATION_STATUS;
    810  }
    811 
    812  get CURRENT_SCHEMA_VERSION() {
    813    return CURRENT_SCHEMA_VERSION;
    814  }
    815 
    816  get connection() {
    817    return this.#conn;
    818  }
    819 
    820  get databaseFileName() {
    821    return DB_FILE_NAME;
    822  }
    823 
    824  get databaseFilePath() {
    825    return PathUtils.join(PathUtils.profileDir, this.databaseFileName);
    826  }
    827 }