pouchdb.service.ts 7.45 KB
Newer Older
1
import { Injectable, NgZone } from '@angular/core';
2
3
import PouchDB from 'pouchdb-browser';
import PouchDBFind from 'pouchdb-find';
4
import { DatabaseModel } from './database-model';
5
import examples from '../../examples';
6
7
import { from, Observable, Subject } from 'rxjs';
import { catchError, finalize } from 'rxjs/operators';
8
import { environment } from '../../environments/environment';
Alexander Philipp Nowosad's avatar
Alexander Philipp Nowosad committed
9
import { DatabaseRootEntry, DbId, DbRev, DbType } from './database-entry';
10

11
12
13
14
15
export enum DatabaseErrors {
  INVALID_NAME = 'Name of database may only contain characters or numbers.',
  MISSING_NAME = 'Database name is missing.',
}

16
17
interface CouchDBFindResponse {
  docs: unknown[];
18
  bookmark?: string;
19
20
21
}

// noinspection JSVoidFunctionReturnValueUsed
22
@Injectable()
23
export class PouchdbService {
Alexander Philipp Nowosad's avatar
Alexander Philipp Nowosad committed
24
  private db?: PouchDB.Database<DatabaseRootEntry>;
25
26
  private expired?: () => Promise<void>;
  private error?: () => Promise<void>;
27

Alexander Philipp Nowosad's avatar
Alexander Philipp Nowosad committed
28
  constructor(private zone: NgZone) {
29
    PouchDB.plugin(PouchDBFind);
30
31
  }

32
33
34
35
36
  init(
    name: string,
    expired: () => Promise<void>,
    error: () => Promise<void>
  ): void {
Alexander Philipp Nowosad's avatar
Alexander Philipp Nowosad committed
37
    if (this.db) {
38
      void this.db.close();
Alexander Philipp Nowosad's avatar
Alexander Philipp Nowosad committed
39
    }
40
    this.db = this.getDatabase(name);
41
    this.expired = expired;
42
    this.error = error;
43
44
  }

Alexander Philipp Nowosad's avatar
Alexander Philipp Nowosad committed
45
46
47
  /**
   * Close the connection to the current database
   */
48
49
50
51
52
  async closeDb(): Promise<void> {
    await this.getDb().close();
    this.db = undefined;
    this.expired = undefined;
    this.error = undefined;
Alexander Philipp Nowosad's avatar
Alexander Philipp Nowosad committed
53
54
  }

55
56
57
58
59
  /**
   * Get the database to connect to
   *
   * @param name the name of the database
   */
Alexander Philipp Nowosad's avatar
Alexander Philipp Nowosad committed
60
  private getDatabase(name: string): PouchDB.Database<DatabaseRootEntry> {
61
62
63
64
65
66
    if (name == null) {
      throw new Error(DatabaseErrors.MISSING_NAME);
    }
    if (!/^[a-zA-Z0-9]+$/.test(name)) {
      throw new Error(DatabaseErrors.INVALID_NAME);
    }
67
    if (environment.localDatabase) {
Alexander Philipp Nowosad's avatar
Alexander Philipp Nowosad committed
68
      return new PouchDB<DatabaseRootEntry>(name);
69
70
71
    } else {
      const options: PouchDB.Configuration.DatabaseConfiguration = {
        fetch: (fetchUrl, fetchOptions) => {
72
          (fetchOptions!.headers as Headers).set(
73
74
75
76
77
78
79
80
            'X-CouchDB-WWW-Authenticate',
            'Cookie'
          );
          return PouchDB.fetch(fetchUrl, fetchOptions);
        },
        skip_setup: true,
      };
      const url = new URL('/database/' + name, location.origin).href;
Alexander Philipp Nowosad's avatar
Alexander Philipp Nowosad committed
81
      return new PouchDB<DatabaseRootEntry>(url, options);
82
    }
83
84
  }

85
  /**
86
   * Get info about the database
87
   */
88
  getDatabaseInfo(): Promise<PouchDB.Core.DatabaseInfo> {
89
90
91
    return this.getDb()
      .info()
      .catch((error) => this.handleError(error));
92
93
  }

94
  async find<T extends DatabaseModel>(
95
    type: DbType,
Alexander Philipp Nowosad's avatar
Alexander Philipp Nowosad committed
96
    request: PouchDB.Find.FindRequest<T>
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
  ): Promise<T[]> {
    try {
      let result: PouchDB.Find.FindResponse<T>;
      if (Object.keys(request.selector).length > 0) {
        result = (await this._find({
          ...request,
          selector: {
            $and: [request.selector, { type }],
          },
        })) as PouchDB.Find.FindResponse<T>;
      } else {
        result = (await this._find({
          ...request,
          selector: {
            type,
          },
        })) as PouchDB.Find.FindResponse<T>;
      }
      return result.docs;
    } catch (error) {
      return this.handleError(error);
118
    }
119
120
  }

121
122
123
124
125
126
  async get<T extends DatabaseModel>(id: DbId): Promise<T> {
    try {
      return await this.getDb().get<T>(id);
    } catch (error) {
      return this.handleError(error);
    }
127
128
  }

129
  async post(model: DatabaseModel): Promise<void> {
130
    try {
131
      await this.getDb().post(model.toDb());
132
    } catch (error) {
133
      await this.handleError(error);
134
    }
135
136
  }

137
  async put<T extends DatabaseModel>(model: T): Promise<void> {
138
    try {
139
      await this.getDb().put(model.toDb());
140
    } catch (error) {
141
      await this.handleError(error);
142
    }
143
144
  }

145
146
147
148
149
150
  async remove(model: DatabaseModel & { _rev: DbRev }): Promise<void> {
    try {
      await this.getDb().remove(model);
    } catch (error) {
      await this.handleError(error);
    }
151
152
  }

153
154
155
156
157
158
  /**
   * Get all changes of a specific model
   *
   * @param id the id of the model
   * @return the changes feed, needs to be canceled to stop receiving changes
   */
159
160
  getChangesFeed<T extends DatabaseModel>(id: DbId): Observable<void> {
    const subject = new Subject<void>();
161
    const changes = this.getDb()
Alexander Philipp Nowosad's avatar
Alexander Philipp Nowosad committed
162
163
164
165
166
      .changes<T>({
        since: 'now',
        live: true,
        doc_ids: [id],
      })
167
      .on('change', () => this.zone.run(() => subject.next()))
168
169
      .on('error', (error) => this.zone.run(() => subject.error(error)));
    return subject.pipe(
170
      catchError((error) => from(this.handleError(error))),
Alexander Philipp Nowosad's avatar
Alexander Philipp Nowosad committed
171
      finalize(() => changes.cancel())
172
    );
173
174
  }

175
  /**
176
   * Called on error
177
   */
178
  private async handleError(error: PouchDB.Core.Error): Promise<never> {
Alexander Philipp Nowosad's avatar
Alexander Philipp Nowosad committed
179
180
181
182
183
    if (
      error.status === 401 &&
      error.error === 'unauthorized' &&
      this.expired != null
    ) {
184
185
      await this.expired();
    }
186
187
188
189
190
191
192
    if (
      error.status === 500 &&
      error.error === 'internal_server_error' &&
      this.error != null
    ) {
      await this.error();
    }
193
194
195
    throw error;
  }

196
  /**
197
   * Add default data to the database.
198
   */
199
200
  async addDefaultData(): Promise<void> {
    try {
Alexander Philipp Nowosad's avatar
Alexander Philipp Nowosad committed
201
      await this.getDb().bulkDocs(examples);
202
203
204
    } catch (error) {
      return this.handleError(error);
    }
205
  }
206
207
208
209

