chromium/content/browser/resources/indexed_db/database.ts

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import './transaction_table.js';

import {CustomElement} from 'chrome://resources/js/custom_element.js';
import {mojoString16ToString} from 'chrome://resources/js/mojo_type_util.js';
import type {UnguessableToken} from 'chrome://resources/mojo/mojo/public/mojom/base/unguessable_token.mojom-webui.js';

import type {BucketClientInfo} from './bucket_client_info.mojom-webui.js';
import {getTemplate} from './database.html.js';
import {IdbInternalsHandler} from './indexed_db_internals.mojom-webui.js';
import type {IdbDatabaseMetadata, IdbTransactionMetadata} from './indexed_db_internals_types.mojom-webui.js';
import type {ExecutionContextToken} from './tokens.mojom-webui.js';
import type {IndexedDbTransactionTable} from './transaction_table.js';

export class IndexedDbDatabase extends CustomElement {
  clients: BucketClientInfo[];

  static override get template() {
    return getTemplate();
  }

  // Similar to CustomElement.$, but asserts that the element exists.
  $a<T extends HTMLElement = HTMLElement>(query: string): T {
    return this.getRequiredElement<T>(query);
  }

  // Setter for `data` property. Updates the component contents with the
  // provided metadata.
  set data(metadata: IdbDatabaseMetadata) {
    const openDatabasesElement = this.$a('.open-databases');
    const openConnectionElement = this.$a('.connection-count.open');
    const activeConnectionElement = this.$a('.connection-count.active');
    const pendingConnectionElement = this.$a('.connection-count.pending');

    openDatabasesElement.textContent = mojoString16ToString(metadata.name);

    openConnectionElement.hidden = metadata.connectionCount === 0n;
    openConnectionElement.querySelector('.value')!.textContent =
        metadata.connectionCount.toString();

    activeConnectionElement.hidden = metadata.activeOpenDelete === 0n;
    activeConnectionElement.querySelector('.value')!.textContent =
        metadata.activeOpenDelete.toString();

    pendingConnectionElement.hidden = metadata.pendingOpenDelete === 0n;
    pendingConnectionElement.querySelector('.value')!.textContent =
        metadata.pendingOpenDelete.toString();

    this.groupAndShowTransactionsByClient(metadata.transactions);
  }

  private groupAndShowTransactionsByClient(transactions:
                                               IdbTransactionMetadata[]) {
    const groupedTransactions = new Map<string, IdbTransactionMetadata[]>();
    for (const transaction of transactions) {
      const client = transaction.clientToken;
      if (!groupedTransactions.has(client)) {
        groupedTransactions.set(client, []);
      }
      groupedTransactions.get(client)!.push(transaction);
    }

    const transactionsBlockElement = this.$a('#transactions');
    transactionsBlockElement.textContent = '';
    for (const [clientToken, clientTransactions] of groupedTransactions) {
      const container = this.createClientTransactionsContainer(
          clientToken, clientTransactions);
      container.classList.add('metadata-list-item');
      transactionsBlockElement.appendChild(container);
    }
  }

  // Creates a div containing an instantiation of the client metadata template
  // and a table of transactions.
  private createClientTransactionsContainer(
      clientToken: string,
      transactions: IdbTransactionMetadata[]): HTMLElement {
    const clientMetadataTemplate =
        this.$a<HTMLTemplateElement>('#client-metadata');
    const clientMetadata =
        clientMetadataTemplate.content.cloneNode(true) as DocumentFragment;
    clientMetadata.querySelector('.client-id')!.textContent = clientToken;
    clientMetadata.querySelector('.control.inspect')!.addEventListener(
        'click', () => {
          // If there are non-zero clients, inspecting any of them should have
          // the same effect.
          const client = this.getClientsMatchingToken(clientToken).pop();
          if (!client) {
            return;
          }
          IdbInternalsHandler.getRemote()
              .inspectClient(client)
              .then(message => {
                if (message.error) {
                  console.error(message.error);
                }
              })
              .catch(errorMsg => console.error(errorMsg));
        });
    const transactionTable =
        document.createElement('indexeddb-transaction-table') as
        IndexedDbTransactionTable;
    transactionTable.transactions = transactions;
    const container = document.createElement('div');
    container.appendChild(clientMetadata);
    container.appendChild(transactionTable);
    return container;
  }

  // Returns the list of clients whose `documentToken` or `contextToken` matches
  // the supplied `token`.
  private getClientsMatchingToken(token: string): BucketClientInfo[] {
    const matchedClients: BucketClientInfo[] = [];
    for (const client of this.clients) {
      const tokenValue = client.documentToken ?
          client.documentToken.value :
          this.getExecutionContextTokenValue(client.contextToken);
      if (tokenValue && this.tokenToHexString(tokenValue) === token) {
        matchedClients.push(client);
      }
    }
    return matchedClients;
  }

  private getExecutionContextTokenValue(token: ExecutionContextToken):
      UnguessableToken {
    if (token.localFrameToken) {
      return token.localFrameToken.value;
    }
    if (token.dedicatedWorkerToken) {
      return token.dedicatedWorkerToken.value;
    }
    if (token.serviceWorkerToken) {
      return token.serviceWorkerToken.value;
    }
    if (token.sharedWorkerToken) {
      return token.sharedWorkerToken.value;
    }
    throw new Error('Unrecognized ExecutionContextToken');
  }

  // This is the equivalent of `base::UnguessableToken::ToString()`.
  private tokenToHexString(token: UnguessableToken) {
    // Return the concatenation of the upper-case hexadecimal representations
    // of high and low, both padded to be 16 characters long.
    return token.high.toString(16).padStart(16, '0').toUpperCase() +
        token.low.toString(16).padStart(16, '0').toUpperCase();
  }
}

customElements.define('indexeddb-database', IndexedDbDatabase);