Navigation

Add Collection-Level Watch

Deployment Type:

Author: MongoDB Documentation Team

This tutorial walks you through adding collection-level Watch to your To-do client(s). With this, your client will be automatically notified when a to-do item is changed in the Atlas database.

Time required: 30 minutes

What You’ll Need

  • If you have not yet built the To-do backend, follow the steps in this guide.
  • You can either follow this tutorial to build the client app, or start with an app that already has most of structural work done for you.

Procedure

A. Update your client

For the client work in this tutorial, you can start with the app you built in the previous tutorial or start with an app that already has some of the Android structural work done for you.

1

Get the latest code

In the MongoDB Stitch Android tutorial repo, run the following command to fetch the tagged version for this tutorial:

git checkout watch
2

Update strings.xml

You need to make sure the client app points to your Stitch backend app. To do so, follow these steps to add your Stitch app ID to the strings.xml file:

  1. Open the res/values/strings.xml file.
  2. Set the value of the stitch_client_app_id key to your Stitch App ID.
3

Add collection-level Watch for all items

We will now add the code to set up a collection-wide watch on the items collection. This initial code will notify your client app every time any item is changed; later on, we’ll change the code to only notify your app if the item belongs to the logged-in user.

Open the TodoListActivity.java file and follow these steps:

  1. Create a method called addWatchToCollection that takes no parameters and returns void. If you are using the sample app, we have created the method stub for you, which looks like the following:

    private void addWatchToCollection() {
      // TODO: Add watchWithFilter() to the items collection
    }
    
  2. We’ll now add code that sets up a filtered collection-level watch for documents in the items collection, so that your client app will be notified whenever a change occurs to one of your items in the collection. On the items collection, call the watchWithFilter method, passing in a new BSON document that limits the search to only documents where the owner_id field matches the userId` value. Then call addOnCompleteListener, passing in a new onComplete method. Your addWatchToCollection method may look like the following:

    private void addWatchToCollection() {
      items.watchWithFilter(new BsonDocument("fullDocument.owner_id",
          new BsonString(userId)))
      .addOnCompleteListener(this::onWatchEventComplete);
    }
    

    When you first set up your Stitch backend app, you configured a rule that prevents a user from reading from, or writing to, another user’s items. Because of this rule, you don’t actually need to use watchWithFilter(), as a call to watch accomplishes the same thing:

    private void addWatchToCollection() {
      items.watch().addOnCompleteListener(this::onWatchEventComplete);
    }
    

    In our example app, we’ve extracted the onCompleteListener to its own method for clarity. The method signature looks like the following:

    private void onWatchEventComplete(Task<AsyncChangeStream<TodoItem, ChangeEvent<TodoItem>>> task) {
      AsyncChangeStream<TodoItem, ChangeEvent<TodoItem>> changeStream = task.getResult();
      changeStream.addChangeEventListener((BsonValue documentId, ChangeEvent<TodoItem> event) -> {
    
        // TODO: Insert logic here to handle changes to the items collection
    
      });
    }
    
  3. In the addOnCompleteListener add your business logic for how you want to handle change notifications. In this tutorial, we’ll do two things:

    • Pop up a Toast to notify the user, and
    • Refresh the list of to-do items to display the changes.

    To do these two things, add the following code to the onCompleteListener:

    TodoItem item = event.getFullDocument();
    runOnUiThread(() -> Toast.makeText(getApplicationContext(),
            "One of the todo items has been updated!" +
                    event.getOperationType() + " to " +
                    item.get_id(),
            Toast.LENGTH_LONG).show());
    todoAdapter.refreshItemList(getItems());
    

    Note

    The use of the runOnUiThread method is required to show a Toast from a non-UI thread.

  4. Finally, call the addWatchToCollection method from the doLogin method, after verifying that we have an authenticated user and setting the global userId parameter. Find the doLogin method, and add the call to addWatchToColleciton. When complete, your doLogin method should look like the following:

    private void doLogin() {
        if (client.getAuth().getUser() != null && client.getAuth().getUser().isLoggedIn()) {
            userId = client.getAuth().getUser().getId();
            addWatchToCollection();
            TextView tvId = findViewById(R.id.txt_user_id);
            tvId.setText("Logged in with ID \"" + userId + "\"");
            todoAdapter.refreshItemList(getItems());
            return;
        } else {
            Intent intent = new Intent(TodoListActivity.this, LogonActivity.class);
            startActivityForResult(intent, 111);
        }
    }
    

A. Update your client

For the client work in this tutorial, you can start with the app you built in the previous tutorial or start with an app that already has some of the iOS structural work done for you.

1

Get the latest code

In the MongoDB Stitch iOS tutorial repo, run the following command to fetch the tagged version for this tutorial:

git checkout watch
2

Update Constants.swift

Open the Constants.swift file and set the STITCH_APP_ID value to your Stitch app ID. Be sure to update the Google and Facebook settings in Constants.swift and Info.plist as you did when adding Google and Facebook authentication.

3

Watch the items collection for changes

We will now add the code to set up a watch on the items collection. This code will notify your client app every time an item is added, updated, or deleted.

Open the TodoTableViewController.swift file and follow these steps:

  1. Create a method called addWatchToCollection():

    func addWatchToCollection() {
      // TODO: Use watch(delegate:) on the items collection
    }
    
  2. Make the TodoTableViewController conform to the ChangeStreamDelegate protocol so that it can receive watch events. First, at the top of the file, import StitchRemoteMongoDBService to make ChangeStreamDelegate available:

    import StitchRemoteMongoDBService
    

    Declare adoption of the ChangeStreamDelegate protocol on TodoTableViewController by adding ChangeStreamDelegate to the end of the adopted protocols list:

    class TodoTableViewController:
      UIViewController,
      UITableViewDataSource,
      UITableViewDelegate,
      ChangeStreamDelegate // adopt ChangeStreamDelegate protocol
    {
      // ...
    

    This protocol relies on a type declaration in your class to determine the document type used by your collection. The type declaration must be called DocumentT. In this case, the itemsCollection uses our TodoItem struct, so our DocumentT must be an alias for TodoItem. Put the type alias at the top of your class that implements the ChangeStreamDelegate protocol:

    class TodoTableViewController:
      UIViewController,
      UITableViewDataSource,
      UITableViewDelegate,
      ChangeStreamDelegate
    {
      typealias DocumentT = TodoItem // Declare Document type for ChangeStreamDelegate
      // ...
    

    Now we just need to implement the methods of the protocol itself. Add these method stubs to the bottom of the class. We will come back to implement didReceive(event:) later:

    // Implementation of the ChangeStreamDelegate protocol:
    
    // Called when the matchFilter matches the change event.
    func didReceive(event: ChangeEvent<DocumentT>) {
      // TODO: Handle change event
    }
    
    // Called when the stream emits an error
    func didReceive(streamError: Error) {
      // TODO: Handle error in the change stream
    }
    
    // Called when the change stream has opened
    func didOpen() {
    }
    
    // Called when the change stream has closed
    func didClose() {
    }
    
  3. The watch() functions of RemoteMongoCollection return a ChangeStreamSession that we must hold on to as long as we want to keep watching. We can hold onto this session at the instance level of TodoTableViewController. Add this to the top of the class near the other member declarations:

    var changeStreamSession: ChangeStreamSession<TodoItem>? // our watch change stream session
    
  4. We’ll now implement addWatchToCollection().

    Stitch allows you to watch with a filter, so that you are only notified of some events according to that filter. If we wanted to watch with a filter on the items collection, we would use the watch(matchFilter:delegate:) method on the collection. There are two arguments: the first is a BSON document representing the match expression against the incoming ChangeEvent; the second is the ChangeStreamDelegate instance that will handle events. Since our TodoTableViewController now conforms to the ChangeStreamDelegate protocol, we can pass the current instance represented by self. The code would look something like this:

    Note

    The following code is an example of how we would use watch with a filter. As you’ll see below, this is not what we want to do in this case.

    // Starts watching the items collection for changes with a filter.
    func addWatchToCollection() {
        do {
            NSLog("Watching changes for user \(userId!)");
            changeStreamSession = try itemsCollection.watch(matchFilter: ["fullDocument.owner_id": userId!] as Document, delegate: self);
        } catch {
            NSLog("Stitch error: \(error)");
        }
    }
    

    When you first set up your Stitch backend app, you configured a rule that prevents a user from reading from or writing to another user’s items. Because of this rule, you don’t actually need to use watch(matchFilter:delegate:), since the filter is essentially redundant. In fact, this filter has another issue: only insert and update events will come through – not delete events. The filter we created matches the userId against the fullDocument.owner_id field, but when a document is deleted, the change event does not include the fullDocument field at all. Therefore, the delete change event would not pass the filter.

    In this case, we actually just want to use watch(delegate:) to accomplish the goal of watching the collection for changes:

    // Starts watching the items collection for changes.
    func addWatchToCollection() {
        do {
            NSLog("Watching changes for user \(userId!)");
            changeStreamSession = try itemsCollection.watch(delegate: self);
        } catch {
            NSLog("Stitch error: \(error)");
        }
    }
    
  5. In the didReceive method we added to conform to the ChangeStreamDelegate protocol, add your business logic for how you want to handle change notifications. In this tutorial, we’ll update the list of todo items according to whether it’s a new item, updated item, or deleted item, then refresh the list on the UI thread to display the updates.

    // Implementation of the ChangeStreamDelegate protocol. Called when the matchFilter matches the change event.
    func didReceive(event: ChangeEvent<DocumentT>) {
        // Update or insert events have a fullDocument field containing the new document.
        // Delete events do not have this field.
        if let item = event.fullDocument {
            NSLog("Item refreshed: \(item)");
            DispatchQueue.main.async { [weak self] in
                // Try to find the item in the array by id.
                if let index = self?.todoItems.firstIndex(where: { $0.id == item.id }) {
                    // Item was already in the list. Here we update it.
                    self?.todoItems[index] = item
                } else {
                    // Item was not already in list, so it's a new item. Add it.
                    self?.todoItems.append(item)
                }
                // Refresh the view.
                self?.tableView.reloadData()
            }
        } else if let id = event.documentKey["_id"] as? ObjectId {
            // We can still retrieve the id of the deleted document in the event's
            // documentKey field.
            NSLog("Item deleted: \(id)");
            DispatchQueue.main.async { [weak self] in
                // Remove the deleted item from the list.
                self?.todoItems.removeAll { $0.id == id }
    
                // Refresh the view.
                self?.tableView.reloadData()
            }
        }
    }
    
  6. Finally, call the addWatchToCollection method from the init method after verifying that a user is logged in:

    override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
        super.init(nibName: nil, bundle: nil)
        // check to make sure a user is logged in
        // if they are, load the user's todo items and refresh the tableview
        if stitch.auth.isLoggedIn {
            addWatchToCollection()
            itemsCollection.find(["owner_id": self.userId!]).toArray { result in
                switch result {
                case .success(let todos):
                    self.todoItems = todos
                    DispatchQueue.main.async {
                        self.tableView.reloadData()
                    }
                case .failure(let e):
                    fatalError(e.localizedDescription)
                }
            }
        } else {
            // no user is logged in, send them back to the welcome view
            self.navigationController?.setViewControllers([WelcomeViewController()], animated: false)
        }
    }
    

A. Update your client

For the client work in this tutorial, you can start with the app you built in the previous tutorial or start with an app that already has some of the JavaScript structural work done for you.

1

Get the latest code

In the MongoDB Stitch web tutorial repo, run the following command to fetch the tagged version for this tutorial. This version of the code is as it should be immediately prior to starting the tutorial.

git checkout watch
2

Open a Change Stream

Our first step in adding collection-level watch to the todo application is to use watch to open a change stream. This will allow you to subsequently monitor and access real-time changes to your todo items.

Navigate to your stitch/mongodb.js file and add the following watchItems function. This function uses the watch method to open a change stream on the items collection. This is asynchronous, so it returns a Promise that will eventually resolve to the stream. We then define two functions: one that lets us access the change stream after it resolves and another to close the stream when the user logs out or quits the app.

export function watchItems() {
  const streamPromise = items.watch();
  const getStream = () => streamPromise;
  const closeStream = () => streamPromise.then(stream => stream.close);
  return [getStream, closeStream];
}

Note

When you first built the todo backend, you configured a rule that prevents a user from reading from, or writing to, another user’s items. Because of this rule, you don’t need to use the watchWithFilter function–Stitch will only send change events for documents where the owner_id field is the same as the user_id field.

3

Add a Watch Listener

At this point, we have a function, watchItems, that lets us know when a todo item is updated, added, or deleted. However, we have not yet taken the steps to reflect any of these changes in our todo app. In the following steps, we will set up a listener for these change events and dispatch state updates to reflect these changes in our application.

  1. First, import the watchItems function you just wrote to useTodoItems.js. We’ll use its resulting change stream in our listener.

    import { watchItems } from "../stitch/mongodb"
    
  2. Now, let’s add the skeleton of our hook, useWatchItems, which will implement the listener. Here, we use the stream returned by watchItems to identify the type of change event that has occurred. Copy this function into useTodoItems.js, right below loadTodos.

    const useWatchItems = () => {
      const [getStream, closeStream] = watchItems();
      React.useEffect(() => {
        getStream().then(stream => stream.onNext( (changeEvent) => {
          switch(changeEvent.operationType) {
            //TODO: update application based on type of change event
          }
        } ));
        return closeStream;
      }, [])
    };
    
  3. Now, it’s time to write the body of our switch statement in useWatchItems. The reducer has cases for insert and delete, so we’ll start by handling those cases. In the pre-existing code, we already call dispatch in the same functions as the database calls. To avoid calling an operation twice (which would cause errors), remove the dispatch lines from every function below the listener you just added. Please note that the dispatch line should remain in loadTodos.

  4. Now, we can safely move the appropriate dispatches to our listener. Add the following lines of code to useWatchItems:

    const useWatchItems = () => {
      const [getStream, closeStream] = watchItems();
      React.useEffect(() => {
        getStream().then(stream => stream.onNext( (changeEvent) => {
          switch(changeEvent.operationType) {
            case "insert": {
              dispatch({ type: "addTodo", payload: changeEvent.fullDocument});
              break;
            }
            case "delete": {
              dispatch({ type: "removeTodo", payload: changeEvent.documentKey._id});
              break;
            }
          }
        } ));
        return closeStream;
      }, [])
    };
    
  5. Finally, call useWatchItems at the end of the useTodoItems function, right above the return statement. After doing so, the end of useTodoItems.js should look roughly like this:

    React.useEffect(() => {
      loadTodos();
    }, []);
    useWatchItems();
    return {
        //existing code
      },
    };
    
4

Add updateTodo

The app should now automatically update any time a todo item is added even if it’s from another device. To test this, open the app in another tab/window and add a new todo item. It works! Now try to we’re only listening for insert and delete change events, and we only dispatch state events in the listener. Therefore, we need to add a case for update to the listener.

  1. Add the following code for updateTodo to the reducer in useTodoItems.js. This function updates the state of existing todo items.

    case "updateTodo": {
      const updatedTodo = payload;
      const updateTodo = todo => {
        const isThisTodo = todo._id.toString() === updatedTodo._id.toString();
        return isThisTodo ? updatedTodo : todo;
      };
      return {
        ...state,
        todos: state.todos.map(updateTodo)
      }
    }
    
  2. Finally, we’ll add the case handler to the change stream listener to dispatch the event we just added to the reducer. Add the update case to useWatchItems:

    const useWatchItems = () => {
      const [getStream, closeStream] = watchItems();
      React.useEffect(() => {
        getStream().then(stream => stream.onNext( (changeEvent) => {
          switch(changeEvent.operationType) {
            case "insert": {
              dispatch({ type: "addTodo", payload: changeEvent.fullDocument});
              break;
            }
            case "delete": {
              dispatch({ type: "removeTodo", payload: changeEvent.documentKey._id});
              break;
            }
            case "update": {
              dispatch({ type: "updateTodo", payload: changeEvent.fullDocument});
              break;
            }
          }
        } ));
      return closeStream;
      }, [])
    };
    

B. Build and Test Your App

To verify that Watch is working, you need to make changes to the items collection while your app is running. There are several ways to accomplish this; while your client app is running in an emulator or on a device, you can:

  • run another instance of the app in a separate emulator or device,
  • run the app on a different platform,
  • use Compass or the Atlas UI to directly modify the items collection, or
  • create a Stitch Function in your backend app, running as System, to change documents.

For this tutorial, we’ll add a Function to change the data for us. To do this, open your Stitch app, navigate to the Functions section, and follow these steps:

  1. Click Create New Function

  2. In the Function Settings:

    1. Name the Function changeMyRandomDoc.
    2. Enable Run As System.
  3. Switch to the Function Editor tab and paste the following code into the editor, replacing all of the existing placeholder code:

    exports = function(args) {
      var owner_id = args;
      var collection = context.services.get("mongodb-atlas").db("todo").collection("items");
      console.log('Finding a doc owned by ' + owner_id);
      return collection.aggregate([ { $match:  {"owner_id": owner_id} }, { $sample: { size: 1 } } ]).toArray()
        .then(docs => {
          var doc = docs[0];
          if (doc === undefined) {
            console.log('No docs found!');
            return "No docs found for user " + owner_id;
          }
          console.log("Updating " + doc._id);
          var change = !doc.checked;
          console.log("Changing 'checked' value to : " + change);
          return collection.updateOne({_id: BSON.ObjectId(doc._id.toString())},
              { $set: { 'checked': change}}, {upsert:true}).then(r => {
                  return "Updated doc " + doc._id.toString() +
                    " by changing the checked state to '" +
                    change + "'.";
          });
      }).catch(err=>{
        console.log('Something is wrong...' + err);
        return err;
      });
    };
    
  4. Save the changeMyRandomDoc function.

You can now test the functionality by ether calling the function directly from the Function Editor or from another app.

To make it easy, we have hosted a basic web app here that you can use for testing. In the web app, follow these steps:

  1. Enter your Stitch app id and the id of the user who is logged in to your device app
  2. Click the Connect to my stitch app button
  3. Click the Change one of my items button.

The page will show the function’s progress, and you will see the notification in your client app.

Summary

Congratulations! Your client app will now be notified every time an item in the collection is updated.