IndexedDb Example

Below will demonstrate a few different ways of using the IndexedDB API. If you're impatient feel free to jump straight to the Usage section.

Models

For our demo, we start with a few model classes.

package japgolly.webapputil.examples.indexeddb

import java.util.UUID

// Let's say these are the data types we want to store in IndexedDb...
final case class PersonId(value: UUID)
final case class Person(id: PersonId, name: String, age: Int)

Stores

Now we'll define our IndexedDB stores (like DB tables) and demonstrate some nice features like data compression and encryption.

package japgolly.webapputil.examples.indexeddb

import boopickle.DefaultBasic._
import japgolly.scalajs.react.{AsyncCallback, Callback}
import japgolly.webapputil.binary._
import japgolly.webapputil.boopickle._
import japgolly.webapputil.indexeddb._

// Our example IndexedDB's stores (like DB tables)
trait IDBExampleStores {
  import IDBExampleStores.{dbName, version}

  // Initialises or upgrades the IndexedDB database
  protected def onUpgradeNeeded(c: IndexedDb.VersionChange): Callback

  // Our IndexedDB object-store for storing our people data.
  // This is an async store because we'll compress and encrypt data before storing it;
  // compression and encryption are async operations so we define an async object-store.
  val people: ObjectStoreDef.Async[PersonId, Person]

  // We'll also create two separate stores to later demonstrate how to use transactions
  val pointsEarned : ObjectStoreDef.Sync[PersonId, Int]
  val pointsPending: ObjectStoreDef.Sync[PersonId, Int]

  // Convenience function to open a connection to the IndexedDB database
  final def open(idb: IndexedDb): AsyncCallback[IndexedDb.Database] =
    idb.open(dbName, version)(IndexedDb.OpenCallbacks(onUpgradeNeeded))
}

// =====================================================================================
object IDBExampleStores {

  // The name of our IndexedDB database
  val dbName = IndexedDb.DatabaseName("demo")

  // The version of our combined protocols for this IndexedDB database
  val version = 1

  // Here we'll define how to convert from our data types to IndexedDB values and back.
  // We'll just define the binary formats, compression and encryption come later.
  private[IDBExampleStores] object Picklers {
    import SafePickler.ConstructionHelperImplicits._

    // This is a binary codec using the Boopickle library
    private implicit def picklerPersonId: Pickler[PersonId] =
      transformPickler(PersonId.apply)(_.value)

    // This is a binary codec using the Boopickle library
    private implicit def picklerPerson: Pickler[Person] =
      new Pickler[Person] {
        override def pickle(a: Person)(implicit state: PickleState): Unit = {
          state.pickle(a.id)
          state.pickle(a.name)
          state.pickle(a.age)
        }
        override def unpickle(implicit state: UnpickleState): Person = {
          val id   = state.unpickle[PersonId]
          val name = state.unpickle[String]
          val age  = state.unpickle[Int]
          Person(id, name, age)
        }
      }

    // Where `Pickler` comes from Boopickle, `SafePickler` is defined here in
    // webapp-util and provides some additional features.
    implicit def safePicklerPerson: SafePickler[Person] =
      picklerPerson
        .asV1(0)                                  // This is v1.0 of our data format.
        .withMagicNumbers(0x8CF0655B, 0x5A8218EB) // Add some header/footer values for
                                                  //   a bit of extra integrity.
  }

  // This is how we finally create an IDBExampleStores instance.
  //
  // @param encryption A means of binary encryption and decryption.
  //                   The encryption key isn't provided directly, it will be in the
  //                   Encryption instance.
  //
  // @param pako An instance of Pako, a JS zlib/compression library.
  //
  def apply(encryption: Encryption)(implicit pako: Pako): IDBExampleStores =
    new IDBExampleStores {
      import Picklers._

      // Here we configure our compression preferences:
      //   - use maximum compression (i.e. set compression level to 9)
      //   - opt-out of using zlib headers
      private val compression: Compression =
        Compression.ViaPako.maxWithoutHeaders

      // Convert from PersonId to raw JS IndexedDB keys
      private val keyCodec: KeyCodec[PersonId] =
        KeyCodec.uuid.xmap(PersonId.apply)(_.value)

      // Here we define our ObjectStore for storing our collection of people.
      // This ties everything together.
      override val people: ObjectStoreDef.Async[PersonId, Person] = {

        // Our all-things-considered binary format for storing Person instances
        def valueFormat: BinaryFormat[Person] =
          // Declare that we want to support binary format evolution.
          // Eg. in the future if we were to add a new field to Person, we'd need a new
          // v1.1 format, but we'd still need to support deserialising data stored with
          // the v1.0 format.
          BinaryFormat.versioned(
            // v1.0: Use implicit SafePickler[Person] then compress & encrypt the binary
            BinaryFormat.pickleCompressEncrypt[Person](compression, encryption),
            // Our hypothetical future v1.1 protocol would be here
            // Our hypothetical future v1.2 protocol would be here
            // etc
          )

        // Convert from Person to raw JS IndexedDB values
        def valueCodec: ValueCodec.Async[Person] =
          ValueCodec.Async.binary(valueFormat)

        // Finally we create the store definition itself
        ObjectStoreDef.Async("people", keyCodec, valueCodec)
      }

      override val pointsEarned: ObjectStoreDef.Sync[PersonId, Int] =
        ObjectStoreDef.Sync("pointsEarned", keyCodec, ValueCodec.int)

      override val pointsPending: ObjectStoreDef.Sync[PersonId, Int] =
        ObjectStoreDef.Sync("pointsPending", keyCodec, ValueCodec.int)

      override protected def onUpgradeNeeded(c: IndexedDb.VersionChange): Callback =
        // This is where we initialise/migrate the database.
        //
        // This is standard logic for working with IndexedDb.
        // Please google "IndexedDb upgradeneeded" for more detail.
        Callback.runAll(
          c.createObjectStore(people,        createdInDbVer = 1),
          c.createObjectStore(pointsEarned,  createdInDbVer = 1),
          c.createObjectStore(pointsPending, createdInDbVer = 1),
        )
    }
}