  /**
   * Clear all documents from the database
   */
210
211
212
213
214
215
216
217
218
219
  async clearDatabase(): Promise<void> {
    try {
      const allDocs = await this.getDb().allDocs();
      const toBeDeleted = allDocs.rows.map((row) => {
        return {
          _id: row.id,
          _rev: row.value.rev,
          _deleted: true,
        };
      });
Alexander Philipp Nowosad's avatar
Alexander Philipp Nowosad committed
220
221
222
      await this.getDb().bulkDocs(
        toBeDeleted as unknown[] as DatabaseRootEntry[]
      );
223
224
225
    } catch (error) {
      await this.handleError(error);
    }
226
  }
227
228
229
230

  /**
   * Export the complete database
   */
Alexander Philipp Nowosad's avatar
Alexander Philipp Nowosad committed
231
  async getAllDocs(): Promise<PouchDB.Find.FindResponse<DatabaseRootEntry>> {
232
233
234
235
236
    const docCount = (await this.getDatabaseInfo()).doc_count;
    const allDocs = await this._find({ selector: {} });
    if (docCount !== allDocs.docs.length) {
      throw new Error('Doc count does not match export size');
    }
Alexander Philipp Nowosad's avatar
Alexander Philipp Nowosad committed
237
    return allDocs as PouchDB.Find.FindResponse<DatabaseRootEntry>;
238
239
  }

Alexander Philipp Nowosad's avatar
Alexander Philipp Nowosad committed
240
  async importDocs(docs: DatabaseRootEntry[]): Promise<void> {
241
    try {
242
      await this.getDb().bulkDocs(docs);
243
244
245
246
    } catch (error) {
      await this.handleError(error);
    }
  }
247
248

  private async _find(
249
250
251
    request: PouchDB.Find.FindRequest<{}>
  ): Promise<PouchDB.Find.FindResponse<{}>> {
    const response = await this.getDb().find(request);
252
253
    if ('bookmark' in response) {
      const couchDbResponse = response as CouchDBFindResponse;
254
      let docCount = couchDbResponse.docs.length;
255
      while (docCount > 0 && couchDbResponse.bookmark != null) {
256
        const nextPage = await this.getDb().find({
257
258
259
260
261
262
263
264
265
          ...request,
          bookmark: couchDbResponse.bookmark,
        } as never);
        docCount = nextPage.docs.length;
        couchDbResponse.docs = couchDbResponse.docs.concat(nextPage.docs);
        if ('bookmark' in response) {
          const couchDbNextPage = nextPage as CouchDBFindResponse;
          couchDbResponse.bookmark = couchDbNextPage.bookmark;
        } else {
266
          couchDbResponse.bookmark = undefined;
267
268
269
270
271
        }
      }
    }
    return response;
  }
272
273
274
275

  /**
   * Get the database, but check whether it is available.
   */
Alexander Philipp Nowosad's avatar
Alexander Philipp Nowosad committed
276
  private getDb(): PouchDB.Database<DatabaseRootEntry> {
277
278
279
280
281
    if (this.db == null) {
      throw new Error('Database is not initialized');
    }
    return this.db;
  }
282
}