IndexedDB programming with Dexie.js

Published: January 12, 2018  •  database, javascript

In the previous blogs posts about IndexedDB we have seen that the API is not the easiest to work with. Everything is asynchronous and uses callbacks unlike the newer interfaces, like Fetch, that use Promises. And you need to write a lot of boilerplate code for transaction handling and queries.

Fortunately there are a few libraries that simplify working with IndexedDB. One is idb from Jake Archibald that I mentioned in this blogs post. It wraps the IndexedDB methods in Promises.

Other libraries like Lovefield (blog post), ZandoDB, JsStore and others build their database on top of IndexedDB use it as a storage engine and completely hide the IndexedDB API from the developer.

In this blog post we take a closer look at another library: Dexie. Like idb it is a thin layer around IndexedDB. All the concepts of IndexedDB are visible but Dexie wraps them in a more user friendly API. The asynchronous operations use Promises that allows us to use async/await. Dexie does not store the data in a special way, you can always use the native IndexedDB API to access and modify the data and you can use both API in the same application.

Compared to idb Dexie goes further and does not just wrap the methods in Promises it also simplifies transaction handling and it adds a more convenient query support.


Database Management

A basic workflow with Dexie looks like this:

First you need to create an instance of the Dexie object. The argument specifies the database name.

const db = new Dexie('dbname');

A web application can create as many databases as it wants. Each database needs to have a unique name within the origin.

Next you specify the schema of the database. db.version() specifies the database version number and returns a Version object. This object provides the stores() method that specifies the database schema (object store, primary key, indexes) for this particular version.

db.version(1).stores({
  contacts: 'id,name,location'
});

stores() takes an object as parameter. The keys of this object define the names the object stores. The value is the schema definition that describes the primary key and the indexes. Each key (object store name) becomes a property of the db object: db.contacts

The example above creates one object store with the name contacts with a primary key id and two indexes name and location.

Schema creation and upgrade only runs when the version number of the database differs from the provided parameter to the db.version call. If you want to change the schema you add another db.version call with a version number that is greater than the previous version.

For example if you want to add another index city you add this code

db.version(2).stores({
  contacts: 'id,name,location,city'
});

Dexie automatically figures out the differences between the two versions and adds the city index.
Note that Dexie internally multiplies the version number by 10. When you check the version number of the database with the browser developer tools you will see 20 for this example.

Before the application can work with the database it needs to open it with db.open(), but you can omit this call because Dexie automatically calls open() when it executes the first query.

There are two cases where you explicitly have to call db.open(). When you created the Dexie object with the autoOpen option set to false (new Dexie('dbname', {autoOpen: false})) or when you closed the database with db.close()

Dexie automatically creates the database when it does not exist.

To delete a database you call the static method delete(): Dexie.delete('dbname').


Transactions

All IndexedDB operations must run inside a transaction. In this blog post you will see that most examples don't explicitly start a transaction and still work. This is a feature of Dexie that automatically starts and commits transactions when it runs a database operation. This is very convenient but you also need to be aware that this can lead to performance degradation.

For example if insert 100 objects like this.

const objs = [{id:1, ....}, {id:2, ....},  ......  ,{id:100, ....} ];
for (const o of objs) {
   db.contacts.add(o);
}

Each time you call add() Dexie opens a transaction and commits it.

It would be far more performant to explicitly start a transaction and run all the inserts in one transaction. The second advantage, maybe even more important than the performance aspect, is that when the operations fails halfway, IndexedDB will roll back all changes. The whole operation becomes an all or nothing operation. You don't have to worry about that only half of the data is inserted.

This aspect is very important whenever you are going to do more than a single operation in a sequence. Always make sure that operations that belong together and need to run in an all or nothing fashion are wrapped in a transaction.

To start a transaction with Dexie you use db.transaction(). The first parameter specifies the mode and supports 'rw' for a read-write transaction and 'r' for a read only operation. The following parameters specify the object stores that you want to access in this transaction. Last parameter is a callback function that is called as soon as the transaction is created and started.

db.transaction('rw', db.contacts, () => {
  const objs = [{id:1, ....}, {id:2, ....},  ......  , {id:100, ....} ];
  for (const o of objs) {
    db.contacts.add(o);
  }
}).catch(function (e) {
  // handle errors
});

db.transactions() returns a Promise so you can use the Promise catch method to handle errors. Alternatively you can use async/await and then surround the code with a try/catch block.

