diff --git a/cache/src/commonMain/kotlin/ExperimentalCacheApi.kt b/cache/src/commonMain/kotlin/ExperimentalCacheApi.kt new file mode 100644 index 0000000..162d57d --- /dev/null +++ b/cache/src/commonMain/kotlin/ExperimentalCacheApi.kt @@ -0,0 +1,6 @@ +package opensavvy.cache + +@RequiresOptIn("This API has not been stabilized yet. We expect to make breaking changes to it in the future. If you have some ideas of how it could be used, please contact us so we can take it into account.") +@MustBeDocumented +@Suppress("unused") // Even if this annotation is never used, it may remain in case it used again in the future +annotation class ExperimentalCacheApi -- 2.51.2 From ba3eae3e2abb8970488c1a874d382ad29adbdc09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 18 Feb 2024 20:29:40 +0100 Subject: [PATCH 2/3] refactor(cache): Remove unused default parameter for MemoryCache --- cache/src/commonMain/kotlin/MemoryCache.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cache/src/commonMain/kotlin/MemoryCache.kt b/cache/src/commonMain/kotlin/MemoryCache.kt index 4b7cb04..930fc88 100644 --- a/cache/src/commonMain/kotlin/MemoryCache.kt +++ b/cache/src/commonMain/kotlin/MemoryCache.kt @@ -12,7 +12,7 @@ import opensavvy.state.progressive.copy internal class MemoryCache( private val upstream: Cache, - private val job: Job = SupervisorJob(), + private val job: Job, ) : Cache { private val log = loggerFor(this) -- 2.51.2 From b3c85c369022c0ea77876a28ea137123ded2b17a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 18 Feb 2024 21:38:10 +0100 Subject: [PATCH 3/3] feat(cache): Experimental LocalStorage-based cache --- .../src/jsMain/kotlin/BrowserStorageCache.kt | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 cache/src/jsMain/kotlin/BrowserStorageCache.kt diff --git a/cache/src/jsMain/kotlin/BrowserStorageCache.kt b/cache/src/jsMain/kotlin/BrowserStorageCache.kt new file mode 100644 index 0000000..63ae323 --- /dev/null +++ b/cache/src/jsMain/kotlin/BrowserStorageCache.kt @@ -0,0 +1,164 @@ +package opensavvy.cache + +import kotlinx.browser.localStorage +import kotlinx.browser.sessionStorage +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onEach +import opensavvy.state.coroutines.ProgressiveFlow +import opensavvy.state.progressive.onSuccess +import opensavvy.state.progressive.successfulWithProgress +import org.w3c.dom.Storage +import org.w3c.dom.get +import org.w3c.dom.set +import kotlin.time.Duration.Companion.seconds + +@ExperimentalCacheApi +private class BrowserStorageCache( + private val upstream: Cache, + private val keyPrefix: String, + private val storage: Storage, + private val serializeIdentifier: (Identifier) -> String?, + private val serializeValue: (Value) -> String?, + private val deserializeValue: (String) -> Value?, +) : Cache { + + /** + * Generates the storage key for the identifier [id]. + * + * This mixes the [id] with the [keyPrefix], to avoid conflicts between multiple cache instances at the storage layer. + * + * If this function returns `null`, generating the key failed and caching in this layer should be aborted. + */ + private fun keyFor(id: Identifier): String? { + val serializedId = serializeIdentifier(id) ?: return null + return "$keyPrefix-$serializedId" + } + + override fun get(id: Identifier): ProgressiveFlow = flow { + val key = keyFor(id) + + // We are in an infinite flow, it ends when the subscriber unsubscribes + while (true) { + val stored = key + ?.let(storage::get) + ?.let(deserializeValue) + + if (stored != null) { + emit(stored.successfulWithProgress()) + } else { + emitAll( + upstream[id] + .onEach { outcome -> outcome.onSuccess { update(id, it) } } + ) + } + + delay(5.seconds) + } + } + + override suspend fun update(values: Collection>) { + for ((id, value) in values) { + val key = keyFor(id) ?: continue + val serializedValue = serializeValue(value) ?: continue + storage[key] = serializedValue + } + + upstream.update(values) + } + + override suspend fun expire(ids: Collection) { + for (id in ids) { + val key = keyFor(id) ?: continue + storage.removeItem(key) + } + + upstream.expire(ids) + } + + override suspend fun expireAll() { + storage.clear() + upstream.expireAll() + } +} + +/** + * In-browser [Cache] layer. + * + * ### General behavior + * + * Updates from the previous cache are stored in the [storage] engine. + * When [get][Cache.get] is called, results are returned from the storage engine if available. + * Otherwise, the request is transmitted to the previous layer. + * + * Elements are deleted when the [storage] engine when [expire][Cache.expire] is called. + * To reduce the size of the cache automatically, add a subsequent layer for it (e.g. [expireAfter]). + * + * ### Observability + * + * Currently, the implementation is based on polling. + * If the value changes in the cache, all cache instances notice after a few seconds. + * This can be used to share values between tabs: if a cache instance in a tab gets a result, other instances in other tabs + * will notice them and avoid starting new requests. + * + * However, the current implementation only stores values when they are successful, so if two tabs start the same request at the same time, + * they won't be deduplicated (both requests will go through). Failed values are not stored either. + * + * ### Example + * + * ```kotlin + * fun serializeKey(id: Int) = id.toString() + * fun serializeValue(value: Double) = value.toString() + * fun deserializeValue(value: String) = value.toDouble() + * + * val squareRoot = cache { sqrt(it) } + * .cachedInBrowserStorage(window.localStorage, "sqrt", serializeKey, serializeValue, deserializeValue) + * .cachedInMemory(…) + * .expireAfter(…) + * ``` + * + * @param storage The browser storage engine to use. + * @param keyPrefix A short string that is appended before the identifier. + * The prefix is important to avoid naming conflicts with other values that may be stored in the [storage] engine. + * @param serializeIdentifier A function that converts an identifier to a [String]. If the function returns `null`, this layer assumes the value cannot be stored and no-ops. + * @param serializeValue A function that converts a cached value to a [String]. If the function returns `null`, this layer assumes the value cannot be stored and no-ops. + * @param deserializeValue A function that converts a string generated by [serializeValue] into its original value. If the function returns `null`, this layer assumes the value cannot be stored and no-ops. + */ +@ExperimentalCacheApi +fun Cache.cachedInBrowserStorage( + storage: Storage, + keyPrefix: String, + serializeIdentifier: (Identifier) -> String?, + serializeValue: (Value) -> String?, + deserializeValue: (String) -> Value?, +): Cache = + BrowserStorageCache(this, keyPrefix, storage, serializeIdentifier, serializeValue, deserializeValue) + +/** + * In-browser [Cache] implementation that is shared between tabs and persists when the browser is closed. + * + * This function is equivalent for [cachedInBrowserStorage] using the [local storage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) engine. + * See the [cachedInBrowserStorage] documentation for more information. + */ +@ExperimentalCacheApi +fun Cache.cachedInLocalStorage( + keyPrefix: String, + serializeIdentifier: (Identifier) -> String?, + serializeValue: (Value) -> String?, + deserializeValue: (String) -> Value?, +) = cachedInBrowserStorage(localStorage, keyPrefix, serializeIdentifier, serializeValue, deserializeValue) + +/** + * In-browser [Cache] implementation that is cleared when the tab is closed. + * + * This function is equivalent for [cachedInBrowserStorage] using the [session storage](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage) engine. + * See the [cachedInBrowserStorage] documentation for more information. + */ +@ExperimentalCacheApi +fun Cache.cachedInSessionStorage( + keyPrefix: String, + serializeIdentifier: (Identifier) -> String?, + serializeValue: (Value) -> String?, + deserializeValue: (String) -> Value?, +) = cachedInBrowserStorage(sessionStorage, keyPrefix, serializeIdentifier, serializeValue, deserializeValue)