Client-side employee lookup with IndexedDB

IndexedDB gives web applications a way to keep meaningful datasets on the client. For an intranet-style app where employees need to search a company directory, storing that directory locally makes the search UI feel instant. The pattern is simple: on first run, download and seed the data into IndexedDB; on subsequent runs, reuse what's already there and let the UI read straight from the local store.

This demo covers that flow with an employee search box. The UI itself is deliberately sparse: an autocomplete text field, a status element for the one-time seed process, and a div for showing the selected employee's details.

<form>
  <p>
    <label for="name">Name:</label> <input id="name" disabled> <span id="status"></span>
    </p>
</form>

<div id="displayEmployee"></div>

That markup is all the application needs. The text field starts out disabled because there is no data to search against yet. JavaScript enables it when the local database is ready.

Opening the database

Browser support for IndexedDB is inconsistent enough that aliasing the core objects is worthwhile. The following block, based on Mozilla's documentation, creates the short references used throughout the rest of the code:

window.indexedDB = window.indexedDB || window.webkitIndexedDB || window.mozIndexedDB;
var IDBTransaction = window.IDBTransaction || window.webkitIDBTransaction;
var IDBKeyRange = window.IDBKeyRange || window.webkitIDBKeyRange;

Those aliases feed into the global variables that track the current database, the autocomplete field, and the employee display area:

var db;
var template;

Opening a connection is the first step. An IndexedDB instance can contain multiple object stores, which are roughly analogous to tables. The onupgradeneeded event fires the first time the database is opened—or whenever the version number changes—and it's the correct place to create the stores and indexes the application depends on.

var openRequest = indexedDB.open("employees", 1);

Inside that handler, the code checks the objectStoreNames collection for an employee store. If missing, it creates the store and adds one index: searchkey.

// Handle setup.
openRequest.onupgradeneeded = function(e) {

  console.log("running onupgradeneeded");
  var thisDb = e.target.result;

  // Create Employee
  if(!thisDb.objectStoreNames.contains("employee")) {
    console.log("I need to make the employee objectstore");
    var objectStore = thisDb.createObjectStore("employee", {keyPath: "id", autoIncrement: true});
    objectStore.createIndex("searchkey", "searchkey", {unique: false});
  }

};

openRequest.onsuccess = function(e) {
  db = e.target.result;

  db.onerror = function(e) {
    alert("Sorry, an unforseen error was thrown.");
    console.log("***ERROR***");
    console.dir(e.target);
  };

  handleSeed();
};

The searchkey index is essential. IndexedDB does not support case-insensitive lookups, so the application needs a lowercase copy of the searchable field to query against. When the open completes successfully—onsuccess—the code calls handleSeed to decide whether local data exists.

Seeding data on first run

Real-world versions of this application would pull employee records from a server API in batches, letting the background sync proceed while the user works. For this demo, a fake data generator stands in for that service. The key difference is the initial state check:

function handleSeed() {
  // This is how we handle the initial data seed. Normally this would be via AJAX.

  db.transaction(["employee"], "readonly").objectStore("employee").count().onsuccess = function(e) {
    var count = e.target.result;
    if (count == 0) {
      console.log("Need to generate fake data - stand by please...");
      $("#status").text("Please stand by, loading in our initial data.");
      var done = 0;
      var employees = db.transaction(["employee"], "readwrite").objectStore("employee");
      // Generate 1k people
      for (var i = 0; i < 1000; i++) {
         var person = generateFakePerson();
         // Modify our data to add a searchable field
         person.searchkey = person.lastname.toLowerCase();
         resp = employees.add(person);
         resp.onsuccess = function(e) {
           done++;
           if (done == 1000) {
             $("#name").removeAttr("disabled");
             $("#status").text("");
             setupAutoComplete();
           } else if (done % 100 == 0) {
             $("#status").text("Approximately "+Math.floor(done/10) +"% done.");
           }
         }
      }
    } else {
      $("#name").removeAttr("disabled");
      setupAutoComplete();
    }
  };
}

Counting records involves a chain of asynchronous calls. First a read-only transaction is created:

db.transaction(["employee"], "readonly");

Then the employee object store is pulled from that transaction:

objectStore("employee");

The count() API reports how many objects already exist:

count()

When the count callback runs, the result is the number of stored objects:

onsuccess = function(e) {

If nothing is stored, the application begins its seed routine. Each fake person object looks like this:

{
  firstname: "Random Name",
  lastname: "Some Random Last Name",
  department: "One of 8 random departments",
  email: "first letter of [email protected]"
}

Before insertion, the code copies the last name to the searchkey property in lowercase. That transformation belongs on the client because it only matters for local lookups—a server would not need to send that field.

// Modify our data to add a searchable field
person.searchkey = person.lastname.toLowerCase();

Bulk inserts should reuse a single transaction. Creating a new transaction for each write forces potential disk writes per record, which turns a 1,000-object seed into a minutes-long operation. Reusing the transaction keeps the whole batch fast.

While the seeding runs, the status span updates the user. Because the operation only happens once, the user should be blocked from searching until it completes. After the last record is added, the code hands off to setupAutoComplete.

Wiring autocomplete to IndexedDB

The jQuery UI Autocomplete widget accepts a custom source function. That function receives the current user input and a callback, and it is responsible for returning the matching array. This is the integration point with IndexedDB:

function setupAutoComplete() {

  //Create the autocomplete
  $("#name").autocomplete({
    source: function(request, response) {

      console.log("Going to look for "+request.term);

      $("#displayEmployee").hide();

      var transaction = db.transaction(["employee"], "readonly");
      var result = [];

      transaction.oncomplete = function(event) {
        response(result);
      };

      // TODO: Handle the error and return to it jQuery UI
      var objectStore = transaction.objectStore("employee");

      // Credit: http://stackoverflow.com/a/8961462/52160
      var range = IDBKeyRange.bound(request.term.toLowerCase(), request.term.toLowerCase() + "z");
      var index = objectStore.index("searchkey");

      index.openCursor(range).onsuccess = function(event) {
        var cursor = event.target.result;
        if(cursor) {
          result.push({
            value: cursor.value.lastname + ", " + cursor.value.firstname,
            person: cursor.value
          });
          cursor.continue();
        }
      };
    },
    minLength: 2,
    select: function(event, ui) {
      $("#displayEmployee").show().html(template(ui.item.person));
    }
  });

}

The search itself relies on an index range. The typed term, lowercased, forms the lower boundary; the same term plus the letter "z" forms the upper boundary. This gives a case-insensitive prefix match against the searchkey index. Opening a cursor over that range iterates the results in order.

Autocomplete results only require a value property. Here, value becomes a formatted full name, and the whole employee object is attached to the result as well. The select handler later uses that payload to render the detail view.

Rendering employee details

Handlebars handles the detail markup. A template is compiled once at startup:

<h2>, </h2>
Department: <br/>
Email: <a href='mailto:'></a>

The compiled template is referenced from the autocomplete select handler. When a user picks a result, the employee div is populated with the matched record's fields. The autocomplete field and the detail view together form a complete—and local—employee lookup experience.

Once the data is seeded, every subsequent page load skips the seed routine entirely and goes straight to the autocomplete. Subsequent queries run entirely against the local object store, which is what makes the interaction feel immediate.