Note that for importing multiple objects a more performant way with bulkAdd() and bulkPut() exist. These methods are not atomic. When they fail halfway the successfully inserted object are not rolled back. Manually start a transaction if you want it to be an insert all or nothing operation.

db.transaction("rw", db.contacts, () => {
  db.contacts.bulkAdd([{id:1, ....}, {id:2, ....},  ......  ,{id:100, ....} ]);
}).catch(function (e) {
  // handle errors
});

There is no commit method, transactions are auto-committed when no error occurs. But you can abort a transaction with abort(). To call this method you can get the current transaction with Dexie.currentTransaction.

  Dexie.currentTransaction.abort(); 

When you call abort() all changes made inside this transaction are discarded.


Dexie also supports nested transactions. Dexie checks, when entering a nested transaction, that all object stores in the nested transaction are also present in the parent transaction and when the mode is 'rw' the parent also must be a read-write transaction. If the two transactions are incompatible the code will fail.

You can override this behavior with the '!' and '?' token in the mode string ('rw!', 'rw?', 'r!', 'r?')

See the documentation for more information about this topic.


Primary Keys

Every object in IndexedDB is referenced by a unique primary key.

IndexedDB supports two types of keys: in-line and out-of-line. An in-line key is stored as part of the object. With such a key an application can only store JavaScript objects as values.
An out-of-line key is stored separately from the value and it allows storing primitives like string and numbers as value, but you can also store JavaScript object with such a key.

Dexie supports all these key types and provides a concise syntax to define them.


out-of-line

To create an out-of-line primary key you omit the first field in the schema definition. This example creates an object store simple with an out-of-line key and an index name.

db.version(1).stores({
  simple: ',name'
});

If you need to create an object store with just a primary key the schema definition becomes an empty string

db.version(1).stores({
  simple: ''
});

The following example maps a number to a string (1->"one", 2->"two", ...). To insert a record you can either use add() or put(). Because the primary key is not auto generated the code has to provide it as the second parameter to the method call. add() can only insert records and throws an error when the primary already exists. put() either inserts or updates the object depending on if an object with the same primary key already exist.

   const db = new Dexie('keys');
   
   db.version(1).stores({
       simple: ''
   });
   
   db.simple.add('one', 1); // insert
   //db.simple.add('another one', 1); //throws an error
   db.simple.add('two', 2); // insert
   db.simple.put('three', 3); // insert    
   db.simple.put('Three', 3); // update
    
   const value = await db.simple.get(3);
   // Three

out-of-line auto generated

To create an auto increment primary key you need to add '++' to the schema definition.
IndexedDB uses a key generator that produces consecutive integer values starting with 1. The key generator does not reset when the application deletes records. With an auto generated key there is no longer a need to provide a second parameter to the add() and put() methods, both methods behave the same when you omit the second parameter and perform an insert. The following code inserts three records and the database generates the keys 1, 2 and 3.

   const db = new Dexie('keys');
   db.version(1).stores({
       simple: '++'
   });

   db.simple.add('one'); // insert
   db.simple.add('two'); // insert
   db.simple.put('three'); // insert    
   db.simple.put('Three', 3); // update
    
   const value = await db.simple.get(3);
   // Three

You can still provide the second parameter, in that case IndexedDB does not auto generate the primary key for this particular record. Note that add() will fail when you try to insert a key that already exists and put() will update the record. With a new database and object store the following code assigns 1 to the first record, 10 to the second and 11 to the third. When you try to run this code a second time it will fail because the primary key 10 already exists.

db.simple.add('one'); 
db.simple.add('two', 10); 
db.simple.put('three');   

in-line

To create an object store with an in-line primary key you specify the name of the property as the first entry in the schema definition string. Like with the other key types add() is an insert only operation and throws an error when the primary key already exists and put() is either an insert or an update operation when an object with that key already exists.

   const db = new Dexie('keys');
   db.version(1).stores({
     objects: 'id'
   });
 
   db.objects.add({id: 'one', data: 1}); //insert
   db.objects.add({id: 'two', data: 2}); //insert
   db.objects.put({id: 'three', data: 3}); //insert  
   db.objects.put({id: 'three', data: 33}); //update
  
   const result = await db.objects.get('three'); 
   // {id: 'three', data: 33}

Because the primary key is not auto generated the primary key must be part of the object.
The following put() statement would throw an exception because of the missing id property.

  db.objects.put({data: 44});

