Created
April 2, 2025 10:27
-
-
Save jillesvangurp/c6923ac3c6f17fa36dd02300e7f95548 to your computer and use it in GitHub Desktop.
indexed db based object store for kotlin js - uses kotlinx serialization and co-routines
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@file:Suppress("unused") | |
package indexeddb | |
import com.jillesvangurp.serializationext.DEFAULT_JSON | |
import kotlin.coroutines.resume | |
import kotlin.coroutines.resumeWithException | |
import kotlin.time.Duration.Companion.milliseconds | |
import kotlinx.coroutines.ExperimentalCoroutinesApi | |
import kotlinx.coroutines.channels.Channel | |
import kotlinx.coroutines.delay | |
import kotlinx.coroutines.flow.Flow | |
import kotlinx.coroutines.flow.consumeAsFlow | |
import kotlinx.coroutines.launch | |
import kotlinx.coroutines.suspendCancellableCoroutine | |
import kotlinx.serialization.ExperimentalSerializationApi | |
import kotlinx.serialization.KSerializer | |
import kotlinx.serialization.json.decodeFromDynamic | |
import util.indexedDbScope | |
import util.toJsObject | |
import web.events.EventHandler | |
import web.idb.IDBCursorDirection | |
import web.idb.IDBDatabase | |
import web.idb.IDBKeyRange | |
import web.idb.IDBObjectStore | |
import web.idb.IDBObjectStoreParameters | |
import web.idb.IDBOpenDBRequest | |
import web.idb.IDBRequest | |
import web.idb.IDBTransactionMode | |
import web.idb.IDBValidKey | |
import web.idb.indexedDB | |
/** | |
* Important, this allows you to do simple single operations on indexeddb. | |
* But beware that the transaction closes after onsuccess/onerror calls. | |
*/ | |
suspend fun <T> IDBRequest<T>.await(): T { | |
return suspendCancellableCoroutine { continuation -> | |
this.onsuccess = EventHandler { event -> | |
continuation.resume(this.result) | |
} | |
this.onerror = EventHandler { | |
continuation.resumeWithException(this.error ?: RuntimeException("Error")) | |
} | |
continuation.invokeOnCancellation { | |
this.onsuccess = null | |
this.onerror = null | |
} | |
} | |
} | |
suspend fun openDB( | |
dbName: String, | |
storeName: String, | |
version: Double, | |
jsonKeyPath: String, | |
block: (IDBObjectStore.() -> Unit)? = null | |
): IDBDatabase { | |
return suspendCancellableCoroutine { continuation -> | |
val request = indexedDB.open(dbName, version) | |
request.onsuccess = EventHandler { event -> | |
val req = event.target as IDBOpenDBRequest | |
val reqDb = req.result | |
continuation.resume(reqDb) | |
} | |
request.onupgradeneeded = EventHandler { event -> | |
console.log("upgrade needed") | |
val req = event.target as IDBOpenDBRequest | |
val reqDb = req.result | |
if (!reqDb.objectStoreNames.contains("storeName")) { | |
console.log("creating store $storeName") | |
val store = reqDb.createObjectStore( | |
storeName, | |
IDBObjectStoreParameters().apply { | |
keyPath = jsonKeyPath | |
autoIncrement = true | |
}, | |
) | |
// create your indices here | |
block?.invoke(store) | |
} | |
} | |
request.onerror = EventHandler { event -> | |
console.log("onerror") | |
continuation.resumeWithException( | |
(event.target as IDBRequest<*>).error ?: Exception("Unknown error"), | |
) | |
} | |
} | |
} | |
class IDBRepository<K, T>( | |
private val serializer: KSerializer<T>, | |
private val dbName: String, | |
private val storeName: String, | |
private val version: Double, | |
private val jsonKeyPath: String, | |
private val keyGenerator: K.() -> IDBValidKey = { | |
IDBValidKey(toString()) | |
}, | |
private val block: (IDBObjectStore.() -> Unit)? = null, | |
) { | |
private var db: IDBDatabase? = null | |
private suspend fun openDbIfNeeded(): IDBDatabase { | |
var theDb = db | |
return if (theDb == null) { | |
theDb = openDB(dbName, storeName, version, jsonKeyPath, block) | |
db = theDb | |
theDb | |
} else { | |
theDb | |
} | |
} | |
suspend fun clearStore() { | |
openDbIfNeeded() | |
val transaction = db!!.transaction(storeName, mode = IDBTransactionMode.readwrite) | |
val store = transaction.objectStore(storeName) | |
store.clear().await() | |
} | |
@OptIn(ExperimentalSerializationApi::class) | |
suspend fun get(key: K): T? { | |
openDbIfNeeded() | |
val transaction = openDbIfNeeded().transaction(storeName, IDBTransactionMode.readonly) | |
val store = transaction.objectStore(storeName) | |
return store[keyGenerator(key)].await()?.let { | |
DEFAULT_JSON.decodeFromDynamic(serializer, it) | |
} | |
} | |
@OptIn(ExperimentalSerializationApi::class) | |
suspend fun update(key: K, updater: (T?) -> T) { | |
openDbIfNeeded() | |
val transaction = openDbIfNeeded().transaction(storeName, IDBTransactionMode.readwrite) | |
val store = transaction.objectStore(storeName) | |
val k = keyGenerator(key) | |
val oldValue = store[k].await()?.let { | |
DEFAULT_JSON.decodeFromDynamic(serializer, it) | |
} | |
val updated = updater.invoke(oldValue) | |
// We can't use the same transaction because indexeddb closes it after the previous await | |
// so just use the put for now | |
// solution would be nesting the put inside the success handler of the get, callback hell being enforced here | |
put(updated) | |
} | |
suspend fun put(value: T) { | |
val transaction = openDbIfNeeded().transaction(storeName, IDBTransactionMode.readwrite) | |
val store = transaction.objectStore(storeName) | |
store.put( | |
toJsObject(serializer, value), | |
).await() | |
} | |
suspend fun put(values: Flow<T>) { | |
val transaction = openDbIfNeeded().transaction(storeName, IDBTransactionMode.readwrite) | |
val store = transaction.objectStore(storeName) | |
values.collect { value -> | |
store.put( | |
toJsObject(serializer, value), | |
).await() | |
} | |
} | |
suspend fun delete(id: String) { | |
val transaction = openDbIfNeeded().transaction(storeName, IDBTransactionMode.readwrite) | |
val store = transaction.objectStore(storeName) | |
store.delete(IDBValidKey(id)).await() | |
} | |
suspend fun put(values: Iterable<T>) { | |
val transaction = openDbIfNeeded().transaction(storeName, IDBTransactionMode.readwrite) | |
val store = transaction.objectStore(storeName) | |
values.forEach { value -> | |
store.put( | |
toJsObject(serializer, value), | |
).await() | |
} | |
} | |
@OptIn(ExperimentalCoroutinesApi::class) | |
suspend fun query( | |
query: IDBValidKey? = null, | |
direction: IDBCursorDirection = IDBCursorDirection.next, | |
indexName: String?=null, | |
): Flow<T> { | |
val transaction = openDbIfNeeded().transaction(storeName, IDBTransactionMode.readonly) | |
val store = transaction.objectStore(storeName) | |
val req = indexName?.let { | |
val index = store.index(indexName) | |
index.openCursor(query, direction) | |
} ?: store.openCursor(query, direction) | |
// be careful not to drop items, originally had CONFLATED here, duh! | |
val channel = Channel<T>(capacity = 100000) | |
req.onsuccess = EventHandler { event -> | |
val cursor = event.currentTarget.result | |
if (cursor != null) { | |
val v = cursor.value | |
if (v != null) { | |
val o = DEFAULT_JSON.decodeFromString(serializer,JSON.stringify(v)) | |
indexedDbScope.launch { | |
channel.send(o) | |
} | |
} | |
cursor.`continue`() | |
} else { | |
indexedDbScope.launch { | |
// wait until channel has been processed | |
while(!channel.isEmpty) { | |
delay(1.milliseconds) | |
} | |
channel.close() | |
} | |
} | |
} | |
req.onerror = EventHandler { event -> | |
throw (event.target as IDBRequest<*>).error ?: Exception("Unknown error") | |
} | |
return channel.consumeAsFlow() | |
} | |
@OptIn(ExperimentalCoroutinesApi::class) | |
suspend fun queryKeyRange( | |
query: IDBKeyRange? = null, | |
direction: IDBCursorDirection = IDBCursorDirection.next, | |
indexName: String?=null, | |
): Flow<T> { | |
val transaction = openDbIfNeeded().transaction(storeName, IDBTransactionMode.readonly) | |
val store = transaction.objectStore(storeName) | |
val req = indexName?.let { | |
val index = store.index(indexName) | |
index.openCursor(query, direction) | |
} ?: store.openCursor(query, direction) | |
val channel = Channel<T>(capacity = 100000) | |
req.onsuccess = EventHandler { event -> | |
val cursor = event.currentTarget.result | |
if (cursor != null) { | |
val v = cursor.value | |
if (v != null) { | |
val o = DEFAULT_JSON.decodeFromString(serializer,JSON.stringify(v)) | |
indexedDbScope.launch { | |
channel.send(o) | |
} | |
} | |
cursor.`continue`() | |
} else { | |
indexedDbScope.launch { | |
while(!channel.isEmpty) { | |
delay(1.milliseconds) | |
} | |
channel.close() | |
} | |
} | |
} | |
req.onerror = EventHandler { event -> | |
throw (event.target as IDBRequest<*>).error ?: Exception("Unknown error") | |
} | |
return channel.consumeAsFlow() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment