src/app/core/database/pouchdb/pouch-database.ts
Wrapper for a PouchDB instance to decouple the code from that external library.
Additional convenience functions on top of the PouchDB API should be implemented in the abstract Database.
Properties |
|
Methods |
|
constructor(dbName: string, globalSyncState?: SyncStateSubject, ngZone?: NgZone)
|
||||||||||||
|
Parameters :
|
| adapter |
Type : string
|
Default value : "indexeddb"
|
|
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 databaseInitialized |
Type : unknown
|
Default value : new Subject<void>()
|
| Protected Readonly destroy$ |
Type : unknown
|
Default value : new Subject<void>()
|
|
trigger to unsubscribe any internal subscriptions |
| Protected indexPromises |
Type : Promise<any>[]
|
Default value : []
|
|
A list of promises that resolve once all the (until now saved) indexes are created |
| Protected pouchDB |
Type : PouchDB.Database
|
|
The reference to the PouchDB instance |
| 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 :
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 :
Returns :
Promise<any>
|
| getPouchDB |
getPouchDB()
|
|
Get the actual instance of the PouchDB
Returns :
PouchDB.Database
|
| Async getPouchDBOnceReady |
getPouchDBOnceReady()
|
|
Returns :
Promise<PouchDB.Database>
|
| init | ||||||||||||||||
init(dbName?: string, options?: PouchDB.Configuration.DatabaseConfiguration | any, suppressSyncCompleted?: boolean)
|
||||||||||||||||
|
Inherited from
Database
|
||||||||||||||||
|
Initialize the PouchDB with the IndexedDB/in-browser adapter (default). See {link https://github.com/pouchdb/pouchdb/tree/master/packages/node_modules/pouchdb-browser}
Parameters :
Returns :
void
|
| 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()
|
|
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
Parameters :
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 :
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.
Parameters :
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. |
| 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 :
Returns :
Promise<any>
|
| remove | ||||||||
remove(object: any)
|
||||||||
|
Inherited from
Database
|
||||||||
|
Delete a document from the database (see Database)
Parameters :
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 Also see the PouchDB documentation regarding indices and queries: https://pouchdb.com/api.html#query_database
Parameters :
Returns :
Promise<void>
|
| Protected shouldSkipIndexUpdate | ||||||
shouldSkipIndexUpdate(_existingDesignDoc: any)
|
||||||
|
Whether to skip updating a design doc that differs from the local version. Overridden in RemotePouchDatabase to prevent older clients from overwriting indexes created by a newer app version on a shared server.
Parameters :
Returns :
boolean
|
| Protected Async subscribeChanges |
subscribeChanges()
|
|
Returns :
any
|
| Protected Async withReadRetry | ||||||
withReadRetry<T>(operation: () => void)
|
||||||
Type parameters :
|
||||||
|
Hook wrapping an idempotent read ( The base implementation runs the operation exactly once (no retry). RemotePouchDatabase overrides this to re-issue reads that fail with a transient network abort/timeout, which recovers e.g. a connection gone stale after the tab was suspended. Only reads use this hook — writes must run exactly once and are never routed through it.
Parameters :
Returns :
Promise<T>
|
| getAll | ||||||||||
getAll(prefix: string)
|
||||||||||
|
Inherited from
Database
|
||||||||||
|
Load all documents (with the given prefix) from the database.
Parameters :
Returns :
Promise<Array<any>>
|
import { Database, GetAllOptions, GetOptions, QueryOptions } from "../database";
import { Logging } from "../../logging/logging.service";
import { DatabaseException } from "./database-exception";
import PouchDB from "pouchdb-browser";
import indexeddbAdapter from "pouchdb-adapter-indexeddb";
import { NgZone } from "@angular/core";
import { PerformanceAnalysisLogging } from "../../../utils/performance-analysis-logging";
import { firstValueFrom, Observable, Subject } from "rxjs";
import { HttpStatusCode } from "@angular/common/http";
import { environment } from "environments/environment";
import { SyncState } from "app/core/session/session-states/sync-state.enum";
import { SyncStateSubject } from "app/core/session/session-type";
import { NotificationEvent } from "#src/app/features/notification/model/notification-event";
// Register the newer "indexeddb" adapter alongside the default "idb" adapter
PouchDB.plugin(indexeddbAdapter);
/**
* Wrapper for a PouchDB instance to decouple the code from
* that external library.
*
* Additional convenience functions on top of the PouchDB API
* should be implemented in the abstract {@link Database}.
*/
export class PouchDatabase extends Database {
/**
* The reference to the PouchDB instance
* @private
*/
protected pouchDB: PouchDB.Database;
/**
* A list of promises that resolve once all the (until now saved) indexes are created
* @private
*/
protected indexPromises: Promise<any>[] = [];
/**
* 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
* @private
*/
protected changesFeed: Subject<any>;
protected databaseInitialized = new Subject<void>();
/** trigger to unsubscribe any internal subscriptions */
protected readonly destroy$ = new Subject<void>();
/**
* 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.
*/
adapter: string = "indexeddb";
constructor(
dbName: string,
protected globalSyncState?: SyncStateSubject,
protected ngZone?: NgZone,
) {
super(dbName);
}
/**
* Initialize the PouchDB with the IndexedDB/in-browser adapter (default).
* See {link https://github.com/pouchdb/pouchdb/tree/master/packages/node_modules/pouchdb-browser}
* @param dbName the name for the database under which the IndexedDB entries will be created
* @param options PouchDB options which are directly passed to the constructor
* @param suppressSyncCompleted whether to skip emitting a SyncState.COMPLETED event to the globalSyncState (because other logic for sync is building on top of this)
*/
init(
dbName?: string,
options?: PouchDB.Configuration.DatabaseConfiguration | any,
suppressSyncCompleted?: boolean,
) {
this.pouchDB = new PouchDB(dbName ?? this.dbName, {
adapter: this.adapter,
...options,
});
this.databaseInitialized.complete();
if (!suppressSyncCompleted) {
this.globalSyncState?.next(SyncState.COMPLETED);
}
}
override isInitialized(): boolean {
return !!this.pouchDB;
}
async getPouchDBOnceReady(): Promise<PouchDB.Database> {
await firstValueFrom(this.databaseInitialized, {
defaultValue: this.pouchDB,
});
return this.pouchDB;
}
/**
* Get the actual instance of the PouchDB
*/
getPouchDB(): PouchDB.Database {
return this.pouchDB;
}
/**
* Hook wrapping an idempotent read (`get`/`allDocs`/`query`) so subclasses
* can transparently retry transient failures.
*
* The base implementation runs the operation exactly once (no retry).
* {@link RemotePouchDatabase} overrides this to re-issue reads that fail with
* a transient network abort/timeout, which recovers e.g. a connection gone
* stale after the tab was suspended. Only reads use this hook — writes must
* run exactly once and are never routed through it.
*/
protected async withReadRetry<T>(operation: () => Promise<T>): Promise<T> {
return operation();
}
/**
* Load a single document by id from the database.
* (see {@link Database})
* @param id The primary key of the document to be loaded
* @param options Optional PouchDB options for the request
* @param returnUndefined (Optional) return undefined instead of throwing error if doc is not found in database
*/
async get(
id: string,
options: GetOptions = {},
returnUndefined?: boolean,
): Promise<any> {
try {
return await this.withReadRetry(async () =>
(await this.getPouchDBOnceReady()).get(id, options),
);
} catch (err) {
if (err.status === 404) {
Logging.debug("Doc not found in database: " + id);
if (returnUndefined) {
return undefined;
}
}
throw new DatabaseException(err, id);
}
}
/**
* Load all documents (matching the given PouchDB options) from the database.
* (see {@link 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.
*
* @param options PouchDB options object as in the normal PouchDB library
*/
async allDocs(options?: GetAllOptions) {
try {
const result = await this.withReadRetry(async () =>
(await this.getPouchDBOnceReady()).allDocs(options),
);
return result.rows.map((row) => row.doc);
} catch (err) {
throw new DatabaseException(
err,
"allDocs; startkey: " + options?.["startkey"],
);
}
}
/**
* Save a document to the database.
* (see {@link Database})
*
* @param object The document to be saved
* @param forceOverwrite (Optional) Whether conflicts should be ignored and an existing conflicting document forcefully overwritten.
*/
async put(object: any, forceOverwrite = false): Promise<any> {
if (forceOverwrite) {
object._rev = undefined;
}
try {
return await (await this.getPouchDBOnceReady()).put(object);
} catch (err) {
if (err.status === 409) {
return this.resolveConflict(object, forceOverwrite, err);
} else {
throw new DatabaseException(err, object._id);
}
}
}
/**
* Save an array of documents to the database
* @param objects the documents to be saved
* @param forceOverwrite whether conflicting versions should be overwritten
* @returns 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, ... }]`)
*/
async putAll(objects: any[], forceOverwrite = false): Promise<any> {
if (forceOverwrite) {
objects.forEach((obj) => (obj._rev = undefined));
}
const pouchDB = await this.getPouchDBOnceReady();
const results = await pouchDB.bulkDocs(objects);
for (let i = 0; i < results.length; i++) {
// Check if document update conflicts happened in the request
const result = results[i] as PouchDB.Core.Error;
if (result.status === 409) {
results[i] = await this.resolveConflict(
objects.find((obj) => obj._id === result.id),
forceOverwrite,
result,
).catch((e) => {
Logging.warn(
"error during putAll",
e,
objects.map((x) => x._id),
);
return new DatabaseException(e);
});
}
}
if (results.some((r) => r instanceof Error)) {
return Promise.reject(results);
}
return results;
}
/**
* Delete a document from the database
* (see {@link Database})
*
* @param object The document to be deleted (usually this object must at least contain the _id and _rev)
*/
remove(object: any) {
return this.getPouchDBOnceReady()
.then((pouchDB) => pouchDB.remove(object))
.catch((err) => {
throw new DatabaseException(err, object["_id"]);
});
}
/**
* Permanently purge a document and all its revisions from the local database.
*
* Unlike {@link 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).
*
* @param id The document ID to purge
* @returns true if the document was purged,
* false if the document did not exist locally (benign — desired state already achieved)
*/
override async purge(id: string): Promise<boolean> {
const db = await this.getPouchDBOnceReady();
let localDoc: PouchDB.Core.IdMeta & PouchDB.Core.GetMeta;
try {
localDoc = await db.get(id, { conflicts: true });
} catch (err) {
if (err.status === 404) {
return false;
}
throw err;
}
// Purge the winning revision and any conflicting leaf revisions
// so the document is fully removed from local storage.
const revsToPurge = [
localDoc._rev,
...((localDoc as any)._conflicts ?? []),
];
for (const rev of revsToPurge) {
await (db as any).purge(id, rev);
}
// PouchDB purge() does not emit change events, so manually notify
// the changes feed so in-memory caches drop the purged entity.
if (this.changesFeed) {
const deletionEvent = { _id: id, _rev: localDoc._rev, _deleted: true };
if (this.ngZone) {
this.ngZone.run(() => this.changesFeed.next(deletionEvent));
} else {
this.changesFeed.next(deletionEvent);
}
}
return true;
}
/**
* Check if a database is new/empty.
* Returns true if there are no documents in the database
*/
isEmpty(): Promise<boolean> {
return this.getPouchDBOnceReady()
.then((pouchDB) => pouchDB.info())
.then((res) => res.doc_count === 0);
}
/**
* Listen to changes to documents in the database.
* Use rxjs operators to filter for specific prefixes etc. if needed.
* @returns observable which emits the filtered changes
*/
changes(): Observable<any> {
if (!this.changesFeed) {
this.changesFeed = new Subject();
// trigger subscription only once DB ready, to go to the right instance (e.g. remote only)
this.getPouchDBOnceReady().then(() => this.subscribeChanges());
}
return this.changesFeed;
}
protected async subscribeChanges() {
const runSubscription = async () => {
const db = await this.getPouchDBOnceReady();
db.changes({
live: true,
since: "now",
include_docs: true,
})
.addListener("change", (change) => {
// Emit changes inside Angular zone to trigger change detection
if (this.ngZone) {
this.ngZone.run(() => this.changesFeed.next(change.doc));
} else {
this.changesFeed.next(change.doc);
}
})
.catch((err) => {
if (
err.statusCode === HttpStatusCode.Unauthorized ||
err.statusCode === HttpStatusCode.GatewayTimeout
) {
Logging.warn(err);
} else {
Logging.error(err);
}
// retry
setTimeout(() => this.subscribeChanges(), 10000);
});
};
// run PouchDB change listener outside Angular zone to avoid excessive change detection
if (this.ngZone) {
this.ngZone.runOutsideAngular(() => runSubscription());
} else {
runSubscription();
}
}
/**
* Destroy the database and all saved data
*/
async destroy(): Promise<any> {
this.destroy$.next();
await Promise.all(this.indexPromises);
if (this.pouchDB) {
return this.pouchDB.destroy();
}
}
/**
* Reset the database state so a new one can be opened.
*/
async reset() {
this.destroy$.next();
this.pouchDB = undefined;
// keep this.changesFeed because some services are already subscribed to this reference
this.databaseInitialized = new Subject();
}
/**
* Query data from the database based on a more complex, indexed request.
* (see {@link Database})
*
* This is directly calling the PouchDB implementation of this function.
* Also see the documentation there: {@link https://pouchdb.com/api.html#query_database}
*
* @param fun The name of a previously saved database index
* @param options Additional options for the query, like a `key`. See the PouchDB docs for details.
*/
query(
fun: string | ((doc: any, emit: any) => void),
options: QueryOptions,
): Promise<any> {
return this.withReadRetry(() =>
this.getPouchDBOnceReady().then((pouchDB) => pouchDB.query(fun, options)),
).catch((err) => {
throw new DatabaseException(
err,
typeof fun === "string" ? fun : undefined,
);
});
}
/**
* Create a database index to `query()` certain data more efficiently in the future.
* (see {@link Database})
*
* Also see the PouchDB documentation regarding indices and queries: {@link https://pouchdb.com/api.html#query_database}
*
* @param designDoc The PouchDB style design document for the map/reduce query
*/
saveDatabaseIndex(designDoc: any): Promise<void> {
const creationPromise = this.createOrUpdateDesignDoc(designDoc).catch(
(err) => {
Logging.debug(
"Could not create/update database index (may be expected in online-only mode)",
err,
);
},
);
this.indexPromises.push(creationPromise);
return creationPromise;
}
private async createOrUpdateDesignDoc(designDoc): Promise<void> {
designDoc.aam_version = environment.appVersion;
const existingDesignDoc = await this.get(designDoc._id, {}, true);
if (!existingDesignDoc) {
Logging.debug("creating new database index");
} else if (
JSON.stringify(existingDesignDoc.views) ===
JSON.stringify(designDoc.views)
) {
// already up to date, nothing more to do
return;
} else if (this.shouldSkipIndexUpdate(existingDesignDoc)) {
return;
} else {
Logging.debug("replacing existing database index");
designDoc._rev = existingDesignDoc._rev;
}
await this.put(designDoc);
// for faster initial loading we disable prebuilding views in development
// TODO: check if this should be completely removed, also for production systems
if (environment.production) {
await this.prebuildViewsOfDesignDoc(designDoc);
}
}
/**
* Whether to skip updating a design doc that differs from the local version.
* Overridden in RemotePouchDatabase to prevent older clients from
* overwriting indexes created by a newer app version on a shared server.
*/
protected shouldSkipIndexUpdate(_existingDesignDoc: any): boolean {
return false;
}
@PerformanceAnalysisLogging
private async prebuildViewsOfDesignDoc(designDoc: any): Promise<void> {
for (const viewName of Object.keys(designDoc.views)) {
const queryName = designDoc._id.replace(/_design\//, "") + "/" + viewName;
await this.query(queryName, { key: "1" });
}
}
/**
* Attempt to intelligently resolve conflicting document versions automatically.
* @param newObject
* @param overwriteChanges
* @param existingError
*/
private async resolveConflict(
newObject: any,
overwriteChanges = false,
existingError: any = {},
): Promise<any> {
const existingObject = await this.get(newObject._id);
const resolvedObject = this.mergeObjects(existingObject, newObject);
if (resolvedObject) {
Logging.debug(
"resolved document conflict automatically (" + resolvedObject._id + ")",
);
return this.put(resolvedObject);
} else if (overwriteChanges) {
Logging.debug(
"overwriting conflicting document version (" + newObject._id + ")",
);
newObject._rev = existingObject._rev;
return this.put(newObject);
} else {
// the document's ID is passed as entityId rather than appended to the
// message: remote monitoring groups by message, so an ID in there would
// fragment one recurring problem into a separate issue per document
existingError.message = `${existingError.message} (unable to resolve)`;
throw new DatabaseException(existingError, newObject._id);
}
}
private mergeObjects(_existingObject: any, _newObject: any) {
// TODO: implement automatic merging of conflicting entity versions
return undefined;
}
/**
* Check if this is a notifications database based on the database name.
* (may be used for special handling of notification DBs)
*/
protected isNotificationsDatabase(): boolean {
return this.dbName?.startsWith(NotificationEvent.DATABASE) ?? false;
}
}
export { DatabaseException } from "./database-exception";