You can also specify properties in nested objects as the key with the dot notation.

   const db = new Dexie('keys');
   db.version(1).stores({
     objects: 'pk.id'
   });
  
   db.objects.add({data: 'one', pk: {id: 1, ts: 1928198298}});
   db.objects.add({data: 'two', pk: {id: 2, ts: 1928198299}});
   db.objects.add({data: 'three', pk: {id: 3, ts: 1928198300}});

in-line auto generated

To create this kind of primary key you add the string '++' to the name of the primary key. If you want to take advantage of the key generator the specified primary key property should not be present in the objects you want to insert. The database automatically adds the property to the object when the insert is successful.

   const db = new Dexie('keys');
   db.version(1).stores({
     objects: '++userId'
   });
 
   db.objects.add({data: 1}); //insert
   db.objects.add({data: 2}); //insert
   db.objects.put({data: 3}); //insert
   db.objects.put({userId: 3, data: 33}); //update

   const result = await db.objects.get(3); 
   // {data: 33, userId: 3}

When you use inline-keys you cannot pass the second parameter to add() and put(). This code throws an exception

  db.objects.add({data: 2}, 20); 

But you can save objects that already contain the primary key property. The following example inserts the records with the primary keys 1, 20, 21 and 40. add() will fail when the key already exists and put() updates the record.

   const db = new Dexie('keys');
   db.version(1).stores({
       objects: '++userId'
   });
  
   db.objects.add({data: 1});
   db.objects.add({userId: 20, data: 2});
   db.objects.put({data: 3});
   db.objects.put({userId: 40, data: 40}); 
  
   const result = await db.objects.get(21); 
   // {data: 3, userId: 21}

Composite keys

IndexedDB supports composite or array keys. This is a key comprises two or more properties. This could be useful when you have an object but none of the properties alone is unique. The database cannot auto generate values for this kind of primary key, the application has to provide the values when it inserts the objects.
To create such a key you need to specify the properties with an array notation separated with plus ('+').

   const db = new Dexie('keys');
   db.version(1).stores({
     objects: '[first+last]'
   });
  
   db.objects.add({first: '1', last: 1, data: 'test'}); //insert
   db.objects.put({first: '2', last: 2, data: 'test2'}); //insert
   db.objects.put({first: '2', last: 2, data: 'test22'}); //update

   const result = await db.objects.get(['2',2]);
   // {first: "2", last: 2, data: "test22"}

To access the record with the get() method you need to provide an array and specify the values in the same order as defined in the index definition.


Indexes

Indexes provide an additional access path to objects stored in an object store. You create, alter and delete indexes via the schema definition in version.stores().

In this example we create an object store contacts with a primary key id and two indexes name and location. The index definition always follows the primary key definition (after the first comma)

db.version(1).stores({
   contacts: 'id,name,location'
});

To add or delete an index you create a new version and provide the new schema definition. If you want to delete an index you omit it from the definition.

db.version(2).stores({
   contacts: 'id,name,city'
});

Dexie automatically figures out the difference between the versions.
When it runs the second schema definition it deletes the index location and creates a new index city.

Note that you can always add and delete indexes without losing data.

IndexedDB supports four different types of indexes and so does Dexie. The library supports a special syntax in the schema string to create the different types


Example:

const db = new Dexie('AppDatabase');
db.version(1).stores({
   contacts: 'id,name,&email,*hobbies,[postCode+city]'
});
db.contacts.add({
   id: 1,
   name: 'John',
   email: 'john@test.com',
   hobbies: ['Reading', 'Traveling', 'Cycling'],
   postCode: 11111,
   city: 'BigCity'
});

This example creates four indexes. After the add() operation the name index contains one entry 'John', the email index 'john@test.com' and the [postCode+city] index one array [11111, 'BigCity']. The hobbies index contains three entries, for each array entry one index entry 'Reading', 'Traveling', 'Cycling'.
Note that the unique index email adds a constraint to the database. IndexedDB throws a ConstraintError when you try to insert an object with the same email address twice.

To access an index you use where() or orderBy()

const contact = await db.contacts.where('hobbies').equals('Cycling').first();

The Read operations section a bit further below contains more information and examples about accessing indexes.


Modify operations

Insert

In the previous sections we have seen examples that insert objects with put() and add(). add() can only be used for inserting objects, put() is either an insert or an update operation depending if the object with that primary key already exists.

   const db = new Dexie('MyDb');
  
   db.version(1).stores({
     contacts: 'id,name'
   });
   db.contacts.add({id:1, name:'Jane', age: 32}); 
   db.contacts.put({id:2, name:'Joe', age: 27});  

   // {id: 1, name: "Jane", age: 32}
   // {id: 2, name: "Joe", age: 27}

