The Practical Client

Leveraging TypeScript When Working with IndexedDB

TypeScript might change the way you design an application that uses IndexedDB.

In my last column, I showed how to use the IndexedDB from TypeScript to store data on the client (in an earlier column, I wrote about using Local Storage, a simpler alternative to IndexedDB). In addition to showing the TypeScript code required to work with IndexedDB, I also showed the equivalent JavaScript code -- my attempt to convince you that TypeScript isn't that much different from JavaScript.

However, in that column all I did was initialize the IndexedDB equivalents of a database and a table. This column will go on to the next obvious step: How to add and read entries in that table. In this column, however, I'm going to discuss how TypeScript might change the way that you design an application that uses IndexedDB. And, along the way, I'll also show how JavaScript can defeat your best TypeScript intentions.

A ViewModel Database Manager
When I write client-side JavaScript code, I generally use the Model-View-ViewModel (MVVM) pattern by creating a ViewModel class that integrates all of the code and data that I want to use in the page. By using TypeScript, I can do that in an object-oriented way. I'd also like to create some reusable code. Putting that all together, I want to create a ViewModel class that will let me manage any database. I'll call that class ManageDatabase.

When I instantiate my ManageDatabase class, I want to acquire a reference to IndexedDb itself (that's available through the window object's IndexedDb property). Once I have that reference, my next step is to open the database that the page will be using. If the database doesn't exist on the user's computer, I'll want to create it and add all the necessary tables to it.

To support that with my ViewModel, I just need to pass the name of the database and an array of information describing each of the tables I want to create to my ViewModel's constructor. I'll put the code that defines the database in a method I'll call OpenInitDB so my ViewModel's constructor looks like this:

class ManageDatabase {

  private IndxDb: IDBFactory;
  public db: IDBDatabase;

