Skip to content

Commit

Permalink
fix(#8777): IndexedDB polyfill (#8785)
Browse files Browse the repository at this point in the history
  • Loading branch information
latin-panda authored Jan 9, 2024
1 parent 25bbec5 commit 7ebd7bf
Show file tree
Hide file tree
Showing 4 changed files with 322 additions and 32 deletions.
99 changes: 99 additions & 0 deletions webapp/src/ts/services/indexed-db.service.ts
Original file line number Diff line number Diff line change
@@ -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<IndexedDBDoc> | 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<IndexedDBDoc> {
// 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[];
}
24 changes: 14 additions & 10 deletions webapp/src/ts/services/telemetry.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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;
Expand Down Expand Up @@ -117,9 +119,9 @@ export class TelemetryService {
};
}

private async getTelemetryDBs(databases): Promise<undefined|string[]> {
return databases
?.map(db => db.name?.replace(this.POUCH_PREFIX, '') || '')
private async getTelemetryDBs(databaseNames): Promise<undefined|string[]> {
return databaseNames
?.map(dbName => dbName?.replace(this.POUCH_PREFIX, '') || '')
.filter(dbName => dbName?.startsWith(this.TELEMETRY_PREFIX));
}

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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);
}
});

Expand Down
179 changes: 179 additions & 0 deletions webapp/tests/karma/ts/services/indexed-db.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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' ],
});
});
});
});
Loading

0 comments on commit 7ebd7bf

Please sign in to comment.