Skip to content

Best Practices

David Fahlander edited this page Apr 7, 2016 · 128 revisions

1. Be wise when catching promises!

Short Version:

When you consume a Promise but not return a Promise, catch it!
Everywhere else, don't catch promises unless you handle them
and expect further code to continue executing and transaction
to commit!

Long Version:

It's bad practice to this everywhere:

function somePromiseReturningFunc() {
    return db.friends.add({
        name: 'foo',
        age: 40})
    .catch (function (err) {
        console.log(err);
    });
}

It's much better to do just this:

function somePromiseReturningFunc() {
    return db.friends.add({
        name: 'foo',
        age: 40});
    // Don't catch! The caller wouldn't find out that an error occurred if you do!
    // We are returning a Promise, aren't we? Let caller catch it instead!
}

If you catch a promise, your resulting promise will be considered successful. It's like doing try..catch in a function where it should be done from the caller, or caller's caller instead. Your flow would continue even after the error has occured.

In transaction scopes, it is even more important to NOT catch promises because if you do, transaction will commit! Catching a promise should mean you have a way to handle the error gracefully. If you don't have that, don't catch it!

function myDataOperations() {
    return db.transaction('rw', db.friends, db.pets, function(){
        return db.friends.add({name: 'foo'}).then(function(id){
            return db.pets.add({name: 'bar', daddy: id});
        }).then (function() {
            return db.pets.where('name').startsWith('b').toArray();
        }).then (function (pets) {
            ....
        }); // Don't catch! Transaction SHOULD abort if error occur, shouldn't it?

    }); // Don't catch! Let the caller catch us instead! I mean we are returning a promise, aren't we?!
}

But on an event handler or other root-level scope, always catch! Why?! Because you are the last one to catch it since you are not returning Promise:

somePromiseReturningFunc().catch(function (err) {
    $('#appErrorLabel').text(err);
    console.error(err.stack || err);
});

Sometimes you really WANT to handle an explicit error because you know it can happen and you have a way to work around it.

function getFooFriend() {
    return db.friends.where('[name+age]').equals(['foo',40]).toArray()
          .catch('DataError', function (err) {
              // May fail in IE/Edge because it lacks support for compound keys.
              // Use a fallback method:
              return db.friends.where('name').equals('foo')
                       .and(function (f) { return f.age === 40; });
          });
    });
});

In the above exampe, we are handling the error because we know it may happen and we have a way to solve that.

What about if you want to log stuff for debugging purpose? Just remember to rethrow the error if you do.

function myFunc() {
    return Dexie.Promise.resolve().then(function(){
        return db.friends.add({name: 'foo'});
    }).catch(function (err) {
        console.error("Failed to add foo!: " + err);
        throw err; // Re-throw the error!
    }).then(function(id){
        return db.pets.add({name: 'bar', daddy: id});
    }).catch(function (err) {
        console.error("Failed to add bar!: " + err);
        throw err; // Re-throw the error!
    }).then (function() {
        ...
    });
});

Since Dexie v1.3.6, all uncaught Dexie.Promises will by default be logged to the console (using console.error()). You can override this behavior by subscribing to Dexie.Promise.on('error'):

Dexie.Promise.on('error', function(err) {
    // Log to console or show en error indicator somewhere in your GUI...
    console.error('Uncaught Promise: ' + (err.stack || err));
    $('#appErrorLabel').text(err.message || err);
});

Subscribing to this event overrides the default handler (that logs to console) so you should also log to console if you do. Note that when this event is triggered, your code has failed to catch a promise at the top of its chain, for example in an event handler that does not return a promise itself.

2. Prevent Database from Blocking

If there are several Dexie instances (db connections) towards the same database, a database upgrade or deletion will block until the other connections close. To debug, detect and prevent this, do:

  • If you run unit tests or other code that creates/upgrades/deletes a database frequently, don't forget to close the database when done.
// If your database is not meant to be globally alive (for entire window life time),
// make sure to close it when you are done:
function createTempDB() {
   var db = new Dexie("test");
   db.version(1).stores({table1: "primKey, indexedProp1, indexedProp2"});

   db.open().then(function(){
       return db.table1.put({primKey: 1, indexedProp1: "Hello", indexedProp2: "World!"});
   }).then(function(){
       return db.table1.get(1);
   }).then (function (item) {
       return db.table1.delete(1);
   }).finally(function() {
       db.close(); // Close or delete database before connection goes out of scope.
   });
}
  • Listen to db.on('blocked', ...) to detect a blocking situation.
// If you suspect database is being blocked, add the following code:
db.on('blocked', function () {
    debugger; // Make sure you get notified if database is blocked!
    // In production code, you may notify user via GUI to shut down 
    // other tabs or browsers that may keep the database blocked.
    // But better is to make sure that situation never occur
    // (by always closing a db before leaving the local db var 
    //  to garbage collection)
});

3. Use transaction() scopes wherever you gonna make more than one operation

