Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Wasm Support for kotlin-document-store #47

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion stores/browser/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,42 @@ kotlin {
}
}
}
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs {
browser {
testTask {
useKarma {
useChromeHeadless()
}
}
}
}
sourceSets {
jsMain {

val webMain by creating {
dependsOn(commonMain.get())
dependencies {
api(npm("idb-keyval", "6.2.1"))
api(projects.core)
}
}
jsMain {
dependsOn(webMain)

}
wasmJsMain {
dependsOn(webMain)

}
jsTest {
dependencies {
implementation(projects.tests)
}
}
wasmJsTest {
dependencies {
implementation(projects.tests)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.github.lamba92.kotlin.document.store.stores.browser

import com.github.lamba92.kotlin.document.store.core.AbstractDataStore
import com.github.lamba92.kotlin.document.store.core.DataStore
import com.github.lamba92.kotlin.document.store.core.PersistentMap
import keyval.delMany
import keyval.keys
import kotlinx.coroutines.await

/**
* Implementation of the [DataStore] for use in web browsers.
*
* `BrowserStore` uses `IndexedDB` as the underlying storage mechanism, providing
* persistent key-value storage in the user's browser. It is designed for use in
* web applications that require durable storage across browser sessions.
*
* This class supports the creation, retrieval, and deletion of named maps, where
* each map is implemented as an [IndexedDBMap]. Concurrency and synchronization
* are managed using locks to ensure thread safety during access to individual maps.
*
* This implementation extends [AbstractDataStore], inheriting utility methods for
* managing locks and operations related to the data store.
*/
public object BrowserStore : AbstractDataStore() {
override suspend fun getMap(name: String): PersistentMap<String, String> = withStoreLock { IndexedDBMap(name, getMutex(name)) }

override suspend fun deleteMap(name: String): Unit =
withStoreLock {
lockAndRemoveMutex(name) {
keys()
.await<JsArray<JsString>>()
.toList()
.filter { it.toString().startsWith(IndexedDBMap.buildPrefix(name)) }
.let { delMany(it.toJsArray()).await() }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package com.github.lamba92.kotlin.document.store.stores.browser

import com.github.lamba92.kotlin.document.store.core.PersistentMap
import com.github.lamba92.kotlin.document.store.core.SerializableEntry
import com.github.lamba92.kotlin.document.store.core.UpdateResult
import keyval.del
import keyval.delMany
import keyval.keys
import keyval.set
import kotlinx.coroutines.await
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

/**
* A browser-based implementation of the `DataStore` that uses `IndexedDB` for persistent storage.
*
* The `BrowserStore` enables web applications to store and manage named maps persistently
* in a client-side database (IndexedDB). It supports creating, retrieving, and deleting
* persistent maps, while ensuring thread safety through synchronization mechanisms.
*
* Each persistent map is backed by an `IndexedDBMap`, which provides efficient key-value
* storage and ensures data durability across browser sessions.
*
* This implementation is ideal for client-side scenarios where durable, structured storage
* is required in the browser environment.
*/
public class IndexedDBMap(
private val name: String,
private val mutex: Mutex,
) : PersistentMap<String, String> {
public companion object {
private const val SEPARATOR = "."

internal fun buildPrefix(name: String) = "$name$SEPARATOR"
}

private val prefixed
get() = buildPrefix(name)

private fun String.prefixed() = "$prefixed$this"

override suspend fun clear(): Unit =
keys()
.await<JsArray<JsString>>()
.toList()
.filter { it.toString().startsWith(prefixed) }
.let { delMany(it.toJsArray()).await() }

override suspend fun size(): Long =
keys()
.await<JsArray<JsString>>()
.toList()
.filter { it.toString().startsWith(prefixed) }
.size
.toLong()

override suspend fun isEmpty(): Boolean = size() == 0L

override suspend fun get(key: String): String? = keyval.get(key.prefixed()).await<JsString?>()?.toString()

override suspend fun put(
key: String,
value: String,
): String? = mutex.withLock { unsafePut(key, value) }

private suspend fun IndexedDBMap.unsafePut(
key: String,
value: String,
): String? {
val previous = get(key)
set(key.prefixed(), value.toJsString()).await<JsAny?>()
return previous
}

override suspend fun remove(key: String): String? =
mutex.withLock {
val previous = get(key)
del(key.prefixed()).await<JsAny?>()
previous
}

override suspend fun containsKey(key: String): Boolean = get(key) != null

override suspend fun update(
key: String,
value: String,
updater: (String) -> String,
): UpdateResult<String> =
mutex.withLock {
val oldValue = get(key)
val newValue = oldValue?.let(updater) ?: value
set(key.prefixed(), newValue.toJsString()).await<JsAny?>()
UpdateResult(oldValue, newValue)
}

override suspend fun getOrPut(
key: String,
defaultValue: () -> String,
): String =
mutex.withLock {
get(key) ?: defaultValue().also { unsafePut(key, it) }
}


override fun entries(): Flow<Map.Entry<String, String>> =
flow {
keys()
.await<JsArray<JsString>>()
.toList()
.asFlow()
.filter { it.toString().startsWith(prefixed) }
.collect { key ->
keyval.get(key.toString()).await<JsString?>()?.let { value ->
emit(SerializableEntry(key.toString().removePrefix(prefixed), value.toString()))
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package externalTypes


public external interface IDBRequest {
public var oncomplete: (() -> Unit)?
public var onsuccess: (() -> Unit)?
public var onabort: (() -> Unit)?
public var onerror: (() -> Unit)?
public val result: JsAny?
public val error: DOMException?
}

public external interface IDBTransaction {
public var oncomplete: (() -> Unit)?
public var onsuccess: (() -> Unit)?
public var onabort: (() -> Unit)?
public var onerror: (() -> Unit)?
public val result: JsAny?
public val error: DOMException?
}

public external interface IDBObjectStore {
public fun put(
value: JsAny?,
key: String,
): IDBRequest

public fun get(key: String): IDBRequest

public fun delete(key: String): IDBRequest

public fun clear(): IDBRequest

public fun openCursor(): IDBRequest

public fun getAll(): IDBRequest

public fun getAllKeys(): IDBRequest

public val transaction: IDBTransaction
}

public external interface IDBCursorWithValue {
public val key: String
public val value: String

@JsName("continue")
public fun next()
}
public external class DOMException(
message: String = definedExternally,
name: String = definedExternally,
) {
public val name: String
public val message: String
public val code: Short


public companion object {
public val INDEX_SIZE_ERR: Short
public val DOMSTRING_SIZE_ERR: Short
public val HIERARCHY_REQUEST_ERR: Short
public val WRONG_DOCUMENT_ERR: Short
public val INVALID_CHARACTER_ERR: Short
public val NO_DATA_ALLOWED_ERR: Short
public val NO_MODIFICATION_ALLOWED_ERR: Short
public val NOT_FOUND_ERR: Short
public val NOT_SUPPORTED_ERR: Short
public val INUSE_ATTRIBUTE_ERR: Short
public val INVALID_STATE_ERR: Short
public val SYNTAX_ERR: Short
public val INVALID_MODIFICATION_ERR: Short
public val NAMESPACE_ERR: Short
public val INVALID_ACCESS_ERR: Short
public val VALIDATION_ERR: Short
public val TYPE_MISMATCH_ERR: Short
public val SECURITY_ERR: Short
public val NETWORK_ERR: Short
public val ABORT_ERR: Short
public val URL_MISMATCH_ERR: Short
public val QUOTA_EXCEEDED_ERR: Short
public val TIMEOUT_ERR: Short
public val INVALID_NODE_TYPE_ERR: Short
public val DATA_CLONE_ERR: Short
}
}
67 changes: 67 additions & 0 deletions stores/browser/src/wasmJsMain/kotlin/keyval/KeyVal.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
@file:JsModule("idb-keyval")
@file:Suppress("unused")

package keyval

import externalTypes.IDBObjectStore
import kotlin.js.Promise


public external interface UseStore {
public operator fun invoke(
txMode: String,
callback: (store: IDBObjectStore) -> JsAny?,
): Promise<JsAny?>
}

public external fun promisifyRequest(request: JsAny?): Promise<JsAny?>

public external fun createStore(
dbName: String,
storeName: String,
): UseStore

public external fun get(
key: String?,
customStore: UseStore = definedExternally,
): Promise<JsString?>

public external fun set(
key: String,
value: JsAny?,
customStore: UseStore = definedExternally,
): Promise<JsAny?>

public external fun setMany(
entries: JsArray<JsArray<JsAny?>>,
customStore: UseStore = definedExternally,
): Promise<JsAny?>

public external fun getMany(
keys: JsArray<JsString>,
customStore: UseStore = definedExternally,
): Promise<JsArray<JsAny?>>

public external fun update(
key: String,
updater: (oldValue: JsAny?) -> JsAny?,
customStore: UseStore = definedExternally,
): Promise<JsAny?>

public external fun del(
key: String,
customStore: UseStore = definedExternally,
): Promise<JsAny?>

public external fun delMany(
keys: JsArray<JsString>,
customStore: UseStore = definedExternally,
): Promise<JsAny?>

public external fun clear(customStore: UseStore = definedExternally): Promise<JsAny?>

public external fun keys(customStore: UseStore = definedExternally): Promise<JsArray<JsString>>

public external fun values(customStore: UseStore = definedExternally): Promise<JsArray<JsString>>

public external fun entries(customStore: UseStore = definedExternally): Promise<JsArray<JsArray<JsString>>>
33 changes: 33 additions & 0 deletions stores/browser/src/wasmJsTest/kotlin/BrowserTests.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import com.github.lamba92.kotlin.document.store.core.DataStore
import com.github.lamba92.kotlin.document.store.stores.browser.BrowserStore
import com.github.lamba92.kotlin.document.store.tests.AbstractDeleteTests
import com.github.lamba92.kotlin.document.store.tests.AbstractDocumentDatabaseTests
import com.github.lamba92.kotlin.document.store.tests.AbstractFindTests
import com.github.lamba92.kotlin.document.store.tests.AbstractIndexTests
import com.github.lamba92.kotlin.document.store.tests.AbstractInsertTests
import com.github.lamba92.kotlin.document.store.tests.AbstractObjectCollectionTests
import com.github.lamba92.kotlin.document.store.tests.AbstractUpdateTests
import com.github.lamba92.kotlin.document.store.tests.DataStoreProvider
import kotlinx.coroutines.await

class BrowserDeleteTests : AbstractDeleteTests(BrowserStoreProvider)

class BrowserDocumentDatabaseTests : AbstractDocumentDatabaseTests(BrowserStoreProvider)

class BrowserIndexTests : AbstractIndexTests(BrowserStoreProvider)

class BrowserInsertTests : AbstractInsertTests(BrowserStoreProvider)

class BrowserUpdateTests : AbstractUpdateTests(BrowserStoreProvider)

class BrowserFindTests : AbstractFindTests(BrowserStoreProvider)

class BrowserObjectCollectionTests : AbstractObjectCollectionTests(BrowserStoreProvider)

object BrowserStoreProvider : DataStoreProvider {
override suspend fun deleteDatabase(testName: String) {
keyval.clear().await<JsAny?>()
}

override fun provide(testName: String): DataStore = BrowserStore
}