src/app/core/database/pouchdb/remote-pouch-database.ts

Description

An alternative implementation of PouchDatabase that directly makes HTTP requests to a remote CouchDB.

Extends

PouchDatabase

Relationships

Used by

No results matching.

Depends on

Index

Properties
Methods

Constructor

constructor(dbName: string, authService: KeycloakAuthService, globalSyncState?: SyncStateSubject, ngZone?: NgZone, alertService?: AlertService)
Parameters :
Name Type Optional
dbName string No
authService KeycloakAuthService No
globalSyncState SyncStateSubject Yes
ngZone NgZone Yes
alertService AlertService Yes

Properties

Optional trackLostPermissions
Type : boolean

Whether to track docs whose permissions were lost as reported by the server. Toggled by SyncedPouchDatabase to skip tracking on first sync.

adapter
Type : string
Default value : "indexeddb"
Inherited from PouchDatabase

The PouchDB adapter to use for local storage. Set by the factory/resolver before calling init(). Default: "indexeddb" (the newer adapter). Use "idb" for the legacy adapter.

Protected changesFeed
Type : Subject<any>
Inherited from PouchDatabase

An observable that emits a value whenever the PouchDB receives a new change. This change can come from the current user or remotely from the (live) synchronization

Protected databaseInitialized
Type : unknown
Default value : new Subject<void>()
Inherited from PouchDatabase
Protected Readonly destroy$
Type : unknown
Default value : new Subject<void>()
Inherited from PouchDatabase

trigger to unsubscribe any internal subscriptions

Protected indexPromises
Type : Promise<any>[]
Default value : []
Inherited from PouchDatabase

A list of promises that resolve once all the (until now saved) indexes are created

Protected pouchDB
Type : PouchDB.Database
Inherited from PouchDatabase

The reference to the PouchDB instance

Methods

collectAndClearLostPermissions
collectAndClearLostPermissions()

Returns all doc IDs whose permissions were lost since the last sync (as reported in _changes responses intercepted during that sync) and resets the internal list.

Returns : string[]
init
init(dbName?: string, config?: { unauthenticatedSession?: boolean; trackLostPermissions?: boolean })
Inherited from Database

Initializes the PouchDB with the http adapter to directly access a remote CouchDB without replication See https://pouchdb.com/adapters.html#pouchdb_over_http

Parameters :
Name Type Optional Description
dbName string Yes

(relative) path to the remote database

config { unauthenticatedSession?: boolean; trackLostPermissions?: boolean } Yes

optional configuration for the remote database session

Returns : void
Protected shouldSkipIndexUpdate
shouldSkipIndexUpdate(existingDesignDoc: any)
Inherited from PouchDatabase
Parameters :
Name Type Optional
existingDesignDoc any No
Returns : boolean
Protected Async subscribeChanges
subscribeChanges()
Inherited from PouchDatabase

Poll the _changes endpoint periodically to detect document changes. Emits individual documents that have changed since the last poll.

Overridden to use periodic polling instead of live long-polling. This avoids connection stability issues with remote-only (anonymous) sessions. Changes are fetched at regular intervals rather than maintaining a persistent connection.

Returns : any
Protected Async withReadRetry
withReadRetry<T>(operation: () => void)
Inherited from PouchDatabase
Type parameters :
  • T

Retry idempotent reads that fail with a transient network error (e.g. an abort/timeout on a connection gone stale after the tab was suspended, or ERR_NETWORK_CHANGED).

Retrying lives here, at the operation level, rather than in the fetch wrapper: fetchWithTimeout can only abort while acquiring the response headers, but an abort during response-body streaming surfaces after the fetch has returned — outside the wrapper's reach. Re-issuing the whole read (fresh fetch and body) recovers both cases transparently. Only reads route through this hook; writes must run exactly once (see fetchWithTimeout).

Parameters :
Name Type Optional
operation function No
Returns : Promise<T>
Async allDocs
allDocs(options?: GetAllOptions)
Inherited from Database

Load all documents (matching the given PouchDB options) from the database. (see Database)

Normally you should rather use "getAll()" or another well typed method of this class instead of passing PouchDB specific options here because that will make your code tightly coupled with PouchDB rather than any other database provider.

Parameters :
Name Type Optional Description
options GetAllOptions Yes

PouchDB options object as in the normal PouchDB library

Returns : unknown
changes
changes()
Inherited from Database

Listen to changes to documents in the database. Use rxjs operators to filter for specific prefixes etc. if needed.

Returns : Observable<any>

observable which emits the filtered changes

