Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save jillesvangurp/c6923ac3c6f17fa36dd02300e7f95548 to your computer and use it in GitHub Desktop.
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
@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