Kotlin IndexedDB
A wrapper around IndexedDB which allows for access from Kotlin/JS code using suspend
blocks and linear, non-callback based control flow.
Usage
The samples for usage here loosely follows several examples in Using IndexedDB.
As such, we'll define our example data type to match:
external interface Customer {
var ssn: String
var name: String
var age: Int
var email: String
}
Creation & Migration
Creating a Database
and handling migrations are done together with the openDatabase
function. The database name and desired version are passed in as arguments. If the desired version and the current version match, then the callback is not called. Otherwise, the callback is called in a VersionChangeTransaction
scope. Generally, a chain of if
blocks checking the oldVersion
are sufficient for handling migrations, including migration from version 0
to 1
:
val database = openDatabase("your-database-name", 1) { database, oldVersion, newVersion ->
if (oldVersion < 1) {
val store = database.createObjectStore("customers", KeyPath("ssn"))
store.createIndex("name", KeyPath("name"), unique = false)
store.createIndex("age", KeyPath("age"), unique = false)
store.createIndex("email", KeyPath("email"), unique = true)
}
}
Transactions, such as the lambda block of openData
, are handled as suspend
functions but with an important constraint: you must not call any suspend
functions except for those provided by this library and scoped on Transaction
(and its subclasses), and flow operations on the flow returned by Transaction.openCursor
. Of course, it is also okay to call suspend
functions which only suspend by calling other legal functions.
This constraint is forced by the design of IndexedDB auto-committing transactions when it detects no remaining callbacks, and failure to adhere to this can cause TransactionInactiveError
to be thrown.
Writing Data
To add data to the Database
created above, open a WriteTransaction
, and then open the ObjectStore
. Use WriteTransaction.add
to guarantee insert-only behavior, and use WriteTransaction.put
for insert-or-update.
Note that transactions must explicitly request every ObjectStore
they reference at time of opening the transaction, even if the store is only used conditionally. Multiple WriteTransaction
which share referenced ObjectStore
will not be executed concurrently.
database.writeTransaction("customers") {
val store = objectStore("customers")
store.add(jsObject<Customer> { ssn = "333-33-3333"; name = "Alice"; age = 33; email = "[email protected]" })
store.add(jsObject<Customer> { ssn = "444-44-4444"; name = "Bill"; age = 35; email = "[email protected]" })
store.add(jsObject<Customer> { ssn = "555-55-5555"; name = "Charlie"; age = 29; email = "[email protected]" })
store.add(jsObject<Customer> { ssn = "666-66-6666"; name = "Donna"; age = 31; email = "[email protected]" })
}
Reading Data
To read data, open a Transaction
, and then open the ObjectStore
. Use Transaction.get
and Transaction.getAll
to retrieve single items and retrieve bulk items, respectively.
As above, all object stores potentially used must be specified in advance. Unlike WriteTransaction
, multiple read-only Transaction
which share an ObjectStore
can operate concurrently, but they still cannot operate concurrently with a WriteTransaction
sharing that store.
val bill = database.transaction("customers") {
objectStore("customers").get(Key("444-44-4444")) as Customer
}
assertEquals("Bill", bill.name)
Key Ranges and Indices
With an ObjectStore
you can query on a previously created Index
instead of the primary key. This is especially useful in combination with key ranges, and together more powerful queries can be constructed.
Three standard key ranges exist: lowerBound
, upperBound
, and bound
(which combines the two). Warning: key range behavior on an array-typed index can have potentially unexpected behavior. As an example, the key [3, 0]
is included in bound(arrayOf(2, 2), arrayOf(4, 4))
.
val donna = database.transaction("customers") {
objectStore("customers").index("age").get(bound(30, 32)) as Customer
}
assertEquals("Donna", donna.name)
Cursors
Cursors are excellent for optimizing complex queries. With either ObjectStore
or Index
, call Transaction.openCursor
to return a Flow
of CursorWithValue
which emits once per row matching the query. The returned flow is cold and properly handles early collection termination. To get the value of the row currently pointed at by the cursor, call CursorWithValue.value
.
As an example we can find the first customer alphabetically with an age under 32:
val charlie = database.transaction("customers") {
objectStore("customers")
.index("name")
.openCursor()
.map { it.value as Customer }
.first { it.age < 32 }
}
assertEquals("Charlie", charlie.name)
Cursors can also be used to update or delete the value at the current index by calling WriteTransaction.update
and WriteTransaction.delete
, respectively.
Setup
Gradle
IndexedDB can be configured via Gradle Kotlin DSL as follows:
repositories {
mavenCentral()
}
dependencies {
implementation("com.juul.indexeddb:core:$version")
}
If you prefer to work with the raw JavaScript API instead of the suspend
-type wrappers, replace the implementation with com.juul.indexeddb:external:$version
.
License
Copyright 2021 JUUL Labs, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.