Skip to content

Commit

Permalink
Initial port for wasmJs
Browse files Browse the repository at this point in the history
  • Loading branch information
eygraber committed Aug 30, 2024
1 parent a8bb7db commit 7c2f77d
Show file tree
Hide file tree
Showing 39 changed files with 1,439 additions and 23 deletions.
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
![badge][badge-js]
![badge][badge-wasmJs]
[![Slack](https://img.shields.io/badge/Slack-%23juul--libraries-ECB22E.svg?logo=data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTQgNTQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMTkuNzEyLjEzM2E1LjM4MSA1LjM4MSAwIDAgMC01LjM3NiA1LjM4NyA1LjM4MSA1LjM4MSAwIDAgMCA1LjM3NiA1LjM4Nmg1LjM3NlY1LjUyQTUuMzgxIDUuMzgxIDAgMCAwIDE5LjcxMi4xMzNtMCAxNC4zNjVINS4zNzZBNS4zODEgNS4zODEgMCAwIDAgMCAxOS44ODRhNS4zODEgNS4zODEgMCAwIDAgNS4zNzYgNS4zODdoMTQuMzM2YTUuMzgxIDUuMzgxIDAgMCAwIDUuMzc2LTUuMzg3IDUuMzgxIDUuMzgxIDAgMCAwLTUuMzc2LTUuMzg2IiBmaWxsPSIjMzZDNUYwIi8+PHBhdGggZD0iTTUzLjc2IDE5Ljg4NGE1LjM4MSA1LjM4MSAwIDAgMC01LjM3Ni01LjM4NiA1LjM4MSA1LjM4MSAwIDAgMC01LjM3NiA1LjM4NnY1LjM4N2g1LjM3NmE1LjM4MSA1LjM4MSAwIDAgMCA1LjM3Ni01LjM4N20tMTQuMzM2IDBWNS41MkE1LjM4MSA1LjM4MSAwIDAgMCAzNC4wNDguMTMzYTUuMzgxIDUuMzgxIDAgMCAwLTUuMzc2IDUuMzg3djE0LjM2NGE1LjM4MSA1LjM4MSAwIDAgMCA1LjM3NiA1LjM4NyA1LjM4MSA1LjM4MSAwIDAgMCA1LjM3Ni01LjM4NyIgZmlsbD0iIzJFQjY3RCIvPjxwYXRoIGQ9Ik0zNC4wNDggNTRhNS4zODEgNS4zODEgMCAwIDAgNS4zNzYtNS4zODcgNS4zODEgNS4zODEgMCAwIDAtNS4zNzYtNS4zODZoLTUuMzc2djUuMzg2QTUuMzgxIDUuMzgxIDAgMCAwIDM0LjA0OCA1NG0wLTE0LjM2NWgxNC4zMzZhNS4zODEgNS4zODEgMCAwIDAgNS4zNzYtNS4zODYgNS4zODEgNS4zODEgMCAwIDAtNS4zNzYtNS4zODdIMzQuMDQ4YTUuMzgxIDUuMzgxIDAgMCAwLTUuMzc2IDUuMzg3IDUuMzgxIDUuMzgxIDAgMCAwIDUuMzc2IDUuMzg2IiBmaWxsPSIjRUNCMjJFIi8+PHBhdGggZD0iTTAgMzQuMjQ5YTUuMzgxIDUuMzgxIDAgMCAwIDUuMzc2IDUuMzg2IDUuMzgxIDUuMzgxIDAgMCAwIDUuMzc2LTUuMzg2di01LjM4N0g1LjM3NkE1LjM4MSA1LjM4MSAwIDAgMCAwIDM0LjI1bTE0LjMzNi0uMDAxdjE0LjM2NEE1LjM4MSA1LjM4MSAwIDAgMCAxOS43MTIgNTRhNS4zODEgNS4zODEgMCAwIDAgNS4zNzYtNS4zODdWMzQuMjVhNS4zODEgNS4zODEgMCAwIDAtNS4zNzYtNS4zODcgNS4zODEgNS4zODEgMCAwIDAtNS4zNzYgNS4zODciIGZpbGw9IiNFMDFFNUEiLz48L2c+PC9zdmc+&labelColor=611f69)](https://kotlinlang.slack.com/messages/juul-libraries/)

# Kotlin IndexedDB

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

## Usage

Expand Down Expand Up @@ -184,4 +185,5 @@ limitations under the License.
[IndexedDB]: https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API
[Using IndexedDB]: https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB
[//]: # (Images)
[badge-js]: http://img.shields.io/badge/platform-js-F8DB5D.svg?style=flat
[badge-js]: https://img.shields.io/badge/platform-js-F8DB5D.svg?style=flat
[badge-wasmJs]: https://img.shields.io/badge/platform-wasmJs-F8DB5D.svg?style=flat
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ plugins {
}

tasks.dokkaHtmlMultiModule.configure {
outputDirectory.set(buildDir.resolve("gh-pages"))
outputDirectory.set(layout.buildDirectory.dir("gh-pages"))
}

allprojects {
Expand Down
42 changes: 25 additions & 17 deletions core/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl

plugins {
kotlin("multiplatform")
id("org.jmailen.kotlinter")
Expand All @@ -13,30 +15,36 @@ kotlin {
binaries.library()
}

@OptIn(ExperimentalWasmDsl::class)
wasmJs {
browser()
binaries.library()
}

sourceSets {
val commonMain by getting {
dependencies {
api(libs.coroutines.core)
}
commonMain.dependencies {
api(libs.coroutines.core)
}

commonTest.dependencies {
implementation(kotlin("test-common"))
implementation(kotlin("test-annotations-common"))
}

jsMain.dependencies {
implementation(project(":external"))
}

val commonTest by getting {
dependencies {
implementation(kotlin("test-common"))
implementation(kotlin("test-annotations-common"))
}
jsTest.dependencies {
implementation(kotlin("test-js"))
}

val jsMain by getting {
dependencies {
implementation(project(":external"))
}
wasmJsMain.dependencies {
implementation(project(":external"))
}

val jsTest by getting {
dependencies {
implementation(kotlin("test-js"))
}
wasmJsTest.dependencies {
implementation(kotlin("test-wasm-js"))
}
}
}
53 changes: 53 additions & 0 deletions core/src/wasmJsMain/kotlin/Cursor.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.juul.indexeddb

import com.juul.indexeddb.external.IDBCursor
import com.juul.indexeddb.external.IDBCursorWithValue
import kotlinx.coroutines.channels.SendChannel

public open class Cursor internal constructor(
internal open val cursor: IDBCursor,
private val channel: SendChannel<*>,
) {
public val key: JsAny
get() = cursor.key

public val primaryKey: JsAny
get() = cursor.primaryKey

public fun close() {
channel.close()
}

public fun `continue`() {
cursor.`continue`()
}

public fun advance(count: Int) {
cursor.advance(count)
}

public fun `continue`(key: Key) {
cursor.`continue`(key.toJs())
}

public fun continuePrimaryKey(key: Key, primaryKey: Key) {
cursor.continuePrimaryKey(key.toJs(), primaryKey.toJs())
}

public enum class Direction(
internal val constant: String,
) {
Next("next"),
NextUnique("nextunique"),
Previous("prev"),
PreviousUnique("prevunique"),
}
}

public class CursorWithValue internal constructor(
override val cursor: IDBCursorWithValue,
channel: SendChannel<*>,
) : Cursor(cursor, channel) {
public val value: JsAny?
get() = cursor.value
}
33 changes: 33 additions & 0 deletions core/src/wasmJsMain/kotlin/CursorStart.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.juul.indexeddb

import com.juul.indexeddb.external.IDBCursor

public sealed class CursorStart {

internal abstract fun apply(cursor: IDBCursor)

public data class Advance(
val count: Int,
) : CursorStart() {
override fun apply(cursor: IDBCursor) {
cursor.advance(count)
}
}

public data class Continue(
val key: Key,
) : CursorStart() {
override fun apply(cursor: IDBCursor) {
cursor.`continue`(key.toJs())
}
}

public data class ContinuePrimaryKey(
val key: Key,
val primaryKey: Key,
) : CursorStart() {
override fun apply(cursor: IDBCursor) {
cursor.continuePrimaryKey(key.toJs(), primaryKey.toJs())
}
}
}
150 changes: 150 additions & 0 deletions core/src/wasmJsMain/kotlin/Database.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package com.juul.indexeddb

import com.juul.indexeddb.external.IDBDatabase
import com.juul.indexeddb.external.IDBFactory
import com.juul.indexeddb.external.IDBTransactionDurability
import com.juul.indexeddb.external.IDBTransactionOptions
import com.juul.indexeddb.external.IDBVersionChangeEvent
import com.juul.indexeddb.external.indexedDB
import kotlinx.browser.window
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

/**
* Inside the [initialize] block, you must not call any `suspend` functions except for:
* - those provided by this library and scoped on [Transaction] (and its subclasses)
* - flow operations on the flows returns by [Transaction.openCursor] and [Transaction.openKeyCursor]
* - `suspend` functions composed entirely of other legal functions
*/
public suspend fun openDatabase(
name: String,
version: Int,
initialize: suspend VersionChangeTransaction.(
database: Database,
oldVersion: Int,
newVersion: Int,
) -> Unit,
): Database = withContext(Dispatchers.Unconfined) {
val indexedDB: IDBFactory? = selfIndexedDB
val factory = checkNotNull(indexedDB) { "Your browser doesn't support IndexedDB." }
val request = factory.open(name, version)
val versionChangeEvent = request.onNextEvent("success", "upgradeneeded", "error", "blocked") { event ->
when (event.type) {
"upgradeneeded" -> event as IDBVersionChangeEvent
"error" -> throw ErrorEventException(event)
"blocked" -> throw OpenBlockedException(name, event)
else -> null
}
}
Database(request.result).also { database ->
if (versionChangeEvent != null) {
val transaction = VersionChangeTransaction(checkNotNull(request.transaction))
transaction.initialize(
database,
versionChangeEvent.oldVersion,
versionChangeEvent.newVersion,
)
transaction.awaitCompletion()
}
}
}

public suspend fun deleteDatabase(name: String) {
val factory = checkNotNull(window.indexedDB) { "Your browser doesn't support IndexedDB." }
val request = factory.deleteDatabase(name)
request.onNextEvent("success", "error", "blocked") { event ->
when (event.type) {
"error", "blocked" -> throw ErrorEventException(event)
else -> null
}
}
}

public class Database internal constructor(
database: IDBDatabase,
) {
private var database: IDBDatabase? = database

init {
// listen for database structure changes (e.g., upgradeneeded while DB is open or deleteDatabase)
database.addEventListener("versionchange") { close() }
// listen for force close, e.g., browser profile on a USB drive that's ejected or db deleted through dev tools
database.addEventListener("close") { close() }
}

internal fun ensureDatabase(): IDBDatabase = checkNotNull(database) { "database is closed" }

/**
* Inside the [action] block, you must not call any `suspend` functions except for:
* - those provided by this library and scoped on [Transaction] (and its subclasses)
* - flow operations on the flows returns by [Transaction.openCursor] and [Transaction.openKeyCursor]
* - `suspend` functions composed entirely of other legal functions
*/
public suspend fun <T> transaction(
vararg store: String,
durability: IDBTransactionDurability = IDBTransactionDurability.Default,
action: suspend Transaction.() -> T,
): T = withContext(Dispatchers.Unconfined) {
check(store.isNotEmpty()) {
"At least one store needs to be passed to transaction"
}

val transaction = Transaction(
ensureDatabase().transaction(
storeNames = ReadonlyArray(
*store.map { it.toJsString() }.toTypedArray(),
),
mode = "readonly",
options = IDBTransactionOptions(durability),
),
)
val result = transaction.action()
transaction.awaitCompletion()
result
}

/**
* Inside the [action] block, you must not call any `suspend` functions except for:
* - those provided by this library and scoped on [Transaction] (and its subclasses)
* - flow operations on the flows returns by [Transaction.openCursor] and [Transaction.openKeyCursor]
* - `suspend` functions composed entirely of other legal functions
*/
public suspend fun <T> writeTransaction(
vararg store: String,
durability: IDBTransactionDurability = IDBTransactionDurability.Default,
action: suspend WriteTransaction.() -> T,
): T = withContext(Dispatchers.Unconfined) {
check(store.isNotEmpty()) {
"At least one store needs to be passed to writeTransaction"
}

val transaction = WriteTransaction(
ensureDatabase()
.transaction(
storeNames = ReadonlyArray(
*store.map { it.toJsString() }.toTypedArray(),
),
mode = "readwrite",
options = IDBTransactionOptions(durability),
),
)

with(transaction) {
// Force overlapping transactions to not call `action` until prior transactions complete.
objectStore(store.first())
.openKeyCursor(autoContinue = false)
.collect { it.close() }
}
val result = transaction.action()
transaction.awaitCompletion()
result
}

public fun close() {
database?.close()
database = null
}
}

@Suppress("RedundantNullableReturnType")
private val selfIndexedDB: IDBFactory? = js("self.indexedDB || self.webkitIndexedDB")
25 changes: 25 additions & 0 deletions core/src/wasmJsMain/kotlin/Exceptions.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.juul.indexeddb

import org.w3c.dom.events.Event

public abstract class EventException(
message: String?,
cause: Throwable?,
public val event: Event,
) : Exception(message, cause)

public class EventHandlerException(
cause: Throwable?,
event: Event,
) : EventException("An inner exception was thrown: $cause", cause, event)

public class ErrorEventException(
event: Event,
) : EventException("An error event was received.", cause = null, event)
public class OpenBlockedException(
public val name: String,
event: Event,
) : EventException("Resource in use: $name.", cause = null, event)
public class AbortTransactionException(
event: Event,
) : EventException("Transaction aborted while waiting for completion.", cause = null, event)
25 changes: 25 additions & 0 deletions core/src/wasmJsMain/kotlin/Index.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.juul.indexeddb

import com.juul.indexeddb.external.IDBCursor
import com.juul.indexeddb.external.IDBCursorWithValue
import com.juul.indexeddb.external.IDBIndex
import com.juul.indexeddb.external.ReadonlyArray

public class Index internal constructor(
internal val index: IDBIndex,
) : Queryable() {
override fun requestGet(key: Key): Request<*> =
Request(index.get(key.toJs()))

override fun requestGetAll(query: Key?): Request<ReadonlyArray<*>> =
Request(index.getAll(query?.toJs()))

override fun requestOpenCursor(query: Key?, direction: Cursor.Direction): Request<IDBCursorWithValue?> =
Request(index.openCursor(query?.toJs(), direction.constant))

override fun requestOpenKeyCursor(query: Key?, direction: Cursor.Direction): Request<IDBCursor?> =
Request(index.openKeyCursor(query?.toJs(), direction.constant))

override fun requestCount(query: Key?): Request<JsNumber> =
Request(index.count(query?.toJs()))
}
Loading

0 comments on commit 7c2f77d

Please sign in to comment.