Working with IndexedDB

Goals

By the end of this lesson, you will:

  • Know how to persist data locally in IndexedDB
  • Understand how service workers interface with IndexedDB

Persisting Data with IndexedDB

Another piece of the offline puzzle is making application data available. Even if we can provide users with a small subset of data to work with while they’re offline, it is a significantly better experience than having none at all.

There are now several data storage options for the web that give us this ability (LocalStorage, CacheStorage, the libraries that exist on top of them, etc.) Today we’ll work with IndexedDB.

Follow Along

We’ll be adding onto the functionality in the markdown previewer application. You can clone the markdown-previewer repo and check out the indexed-db branch as your starting point.

Opening a New Database

The first step to working with IndexedDB, like any data storage API, is to create a new database. Add a new javascript file to handle the database code, and add it as a script tag in index.html.

We’ll start by creating a couple of constants:

const DB_NAME = 'mdFileHistory';
const DB_VERSION = 1;
const DB_STORE_NAME = 'mdFiles';

let db;

Here I’m specifying a database name, a version (which can be used to denote that the database needs to be updated), and a store name. Each database can have a set of object stores that hold a list of records. In our case, we’re just adding a single object store called mdFiles. We’re also declaring a db variable, which we’ll eventually assign to an instance of indexedDB.

In order to create our database, we need to make a request to open a new IndexedDB:

let request = indexedDB.open(DB_NAME, DB_VERSION);

The concept of a request in IndexedDB is slightly different than what you might be familiar with. It’s not same as making an Ajax request to an API endpoint, but rather it’s more of a database operation. The result of our successful request here will return a new instance of an indexedDB database.

Retrieving this database instance requires us to handle the onsuccess event of our open() request:

request.onsuccess = function(event) {
  console.log("IndexedDB opened successfully");
  db = this.result;
};

We can access the database instance through this.result in our onsuccess handler, and assign it to our db variable.

Creating an Object Store

Now that we have a database to work with, we have to specify how it should be structured. We can do this by creating an object store, (that will contain our list of data), and adding indexes to this store, (that will tell us the keys and properties of our data objects). You can think of object stores as the equivalent of tables in any other database.

When our database first initializes, or any time we change the version number of our database to denote a schema change, an onupgradeneeded event will fire. Here we can add a handler to create and structure our object store:

request.onupgradeneeded = function(event) {
  let dbResult = event.target.result;

  // check if our database already exists and contains our object store
  if (dbResult.objectStoreNames.contains(DB_STORE_NAME)) {
    // if so, delete it so we can re-create it with our new structure
    dbResult.deleteObjectStore(DB_STORE_NAME);
  }

  // create a new object store
  let store = dbResult.createObjectStore(
    DB_STORE_NAME, { keyPath: 'id', autoIncrement: true }
  );

  // create indices for each of our key property names
  store.createIndex('authorName', 'authorName', { unique: false });
  store.createIndex('fileName', 'fileName', { unique: true });
  store.createIndex('markdownContent', 'markdownContent', { unique: false });
};

Here we are doing a couple of things. Within this event, we have access to any object stores that currently exist through objectStoreNames. Because this event will fire any time a new database version is detected, it’s possible that our object store already exists. If we want to add a new index or change attributes of an existing index, we’ll want to clear out the existing store so we can rebuild it appropriately.

Creating a new object store can be done with the createObjectStore() method. This method takes in two parameters. The first is the name of the object store you want to create (mdFiles, in our case). And the second is an object of options. Here we’re specifying that we want a unique property on each of our records called id that automatically increments its value any time a new record is added.

We want each of our data objects to have an authorName, fileName, and markdownContent key. The fileName key should be unique so we don’t accidentally override a previously existing markdown file. Creating these object keys in an IndexedDB database is done with store.createIndex(). This method takes in two parameters - the first is the name of the index, and the second is an object of options to describe how the index should behave.

Adding, Removing and Retrieving Data

Now that we’ve set up our database and an object store for our markdown files, let’s add a function to display our records in the UI. We’ll want to update the count in our counter span, and add option elements to our select menu for each record.

Retrieving All Records

In our indexedDB file, add another function to cycle through your database records:

function populateDbRecords() {
  // access our object store
  let objectStore = db.transaction(DB_STORE_NAME, 'readwrite').objectStore(DB_STORE_NAME);

  // grab all the unique fileNames in our object
  // store and determine how many records we have
  let fileNameIndex = objectStore.index('fileName');
  let mdFilesCount = fileNameIndex.count();

  // cycle through each record and add an option to our select menu
  objectStore.openCursor().onsuccess = (event) => {
    let cursor = event.target.result;

    if (cursor) {
      let record = cursor.value;
      $('#markdown-records').append(`<option value=${record.id}>${record.fileName}</option>`);
      cursor.continue();
    } else {

      // when we're done iterating over records, add the count
      // and a `change` handler to our select menu for loading
      // each markdown file
      $('.counter').text(mdFilesCount.result);
      $('#markdown-records').change(event => {
        // loadMarkdown()
      });
    }
  }
}

