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:
- Write your code in such a way that it asks for an
IndexedDb
instance as a normal function argument - Create a
FakeIndexedDb()
instance - Simply provide the
FakeIndexedDb()
to your main code - (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))
}
}
}
}