Usage

Here we get to actual usage.

package japgolly.webapputil.examples.indexeddb

import japgolly.scalajs.react.AsyncCallback
import japgolly.webapputil.binary._
import japgolly.webapputil.boopickle._
import japgolly.webapputil.indexeddb.IndexedDb
import java.util.UUID

object IDBExample {

  // Firstly, let's setup our dependencies.

  // 1) We need an instance of `window.indexeddb`
  val idb = IndexedDb.global()
    .getOrElse(throw new RuntimeException("indexeddb is not available"))

  // 2) We need an instance of `window.crypto`
  val encryptionEngine = EncryptionEngine.global
    .getOrElse(throw new RuntimeException("crypto is not available"))

  // 3) We need an instance of JS library `Pako` for compression
  implicit def pako: Pako = Pako.global

  // 4) We need an encryption key
  val encKey = BinaryData.fromStringAsUtf8("!" * 32)

  // Next, let's create some sample data
  val bobId  = PersonId(UUID.fromString("174b625b-9057-4d64-a92e-dee2fad89d27"))
  val bob    = Person(bobId, "Bob Loblaw", 100)

  // Now, we arrive at our examples:
  def examples: AsyncCallback[Unit] =
    for {
      enc  <- encryptionEngine(encKey) // initialise our encryption
      s     = IDBExampleStores(enc)    // initialise our stores
      db   <- s.open(idb)              // open and initialise the DB

      // ===============================================================================
      // Example 1: Simple usage
      //
      // All encoding, data compression, and encryption is handled automatically via
      // the `people` store.

      _    <- db.put(s.people)(bob.id, bob) // save a Person instance
      bob2 <- db.get(s.people)(bob.id)      // load a Person instance
      _     = assert(bob2 == Some(bob))

      // ===============================================================================
      // Example 2: Atomic modification
      //
      // In the above example, both loading and saving occur in separate transactions.
      // If one were to attempt to modify a stored Person via a get and then a set,
      // it would similarly result in two separate transactions, which would in turn
      // allow bugs if another instance of your app (eg. another browser tab or a web
      // worker) changes the database between the two transactions.
      //
      // DSL exists for working within a transaction however, due to constraints in
      // IndexedDB itself, no arbitrary async processing can occur mid-transaction.
      // We're using compression and encryption in this demo, both of which are async
      // and by necessity, must be performed outside of an IndexedDB transaction.
      // In our example above, a transaction is opened and closed automatically on both
      //  db.put() and db.get(). Thus modification via get and put wouldn't be atomic.
      //
      // Here we'll use db.atomic() to modify a person atomically, despite the necessity
      // for multiple IDB transactions. Under-the-hood, db.atomic() will call
      // db.compareAndSet() which is able to detect when external changes to the DB
      // occur, automatically retry, and only make changes when we've detected it's
      // safe to do so, and we know they're correct.

      _ <- db.atomic(s.people).modify(bob.id)(x => x.copy(age = x.age + 1))

      // ===============================================================================
      // Example 3: Transaction
      //
      // This example locks two stores into a single transaction. It atomically moves
      // pending points into Bob's earned points.

      // The RW here means "Read/Write"
      _ <- db.transactionRW(s.pointsEarned, s.pointsPending) { txn =>
        for {
          pending <- txn.objectStore(s.pointsPending)
          earned  <- txn.objectStore(s.pointsEarned)
          m       <- earned .get(bob.id).map(_ getOrElse 0)
          n       <- pending.get(bob.id).map(_ getOrElse 0)
          _       <- earned .put(bob.id, m + n)
          _       <- pending.put(bob.id, 0)
        } yield ()
      }

    } yield ()
}

Testing

Here's a quick test that demonstrates we can save and load a Person.

To test your IndexedDb code, you would typically:

  1. Write your code in such a way that it asks for an IndexedDb instance as a normal function argument
  2. Create a FakeIndexedDb() instance
  3. Simply provide the FakeIndexedDb() to your main code
  4. (Optionally) inspect the DB contents directly by using the IndexedDb API as normal
package japgolly.webapputil.examples.indexeddb

import japgolly.webapputil.binary._
import japgolly.webapputil.boopickle.test._
import japgolly.webapputil.indexeddb._
import japgolly.webapputil.test.node.TestNode.asyncTest
import java.util.UUID
import utest._

object IDBExampleTest extends TestSuite {

  private implicit def idb: IndexedDb = FakeIndexedDb()
  private implicit def pako: Pako = Pako.global

  private val encKey = BinaryData.fromStringAsUtf8("?" * 32)
  private val stores = TestEncryption(encKey).map(IDBExampleStores(_))
  private val bob    = Person(PersonId(UUID.randomUUID()), "Bob Loblaw", 100)

  override def tests = Tests {

    "saveAndReload" - asyncTest() {
      for {
        s    <- stores
        db   <- TestIndexedDb(s.people)
        _    <- db.put(s.people)(bob.id, bob) // save a Person instance
        bob2 <- db.get(s.people)(bob.id)      // load a Person instance
      } yield {
        assert(bob2 == Some(bob))
      }
    }

  }
}