Async destroy
destroy()
Inherited from Database

Destroy the database and all saved data

Returns : Promise<any>
Async get
get(id: string, options: GetOptions, returnUndefined?: boolean)
Inherited from Database

Load a single document by id from the database. (see Database)

Parameters :
Name Type Optional Default value Description
id string No

The primary key of the document to be loaded

options GetOptions No {}

Optional PouchDB options for the request

returnUndefined boolean Yes

(Optional) return undefined instead of throwing error if doc is not found in database

Returns : Promise<any>
getPouchDB
getPouchDB()
Inherited from PouchDatabase

Get the actual instance of the PouchDB

Returns : PouchDB.Database
Async getPouchDBOnceReady
getPouchDBOnceReady()
Inherited from PouchDatabase
isEmpty
isEmpty()
Inherited from Database

Check if a database is new/empty. Returns true if there are no documents in the database

Returns : Promise<boolean>
isInitialized
isInitialized()
Inherited from Database
Returns : boolean
Protected isNotificationsDatabase
isNotificationsDatabase()
Inherited from PouchDatabase

Check if this is a notifications database based on the database name. (may be used for special handling of notification DBs)

Returns : boolean
Async purge
purge(id: string)
Inherited from Database

Permanently purge a document and all its revisions from the local database.

Unlike remove, which creates a deletion tombstone that is synced, purge completely removes local data without affecting the remote database. This also emits a synthetic deletion event via the changes feed so that in-memory caches (entity stores) drop the purged entity.

Adapter limitation: The underlying PouchDB purge() API is only available on the indexeddb adapter (PouchDB 8+). On unsupported adapters PouchDB will throw its own error, which callers should handle (e.g. via try/catch + logging).

Example :
     false if the document did not exist locally (benign — desired state already achieved)
Parameters :
Name Type Optional Description
id string No

The document ID to purge

Returns : Promise<boolean>

true if the document was purged, false if the document did not exist locally (benign — desired state already achieved)

Async put
put(object: any, forceOverwrite: unknown)
Inherited from Database

Save a document to the database. (see Database)

Parameters :
Name Type Optional Default value Description
object any No

The document to be saved

forceOverwrite unknown No false

(Optional) Whether conflicts should be ignored and an existing conflicting document forcefully overwritten.

Returns : Promise<any>
Async putAll
putAll(objects: any[], forceOverwrite: unknown)
Inherited from Database

Save an array of documents to the database The save can partially fail and return a mix of success and error states in the array (e.g. [{ ok: true, ... }, { error: true, ... }])

Parameters :
Name Type Optional Default value Description
objects any[] No

the documents to be saved

forceOverwrite unknown No false

whether conflicting versions should be overwritten

Returns : Promise<any>

array with the result for each object to be saved, if any item fails to be saved, this returns a rejected Promise. The save can partially fail and return a mix of success and error states in the array (e.g. [{ ok: true, ... }, { error: true, ... }])

query
query(fun: string | unknown, options: QueryOptions)
Inherited from Database

Query data from the database based on a more complex, indexed request. (see Database)

This is directly calling the PouchDB implementation of this function. Also see the documentation there: https://pouchdb.com/api.html#query_database

Parameters :
Name Type Optional Description
fun string | unknown No

The name of a previously saved database index

options QueryOptions No

Additional options for the query, like a key. See the PouchDB docs for details.

Returns : Promise<any>
remove
remove(object: any)
Inherited from Database

Delete a document from the database (see Database)

Parameters :
Name Type Optional Description
object any No

The document to be deleted (usually this object must at least contain the _id and _rev)

Returns : any
Async reset
reset()
Inherited from Database

Reset the database state so a new one can be opened.

Returns : any
saveDatabaseIndex
saveDatabaseIndex(designDoc: any)
Inherited from Database

Create a database index to query() certain data more efficiently in the future. (see Database)

Also see the PouchDB documentation regarding indices and queries: https://pouchdb.com/api.html#query_database

Parameters :
Name Type Optional Description
designDoc any No

The PouchDB style design document for the map/reduce query

Returns : Promise<void>
getAll
getAll(prefix: string)
Inherited from Database

Load all documents (with the given prefix) from the database.

Parameters :
Name Type Optional Default value Description
prefix string No ""

The string prefix of document ids that should be retrieved

