diff --git a/build.gradle.kts b/build.gradle.kts index 6c7def3af..7c1c8b396 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,6 +20,7 @@ buildscript { classpath(libs.maven.publish.plugin) classpath(libs.kover.plugin) classpath(libs.atomic.fu.gradle.plugin) + classpath(libs.molecule.gradle.plugin) } } diff --git a/cache/build.gradle.kts b/cache/build.gradle.kts index 15527d9dd..b13569c13 100644 --- a/cache/build.gradle.kts +++ b/cache/build.gradle.kts @@ -44,6 +44,8 @@ kotlin { val commonMain by getting { dependencies { api(libs.kotlinx.atomic.fu) + api(project(":core")) + implementation(libs.kotlinx.coroutines.core) } } val jvmMain by getting diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Identifiable.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Identifiable.kt deleted file mode 100644 index d523bfb67..000000000 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Identifiable.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.mobilenativefoundation.store.cache5 - -interface Identifiable { - val id: Id -} diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MultiCache.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MultiCache.kt deleted file mode 100644 index 3dfc0b561..000000000 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MultiCache.kt +++ /dev/null @@ -1,77 +0,0 @@ -@file:Suppress("UNCHECKED_CAST") - -package org.mobilenativefoundation.store.cache5 - -/** - * Implementation of a cache with collection decomposition. - * Stores and manages the relationship among single items and collections. - * Delegates cache storage and behavior to Guava caches. - */ -class MultiCache>( - cacheBuilder: CacheBuilder -) { - private val collectionCacheBuilder = CacheBuilder>().apply { - expireAfterAccess(cacheBuilder.expireAfterAccess) - expireAfterWrite(cacheBuilder.expireAfterWrite) - - if (cacheBuilder.maximumSize > 0) { - maximumSize(cacheBuilder.maximumSize) - } - // TODO(): Support weigher - } - - private val itemKeyToCollectionKey = mutableMapOf() - - private val itemCache: Cache = cacheBuilder.build() - - private val collectionCache: Cache> = collectionCacheBuilder.build() - - fun getItem(key: Key): Output? { - return itemCache.getIfPresent(key) - } - - fun putItem(key: Key, item: Output) { - itemCache.put(key, item) - - val collectionKey = itemKeyToCollectionKey[key] - if (collectionKey != null) { - val updatedCollection = collectionCache.getIfPresent(collectionKey)?.map { if (it.id == key) item else it } - if (updatedCollection != null) { - collectionCache.put(collectionKey, updatedCollection) - } - } - } - - fun > getCollection(key: Key): T? { - return collectionCache.getIfPresent(key) as? T - } - - fun putCollection(key: Key, items: Collection) { - collectionCache.put(key, items) - items.forEach { item -> - itemCache.put(item.id, item) - itemKeyToCollectionKey[item.id] = key - } - } - - fun invalidateItem(key: Key) { - itemCache.invalidate(key) - } - - fun invalidateCollection(key: Key) { - val collection = collectionCache.getIfPresent(key) - collection?.forEach { item -> - invalidateItem(item.id) - } - collectionCache.invalidate(key) - } - - fun invalidateAll() { - collectionCache.invalidateAll() - itemCache.invalidateAll() - } - - fun size(): Long { - return itemCache.size() + collectionCache.size() - } -} diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt new file mode 100644 index 000000000..7003bc582 --- /dev/null +++ b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt @@ -0,0 +1,153 @@ +@file:Suppress("UNCHECKED_CAST") + +package org.mobilenativefoundation.store.cache5 + +import org.mobilenativefoundation.store.core5.KeyProvider +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.core5.StoreKey + +/** + * A class that represents a caching system with collection decomposition. + * Manages data with utility functions to get, invalidate, and add items to the cache. + * Depends on [StoreMultiCacheAccessor] for internal data management. + * @see [Cache]. + */ +class StoreMultiCache, Single : StoreData.Single, Collection : StoreData.Collection, Output : StoreData>( + private val keyProvider: KeyProvider, + singlesCache: Cache, Single> = CacheBuilder, Single>().build(), + collectionsCache: Cache, Collection> = CacheBuilder, Collection>().build(), +) : Cache { + + private val accessor = StoreMultiCacheAccessor( + singlesCache = singlesCache, + collectionsCache = collectionsCache, + ) + + private fun Key.castSingle() = this as StoreKey.Single + private fun Key.castCollection() = this as StoreKey.Collection + + private fun StoreKey.Collection.cast() = this as Key + private fun StoreKey.Single.cast() = this as Key + + override fun getIfPresent(key: Key): Output? { + return when (key) { + is StoreKey.Single<*> -> accessor.getSingle(key.castSingle()) as? Output + is StoreKey.Collection<*> -> accessor.getCollection(key.castCollection()) as? Output + else -> { + throw UnsupportedOperationException(invalidKeyErrorMessage(key)) + } + } + } + + override fun getOrPut(key: Key, valueProducer: () -> Output): Output { + return when (key) { + is StoreKey.Single<*> -> { + val single = accessor.getSingle(key.castSingle()) as? Output + if (single != null) { + single + } else { + val producedSingle = valueProducer() + put(key, producedSingle) + producedSingle + } + } + + is StoreKey.Collection<*> -> { + val collection = accessor.getCollection(key.castCollection()) as? Output + if (collection != null) { + collection + } else { + val producedCollection = valueProducer() + put(key, producedCollection) + producedCollection + } + } + + else -> { + throw UnsupportedOperationException(invalidKeyErrorMessage(key)) + } + } + } + + override fun getAllPresent(keys: List<*>): Map { + val map = mutableMapOf() + keys.filterIsInstance>().forEach { key -> + when (key) { + is StoreKey.Collection -> { + val collection = accessor.getCollection(key) + collection?.let { map[key.cast()] = it as Output } + } + + is StoreKey.Single -> { + val single = accessor.getSingle(key) + single?.let { map[key.cast()] = it as Output } + } + } + } + + return map + } + + override fun invalidateAll(keys: List) { + keys.forEach { key -> invalidate(key) } + } + + override fun invalidate(key: Key) { + when (key) { + is StoreKey.Single<*> -> accessor.invalidateSingle(key.castSingle()) + is StoreKey.Collection<*> -> accessor.invalidateCollection(key.castCollection()) + } + } + + override fun putAll(map: Map) { + map.entries.forEach { (key, value) -> put(key, value) } + } + + override fun put(key: Key, value: Output) { + when (key) { + is StoreKey.Single<*> -> { + val single = value as Single + accessor.putSingle(key.castSingle(), single) + + val collectionKey = keyProvider.fromSingle(key.castSingle(), single) + val existingCollection = accessor.getCollection(collectionKey) + if (existingCollection != null) { + val updatedItems = existingCollection.items.toMutableList().map { + if (it.id == single.id) { + single + } else { + it + } + } + val updatedCollection = existingCollection.copyWith(items = updatedItems) as Collection + accessor.putCollection(collectionKey, updatedCollection) + } + } + + is StoreKey.Collection<*> -> { + val collection = value as Collection + accessor.putCollection(key.castCollection(), collection) + + collection.items.forEach { + val single = it as? Single + if (single != null) { + accessor.putSingle(keyProvider.fromCollection(key.castCollection(), single), single) + } + } + } + } + } + + override fun invalidateAll() { + accessor.invalidateAll() + } + + override fun size(): Long { + return accessor.size() + } + + companion object { + fun invalidKeyErrorMessage(key: Any) = + "Expected StoreKey.Single or StoreKey.Collection, but received ${key::class}" + } +} diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor.kt new file mode 100644 index 000000000..04e990569 --- /dev/null +++ b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor.kt @@ -0,0 +1,142 @@ +package org.mobilenativefoundation.store.cache5 + +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.core5.StoreKey + +/** + * Responsible for managing and accessing cached data. + * Provides functionality to retrieve, store, and invalidate single items and collections of items. + * All operations are thread-safe, ensuring safe usage across multiple threads. + * + * The thread safety of this class is ensured through the use of synchronized blocks. + * Synchronized blocks guarantee only one thread can execute any of the methods at a time. + * This prevents concurrent modifications and ensures consistency of the data. + * + * @param Id The type of the identifier used for the data. + * @param Collection The type of the data collection. + * @param Single The type of the single data item. + * @property singlesCache The cache used to store single data items. + * @property collectionsCache The cache used to store collections of data items. + */ +class StoreMultiCacheAccessor, Single : StoreData.Single>( + private val singlesCache: Cache, Single>, + private val collectionsCache: Cache, Collection>, +) : SynchronizedObject() { + private val keys = mutableSetOf>() + + /** + * Retrieves a collection of items from the cache using the provided key. + * + * This operation is thread-safe. + * + * @param key The key used to retrieve the collection. + * @return The cached collection or null if it's not present. + */ + fun getCollection(key: StoreKey.Collection): Collection? = synchronized(this) { + collectionsCache.getIfPresent(key) + } + + /** + * Retrieves an individual item from the cache using the provided key. + * + * This operation is thread-safe. + * + * @param key The key used to retrieve the single item. + * @return The cached single item or null if it's not present. + */ + fun getSingle(key: StoreKey.Single): Single? = synchronized(this) { + singlesCache.getIfPresent(key) + } + + /** + * Stores a collection of items in the cache and updates the key set. + * + * This operation is thread-safe. + * + * @param key The key associated with the collection. + * @param collection The collection to be stored in the cache. + */ + fun putCollection(key: StoreKey.Collection, collection: Collection) = synchronized(this) { + collectionsCache.put(key, collection) + keys.add(key) + } + + /** + * Stores an individual item in the cache and updates the key set. + * + * This operation is thread-safe. + * + * @param key The key associated with the single item. + * @param single The single item to be stored in the cache. + */ + fun putSingle(key: StoreKey.Single, single: Single) = synchronized(this) { + singlesCache.put(key, single) + keys.add(key) + } + + /** + * Removes all cache entries and clears the key set. + * + * This operation is thread-safe. + */ + fun invalidateAll() = synchronized(this) { + collectionsCache.invalidateAll() + singlesCache.invalidateAll() + keys.clear() + } + + /** + * Removes an individual item from the cache and updates the key set. + * + * This operation is thread-safe. + * + * @param key The key associated with the single item to be invalidated. + */ + fun invalidateSingle(key: StoreKey.Single) = synchronized(this) { + singlesCache.invalidate(key) + keys.remove(key) + } + + /** + * Removes a collection of items from the cache and updates the key set. + * + * This operation is thread-safe. + * + * @param key The key associated with the collection to be invalidated. + */ + fun invalidateCollection(key: StoreKey.Collection) = synchronized(this) { + collectionsCache.invalidate(key) + keys.remove(key) + } + + /** + * Calculates the total count of items in the cache, including both single items and items in collections. + * + * This operation is thread-safe. + * + * @return The total count of items in the cache. + */ + fun size(): Long = synchronized(this) { + var count = 0L + for (key in keys) { + when (key) { + is StoreKey.Single -> { + val single = singlesCache.getIfPresent(key) + if (single != null) { + count++ + } + } + + is StoreKey.Collection -> { + val collection = collectionsCache.getIfPresent(key) + if (collection != null) { + count += collection.items.size + } + } + } + } + count + } +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts new file mode 100644 index 000000000..f7277233d --- /dev/null +++ b/core/build.gradle.kts @@ -0,0 +1,39 @@ + +plugins { + kotlin("multiplatform") + kotlin("plugin.serialization") + id("com.android.library") + id("com.vanniktech.maven.publish") + id("org.jetbrains.dokka") + id("org.jetbrains.kotlinx.kover") + `maven-publish` + id("kotlinx-atomicfu") +} + +kotlin { + androidTarget() + jvm() + iosArm64() + iosX64() + linuxX64() + iosSimulatorArm64() + js { + browser() + nodejs() + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation(libs.kotlin.stdlib) + } + } + } +} + +android { + + compileSdk = 33 + + sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") +} diff --git a/core/src/androidMain/AndroidManifest.xml b/core/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..463e3657a --- /dev/null +++ b/core/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/InsertionStrategy.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/InsertionStrategy.kt new file mode 100644 index 000000000..a0d73254b --- /dev/null +++ b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/InsertionStrategy.kt @@ -0,0 +1,7 @@ +package org.mobilenativefoundation.store.core5 + +enum class InsertionStrategy { + APPEND, + PREPEND, + REPLACE +} diff --git a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/KeyProvider.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/KeyProvider.kt new file mode 100644 index 000000000..c65579067 --- /dev/null +++ b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/KeyProvider.kt @@ -0,0 +1,6 @@ +package org.mobilenativefoundation.store.core5 + +interface KeyProvider> { + fun fromCollection(key: StoreKey.Collection, value: Single): StoreKey.Single + fun fromSingle(key: StoreKey.Single, value: Single): StoreKey.Collection +} diff --git a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreData.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreData.kt new file mode 100644 index 000000000..ca285a6a2 --- /dev/null +++ b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreData.kt @@ -0,0 +1,34 @@ +package org.mobilenativefoundation.store.core5 + +/** + * An interface that defines items that can be uniquely identified. + * Every item that implements the [StoreData] interface must have a means of identification. + * This is useful in scenarios when data can be represented as singles or collections. + */ + +interface StoreData { + + /** + * Represents a single identifiable item. + */ + interface Single : StoreData { + val id: Id + } + + /** + * Represents a collection of identifiable items. + */ + interface Collection> : StoreData { + val items: List + + /** + * Returns a new collection with the updated items. + */ + fun copyWith(items: List): Collection + + /** + * Inserts items to the existing collection and returns the updated collection. + */ + fun insertItems(strategy: InsertionStrategy, items: List): Collection + } +} diff --git a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt new file mode 100644 index 000000000..9026c9dd0 --- /dev/null +++ b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt @@ -0,0 +1,61 @@ +package org.mobilenativefoundation.store.core5 + +/** + * An interface that defines keys used by Store for data-fetching operations. + * Allows Store to fetch individual items and collections of items. + * Provides mechanisms for ID-based fetch, page-based fetch, and cursor-based fetch. + * Includes options for sorting and filtering. + */ +interface StoreKey { + + /** + * Represents a key for fetching an individual item. + */ + interface Single : StoreKey { + val id: Id + } + + /** + * Represents a key for fetching collections of items. + */ + interface Collection : StoreKey { + val insertionStrategy: InsertionStrategy + + /** + * Represents a key for page-based fetching. + */ + interface Page : Collection { + val page: Int + val size: Int + val sort: Sort? + val filters: List>? + } + + /** + * Represents a key for cursor-based fetching. + */ + interface Cursor : Collection { + val cursor: Id? + val size: Int + val sort: Sort? + val filters: List>? + } + } + + /** + * An enum defining sorting options that can be applied during fetching. + */ + enum class Sort { + NEWEST, + OLDEST, + ALPHABETICAL, + REVERSE_ALPHABETICAL + } + + /** + * Defines filters that can be applied during fetching. + */ + interface Filter { + operator fun invoke(items: List): List + } +} diff --git a/gradle.properties b/gradle.properties index 0d65fbabf..8e82db2b1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,7 +8,7 @@ org.gradle.jvmargs=-XX:MaxMetaspaceSize=2G # POM file GROUP=org.mobilenativefoundation.store -VERSION_NAME=5.0.0 +VERSION_NAME=5.1.0-SNAPSHOT POM_PACKAGING=pom POM_DESCRIPTION = Store5 is a Kotlin Multiplatform network-resilient repository layer @@ -23,4 +23,6 @@ POM_DEVELOPER_ID=dropbox POM_DEVELOPER_NAME=Dropbox kotlinx.atomicfu.enableJvmIrTransformation=false kotlinx.atomicfu.enableJsIrTransformation=false -kotlin.js.compiler=ir \ No newline at end of file +kotlin.js.compiler=ir + +org.jetbrains.compose.experimental.uikit.enabled=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b5cd5a9be..c80fac0e9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,11 +4,14 @@ androidCompileSdk = "33" androidGradlePlugin = "7.4.2" androidTargetSdk = "33" atomicFu = "0.20.2" -baseKotlin = "1.8.21" +baseKotlin = "1.9.10" dokkaGradlePlugin = "1.6.0" ktlintGradle = "10.2.1" jacocoGradlePlugin = "0.8.7" mavenPublishPlugin = "0.22.0" +moleculeGradlePlugin = "1.2.1" +pagingCompose = "3.3.0-alpha02" +pagingRuntime = "3.2.1" spotlessPluginGradle = "6.4.1" junit = "4.13.2" kotlinxCoroutines = "1.7.1" @@ -18,11 +21,13 @@ testCore = "1.5.0" kmmBridge = "0.3.2" ktlint = "0.39.0" kover = "0.6.0" -store = "5.0.0-beta03" +store = "5.0.0" truth = "1.1.3" [libraries] android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } +androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "pagingCompose" } +androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "pagingRuntime" } kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "baseKotlin" } kotlin-serialization-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-serialization", version.ref = "baseKotlin" } dokka-gradle-plugin = { group = "org.jetbrains.dokka", name = "dokka-gradle-plugin", version.ref = "dokkaGradlePlugin" } @@ -40,9 +45,12 @@ kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx- kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-rx2 = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-rx2", version.ref = "kotlinxCoroutines" } kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version = "0.4.0" } +molecule-gradle-plugin = { module = "app.cash.molecule:molecule-gradle-plugin", version.ref = "moleculeGradlePlugin" } +molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "moleculeGradlePlugin" } rxjava = { group = "io.reactivex.rxjava2", name = "rxjava", version = "2.2.21" } androidx-test-core = { group = "androidx.test", name = "core", version.ref = "testCore" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } junit = { group = "junit", name = "junit", version.ref = "junit" } google-truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } touchlab-kermit = { group = "co.touchlab", name = "kermit", version.ref = "kermit" } +turbine = "app.cash.turbine:turbine:0.12.3" diff --git a/paging/build.gradle.kts b/paging/build.gradle.kts new file mode 100644 index 000000000..336069779 --- /dev/null +++ b/paging/build.gradle.kts @@ -0,0 +1,70 @@ + +plugins { + kotlin("multiplatform") + kotlin("plugin.serialization") + id("com.android.library") + id("com.vanniktech.maven.publish") + id("org.jetbrains.dokka") + id("org.jetbrains.kotlinx.kover") + `maven-publish` + id("kotlinx-atomicfu") + id("org.jetbrains.compose") version("1.5.1") +} + +kotlin { + androidTarget() + + sourceSets { + val commonMain by getting { + dependencies { + implementation(libs.kotlin.stdlib) + implementation(project(":store")) + implementation(project(":cache")) + implementation(compose.runtime) + implementation(libs.molecule.runtime) + implementation(compose.ui) + implementation(compose.foundation) + implementation(compose.material) + api(project(":core")) + } + } + + val androidMain by getting { + dependencies { + implementation(libs.androidx.paging.runtime) + implementation(libs.androidx.paging.compose) + } + } + @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + implementation(libs.turbine) + implementation(libs.kotlinx.coroutines.test) + implementation(compose.uiTestJUnit4) + implementation(compose.ui) + } + } + } +} + +android { + sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") + compileSdk = 33 + + defaultConfig { + minSdk = 24 + } + + lint { + disable += "ComposableModifierFactory" + disable += "ModifierFactoryExtensionFunction" + disable += "ModifierFactoryReturnType" + disable += "ModifierFactoryUnreferencedReceiver" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} diff --git a/paging/docs/design_doc.md b/paging/docs/design_doc.md new file mode 100644 index 000000000..493a95ffb --- /dev/null +++ b/paging/docs/design_doc.md @@ -0,0 +1,246 @@ +# Technical Design Doc: Native Paging Support in Store5 + +## Context and Scope +Feature request: [MobileNativeFoundation/Store#250](https://github.com/MobileNativeFoundation/Store/issues/250) + +This proposal addresses the need for paging support in Store. This enhancement aims to provide a simple, efficient, and flexible way to handle complex operations on large datasets. + +## Goals and Non-Goals +### Goals +- Provide native support for page-based and cursor-based fetches, handling both single items and collections. +- Enable read and write operations within a paging store. +- Support complex loading and fetching operations such as sorting and filtering. +- Ensure thread safety and concurrency support. +- Layer on top of existing Store APIs: no breaking changes! +### Non-Goals +- Integration with Paging3. +- Providing a one-size-fits-all solution: our approach should be flexible to cater to different use cases. + +## The Actual Design + +### APIs +#### StoreKey +An interface that defines keys used by Store for data-fetching operations. Allows Store to load individual items and collections of items. Provides mechanisms for ID-based fetch, page-based fetch, and cursor-based fetch. Includes options for sorting and filtering. + +```kotlin + interface StoreKey { + interface Single : StoreKey { + val id: Id + } + interface Collection : StoreKey { + val insertionStrategy: InsertionStrategy + interface Page : Collection { + val page: Int + val size: Int + val sort: Sort? + val filters: List>? + } + interface Cursor : Collection { + val cursor: Id? + val size: Int + val sort: Sort? + val filters: List>? + } + } + } +``` + +#### StoreData +An interface that defines items that can be uniquely identified. Every item that implements the `StoreData` interface must have a means of identification. This is useful in scenarios when data can be represented as singles or collections. + +```kotlin + interface StoreData { + interface Single : StoreData { + val id: Id + } + interface Collection> : StoreData { + val items: List + fun copyWith(items: List): Collection + fun insertItems(strategy: InsertionStrategy, items: List): Collection + } + } +``` + +#### KeyProvider +An interface to derive keys based on provided data. `StoreMultiCache` depends on `KeyProvider` to: + +1. Derive a single key for a collection item based on the collection’s key and that item’s value. +2. Insert a single item into the correct collection based on its key and value. + +```kotlin + interface KeyProvider> { + fun from(key: StoreKey.Collection, value: Single): StoreKey.Single + fun from(key: StoreKey.Single, value: Single): StoreKey.Collection + } +``` + +### Implementations + +#### StoreMultiCache +Thread-safe caching system with collection decomposition. Manages data with utility functions to get, invalidate, and add items to the cache. Depends on `StoreMultiCacheAccessor` for internal data management. Should be used instead of `MultiCache`. + +```kotlin + class StoreMultiCache, Single : StoreData.Single, Collection : StoreData.Collection, Output : StoreData>( + private val keyProvider: KeyProvider, + singlesCache: Cache, Single> = CacheBuilder, Single>().build(), + collectionsCache: Cache, Collection> = CacheBuilder, Collection>().build(), + ): Cache +``` + +#### StoreMultiCacheAccessor +Thread-safe intermediate data manager for a caching system supporting list decomposition. Tracks keys for rapid data retrieval and modification. + +#### LaunchPagingStore +Main entry point for the paging mechanism. This will launch and manage a `StateFlow` that reflects the current state of the Store. + +```kotlin + fun , Output : StoreData> Store.launchPagingStore( + scope: CoroutineScope, + keys: Flow, + ): StateFlow> + + @OptIn(ExperimentalStoreApi::class) + fun , Output : StoreData> MutableStore.launchPagingStore( + scope: CoroutineScope, + keys: Flow, + ): StateFlow> +``` + +## Usage +### StoreKey Example +```kotlin + sealed class ExampleKey : StoreKey { + data class Cursor( + override val cursor: String?, + override val size: Int, + override val sort: StoreKey.Sort? = null, + override val filters: List>? = null, + override val insertionStrategy: InsertionStrategy = InsertionStrategy.APPEND + ) : StoreKey.Collection.Cursor, ExampleKey() + + data class Single( + override val id: String + ) : StoreKey.Single, ExampleKey() + } +``` + +### StoreData Example +```kotlin + sealed class ExampleData : StoreData { + data class Single(val postId: String, val title: String) : StoreData.Single, ExampleData() { + override val id: String get() = postId + } + + data class Collection(val singles: List) : StoreData.Collection, ExampleData() { + override val items: List get() = singles + override fun copyWith(items: List): StoreData.Collection = copy(singles = items) + override fun insertItems(strategy: InsertionStrategy, items: List): StoreData.Collection { + + return when (strategy) { + InsertionStrategy.APPEND -> { + val updatedItems = items.toMutableList() + updatedItems.addAll(singles) + copyWith(items = updatedItems) + } + + InsertionStrategy.PREPEND -> { + val updatedItems = singles.toMutableList() + updatedItems.addAll(items) + copyWith(items = updatedItems) + } + } + } + } + } +``` + +### LaunchPagingStore Example +```kotlin + @OptIn(ExperimentalStoreApi::class) + class ExampleViewModel( + private val store: MutableStore, + private val coroutineScope: CoroutineScope = viewModelScope, + private val loadSize: Int = DEFAULT_LOAD_SIZE + ) : ViewModel() { + + private val keys = MutableStateFlow(ExampleKey.Cursor(null, loadSize)) + private val _loading = MutableStateFlow(false) + private val _error = MutableStateFlow(null) + + val stateFlow = store.launchPagingStore(coroutineScope, keys) + val loading: StateFlow = _loading.asStateFlow() + val error: StateFlow = _error.asStateFlow() + + init { + TODO("Observe loading and error states and perform any other necessary initializations") + } + + fun loadMore() { + if (_loading.value) return // Prevent loading more if already loading + _loading.value = true + + coroutineScope.launch { + try { + val currentKey = keys.value + val currentCursor = currentKey.cursor + val nextCursor = determineNextCursor(currentCursor) + val nextKey = currentKey.copy(cursor = nextCursor) + keys.value = nextKey + } catch (e: Throwable) { + _error.value = e + } finally { + _loading.value = false + } + } + } + + fun write(key: ExampleKey.Single, value: ExampleData.Single) { + coroutineScope.launch { + try { + store.write(StoreWriteRequest.of(key, value)) + } catch (e: Throwable) { + _error.value = e + } + } + } + + private fun determineNextCursor(cursor: String?): String? { + // Implementation based on specific use case + // Return the next cursor or null if there are no more items to load + TODO("Provide an implementation or handle accordingly") + } + + companion object { + private const val DEFAULT_LOAD_SIZE = 100 + } + } +``` + +## Degree of Constraint +- Data items must implement the `StoreData` interface, ensuring they can be uniquely identified. +- Keys for loading data must implement the `StoreKey` interface. + +## Deprecations +- MultiCache +- Identifiable + +## Alternatives Considered +### Tailored Solution for Paging +#### Direct integration with Paging3 +Paging3 doesn’t have built-in support for: +- Singles and collections +- Write operations +- Sorting and filtering operations + +### Custom `StoreKey` and `StoreData` Structures +#### Loose Typing +#### Annotations and Reflection +#### Functional Programming Approach + +## Cross-Cutting Concerns +- Will Paging3 extensions be a maintenance nightmare? +- Will these APIs be simpler than Paging3? + +## Future Directions +- Bindings for Paging3 (follow-up PR) +- Support for KMP Compose UI (follow-up PR) \ No newline at end of file diff --git a/paging/src/androidMain/AndroidManifest.xml b/paging/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..b617bd2bf --- /dev/null +++ b/paging/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt new file mode 100644 index 000000000..563800138 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt @@ -0,0 +1,113 @@ +@file:Suppress("UNCHECKED_CAST") + +package org.mobilenativefoundation.store.paging5 + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.core5.StoreKey +import org.mobilenativefoundation.store.store5.ExperimentalStoreApi +import org.mobilenativefoundation.store.store5.MutableStore +import org.mobilenativefoundation.store.store5.Store +import org.mobilenativefoundation.store.store5.StoreReadRequest +import org.mobilenativefoundation.store.store5.StoreReadResponse + +private class StopProcessingException : Exception() + +/** + * Initializes and returns a [StateFlow] that reflects the state of the Store, updating by a flow of provided keys. + * @param scope A [CoroutineScope]. + * @param keys A flow of keys that dictate how the Store should be updated. + * @param stream A lambda that invokes [Store.stream]. + * @return A read-only [StateFlow] reflecting the state of the Store. + */ +private fun , Output : StoreData> launchPagingStore( + scope: CoroutineScope, + keys: Flow, + stream: (key: Key) -> Flow>, +): StateFlow> { + val stateFlow = MutableStateFlow>(StoreReadResponse.Initial) + + scope.launch { + + try { + val firstKey = keys.first() + if (firstKey !is StoreKey.Collection<*>) throw IllegalArgumentException("Invalid key type") + + stream(firstKey).collect { response -> + if (response is StoreReadResponse.Data) { + val joinedDataResponse = joinData(firstKey, stateFlow.value, response) + stateFlow.emit(joinedDataResponse) + } else { + stateFlow.emit(response) + } + + if (response is StoreReadResponse.Data || + response is StoreReadResponse.Error || + response is StoreReadResponse.NoNewData + ) { + throw StopProcessingException() + } + } + } catch (_: StopProcessingException) { + } + + keys.drop(1).collect { key -> + if (key !is StoreKey.Collection<*>) throw IllegalArgumentException("Invalid key type") + val firstDataResponse = stream(key).first { it.dataOrNull() != null } as StoreReadResponse.Data + val joinedDataResponse = joinData(key, stateFlow.value, firstDataResponse) + stateFlow.emit(joinedDataResponse) + } + } + + return stateFlow.asStateFlow() +} + +/** + * Initializes and returns a [StateFlow] that reflects the state of the [Store], updating by a flow of provided keys. + * @see [launchPagingStore]. + */ +fun , Output : StoreData> Store.launchPagingStore( + scope: CoroutineScope, + keys: Flow, +): StateFlow> { + return launchPagingStore(scope, keys) { key -> + this.stream(StoreReadRequest.fresh(key)) + } +} + +/** + * Initializes and returns a [StateFlow] that reflects the state of the [Store], updating by a flow of provided keys. + * @see [launchPagingStore]. + */ +@OptIn(ExperimentalStoreApi::class) +fun , Output : StoreData> MutableStore.launchPagingStore( + scope: CoroutineScope, + keys: Flow, +): StateFlow> { + return launchPagingStore(scope, keys) { key -> + this.stream(StoreReadRequest.fresh(key)) + } +} + +private fun , Output : StoreData> joinData( + key: Key, + prevResponse: StoreReadResponse, + currentResponse: StoreReadResponse.Data +): StoreReadResponse.Data { + val lastOutput = when (prevResponse) { + is StoreReadResponse.Data -> prevResponse.value as? StoreData.Collection> + else -> null + } + + val currentData = currentResponse.value as StoreData.Collection> + + val joinedOutput = (lastOutput?.insertItems(key.insertionStrategy, currentData.items) ?: currentData) as Output + return StoreReadResponse.Data(joinedOutput, currentResponse.origin) +} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt new file mode 100644 index 000000000..8dc2b78d2 --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt @@ -0,0 +1,166 @@ +package org.mobilenativefoundation.store.paging5 + +import app.cash.turbine.test +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.mobilenativefoundation.store.paging5.util.FakePostApi +import org.mobilenativefoundation.store.paging5.util.FakePostDatabase +import org.mobilenativefoundation.store.paging5.util.PostApi +import org.mobilenativefoundation.store.paging5.util.PostData +import org.mobilenativefoundation.store.paging5.util.PostDatabase +import org.mobilenativefoundation.store.paging5.util.PostKey +import org.mobilenativefoundation.store.paging5.util.PostPutRequestResult +import org.mobilenativefoundation.store.paging5.util.PostStoreFactory +import org.mobilenativefoundation.store.store5.ExperimentalStoreApi +import org.mobilenativefoundation.store.store5.MutableStore +import org.mobilenativefoundation.store.store5.StoreReadRequest +import org.mobilenativefoundation.store.store5.StoreReadResponse +import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin +import org.mobilenativefoundation.store.store5.StoreWriteRequest +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@OptIn(ExperimentalStoreApi::class) +class LaunchPagingStoreTests { + private val testScope = TestScope() + + private val userId = "123" + private lateinit var api: PostApi + private lateinit var db: PostDatabase + private lateinit var store: MutableStore + + @Before + fun setup() { + api = FakePostApi() + db = FakePostDatabase(userId) + val factory = PostStoreFactory(api, db) + store = factory.create() + } + + @Test + fun transitionFromInitialToData() = testScope.runTest { + val key = PostKey.Cursor("1", 10) + val keys = flowOf(key) + val stateFlow = store.launchPagingStore(this, keys) + + stateFlow.test { + val state1 = awaitItem() + assertIs(state1) + val state2 = awaitItem() + assertIs(state2) + val state3 = awaitItem() + assertIs>(state3) + expectNoEvents() + } + } + + @Test + fun multipleValidKeysEmittedInSuccession() = testScope.runTest { + val key1 = PostKey.Cursor("1", 10) + val key2 = PostKey.Cursor("11", 10) + val keys = flowOf(key1, key2) + val stateFlow = store.launchPagingStore(this, keys) + + stateFlow.test { + val state1 = awaitItem() + assertIs(state1) + val state2 = awaitItem() + assertIs(state2) + val state3 = awaitItem() + assertIs>(state3) + expectNoEvents() + + val state4 = awaitItem() + assertIs>(state4) + val data4 = state4.value + assertIs(data4) + assertEquals(20, data4.items.size) + + expectNoEvents() + } + } + + @Test + fun sameKeyEmittedMultipleTimes() = testScope.runTest { + val key = PostKey.Cursor("1", 10) + val keys = flowOf(key, key) + val stateFlow = store.launchPagingStore(this, keys) + + stateFlow.test { + val state1 = awaitItem() + assertIs(state1) + val state2 = awaitItem() + assertIs(state2) + val state3 = awaitItem() + assertIs>(state3) + expectNoEvents() + } + } + + @Test + fun multipleKeysWithReadsAndWrites() = testScope.runTest { + val api = FakePostApi() + val db = FakePostDatabase(userId) + val factory = PostStoreFactory(api = api, db = db) + val store = factory.create() + + val key1 = PostKey.Cursor("1", 10) + val key2 = PostKey.Cursor("11", 10) + val keys = flowOf(key1, key2) + + val stateFlow = store.launchPagingStore(this, keys) + stateFlow.test { + val initialState = awaitItem() + assertIs(initialState) + val loadingState = awaitItem() + assertIs(loadingState) + val loadedState1 = awaitItem() + assertIs>(loadedState1) + val data1 = loadedState1.value + assertEquals(10, data1.posts.size) + val loadedState2 = awaitItem() + assertIs>(loadedState2) + val data2 = loadedState2.value + assertEquals(20, data2.posts.size) + } + + val cached = store.stream(StoreReadRequest.cached(key1, refresh = false)) + .first { it.dataOrNull() != null } + assertIs>(cached) + assertEquals(StoreReadResponseOrigin.Cache, cached.origin) + val data = cached.requireData() + assertIs(data) + assertEquals(10, data.posts.size) + + val cached2 = store.stream(StoreReadRequest.cached(PostKey.Single("2"), refresh = false)) + .first { it.dataOrNull() != null } + assertIs>(cached2) + assertEquals(StoreReadResponseOrigin.Cache, cached2.origin) + val data2 = cached2.requireData() + assertIs(data2) + assertEquals("2", data2.title) + + store.write(StoreWriteRequest.of(PostKey.Single("2"), PostData.Post("2", "2-modified"))) + + val cached3 = store.stream(StoreReadRequest.cached(PostKey.Single("2"), refresh = false)) + .first { it.dataOrNull() != null } + assertIs>(cached3) + assertEquals(StoreReadResponseOrigin.Cache, cached3.origin) + val data3 = cached3.requireData() + assertIs(data3) + assertEquals("2-modified", data3.title) + + val cached4 = + store.stream(StoreReadRequest.cached(PostKey.Cursor("1", 10), refresh = false)) + .first { it.dataOrNull() != null } + assertIs>(cached4) + assertEquals(StoreReadResponseOrigin.Cache, cached4.origin) + val data4 = cached4.requireData() + assertIs(data4) + assertEquals("2-modified", data4.posts[1].title) + } +} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostApi.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostApi.kt new file mode 100644 index 000000000..7764cc65e --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostApi.kt @@ -0,0 +1,36 @@ +package org.mobilenativefoundation.store.paging5.util + +class FakePostApi : PostApi { + + private val posts = mutableMapOf() + private val postsList = mutableListOf() + + init { + (1..100).forEach { + val id = it.toString() + posts[id] = PostData.Post(id, id) + postsList.add(PostData.Post(id, id)) + } + } + + override suspend fun get(postId: String): PostGetRequestResult { + val post = posts[postId] + return if (post != null) { + PostGetRequestResult.Data(post) + } else { + PostGetRequestResult.Error.Message("Post $postId was not found") + } + } + + override suspend fun get(cursor: String?, size: Int): FeedGetRequestResult { + val firstIndexInclusive = postsList.indexOfFirst { it.postId == cursor } + val lastIndexExclusive = firstIndexInclusive + size + val posts = postsList.subList(firstIndexInclusive, lastIndexExclusive) + return FeedGetRequestResult.Data(PostData.Feed(posts = posts)) + } + + override suspend fun put(post: PostData.Post): PostPutRequestResult { + posts.put(post.id, post) + return PostPutRequestResult.Data(post) + } +} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostDatabase.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostDatabase.kt new file mode 100644 index 000000000..a126c9b3f --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostDatabase.kt @@ -0,0 +1,34 @@ +package org.mobilenativefoundation.store.paging5.util + +class FakePostDatabase(private val userId: String) : PostDatabase { + private val posts = mutableMapOf() + private val feeds = mutableMapOf() + override fun add(post: PostData.Post) { + posts[post.id] = post + + val nextFeed = feeds[userId]?.posts?.map { + if (it.postId == post.postId) { + post + } else { + it + } + } + + nextFeed?.let { + feeds[userId] = PostData.Feed(nextFeed) + } + } + + override fun add(feed: PostData.Feed) { + feeds[userId] = feed + } + + override fun findPostByPostId(postId: String): PostData.Post? { + return posts[postId] + } + + override fun findFeedByUserId(cursor: String?, size: Int): PostData.Feed? { + val feed = feeds[userId] + return feed + } +} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FeedGetRequestResult.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FeedGetRequestResult.kt new file mode 100644 index 000000000..e1d13e50e --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FeedGetRequestResult.kt @@ -0,0 +1,10 @@ +package org.mobilenativefoundation.store.paging5.util + +sealed class FeedGetRequestResult { + + data class Data(val data: PostData.Feed) : FeedGetRequestResult() + sealed class Error : FeedGetRequestResult() { + data class Message(val error: String) : Error() + data class Exception(val error: kotlin.Exception) : Error() + } +} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostApi.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostApi.kt new file mode 100644 index 000000000..90d87e601 --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostApi.kt @@ -0,0 +1,7 @@ +package org.mobilenativefoundation.store.paging5.util + +interface PostApi { + suspend fun get(postId: String): PostGetRequestResult + suspend fun get(cursor: String?, size: Int): FeedGetRequestResult + suspend fun put(post: PostData.Post): PostPutRequestResult +} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt new file mode 100644 index 000000000..ad6b05d28 --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt @@ -0,0 +1,35 @@ +package org.mobilenativefoundation.store.paging5.util + +import org.mobilenativefoundation.store.core5.InsertionStrategy +import org.mobilenativefoundation.store.core5.StoreData + +sealed class PostData : StoreData { + data class Post(val postId: String, val title: String) : StoreData.Single, PostData() { + override val id: String get() = postId + } + + data class Feed(val posts: List) : StoreData.Collection, PostData() { + override val items: List get() = posts + override fun copyWith(items: List): StoreData.Collection = copy(posts = items) + override fun insertItems(strategy: InsertionStrategy, items: List): StoreData.Collection { + + return when (strategy) { + InsertionStrategy.APPEND -> { + val updatedItems = items.toMutableList() + updatedItems.addAll(posts) + copyWith(items = updatedItems) + } + + InsertionStrategy.PREPEND -> { + val updatedItems = posts.toMutableList() + updatedItems.addAll(items) + copyWith(items = updatedItems) + } + + InsertionStrategy.REPLACE -> { + copyWith(items = posts) + } + } + } + } +} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostDatabase.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostDatabase.kt new file mode 100644 index 000000000..d8ae595c9 --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostDatabase.kt @@ -0,0 +1,8 @@ +package org.mobilenativefoundation.store.paging5.util + +interface PostDatabase { + fun add(post: PostData.Post) + fun add(feed: PostData.Feed) + fun findPostByPostId(postId: String): PostData.Post? + fun findFeedByUserId(cursor: String?, size: Int): PostData.Feed? +} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostGetRequestResult.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostGetRequestResult.kt new file mode 100644 index 000000000..d481f661f --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostGetRequestResult.kt @@ -0,0 +1,10 @@ +package org.mobilenativefoundation.store.paging5.util + +sealed class PostGetRequestResult { + + data class Data(val data: PostData.Post) : PostGetRequestResult() + sealed class Error : PostGetRequestResult() { + data class Message(val error: String) : Error() + data class Exception(val error: kotlin.Exception) : Error() + } +} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostKey.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostKey.kt new file mode 100644 index 000000000..451c5e0b9 --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostKey.kt @@ -0,0 +1,18 @@ +package org.mobilenativefoundation.store.paging5.util + +import org.mobilenativefoundation.store.core5.InsertionStrategy +import org.mobilenativefoundation.store.core5.StoreKey + +sealed class PostKey : StoreKey { + data class Cursor( + override val cursor: String?, + override val size: Int, + override val sort: StoreKey.Sort? = null, + override val filters: List>? = null, + override val insertionStrategy: InsertionStrategy = InsertionStrategy.APPEND + ) : StoreKey.Collection.Cursor, PostKey() + + data class Single( + override val id: String + ) : StoreKey.Single, PostKey() +} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostPutRequestResult.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostPutRequestResult.kt new file mode 100644 index 000000000..fdf855dbb --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostPutRequestResult.kt @@ -0,0 +1,10 @@ +package org.mobilenativefoundation.store.paging5.util + +sealed class PostPutRequestResult { + + data class Data(val data: PostData.Post) : PostPutRequestResult() + sealed class Error : PostPutRequestResult() { + data class Message(val error: String) : Error() + data class Exception(val error: kotlin.Exception) : Error() + } +} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt new file mode 100644 index 000000000..40ef9d872 --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt @@ -0,0 +1,138 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store.paging5.util + +import kotlinx.coroutines.flow.flow +import org.mobilenativefoundation.store.cache5.Cache +import org.mobilenativefoundation.store.cache5.StoreMultiCache +import org.mobilenativefoundation.store.core5.KeyProvider +import org.mobilenativefoundation.store.core5.StoreKey +import org.mobilenativefoundation.store.store5.Converter +import org.mobilenativefoundation.store.store5.ExperimentalStoreApi +import org.mobilenativefoundation.store.store5.Fetcher +import org.mobilenativefoundation.store.store5.MutableStore +import org.mobilenativefoundation.store.store5.SourceOfTruth +import org.mobilenativefoundation.store.store5.StoreBuilder +import org.mobilenativefoundation.store.store5.Updater +import org.mobilenativefoundation.store.store5.UpdaterResult +import kotlin.math.floor + +class PostStoreFactory(private val api: PostApi, private val db: PostDatabase) { + + private fun createFetcher(): Fetcher = Fetcher.of { key -> + when (key) { + is PostKey.Single -> { + when (val result = api.get(key.id)) { + is PostGetRequestResult.Data -> { + result.data + } + + is PostGetRequestResult.Error.Exception -> { + throw Throwable(result.error) + } + + is PostGetRequestResult.Error.Message -> { + throw Throwable(result.error) + } + } + } + + is PostKey.Cursor -> { + when (val result = api.get(key.cursor, key.size)) { + is FeedGetRequestResult.Data -> { + result.data + } + + is FeedGetRequestResult.Error.Exception -> { + throw Throwable(result.error) + } + + is FeedGetRequestResult.Error.Message -> { + throw Throwable(result.error) + } + } + } + } + } + + private fun createSourceOfTruth(): SourceOfTruth = SourceOfTruth.of( + reader = { key -> + flow { + when (key) { + is PostKey.Single -> { + val post = db.findPostByPostId(key.id) + emit(post) + } + + is PostKey.Cursor -> { + val feed = db.findFeedByUserId(key.cursor, key.size) + emit(feed) + } + } + } + }, + writer = { key, data -> + when { + key is PostKey.Single && data is PostData.Post -> { + db.add(data) + } + + key is PostKey.Cursor && data is PostData.Feed -> { + db.add(data) + } + } + } + ) + + private fun createConverter(): Converter = + Converter.Builder() + .fromNetworkToLocal { it } + .fromOutputToLocal { it } + .build() + + private fun createUpdater(): Updater = Updater.by( + post = { key, data -> + when { + key is PostKey.Single && data is PostData.Post -> { + when (val result = api.put(data)) { + is PostPutRequestResult.Data -> UpdaterResult.Success.Typed(result) + is PostPutRequestResult.Error.Exception -> UpdaterResult.Error.Exception(result.error) + is PostPutRequestResult.Error.Message -> UpdaterResult.Error.Message(result.error) + } + } + + else -> UpdaterResult.Error.Message("Unsupported: key: ${key::class}, data: ${data::class}") + } + } + ) + + private fun createPagingCacheKeyProvider(): KeyProvider = + object : KeyProvider { + override fun fromCollection( + key: StoreKey.Collection, + value: PostData.Post + ): StoreKey.Single { + return PostKey.Single(value.postId) + } + + override fun fromSingle(key: StoreKey.Single, value: PostData.Post): StoreKey.Collection { + val id = value.postId.toInt() + val cursor = (floor(id.toDouble() / 10) * 10) + 1 + return PostKey.Cursor(cursor.toInt().toString(), 10) + } + } + + private fun createMemoryCache(): Cache = + StoreMultiCache(createPagingCacheKeyProvider()) + + fun create(): MutableStore = StoreBuilder.from( + fetcher = createFetcher(), + sourceOfTruth = createSourceOfTruth(), + memoryCache = createMemoryCache() + ).toMutableStoreBuilder( + converter = createConverter() + ).build( + updater = createUpdater(), + bookkeeper = null + ) +} diff --git a/settings.gradle b/settings.gradle index cadabf8da..8c532892f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,3 +2,5 @@ include ':store' include ':cache' include ':multicast' include ':rx2' +include ':paging' +include ':core' \ No newline at end of file diff --git a/store/build.gradle.kts b/store/build.gradle.kts index 68813dacc..22a2d1bde 100644 --- a/store/build.gradle.kts +++ b/store/build.gradle.kts @@ -56,6 +56,7 @@ kotlin { implementation(libs.touchlab.kermit) implementation(project(":multicast")) implementation(project(":cache")) + api(project(":core")) } } @@ -115,7 +116,7 @@ addGithubPackagesRepository() kmmbridge { githubReleaseArtifacts() githubReleaseVersions() - versionPrefix.set("5.0.0-alpha") + versionPrefix.set("5.0.0") spm() } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponse.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponse.kt index e71dd93a3..658baa216 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponse.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponse.kt @@ -28,6 +28,10 @@ sealed class StoreReadResponse { */ abstract val origin: StoreReadResponseOrigin + object Initial : StoreReadResponse() { + override val origin: StoreReadResponseOrigin = StoreReadResponseOrigin.Initial + } + /** * Loading event dispatched by [Store] to signal the [Fetcher] is in progress. */ @@ -107,6 +111,7 @@ sealed class StoreReadResponse { is Loading -> this is NoNewData -> this is Data -> throw RuntimeException("cannot swap type for StoreResponse.Data") + is Initial -> this } } @@ -129,6 +134,8 @@ sealed class StoreReadResponseOrigin { * @property name Unique name to enable differentiation when [org.mobilenativefoundation.store.store5.Fetcher.fallback] exists */ data class Fetcher(val name: String? = null) : StoreReadResponseOrigin() + + object Initial : StoreReadResponseOrigin() } fun StoreReadResponse.Error.doThrow(): Nothing = when (this) { diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt index 878d22c72..cc835132b 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt @@ -294,6 +294,7 @@ internal class RealStore( // for other errors, don't do anything, wait for the read attempt } + is StoreReadResponse.Initial, is StoreReadResponse.Loading, is StoreReadResponse.NoNewData -> { } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/store.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/store.kt index 29f41984d..21ab44e54 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/store.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/store.kt @@ -3,6 +3,7 @@ package org.mobilenativefoundation.store.store5.impl.extensions import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.first import org.mobilenativefoundation.store.store5.Bookkeeper +import org.mobilenativefoundation.store.store5.ExperimentalStoreApi import org.mobilenativefoundation.store.store5.MutableStore import org.mobilenativefoundation.store.store5.Store import org.mobilenativefoundation.store.store5.StoreReadRequest @@ -35,6 +36,7 @@ suspend fun Store.fresh(key: Key) = .first() .requireData() +@OptIn(ExperimentalStoreApi::class) @Suppress("UNCHECKED_CAST") fun Store.asMutableStore( updater: Updater, @@ -49,3 +51,17 @@ fun Store< bookkeeper = bookkeeper ) } + +@OptIn(ExperimentalStoreApi::class) +suspend fun MutableStore.get(key: Key) = + stream(StoreReadRequest.cached(key, refresh = false)) + .filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData } + .first() + .requireData() + +@OptIn(ExperimentalStoreApi::class) +suspend fun MutableStore.fresh(key: Key) = + stream(StoreReadRequest.fresh(key)) + .filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData } + .first() + .requireData() diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/MutableStoreWithMultiCacheTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/MutableStoreWithMultiCacheTests.kt deleted file mode 100644 index 3b213e9e6..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/MutableStoreWithMultiCacheTests.kt +++ /dev/null @@ -1,88 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.cache5.CacheBuilder -import org.mobilenativefoundation.store.cache5.MultiCache -import org.mobilenativefoundation.store.store5.util.fake.NoteCollections -import org.mobilenativefoundation.store.store5.util.fake.Notes -import org.mobilenativefoundation.store.store5.util.fake.NotesApi -import org.mobilenativefoundation.store.store5.util.fake.NotesDatabase -import org.mobilenativefoundation.store.store5.util.fake.NotesKey -import org.mobilenativefoundation.store.store5.util.fake.NotesMemoryCache -import org.mobilenativefoundation.store.store5.util.model.InputNote -import org.mobilenativefoundation.store.store5.util.model.NetworkNote -import org.mobilenativefoundation.store.store5.util.model.NoteData -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs - -@OptIn(ExperimentalStoreApi::class) -class MutableStoreWithMultiCacheTests { - private val testScope = TestScope() - private lateinit var api: NotesApi - private lateinit var database: NotesDatabase - - @BeforeTest - fun before() { - api = NotesApi() - database = NotesDatabase() - } - - @Test - fun givenEmptyStoreWhenListFromFetcherThenListIsDecomposed() = testScope.runTest { - val memoryCache = - NotesMemoryCache(MultiCache(CacheBuilder())) - - val converter: Converter = - Converter.Builder() - .fromNetworkToLocal { network: NetworkNote -> network } - .fromOutputToLocal { output: NoteData -> NetworkNote(output, Long.MAX_VALUE) } - .build() - val store = StoreBuilder.from( - fetcher = Fetcher.of { key -> api.get(key) }, - sourceOfTruth = SourceOfTruth.of( - nonFlowReader = { key -> database.get(key)!!.data }, - writer = { key, note -> database.put(key, InputNote(note.data, Long.MAX_VALUE)) }, - delete = null, - deleteAll = null - ), - memoryCache = memoryCache - ).toMutableStoreBuilder( - converter - ).build( - updater = Updater.by( - post = { _, _ -> UpdaterResult.Error.Exception(Exception()) } - ) - ) - - val freshRequest = - StoreReadRequest.fresh(NotesKey.Collection(NoteCollections.Keys.OneAndTwo)) - - val freshStream = store.stream(freshRequest) - - val actualResultFromFreshStream = freshStream.take(2).toList() - val expectedResultFromFreshStream = listOf( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data(NoteCollections.OneAndTwo, StoreReadResponseOrigin.Fetcher()) - ) - - assertEquals(expectedResultFromFreshStream, actualResultFromFreshStream) - - val singleFromMemoryCache = memoryCache.getIfPresent(NotesKey.Single(Notes.One.id)) - assertIs(singleFromMemoryCache) - assertEquals(singleFromMemoryCache.item, Notes.One) - - val cachedRequest = StoreReadRequest.cached(NotesKey.Single(Notes.One.id), refresh = true) - val cachedStream = store.stream(cachedRequest) - val actualResultFromCachedStream = cachedStream.take(1).toList() - val expectedResultFromCachedStream = listOf( - StoreReadResponse.Data(NoteData.Single(Notes.One), StoreReadResponseOrigin.Cache) - ) - - assertEquals(expectedResultFromCachedStream, actualResultFromCachedStream) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesMemoryCache.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesMemoryCache.kt deleted file mode 100644 index 98bf49c51..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesMemoryCache.kt +++ /dev/null @@ -1,89 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake - -import org.mobilenativefoundation.store.cache5.Cache -import org.mobilenativefoundation.store.cache5.MultiCache -import org.mobilenativefoundation.store.store5.util.model.Note -import org.mobilenativefoundation.store.store5.util.model.NoteData - -internal class NotesMemoryCache(private val delegate: MultiCache) : Cache { - override fun getIfPresent(key: NotesKey): NoteData? = when (key) { - is NotesKey.Collection -> { - val items = delegate.getCollection>(key.id) - if (items != null) { - NoteData.Collection(items) - } else { - null - } - } - - is NotesKey.Single -> { - val item = delegate.getItem(key.id) - if (item != null) { - NoteData.Single(item) - } else { - null - } - } - } - - override fun getOrPut(key: NotesKey, valueProducer: () -> NoteData): NoteData { - val collection = getIfPresent(key) - return if (collection == null) { - val noteData = valueProducer() - put(key, noteData) - noteData - } else { - collection - } - } - - override fun getAllPresent(keys: List<*>): Map { - val map = mutableMapOf() - - keys.filterIsInstance().forEach { key -> - val noteData = getIfPresent(key) - if (noteData != null) { - map[key] = noteData - } - } - - return map - } - - override fun invalidateAll() { - delegate.invalidateAll() - } - - override fun size(): Long { - return delegate.size() - } - - override fun invalidateAll(keys: List) { - keys.forEach { key -> - invalidate(key) - } - } - - override fun invalidate(key: NotesKey) = when (key) { - is NotesKey.Collection -> delegate.invalidateCollection(key.id) - is NotesKey.Single -> delegate.invalidateItem(key.id) - } - - override fun putAll(map: Map) { - map.entries.forEach { (key, noteData) -> - put(key, noteData) - } - } - - override fun put(key: NotesKey, value: NoteData) = when (key) { - is NotesKey.Collection -> { - require(value is NoteData.Collection) - delegate.putCollection(key.id, value.items) - } - - is NotesKey.Single -> { - require(value is NoteData.Single) - delegate.putItem(key.id, value.item) - } - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/model/NoteData.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/model/NoteData.kt index 799506b68..dc0808c1b 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/model/NoteData.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/model/NoteData.kt @@ -1,6 +1,5 @@ package org.mobilenativefoundation.store.store5.util.model -import org.mobilenativefoundation.store.cache5.Identifiable import org.mobilenativefoundation.store.store5.util.fake.NotesKey internal sealed class NoteData { @@ -29,7 +28,7 @@ internal data class OutputNote( ) internal data class Note( - override val id: String, + val id: String, val title: String, val content: String -) : Identifiable +)