diff --git a/webapp/src/ts/services/indexed-db.service.ts b/webapp/src/ts/services/indexed-db.service.ts new file mode 100644 index 00000000000..5da1d1ae9ec --- /dev/null +++ b/webapp/src/ts/services/indexed-db.service.ts @@ -0,0 +1,99 @@ +import { Inject, Injectable } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; + +import { DbService } from '@mm-services/db.service'; + +@Injectable({ + providedIn: 'root' +}) +export class IndexedDbService { + private readonly LOCAL_DOC = '_local/indexeddb-placeholder'; + private loadingLocalDoc: Promise | undefined; + private hasDatabasesFn: boolean; + private isUpdatingLocalDoc: boolean; + + constructor( + private dbService: DbService, + @Inject(DOCUMENT) private document: Document + ) { + // Firefox doesn't support the databases() function. Issue: https://github.com/medic/cht-core/issues/8777 + this.hasDatabasesFn = !!this.document.defaultView?.indexedDB?.databases; + } + + async getDatabaseNames() { + if (this.hasDatabasesFn) { + const databases = await this.document.defaultView?.indexedDB?.databases(); + return databases?.map(db => db.name); + } + + const doc = await this.getLocalDoc(); + return doc?.db_names || []; + } + + async saveDatabaseName(name: string) { + if (this.hasDatabasesFn || this.isUpdatingLocalDoc) { + return; + } + + this.isUpdatingLocalDoc = true; + const localDoc = await this.getLocalDoc(); + if (localDoc.db_names.indexOf(name) > -1) { + this.isUpdatingLocalDoc = false; + return; + } + + localDoc.db_names.push(name); + await this.updateLocalDoc(localDoc); + this.isUpdatingLocalDoc = false; + } + + async deleteDatabaseName(name: string) { + if (this.hasDatabasesFn || this.isUpdatingLocalDoc) { + return; + } + + this.isUpdatingLocalDoc = true; + const localDoc = await this.getLocalDoc(); + const dbNameIdx = localDoc.db_names.indexOf(name); + if (dbNameIdx === -1) { + this.isUpdatingLocalDoc = false; + return; + } + + localDoc.db_names.splice(dbNameIdx, 1); + await this.updateLocalDoc(localDoc); + this.isUpdatingLocalDoc = false; + } + + private async updateLocalDoc(localDoc) { + await this.dbService + .get({ meta: true }) + .put(localDoc); + this.loadingLocalDoc = undefined; // To fetch again and get the new doc's _rev number. + } + + private async getLocalDoc(): Promise { + // Avoids "Failed to execute transaction on IDBDatabase" exception. + if (!this.loadingLocalDoc) { + this.loadingLocalDoc = this.dbService + .get({ meta: true }) + .get(this.LOCAL_DOC); + } + + let localDoc; + try { + localDoc = await this.loadingLocalDoc; + } catch (error) { + if (error.status !== 404) { + throw error; + } + console.debug('IndexedDbService :: Local doc not created yet. Ignoring error.'); + } + return localDoc ? { ...localDoc } : { _id: this.LOCAL_DOC, db_names: [] }; + } +} + +interface IndexedDBDoc { + _id: string; + db_names: string[]; +} diff --git a/webapp/src/ts/services/telemetry.service.ts b/webapp/src/ts/services/telemetry.service.ts index 3546d08b0a8..a2f02820dc9 100644 --- a/webapp/src/ts/services/telemetry.service.ts +++ b/webapp/src/ts/services/telemetry.service.ts @@ -5,6 +5,7 @@ import * as moment from 'moment'; import { DbService } from '@mm-services/db.service'; import { SessionService } from '@mm-services/session.service'; +import { IndexedDbService } from '@mm-services/indexed-db.service'; @Injectable({ providedIn: 'root' @@ -24,6 +25,7 @@ export class TelemetryService { private dbService:DbService, private sessionService:SessionService, private ngZone:NgZone, + private indexedDbService:IndexedDbService, @Inject(DOCUMENT) private document:Document, ) { this.windowRef = this.document.defaultView; @@ -117,9 +119,9 @@ export class TelemetryService { }; } - private async getTelemetryDBs(databases): Promise { - return databases - ?.map(db => db.name?.replace(this.POUCH_PREFIX, '') || '') + private async getTelemetryDBs(databaseNames): Promise { + return databaseNames + ?.map(dbName => dbName?.replace(this.POUCH_PREFIX, '') || '') .filter(dbName => dbName?.startsWith(this.TELEMETRY_PREFIX)); } @@ -215,6 +217,7 @@ export class TelemetryService { currentDB = this.generateTelemetryDBName(today); } + await this.indexedDbService.saveDatabaseName(currentDB); // Firefox support. return this.windowRef.PouchDB(currentDB); // Avoid angular-pouch as digest isn't necessary here } @@ -244,6 +247,7 @@ export class TelemetryService { const db = this.windowRef.PouchDB(dbName); await this.aggregate(db, dbName); await db.destroy(); + await this.indexedDbService.deleteDatabaseName(dbName); // Firefox support. } catch (error) { console.error('Error when aggregating the telemetry records', error); } finally { @@ -313,9 +317,9 @@ export class TelemetryService { try { const today = this.getToday(); - const databases = await this.windowRef?.indexedDB?.databases(); - await this.deleteDeprecatedTelemetryDB(databases); - const telemetryDBs = await this.getTelemetryDBs(databases); + const databaseNames = await this.indexedDbService.getDatabaseNames(); + await this.deleteDeprecatedTelemetryDB(databaseNames); + const telemetryDBs = await this.getTelemetryDBs(databaseNames); await this.submitIfNeeded(today, telemetryDBs); const currentDB = await this.getCurrentTelemetryDB(today, telemetryDBs); await this @@ -333,19 +337,19 @@ export class TelemetryService { * It was decided to not aggregate the DB content. * @private */ - private async deleteDeprecatedTelemetryDB(databases) { + private async deleteDeprecatedTelemetryDB(databaseNames) { //NOSONAR if (this.hasTransitionFinished) { return; } - databases?.forEach(db => { - const nameNoPrefix = db.name?.replace(this.POUCH_PREFIX, '') || ''; + databaseNames?.forEach(dbName => { + const nameNoPrefix = dbName?.replace(this.POUCH_PREFIX, '') || ''; // Skips new Telemetry DB, then matches the old deprecated Telemetry DB. if (!nameNoPrefix.startsWith(this.TELEMETRY_PREFIX) && nameNoPrefix.includes(this.TELEMETRY_PREFIX) && nameNoPrefix.includes(this.sessionService.userCtx().name)) { - this.windowRef?.indexedDB.deleteDatabase(db.name); + this.windowRef?.indexedDB.deleteDatabase(dbName); } }); diff --git a/webapp/tests/karma/ts/services/indexed-db.service.spec.ts b/webapp/tests/karma/ts/services/indexed-db.service.spec.ts new file mode 100644 index 00000000000..1e5489ea823 --- /dev/null +++ b/webapp/tests/karma/ts/services/indexed-db.service.spec.ts @@ -0,0 +1,179 @@ +import { TestBed } from '@angular/core/testing'; +import { DOCUMENT } from '@angular/common'; +import sinon from 'sinon'; +import { expect } from 'chai'; + +import { DbService } from '@mm-services/db.service'; +import { IndexedDbService } from '@mm-services/indexed-db.service'; + +describe('IndexedDbService', () => { + let service: IndexedDbService; + let dbService; + let metaDb; + let documentMock; + + beforeEach(() => { + metaDb = { + put: sinon.stub(), + get: sinon.stub(), + }; + dbService = { + get: sinon.stub().withArgs({ meta: true }).returns(metaDb) + }; + documentMock = { + defaultView: { + indexedDB: { databases: null }, + }, + querySelectorAll: sinon.stub().returns([]), + }; + + TestBed.configureTestingModule({ + providers: [ + { provide: DbService, useValue: dbService }, + { provide: DOCUMENT, useValue: documentMock }, + ], + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('getDatabaseNames', () => { + it('should do return list of database names when browser supports databases() function', async () => { + documentMock.defaultView.indexedDB.databases = sinon.stub(); + documentMock.defaultView.indexedDB.databases.resolves([ + { name: 'db-1' }, + { name: 'db-2' }, + ]); + service = TestBed.inject(IndexedDbService); + + const dbNames = await service.getDatabaseNames(); + + expect(documentMock.defaultView.indexedDB.databases.calledOnce).to.be.true; + expect(dbNames).to.have.members([ 'db-1', 'db-2' ]); + }); + + it('should return list of database names', async () => { + metaDb.get.resolves({ + db_names: [ 'db-a', 'db-b', 'db-c' ], + }); + service = TestBed.inject(IndexedDbService); + + const dbNames = await service.getDatabaseNames(); + + expect(dbNames).to.have.members([ 'db-a', 'db-b', 'db-c' ]); + }); + + it('should return empty array if no db_names in local doc', async () => { + metaDb.get.resolves({ db_names: [] }); + service = TestBed.inject(IndexedDbService); + + const dbNames = await service.getDatabaseNames(); + + expect(dbNames?.length).to.equal(0); + }); + }); + + describe('saveDatabaseName', () => { + it('should do nothing when browser supports databases() function', async () => { + documentMock.defaultView.indexedDB.databases = sinon.stub(); + documentMock.defaultView.indexedDB.databases.resolves([ + { name: 'db-1' }, + { name: 'db-2' }, + ]); + service = TestBed.inject(IndexedDbService); + + await service.saveDatabaseName('db-new'); + + expect(documentMock.defaultView.indexedDB.databases.notCalled).to.be.true; + expect(metaDb.get.notCalled).to.be.true; + expect(metaDb.put.notCalled).to.be.true; + }); + + it('should do nothing when name already exists', async () => { + metaDb.get.resolves({ + db_names: [ 'db-a', 'db-b', 'db-c' ], + }); + service = TestBed.inject(IndexedDbService); + + await service.saveDatabaseName('db-b'); + + expect(metaDb.get.calledOnce).to.be.true; + expect(metaDb.put.notCalled).to.be.true; + }); + + it('should save name when it is new', async () => { + metaDb.get.resolves({ + _id: '_local/indexeddb-placeholder', + db_names: [ 'db-a', 'db-b', 'db-c' ], + }); + service = TestBed.inject(IndexedDbService); + + await service.saveDatabaseName('db-new'); + + expect(metaDb.get.calledOnce).to.be.true; + expect(metaDb.put.calledOnce).to.be.true; + expect(metaDb.put.args[0][0]).to.deep.equal({ + _id: '_local/indexeddb-placeholder', + db_names: [ 'db-a', 'db-b', 'db-c', 'db-new' ], + }); + }); + }); + + describe('deleteDatabaseName', () => { + it('should do nothing when browser supports databases() function', async () => { + documentMock.defaultView.indexedDB.databases = sinon.stub(); + documentMock.defaultView.indexedDB.databases.resolves([ + { name: 'db-1' }, + { name: 'db-2' }, + ]); + service = TestBed.inject(IndexedDbService); + + await service.deleteDatabaseName('db-1'); + + expect(documentMock.defaultView.indexedDB.databases.notCalled).to.be.true; + expect(metaDb.get.notCalled).to.be.true; + expect(metaDb.put.notCalled).to.be.true; + }); + + it('should do nothing when name not found', async () => { + metaDb.get.resolves({ + db_names: [ 'db-a', 'db-b', 'db-c' ], + }); + service = TestBed.inject(IndexedDbService); + + await service.deleteDatabaseName('db-other'); + + expect(metaDb.get.calledOnce).to.be.true; + expect(metaDb.put.notCalled).to.be.true; + }); + + it('should do nothing when db_names in local doc is empty', async () => { + metaDb.get.resolves({ db_names: [] }); + service = TestBed.inject(IndexedDbService); + + await service.deleteDatabaseName('db-b'); + + expect(metaDb.get.calledOnce).to.be.true; + expect(metaDb.put.notCalled).to.be.true; + }); + + it('should delete name when it exists in db_names', async () => { + metaDb.get.resolves({ + _id: '_local/indexeddb-placeholder', + db_names: [ 'db-a', 'db-b', 'db-c' ], + }); + service = TestBed.inject(IndexedDbService); + + await service.deleteDatabaseName('db-b'); + + expect(metaDb.get.calledOnce).to.be.true; + expect(metaDb.put.calledOnce).to.be.true; + expect(metaDb.put.args[0][0]).to.deep.equal({ + _id: '_local/indexeddb-placeholder', + db_names: [ 'db-a', 'db-c' ], + }); + }); + }); +}); diff --git a/webapp/tests/karma/ts/services/telemetry.service.spec.ts b/webapp/tests/karma/ts/services/telemetry.service.spec.ts index cff4707f3d8..6ff7f714359 100644 --- a/webapp/tests/karma/ts/services/telemetry.service.spec.ts +++ b/webapp/tests/karma/ts/services/telemetry.service.spec.ts @@ -6,6 +6,7 @@ import { expect } from 'chai'; import { TelemetryService } from '@mm-services/telemetry.service'; import { DbService } from '@mm-services/db.service'; import { SessionService } from '@mm-services/session.service'; +import { IndexedDbService } from '@mm-services/indexed-db.service'; describe('TelemetryService', () => { const NOW = new Date(2018, 10, 10, 12, 33).getTime(); // -> 2018-11-10T12:33:00 @@ -14,6 +15,7 @@ describe('TelemetryService', () => { let metaDb; let medicDb; let sessionService; + let indexedDbService; let clock; let telemetryDb; let consoleErrorSpy; @@ -99,6 +101,11 @@ describe('TelemetryService', () => { return Promise.resolve(); }) }; + indexedDbService = { + getDatabaseNames: sinon.stub(), + saveDatabaseName: sinon.stub(), + deleteDatabaseName: sinon.stub(), + }; sessionService = { userCtx: sinon.stub().returns({ name: 'greg' }) }; windowMock = { PouchDB: sinon.stub().returns(telemetryDb), @@ -115,6 +122,7 @@ describe('TelemetryService', () => { { provide: DbService, useValue: dbService }, { provide: SessionService, useValue: sessionService }, { provide: DOCUMENT, useValue: documentMock }, + { provide: IndexedDbService, useValue: indexedDbService }, ] }); @@ -132,10 +140,10 @@ describe('TelemetryService', () => { it('should record a piece of telemetry', async () => { medicDb.query.resolves({ rows: [] }); telemetryDb.query.resolves({ rows: [] }); - windowMock.indexedDB.databases.resolves([ - { name: '_pouch_telemetry-2018-11-10-greg' }, - { name: '_pouch_some-other-db' }, - { name: '_pouch_telemetry-2018-11-09-greg' }, + indexedDbService.getDatabaseNames.resolves([ + '_pouch_telemetry-2018-11-10-greg', + '_pouch_some-other-db', + '_pouch_telemetry-2018-11-09-greg', ]); await service.record('test', 100); @@ -144,7 +152,7 @@ describe('TelemetryService', () => { expect(telemetryDb.post.calledOnce).to.be.true; expect(telemetryDb.post.args[0][0]).to.deep.include({ key: 'test', value: 100 }); expect(telemetryDb.post.args[0][0].date_recorded).to.be.above(0); - expect(windowMock.indexedDB.databases.calledOnce).to.be.true; + expect(indexedDbService.getDatabaseNames.calledOnce).to.be.true; expect(windowMock.PouchDB.calledTwice).to.be.true; expect(windowMock.PouchDB.args[0]).to.deep.equal([ 'telemetry-2018-11-09-greg' ]); expect(windowMock.PouchDB.args[1]).to.deep.equal([ 'telemetry-2018-11-10-greg' ]); @@ -154,10 +162,10 @@ describe('TelemetryService', () => { it('should default the value to 1 if not passed', async () => { medicDb.query.resolves({ rows: [] }); telemetryDb.query.resolves({ rows: [] }); - windowMock.indexedDB.databases.resolves([ - { name: 'telemetry-2018-11-10-greg' }, - { name: 'some-other-db' }, - { name: 'telemetry-2018-11-09-greg' }, + indexedDbService.getDatabaseNames.resolves([ + 'telemetry-2018-11-10-greg', + 'some-other-db', + 'telemetry-2018-11-09-greg', ]); await service.record('test'); @@ -211,11 +219,11 @@ describe('TelemetryService', () => { }; it('should aggregate once a day and delete previous telemetry databases', async () => { - windowMock.indexedDB.databases.resolves([ - { name: 'telemetry-2018-11-10-greg' }, - { name: 'some-other-db' }, - { name: 'telemetry-2018-11-09-greg' }, - { name: 'telemetry-2018-10-02-greg' }, + indexedDbService.getDatabaseNames.resolves([ + 'telemetry-2018-11-10-greg', + 'some-other-db', + 'telemetry-2018-11-09-greg', + 'telemetry-2018-10-02-greg', ]); setupDbMocks(); @@ -283,9 +291,9 @@ describe('TelemetryService', () => { }); it('should not aggregate when recording the day the db was created and next day it should aggregate', async () => { - windowMock.indexedDB.databases.resolves([ - { name: 'telemetry-2018-11-10-greg' }, - { name: 'some-other-db' }, + indexedDbService.getDatabaseNames.resolves([ + 'telemetry-2018-11-10-greg', + 'some-other-db', ]); setupDbMocks(); @@ -334,7 +342,7 @@ describe('TelemetryService', () => { }); it('should aggregate from days with records skipping days without records', async () => { - windowMock.indexedDB.databases.resolves([]); + indexedDbService.getDatabaseNames.resolves([]); setupDbMocks(); await service.record('datapoint', 12); @@ -353,7 +361,7 @@ describe('TelemetryService', () => { expect(metaDb.put.notCalled).to.be.true; // still NO telemetry has been recorded (same day) clock.tick('48:00:00'); // 2 days later ... - windowMock.indexedDB.databases.resolves([ { name: 'telemetry-2018-11-10-greg' } ]); + indexedDbService.getDatabaseNames.resolves([ 'telemetry-2018-11-10-greg' ]); await service.record('test', 2); expect(telemetryDb.post.calledThrice).to.be.true; // third call @@ -367,7 +375,7 @@ describe('TelemetryService', () => { expect(telemetryDb.destroy.calledOnce).to.be.true; // from 2 days ago (not Yesterday) clock.tick(5 * 24 * 60 * 60 * 1000); // 5 more days later ... - windowMock.indexedDB.databases.resolves([ { name: 'telemetry-2018-11-12-greg' } ]); + indexedDbService.getDatabaseNames.resolves([ 'telemetry-2018-11-12-greg' ]); await service.record('point.a', 1); expect(telemetryDb.post.callCount).to.equal(4); // 4th call @@ -380,7 +388,7 @@ describe('TelemetryService', () => { // A new record is added ... clock.tick('02:00:00'); // 2 hours later ... - windowMock.indexedDB.databases.resolves([]); + indexedDbService.getDatabaseNames.resolves([]); await service.record('point.b', 0); // 1 record added // ...the aggregation count is the same because // the aggregation was already performed 2 hours ago within the same day @@ -393,7 +401,7 @@ describe('TelemetryService', () => { describe('storeConflictedAggregate()', () => { it('should deal with conflicts by making the ID unique and noting the conflict in the new document', async () => { - windowMock.indexedDB.databases.resolves([ { name: '_pouch_telemetry-2018-11-05-greg' } ]); + indexedDbService.getDatabaseNames.resolves([ '_pouch_telemetry-2018-11-05-greg' ]); telemetryDb.query = sinon.stub().resolves({ rows: [