Returns : Promise<Array<any>>
import { DatabaseException, PouchDatabase } from "./pouch-database";
import { environment } from "../../../../environments/environment";
import PouchDB from "pouchdb-browser";
import { Logging } from "../../logging/logging.service";
import { HttpStatusCode } from "@angular/common/http";
import { KeycloakAuthService } from "../../session/auth/keycloak/keycloak-auth.service";
import { SyncStateSubject } from "app/core/session/session-type";
import { SyncState } from "app/core/session/session-states/sync-state.enum";
import { NgZone } from "@angular/core";
import { timer } from "rxjs";
import { exhaustMap, takeUntil } from "rxjs/operators";
import { AlertService } from "../../alerts/alert.service";
import { isVersionNewer } from "./version-comparison.utils";
import { isConnectivityError } from "#src/app/utils/connectivity-error";

/**
 * 4XX statuses that occur during normal operation
 * (and are handled by callers or the auth layer),
 * so they are not reported to remote logging.
 */
const EXPECTED_4XX_STATUSES: number[] = [
  HttpStatusCode.Unauthorized,
  HttpStatusCode.Forbidden,
  HttpStatusCode.NotFound,
];

/**
 * An alternative implementation of PouchDatabase that directly makes HTTP requests to a remote CouchDB.
 */
export class RemotePouchDatabase extends PouchDatabase {
  /**
   * Whether the session is not logging in any user (e.g. for public forms).
   * @private
   */
  private unauthenticatedSession?: boolean;

  /**
   * Whether to track docs whose permissions were lost as reported by the server.
   * Toggled by {@link SyncedPouchDatabase} to skip tracking on first sync.
   */
  trackLostPermissions?: boolean;

  /**
   * Doc IDs whose permissions were lost as reported by the server in `_changes` responses.
   * Accumulated across all `_changes` calls during a sync and consumed after sync completes.
   */
  private pendingLostPermissions: string[] = [];

  /**
   * Polling interval for changes in milliseconds (for remote-only databases).
   * Avoids long-polling connection issues by using periodic polling instead.
   * @private
   */
  private readonly CHANGES_POLLING_INTERVAL = 10000; // 10 seconds

  /** Cooldown (ms) between user-facing connection issue alerts. */
  private readonly CONNECTION_ALERT_COOLDOWN_MS = 60000;
  private lastConnectionAlertTime = 0;

  constructor(
    dbName: string,
    private authService: KeycloakAuthService,
    globalSyncState?: SyncStateSubject,
    ngZone?: NgZone,
    private readonly alertService?: AlertService,
  ) {
    super(dbName, globalSyncState, ngZone);
  }

  /**
   * Initializes the PouchDB with the http adapter to directly access a remote CouchDB without replication
   * See {@link https://pouchdb.com/adapters.html#pouchdb_over_http}
   * @param dbName (relative) path to the remote database
   * @param config optional configuration for the remote database session
   */
  override init(
    dbName?: string,
    config?: {
      unauthenticatedSession?: boolean;
      trackLostPermissions?: boolean;
    },
  ) {
    this.unauthenticatedSession = config?.unauthenticatedSession;
    this.trackLostPermissions = config?.trackLostPermissions;

    if (dbName) {
      this.dbName = dbName;
    }
    this.pendingLostPermissions = [];

    const options = {
      adapter: "http",
      skip_setup: true,
      fetch: (url: string | Request, opts: RequestInit) =>
        this.defaultFetch(url, opts),
    };
    // add the proxy prefix to the database name so that we get a correct remote URL
    this.pouchDB = new PouchDB(
      `${environment.DB_PROXY_PREFIX}/${this.dbName}`,
      options,
    );
    this.databaseInitialized.complete();

    // No local sync needed — immediately signal that data is available
    this.globalSyncState?.next(SyncState.COMPLETED);
  }

  /**
   * Maximum number of retries for transient network errors (e.g. ERR_NETWORK_CHANGED).
   */
  private readonly TRANSIENT_ERROR_RETRIES = 2;
  private readonly TRANSIENT_ERROR_DELAY_MS = 2000;

  /**
   * Per-request timeout in ms for READ requests only. If the server sends no
   * response within this window, the read fetch is aborted (and retried at the
   * operation level by {@link withReadRetry}). This prevents long-idle
   * connections from being killed unpredictably by Chrome (ERR_NETWORK_CHANGED)
   * or proxies. Writes are never aborted or retried — see
   * {@link fetchWithTimeout}.
   */
  private readonly FETCH_TIMEOUT_MS = 15000;

