diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/FirestoreTest.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/FirestoreTest.java index 158ecdf67d0..a6188235b67 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/FirestoreTest.java +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/FirestoreTest.java @@ -50,12 +50,14 @@ import com.google.firebase.firestore.testutil.IntegrationTestUtil; import com.google.firebase.firestore.util.AsyncQueue.TimerId; import com.google.firebase.firestore.util.Logger.Level; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Map; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Semaphore; import org.junit.After; import org.junit.Test; import org.junit.runner.RunWith; @@ -509,6 +511,50 @@ public void testAddingToACollectionYieldsTheCorrectDocumentReference() { assertEquals(data, document.getData()); } + @Test + public void testSnapshotsInSyncListenerFiresAfterListenersInSync() { + Map data = map("foo", 1.0); + CollectionReference collection = testCollection(); + DocumentReference documentReference = waitFor(collection.add(data)); + List events = new ArrayList<>(); + + Semaphore gotInitialSnapshot = new Semaphore(0); + Semaphore done = new Semaphore(0); + + ListenerRegistration listenerRegistration = null; + + documentReference.addSnapshotListener( + (value, error) -> { + events.add("doc"); + gotInitialSnapshot.release(); + }); + waitFor(gotInitialSnapshot); + events.clear(); + + try { + listenerRegistration = + documentReference + .getFirestore() + .addSnapshotsInSyncListener( + () -> { + events.add("snapshots-in-sync"); + if (events.size() == 3) { + // We should have an initial snapshots-in-sync event, then a snapshot event + // for set(), then another event to indicate we're in sync again. + assertEquals( + Arrays.asList("snapshots-in-sync", "doc", "snapshots-in-sync"), events); + done.release(); + } + }); + waitFor(documentReference.set(map("foo", 3.0))); + waitFor(done); + } finally { + if (listenerRegistration != null) { + listenerRegistration.remove(); + } + } + } + @Test public void testQueriesAreValidatedOnClient() { // NOTE: Failure cases are validated in ValidationTest. diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/FirebaseFirestore.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/FirebaseFirestore.java index 9d51cf5af55..1124fc7bb80 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/FirebaseFirestore.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/FirebaseFirestore.java @@ -15,7 +15,9 @@ package com.google.firebase.firestore; import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.firebase.firestore.util.Assert.hardAssert; +import android.app.Activity; import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -30,12 +32,15 @@ import com.google.firebase.firestore.auth.CredentialsProvider; import com.google.firebase.firestore.auth.EmptyCredentialsProvider; import com.google.firebase.firestore.auth.FirebaseAuthCredentialsProvider; +import com.google.firebase.firestore.core.ActivityScope; +import com.google.firebase.firestore.core.AsyncEventListener; import com.google.firebase.firestore.core.DatabaseInfo; import com.google.firebase.firestore.core.FirestoreClient; import com.google.firebase.firestore.local.SQLitePersistence; import com.google.firebase.firestore.model.DatabaseId; import com.google.firebase.firestore.model.ResourcePath; import com.google.firebase.firestore.util.AsyncQueue; +import com.google.firebase.firestore.util.Executors; import com.google.firebase.firestore.util.Logger; import com.google.firebase.firestore.util.Logger.Level; import java.util.concurrent.Executor; @@ -460,6 +465,94 @@ public Task clearPersistence() { return source.getTask(); } + /** + * Attaches a listener for a snapshots-in-sync event. The snapshots-in-sync event indicates that + * all listeners affected by a given change have fired, even if a single server-generated change + * affects multiple listeners. + * + *

NOTE: The snapshots-in-sync event only indicates that listeners are in sync with each other, + * but does not relate to whether those snapshots are in sync with the server. Use + * SnapshotMetadata in the individual listeners to determine if a snapshot is from the cache or + * the server. + * + * @param runnable A callback to be called every time all snapshot listeners are in sync with each + * other. + * @return A registration object that can be used to remove the listener. + */ + ListenerRegistration addSnapshotsInSyncListener(@NonNull Runnable runnable) { + return addSnapshotsInSyncListener(Executors.DEFAULT_CALLBACK_EXECUTOR, runnable); + } + + /** + * Attaches a listener for a snapshots-in-sync event. The snapshots-in-sync event indicates that + * all listeners affected by a given change have fired, even if a single server-generated change + * affects multiple listeners. + * + *

NOTE: The snapshots-in-sync event only indicates that listeners are in sync with each other, + * but does not relate to whether those snapshots are in sync with the server. Use + * SnapshotMetadata in the individual listeners to determine if a snapshot is from the cache or + * the server. + * + * @param activity The activity to scope the listener to. + * @param runnable A callback to be called every time all snapshot listeners are in sync with each + * other. + * @return A registration object that can be used to remove the listener. + */ + @NonNull + ListenerRegistration addSnapshotsInSyncListener(Activity activity, @NonNull Runnable runnable) { + return addSnapshotsInSyncListener(Executors.DEFAULT_CALLBACK_EXECUTOR, activity, runnable); + } + + /** + * Attaches a listener for a snapshots-in-sync event. The snapshots-in-sync event indicates that + * all listeners affected by a given change have fired, even if a single server-generated change + * affects multiple listeners. + * + *

NOTE: The snapshots-in-sync event only indicates that listeners are in sync with each other, + * but does not relate to whether those snapshots are in sync with the server. Use + * SnapshotMetadata in the individual listeners to determine if a snapshot is from the cache or + * the server. + * + * @param executor The executor to use to call the listener. + * @param runnable A callback to be called every time all snapshot listeners are in sync with each + * other. + * @return A registration object that can be used to remove the listener. + */ + @NonNull + ListenerRegistration addSnapshotsInSyncListener(Executor executor, @NonNull Runnable runnable) { + return addSnapshotsInSyncListener(executor, null, runnable); + } + + /** + * Internal helper method to add a snapshotsInSync listener. + * + *

Will be Activity scoped if the activity parameter is non-{@code null}. + * + * @param userExecutor The executor to use to call the listener. + * @param activity Optional activity this listener is scoped to. + * @param runnable A callback to be called every time all snapshot listeners are in sync with each + * other. + * @return A registration object that can be used to remove the listener. + */ + private ListenerRegistration addSnapshotsInSyncListener( + Executor userExecutor, @Nullable Activity activity, @NonNull Runnable runnable) { + ensureClientConfigured(); + EventListener eventListener = + (Void v, FirebaseFirestoreException error) -> { + hardAssert(error == null, "snapshots-in-sync listeners should never get errors."); + runnable.run(); + }; + AsyncEventListener asyncListener = + new AsyncEventListener(userExecutor, eventListener); + client.addSnapshotsInSyncListener(asyncListener); + return ActivityScope.bind( + activity, + () -> { + asyncListener.mute(); + client.removeSnapshotsInSyncListener(asyncListener); + }); + } + FirestoreClient getClient() { return client; } diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/EventManager.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/EventManager.java index ff5b8f64352..09c77c42d34 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/EventManager.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/EventManager.java @@ -14,13 +14,18 @@ package com.google.firebase.firestore.core; +import static com.google.firebase.firestore.util.Assert.hardAssert; + +import com.google.firebase.firestore.EventListener; import com.google.firebase.firestore.core.SyncEngine.SyncEngineCallback; import com.google.firebase.firestore.util.Util; import io.grpc.Status; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; /** * EventManager is responsible for mapping queries to query event listeners. It handles "fan-out." @@ -54,6 +59,8 @@ public static class ListenOptions { private final Map queries; + private final Set> snapshotsInSyncListeners = new HashSet<>(); + private OnlineState onlineState = OnlineState.UNKNOWN; public EventManager(SyncEngine syncEngine) { @@ -81,10 +88,16 @@ public int addQueryListener(QueryListener queryListener) { queryInfo.listeners.add(queryListener); - queryListener.onOnlineStateChanged(onlineState); + // Run global snapshot listeners if a consistent snapshot has been emitted. + boolean raisedEvent = queryListener.onOnlineStateChanged(onlineState); + hardAssert( + !raisedEvent, "onOnlineStateChanged() shouldn't raise an event for brand-new listeners."); if (queryInfo.viewSnapshot != null) { - queryListener.onViewSnapshot(queryInfo.viewSnapshot); + raisedEvent = queryListener.onViewSnapshot(queryInfo.viewSnapshot); + if (raisedEvent) { + raiseSnapshotsInSyncEvent(); + } } if (firstListen) { @@ -109,18 +122,40 @@ public void removeQueryListener(QueryListener listener) { } } + public void addSnapshotsInSyncListener(EventListener listener) { + snapshotsInSyncListeners.add(listener); + listener.onEvent(null, null); + } + + public void removeSnapshotsInSyncListener(EventListener listener) { + snapshotsInSyncListeners.remove(listener); + } + + /** Call all global snapshot listeners that have been set. */ + private void raiseSnapshotsInSyncEvent() { + for (EventListener listener : snapshotsInSyncListeners) { + listener.onEvent(null, null); + } + } + @Override public void onViewSnapshots(List snapshotList) { + boolean raisedEvent = false; for (ViewSnapshot viewSnapshot : snapshotList) { Query query = viewSnapshot.getQuery(); QueryListenersInfo info = queries.get(query); if (info != null) { for (QueryListener listener : info.listeners) { - listener.onViewSnapshot(viewSnapshot); + if (listener.onViewSnapshot(viewSnapshot)) { + raisedEvent = true; + } } info.viewSnapshot = viewSnapshot; } } + if (raisedEvent) { + raiseSnapshotsInSyncEvent(); + } } @Override @@ -136,11 +171,17 @@ public void onError(Query query, Status error) { @Override public void handleOnlineStateChange(OnlineState onlineState) { + boolean raisedEvent = false; this.onlineState = onlineState; for (QueryListenersInfo info : queries.values()) { for (QueryListener listener : info.listeners) { - listener.onOnlineStateChanged(onlineState); + if (listener.onOnlineStateChanged(onlineState)) { + raisedEvent = true; + } } } + if (raisedEvent) { + raiseSnapshotsInSyncEvent(); + } } } diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/FirestoreClient.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/FirestoreClient.java index b62c4e850d2..09560da2ac8 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/FirestoreClient.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/FirestoreClient.java @@ -289,6 +289,18 @@ private void initialize(Context context, User user, boolean usePersistence, long remoteStore.start(); } + public void addSnapshotsInSyncListener(EventListener listener) { + verifyNotTerminated(); + asyncQueue.enqueueAndForget(() -> eventManager.addSnapshotsInSyncListener(listener)); + } + + public void removeSnapshotsInSyncListener(EventListener listener) { + if (isTerminated()) { + return; + } + eventManager.removeSnapshotsInSyncListener(listener); + } + private void verifyNotTerminated() { if (this.isTerminated()) { throw new IllegalStateException("The client has already been terminated"); diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/QueryListener.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/QueryListener.java index fb8d99d3afb..b5201cfda43 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/QueryListener.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/QueryListener.java @@ -59,11 +59,17 @@ public Query getQuery() { return query; } - public void onViewSnapshot(ViewSnapshot newSnapshot) { + /** + * Applies the new ViewSnapshot to this listener, raising a user-facing event if applicable + * (depending on what changed, whether the user has opted into metadata-only changes, etc.). + * Returns true if a user-facing event was indeed raised. + */ + public boolean onViewSnapshot(ViewSnapshot newSnapshot) { hardAssert( !newSnapshot.getChanges().isEmpty() || newSnapshot.didSyncStateChange(), "We got a new snapshot with no changes?"); + boolean raisedEvent = false; if (!options.includeDocumentMetadataChanges) { // Remove the metadata only changes List documentChanges = new ArrayList<>(); @@ -87,23 +93,30 @@ public void onViewSnapshot(ViewSnapshot newSnapshot) { if (!raisedInitialEvent) { if (shouldRaiseInitialEvent(newSnapshot, onlineState)) { raiseInitialEvent(newSnapshot); + raisedEvent = true; } } else if (shouldRaiseEvent(newSnapshot)) { listener.onEvent(newSnapshot, null); + raisedEvent = true; } this.snapshot = newSnapshot; + return raisedEvent; } public void onError(FirebaseFirestoreException error) { listener.onEvent(null, error); } - public void onOnlineStateChanged(OnlineState onlineState) { + /** Returns whether a snapshot was raised. */ + public boolean onOnlineStateChanged(OnlineState onlineState) { this.onlineState = onlineState; + boolean raisedEvent = false; if (snapshot != null && !raisedInitialEvent && shouldRaiseInitialEvent(snapshot, onlineState)) { raiseInitialEvent(snapshot); + raisedEvent = true; } + return raisedEvent; } private boolean shouldRaiseInitialEvent(ViewSnapshot snapshot, OnlineState onlineState) { diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/core/EventManagerTest.java b/firebase-firestore/src/test/java/com/google/firebase/firestore/core/EventManagerTest.java index 02644d365ef..3d8c28b80ec 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/core/EventManagerTest.java +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/core/EventManagerTest.java @@ -118,7 +118,11 @@ public void testWillForwardOnOnlineStateChangedCalls() { QueryListener spy = mock(QueryListener.class); when(spy.getQuery()).thenReturn(query1); - doAnswer(invocation -> events.add(invocation.getArguments()[0])) + doAnswer( + invocation -> { + events.add(invocation.getArguments()[0]); + return false; + }) .when(spy) .onOnlineStateChanged(any()); diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/spec/SpecTestCase.java b/firebase-firestore/src/test/java/com/google/firebase/firestore/spec/SpecTestCase.java index b4816ba1adb..beaab7ae4cf 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/spec/SpecTestCase.java +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/spec/SpecTestCase.java @@ -36,6 +36,8 @@ import com.google.android.gms.tasks.TaskCompletionSource; import com.google.common.collect.Sets; import com.google.firebase.database.collection.ImmutableSortedSet; +import com.google.firebase.firestore.EventListener; +import com.google.firebase.firestore.FirebaseFirestoreException; import com.google.firebase.firestore.auth.User; import com.google.firebase.firestore.core.DocumentViewChange; import com.google.firebase.firestore.core.DocumentViewChange.Type; @@ -187,6 +189,8 @@ public abstract class SpecTestCase implements RemoteStoreCallback { private final List acknowledgedDocs = Collections.synchronizedList(new ArrayList<>()); private final List rejectedDocs = Collections.synchronizedList(new ArrayList<>()); + private List> snapshotsInSyncListeners; + private int snapshotsInSyncEvents = 0; /** An executor to use for test callbacks. */ private final RoboExecutorService backgroundExecutor = new RoboExecutorService(); @@ -249,6 +253,8 @@ protected void specSetUp(JSONObject config) { expectedLimboDocs = new HashSet<>(); expectedActiveTargets = new HashMap<>(); + + snapshotsInSyncListeners = Collections.synchronizedList(new ArrayList<>()); } protected void specTearDown() throws Exception { @@ -490,6 +496,22 @@ private void doDelete(String key) throws Exception { doMutation(deleteMutation(key)); } + private void doAddSnapshotsInSyncListener() { + EventListener eventListener = + (Void v, FirebaseFirestoreException error) -> snapshotsInSyncEvents += 1; + snapshotsInSyncListeners.add(eventListener); + eventManager.addSnapshotsInSyncListener(eventListener); + } + + private void doRemoveSnapshotsInSyncListener() throws Exception { + if (snapshotsInSyncListeners.size() == 0) { + throw Assert.fail("There must be a listener to unlisten to"); + } else { + EventListener listenerToRemove = snapshotsInSyncListeners.remove(0); + eventManager.removeSnapshotsInSyncListener(listenerToRemove); + } + } + // Helper for calling datastore.writeWatchChange() on the AsyncQueue. private void writeWatchChange(WatchChange change, SnapshotVersion version) throws Exception { queue.runSync(() -> datastore.writeWatchChange(change, version)); @@ -724,6 +746,10 @@ private void doStep(JSONObject step) throws Exception { doPatch(step.getJSONArray("userPatch")); } else if (step.has("userDelete")) { doDelete(step.getString("userDelete")); + } else if (step.has("addSnapshotsInSyncListener")) { + doAddSnapshotsInSyncListener(); + } else if (step.has("removeSnapshotsInSyncListener")) { + doRemoveSnapshotsInSyncListener(); } else if (step.has("drainQueue")) { doDrainQueue(); } else if (step.has("watchAck")) { @@ -899,6 +925,11 @@ private void validateStateExpectations(@Nullable JSONObject expected) throws JSO validateActiveTargets(); } + private void validateSnapshotsInSyncEvents(int expectedCount) { + assertEquals(expectedCount, snapshotsInSyncEvents); + snapshotsInSyncEvents = 0; + } + private void validateUserCallbacks(@Nullable JSONObject expected) throws JSONException { if (expected != null && expected.has("userCallbacks")) { JSONObject userCallbacks = expected.getJSONObject("userCallbacks"); @@ -986,6 +1017,8 @@ private void runSteps(JSONArray steps, JSONObject config) throws Exception { step.remove("expect"); @Nullable JSONObject stateExpect = step.optJSONObject("stateExpect"); step.remove("stateExpect"); + int expectedSnapshotsInSyncEvents = step.optInt("expectedSnapshotsInSyncEvents"); + step.remove("expectedSnapshotsInSyncEvents"); log(" Doing step " + step); doStep(step); @@ -1002,6 +1035,7 @@ private void runSteps(JSONArray steps, JSONObject config) throws Exception { log(" Validating state expectations " + stateExpect); } validateStateExpectations(stateExpect); + validateSnapshotsInSyncEvents(expectedSnapshotsInSyncEvents); events.clear(); acknowledgedDocs.clear(); rejectedDocs.clear(); diff --git a/firebase-firestore/src/test/resources/json/listen_spec_test.json b/firebase-firestore/src/test/resources/json/listen_spec_test.json index 680c45bb39f..00df5877114 100644 --- a/firebase-firestore/src/test/resources/json/listen_spec_test.json +++ b/firebase-firestore/src/test/resources/json/listen_spec_test.json @@ -10279,5 +10279,814 @@ "clientIndex": 0 } ] + }, + "onSnapshotsInSync should not fire for doc changes if there are no listeners": { + "describeName": "Listens:", + "itName": "onSnapshotsInSync should not fire for doc changes if there are no listeners", + "tags": [], + "config": { + "useGarbageCollection": true, + "numClients": 1 + }, + "steps": [ + { + "addSnapshotsInSyncListener": true, + "expectedSnapshotsInSyncEvents": 1 + }, + { + "userSet": [ + "collection/a", + { + "v": 2 + } + ] + } + ] + }, + "onSnapshotsInSync fires when called even if there are no local listeners": { + "describeName": "Listens:", + "itName": "onSnapshotsInSync fires when called even if there are no local listeners", + "tags": [], + "config": { + "useGarbageCollection": true, + "numClients": 1 + }, + "steps": [ + { + "addSnapshotsInSyncListener": true, + "expectedSnapshotsInSyncEvents": 1 + }, + { + "addSnapshotsInSyncListener": true, + "expectedSnapshotsInSyncEvents": 1 + } + ] + }, + "onSnapshotsInSync fires for metadata changes": { + "describeName": "Listens:", + "itName": "onSnapshotsInSync fires for metadata changes", + "tags": [], + "config": { + "useGarbageCollection": true, + "numClients": 1 + }, + "steps": [ + { + "userListen": [ + 2, + { + "path": "collection", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + } + } + } + }, + { + "watchAck": [ + 2 + ] + }, + { + "watchEntity": { + "docs": [ + { + "key": "collection/a", + "version": 1000, + "value": { + "v": 1 + }, + "options": { + "hasLocalMutations": false, + "hasCommittedMutations": false + } + } + ], + "targets": [ + 2 + ] + } + }, + { + "watchCurrent": [ + [ + 2 + ], + "resume-token-1000" + ] + }, + { + "watchSnapshot": { + "version": 1000, + "targetIds": [] + }, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "added": [ + { + "key": "collection/a", + "version": 1000, + "value": { + "v": 1 + }, + "options": { + "hasLocalMutations": false, + "hasCommittedMutations": false + } + } + ], + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + } + ] + }, + { + "addSnapshotsInSyncListener": true, + "expectedSnapshotsInSyncEvents": 1 + }, + { + "userSet": [ + "collection/a", + { + "v": 2 + } + ], + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "modified": [ + { + "key": "collection/a", + "version": 1000, + "value": { + "v": 2 + }, + "options": { + "hasLocalMutations": true, + "hasCommittedMutations": false + } + } + ], + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": true + } + ], + "expectedSnapshotsInSyncEvents": 1 + }, + { + "watchEntity": { + "docs": [ + { + "key": "collection/a", + "version": 2000, + "value": { + "v": 2 + }, + "options": { + "hasLocalMutations": false, + "hasCommittedMutations": false + } + } + ], + "targets": [ + 2 + ] + } + }, + { + "watchSnapshot": { + "version": 2000, + "targetIds": [] + } + }, + { + "writeAck": { + "version": 2000 + }, + "stateExpect": { + "userCallbacks": { + "acknowledgedDocs": [ + "collection/a" + ], + "rejectedDocs": [] + } + }, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "metadata": [ + { + "key": "collection/a", + "version": 2000, + "value": { + "v": 2 + }, + "options": { + "hasLocalMutations": false, + "hasCommittedMutations": false + } + } + ], + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + } + ], + "expectedSnapshotsInSyncEvents": 1 + } + ] + }, + "onSnapshotsInSync fires once for multiple event snapshots": { + "describeName": "Listens:", + "itName": "onSnapshotsInSync fires once for multiple event snapshots", + "tags": [], + "config": { + "useGarbageCollection": true, + "numClients": 1 + }, + "steps": [ + { + "userListen": [ + 2, + { + "path": "collection", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + } + } + } + }, + { + "watchAck": [ + 2 + ] + }, + { + "watchEntity": { + "docs": [ + { + "key": "collection/a", + "version": 1000, + "value": { + "v": 1 + }, + "options": { + "hasLocalMutations": false, + "hasCommittedMutations": false + } + } + ], + "targets": [ + 2 + ] + } + }, + { + "watchCurrent": [ + [ + 2 + ], + "resume-token-1000" + ] + }, + { + "watchSnapshot": { + "version": 1000, + "targetIds": [] + }, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "added": [ + { + "key": "collection/a", + "version": 1000, + "value": { + "v": 1 + }, + "options": { + "hasLocalMutations": false, + "hasCommittedMutations": false + } + } + ], + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + } + ] + }, + { + "userListen": [ + 4, + { + "path": "collection/a", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + }, + "4": { + "query": { + "path": "collection/a", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + } + } + }, + "expect": [ + { + "query": { + "path": "collection/a", + "filters": [], + "orderBys": [] + }, + "added": [ + { + "key": "collection/a", + "version": 1000, + "value": { + "v": 1 + }, + "options": { + "hasLocalMutations": false, + "hasCommittedMutations": false + } + } + ], + "errorCode": 0, + "fromCache": true, + "hasPendingWrites": false + } + ] + }, + { + "watchAck": [ + 4 + ] + }, + { + "watchEntity": { + "docs": [ + { + "key": "collection/a", + "version": 1000, + "value": { + "v": 1 + }, + "options": { + "hasLocalMutations": false, + "hasCommittedMutations": false + } + } + ], + "targets": [ + 4 + ] + } + }, + { + "watchCurrent": [ + [ + 4 + ], + "resume-token-1000" + ] + }, + { + "watchSnapshot": { + "version": 1000, + "targetIds": [] + }, + "expect": [ + { + "query": { + "path": "collection/a", + "filters": [], + "orderBys": [] + }, + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + } + ] + }, + { + "addSnapshotsInSyncListener": true, + "expectedSnapshotsInSyncEvents": 1 + }, + { + "userSet": [ + "collection/a", + { + "v": 2 + } + ], + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "modified": [ + { + "key": "collection/a", + "version": 1000, + "value": { + "v": 2 + }, + "options": { + "hasLocalMutations": true, + "hasCommittedMutations": false + } + } + ], + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": true + }, + { + "query": { + "path": "collection/a", + "filters": [], + "orderBys": [] + }, + "modified": [ + { + "key": "collection/a", + "version": 1000, + "value": { + "v": 2 + }, + "options": { + "hasLocalMutations": true, + "hasCommittedMutations": false + } + } + ], + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": true + } + ], + "expectedSnapshotsInSyncEvents": 1 + }, + { + "watchEntity": { + "docs": [ + { + "key": "collection/a", + "version": 2000, + "value": { + "v": 2 + }, + "options": { + "hasLocalMutations": false, + "hasCommittedMutations": false + } + } + ], + "targets": [ + 2, + 4 + ] + } + }, + { + "watchSnapshot": { + "version": 2000, + "targetIds": [] + } + }, + { + "writeAck": { + "version": 2000 + }, + "stateExpect": { + "userCallbacks": { + "acknowledgedDocs": [ + "collection/a" + ], + "rejectedDocs": [] + } + }, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "metadata": [ + { + "key": "collection/a", + "version": 2000, + "value": { + "v": 2 + }, + "options": { + "hasLocalMutations": false, + "hasCommittedMutations": false + } + } + ], + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + }, + { + "query": { + "path": "collection/a", + "filters": [], + "orderBys": [] + }, + "metadata": [ + { + "key": "collection/a", + "version": 2000, + "value": { + "v": 2 + }, + "options": { + "hasLocalMutations": false, + "hasCommittedMutations": false + } + } + ], + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + } + ], + "expectedSnapshotsInSyncEvents": 1 + } + ] + }, + "onSnapshotsInSync fires for multiple listeners": { + "describeName": "Listens:", + "itName": "onSnapshotsInSync fires for multiple listeners", + "tags": [], + "config": { + "useGarbageCollection": true, + "numClients": 1 + }, + "steps": [ + { + "userListen": [ + 2, + { + "path": "collection", + "filters": [], + "orderBys": [] + } + ], + "stateExpect": { + "activeTargets": { + "2": { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "resumeToken": "" + } + } + } + }, + { + "watchAck": [ + 2 + ] + }, + { + "watchEntity": { + "docs": [ + { + "key": "collection/a", + "version": 1000, + "value": { + "v": 1 + }, + "options": { + "hasLocalMutations": false, + "hasCommittedMutations": false + } + } + ], + "targets": [ + 2 + ] + } + }, + { + "watchCurrent": [ + [ + 2 + ], + "resume-token-1000" + ] + }, + { + "watchSnapshot": { + "version": 1000, + "targetIds": [] + }, + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "added": [ + { + "key": "collection/a", + "version": 1000, + "value": { + "v": 1 + }, + "options": { + "hasLocalMutations": false, + "hasCommittedMutations": false + } + } + ], + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": false + } + ] + }, + { + "addSnapshotsInSyncListener": true, + "expectedSnapshotsInSyncEvents": 1 + }, + { + "userSet": [ + "collection/a", + { + "v": 2 + } + ], + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "modified": [ + { + "key": "collection/a", + "version": 1000, + "value": { + "v": 2 + }, + "options": { + "hasLocalMutations": true, + "hasCommittedMutations": false + } + } + ], + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": true + } + ], + "expectedSnapshotsInSyncEvents": 1 + }, + { + "addSnapshotsInSyncListener": true, + "expectedSnapshotsInSyncEvents": 1 + }, + { + "addSnapshotsInSyncListener": true, + "expectedSnapshotsInSyncEvents": 1 + }, + { + "userSet": [ + "collection/a", + { + "v": 3 + } + ], + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "modified": [ + { + "key": "collection/a", + "version": 1000, + "value": { + "v": 3 + }, + "options": { + "hasLocalMutations": true, + "hasCommittedMutations": false + } + } + ], + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": true + } + ], + "expectedSnapshotsInSyncEvents": 3 + }, + { + "removeSnapshotsInSyncListener": true + }, + { + "userSet": [ + "collection/a", + { + "v": 4 + } + ], + "expect": [ + { + "query": { + "path": "collection", + "filters": [], + "orderBys": [] + }, + "modified": [ + { + "key": "collection/a", + "version": 1000, + "value": { + "v": 4 + }, + "options": { + "hasLocalMutations": true, + "hasCommittedMutations": false + } + } + ], + "errorCode": 0, + "fromCache": false, + "hasPendingWrites": true + } + ], + "expectedSnapshotsInSyncEvents": 2 + } + ] } }