diff --git a/.changeset/smart-crabs-warn.md b/.changeset/smart-crabs-warn.md new file mode 100644 index 00000000000..604a01f2a89 --- /dev/null +++ b/.changeset/smart-crabs-warn.md @@ -0,0 +1,6 @@ +--- +"@firebase/database": patch +--- + +Fix issue with how get results for filtered queries are added to cache. +Fix issue with events not getting propagated to listeners by get. diff --git a/packages/database-compat/test/info.test.ts b/packages/database-compat/test/info.test.ts index 45afd66f90e..6832488bbf3 100644 --- a/packages/database-compat/test/info.test.ts +++ b/packages/database-compat/test/info.test.ts @@ -130,8 +130,7 @@ describe('.info Tests', function () { await ea.promise; - expect(typeof offsets[0]).to.equal('number'); - expect(offsets[0]).not.to.be.greaterThan(0); + expect(offsets[0]).to.be.a('number'); // Make sure push still works ref.push(); diff --git a/packages/database/src/api/Reference_impl.ts b/packages/database/src/api/Reference_impl.ts index 3a54a6ec725..97e20ef635d 100644 --- a/packages/database/src/api/Reference_impl.ts +++ b/packages/database/src/api/Reference_impl.ts @@ -39,13 +39,13 @@ import { parseRepoInfo } from '../core/util/libs/parser'; import { nextPushId } from '../core/util/NextPushId'; import { Path, - pathChild, pathEquals, pathGetBack, pathGetFront, - pathIsEmpty, + pathChild, pathParent, - pathToUrlEncodedString + pathToUrlEncodedString, + pathIsEmpty } from '../core/util/Path'; import { fatal, @@ -246,7 +246,6 @@ function validateLimit(params: QueryParams) { ); } } - /** * @internal */ @@ -465,6 +464,7 @@ export class DataSnapshot { return this._node.val(); } } + /** * * Returns a `Reference` representing the location in the Database @@ -525,7 +525,6 @@ export function refFromURL(db: Database, url: string): DatabaseReference { return ref(db, parsedURL.path.toString()); } - /** * Gets a `Reference` for the location at the specified relative path. * @@ -811,7 +810,9 @@ export function update(ref: DatabaseReference, values: object): Promise { */ export function get(query: Query): Promise { query = getModularInstance(query) as QueryImpl; - return repoGetValue(query._repo, query).then(node => { + const callbackContext = new CallbackContext(() => {}); + const container = new ValueEventRegistration(callbackContext); + return repoGetValue(query._repo, query, container).then(node => { return new DataSnapshot( node, new ReferenceImpl(query._repo, query._path), @@ -819,7 +820,6 @@ export function get(query: Query): Promise { ); }); } - /** * Represents registration for 'value' events. */ diff --git a/packages/database/src/core/Repo.ts b/packages/database/src/core/Repo.ts index 3c2a457ba59..2d76af39a08 100644 --- a/packages/database/src/core/Repo.ts +++ b/packages/database/src/core/Repo.ts @@ -24,6 +24,8 @@ import { stringify } from '@firebase/util'; +import { ValueEventRegistration } from '../api/Reference_impl'; + import { AppCheckTokenProvider } from './AppCheckTokenProvider'; import { AuthTokenProvider } from './AuthTokenProvider'; import { PersistentConnection } from './PersistentConnection'; @@ -61,7 +63,7 @@ import { syncTreeCalcCompleteEventCache, syncTreeGetServerValue, syncTreeRemoveEventRegistration, - syncTreeRegisterQuery + syncTreeTagForQuery } from './SyncTree'; import { Indexable } from './util/misc'; import { @@ -452,14 +454,18 @@ function repoGetNextWriteId(repo: Repo): number { * belonging to active listeners. If they are found, such values * are considered to be the most up-to-date. * - * If the client is not connected, this method will try to - * establish a connection and request the value for `query`. If - * the client is not able to retrieve the query result, it reports - * an error. + * If the client is not connected, this method will wait until the + * repo has established a connection and then request the value for `query`. + * If the client is not able to retrieve the query result for another reason, + * it reports an error. * * @param query - The query to surface a value for. */ -export function repoGetValue(repo: Repo, query: QueryContext): Promise { +export function repoGetValue( + repo: Repo, + query: QueryContext, + eventRegistration: ValueEventRegistration +): Promise { // Only active queries are cached. There is no persisted cache. const cached = syncTreeGetServerValue(repo.serverSyncTree_, query); if (cached != null) { @@ -470,32 +476,57 @@ export function repoGetValue(repo: Repo, query: QueryContext): Promise { const node = nodeFromJSON(payload).withIndex( query._queryParams.getIndex() ); - // if this is a filtered query, then overwrite at path + /** + * Below we simulate the actions of an `onlyOnce` `onValue()` event where: + * Add an event registration, + * Update data at the path, + * Raise any events, + * Cleanup the SyncTree + */ + syncTreeAddEventRegistration( + repo.serverSyncTree_, + query, + eventRegistration, + true + ); + let events: Event[]; if (query._queryParams.loadsAllData()) { - syncTreeApplyServerOverwrite(repo.serverSyncTree_, query._path, node); + events = syncTreeApplyServerOverwrite( + repo.serverSyncTree_, + query._path, + node + ); } else { - // Simulate `syncTreeAddEventRegistration` without events/listener setup. - // We do this (along with the syncTreeRemoveEventRegistration` below) so that - // `repoGetValue` results have the same cache effects as initial listener(s) - // updates. - const tag = syncTreeRegisterQuery(repo.serverSyncTree_, query); - syncTreeApplyTaggedQueryOverwrite( + const tag = syncTreeTagForQuery(repo.serverSyncTree_, query); + events = syncTreeApplyTaggedQueryOverwrite( repo.serverSyncTree_, query._path, node, tag ); - // Call `syncTreeRemoveEventRegistration` with a null event registration, since there is none. - // Note: The below code essentially unregisters the query and cleans up any views/syncpoints temporarily created above. } - const cancels = syncTreeRemoveEventRegistration( + /* + * We need to raise events in the scenario where `get()` is called at a parent path, and + * while the `get()` is pending, `onValue` is called at a child location. While get() is waiting + * for the data, `onValue` will register a new event. Then, get() will come back, and update the syncTree + * and its corresponding serverCache, including the child location where `onValue` is called. Then, + * `onValue` will receive the event from the server, but look at the syncTree and see that the data received + * from the server is already at the SyncPoint, and so the `onValue` callback will never get fired. + * Calling `eventQueueRaiseEventsForChangedPath()` is the correct way to propagate the events and + * ensure the corresponding child events will get fired. + */ + eventQueueRaiseEventsForChangedPath( + repo.eventQueue_, + query._path, + events + ); + syncTreeRemoveEventRegistration( repo.serverSyncTree_, query, - null + eventRegistration, + null, + true ); - if (cancels.length > 0) { - repoLog(repo, 'unexpected cancel events in repoGetValue'); - } return node; }, err => { diff --git a/packages/database/src/core/SyncTree.ts b/packages/database/src/core/SyncTree.ts index bffb4aeb104..93387a2f27f 100644 --- a/packages/database/src/core/SyncTree.ts +++ b/packages/database/src/core/SyncTree.ts @@ -318,13 +318,16 @@ export function syncTreeApplyTaggedListenComplete( * * @param eventRegistration - If null, all callbacks are removed. * @param cancelError - If a cancelError is provided, appropriate cancel events will be returned. + * @param skipListenerDedup - When performing a `get()`, we don't add any new listeners, so no + * deduping needs to take place. This flag allows toggling of that behavior * @returns Cancel events, if cancelError was provided. */ export function syncTreeRemoveEventRegistration( syncTree: SyncTree, query: QueryContext, eventRegistration: EventRegistration | null, - cancelError?: Error + cancelError?: Error, + skipListenerDedup = false ): Event[] { // Find the syncPoint first. Then deal with whether or not it has matching listeners const path = query._path; @@ -347,72 +350,77 @@ export function syncTreeRemoveEventRegistration( if (syncPointIsEmpty(maybeSyncPoint)) { syncTree.syncPointTree_ = syncTree.syncPointTree_.remove(path); } + const removed = removedAndEvents.removed; cancelEvents = removedAndEvents.events; - // We may have just removed one of many listeners and can short-circuit this whole process - // We may also not have removed a default listener, in which case all of the descendant listeners should already be - // properly set up. - // - // Since indexed queries can shadow if they don't have other query constraints, check for loadsAllData(), instead of - // queryId === 'default' - const removingDefault = - -1 !== - removed.findIndex(query => { - return query._queryParams.loadsAllData(); - }); - const covered = syncTree.syncPointTree_.findOnPath( - path, - (relativePath, parentSyncPoint) => - syncPointHasCompleteView(parentSyncPoint) - ); - if (removingDefault && !covered) { - const subtree = syncTree.syncPointTree_.subtree(path); - // There are potentially child listeners. Determine what if any listens we need to send before executing the - // removal - if (!subtree.isEmpty()) { - // We need to fold over our subtree and collect the listeners to send - const newViews = syncTreeCollectDistinctViewsForSubTree_(subtree); + if (!skipListenerDedup) { + /** + * We may have just removed one of many listeners and can short-circuit this whole process + * We may also not have removed a default listener, in which case all of the descendant listeners should already be + * properly set up. + */ + + // Since indexed queries can shadow if they don't have other query constraints, check for loadsAllData(), instead of + // queryId === 'default' + const removingDefault = + -1 !== + removed.findIndex(query => { + return query._queryParams.loadsAllData(); + }); + const covered = syncTree.syncPointTree_.findOnPath( + path, + (relativePath, parentSyncPoint) => + syncPointHasCompleteView(parentSyncPoint) + ); - // Ok, we've collected all the listens we need. Set them up. - for (let i = 0; i < newViews.length; ++i) { - const view = newViews[i], - newQuery = view.query; - const listener = syncTreeCreateListenerForView_(syncTree, view); - syncTree.listenProvider_.startListening( - syncTreeQueryForListening_(newQuery), - syncTreeTagForQuery_(syncTree, newQuery), - listener.hashFn, - listener.onComplete - ); + if (removingDefault && !covered) { + const subtree = syncTree.syncPointTree_.subtree(path); + // There are potentially child listeners. Determine what if any listens we need to send before executing the + // removal + if (!subtree.isEmpty()) { + // We need to fold over our subtree and collect the listeners to send + const newViews = syncTreeCollectDistinctViewsForSubTree_(subtree); + + // Ok, we've collected all the listens we need. Set them up. + for (let i = 0; i < newViews.length; ++i) { + const view = newViews[i], + newQuery = view.query; + const listener = syncTreeCreateListenerForView_(syncTree, view); + syncTree.listenProvider_.startListening( + syncTreeQueryForListening_(newQuery), + syncTreeTagForQuery(syncTree, newQuery), + listener.hashFn, + listener.onComplete + ); + } } - } else { - // There's nothing below us, so nothing we need to start listening on + // Otherwise there's nothing below us, so nothing we need to start listening on } - } - // If we removed anything and we're not covered by a higher up listen, we need to stop listening on this query - // The above block has us covered in terms of making sure we're set up on listens lower in the tree. - // Also, note that if we have a cancelError, it's already been removed at the provider level. - if (!covered && removed.length > 0 && !cancelError) { - // If we removed a default, then we weren't listening on any of the other queries here. Just cancel the one - // default. Otherwise, we need to iterate through and cancel each individual query - if (removingDefault) { - // We don't tag default listeners - const defaultTag: number | null = null; - syncTree.listenProvider_.stopListening( - syncTreeQueryForListening_(query), - defaultTag - ); - } else { - removed.forEach((queryToRemove: QueryContext) => { - const tagToRemove = syncTree.queryToTagMap.get( - syncTreeMakeQueryKey_(queryToRemove) - ); + // If we removed anything and we're not covered by a higher up listen, we need to stop listening on this query + // The above block has us covered in terms of making sure we're set up on listens lower in the tree. + // Also, note that if we have a cancelError, it's already been removed at the provider level. + if (!covered && removed.length > 0 && !cancelError) { + // If we removed a default, then we weren't listening on any of the other queries here. Just cancel the one + // default. Otherwise, we need to iterate through and cancel each individual query + if (removingDefault) { + // We don't tag default listeners + const defaultTag: number | null = null; syncTree.listenProvider_.stopListening( - syncTreeQueryForListening_(queryToRemove), - tagToRemove + syncTreeQueryForListening_(query), + defaultTag ); - }); + } else { + removed.forEach((queryToRemove: QueryContext) => { + const tagToRemove = syncTree.queryToTagMap.get( + syncTreeMakeQueryKey_(queryToRemove) + ); + syncTree.listenProvider_.stopListening( + syncTreeQueryForListening_(queryToRemove), + tagToRemove + ); + }); + } } } // Now, clear all of the tags we're tracking for the removed listens @@ -423,38 +431,6 @@ export function syncTreeRemoveEventRegistration( return cancelEvents; } -/** - * This function was added to support non-listener queries, - * specifically for use in repoGetValue. It sets up all the same - * local cache data-structures (SyncPoint + View) that are - * needed for listeners without installing an event registration. - * If `query` is not `loadsAllData`, it will also provision a tag for - * the query so that query results can be merged into the sync - * tree using existing logic for tagged listener queries. - * - * @param syncTree - Synctree to add the query to. - * @param query - Query to register - * @returns tag as a string if query is not a default query, null if query is not. - */ -export function syncTreeRegisterQuery(syncTree: SyncTree, query: QueryContext) { - const { syncPoint, serverCache, writesCache, serverCacheComplete } = - syncTreeRegisterSyncPoint(query, syncTree); - const view = syncPointGetView( - syncPoint, - query, - writesCache, - serverCache, - serverCacheComplete - ); - if (!syncPoint.views.has(query._queryIdentifier)) { - syncPoint.views.set(query._queryIdentifier, view); - } - if (!query._queryParams.loadsAllData()) { - return syncTreeTagForQuery_(syncTree, query); - } - return null; -} - /** * Apply new server data for the specified tagged query. * @@ -515,14 +491,16 @@ export function syncTreeApplyTaggedQueryMerge( } /** - * Creates a new syncpoint for a query and creates a tag if the view doesn't exist. - * Extracted from addEventRegistration to allow `repoGetValue` to properly set up the SyncTree - * without actually listening on a query. + * Add an event callback for the specified query. + * + * @returns Events to raise. */ -export function syncTreeRegisterSyncPoint( +export function syncTreeAddEventRegistration( + syncTree: SyncTree, query: QueryContext, - syncTree: SyncTree -) { + eventRegistration: EventRegistration, + skipSetupListener = false +): Event[] { const path = query._path; let serverCache: Node | null = null; @@ -581,35 +559,6 @@ export function syncTreeRegisterSyncPoint( syncTree.tagToQueryMap.set(tag, queryKey); } const writesCache = writeTreeChildWrites(syncTree.pendingWriteTree_, path); - return { - syncPoint, - writesCache, - serverCache, - serverCacheComplete, - foundAncestorDefaultView, - viewAlreadyExists - }; -} - -/** - * Add an event callback for the specified query. - * - * @returns Events to raise. - */ -export function syncTreeAddEventRegistration( - syncTree: SyncTree, - query: QueryContext, - eventRegistration: EventRegistration -): Event[] { - const { - syncPoint, - serverCache, - writesCache, - serverCacheComplete, - viewAlreadyExists, - foundAncestorDefaultView - } = syncTreeRegisterSyncPoint(query, syncTree); - let events = syncPointAddEventRegistration( syncPoint, query, @@ -618,7 +567,7 @@ export function syncTreeAddEventRegistration( serverCache, serverCacheComplete ); - if (!viewAlreadyExists && !foundAncestorDefaultView) { + if (!viewAlreadyExists && !foundAncestorDefaultView && !skipSetupListener) { const view = syncPointViewForQuery(syncPoint, query); events = events.concat(syncTreeSetupListener_(syncTree, query, view)); } @@ -831,7 +780,7 @@ function syncTreeCreateListenerForView_( view: View ): { hashFn(): string; onComplete(a: string, b?: unknown): Event[] } { const query = view.query; - const tag = syncTreeTagForQuery_(syncTree, query); + const tag = syncTreeTagForQuery(syncTree, query); return { hashFn: () => { @@ -863,7 +812,7 @@ function syncTreeCreateListenerForView_( /** * Return the tag associated with the given query. */ -function syncTreeTagForQuery_( +export function syncTreeTagForQuery( syncTree: SyncTree, query: QueryContext ): number | null { @@ -995,8 +944,9 @@ function syncTreeSetupListener_( view: View ): Event[] { const path = query._path; - const tag = syncTreeTagForQuery_(syncTree, query); + const tag = syncTreeTagForQuery(syncTree, query); const listener = syncTreeCreateListenerForView_(syncTree, view); + const events = syncTree.listenProvider_.startListening( syncTreeQueryForListening_(query), tag, @@ -1043,7 +993,7 @@ function syncTreeSetupListener_( const queryToStop = queriesToStop[i]; syncTree.listenProvider_.stopListening( syncTreeQueryForListening_(queryToStop), - syncTreeTagForQuery_(syncTree, queryToStop) + syncTreeTagForQuery(syncTree, queryToStop) ); } } diff --git a/packages/database/test/exp/integration.test.ts b/packages/database/test/exp/integration.test.ts index 3789aac7e46..1e0072cf1ff 100644 --- a/packages/database/test/exp/integration.test.ts +++ b/packages/database/test/exp/integration.test.ts @@ -21,13 +21,15 @@ import { expect, use } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import { + child, get, limitToFirst, onValue, query, refFromURL, set, - startAt + startAt, + orderByKey } from '../../src/api/Reference_impl'; import { getDatabase, @@ -41,8 +43,11 @@ import { EventAccumulatorFactory } from '../helpers/EventAccumulator'; import { DATABASE_ADDRESS, DATABASE_URL, - getUniqueRef, - waitFor + getFreshRepo, + getRWRefs, + waitFor, + waitUntil, + writeAndValidate } from '../helpers/util'; use(chaiAsPromised); @@ -114,92 +119,120 @@ describe('Database@exp Tests', () => { // Tests to make sure onValue's data does not get mutated after calling get it('calls onValue only once after get request with a non-default query', async () => { - const db = getDatabase(defaultApp); - const { ref: testRef } = getUniqueRef(db); + const { readerRef } = getRWRefs(getDatabase(defaultApp)); const queries = [ - query(testRef, limitToFirst(1)), - query(testRef, startAt('child1')), - query(testRef, startAt('child2')), - query(testRef, limitToFirst(2)) + query(readerRef, limitToFirst(1)), + query(readerRef, startAt('child1')), + query(readerRef, startAt('child2')), + query(readerRef, limitToFirst(2)) ]; await Promise.all( queries.map(async q => { const initial = [{ name: 'child1' }, { name: 'child2' }]; const ec = EventAccumulatorFactory.waitsForExactCount(1); - - await set(testRef, initial); - const unsubscribe = onValue(testRef, snapshot => { - ec.addEvent(snapshot.val()); + const writerPath = getFreshRepo(readerRef._path); + await set(writerPath, initial); + const unsubscribe = onValue(readerRef, snapshot => { + ec.addEvent(snapshot); }); await get(q); await waitFor(2000); const [snap] = await ec.promise; - expect(snap).to.deep.equal(initial); + expect(snap.val()).to.deep.equal(initial); unsubscribe(); }) ); }); + it('[smoketest] - calls onValue() listener when get() is called on a parent node', async () => { + // Test that when get() is pending on a parent node, and then onValue is called on a child node, that after the get() comes back, the onValue() listener fires. + const db = getDatabase(defaultApp); + const { readerRef, writerRef } = getRWRefs(db); + await set(writerRef, { + foo1: { + a: 1 + }, + foo2: { + b: 1 + } + }); + await waitUntil(() => { + // Because this is a test reliant on network latency, it can be difficult to reproduce. There are situations when get() resolves immediately, and the above behavior is not observed. + let pending = false; + get(readerRef).then(() => (pending = true)); + return !pending; + }); + const childPath = child(readerRef, 'foo1'); + const ec = EventAccumulatorFactory.waitsForExactCount(1); + onValue(childPath, snapshot => { + ec.addEvent(snapshot.val()); + }); + const events = await ec.promise; + expect(events.length).to.eq(1); + const snapshot = events[0]; + expect(snapshot).to.deep.eq({ a: 1 }); + }); + it('calls onValue and expects no issues with removing the listener', async () => { const db = getDatabase(defaultApp); - const { ref: testRef } = getUniqueRef(db); + const { readerRef, writerRef } = getRWRefs(db); const initial = [{ name: 'child1' }, { name: 'child2' }]; const ea = EventAccumulatorFactory.waitsForExactCount(1); - await set(testRef, initial); - const unsubscribe = onValue(testRef, snapshot => { + await set(writerRef, initial); + const unsubscribe = onValue(readerRef, snapshot => { ea.addEvent(snapshot.val()); }); - await get(query(testRef)); + await get(query(readerRef)); await waitFor(2000); const update = [{ name: 'child1' }, { name: 'child20' }]; unsubscribe(); - await set(testRef, update); + await set(writerRef, update); const [snap1] = await ea.promise; expect(snap1).to.deep.eq(initial); }); it('calls onValue only once after get request with a default query', async () => { const db = getDatabase(defaultApp); - const { ref: testRef } = getUniqueRef(db); + const { readerRef, writerRef } = getRWRefs(db); const initial = [{ name: 'child1' }, { name: 'child2' }]; const ea = EventAccumulatorFactory.waitsForExactCount(1); - await set(testRef, initial); - const unsubscribe = onValue(testRef, snapshot => { - ea.addEvent(snapshot.val()); + await set(writerRef, initial); + const unsubscribe = onValue(readerRef, snapshot => { + ea.addEvent(snapshot); expect(snapshot.val()).to.deep.eq(initial); }); - await get(query(testRef)); + await get(query(readerRef)); await waitFor(2000); const [snap] = await ea.promise; - expect(snap).to.deep.equal(initial); + expect(snap.val()).to.deep.equal(initial); unsubscribe(); }); + it('calls onValue only once after get request with a nested query', async () => { const db = getDatabase(defaultApp); const ea = EventAccumulatorFactory.waitsForExactCount(1); - const { ref: testRef, path } = getUniqueRef(db); + const { readerRef, writerRef } = getRWRefs(db); const initial = { test: { abc: 123 } }; - - await set(testRef, initial); - const unsubscribe = onValue(testRef, snapshot => { - ea.addEvent(snapshot.val()); + await set(writerRef, initial); + const unsubscribe = onValue(readerRef, snapshot => { + ea.addEvent(snapshot); }); - const nestedRef = ref(db, path + '/test'); + const nestedRef = child(readerRef, 'test'); const result = await get(query(nestedRef)); await waitFor(2000); const [snap] = await ea.promise; - expect(snap).to.deep.equal(initial); + expect(snap.val()).to.deep.equal(initial); expect(result.val()).to.deep.eq(initial.test); unsubscribe(); }); it('calls onValue only once after parent get request', async () => { const db = getDatabase(defaultApp); - const { ref: testRef, path } = getUniqueRef(db); + const { readerRef, writerRef } = getRWRefs(db); const ea = EventAccumulatorFactory.waitsForExactCount(1); const initial = { test: { @@ -207,16 +240,16 @@ describe('Database@exp Tests', () => { } }; - await set(testRef, initial); - const nestedRef = ref(db, path + '/test'); + await set(writerRef, initial); + const nestedRef = child(readerRef, 'test'); const unsubscribe = onValue(nestedRef, snapshot => { - ea.addEvent(snapshot.val()); + ea.addEvent(snapshot); }); - const result = await get(query(testRef)); + const result = await get(query(readerRef)); const events = await ea.promise; await waitFor(2000); expect(events.length).to.equal(1); - expect(events[0]).to.deep.eq(initial.test); + expect(events[0].val()).to.deep.eq(initial.test); expect(result.val()).to.deep.equal(initial); unsubscribe(); }); @@ -292,6 +325,103 @@ describe('Database@exp Tests', () => { expect(resolvedData.val()).to.deep.equal(initial); }); + it('resolves get to serverCache when the database is offline', async () => { + const db = getDatabase(defaultApp); + const { writerRef } = getRWRefs(db); + const expected = { + test: 'abc' + }; + await set(writerRef, expected); + goOffline(db); + const result = await get(writerRef); + expect(result.val()).to.deep.eq(expected); + goOnline(db); + }); + + it('resolves get to serverCache when the database is offline and using a parent-level listener', async () => { + const db = getDatabase(defaultApp); + const { readerRef, writerRef } = getRWRefs(db); + const toWrite = { + test: 'def' + }; + const ec = EventAccumulatorFactory.waitsForExactCount(1); + await set(writerRef, toWrite); + onValue(readerRef, snapshot => { + ec.addEvent(snapshot); + }); + await ec.promise; + goOffline(db); + const result = await get(child(readerRef, 'test')); + expect(result.val()).to.deep.eq(toWrite.test); + goOnline(db); + }); + + it('only fires listener once when calling get with limitTo', async () => { + const db = getDatabase(defaultApp); + const { readerRef, writerRef } = getRWRefs(db); + const ec = EventAccumulatorFactory.waitsForExactCount(1); + const toWrite = { + child1: 'test1', + child2: 'test2' + }; + await writeAndValidate(writerRef, readerRef, toWrite, ec); + const q = query(readerRef, limitToFirst(1)); + const snapshot = await get(q); + const expected = { + child1: 'test1' + }; + expect(snapshot.val()).to.deep.eq(expected); + }); + + it('should listen to a disjointed path and get should return the corresponding value', async () => { + const db = getDatabase(defaultApp); + const { readerRef, writerRef } = getRWRefs(db); + const toWrite = { + child1: 'test1', + child2: 'test2', + child3: 'test3' + }; + let ec = EventAccumulatorFactory.waitsForExactCount(1); + await writeAndValidate(writerRef, readerRef, toWrite, ec); + ec = EventAccumulatorFactory.waitsForExactCount(1); + const child1Ref = child(readerRef, 'child1'); + onValue(child1Ref, snapshot => { + ec.addEvent(snapshot); + }); + const otherChildrenQuery = query( + readerRef, + orderByKey(), + startAt('child2') + ); + const expected = { + child2: 'test2', + child3: 'test3' + }; + const [child1Snapshot] = await ec.promise; + expect(child1Snapshot.val()).to.eq('test1'); + const snapshot = await get(otherChildrenQuery); + expect(snapshot.val()).to.deep.eq(expected); + }); + + it('should test startAt get with listener only fires once', async () => { + const db = getDatabase(defaultApp); + const { readerRef, writerRef } = getRWRefs(db); + const expected = { + child1: 'test1', + child2: 'test2', + child3: 'test3' + }; + const ec = EventAccumulatorFactory.waitsForExactCount(1); + await writeAndValidate(writerRef, readerRef, expected, ec); + const q = query(readerRef, orderByKey(), startAt('child2')); + const snapshot = await get(q); + const expectedQRes = { + child2: 'test2', + child3: 'test3' + }; + expect(snapshot.val()).to.deep.eq(expectedQRes); + }); + it('Can listen to transaction changes', async () => { // Repro for https://github.com/firebase/firebase-js-sdk/issues/5195 let latestValue = 0; diff --git a/packages/database/test/helpers/util.ts b/packages/database/test/helpers/util.ts index 7b3a9ff0764..91c627c9a14 100644 --- a/packages/database/test/helpers/util.ts +++ b/packages/database/test/helpers/util.ts @@ -15,10 +15,22 @@ * limitations under the License. */ +import { FirebaseApp, initializeApp } from '@firebase/app'; import { uuidv4 } from '@firebase/util'; +import { expect } from 'chai'; -import { Database, ref } from '../../src'; +import { + Database, + DatabaseReference, + getDatabase, + onValue, + ref, + set +} from '../../src'; import { ConnectionTarget } from '../../src/api/test_access'; +import { Path } from '../../src/core/util/Path'; + +import { EventAccumulator } from './EventAccumulator'; // eslint-disable-next-line @typescript-eslint/no-require-imports export const TEST_PROJECT = require('../../../../config/project.json'); @@ -26,6 +38,17 @@ const EMULATOR_PORT = process.env.RTDB_EMULATOR_PORT; const EMULATOR_NAMESPACE = process.env.RTDB_EMULATOR_NAMESPACE; const USE_EMULATOR = !!EMULATOR_PORT; +let freshRepoId = 0; +const activeFreshApps: FirebaseApp[] = []; +export function getFreshRepo(path: Path) { + const app = initializeApp( + { databaseURL: DATABASE_URL }, + 'ISOLATED_REPO_' + freshRepoId++ + ); + activeFreshApps.push(app); + return ref(getDatabase(app), path.toString()); +} + /* * When running against the emulator, the hostname will be "localhost" rather * than ".firebaseio.com", and so we need to append the namespace @@ -83,5 +106,41 @@ export function waitFor(waitTimeInMS: number) { // Creates a unique reference using uuid export function getUniqueRef(db: Database) { const path = uuidv4(); - return { ref: ref(db, path), path }; + return ref(db, path); +} + +// Get separate Reader and Writer Refs to prevent caching. +export function getRWRefs(db: Database) { + const readerRef = getUniqueRef(db); + const writerRef = getFreshRepo(readerRef._path); + return { readerRef, writerRef }; +} + +// Validates that the ref was successfully written to. +export async function writeAndValidate( + writerRef: DatabaseReference, + readerRef: DatabaseReference, + toWrite: unknown, + ec: EventAccumulator +) { + await set(writerRef, toWrite); + onValue(readerRef, snapshot => { + ec.addEvent(snapshot); + }); + const [snap] = await ec.promise; + expect(snap.val()).to.deep.eq(toWrite); +} + +// Waits until callback function returns true +export async function waitUntil(cb: () => boolean, maxRetries = 5) { + let count = 1; + return new Promise((resolve, reject) => { + if (cb()) { + resolve(true); + } else { + if (count++ === maxRetries) { + reject('waited too many times for conditional to be true'); + } + } + }); }