If you want to insert multiple objects consider using bulkPut() and bulkAdd() which are more performant.

  db.contacts.bulkAdd([{id:1, name:'Jane', age: 32}, {id:2, name:'Joe', age: 27}]);

Update

To update an object you call put() with an existing primary key, it then overrides the previous object

db.contacts.put({id:1, name:'Sarah', age: 32});
// {id: 1, name: "Sarah", age: 32}

Another way is to use update(). This method updates an existing object with the given primary key (first parameter) and applies all the changes from the given object (second parameter)

db.contacts.update(1, {name:'Sarah'});
// {id: 1, name: "Sarah", age: 32}

update() only applies the given changes to the object while put() replaces the entire object. put() also inserts a new object when the primary key does not exist, update() does nothing.

update() works similar to this code. Read the object with get(), apply the changes and call put().

   const contact = await db.contacts.get(1);
   if (contact) {
     contact.name = 'Sarah';
     db.contacts.put(contact);
   }

Another option is to update objects based on a query with the modify() method

  db.contacts.where('name').equals('Jane').modify({name: 'Sarah'});

modify() takes either an object and then applies all the changes to the selected objects. Or you pass a function and modify() calls it for each matching object. In this function you can update, add and delete properties. modify() then updates the object in the database.

This example replaces each contact with the name 'Jane' to 'Sarah', deletes the age property and adds a new hobbies property with the value ["Reading"].

   db.contacts.where('name').equals('Jane').modify(contact => {
       contact.name = 'Sarah';
       contact.hobbies = ['Reading'];
       delete contact.age;       
   });
   // {id: 1, name: "Sarah", hobbies: ['Reading']}

You can also use the special ref.value property to entirely override an object.

db.contacts.where('name').equals('Jane').modify((contact, ref) => {
  ref.value = {id: 1, name: 'Sarah', hobbies: ['Reading']};
});

And you can delete an object by deleting the ref.value: delete ref.value;


Delete

To delete an object with the primary key you call delete() on the object store.

db.contacts.delete(2);

Dexie also provides bulkDelete() to delete multiple entries.

db.contacts.bulkDelete([1,2]);

When you want to delete all objects from an object store call clear()

db.contacts.clear();

Another option is to delete objects based on a query.

db.contacts.where('name').equals('Jane').delete();

Read operations

As a base for the following examples I use the following dataset.

Note that IndexedDB does not support indexes on boolean properties. Therefore I use a number for the business flag and take advantage of the fact that when an index property is missing the object will not be part of that index. So in this example the business index will only contain three entries. The value of the business property does not matter it just cannot be a boolean.

   const db = new Dexie("MyDb");
   db.version(1).stores({
     contacts: 'id,business,firstName,lastName,age,&email,*hobbies,[firstName+lastName]'
   });
  
   db.contacts.bulkAdd([
   {
     id: 1, business: 1, firstName: 'Cassio', lastName: 'Zen', age: 32,
       email: 'cassiozen@gmail.com', hobbies: ['Reading', 'Cycling']
   },
   {
     id: 2, firstName: 'Dan', lastName: 'Abramov', age: 47,
       email: 'gaearon@somewhere.com', hobbies: ['Reading', 'Cycling']
   },
   {
     id: 3, firstName: 'Pete', lastName: 'Hunt', age: 27,
       email: 'floydophone@somewhere.com', hobbies: ['Cosplaying', 'Painting']
   },
   {
     id: 4, firstName: 'Paul', lastName: 'O\'Shannessy', age: 29,  
       email: 'zpao@somewhere.com', hobbies: ['Reading', 'Swimming']
   },
   {
     id: 5, business: 1, firstName: 'Ryan', lastName: 'Florence',  age: 35,    
       email: 'rpflorence@somewhere.com', hobbies: ['Reading', 'Jogging', 'Swimming']
   },
   {
     id: 6, business: 1, firstName: 'Sebastian', lastName: 'Markbage', age: 40,
       email: 'sebmarkbage@here.com', hobbies: ['Reading', 'Mountain biking']
   }
   ]);

Accessing all elements

each() takes a function that is called for each object. The objects are ordered by the primary key in ascending order. You can reverse the order with reverse().

 db.contacts.each(contact => console.log(contact));
 /*
 {id: 1, …}
 ...
 {id: 6, …}
 */

 db.contacts.reverse().each(contact => console.log(contact));
 /*
 {id: 6, …}
 ...
 {id: 1, …}
 */