  constructor(public dbName: string, public tInfos: Array<TableInfo>) {
    this.IndxDb = window.indexedDB;
    this.OpenInitDB();
  }

In my OpenInitDB method, I'll use the IndexedDB's open method to open the database. The open method returns ad IDBOpenDBRequest object. If the database doesn't exist, IndexedDB will create the database and execute whatever function I've put in the IDBOpenDBReqest object's onupgradeneeded property -- that's where I'll add my tables to the database. If everything goes well (either in the open or in the onupgradeneeded function), IndexedDB will execute the function I put in the IDBOpenDBRequest object's onsuccess property. Both methods are passed an event object that has a reference to database in its target.result property. I'll capture that reference to my database and store it in a property in my ManageDatabase class like this:

public db: IDBDatabase;
OpenInitDB() {
  var req: IDBOpenDBRequest;
  req = this.IndxDb.open(this.dbName);
  req.onupgradeneeded = this.AddTables;
  req.onsuccess = function (e: any) {
    md.db = e.target.result;
  }
}

The code in that onsuccess method shows where I start running into problems with implementing my ViewModel. When I retrieve the reference to the database, I want to store it my class's db property. However, the onsuccess method is run asynchronously and within a different scope than my OpenInitDB method: The this keyword inside AddTables doesn't refer to my ViewModel class. In addition, the structure of the onsuccess method prevents me from passing any parameters to the method.

In the end, the only way I could reference the db variable inside my class was to refer to the db property through the variable that referenced my class (you'll see that variable in the next section). Referencing an internal property through an external variable is hardly a best practice but it was the best I could do.

In my AddTables method, I loop through the collection of TableInfo objects passed into my constructor and create a table (or, in IndexedDB-talk, an ObjectStore) for each TableInfo object. All I need from each TableInfo object is the table name, the name of the field that's to act as the primary identifier for each item added to the table, and the name of the index on that field. Here's what that code looks like:

AddTables(e: any) {
        md.db = e.target.result;
        var parms: IDBObjectStoreParameters;
        var tInfo: TableInfo;
        for (var it in md.tInfos) {
            tInfo = md.tInfos[it];
            parms = { keyPath: tInfo.PrimaryFieldName };
            var tblLocal: IDBObjectStore;
            tblLocal = md.db.createObjectStore(tInfo.TableName, parms);
            tblLocal.createIndex(tInfo.PrimaryIndexName, tInfo.PrimaryFieldName);
        }
    }

As long as I've written these methods, I'll add one that lets the user wipe out their existing data and recreate it. All that's necessary is to close the database I opened in my constructor, call IndexedDB's deleteDatabase method to get rid of the database and then call my OpenInitDB method:

ResetDB() {
      this.db.close();
      this.IndxDb.deleteDatabase(this.dbName);
      this.OpenInitDB();
  }

Using ManageTable
To use my ViewModel, I first need to define a TableInfo object to hold the name of the table, its primary key field and primary index. That object looks like this:

class TableInfo {
  TableName: string;
  PrimaryFieldName: string;
  PrimaryIndexName: string;
}

Before instantiating my ManageDatabase class, I need to create at least one TableInfo object and add it to the array I'll pass to my ManageDatabase class' constructor. This example sets up a table called CustOrders with a primary key field called CustId and an index on that field called CustIdIndex:

var ti: TableInfo;
var tis: Array<TableInfo>;
tis = new Array<TableInfo>();

ti = new TableInfo();
ti.TableName = "CustOrders";
ti.PrimaryFieldName = "CustId";
ti.PrimaryIndexName = "CustIdIndex";
tis[0] = ti;

My final step on any page that uses IndexedDB is to instantiate my ManageDB class, passing the name of the database I want to use and an array of TableInfo objects. I have to make sure that the variable I use here matches the name I use inside the class:

var md: ManageDatabase;
md = new ManageDatabase("CustomerOrder", tis)

Defining a Table Manager
As with ManageDatabase, I'd like to have a ViewModel class that I can use to manage any table within a database: A ManageTable class. Because an IndexedDB table holds JavaScript objects, my first step here is to define a class for the objects I'll use in the table. In this case, that's a set of Customer objects:

class Customer {
  Name: string;
  City: string;
  CustId: string;
}

To support working with any object, I create my ManageTable class as a generic class whose constructor accepts the TableInfo object that holds the critical information about the class. The class also has a db property to hold a reference to the database of which the table is part:

class ManageTable<T> {
  public db: IDBDatabase;

  constructor(public tInfo: TableInfo) {
  }

To use my generic class, I instantiate it passing the object type and the relevant TableInfo object, like this:

var mt: ManageTable<Customer>;
mt = new ManageTable<Customer>(ti);

When I go to use this class, I'll set its db property from the equivalent property on my ManageTable class, like this:

mt.db = md.db

Which raises the question: Why not pass the reference to the database in to ManageTable's constructor? The issue I face is that the onupgradeneeded and onsuccess methods in ManageTable are asynchronous -- I can't guarantee when those methods will have finished running when I instantiate my ManageTable class. By deferring setting this property until I use my ManageTable class, I'm hoping those asynchronous processes will have finished.

Supporting Updates
Obviously, my ManageTable ViewModel needs methods to support the four CRUD operations: Create, Read, Update and Delete. The CreateRow method, which accepts a Customer object and adds it to the table managed by the ViewModel, is the easiest to write. With IndexedDB, I first need to use my class's reference to the database to create a transaction, specifying the table name I'll be using and the type of the transaction (for all of my operations, I'll use "readwrite" as the type). Using the transaction, I can retrieve a reference to the table and then call the table's add method, passing the object to add. Here's that method:

CreateRow(obj: T) {
  var trans: IDBTransaction;
  var tbl: IDBObjectStore;
  trans = this.db.transaction([this.tInfo.TableName], "readwrite");
  tbl = trans.objectStore(this.tInfo.TableName);
  tbl.add(ob);
}

The DeleteRow method is almost identical, except that it only requires the primary key value to be passed to it in order to delete the corresponding row:

DeleteRow(id: string) {
  var trans: IDBTransaction;
  var tbl: IDBObjectStore;
  
  trans = this.db.transaction([this.tInfo.TableName], "readwrite");
  tbl = trans.objectStore(this.tInfo.TableName);
  tbl.delete(id)
}

For the Update method, I'll pass in a Customer object holding all the data to be used to update the database. To replace an object using IndexedDB, I must first retrieve the existing object using IndexedDB's get method. The get method doesn't, however, return the object. Instead, like the open method, the get method returns a request object (IDBRequest, in this case). And, before I can use the get method, however, I have to specify the index I intend to use to find my object. Putting that all together, the part of my UpdateRow method that retrieves the existing row looks like this:

UpdateRow(obj: T) {
  var trans: IDBTransaction;
  var tbl: IDBObjectStore;
  var req: IDBRequest;
  var idx: IDBIndex;

  trans = this.db.transaction([this.tInfo.TableName], "readwrite");
  tbl = trans.objectStore(this.tInfo.TableName);
  idx = tbl.index(this.tInfo.PrimaryIndexName);
  req = idx.get(obj[this.tInfo.PrimaryFieldName]);

Now, to process the retrieved record, I put a function in the IDBRequest object's onsuccess method. Again, that method is passed an event object whose target.result property holds the retrieved Customer object. I could transfer properties between the two Customer objects I have n play but it's just as easy to simply use the object passed to the method in IndexedDB's put method. In case something goes wrong (specifically, that I don't find the original object in the database), I also put a method in the IDBRequest object's onerror property:

req.onsuccess = function (e: any) {
        tbl.put(obj);
      };
      req.onerror = function (e: any) {
        alert(e.target.result);
    }
  }
}

Handling Asynchronous Reads
You'll notice that none of the operations required the workarounds I had to use in my ManageDatabase class. In part that's because all of these methods are one way: I pass data into the method and I don't expect to get anything back. The read method, however, is different because I want (presumably) to update my UI with the data retrieved from the database. I can't, however, simply update my UI after my read method completes because the actual read operation is done asynchronously. I'd also prefer not to tie this class to any particular page by inserting jQuery statements into it. The problem is how to get that asynchronous data to my UI code.

The answer is to pass a callback object into the RetreiveRow method. That object will implement an interface I've called IDBCallback, which contains a single method, called Refresh. A developer using my ManageTable class will need to create a callback object that implements this interface and put the code that will update the UI in the Refresh method. My read method will, in turn, call that Refresh method. Here's the interface:

interface IDBCallback<T>
{
  Refresh(obj: T);
}
And here's a typical IDBCallback class:
class CustomerCallBack implements IDBCallback<Customer> {
  Refresh(cust: Customer) {
    $("#CustName").val(cust.Name);
    ...code to update the remainder of the UI...
  }
}

Now I'm ready to add a ReadRow method to my ManageTable class. The start of the ReadRow method looks like the beginning of the Update method, except the ReadRow method accepts an IDBCallback object in addition to the Id of the row to be retrieved:

ReadRow(Id: string, callback: IDBCallback<T>) {
  var trans: IDBTransaction;
  var tbl: IDBObjectStore;
  var idx: IDBIndex;
  var req: IDBRequest;

  trans = this.db.transaction([this.tInfo.TableName], "readwrite");
  tbl = trans.objectStore(this.tInfo.TableName);
  idx = tbl.index(this.tInfo.PrimaryIndexName);
  req = idx.get(Id);

As with the update method, I need to put methods in the IDBRequest object's onsuccess and onerror properties. This time, in the onsuccess method, I extract the retrieved object from the event parameter's target.result property and pass that to the Refresh method of the callback object. The onerror method just needs to report any problems. The resulting code looks like this:

req.onsuccess = function (e: any) {
                var obj: T;
                obj = e.target.result;
                callback.Refresh(obj);
            };
req.onerror = function (e: any) {
                alert(e.target.result);
            }
        }

I'll stop here for now. In my next Practical TypeScript column I'm going to look at wiring these two objects into a simple UI. I'll also show a different solution that, perversely for TypeScript, works by removing type information.

About the Author

Peter Vogel is a system architect and principal in PH&V Information Services. PH&V provides full-stack consulting from UX design through object modeling to database design. Peter tweets about his VSM columns with the hashtag #vogelarticles. His blog posts on user experience design can be found at http://blog.learningtree.com/tag/ui/.

comments powered by Disqus

Featured

  • Compare New GitHub Copilot Free Plan for Visual Studio/VS Code to Paid Plans

    The free plan restricts the number of completions, chat requests and access to AI models, being suitable for occasional users and small projects.

  • Diving Deep into .NET MAUI

    Ever since someone figured out that fiddling bits results in source code, developers have sought one codebase for all types of apps on all platforms, with Microsoft's latest attempt to further that effort being .NET MAUI.

  • Copilot AI Boosts Abound in New VS Code v1.96

    Microsoft improved on its new "Copilot Edit" functionality in the latest release of Visual Studio Code, v1.96, its open-source based code editor that has become the most popular in the world according to many surveys.

  • AdaBoost Regression Using C#

    Dr. James McCaffrey from Microsoft Research presents a complete end-to-end demonstration of the AdaBoost.R2 algorithm for regression problems (where the goal is to predict a single numeric value). The implementation follows the original source research paper closely, so you can use it as a guide for customization for specific scenarios.

  • Versioning and Documenting ASP.NET Core Services

    Building an API with ASP.NET Core is only half the job. If your API is going to live more than one release cycle, you're going to need to version it. If you have other people building clients for it, you're going to need to document it.

Subscribe on YouTube