Skip to content

JuulLabs/indexeddb

Repository files navigation

badge Slack

Kotlin IndexedDB

A wrapper around IndexedDB which allows for access from Kotlin/JS and Kotlin/WASM code using suspend blocks and linear, non-callback based control flow.

Migration Notes

In IndexedDB 0.12.0, support for Kotlin/WASM was added. This change removed all usages of dynamic in favor of JsAny. Often JsAny will be expressed via a typealias, such as IDBKey, where the alias name and documentation can provide additional context for expected values of that type.

On Kotlin/JS, JsAny is a typealias for Any so no changes are required for inputs to IndexedDB. However, outputs from IndexedDB might require casting via asDynamic() if you access fields on them directly. If you already defined external interfaces as interop types and cast outputs to those types, you are likely insulated from most changes.

On Kotlin/WASM (or common code targeting both JS and WASM), JsAny is a genuine type bound that must be satisfied. This means that external interfaces must extend from JsAny to be used as inputs or cast from outputs.

Usage

The samples for usage here loosely follows several examples in Using IndexedDB. As such, we'll define our example data type to match.

Important: our database type is defined as an external interface, which guarantees that the Kotlin compiler emits a plain JavaScript object for it.

external interface JsCustomer : JsAny {
    var ssn: String
    var name: String
    var age: Int
    var email: String
}

Because external interfaces are missing several niceties you might expect from a data class, it often makes sense to define a data class with interop functions to convert between Kotlin-first and JS-first representations.

Click to expand
private fun <T: JsAny> emptyObject(): T = js("{}") as T

data class Customer(
    val ssn: String,
    val name: String,
    val age: Int,
    val email: String,
) {

    constructor(js: JsCustomer) : this(
        ssn = js.ssn,
        name = js.name,
        age = js.age,
        email = js.email,
    )

    fun toJs(): JsCustomer {
        val js = emptyObject<JsCustomer>()
        js.ssn = ssn
        js.name = name
        js.age = age
        js.email = email
        return js
    }
}

fun JsCustomer.toKotlin(): Customer = Customer(this)

Note that JsAny and friends are currently considered experimental. To opt-in to these project wide, add the following to your gradle.properties:

kotlin {
    sourceSets {
        all {
            compilerOptions.optIn.add("kotlin.js.ExperimentalWasmJsInterop")
        }
    }
}

Available Types

Care must be taken to ensure that all fields passed into IndexedDB are of types understood by the database.

For keys, JavaScript's string, date, number, Blob, and arrays of these types may be used. Some additional types can be used inside of values: boolean, object, regexp, undefined, and null.

Inside of an external interface, Kotlin will reject most types at compile time. However, some types are allowed by the compiler that are extremely likely to crash at runtime (such as Long, which compiles into the unsupported bigint). Note that usage of incorrect types is not currently detected by this library. Type related failures are likely to manifest as either class cast exceptions or internal errors directly from JavaScript.

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(Customer(ssn = "333-33-3333", name = "Alice", age = 33, email = "alice@company.com").toJs())
    store.add(Customer(ssn = "444-44-4444", name = "Bill", age = 35, email = "bill@company.com").toJs())
    store.add(Customer(ssn = "555-55-5555", name = "Charlie", age = 29, email = "charlie@home.org").toJs())
    store.add(Customer(ssn = "666-66-6666", name = "Donna", age = 31, email = "donna@home.org").toJs())
}

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 JsCustomer
}.toKotlin()
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.toJsNumber(), 32.toJsNumber())) as JsCustomer
}.toKotlin()
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(autoContinue = true)
        .map { it.value as JsCustomer }
        .first { it.age < 32.toJsNumber() }
}.toKotlin()
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

Maven Central

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 2026 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.

About

A Kotlin coroutines wrapper for IndexedDB.

Topics

Resources

License

Stars

Watchers

Forks

Contributors 10

Languages