Instead of each() you can use toArray() that returns all objects in an array. Be careful with that method when the store contains thousands of records, because Dexie reads them all into memory. Consider using each() for these situations.

 const all = await db.contacts.toArray();
 /* [{id: 1, ....}, ...., {id: 6, ....} */

 const all = await db.contacts.reverse().toArray();
 /* [{id: 6, ....}, ...., {id: 1, ....} */

You can also return all objects sorted by an index with orderBy(). The method returns the elements by default in ascending order of the index value, reverse() returns them in the reverse order.

 const allOrderedByAge = await db.contacts.orderBy('age').toArray();
 /*
   [{id: 3, age: 27, ....}, {id: 4, age: 29, ....}, {id: 1, age: 32, ....}, 
    {id: 5, age: 35, ....}, {id: 6, age: 40, ....}, {id: 2, age: 47, ....}]
 */

Note that orderBy() only returns the objects that are referenced in the index. The business index in our example only contains 3 entries.

 db.contacts.orderBy('business').each(contact => console.log(contact));
 /*
  {id: 1, ....}
  {id: 5, ....}
  {id: 6, ....}
 */

Primary key access

get() returns an object with the primary key. Returns undefined when the object with that key does not exist.

 const contact = await db.contacts.get(1);
 // {id: 1, firstName: "Cassio", lastName: "Zen", ....

Index access with where()

The where() method is the entry point for queries. The method expects one parameter, the name of an index or the special string ':id' representing the primary key and returns a WhereClause object that provides methods like equals(), above(), startsWith(), between().

 const ryan = await db.contacts.where('firstName').equals('Ryan').toArray();
 // [{id: 5, firstName: "Ryan", ....]

where() supports a shorthand form for the equals comparison.

 const ryan = await db.contacts.where({firstName: 'Ryan'}).toArray();
 const ryan = await db.contacts.where({firstName: 'Ryan', age: 35}).toArray();
 // {id: 5, firstName: "Ryan", ....
 
 const ryan = await db.contacts.where({firstName: 'Ryan', lastName: 'Florence'}).toArray();
 // {id: 5, firstName: "Ryan", ....

Dexie also supports queries that ignore case

 const startingWithP = await db.contacts.where('firstName').startsWithIgnoreCase('p').toArray();
 // [{id: 4, firstName: "Paul", ...}, {id: 3, firstName: "Pete", ...}]

 const ryans = await db.contacts.where('firstName').equalsIgnoreCase('ryan').toArray();
 // [{id: 5, firstName: "Ryan", ....}]

Greater and less than queries are supported with above(), aboveOrEqual(), below(), belowOrEqual()

 const over34 = await db.contacts.where('age').aboveOrEqual(35).toArray();
 // [{id: 5, age: 35, ....},{id: 6, age: 40, ....},{id: 2, age: 47, ....}]
 
 const over35 = await db.contacts.where('age').above(35).toArray();
 // [{id: 6, age: 40, ....},{id: 2, age: 47, ....}]
 
 const under30 = await db.contacts.where('age').below(30).toArray();
 // [{{id: 3, age: 27, ....}, {id: 4, age: 29, ....}]

between() supports range queries. The lower bound is by default included and the upper bound excluded. You can change that with the third (lower) and fourth parameter (upper). Setting it to true includes this bound.

 const result1 = await db.contacts.where('age').between(27, 35).toArray();
 // [{id: 3, age: 27, ....}, {id: 4, age: 29, ....}, {id: 1, age: 32, ....}]
 
 const result2 = await db.contacts.where('age').between(27, 35, false, true).toArray();
 // [{id: 4, age: 29, ....}, {id: 1, age: 32, ....}, {id: 5, age: 35, ....}]

anyOf() and noneOf() compare an index with multiple values.

 const result1 = await db.contacts.where('hobbies').anyOf('Mountain biking', 'Cosplaying').toArray();
 // [{id: 3, hobbies: ["Cosplaying", "Painting"], ....}, 
 //  {id: 6, hobbies: ["Reading", "Mountain biking"], ....}]

 const result2 = await db.contacts.where('hobbies')
                                  .noneOf('Reading', 'Swimming', 'Mountain biking', 'Cycling')
                                  .distinct().toArray();
 // [{id: 3, hobbies: ["Cosplaying", "Painting"], ....}, 
 //  {id: 5, hobbies: ["Reading", "Jogging", "Swimming"], ....}]