Whenever you are going to do more than a single operation on your database in a sequence, use a transaction. This will not only encapsulate your changes into an atomic operation, but also optimize your code! Internally, non-transactional operations also use a transaction but it is only used in the single operation, so if you surround your code withing a transaction, you will perform less costly operations in total.

Using transactions gives you the following benefits:

  • Robustness: If any error occur, transaction will be rolled back!
  • Simpler code: You may do all operations sequencially - they get queued on the transaction.
  • One single line to catch them all - exceptions, errors wherever they occur.
  • Faster execution
  • Remember that a browser can close down at any moment. Think about what would happen if the user closes the browser somewhere between your operations. Would that lead to an invalid state? If so, use a transaction - that will make all operations abort if browser is closed between operations.

Here is how you enter a transaction block:

db.transaction("rw", db.friends, db.pets, function() {
    db.friends.add({name: "Måns", isCloseFriend: 1});
    db.friends.add({name: "Nils", isCloseFriend: 1});
    db.friends.add({name: "Jon", isCloseFriend: 1});
    db.pets.add({name: "Josephina", kind: "dog"});

    // Since we are in a transaction, we can query the table right away.
    // If this was not in a transaction, we would have to wait for all three
    // add() operations to complete before querying it if we would like to get
    // the latest added data.
    db.friends.where("isCloseFriend").equals(1).each(function(friend){
        console.log("Found close friend: " + JSON.stringify(friend);
        // Any database error event that occur will abort transaction and
        // be sent to the catch() method below.
        // The exact same rule if any exception is thrown what so ever.
    });
}).catch(function (error) {
    // Log or display the error.
    console.error(error);
    // Notice that when using a transaction, it's enough to catch
    // the transaction Promise instead of each db operation promise.
});

Notes:

  • friends and pets are objectStores registered using Version.stores() method.
  • "rw" should be replaced with "r" if you are just going to read from database.
  • Also errors occurring in nested callbacks in the block will be catched by the catch() method.

4. Rethrow errors if transaction should be aborted

Saying this again.

When you catch database operations explicitely for logging purpose, transaction will not abort unless you rethrow the error or return the rejected Promise.

db.transaction("rw", db.friends, function() {
    db.friends.add ({name: "Måns", isCloseFriend: 1})
      .catch(function (error) {
          console.error("Couldnt add Måns to the database");
          // If not rethrowing here, error will be regarded as "handled"
          // and transaction would not abort.
          throw error;
      });
    db.friends.add ({name: "Nils", isCloseFriend: 1});
});

If not rethrowing the error, Nils would be successfully added and transaction would commit since the error is regarded as handled when you catch the database operation.

An alternate way of rethrowing the error is to replace throw error; with return Promise.reject(error).

5. Don't use non-dexie Promise inside transactions

Dexie.Promise is ES6 and A+ compliant, meaning that you can use any favourite promise together with Dexie. However, within transactions, DO NEVER use any other promise implementation than Dexie.Promise! Otherwise the effective transaction will be gone.

6. (Optionally:) Declare Classes

When you declare your object stores (db.version(1).stores({...})), you only specify nescessary indexes, not all properties. A good practice is to have a more detailed class declaration for your persistant classes. It will help the IDE with autocomplete making life easier for you while coding. Also, it is very good to have a reference somewhere of what properties are actually used on your objects.

There are two different methods available to accomplish this. Use whichever you prefer:

  1. mapToClass() - map an existing class to an objectStore
  2. defineClass() - let Dexie declare a class for you

Whichever method you use, your database will return real instances of your mapped class, so that the expression

(obj instanceof Class)

will return true, and you may use any methods declared via

Class.prototype.method = function(){}

.

Method 1: Use mapToClass() (map existing class)
var db = new Dexie("MyAppDB");

db.version(1).stores({
    folders: "++id,&path",
    files: "++id,filename,extension,folderId"
});

// Folder class
function Folder(path, description) {
    this.path = path;
    this.description = description; 
}
Folder.prototype.save = function () {
    return db.folders.put(this);
}

/// File class
function File(filename, extention, parentFolderId) {
    this.filename = filename;
    this.extention = extention;
    this.folderId = parentFolderId;
}

File.prototype.save = function () {
    return db.files.put(this);
}

db.folders.mapToClass(Folder);
db.files.mapToClass(File);

db.open();
Method 2: Use defineClass()
var db = new Dexie("MyAppDB");

db.version(1).stores({
    folders: "++id,&path",
    files: "++id,filename,extension,folderId"
});

var Folder = db.folders.defineClass({
    id: Number,
    path: String,
    description: String
});

Folder.prototype.save = function () {
    return db.folders.put(this);
}

var File = db.files.defineClass({
    id: Number,
    filename: String,
    extension: String,
    folderId: Number,
    tags: [String]
});

File.prototype.save = function () {
    return db.files.put(this);
}

db.open();
Clone this wiki locally