Transactions & Cursors

There are some new terms in this function that we’ll want to make sure we’re familiar with. The first is transaction. When we want readwrite access to one of our object stores, we must do it through a transaction. In fact, all changes within an IndexedDB database must be done through transactions. This is to ensure that no two people can be in the middle of a transaction on the same data at the same time, which gives us more peace of mind when it comes to the integrity of our data.

In this example, we use a transaction to gain readwrite access to our mdFiles object store:

let objectStore = db.transaction(DB_STORE_NAME, 'readwrite').objectStore(DB_STORE_NAME);

The second term we need to understand is a cursor. A cursor is a mechanism for iterating over a particular object store. It keeps track of its position when iterating over the loop, and gives us detailed information about that particular IndexedDB record. You can think of the position as the index when you are iterating over an array, but a cursor gives us a bit more insight into our current context within the database.

We can cycle through all the data in our mdFiles object store by “opening” a new cursor, and handling its success event:

objectStore.openCursor().onsuccess = (event) => {
  // do things
};

Finally, we’ll want to call this function in the onsuccess handler of our open request:

request.onsuccess = function(event) {
  console.log("IndexedDB opened successfully", this);
  db = this.result;
  populateDbRecords();
};

Retrieving a Single Record

We added a change handler to our select menu in the previous code, but we’re not actually doing anything with it yet. Let’s stub in a call to a loadMarkdown function that will allow users to see the markdown and HTML for that file when they select it:

$('#markdown-records').change(event => {
  // call loadMarkdown, passing in the current ID of
  // our data record as an integer
  loadMarkdown(parseInt(event.currentTarget.value));
});

Now, in app.js, we can add a loadMarkdown function that takes a single ID parameter to retrieve all the details for the existing record:

function loadMarkdown(markdownFileId) {
  let mdFiles = db.transaction(DB_STORE_NAME, 'readwrite').objectStore(DB_STORE_NAME);
  let getRecord = mdFiles.get(markdownFileId);

  getRecord.onsuccess = function(event) {
    let mdResult = getRecord.result.markdownContent;
    let mdFileName = getRecord.result.fileName;

    $('#file-name').val(mdFileName)
    $('#live-markdown').val(mdResult);
    updatePreview(mdResult);
  };
}

This transaction is a bit simpler than trying to retrieve an entire list of records. We are still opening the same objectStore with read/write access, but now we can simply call mdFiles.get() to retrieve the specific record we’re looking for. By passing in the id of the file to get, (remember we used id as our keypath when setting up our object store), we will be given our data object that we can now use to fill in our textareas.

Adding a New Record

We’ve set up all the logic we need for displaying our data records, but we haven’t actually added any functionality for putting one into the database. We’ll use our submit button and service worker to add a record to our newly created object store. In app.js, let’s send a message to our service worker with the markdown file information we want to store:

function enableSubmitButton(event) {
  if (navigator.serviceWorker.controller) {
    $('#submit-markdown').on('click', function() {
      navigator.serviceWorker.controller.postMessage({
        authorName: 'Joe Shmoe',
        mdFileName: $('#file-name').val(),
        mdContent: $('#live-markdown').val()
      });
    });
  }
}

In our service worker, we can add an event listener to handle this message:

self.addEventListener('message', event => {
  let mdRecordInfo = event.data;
  let openDBRequest = indexedDB.open('mdFileHistory');

  openDBRequest.onsuccess = (event) => {
    console.log('DB opened from service worker');
    
    let db = event.target.result;
    let mdFiles = db.transaction(['mdFiles'], 'readwrite').objectStore('mdFiles');
    let addRecord = mdFiles.add(mdRecordInfo);

    addRecord.onsuccess = (event) => {
      console.log('addRecord request succeeded');    
      self.clients.matchAll().then(clients => 
        clients[0].postMessage({
          'updateRecordCount': true,
          'recordId': event.target.result,
          'fileName': mdFileName
        })
      );
    }
  };
});

Once our record is added successfully, we’re sending a message back to our application with an object of information that we can use to update our UI. In app.js, we can handle this message by adding an event listener to navigator.serviceWorker:

navigator.serviceWorker.addEventListener('message', message => {
 if (message.data.updateRecordCount) {
  let currentCount = parseInt($('.counter').text());
  $('.counter').text(currentCount + 1);
  $('#markdown-records').append(`<option value=${message.data.recordId}>${message.data.fileName}</option>`);
 }
});

Now when we add a new record, we should see our UI automatically update with the latest information.

Resources

IndexedDB API Using IndexedDB MDN