From 927c1afc103e4f9b8a75320d3946a4c840445a2a Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Thu, 10 Mar 2022 12:17:12 -0800 Subject: [PATCH] Add a last sent date to heartbeat storage (#6039) --- .changeset/sweet-pumas-dance.md | 5 + packages/app/src/heartbeatService.test.ts | 187 ++++++++++++++++------ packages/app/src/heartbeatService.ts | 100 ++++++------ packages/app/src/indexeddb.ts | 15 -- packages/app/src/types.ts | 15 +- 5 files changed, 198 insertions(+), 124 deletions(-) create mode 100644 .changeset/sweet-pumas-dance.md diff --git a/.changeset/sweet-pumas-dance.md b/.changeset/sweet-pumas-dance.md new file mode 100644 index 00000000000..da2ba9f6b9f --- /dev/null +++ b/.changeset/sweet-pumas-dance.md @@ -0,0 +1,5 @@ +--- +'@firebase/app': patch +--- + +Fix heartbeat controller to ensure not sending more than one a day. diff --git a/packages/app/src/heartbeatService.test.ts b/packages/app/src/heartbeatService.test.ts index ad0d453b706..8877cb342fc 100644 --- a/packages/app/src/heartbeatService.test.ts +++ b/packages/app/src/heartbeatService.test.ts @@ -32,7 +32,6 @@ import { FirebaseApp } from './public-types'; import * as firebaseUtil from '@firebase/util'; import { SinonStub, stub, useFakeTimers } from 'sinon'; import * as indexedDb from './indexeddb'; -import { base64Encode, isIndexedDBAvailable } from '@firebase/util'; declare module '@firebase/component' { interface NameServiceMapping { @@ -99,38 +98,37 @@ describe('HeartbeatServiceImpl', () => { */ it(`triggerHeartbeat() stores a heartbeat`, async () => { await heartbeatService.triggerHeartbeat(); - expect(heartbeatService._heartbeatsCache?.length).to.equal(1); - const heartbeat1 = heartbeatService._heartbeatsCache?.[0]; - expect(heartbeat1?.userAgent).to.equal(USER_AGENT_STRING_1); + expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(1); + const heartbeat1 = heartbeatService._heartbeatsCache?.heartbeats[0]; + expect(heartbeat1?.agent).to.equal(USER_AGENT_STRING_1); expect(heartbeat1?.date).to.equal('1970-01-01'); - expect(writeStub).to.be.calledWith([heartbeat1]); + expect(writeStub).to.be.calledWith({ heartbeats: [heartbeat1] }); }); it(`triggerHeartbeat() doesn't store another heartbeat on the same day`, async () => { - expect(heartbeatService._heartbeatsCache?.length).to.equal(1); + expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(1); await heartbeatService.triggerHeartbeat(); - expect(heartbeatService._heartbeatsCache?.length).to.equal(1); + expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(1); }); it(`triggerHeartbeat() does store another heartbeat on a different day`, async () => { - expect(heartbeatService._heartbeatsCache?.length).to.equal(1); + expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(1); clock.tick(24 * 60 * 60 * 1000); await heartbeatService.triggerHeartbeat(); - expect(heartbeatService._heartbeatsCache?.length).to.equal(2); - expect(heartbeatService._heartbeatsCache?.[1].date).to.equal( + expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(2); + expect(heartbeatService._heartbeatsCache?.heartbeats[1].date).to.equal( '1970-01-02' ); }); it(`triggerHeartbeat() stores another entry for a different user agent`, async () => { userAgentString = USER_AGENT_STRING_2; - expect(heartbeatService._heartbeatsCache?.length).to.equal(2); + expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(2); clock.tick(2 * 24 * 60 * 60 * 1000); await heartbeatService.triggerHeartbeat(); - expect(heartbeatService._heartbeatsCache?.length).to.equal(3); - expect(heartbeatService._heartbeatsCache?.[2].date).to.equal( + expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(3); + expect(heartbeatService._heartbeatsCache?.heartbeats[2].date).to.equal( '1970-01-03' ); }); it('getHeartbeatHeaders() gets stored heartbeats and clears heartbeats', async () => { - const deleteStub = stub(heartbeatService._storage, 'deleteAll'); const heartbeatHeaders = firebaseUtil.base64Decode( await heartbeatService.getHeartbeatsHeader() ); @@ -140,10 +138,13 @@ describe('HeartbeatServiceImpl', () => { expect(heartbeatHeaders).to.include('1970-01-02'); expect(heartbeatHeaders).to.include('1970-01-03'); expect(heartbeatHeaders).to.include(`"version":2`); - expect(heartbeatService._heartbeatsCache).to.equal(null); + expect(heartbeatService._heartbeatsCache?.heartbeats).to.be.empty; + expect(writeStub).to.be.calledWith({ + lastSentHeartbeatDate: '1970-01-01', + heartbeats: [] + }); const emptyHeaders = await heartbeatService.getHeartbeatsHeader(); expect(emptyHeaders).to.equal(''); - expect(deleteStub).to.be.called; }); }); describe('If IndexedDB has entries', () => { @@ -154,11 +155,11 @@ describe('HeartbeatServiceImpl', () => { const mockIndexedDBHeartbeats = [ // Chosen so one will exceed 30 day limit and one will not. { - userAgent: 'old-user-agent', + agent: 'old-user-agent', date: '1969-12-01' }, { - userAgent: 'old-user-agent', + agent: 'old-user-agent', date: '1969-12-31' } ]; @@ -197,61 +198,149 @@ describe('HeartbeatServiceImpl', () => { */ it(`new heartbeat service reads from indexedDB cache`, async () => { const promiseResult = await heartbeatService._heartbeatsCachePromise; - if (isIndexedDBAvailable()) { - expect(promiseResult).to.deep.equal(mockIndexedDBHeartbeats); - expect(heartbeatService._heartbeatsCache).to.deep.equal( - mockIndexedDBHeartbeats - ); + if (firebaseUtil.isIndexedDBAvailable()) { + expect(promiseResult).to.deep.equal({ + heartbeats: mockIndexedDBHeartbeats + }); + expect(heartbeatService._heartbeatsCache).to.deep.equal({ + heartbeats: mockIndexedDBHeartbeats + }); } else { // In Node or other no-indexed-db environments it will fail the // `canUseIndexedDb` check and return an empty array. - expect(promiseResult).to.deep.equal([]); - expect(heartbeatService._heartbeatsCache).to.deep.equal([]); + expect(promiseResult).to.deep.equal({ + heartbeats: [] + }); + expect(heartbeatService._heartbeatsCache).to.deep.equal({ + heartbeats: [] + }); } }); it(`triggerHeartbeat() writes new heartbeats and retains old ones newer than 30 days`, async () => { userAgentString = USER_AGENT_STRING_2; clock.tick(3 * 24 * 60 * 60 * 1000); await heartbeatService.triggerHeartbeat(); - if (isIndexedDBAvailable()) { - expect(writeStub).to.be.calledWith([ - // The first entry exceeds the 30 day retention limit. - mockIndexedDBHeartbeats[1], - { userAgent: USER_AGENT_STRING_2, date: '1970-01-04' } - ]); + if (firebaseUtil.isIndexedDBAvailable()) { + expect(writeStub).to.be.calledWith({ + heartbeats: [ + // The first entry exceeds the 30 day retention limit. + mockIndexedDBHeartbeats[1], + { agent: USER_AGENT_STRING_2, date: '1970-01-04' } + ] + }); } else { - expect(writeStub).to.be.calledWith([ - { userAgent: USER_AGENT_STRING_2, date: '1970-01-04' } - ]); + expect(writeStub).to.be.calledWith({ + heartbeats: [{ agent: USER_AGENT_STRING_2, date: '1970-01-04' }] + }); } }); it('getHeartbeatHeaders() gets stored heartbeats and clears heartbeats', async () => { - const deleteStub = stub(heartbeatService._storage, 'deleteAll'); const heartbeatHeaders = firebaseUtil.base64Decode( await heartbeatService.getHeartbeatsHeader() ); - if (isIndexedDBAvailable()) { + if (firebaseUtil.isIndexedDBAvailable()) { expect(heartbeatHeaders).to.include('old-user-agent'); expect(heartbeatHeaders).to.include('1969-12-31'); } expect(heartbeatHeaders).to.include(USER_AGENT_STRING_2); expect(heartbeatHeaders).to.include('1970-01-04'); expect(heartbeatHeaders).to.include(`"version":2`); - expect(heartbeatService._heartbeatsCache).to.equal(null); + expect(heartbeatService._heartbeatsCache?.heartbeats).to.be.empty; + expect(writeStub).to.be.calledWith({ + lastSentHeartbeatDate: '1970-01-01', + heartbeats: [] + }); const emptyHeaders = await heartbeatService.getHeartbeatsHeader(); expect(emptyHeaders).to.equal(''); - expect(deleteStub).to.be.called; + }); + }); + + describe('If IndexedDB records that a header was sent today', () => { + let heartbeatService: HeartbeatServiceImpl; + let writeStub: SinonStub; + const userAgentString = USER_AGENT_STRING_1; + const mockIndexedDBHeartbeats = [ + // Chosen so one will exceed 30 day limit and one will not. + { + agent: 'old-user-agent', + date: '1969-12-01' + }, + { + agent: 'old-user-agent', + date: '1969-12-31' + } + ]; + before(() => { + const container = new ComponentContainer('heartbeatTestContainer'); + container.addComponent( + new Component( + 'app', + () => + ({ + options: { appId: 'an-app-id' }, + name: 'an-app-name' + } as FirebaseApp), + ComponentType.VERSION + ) + ); + container.addComponent( + new Component( + 'platform-logger', + () => ({ getPlatformInfoString: () => userAgentString }), + ComponentType.VERSION + ) + ); + stub(indexedDb, 'readHeartbeatsFromIndexedDB').resolves({ + lastSentHeartbeatDate: '1970-01-01', + heartbeats: [...mockIndexedDBHeartbeats] + }); + heartbeatService = new HeartbeatServiceImpl(container); + }); + beforeEach(() => { + useFakeTimers(); + writeStub = stub(heartbeatService._storage, 'overwrite'); + }); + it(`new heartbeat service reads from indexedDB cache`, async () => { + const promiseResult = await heartbeatService._heartbeatsCachePromise; + if (firebaseUtil.isIndexedDBAvailable()) { + expect(promiseResult).to.deep.equal({ + lastSentHeartbeatDate: '1970-01-01', + heartbeats: mockIndexedDBHeartbeats + }); + expect(heartbeatService._heartbeatsCache).to.deep.equal({ + lastSentHeartbeatDate: '1970-01-01', + heartbeats: mockIndexedDBHeartbeats + }); + } else { + // In Node or other no-indexed-db environments it will fail the + // `canUseIndexedDb` check and return an empty array. + expect(promiseResult).to.deep.equal({ + heartbeats: [] + }); + expect(heartbeatService._heartbeatsCache).to.deep.equal({ + heartbeats: [] + }); + } + }); + it(`triggerHeartbeat() will skip storing new data`, async () => { + await heartbeatService.triggerHeartbeat(); + expect(writeStub).to.not.be.called; + if (firebaseUtil.isIndexedDBAvailable()) { + expect(heartbeatService._heartbeatsCache?.heartbeats).to.deep.equal( + mockIndexedDBHeartbeats + ); + } }); }); describe('countBytes()', () => { it('counts how many bytes there will be in a stringified, encoded header', () => { const heartbeats = [ - { userAgent: generateUserAgentString(1), dates: generateDates(1) }, - { userAgent: generateUserAgentString(3), dates: generateDates(2) } + { agent: generateUserAgentString(1), dates: generateDates(1) }, + { agent: generateUserAgentString(3), dates: generateDates(2) } ]; let size: number = 0; - const headerString = base64Encode( + const headerString = firebaseUtil.base64urlEncodeWithoutPadding( JSON.stringify({ version: 2, heartbeats }) ); // Use independent methods to validate our byte count method matches. @@ -272,7 +361,7 @@ describe('HeartbeatServiceImpl', () => { describe('_extractHeartbeatsForHeader()', () => { it('returns empty heartbeatsToKeep if it cannot get under maxSize', () => { const heartbeats = [ - { userAgent: generateUserAgentString(1), date: '2022-01-01' } + { agent: generateUserAgentString(1), date: '2022-01-01' } ]; const { unsentEntries, heartbeatsToSend } = extractHeartbeatsForHeader( heartbeats, @@ -283,11 +372,11 @@ describe('HeartbeatServiceImpl', () => { }); it('splits heartbeats array', () => { const heartbeats = [ - { userAgent: generateUserAgentString(20), date: '2022-01-01' }, - { userAgent: generateUserAgentString(4), date: '2022-01-02' } + { agent: generateUserAgentString(20), date: '2022-01-01' }, + { agent: generateUserAgentString(4), date: '2022-01-02' } ]; const sizeWithHeartbeat0Only = countBytes([ - { userAgent: heartbeats[0].userAgent, dates: [heartbeats[0].date] } + { agent: heartbeats[0].agent, dates: [heartbeats[0].date] } ]); const { unsentEntries, heartbeatsToSend } = extractHeartbeatsForHeader( heartbeats, @@ -299,12 +388,12 @@ describe('HeartbeatServiceImpl', () => { it('splits the first heartbeat if needed', () => { const uaString = generateUserAgentString(20); const heartbeats = [ - { userAgent: uaString, date: '2022-01-01' }, - { userAgent: uaString, date: '2022-01-02' }, - { userAgent: uaString, date: '2022-01-03' } + { agent: uaString, date: '2022-01-01' }, + { agent: uaString, date: '2022-01-02' }, + { agent: uaString, date: '2022-01-03' } ]; const sizeWithHeartbeat0Only = countBytes([ - { userAgent: heartbeats[0].userAgent, dates: [heartbeats[0].date] } + { agent: heartbeats[0].agent, dates: [heartbeats[0].date] } ]); const { unsentEntries, heartbeatsToSend } = extractHeartbeatsForHeader( heartbeats, diff --git a/packages/app/src/heartbeatService.ts b/packages/app/src/heartbeatService.ts index 88ada8c9cf9..0c15460ba61 100644 --- a/packages/app/src/heartbeatService.ts +++ b/packages/app/src/heartbeatService.ts @@ -17,12 +17,11 @@ import { ComponentContainer } from '@firebase/component'; import { - base64Encode, + base64urlEncodeWithoutPadding, isIndexedDBAvailable, validateIndexedDBOpenable } from '@firebase/util'; import { - deleteHeartbeatsFromIndexedDB, readHeartbeatsFromIndexedDB, writeHeartbeatsToIndexedDB } from './indexeddb'; @@ -30,6 +29,7 @@ import { FirebaseApp } from './public-types'; import { HeartbeatsByUserAgent, HeartbeatService, + HeartbeatsInIndexedDB, HeartbeatStorage, SingleDateHeartbeat } from './types'; @@ -54,7 +54,7 @@ export class HeartbeatServiceImpl implements HeartbeatService { * be kept in sync with indexedDB. * Leave public for easier testing. */ - _heartbeatsCache: SingleDateHeartbeat[] | null = null; + _heartbeatsCache: HeartbeatsInIndexedDB | null = null; /** * the initialization promise for populating heartbeatCache. @@ -62,7 +62,7 @@ export class HeartbeatServiceImpl implements HeartbeatService { * (hearbeatsCache == null), it should wait for this promise * Leave public for easier testing. */ - _heartbeatsCachePromise: Promise; + _heartbeatsCachePromise: Promise; constructor(private readonly container: ComponentContainer) { const app = this.container.getProvider('app').getImmediate(); this._storage = new HeartbeatStorageImpl(app); @@ -86,24 +86,26 @@ export class HeartbeatServiceImpl implements HeartbeatService { // This is the "Firebase user agent" string from the platform logger // service, not the browser user agent. - const userAgent = platformLogger.getPlatformInfoString(); + const agent = platformLogger.getPlatformInfoString(); const date = getUTCDateString(); if (this._heartbeatsCache === null) { this._heartbeatsCache = await this._heartbeatsCachePromise; } + // Do not store a heartbeat if one is already stored for this day + // or if a header has already been sent today. if ( - this._heartbeatsCache.some( + this._heartbeatsCache.lastSentHeartbeatDate === date || + this._heartbeatsCache.heartbeats.some( singleDateHeartbeat => singleDateHeartbeat.date === date ) ) { - // Do not store a heartbeat if one is already stored for this day. return; } else { // There is no entry for this date. Create one. - this._heartbeatsCache.push({ date, userAgent }); + this._heartbeatsCache.heartbeats.push({ date, agent }); } // Remove entries older than 30 days. - this._heartbeatsCache = this._heartbeatsCache.filter( + this._heartbeatsCache.heartbeats = this._heartbeatsCache.heartbeats.filter( singleDateHeartbeat => { const hbTimestamp = new Date(singleDateHeartbeat.date).valueOf(); const now = Date.now(); @@ -117,34 +119,41 @@ export class HeartbeatServiceImpl implements HeartbeatService { * Returns a base64 encoded string which can be attached to the heartbeat-specific header directly. * It also clears all heartbeats from memory as well as in IndexedDB. * - * NOTE: It will read heartbeats from the heartbeatsCache, instead of from indexedDB to reduce latency + * NOTE: Consuming product SDKs should not send the header if this method + * returns an empty string. */ async getHeartbeatsHeader(): Promise { if (this._heartbeatsCache === null) { await this._heartbeatsCachePromise; } - // If it's still null, it's been cleared and has not been repopulated. - if (this._heartbeatsCache === null) { + // If it's still null or the array is empty, there is no data to send. + if ( + this._heartbeatsCache === null || + this._heartbeatsCache.heartbeats.length === 0 + ) { return ''; } + const date = getUTCDateString(); // Extract as many heartbeats from the cache as will fit under the size limit. const { heartbeatsToSend, unsentEntries } = extractHeartbeatsForHeader( - this._heartbeatsCache + this._heartbeatsCache.heartbeats ); - const headerString = base64Encode( + const headerString = base64urlEncodeWithoutPadding( JSON.stringify({ version: 2, heartbeats: heartbeatsToSend }) ); + // Store last sent date to prevent another being logged/sent for the same day. + this._heartbeatsCache.lastSentHeartbeatDate = date; if (unsentEntries.length > 0) { // Store any unsent entries if they exist. - this._heartbeatsCache = unsentEntries; - // This seems more likely than deleteAll (below) to lead to some odd state + this._heartbeatsCache.heartbeats = unsentEntries; + // This seems more likely than emptying the array (below) to lead to some odd state // since the cache isn't empty and this will be called again on the next request, // and is probably safest if we await it. await this._storage.overwrite(this._heartbeatsCache); } else { - this._heartbeatsCache = null; + this._heartbeatsCache.heartbeats = []; // Do not wait for this, to reduce latency. - void this._storage.deleteAll(); + void this._storage.overwrite(this._heartbeatsCache); } return headerString; } @@ -171,12 +180,12 @@ export function extractHeartbeatsForHeader( for (const singleDateHeartbeat of heartbeatsCache) { // Look for an existing entry with the same user agent. const heartbeatEntry = heartbeatsToSend.find( - hb => hb.userAgent === singleDateHeartbeat.userAgent + hb => hb.agent === singleDateHeartbeat.agent ); if (!heartbeatEntry) { // If no entry for this user agent exists, create one. heartbeatsToSend.push({ - userAgent: singleDateHeartbeat.userAgent, + agent: singleDateHeartbeat.agent, dates: [singleDateHeartbeat.date] }); if (countBytes(heartbeatsToSend) > maxSize) { @@ -221,59 +230,48 @@ export class HeartbeatStorageImpl implements HeartbeatStorage { /** * Read all heartbeats. */ - async read(): Promise { + async read(): Promise { const canUseIndexedDB = await this._canUseIndexedDBPromise; if (!canUseIndexedDB) { - return []; + return { heartbeats: [] }; } else { const idbHeartbeatObject = await readHeartbeatsFromIndexedDB(this.app); - return idbHeartbeatObject?.heartbeats || []; + return idbHeartbeatObject || { heartbeats: [] }; } } // overwrite the storage with the provided heartbeats - async overwrite(heartbeats: SingleDateHeartbeat[]): Promise { - const canUseIndexedDB = await this._canUseIndexedDBPromise; - if (!canUseIndexedDB) { - return; - } else { - return writeHeartbeatsToIndexedDB(this.app, { heartbeats }); - } - } - // add heartbeats - async add(heartbeats: SingleDateHeartbeat[]): Promise { + async overwrite(heartbeatsObject: HeartbeatsInIndexedDB): Promise { const canUseIndexedDB = await this._canUseIndexedDBPromise; if (!canUseIndexedDB) { return; } else { - const existingHeartbeats = await this.read(); + const existingHeartbeatsObject = await this.read(); return writeHeartbeatsToIndexedDB(this.app, { - heartbeats: [...existingHeartbeats, ...heartbeats] + lastSentHeartbeatDate: + heartbeatsObject.lastSentHeartbeatDate ?? + existingHeartbeatsObject.lastSentHeartbeatDate, + heartbeats: heartbeatsObject.heartbeats }); } } - // delete heartbeats - async delete(heartbeats: SingleDateHeartbeat[]): Promise { + // add heartbeats + async add(heartbeatsObject: HeartbeatsInIndexedDB): Promise { const canUseIndexedDB = await this._canUseIndexedDBPromise; if (!canUseIndexedDB) { return; } else { - const existingHeartbeats = await this.read(); + const existingHeartbeatsObject = await this.read(); return writeHeartbeatsToIndexedDB(this.app, { - heartbeats: existingHeartbeats.filter( - existingHeartbeat => !heartbeats.includes(existingHeartbeat) - ) + lastSentHeartbeatDate: + heartbeatsObject.lastSentHeartbeatDate ?? + existingHeartbeatsObject.lastSentHeartbeatDate, + heartbeats: [ + ...existingHeartbeatsObject.heartbeats, + ...heartbeatsObject.heartbeats + ] }); } } - // delete all heartbeats - async deleteAll(): Promise { - const canUseIndexedDB = await this._canUseIndexedDBPromise; - if (!canUseIndexedDB) { - return; - } else { - return deleteHeartbeatsFromIndexedDB(this.app); - } - } } /** @@ -283,7 +281,7 @@ export class HeartbeatStorageImpl implements HeartbeatStorage { */ export function countBytes(heartbeatsCache: HeartbeatsByUserAgent[]): number { // base64 has a restricted set of characters, all of which should be 1 byte. - return base64Encode( + return base64urlEncodeWithoutPadding( // heartbeatsCache wrapper properties JSON.stringify({ version: 2, heartbeats: heartbeatsCache }) ).length; diff --git a/packages/app/src/indexeddb.ts b/packages/app/src/indexeddb.ts index 5bdaad0b1b3..96c1b208bd9 100644 --- a/packages/app/src/indexeddb.ts +++ b/packages/app/src/indexeddb.ts @@ -78,21 +78,6 @@ export async function writeHeartbeatsToIndexedDB( } } -export async function deleteHeartbeatsFromIndexedDB( - app: FirebaseApp -): Promise { - try { - const db = await getDbPromise(); - const tx = db.transaction(STORE_NAME, 'readwrite'); - await tx.objectStore(STORE_NAME).delete(computeKey(app)); - return tx.complete; - } catch (e) { - throw ERROR_FACTORY.create(AppError.STORAGE_DELETE, { - originalErrorMessage: e.message - }); - } -} - function computeKey(app: FirebaseApp): string { return `${app.name}!${app.options.appId}`; } diff --git a/packages/app/src/types.ts b/packages/app/src/types.ts index 2b47c967382..9b78fb7b02c 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -42,28 +42,25 @@ export interface HeartbeatService { // Heartbeats grouped by the same user agent string export interface HeartbeatsByUserAgent { - userAgent: string; + agent: string; dates: string[]; } export interface SingleDateHeartbeat { - userAgent: string; + agent: string; date: string; } export interface HeartbeatStorage { // overwrite the storage with the provided heartbeats - overwrite(heartbeats: SingleDateHeartbeat[]): Promise; + overwrite(heartbeats: HeartbeatsInIndexedDB): Promise; // add heartbeats - add(heartbeats: SingleDateHeartbeat[]): Promise; - // delete heartbeats - delete(heartbeats: SingleDateHeartbeat[]): Promise; - // delete all heartbeats - deleteAll(): Promise; + add(heartbeats: HeartbeatsInIndexedDB): Promise; // read all heartbeats - read(): Promise; + read(): Promise; } export interface HeartbeatsInIndexedDB { + lastSentHeartbeatDate?: string; heartbeats: SingleDateHeartbeat[]; }