The special string ':id' allows you to create queries with the primary key. For example read all objects with a primary key smaller than 3

 const result = await db.contacts.where(':id').below(3).toArray();
 // [{id: 1, ....}, {id: 2, ....}]

When you query compound indexes make sure that you specify the comparison values in the correct order.

 const result = await db.contacts.where('[firstName+lastName]').equals(['Dan', 'Abramov']).toArray();
 // [{id: 2, firstName: "Dan", lastName: "Abramov", ....}]
 
 // wrong order
 const result = await db.contacts.where('[firstName+lastName]').equals(['Abramov', 'Dan']).toArray();
 // []

Results are naturally sorted by the index or primary key specified in the where clause. If you need to sort the objects on another property you can use the sortBy() method. Note that this sorts the results in JavaScript. IndexedDB does not support sorting natively. sortBy() is especially useful for or() queries because the sort order of the result set is undefined.

 const result = await db.contacts.where('hobbies').equals('Mountain biking')
                                 .or('age').above(45).sortBy('firstName');
 // [{{id: 2, firstName: "Dan", ....}, {id: 6, business: 1, firstName: "Sebastian", ....}]

These are just a few of the provided methods from the WhereClause object. See the documentation for a complete list of supported operations.


OR / AND

You can also combine multiple query clauses with and() and or(). They are differently implemented. or() runs all the queries native in IndexedDB and Dexie combines the results.

 const result = await db.contacts.where('hobbies').equals('Mountain biking')
                                 .or('age').above(45).toArray();
 // [{id: 6, age: 40, hobbies: ["Reading", "Mountain biking"], ....}, {id: 2, age: 47, ....}]

An and() query only runs the first query in IndexedDB. All the following clauses are handled in JavaScript. That's the reason you have to pass a function to the and() function. Because of this behavior you need to think carefully about the order of the query clauses. Try to execute first the query that returns the smaller amount of objects.

 const result = await db.contacts.where('business').equals(1).and(value => value.age > 35).toArray();
 // [{id: 6, business: 1, age: 40, ....}]
 
 const result = await db.contacts.where('age').above(35).and(value => value.business === 1).toArray();
 // [{id: 6, business: 1, age: 40, ....}]

Limit and offset

With limit() and offset() you can limit the number of returned objects and skip a specific amount of objects.

 const first = await db.contacts.limit(1).toArray();
 // [{id: 1, ....}]

 const last = await db.contacts.reverse().limit(1).toArray();
 // [{id: 6, ....}]
  
 const secondAndThird = await db.contacts.offset(1).limit(2).toArray();
 // [{id: 2, ....}, {id: 3, ....}]

 const skipFirstFour = await db.contacts.offset(4).toArray();
 // [{id: 5, ....}, {id: 6, ....}]

You can also use these methods together with where()

 const firstTwoOver20 = await db.contacts.where('age').above(20).limit(2).toArray();
 // [{id: 3, ....}, {id: 4, ....}]

First / Last

first() and last() return the first respectively the last object of a resultset.

 const first = await db.contacts.toCollection().first();
 console.log(first);
 // {id: 1, ....}

 const last = await db.contacts.toCollection().last();
 console.log(last);
 // {id: 6, ....}

You can also combine this with a query and return the first or last of the matching objects.

 const youngestOver20 = await db.contacts.where('age').above(20).first();
 // {id: 3, age: 27, ....}
 
 const oldestOver20 = await db.contacts.where('age').above(20).last();
 // {id: 2, age: 47, ....}

Counting

To count the number of all entries in the object store you use count()

const numberOfContacts = await db.contacts.count();
// 6

You can also count the entries based on a query

const contactsOver40 = await db.contacts.where('age').above(40).count();
// 1

Key results

All the queries we have seen so far return the whole object. Sometimes you only need the primary key or the index. Dexie provides keys(), uniqueKeys() and primaryKeys() for this purpose.

primaryKeys() returns the primary key of the objects

  const result = await db.contacts.where('age').above(30).primaryKeys();
  // [1, 5, 6, 2]

