Building a Trello-like React/Redux App with Nun-DB with offline and conflict resolution features

This tutorial will demonstrate how to implement Nun-DB in a Trello-like application, making it a durable and offline-capable database. In this tutorial, we’ll explore how Nun-DB can be used to create a real-time collaborative app with conflict resolution capabilities. As the major goal of the project is to show how to deal with conflicts in Nun-DB, we will not try to auto-merge the models.

We used “Trello clone” from marconunnari as a starting point and only making the necessary modifications to integrate Nun-DB. By using this pre-existing codebase, we can quickly demonstrate the benefits of using Nun-DB without getting bogged down in the details of building a fully-featured application from scratch.

The goal of the project is to help you understand how Nun-DB can be used to build real-time collaborative applications and how to deal with conflicts that may arise during concurrent editing.

Storing data

In the original code, the author has already provided a convenient location for adding data storage in the store.js file, specifically the line: localStorage.setItem("state", serializedState);. We will be replacing this with storing the data in Nun-DB by changing the line to: nunDb.setValueSafe("state", state);. This will allow us to take advantage of the durability and offline capabilities of Nun-DB in our application. But first we need to connect to an already existed database and import the NunDb library.

Connecting to Nun-DB at the start

import seed from "./seed";
import NunDb from 'nun-db';// Import here

// Connect to the database
const nunDb = NunDb("ws://nun-db-1.localhost:3058", "trelo-real-time-arbiter", "$database-pwd"); // Connect to the database here
//...
const saveState = state => {
    try {
        //localStorage.setItem("state", serializedState); //@mateusfreira This is the old code
        nunDb.setValueSafe("state", state);
    } catch {//@mateusfreira I don't like this but I will not chage it for now
        // ignore write errors
    }
};
//...

Watch for updates from the other users

Now with the database connected we must and pushing the updates to Nun-DB, we must implement the watch feature, with that updates from other clients (other browsers) will be automatically sync with everyone connected.

For that we implemented a new reducer, the most interesting peace here is the UPDATE_STATE we will use that event to propagate all changes to other clients.

In this code we also adding CONFLICT_RESOLUTION and CONFLICT_RESOLUTION_RESOLVED we will speack more about them latter in the chapter “Dealing with Conflicts”. Important point here is that for those events we return { changed: false } meaning this event does not need to be propagated to Nun-DB, and that the last event is already coming from the database, this is a important point of attention because if you do not treat that you must like will end up with a infinity loop sending data back and forth to Nun-DB. In all other cases we return { changed: true }, sinalizing to push the update to Nun-db at that time.

//store.js
const remoteState = (state = { changed: false }, action) => {
  switch (action.type) {
    case "UPDATE_STATE":
    case "CONFLICT_RESOLUTION": 
    case "CONFLICT_RESOLUTION_RESOLVED": 
      return {
        changed: false,
      };
    default:
      return {
        changed: true,
      };
  }
};

And now we need to change the code to deal with that and add and subscribe to all events in the store and save the data to Nun-Db, here we check for the remoteState.changed.

store.subscribe(
    throttle(() => {
        const state = store.getState();
        if (state.remoteState.changed) {
            saveState(state);
        }
    }, 1000)
);

Now we need to change all Next we need to update all stores to deal with the new sate coming from the other users.

// https://github.com/marconunnari/trello-clone/blob/854f5a11f72098df7427a26bb73eea5adf55225c/src/store.js#L6
const board = (state = {
    lists: []
}, action) => {
    switch (action.type) {
        case 'UPDATE_STATE':
            {
                return action.state.board;
            }
...

const listsById = (state = {}, action) => {
    switch (action.type) {
        case "UPDATE_STATE":
            {
                return action.state.listsById;
            }
...
const cardsById = (state = {}, action) => {
    switch (action.type) {
        case "UPDATE_STATE":
            {
                return action.state.cardsById;
            }
...

Next, we need to differentiate from a store perspective, changes locally to changes coming from the database made by other users.

//store.js
const changed = (state = {
    changed: false
}, action) => {
    switch (action.type) {
        case "UPDATE_STATE":// Comming from the database
            {
                return {
                    changed: false
                };
            }
        default:
            return {
                changed: true
            };// Local change
    }

};
//...
const reducers = combineReducers({
    board,
    listsById,
    cardsById,
    changed // added
});

And we Remove data seed since the initial value will now come from the NunDb servers.

// seed.js
export default function seed(store) {
  return;
//...

With that we get the basic of it working as expected and we can do quick demo of the data being updated in multiple browsers.

Demo time, Now the basic is working

Lets deal with conflicts now

Next step it to deal with conflicts, lets suppose you are working offline and you collegue is also working on the same project and you both change the same card. Once you come online that would result into a conflict that needs to be solved. We will not implement any smart way to solve project here, instead any conflict (changes in 2 clients that may colide) we will trigger conflicts and ask the user to choose one of them to use.

First we need to tell Nun-db server this client is allow to fix conflicts. For that we need to call the method nun.arbiter(). This method will be called by each conflict and it may be fired multiple times even before the resolution of the previous call. As in the UI we will be able to solve only one conflict at a time we need to create a queue of promises to support multiple conflicts.

The callback for the arbiter function must return a Promise.

const resolveQueue = {
    lastConflict: Promise.resolve(),
    peddingConflicts: new Map(),
};
function resolveConflict(e) {
    const conflictId = +(new Date());
    return new Promise(resolve => {
        resolveQueue.peddingConflicts.set(conflictId, resolve);
        store.dispatch({
            type: 'CONFLICT_RESOLUTION',
            conflictResolver: {
                conflict: e,
                conflictId,
            }
        });
    });
}
nunDb.becameArbiter((e) => {
    resolveQueue.lastConflict = resolveQueue.lastConflict.then(v => {
        return resolveConflict(e);
    });
    return resolveQueue.lastConflict;
});
  • Now our basic is working lets start coding around the conflict resolution. First I want to create the UI infra to allow the users to deal with the conflict.

  • Here I am choosing to let the user decide between 2 versions. And showing 2 buttons. As the user clicks on one, that version will show up in the board , clicking in done will apply the showing state as the current state and conclude de conflict resolution.

We implemented a new reducer to solve handle the conflict actions.

const resolveQueue = {
  lastConflict: Promise.resolve(),
  pendingConflicts: new Map(), // Fixed typo in variable name
};

function resolveConflict(e) {
  const conflictId = Date.now(); // Simplified creation of conflictId
  return new Promise((resolve) => {
    resolveQueue.pendingConflicts.set(conflictId, resolve);
    store.dispatch({
      type: 'CONFLICT_RESOLUTION', // Updated action type for consistency
      conflictResolver: {
        conflict: e,
        conflictId,
      },
    });
  });
}

nunDb.becameArbiter((e) => {
  resolveQueue.lastConflict = resolveQueue.lastConflict.then((v) => {
    return resolveConflict(e);
  });
  return resolveQueue.lastConflict;
});

Now the code is already working as we expect.

Conclusion

In this tutorial demonstrate how to implement the conflict resolution on Nun-db using the Trello application as the example. You can see close and test the code by yourself in https://github.com/mateusfreira/trello-clone

Written on April 22, 2023