diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt index 7003bc582..29f35386b 100644 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt +++ b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt @@ -2,6 +2,7 @@ package org.mobilenativefoundation.store.cache5 +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi import org.mobilenativefoundation.store.core5.KeyProvider import org.mobilenativefoundation.store.core5.StoreData import org.mobilenativefoundation.store.core5.StoreKey @@ -12,7 +13,8 @@ import org.mobilenativefoundation.store.core5.StoreKey * Depends on [StoreMultiCacheAccessor] for internal data management. * @see [Cache]. */ -class StoreMultiCache, Single : StoreData.Single, Collection : StoreData.Collection, Output : StoreData>( +@OptIn(ExperimentalStoreApi::class) +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(), diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor.kt index 04e990569..5a21b4faa 100644 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor.kt +++ b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor.kt @@ -2,6 +2,7 @@ package org.mobilenativefoundation.store.cache5 import kotlinx.atomicfu.locks.SynchronizedObject import kotlinx.atomicfu.locks.synchronized +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi import org.mobilenativefoundation.store.core5.StoreData import org.mobilenativefoundation.store.core5.StoreKey @@ -20,7 +21,8 @@ import org.mobilenativefoundation.store.core5.StoreKey * @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>( +@ExperimentalStoreApi +class StoreMultiCacheAccessor, Single : StoreData.Single>( private val singlesCache: Cache, Single>, private val collectionsCache: Cache, Collection>, ) : SynchronizedObject() { diff --git a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreData.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreData.kt index 30895bba1..dfed2fcc7 100644 --- a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreData.kt +++ b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreData.kt @@ -18,17 +18,21 @@ interface StoreData { /** * Represents a collection of identifiable items. */ - interface Collection> : StoreData { - val items: List + interface Collection, SO : Single> : StoreData { + val items: List + val itemsBefore: Int? + val itemsAfter: Int? + val prevKey: CK + val nextKey: CK? /** * Returns a new collection with the updated items. */ - fun copyWith(items: List): Collection + fun copyWith(items: List): Collection /** * Inserts items to the existing collection and returns the updated collection. */ - fun insertItems(strategy: InsertionStrategy, items: List): 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 index 529f762a8..f5560e7f9 100644 --- a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt +++ b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt @@ -12,7 +12,7 @@ interface StoreKey { /** * Represents a key for fetching an individual item. */ - interface Single : StoreKey { + interface Single : StoreKey { val id: Id } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6de9532a9..b34a8bd14 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,19 +3,19 @@ androidMinSdk = "24" androidCompileSdk = "33" androidGradlePlugin = "7.4.2" androidTargetSdk = "33" -atomicFu = "0.20.2" -baseKotlin = "1.9.10" +atomicFu = "0.23.1" +baseKotlin = "1.9.22" dokkaGradlePlugin = "1.6.0" -ktlintGradle = "10.2.1" +ktlintGradle = "11.0.0" jacocoGradlePlugin = "0.8.7" mavenPublishPlugin = "0.22.0" moleculeGradlePlugin = "1.2.1" -pagingCompose = "3.3.0-alpha02" +pagingCompose = "3.3.0-alpha03" pagingRuntime = "3.2.1" spotlessPluginGradle = "6.4.1" junit = "4.13.2" -kotlinxCoroutines = "1.7.1" -kotlinxSerialization = "1.5.1" +kotlinxCoroutines = "1.7.3" +kotlinxSerialization = "1.6.3" kermit = "1.2.2" testCore = "1.5.0" kmmBridge = "0.3.2" @@ -23,6 +23,8 @@ ktlint = "0.39.0" kover = "0.6.0" store = "5.1.0-alpha02" truth = "1.1.3" +jetbrains-compose = "1.5.12" + [libraries] android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } @@ -54,3 +56,7 @@ 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:1.0.0" + + +[plugins] +compose = { id = "org.jetbrains.compose", version.ref = "jetbrains-compose" } \ No newline at end of file diff --git a/paging/compose/build.gradle.kts b/paging/compose/build.gradle.kts new file mode 100644 index 000000000..87843f3ab --- /dev/null +++ b/paging/compose/build.gradle.kts @@ -0,0 +1,124 @@ +import com.vanniktech.maven.publish.SonatypeHost +import org.jetbrains.dokka.gradle.DokkaTask + +plugins { + kotlin("multiplatform") + kotlin("plugin.serialization") + id("com.android.library") + id("com.vanniktech.maven.publish") + id("org.jetbrains.dokka") + id("org.jetbrains.kotlinx.kover") + id("co.touchlab.faktory.kmmbridge") version("0.3.2") + `maven-publish` + kotlin("native.cocoapods") + id("kotlinx-atomicfu") + alias(libs.plugins.compose) +} + +kotlin { + android() + jvm() + iosArm64() + iosX64() + linuxX64() + iosSimulatorArm64() + js { + browser() + nodejs() + } + cocoapods { + summary = "Store5/Paging" + homepage = "https://github.com/MobileNativeFoundation/Store" + ios.deploymentTarget = "13" + version = libs.versions.store.get() + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation(libs.kotlin.stdlib) + implementation(project(":store")) + implementation(project(":cache")) + api(project(":core")) + implementation(compose.runtime) + implementation(libs.kotlinx.coroutines.core) + api(project(":paging")) + } + } + + val androidMain by getting + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + implementation(libs.turbine) + implementation(libs.kotlinx.coroutines.test) + } + } + } +} + +android { + sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") + compileSdk = 33 + + defaultConfig { + minSdk = 24 + targetSdk = 33 + } + + lint { + disable += "ComposableModifierFactory" + disable += "ModifierFactoryExtensionFunction" + disable += "ModifierFactoryReturnType" + disable += "ModifierFactoryUnreferencedReceiver" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +tasks.withType().configureEach { + dokkaSourceSets.configureEach { + reportUndocumented.set(false) + skipDeprecated.set(true) + jdkVersion.set(11) + } +} + +mavenPublishing { + publishToMavenCentral(SonatypeHost.S01) + signAllPublications() +} + +addGithubPackagesRepository() +kmmbridge { + githubReleaseArtifacts() + githubReleaseVersions() + versionPrefix.set(libs.versions.store.get()) + spm() +} + +koverMerged { + enable() + + xmlReport { + onCheck.set(true) + reportFile.set(layout.projectDirectory.file("kover/coverage.xml")) + } + + htmlReport { + onCheck.set(true) + reportDir.set(layout.projectDirectory.dir("kover/html")) + } + + verify { + onCheck.set(true) + } +} + +atomicfu { + transformJvm = false + transformJs = false +} diff --git a/paging/compose/gradle.properties b/paging/compose/gradle.properties new file mode 100644 index 000000000..9b757bfed --- /dev/null +++ b/paging/compose/gradle.properties @@ -0,0 +1,3 @@ +POM_NAME=org.mobilenativefoundation.store +POM_ARTIFACT_ID=paging-compose +POM_PACKAGING=jar \ No newline at end of file diff --git a/paging/compose/src/commonMain/kotlin/org/mobilenativefoundation/store/paging/compose/rememberPager.kt b/paging/compose/src/commonMain/kotlin/org/mobilenativefoundation/store/paging/compose/rememberPager.kt new file mode 100644 index 000000000..a2750e148 --- /dev/null +++ b/paging/compose/src/commonMain/kotlin/org/mobilenativefoundation/store/paging/compose/rememberPager.kt @@ -0,0 +1,60 @@ +package org.mobilenativefoundation.store.paging.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.core5.StoreKey +import org.mobilenativefoundation.store.paging5.PageAggregatingStrategy +import org.mobilenativefoundation.store.paging5.PageFetchingStrategy +import org.mobilenativefoundation.store.paging5.Pager +import org.mobilenativefoundation.store.paging5.PagingConfig +import org.mobilenativefoundation.store.paging5.PagingSource +import org.mobilenativefoundation.store.paging5.impl.DefaultPageAggregatingStrategy +import org.mobilenativefoundation.store.paging5.impl.DefaultPageFetchingStrategy + +@Composable +@ExperimentalStoreApi +inline fun , CK : StoreKey.Collection, SO : StoreData.Single> rememberPager( + coroutineScope: CoroutineScope = rememberCoroutineScope(), + initialKey: CK, + anchorPosition: StateFlow = MutableStateFlow(null), + pagingConfig: PagingConfig = PagingConfig(), + aggregator: PageAggregatingStrategy = DefaultPageAggregatingStrategy(), + pageFetchingStrategy: PageFetchingStrategy = DefaultPageFetchingStrategy(), + noinline pagingSourceFactory: () -> PagingSource +): Pager = remember(coroutineScope, initialKey, anchorPosition, pagingConfig, aggregator, pageFetchingStrategy, pagingSourceFactory) { + Pager.create( + scope = coroutineScope, + initialKey = initialKey, + anchorPosition = anchorPosition, + pagingConfig = pagingConfig, + aggregator = aggregator, + pageFetchingStrategy = pageFetchingStrategy, + pagingSourceFactory = pagingSourceFactory + ) +} + + + +@Composable +@ExperimentalStoreApi +fun , CK : StoreKey.Collection, SO : StoreData.Single> Pager( + pager: Pager, + content: @Composable (data: Pager.PagingData, error: Pager.PagingError?) -> Unit +) { + + val combinedFlow = combine(pager.data, pager.errors) { data, error -> + data to error + } + + val (data, error) = combinedFlow.collectAsState(Pager.PagingData(emptyList()) to null).value + + content(data, error) +} \ No newline at end of file diff --git a/paging/docs/design_doc.md b/paging/docs/design_doc.md deleted file mode 100644 index 493a95ffb..000000000 --- a/paging/docs/design_doc.md +++ /dev/null @@ -1,246 +0,0 @@ -# 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/commonMain/kotlin/org/mobilenativefoundation/store/paging5/ErrorHandlingStrategy.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/ErrorHandlingStrategy.kt new file mode 100644 index 000000000..72104cb51 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/ErrorHandlingStrategy.kt @@ -0,0 +1,12 @@ +package org.mobilenativefoundation.store.paging5 + +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi + +@ExperimentalStoreApi +sealed interface ErrorHandlingStrategy { + object RetryLast : ErrorHandlingStrategy + + object Ignore : ErrorHandlingStrategy + + data class Custom(val action: () -> Unit) : ErrorHandlingStrategy +} \ 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 deleted file mode 100644 index 58138e5e5..000000000 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt +++ /dev/null @@ -1,116 +0,0 @@ -@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.ExperimentalStoreApi -import org.mobilenativefoundation.store.core5.StoreData -import org.mobilenativefoundation.store.core5.StoreKey -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. - */ -@ExperimentalStoreApi -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]. - */ -@ExperimentalStoreApi -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]. - */ -@ExperimentalStoreApi -fun , Output : StoreData> MutableStore.launchPagingStore( - scope: CoroutineScope, - keys: Flow, -): StateFlow> { - return launchPagingStore(scope, keys) { key -> - this.stream(StoreReadRequest.fresh(key)) - } -} - -@ExperimentalStoreApi -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/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PageAggregatingStrategy.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PageAggregatingStrategy.kt new file mode 100644 index 000000000..b1cedd1e7 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PageAggregatingStrategy.kt @@ -0,0 +1,10 @@ +package org.mobilenativefoundation.store.paging5 + +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.core5.StoreKey + +@ExperimentalStoreApi +fun interface PageAggregatingStrategy, SO : StoreData.Single> { + fun aggregate(state: PagingState): Pager.PagingData +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PageFetchingStrategy.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PageFetchingStrategy.kt new file mode 100644 index 000000000..55444391e --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PageFetchingStrategy.kt @@ -0,0 +1,10 @@ +package org.mobilenativefoundation.store.paging5 + +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.core5.StoreKey + +@ExperimentalStoreApi +fun interface PageFetchingStrategy, CK : StoreKey.Collection, SO : StoreData.Single> { + fun shouldFetchNextPage(state: PagingState): Boolean +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Pager.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Pager.kt new file mode 100644 index 000000000..a43e238ce --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Pager.kt @@ -0,0 +1,64 @@ +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 org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.core5.StoreKey +import org.mobilenativefoundation.store.paging5.impl.DefaultPageAggregatingStrategy +import org.mobilenativefoundation.store.paging5.impl.DefaultPageFetchingStrategy +import org.mobilenativefoundation.store.paging5.impl.RealJobCoordinator +import org.mobilenativefoundation.store.paging5.impl.RealPager +import org.mobilenativefoundation.store.paging5.impl.RealPagingErrorManager +import org.mobilenativefoundation.store.paging5.impl.RealPagingStateManager + + +@ExperimentalStoreApi +interface Pager, CK : StoreKey.Collection, SO : StoreData.Single> { + val data: Flow> + + // Exposing error flow for UI components to observe and react to errors + val errors: Flow + + data class PagingData>( + val items: List + ) + + data class PagingError(val error: Throwable) + + companion object { + + fun , CK : StoreKey.Collection, SO : StoreData.Single> create( + scope: CoroutineScope, + initialKey: CK, + anchorPosition: StateFlow, + pagingConfig: PagingConfig = PagingConfig(), + aggregator: PageAggregatingStrategy = DefaultPageAggregatingStrategy(), + pageFetchingStrategy: PageFetchingStrategy = DefaultPageFetchingStrategy(), + pagingSourceFactory: () -> PagingSource + ): Pager { + + val stateManager = RealPagingStateManager() + + return RealPager( + scope = scope, + initialKey = initialKey, + anchorPosition = anchorPosition, + pagingConfig = pagingConfig, + aggregator = aggregator, + pageFetchingStrategy = pageFetchingStrategy, + stateManager = stateManager, + errorManagerFactory = { retry -> + RealPagingErrorManager(pagingConfig, stateManager, retry) + }, + jobCoordinatorFactory = { childScope -> + RealJobCoordinator(childScope) + }, + pagingSourceFactory + ) + } + } +} + diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingConfig.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingConfig.kt new file mode 100644 index 000000000..380d432b5 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingConfig.kt @@ -0,0 +1,10 @@ +package org.mobilenativefoundation.store.paging5 + +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi + +@ExperimentalStoreApi +data class PagingConfig( + val pageSize: Int = 10, + val prefetchDistance: Int = 100, + val errorHandlingStrategy: ErrorHandlingStrategy = ErrorHandlingStrategy.RetryLast +) \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingSource.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingSource.kt new file mode 100644 index 000000000..58382c6e2 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingSource.kt @@ -0,0 +1,29 @@ +package org.mobilenativefoundation.store.paging5 + +import kotlinx.coroutines.flow.Flow +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.core5.StoreKey + +@ExperimentalStoreApi +interface PagingSource, SO: StoreData.Single> { + fun stream(params: LoadParams): Flow + + data class LoadParams>( + val key: CK, + val refresh: Boolean + ) + + sealed class LoadResult { + data class Error(val throwable: Throwable): LoadResult() + data class Page, SO: StoreData.Single>( + val data: List, + val itemsAfter: Int?, + val itemsBefore: Int?, + val nextKey: CK?, + val prevKey: CK?, + ): LoadResult() + } +} + + diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingState.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingState.kt new file mode 100644 index 000000000..e9ffa274d --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingState.kt @@ -0,0 +1,13 @@ +package org.mobilenativefoundation.store.paging5 + +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.core5.StoreKey + +@ExperimentalStoreApi +data class PagingState, SO : StoreData.Single>( + val anchorPosition: Id?, + val prefetchPosition: Id?, + val config: PagingConfig, + val pages: LinkedHashMap, PagingSource.LoadResult.Page> +) \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingStreamProvider.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingStreamProvider.kt new file mode 100644 index 000000000..8732c80cb --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingStreamProvider.kt @@ -0,0 +1,10 @@ +package org.mobilenativefoundation.store.paging5 + +import kotlinx.coroutines.flow.Flow +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.StoreKey + +@ExperimentalStoreApi +fun interface PagingStreamProvider> { + fun stream(params: PagingSource.LoadParams): Flow +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/DefaultPageAggregatingStrategy.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/DefaultPageAggregatingStrategy.kt new file mode 100644 index 000000000..f34de9ac9 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/DefaultPageAggregatingStrategy.kt @@ -0,0 +1,39 @@ +package org.mobilenativefoundation.store.paging5.impl + +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.InsertionStrategy +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.core5.StoreKey +import org.mobilenativefoundation.store.paging5.PageAggregatingStrategy +import org.mobilenativefoundation.store.paging5.Pager +import org.mobilenativefoundation.store.paging5.PagingSource +import org.mobilenativefoundation.store.paging5.PagingState + +@ExperimentalStoreApi +class DefaultPageAggregatingStrategy, SO : StoreData.Single> : + PageAggregatingStrategy { + override fun aggregate(state: PagingState): Pager.PagingData { + if (state.pages.isEmpty()) return Pager.PagingData(emptyList()) + + val orderedItems = mutableListOf() + + val keyToPage = state.pages.values.associateBy { it.prevKey } + + var currentPage: PagingSource.LoadResult.Page? = state.pages.values.first() + + while (currentPage != null) { + when (currentPage.prevKey?.insertionStrategy) { + InsertionStrategy.APPEND -> orderedItems.addAll(currentPage.data) + InsertionStrategy.PREPEND -> orderedItems.addAll(0, currentPage.data) + null, InsertionStrategy.REPLACE -> { + orderedItems.clear() + orderedItems.addAll(currentPage.data) + } + } + + currentPage = currentPage.nextKey?.let { keyToPage[it] } + } + + return Pager.PagingData(orderedItems) + } +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/DefaultPageFetchingStrategy.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/DefaultPageFetchingStrategy.kt new file mode 100644 index 000000000..468df3611 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/DefaultPageFetchingStrategy.kt @@ -0,0 +1,28 @@ +package org.mobilenativefoundation.store.paging5.impl + +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.core5.StoreKey +import org.mobilenativefoundation.store.paging5.PageFetchingStrategy +import org.mobilenativefoundation.store.paging5.PagingState + +@ExperimentalStoreApi +class DefaultPageFetchingStrategy, CK : StoreKey.Collection, SO : StoreData.Single> : + PageFetchingStrategy { + + override fun shouldFetchNextPage(state: PagingState): Boolean { + + if (state.prefetchPosition == null) return true + + val orderedPagingData = state.pages.entries.flatMap { (_, page) -> + page.data + } + + val indexOfAnchor = + if (state.anchorPosition == null) -1 else orderedPagingData.indexOfFirst { it.id == state.anchorPosition } + val indexOfPrefetch = orderedPagingData.indexOfFirst { it.id == state.prefetchPosition } + + if (indexOfAnchor == -1 && indexOfPrefetch == -1 || indexOfPrefetch == -1) return true + return indexOfPrefetch - indexOfAnchor < state.config.prefetchDistance + } +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/DefaultPagingSource.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/DefaultPagingSource.kt new file mode 100644 index 000000000..b51ae4530 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/DefaultPagingSource.kt @@ -0,0 +1,24 @@ +package org.mobilenativefoundation.store.paging5.impl + +import kotlinx.coroutines.flow.Flow +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.core5.StoreKey +import org.mobilenativefoundation.store.paging5.PagingSource +import org.mobilenativefoundation.store.paging5.PagingStreamProvider + + +@ExperimentalStoreApi +class DefaultPagingSource, SO : StoreData.Single>( + private val streamProvider: PagingStreamProvider, +) : PagingSource { + + private val streams: MutableMap> = mutableMapOf() + + override fun stream(params: PagingSource.LoadParams): Flow { + if (params.key in streams) return streams[params.key]!! + val stream = streamProvider.stream(params) + streams[params.key] = stream + return stream + } +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/DefaultPagingStreamProvider.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/DefaultPagingStreamProvider.kt new file mode 100644 index 000000000..72f0e2a0c --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/DefaultPagingStreamProvider.kt @@ -0,0 +1,128 @@ +package org.mobilenativefoundation.store.paging5.impl + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.core5.StoreKey +import org.mobilenativefoundation.store.paging5.PagingSource +import org.mobilenativefoundation.store.paging5.PagingStreamProvider +import org.mobilenativefoundation.store.store5.MutableStore +import org.mobilenativefoundation.store.store5.Store +import org.mobilenativefoundation.store.store5.StoreReadRequest +import org.mobilenativefoundation.store.store5.StoreReadResponse +import org.mobilenativefoundation.store.store5.impl.extensions.PagingResult +import org.mobilenativefoundation.store.store5.impl.extensions.paged + +@Suppress("UNCHECKED_CAST") +@ExperimentalStoreApi +fun , O : StoreData, SK : StoreKey.Single, CK : StoreKey.Collection, SO : StoreData.Single> MutableStore.defaultPagingStreamProvider( + keyFactory: PagingKeyFactory +): PagingStreamProvider { + fun parentPager(key: CK) = paged(key) + + fun childStreamer(key: SK): Flow> = + stream(StoreReadRequest.cached(key as K, refresh = false)) + + val delegate = DelegatePagingStreamProvider(::parentPager, ::childStreamer, keyFactory) + return DefaultPagingStreamProvider(delegate) +} + +@Suppress("UNCHECKED_CAST") +@ExperimentalStoreApi +fun , O : StoreData, SK : StoreKey.Single, CK : StoreKey.Collection, SO : StoreData.Single> Store.defaultPagingStreamProvider( + keyFactory: PagingKeyFactory +): PagingStreamProvider { + fun parentPager(key: CK) = paged(key) + + fun childStreamer(key: SK): Flow> = + stream(StoreReadRequest.cached(key as K, refresh = false)) + + val delegate = DelegatePagingStreamProvider(::parentPager, ::childStreamer, keyFactory) + return DefaultPagingStreamProvider(delegate) +} + +@Suppress("UNCHECKED_CAST") +@ExperimentalStoreApi +internal class DelegatePagingStreamProvider, SK : StoreKey.Single, CK : StoreKey.Collection, SO : StoreData.Single, CO : StoreData.Collection>( + private val parentPager: (key: CK) -> Flow, + private val childStreamer: (key: SK) -> Flow>, + private val keyFactory: PagingKeyFactory +) { + + private val pages: MutableMap> = mutableMapOf() + private val mutexForPages = Mutex() + + fun page(params: PagingSource.LoadParams) = parentPager(params.key).map { pagingResult -> + when (pagingResult) { + is PagingResult.Data<*, *, *, *> -> { + val co = pagingResult.value as CO + val items = co.items + val page = PagingSource.LoadResult.Page( + items, + co.itemsAfter, + co.itemsBefore, + co.nextKey, + params.key + ) + + mutexForPages.withLock { + pages[params.key] = page + } + + var liveData = page + + pagingResult.value.items.forEach { single -> + val childKey = keyFactory.createKeyFor(single as SO) + initStreamAndHandleSingle(single, childKey, params.key) { updatedData -> + liveData = updatedData + } + } + + liveData + } + + is PagingResult.Error -> PagingSource.LoadResult.Error(pagingResult.throwable) + } + } + + private fun initStreamAndHandleSingle( + single: SO, + childKey: SK, + parentKey: CK, + emitInParentStream: (updatedData: PagingSource.LoadResult.Page) -> Unit + ) { + childStreamer(childKey).distinctUntilChanged().onEach { response -> + if (response is StoreReadResponse.Data) { + (response as? StoreReadResponse.Data)?.let { postData: StoreReadResponse.Data -> + mutexForPages.withLock { + pages[parentKey]?.let { currentPage -> + val updatedItems = currentPage.data.toMutableList() + val indexOfSingle = updatedItems.indexOfFirst { it.id == single.id } + if (updatedItems[indexOfSingle] != postData.value) { + updatedItems[indexOfSingle] = postData.value + + val updatedPagingData = currentPage.copy(updatedItems) + pages[parentKey] = updatedPagingData + + emitInParentStream(updatedPagingData) + } + } + } + } + } + } + } +} + + +@ExperimentalStoreApi +internal class DefaultPagingStreamProvider, SK : StoreKey.Single, O : StoreData, SO : StoreData.Single, CO : StoreData.Collection>( + private val delegate: DelegatePagingStreamProvider +) : PagingStreamProvider { + override fun stream(params: PagingSource.LoadParams): Flow = delegate.page(params) +} diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/JobCoordinator.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/JobCoordinator.kt new file mode 100644 index 000000000..7659faa83 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/JobCoordinator.kt @@ -0,0 +1,9 @@ +package org.mobilenativefoundation.store.paging5.impl + +import kotlinx.coroutines.CoroutineScope + +internal interface JobCoordinator { + fun launchIfNotActive(key: Any, block: suspend CoroutineScope.() -> Unit) + fun cancel(key: Any) + fun cancelAll() +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/PageLoader.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/PageLoader.kt new file mode 100644 index 000000000..d4af714c2 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/PageLoader.kt @@ -0,0 +1,11 @@ +package org.mobilenativefoundation.store.paging5.impl + +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.core5.StoreKey +import org.mobilenativefoundation.store.paging5.PagingSource + +@ExperimentalStoreApi +internal interface PageLoader, CK : StoreKey.Collection, SO : StoreData.Single> { + suspend fun loadPage(params: PagingSource.LoadParams) +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/PagingErrorManager.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/PagingErrorManager.kt new file mode 100644 index 000000000..466cc3827 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/PagingErrorManager.kt @@ -0,0 +1,10 @@ +package org.mobilenativefoundation.store.paging5.impl + +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi + +@ExperimentalStoreApi +internal interface PagingErrorManager { + fun handleError(error: Throwable) +} + + diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/PagingKeyFactory.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/PagingKeyFactory.kt new file mode 100644 index 000000000..40c033d29 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/PagingKeyFactory.kt @@ -0,0 +1,10 @@ +package org.mobilenativefoundation.store.paging5.impl + +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.core5.StoreKey + +@ExperimentalStoreApi +interface PagingKeyFactory, SO : StoreData.Single> { + fun createKeyFor(data: SO): SK +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/PagingStateManager.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/PagingStateManager.kt new file mode 100644 index 000000000..074c0e04b --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/PagingStateManager.kt @@ -0,0 +1,18 @@ +package org.mobilenativefoundation.store.paging5.impl + +import kotlinx.coroutines.flow.StateFlow +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.paging5.Pager + +@ExperimentalStoreApi +internal interface PagingStateManager, SO : StoreData.Single> { + val data: StateFlow> + val errors: StateFlow + + fun updateData(newData: Pager.PagingData) + fun updateError(newError: Pager.PagingError?) + + fun invalidateData() + fun clearError() +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/QueueManager.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/QueueManager.kt new file mode 100644 index 000000000..5e9a3962b --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/QueueManager.kt @@ -0,0 +1,9 @@ +package org.mobilenativefoundation.store.paging5.impl + +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.StoreKey + +@ExperimentalStoreApi +internal interface QueueManager, CK : StoreKey.Collection> { + fun enqueue(key: CK) +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/RealJobCoordinator.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/RealJobCoordinator.kt new file mode 100644 index 000000000..e52c51950 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/RealJobCoordinator.kt @@ -0,0 +1,35 @@ +package org.mobilenativefoundation.store.paging5.impl + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +internal class RealJobCoordinator( + private val scope: CoroutineScope +) : JobCoordinator { + + private val jobs: MutableMap = mutableMapOf() + + override fun launchIfNotActive(key: Any, block: suspend CoroutineScope.() -> Unit) { + if (jobs[key]?.isActive != true) { + val job = scope.launch { + block() + } + jobs[key] = job + + job.invokeOnCompletion { + cancel(key) + } + } + } + + override fun cancel(key: Any) { + jobs[key]?.cancel() + jobs.remove(key) + } + + override fun cancelAll() { + jobs.keys.forEach { cancel(it) } + } + +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/RealPageLoader.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/RealPageLoader.kt new file mode 100644 index 000000000..e87ce4d0c --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/RealPageLoader.kt @@ -0,0 +1,27 @@ +package org.mobilenativefoundation.store.paging5.impl + +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.core5.StoreKey +import org.mobilenativefoundation.store.paging5.PagingSource + +@Suppress("UNCHECKED_CAST") +@ExperimentalStoreApi +internal class RealPageLoader, CK : StoreKey.Collection, SO : StoreData.Single>( + private val pagingSource: PagingSource, + private val onPageLoaded: (params: PagingSource.LoadParams, page: PagingSource.LoadResult.Page) -> Unit, + private val onLoadError: (error: Throwable) -> Unit +) : PageLoader { + override suspend fun loadPage(params: PagingSource.LoadParams) { + pagingSource.stream(params).collect { loadResult -> + when (loadResult) { + is PagingSource.LoadResult.Error -> onLoadError(loadResult.throwable) + is PagingSource.LoadResult.Page<*, *, *> -> onPageLoaded( + params, + loadResult as PagingSource.LoadResult.Page + ) + } + } + } + +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/RealPager.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/RealPager.kt new file mode 100644 index 000000000..7197fe9cf --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/RealPager.kt @@ -0,0 +1,104 @@ +package org.mobilenativefoundation.store.paging5.impl + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.plus +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.core5.StoreKey +import org.mobilenativefoundation.store.paging5.PageAggregatingStrategy +import org.mobilenativefoundation.store.paging5.PageFetchingStrategy +import org.mobilenativefoundation.store.paging5.Pager +import org.mobilenativefoundation.store.paging5.PagingConfig +import org.mobilenativefoundation.store.paging5.PagingSource +import org.mobilenativefoundation.store.paging5.PagingState + + +@ExperimentalStoreApi +internal class RealPager, CK : StoreKey.Collection, SO : StoreData.Single>( + scope: CoroutineScope, + initialKey: CK, + private val anchorPosition: StateFlow, + private val pagingConfig: PagingConfig, + private val aggregator: PageAggregatingStrategy, + pageFetchingStrategy: PageFetchingStrategy, + private val stateManager: PagingStateManager, + errorManagerFactory: (retry: () -> Unit) -> PagingErrorManager, + jobCoordinatorFactory: (childScope: CoroutineScope) -> RealJobCoordinator, + pagingSourceFactory: () -> PagingSource, +) : Pager { + + private val childScope = scope + Job() + + private val pages: LinkedHashMap, PagingSource.LoadResult.Page> = + linkedMapOf() + + private val currentKey = MutableStateFlow(initialKey) + private val prefetchPosition: MutableStateFlow = MutableStateFlow(null) + + private val pagingState: StateFlow> + get() = MutableStateFlow( + PagingState( + anchorPosition = anchorPosition.value, + prefetchPosition = prefetchPosition.value, + config = pagingConfig, + pages = pages + ) + ) + + private val pagingSource = pagingSourceFactory() + private val errorManager = errorManagerFactory { retryLast() } + private val jobCoordinator = jobCoordinatorFactory(childScope) + private val pageLoader: PageLoader = RealPageLoader(pagingSource, ::handlePageLoaded, ::handleLoadError) + private val queueManager: QueueManager = RealQueueManager(pagingState, pageFetchingStrategy, ::load) + + override val errors: Flow = stateManager.errors + override val data: Flow> = stateManager.data + + + init { + load(initialKey) + } + + private fun load(key: CK) { + currentKey.value = key + + val params = PagingSource.LoadParams(key, refresh = true) + + jobCoordinator.launchIfNotActive(params) { + pageLoader.loadPage(params) + } + } + + private fun handleLoadError(error: Throwable) { + errorManager.handleError(error) + } + + private fun handlePageLoaded( + params: PagingSource.LoadParams, + page: PagingSource.LoadResult.Page + ) { + + pages[params] = page + stateManager.updateData(aggregator.aggregate(pagingState.value)) + stateManager.clearError() + + page.data.last().id.let { + prefetchPosition.value = it + } + + if (page.nextKey != null) { + queueManager.enqueue(page.nextKey) + } + } + + + // Retry mechanism that can be triggered by the user action suggested in the error state + private fun retryLast() { + load(currentKey.value) + } +} + diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/RealPagingErrorManager.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/RealPagingErrorManager.kt new file mode 100644 index 000000000..f238d7628 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/RealPagingErrorManager.kt @@ -0,0 +1,26 @@ +package org.mobilenativefoundation.store.paging5.impl + +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.paging5.ErrorHandlingStrategy +import org.mobilenativefoundation.store.paging5.Pager +import org.mobilenativefoundation.store.paging5.PagingConfig + +@ExperimentalStoreApi +internal class RealPagingErrorManager, SO : StoreData.Single>( + private val pagingConfig: PagingConfig, + private val stateManager: PagingStateManager, + private val retryLast: () -> Unit, +) : PagingErrorManager { + + override fun handleError(error: Throwable) { + when (val strategy = pagingConfig.errorHandlingStrategy) { + is ErrorHandlingStrategy.Custom -> strategy.action.invoke() + ErrorHandlingStrategy.Ignore -> {} + ErrorHandlingStrategy.RetryLast -> retryLast + } + + val pagingError = Pager.PagingError(error) + stateManager.updateError(pagingError) + } +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/RealPagingStateManager.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/RealPagingStateManager.kt new file mode 100644 index 000000000..477a66046 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/RealPagingStateManager.kt @@ -0,0 +1,36 @@ +package org.mobilenativefoundation.store.paging5.impl + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.paging5.Pager + +@ExperimentalStoreApi +internal class RealPagingStateManager, SO : StoreData.Single> : PagingStateManager { + private val _data = MutableStateFlow(createInitialPagingData()) + private val _errors = MutableStateFlow(null) + + override val data: StateFlow> = _data.asStateFlow() + override val errors: StateFlow = _errors.asStateFlow() + + override fun updateError(newError: Pager.PagingError?) { + _errors.value = newError + } + + override fun invalidateData() { + _data.value = createInitialPagingData() + } + + override fun clearError() { + _errors.value = null + } + + override fun updateData(newData: Pager.PagingData) { + _data.value = newData + } + + private fun createInitialPagingData() = Pager.PagingData(emptyList()) + +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/RealQueueManager.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/RealQueueManager.kt new file mode 100644 index 000000000..cd7c133ff --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/impl/RealQueueManager.kt @@ -0,0 +1,28 @@ +package org.mobilenativefoundation.store.paging5.impl + +import kotlinx.coroutines.flow.StateFlow +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.core5.StoreKey +import org.mobilenativefoundation.store.paging5.PageFetchingStrategy +import org.mobilenativefoundation.store.paging5.PagingState + +@ExperimentalStoreApi +internal class RealQueueManager, CK : StoreKey.Collection, SO : StoreData.Single>( + private val state: StateFlow>, + private val pageFetchingStrategy: PageFetchingStrategy, + private val loadPage: (key: CK) -> Unit +) : QueueManager { + private val queue: ArrayDeque = ArrayDeque() + + override fun enqueue(key: CK) { + queue.addLast(key) + processQueue() + } + + private fun processQueue() { + while (queue.isNotEmpty() && pageFetchingStrategy.shouldFetchNextPage(state.value)) { + loadPage(queue.removeFirst()) + } + } +} \ No newline at end of file diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt deleted file mode 100644 index af2d0e036..000000000 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt +++ /dev/null @@ -1,167 +0,0 @@ -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.mobilenativefoundation.store.core5.ExperimentalStoreApi -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.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.BeforeTest -import kotlin.test.Test -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 - - @BeforeTest - 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) - assertEquals("1", state3.value.posts[0].postId) - - val state4 = awaitItem() - assertIs>(state4) - assertEquals("11", state4.value.posts[0].postId) - assertEquals("1", state4.value.posts[10].postId) - 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/RealPagerTest.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/RealPagerTest.kt new file mode 100644 index 000000000..b3dd5b61f --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/RealPagerTest.kt @@ -0,0 +1,187 @@ +package org.mobilenativefoundation.store.paging5 + +import app.cash.turbine.test +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.paging5.impl.DefaultPagingSource +import org.mobilenativefoundation.store.paging5.impl.PagingKeyFactory +import org.mobilenativefoundation.store.paging5.impl.defaultPagingStreamProvider +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.PostStoreFactory +import org.mobilenativefoundation.store.paging5.util.TestPostApi +import org.mobilenativefoundation.store.paging5.util.TestPostDatabase +import org.mobilenativefoundation.store.store5.MutableStore +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalStoreApi::class) +class PostPagingKeyFactory : PagingKeyFactory { + override fun createKeyFor(data: PostData.Post): PostKey.Single { + return PostKey.Single(data.postId) + } +} + +@OptIn(ExperimentalStoreApi::class) +class RealPagerTest { + private val testScope = TestScope() + + private val userId = "123" + private lateinit var api: PostApi + private lateinit var db: PostDatabase + private lateinit var store: MutableStore + private lateinit var streamProvider: PagingStreamProvider + private lateinit var pagingSource: PagingSource + private lateinit var pager: Pager + + @BeforeTest + fun setup() { + api = TestPostApi() + db = TestPostDatabase(userId) + } + + private fun TestScope.runPagingTest( + anchorPosition: StateFlow, + initialKey: PostKey.Cursor = PostKey.Cursor("1", 10), + pagingConfig: PagingConfig = PagingConfig(prefetchDistance = 10), + testBody: suspend TestScope.() -> Unit + ) = runTest { + val factory = PostStoreFactory(this, api, db) + store = factory.create() + + val keyFactory = PostPagingKeyFactory() + + streamProvider = store.defaultPagingStreamProvider(keyFactory) + pagingSource = DefaultPagingSource(streamProvider) + + pager = Pager.create( + scope = this, + initialKey = initialKey, + pagingConfig = pagingConfig, + anchorPosition = anchorPosition + ) { + pagingSource + } + + testBody() + } + + @Test + fun testMultipleValidKeysWithIncreasingAnchorPosition() { + val anchorPosition = MutableStateFlow("1") + + testScope.runPagingTest(anchorPosition) { + val flow = pager.data + + flow.test { + val data1 = awaitItem() + assertEquals(0, data1.items.size) + + + val data2 = awaitItem() + assertEquals(10, data2.items.size) + data2.items.forEachIndexed { index, value -> + assertEquals("${index + 1}", value.postId) + } + + expectNoEvents() + + anchorPosition.value = "11" + + val data3 = awaitItem() + data3.items.forEachIndexed { index, value -> + assertEquals("${index + 1}", value.postId) + } + + expectNoEvents() + + anchorPosition.value = "21" + + val data4 = awaitItem() + data4.items.forEachIndexed { index, value -> + assertEquals("${index + 1}", value.postId) + } + + expectNoEvents() + + anchorPosition.value = "31" + + val data5 = awaitItem() + data5.items.forEachIndexed { index, value -> + assertEquals("${index + 1}", value.postId) + } + + expectNoEvents() + anchorPosition.value = "41" + + val data6 = awaitItem() + data6.items.forEachIndexed { index, value -> + assertEquals("${index + 1}", value.postId) + } + expectNoEvents() + } + } + } + + @Test + fun testMultipleValidKeysWithNoChangeInAnchorPositionAndPrefetchDistanceOf10() { + val anchorPosition = MutableStateFlow(null) + + testScope.runPagingTest(anchorPosition) { + val flow = pager.data + + flow.test { + val data1 = awaitItem() + assertEquals(0, data1.items.size) + val data2 = awaitItem() + assertEquals(10, data2.items.size) + data2.items.forEachIndexed { index, value -> + assertEquals("${index + 1}", value.postId) + } + expectNoEvents() + } + } + } + + @Test + fun testMultipleValidKeysWithNoChangeInAnchorPositionAndPrefetchDistanceOf50() { + val anchorPosition = MutableStateFlow(null) + + testScope.runPagingTest(anchorPosition, pagingConfig = PagingConfig(prefetchDistance = 50)) { + val flow = pager.data + + flow.test { + val data1 = awaitItem() + assertEquals(0, data1.items.size) + val data2 = awaitItem() + assertEquals(10, data2.items.size) + data2.items.forEachIndexed { index, value -> + assertEquals("${index + 1}", value.postId) + } + val data3 = awaitItem() + data3.items.forEachIndexed { index, value -> + assertEquals("${index + 1}", value.postId) + } + val data4 = awaitItem() + data4.items.forEachIndexed { index, value -> + assertEquals("${index + 1}", value.postId) + } + val data5 = awaitItem() + data5.items.forEachIndexed { index, value -> + assertEquals("${index + 1}", value.postId) + } + val data6 = awaitItem() + data6.items.forEachIndexed { index, value -> + assertEquals("${index + 1}", value.postId) + } + expectNoEvents() + } + } + } +} 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 deleted file mode 100644 index 7764cc65e..000000000 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostApi.kt +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index a126c9b3f..000000000 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostDatabase.kt +++ /dev/null @@ -1,34 +0,0 @@ -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/PostApi.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostApi.kt index 90d87e601..d3102c23b 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostApi.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostApi.kt @@ -1,7 +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 get(key: PostKey.Single): PostGetRequestResult + suspend fun get(key: PostKey.Cursor): 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 index ad6b05d28..8eaf8e9ca 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt @@ -1,28 +1,42 @@ package org.mobilenativefoundation.store.paging5.util +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi import org.mobilenativefoundation.store.core5.InsertionStrategy import org.mobilenativefoundation.store.core5.StoreData +@OptIn(ExperimentalStoreApi::class) 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() { + data class Feed( + val posts: List, + override val prevKey: PostKey.Cursor, + override val nextKey: PostKey.Cursor? + ) : + 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 { + override val itemsAfter: Int? = null + override val itemsBefore: Int? = null + 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) + val updatedItems = posts.toMutableList() + updatedItems.addAll(items) copyWith(items = updatedItems) } InsertionStrategy.PREPEND -> { - val updatedItems = posts.toMutableList() - updatedItems.addAll(items) + val updatedItems = items.toMutableList() + updatedItems.addAll(posts) copyWith(items = updatedItems) } 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 index d8ae595c9..b68e14499 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostDatabase.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostDatabase.kt @@ -2,7 +2,7 @@ package org.mobilenativefoundation.store.paging5.util interface PostDatabase { fun add(post: PostData.Post) - fun add(feed: PostData.Feed) + fun add(key: PostKey.Cursor, feed: PostData.Feed) fun findPostByPostId(postId: String): PostData.Post? - fun findFeedByUserId(cursor: String?, size: Int): PostData.Feed? + fun findFeedByKey(key: PostKey.Cursor, size: Int): PostData.Feed? } 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 index 451c5e0b9..af7356abc 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostKey.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostKey.kt @@ -1,8 +1,10 @@ package org.mobilenativefoundation.store.paging5.util +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi import org.mobilenativefoundation.store.core5.InsertionStrategy import org.mobilenativefoundation.store.core5.StoreKey +@OptIn(ExperimentalStoreApi::class) sealed class PostKey : StoreKey { data class Cursor( override val cursor: String?, @@ -15,4 +17,4 @@ sealed class PostKey : StoreKey { data class Single( override val id: String ) : StoreKey.Single, PostKey() -} +} \ No newline at end of file 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 index 8ed9b2011..92c4f767e 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt @@ -2,27 +2,23 @@ package org.mobilenativefoundation.store.paging5.util +import kotlinx.coroutines.CoroutineScope 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.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.store5.Converter 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) { +class PostStoreFactory(private val scope: CoroutineScope, 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)) { + when (val result = api.get(key)) { is PostGetRequestResult.Data -> { result.data } @@ -38,7 +34,7 @@ class PostStoreFactory(private val api: PostApi, private val db: PostDatabase) { } is PostKey.Cursor -> { - when (val result = api.get(key.cursor, key.size)) { + when (val result = api.get(key)) { is FeedGetRequestResult.Data -> { result.data } @@ -65,7 +61,7 @@ class PostStoreFactory(private val api: PostApi, private val db: PostDatabase) { } is PostKey.Cursor -> { - val feed = db.findFeedByUserId(key.cursor, key.size) + val feed = db.findFeedByKey(key, key.size) emit(feed) } } @@ -78,7 +74,7 @@ class PostStoreFactory(private val api: PostApi, private val db: PostDatabase) { } key is PostKey.Cursor && data is PostData.Feed -> { - db.add(data) + db.add(key, data) } } } @@ -106,29 +102,9 @@ class PostStoreFactory(private val api: PostApi, private val db: PostDatabase) { } ) - 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( diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/TestPostApi.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/TestPostApi.kt new file mode 100644 index 000000000..8fcb2a896 --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/TestPostApi.kt @@ -0,0 +1,58 @@ +package org.mobilenativefoundation.store.paging5.util + +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi + +@OptIn(ExperimentalStoreApi::class) +class TestPostApi : PostApi { + + private val posts = mutableMapOf() + private val postsList = mutableListOf() + + init { + (1..50).forEach { + val id = it.toString() + posts[id] = PostData.Post(id, id) + postsList.add(PostData.Post(id, id)) + } + } + + override suspend fun get(key: PostKey.Single): PostGetRequestResult { + val post = posts[key.id] + return if (post != null) { + PostGetRequestResult.Data(post) + } else { + PostGetRequestResult.Error.Message("Post ${key.id} was not found") + } + } + + override suspend fun get(key: PostKey.Cursor): FeedGetRequestResult { + + val firstIndexInclusive = postsList.indexOfFirst { it.postId == key.cursor } + val lastIndexExclusive = firstIndexInclusive + key.size + + val (posts, nextCursor) = if (lastIndexExclusive > postsList.lastIndex) { + val posts = postsList.subList(firstIndexInclusive, postsList.size) + val nextCursor = null + posts to nextCursor + } else { + val posts = postsList.subList(firstIndexInclusive, lastIndexExclusive) + val nextCursor = postsList[lastIndexExclusive].id + + posts to nextCursor + } + + return FeedGetRequestResult.Data( + PostData.Feed( + posts = posts, + prevKey = key, + nextKey = key.copy(cursor = nextCursor) + ) + ) + + } + + override suspend fun put(post: PostData.Post): PostPutRequestResult { + posts[post.id] = post + return PostPutRequestResult.Data(post) + } +} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/TestPostDatabase.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/TestPostDatabase.kt new file mode 100644 index 000000000..4ae18371f --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/TestPostDatabase.kt @@ -0,0 +1,32 @@ +package org.mobilenativefoundation.store.paging5.util + +class TestPostDatabase(private val userId: String) : PostDatabase { + private val posts = mutableMapOf() + private val feeds = mutableMapOf() + override fun add(post: PostData.Post) { + posts[post.id] = post + + val (key, feed) = feeds.entries.first { (_, feed) -> + feed.posts.firstOrNull { it.postId == post.id } != null + } + + val updatedPosts = feed.posts.toMutableList() + val indexOfPost = updatedPosts.indexOfFirst { it.id == post.id } + updatedPosts[indexOfPost] = post + feeds[key] = feed.copy(posts = updatedPosts) + } + + override fun add(key: PostKey.Cursor, feed: PostData.Feed) { + feeds[key] = feed + + feed.posts.forEach { add(it) } + } + + override fun findPostByPostId(postId: String): PostData.Post? { + return posts[postId] + } + + override fun findFeedByKey(key: PostKey.Cursor, size: Int): PostData.Feed? { + return feeds[key] + } +} diff --git a/settings.gradle b/settings.gradle index 8c532892f..d23f0cf5c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,4 +3,5 @@ include ':cache' include ':multicast' include ':rx2' include ':paging' -include ':core' \ No newline at end of file +include ':core' +include ':paging:compose' \ No newline at end of file diff --git a/store/build.gradle.kts b/store/build.gradle.kts index a495ae20e..76d76b4c0 100644 --- a/store/build.gradle.kts +++ b/store/build.gradle.kts @@ -10,7 +10,7 @@ plugins { id("com.vanniktech.maven.publish") id("org.jetbrains.dokka") id("org.jetbrains.kotlinx.kover") - id("co.touchlab.faktory.kmmbridge") version("0.3.2") + id("co.touchlab.faktory.kmmbridge") version ("0.3.2") `maven-publish` kotlin("native.cocoapods") id("kotlinx-atomicfu") @@ -61,7 +61,6 @@ kotlin { } val commonTest by getting { - dependsOn(commonMain) dependencies { implementation(kotlin("test")) implementation(libs.junit) 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 60b92be64..a9426fb8f 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 @@ -1,9 +1,13 @@ package org.mobilenativefoundation.store.store5.impl.extensions +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.first -import org.mobilenativefoundation.store.store5.Bookkeeper +import kotlinx.coroutines.flow.mapNotNull import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.core5.StoreKey +import org.mobilenativefoundation.store.store5.Bookkeeper import org.mobilenativefoundation.store.store5.MutableStore import org.mobilenativefoundation.store.store5.Store import org.mobilenativefoundation.store.store5.StoreReadRequest @@ -22,6 +26,47 @@ suspend fun Store.get(key: Key) = .first() .requireData() + +@Suppress("UNCHECKED_CAST") +@ExperimentalStoreApi +fun , K : StoreKey, SO : StoreData.Single, CO : StoreData.Collection, O : StoreData> MutableStore.paged( + key: CK +): Flow { + val storeKey = key as K + return stream(StoreReadRequest.cached(storeKey, refresh = false)).mapNotNull { + when (it) { + is StoreReadResponse.Data -> PagingResult.Data((it as StoreReadResponse.Data).value) + is StoreReadResponse.Error -> PagingResult.Error(it.errorOrNull() ?: Throwable()) + else -> null + } + } +} + +@Suppress("UNCHECKED_CAST") +@ExperimentalStoreApi +fun , K : StoreKey, SO : StoreData.Single, CO : StoreData.Collection, O : StoreData> Store.paged( + key: CK +): Flow { + val storeKey = key as K + return stream(StoreReadRequest.cached(storeKey, refresh = false)).mapNotNull { + when (it) { + is StoreReadResponse.Data -> PagingResult.Data((it as StoreReadResponse.Data).value) + is StoreReadResponse.Error -> PagingResult.Error(it.errorOrNull() ?: Throwable()) + else -> null + } + } +} + +@ExperimentalStoreApi +sealed class PagingResult { + data class Data, SO : StoreData.Single, CO : StoreData.Collection>( + val value: CO + ) : PagingResult() + + data class Error(val throwable: Throwable) : PagingResult() +} + + /** * Helper factory that will return fresh data for [key] while updating your caches *