keys() and uniqueKeys() return the index value. The difference is when you query a normal index that allows duplicates, in that case uniqueKeys() filters out the duplicate values. There is no difference in the result when you query a unique index.

  const result = await db.contacts.where('age').above(30).keys(); 
  // [32, 35, 40, 47]

  const result = await db.contacts.where('age').above(30).uniqueKeys(); 
  // [32, 35, 40, 47]


  const result = await db.contacts.orderBy('hobbies').keys();
  // ["Cosplaying", "Cycling", "Cycling", "Jogging", "Mountain biking", "Painting", 
  //  "Reading", "Reading", "Reading", "Reading", "Reading", "Swimming", "Swimming"]

  const result = await db.contacts.orderBy('hobbies').uniqueKeys();
  // ["Cosplaying", "Cycling", "Jogging", "Mountain biking", "Painting", "Reading", "Swimming"]

If you work with large result sets you should consider using the each variants: eachKey(), eachUniqueKey() and eachPrimaryKey().
These methods don't load everything at once into memory and instead use internally a cursor and call an iteration function for each matching object.

  db.contacts.where('age').above(30).eachPrimaryKey(pk => console.log(pk));
  // 1  5  6  2

  db.contacts.where('age').above(30).eachKey(age => console.log(age)); 
  // 32  35  40  47

  db.contacts.orderBy('hobbies').eachUniqueKey(hobby => console.log(hobby));
  // Cosplaying  Cycling  Jogging  Mountain biking  Painting  Reading  Swimming

Initial Dataload

A common use case is to store initial data when a new database is created. For this purpose you can subscribe a handler to the populate event. Dexie will only call this handler in case the database is initially created, it will not call it when the database is upgraded.

   const db = new Dexie('MyDb');
   db.version(1).stores({
     countries: 'isoCode,name'
   });
  
   db.on('populate', () => {
     db.countries.bulkAdd([{isoCode:'CH', name: 'Switzerland'},
                           {isoCode:'AT', name: 'Austria'},
                           {isoCode:'IT', name: 'Italy'}]);
   });

Migration

An application over time changes and with that the schema of the database. In this section we look at an application with several schema changes and see how to define these changes with Dexie.

The initial version of our example application stores contact objects that look like this.

{
  name: 'John, Doe',
  city: 'London',
  email: 'john@email.com',
  gender: 'M'
}

Version 1

The initial version of our app creates a database with the name myApp with one object store contacts. The primary key id is defined as in-line and auto increment. The application also defines an index on the name property and a unique index on the email property.

   const db = new Dexie('myApp');
   db.version(1).stores({
     contacts: '++id,name,&email'
   });

Version 2

Next version of the app also needs to store todo entries. For that we create a new object store todo. The primary key is an in-line key mapped to the id property but not autogenerated. Additionally an index over the priority property is created.

   const db = new Dexie('myApp');
   db.version(1).stores({
     contacts: '++id,name,&email'
   });
   db.version(2).stores({
     todo: 'id,priority'
   });

Important to note here is that the call for the version 1 schema stays untouched in the code. Never edit old schemas as soon as you deployed them to production. Always add new versions when you want to change a schema. New schemas only have to specify the changes to the previous schema. You don't need to repeat schema definitions for object stores that do not change.

Dexie automatically figures out what schema method is has to call. Dexie calls both methods when a new user without a database visits your app. For recurring users with an old version 1 database Dexie runs the version 2 code and it skips both methods when the user's database is already up to date.


Version 3

In the next update we not only want to change the structure of the database but also want to migrate some data. We want to split the name field into two new fields first and last name. We also no longer need the name index.

To delete an index you simply omit the field from the new schema definition. Dexie automatically figures out the difference to version 1 and deletes the index. To run a migration job you add an upgrade function. In this function you can run any database operation to migrate the data.

   const db = new Dexie('myApp');
   db.version(1).stores({
     contacts: '++id,name,&email'
   });
  
   db.version(2).stores({
     todo: 'id,priority'
   });
  
   db.version(3).stores({
     contacts: '++id,&email'
   }).upgrade(() => {
     return db.contacts.toCollection().modify(contact => {
       const splitted = contact.name.split(' ');
       contact.firstName = splitted[0].trim();
       contact.lastName = splitted[1].trim();
       delete contact.name;
     });
   }); 

Version 4

In the last version of our app we decide to remove the todo functionality and the todo object store. To delete a store you need to specify null as the schema definition, Dexie then deletes the store including the data.

db.version(4).stores({
  todo: null
});

Examples

In the final section we look at some examples. These are the same examples from my blog post about IndexedDB, but use Dexie instead of native IndexedDB code.


Pokémons

The first example fetches this dataset https://github.com/Biuni/PokemonGO-Pokedex/blob/master/pokedex.json, imports it into a database and runs some queries.