  private defaultFetch: Fetch = async (url: string | Request, opts: any) => {
    if (typeof url !== "string") {
      const err = new Error("PouchDatabase.fetch: url is not a string");
      err["details"] = url;
      throw err;
    }

    const remoteUrl =
      environment.DB_PROXY_PREFIX + url.split(environment.DB_PROXY_PREFIX)[1];
    this.authService.addAuthHeader(opts.headers);
    // bypass Angular service worker to avoid synthetic 504 errors on network blips
    if (opts.headers?.set && typeof opts.headers.set === "function") {
      opts.headers.set("ngsw-bypass", "true");
    } else if (opts.headers) {
      opts.headers["ngsw-bypass"] = "true";
    }

    let result: Response;
    try {
      result = await this.fetchWithTimeout(remoteUrl, opts);
    } catch (err) {
      Logging.debug("Failed initial fetch from DB", err);
      Logging.debug("navigator.onLine", navigator.onLine);
      this.showConnectionIssueAlert();
    }

    // Retry login if request failed with unauthorized.
    // This will redirect to Keycloak if the token is expired or missing,
    // which is intentional — it ensures users re-authenticate online
    // when connectivity is available (including after an offline login).
    if (
      result?.status === HttpStatusCode.Unauthorized &&
      !this.unauthenticatedSession
    ) {
      try {
        await this.authService.login();
        this.authService.addAuthHeader(opts.headers);
        result = await PouchDB.fetch(remoteUrl, opts);
      } catch (err) {
        Logging.debug("Failed retried fetch from DB after 401", err);
      }
    }

    if (!result || result.status >= 500) {
      Logging.debug("Actual DB Fetch response", result);
      Logging.debug("navigator.onLine", navigator.onLine);
      throw new DatabaseException({
        message: "Failed to fetch from DB",
        requestedUrl: remoteUrl,
        actualResponse: JSON.stringify(result),
        actualResponseBody: await result?.text(),
      });
    }

    // additional output for debugging
    if (result?.status >= 400) {
      if (this.isNotificationsDatabase() && result.status === 404) {
        Logging.debug(
          "Notifications database not found (404) - may be expected",
        );
      } else if (EXPECTED_4XX_STATUSES.includes(result.status)) {
        // expired session (401), permission-filtered doc (403) and missing doc (404)
        // are part of normal operation and handled by callers
        Logging.debug("Failed to fetch from DB with 40X error", result);
      } else {
        Logging.warn("Failed to fetch from DB with 40X error", result);
      }
    }

    if (
      this.trackLostPermissions &&
      result?.status === HttpStatusCode.Ok &&
      remoteUrl.includes("_changes")
    ) {
      await this.extractLostPermissions(result.clone());
    }

    return result;
  };

  /**
   * Retry idempotent reads that fail with a transient network error
   * (e.g. an abort/timeout on a connection gone stale after the tab was
   * suspended, or ERR_NETWORK_CHANGED).
   *
   * Retrying lives here, at the operation level, rather than in the fetch
   * wrapper: {@link fetchWithTimeout} can only abort while acquiring the
   * response headers, but an abort during response-body streaming surfaces
   * after the fetch has returned — outside the wrapper's reach. Re-issuing the
   * whole read (fresh fetch and body) recovers both cases transparently.
   * Only reads route through this hook; writes must run exactly once
   * (see {@link fetchWithTimeout}).
   */
  protected override async withReadRetry<T>(
    operation: () => Promise<T>,
  ): Promise<T> {
    for (let attempt = 0; ; attempt++) {
      try {
        return await operation();
      } catch (err) {
        if (
          !isConnectivityError(err) ||
          attempt >= this.TRANSIENT_ERROR_RETRIES
        ) {
          throw err;
        }
        Logging.debug(
          `Transient DB read error (attempt ${attempt + 1}/${this.TRANSIENT_ERROR_RETRIES}), retrying...`,
          err,
        );
        await new Promise((resolve) =>
          setTimeout(resolve, this.TRANSIENT_ERROR_DELAY_MS),
        );
      }
    }
  }

  /**
   * Fetch a request with a per-request timeout so a hung/stale connection is
   * aborted cleanly instead of hanging indefinitely (e.g. after the tab was
   * suspended, or ERR_NETWORK_CHANGED). The abort surfaces as a transient
   * error that {@link withReadRetry} retries at the operation level.
   *
   * Only safe/idempotent read methods (GET, HEAD) get the abort timeout.
   * Non-idempotent writes (PUT, POST, DELETE) are run exactly once with no
   * client-side timeout: a write may have already committed on the server even
   * when the client never sees the response, so aborting it can turn a "create"
   * into a forbidden "update" (the public role is create-only) or create a
   * duplicate — surfacing as spurious "unauthorized" errors. Letting the write
   * run to completion ensures the client reliably learns the new `_rev`.
   */
  private async fetchWithTimeout(
    url: string,
    opts: RequestInit,
  ): Promise<Response> {
    const method = (opts.method ?? "GET").toUpperCase();
    const isSafeMethod = method === "GET" || method === "HEAD";

    if (!isSafeMethod) {
      // Write: run once, to completion, without abort timeout.
      return PouchDB.fetch(url, opts);
    }

    const controller = new AbortController();
    const timeoutId = setTimeout(
      () => controller.abort(),
      this.FETCH_TIMEOUT_MS,
    );
    try {
      return await PouchDB.fetch(url, { ...opts, signal: controller.signal });
    } finally {
      clearTimeout(timeoutId);
    }
  }

