From fb1d3e29f825e8b261642def0104550df52672c1 Mon Sep 17 00:00:00 2001 From: Apolo Studio Date: Tue, 14 Jan 2025 02:41:06 +0100 Subject: [PATCH 1/3] Add Wasm Support for kotlin-document-store. Basically copying js source set code and adapting it to WasmJs. --- stores/browser/build.gradle.kts | 27 +++- .../store/stores/browser/BrowserStore.kt | 37 ++++++ .../store/stores/browser/IndexedDBMap.kt | 122 ++++++++++++++++++ .../kotlin/externalTypes/ExternalTypes.kt | 86 ++++++++++++ .../src/wasmJsMain/kotlin/keyval/KeyVal.kt | 67 ++++++++++ .../src/wasmJsTest/kotlin/BrowserTests.kt | 33 +++++ 6 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 stores/browser/src/wasmJsMain/kotlin/com/github/lamba92/kotlin/document/store/stores/browser/BrowserStore.kt create mode 100644 stores/browser/src/wasmJsMain/kotlin/com/github/lamba92/kotlin/document/store/stores/browser/IndexedDBMap.kt create mode 100644 stores/browser/src/wasmJsMain/kotlin/externalTypes/ExternalTypes.kt create mode 100644 stores/browser/src/wasmJsMain/kotlin/keyval/KeyVal.kt create mode 100644 stores/browser/src/wasmJsTest/kotlin/BrowserTests.kt diff --git a/stores/browser/build.gradle.kts b/stores/browser/build.gradle.kts index 493b68d..2753d02 100644 --- a/stores/browser/build.gradle.kts +++ b/stores/browser/build.gradle.kts @@ -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) + } + } } } diff --git a/stores/browser/src/wasmJsMain/kotlin/com/github/lamba92/kotlin/document/store/stores/browser/BrowserStore.kt b/stores/browser/src/wasmJsMain/kotlin/com/github/lamba92/kotlin/document/store/stores/browser/BrowserStore.kt new file mode 100644 index 0000000..39390cf --- /dev/null +++ b/stores/browser/src/wasmJsMain/kotlin/com/github/lamba92/kotlin/document/store/stores/browser/BrowserStore.kt @@ -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 = withStoreLock { IndexedDBMap(name, getMutex(name)) } + + override suspend fun deleteMap(name: String): Unit = + withStoreLock { + lockAndRemoveMutex(name) { + keys() + .await>() + .toList() + .filter { it.toString().startsWith(IndexedDBMap.buildPrefix(name)) } + .let { delMany(it.toJsArray()).await() } + } + } +} diff --git a/stores/browser/src/wasmJsMain/kotlin/com/github/lamba92/kotlin/document/store/stores/browser/IndexedDBMap.kt b/stores/browser/src/wasmJsMain/kotlin/com/github/lamba92/kotlin/document/store/stores/browser/IndexedDBMap.kt new file mode 100644 index 0000000..37bac9a --- /dev/null +++ b/stores/browser/src/wasmJsMain/kotlin/com/github/lamba92/kotlin/document/store/stores/browser/IndexedDBMap.kt @@ -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 { + 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>() + .toList() + .filter { it.toString().startsWith(prefixed) } + .let { delMany(it.toJsArray()).await() } + + override suspend fun size(): Long = + keys() + .await>() + .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()?.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() + return previous + } + + override suspend fun remove(key: String): String? = + mutex.withLock { + val previous = get(key) + del(key.prefixed()).await() + previous + } + + override suspend fun containsKey(key: String): Boolean = get(key) != null + + override suspend fun update( + key: String, + value: String, + updater: (String) -> String, + ): UpdateResult = + mutex.withLock { + val oldValue = get(key) + val newValue = oldValue?.let(updater) ?: value + set(key.prefixed(), newValue.toJsString()).await() + 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> = + flow { + keys() + .await>() + .toList() + .asFlow() + .filter { it.toString().startsWith(prefixed) } + .collect { key -> + keyval.get(key.toString()).await()?.let { value -> + emit(SerializableEntry(key.toString().removePrefix(prefixed), value.toString())) + } + } + } +} diff --git a/stores/browser/src/wasmJsMain/kotlin/externalTypes/ExternalTypes.kt b/stores/browser/src/wasmJsMain/kotlin/externalTypes/ExternalTypes.kt new file mode 100644 index 0000000..908f62f --- /dev/null +++ b/stores/browser/src/wasmJsMain/kotlin/externalTypes/ExternalTypes.kt @@ -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 + } +} \ No newline at end of file diff --git a/stores/browser/src/wasmJsMain/kotlin/keyval/KeyVal.kt b/stores/browser/src/wasmJsMain/kotlin/keyval/KeyVal.kt new file mode 100644 index 0000000..7c23f72 --- /dev/null +++ b/stores/browser/src/wasmJsMain/kotlin/keyval/KeyVal.kt @@ -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 +} + +public external fun promisifyRequest(request: JsAny?): Promise + +public external fun createStore( + dbName: String, + storeName: String, +): UseStore + +public external fun get( + key: String?, + customStore: UseStore = definedExternally, +): Promise + +public external fun set( + key: String, + value: JsAny?, + customStore: UseStore = definedExternally, +): Promise + +public external fun setMany( + entries: JsArray>, + customStore: UseStore = definedExternally, +): Promise + +public external fun getMany( + keys: JsArray, + customStore: UseStore = definedExternally, +): Promise> + +public external fun update( + key: String, + updater: (oldValue: JsAny?) -> JsAny?, + customStore: UseStore = definedExternally, +): Promise + +public external fun del( + key: String, + customStore: UseStore = definedExternally, +): Promise + +public external fun delMany( + keys: JsArray, + customStore: UseStore = definedExternally, +): Promise + +public external fun clear(customStore: UseStore = definedExternally): Promise + +public external fun keys(customStore: UseStore = definedExternally): Promise> + +public external fun values(customStore: UseStore = definedExternally): Promise> + +public external fun entries(customStore: UseStore = definedExternally): Promise>> \ No newline at end of file diff --git a/stores/browser/src/wasmJsTest/kotlin/BrowserTests.kt b/stores/browser/src/wasmJsTest/kotlin/BrowserTests.kt new file mode 100644 index 0000000..8457921 --- /dev/null +++ b/stores/browser/src/wasmJsTest/kotlin/BrowserTests.kt @@ -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() + } + + override fun provide(testName: String): DataStore = BrowserStore +} From b41c9380a090d2e0ed4c3f1044af127e808a9c70 Mon Sep 17 00:00:00 2001 From: Dev Date: Tue, 28 Jan 2025 16:38:34 +0100 Subject: [PATCH 2/3] Formatted with Klint --- stores/browser/build.gradle.kts | 4 +--- .../kotlin/document/store/stores/browser/IndexedDBMap.kt | 3 +-- .../src/wasmJsMain/kotlin/externalTypes/ExternalTypes.kt | 5 ++--- stores/browser/src/wasmJsMain/kotlin/keyval/KeyVal.kt | 3 +-- 4 files changed, 5 insertions(+), 10 deletions(-) diff --git a/stores/browser/build.gradle.kts b/stores/browser/build.gradle.kts index 2753d02..9f509fe 100644 --- a/stores/browser/build.gradle.kts +++ b/stores/browser/build.gradle.kts @@ -15,7 +15,7 @@ kotlin { } } @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) - wasmJs { + wasmJs { browser { testTask { useKarma { @@ -35,11 +35,9 @@ kotlin { } jsMain { dependsOn(webMain) - } wasmJsMain { dependsOn(webMain) - } jsTest { dependencies { diff --git a/stores/browser/src/wasmJsMain/kotlin/com/github/lamba92/kotlin/document/store/stores/browser/IndexedDBMap.kt b/stores/browser/src/wasmJsMain/kotlin/com/github/lamba92/kotlin/document/store/stores/browser/IndexedDBMap.kt index 37bac9a..c713858 100644 --- a/stores/browser/src/wasmJsMain/kotlin/com/github/lamba92/kotlin/document/store/stores/browser/IndexedDBMap.kt +++ b/stores/browser/src/wasmJsMain/kotlin/com/github/lamba92/kotlin/document/store/stores/browser/IndexedDBMap.kt @@ -105,14 +105,13 @@ public class IndexedDBMap( get(key) ?: defaultValue().also { unsafePut(key, it) } } - override fun entries(): Flow> = flow { keys() .await>() .toList() .asFlow() - .filter { it.toString().startsWith(prefixed) } + .filter { it.toString().startsWith(prefixed) } .collect { key -> keyval.get(key.toString()).await()?.let { value -> emit(SerializableEntry(key.toString().removePrefix(prefixed), value.toString())) diff --git a/stores/browser/src/wasmJsMain/kotlin/externalTypes/ExternalTypes.kt b/stores/browser/src/wasmJsMain/kotlin/externalTypes/ExternalTypes.kt index 908f62f..0987853 100644 --- a/stores/browser/src/wasmJsMain/kotlin/externalTypes/ExternalTypes.kt +++ b/stores/browser/src/wasmJsMain/kotlin/externalTypes/ExternalTypes.kt @@ -1,6 +1,5 @@ package externalTypes - public external interface IDBRequest { public var oncomplete: (() -> Unit)? public var onsuccess: (() -> Unit)? @@ -47,6 +46,7 @@ public external interface IDBCursorWithValue { @JsName("continue") public fun next() } + public external class DOMException( message: String = definedExternally, name: String = definedExternally, @@ -55,7 +55,6 @@ public external class DOMException( public val message: String public val code: Short - public companion object { public val INDEX_SIZE_ERR: Short public val DOMSTRING_SIZE_ERR: Short @@ -83,4 +82,4 @@ public external class DOMException( public val INVALID_NODE_TYPE_ERR: Short public val DATA_CLONE_ERR: Short } -} \ No newline at end of file +} diff --git a/stores/browser/src/wasmJsMain/kotlin/keyval/KeyVal.kt b/stores/browser/src/wasmJsMain/kotlin/keyval/KeyVal.kt index 7c23f72..aa8cda8 100644 --- a/stores/browser/src/wasmJsMain/kotlin/keyval/KeyVal.kt +++ b/stores/browser/src/wasmJsMain/kotlin/keyval/KeyVal.kt @@ -6,7 +6,6 @@ package keyval import externalTypes.IDBObjectStore import kotlin.js.Promise - public external interface UseStore { public operator fun invoke( txMode: String, @@ -64,4 +63,4 @@ public external fun keys(customStore: UseStore = definedExternally): Promise> -public external fun entries(customStore: UseStore = definedExternally): Promise>> \ No newline at end of file +public external fun entries(customStore: UseStore = definedExternally): Promise>> From abb1df51c0a4f0b37d38cd1205b65a76cfb7e4a4 Mon Sep 17 00:00:00 2001 From: Dev Date: Tue, 28 Jan 2025 16:42:31 +0100 Subject: [PATCH 3/3] Updated to file OptIn annotations and reformatted again --- stores/browser/build.gradle.kts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/stores/browser/build.gradle.kts b/stores/browser/build.gradle.kts index 9f509fe..edcd0d7 100644 --- a/stores/browser/build.gradle.kts +++ b/stores/browser/build.gradle.kts @@ -1,3 +1,7 @@ +@file:OptIn(ExperimentalWasmDsl::class) + +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl + plugins { `publishing-convention` `kotlin-multiplatform-convention` @@ -14,7 +18,7 @@ kotlin { } } } - @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) + wasmJs { browser { testTask {