The application creates one object store with name pokemons and the primary key id. The data we read already contains this property so we don't have to create it as an autonumber. Furthermore it creates indexes on the type and weaknesses properties. Both properties are arrays so we must define the index as a multi entry index (*).

   const db = new Dexie('PokeDex');
  
   db.version(1).stores({
     pokemons: 'id,*type,*weaknesses'
   });

After the set up we fetch the dataset with the Fetch API and insert it with bulkPut() into the database.

   const response = await fetch(
         'https://raw.githubusercontent.com/Biuni/PokemonGO-Pokedex/master/pokedex.json');
   const json = await response.json();
   db.pokemons.bulkPut(json.pokemon);

Next we execute some queries. First we run the count() method that returns the total number of objects in the object store.

   const count = await db.pokemons.count();

The following queries use this helper function to print out the Pokémons in the console.

   function printPokemon(p) {
     console.log(`ID: ${p.id} - Name: ${p.name} - Type: ${p.type.join(', ')} 
                              - Weakness: ${p.weaknesses.join(', ')}`);
   }

The first query searches all Pokémons of type 'Grass'. Either call where() with an index name and then the comparison method (equals()). Or provide an object where the key is the index name and the value is the criteria that has to match.
Both methods are equivalent and return the same objects

   await db.pokemons.where('type').equals('Grass').each(printPokemon);
   await db.pokemons.where({ type: 'Grass'}).each(printPokemon);

The next query uses the weaknesses index to find all Pokémons that are weak against 'Flying'.

   await db.pokemons.where('weaknesses').equals('Flying').each(printPokemon);

In the next query we combine two criterias with and(). First we select all objects with type === 'Bug', this part of the query runs in IndexedDB. The second part of the criteria runs in JavaScript and selects all objects that contain 'Fire' in the weaknesses array.

await db.pokemons.where('type').equals('Bug').and(p=>p.weaknesses.includes('Fire')).each(printPokemon);

In the next example we combine two criterias with or(). or() works differently than and(). Dexie runs both queries in IndexedDB and combines the two result sets. Returns all Pokémons that are weak against Ice or Flying.

await db.pokemons.where('weaknesses').equals('Ice').or('weaknesses').equals('Flying').each(printPokemon);

Earthquakes

The next example reads a list of earthquakes that happened in the past 30 days and imports them into a database. The properties we are interested in, except the primary key id, are inside the nested properties object. The magTsunami index is a compound index that spans two properties.

   const db = new Dexie('Earthquakes');
  
   db.version(1).stores({
     earthquakes: 'id,properties.mag,properties.time,[properties.tsunami+properties.mag]'
   });

After we created the Dexie object and defined the schema we fetch the dataset and import it into the database with bulkPut()

   const response = await fetch(
          'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_month.geojson');
   const json = await response.json();
   db.earthquakes.bulkPut(json.features);

Then we count all the objects in the database

   const count = await db.earthquakes.count();

The next query lists all earthquakes with a magnitude of 6 or higher

   db.earthquakes.where('properties.mag').aboveOrEqual(6).each(printEarthquake);

The next query lists all earthquakes that happened during the last 6 hours.

   const sixHoursAgoInMillis = Date.now() - (6*60*60*1000);
   db.earthquakes.where('properties.time').aboveOrEqual(sixHoursAgoInMillis).each(printEarthquake);

The next query returns only one result, the earthquake with the highest magnitude. For that we take advantage of the fact that indexes are sorted so we can access the last object referenced by the magnitude index.

   const earthquake = await db.earthquakes.orderBy('properties.mag').last();
   printEarthquake(earthquake);

In the last query we use the compound index and print out all earthquakes with a magnitude of 5 (inclusive) and 5.5 (exclusive) and probably caused a tsunami (tsunami === 1). When you use compound indexes you need to make sure that you specify the query values in the same order as the properties are defined in the index.

   await db.earthquakes.where('[properties.tsunami+properties.mag]').between([1, 5], [1, 5.5])
                       .each(printEarthquake);

The previous examples use this helper method to print out the objects.


   function printEarthquake(e) {
     console.log(`Time: ${new Date(e.properties.time)} - Magnitude: ${e.properties.mag} 
                                                       - ${e.properties.place}`);
   }

These examples showed you how Dexie makes working with IndexedDB much more easier and requires less code. Queries are easier to implement and are more concise than their native IndexedDB counterparts (see my previous blog post for comparison).

This blog post does not cover everything that Dexie has to offer.
See the official documentation to learn more about Dexie.