  private showConnectionIssueAlert(): void {
    const now = Date.now();
    if (
      now - this.lastConnectionAlertTime <
      this.CONNECTION_ALERT_COOLDOWN_MS
    ) {
      return;
    }
    this.lastConnectionAlertTime = now;
    this.alertService?.addWarning(
      $localize`We are observing connection issues while syncing your data. Sync continues and retries automatically but may take longer than usual.`,
    );
  }

  /**
   * Parse `lostPermissions` from a `_changes` response and accumulate them
   * for later retrieval via {@link collectAndClearLostPermissions}.
   *
   * Awaited inside `defaultFetch` to ensure items are collected before the
   * fetch resolves — preventing a race with `collectAndClearLostPermissions`.
   */
  private async extractLostPermissions(response: Response): Promise<void> {
    try {
      const body = await response.json();
      if (body.lostPermissions?.length) {
        this.pendingLostPermissions.push(...body.lostPermissions);
      }
    } catch (err) {
      Logging.debug(
        "Could not parse lostPermissions from _changes response",
        err,
      );
    }
  }

  /**
   * Returns all doc IDs whose permissions were lost since the last sync
   * (as reported in `_changes` responses intercepted during that sync)
   * and resets the internal list.
   */
  collectAndClearLostPermissions(): string[] {
    const collected = this.pendingLostPermissions;
    this.pendingLostPermissions = [];
    return collected;
  }

  protected override shouldSkipIndexUpdate(existingDesignDoc: any): boolean {
    if (
      existingDesignDoc.aam_version &&
      isVersionNewer(existingDesignDoc.aam_version, environment.appVersion)
    ) {
      Logging.debug(
        `skipping index update for ${existingDesignDoc._id}: server has version ${existingDesignDoc.aam_version}, we are ${environment.appVersion}`,
      );
      return true;
    }
    return false;
  }

  /**
   * Poll the _changes endpoint periodically to detect document changes.
   * Emits individual documents that have changed since the last poll.
   *
   * Overridden to use periodic polling instead of live long-polling.
   * This avoids connection stability issues with remote-only (anonymous) sessions.
   * Changes are fetched at regular intervals rather than maintaining a persistent connection.
   *
   * @private
   */
  protected override async subscribeChanges() {
    const db = await this.getPouchDBOnceReady();
    let lastSequence: string | number = "now";

    // Run the polling loop outside Angular's zone to avoid:
    //  - a full app-wide change-detection cycle for every poll fetch
    //  - PouchDB-internal promise rejections being routed to Angular's
    //    ErrorHandler / Sentry. We re-enter the zone explicitly only when
    //    emitting to changesFeed so subscribers still trigger CD.
    const startPolling = () =>
      timer(0, this.CHANGES_POLLING_INTERVAL)
        .pipe(
          exhaustMap(async () => {
            try {
              const result = await db.changes({
                since: lastSequence,
                include_docs: true,
              });

              if (result?.results) {
                result.results.forEach(
                  (change: PouchDB.Core.ChangesResponseChange<{}>) => {
                    if (this.ngZone) {
                      this.ngZone.run(() => this.changesFeed.next(change.doc));
                    } else {
                      this.changesFeed.next(change.doc);
                    }
                  },
                );
                lastSequence = result.last_seq;
              }

              return result;
            } catch (err) {
              Logging.debug("Error polling changes from remote database", err);
              // Continue polling despite errors
              return null;
            }
          }),
          takeUntil(this.destroy$),
        )
        .subscribe();

    if (this.ngZone) {
      this.ngZone.runOutsideAngular(startPolling);
    } else {
      startPolling();
    }

    Logging.debug(
      `Started periodic changes polling for ${this.dbName} (interval: ${this.CHANGES_POLLING_INTERVAL}ms)`,
    );
  }
}

results matching ""

    No results matching ""