src/app/core/database/pouchdb/pouchdb-corruption-recovery.service.ts

Description

Handles user-facing recovery flows when PouchDB IndexedDB corruption is detected. Shows targeted dialogs and optionally resets the local database to a clean state.

Metadata

Index

Methods

Methods

handleKnownMultiTabCorruption
handleKnownMultiTabCorruption(err: unknown, logMessage: string, context?: Record)

occurrences of the problem in one issue (see core/logging/README.md) structured data alongside the message rather than interpolated into it

Parameters :
Name Type Optional Description
err unknown No

the error to check and, if it is a known corruption, report

logMessage string No

what failed - static, so that remote monitoring keeps all occurrences of the problem in one issue (see core/logging/README.md)

context Record<string, unknown> Yes

which database and other varying details, reported as structured data alongside the message rather than interpolated into it

Returns : void
Async promptMultiTabWarningDialog
promptMultiTabWarningDialog()
Returns : Promise<void>
Async promptResetApplicationDialog
promptResetApplicationDialog()
Returns : Promise<void>
import { Injectable, inject } from "@angular/core";
import { ConfirmationDialogService } from "#src/app/core/common-components/confirmation-dialog/confirmation-dialog.service";
import {
  CustomYesNoButtons,
  OkButton,
} from "#src/app/core/common-components/confirmation-dialog/confirmation-dialog/confirmation-dialog.component";
import { LocalDeviceResetService } from "../local-device-reset.service";
import { Logging } from "../../logging/logging.service";
import { environment } from "../../../../environments/environment";
import { SessionType } from "../../session/session-type";

/**
 * Handles user-facing recovery flows when PouchDB IndexedDB corruption is detected.
 * Shows targeted dialogs and optionally resets the local database to a clean state.
 */
@Injectable({ providedIn: "root" })
export class PouchdbCorruptionRecoveryService {
  private readonly confirmationDialog = inject(ConfirmationDialogService);
  private readonly localDeviceReset = inject(LocalDeviceResetService);
  private warningDialogOpen = false;
  private resetDialogOpen = false;

  /**
   * Shared closing line of both recovery dialogs.
   * Must stay a getter: this service is in the eagerly imported graph of main.ts, so a
   * module-level `$localize` would run before `initLanguage()` and never be translated.
   */
  private get improvementsPlannedHint(): string {
    return $localize`:local db corruption dialog improvements hint:We are working on improvements to allow this in the future.`;
  }

  async promptMultiTabWarningDialog(): Promise<void> {
    if (environment.session_type === SessionType.online) {
      return;
    }
    if (this.warningDialogOpen) {
      return;
    }
    this.warningDialogOpen = true;

    try {
      await this.confirmationDialog.getConfirmation(
        $localize`:multi-tab warning dialog title:Multiple Tabs Open`,
        $localize`:multi-tab warning dialog text:The app is open in multiple tabs, which can break the local database.

Please close the other tabs and try again to avoid local database corruption.` +
          "\n\n" +
          this.improvementsPlannedHint,
        OkButton,
        false,
      );
    } finally {
      this.warningDialogOpen = false;
    }
  }

  async promptResetApplicationDialog(): Promise<void> {
    if (this.resetDialogOpen) {
      return;
    }
    this.resetDialogOpen = true;

    try {
      const shouldReset = await this.confirmationDialog.getConfirmation(
        $localize`:local db corruption dialog title:Local Database Needs Reset`,
        $localize`:local db corruption dialog text:The local database appears corrupted and saving is no longer reliable.

This can happen after using multiple tabs in parallel.` +
          "\n" +
          this.improvementsPlannedHint,
        CustomYesNoButtons(
          $localize`:local db corruption dialog button reset:Reset Application`,
          $localize`:Confirmation dialog Cancel:Cancel`,
        ),
        false,
      );

      if (shouldReset === true) {
        this.resetApplication();
      }
    } finally {
      this.resetDialogOpen = false;
    }
  }

  private resetApplication() {
    // deleting ALL local data (incl. possibly unsynced docs) - log for traceability of possible data loss
    Logging.warn(
      "Resetting application data after suspected local database corruption (user confirmed)",
    );
    this.localDeviceReset.markResetPendingAndReload();
  }

  /**
   * @param err the error to check and, if it is a known corruption, report
   * @param logMessage what failed - static, so that remote monitoring keeps all
   *   occurrences of the problem in one issue (see `core/logging/README.md`)
   * @param context which database and other varying details, reported as
   *   structured data alongside the message rather than interpolated into it
   */
  handleKnownMultiTabCorruption(
    err: unknown,
    logMessage: string,
    context?: Record<string, unknown>,
  ): void {
    if (!isKnownMultiTabDatabaseCorruption(err)) {
      return;
    }
    Logging.warn(logMessage, err, ...(context ? [context] : []));
    this.promptResetApplicationDialog().catch((callbackError) =>
      Logging.warn("onKnownMultiTabCorruption callback failed", callbackError),
    );
  }
}

/**
 * Detect IndexedDB/PouchDB corruption symptoms commonly observed when the app
 * is used in multiple tabs with concurrent writes.
 *
 * We intentionally match on `transaction was aborted` because this is the
 * reliable signal present on the actual error object propagated to app code
 * after IndexedDB global-failure cases. The `unknown_error` label is too
 * generic and may appear for unrelated failures.
 */
export function isKnownMultiTabDatabaseCorruption(error: unknown): boolean {
  const text = errorToText(error).toLowerCase();
  const transactionAbortedMatch = text.includes("transaction was aborted");
  const globalFailureMatch = text.includes("database has a global failure");
  const constraintSeqMatch =
    text.includes("constrainterror") && text.includes("seq");
  const isKnownCorruption =
    transactionAbortedMatch || globalFailureMatch || constraintSeqMatch;

  return isKnownCorruption;
}

function errorToText(error: unknown): string {
  if (error instanceof Error) {
    const errorWithExtras = error as Error & Record<string, unknown>;
    return `${error.name} ${error.message} ${JSON.stringify(errorWithExtras)}`;
  }
  if (typeof error === "string") return error;
  return JSON.stringify(error ?? "");
}

results matching ""

    No results matching ""