From b56ecdb9769d31174cc25a5b101a6ffbc059b6c2 Mon Sep 17 00:00:00 2001 From: itsandreramon Date: Wed, 26 Feb 2025 17:34:09 +0100 Subject: [PATCH 1/7] remove ktlint --- .github/workflows/ci.yml | 6 ++++++ build.gradle.kts | 28 ++++++++++++++++++++-------- cache/config/ktlint/baseline.xml | 24 ------------------------ config/detekt/rules.yml | 0 core/config/ktlint/baseline.xml | 3 --- gradle/libs.versions.toml | 10 +++++----- multicast/config/ktlint/baseline.xml | 3 --- rx2/config/ktlint/baseline.xml | 3 --- store/config/ktlint/baseline.xml | 24 ------------------------ 9 files changed, 31 insertions(+), 70 deletions(-) delete mode 100644 cache/config/ktlint/baseline.xml create mode 100644 config/detekt/rules.yml delete mode 100644 core/config/ktlint/baseline.xml delete mode 100644 multicast/config/ktlint/baseline.xml delete mode 100644 rx2/config/ktlint/baseline.xml delete mode 100644 store/config/ktlint/baseline.xml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f81dd60b..52e6c3eb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,12 @@ jobs: - name: Grant execute permission for Gradlew run: chmod +x gradlew + - name: Check Kotlin formatting + run: ./gradlew spotlessCheck + + - name: Run Kotlin static analysis + run: ./gradlew detekt + - name: Build and Test with Coverage run: ./gradlew clean build koverXmlReport --stacktrace diff --git a/build.gradle.kts b/build.gradle.kts index b3dfe068a..46f404152 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,6 @@ plugins { - alias(libs.plugins.ktlint) - id("com.diffplug.spotless") version "6.4.1" + alias(libs.plugins.spotless) + alias(libs.plugins.detekt) } buildscript { @@ -15,7 +15,6 @@ buildscript { classpath(libs.kotlin.gradle.plugin) classpath(libs.kotlin.serialization.plugin) classpath(libs.dokka.gradle.plugin) - classpath(libs.ktlint.gradle.plugin) classpath(libs.jacoco.gradle.plugin) classpath(libs.maven.publish.plugin) classpath(libs.atomic.fu.gradle.plugin) @@ -32,18 +31,31 @@ allprojects { } subprojects { - apply(plugin = "org.jlleitschuh.gradle.ktlint") apply(plugin = "com.diffplug.spotless") - - ktlint { - disabledRules.add("import-ordering") - } + apply(plugin = "io.gitlab.arturbosch.detekt") spotless { kotlin { + ktfmt(libs.versions.ktfmt.get()).googleStyle() target("src/**/*.kt") + trimTrailingWhitespace() + endWithNewline() + } + + kotlinGradle { + ktfmt(libs.versions.ktfmt.get()).googleStyle() + target("*.kts") + trimTrailingWhitespace() + endWithNewline() } } + + detekt { + buildUponDefaultConfig = true + baseline = file("${projectDir}/config/detekt/baseline.xml") + config.setFrom("$rootDir/config/detekt/rules.yml") + source.setFrom("src") + } } tasks { diff --git a/cache/config/ktlint/baseline.xml b/cache/config/ktlint/baseline.xml deleted file mode 100644 index 7d1ab2676..000000000 --- a/cache/config/ktlint/baseline.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/config/detekt/rules.yml b/config/detekt/rules.yml new file mode 100644 index 000000000..e69de29bb diff --git a/core/config/ktlint/baseline.xml b/core/config/ktlint/baseline.xml deleted file mode 100644 index 981420778..000000000 --- a/core/config/ktlint/baseline.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 46831ab1f..ade3e122c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,22 +6,22 @@ androidTargetSdk = "33" atomicFu = "0.24.0" baseKotlin = "2.0.20" dokkaGradlePlugin = "1.9.20" -ktlintGradle = "12.1.0" +detekt = "1.23.8" jacocoGradlePlugin = "0.8.12" 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.8.1" kotlinxSerialization = "1.6.3" kermit = "2.0.5" testCore = "1.6.1" kmmBridge = "0.3.2" -ktlint = "0.39.0" +ktfmt = "0.54" kover = "0.9.0-RC" store = "5.1.0-SNAPSHOT" +spotless = "7.0.2" truth = "1.1.3" turbine = "1.2.0" binary-compatibility-validator = "0.15.0-Beta.2" @@ -33,7 +33,6 @@ androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.r 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" } -ktlint-gradle-plugin = { group = "org.jlleitschuh.gradle", name = "ktlint-gradle", version.ref = "ktlintGradle" } jacoco-gradle-plugin = { group = "org.jacoco", name = "org.jacoco.core", version.ref = "jacocoGradlePlugin" } maven-publish-plugin = { group = "com.vanniktech", name = "gradle-maven-publish-plugin", version.ref = "mavenPublishPlugin" } kover-gradle-plugin = {group = "org.jetbrains.kotlinx", name = "kover-gradle-plugin", version.ref = "kover"} @@ -61,6 +60,7 @@ turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine binary-compatibility-validator = {module = "org.jetbrains.kotlinx:binary-compatibility-validator", version.ref = "binary-compatibility-validator"} [plugins] -ktlint = {id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintGradle"} +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } binary-compatibility-validator = {id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binary-compatibility-validator"} kover = {id = "org.jetbrains.kotlinx.kover", version.ref = "kover"} diff --git a/multicast/config/ktlint/baseline.xml b/multicast/config/ktlint/baseline.xml deleted file mode 100644 index 981420778..000000000 --- a/multicast/config/ktlint/baseline.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/rx2/config/ktlint/baseline.xml b/rx2/config/ktlint/baseline.xml deleted file mode 100644 index 981420778..000000000 --- a/rx2/config/ktlint/baseline.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/store/config/ktlint/baseline.xml b/store/config/ktlint/baseline.xml deleted file mode 100644 index 0642aee1c..000000000 --- a/store/config/ktlint/baseline.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - From 1a67af7c570faf03f8cc475e27906e62322a65fb Mon Sep 17 00:00:00 2001 From: itsandreramon Date: Wed, 26 Feb 2025 17:36:00 +0100 Subject: [PATCH 2/7] add baseline.xml --- cache/config/detekt/baseline.xml | 50 ++++++++++++++++++ core/config/detekt/baseline.xml | 3 ++ multicast/config/detekt/baseline.xml | 12 +++++ rx2/config/detekt/baseline.xml | 10 ++++ store/config/detekt/baseline.xml | 79 ++++++++++++++++++++++++++++ 5 files changed, 154 insertions(+) create mode 100644 cache/config/detekt/baseline.xml create mode 100644 core/config/detekt/baseline.xml create mode 100644 multicast/config/detekt/baseline.xml create mode 100644 rx2/config/detekt/baseline.xml create mode 100644 store/config/detekt/baseline.xml diff --git a/cache/config/detekt/baseline.xml b/cache/config/detekt/baseline.xml new file mode 100644 index 000000000..6beb6d4d1 --- /dev/null +++ b/cache/config/detekt/baseline.xml @@ -0,0 +1,50 @@ + + + + + EmptyFunctionBlock:LocalCache.kt$LocalCache.Companion.<no name provided>${} + EmptyFunctionBlock:LocalCache.kt$LocalCache.StrongValueReference${} + LongMethod:LocalCache.kt$LocalCache.Segment$fun put( key: K, hash: Int, value: V, onlyIfAbsent: Boolean, ): V? + LongParameterList:LocalCache.kt$LocalCache.Segment$( first: ReferenceEntry<K, V>, entry: ReferenceEntry<K, V>, key: K, hash: Int, valueReference: ValueReference<K, V>, cause: RemovalCause?, ) + MagicNumber:CacheBuilder.kt$CacheBuilder$16 + MagicNumber:CacheBuilder.kt$CacheBuilder$4 + MagicNumber:LocalCache.kt$LocalCache$20 + MagicNumber:LocalCache.kt$LocalCache$32 + MagicNumber:LocalCache.kt$LocalCache.Companion$10 + MagicNumber:LocalCache.kt$LocalCache.Companion$14 + MagicNumber:LocalCache.kt$LocalCache.Companion$15 + MagicNumber:LocalCache.kt$LocalCache.Companion$16 + MagicNumber:LocalCache.kt$LocalCache.Companion$3 + MagicNumber:LocalCache.kt$LocalCache.Companion$6 + MagicNumber:LocalCache.kt$LocalCache.Segment$3 + MagicNumber:LocalCache.kt$LocalCache.Segment$4 + MaxLineLength:Cache.kt$Cache$* + MaxLineLength:LocalCache.kt$LocalCache.WriteQueue$* + MaxLineLength:MonotonicTicker.kt$internal + MaxLineLength:RemovalCause.kt$RemovalCause$* + MaxLineLength:StoreMultiCache.kt$StoreMultiCache$class + MaxLineLength:StoreMultiCache.kt$StoreMultiCache$collectionsCache: Cache<StoreKey.Collection<Id>, Collection> = CacheBuilder<StoreKey.Collection<Id>, Collection>().build() + MaxLineLength:StoreMultiCache.kt$StoreMultiCache.Companion$fun invalidKeyErrorMessage(key: Any) + MaxLineLength:Weigher.kt$* @return Weight of a cache entry. Must be non-negative. There is no unit for entry weights. Rather, they are simply relative to each other. + NestedBlockDepth:LocalCache.kt$LocalCache.Segment$fun activeEntries(): Map<K, V> + NestedBlockDepth:LocalCache.kt$LocalCache.Segment$fun clear() + NestedBlockDepth:LocalCache.kt$LocalCache.Segment$fun put( key: K, hash: Int, value: V, onlyIfAbsent: Boolean, ): V? + NestedBlockDepth:LocalCache.kt$LocalCache.Segment$fun remove( key: K, hash: Int, ): V? + NestedBlockDepth:LocalCache.kt$LocalCache.Segment$private fun expand() + ReturnCount:LocalCache.kt$LocalCache.Segment$fun get( key: K, hash: Int, ): V? + ReturnCount:LocalCache.kt$LocalCache.Segment$fun remove( key: K, hash: Int, ): V? + ReturnCount:LocalCache.kt$LocalCache.Segment$private fun getLiveEntry( key: K, hash: Int, now: Long, ): ReferenceEntry<K, V>? + TooManyFunctions:LocalCache.kt$LocalCache$Segment<K : Any, V : Any> + TooManyFunctions:LocalCache.kt$LocalCache<K : Any, V : Any> + TooManyFunctions:StoreMultiCache.kt$StoreMultiCache<Id : Any, Key : StoreKey<Id>, Single : StoreData.Single<Id>, Collection : StoreData.Collection<Id, Single>, Output : StoreData<Id>> : Cache + UnusedParameter:LocalCache.kt$LocalCache.Segment$cause: RemovalCause? + UnusedParameter:LocalCache.kt$LocalCache.Segment$hash: Int + UnusedParameter:LocalCache.kt$LocalCache.Segment$key: K? + UnusedPrivateMember:LocalCache.kt$LocalCache$private fun newValueReference( entry: ReferenceEntry<K, V>, value: V, weight: Int, ): ValueReference<K, V> + UseCheckOrError:CacheBuilder.kt$CacheBuilder$throw IllegalStateException("Maximum size cannot be combined with weigher.") + UseCheckOrError:LocalCache.kt$LocalCache.Segment$throw IllegalStateException("Weights must be non-negative") + UseRequire:CacheBuilder.kt$CacheBuilder$throw IllegalArgumentException("Duration must be non-negative.") + UseRequire:CacheBuilder.kt$CacheBuilder$throw IllegalArgumentException("Maximum size must be non-negative.") + UseRequire:CacheBuilder.kt$CacheBuilder$throw IllegalArgumentException("Maximum weight must be non-negative.") + + diff --git a/core/config/detekt/baseline.xml b/core/config/detekt/baseline.xml new file mode 100644 index 000000000..512288dec --- /dev/null +++ b/core/config/detekt/baseline.xml @@ -0,0 +1,3 @@ + + + diff --git a/multicast/config/detekt/baseline.xml b/multicast/config/detekt/baseline.xml new file mode 100644 index 000000000..14bb64764 --- /dev/null +++ b/multicast/config/detekt/baseline.xml @@ -0,0 +1,12 @@ + + + + + MagicNumber:ChannelManager.kt$BufferImpl$10 + MaxLineLength:ChannelManager.kt$StoreChannelManager$override suspend fun removeDownstream(channel: SendChannel<Message.Dispatch.Value<T>>) + SwallowedException:ChannelManager.kt$ChannelManager.ChannelEntry$e: CancellationException + SwallowedException:Multicaster.kt$Multicaster$closed: ClosedSendChannelException + SwallowedException:SharedFlowProducer.kt$SharedFlowProducer$closed: ClosedSendChannelException + SwallowedException:StoreRealActor.kt$StoreRealActor$closed: ClosedSendChannelException + + diff --git a/rx2/config/detekt/baseline.xml b/rx2/config/detekt/baseline.xml new file mode 100644 index 000000000..4eb9e73ca --- /dev/null +++ b/rx2/config/detekt/baseline.xml @@ -0,0 +1,10 @@ + + + + + MaxLineLength:RxFetcher.kt$fun + MaxLineLength:RxSingleStoreTest.kt$RxSingleStoreTest$fun + MaxLineLength:RxStore.kt$* Helper factory that will return data as a [Single] for [key] if it is cached otherwise will return fresh/network data (updating your caches) + MaxLineLength:RxStore.kt$fun + + diff --git a/store/config/detekt/baseline.xml b/store/config/detekt/baseline.xml new file mode 100644 index 000000000..2169840de --- /dev/null +++ b/store/config/detekt/baseline.xml @@ -0,0 +1,79 @@ + + + + + CyclomaticComplexMethod:RealStore.kt$RealStore$@Suppress("UNCHECKED_CAST") override fun stream(request: StoreReadRequest<Key>): Flow<StoreReadResponse<Output>> + CyclomaticComplexMethod:RealStore.kt$RealStore$private fun diskNetworkCombined( request: StoreReadRequest<Key>, sourceOfTruth: SourceOfTruthWithBarrier<Key, Network, Output, Local>, ): Flow<StoreReadResponse<Output>> + EmptyDefaultConstructor:SecondaryPagesApi.kt$SecondaryPagesApi$() + ExplicitItLambdaParameter:RealStore.kt$RealStore${ it: Output -> // if we read a value from cache, dispatch it first emit(StoreReadResponse.Data(value = it, origin = StoreReadResponseOrigin.Cache)) } + InstanceOfCheckForException:SourceOfTruthWithBarrier.kt$SourceOfTruthWithBarrier$throwable !is CancellationException + LargeClass:FlowStoreTests.kt$FlowStoreTests + LongMethod:ClearAllStoreTests.kt$ClearAllStoreTests$@Test fun callingClearAllOnStoreWithPersisterAndNoInMemoryCacheDeletesAllEntriesFromThePersister() + LongMethod:FlowStoreTests.kt$FlowStoreTests$@Test fun diskChangeWhileNetworkIsFlowing_overwrite() + LongMethod:FlowStoreTests.kt$FlowStoreTests$@Test fun errorTest() + LongMethod:FlowStoreTests.kt$FlowStoreTests$@Test fun flowingFetcher() + LongMethod:FlowStoreTests.kt$FlowStoreTests$@Test fun getAndFresh_withPersister() + LongMethod:FlowStoreTests.kt$FlowStoreTests$@Test fun givenSourceOfTruthAndCacheHitWhenStreamCachedDataWithoutRefreshThenNoFetchIsTriggeredAndReceivesFollowingNetworkUpdates() + LongMethod:FlowStoreTests.kt$FlowStoreTests$@Test fun testSlowFirstCollectorGetsAllFetchUpdatesOthersGetCacheAndLatestFetchResult() + LongMethod:RealStore.kt$RealStore$@Suppress("UNCHECKED_CAST") override fun stream(request: StoreReadRequest<Key>): Flow<StoreReadResponse<Output>> + LongMethod:RealStore.kt$RealStore$private fun diskNetworkCombined( request: StoreReadRequest<Key>, sourceOfTruth: SourceOfTruthWithBarrier<Key, Network, Output, Local>, ): Flow<StoreReadResponse<Output>> + LongMethod:SourceOfTruthErrorsTests.kt$SourceOfTruthErrorsTests$@Test fun givenSourceOfTruthWhenFirstWriteFailsThenItShouldKeepReadingFromFetcher() + LongMethod:SourceOfTruthWithBarrier.kt$SourceOfTruthWithBarrier$fun reader( key: Key, lock: CompletableDeferred<Unit>, ): Flow<StoreReadResponse<Output?>> + LongMethod:StoreWithInMemoryCacheTests.kt$StoreWithInMemoryCacheTests$@Test fun storeDeadlock() + LongMethod:UpdaterTests.kt$UpdaterTests$@Test fun givenNonEmptyMarketWhenWriteThenStoredAndAPIUpdated() + LongMethod:UpdaterTests.kt$UpdaterTests$@Test fun givenNonEmptyMarketWithValidatorWhenInvalidThenSuccessOriginatingFromFetcher() + MagicNumber:StoreDefaults.kt$StoreDefaults$100 + MaxLineLength:Bookkeeper.kt$Bookkeeper$* + MaxLineLength:ClearStoreByKeyTests.kt$ClearStoreByKeyTests$fun + MaxLineLength:Fetcher.kt$Fetcher.Companion$fun + MaxLineLength:FlowStoreTests.kt$FlowStoreTests$fun + MaxLineLength:FlowStoreTests.kt$FlowStoreTests$suspend fun Store<Int, Int>.get(request: StoreReadRequest<Int>) + MaxLineLength:Lightswitch.kt$Lightswitch$* + MaxLineLength:RealMutableStore.kt$RealMutableStore$is UpdaterResult.Error.Exception -> EagerConflictResolutionResult.Error.Exception(updaterResult.error) + MaxLineLength:RealMutableStore.kt$RealMutableStore$is UpdaterResult.Error.Exception -> StoreWriteResponse.Error.Exception(updaterResult.error) + MaxLineLength:RealMutableStore.kt$RealMutableStore$is UpdaterResult.Error.Message -> EagerConflictResolutionResult.Error.Message(updaterResult.message) + MaxLineLength:RealMutableStore.kt$RealMutableStore$is UpdaterResult.Error.Message -> StoreWriteResponse.Error.Message(updaterResult.message) + MaxLineLength:RealMutableStore.kt$RealMutableStore$is UpdaterResult.Success -> EagerConflictResolutionResult.Success.ConflictsResolved(updaterResult) + MaxLineLength:RealMutableStore.kt$RealMutableStore$is UpdaterResult.Success.Untyped -> StoreWriteResponse.Success.Untyped(updaterResult.value) + MaxLineLength:RealMutableStore.kt$RealMutableStore$latest == null || bookkeeper == null || conflictsMightExist(key).not() -> EagerConflictResolutionResult.Success.NoConflicts + MaxLineLength:RealMutableStore.kt$RealMutableStore$override + MaxLineLength:RealMutableStore.kt$RealMutableStore$private suspend + MaxLineLength:RealMutableStore.kt$RealMutableStore$updateWriteRequestQueue<Response>(key = key, created = now(), updaterResult = updaterResult) + MaxLineLength:RealMutableStoreBuilder.kt$) + MaxLineLength:RealMutableStoreBuilder.kt$RealMutableStoreBuilder$override + MaxLineLength:RealStore.kt$RealStore$if + MaxLineLength:RealStoreBuilder.kt$DefaultConverter$override fun fromOutputToLocal(output: Output): Local + MaxLineLength:SourceOfTruthErrorsTests.kt$SourceOfTruthErrorsTests$// fun givenSourceOfTruthWithFailingWriteWhenAFreshValueReaderArrivesThenItShouldNotGetDiskErrorsFromAPendingWrite() = testScope.runTest { + MaxLineLength:SourceOfTruthErrorsTests.kt$SourceOfTruthErrorsTests$// fun givenSourceOfTruthWithFailingWriteWhenAPassiveReaderArrivesThenItShouldReceiveTheNewWriteError() = testScope.runTest { + MaxLineLength:Store.kt$Store$* + MaxLineLength:StoreBuilder.kt$StoreBuilder.Companion$) + MaxLineLength:StoreReadRequest.kt$StoreReadRequest.Companion$* Creates a [StoreReadRequest] skipping all caches and returning data from network on success and data from [SourceOfTruth] on failure. + MaxLineLength:StoreReadResponse.kt$StoreReadResponseOrigin.Fetcher$* + MaxLineLength:Validator.kt$Validator.Companion$fun <Output : Any> by(validator: suspend (item: Output) -> Boolean): Validator<Output> + MaxLineLength:Write.kt$Write.Stream$fun + MayBeConst:StoreDefaults.kt$StoreDefaults$/** * Cache size (default is 100), can be overridden * * @return memory cache size */ val cacheSize: Long = 100 + TooGenericExceptionCaught:RealMutableStore.kt$RealMutableStore$throwable: Throwable + TooGenericExceptionCaught:RealStore.kt$RealStore$error: Throwable + TooGenericExceptionCaught:SourceOfTruthWithBarrier.kt$SourceOfTruthWithBarrier$throwable: Throwable + TooGenericExceptionThrown:FallbackTests.kt$FallbackTests$throw Exception() + TooGenericExceptionThrown:FetcherResponseTests.kt$FetcherResponseTests$throw RuntimeException("don't catch me") + TooGenericExceptionThrown:HardcodedPages.kt$HardcodedPages$throw Exception() + TooGenericExceptionThrown:LocalOnlyTests.kt$LocalOnlyTests$throw RuntimeException("Fetcher shouldn't be hit") + TooGenericExceptionThrown:NotesApi.kt$NotesApi$throw Exception() + TooGenericExceptionThrown:NotesBookkeeping.kt$NotesBookkeeping$throw Exception() + TooGenericExceptionThrown:NotesDatabase.kt$NotesDatabase$throw Exception() + TooGenericExceptionThrown:PrimaryPagesApi.kt$PrimaryPagesApi$throw Exception() + TooGenericExceptionThrown:SecondaryPagesApi.kt$SecondaryPagesApi$throw Exception() + TooGenericExceptionThrown:StoreReadResponse.kt$StoreReadResponse$throw RuntimeException("cannot swap type for StoreResponse.Data") + TooGenericExceptionThrown:store.kt$throw Exception("MutableStore requires Store to be built using StoreBuilder") + TooManyFunctions:RealMutableStore.kt$RealMutableStore<Key : Any, Network : Any, Output : Any, Local : Any> : MutableStoreKeyAll + UnusedParameter:PrimaryPagesApi.kt$PrimaryPagesApi$ttl: Long? + UnusedPrivateProperty:RealMutableStore.kt$RealMutableStore.Companion$private const val UNKNOWN_ERROR = "Unknown error occurred" + UnusedPrivateProperty:SourceOfTruthWithBarrier.kt$SourceOfTruthWithBarrier$private val converter: Converter<Network, Local, Output>? = null + UseCheckOrError:ClearAllStoreTests.kt$ClearAllStoreTests$throw IllegalStateException("Unknown key") + UseCheckOrError:ClearStoreByKeyTests.kt$ClearStoreByKeyTests$throw IllegalStateException("Unknown key") + UseCheckOrError:FetcherController.kt$FetcherController.<no name provided>$throw IllegalStateException("Not used") + UseCheckOrError:RealStoreBuilder.kt$DefaultConverter$throw IllegalStateException("non mutable store never call this function") + UseRequire:StoreWithInMemoryCacheTests.kt$StoreWithInMemoryCacheTests.<no name provided>$throw IllegalArgumentException("$key value: $value") + + From 09ef68150021ec2e2b3073b584b3c90a68ce4f23 Mon Sep 17 00:00:00 2001 From: itsandreramon Date: Wed, 26 Feb 2025 17:45:14 +0100 Subject: [PATCH 3/7] include tooling project --- tooling/config/detekt/baseline.xml | 11 + tooling/plugins/build.gradle.kts | 74 +++-- .../plugins/AndroidConventionPlugin.kt | 66 +++-- .../KotlinMultiplatformConventionPlugin.kt | 260 ++++++++---------- 4 files changed, 210 insertions(+), 201 deletions(-) create mode 100644 tooling/config/detekt/baseline.xml diff --git a/tooling/config/detekt/baseline.xml b/tooling/config/detekt/baseline.xml new file mode 100644 index 000000000..c7993f85d --- /dev/null +++ b/tooling/config/detekt/baseline.xml @@ -0,0 +1,11 @@ + + + + + LongMethod:KotlinMultiplatformConventionPlugin.kt$KotlinMultiplatformConventionPlugin$override fun apply(project: Project) + MagicNumber:AndroidConventionPlugin.kt$AndroidConventionPlugin$24 + MagicNumber:AndroidConventionPlugin.kt$AndroidConventionPlugin$34 + MagicNumber:KotlinMultiplatformConventionPlugin.kt$11 + MagicNumber:KotlinMultiplatformConventionPlugin.kt$KotlinMultiplatformConventionPlugin$11 + + diff --git a/tooling/plugins/build.gradle.kts b/tooling/plugins/build.gradle.kts index 49a28d78c..4a810a959 100644 --- a/tooling/plugins/build.gradle.kts +++ b/tooling/plugins/build.gradle.kts @@ -1,37 +1,65 @@ +import org.gradle.internal.impldep.org.eclipse.jgit.util.RawCharUtil.trimTrailingWhitespace +import org.jetbrains.kotlin.builtins.StandardNames.FqNames.target + plugins { - `kotlin-dsl` + `kotlin-dsl` + alias(libs.plugins.spotless) + alias(libs.plugins.detekt) } group = "org.mobilenativefoundation.store" java { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + + toolchain { + languageVersion.set(JavaLanguageVersion.of(11)) + } +} + +spotless { + kotlin { + ktfmt(libs.versions.ktfmt.get()).googleStyle() + target("src/**/*.kt") + trimTrailingWhitespace() + endWithNewline() + } - toolchain { - languageVersion.set(JavaLanguageVersion.of(11)) - } + kotlinGradle { + ktfmt(libs.versions.ktfmt.get()).googleStyle() + target("*.kts") + trimTrailingWhitespace() + endWithNewline() + } +} + +detekt { + buildUponDefaultConfig = true + baseline = file("../config/detekt/baseline.xml") + config.setFrom("../../config/detekt/rules.yml") + source.setFrom("src") } dependencies { - compileOnly(libs.android.gradle.plugin) - compileOnly(libs.kotlin.gradle.plugin) - compileOnly(libs.dokka.gradle.plugin) - compileOnly(libs.maven.publish.plugin) - compileOnly(libs.kmmBridge.gradle.plugin) - compileOnly(libs.atomic.fu.gradle.plugin) + compileOnly(libs.android.gradle.plugin) + compileOnly(libs.kotlin.gradle.plugin) + compileOnly(libs.dokka.gradle.plugin) + compileOnly(libs.maven.publish.plugin) + compileOnly(libs.kmmBridge.gradle.plugin) + compileOnly(libs.atomic.fu.gradle.plugin) } gradlePlugin { - plugins { - register("kotlinMultiplatformConventionPlugin") { - id = "org.mobilenativefoundation.store.multiplatform" - implementationClass = "org.mobilenativefoundation.store.tooling.plugins.KotlinMultiplatformConventionPlugin" - } - - register("androidConventionPlugin") { - id = "org.mobilenativefoundation.store.android" - implementationClass = "org.mobilenativefoundation.store.tooling.plugins.AndroidConventionPlugin" - } - } + plugins { + register("kotlinMultiplatformConventionPlugin") { + id = "org.mobilenativefoundation.store.multiplatform" + implementationClass = "org.mobilenativefoundation.store.tooling.plugins.KotlinMultiplatformConventionPlugin" + } + + register("androidConventionPlugin") { + id = "org.mobilenativefoundation.store.android" + implementationClass = "org.mobilenativefoundation.store.tooling.plugins.AndroidConventionPlugin" + } + } } \ No newline at end of file diff --git a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/AndroidConventionPlugin.kt b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/AndroidConventionPlugin.kt index a4cdfcf31..c6cbb7425 100644 --- a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/AndroidConventionPlugin.kt +++ b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/AndroidConventionPlugin.kt @@ -9,45 +9,41 @@ import org.gradle.api.Project import org.gradle.kotlin.dsl.configure class AndroidConventionPlugin : Plugin { - override fun apply(project: Project) = with(project) { - with(pluginManager) { - apply("org.jetbrains.kotlin.android") - apply("com.android.library") - apply("com.vanniktech.maven.publish") - apply("org.jetbrains.dokka") - apply("maven-publish") - apply("org.jetbrains.kotlin.native.cocoapods") - apply("org.jetbrains.kotlinx.binary-compatibility-validator") + override fun apply(project: Project) = + with(project) { + with(pluginManager) { + apply("org.jetbrains.kotlin.android") + apply("com.android.library") + apply("com.vanniktech.maven.publish") + apply("org.jetbrains.dokka") + apply("maven-publish") + apply("org.jetbrains.kotlin.native.cocoapods") + apply("org.jetbrains.kotlinx.binary-compatibility-validator") + } + + extensions.configure { + compileSdk = 34 + + defaultConfig { + minSdk = 24 + targetSdk = 34 } + lint { + disable += "ComposableModifierFactory" + disable += "ModifierFactoryExtensionFunction" + disable += "ModifierFactoryReturnType" + disable += "ModifierFactoryUnreferencedReceiver" + } - extensions.configure { - - compileSdk = 34 - - defaultConfig { - minSdk = 24 - targetSdk = 34 - } - - lint { - disable += "ComposableModifierFactory" - disable += "ModifierFactoryExtensionFunction" - disable += "ModifierFactoryReturnType" - disable += "ModifierFactoryUnreferencedReceiver" - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } + } - configureKotlin() - configureDokka() - configureMavenPublishing() + configureKotlin() + configureDokka() + configureMavenPublishing() } } - - - diff --git a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/KotlinMultiplatformConventionPlugin.kt b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/KotlinMultiplatformConventionPlugin.kt index 5b5d27e50..feb51e9ef 100644 --- a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/KotlinMultiplatformConventionPlugin.kt +++ b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/KotlinMultiplatformConventionPlugin.kt @@ -22,191 +22,165 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget class KotlinMultiplatformConventionPlugin : Plugin { - override fun apply(project: Project) = with(project) { - - - with(pluginManager) { - apply("org.jetbrains.kotlin.multiplatform") - apply("org.jetbrains.kotlin.plugin.serialization") - apply("com.android.library") - apply("com.vanniktech.maven.publish") - apply("org.jetbrains.dokka") - apply("co.touchlab.faktory.kmmbridge") - apply("maven-publish") - apply("org.jetbrains.kotlin.native.cocoapods") - apply("kotlinx-atomicfu") - apply("org.jetbrains.kotlinx.binary-compatibility-validator") + override fun apply(project: Project) = + with(project) { + with(pluginManager) { + apply("org.jetbrains.kotlin.multiplatform") + apply("org.jetbrains.kotlin.plugin.serialization") + apply("com.android.library") + apply("com.vanniktech.maven.publish") + apply("org.jetbrains.dokka") + apply("co.touchlab.faktory.kmmbridge") + apply("maven-publish") + apply("org.jetbrains.kotlin.native.cocoapods") + apply("kotlinx-atomicfu") + apply("org.jetbrains.kotlinx.binary-compatibility-validator") + } + + extensions.configure { + applyDefaultHierarchyTemplate() + + androidTarget() + + jvm() + + iosX64() + iosArm64() + iosSimulatorArm64() + + linuxX64() + + js { + browser() + nodejs() } + @OptIn(ExperimentalWasmDsl::class) wasmJs { nodejs() } - extensions.configure { + jvmToolchain(11) - applyDefaultHierarchyTemplate() - - androidTarget() - - jvm() - - iosX64() - iosArm64() - iosSimulatorArm64() - - linuxX64() - - js { - browser() - nodejs() - } - - @OptIn(ExperimentalWasmDsl::class) - wasmJs { - nodejs() - } - - jvmToolchain(11) - - targets.all { - compilations.all { - compilerOptions.configure { - freeCompilerArgs.add("-Xexpect-actual-classes") - } - } - } - - targets.withType().configureEach { - compilations.configureEach { - compilerOptions.configure { - freeCompilerArgs.add("-Xallocator=custom") - freeCompilerArgs.add("-XXLanguage:+ImplicitSignedToUnsignedIntegerConversion") - freeCompilerArgs.add("-Xadd-light-debug=enable") - - freeCompilerArgs.addAll( - "-opt-in=kotlin.RequiresOptIn", - "-opt-in=kotlin.time.ExperimentalTime", - "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", - "-opt-in=kotlinx.coroutines.FlowPreview", - "-opt-in=kotlinx.cinterop.ExperimentalForeignApi", - "-opt-in=kotlinx.cinterop.BetaInteropApi", - ) - } - } - } - - sourceSets.all { - languageSettings.apply { - optIn("kotlin.contracts.ExperimentalContracts") - optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") - optIn("kotlin.RequiresOptIn") - } - } + targets.all { + compilations.all { + compilerOptions.configure { freeCompilerArgs.add("-Xexpect-actual-classes") } + } + } - sourceSets.getByName("commonTest") { - dependencies { - implementation(kotlin("test")) - } + targets.withType().configureEach { + compilations.configureEach { + compilerOptions.configure { + freeCompilerArgs.add("-Xallocator=custom") + freeCompilerArgs.add("-XXLanguage:+ImplicitSignedToUnsignedIntegerConversion") + freeCompilerArgs.add("-Xadd-light-debug=enable") + + freeCompilerArgs.addAll( + "-opt-in=kotlin.RequiresOptIn", + "-opt-in=kotlin.time.ExperimentalTime", + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-opt-in=kotlinx.coroutines.FlowPreview", + "-opt-in=kotlinx.cinterop.ExperimentalForeignApi", + "-opt-in=kotlinx.cinterop.BetaInteropApi", + ) } + } + } - sourceSets.getByName("jvmTest") { - dependencies { - implementation(kotlin("test-junit")) - } - } + sourceSets.all { + languageSettings.apply { + optIn("kotlin.contracts.ExperimentalContracts") + optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") + optIn("kotlin.RequiresOptIn") + } + } - sourceSets.getByName("nativeMain") { - dependsOn(sourceSets.getByName("commonMain")) - } + sourceSets.getByName("commonTest") { dependencies { implementation(kotlin("test")) } } + sourceSets.getByName("jvmTest") { dependencies { implementation(kotlin("test-junit")) } } - } + sourceSets.getByName("nativeMain") { dependsOn(sourceSets.getByName("commonMain")) } + } - configureKotlin() - configureAndroid() - configureDokka() - configureMavenPublishing() - addGithubPackagesRepository() - configureKmmBridge() - configureAtomicFu() + configureKotlin() + configureAndroid() + configureDokka() + configureMavenPublishing() + addGithubPackagesRepository() + configureKmmBridge() + configureAtomicFu() } } - fun Project.configureKotlin() { - configureJava() + configureJava() } fun Project.configureJava() { - java { - toolchain { - languageVersion.set(JavaLanguageVersion.of(11)) - } - } + java { toolchain { languageVersion.set(JavaLanguageVersion.of(11)) } } } fun Project.configureAndroid() { - android { - sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") - compileSdk = Versions.COMPILE_SDK - defaultConfig { - minSdk = Versions.MIN_SDK - targetSdk = Versions.TARGET_SDK - } - lint { - disable += "ComposableModifierFactory" - disable += "ModifierFactoryExtensionFunction" - disable += "ModifierFactoryReturnType" - disable += "ModifierFactoryUnreferencedReceiver" - } + android { + sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") + compileSdk = Versions.COMPILE_SDK + defaultConfig { + minSdk = Versions.MIN_SDK + targetSdk = Versions.TARGET_SDK + } + lint { + disable += "ComposableModifierFactory" + disable += "ModifierFactoryExtensionFunction" + disable += "ModifierFactoryReturnType" + disable += "ModifierFactoryUnreferencedReceiver" + } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } + } } +fun Project.android(action: LibraryExtension.() -> Unit) = + extensions.configure(action) -fun Project.android(action: LibraryExtension.() -> Unit) = extensions.configure(action) - -private fun Project.java(action: JavaPluginExtension.() -> Unit) = extensions.configure(action) - +private fun Project.java(action: JavaPluginExtension.() -> Unit) = + extensions.configure(action) object Versions { - const val COMPILE_SDK = 34 - const val MIN_SDK = 24 - const val TARGET_SDK = 34 - const val STORE = "5.1.0-SNAPSHOT" + const val COMPILE_SDK = 34 + const val MIN_SDK = 24 + const val TARGET_SDK = 34 + const val STORE = "5.1.0-SNAPSHOT" } - -fun Project.configureMavenPublishing() = extensions.configure { +fun Project.configureMavenPublishing() = + extensions.configure { publishToMavenCentral(S01) signAllPublications() -} - + } -fun Project.configureKmmBridge() = extensions.configure { +fun Project.configureKmmBridge() = + extensions.configure { githubReleaseArtifacts() githubReleaseVersions() versionPrefix.set(Versions.STORE) spm() -} + } fun Project.configureAtomicFu() = - extensions.configure { - transformJvm = false - transformJs = false - } + extensions.configure { + transformJvm = false + transformJs = false + } -fun Project.configureDokka() = tasks.withType().configureEach { +fun Project.configureDokka() = + tasks.withType().configureEach { dokkaSourceSets.configureEach { - reportUndocumented.set(false) - skipDeprecated.set(true) - jdkVersion.set(11) + reportUndocumented.set(false) + skipDeprecated.set(true) + jdkVersion.set(11) } -} + } fun Project.android(name: String) { - android { - namespace = "org.mobilenativefoundation.store.$name" - } + android { namespace = "org.mobilenativefoundation.store.$name" } } From 9e91b24f393754a530cb9265a4be9b2071d577bf Mon Sep 17 00:00:00 2001 From: itsandreramon Date: Wed, 26 Feb 2025 17:46:35 +0100 Subject: [PATCH 4/7] include tooling project --- tooling/plugins/build.gradle.kts | 87 +++++++++++++++----------------- 1 file changed, 42 insertions(+), 45 deletions(-) diff --git a/tooling/plugins/build.gradle.kts b/tooling/plugins/build.gradle.kts index 4a810a959..81cc78456 100644 --- a/tooling/plugins/build.gradle.kts +++ b/tooling/plugins/build.gradle.kts @@ -1,65 +1,62 @@ -import org.gradle.internal.impldep.org.eclipse.jgit.util.RawCharUtil.trimTrailingWhitespace -import org.jetbrains.kotlin.builtins.StandardNames.FqNames.target - plugins { - `kotlin-dsl` - alias(libs.plugins.spotless) - alias(libs.plugins.detekt) + `kotlin-dsl` + alias(libs.plugins.spotless) + alias(libs.plugins.detekt) } group = "org.mobilenativefoundation.store" java { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 - toolchain { - languageVersion.set(JavaLanguageVersion.of(11)) - } + toolchain { + languageVersion.set(JavaLanguageVersion.of(11)) + } } spotless { - kotlin { - ktfmt(libs.versions.ktfmt.get()).googleStyle() - target("src/**/*.kt") - trimTrailingWhitespace() - endWithNewline() - } - - kotlinGradle { - ktfmt(libs.versions.ktfmt.get()).googleStyle() - target("*.kts") - trimTrailingWhitespace() - endWithNewline() - } + kotlin { + ktfmt(libs.versions.ktfmt.get()).googleStyle() + target("src/**/*.kt") + trimTrailingWhitespace() + endWithNewline() + } + + kotlinGradle { + ktfmt(libs.versions.ktfmt.get()).googleStyle() + target("*.kts") + trimTrailingWhitespace() + endWithNewline() + } } detekt { - buildUponDefaultConfig = true - baseline = file("../config/detekt/baseline.xml") - config.setFrom("../../config/detekt/rules.yml") - source.setFrom("src") + buildUponDefaultConfig = true + baseline = file("../config/detekt/baseline.xml") + config.setFrom("../../config/detekt/rules.yml") + source.setFrom("src") } dependencies { - compileOnly(libs.android.gradle.plugin) - compileOnly(libs.kotlin.gradle.plugin) - compileOnly(libs.dokka.gradle.plugin) - compileOnly(libs.maven.publish.plugin) - compileOnly(libs.kmmBridge.gradle.plugin) - compileOnly(libs.atomic.fu.gradle.plugin) + compileOnly(libs.android.gradle.plugin) + compileOnly(libs.kotlin.gradle.plugin) + compileOnly(libs.dokka.gradle.plugin) + compileOnly(libs.maven.publish.plugin) + compileOnly(libs.kmmBridge.gradle.plugin) + compileOnly(libs.atomic.fu.gradle.plugin) } gradlePlugin { - plugins { - register("kotlinMultiplatformConventionPlugin") { - id = "org.mobilenativefoundation.store.multiplatform" - implementationClass = "org.mobilenativefoundation.store.tooling.plugins.KotlinMultiplatformConventionPlugin" - } - - register("androidConventionPlugin") { - id = "org.mobilenativefoundation.store.android" - implementationClass = "org.mobilenativefoundation.store.tooling.plugins.AndroidConventionPlugin" - } - } + plugins { + register("kotlinMultiplatformConventionPlugin") { + id = "org.mobilenativefoundation.store.multiplatform" + implementationClass = "org.mobilenativefoundation.store.tooling.plugins.KotlinMultiplatformConventionPlugin" + } + + register("androidConventionPlugin") { + id = "org.mobilenativefoundation.store.android" + implementationClass = "org.mobilenativefoundation.store.tooling.plugins.AndroidConventionPlugin" + } + } } \ No newline at end of file From a5d0f35183432e6ca38569bd47bed052b1b61b61 Mon Sep 17 00:00:00 2001 From: itsandreramon Date: Wed, 26 Feb 2025 17:47:55 +0100 Subject: [PATCH 5/7] revert formatting --- .../plugins/AndroidConventionPlugin.kt | 66 ++--- .../KotlinMultiplatformConventionPlugin.kt | 260 ++++++++++-------- 2 files changed, 178 insertions(+), 148 deletions(-) diff --git a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/AndroidConventionPlugin.kt b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/AndroidConventionPlugin.kt index c6cbb7425..a4cdfcf31 100644 --- a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/AndroidConventionPlugin.kt +++ b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/AndroidConventionPlugin.kt @@ -9,41 +9,45 @@ import org.gradle.api.Project import org.gradle.kotlin.dsl.configure class AndroidConventionPlugin : Plugin { - override fun apply(project: Project) = - with(project) { - with(pluginManager) { - apply("org.jetbrains.kotlin.android") - apply("com.android.library") - apply("com.vanniktech.maven.publish") - apply("org.jetbrains.dokka") - apply("maven-publish") - apply("org.jetbrains.kotlin.native.cocoapods") - apply("org.jetbrains.kotlinx.binary-compatibility-validator") - } - - extensions.configure { - compileSdk = 34 - - defaultConfig { - minSdk = 24 - targetSdk = 34 + override fun apply(project: Project) = with(project) { + with(pluginManager) { + apply("org.jetbrains.kotlin.android") + apply("com.android.library") + apply("com.vanniktech.maven.publish") + apply("org.jetbrains.dokka") + apply("maven-publish") + apply("org.jetbrains.kotlin.native.cocoapods") + apply("org.jetbrains.kotlinx.binary-compatibility-validator") } - lint { - disable += "ComposableModifierFactory" - disable += "ModifierFactoryExtensionFunction" - disable += "ModifierFactoryReturnType" - disable += "ModifierFactoryUnreferencedReceiver" - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + extensions.configure { + + compileSdk = 34 + + defaultConfig { + minSdk = 24 + targetSdk = 34 + } + + lint { + disable += "ComposableModifierFactory" + disable += "ModifierFactoryExtensionFunction" + disable += "ModifierFactoryReturnType" + disable += "ModifierFactoryUnreferencedReceiver" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } } - } - configureKotlin() - configureDokka() - configureMavenPublishing() + configureKotlin() + configureDokka() + configureMavenPublishing() } } + + + diff --git a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/KotlinMultiplatformConventionPlugin.kt b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/KotlinMultiplatformConventionPlugin.kt index feb51e9ef..5b5d27e50 100644 --- a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/KotlinMultiplatformConventionPlugin.kt +++ b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/KotlinMultiplatformConventionPlugin.kt @@ -22,165 +22,191 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget class KotlinMultiplatformConventionPlugin : Plugin { - override fun apply(project: Project) = - with(project) { - with(pluginManager) { - apply("org.jetbrains.kotlin.multiplatform") - apply("org.jetbrains.kotlin.plugin.serialization") - apply("com.android.library") - apply("com.vanniktech.maven.publish") - apply("org.jetbrains.dokka") - apply("co.touchlab.faktory.kmmbridge") - apply("maven-publish") - apply("org.jetbrains.kotlin.native.cocoapods") - apply("kotlinx-atomicfu") - apply("org.jetbrains.kotlinx.binary-compatibility-validator") - } - - extensions.configure { - applyDefaultHierarchyTemplate() - - androidTarget() - - jvm() - - iosX64() - iosArm64() - iosSimulatorArm64() - - linuxX64() - - js { - browser() - nodejs() + override fun apply(project: Project) = with(project) { + + + with(pluginManager) { + apply("org.jetbrains.kotlin.multiplatform") + apply("org.jetbrains.kotlin.plugin.serialization") + apply("com.android.library") + apply("com.vanniktech.maven.publish") + apply("org.jetbrains.dokka") + apply("co.touchlab.faktory.kmmbridge") + apply("maven-publish") + apply("org.jetbrains.kotlin.native.cocoapods") + apply("kotlinx-atomicfu") + apply("org.jetbrains.kotlinx.binary-compatibility-validator") } - @OptIn(ExperimentalWasmDsl::class) wasmJs { nodejs() } - jvmToolchain(11) + extensions.configure { - targets.all { - compilations.all { - compilerOptions.configure { freeCompilerArgs.add("-Xexpect-actual-classes") } - } - } + applyDefaultHierarchyTemplate() + + androidTarget() + + jvm() + + iosX64() + iosArm64() + iosSimulatorArm64() + + linuxX64() - targets.withType().configureEach { - compilations.configureEach { - compilerOptions.configure { - freeCompilerArgs.add("-Xallocator=custom") - freeCompilerArgs.add("-XXLanguage:+ImplicitSignedToUnsignedIntegerConversion") - freeCompilerArgs.add("-Xadd-light-debug=enable") - - freeCompilerArgs.addAll( - "-opt-in=kotlin.RequiresOptIn", - "-opt-in=kotlin.time.ExperimentalTime", - "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", - "-opt-in=kotlinx.coroutines.FlowPreview", - "-opt-in=kotlinx.cinterop.ExperimentalForeignApi", - "-opt-in=kotlinx.cinterop.BetaInteropApi", - ) + js { + browser() + nodejs() } - } - } - sourceSets.all { - languageSettings.apply { - optIn("kotlin.contracts.ExperimentalContracts") - optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") - optIn("kotlin.RequiresOptIn") - } - } + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + nodejs() + } + + jvmToolchain(11) + + targets.all { + compilations.all { + compilerOptions.configure { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + } + } + + targets.withType().configureEach { + compilations.configureEach { + compilerOptions.configure { + freeCompilerArgs.add("-Xallocator=custom") + freeCompilerArgs.add("-XXLanguage:+ImplicitSignedToUnsignedIntegerConversion") + freeCompilerArgs.add("-Xadd-light-debug=enable") + + freeCompilerArgs.addAll( + "-opt-in=kotlin.RequiresOptIn", + "-opt-in=kotlin.time.ExperimentalTime", + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-opt-in=kotlinx.coroutines.FlowPreview", + "-opt-in=kotlinx.cinterop.ExperimentalForeignApi", + "-opt-in=kotlinx.cinterop.BetaInteropApi", + ) + } + } + } + + sourceSets.all { + languageSettings.apply { + optIn("kotlin.contracts.ExperimentalContracts") + optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") + optIn("kotlin.RequiresOptIn") + } + } + + sourceSets.getByName("commonTest") { + dependencies { + implementation(kotlin("test")) + } + } + + sourceSets.getByName("jvmTest") { + dependencies { + implementation(kotlin("test-junit")) + } + } - sourceSets.getByName("commonTest") { dependencies { implementation(kotlin("test")) } } + sourceSets.getByName("nativeMain") { + dependsOn(sourceSets.getByName("commonMain")) + } - sourceSets.getByName("jvmTest") { dependencies { implementation(kotlin("test-junit")) } } - sourceSets.getByName("nativeMain") { dependsOn(sourceSets.getByName("commonMain")) } - } + } - configureKotlin() - configureAndroid() - configureDokka() - configureMavenPublishing() - addGithubPackagesRepository() - configureKmmBridge() - configureAtomicFu() + configureKotlin() + configureAndroid() + configureDokka() + configureMavenPublishing() + addGithubPackagesRepository() + configureKmmBridge() + configureAtomicFu() } } + fun Project.configureKotlin() { - configureJava() + configureJava() } fun Project.configureJava() { - java { toolchain { languageVersion.set(JavaLanguageVersion.of(11)) } } + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(11)) + } + } } fun Project.configureAndroid() { - android { - sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") - compileSdk = Versions.COMPILE_SDK - defaultConfig { - minSdk = Versions.MIN_SDK - targetSdk = Versions.TARGET_SDK - } - lint { - disable += "ComposableModifierFactory" - disable += "ModifierFactoryExtensionFunction" - disable += "ModifierFactoryReturnType" - disable += "ModifierFactoryUnreferencedReceiver" - } + android { + sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") + compileSdk = Versions.COMPILE_SDK + defaultConfig { + minSdk = Versions.MIN_SDK + targetSdk = Versions.TARGET_SDK + } + lint { + disable += "ComposableModifierFactory" + disable += "ModifierFactoryExtensionFunction" + disable += "ModifierFactoryReturnType" + disable += "ModifierFactoryUnreferencedReceiver" + } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } } - } } -fun Project.android(action: LibraryExtension.() -> Unit) = - extensions.configure(action) -private fun Project.java(action: JavaPluginExtension.() -> Unit) = - extensions.configure(action) +fun Project.android(action: LibraryExtension.() -> Unit) = extensions.configure(action) + +private fun Project.java(action: JavaPluginExtension.() -> Unit) = extensions.configure(action) + object Versions { - const val COMPILE_SDK = 34 - const val MIN_SDK = 24 - const val TARGET_SDK = 34 - const val STORE = "5.1.0-SNAPSHOT" + const val COMPILE_SDK = 34 + const val MIN_SDK = 24 + const val TARGET_SDK = 34 + const val STORE = "5.1.0-SNAPSHOT" } -fun Project.configureMavenPublishing() = - extensions.configure { + +fun Project.configureMavenPublishing() = extensions.configure { publishToMavenCentral(S01) signAllPublications() - } +} + -fun Project.configureKmmBridge() = - extensions.configure { +fun Project.configureKmmBridge() = extensions.configure { githubReleaseArtifacts() githubReleaseVersions() versionPrefix.set(Versions.STORE) spm() - } +} fun Project.configureAtomicFu() = - extensions.configure { - transformJvm = false - transformJs = false - } + extensions.configure { + transformJvm = false + transformJs = false + } -fun Project.configureDokka() = - tasks.withType().configureEach { +fun Project.configureDokka() = tasks.withType().configureEach { dokkaSourceSets.configureEach { - reportUndocumented.set(false) - skipDeprecated.set(true) - jdkVersion.set(11) + reportUndocumented.set(false) + skipDeprecated.set(true) + jdkVersion.set(11) } - } +} fun Project.android(name: String) { - android { namespace = "org.mobilenativefoundation.store.$name" } + android { + namespace = "org.mobilenativefoundation.store.$name" + } } From b213bbdca259bc62b4f3206e9219051a1094589f Mon Sep 17 00:00:00 2001 From: itsandreramon Date: Wed, 26 Feb 2025 17:53:15 +0100 Subject: [PATCH 6/7] update workflow --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52e6c3eb3..62fe305d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,10 +39,10 @@ jobs: run: chmod +x gradlew - name: Check Kotlin formatting - run: ./gradlew spotlessCheck + run: ./gradlew spotlessCheck :tooling:plugins:spotlessCheck - name: Run Kotlin static analysis - run: ./gradlew detekt + run: ./gradlew detekt :tooling:plugins:detekt - name: Build and Test with Coverage run: ./gradlew clean build koverXmlReport --stacktrace From d2154fe5a68d821a27bfb486ce639a1bd71e33e0 Mon Sep 17 00:00:00 2001 From: itsandreramon Date: Mon, 3 Mar 2025 19:11:34 +0100 Subject: [PATCH 7/7] run spotless --- cache/build.gradle.kts | 37 +- .../store/cache5/Cache.kt | 116 +- .../store/cache5/CacheBuilder.kt | 136 +- .../store/cache5/LocalCache.kt | 3436 ++++++++--------- .../store/cache5/MonotonicTicker.kt | 3 +- .../store/cache5/RemovalCause.kt | 14 +- .../store/cache5/StoreMultiCache.kt | 271 +- .../store/cache5/StoreMultiCacheAccessor.kt | 291 +- .../store/cache5/Ticker.kt | 4 +- .../store/cache5/Weigher.kt | 3 +- .../store/cache5/CacheTests.kt | 204 +- core/build.gradle.kts | 19 +- .../store/core5/ExperimentalStoreApi.kt | 4 +- .../store/core5/InsertionStrategy.kt | 6 +- .../store/core5/KeyProvider.kt | 10 +- .../store/core5/StoreData.kt | 41 +- .../store/core5/StoreKey.kt | 83 +- multicast/build.gradle.kts | 38 +- .../store/multicast5/Actor.kt | 34 +- .../store/multicast5/ChannelManager.kt | 599 ++- .../store/multicast5/Multicaster.kt | 175 +- .../store/multicast5/SharedFlowProducer.kt | 93 +- .../store/multicast5/StoreRealActor.kt | 101 +- .../multicast5/StoreChannelManagerTests.kt | 102 +- rx2/build.gradle.kts | 28 +- .../store/rx2/RxFetcher.kt | 28 +- .../store/rx2/RxSourceOfTruth.kt | 54 +- .../store/rx2/RxStore.kt | 42 +- .../store/rx2/RxStoreBuilder.kt | 22 +- .../store/rx2/test/FlowTestExt.kt | 91 +- .../store/rx2/test/HotRxSingleStoreTest.kt | 91 +- .../store/rx2/test/RxFlowableStoreTest.kt | 160 +- .../rx2/test/RxSingleStoreExtensionsTest.kt | 78 +- .../store/rx2/test/RxSingleStoreTest.kt | 177 +- store/build.gradle.kts | 66 +- .../store/store5/Bookkeeper.kt | 32 +- .../store/store5/Clear.kt | 33 +- .../store/store5/Converter.kt | 54 +- .../store/store5/Fetcher.kt | 335 +- .../store/store5/FetcherResult.kt | 13 +- .../store/store5/Logger.kt | 33 +- .../store/store5/MemoryPolicy.kt | 185 +- .../store/store5/MutableStore.kt | 10 +- .../store/store5/MutableStoreBuilder.kt | 82 +- .../store/store5/OnFetcherCompletion.kt | 4 +- .../store/store5/OnUpdaterCompletion.kt | 4 +- .../store/store5/Read.kt | 21 +- .../store/store5/SourceOfTruth.kt | 271 +- .../store/store5/Store.kt | 38 +- .../store/store5/StoreBuilder.kt | 147 +- .../store/store5/StoreDefaults.kt | 31 +- .../store/store5/StoreReadRequest.kt | 138 +- .../store/store5/StoreReadResponse.kt | 241 +- .../store/store5/StoreWriteRequest.kt | 25 +- .../store/store5/StoreWriteResponse.kt | 16 +- .../store/store5/Updater.kt | 41 +- .../store/store5/UpdaterResult.kt | 16 +- .../store/store5/Validator.kt | 19 +- .../store/store5/Write.kt | 16 +- .../store/store5/impl/DefaultLogger.kt | 29 +- .../store/store5/impl/FetcherController.kt | 212 +- .../store5/impl/OnStoreWriteCompletion.kt | 4 +- .../store/store5/impl/RealBookkeeper.kt | 20 +- .../store/store5/impl/RealMutableStore.kt | 564 +-- .../store5/impl/RealMutableStoreBuilder.kt | 168 +- .../store/store5/impl/RealSourceOfTruth.kt | 71 +- .../store/store5/impl/RealStore.kt | 535 ++- .../store/store5/impl/RealStoreBuilder.kt | 223 +- .../store5/impl/RealStoreWriteRequest.kt | 8 +- .../store/store5/impl/RealValidator.kt | 4 +- .../store/store5/impl/RefCountedResource.kt | 58 +- .../store5/impl/SourceOfTruthWithBarrier.kt | 296 +- .../store/store5/impl/extensions/clock.kt | 2 +- .../store/store5/impl/extensions/store.kt | 58 +- .../store/store5/impl/operators/FlowMerge.kt | 25 +- .../store/store5/impl/operators/MapIndexed.kt | 9 +- .../store5/internal/concurrent/Lightswitch.kt | 40 +- .../internal/concurrent/ThreadSafety.kt | 8 +- .../internal/definition/WriteRequestQueue.kt | 3 +- .../result/EagerConflictResolutionResult.kt | 17 +- .../result/StoreDelegateWriteResult.kt | 10 +- .../store/store5/ClearAllStoreTests.kt | 259 +- .../store/store5/ClearStoreByKeyTests.kt | 260 +- .../store/store5/FallbackTests.kt | 262 +- .../store/store5/FetcherControllerTests.kt | 199 +- .../store/store5/FetcherResponseTests.kt | 502 ++- .../store/store5/FlowStoreTests.kt | 1679 ++++---- .../store/store5/HotFlowStoreTests.kt | 125 +- .../store/store5/KeyTrackerTests.kt | 193 +- .../store/store5/LocalOnlyTests.kt | 282 +- .../store/store5/MapIndexedTests.kt | 24 +- .../store/store5/SourceOfTruthErrorsTests.kt | 725 ++-- .../store5/SourceOfTruthWithBarrierTests.kt | 404 +- .../store/store5/StoreReadResponseTests.kt | 73 +- .../store5/StoreWithInMemoryCacheTests.kt | 212 +- .../store5/StreamWithoutSourceOfTruthTests.kt | 165 +- .../store/store5/UpdaterTests.kt | 444 +-- .../store/store5/ValueFetcherTests.kt | 102 +- .../mutablestore/RealMutableStoreTest.kt | 653 ++-- .../store5/mutablestore/util/TestCache.kt | 110 +- .../store5/mutablestore/util/TestConverter.kt | 42 +- .../store5/mutablestore/util/TestFetcher.kt | 23 +- .../util/TestInMemoryBookkeeper.kt | 33 +- .../store5/mutablestore/util/TestLogger.kt | 19 +- .../mutablestore/util/TestSourceOfTruth.kt | 99 +- .../store5/mutablestore/util/TestStore.kt | 30 +- .../store5/mutablestore/util/TestUpdater.kt | 27 +- .../store5/mutablestore/util/TestValidator.kt | 17 +- .../store/store5/util/AsFlowable.kt | 180 +- .../store/store5/util/FakeFetcher.kt | 59 +- .../store/store5/util/InMemoryPersister.kt | 78 +- .../store/store5/util/TestApi.kt | 12 +- .../store/store5/util/TestStoreExt.kt | 18 +- .../store/store5/util/fake/NoteCollections.kt | 8 +- .../store/store5/util/fake/Notes.kt | 20 +- .../store/store5/util/fake/NotesApi.kt | 78 +- .../store5/util/fake/NotesBookkeeping.kt | 60 +- .../util/fake/NotesConverterProvider.kt | 17 +- .../store/store5/util/fake/NotesDatabase.kt | 62 +- .../store/store5/util/fake/NotesKey.kt | 4 +- .../store5/util/fake/NotesUpdaterProvider.kt | 22 +- .../store/store5/util/fake/NotesValidator.kt | 10 +- .../util/fake/fallback/HardcodedPages.kt | 22 +- .../store/store5/util/fake/fallback/Page.kt | 7 +- .../util/fake/fallback/PagesDatabase.kt | 15 +- .../util/fake/fallback/PrimaryPagesApi.kt | 36 +- .../util/fake/fallback/SecondaryPagesApi.kt | 22 +- .../store/store5/util/model/NoteData.kt | 38 +- tooling/plugins/build.gradle.kts | 84 +- .../plugins/AndroidConventionPlugin.kt | 66 +- .../KotlinMultiplatformConventionPlugin.kt | 260 +- 131 files changed, 8700 insertions(+), 10267 deletions(-) diff --git a/cache/build.gradle.kts b/cache/build.gradle.kts index e1aecc671..811b513df 100644 --- a/cache/build.gradle.kts +++ b/cache/build.gradle.kts @@ -1,26 +1,21 @@ -plugins { - id("org.mobilenativefoundation.store.multiplatform") -} +plugins { id("org.mobilenativefoundation.store.multiplatform") } kotlin { - - sourceSets { - val commonMain by getting { - dependencies { - api(libs.kotlinx.atomic.fu) - api(projects.core) - implementation(libs.kotlinx.coroutines.core) - } - } - val commonTest by getting { - dependencies { - implementation(libs.junit) - implementation(libs.kotlinx.coroutines.test) - } - } + sourceSets { + val commonMain by getting { + dependencies { + api(libs.kotlinx.atomic.fu) + api(projects.core) + implementation(libs.kotlinx.coroutines.core) + } } + val commonTest by getting { + dependencies { + implementation(libs.junit) + implementation(libs.kotlinx.coroutines.test) + } + } + } } -android { - namespace = "org.mobilenativefoundation.store.cache" -} +android { namespace = "org.mobilenativefoundation.store.cache" } diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Cache.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Cache.kt index b2e89d042..8bc0202dc 100644 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Cache.kt +++ b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Cache.kt @@ -1,69 +1,55 @@ package org.mobilenativefoundation.store.cache5 interface Cache { - /** - * @return [Value] associated with [key] or `null` if there is no cached value for [key]. - */ - fun getIfPresent(key: Key): Value? - - /** - * @return [Value] associated with [key], obtaining the value from [valueProducer] if necessary. - * No observable state associated with this cache is modified until loading completes. - * @param [valueProducer] Must not return `null`. It may either return a non-null value or throw an exception. - * @throws ExecutionExeption If a checked exception was thrown while loading the value. - * @throws UncheckedExecutionException If an unchecked exception was thrown while loading the value. - * @throws ExecutionError If an error was thrown while loading the value. - */ - fun getOrPut( - key: Key, - valueProducer: () -> Value, - ): Value - - /** - * @return Map of the [Value] associated with each [Key] in [keys]. Returned map only contains entries already present in the cache. - * The default implementation provided here throws a [NotImplementedError] to maintain backward compatibility for existing implementations. - */ - fun getAllPresent(keys: List<*>): Map - - /** - * @return Map of the [Value] associated with each [Key] in the cache. - */ - fun getAllPresent(): Map = throw NotImplementedError() - - /** - * Associates [value] with [key]. - * If the cache previously contained a value associated with [key], the old value is replaced by [value]. - * Prefer [getOrPut] when using the conventional "If cached, then return. Otherwise create, cache, and then return" pattern. - */ - fun put( - key: Key, - value: Value, - ) - - /** - * Copies all of the mappings from the specified map to the cache. The effect of this call is - * equivalent to that of calling [put] on this map once for each mapping from [Key] to [Value] in the specified map. - * The behavior of this operation is undefined if the specified map is modified while the operation is in progress. - */ - fun putAll(map: Map) - - /** - * Discards any cached value associated with [key]. - */ - fun invalidate(key: Key) - - /** - * Discards any cached value associated for [keys]. - */ - fun invalidateAll(keys: List) - - /** - * Discards all entries in the cache. - */ - fun invalidateAll() - - /** - * @return Approximate number of entries in the cache. - */ - fun size(): Long + /** @return [Value] associated with [key] or `null` if there is no cached value for [key]. */ + fun getIfPresent(key: Key): Value? + + /** + * @param [valueProducer] Must not return `null`. It may either return a non-null value or throw + * an exception. + * @return [Value] associated with [key], obtaining the value from [valueProducer] if necessary. + * No observable state associated with this cache is modified until loading completes. + * @throws ExecutionExeption If a checked exception was thrown while loading the value. + * @throws UncheckedExecutionException If an unchecked exception was thrown while loading the + * value. + * @throws ExecutionError If an error was thrown while loading the value. + */ + fun getOrPut(key: Key, valueProducer: () -> Value): Value + + /** + * @return Map of the [Value] associated with each [Key] in [keys]. Returned map only contains + * entries already present in the cache. The default implementation provided here throws a + * [NotImplementedError] to maintain backward compatibility for existing implementations. + */ + fun getAllPresent(keys: List<*>): Map + + /** @return Map of the [Value] associated with each [Key] in the cache. */ + fun getAllPresent(): Map = throw NotImplementedError() + + /** + * Associates [value] with [key]. If the cache previously contained a value associated with [key], + * the old value is replaced by [value]. Prefer [getOrPut] when using the conventional "If cached, + * then return. Otherwise create, cache, and then return" pattern. + */ + fun put(key: Key, value: Value) + + /** + * Copies all of the mappings from the specified map to the cache. The effect of this call is + * equivalent to that of calling [put] on this map once for each mapping from [Key] to [Value] in + * the specified map. The behavior of this operation is undefined if the specified map is modified + * while the operation is in progress. + */ + fun putAll(map: Map) + + /** Discards any cached value associated with [key]. */ + fun invalidate(key: Key) + + /** Discards any cached value associated for [keys]. */ + fun invalidateAll(keys: List) + + /** Discards all entries in the cache. */ + fun invalidateAll() + + /** @return Approximate number of entries in the cache. */ + fun size(): Long } diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/CacheBuilder.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/CacheBuilder.kt index 9a0782da5..b915a2b0a 100644 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/CacheBuilder.kt +++ b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/CacheBuilder.kt @@ -3,77 +3,73 @@ package org.mobilenativefoundation.store.cache5 import kotlin.time.Duration class CacheBuilder { - internal var concurrencyLevel = 4 - private set - internal val initialCapacity = 16 - internal var maximumSize = UNSET - private set - internal var maximumWeight = UNSET - private set - internal var expireAfterAccess: Duration = Duration.INFINITE - private set - internal var expireAfterWrite: Duration = Duration.INFINITE - private set - internal var weigher: Weigher? = null - private set - internal var ticker: Ticker? = null - private set - - fun concurrencyLevel(producer: () -> Int): CacheBuilder = - apply { - concurrencyLevel = producer.invoke() - } - - fun maximumSize(maximumSize: Long): CacheBuilder = - apply { - if (maximumSize < 0) { - throw IllegalArgumentException("Maximum size must be non-negative.") - } - this.maximumSize = maximumSize - } - - fun expireAfterAccess(duration: Duration): CacheBuilder = - apply { - if (duration.isNegative()) { - throw IllegalArgumentException("Duration must be non-negative.") - } - expireAfterAccess = duration - } - - fun expireAfterWrite(duration: Duration): CacheBuilder = - apply { - if (duration.isNegative()) { - throw IllegalArgumentException("Duration must be non-negative.") - } - expireAfterWrite = duration - } - - fun ticker(ticker: Ticker): CacheBuilder = - apply { - this.ticker = ticker - } - - fun weigher( - maximumWeight: Long, - weigher: Weigher, - ): CacheBuilder = - apply { - if (maximumWeight < 0) { - throw IllegalArgumentException("Maximum weight must be non-negative.") - } - - this.maximumWeight = maximumWeight - this.weigher = weigher - } - - fun build(): Cache { - if (maximumSize != -1L && weigher != null) { - throw IllegalStateException("Maximum size cannot be combined with weigher.") - } - return LocalCache.LocalManualCache(this) + internal var concurrencyLevel = 4 + private set + + internal val initialCapacity = 16 + internal var maximumSize = UNSET + private set + + internal var maximumWeight = UNSET + private set + + internal var expireAfterAccess: Duration = Duration.INFINITE + private set + + internal var expireAfterWrite: Duration = Duration.INFINITE + private set + + internal var weigher: Weigher? = null + private set + + internal var ticker: Ticker? = null + private set + + fun concurrencyLevel(producer: () -> Int): CacheBuilder = apply { + concurrencyLevel = producer.invoke() + } + + fun maximumSize(maximumSize: Long): CacheBuilder = apply { + if (maximumSize < 0) { + throw IllegalArgumentException("Maximum size must be non-negative.") } + this.maximumSize = maximumSize + } - companion object { - private const val UNSET = -1L + fun expireAfterAccess(duration: Duration): CacheBuilder = apply { + if (duration.isNegative()) { + throw IllegalArgumentException("Duration must be non-negative.") } + expireAfterAccess = duration + } + + fun expireAfterWrite(duration: Duration): CacheBuilder = apply { + if (duration.isNegative()) { + throw IllegalArgumentException("Duration must be non-negative.") + } + expireAfterWrite = duration + } + + fun ticker(ticker: Ticker): CacheBuilder = apply { this.ticker = ticker } + + fun weigher(maximumWeight: Long, weigher: Weigher): CacheBuilder = + apply { + if (maximumWeight < 0) { + throw IllegalArgumentException("Maximum weight must be non-negative.") + } + + this.maximumWeight = maximumWeight + this.weigher = weigher + } + + fun build(): Cache { + if (maximumSize != -1L && weigher != null) { + throw IllegalStateException("Maximum size cannot be combined with weigher.") + } + return LocalCache.LocalManualCache(this) + } + + companion object { + private const val UNSET = -1L + } } diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/LocalCache.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/LocalCache.kt index a71382a9f..0be801964 100644 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/LocalCache.kt +++ b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/LocalCache.kt @@ -18,2065 +18,1845 @@ */ package org.mobilenativefoundation.store.cache5 +import kotlin.math.min +import kotlin.time.Duration import kotlinx.atomicfu.AtomicArray import kotlinx.atomicfu.AtomicRef import kotlinx.atomicfu.atomic import kotlinx.atomicfu.atomicArrayOfNulls import kotlinx.atomicfu.locks.reentrantLock import kotlinx.atomicfu.loop -import kotlin.math.min -import kotlin.time.Duration internal class LocalCache(builder: CacheBuilder) { - /** - * Mask value for indexing into segments. The upper bits of a key's hash code are used to choose - * the segment. - */ - private val segmentMask: Int + /** + * Mask value for indexing into segments. The upper bits of a key's hash code are used to choose + * the segment. + */ + private val segmentMask: Int - /** - * Shift value for indexing within segments. Helps prevent entries that end up in the same segment - * from also ending up in the same bucket. - */ - private val segmentShift: Int + /** + * Shift value for indexing within segments. Helps prevent entries that end up in the same segment + * from also ending up in the same bucket. + */ + private val segmentShift: Int - /** - * The segments, each of which is a specialized hash table. - */ + /** The segments, each of which is a specialized hash table. */ + private val segments: Array?> - private val segments: Array?> + /** Strategy for referencing values. */ + private val valueStrength: Strength = Strength.Strong - /** - * Strategy for referencing values. - */ - private val valueStrength: Strength = Strength.Strong + /** The maximum weight of this map. UNSET_LONG if there is no maximum. */ + private val maxWeight: Long - /** - * The maximum weight of this map. UNSET_LONG if there is no maximum. - */ - private val maxWeight: Long + /** Weigher to weigh cache entries. */ + private val weigher: Weigher - /** - * Weigher to weigh cache entries. - */ - private val weigher: Weigher + /** How long after the last access to an entry the map will retain that entry. */ + private val expireAfterAccessNanos: Long - /** - * How long after the last access to an entry the map will retain that entry. - */ - private val expireAfterAccessNanos: Long + /** How long after the last write to an entry the map will retain that entry. */ + private val expireAfterWriteNanos: Long - /** - * How long after the last write to an entry the map will retain that entry. - */ - private val expireAfterWriteNanos: Long - - /** - * Measures time in a testable way. - */ - private val ticker: Ticker + /** Measures time in a testable way. */ + private val ticker: Ticker - /** - * Factory used to create new entries. - */ - private val entryFactory: EntryFactory + /** Factory used to create new entries. */ + private val entryFactory: EntryFactory - private val evictsBySize: Boolean get() = maxWeight >= 0 + private val evictsBySize: Boolean + get() = maxWeight >= 0 - private val customWeigher: Boolean get() = weigher !== OneWeigher + private val customWeigher: Boolean + get() = weigher !== OneWeigher - private val expiresAfterWrite: Boolean get() = expireAfterWriteNanos > 0 + private val expiresAfterWrite: Boolean + get() = expireAfterWriteNanos > 0 - private val expiresAfterAccess: Boolean get() = expireAfterAccessNanos > 0 + private val expiresAfterAccess: Boolean + get() = expireAfterAccessNanos > 0 - private val usesAccessQueue: Boolean get() = expiresAfterAccess || evictsBySize + private val usesAccessQueue: Boolean + get() = expiresAfterAccess || evictsBySize - private val usesWriteQueue: Boolean get() = expiresAfterWrite + private val usesWriteQueue: Boolean + get() = expiresAfterWrite - private val recordsWrite: Boolean get() = expiresAfterWrite + private val recordsWrite: Boolean + get() = expiresAfterWrite - private val recordsAccess: Boolean get() = expiresAfterAccess + private val recordsAccess: Boolean + get() = expiresAfterAccess - private val recordsTime: Boolean get() = recordsWrite || recordsAccess + private val recordsTime: Boolean + get() = recordsWrite || recordsAccess - private val usesWriteEntries: Boolean get() = usesWriteQueue || recordsWrite + private val usesWriteEntries: Boolean + get() = usesWriteQueue || recordsWrite - private val usesAccessEntries: Boolean get() = usesAccessQueue || recordsAccess + private val usesAccessEntries: Boolean + get() = usesAccessQueue || recordsAccess - private sealed class Strength { - /* - * TODO(kevinb): If we strongly reference the value and aren't loading, we needn't wrap the - * value. This could save ~8 bytes per entry. - */ - object Strong : Strength() { - override fun referenceValue( - segment: Segment?, - entry: ReferenceEntry?, - value: V, - weight: Int, - ): ValueReference { - return if (weight == 1) { - StrongValueReference(value) - } else { - WeightedStrongValueReference( - value, - weight, - ) - } - } + private sealed class Strength { + /* + * TODO(kevinb): If we strongly reference the value and aren't loading, we needn't wrap the + * value. This could save ~8 bytes per entry. + */ + object Strong : Strength() { + override fun referenceValue( + segment: Segment?, + entry: ReferenceEntry?, + value: V, + weight: Int, + ): ValueReference { + return if (weight == 1) { + StrongValueReference(value) + } else { + WeightedStrongValueReference(value, weight) } + } + } - /** - * Creates a reference for the given value according to this value strength. - */ - abstract fun referenceValue( - segment: Segment?, - entry: ReferenceEntry?, - value: V, - weight: Int, - ): ValueReference + /** Creates a reference for the given value according to this value strength. */ + abstract fun referenceValue( + segment: Segment?, + entry: ReferenceEntry?, + value: V, + weight: Int, + ): ValueReference + } + + /** Creates new entries. */ + private sealed class EntryFactory { + object Strong : EntryFactory() { + override fun newEntry( + segment: Segment?, + key: K, + hash: Int, + next: ReferenceEntry?, + ): ReferenceEntry { + return StrongEntry(key, hash, next) + } } - /** - * Creates new entries. - */ - private sealed class EntryFactory { - object Strong : EntryFactory() { - override fun newEntry( - segment: Segment?, - key: K, - hash: Int, - next: ReferenceEntry?, - ): ReferenceEntry { - return StrongEntry(key, hash, next) - } - } + object StrongAccess : EntryFactory() { + override fun newEntry( + segment: Segment?, + key: K, + hash: Int, + next: ReferenceEntry?, + ): ReferenceEntry { + return StrongAccessEntry(key, hash, next) + } - object StrongAccess : EntryFactory() { - override fun newEntry( - segment: Segment?, - key: K, - hash: Int, - next: ReferenceEntry?, - ): ReferenceEntry { - return StrongAccessEntry(key, hash, next) - } + override fun copyEntry( + segment: Segment?, + original: ReferenceEntry, + newNext: ReferenceEntry?, + ): ReferenceEntry { + val newEntry = super.copyEntry(segment, original, newNext) + copyAccessEntry(original, newEntry) + return newEntry + } + } - override fun copyEntry( - segment: Segment?, - original: ReferenceEntry, - newNext: ReferenceEntry?, - ): ReferenceEntry { - val newEntry = super.copyEntry(segment, original, newNext) - copyAccessEntry(original, newEntry) - return newEntry - } - } + object StrongWrite : EntryFactory() { + override fun newEntry( + segment: Segment?, + key: K, + hash: Int, + next: ReferenceEntry?, + ): ReferenceEntry { + return StrongWriteEntry(key, hash, next) + } - object StrongWrite : EntryFactory() { - override fun newEntry( - segment: Segment?, - key: K, - hash: Int, - next: ReferenceEntry?, - ): ReferenceEntry { - return StrongWriteEntry(key, hash, next) - } + override fun copyEntry( + segment: Segment?, + original: ReferenceEntry, + newNext: ReferenceEntry?, + ): ReferenceEntry { + val newEntry = super.copyEntry(segment, original, newNext) + copyWriteEntry(original, newEntry) + return newEntry + } + } - override fun copyEntry( - segment: Segment?, - original: ReferenceEntry, - newNext: ReferenceEntry?, - ): ReferenceEntry { - val newEntry = super.copyEntry(segment, original, newNext) - copyWriteEntry(original, newEntry) - return newEntry - } - } + object StrongAccessWrite : EntryFactory() { + override fun newEntry( + segment: Segment?, + key: K, + hash: Int, + next: ReferenceEntry?, + ): ReferenceEntry { + return StrongAccessWriteEntry(key, hash, next) + } - object StrongAccessWrite : EntryFactory() { - override fun newEntry( - segment: Segment?, - key: K, - hash: Int, - next: ReferenceEntry?, - ): ReferenceEntry { - return StrongAccessWriteEntry(key, hash, next) - } + override fun copyEntry( + segment: Segment?, + original: ReferenceEntry, + newNext: ReferenceEntry?, + ): ReferenceEntry { + val newEntry = super.copyEntry(segment, original, newNext) + copyAccessEntry(original, newEntry) + copyWriteEntry(original, newEntry) + return newEntry + } + } - override fun copyEntry( - segment: Segment?, - original: ReferenceEntry, - newNext: ReferenceEntry?, - ): ReferenceEntry { - val newEntry = super.copyEntry(segment, original, newNext) - copyAccessEntry(original, newEntry) - copyWriteEntry(original, newEntry) - return newEntry - } - } + /** + * Creates a new entry. + * + * @param segment to create the entry for + * @param key of the entry + * @param hash of the key + * @param next entry in the same bucket + */ + abstract fun newEntry( + segment: Segment?, + key: K, + hash: Int, + next: ReferenceEntry?, + ): ReferenceEntry - /** - * Creates a new entry. - * - * @param segment to create the entry for - * @param key of the entry - * @param hash of the key - * @param next entry in the same bucket - */ - abstract fun newEntry( - segment: Segment?, - key: K, - hash: Int, - next: ReferenceEntry?, - ): ReferenceEntry - - /** - * Copies an entry, assigning it a new `next` entry. - * - * @param original the entry to copy - * @param newNext entry in the same bucket - */ - // Guarded By Segment.this - open fun copyEntry( - segment: Segment?, - original: ReferenceEntry, - newNext: ReferenceEntry?, - ): ReferenceEntry { - return newEntry(segment, original.key, original.hash, newNext) - } + /** + * Copies an entry, assigning it a new `next` entry. + * + * @param original the entry to copy + * @param newNext entry in the same bucket + */ + // Guarded By Segment.this + open fun copyEntry( + segment: Segment?, + original: ReferenceEntry, + newNext: ReferenceEntry?, + ): ReferenceEntry { + return newEntry(segment, original.key, original.hash, newNext) + } - // Guarded By Segment.this - fun copyAccessEntry( - original: ReferenceEntry, - newEntry: ReferenceEntry, - ) { - // TODO(fry): when we link values instead of entries this method can go - // away, as can connectAccessOrder, nullifyAccessOrder. - newEntry.accessTime = original.accessTime - connectAccessOrder(original.previousInAccessQueue, newEntry) - connectAccessOrder(newEntry, original.nextInAccessQueue) - nullifyAccessOrder(original) - } + // Guarded By Segment.this + fun copyAccessEntry( + original: ReferenceEntry, + newEntry: ReferenceEntry, + ) { + // TODO(fry): when we link values instead of entries this method can go + // away, as can connectAccessOrder, nullifyAccessOrder. + newEntry.accessTime = original.accessTime + connectAccessOrder(original.previousInAccessQueue, newEntry) + connectAccessOrder(newEntry, original.nextInAccessQueue) + nullifyAccessOrder(original) + } - // Guarded By Segment.this - fun copyWriteEntry( - original: ReferenceEntry, - newEntry: ReferenceEntry, - ) { - // TODO(fry): when we link values instead of entries this method can go - // away, as can connectWriteOrder, nullifyWriteOrder. - newEntry.writeTime = original.writeTime - connectWriteOrder(original.previousInWriteQueue, newEntry) - connectWriteOrder(newEntry, original.nextInWriteQueue) - nullifyWriteOrder(original) - } + // Guarded By Segment.this + fun copyWriteEntry( + original: ReferenceEntry, + newEntry: ReferenceEntry, + ) { + // TODO(fry): when we link values instead of entries this method can go + // away, as can connectWriteOrder, nullifyWriteOrder. + newEntry.writeTime = original.writeTime + connectWriteOrder(original.previousInWriteQueue, newEntry) + connectWriteOrder(newEntry, original.nextInWriteQueue) + nullifyWriteOrder(original) + } - companion object { - /** - * Masks used to compute indices in the following table. - */ - private const val ACCESS_MASK = 1 - private const val WRITE_MASK = 2 - - /** - * Look-up table for factories. - */ - private val factories = arrayOf(Strong, StrongAccess, StrongWrite, StrongAccessWrite) - - fun getFactory( - usesAccessQueue: Boolean, - usesWriteQueue: Boolean, - ): EntryFactory { - val flags = ((if (usesAccessQueue) ACCESS_MASK else 0) or if (usesWriteQueue) WRITE_MASK else 0) - return factories[flags] - } - } + companion object { + /** Masks used to compute indices in the following table. */ + private const val ACCESS_MASK = 1 + private const val WRITE_MASK = 2 + + /** Look-up table for factories. */ + private val factories = arrayOf(Strong, StrongAccess, StrongWrite, StrongAccessWrite) + + fun getFactory(usesAccessQueue: Boolean, usesWriteQueue: Boolean): EntryFactory { + val flags = + ((if (usesAccessQueue) ACCESS_MASK else 0) or if (usesWriteQueue) WRITE_MASK else 0) + return factories[flags] + } } + } + + /** A reference to a value. */ + private interface ValueReference { + /** Returns the value. Does not block or throw exceptions. */ + fun get(): V? + + /** Returns the weight of this entry. This is assumed to be static between calls to setValue. */ + val weight: Int /** - * A reference to a value. + * Returns the entry associated with this value reference, or `null` if this value reference is + * independent of any entry. */ - private interface ValueReference { - /** - * Returns the value. Does not block or throw exceptions. - */ - fun get(): V? - - /** - * Returns the weight of this entry. This is assumed to be static between calls to setValue. - */ - val weight: Int - - /** - * Returns the entry associated with this value reference, or `null` if this value - * reference is independent of any entry. - */ - val entry: ReferenceEntry? - - /** - * Creates a copy of this reference for the given entry. - * - * - * - * `value` may be null only for a loading reference. - */ - - fun copyFor( - value: V?, - entry: ReferenceEntry?, - ): ValueReference - - /** - * Notifify pending loads that a new value was set. This is only relevant to loading - * value references. - */ - fun notifyNewValue(newValue: V) - - /** - * Returns true if this reference contains an active value, meaning one that is still considered - * present in the cache. Active values consist of live values, which are returned by cache - * lookups, and dead values, which have been evicted but awaiting removal. Non-active values - * consist strictly of loading values, though during refresh a value may be both active and - * loading. - */ - val isActive: Boolean - } + val entry: ReferenceEntry? /** - * An entry in a reference map. - * - * - * Entries in the map can be in the following states: - * - * - * Valid: - * - Live: valid key/value are set - * - Loading: loading is pending + * Creates a copy of this reference for the given entry. * - * - * Invalid: - * - Expired: time expired (key/value may still be set) - * - Collected: key/value was partially collected, but not yet cleaned up - * - Unset: marked as unset, awaiting cleanup or reuse + * `value` may be null only for a loading reference. */ - private interface ReferenceEntry { - /** - * Returns the value reference from this entry. - */ - /** - * Sets the value reference for this entry. - */ - var valueReference: ValueReference? - get() = throw UnsupportedOperationException() - set(_) = throw UnsupportedOperationException() - - /** - * Returns the next entry in the chain. - */ - val next: ReferenceEntry? - get() = throw UnsupportedOperationException() - - /** - * Returns the entry's hash. - */ - val hash: Int - get() = throw UnsupportedOperationException() - - /** - * Returns the key for this entry. - */ - val key: K - get() = throw UnsupportedOperationException() - /* - * Used by entries that use access order. Access entries are maintained in a doubly-linked list. - * New entries are added at the tail of the list at write time; stale entries are expired from - * the head of the list. - */ - /** - * Returns the time that this entry was last accessed, in ns. - */ - /** - * Sets the entry access time in ns. - */ - var accessTime: Long - get() = throw UnsupportedOperationException() - set(_) = throw UnsupportedOperationException() - /** - * Returns the next entry in the access queue. - */ - /** - * Sets the next entry in the access queue. - */ - var nextInAccessQueue: ReferenceEntry - get() = throw UnsupportedOperationException() - set(_) = throw UnsupportedOperationException() - /** - * Returns the previous entry in the access queue. - */ - /** - * Sets the previous entry in the access queue. - */ - var previousInAccessQueue: ReferenceEntry - get() = throw UnsupportedOperationException() - set(_) = throw UnsupportedOperationException() - /* - * Implemented by entries that use write order. Write entries are maintained in a - * doubly-linked list. New entries are added at the tail of the list at write time and stale - * entries are expired from the head of the list. - */ - /** - * Returns the time that this entry was last written, in ns. - */ - /** - * Sets the entry write time in ns. - */ - var writeTime: Long - get() = throw UnsupportedOperationException() - set(_) = throw UnsupportedOperationException() - /** - * Returns the next entry in the write queue. - */ - /** - * Sets the next entry in the write queue. - */ - var nextInWriteQueue: ReferenceEntry - get() = throw UnsupportedOperationException() - set(_) = throw UnsupportedOperationException() - /** - * Returns the previous entry in the write queue. - */ - /** - * Sets the previous entry in the write queue. - */ - var previousInWriteQueue: ReferenceEntry - get() = throw UnsupportedOperationException() - set(_) = throw UnsupportedOperationException() - } - - private object NullEntry : ReferenceEntry { - override var valueReference: ValueReference? - get() = null - set(_) {} - - override val next: ReferenceEntry? - get() = null - - override val hash: Int - get() = 0 - - override val key: Any - get() = Unit - - override var accessTime: Long - get() = 0 - set(_) {} - - override var nextInAccessQueue: ReferenceEntry - get() = this - set(_) {} - - override var previousInAccessQueue: ReferenceEntry - get() = this - set(_) {} - - override var writeTime: Long - get() = 0 - set(_) {} + fun copyFor(value: V?, entry: ReferenceEntry?): ValueReference - override var nextInWriteQueue: ReferenceEntry - get() = this - set(_) {} + /** + * Notifify pending loads that a new value was set. This is only relevant to loading value + * references. + */ + fun notifyNewValue(newValue: V) - override var previousInWriteQueue: ReferenceEntry - get() = this - set(_) {} - } + /** + * Returns true if this reference contains an active value, meaning one that is still considered + * present in the cache. Active values consist of live values, which are returned by cache + * lookups, and dead values, which have been evicted but awaiting removal. Non-active values + * consist strictly of loading values, though during refresh a value may be both active and + * loading. + */ + val isActive: Boolean + } + + /** + * An entry in a reference map. + * + * Entries in the map can be in the following states: + * + * Valid: + * - Live: valid key/value are set + * - Loading: loading is pending + * + * Invalid: + * - Expired: time expired (key/value may still be set) + * - Collected: key/value was partially collected, but not yet cleaned up + * - Unset: marked as unset, awaiting cleanup or reuse + */ + private interface ReferenceEntry { + /** Returns the value reference from this entry. */ + /** Sets the value reference for this entry. */ + var valueReference: ValueReference? + get() = throw UnsupportedOperationException() + set(_) = throw UnsupportedOperationException() + + /** Returns the next entry in the chain. */ + val next: ReferenceEntry? + get() = throw UnsupportedOperationException() + + /** Returns the entry's hash. */ + val hash: Int + get() = throw UnsupportedOperationException() + + /** Returns the key for this entry. */ + val key: K + get() = throw UnsupportedOperationException() /* - * Note: All of this duplicate code sucks, but it saves a lot of memory. If only Java had mixins! - * To maintain this code, make a change for the strong reference type. Then, cut and paste, and - * replace "Strong" with "Soft" or "Weak" within the pasted text. The primary difference is that - * strong entries store the key reference directly while soft and weak entries delegate to their - * respective superclasses. + * Used by entries that use access order. Access entries are maintained in a doubly-linked list. + * New entries are added at the tail of the list at write time; stale entries are expired from + * the head of the list. */ + /** Returns the time that this entry was last accessed, in ns. */ + /** Sets the entry access time in ns. */ + var accessTime: Long + get() = throw UnsupportedOperationException() + set(_) = throw UnsupportedOperationException() + + /** Returns the next entry in the access queue. */ + /** Sets the next entry in the access queue. */ + var nextInAccessQueue: ReferenceEntry + get() = throw UnsupportedOperationException() + set(_) = throw UnsupportedOperationException() + + /** Returns the previous entry in the access queue. */ + /** Sets the previous entry in the access queue. */ + var previousInAccessQueue: ReferenceEntry + get() = throw UnsupportedOperationException() + set(_) = throw UnsupportedOperationException() - /** - * Used for strongly-referenced keys. + /* + * Implemented by entries that use write order. Write entries are maintained in a + * doubly-linked list. New entries are added at the tail of the list at write time and stale + * entries are expired from the head of the list. */ - private open class StrongEntry( - override val key: K, // The code below is exactly the same for each entry type. - override val hash: Int, - override val next: ReferenceEntry?, - ) : ReferenceEntry { - private val _valueReference = atomic?>(unset()) - override var valueReference: ValueReference? = _valueReference.value - } + /** Returns the time that this entry was last written, in ns. */ + /** Sets the entry write time in ns. */ + var writeTime: Long + get() = throw UnsupportedOperationException() + set(_) = throw UnsupportedOperationException() + + /** Returns the next entry in the write queue. */ + /** Sets the next entry in the write queue. */ + var nextInWriteQueue: ReferenceEntry + get() = throw UnsupportedOperationException() + set(_) = throw UnsupportedOperationException() + + /** Returns the previous entry in the write queue. */ + /** Sets the previous entry in the write queue. */ + var previousInWriteQueue: ReferenceEntry + get() = throw UnsupportedOperationException() + set(_) = throw UnsupportedOperationException() + } + + private object NullEntry : ReferenceEntry { + override var valueReference: ValueReference? + get() = null + set(_) {} + + override val next: ReferenceEntry? + get() = null + + override val hash: Int + get() = 0 + + override val key: Any + get() = Unit + + override var accessTime: Long + get() = 0 + set(_) {} + + override var nextInAccessQueue: ReferenceEntry + get() = this + set(_) {} + + override var previousInAccessQueue: ReferenceEntry + get() = this + set(_) {} + + override var writeTime: Long + get() = 0 + set(_) {} + + override var nextInWriteQueue: ReferenceEntry + get() = this + set(_) {} + + override var previousInWriteQueue: ReferenceEntry + get() = this + set(_) {} + } + + /* + * Note: All of this duplicate code sucks, but it saves a lot of memory. If only Java had mixins! + * To maintain this code, make a change for the strong reference type. Then, cut and paste, and + * replace "Strong" with "Soft" or "Weak" within the pasted text. The primary difference is that + * strong entries store the key reference directly while soft and weak entries delegate to their + * respective superclasses. + */ + + /** Used for strongly-referenced keys. */ + private open class StrongEntry( + override val key: K, // The code below is exactly the same for each entry type. + override val hash: Int, + override val next: ReferenceEntry?, + ) : ReferenceEntry { + private val _valueReference = atomic?>(unset()) + override var valueReference: ValueReference? = _valueReference.value + } + + private class StrongAccessEntry( + key: K, + hash: Int, + next: ReferenceEntry?, + ) : StrongEntry(key, hash, next) { + // The code below is exactly the same for each access entry type. + + private val _accessTime = atomic(Long.MAX_VALUE) + override var accessTime = _accessTime.value - private class StrongAccessEntry( - key: K, - hash: Int, - next: ReferenceEntry?, - ) : - StrongEntry(key, hash, next) { - // The code below is exactly the same for each access entry type. + // Guarded By Segment.this + override var nextInAccessQueue: ReferenceEntry = nullEntry() - private val _accessTime = atomic(Long.MAX_VALUE) - override var accessTime = _accessTime.value + // Guarded By Segment.this + override var previousInAccessQueue: ReferenceEntry = nullEntry() + } - // Guarded By Segment.this - override var nextInAccessQueue: ReferenceEntry = nullEntry() + private class StrongWriteEntry(key: K, hash: Int, next: ReferenceEntry?) : + StrongEntry(key, hash, next) { + // The code below is exactly the same for each write entry type. + private val _writeTime = atomic(Long.MAX_VALUE) + override var writeTime = _writeTime.value - // Guarded By Segment.this - override var previousInAccessQueue: ReferenceEntry = nullEntry() - } + // Guarded By Segment.this + override var nextInWriteQueue: ReferenceEntry = nullEntry() - private class StrongWriteEntry( - key: K, - hash: Int, - next: ReferenceEntry?, - ) : - StrongEntry(key, hash, next) { - // The code below is exactly the same for each write entry type. - private val _writeTime = atomic(Long.MAX_VALUE) - override var writeTime = _writeTime.value + // Guarded By Segment.this + override var previousInWriteQueue: ReferenceEntry = nullEntry() + } + + private class StrongAccessWriteEntry( + key: K, + hash: Int, + next: ReferenceEntry?, + ) : StrongEntry(key, hash, next) { + // The code below is exactly the same for each access entry type. + private val _accessTime = atomic(Long.MAX_VALUE) + override var accessTime: Long = _accessTime.value - // Guarded By Segment.this - override var nextInWriteQueue: ReferenceEntry = nullEntry() + // Guarded By Segment.this + override var nextInAccessQueue: ReferenceEntry = nullEntry() - // Guarded By Segment.this - override var previousInWriteQueue: ReferenceEntry = nullEntry() - } + // Guarded By Segment.this + override var previousInAccessQueue: ReferenceEntry = nullEntry() - private class StrongAccessWriteEntry( - key: K, - hash: Int, - next: ReferenceEntry?, - ) : - StrongEntry(key, hash, next) { - // The code below is exactly the same for each access entry type. - private val _accessTime = atomic(Long.MAX_VALUE) - override var accessTime: Long = _accessTime.value + // The code below is exactly the same for each write entry type. + private val _writeTime = atomic(Long.MAX_VALUE) + override var writeTime: Long = _writeTime.value - // Guarded By Segment.this - override var nextInAccessQueue: ReferenceEntry = nullEntry() + // Guarded By Segment.this + override var nextInWriteQueue: ReferenceEntry = nullEntry() - // Guarded By Segment.this - override var previousInAccessQueue: ReferenceEntry = nullEntry() + // Guarded By Segment.this + override var previousInWriteQueue: ReferenceEntry = nullEntry() + } + + /** References a strong value. */ + private open class StrongValueReference(private val referent: V) : + ValueReference { + override fun get(): V = referent + + override val weight: Int = 1 + override val entry: ReferenceEntry? = null + + override fun copyFor(value: V?, entry: ReferenceEntry?): ValueReference = this + + override val isActive: Boolean = true + + override fun notifyNewValue(newValue: V) {} + } + + /** References a strong value. */ + private class WeightedStrongValueReference( + referent: V, + override val weight: Int, + ) : StrongValueReference(referent) + + /** This method is a convenience for testing. Code should call [Segment.newEntry] directly. */ + private fun newEntry(key: K, hash: Int, next: ReferenceEntry?): ReferenceEntry { + val segment = segmentFor(hash) + segment.reentrantLock.lock() + return try { + segment.newEntry(key, hash, next) + } finally { + segment.reentrantLock.unlock() + } + } + + /** This method is a convenience for testing. Code should call [Segment.copyEntry] directly. */ + // Guarded By Segment.this + private fun copyEntry( + original: ReferenceEntry, + newNext: ReferenceEntry?, + ): ReferenceEntry? { + val hash = original.hash + return segmentFor(hash).copyEntry(original, newNext) + } + + /** This method is a convenience for testing. Code should call [Segment.setValue] instead. */ + // Guarded By Segment.this + private fun newValueReference( + entry: ReferenceEntry, + value: V, + weight: Int, + ): ValueReference { + val hash = entry.hash + return valueStrength.referenceValue(segmentFor(hash), entry, value, weight) + } + + private fun hash(key: K): Int = rehash(key.hashCode()) + + /** + * Returns the segment that should be used for a key with the given hash. + * + * @param hash the hash code for the key + * @return the segment + */ + private fun segmentFor(hash: Int): Segment = + // TODO(fry): Lazily create segments? + segments[hash ushr segmentShift and segmentMask] as Segment + + private fun createSegment(initialCapacity: Int, maxSegmentWeight: Long): Segment = + Segment(this, initialCapacity, maxSegmentWeight) + + // expiration + + /** Returns true if the entry has expired. */ + private fun isExpired(entry: ReferenceEntry, now: Long): Boolean = + if (expiresAfterAccess && now - entry.accessTime >= expireAfterAccessNanos) { + true + } else { + expiresAfterWrite && now - entry.writeTime >= expireAfterWriteNanos + } - // The code below is exactly the same for each write entry type. - private val _writeTime = atomic(Long.MAX_VALUE) - override var writeTime: Long = _writeTime.value + // Inner Classes - // Guarded By Segment.this - override var nextInWriteQueue: ReferenceEntry = nullEntry() + private class SegmentTable(val size: Int) { + private val table: AtomicArray?> = atomicArrayOfNulls(size) - // Guarded By Segment.this - override var previousInWriteQueue: ReferenceEntry = nullEntry() + operator fun get(idx: Int) = table[idx].value + + operator fun set(idx: Int, value: ReferenceEntry?) { + table[idx].value = value } + } + + /** Segments are specialized versions of hash tables. */ + private class Segment( + private val map: LocalCache, + initialCapacity: Int, + private val maxSegmentWeight: Long, + ) { + /* + * TODO(fry): Consider copying variables (like evictsBySize) from outer class into this class. + * It will require more memory but will reduce indirection. + */ + /* + * Segments maintain a table of entry lists that are ALWAYS kept in a consistent state, so can + * be read without locking. Next fields of nodes are immutable (final). All list additions are + * performed at the front of each bin. This makes it easy to check changes, and also fast to + * traverse. When nodes would otherwise be changed, new nodes are created to replace them. This + * works well for hash tables since the bin lists tend to be short. (The average length is less + * than two.) + * + * Read operations can thus proceed without locking, but rely on selected uses of volatiles to + * ensure that completed write operations performed by other threads are noticed. For most + * purposes, the "count" field, tracking the number of elements, serves as that volatile + * variable ensuring visibility. This is convenient because this field needs to be read in many + * read operations anyway: + * + * - All (unsynchronized) read operations must first read the "count" field, and should not + * look at table entries if it is 0. + * + * - All (synchronized) write operations should write to the "count" field after structurally + * changing any bin. The operations must not take any action that could even momentarily + * cause a concurrent read operation to see inconsistent data. This is made easier by the + * nature of the read operations in Map. For example, no operation can reveal that the table + * has grown but the threshold has not yet been updated, so there are no atomicity requirements + * for this with respect to reads. + * + * As a guide, all critical volatile reads and writes to the count field are marked in code + * comments. + */ + + val reentrantLock = reentrantLock() + + /** The number of live elements in this segment's region. */ + private val count = atomic(0) + + /** The weight of the live elements in this segment's region. */ + private var totalWeight: Long = 0 /** - * References a strong value. + * Number of updates that alter the size of the table. This is used during bulk-read methods to + * make sure they see a consistent snapshot: If modCounts change during a traversal of segments + * loading size or checking containsValue, then we might have an inconsistent view of state so + * (usually) must retry. */ - private open class StrongValueReference(private val referent: V) : - ValueReference { - override fun get(): V = referent + private var modCount = 0 - override val weight: Int = 1 - override val entry: ReferenceEntry? = null + /** + * The table is expanded when its size exceeds this threshold. (The value of this field is + * always `(int) (capacity * 0.75)`.) + */ + private var threshold = 0 - override fun copyFor( - value: V?, - entry: ReferenceEntry?, - ): ValueReference = this + /** The per-segment table. */ + private val table: AtomicRef> - override val isActive: Boolean = true + /** + * The recency queue is used to record which entries were accessed for updating the access + * list's ordering. It is drained as a batch operation when either the DRAIN_THRESHOLD is + * crossed or a write occurs on the segment. + */ + private val recencyQueue: Queue> - override fun notifyNewValue(newValue: V) {} - } + /** + * A counter of the number of reads since the last write, used to drain queues on a small + * fraction of read operations. + */ + private val readCount = atomic(0) /** - * References a strong value. + * A queue of elements currently in the map, ordered by write time. Elements are added to the + * tail of the queue on write. */ - private class WeightedStrongValueReference( - referent: V, - override val weight: Int, - ) : - StrongValueReference(referent) + private val writeQueue: MutableQueue> /** - * This method is a convenience for testing. Code should call [Segment.newEntry] directly. + * A queue of elements currently in the map, ordered by access time. Elements are added to the + * tail of the queue on access (note that writes count as accesses). */ + private val accessQueue: MutableQueue> - private fun newEntry( - key: K, - hash: Int, - next: ReferenceEntry?, - ): ReferenceEntry { - val segment = segmentFor(hash) - segment.reentrantLock.lock() - return try { - segment.newEntry(key, hash, next) - } finally { - segment.reentrantLock.unlock() - } - } + fun newEntry(key: K, hash: Int, next: ReferenceEntry?): ReferenceEntry = + map.entryFactory.newEntry(this, key, hash, next) /** - * This method is a convenience for testing. Code should call [Segment.copyEntry] directly. + * Copies `original` into a new entry chained to `newNext`. Returns the new entry, or `null` if + * `original` was already garbage collected. */ - // Guarded By Segment.this - private fun copyEntry( - original: ReferenceEntry, - newNext: ReferenceEntry?, + fun copyEntry( + original: ReferenceEntry, + newNext: ReferenceEntry?, ): ReferenceEntry? { - val hash = original.hash - return segmentFor(hash).copyEntry(original, newNext) + val valueReference = original.valueReference + val value = valueReference!!.get() + if (value == null && valueReference.isActive) { + // value collected + return null + } + val newEntry = map.entryFactory.copyEntry(this, original, newNext) + newEntry.valueReference = valueReference.copyFor(value, newEntry) + return newEntry } - /** - * This method is a convenience for testing. Code should call [Segment.setValue] instead. - */ - // Guarded By Segment.this - private fun newValueReference( - entry: ReferenceEntry, - value: V, - weight: Int, - ): ValueReference { - val hash = entry.hash - return valueStrength.referenceValue(segmentFor(hash), entry, value, weight) + /** Sets a new value of an entry. Adds newly created entries at the end of the access queue. */ + fun setValue(entry: ReferenceEntry, key: K, value: V, now: Long) { + val previous = entry.valueReference + val weight = map.weigher(key, value) + if (weight < 0) throw IllegalStateException("Weights must be non-negative") + entry.valueReference = map.valueStrength.referenceValue(this, entry, value, weight) + recordWrite(entry, weight, now) + previous?.notifyNewValue(value) } - private fun hash(key: K): Int = rehash(key.hashCode()) + // recency queue, shared by expiration and eviction /** - * Returns the segment that should be used for a key with the given hash. + * Records the relative order in which this read was performed by adding `entry` to the recency + * queue. At write-time, or when the queue is full past the threshold, the queue will be drained + * and the entries therein processed. * - * @param hash the hash code for the key - * @return the segment + * Note: locked reads should use [.recordLockedRead]. */ - private fun segmentFor(hash: Int): Segment = - // TODO(fry): Lazily create segments? - segments[hash ushr segmentShift and segmentMask] as Segment - - private fun createSegment( - initialCapacity: Int, - maxSegmentWeight: Long, - ): Segment = Segment(this, initialCapacity, maxSegmentWeight) - // expiration + private fun recordRead(entry: ReferenceEntry, now: Long) { + if (map.recordsAccess) { + entry.accessTime = now + } + recencyQueue.add(entry) + } /** - * Returns true if the entry has expired. + * Updates the eviction metadata that `entry` was just read. This currently amounts to adding + * `entry` to relevant eviction lists. + * + * Note: this method should only be called under lock, as it directly manipulates the eviction + * queues. Unlocked reads should use [.recordRead]. */ - private fun isExpired( - entry: ReferenceEntry, - now: Long, - ): Boolean = - if (expiresAfterAccess && now - entry.accessTime >= expireAfterAccessNanos) { - true - } else { - expiresAfterWrite && now - entry.writeTime >= expireAfterWriteNanos - } - - // Inner Classes - - private class SegmentTable(val size: Int) { - private val table: AtomicArray?> = atomicArrayOfNulls(size) - - operator fun get(idx: Int) = table[idx].value - - operator fun set( - idx: Int, - value: ReferenceEntry?, - ) { - table[idx].value = value - } + private fun recordLockedRead(entry: ReferenceEntry, now: Long) { + if (map.recordsAccess) { + entry.accessTime = now + } + accessQueue.add(entry) } /** - * Segments are specialized versions of hash tables. + * Updates eviction metadata that `entry` was just written. This currently amounts to adding + * `entry` to relevant eviction lists. */ - private class Segment( - private val map: LocalCache, - initialCapacity: Int, - private val maxSegmentWeight: Long, - ) { - /* - * TODO(fry): Consider copying variables (like evictsBySize) from outer class into this class. - * It will require more memory but will reduce indirection. - */ - /* - * Segments maintain a table of entry lists that are ALWAYS kept in a consistent state, so can - * be read without locking. Next fields of nodes are immutable (final). All list additions are - * performed at the front of each bin. This makes it easy to check changes, and also fast to - * traverse. When nodes would otherwise be changed, new nodes are created to replace them. This - * works well for hash tables since the bin lists tend to be short. (The average length is less - * than two.) - * - * Read operations can thus proceed without locking, but rely on selected uses of volatiles to - * ensure that completed write operations performed by other threads are noticed. For most - * purposes, the "count" field, tracking the number of elements, serves as that volatile - * variable ensuring visibility. This is convenient because this field needs to be read in many - * read operations anyway: - * - * - All (unsynchronized) read operations must first read the "count" field, and should not - * look at table entries if it is 0. - * - * - All (synchronized) write operations should write to the "count" field after structurally - * changing any bin. The operations must not take any action that could even momentarily - * cause a concurrent read operation to see inconsistent data. This is made easier by the - * nature of the read operations in Map. For example, no operation can reveal that the table - * has grown but the threshold has not yet been updated, so there are no atomicity requirements - * for this with respect to reads. - * - * As a guide, all critical volatile reads and writes to the count field are marked in code - * comments. - */ - - val reentrantLock = reentrantLock() - - /** - * The number of live elements in this segment's region. - */ - private val count = atomic(0) - - /** - * The weight of the live elements in this segment's region. - */ - private var totalWeight: Long = 0 - - /** - * Number of updates that alter the size of the table. This is used during bulk-read methods to - * make sure they see a consistent snapshot: If modCounts change during a traversal of segments - * loading size or checking containsValue, then we might have an inconsistent view of state - * so (usually) must retry. - */ - private var modCount = 0 - - /** - * The table is expanded when its size exceeds this threshold. (The value of this field is - * always `(int) (capacity * 0.75)`.) - */ - private var threshold = 0 - - /** - * The per-segment table. - */ - private val table: AtomicRef> - - /** - * The recency queue is used to record which entries were accessed for updating the access - * list's ordering. It is drained as a batch operation when either the DRAIN_THRESHOLD is - * crossed or a write occurs on the segment. - */ - private val recencyQueue: Queue> - - /** - * A counter of the number of reads since the last write, used to drain queues on a small - * fraction of read operations. - */ - private val readCount = atomic(0) - - /** - * A queue of elements currently in the map, ordered by write time. Elements are added to the - * tail of the queue on write. - */ - private val writeQueue: MutableQueue> - - /** - * A queue of elements currently in the map, ordered by access time. Elements are added to the - * tail of the queue on access (note that writes count as accesses). - */ - private val accessQueue: MutableQueue> - - fun newEntry( - key: K, - hash: Int, - next: ReferenceEntry?, - ): ReferenceEntry = map.entryFactory.newEntry(this, key, hash, next) - - /** - * Copies `original` into a new entry chained to `newNext`. Returns the new entry, - * or `null` if `original` was already garbage collected. - */ - fun copyEntry( - original: ReferenceEntry, - newNext: ReferenceEntry?, - ): ReferenceEntry? { - val valueReference = original.valueReference - val value = valueReference!!.get() - if (value == null && valueReference.isActive) { - // value collected - return null - } - val newEntry = map.entryFactory.copyEntry(this, original, newNext) - newEntry.valueReference = valueReference.copyFor(value, newEntry) - return newEntry - } - - /** - * Sets a new value of an entry. Adds newly created entries at the end of the access queue. - */ - fun setValue( - entry: ReferenceEntry, - key: K, - value: V, - now: Long, - ) { - val previous = entry.valueReference - val weight = map.weigher(key, value) - if (weight < 0) throw IllegalStateException("Weights must be non-negative") - entry.valueReference = map.valueStrength.referenceValue(this, entry, value, weight) - recordWrite(entry, weight, now) - previous?.notifyNewValue(value) - } - - // recency queue, shared by expiration and eviction - - /** - * Records the relative order in which this read was performed by adding `entry` to the - * recency queue. At write-time, or when the queue is full past the threshold, the queue will - * be drained and the entries therein processed. - * - * - * - * Note: locked reads should use [.recordLockedRead]. - */ - private fun recordRead( - entry: ReferenceEntry, - now: Long, - ) { - if (map.recordsAccess) { - entry.accessTime = now - } - recencyQueue.add(entry) - } - - /** - * Updates the eviction metadata that `entry` was just read. This currently amounts to - * adding `entry` to relevant eviction lists. - * - * - * - * Note: this method should only be called under lock, as it directly manipulates the - * eviction queues. Unlocked reads should use [.recordRead]. - */ - private fun recordLockedRead( - entry: ReferenceEntry, - now: Long, - ) { - if (map.recordsAccess) { - entry.accessTime = now - } - accessQueue.add(entry) - } - - /** - * Updates eviction metadata that `entry` was just written. This currently amounts to - * adding `entry` to relevant eviction lists. - */ - private fun recordWrite( - entry: ReferenceEntry, - weight: Int, - now: Long, - ) { - // we are already under lock, so drain the recency queue immediately - drainRecencyQueue() - totalWeight += weight.toLong() - if (map.recordsAccess) { - entry.accessTime = now - } - if (map.recordsWrite) { - entry.writeTime = now - } - accessQueue.add(entry) - writeQueue.add(entry) - } + private fun recordWrite(entry: ReferenceEntry, weight: Int, now: Long) { + // we are already under lock, so drain the recency queue immediately + drainRecencyQueue() + totalWeight += weight.toLong() + if (map.recordsAccess) { + entry.accessTime = now + } + if (map.recordsWrite) { + entry.writeTime = now + } + accessQueue.add(entry) + writeQueue.add(entry) + } - /** - * Drains the recency queue, updating eviction metadata that the entries therein were read in - * the specified relative order. This currently amounts to adding them to relevant eviction - * lists (accounting for the fact that they could have been removed from the map since being - * added to the recency queue). - */ - private fun drainRecencyQueue() { - while (true) { - val e = recencyQueue.poll() ?: break - // An entry may be in the recency queue despite it being removed from - // the map . This can occur when the entry was concurrently read while a - // writer is removing it from the segment or after a clear has removed - // all of the segment's entries. - if (accessQueue.contains(e)) { - accessQueue.add(e) - } - } - } - // expiration - - /** - * Cleanup expired entries when the lock is available. - */ - private fun tryExpireEntries(now: Long) { - if (reentrantLock.tryLock()) { - try { - expireEntries(now) - } finally { - reentrantLock.unlock() - // don't call postWriteCleanup as we're in a read - } - } - } + /** + * Drains the recency queue, updating eviction metadata that the entries therein were read in + * the specified relative order. This currently amounts to adding them to relevant eviction + * lists (accounting for the fact that they could have been removed from the map since being + * added to the recency queue). + */ + private fun drainRecencyQueue() { + while (true) { + val e = recencyQueue.poll() ?: break + // An entry may be in the recency queue despite it being removed from + // the map . This can occur when the entry was concurrently read while a + // writer is removing it from the segment or after a clear has removed + // all of the segment's entries. + if (accessQueue.contains(e)) { + accessQueue.add(e) + } + } + } - private fun expireEntries(now: Long) { - drainRecencyQueue() - while (true) { - val e = writeQueue.peek()?.takeIf { map.isExpired(it, now) } ?: break - if (!removeEntry(e, e.hash, RemovalCause.EXPIRED)) { - throw AssertionError() - } - } + // expiration - while (true) { - val e = accessQueue.peek()?.takeIf { map.isExpired(it, now) } ?: break - if (!removeEntry(e, e.hash, RemovalCause.EXPIRED)) { - throw AssertionError() - } - } + /** Cleanup expired entries when the lock is available. */ + private fun tryExpireEntries(now: Long) { + if (reentrantLock.tryLock()) { + try { + expireEntries(now) + } finally { + reentrantLock.unlock() + // don't call postWriteCleanup as we're in a read } + } + } - // eviction - private fun enqueueNotification( - entry: ReferenceEntry, - cause: RemovalCause?, - ) { - enqueueNotification(entry.key, entry.hash, entry.valueReference, cause) + private fun expireEntries(now: Long) { + drainRecencyQueue() + while (true) { + val e = writeQueue.peek()?.takeIf { map.isExpired(it, now) } ?: break + if (!removeEntry(e, e.hash, RemovalCause.EXPIRED)) { + throw AssertionError() } + } - private fun enqueueNotification( - key: K?, - hash: Int, - valueReference: ValueReference?, - cause: RemovalCause?, - ) { - valueReference?.weight?.toLong()?.apply { - totalWeight -= this - } + while (true) { + val e = accessQueue.peek()?.takeIf { map.isExpired(it, now) } ?: break + if (!removeEntry(e, e.hash, RemovalCause.EXPIRED)) { + throw AssertionError() } + } + } - /** - * Performs eviction if the segment is over capacity. Avoids flushing the entire cache if the - * newest entry exceeds the maximum weight all on its own. - * - * @param newest the most recently added entry - */ - private fun evictEntries(newest: ReferenceEntry) { - if (!map.evictsBySize) { - return - } - drainRecencyQueue() + // eviction + private fun enqueueNotification(entry: ReferenceEntry, cause: RemovalCause?) { + enqueueNotification(entry.key, entry.hash, entry.valueReference, cause) + } - // If the newest entry by itself is too heavy for the segment, don't bother evicting - // anything else, just that - if (newest.valueReference!!.weight > maxSegmentWeight) { - if (!removeEntry(newest, newest.hash, RemovalCause.SIZE)) { - throw AssertionError() - } - } - while (totalWeight > maxSegmentWeight) { - val e = nextEvictable - if (!removeEntry(e, e.hash, RemovalCause.SIZE)) { - throw AssertionError() - } - } - } + private fun enqueueNotification( + key: K?, + hash: Int, + valueReference: ValueReference?, + cause: RemovalCause?, + ) { + valueReference?.weight?.toLong()?.apply { totalWeight -= this } + } - // TODO(fry): instead implement this with an eviction head + /** + * Performs eviction if the segment is over capacity. Avoids flushing the entire cache if the + * newest entry exceeds the maximum weight all on its own. + * + * @param newest the most recently added entry + */ + private fun evictEntries(newest: ReferenceEntry) { + if (!map.evictsBySize) { + return + } + drainRecencyQueue() + + // If the newest entry by itself is too heavy for the segment, don't bother evicting + // anything else, just that + if (newest.valueReference!!.weight > maxSegmentWeight) { + if (!removeEntry(newest, newest.hash, RemovalCause.SIZE)) { + throw AssertionError() + } + } + while (totalWeight > maxSegmentWeight) { + val e = nextEvictable + if (!removeEntry(e, e.hash, RemovalCause.SIZE)) { + throw AssertionError() + } + } + } - private val nextEvictable: ReferenceEntry - get() { - for (e in accessQueue) { - val weight = e.valueReference!!.weight - if (weight > 0) { - return e - } - } - throw AssertionError() - } + // TODO(fry): instead implement this with an eviction head - /** - * Returns first entry of bin for given hash. - */ - private fun getFirst(hash: Int): ReferenceEntry? { - // read this volatile field only once - val table = table.value - return table[hash and table.size - 1] + private val nextEvictable: ReferenceEntry + get() { + for (e in accessQueue) { + val weight = e.valueReference!!.weight + if (weight > 0) { + return e + } } + throw AssertionError() + } - // Specialized implementations of map methods - private fun getEntry( - key: K, - hash: Int, - ): ReferenceEntry? { - var e = getFirst(hash) - while (e != null) { - if (e.hash != hash) { - e = e.next - continue - } - val entryKey = e.key - if (key == entryKey) { - return e - } - e = e.next - } - return null - } + /** Returns first entry of bin for given hash. */ + private fun getFirst(hash: Int): ReferenceEntry? { + // read this volatile field only once + val table = table.value + return table[hash and table.size - 1] + } - private fun getLiveEntry( - key: K, - hash: Int, - now: Long, - ): ReferenceEntry? { - val e = getEntry(key, hash) - if (e == null) { - return null - } else if (map.isExpired(e, now)) { - tryExpireEntries(now) - return null - } - return e - } + // Specialized implementations of map methods + private fun getEntry(key: K, hash: Int): ReferenceEntry? { + var e = getFirst(hash) + while (e != null) { + if (e.hash != hash) { + e = e.next + continue + } + val entryKey = e.key + if (key == entryKey) { + return e + } + e = e.next + } + return null + } - /** - * Gets the value from an entry. Returns null if the entry is invalid, partially-collected, - * loading, or expired. - */ - - fun get( - key: K, - hash: Int, - ): V? { - return try { - if (count.value != 0) { // read-volatile - val now = map.ticker() - val e = getLiveEntry(key, hash, now) ?: return null - val value = e.valueReference?.get() - if (value != null) { - recordRead(e, now) - return value - } - } - null - } finally { - postReadCleanup() - } - } + private fun getLiveEntry(key: K, hash: Int, now: Long): ReferenceEntry? { + val e = getEntry(key, hash) + if (e == null) { + return null + } else if (map.isExpired(e, now)) { + tryExpireEntries(now) + return null + } + return e + } - fun getOrPut( - key: K, - hash: Int, - defaultValue: () -> V, - ): V { - reentrantLock.lock() - return try { - if (count.value != 0) { // read-volatile - val now = map.ticker() - val e = getLiveEntry(key, hash, now) - val value = e?.valueReference?.get() - if (value != null) { - recordRead(e, now) - return value - } - } - val default = defaultValue() - put(key, hash, default, false) - default - } finally { - reentrantLock.unlock() - postReadCleanup() - } - } + /** + * Gets the value from an entry. Returns null if the entry is invalid, partially-collected, + * loading, or expired. + */ + fun get(key: K, hash: Int): V? { + return try { + if (count.value != 0) { // read-volatile + val now = map.ticker() + val e = getLiveEntry(key, hash, now) ?: return null + val value = e.valueReference?.get() + if (value != null) { + recordRead(e, now) + return value + } + } + null + } finally { + postReadCleanup() + } + } - fun put( - key: K, - hash: Int, - value: V, - onlyIfAbsent: Boolean, - ): V? { - reentrantLock.lock() - return try { - val now = map.ticker() - preWriteCleanup(now) - if (count.value + 1 > threshold) { // ensure capacity - expand() - } - val table = table.value - val index = hash and table.size - 1 - val first = table[index] - - // Look for an existing entry. - var e: ReferenceEntry? = first - while (e != null) { - val entryKey = e.key - if (e.hash == hash && key == entryKey) { - // We found an existing entry. - val valueReference = e.valueReference - val entryValue = valueReference!!.get() - return when { - entryValue == null -> { - ++modCount - val newCount = - if (valueReference.isActive) { - enqueueNotification( - key, - hash, - valueReference, - RemovalCause.COLLECTED, - ) - setValue(e, key, value, now) - count.value // count remains unchanged - } else { - setValue(e, key, value, now) - count.value + 1 - } - count.value = newCount // write-volatile - evictEntries(e) - null - } - - onlyIfAbsent -> { - // Mimic - // "if (!map.containsKey(key)) ... - // else return map.get(key); - recordLockedRead(e, now) - entryValue - } - - else -> { - // clobber existing entry, count remains unchanged - ++modCount - enqueueNotification( - key, - hash, - valueReference, - RemovalCause.REPLACED, - ) - setValue(e, key, value, now) - evictEntries(e) - entryValue - } - } - } - e = e.next - } + fun getOrPut(key: K, hash: Int, defaultValue: () -> V): V { + reentrantLock.lock() + return try { + if (count.value != 0) { // read-volatile + val now = map.ticker() + val e = getLiveEntry(key, hash, now) + val value = e?.valueReference?.get() + if (value != null) { + recordRead(e, now) + return value + } + } + val default = defaultValue() + put(key, hash, default, false) + default + } finally { + reentrantLock.unlock() + postReadCleanup() + } + } - // Create a new entry. + fun put(key: K, hash: Int, value: V, onlyIfAbsent: Boolean): V? { + reentrantLock.lock() + return try { + val now = map.ticker() + preWriteCleanup(now) + if (count.value + 1 > threshold) { // ensure capacity + expand() + } + val table = table.value + val index = hash and table.size - 1 + val first = table[index] + + // Look for an existing entry. + var e: ReferenceEntry? = first + while (e != null) { + val entryKey = e.key + if (e.hash == hash && key == entryKey) { + // We found an existing entry. + val valueReference = e.valueReference + val entryValue = valueReference!!.get() + return when { + entryValue == null -> { ++modCount - val newEntry = newEntry(key, hash, first) - setValue(newEntry, key, value, now) - table[index] = newEntry - count.plusAssign(1) - evictEntries(newEntry) - null - } finally { - reentrantLock.unlock() - postWriteCleanup() - } - } - - fun remove( - key: K, - hash: Int, - ): V? { - reentrantLock.lock() - return try { - val now = map.ticker() - preWriteCleanup(now) - val table = table.value - val index = hash and table.size - 1 - val first = table[index] - var e = first - while (e != null) { - val entryKey = e.key - if (e.hash == hash && key == entryKey) { - val valueReference = e.valueReference - val entryValue = valueReference!!.get() - val cause: RemovalCause = - when { - entryValue != null -> { - RemovalCause.EXPLICIT - } - - valueReference.isActive -> { - RemovalCause.COLLECTED - } - - else -> { - // currently loading - return null - } - } - ++modCount - val newFirst = - removeValueFromChain( - first!!, - e, - entryKey, - hash, - valueReference, - cause, - ) - val newCount = count.value - 1 - table[index] = newFirst - count.value = newCount // write-volatile - return entryValue - } - e = e.next - } + val newCount = + if (valueReference.isActive) { + enqueueNotification(key, hash, valueReference, RemovalCause.COLLECTED) + setValue(e, key, value, now) + count.value // count remains unchanged + } else { + setValue(e, key, value, now) + count.value + 1 + } + count.value = newCount // write-volatile + evictEntries(e) null - } finally { - reentrantLock.unlock() - postWriteCleanup() - } - } + } + + onlyIfAbsent -> { + // Mimic + // "if (!map.containsKey(key)) ... + // else return map.get(key); + recordLockedRead(e, now) + entryValue + } + + else -> { + // clobber existing entry, count remains unchanged + ++modCount + enqueueNotification(key, hash, valueReference, RemovalCause.REPLACED) + setValue(e, key, value, now) + evictEntries(e) + entryValue + } + } + } + e = e.next + } + + // Create a new entry. + ++modCount + val newEntry = newEntry(key, hash, first) + setValue(newEntry, key, value, now) + table[index] = newEntry + count.plusAssign(1) + evictEntries(newEntry) + null + } finally { + reentrantLock.unlock() + postWriteCleanup() + } + } - fun clear() { - if (count.value != 0) { // read-volatile - reentrantLock.lock() - try { - val table = table.value - for (i in 0 until table.size) { - var e = table[i] - while (e != null) { - // Loading references aren't actually in the map yet. - if (e.valueReference!!.isActive) { - enqueueNotification(e, RemovalCause.EXPLICIT) - } - e = e.next - } - } - for (i in 0 until table.size) { - table[i] = null - } - writeQueue.clear() - accessQueue.clear() - readCount.value = 0 - ++modCount - count.value = 0 // write-volatile - } finally { - reentrantLock.unlock() - postWriteCleanup() + fun remove(key: K, hash: Int): V? { + reentrantLock.lock() + return try { + val now = map.ticker() + preWriteCleanup(now) + val table = table.value + val index = hash and table.size - 1 + val first = table[index] + var e = first + while (e != null) { + val entryKey = e.key + if (e.hash == hash && key == entryKey) { + val valueReference = e.valueReference + val entryValue = valueReference!!.get() + val cause: RemovalCause = + when { + entryValue != null -> { + RemovalCause.EXPLICIT } - } - } - - /** - * Expands the table if possible. - */ - private fun expand() { - val oldTable = table.value - val oldCapacity = oldTable.size - if (oldCapacity >= MAXIMUM_CAPACITY) { - return - } - /* - * Reclassify nodes in each list to new Map. Because we are using power-of-two expansion, the - * elements from each bin must either stay at same index, or move with a power of two offset. - * We eliminate unnecessary node creation by catching cases where old nodes can be reused - * because their next fields won't change. Statistically, at the default threshold, only - * about one-sixth of them need cloning when a table doubles. The nodes they replace will be - * garbage collectable as soon as they are no longer referenced by any reader thread that may - * be in the midst of traversing table right now. - */ - var newCount = count.value - val newTable = SegmentTable(oldCapacity shl 1) - threshold = newTable.size * 3 / 4 - val newMask = newTable.size - 1 - for (oldIndex in 0 until oldCapacity) { - // We need to guarantee that any existing reads of old Map can - // proceed. So we cannot yet null out each bin. - val head = oldTable[oldIndex] ?: continue - - val next = head.next - val headIndex = head.hash and newMask - - // Single node on list - if (next == null) { - newTable[headIndex] = head - } else { - // Reuse the consecutive sequence of nodes with the same target - // index from the end of the list. tail points to the first - // entry in the reusable list. - var tail = head - var tailIndex = headIndex - var entry = next - while (entry != null) { - val newIndex = entry.hash and newMask - if (newIndex != tailIndex) { - // The index changed. We'll need to copy the previous entry. - tailIndex = newIndex - tail = entry - } - entry = entry.next - } - newTable[tailIndex] = tail - - // Clone nodes leading up to the tail. - var headEntry = head - while (headEntry !== tail) { - val newIndex = headEntry.hash and newMask - val newNext = newTable[newIndex] - val newFirst = copyEntry(headEntry, newNext) - if (newFirst != null) { - newTable[newIndex] = newFirst - } else { - removeCollectedEntry(headEntry) - newCount-- - } - headEntry = headEntry.next ?: break - } + valueReference.isActive -> { + RemovalCause.COLLECTED } - } - table.value = newTable - count.value = newCount - } - - private fun removeValueFromChain( - first: ReferenceEntry, - entry: ReferenceEntry, - key: K, - hash: Int, - valueReference: ValueReference, - cause: RemovalCause?, - ): ReferenceEntry? { - enqueueNotification(key, hash, valueReference, cause) - writeQueue.remove(entry) - accessQueue.remove(entry) - return removeEntryFromChain(first, entry) - } - private fun removeEntryFromChain( - first: ReferenceEntry, - entry: ReferenceEntry, - ): ReferenceEntry? { - var newCount = count.value - var newFirst = entry.next - var e = first - while (e !== entry) { - val next = copyEntry(e, newFirst) - if (next != null) { - newFirst = next - } else { - removeCollectedEntry(e) - newCount-- + else -> { + // currently loading + return null } - e = e.next ?: break - } - count.value = newCount - return newFirst - } - - private fun removeCollectedEntry(entry: ReferenceEntry) { - enqueueNotification(entry, RemovalCause.COLLECTED) - writeQueue.remove(entry) - accessQueue.remove(entry) - } - - private fun removeEntry( - entry: ReferenceEntry, - hash: Int, - cause: RemovalCause?, - ): Boolean { - val table = table.value - val index = hash and table.size - 1 - val first = table[index] - var e = first + } + ++modCount + val newFirst = removeValueFromChain(first!!, e, entryKey, hash, valueReference, cause) + val newCount = count.value - 1 + table[index] = newFirst + count.value = newCount // write-volatile + return entryValue + } + e = e.next + } + null + } finally { + reentrantLock.unlock() + postWriteCleanup() + } + } + fun clear() { + if (count.value != 0) { // read-volatile + reentrantLock.lock() + try { + val table = table.value + for (i in 0 until table.size) { + var e = table[i] while (e != null) { - if (e === entry) { - ++modCount - val newFirst = - removeValueFromChain( - first!!, - e, - e.key, - hash, - e.valueReference!!, - cause, - ) - val newCount = count.value - 1 - table[index] = newFirst - count.value = newCount // write-volatile - return true - } - e = e.next - } - return false - } - - /** - * Performs routine cleanup following a read. Normally cleanup happens during writes. If cleanup - * is not observed after a sufficient number of reads, try cleaning up from the read thread. - */ - private fun postReadCleanup() { - if (readCount.incrementAndGet() and DRAIN_THRESHOLD == 0) { - cleanUp() - } + // Loading references aren't actually in the map yet. + if (e.valueReference!!.isActive) { + enqueueNotification(e, RemovalCause.EXPLICIT) + } + e = e.next + } + } + for (i in 0 until table.size) { + table[i] = null + } + writeQueue.clear() + accessQueue.clear() + readCount.value = 0 + ++modCount + count.value = 0 // write-volatile + } finally { + reentrantLock.unlock() + postWriteCleanup() } + } + } - /** - * Performs routine cleanup prior to executing a write. This should be called every time a - * write thread acquires the segment lock, immediately after acquiring the lock. - * - * - * - * Post-condition: expireEntries has been run. - */ - private fun preWriteCleanup(now: Long) { - runLockedCleanup(now) - } + /** Expands the table if possible. */ + private fun expand() { + val oldTable = table.value + val oldCapacity = oldTable.size + if (oldCapacity >= MAXIMUM_CAPACITY) { + return + } + + /* + * Reclassify nodes in each list to new Map. Because we are using power-of-two expansion, the + * elements from each bin must either stay at same index, or move with a power of two offset. + * We eliminate unnecessary node creation by catching cases where old nodes can be reused + * because their next fields won't change. Statistically, at the default threshold, only + * about one-sixth of them need cloning when a table doubles. The nodes they replace will be + * garbage collectable as soon as they are no longer referenced by any reader thread that may + * be in the midst of traversing table right now. + */ + var newCount = count.value + val newTable = SegmentTable(oldCapacity shl 1) + threshold = newTable.size * 3 / 4 + val newMask = newTable.size - 1 + for (oldIndex in 0 until oldCapacity) { + // We need to guarantee that any existing reads of old Map can + // proceed. So we cannot yet null out each bin. + val head = oldTable[oldIndex] ?: continue + + val next = head.next + val headIndex = head.hash and newMask + + // Single node on list + if (next == null) { + newTable[headIndex] = head + } else { + // Reuse the consecutive sequence of nodes with the same target + // index from the end of the list. tail points to the first + // entry in the reusable list. + var tail = head + var tailIndex = headIndex + var entry = next + while (entry != null) { + val newIndex = entry.hash and newMask + if (newIndex != tailIndex) { + // The index changed. We'll need to copy the previous entry. + tailIndex = newIndex + tail = entry + } + entry = entry.next + } + newTable[tailIndex] = tail + + // Clone nodes leading up to the tail. + var headEntry = head + while (headEntry !== tail) { + val newIndex = headEntry.hash and newMask + val newNext = newTable[newIndex] + val newFirst = copyEntry(headEntry, newNext) + if (newFirst != null) { + newTable[newIndex] = newFirst + } else { + removeCollectedEntry(headEntry) + newCount-- + } + headEntry = headEntry.next ?: break + } + } + } + table.value = newTable + count.value = newCount + } - /** - * Performs routine cleanup following a write. - */ - private fun postWriteCleanup() { - runUnlockedCleanup() - } + private fun removeValueFromChain( + first: ReferenceEntry, + entry: ReferenceEntry, + key: K, + hash: Int, + valueReference: ValueReference, + cause: RemovalCause?, + ): ReferenceEntry? { + enqueueNotification(key, hash, valueReference, cause) + writeQueue.remove(entry) + accessQueue.remove(entry) + return removeEntryFromChain(first, entry) + } - fun cleanUp() { - val now = map.ticker() - runLockedCleanup(now) - runUnlockedCleanup() + private fun removeEntryFromChain( + first: ReferenceEntry, + entry: ReferenceEntry, + ): ReferenceEntry? { + var newCount = count.value + var newFirst = entry.next + var e = first + while (e !== entry) { + val next = copyEntry(e, newFirst) + if (next != null) { + newFirst = next + } else { + removeCollectedEntry(e) + newCount-- } + e = e.next ?: break + } + count.value = newCount + return newFirst + } - private fun runLockedCleanup(now: Long) { - if (reentrantLock.tryLock()) { - try { - expireEntries(now) // calls drainRecencyQueue - readCount.value = 0 - } finally { - reentrantLock.unlock() - } - } - } + private fun removeCollectedEntry(entry: ReferenceEntry) { + enqueueNotification(entry, RemovalCause.COLLECTED) + writeQueue.remove(entry) + accessQueue.remove(entry) + } - private fun runUnlockedCleanup() { - // locked cleanup may generate notifications we can send unlocked - /*if (!isHeldByCurrentThread) { - map.processPendingNotifications() - }*/ - } + private fun removeEntry(entry: ReferenceEntry, hash: Int, cause: RemovalCause?): Boolean { + val table = table.value + val index = hash and table.size - 1 + val first = table[index] + var e = first + + while (e != null) { + if (e === entry) { + ++modCount + val newFirst = removeValueFromChain(first!!, e, e.key, hash, e.valueReference!!, cause) + val newCount = count.value - 1 + table[index] = newFirst + count.value = newCount // write-volatile + return true + } + e = e.next + } + return false + } - fun activeEntries(): Map { - // read-volatile - if (count.value == 0) return emptyMap() - reentrantLock.lock() - return try { - val activeMap = mutableMapOf() - val table = table.value - for (i in 0 until table.size) { - var e = table[i] - while (e != null) { - if (e.valueReference?.isActive == true) { - activeMap[e.key] = e.valueReference?.get()!! - } - e = e.next - } - } - activeMap.ifEmpty { emptyMap() } - } finally { - reentrantLock.unlock() - } - } + /** + * Performs routine cleanup following a read. Normally cleanup happens during writes. If cleanup + * is not observed after a sufficient number of reads, try cleaning up from the read thread. + */ + private fun postReadCleanup() { + if (readCount.incrementAndGet() and DRAIN_THRESHOLD == 0) { + cleanUp() + } + } - init { - threshold = initialCapacity * 3 / 4 // 0.75 - if (!map.customWeigher && threshold.toLong() == maxSegmentWeight) { - // prevent spurious expansion before eviction - threshold++ - } - table = atomic(SegmentTable(initialCapacity)) - recencyQueue = if (map.usesAccessQueue) AtomicLinkedQueue() else discardingQueue() - writeQueue = if (map.usesWriteQueue) WriteQueue() else discardingQueue() - accessQueue = if (map.usesAccessQueue) AccessQueue() else discardingQueue() - } + /** + * Performs routine cleanup prior to executing a write. This should be called every time a write + * thread acquires the segment lock, immediately after acquiring the lock. + * + * Post-condition: expireEntries has been run. + */ + private fun preWriteCleanup(now: Long) { + runLockedCleanup(now) } - // Queues - private interface Queue { - fun poll(): T? + /** Performs routine cleanup following a write. */ + private fun postWriteCleanup() { + runUnlockedCleanup() + } - fun add(value: T) + fun cleanUp() { + val now = map.ticker() + runLockedCleanup(now) + runUnlockedCleanup() } - private interface MutableQueue : Queue, Iterable { - fun peek(): E? + private fun runLockedCleanup(now: Long) { + if (reentrantLock.tryLock()) { + try { + expireEntries(now) // calls drainRecencyQueue + readCount.value = 0 + } finally { + reentrantLock.unlock() + } + } + } - fun isEmpty(): Boolean + private fun runUnlockedCleanup() { + // locked cleanup may generate notifications we can send unlocked + /*if (!isHeldByCurrentThread) { + map.processPendingNotifications() + }*/ + } - val size: Int + fun activeEntries(): Map { + // read-volatile + if (count.value == 0) return emptyMap() + reentrantLock.lock() + return try { + val activeMap = mutableMapOf() + val table = table.value + for (i in 0 until table.size) { + var e = table[i] + while (e != null) { + if (e.valueReference?.isActive == true) { + activeMap[e.key] = e.valueReference?.get()!! + } + e = e.next + } + } + activeMap.ifEmpty { emptyMap() } + } finally { + reentrantLock.unlock() + } + } - fun clear() + init { + threshold = initialCapacity * 3 / 4 // 0.75 + if (!map.customWeigher && threshold.toLong() == maxSegmentWeight) { + // prevent spurious expansion before eviction + threshold++ + } + table = atomic(SegmentTable(initialCapacity)) + recencyQueue = if (map.usesAccessQueue) AtomicLinkedQueue() else discardingQueue() + writeQueue = if (map.usesWriteQueue) WriteQueue() else discardingQueue() + accessQueue = if (map.usesAccessQueue) AccessQueue() else discardingQueue() + } + } - fun remove(element: E): Boolean + // Queues - fun contains(element: E): Boolean - } + private interface Queue { + fun poll(): T? - private class AtomicLinkedQueue : Queue { - private val head: AtomicRef> = atomic(Node(null)) - private val tail: AtomicRef> = atomic(head.value) + fun add(value: T) + } - private class Node(val value: T) { - val next = atomic?>(null) - } + private interface MutableQueue : Queue, Iterable { + fun peek(): E? - override fun add(value: T) { - val node: Node = Node(value) - tail.loop { curTail -> - val curNext = curTail.next.value - if (curNext != null) { - tail.compareAndSet(curTail, curNext) - return@loop - } - if (curTail.next.compareAndSet(null, node)) { - tail.compareAndSet(curTail, node) - return - } - } - } + fun isEmpty(): Boolean - override fun poll(): T? { - head.loop { curHead -> - val next = curHead.next.value ?: return null - if (head.compareAndSet(curHead, next)) return next.value - } - } - } + val size: Int - /** - * A custom queue for managing eviction order. Note that this is tightly integrated with `ReferenceEntry`, upon which it relies to perform its linking. - * - * - * - * Note that this entire implementation makes the assumption that all elements which are in - * the map are also in this queue, and that all elements not in the queue are not in the map. - * - * - * - * The benefits of creating our own queue are that (1) we can replace elements in the middle - * of the queue as part of copyWriteEntry, and (2) the contains method is highly optimized - * for the current model. - */ + fun clear() - private class WriteQueue : MutableQueue> { - private val head: ReferenceEntry = - object : ReferenceEntry { - override var writeTime: Long - get() = Long.MAX_VALUE - set(_) {} - override var nextInWriteQueue: ReferenceEntry = this - override var previousInWriteQueue: ReferenceEntry = this - } + fun remove(element: E): Boolean - // implements Queue - override fun add(value: ReferenceEntry) { - // unlink - connectWriteOrder(value.previousInWriteQueue, value.nextInWriteQueue) + fun contains(element: E): Boolean + } - // add to tail - connectWriteOrder(head.previousInWriteQueue, value) - connectWriteOrder(value, head) - } + private class AtomicLinkedQueue : Queue { + private val head: AtomicRef> = atomic(Node(null)) + private val tail: AtomicRef> = atomic(head.value) - override fun peek(): ReferenceEntry? { - val next = head.nextInWriteQueue - return if (next === head) null else next - } + private class Node(val value: T) { + val next = atomic?>(null) + } - override fun poll(): ReferenceEntry? { - val next = head.nextInWriteQueue - if (next === head) { - return null - } - remove(next) - return next + override fun add(value: T) { + val node: Node = Node(value) + tail.loop { curTail -> + val curNext = curTail.next.value + if (curNext != null) { + tail.compareAndSet(curTail, curNext) + return@loop } - - override fun remove(element: ReferenceEntry): Boolean { - val previous = element.previousInWriteQueue - val next = element.nextInWriteQueue - connectWriteOrder(previous, next) - nullifyWriteOrder(element) - return next !== NullEntry + if (curTail.next.compareAndSet(null, node)) { + tail.compareAndSet(curTail, node) + return } + } + } - override fun contains(element: ReferenceEntry): Boolean = element.nextInWriteQueue !== NullEntry - - override fun isEmpty(): Boolean = head.nextInWriteQueue === head + override fun poll(): T? { + head.loop { curHead -> + val next = curHead.next.value ?: return null + if (head.compareAndSet(curHead, next)) return next.value + } + } + } + + /** + * A custom queue for managing eviction order. Note that this is tightly integrated with + * `ReferenceEntry`, upon which it relies to perform its linking. + * + * Note that this entire implementation makes the assumption that all elements which are in the + * map are also in this queue, and that all elements not in the queue are not in the map. + * + * The benefits of creating our own queue are that (1) we can replace elements in the middle of + * the queue as part of copyWriteEntry, and (2) the contains method is highly optimized for the + * current model. + */ + private class WriteQueue : MutableQueue> { + private val head: ReferenceEntry = + object : ReferenceEntry { + override var writeTime: Long + get() = Long.MAX_VALUE + set(_) {} - override val size: Int - get() { - var size = 0 - var e = head.nextInWriteQueue - while (e !== head) { - size++ - e = e.nextInWriteQueue - } - return size - } + override var nextInWriteQueue: ReferenceEntry = this + override var previousInWriteQueue: ReferenceEntry = this + } - override fun clear() { - var e = head.nextInWriteQueue - while (e !== head) { - val next = e.nextInWriteQueue - nullifyWriteOrder(e) - e = next - } - head.nextInWriteQueue = head - head.previousInWriteQueue = head - } + // implements Queue + override fun add(value: ReferenceEntry) { + // unlink + connectWriteOrder(value.previousInWriteQueue, value.nextInWriteQueue) - override fun iterator(): Iterator> = - iterator { - var value = peek() - while (value != null) { - yield(value) - val next = value.nextInWriteQueue - value = if (next === head) null else next - } - } + // add to tail + connectWriteOrder(head.previousInWriteQueue, value) + connectWriteOrder(value, head) } - /** - * A custom queue for managing access order. Note that this is tightly integrated with - * `ReferenceEntry`, upon which it reliese to perform its linking. - * - * - * - * Note that this entire implementation makes the assumption that all elements which are in - * the map are also in this queue, and that all elements not in the queue are not in the map. - * - * - * - * The benefits of creating our own queue are that (1) we can replace elements in the middle - * of the queue as part of copyWriteEntry, and (2) the contains method is highly optimized - * for the current model. - */ - private class AccessQueue : MutableQueue> { - private val head: ReferenceEntry = - object : ReferenceEntry { - override var accessTime: Long - get() = Long.MAX_VALUE - set(_) {} - override var nextInAccessQueue: ReferenceEntry = this - override var previousInAccessQueue: ReferenceEntry = this - } + override fun peek(): ReferenceEntry? { + val next = head.nextInWriteQueue + return if (next === head) null else next + } - // implements Queue - override fun add(value: ReferenceEntry) { - // unlink - connectAccessOrder(value.previousInAccessQueue, value.nextInAccessQueue) + override fun poll(): ReferenceEntry? { + val next = head.nextInWriteQueue + if (next === head) { + return null + } + remove(next) + return next + } - // add to tail - connectAccessOrder(head.previousInAccessQueue, value) - connectAccessOrder(value, head) - } + override fun remove(element: ReferenceEntry): Boolean { + val previous = element.previousInWriteQueue + val next = element.nextInWriteQueue + connectWriteOrder(previous, next) + nullifyWriteOrder(element) + return next !== NullEntry + } - override fun peek(): ReferenceEntry? { - val next = head.nextInAccessQueue - return if (next === head) null else next - } + override fun contains(element: ReferenceEntry): Boolean = + element.nextInWriteQueue !== NullEntry + + override fun isEmpty(): Boolean = head.nextInWriteQueue === head + + override val size: Int + get() { + var size = 0 + var e = head.nextInWriteQueue + while (e !== head) { + size++ + e = e.nextInWriteQueue + } + return size + } + + override fun clear() { + var e = head.nextInWriteQueue + while (e !== head) { + val next = e.nextInWriteQueue + nullifyWriteOrder(e) + e = next + } + head.nextInWriteQueue = head + head.previousInWriteQueue = head + } - override fun poll(): ReferenceEntry? { - val next = head.nextInAccessQueue - if (next === head) { - return null - } - remove(next) - return next - } + override fun iterator(): Iterator> = iterator { + var value = peek() + while (value != null) { + yield(value) + val next = value.nextInWriteQueue + value = if (next === head) null else next + } + } + } + + /** + * A custom queue for managing access order. Note that this is tightly integrated with + * `ReferenceEntry`, upon which it reliese to perform its linking. + * + * Note that this entire implementation makes the assumption that all elements which are in the + * map are also in this queue, and that all elements not in the queue are not in the map. + * + * The benefits of creating our own queue are that (1) we can replace elements in the middle of + * the queue as part of copyWriteEntry, and (2) the contains method is highly optimized for the + * current model. + */ + private class AccessQueue : MutableQueue> { + private val head: ReferenceEntry = + object : ReferenceEntry { + override var accessTime: Long + get() = Long.MAX_VALUE + set(_) {} - override fun remove(element: ReferenceEntry): Boolean { - val previous = element.previousInAccessQueue - val next = element.nextInAccessQueue - connectAccessOrder(previous, next) - nullifyAccessOrder(element) - return next !== NullEntry - } + override var nextInAccessQueue: ReferenceEntry = this + override var previousInAccessQueue: ReferenceEntry = this + } - override fun contains(element: ReferenceEntry): Boolean = element.nextInAccessQueue !== NullEntry + // implements Queue + override fun add(value: ReferenceEntry) { + // unlink + connectAccessOrder(value.previousInAccessQueue, value.nextInAccessQueue) - override fun isEmpty(): Boolean = head.nextInAccessQueue === head + // add to tail + connectAccessOrder(head.previousInAccessQueue, value) + connectAccessOrder(value, head) + } - override val size: Int - get() { - var size = 0 - var e = head.nextInAccessQueue - while (e !== head) { - size++ - e = e.nextInAccessQueue - } - return size - } + override fun peek(): ReferenceEntry? { + val next = head.nextInAccessQueue + return if (next === head) null else next + } - override fun clear() { - var e = head.nextInAccessQueue - while (e !== head) { - val next = e.nextInAccessQueue - nullifyAccessOrder(e) - e = next - } - head.nextInAccessQueue = head - head.previousInAccessQueue = head - } + override fun poll(): ReferenceEntry? { + val next = head.nextInAccessQueue + if (next === head) { + return null + } + remove(next) + return next + } - override fun iterator(): Iterator> = - iterator { - var value = peek() - while (value != null) { - yield(value) - val next = value.nextInAccessQueue - value = if (next === head) null else next - } - } + override fun remove(element: ReferenceEntry): Boolean { + val previous = element.previousInAccessQueue + val next = element.nextInAccessQueue + connectAccessOrder(previous, next) + nullifyAccessOrder(element) + return next !== NullEntry } - // Cache support - fun cleanUp() { - for (segment in segments) { - segment?.cleanUp() - } + override fun contains(element: ReferenceEntry): Boolean = + element.nextInAccessQueue !== NullEntry + + override fun isEmpty(): Boolean = head.nextInAccessQueue === head + + override val size: Int + get() { + var size = 0 + var e = head.nextInAccessQueue + while (e !== head) { + size++ + e = e.nextInAccessQueue + } + return size + } + + override fun clear() { + var e = head.nextInAccessQueue + while (e !== head) { + val next = e.nextInAccessQueue + nullifyAccessOrder(e) + e = next + } + head.nextInAccessQueue = head + head.previousInAccessQueue = head } - // ConcurrentMap methods - fun getIfPresent(key: K): V? { - val hash = hash(key) - return segmentFor(hash).get(key, hash) + override fun iterator(): Iterator> = iterator { + var value = peek() + while (value != null) { + yield(value) + val next = value.nextInAccessQueue + value = if (next === head) null else next + } } + } - fun put( - key: K, - value: V, - ): V? { - val hash = hash(key) - return segmentFor(hash).put(key, hash, value, false) + // Cache support + fun cleanUp() { + for (segment in segments) { + segment?.cleanUp() + } + } + + // ConcurrentMap methods + fun getIfPresent(key: K): V? { + val hash = hash(key) + return segmentFor(hash).get(key, hash) + } + + fun put(key: K, value: V): V? { + val hash = hash(key) + return segmentFor(hash).put(key, hash, value, false) + } + + fun getOrPut(key: K, defaultValue: () -> V): V { + val hash = hash(key) + return segmentFor(hash).getOrPut(key, hash, defaultValue) + } + + fun clear() { + for (segment in segments) { + segment?.clear() + } + } + + fun remove(key: K): V? { + val hash = hash(key) + return segmentFor(hash).remove(key, hash) + } + + fun getAllPresent(): Map { + return buildMap { + for (segment in segments) { + segment?.let { putAll(it.activeEntries()) } + } } + } - fun getOrPut( - key: K, - defaultValue: () -> V, - ): V { - val hash = hash(key) - return segmentFor(hash).getOrPut(key, hash, defaultValue) + // Serialization Support + internal class LocalManualCache + private constructor(private val localCache: LocalCache) : Cache { + constructor(builder: CacheBuilder) : this(LocalCache(builder)) + + // Cache methods + override fun getIfPresent(key: K): V? { + return localCache.getIfPresent(key) } - fun clear() { - for (segment in segments) { - segment?.clear() - } + override fun put(key: K, value: V) { + localCache.put(key, value) } - fun remove(key: K): V? { - val hash = hash(key) - return segmentFor(hash).remove(key, hash) + override fun invalidate(key: K) { + localCache.remove(key) } - fun getAllPresent(): Map { - return buildMap { - for (segment in segments) { - segment?.let { putAll(it.activeEntries()) } - } - } + override fun getOrPut(key: K, valueProducer: () -> V): V { + return localCache.getOrPut(key, valueProducer) } - // Serialization Support - internal class LocalManualCache private constructor(private val localCache: LocalCache) : - Cache { - constructor(builder: CacheBuilder) : this(LocalCache(builder)) + override fun getAllPresent(keys: List<*>): Map { + return localCache.getAllPresent().filterKeys { it in keys } + } - // Cache methods - override fun getIfPresent(key: K): V? { - return localCache.getIfPresent(key) - } + override fun getAllPresent(): Map { + return localCache.getAllPresent() + } - override fun put( - key: K, - value: V, - ) { - localCache.put(key, value) - } + override fun invalidateAll(keys: List) { + TODO("Not yet implemented") + } - override fun invalidate(key: K) { - localCache.remove(key) - } + override fun putAll(map: Map) { + TODO("Not yet implemented") + } - override fun getOrPut( - key: K, - valueProducer: () -> V, - ): V { - return localCache.getOrPut(key, valueProducer) - } + override fun invalidateAll() { + localCache.clear() + } - override fun getAllPresent(keys: List<*>): Map { - return localCache.getAllPresent().filterKeys { it in keys } - } + override fun size(): Long { + TODO("Not yet implemented") + } + } - override fun getAllPresent(): Map { - return localCache.getAllPresent() - } + companion object { + /* + * The basic strategy is to subdivide the table among Segments, each of which itself is a + * concurrently readable hash table. The map supports non-blocking reads and concurrent writes + * across different segments. + * + * If a maximum size is specified, a best-effort bounding is performed per segment, using a + * page-replacement algorithm to determine which entries to evict when the capacity has been + * exceeded. + * + * The page replacement algorithm's data structures are kept casually consistent with the map. The + * ordering of writes to a segment is sequentially consistent. An update to the map and recording + * of reads may not be immediately reflected on the algorithm's data structures. These structures + * are guarded by a lock and operations are applied in batches to avoid lock contention. The + * penalty of applying the batches is spread across threads so that the amortized cost is slightly + * higher than performing just the operation without enforcing the capacity constraint. + * + * This implementation uses a per-segment queue to record a memento of the additions, removals, + * and accesses that were performed on the map. The queue is drained on writes and when it exceeds + * its capacity threshold. + * + * The Least Recently Used page replacement algorithm was chosen due to its simplicity, high hit + * rate, and ability to be implemented with O(1) time complexity. The initial LRU implementation + * operates per-segment rather than globally for increased implementation simplicity. We expect + * the cache hit rate to be similar to that of a global LRU algorithm. + */ + // Constants + private val OneWeigher: Weigher = { _, _ -> 1 } - override fun invalidateAll(keys: List) { - TODO("Not yet implemented") - } + /** + * The maximum capacity, used if a higher value is implicitly specified by either of the + * constructors with arguments. MUST be a power of two <= 1<<30 to ensure that entries are + * indexable using ints. + */ + const val MAXIMUM_CAPACITY = 1 shl 30 - override fun putAll(map: Map) { - TODO("Not yet implemented") - } + /** The maximum number of segments to allow; used to bound constructor arguments. */ + const val MAX_SEGMENTS = 1 shl 16 // slightly conservative - override fun invalidateAll() { - localCache.clear() - } + /** + * Number of cache access operations that can be buffered per segment before the cache's recency + * ordering information is updated. This is used to avoid lock contention by recording a memento + * of reads and delaying a lock acquisition until the threshold is crossed or a mutation occurs. + * + * This must be a (2^n)-1 as it is used as a mask. + */ + const val DRAIN_THRESHOLD = 0x3F - override fun size(): Long { - TODO("Not yet implemented") - } + /** Placeholder. Indicates that the value hasn't been set yet. */ + private val UNSET: ValueReference = + object : ValueReference { + override fun get(): Any? { + return null } - companion object { - /* - * The basic strategy is to subdivide the table among Segments, each of which itself is a - * concurrently readable hash table. The map supports non-blocking reads and concurrent writes - * across different segments. - * - * If a maximum size is specified, a best-effort bounding is performed per segment, using a - * page-replacement algorithm to determine which entries to evict when the capacity has been - * exceeded. - * - * The page replacement algorithm's data structures are kept casually consistent with the map. The - * ordering of writes to a segment is sequentially consistent. An update to the map and recording - * of reads may not be immediately reflected on the algorithm's data structures. These structures - * are guarded by a lock and operations are applied in batches to avoid lock contention. The - * penalty of applying the batches is spread across threads so that the amortized cost is slightly - * higher than performing just the operation without enforcing the capacity constraint. - * - * This implementation uses a per-segment queue to record a memento of the additions, removals, - * and accesses that were performed on the map. The queue is drained on writes and when it exceeds - * its capacity threshold. - * - * The Least Recently Used page replacement algorithm was chosen due to its simplicity, high hit - * rate, and ability to be implemented with O(1) time complexity. The initial LRU implementation - * operates per-segment rather than globally for increased implementation simplicity. We expect - * the cache hit rate to be similar to that of a global LRU algorithm. - */ - // Constants - private val OneWeigher: Weigher = { _, _ -> 1 } - - /** - * The maximum capacity, used if a higher value is implicitly specified by either of the - * constructors with arguments. MUST be a power of two <= 1<<30 to ensure that entries are - * indexable using ints. - */ - const val MAXIMUM_CAPACITY = 1 shl 30 - - /** - * The maximum number of segments to allow; used to bound constructor arguments. - */ - const val MAX_SEGMENTS = 1 shl 16 // slightly conservative - - /** - * Number of cache access operations that can be buffered per segment before the cache's recency - * ordering information is updated. This is used to avoid lock contention by recording a memento - * of reads and delaying a lock acquisition until the threshold is crossed or a mutation occurs. - * - * - * - * This must be a (2^n)-1 as it is used as a mask. - */ - const val DRAIN_THRESHOLD = 0x3F - - /** - * Placeholder. Indicates that the value hasn't been set yet. - */ - private val UNSET: ValueReference = - object : ValueReference { - override fun get(): Any? { - return null - } + override val weight: Int + get() = 0 - override val weight: Int - get() = 0 - override val entry: ReferenceEntry? - get() = null + override val entry: ReferenceEntry? + get() = null - override fun copyFor( - value: Any?, - entry: ReferenceEntry?, - ): ValueReference { - return this - } - - override val isActive: Boolean - get() = false - - override fun notifyNewValue(newValue: Any) {} - } - - /** - * Singleton placeholder that indicates a value is being loaded. - */ - @Suppress("UNCHECKED_CAST") - private fun unset() = UNSET as ValueReference - - @Suppress("UNCHECKED_CAST") - private fun nullEntry() = NullEntry as ReferenceEntry + override fun copyFor( + value: Any?, + entry: ReferenceEntry?, + ): ValueReference { + return this + } - private val DISCARDING_QUEUE: MutableQueue = - object : MutableQueue { - override fun add(value: Any) {} + override val isActive: Boolean + get() = false - override fun peek(): Any? = null + override fun notifyNewValue(newValue: Any) {} + } - override fun poll(): Any? = null + /** Singleton placeholder that indicates a value is being loaded. */ + @Suppress("UNCHECKED_CAST") + private fun unset() = UNSET as ValueReference - override fun iterator(): MutableIterator = HashSet().iterator() + @Suppress("UNCHECKED_CAST") + private fun nullEntry() = NullEntry as ReferenceEntry - override val size: Int = 0 + private val DISCARDING_QUEUE: MutableQueue = + object : MutableQueue { + override fun add(value: Any) {} - override fun isEmpty(): Boolean = true + override fun peek(): Any? = null - override fun clear() {} + override fun poll(): Any? = null - override fun remove(element: Any): Boolean = false + override fun iterator(): MutableIterator = HashSet().iterator() - override fun contains(element: Any): Boolean = false - } + override val size: Int = 0 - /** - * Queue that discards all elements. - */ - @Suppress("UNCHECKED_CAST") - private fun discardingQueue(): MutableQueue = DISCARDING_QUEUE as MutableQueue - - /** - * Applies a supplemental hash function to a given hash code, which defends against poor quality - * hash functions. This is critical when the concurrent hash map uses power-of-two length hash - * tables, that otherwise encounter collisions for hash codes that do not differ in lower or - * upper bits. - * - * @param hash hash code - */ - fun rehash(hash: Int): Int { - // Spread bits to regularize both segment and index locations, - // using variant of single-word Wang/Jenkins hash. - // TODO(kevinb): use Hashing/move this to Hashing? - var h = hash - h += h shl 15 xor -0x3283 - h = h xor (h ushr 10) - h += h shl 3 - h = h xor (h ushr 6) - h += (h shl 2) + (h shl 14) - return h xor (h ushr 16) - } + override fun isEmpty(): Boolean = true - // queues - // Guarded By Segment.this - private fun connectAccessOrder( - previous: ReferenceEntry, - next: ReferenceEntry, - ) { - previous.nextInAccessQueue = next - next.previousInAccessQueue = previous - } + override fun clear() {} - // Guarded By Segment.this - private fun nullifyAccessOrder(nulled: ReferenceEntry) { - val nullEntry: ReferenceEntry = nullEntry() - nulled.nextInAccessQueue = nullEntry - nulled.previousInAccessQueue = nullEntry - } + override fun remove(element: Any): Boolean = false - // Guarded By Segment.this - private fun connectWriteOrder( - previous: ReferenceEntry, - next: ReferenceEntry, - ) { - previous.nextInWriteQueue = next - next.previousInWriteQueue = previous - } + override fun contains(element: Any): Boolean = false + } - // Guarded By Segment.this - private fun nullifyWriteOrder(nulled: ReferenceEntry) { - val nullEntry: ReferenceEntry = nullEntry() - nulled.nextInWriteQueue = nullEntry - nulled.previousInWriteQueue = nullEntry - } - } + /** Queue that discards all elements. */ + @Suppress("UNCHECKED_CAST") + private fun discardingQueue(): MutableQueue = DISCARDING_QUEUE as MutableQueue /** - * Creates a new, empty map with the specified strategy, initial capacity and concurrency level. + * Applies a supplemental hash function to a given hash code, which defends against poor quality + * hash functions. This is critical when the concurrent hash map uses power-of-two length hash + * tables, that otherwise encounter collisions for hash codes that do not differ in lower or + * upper bits. + * + * @param hash hash code */ - init { - this.maxWeight = - when { - builder.expireAfterAccess == Duration.ZERO || builder.expireAfterWrite == Duration.ZERO -> 0L - builder.weigher != null -> builder.maximumWeight - else -> builder.maximumSize - } - this.weigher = builder.weigher ?: OneWeigher as Weigher + fun rehash(hash: Int): Int { + // Spread bits to regularize both segment and index locations, + // using variant of single-word Wang/Jenkins hash. + // TODO(kevinb): use Hashing/move this to Hashing? + var h = hash + h += h shl 15 xor -0x3283 + h = h xor (h ushr 10) + h += h shl 3 + h = h xor (h ushr 6) + h += (h shl 2) + (h shl 14) + return h xor (h ushr 16) + } - this.expireAfterAccessNanos = - (if (builder.expireAfterAccess == Duration.INFINITE) Duration.ZERO else builder.expireAfterAccess) - .inWholeNanoseconds + // queues + // Guarded By Segment.this + private fun connectAccessOrder( + previous: ReferenceEntry, + next: ReferenceEntry, + ) { + previous.nextInAccessQueue = next + next.previousInAccessQueue = previous + } - this.expireAfterWriteNanos = - (if (builder.expireAfterWrite == Duration.INFINITE) Duration.ZERO else builder.expireAfterWrite) - .inWholeNanoseconds + // Guarded By Segment.this + private fun nullifyAccessOrder(nulled: ReferenceEntry) { + val nullEntry: ReferenceEntry = nullEntry() + nulled.nextInAccessQueue = nullEntry + nulled.previousInAccessQueue = nullEntry + } - this.ticker = if (recordsTime) (builder.ticker ?: MonotonicTicker) else ({ 0L }) - this.entryFactory = EntryFactory.getFactory(usesAccessEntries, usesWriteEntries) - var initialCapacity = builder.initialCapacity.coerceAtMost(MAXIMUM_CAPACITY) - if (evictsBySize && !customWeigher) { - initialCapacity = min(initialCapacity, maxWeight.toInt()) - } - val concurrencyLevel = builder.concurrencyLevel.coerceAtMost(MAX_SEGMENTS) - // Find the lowest power-of-two segmentCount that exceeds concurrencyLevel, unless - // maximumSize/Weight is specified in which case ensure that each segment gets at least 10 - // entries. The special casing for size-based eviction is only necessary because that eviction - // happens per segment instead of globally, so too many segments compared to the maximum size - // will result in random eviction behavior. - var segmentShift = 0 - var segmentCount = 1 - while (segmentCount < concurrencyLevel && (!evictsBySize || segmentCount * 20 <= maxWeight)) { - ++segmentShift - segmentCount = segmentCount shl 1 - } - this.segmentShift = 32 - segmentShift - segmentMask = segmentCount - 1 - segments = arrayOfNulls(segmentCount) - var segmentCapacity = initialCapacity / segmentCount - if (segmentCapacity * segmentCount < initialCapacity) { - ++segmentCapacity - } - var segmentSize = 1 - while (segmentSize < segmentCapacity) { - segmentSize = segmentSize shl 1 - } - if (evictsBySize) { - // Ensure sum of segment max weights = overall max weights - var maxSegmentWeight = maxWeight / segmentCount + 1 - val remainder = maxWeight % segmentCount - for (i in segments.indices) { - if (i.toLong() == remainder) { - maxSegmentWeight-- - } - segments[i] = createSegment(segmentSize, maxSegmentWeight) - } - } else { - for (i in segments.indices) { - segments[i] = createSegment(segmentSize, -1) - } - } + // Guarded By Segment.this + private fun connectWriteOrder( + previous: ReferenceEntry, + next: ReferenceEntry, + ) { + previous.nextInWriteQueue = next + next.previousInWriteQueue = previous + } + + // Guarded By Segment.this + private fun nullifyWriteOrder(nulled: ReferenceEntry) { + val nullEntry: ReferenceEntry = nullEntry() + nulled.nextInWriteQueue = nullEntry + nulled.previousInWriteQueue = nullEntry + } + } + + /** + * Creates a new, empty map with the specified strategy, initial capacity and concurrency level. + */ + init { + this.maxWeight = + when { + builder.expireAfterAccess == Duration.ZERO || builder.expireAfterWrite == Duration.ZERO -> + 0L + builder.weigher != null -> builder.maximumWeight + else -> builder.maximumSize + } + this.weigher = builder.weigher ?: OneWeigher as Weigher + + this.expireAfterAccessNanos = + (if (builder.expireAfterAccess == Duration.INFINITE) Duration.ZERO + else builder.expireAfterAccess) + .inWholeNanoseconds + + this.expireAfterWriteNanos = + (if (builder.expireAfterWrite == Duration.INFINITE) Duration.ZERO + else builder.expireAfterWrite) + .inWholeNanoseconds + + this.ticker = if (recordsTime) (builder.ticker ?: MonotonicTicker) else ({ 0L }) + this.entryFactory = EntryFactory.getFactory(usesAccessEntries, usesWriteEntries) + var initialCapacity = builder.initialCapacity.coerceAtMost(MAXIMUM_CAPACITY) + if (evictsBySize && !customWeigher) { + initialCapacity = min(initialCapacity, maxWeight.toInt()) + } + val concurrencyLevel = builder.concurrencyLevel.coerceAtMost(MAX_SEGMENTS) + // Find the lowest power-of-two segmentCount that exceeds concurrencyLevel, unless + // maximumSize/Weight is specified in which case ensure that each segment gets at least 10 + // entries. The special casing for size-based eviction is only necessary because that eviction + // happens per segment instead of globally, so too many segments compared to the maximum size + // will result in random eviction behavior. + var segmentShift = 0 + var segmentCount = 1 + while (segmentCount < concurrencyLevel && (!evictsBySize || segmentCount * 20 <= maxWeight)) { + ++segmentShift + segmentCount = segmentCount shl 1 + } + this.segmentShift = 32 - segmentShift + segmentMask = segmentCount - 1 + segments = arrayOfNulls(segmentCount) + var segmentCapacity = initialCapacity / segmentCount + if (segmentCapacity * segmentCount < initialCapacity) { + ++segmentCapacity + } + var segmentSize = 1 + while (segmentSize < segmentCapacity) { + segmentSize = segmentSize shl 1 + } + if (evictsBySize) { + // Ensure sum of segment max weights = overall max weights + var maxSegmentWeight = maxWeight / segmentCount + 1 + val remainder = maxWeight % segmentCount + for (i in segments.indices) { + if (i.toLong() == remainder) { + maxSegmentWeight-- + } + segments[i] = createSegment(segmentSize, maxSegmentWeight) + } + } else { + for (i in segments.indices) { + segments[i] = createSegment(segmentSize, -1) + } } + } } diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MonotonicTicker.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MonotonicTicker.kt index 30e346448..9cb1eacb8 100644 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MonotonicTicker.kt +++ b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MonotonicTicker.kt @@ -4,4 +4,5 @@ import kotlin.time.ExperimentalTime import kotlin.time.TimeSource @OptIn(ExperimentalTime::class) -internal val MonotonicTicker: Ticker = TimeSource.Monotonic.markNow().let { timeMark -> { timeMark.elapsedNow().inWholeNanoseconds } } +internal val MonotonicTicker: Ticker = + TimeSource.Monotonic.markNow().let { timeMark -> { timeMark.elapsedNow().inWholeNanoseconds } } diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/RemovalCause.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/RemovalCause.kt index 2f9f209fa..f27eeca5d 100644 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/RemovalCause.kt +++ b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/RemovalCause.kt @@ -2,14 +2,16 @@ package org.mobilenativefoundation.store.cache5 /** * The reason why a cached entry was removed. - * @param wasEvicted True if entry removal was automatic due to eviction. That is, the cause of removal is neither [EXPLICIT] or [REPLACED]. + * + * @param wasEvicted True if entry removal was automatic due to eviction. That is, the cause of + * removal is neither [EXPLICIT] or [REPLACED]. * @author Charles Fry * @since 10.0 */ internal enum class RemovalCause(val wasEvicted: Boolean) { - EXPLICIT(false), - REPLACED(false), - COLLECTED(true), - EXPIRED(true), - SIZE(true), + EXPLICIT(false), + REPLACED(false), + COLLECTED(true), + EXPIRED(true), + SIZE(true), } 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 880b73042..e6af10300 100644 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt +++ b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt @@ -7,165 +7,166 @@ 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. + * 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(), +class StoreMultiCache< + Id : Any, + Key : StoreKey, + 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 val accessor = + StoreMultiCacheAccessor(singlesCache = singlesCache, collectionsCache = collectionsCache) - private fun Key.castSingle() = this as StoreKey.Single + private fun Key.castSingle() = this as StoreKey.Single - private fun Key.castCollection() = this as StoreKey.Collection + private fun Key.castCollection() = this as StoreKey.Collection - private fun StoreKey.Collection.cast() = this as Key + private fun StoreKey.Collection.cast() = this as Key - private fun StoreKey.Single.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 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 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 } - } - - 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 } - } - } + } + + is StoreKey.Collection<*> -> { + val collection = accessor.getCollection(key.castCollection()) as? Output + if (collection != null) { + collection + } else { + val producedCollection = valueProducer() + put(key, producedCollection) + producedCollection } + } - return map - } - - override fun getAllPresent(): Map { - return accessor.getAllPresent().mapKeys { (key, _) -> - when (key) { - is StoreKey.Collection -> key.cast() - is StoreKey.Single -> key.cast() - else -> throw UnsupportedOperationException(invalidKeyErrorMessage(key)) - } - } as Map - } - - override fun invalidateAll(keys: List) { - keys.forEach { key -> invalidate(key) } + 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 } + } - override fun invalidate(key: Key) { - when (key) { - is StoreKey.Single<*> -> accessor.invalidateSingle(key.castSingle()) - is StoreKey.Collection<*> -> accessor.invalidateCollection(key.castCollection()) + is StoreKey.Single -> { + val single = accessor.getSingle(key) + single?.let { map[key.cast()] = it as Output } } + } } - override fun putAll(map: Map) { - map.entries.forEach { (key, value) -> put(key, value) } + return map + } + + override fun getAllPresent(): Map { + return accessor.getAllPresent().mapKeys { (key, _) -> + when (key) { + is StoreKey.Collection -> key.cast() + is StoreKey.Single -> key.cast() + else -> throw UnsupportedOperationException(invalidKeyErrorMessage(key)) + } + } as 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 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) - } + } + + 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) + 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) - } - } - } + 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 invalidateAll() { + accessor.invalidateAll() + } - override fun size(): Long { - return accessor.size() - } + override fun size(): Long { + return accessor.size() + } - companion object { - fun invalidKeyErrorMessage(key: Any) = "Expected StoreKey.Single or StoreKey.Collection, but received ${key::class}" - } + 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 index d7a69e0b5..19e944ea9 100644 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor.kt +++ b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor.kt @@ -6,13 +6,13 @@ 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. + * 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. + * 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. @@ -20,163 +20,160 @@ 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>( - private val singlesCache: Cache, Single>, - private val collectionsCache: Cache, Collection>, +class StoreMultiCacheAccessor< + Id : Any, + Collection : StoreData.Collection, + Single : StoreData.Single, +>( + private val singlesCache: Cache, Single>, + private val collectionsCache: Cache, Collection>, ) : SynchronizedObject() { - private val keys = mutableSetOf>() + 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 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) - } + /** + * 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) } - /** - * Retrieves all items from the cache. - * - * This operation is thread-safe. - */ - fun getAllPresent(): Map, Any> = - synchronized(this) { - val result = mutableMapOf, Any>() - for (key in keys) { - when (key) { - is StoreKey.Single -> { - val single = singlesCache.getIfPresent(key) - if (single != null) { - result[key] = single - } - } + /** + * Retrieves all items from the cache. + * + * This operation is thread-safe. + */ + fun getAllPresent(): Map, Any> = + synchronized(this) { + val result = mutableMapOf, Any>() + for (key in keys) { + when (key) { + is StoreKey.Single -> { + val single = singlesCache.getIfPresent(key) + if (single != null) { + result[key] = single + } + } - is StoreKey.Collection -> { - val collection = collectionsCache.getIfPresent(key) - if (collection != null) { - result[key] = collection - } - } - } + is StoreKey.Collection -> { + val collection = collectionsCache.getIfPresent(key) + if (collection != null) { + result[key] = collection } - result + } } + } + result + } - /** - * 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 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) + /** + * 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 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 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) - } + /** + * 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++ - } - } + /** + * 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 - } - } - } + is StoreKey.Collection -> { + val collection = collectionsCache.getIfPresent(key) + if (collection != null) { + count += collection.items.size } - count + } } + } + count + } } diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Ticker.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Ticker.kt index 434cfc61f..10dc943ba 100644 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Ticker.kt +++ b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Ticker.kt @@ -1,6 +1,4 @@ package org.mobilenativefoundation.store.cache5 -/** - * @return Number of nanoseconds elapsed since the ticker's fixed point of reference. - */ +/** @return Number of nanoseconds elapsed since the ticker's fixed point of reference. */ typealias Ticker = () -> Long diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Weigher.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Weigher.kt index dbb73292e..59f2dd451 100644 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Weigher.kt +++ b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Weigher.kt @@ -1,6 +1,7 @@ package org.mobilenativefoundation.store.cache5 /** - * @return Weight of a cache entry. Must be non-negative. There is no unit for entry weights. Rather, they are simply relative to each other. + * @return Weight of a cache entry. Must be non-negative. There is no unit for entry weights. + * Rather, they are simply relative to each other. */ typealias Weigher = (key: Key, value: Value) -> Int diff --git a/cache/src/commonTest/kotlin/org/mobilenativefoundation/store/cache5/CacheTests.kt b/cache/src/commonTest/kotlin/org/mobilenativefoundation/store/cache5/CacheTests.kt index af1a11de5..5629c4041 100644 --- a/cache/src/commonTest/kotlin/org/mobilenativefoundation/store/cache5/CacheTests.kt +++ b/cache/src/commonTest/kotlin/org/mobilenativefoundation/store/cache5/CacheTests.kt @@ -1,108 +1,114 @@ package org.mobilenativefoundation.store.cache5 -import kotlinx.coroutines.test.runTest import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertEquals import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.test.runTest class CacheTests { - private val cache: Cache = CacheBuilder().build() - - @Test - fun getIfPresent() { - cache.put("key", "value") - assertEquals("value", cache.getIfPresent("key")) - } - - @Test - fun getOrPut() { - assertEquals("value", cache.getOrPut("key") { "value" }) - } - - @Test - fun getAllPresent() { - cache.put("key1", "value1") - cache.put("key2", "value2") - assertEquals(mapOf("key1" to "value1", "key2" to "value2"), cache.getAllPresent(listOf("key1", "key2"))) - assertEquals(mapOf("key1" to "value1", "key2" to "value2"), cache.getAllPresent()) - } - - @Ignore // Not implemented yet - @Test - fun putAll() { - cache.putAll(mapOf("key1" to "value1", "key2" to "value2")) - assertEquals(mapOf("key1" to "value1", "key2" to "value2"), cache.getAllPresent(listOf("key1", "key2"))) - } - - @Test - fun invalidate() { - cache.put("key", "value") - cache.invalidate("key") - assertEquals(null, cache.getIfPresent("key")) - } - - @Ignore // Not implemented yet - @Test - fun invalidateAll() { - cache.put("key1", "value1") - cache.put("key2", "value2") - cache.invalidateAll(listOf("key1", "key2")) - assertEquals(null, cache.getIfPresent("key1")) - assertEquals(null, cache.getIfPresent("key2")) - } - - @Ignore // Not implemented yet - @Test - fun size() { - cache.put("key1", "value1") - cache.put("key2", "value2") - assertEquals(2, cache.size()) - } - - @Test - fun maximumSize() { - val cache = CacheBuilder().maximumSize(1).build() - cache.put("key1", "value1") - cache.put("key2", "value2") - assertEquals(null, cache.getIfPresent("key1")) - assertEquals("value2", cache.getIfPresent("key2")) - } - - @Test - fun maximumWeight() { - val cache = CacheBuilder().weigher(399) { _, _ -> 100 }.build() - cache.put("key1", "value1") - cache.put("key2", "value2") - assertEquals(null, cache.getIfPresent("key1")) - assertEquals("value2", cache.getIfPresent("key2")) - } - - @Test - fun expireAfterAccess() = - runTest { - var timeNs = 0L - val cache = CacheBuilder().expireAfterAccess(100.milliseconds).ticker { timeNs }.build() - cache.put("key", "value") - - timeNs += 50.milliseconds.inWholeNanoseconds - assertEquals("value", cache.getIfPresent("key")) - - timeNs += 100.milliseconds.inWholeNanoseconds - assertEquals(null, cache.getIfPresent("key")) - } - - @Test - fun expireAfterWrite() = - runTest { - var timeNs = 0L - val cache = CacheBuilder().expireAfterWrite(100.milliseconds).ticker { timeNs }.build() - cache.put("key", "value") - - timeNs += 50.milliseconds.inWholeNanoseconds - assertEquals("value", cache.getIfPresent("key")) - - timeNs += 50.milliseconds.inWholeNanoseconds - assertEquals(null, cache.getIfPresent("key")) - } + private val cache: Cache = CacheBuilder().build() + + @Test + fun getIfPresent() { + cache.put("key", "value") + assertEquals("value", cache.getIfPresent("key")) + } + + @Test + fun getOrPut() { + assertEquals("value", cache.getOrPut("key") { "value" }) + } + + @Test + fun getAllPresent() { + cache.put("key1", "value1") + cache.put("key2", "value2") + assertEquals( + mapOf("key1" to "value1", "key2" to "value2"), + cache.getAllPresent(listOf("key1", "key2")), + ) + assertEquals(mapOf("key1" to "value1", "key2" to "value2"), cache.getAllPresent()) + } + + @Ignore // Not implemented yet + @Test + fun putAll() { + cache.putAll(mapOf("key1" to "value1", "key2" to "value2")) + assertEquals( + mapOf("key1" to "value1", "key2" to "value2"), + cache.getAllPresent(listOf("key1", "key2")), + ) + } + + @Test + fun invalidate() { + cache.put("key", "value") + cache.invalidate("key") + assertEquals(null, cache.getIfPresent("key")) + } + + @Ignore // Not implemented yet + @Test + fun invalidateAll() { + cache.put("key1", "value1") + cache.put("key2", "value2") + cache.invalidateAll(listOf("key1", "key2")) + assertEquals(null, cache.getIfPresent("key1")) + assertEquals(null, cache.getIfPresent("key2")) + } + + @Ignore // Not implemented yet + @Test + fun size() { + cache.put("key1", "value1") + cache.put("key2", "value2") + assertEquals(2, cache.size()) + } + + @Test + fun maximumSize() { + val cache = CacheBuilder().maximumSize(1).build() + cache.put("key1", "value1") + cache.put("key2", "value2") + assertEquals(null, cache.getIfPresent("key1")) + assertEquals("value2", cache.getIfPresent("key2")) + } + + @Test + fun maximumWeight() { + val cache = CacheBuilder().weigher(399) { _, _ -> 100 }.build() + cache.put("key1", "value1") + cache.put("key2", "value2") + assertEquals(null, cache.getIfPresent("key1")) + assertEquals("value2", cache.getIfPresent("key2")) + } + + @Test + fun expireAfterAccess() = runTest { + var timeNs = 0L + val cache = + CacheBuilder().expireAfterAccess(100.milliseconds).ticker { timeNs }.build() + cache.put("key", "value") + + timeNs += 50.milliseconds.inWholeNanoseconds + assertEquals("value", cache.getIfPresent("key")) + + timeNs += 100.milliseconds.inWholeNanoseconds + assertEquals(null, cache.getIfPresent("key")) + } + + @Test + fun expireAfterWrite() = runTest { + var timeNs = 0L + val cache = + CacheBuilder().expireAfterWrite(100.milliseconds).ticker { timeNs }.build() + cache.put("key", "value") + + timeNs += 50.milliseconds.inWholeNanoseconds + assertEquals("value", cache.getIfPresent("key")) + + timeNs += 50.milliseconds.inWholeNanoseconds + assertEquals(null, cache.getIfPresent("key")) + } } diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 6fdc456a6..ea355c3bf 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,18 +1,9 @@ -plugins { - id("org.mobilenativefoundation.store.multiplatform") -} +plugins { id("org.mobilenativefoundation.store.multiplatform") } kotlin { - - sourceSets { - val commonMain by getting { - dependencies { - implementation(libs.kotlin.stdlib) - } - } - } + sourceSets { + val commonMain by getting { dependencies { implementation(libs.kotlin.stdlib) } } + } } -android { - namespace = "org.mobilenativefoundation.store.core" -} +android { namespace = "org.mobilenativefoundation.store.core" } diff --git a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/ExperimentalStoreApi.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/ExperimentalStoreApi.kt index 2191f0934..f9ff317c8 100644 --- a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/ExperimentalStoreApi.kt +++ b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/ExperimentalStoreApi.kt @@ -1,8 +1,8 @@ package org.mobilenativefoundation.store.core5 /** - * Marks declarations that are still **experimental** in store API. - * Declarations marked with this annotation are unstable and subject to change. + * Marks declarations that are still **experimental** in store API. Declarations marked with this + * annotation are unstable and subject to change. */ @MustBeDocumented @Retention(value = AnnotationRetention.BINARY) diff --git a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/InsertionStrategy.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/InsertionStrategy.kt index 23206ad77..4fa794693 100644 --- a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/InsertionStrategy.kt +++ b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/InsertionStrategy.kt @@ -2,7 +2,7 @@ package org.mobilenativefoundation.store.core5 @ExperimentalStoreApi enum class InsertionStrategy { - APPEND, - PREPEND, - REPLACE, + 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 index 3e320a9fe..7c9935eac 100644 --- a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/KeyProvider.kt +++ b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/KeyProvider.kt @@ -2,13 +2,7 @@ package org.mobilenativefoundation.store.core5 @ExperimentalStoreApi interface KeyProvider> { - fun fromCollection( - key: StoreKey.Collection, - value: Single, - ): StoreKey.Single + fun fromCollection(key: StoreKey.Collection, value: Single): StoreKey.Single - fun fromSingle( - key: StoreKey.Single, - value: Single, - ): StoreKey.Collection + 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 index 2011c4f83..d6b595988 100644 --- a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreData.kt +++ b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreData.kt @@ -1,36 +1,25 @@ 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. + * 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. */ @ExperimentalStoreApi interface StoreData { - /** - * Represents a single identifiable item. - */ - interface Single : StoreData { - val id: Id - } + /** Represents a single identifiable item. */ + interface Single : StoreData { + val id: Id + } - /** - * Represents a collection of identifiable items. - */ - interface Collection> : StoreData { - val items: List + /** 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 + /** 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 - } + /** 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 index ab367d564..a66a6a700 100644 --- a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt +++ b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt @@ -1,61 +1,48 @@ 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. + * 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. */ @ExperimentalStoreApi 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 fetching an individual item. */ + interface Single : StoreKey { + val id: Id + } - /** - * 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 fetching collections of items. */ + interface Collection : StoreKey { + val insertionStrategy: InsertionStrategy - /** - * Represents a key for cursor-based fetching. - */ - interface Cursor : Collection { - val cursor: Id? - val size: Int - val sort: Sort? - val filters: List>? - } + /** Represents a key for page-based fetching. */ + interface Page : Collection { + val page: Int + 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, + /** Represents a key for cursor-based fetching. */ + interface Cursor : Collection { + val cursor: Id? + val size: Int + val sort: Sort? + val filters: List>? } + } - /** - * Defines filters that can be applied during fetching. - */ - interface Filter { - operator fun invoke(items: List): 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/multicast/build.gradle.kts b/multicast/build.gradle.kts index 1662da7a6..d2b71d20d 100644 --- a/multicast/build.gradle.kts +++ b/multicast/build.gradle.kts @@ -1,28 +1,22 @@ -plugins { - id("org.mobilenativefoundation.store.multiplatform") -} +plugins { id("org.mobilenativefoundation.store.multiplatform") } kotlin { + sourceSets { + val commonMain by getting { + dependencies { + api(libs.kotlinx.atomic.fu) + implementation(libs.kotlinx.coroutines.core) + } + } - sourceSets { - - val commonMain by getting { - dependencies { - api(libs.kotlinx.atomic.fu) - implementation(libs.kotlinx.coroutines.core) - } - } - - val commonTest by getting { - dependencies { - implementation(libs.junit) - implementation(libs.kotlinx.coroutines.test) - implementation(libs.turbine) - } - } + val commonTest by getting { + dependencies { + implementation(libs.junit) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.turbine) + } } + } } -android { - namespace = "org.mobilenativefoundation.store.multicast" -} +android { namespace = "org.mobilenativefoundation.store.multicast" } diff --git a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/Actor.kt b/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/Actor.kt index 9a483141a..250c045bd 100644 --- a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/Actor.kt +++ b/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/Actor.kt @@ -1,5 +1,7 @@ package org.mobilenativefoundation.store.multicast5 +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.CompletionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel @@ -7,28 +9,26 @@ import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext /* * Credits to nickallendev * https://discuss.kotlinlang.org/t/actor-kotlin-common/19569 */ internal fun CoroutineScope.actor( - context: CoroutineContext = EmptyCoroutineContext, - capacity: Int = 0, - onCompletion: CompletionHandler? = null, - block: suspend CoroutineScope.(ReceiveChannel) -> Unit, + context: CoroutineContext = EmptyCoroutineContext, + capacity: Int = 0, + onCompletion: CompletionHandler? = null, + block: suspend CoroutineScope.(ReceiveChannel) -> Unit, ): SendChannel { - val channel = Channel(capacity) - val job = - launch(context) { - try { - block(channel) - } finally { - if (isActive) channel.cancel() - } - } - if (onCompletion != null) job.invokeOnCompletion(handler = onCompletion) - return channel + val channel = Channel(capacity) + val job = + launch(context) { + try { + block(channel) + } finally { + if (isActive) channel.cancel() + } + } + if (onCompletion != null) job.invokeOnCompletion(handler = onCompletion) + return channel } diff --git a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/ChannelManager.kt b/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/ChannelManager.kt index 61948bd93..f73de3b47 100644 --- a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/ChannelManager.kt +++ b/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/ChannelManager.kt @@ -17,116 +17,96 @@ package org.mobilenativefoundation.store.multicast5 +import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.flow.Flow import org.mobilenativefoundation.store.multicast5.ChannelManager.Message -import kotlin.coroutines.cancellation.CancellationException internal interface ChannelManager { - suspend fun addDownstream( - channel: SendChannel>, - piggybackOnly: Boolean = false, - ) + suspend fun addDownstream( + channel: SendChannel>, + piggybackOnly: Boolean = false, + ) - suspend fun removeDownstream(channel: SendChannel>) + suspend fun removeDownstream(channel: SendChannel>) - suspend fun close() + suspend fun close() + /** Holder for each downstream collector */ + data class ChannelEntry( + /** The channel used by the collector */ + private val channel: SendChannel>, /** - * Holder for each downstream collector + * Tracking whether this channel is a piggyback only channel that can be closed without ever + * receiving a value or error. */ - data class ChannelEntry( - /** - * The channel used by the collector - */ - private val channel: SendChannel>, - /** - * Tracking whether this channel is a piggyback only channel that can be closed without ever - * receiving a value or error. - */ - val piggybackOnly: Boolean = false, - ) { - private var _awaitsDispatch: Boolean = !piggybackOnly - - val awaitsDispatch - get() = _awaitsDispatch - - suspend fun dispatchValue(value: Message.Dispatch.Value) { - _awaitsDispatch = false - try { - channel.send(value) - } catch (e: CancellationException) { - // ignore - } - } + val piggybackOnly: Boolean = false, + ) { + private var _awaitsDispatch: Boolean = !piggybackOnly + + val awaitsDispatch + get() = _awaitsDispatch + + suspend fun dispatchValue(value: Message.Dispatch.Value) { + _awaitsDispatch = false + try { + channel.send(value) + } catch (e: CancellationException) { + // ignore + } + } - fun dispatchError(error: Throwable) { - _awaitsDispatch = false - channel.close(error) - } + fun dispatchError(error: Throwable) { + _awaitsDispatch = false + channel.close(error) + } - fun close() { - channel.close() - } + fun close() { + channel.close() + } - fun hasChannel(channel: SendChannel>) = this.channel === channel + fun hasChannel(channel: SendChannel>) = this.channel === channel - fun hasChannel(entry: ChannelEntry) = this.channel === entry.channel - } + fun hasChannel(entry: ChannelEntry) = this.channel === entry.channel + } - /** - * Messages accepted by the [ChannelManager]. - */ - sealed class Message { - /** - * Add a new channel, that means a new downstream subscriber - */ - class AddChannel( - val channel: SendChannel>, - val piggybackOnly: Boolean = false, - ) : Message() + /** Messages accepted by the [ChannelManager]. */ + sealed class Message { + /** Add a new channel, that means a new downstream subscriber */ + class AddChannel( + val channel: SendChannel>, + val piggybackOnly: Boolean = false, + ) : Message() + /** Remove a downstream subscriber, that means it completed */ + class RemoveChannel(val channel: SendChannel>) : Message() + + sealed class Dispatch : Message() { + /** Upstream dispatched a new value, send it to all downstream items */ + class Value( + /** The value dispatched by the upstream */ + val value: T, /** - * Remove a downstream subscriber, that means it completed + * Ack that is completed by all receiver. Upstream producer will await this before asking + * for a new value from upstream */ - class RemoveChannel(val channel: SendChannel>) : Message() - - sealed class Dispatch : Message() { - /** - * Upstream dispatched a new value, send it to all downstream items - */ - class Value( - /** - * The value dispatched by the upstream - */ - val value: T, - /** - * Ack that is completed by all receiver. Upstream producer will await this before asking - * for a new value from upstream - */ - val delivered: CompletableDeferred, - ) : Dispatch() - - /** - * Upstream dispatched an error, send it to all downstream items - */ - class Error( - /** - * The error sent by the upstream - */ - val error: Throwable, - ) : Dispatch() - - class UpstreamFinished( - /** - * SharedFlowProducer finished emitting - */ - val producer: SharedFlowProducer, - ) : Dispatch() - } + val delivered: CompletableDeferred, + ) : Dispatch() + + /** Upstream dispatched an error, send it to all downstream items */ + class Error( + /** The error sent by the upstream */ + val error: Throwable + ) : Dispatch() + + class UpstreamFinished( + /** SharedFlowProducer finished emitting */ + val producer: SharedFlowProducer + ) : Dispatch() } + } } /** @@ -134,294 +114,243 @@ internal interface ChannelManager { * parallel. The upstream is suspended after producing a value until at least one of the downstreams * acknowledges receiving it via [Message.Dispatch.Value.delivered]. * - * The [ChannelManager] will start the upstream from the given [upstream] [Flow] if there - * is no active upstream and there's at least one downstream that has not received a value. - * + * The [ChannelManager] will start the upstream from the given [upstream] [Flow] if there is no + * active upstream and there's at least one downstream that has not received a value. */ internal class StoreChannelManager( - /** - * The scope in which ChannelManager actor runs - */ - private val scope: CoroutineScope, - /** - * The buffer size that is used while the upstream is active - */ - private val bufferSize: Int, - /** - * If true, downstream is never closed by the ChannelManager unless upstream throws an error. - * Instead, it is kept open and if a new downstream shows up that causes us to restart the flow, - * it will receive values as well. - */ - private val piggybackingDownstream: Boolean = false, - /** - * If true, an active upstream will stay alive even if all downstreams are closed. A downstream - * coming in later will receive a value from the live upstream. - * - * The upstream will be kept alive until [scope] cancels or [close] is called. - */ - private val keepUpstreamAlive: Boolean = false, - /** - * Called when a value is dispatched - */ - private val onEach: suspend (T) -> Unit, - private val upstream: Flow, + /** The scope in which ChannelManager actor runs */ + private val scope: CoroutineScope, + /** The buffer size that is used while the upstream is active */ + private val bufferSize: Int, + /** + * If true, downstream is never closed by the ChannelManager unless upstream throws an error. + * Instead, it is kept open and if a new downstream shows up that causes us to restart the flow, + * it will receive values as well. + */ + private val piggybackingDownstream: Boolean = false, + /** + * If true, an active upstream will stay alive even if all downstreams are closed. A downstream + * coming in later will receive a value from the live upstream. + * + * The upstream will be kept alive until [scope] cancels or [close] is called. + */ + private val keepUpstreamAlive: Boolean = false, + /** Called when a value is dispatched */ + private val onEach: suspend (T) -> Unit, + private val upstream: Flow, ) : ChannelManager { - init { - require(!keepUpstreamAlive || bufferSize > 0) { - "Must set bufferSize > 0 if keepUpstreamAlive is enabled" - } + init { + require(!keepUpstreamAlive || bufferSize > 0) { + "Must set bufferSize > 0 if keepUpstreamAlive is enabled" } + } + + override suspend fun addDownstream( + channel: SendChannel>, + piggybackOnly: Boolean, + ) = actor.send(Message.AddChannel(channel, piggybackOnly)) + + override suspend fun removeDownstream(channel: SendChannel>) = + actor.send(Message.RemoveChannel(channel)) - override suspend fun addDownstream( - channel: SendChannel>, - piggybackOnly: Boolean, - ) = actor.send(Message.AddChannel(channel, piggybackOnly)) + override suspend fun close() = actor.close() - override suspend fun removeDownstream(channel: SendChannel>) = actor.send(Message.RemoveChannel(channel)) + private val actor = Actor() - override suspend fun close() = actor.close() + /** Actor that does all the work. Any state and functionality should go here. */ + private inner class Actor : StoreRealActor>(scope) { + private val buffer = Buffer(bufferSize) - private val actor = Actor() + /** The current producer */ + private var producer: SharedFlowProducer? = null /** - * Actor that does all the work. Any state and functionality should go here. + * Tracks whether we've ever dispatched value or error from the current producer. Reset when + * producer finishes. */ - private inner class Actor : StoreRealActor>(scope) { - private val buffer = Buffer(bufferSize) + private var dispatchedValue: Boolean = false - /** - * The current producer - */ - private var producer: SharedFlowProducer? = null - - /** - * Tracks whether we've ever dispatched value or error from the current producer. - * Reset when producer finishes. - */ - private var dispatchedValue: Boolean = false + /** + * The ack for the very last message we've delivered. When a new downstream comes and buffer is + * 0, we ack this message so that new downstream can immediately start receiving values instead + * of waiting for values that it'll never receive. + */ + private var lastDeliveryAck: CompletableDeferred? = null + + /** List of downstream collectors. */ + private val channels = mutableListOf>() + + override suspend fun handle(msg: ChannelManager.Message) { + when (msg) { + is Message.AddChannel -> doAdd(msg) + is Message.RemoveChannel -> doRemove(msg.channel) + is Message.Dispatch.Value -> doDispatchValue(msg) + is Message.Dispatch.Error -> doDispatchError(msg) + is Message.Dispatch.UpstreamFinished -> doHandleUpstreamClose(msg.producer) + } + } - /** - * The ack for the very last message we've delivered. - * When a new downstream comes and buffer is 0, we ack this message so that new downstream - * can immediately start receiving values instead of waiting for values that it'll never - * receive. - */ - private var lastDeliveryAck: CompletableDeferred? = null + /** + * Called when the channel manager is active (e.g. it has downstream collectors and needs a + * producer) + */ + private fun newProducer() = SharedFlowProducer(scope, upstream, ::send) - /** - * List of downstream collectors. - */ - private val channels = mutableListOf>() - - override suspend fun handle(msg: ChannelManager.Message) { - when (msg) { - is Message.AddChannel -> doAdd(msg) - is Message.RemoveChannel -> doRemove(msg.channel) - is Message.Dispatch.Value -> doDispatchValue(msg) - is Message.Dispatch.Error -> doDispatchError(msg) - is Message.Dispatch.UpstreamFinished -> doHandleUpstreamClose(msg.producer) + /** + * We are closing. Do a cleanup on existing channels where we'll close them and also decide on + * the list of leftovers. + */ + private fun doHandleUpstreamClose(producer: SharedFlowProducer?) { + if (this.producer !== producer) { + return + } + val piggyBacked = mutableListOf>() + val leftovers = mutableListOf>() + channels.forEach { + when { + !it.awaitsDispatch -> { + if (!piggybackingDownstream) { + it.close() + } else { + piggyBacked.add(it) } - } + } - /** - * Called when the channel manager is active (e.g. it has downstream collectors and needs a - * producer) - */ - private fun newProducer() = SharedFlowProducer(scope, upstream, ::send) + dispatchedValue -> + // we dispatched a value but this channel didn't receive so put it into + // leftovers + leftovers.add(it) - /** - * We are closing. Do a cleanup on existing channels where we'll close them and also decide - * on the list of leftovers. - */ - private fun doHandleUpstreamClose(producer: SharedFlowProducer?) { - if (this.producer !== producer) { - return - } - val piggyBacked = mutableListOf>() - val leftovers = mutableListOf>() - channels.forEach { - when { - !it.awaitsDispatch -> { - if (!piggybackingDownstream) { - it.close() - } else { - piggyBacked.add(it) - } - } - - dispatchedValue -> - // we dispatched a value but this channel didn't receive so put it into - // leftovers - leftovers.add(it) - - else -> { // upstream didn't dispatch - if (!piggybackingDownstream) { - it.close() - } else { - piggyBacked.add(it) - } - } - } - } - channels.clear() // empty references - channels.addAll(leftovers) - channels.addAll(piggyBacked) - this.producer = null - // we only reactivate if leftovers is not empty - if (leftovers.isNotEmpty()) { - activateIfNecessary() + else -> { // upstream didn't dispatch + if (!piggybackingDownstream) { + it.close() + } else { + piggyBacked.add(it) } + } } + } + channels.clear() // empty references + channels.addAll(leftovers) + channels.addAll(piggyBacked) + this.producer = null + // we only reactivate if leftovers is not empty + if (leftovers.isNotEmpty()) { + activateIfNecessary() + } + } - override fun onClosed() { - channels.forEach { - it.close() - } - channels.clear() - producer?.cancel() - } + override fun onClosed() { + channels.forEach { it.close() } + channels.clear() + producer?.cancel() + } - /** - * Dispatch value to all downstream collectors. - */ - private suspend fun doDispatchValue(msg: Message.Dispatch.Value) { - onEach(msg.value) - buffer.add(msg) - dispatchedValue = true - if (buffer.isEmpty()) { - // if a new downstream arrives, we need to ack this so that it won't wait for - // values that it'll never receive - lastDeliveryAck = msg.delivered - } - channels.forEach { - it.dispatchValue(msg) - } - } + /** Dispatch value to all downstream collectors. */ + private suspend fun doDispatchValue(msg: Message.Dispatch.Value) { + onEach(msg.value) + buffer.add(msg) + dispatchedValue = true + if (buffer.isEmpty()) { + // if a new downstream arrives, we need to ack this so that it won't wait for + // values that it'll never receive + lastDeliveryAck = msg.delivered + } + channels.forEach { it.dispatchValue(msg) } + } - /** - * Dispatch an upstream error to downstream collectors. - */ - private fun doDispatchError(msg: Message.Dispatch.Error) { - // dispatching error is as good as dispatching value - dispatchedValue = true - channels.forEach { - it.dispatchError(msg.error) - } - } + /** Dispatch an upstream error to downstream collectors. */ + private fun doDispatchError(msg: Message.Dispatch.Error) { + // dispatching error is as good as dispatching value + dispatchedValue = true + channels.forEach { it.dispatchError(msg.error) } + } - /** - * Remove a downstream collector. - */ - private suspend fun doRemove(channel: SendChannel>) { - val index = - channels.indexOfFirst { - it.hasChannel(channel) - } - if (index >= 0) { - channels.removeAt(index) - if (!keepUpstreamAlive && channels.isEmpty()) { - producer?.cancelAndJoin() - } - } + /** Remove a downstream collector. */ + private suspend fun doRemove(channel: SendChannel>) { + val index = channels.indexOfFirst { it.hasChannel(channel) } + if (index >= 0) { + channels.removeAt(index) + if (!keepUpstreamAlive && channels.isEmpty()) { + producer?.cancelAndJoin() } + } + } - /** - * Add a new downstream collector - */ - private suspend fun doAdd(msg: Message.AddChannel) { - check(!msg.piggybackOnly || piggybackingDownstream) { - "cannot add a piggyback only downstream when piggybackDownstream is disabled" - } - addEntry( - entry = - ChannelManager.ChannelEntry( - channel = msg.channel, - piggybackOnly = msg.piggybackOnly, - ), - ) - if (!msg.piggybackOnly) { - activateIfNecessary() - } - } + /** Add a new downstream collector */ + private suspend fun doAdd(msg: Message.AddChannel) { + check(!msg.piggybackOnly || piggybackingDownstream) { + "cannot add a piggyback only downstream when piggybackDownstream is disabled" + } + addEntry( + entry = + ChannelManager.ChannelEntry(channel = msg.channel, piggybackOnly = msg.piggybackOnly) + ) + if (!msg.piggybackOnly) { + activateIfNecessary() + } + } - private fun activateIfNecessary() { - if (producer == null) { - producer = newProducer() - dispatchedValue = false - producer!!.start() - } - } + private fun activateIfNecessary() { + if (producer == null) { + producer = newProducer() + dispatchedValue = false + producer!!.start() + } + } - /** - * Internally add the new downstream collector to our list, send it anything buffered. - */ - private suspend fun addEntry(entry: ChannelManager.ChannelEntry) { - val new = - channels.none { - it.hasChannel(entry) - } - check(new) { - "$entry is already in the list." - } - channels.add(entry) - if (buffer.items.isNotEmpty()) { - // if there is anything in the buffer, send it - buffer.items.forEach { - entry.dispatchValue(it) - } - } else { - lastDeliveryAck?.complete(Unit) - } - } + /** Internally add the new downstream collector to our list, send it anything buffered. */ + private suspend fun addEntry(entry: ChannelManager.ChannelEntry) { + val new = channels.none { it.hasChannel(entry) } + check(new) { "$entry is already in the list." } + channels.add(entry) + if (buffer.items.isNotEmpty()) { + // if there is anything in the buffer, send it + buffer.items.forEach { entry.dispatchValue(it) } + } else { + lastDeliveryAck?.complete(Unit) + } } + } } -/** - * Buffer implementation for any late arrivals. - */ +/** Buffer implementation for any late arrivals. */ private interface Buffer { - fun add(item: Message.Dispatch.Value) + fun add(item: Message.Dispatch.Value) - fun isEmpty() = items.isEmpty() + fun isEmpty() = items.isEmpty() - val items: Collection> + val items: Collection> } -/** - * Default implementation of buffer which does not buffer anything. - */ +/** Default implementation of buffer which does not buffer anything. */ private class NoBuffer : Buffer { - override val items: Collection> - get() = emptyList() + override val items: Collection> + get() = emptyList() - // ignore - override fun add(item: Message.Dispatch.Value) = Unit + // ignore + override fun add(item: Message.Dispatch.Value) = Unit } -/** - * Create a new buffer insteance based on the provided limit. - */ +/** Create a new buffer insteance based on the provided limit. */ @Suppress("FunctionName") private fun Buffer(limit: Int): Buffer = - if (limit > 0) { - BufferImpl(limit) - } else { - NoBuffer() - } - -/** - * A real buffer implementation that has a FIFO queue. - */ -private class BufferImpl(private val limit: Int) : - Buffer { - override val items = ArrayDeque>(limit.coerceAtMost(10)) - - override fun add(item: Message.Dispatch.Value) { - while (items.size >= limit) { - items.removeFirst() - } - items.addLast(item) + if (limit > 0) { + BufferImpl(limit) + } else { + NoBuffer() + } + +/** A real buffer implementation that has a FIFO queue. */ +private class BufferImpl(private val limit: Int) : Buffer { + override val items = ArrayDeque>(limit.coerceAtMost(10)) + + override fun add(item: Message.Dispatch.Value) { + while (items.size >= limit) { + items.removeFirst() } + items.addLast(item) + } } internal fun Message.Dispatch.Value.markDelivered() = delivered.complete(Unit) diff --git a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/Multicaster.kt b/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/Multicaster.kt index f006e853b..09fa3b815 100644 --- a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/Multicaster.kt +++ b/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/Multicaster.kt @@ -37,100 +37,93 @@ import kotlinx.coroutines.withContext * is empty. */ class Multicaster( - /** - * The [CoroutineScope] to use for upstream subscription - */ - private val scope: CoroutineScope, - /** - * The buffer size that is used only if the upstream has not complete yet. - * Defaults to 0. - */ - bufferSize: Int = 0, - /** - * Source function to create a new flow when necessary. - */ - private val source: Flow, - /** - * If true, downstream is never closed by the multicaster unless upstream throws an error. - * Instead, it is kept open and if a new downstream shows up that causes us to restart the flow, - * it will receive values as well. - */ - private val piggybackingDownstream: Boolean = false, - /** - * If true, an active upstream will stay alive even if all downstreams are closed. A downstream - * coming in later will receive a value from the live upstream. - * - * The upstream will be kept alive until [scope] cancels or [close] is called. - */ - private val keepUpstreamAlive: Boolean = false, - /** - * Called when upstream dispatches a value. - */ - private val onEach: suspend (T) -> Unit, + /** The [CoroutineScope] to use for upstream subscription */ + private val scope: CoroutineScope, + /** The buffer size that is used only if the upstream has not complete yet. Defaults to 0. */ + bufferSize: Int = 0, + /** Source function to create a new flow when necessary. */ + private val source: Flow, + /** + * If true, downstream is never closed by the multicaster unless upstream throws an error. + * Instead, it is kept open and if a new downstream shows up that causes us to restart the flow, + * it will receive values as well. + */ + private val piggybackingDownstream: Boolean = false, + /** + * If true, an active upstream will stay alive even if all downstreams are closed. A downstream + * coming in later will receive a value from the live upstream. + * + * The upstream will be kept alive until [scope] cancels or [close] is called. + */ + private val keepUpstreamAlive: Boolean = false, + /** Called when upstream dispatches a value. */ + private val onEach: suspend (T) -> Unit, ) { - internal var channelManagerFactory: () -> ChannelManager = { - StoreChannelManager( - scope = scope, - bufferSize = bufferSize, - upstream = source, - piggybackingDownstream = piggybackingDownstream, - keepUpstreamAlive = keepUpstreamAlive, - onEach = onEach, - ) - } + internal var channelManagerFactory: () -> ChannelManager = { + StoreChannelManager( + scope = scope, + bufferSize = bufferSize, + upstream = source, + piggybackingDownstream = piggybackingDownstream, + keepUpstreamAlive = keepUpstreamAlive, + onEach = onEach, + ) + } - private val channelManager by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { channelManagerFactory() } + private val channelManager by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { channelManagerFactory() } - /** - * Gets a new downstream flow. Collectors of this flow will share values dispatched by a - * single upstream [source] Flow. - * - * @param piggybackOnly if true this downstream will not cause a new upstream to start running - * (which only happens if no upstream is running already, e.g if this is the first downstream - * added). [piggybackOnly] is only valid if [piggybackingDownstream] is enabled for this - * [Multicaster]. - */ - fun newDownstream(piggybackOnly: Boolean = false): Flow { - check(!piggybackOnly || piggybackingDownstream) { - "cannot create a piggyback only flow when piggybackDownstream is disabled" - } - return flow { - val channel = Channel>(Channel.UNLIMITED) - val subFlow = - channel.consumeAsFlow() - .onStart { - try { - channelManager.addDownstream(channel, piggybackOnly) - } catch (closed: ClosedSendChannelException) { - // before we could start, channel manager was closed. - // close our downstream manually as it won't be closed by the ChannelManager - channel.close() - } - } - .transform, T> { - emit(it.value) - it.delivered.complete(Unit) - }.onCompletion { - withContext(NonCancellable) { - try { - channelManager.removeDownstream(channel) - } catch (closed: ClosedSendChannelException) { - // ignore, we might be closed because ChannelManager is closed - } - } - } - emitAll(subFlow) - } + /** + * Gets a new downstream flow. Collectors of this flow will share values dispatched by a single + * upstream [source] Flow. + * + * @param piggybackOnly if true this downstream will not cause a new upstream to start running + * (which only happens if no upstream is running already, e.g if this is the first downstream + * added). [piggybackOnly] is only valid if [piggybackingDownstream] is enabled for this + * [Multicaster]. + */ + fun newDownstream(piggybackOnly: Boolean = false): Flow { + check(!piggybackOnly || piggybackingDownstream) { + "cannot create a piggyback only flow when piggybackDownstream is disabled" } - - /** - * Closes the [Multicaster]. All current collectors on the [flow] will complete and any new - * collector will receive 0 values and immediately close even if the [bufferSize] is set to a - * positive value. - * - * This is an idempotent operation. - */ - suspend fun close() { - channelManager.close() + return flow { + val channel = Channel>(Channel.UNLIMITED) + val subFlow = + channel + .consumeAsFlow() + .onStart { + try { + channelManager.addDownstream(channel, piggybackOnly) + } catch (closed: ClosedSendChannelException) { + // before we could start, channel manager was closed. + // close our downstream manually as it won't be closed by the ChannelManager + channel.close() + } + } + .transform, T> { + emit(it.value) + it.delivered.complete(Unit) + } + .onCompletion { + withContext(NonCancellable) { + try { + channelManager.removeDownstream(channel) + } catch (closed: ClosedSendChannelException) { + // ignore, we might be closed because ChannelManager is closed + } + } + } + emitAll(subFlow) } + } + + /** + * Closes the [Multicaster]. All current collectors on the [flow] will complete and any new + * collector will receive 0 values and immediately close even if the [bufferSize] is set to a + * positive value. + * + * This is an idempotent operation. + */ + suspend fun close() { + channelManager.close() + } } diff --git a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/SharedFlowProducer.kt b/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/SharedFlowProducer.kt index a4ae02aa3..1e65e4c5f 100644 --- a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/SharedFlowProducer.kt +++ b/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/SharedFlowProducer.kt @@ -26,8 +26,8 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.launch /** - * A flow collector that works with a [ChannelManager] to collect values from an upstream flow - * and dispatch to the [sendUpsteamMessage] which then dispatches to downstream collectors. + * A flow collector that works with a [ChannelManager] to collect values from an upstream flow and + * dispatch to the [sendUpsteamMessage] which then dispatches to downstream collectors. * * They work in sync such that this producer always expects an ack from the [ChannelManager] after * sending an event. @@ -36,57 +36,52 @@ import kotlinx.coroutines.launch * or the [ChannelManager] (e.g. all active collectors complete). */ internal class SharedFlowProducer( - private val scope: CoroutineScope, - private val src: Flow, - private val sendUpsteamMessage: suspend (ChannelManager.Message.Dispatch) -> Unit, + private val scope: CoroutineScope, + private val src: Flow, + private val sendUpsteamMessage: suspend (ChannelManager.Message.Dispatch) -> Unit, ) { - private val collectionJob: Job = - scope.launch(start = CoroutineStart.LAZY) { - try { - src.catch { - sendUpsteamMessage(ChannelManager.Message.Dispatch.Error(it)) - }.collect { - val ack = CompletableDeferred() - sendUpsteamMessage( - ChannelManager.Message.Dispatch.Value( - it, - ack, - ), - ) - // suspend until at least 1 receives the new value - ack.await() - } - } catch (closed: ClosedSendChannelException) { - // ignore. if consumers are gone, it might close itself. - } - } + private val collectionJob: Job = + scope.launch(start = CoroutineStart.LAZY) { + try { + src + .catch { sendUpsteamMessage(ChannelManager.Message.Dispatch.Error(it)) } + .collect { + val ack = CompletableDeferred() + sendUpsteamMessage(ChannelManager.Message.Dispatch.Value(it, ack)) + // suspend until at least 1 receives the new value + ack.await() + } + } catch (closed: ClosedSendChannelException) { + // ignore. if consumers are gone, it might close itself. + } + } - /** - * Starts the collection of the upstream flow. - */ - fun start() { - scope.launch { - try { - // trigger start of the collection and wait until collection ends, either due to an - // error or ordered by the channel manager - collectionJob.join() - } finally { - // cleanup the channel manager so that downstreams can be closed if they are not - // closed already and leftovers can be moved to a new producer if necessary. - try { - sendUpsteamMessage(ChannelManager.Message.Dispatch.UpstreamFinished(this@SharedFlowProducer)) - } catch (closed: ClosedSendChannelException) { - // it might close before us, its fine. - } - } + /** Starts the collection of the upstream flow. */ + fun start() { + scope.launch { + try { + // trigger start of the collection and wait until collection ends, either due to an + // error or ordered by the channel manager + collectionJob.join() + } finally { + // cleanup the channel manager so that downstreams can be closed if they are not + // closed already and leftovers can be moved to a new producer if necessary. + try { + sendUpsteamMessage( + ChannelManager.Message.Dispatch.UpstreamFinished(this@SharedFlowProducer) + ) + } catch (closed: ClosedSendChannelException) { + // it might close before us, its fine. } + } } + } - suspend fun cancelAndJoin() { - collectionJob.cancelAndJoin() - } + suspend fun cancelAndJoin() { + collectionJob.cancelAndJoin() + } - fun cancel() { - collectionJob.cancel() - } + fun cancel() { + collectionJob.cancel() + } } diff --git a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/StoreRealActor.kt b/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/StoreRealActor.kt index 502ef6c5b..856ee0600 100644 --- a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/StoreRealActor.kt +++ b/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/StoreRealActor.kt @@ -22,69 +22,64 @@ import kotlinx.coroutines.channels.ClosedSendChannelException import kotlinx.coroutines.channels.SendChannel /** - * Simple actor implementation abstracting away Coroutine.actor since it is deprecated. - * It also enforces a 0 capacity buffer. + * Simple actor implementation abstracting away Coroutine.actor since it is deprecated. It also + * enforces a 0 capacity buffer. */ @Suppress("EXPERIMENTAL_API_USAGE") -internal abstract class StoreRealActor( - scope: CoroutineScope, -) { - private val inboundChannel: SendChannel - private val closeCompleted = CompletableDeferred() - private val didClose = atomic(false) +internal abstract class StoreRealActor(scope: CoroutineScope) { + private val inboundChannel: SendChannel + private val closeCompleted = CompletableDeferred() + private val didClose = atomic(false) - init { - inboundChannel = - scope.actor( - capacity = 0, - ) { - try { - for (msg in it) { - if (msg === CLOSE_TOKEN) { - doClose() - break - } else { - @Suppress("UNCHECKED_CAST") - handle(msg as T) - } - } - } finally { - doClose() - } - } - } - - private fun doClose() { - if (didClose.compareAndSet(expect = false, update = true)) { - try { - onClosed() - } finally { - inboundChannel.close() - closeCompleted.complete(Unit) + init { + inboundChannel = + scope.actor(capacity = 0) { + try { + for (msg in it) { + if (msg === CLOSE_TOKEN) { + doClose() + break + } else { + @Suppress("UNCHECKED_CAST") handle(msg as T) } + } + } finally { + doClose() } + } + } + + private fun doClose() { + if (didClose.compareAndSet(expect = false, update = true)) { + try { + onClosed() + } finally { + inboundChannel.close() + closeCompleted.complete(Unit) + } } + } - open fun onClosed() = Unit + open fun onClosed() = Unit - abstract suspend fun handle(msg: T) + abstract suspend fun handle(msg: T) - suspend fun send(msg: T) { - inboundChannel.send(msg) - } + suspend fun send(msg: T) { + inboundChannel.send(msg) + } - suspend fun close() { - try { - // using a custom token to close so that we can gracefully close the downstream - inboundChannel.send(CLOSE_TOKEN) - // wait until close is done done - closeCompleted.await() - } catch (closed: ClosedSendChannelException) { - // already closed, ignore - } + suspend fun close() { + try { + // using a custom token to close so that we can gracefully close the downstream + inboundChannel.send(CLOSE_TOKEN) + // wait until close is done done + closeCompleted.await() + } catch (closed: ClosedSendChannelException) { + // already closed, ignore } + } - companion object { - val CLOSE_TOKEN = Any() - } + companion object { + val CLOSE_TOKEN = Any() + } } diff --git a/multicast/src/commonTest/kotlin/org/mobilenativefoundation/store/multicast5/StoreChannelManagerTests.kt b/multicast/src/commonTest/kotlin/org/mobilenativefoundation/store/multicast5/StoreChannelManagerTests.kt index c765b7c11..1955b4c89 100644 --- a/multicast/src/commonTest/kotlin/org/mobilenativefoundation/store/multicast5/StoreChannelManagerTests.kt +++ b/multicast/src/commonTest/kotlin/org/mobilenativefoundation/store/multicast5/StoreChannelManagerTests.kt @@ -1,6 +1,8 @@ package org.mobilenativefoundation.store.multicast5 import app.cash.turbine.test +import kotlin.test.Test +import kotlin.test.assertEquals import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel @@ -12,67 +14,53 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals class StoreChannelManagerTests { - @Test - fun cancelledDownstreamChannelShouldNotCancelOtherChannels() = - runTest { - val coroutineScope = CoroutineScope(Dispatchers.Default) - val lockUpstream = Mutex(true) - val testMessages = listOf(1, 2, 3) - val numChannels = 20 - val upstreamFlow = - flow { - lockUpstream.withLock { - testMessages.onEach { emit(it) } - } - } - val channelManager = - StoreChannelManager( - scope = coroutineScope, - bufferSize = 0, - upstream = upstreamFlow, - piggybackingDownstream = false, - keepUpstreamAlive = false, - onEach = { }, - ) - val channels = createChannels(numChannels) - val channelToBeCancelled = - Channel>(Channel.UNLIMITED) - .also { channel -> - coroutineScope.launch { - channel.consumeAsFlow().test { - cancelAndIgnoreRemainingEvents() - } - } - } - coroutineScope.launch { - channels.forEach { channelManager.addDownstream(it) } - lockUpstream.unlock() - } - coroutineScope.launch { - channelManager.addDownstream(channelToBeCancelled) - } + @Test + fun cancelledDownstreamChannelShouldNotCancelOtherChannels() = runTest { + val coroutineScope = CoroutineScope(Dispatchers.Default) + val lockUpstream = Mutex(true) + val testMessages = listOf(1, 2, 3) + val numChannels = 20 + val upstreamFlow = flow { lockUpstream.withLock { testMessages.onEach { emit(it) } } } + val channelManager = + StoreChannelManager( + scope = coroutineScope, + bufferSize = 0, + upstream = upstreamFlow, + piggybackingDownstream = false, + keepUpstreamAlive = false, + onEach = {}, + ) + val channels = createChannels(numChannels) + val channelToBeCancelled = + Channel>(Channel.UNLIMITED).also { channel -> + coroutineScope.launch { channel.consumeAsFlow().test { cancelAndIgnoreRemainingEvents() } } + } + coroutineScope.launch { + channels.forEach { channelManager.addDownstream(it) } + lockUpstream.unlock() + } + coroutineScope.launch { channelManager.addDownstream(channelToBeCancelled) } - channels.forEach { channel -> - val messagesFlow = - channel.consumeAsFlow() - .filterIsInstance>() - .onEach { it.delivered.complete(Unit) } + channels.forEach { channel -> + val messagesFlow = + channel + .consumeAsFlow() + .filterIsInstance>() + .onEach { it.delivered.complete(Unit) } - messagesFlow.test { - for (message in testMessages) { - val dispatchValue = awaitItem() - assertEquals(message, dispatchValue.value) - } - awaitComplete() - } - } + messagesFlow.test { + for (message in testMessages) { + val dispatchValue = awaitItem() + assertEquals(message, dispatchValue.value) } - - private fun createChannels(count: Int): List>> { - return (1..count).map { Channel(Channel.UNLIMITED) } + awaitComplete() + } } + } + + private fun createChannels(count: Int): List>> { + return (1..count).map { Channel(Channel.UNLIMITED) } + } } diff --git a/rx2/build.gradle.kts b/rx2/build.gradle.kts index bf887b115..d5544ea04 100644 --- a/rx2/build.gradle.kts +++ b/rx2/build.gradle.kts @@ -1,23 +1,19 @@ @file:Suppress("UnstableApiUsage") -plugins { - id("org.mobilenativefoundation.store.android") -} +plugins { id("org.mobilenativefoundation.store.android") } dependencies { - implementation(libs.kotlinx.coroutines.rx2) - implementation(libs.kotlinx.coroutines.core) - implementation(libs.kotlinx.coroutines.android) - implementation(libs.rxjava) - implementation(projects.store) + implementation(libs.kotlinx.coroutines.rx2) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.rxjava) + implementation(projects.store) - testImplementation(kotlin("test")) - testImplementation(libs.junit) - testImplementation(libs.google.truth) - testImplementation(libs.androidx.test.core) - testImplementation(libs.kotlinx.coroutines.test) + testImplementation(kotlin("test")) + testImplementation(libs.junit) + testImplementation(libs.google.truth) + testImplementation(libs.androidx.test.core) + testImplementation(libs.kotlinx.coroutines.test) } -android { - namespace = "org.mobilenativefoundation.store.rx2" -} +android { namespace = "org.mobilenativefoundation.store.rx2" } diff --git a/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxFetcher.kt b/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxFetcher.kt index 57332ee8e..1c52d233d 100644 --- a/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxFetcher.kt +++ b/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxFetcher.kt @@ -13,13 +13,13 @@ import org.mobilenativefoundation.store.store5.Store * [Store] does not catch exception thrown in [flowableFactory] or in the returned [Flowable]. These * exception will be propagated to the caller. * - * Use when creating a [Store] that fetches objects in a multiple responses per request - * network protocol (e.g Web Sockets). + * Use when creating a [Store] that fetches objects in a multiple responses per request network + * protocol (e.g Web Sockets). * * @param flowableFactory a factory for a [Flowable] source of network records. */ fun Fetcher.Companion.ofResultFlowable( - flowableFactory: (key: Key) -> Flowable>, + flowableFactory: (key: Key) -> Flowable> ): Fetcher = ofResultFlow { key: Key -> flowableFactory(key).asFlow() } /** @@ -34,7 +34,7 @@ fun Fetcher.Companion.ofResultFlowable( * @param singleFactory a factory for a [Single] source of network records. */ fun Fetcher.Companion.ofResultSingle( - singleFactory: (key: Key) -> Single>, + singleFactory: (key: Key) -> Single> ): Fetcher = ofResultFlowable { key: Key -> singleFactory(key).toFlowable() } /** @@ -44,25 +44,27 @@ fun Fetcher.Companion.ofResultSingle( * it will be wrapped in [FetcherResult.Error]. Exceptions thrown in [flowableFactory] itself are * not caught and will be returned to the caller. * - * Use when creating a [Store] that fetches objects in a multiple responses per request - * network protocol (e.g Web Sockets). + * Use when creating a [Store] that fetches objects in a multiple responses per request network + * protocol (e.g Web Sockets). * * @param flowFactory a factory for a [Flowable] source of network records. */ -fun Fetcher.Companion.ofFlowable(flowableFactory: (key: Key) -> Flowable): Fetcher = - ofFlow { key: Key -> flowableFactory(key).asFlow() } +fun Fetcher.Companion.ofFlowable( + flowableFactory: (key: Key) -> Flowable +): Fetcher = ofFlow { key: Key -> flowableFactory(key).asFlow() } /** * Creates a new [Fetcher] from a [singleFactory] and translate the results to a [FetcherResult]. * - * The emitted value will be wrapped in [FetcherResult.Data]. if an exception is returned then - * it will be wrapped in [FetcherResult.Error]. Exceptions thrown in [singleFactory] itself are - * not caught and will be returned to the caller. + * The emitted value will be wrapped in [FetcherResult.Data]. if an exception is returned then it + * will be wrapped in [FetcherResult.Error]. Exceptions thrown in [singleFactory] itself are not + * caught and will be returned to the caller. * * Use when creating a [Store] that fetches objects in a single response per request network * protocol (e.g Http). * * @param singleFactory a factory for a [Single] source of network records. */ -fun Fetcher.Companion.ofSingle(singleFactory: (key: Key) -> Single): Fetcher = - ofFlowable { key: Key -> singleFactory(key).toFlowable() } +fun Fetcher.Companion.ofSingle( + singleFactory: (key: Key) -> Single +): Fetcher = ofFlowable { key: Key -> singleFactory(key).toFlowable() } diff --git a/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxSourceOfTruth.kt b/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxSourceOfTruth.kt index 68e18adc5..b46198cdb 100644 --- a/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxSourceOfTruth.kt +++ b/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxSourceOfTruth.kt @@ -16,23 +16,22 @@ import org.mobilenativefoundation.store.store5.SourceOfTruth * @param writer function for writing updates to the backing source of truth * @param delete function for deleting records in the source of truth for the given key * @param deleteAll function for deleting all records in the source of truth - * */ fun SourceOfTruth.Companion.ofMaybe( - reader: (Key) -> Maybe, - writer: (Key, Local) -> Completable, - delete: ((Key) -> Completable)? = null, - deleteAll: (() -> Completable)? = null, + reader: (Key) -> Maybe, + writer: (Key, Local) -> Completable, + delete: ((Key) -> Completable)? = null, + deleteAll: (() -> Completable)? = null, ): SourceOfTruth { - val deleteFun: (suspend (Key) -> Unit)? = - if (delete != null) { key -> delete(key).await() } else null - val deleteAllFun: (suspend () -> Unit)? = deleteAll?.let { { deleteAll().await() } } - return of( - nonFlowReader = { key -> reader.invoke(key).awaitSingleOrNull() }, - writer = { key, output -> writer.invoke(key, output).await() }, - delete = deleteFun, - deleteAll = deleteAllFun, - ) + val deleteFun: (suspend (Key) -> Unit)? = + if (delete != null) { key -> delete(key).await() } else null + val deleteAllFun: (suspend () -> Unit)? = deleteAll?.let { { deleteAll().await() } } + return of( + nonFlowReader = { key -> reader.invoke(key).awaitSingleOrNull() }, + writer = { key, output -> writer.invoke(key, output).await() }, + delete = deleteFun, + deleteAll = deleteAllFun, + ) } /** @@ -43,21 +42,20 @@ fun SourceOfTruth.Companion.ofMaybe( * @param writer function for writing updates to the backing source of truth * @param delete function for deleting records in the source of truth for the given key * @param deleteAll function for deleting all records in the source of truth - * */ fun SourceOfTruth.Companion.ofFlowable( - reader: (Key) -> Flowable, - writer: (Key, Local) -> Completable, - delete: ((Key) -> Completable)? = null, - deleteAll: (() -> Completable)? = null, + reader: (Key) -> Flowable, + writer: (Key, Local) -> Completable, + delete: ((Key) -> Completable)? = null, + deleteAll: (() -> Completable)? = null, ): SourceOfTruth { - val deleteFun: (suspend (Key) -> Unit)? = - if (delete != null) { key -> delete(key).await() } else null - val deleteAllFun: (suspend () -> Unit)? = deleteAll?.let { { deleteAll().await() } } - return of( - reader = { key -> reader.invoke(key).asFlow() }, - writer = { key, output -> writer.invoke(key, output).await() }, - delete = deleteFun, - deleteAll = deleteAllFun, - ) + val deleteFun: (suspend (Key) -> Unit)? = + if (delete != null) { key -> delete(key).await() } else null + val deleteAllFun: (suspend () -> Unit)? = deleteAll?.let { { deleteAll().await() } } + return of( + reader = { key -> reader.invoke(key).asFlow() }, + writer = { key, output -> writer.invoke(key, output).await() }, + delete = deleteFun, + deleteAll = deleteAllFun, + ) } diff --git a/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxStore.kt b/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxStore.kt index b411961bb..78f5ac6e6 100644 --- a/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxStore.kt +++ b/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxStore.kt @@ -15,32 +15,42 @@ import org.mobilenativefoundation.store.store5.impl.extensions.get /** * Return a [Flowable] for the given key + * * @param request - see [StoreReadRequest] for configurations */ -fun Store.observe(request: StoreReadRequest): Flowable> = - stream(request).asFlowable() +fun Store.observe( + request: StoreReadRequest +): Flowable> = stream(request).asFlowable() /** - * Purge a particular entry from memory and disk cache. - * Persistent storage will only be cleared if a delete function was passed to - * [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store]. + * Purge a particular entry from memory and disk cache. Persistent storage will only be cleared if a + * delete function was passed to [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when + * creating the [Store]. */ -fun Store.observeClear(key: Key): Completable = rxCompletable { clear(key) } +fun Store.observeClear(key: Key): Completable = + rxCompletable { + clear(key) + } /** - * Purge all entries from memory and disk cache. - * Persistent storage will only be cleared if a deleteAll function was passed to - * [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store]. + * Purge all entries from memory and disk cache. Persistent storage will only be cleared if a + * deleteAll function was passed to [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] + * when creating the [Store]. */ @ExperimentalStoreApi -fun Store.observeClearAll(): Completable = rxCompletable { clear() } +fun Store.observeClearAll(): Completable = rxCompletable { + clear() +} /** - * Helper factory that will return data as a [Single] for [key] if it is cached otherwise will return fresh/network data (updating your caches) + * Helper factory that will return data as a [Single] for [key] if it is cached otherwise will + * return fresh/network data (updating your caches) */ -fun Store.getSingle(key: Key) = rxSingle { this@getSingle.get(key) } +fun Store.getSingle(key: Key) = rxSingle { + this@getSingle.get(key) +} -/** - * Helper factory that will return fresh data as a [Single] for [key] while updating your caches - */ -fun Store.freshSingle(key: Key) = rxSingle { this@freshSingle.fresh(key) } +/** Helper factory that will return fresh data as a [Single] for [key] while updating your caches */ +fun Store.freshSingle(key: Key) = rxSingle { + this@freshSingle.fresh(key) +} diff --git a/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxStoreBuilder.kt b/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxStoreBuilder.kt index 5d378c3d4..9ab6cfe11 100644 --- a/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxStoreBuilder.kt +++ b/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxStoreBuilder.kt @@ -8,17 +8,19 @@ import org.mobilenativefoundation.store.store5.Store import org.mobilenativefoundation.store.store5.StoreBuilder /** - * A store multicasts same [Output] value to many consumers (Similar to RxJava.share()), by default - * [Store] will open a global scope for management of shared responses, if instead you'd like to control - * the scheduler that sharing/multicasting happens in you can pass a @param [scheduler] + * A store multicasts same [Output] value to many consumers (Similar to RxJava.share()), by default + * [Store] will open a global scope for management of shared responses, if instead you'd like to + * control the scheduler that sharing/multicasting happens in you can pass a @param [scheduler] * - * Note this does not control what scheduler a response is emitted on but rather what thread/scheduler - * to use when managing in flight responses. This is usually used for things like testing where you - * may want to confine to a scheduler backed by a single thread executor + * Note this does not control what scheduler a response is emitted on but rather what + * thread/scheduler to use when managing in flight responses. This is usually used for things like + * testing where you may want to confine to a scheduler backed by a single thread executor * - * @param scheduler - scheduler to use for sharing - * if a scheduler is not set Store will use [GlobalScope] + * @param scheduler - scheduler to use for sharing if a scheduler is not set Store will use + * [GlobalScope] */ -fun StoreBuilder.withScheduler(scheduler: Scheduler): StoreBuilder { - return scope(CoroutineScope(scheduler.asCoroutineDispatcher())) +fun StoreBuilder.withScheduler( + scheduler: Scheduler +): StoreBuilder { + return scope(CoroutineScope(scheduler.asCoroutineDispatcher())) } diff --git a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/FlowTestExt.kt b/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/FlowTestExt.kt index 422460532..379db8ac4 100644 --- a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/FlowTestExt.kt +++ b/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/FlowTestExt.kt @@ -29,59 +29,56 @@ import kotlinx.coroutines.test.advanceUntilIdle @OptIn(ExperimentalCoroutinesApi::class) internal fun TestCoroutineScope.assertThat(flow: Flow): FlowSubject { - return Truth.assertAbout(FlowSubject.Factory(this)).that(flow) + return Truth.assertAbout(FlowSubject.Factory(this)).that(flow) } @OptIn(ExperimentalCoroutinesApi::class) -internal class FlowSubject constructor( - failureMetadata: FailureMetadata, - private val testCoroutineScope: TestCoroutineScope, - private val actual: Flow, +internal class FlowSubject +constructor( + failureMetadata: FailureMetadata, + private val testCoroutineScope: TestCoroutineScope, + private val actual: Flow, ) : Subject(failureMetadata, actual) { - /** - * Takes all items in the flow that are available by collecting on it as long as there are - * active jobs in the given [TestCoroutineScope]. - * - * It ensures all expected items are dispatched as well as no additional unexpected items are - * dispatched. - */ - suspend fun emitsExactly(vararg expected: T) { - val collectedSoFar = mutableListOf() - val collectionCoroutine = - testCoroutineScope.async { - actual.collect { - collectedSoFar.add(it) - if (collectedSoFar.size > expected.size) { - assertWithMessage("Too many emissions in the flow (only first additional item is shown)") - .that(collectedSoFar) - .isEqualTo(expected) - } - } - } - testCoroutineScope.advanceUntilIdle() - if (!collectionCoroutine.isActive) { - collectionCoroutine.getCompletionExceptionOrNull()?.let { - throw it - } + /** + * Takes all items in the flow that are available by collecting on it as long as there are active + * jobs in the given [TestCoroutineScope]. + * + * It ensures all expected items are dispatched as well as no additional unexpected items are + * dispatched. + */ + suspend fun emitsExactly(vararg expected: T) { + val collectedSoFar = mutableListOf() + val collectionCoroutine = + testCoroutineScope.async { + actual.collect { + collectedSoFar.add(it) + if (collectedSoFar.size > expected.size) { + assertWithMessage( + "Too many emissions in the flow (only first additional item is shown)" + ) + .that(collectedSoFar) + .isEqualTo(expected) + } } - collectionCoroutine.cancelAndJoin() - assertWithMessage("Flow didn't exactly emit expected items") - .that(collectedSoFar) - .isEqualTo(expected.toList()) + } + testCoroutineScope.advanceUntilIdle() + if (!collectionCoroutine.isActive) { + collectionCoroutine.getCompletionExceptionOrNull()?.let { throw it } } + collectionCoroutine.cancelAndJoin() + assertWithMessage("Flow didn't exactly emit expected items") + .that(collectedSoFar) + .isEqualTo(expected.toList()) + } - class Factory( - private val testCoroutineScope: TestCoroutineScope, - ) : Subject.Factory, Flow> { - override fun createSubject( - metadata: FailureMetadata, - actual: Flow, - ): FlowSubject { - return FlowSubject( - failureMetadata = metadata, - actual = actual, - testCoroutineScope = testCoroutineScope, - ) - } + class Factory(private val testCoroutineScope: TestCoroutineScope) : + Subject.Factory, Flow> { + override fun createSubject(metadata: FailureMetadata, actual: Flow): FlowSubject { + return FlowSubject( + failureMetadata = metadata, + actual = actual, + testCoroutineScope = testCoroutineScope, + ) } + } } diff --git a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/HotRxSingleStoreTest.kt b/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/HotRxSingleStoreTest.kt index 3cb617ddf..730922db7 100644 --- a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/HotRxSingleStoreTest.kt +++ b/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/HotRxSingleStoreTest.kt @@ -21,66 +21,47 @@ import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin @FlowPreview @ExperimentalCoroutinesApi class HotRxSingleStoreTest { - private val testScope = TestCoroutineScope() + private val testScope = TestCoroutineScope() - @Test - fun `GIVEN a hot fetcher WHEN two cached and one fresh call THEN fetcher is only called twice`() = - testScope.runBlockingTest { - val fetcher: FakeRxFetcher> = - FakeRxFetcher( - 3 to FetcherResult.Data("three-1"), - 3 to FetcherResult.Data("three-2"), - ) - val pipeline = - StoreBuilder.from(Fetcher.ofResultSingle { fetcher.fetch(it) }) - .scope(testScope) - .build() + @Test + fun `GIVEN a hot fetcher WHEN two cached and one fresh call THEN fetcher is only called twice`() = + testScope.runBlockingTest { + val fetcher: FakeRxFetcher> = + FakeRxFetcher(3 to FetcherResult.Data("three-1"), 3 to FetcherResult.Data("three-2")) + val pipeline = + StoreBuilder.from(Fetcher.ofResultSingle { fetcher.fetch(it) }) + .scope(testScope) + .build() - assertThat(pipeline.stream(StoreReadRequest.cached(3, refresh = false))) - .emitsExactly( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - StoreReadResponse.Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - ) - assertThat( - pipeline.stream(StoreReadRequest.cached(3, refresh = false)), - ).emitsExactly( - StoreReadResponse.Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - ) + assertThat(pipeline.stream(StoreReadRequest.cached(3, refresh = false))) + .emitsExactly( + StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), + StoreReadResponse.Data(value = "three-1", origin = StoreReadResponseOrigin.Fetcher()), + ) + assertThat(pipeline.stream(StoreReadRequest.cached(3, refresh = false))) + .emitsExactly( + StoreReadResponse.Data(value = "three-1", origin = StoreReadResponseOrigin.Cache) + ) - assertThat(pipeline.stream(StoreReadRequest.fresh(3))) - .emitsExactly( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - StoreReadResponse.Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - ) - } + assertThat(pipeline.stream(StoreReadRequest.fresh(3))) + .emitsExactly( + StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), + StoreReadResponse.Data(value = "three-2", origin = StoreReadResponseOrigin.Fetcher()), + ) + } } -class FakeRxFetcher( - vararg val responses: Pair, -) { - private var index = 0 +class FakeRxFetcher(vararg val responses: Pair) { + private var index = 0 - @Suppress("RedundantSuspendModifier") // needed for function reference - fun fetch(key: Key): Single { - // will throw if fetcher called more than twice - if (index >= responses.size) { - throw AssertionError("unexpected fetch request") - } - val pair = responses[index++] - assertThat(pair.first).isEqualTo(key) - return Single.just(pair.second) + @Suppress("RedundantSuspendModifier") // needed for function reference + fun fetch(key: Key): Single { + // will throw if fetcher called more than twice + if (index >= responses.size) { + throw AssertionError("unexpected fetch request") } + val pair = responses[index++] + assertThat(pair.first).isEqualTo(key) + return Single.just(pair.second) + } } diff --git a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxFlowableStoreTest.kt b/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxFlowableStoreTest.kt index d7ec5d998..80fdd8a66 100644 --- a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxFlowableStoreTest.kt +++ b/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxFlowableStoreTest.kt @@ -4,6 +4,7 @@ import io.reactivex.BackpressureStrategy import io.reactivex.Completable import io.reactivex.Flowable import io.reactivex.schedulers.TestScheduler +import java.util.concurrent.atomic.AtomicInteger import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import org.junit.Test @@ -20,99 +21,86 @@ import org.mobilenativefoundation.store.store5.StoreBuilder import org.mobilenativefoundation.store.store5.StoreReadRequest import org.mobilenativefoundation.store.store5.StoreReadResponse import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin -import java.util.concurrent.atomic.AtomicInteger @RunWith(JUnit4::class) @FlowPreview @ExperimentalCoroutinesApi class RxFlowableStoreTest { - private val testScheduler = TestScheduler() - private val atomicInteger = AtomicInteger(0) - private val fakeDisk = mutableMapOf() - private val store = - StoreBuilder.from( - fetcher = - Fetcher.ofResultFlowable { - Flowable.create( - { emitter -> - emitter.onNext( - FetcherResult.Data("$it ${atomicInteger.incrementAndGet()} occurrence"), - ) - emitter.onNext( - FetcherResult.Data("$it ${atomicInteger.incrementAndGet()} occurrence"), - ) - emitter.onComplete() - }, - BackpressureStrategy.BUFFER, - ) - }, - sourceOfTruth = - SourceOfTruth.ofFlowable( - reader = { - if (fakeDisk[it] != null) { - Flowable.fromCallable { fakeDisk[it]!! } - } else { - Flowable.empty() - } - }, - writer = { key, value -> - Completable.fromAction { fakeDisk[key] = value } - }, - ), - ) - .withScheduler(testScheduler) - .build() - - @Test - fun simpleTest() { - val testSubscriber1 = - store.observe(StoreReadRequest.fresh(3)) - .subscribeOn(testScheduler) - .test() - testScheduler.triggerActions() - testSubscriber1 - .awaitCount(3) - .assertValues( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("3 1 occurrence", StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("3 2 occurrence", StoreReadResponseOrigin.Fetcher()), + private val testScheduler = TestScheduler() + private val atomicInteger = AtomicInteger(0) + private val fakeDisk = mutableMapOf() + private val store = + StoreBuilder.from( + fetcher = + Fetcher.ofResultFlowable { + Flowable.create( + { emitter -> + emitter.onNext( + FetcherResult.Data("$it ${atomicInteger.incrementAndGet()} occurrence") + ) + emitter.onNext( + FetcherResult.Data("$it ${atomicInteger.incrementAndGet()} occurrence") + ) + emitter.onComplete() + }, + BackpressureStrategy.BUFFER, ) + }, + sourceOfTruth = + SourceOfTruth.ofFlowable( + reader = { + if (fakeDisk[it] != null) { + Flowable.fromCallable { fakeDisk[it]!! } + } else { + Flowable.empty() + } + }, + writer = { key, value -> Completable.fromAction { fakeDisk[key] = value } }, + ), + ) + .withScheduler(testScheduler) + .build() - val testSubscriber2 = - store.observe(StoreReadRequest.cached(3, false)) - .subscribeOn(testScheduler) - .test() - testScheduler.triggerActions() - testSubscriber2 - .awaitCount(2) - .assertValues( - StoreReadResponse.Data("3 2 occurrence", StoreReadResponseOrigin.Cache), - StoreReadResponse.Data("3 2 occurrence", StoreReadResponseOrigin.SourceOfTruth), - ) + @Test + fun simpleTest() { + val testSubscriber1 = store.observe(StoreReadRequest.fresh(3)).subscribeOn(testScheduler).test() + testScheduler.triggerActions() + testSubscriber1 + .awaitCount(3) + .assertValues( + StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), + StoreReadResponse.Data("3 1 occurrence", StoreReadResponseOrigin.Fetcher()), + StoreReadResponse.Data("3 2 occurrence", StoreReadResponseOrigin.Fetcher()), + ) - val testSubscriber3 = - store.observe(StoreReadRequest.fresh(3)) - .subscribeOn(testScheduler) - .test() - testScheduler.triggerActions() - testSubscriber3 - .awaitCount(3) - .assertValues( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("3 3 occurrence", StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("3 4 occurrence", StoreReadResponseOrigin.Fetcher()), - ) + val testSubscriber2 = + store.observe(StoreReadRequest.cached(3, false)).subscribeOn(testScheduler).test() + testScheduler.triggerActions() + testSubscriber2 + .awaitCount(2) + .assertValues( + StoreReadResponse.Data("3 2 occurrence", StoreReadResponseOrigin.Cache), + StoreReadResponse.Data("3 2 occurrence", StoreReadResponseOrigin.SourceOfTruth), + ) - val testSubscriber4 = - store.observe(StoreReadRequest.cached(3, false)) - .subscribeOn(testScheduler) - .test() - testScheduler.triggerActions() - testSubscriber4 - .awaitCount(2) - .assertValues( - StoreReadResponse.Data("3 4 occurrence", StoreReadResponseOrigin.Cache), - StoreReadResponse.Data("3 4 occurrence", StoreReadResponseOrigin.SourceOfTruth), - ) - } + val testSubscriber3 = store.observe(StoreReadRequest.fresh(3)).subscribeOn(testScheduler).test() + testScheduler.triggerActions() + testSubscriber3 + .awaitCount(3) + .assertValues( + StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), + StoreReadResponse.Data("3 3 occurrence", StoreReadResponseOrigin.Fetcher()), + StoreReadResponse.Data("3 4 occurrence", StoreReadResponseOrigin.Fetcher()), + ) + + val testSubscriber4 = + store.observe(StoreReadRequest.cached(3, false)).subscribeOn(testScheduler).test() + testScheduler.triggerActions() + testSubscriber4 + .awaitCount(2) + .assertValues( + StoreReadResponse.Data("3 4 occurrence", StoreReadResponseOrigin.Cache), + StoreReadResponse.Data("3 4 occurrence", StoreReadResponseOrigin.SourceOfTruth), + ) + } } diff --git a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxSingleStoreExtensionsTest.kt b/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxSingleStoreExtensionsTest.kt index 6ddee0df1..f1f8f8a6a 100644 --- a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxSingleStoreExtensionsTest.kt +++ b/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxSingleStoreExtensionsTest.kt @@ -4,6 +4,7 @@ import io.reactivex.Completable import io.reactivex.Maybe import io.reactivex.Single import io.reactivex.schedulers.Schedulers +import java.util.concurrent.atomic.AtomicInteger import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import org.junit.Test @@ -19,62 +20,43 @@ import org.mobilenativefoundation.store.store5.Fetcher import org.mobilenativefoundation.store.store5.FetcherResult import org.mobilenativefoundation.store.store5.SourceOfTruth import org.mobilenativefoundation.store.store5.StoreBuilder -import java.util.concurrent.atomic.AtomicInteger @ExperimentalStoreApi @RunWith(JUnit4::class) @FlowPreview @ExperimentalCoroutinesApi class RxSingleStoreExtensionsTest { - private val atomicInteger = AtomicInteger(0) - private var fakeDisk = mutableMapOf() - private val store = - StoreBuilder.from( - fetcher = - Fetcher.ofResultSingle { - Single.fromCallable { FetcherResult.Data("$it ${atomicInteger.incrementAndGet()}") } - }, - sourceOfTruth = - SourceOfTruth.ofMaybe( - reader = { Maybe.fromCallable { fakeDisk[it] } }, - writer = { key, value -> - Completable.fromAction { fakeDisk[key] = value } - }, - delete = { key -> - Completable.fromAction { fakeDisk.remove(key) } - }, - deleteAll = { - Completable.fromAction { fakeDisk.clear() } - }, - ), - ) - .withScheduler(Schedulers.trampoline()) - .build() + private val atomicInteger = AtomicInteger(0) + private var fakeDisk = mutableMapOf() + private val store = + StoreBuilder.from( + fetcher = + Fetcher.ofResultSingle { + Single.fromCallable { FetcherResult.Data("$it ${atomicInteger.incrementAndGet()}") } + }, + sourceOfTruth = + SourceOfTruth.ofMaybe( + reader = { Maybe.fromCallable { fakeDisk[it] } }, + writer = { key, value -> Completable.fromAction { fakeDisk[key] = value } }, + delete = { key -> Completable.fromAction { fakeDisk.remove(key) } }, + deleteAll = { Completable.fromAction { fakeDisk.clear() } }, + ), + ) + .withScheduler(Schedulers.trampoline()) + .build() - @Test - fun `store rx extension tests`() { - // Return from cache - after initial fetch - store.getSingle(3) - .test() - .await() - .assertValue("3 1") + @Test + fun `store rx extension tests`() { + // Return from cache - after initial fetch + store.getSingle(3).test().await().assertValue("3 1") - // Return from cache - store.getSingle(3) - .test() - .await() - .assertValue("3 1") + // Return from cache + store.getSingle(3).test().await().assertValue("3 1") - // Return from fresh - forcing a new fetch - store.freshSingle(3) - .test() - .await() - .assertValue("3 2") + // Return from fresh - forcing a new fetch + store.freshSingle(3).test().await().assertValue("3 2") - // Return from cache - different to initial - store.getSingle(3) - .test() - .await() - .assertValue("3 2") - } + // Return from cache - different to initial + store.getSingle(3).test().await().assertValue("3 2") + } } diff --git a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxSingleStoreTest.kt b/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxSingleStoreTest.kt index 6276514de..796b72d99 100644 --- a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxSingleStoreTest.kt +++ b/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxSingleStoreTest.kt @@ -4,6 +4,7 @@ import io.reactivex.Completable import io.reactivex.Maybe import io.reactivex.Single import io.reactivex.schedulers.Schedulers +import java.util.concurrent.atomic.AtomicInteger import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import org.junit.Test @@ -23,109 +24,109 @@ import org.mobilenativefoundation.store.store5.StoreBuilder import org.mobilenativefoundation.store.store5.StoreReadRequest import org.mobilenativefoundation.store.store5.StoreReadResponse import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin -import java.util.concurrent.atomic.AtomicInteger @ExperimentalStoreApi @RunWith(JUnit4::class) @FlowPreview @ExperimentalCoroutinesApi class RxSingleStoreTest { - private val atomicInteger = AtomicInteger(0) - private var fakeDisk = mutableMapOf() - private val store = - StoreBuilder.from( - fetcher = - Fetcher.ofResultSingle { - Single.fromCallable { FetcherResult.Data("$it ${atomicInteger.incrementAndGet()}") } - }, - sourceOfTruth = - SourceOfTruth.ofMaybe( - reader = { Maybe.fromCallable { fakeDisk[it] } }, - writer = { key, value -> - Completable.fromAction { fakeDisk[key] = value } - }, - delete = { key -> - Completable.fromAction { fakeDisk.remove(key) } - }, - deleteAll = { - Completable.fromAction { fakeDisk.clear() } - }, - ), - ) - .withScheduler(Schedulers.trampoline()) - .build() + private val atomicInteger = AtomicInteger(0) + private var fakeDisk = mutableMapOf() + private val store = + StoreBuilder.from( + fetcher = + Fetcher.ofResultSingle { + Single.fromCallable { FetcherResult.Data("$it ${atomicInteger.incrementAndGet()}") } + }, + sourceOfTruth = + SourceOfTruth.ofMaybe( + reader = { Maybe.fromCallable { fakeDisk[it] } }, + writer = { key, value -> Completable.fromAction { fakeDisk[key] = value } }, + delete = { key -> Completable.fromAction { fakeDisk.remove(key) } }, + deleteAll = { Completable.fromAction { fakeDisk.clear() } }, + ), + ) + .withScheduler(Schedulers.trampoline()) + .build() - @Test - fun simpleTest() { - store.observe(StoreReadRequest.cached(3, false)) - .test() - .awaitCount(2) - .assertValues( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("3 1", StoreReadResponseOrigin.Fetcher()), - ) + @Test + fun simpleTest() { + store + .observe(StoreReadRequest.cached(3, false)) + .test() + .awaitCount(2) + .assertValues( + StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), + StoreReadResponse.Data("3 1", StoreReadResponseOrigin.Fetcher()), + ) - store.observe(StoreReadRequest.cached(3, false)) - .test() - .awaitCount(2) - .assertValues( - StoreReadResponse.Data("3 1", StoreReadResponseOrigin.Cache), - StoreReadResponse.Data("3 1", StoreReadResponseOrigin.SourceOfTruth), - ) + store + .observe(StoreReadRequest.cached(3, false)) + .test() + .awaitCount(2) + .assertValues( + StoreReadResponse.Data("3 1", StoreReadResponseOrigin.Cache), + StoreReadResponse.Data("3 1", StoreReadResponseOrigin.SourceOfTruth), + ) - store.observe(StoreReadRequest.fresh(3)) - .test() - .awaitCount(2) - .assertValues( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("3 2", StoreReadResponseOrigin.Fetcher()), - ) + store + .observe(StoreReadRequest.fresh(3)) + .test() + .awaitCount(2) + .assertValues( + StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), + StoreReadResponse.Data("3 2", StoreReadResponseOrigin.Fetcher()), + ) - store.observe(StoreReadRequest.cached(3, false)) - .test() - .awaitCount(2) - .assertValues( - StoreReadResponse.Data("3 2", StoreReadResponseOrigin.Cache), - StoreReadResponse.Data("3 2", StoreReadResponseOrigin.SourceOfTruth), - ) - } + store + .observe(StoreReadRequest.cached(3, false)) + .test() + .awaitCount(2) + .assertValues( + StoreReadResponse.Data("3 2", StoreReadResponseOrigin.Cache), + StoreReadResponse.Data("3 2", StoreReadResponseOrigin.SourceOfTruth), + ) + } - @Test - fun `GIVEN a store with persister values WHEN observeClear is Called THEN next Store get hits network`() { - fakeDisk[3] = "seeded occurrence" + @Test + fun `GIVEN a store with persister values WHEN observeClear is Called THEN next Store get hits network`() { + fakeDisk[3] = "seeded occurrence" - store.observeClear(3).blockingGet() + store.observeClear(3).blockingGet() - store.observe(StoreReadRequest.cached(3, false)) - .test() - .awaitCount(2) - .assertValues( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("3 1", StoreReadResponseOrigin.Fetcher()), - ) - } + store + .observe(StoreReadRequest.cached(3, false)) + .test() + .awaitCount(2) + .assertValues( + StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), + StoreReadResponse.Data("3 1", StoreReadResponseOrigin.Fetcher()), + ) + } - @Test - fun `GIVEN a store with persister values WHEN observeClearAll is called THEN next Store get calls both hit network`() { - fakeDisk[3] = "seeded occurrence" - fakeDisk[4] = "another seeded occurrence" + @Test + fun `GIVEN a store with persister values WHEN observeClearAll is called THEN next Store get calls both hit network`() { + fakeDisk[3] = "seeded occurrence" + fakeDisk[4] = "another seeded occurrence" - store.observeClearAll().blockingGet() + store.observeClearAll().blockingGet() - store.observe(StoreReadRequest.cached(3, false)) - .test() - .awaitCount(2) - .assertValues( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("3 1", StoreReadResponseOrigin.Fetcher()), - ) + store + .observe(StoreReadRequest.cached(3, false)) + .test() + .awaitCount(2) + .assertValues( + StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), + StoreReadResponse.Data("3 1", StoreReadResponseOrigin.Fetcher()), + ) - store.observe(StoreReadRequest.cached(4, false)) - .test() - .awaitCount(2) - .assertValues( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("4 2", StoreReadResponseOrigin.Fetcher()), - ) - } + store + .observe(StoreReadRequest.cached(4, false)) + .test() + .awaitCount(2) + .assertValues( + StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), + StoreReadResponse.Data("4 2", StoreReadResponseOrigin.Fetcher()), + ) + } } diff --git a/store/build.gradle.kts b/store/build.gradle.kts index 3b928ce89..48ecc3a97 100644 --- a/store/build.gradle.kts +++ b/store/build.gradle.kts @@ -1,49 +1,45 @@ import org.gradle.internal.impldep.org.testng.reporters.XMLUtils.xml - plugins { - id("org.mobilenativefoundation.store.multiplatform") - alias(libs.plugins.kover) + id("org.mobilenativefoundation.store.multiplatform") + alias(libs.plugins.kover) } kotlin { - sourceSets { - val commonMain by getting { - dependencies { - implementation(libs.kotlin.stdlib) - implementation(libs.kotlinx.coroutines.core) - implementation(libs.kotlinx.serialization.core) - implementation(libs.kotlinx.datetime) - api(libs.kotlinx.atomic.fu) - implementation(libs.touchlab.kermit) - implementation(projects.multicast) - implementation(projects.cache) - api(projects.core) - } - } + sourceSets { + val commonMain by getting { + dependencies { + implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.core) + implementation(libs.kotlinx.datetime) + api(libs.kotlinx.atomic.fu) + implementation(libs.touchlab.kermit) + implementation(projects.multicast) + implementation(projects.cache) + api(projects.core) + } + } - val commonTest by getting { - dependencies { - implementation(libs.junit) - implementation(libs.kotlinx.coroutines.test) - implementation(libs.turbine) - } - } + val commonTest by getting { + dependencies { + implementation(libs.junit) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.turbine) + } } + } } -android { - namespace = "org.mobilenativefoundation.store.store5" -} +android { namespace = "org.mobilenativefoundation.store.store5" } kover { - - reports { - total { - xml { - onCheck = true - xmlFile.set(file("${layout.buildDirectory}/reports/kover/coverage.xml")) - } - } + reports { + total { + xml { + onCheck = true + xmlFile.set(file("${layout.buildDirectory}/reports/kover/coverage.xml")) + } } + } } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Bookkeeper.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Bookkeeper.kt index 35b3e42db..be175bf0e 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Bookkeeper.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Bookkeeper.kt @@ -6,27 +6,25 @@ import org.mobilenativefoundation.store.store5.impl.extensions.now /** * Tracks when local changes fail to sync with network. - * @see [RealMutableStore] usage to persist write request failures and eagerly resolve conflicts before completing a read request. + * + * @see [RealMutableStore] usage to persist write request failures and eagerly resolve conflicts + * before completing a read request. */ - interface Bookkeeper { - suspend fun getLastFailedSync(key: Key): Long? + suspend fun getLastFailedSync(key: Key): Long? - suspend fun setLastFailedSync( - key: Key, - timestamp: Long = now(), - ): Boolean + suspend fun setLastFailedSync(key: Key, timestamp: Long = now()): Boolean - suspend fun clear(key: Key): Boolean + suspend fun clear(key: Key): Boolean - suspend fun clearAll(): Boolean + suspend fun clearAll(): Boolean - companion object { - fun by( - getLastFailedSync: suspend (key: Key) -> Long?, - setLastFailedSync: suspend (key: Key, timestamp: Long) -> Boolean, - clear: suspend (key: Key) -> Boolean, - clearAll: suspend () -> Boolean, - ): Bookkeeper = RealBookkeeper(getLastFailedSync, setLastFailedSync, clear, clearAll) - } + companion object { + fun by( + getLastFailedSync: suspend (key: Key) -> Long?, + setLastFailedSync: suspend (key: Key, timestamp: Long) -> Boolean, + clear: suspend (key: Key) -> Boolean, + clearAll: suspend () -> Boolean, + ): Bookkeeper = RealBookkeeper(getLastFailedSync, setLastFailedSync, clear, clearAll) + } } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Clear.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Clear.kt index 0311d7455..c1eb63201 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Clear.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Clear.kt @@ -3,22 +3,21 @@ package org.mobilenativefoundation.store.store5 import org.mobilenativefoundation.store.core5.ExperimentalStoreApi interface Clear { - interface Key { - /** - * Purge a particular entry from memory and disk cache. - * Persistent storage will only be cleared if a delete function was passed to - * [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store]. - */ - suspend fun clear(key: Key) - } + interface Key { + /** + * Purge a particular entry from memory and disk cache. Persistent storage will only be cleared + * if a delete function was passed to [StoreBuilder.persister] or + * [StoreBuilder.nonFlowingPersister] when creating the [Store]. + */ + suspend fun clear(key: Key) + } - interface All { - /** - * Purge all entries from memory and disk cache. - * Persistent storage will only be cleared if a clear function was passed to - * [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store]. - */ - @ExperimentalStoreApi - suspend fun clear() - } + interface All { + /** + * Purge all entries from memory and disk cache. Persistent storage will only be cleared if a + * clear function was passed to [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] + * when creating the [Store]. + */ + @ExperimentalStoreApi suspend fun clear() + } } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Converter.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Converter.kt index b6bbe74a3..f48003882 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Converter.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Converter.kt @@ -1,42 +1,48 @@ package org.mobilenativefoundation.store.store5 /** - * Converter is a utility interface that can be used to convert a network or output model to a local model. - * Network to Local conversion is needed when the network model is different what you are saving in - * your Source of Truth. - * Output to Local conversion is needed when you are doing local writes in a MutableStore + * Converter is a utility interface that can be used to convert a network or output model to a local + * model. Network to Local conversion is needed when the network model is different what you are + * saving in your Source of Truth. Output to Local conversion is needed when you are doing local + * writes in a MutableStore + * * @param Network The network data source model type. This is the type used in [Fetcher] - * @param Output The common model type emitted from Store, typically the type returend from your Source of Truth - * @param Local The local data source model type. This is the type used to save to your Source of Truth + * @param Output The common model type emitted from Store, typically the type returend from your + * Source of Truth + * @param Local The local data source model type. This is the type used to save to your Source of + * Truth */ interface Converter { - fun fromNetworkToLocal(network: Network): Local + fun fromNetworkToLocal(network: Network): Local - fun fromOutputToLocal(output: Output): Local + fun fromOutputToLocal(output: Output): Local - class Builder { - lateinit var fromOutputToLocal: ((output: Output) -> Local) - lateinit var fromNetworkToLocal: ((network: Network) -> Local) + class Builder { + lateinit var fromOutputToLocal: ((output: Output) -> Local) + lateinit var fromNetworkToLocal: ((network: Network) -> Local) - fun build(): Converter = RealConverter(fromOutputToLocal, fromNetworkToLocal) + fun build(): Converter = + RealConverter(fromOutputToLocal, fromNetworkToLocal) - fun fromOutputToLocal(converter: (output: Output) -> Local): Builder { - fromOutputToLocal = converter - return this - } + fun fromOutputToLocal(converter: (output: Output) -> Local): Builder { + fromOutputToLocal = converter + return this + } - fun fromNetworkToLocal(converter: (network: Network) -> Local): Builder { - fromNetworkToLocal = converter - return this - } + fun fromNetworkToLocal( + converter: (network: Network) -> Local + ): Builder { + fromNetworkToLocal = converter + return this } + } } private class RealConverter( - private val fromOutputToLocal: ((output: Output) -> Local), - private val fromNetworkToLocal: ((network: Network) -> Local), + private val fromOutputToLocal: ((output: Output) -> Local), + private val fromNetworkToLocal: ((network: Network) -> Local), ) : Converter { - override fun fromNetworkToLocal(network: Network): Local = fromNetworkToLocal.invoke(network) + override fun fromNetworkToLocal(network: Network): Local = fromNetworkToLocal.invoke(network) - override fun fromOutputToLocal(output: Output): Local = fromOutputToLocal.invoke(output) + override fun fromOutputToLocal(output: Output): Local = fromOutputToLocal.invoke(output) } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Fetcher.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Fetcher.kt index 940e1ee20..3c807f3fd 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Fetcher.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Fetcher.kt @@ -16,185 +16,188 @@ import org.mobilenativefoundation.store.store5.Fetcher.Companion.ofResult * Note: Store does not catch exceptions thrown by a [Fetcher]. This is done in order to avoid * silently swallowing NPEs and such. Use [FetcherResult.Error] to communicate expected errors. * - * See [ofResult] for easily translating from a regular `suspend` function. - * See [ofFlow], [of] for easily translating to [FetcherResult] (and - * automatically transforming exceptions into [FetcherResult.Error]). + * See [ofResult] for easily translating from a regular `suspend` function. See [ofFlow], [of] for + * easily translating to [FetcherResult] (and automatically transforming exceptions into + * [FetcherResult.Error]). * * @property name Unique name to enable differentiation when [fallback] exists. */ interface Fetcher { - val name: String? + val name: String? - val fallback: Fetcher? + val fallback: Fetcher? + /** Returns a flow of the item represented by the given [key]. */ + operator fun invoke(key: Key): Flow> + + companion object { /** - * Returns a flow of the item represented by the given [key]. + * "Creates" a [Fetcher] from a [flowFactory]. + * + * Use when creating a [Store] that fetches objects in a multiple responses per request network + * protocol (e.g., Web Sockets). + * + * [Store] does not catch exception thrown in [flowFactory] or in the returned [Flow]. These + * exception will be propagated to the caller. + * + * @param flowFactory a factory for a [Flow]ing source of network records. */ - operator fun invoke(key: Key): Flow> - - companion object { - /** - * "Creates" a [Fetcher] from a [flowFactory]. - * - * Use when creating a [Store] that fetches objects in a multiple responses per request - * network protocol (e.g., Web Sockets). - * - * [Store] does not catch exception thrown in [flowFactory] or in the returned [Flow]. These - * exception will be propagated to the caller. - * - * @param flowFactory a factory for a [Flow]ing source of network records. - */ - fun ofResultFlow(flowFactory: (Key) -> Flow>): Fetcher = - FactoryFetcher(factory = flowFactory) - - /** - * Creates a [Fetcher] with a [fallback] from a [flowFactory]. - * Use instead of [ofResultFlow] if implementing fallback mechanisms. - * @param name Unique name to enable differentiation of fetchers. - */ - fun ofResultFlowWithFallback( - name: String, - flowFactory: (Key) -> Flow>, - fallback: Fetcher, - ): Fetcher = FactoryFetcherWithFallback(name = name, factory = flowFactory, fallback = fallback) - - /** - * "Creates" a [Fetcher] from a non-[Flow] source. - * - * Use when creating a [Store] that fetches objects in a single response per request network - * protocol (e.g., Http). - * - * [Store] does not catch exception thrown in [fetch]. These exception will be propagated to the - * caller. - * - * @param fetch a source of network records. - */ - fun ofResult(fetch: suspend (Key) -> FetcherResult): Fetcher = - ofResultFlow(fetch.asFlow()) - - /** - * Creates a [Fetcher] with a [fallback] from a non-Flow source. - * Use instead of [ofResult] if implementing fallback mechanisms. - * @param name Unique name to enable differentiation of fetchers. - */ - fun ofResultWithFallback( - name: String, - fetch: suspend (Key) -> FetcherResult, - fallback: Fetcher, - ): Fetcher = ofResultFlowWithFallback(name, fetch.asFlow(), fallback) - - /** - * "Creates" a [Fetcher] from a [flowFactory] and translate the results to a [FetcherResult]. - * - * Emitted values will be wrapped in [FetcherResult.Data]. if an exception disrupts the flow then - * it will be wrapped in [FetcherResult.Error]. Exceptions thrown in [flowFactory] itself are not - * caught and will be returned to the caller. - * - * Use when creating a [Store] that fetches objects in a multiple responses per request - * network protocol (e.g Web Sockets). - * - * @param flowFactory a factory for a [Flow]ing source of network records. - */ - fun ofFlow( - name: String? = null, - flowFactory: (Key) -> Flow, - ): Fetcher = - FactoryFetcher { key: Key -> - flowFactory(key) - .map> { FetcherResult.Data(it, name) } - .catch { throwable: Throwable -> emit(FetcherResult.Error.Exception(throwable)) } - } + fun ofResultFlow( + flowFactory: (Key) -> Flow> + ): Fetcher = FactoryFetcher(factory = flowFactory) - /** - * Creates a [Fetcher] with a [fallback] from a [flowFactory]. - * Use instead of [ofFlow] if implementing fallback mechanisms. - * @param name Unique name to enable differentiation of fetchers - */ - fun ofFlowWithFallback( - name: String, - fallback: Fetcher, - flowFactory: (Key) -> Flow, - ): Fetcher = - FactoryFetcherWithFallback(name = name, factory = { key: Key -> - flowFactory(key) - .map> { - FetcherResult.Data(it, name) - } - .catch { throwable: Throwable -> emit(FetcherResult.Error.Exception(throwable)) } - }, fallback = fallback) - - /** - * "Creates" a [Fetcher] from a non-[Flow] source and translate the results to a [FetcherResult]. - * - * Emitted values will be wrapped in [FetcherResult.Data]. if an exception disrupts the flow then - * it will be wrapped in [FetcherResult.Error] - * - * Use when creating a [Store] that fetches objects in a single response per request - * network protocol (e.g Http). - * - * @param fetch a source of network records. - */ - fun of( - name: String? = null, - fetch: suspend (key: Key) -> Network, - ): Fetcher = ofFlow(name, fetch.asFlow()) - - /** - * Creates a [Fetcher] with a [fallback] from a non-Flow source. - * Use instead of [of] if implementing fallback mechanisms. - * @param name Unique name to enable differentiation of fetchers - */ - fun withFallback( - name: String, - fallback: Fetcher, - fetch: suspend (key: Key) -> Network, - ): Fetcher = ofFlowWithFallback(name, fallback, fetch.asFlow()) - - private fun (suspend (key: Key) -> Network).asFlow() = - { key: Key -> - flow { - emit(invoke(key)) - } - } + /** + * Creates a [Fetcher] with a [fallback] from a [flowFactory]. Use instead of [ofResultFlow] if + * implementing fallback mechanisms. + * + * @param name Unique name to enable differentiation of fetchers. + */ + fun ofResultFlowWithFallback( + name: String, + flowFactory: (Key) -> Flow>, + fallback: Fetcher, + ): Fetcher = + FactoryFetcherWithFallback(name = name, factory = flowFactory, fallback = fallback) - private class FactoryFetcher( - private val factory: (Key) -> Flow>, - ) : Fetcher { - override val name: String? = null - override val fallback: Fetcher? = null + /** + * "Creates" a [Fetcher] from a non-[Flow] source. + * + * Use when creating a [Store] that fetches objects in a single response per request network + * protocol (e.g., Http). + * + * [Store] does not catch exception thrown in [fetch]. These exception will be propagated to the + * caller. + * + * @param fetch a source of network records. + */ + fun ofResult( + fetch: suspend (Key) -> FetcherResult + ): Fetcher = ofResultFlow(fetch.asFlow()) - override fun invoke(key: Key): Flow> = factory(key) - } + /** + * Creates a [Fetcher] with a [fallback] from a non-Flow source. Use instead of [ofResult] if + * implementing fallback mechanisms. + * + * @param name Unique name to enable differentiation of fetchers. + */ + fun ofResultWithFallback( + name: String, + fetch: suspend (Key) -> FetcherResult, + fallback: Fetcher, + ): Fetcher = ofResultFlowWithFallback(name, fetch.asFlow(), fallback) - private fun tryFetch( - key: Key, - factory: (Key) -> Flow>, - fallback: Fetcher?, - ): Flow> = - channelFlow { - factory(key).collect { fetcherResult -> - when (fetcherResult) { - is FetcherResult.Data -> { - send(fetcherResult) - } - - is FetcherResult.Error -> { - if (fallback != null) { - tryFetch(key, fallback::invoke, fallback.fallback).collect { send(it) } - } else { - send(fetcherResult) - } - } - } - } - } + /** + * "Creates" a [Fetcher] from a [flowFactory] and translate the results to a [FetcherResult]. + * + * Emitted values will be wrapped in [FetcherResult.Data]. if an exception disrupts the flow + * then it will be wrapped in [FetcherResult.Error]. Exceptions thrown in [flowFactory] itself + * are not caught and will be returned to the caller. + * + * Use when creating a [Store] that fetches objects in a multiple responses per request network + * protocol (e.g Web Sockets). + * + * @param flowFactory a factory for a [Flow]ing source of network records. + */ + fun ofFlow( + name: String? = null, + flowFactory: (Key) -> Flow, + ): Fetcher = FactoryFetcher { key: Key -> + flowFactory(key) + .map> { FetcherResult.Data(it, name) } + .catch { throwable: Throwable -> emit(FetcherResult.Error.Exception(throwable)) } + } + + /** + * Creates a [Fetcher] with a [fallback] from a [flowFactory]. Use instead of [ofFlow] if + * implementing fallback mechanisms. + * + * @param name Unique name to enable differentiation of fetchers + */ + fun ofFlowWithFallback( + name: String, + fallback: Fetcher, + flowFactory: (Key) -> Flow, + ): Fetcher = + FactoryFetcherWithFallback( + name = name, + factory = { key: Key -> + flowFactory(key) + .map> { FetcherResult.Data(it, name) } + .catch { throwable: Throwable -> emit(FetcherResult.Error.Exception(throwable)) } + }, + fallback = fallback, + ) + + /** + * "Creates" a [Fetcher] from a non-[Flow] source and translate the results to a + * [FetcherResult]. + * + * Emitted values will be wrapped in [FetcherResult.Data]. if an exception disrupts the flow + * then it will be wrapped in [FetcherResult.Error] + * + * Use when creating a [Store] that fetches objects in a single response per request network + * protocol (e.g Http). + * + * @param fetch a source of network records. + */ + fun of( + name: String? = null, + fetch: suspend (key: Key) -> Network, + ): Fetcher = ofFlow(name, fetch.asFlow()) - private class FactoryFetcherWithFallback( - override val name: String, - private val factory: (Key) -> Flow>, - override val fallback: Fetcher, - ) : Fetcher { - override fun invoke(key: Key): Flow> = tryFetch(key, factory, fallback) + /** + * Creates a [Fetcher] with a [fallback] from a non-Flow source. Use instead of [of] if + * implementing fallback mechanisms. + * + * @param name Unique name to enable differentiation of fetchers + */ + fun withFallback( + name: String, + fallback: Fetcher, + fetch: suspend (key: Key) -> Network, + ): Fetcher = ofFlowWithFallback(name, fallback, fetch.asFlow()) + + private fun (suspend (key: Key) -> Network).asFlow() = { key: Key -> + flow { emit(invoke(key)) } + } + + private class FactoryFetcher( + private val factory: (Key) -> Flow> + ) : Fetcher { + override val name: String? = null + override val fallback: Fetcher? = null + + override fun invoke(key: Key): Flow> = factory(key) + } + + private fun tryFetch( + key: Key, + factory: (Key) -> Flow>, + fallback: Fetcher?, + ): Flow> = channelFlow { + factory(key).collect { fetcherResult -> + when (fetcherResult) { + is FetcherResult.Data -> { + send(fetcherResult) + } + + is FetcherResult.Error -> { + if (fallback != null) { + tryFetch(key, fallback::invoke, fallback.fallback).collect { send(it) } + } else { + send(fetcherResult) + } + } } + } + } + + private class FactoryFetcherWithFallback( + override val name: String, + private val factory: (Key) -> Flow>, + override val fallback: Fetcher, + ) : Fetcher { + override fun invoke(key: Key): Flow> = tryFetch(key, factory, fallback) } + } } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/FetcherResult.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/FetcherResult.kt index 1160e5416..df1b08032 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/FetcherResult.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/FetcherResult.kt @@ -1,13 +1,14 @@ package org.mobilenativefoundation.store.store5 sealed class FetcherResult { - data class Data(val value: Network, val origin: String? = null) : FetcherResult() + data class Data(val value: Network, val origin: String? = null) : + FetcherResult() - sealed class Error : FetcherResult() { - data class Exception(val error: Throwable) : Error() + sealed class Error : FetcherResult() { + data class Exception(val error: Throwable) : Error() - data class Message(val message: String) : Error() + data class Message(val message: String) : Error() - data class Custom(val error: E) : Error() - } + data class Custom(val error: E) : Error() + } } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Logger.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Logger.kt index d7318b9af..18b05715a 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Logger.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Logger.kt @@ -1,24 +1,19 @@ package org.mobilenativefoundation.store.store5 -/** - * A simple logging interface for logging error and debug messages. - */ +/** A simple logging interface for logging error and debug messages. */ interface Logger { - /** - * Logs an error message, optionally with a throwable. - * - * @param message The error message to log. - * @param throwable An optional [Throwable] associated with the error. - */ - fun error( - message: String, - throwable: Throwable? = null, - ) + /** + * Logs an error message, optionally with a throwable. + * + * @param message The error message to log. + * @param throwable An optional [Throwable] associated with the error. + */ + fun error(message: String, throwable: Throwable? = null) - /** - * Logs a debug message. - * - * @param message The debug message to log. - */ - fun debug(message: String) + /** + * Logs a debug message. + * + * @param message The debug message to log. + */ + fun debug(message: String) } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MemoryPolicy.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MemoryPolicy.kt index fe1e816f4..433778cd0 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MemoryPolicy.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MemoryPolicy.kt @@ -1,116 +1,107 @@ package org.mobilenativefoundation.store.store5 -import org.mobilenativefoundation.store.cache5.Cache import kotlin.time.Duration +import org.mobilenativefoundation.store.cache5.Cache fun interface Weigher { - /** - * Returns the weight of a cache entry. There is no unit for entry weights; rather they are simply - * relative to each other. - * - * @return the weight of the entry; must be non-negative - */ - fun weigh( - key: K, - value: V, - ): Int + /** + * Returns the weight of a cache entry. There is no unit for entry weights; rather they are simply + * relative to each other. + * + * @return the weight of the entry; must be non-negative + */ + fun weigh(key: K, value: V): Int } internal object OneWeigher : Weigher { - override fun weigh( - key: Any, - value: Any, - ): Int = 1 + override fun weigh(key: Any, value: Any): Int = 1 } /** - * Defines behavior of in-memory [Cache]. - * Used by [Store]. + * Defines behavior of in-memory [Cache]. Used by [Store]. + * * @see [Store] */ -class MemoryPolicy internal constructor( - val expireAfterWrite: Duration, - val expireAfterAccess: Duration, - val maxSize: Long, - val maxWeight: Long, - val weigher: Weigher, +class MemoryPolicy +internal constructor( + val expireAfterWrite: Duration, + val expireAfterAccess: Duration, + val maxSize: Long, + val maxWeight: Long, + val weigher: Weigher, ) { - val isDefaultWritePolicy: Boolean = expireAfterWrite == DEFAULT_DURATION_POLICY - - val hasWritePolicy: Boolean = expireAfterWrite != DEFAULT_DURATION_POLICY - - val hasAccessPolicy: Boolean = expireAfterAccess != DEFAULT_DURATION_POLICY - - val hasMaxSize: Boolean = maxSize != DEFAULT_SIZE_POLICY - - val hasMaxWeight: Boolean = maxWeight != DEFAULT_SIZE_POLICY - - class MemoryPolicyBuilder { - private var expireAfterWrite = DEFAULT_DURATION_POLICY - private var expireAfterAccess = DEFAULT_DURATION_POLICY - private var maxSize: Long = DEFAULT_SIZE_POLICY - private var maxWeight: Long = DEFAULT_SIZE_POLICY - private var weigher: Weigher = OneWeigher - - fun setExpireAfterWrite(expireAfterWrite: Duration): MemoryPolicyBuilder = - apply { - check(expireAfterAccess == DEFAULT_DURATION_POLICY) { - "Cannot set expireAfterWrite with expireAfterAccess already set" - } - this.expireAfterWrite = expireAfterWrite - } - - fun setExpireAfterAccess(expireAfterAccess: Duration): MemoryPolicyBuilder = - apply { - check(expireAfterWrite == DEFAULT_DURATION_POLICY) { - "Cannot set expireAfterAccess with expireAfterWrite already set" - } - this.expireAfterAccess = expireAfterAccess - } - - /** - * Sets the maximum number of items ([maxSize]) kept in the cache. - * - * When [maxSize] is 0, entries will be discarded immediately and no values will be cached. - * - * If not set, cache size will be unlimited. - */ - fun setMaxSize(maxSize: Long): MemoryPolicyBuilder = - apply { - check(maxWeight == DEFAULT_SIZE_POLICY && weigher == OneWeigher) { - "Cannot setMaxSize when maxWeight or weigher are already set" - } - check(maxSize >= 0) { "maxSize cannot be negative" } - this.maxSize = maxSize - } - - fun setWeigherAndMaxWeight( - weigher: Weigher, - maxWeight: Long, - ): MemoryPolicyBuilder = - apply { - check(maxSize == DEFAULT_SIZE_POLICY) { - "Cannot setWeigherAndMaxWeight when maxSize already set" - } - check(maxWeight >= 0) { "maxWeight cannot be negative" } - this.weigher = weigher - this.maxWeight = maxWeight - } - - fun build() = - MemoryPolicy( - expireAfterWrite = expireAfterWrite, - expireAfterAccess = expireAfterAccess, - maxSize = maxSize, - maxWeight = maxWeight, - weigher = weigher, - ) + val isDefaultWritePolicy: Boolean = expireAfterWrite == DEFAULT_DURATION_POLICY + + val hasWritePolicy: Boolean = expireAfterWrite != DEFAULT_DURATION_POLICY + + val hasAccessPolicy: Boolean = expireAfterAccess != DEFAULT_DURATION_POLICY + + val hasMaxSize: Boolean = maxSize != DEFAULT_SIZE_POLICY + + val hasMaxWeight: Boolean = maxWeight != DEFAULT_SIZE_POLICY + + class MemoryPolicyBuilder { + private var expireAfterWrite = DEFAULT_DURATION_POLICY + private var expireAfterAccess = DEFAULT_DURATION_POLICY + private var maxSize: Long = DEFAULT_SIZE_POLICY + private var maxWeight: Long = DEFAULT_SIZE_POLICY + private var weigher: Weigher = OneWeigher + + fun setExpireAfterWrite(expireAfterWrite: Duration): MemoryPolicyBuilder = apply { + check(expireAfterAccess == DEFAULT_DURATION_POLICY) { + "Cannot set expireAfterWrite with expireAfterAccess already set" + } + this.expireAfterWrite = expireAfterWrite } - companion object { - val DEFAULT_DURATION_POLICY: Duration = Duration.INFINITE - const val DEFAULT_SIZE_POLICY: Long = -1 + fun setExpireAfterAccess(expireAfterAccess: Duration): MemoryPolicyBuilder = apply { + check(expireAfterWrite == DEFAULT_DURATION_POLICY) { + "Cannot set expireAfterAccess with expireAfterWrite already set" + } + this.expireAfterAccess = expireAfterAccess + } - fun builder(): MemoryPolicyBuilder = MemoryPolicyBuilder() + /** + * Sets the maximum number of items ([maxSize]) kept in the cache. + * + * When [maxSize] is 0, entries will be discarded immediately and no values will be cached. + * + * If not set, cache size will be unlimited. + */ + fun setMaxSize(maxSize: Long): MemoryPolicyBuilder = apply { + check(maxWeight == DEFAULT_SIZE_POLICY && weigher == OneWeigher) { + "Cannot setMaxSize when maxWeight or weigher are already set" + } + check(maxSize >= 0) { "maxSize cannot be negative" } + this.maxSize = maxSize + } + + fun setWeigherAndMaxWeight( + weigher: Weigher, + maxWeight: Long, + ): MemoryPolicyBuilder = apply { + check(maxSize == DEFAULT_SIZE_POLICY) { + "Cannot setWeigherAndMaxWeight when maxSize already set" + } + check(maxWeight >= 0) { "maxWeight cannot be negative" } + this.weigher = weigher + this.maxWeight = maxWeight } + + fun build() = + MemoryPolicy( + expireAfterWrite = expireAfterWrite, + expireAfterAccess = expireAfterAccess, + maxSize = maxSize, + maxWeight = maxWeight, + weigher = weigher, + ) + } + + companion object { + val DEFAULT_DURATION_POLICY: Duration = Duration.INFINITE + const val DEFAULT_SIZE_POLICY: Long = -1 + + fun builder(): MemoryPolicyBuilder = MemoryPolicyBuilder() + } } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MutableStore.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MutableStore.kt index 8018451c5..bf6f3eebb 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MutableStore.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MutableStore.kt @@ -4,8 +4,8 @@ import org.mobilenativefoundation.store.core5.ExperimentalStoreApi @ExperimentalStoreApi interface MutableStore : - Read.StreamWithConflictResolution, - Write, - Write.Stream, - Clear.Key, - Clear + Read.StreamWithConflictResolution, + Write, + Write.Stream, + Clear.Key, + Clear diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MutableStoreBuilder.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MutableStoreBuilder.kt index 21ec51c89..050c1fa43 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MutableStoreBuilder.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MutableStoreBuilder.kt @@ -4,50 +4,50 @@ import kotlinx.coroutines.CoroutineScope import org.mobilenativefoundation.store.store5.impl.mutableStoreBuilderFromFetcherAndSourceOfTruth interface MutableStoreBuilder { - fun build( - updater: Updater, - bookkeeper: Bookkeeper? = null, - ): MutableStore + fun build( + updater: Updater, + bookkeeper: Bookkeeper? = null, + ): MutableStore - /** - * A store multicasts same [Output] value to many consumers (Similar to RxJava.share()), by default - * [Store] will open a global scope for management of shared responses, if instead you'd like to control - * the scope that sharing/multicasting happens in you can pass a @param [scope] - * - * @param scope - scope to use for sharing - */ - fun scope(scope: CoroutineScope): MutableStoreBuilder + /** + * A store multicasts same [Output] value to many consumers (Similar to RxJava.share()), by + * default [Store] will open a global scope for management of shared responses, if instead you'd + * like to control the scope that sharing/multicasting happens in you can pass a @param [scope] + * + * @param scope - scope to use for sharing + */ + fun scope(scope: CoroutineScope): MutableStoreBuilder - /** - * controls eviction policy for a store cache, use [MemoryPolicy.MemoryPolicyBuilder] to configure a TTL - * or size based eviction - * Example: MemoryPolicy.builder().setExpireAfterWrite(10.seconds).build() - */ - fun cachePolicy(memoryPolicy: MemoryPolicy?): MutableStoreBuilder + /** + * controls eviction policy for a store cache, use [MemoryPolicy.MemoryPolicyBuilder] to configure + * a TTL or size based eviction Example: + * MemoryPolicy.builder().setExpireAfterWrite(10.seconds).build() + */ + fun cachePolicy( + memoryPolicy: MemoryPolicy? + ): MutableStoreBuilder - /** - * by default a Store caches in memory with a default policy of max items = 100 - */ - fun disableCache(): MutableStoreBuilder + /** by default a Store caches in memory with a default policy of max items = 100 */ + fun disableCache(): MutableStoreBuilder - fun validator(validator: Validator): MutableStoreBuilder + fun validator(validator: Validator): MutableStoreBuilder - companion object { - /** - * Creates a new [MutableStoreBuilder] from a [Fetcher] and a [SourceOfTruth]. - * - * @param fetcher a function for fetching a flow of network records. - * @param sourceOfTruth a [SourceOfTruth] for the store. - */ - fun from( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - converter: Converter, - ): MutableStoreBuilder = - mutableStoreBuilderFromFetcherAndSourceOfTruth( - fetcher = fetcher, - sourceOfTruth = sourceOfTruth, - converter = converter, - ) - } + companion object { + /** + * Creates a new [MutableStoreBuilder] from a [Fetcher] and a [SourceOfTruth]. + * + * @param fetcher a function for fetching a flow of network records. + * @param sourceOfTruth a [SourceOfTruth] for the store. + */ + fun from( + fetcher: Fetcher, + sourceOfTruth: SourceOfTruth, + converter: Converter, + ): MutableStoreBuilder = + mutableStoreBuilderFromFetcherAndSourceOfTruth( + fetcher = fetcher, + sourceOfTruth = sourceOfTruth, + converter = converter, + ) + } } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/OnFetcherCompletion.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/OnFetcherCompletion.kt index 4ad18f3a1..f43531ce0 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/OnFetcherCompletion.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/OnFetcherCompletion.kt @@ -1,6 +1,6 @@ package org.mobilenativefoundation.store.store5 data class OnFetcherCompletion( - val onSuccess: (FetcherResult.Data) -> Unit, - val onFailure: (FetcherResult.Error) -> Unit, + val onSuccess: (FetcherResult.Data) -> Unit, + val onFailure: (FetcherResult.Error) -> Unit, ) diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/OnUpdaterCompletion.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/OnUpdaterCompletion.kt index 84c8544c5..5f92c86d5 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/OnUpdaterCompletion.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/OnUpdaterCompletion.kt @@ -1,6 +1,6 @@ package org.mobilenativefoundation.store.store5 data class OnUpdaterCompletion( - val onSuccess: (UpdaterResult.Success) -> Unit, - val onFailure: (UpdaterResult.Error) -> Unit, + val onSuccess: (UpdaterResult.Success) -> Unit, + val onFailure: (UpdaterResult.Error) -> Unit, ) diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Read.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Read.kt index e5c142b8f..7de24e71c 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Read.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Read.kt @@ -3,15 +3,16 @@ package org.mobilenativefoundation.store.store5 import kotlinx.coroutines.flow.Flow interface Read { - interface Stream { - /** - * Return a flow for the given key - * @param request - see [StoreReadRequest] for configurations - */ - fun stream(request: StoreReadRequest): Flow> - } + interface Stream { + /** + * Return a flow for the given key + * + * @param request - see [StoreReadRequest] for configurations + */ + fun stream(request: StoreReadRequest): Flow> + } - interface StreamWithConflictResolution { - fun stream(request: StoreReadRequest): Flow> - } + interface StreamWithConflictResolution { + fun stream(request: StoreReadRequest): Flow> + } } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruth.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruth.kt index 08a4fe3f8..e946b75ac 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruth.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruth.kt @@ -15,19 +15,18 @@ */ package org.mobilenativefoundation.store.store5 +import kotlin.jvm.JvmName import kotlinx.coroutines.flow.Flow import org.mobilenativefoundation.store.store5.impl.PersistentNonFlowingSourceOfTruth import org.mobilenativefoundation.store.store5.impl.PersistentSourceOfTruth -import kotlin.jvm.JvmName /** - * * [SourceOfTruth], as name implies, is the persistence API which [Store] uses to serve values to * the collectors. If provided, [Store] will only return values received from [SourceOfTruth] back * to the collectors. * - * In other words, values coming from the [Fetcher] will always be sent to the [SourceOfTruth] - * and will be read back via [reader] to be returned to the collector. + * In other words, values coming from the [Fetcher] will always be sent to the [SourceOfTruth] and + * will be read back via [reader] to be returned to the collector. * * This round-trip ensures the data is consistent across the application in case the [Fetcher] may * not return all fields or return a different class type than the app uses. It is particularly @@ -35,169 +34,149 @@ import kotlin.jvm.JvmName * as Store can observe these changes and update the collectors even before value is synced to the * backend. * - * Source of truth takes care of making any source (no matter if it has flowing reads or not) into - * a common flowing API. + * Source of truth takes care of making any source (no matter if it has flowing reads or not) into a + * common flowing API. * - * A source of truth is usually backed by local storage. It's purpose is to eliminate the need - * for waiting on network update before local modifications are available (via [Store.Stream.read]). - * - * For maximal flexibility, [writer]'s record type ([Input]] and [reader]'s record type - * ([Output]) are not identical. This allows us to read one type of objects from network and - * transform them to another type when placing them in local storage. + * A source of truth is usually backed by local storage. It's purpose is to eliminate the need for + * waiting on network update before local modifications are available (via [Store.Stream.read]). * + * For maximal flexibility, [writer]'s record type ([Input]] and [reader]'s record type ([Output]) + * are not identical. This allows us to read one type of objects from network and transform them to + * another type when placing them in local storage. */ interface SourceOfTruth { - /** - * Used by [Store] to read records from the source of truth. - * - * @param key The key to read for. - */ - fun reader(key: Key): Flow + /** + * Used by [Store] to read records from the source of truth. + * + * @param key The key to read for. + */ + fun reader(key: Key): Flow + + /** + * Used by [Store] to write records **coming in from the fetcher (network)** to the source of + * truth. + * + * **Note:** [Store] currently does not support updating the source of truth with local user + * updates (i.e writing record of type [Output]). However, any changes in the local database will + * still be visible via [Store.Stream.read] APIs as long as you are using a local storage that + * supports observability (e.g. Room, SQLDelight, Realm). + * + * @param key The key to update for. + */ + suspend fun write(key: Key, value: Local) + + /** + * Used by [Store] to delete records in the source of truth for the given key. + * + * @param key The key to delete for. + */ + suspend fun delete(key: Key) + /** Used by [Store] to delete all records in the source of truth. */ + suspend fun deleteAll() + + companion object { /** - * Used by [Store] to write records **coming in from the fetcher (network)** to the source of - * truth. - * - * **Note:** [Store] currently does not support updating the source of truth with local user - * updates (i.e writing record of type [Output]). However, any changes in the local database - * will still be visible via [Store.Stream.read] APIs as long as you are using a local storage that - * supports observability (e.g. Room, SQLDelight, Realm). + * Creates a (non-[Flow]) source of truth that is accessible via [nonFlowReader], [writer], + * [delete] and [deleteAll]. * - * @param key The key to update for. + * @param nonFlowReader function for reading records from the source of truth + * @param writer function for writing updates to the backing source of truth + * @param delete function for deleting records in the source of truth for the given key + * @param deleteAll function for deleting all records in the source of truth */ - suspend fun write( - key: Key, - value: Local, - ) + fun of( + nonFlowReader: suspend (Key) -> Output?, + writer: suspend (Key, Local) -> Unit, + delete: (suspend (Key) -> Unit)? = null, + deleteAll: (suspend () -> Unit)? = null, + ): SourceOfTruth = + PersistentNonFlowingSourceOfTruth( + realReader = nonFlowReader, + realWriter = writer, + realDelete = delete, + realDeleteAll = deleteAll, + ) /** - * Used by [Store] to delete records in the source of truth for the given key. + * Creates a ([Flow]) source of truth that is accessed via [reader], [writer], [delete] and + * [deleteAll]. * - * @param key The key to delete for. + * @param reader function for reading records from the source of truth + * @param writer function for writing updates to the backing source of truth + * @param delete function for deleting records in the source of truth for the given key + * @param deleteAll function for deleting all records in the source of truth */ - suspend fun delete(key: Key) + @JvmName("ofFlow") + fun of( + reader: (Key) -> Flow, + writer: suspend (Key, Local) -> Unit, + delete: (suspend (Key) -> Unit)? = null, + deleteAll: (suspend () -> Unit)? = null, + ): SourceOfTruth = + PersistentSourceOfTruth( + realReader = reader, + realWriter = writer, + realDelete = delete, + realDeleteAll = deleteAll, + ) + } - /** - * Used by [Store] to delete all records in the source of truth. - */ - suspend fun deleteAll() + /** + * The exception provided when a write operation fails in SourceOfTruth. + * + * see [StoreReadResponse.Error.Exception] + */ + class WriteException( + /** The key for the failed write attempt */ + val key: Any?, // TODO why are we not marking keys non-null ? + /** The value for the failed write attempt */ + val value: Any?, + /** The exception thrown from the [SourceOfTruth]'s [write] method. */ + cause: Throwable, + ) : RuntimeException("Failed to write value to Source of Truth. key: $key", cause) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false - companion object { - /** - * Creates a (non-[Flow]) source of truth that is accessible via [nonFlowReader], [writer], - * [delete] and [deleteAll]. - * - * @param nonFlowReader function for reading records from the source of truth - * @param writer function for writing updates to the backing source of truth - * @param delete function for deleting records in the source of truth for the given key - * @param deleteAll function for deleting all records in the source of truth - */ - fun of( - nonFlowReader: suspend (Key) -> Output?, - writer: suspend (Key, Local) -> Unit, - delete: (suspend (Key) -> Unit)? = null, - deleteAll: (suspend () -> Unit)? = null, - ): SourceOfTruth = - PersistentNonFlowingSourceOfTruth( - realReader = nonFlowReader, - realWriter = writer, - realDelete = delete, - realDeleteAll = deleteAll, - ) + other as WriteException - /** - * Creates a ([Flow]) source of truth that is accessed via [reader], [writer], - * [delete] and [deleteAll]. - * - * @param reader function for reading records from the source of truth - * @param writer function for writing updates to the backing source of truth - * @param delete function for deleting records in the source of truth for the given key - * @param deleteAll function for deleting all records in the source of truth - */ - @JvmName("ofFlow") - fun of( - reader: (Key) -> Flow, - writer: suspend (Key, Local) -> Unit, - delete: (suspend (Key) -> Unit)? = null, - deleteAll: (suspend () -> Unit)? = null, - ): SourceOfTruth = - PersistentSourceOfTruth( - realReader = reader, - realWriter = writer, - realDelete = delete, - realDeleteAll = deleteAll, - ) + if (key != other.key) return false + if (value != other.value) return false + if (cause != other.cause) return false + return true } - /** - * The exception provided when a write operation fails in SourceOfTruth. - * - * see [StoreReadResponse.Error.Exception] - */ - class WriteException( - /** - * The key for the failed write attempt - */ - val key: Any?, // TODO why are we not marking keys non-null ? - /** - * The value for the failed write attempt - */ - val value: Any?, - /** - * The exception thrown from the [SourceOfTruth]'s [write] method. - */ - cause: Throwable, - ) : RuntimeException( - "Failed to write value to Source of Truth. key: $key", - cause, - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as WriteException - - if (key != other.key) return false - if (value != other.value) return false - if (cause != other.cause) return false - return true - } - - override fun hashCode(): Int { - var result = key.hashCode() - result = 31 * result + value.hashCode() - return result - } + override fun hashCode(): Int { + var result = key.hashCode() + result = 31 * result + value.hashCode() + return result } + } - /** - * Exception created when a [reader] throws an exception. - * - * see [StoreReadResponse.Error.Exception] - */ - class ReadException( - /** - * The key for the failed write attempt - */ - val key: Any?, // TODO shouldn't key be non-null? - cause: Throwable, - ) : RuntimeException( - "Failed to read from Source of Truth. key: $key", - cause, - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false + /** + * Exception created when a [reader] throws an exception. + * + * see [StoreReadResponse.Error.Exception] + */ + class ReadException( + /** The key for the failed write attempt */ + val key: Any?, // TODO shouldn't key be non-null? + cause: Throwable, + ) : RuntimeException("Failed to read from Source of Truth. key: $key", cause) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false - other as ReadException + other as ReadException - if (key != other.key) return false - if (cause != other.cause) return false - return true - } + if (key != other.key) return false + if (cause != other.cause) return false + return true + } - override fun hashCode(): Int { - return key.hashCode() - } + override fun hashCode(): Int { + return key.hashCode() } + } } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Store.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Store.kt index 6fc93df2e..d969a85fd 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Store.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Store.kt @@ -3,35 +3,23 @@ package org.mobilenativefoundation.store.store5 /** * A Store is responsible for managing a particular data request. * - * When you create an implementation of a Store, you provide it with a Fetcher, a function that defines how data will be fetched over network. + * When you create an implementation of a Store, you provide it with a Fetcher, a function that + * defines how data will be fetched over network. * - * You can also define how your Store will cache data in-memory and on-disk. See [StoreBuilder] for full configuration + * You can also define how your Store will cache data in-memory and on-disk. See [StoreBuilder] for + * full configuration * * Example usage: * - * val store = StoreBuilder - * .fromNonFlow, List> { (query, config) -> - * provideRetrofit().fetchData(query, config.limit).data.children.map(::toPosts) - * } - * .persister(reader = { (query, _) -> db.postDao().loadData(query) }, - * writer = { (query, _), posts -> db.dataDAO().insertData(query, posts) }, - * delete = { (query, _) -> db.dataDAO().clearData(query) }, - * deleteAll = db.postDao()::clearAllFeeds) - * .build() + * val store = StoreBuilder .fromNonFlow, List> { (query, config) + * -> provideRetrofit().fetchData(query, config.limit).data.children.map(::toPosts) } + * .persister(reader = { (query, _) -> db.postDao().loadData(query) }, writer = { (query, _), posts + * -> db.dataDAO().insertData(query, posts) }, delete = { (query, _) -> + * db.dataDAO().clearData(query) }, deleteAll = db.postDao()::clearAllFeeds) .build() * - * // single shot response - * viewModelScope.launch { - * val data = store.fresh(key) - * } - * - * // get cached data and collect future emissions as well - * viewModelScope.launch { - * val data = store.cached(key, refresh=true) - * .collect{data.value=it } - * } + * // single shot response viewModelScope.launch { val data = store.fresh(key) } * + * // get cached data and collect future emissions as well viewModelScope.launch { val data = + * store.cached(key, refresh=true) .collect{data.value=it } } */ -interface Store : - Read.Stream, - Clear.Key, - Clear.All +interface Store : Read.Stream, Clear.Key, Clear.All diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreBuilder.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreBuilder.kt index f91b94af4..6f09bbffe 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreBuilder.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreBuilder.kt @@ -22,93 +22,86 @@ import org.mobilenativefoundation.store.store5.impl.storeBuilderFromFetcherAndSo import org.mobilenativefoundation.store.store5.impl.storeBuilderFromFetcherSourceOfTruthAndMemoryCache import org.mobilenativefoundation.store.store5.impl.storeBuilderFromFetcherSourceOfTruthMemoryCacheAndConverter -/** - * Main entry point for creating a [Store]. - */ +/** Main entry point for creating a [Store]. */ interface StoreBuilder { - fun build(): Store + fun build(): Store - fun toMutableStoreBuilder( - converter: Converter, - ): MutableStoreBuilder + fun toMutableStoreBuilder( + converter: Converter + ): MutableStoreBuilder - /** - * A store multicasts same [Output] value to many consumers (Similar to RxJava.share()), by default - * [Store] will open a global scope for management of shared responses, if instead you'd like to control - * the scope that sharing/multicasting happens in you can pass a @param [scope] - * - * @param scope - scope to use for sharing - */ - fun scope(scope: CoroutineScope): StoreBuilder + /** + * A store multicasts same [Output] value to many consumers (Similar to RxJava.share()), by + * default [Store] will open a global scope for management of shared responses, if instead you'd + * like to control the scope that sharing/multicasting happens in you can pass a @param [scope] + * + * @param scope - scope to use for sharing + */ + fun scope(scope: CoroutineScope): StoreBuilder + + /** + * controls eviction policy for a store cache, use [MemoryPolicy.MemoryPolicyBuilder] to configure + * a TTL or size based eviction Example: + * MemoryPolicy.builder().setExpireAfterWrite(10.seconds).build() + */ + fun cachePolicy(memoryPolicy: MemoryPolicy?): StoreBuilder + + /** by default a Store caches in memory with a default policy of max items = 100 */ + fun disableCache(): StoreBuilder + fun validator(validator: Validator): StoreBuilder + + companion object { /** - * controls eviction policy for a store cache, use [MemoryPolicy.MemoryPolicyBuilder] to configure a TTL - * or size based eviction - * Example: MemoryPolicy.builder().setExpireAfterWrite(10.seconds).build() + * Creates a new [StoreBuilder] from a [Fetcher]. + * + * @param fetcher a [Fetcher] flow of network records. */ - fun cachePolicy(memoryPolicy: MemoryPolicy?): StoreBuilder + fun from(fetcher: Fetcher): StoreBuilder = + storeBuilderFromFetcher(fetcher = fetcher) /** - * by default a Store caches in memory with a default policy of max items = 100 + * Creates a new [StoreBuilder] from a [Fetcher] and a [SourceOfTruth]. + * + * @param fetcher a function for fetching a flow of network records. + * @param sourceOfTruth a [SourceOfTruth] for the store. */ - fun disableCache(): StoreBuilder - - fun validator(validator: Validator): StoreBuilder - - companion object { - /** - * Creates a new [StoreBuilder] from a [Fetcher]. - * - * @param fetcher a [Fetcher] flow of network records. - */ - fun from(fetcher: Fetcher): StoreBuilder = - storeBuilderFromFetcher(fetcher = fetcher) - - /** - * Creates a new [StoreBuilder] from a [Fetcher] and a [SourceOfTruth]. - * - * @param fetcher a function for fetching a flow of network records. - * @param sourceOfTruth a [SourceOfTruth] for the store. - */ - fun from( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - ): StoreBuilder = storeBuilderFromFetcherAndSourceOfTruth(fetcher = fetcher, sourceOfTruth = sourceOfTruth) + fun from( + fetcher: Fetcher, + sourceOfTruth: SourceOfTruth, + ): StoreBuilder = + storeBuilderFromFetcherAndSourceOfTruth(fetcher = fetcher, sourceOfTruth = sourceOfTruth) - fun from( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - memoryCache: Cache, - ): StoreBuilder = - storeBuilderFromFetcherSourceOfTruthAndMemoryCache( - fetcher, - sourceOfTruth, - memoryCache, - ) + fun from( + fetcher: Fetcher, + sourceOfTruth: SourceOfTruth, + memoryCache: Cache, + ): StoreBuilder = + storeBuilderFromFetcherSourceOfTruthAndMemoryCache(fetcher, sourceOfTruth, memoryCache) - fun from( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - converter: Converter, - ): StoreBuilder = - storeBuilderFromFetcherSourceOfTruthMemoryCacheAndConverter( - fetcher, - sourceOfTruth, - null, - converter, - ) + fun from( + fetcher: Fetcher, + sourceOfTruth: SourceOfTruth, + converter: Converter, + ): StoreBuilder = + storeBuilderFromFetcherSourceOfTruthMemoryCacheAndConverter( + fetcher, + sourceOfTruth, + null, + converter, + ) - fun from( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - memoryCache: Cache, - converter: Converter, - ): StoreBuilder = - storeBuilderFromFetcherSourceOfTruthMemoryCacheAndConverter( - fetcher, - sourceOfTruth, - memoryCache, - converter, - ) - } + fun from( + fetcher: Fetcher, + sourceOfTruth: SourceOfTruth, + memoryCache: Cache, + converter: Converter, + ): StoreBuilder = + storeBuilderFromFetcherSourceOfTruthMemoryCacheAndConverter( + fetcher, + sourceOfTruth, + memoryCache, + converter, + ) + } } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreDefaults.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreDefaults.kt index 341f3e579..068ed1a52 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreDefaults.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreDefaults.kt @@ -4,23 +4,20 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.hours internal object StoreDefaults { - /** - * Cache TTL (default is 24 hours), can be overridden - * - * @return memory cache TTL - */ - val cacheTTL: Duration = 24.hours + /** + * Cache TTL (default is 24 hours), can be overridden + * + * @return memory cache TTL + */ + val cacheTTL: Duration = 24.hours - /** - * Cache size (default is 100), can be overridden - * - * @return memory cache size - */ - val cacheSize: Long = 100 + /** + * Cache size (default is 100), can be overridden + * + * @return memory cache size + */ + val cacheSize: Long = 100 - val memoryPolicy = - MemoryPolicy.builder() - .setMaxSize(cacheSize) - .setExpireAfterWrite(cacheTTL) - .build() + val memoryPolicy = + MemoryPolicy.builder().setMaxSize(cacheSize).setExpireAfterWrite(cacheTTL).build() } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadRequest.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadRequest.kt index d31fea5c4..2446e3b07 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadRequest.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadRequest.kt @@ -17,95 +17,79 @@ package org.mobilenativefoundation.store.store5 /** * data class to represent a single store request + * * @param key a unique identifier for your data - * @param skippedCaches List of cache types that should be skipped when retuning the response see [CacheType] - * @param refresh If set to true [Store] will always get fresh value from fetcher while also - * starting the stream from the local [com.dropbox.android.external.store4.impl.SourceOfTruth] and memory cache + * @param skippedCaches List of cache types that should be skipped when retuning the response see + * [CacheType] + * @param refresh If set to true [Store] will always get fresh value from fetcher while also + * starting the stream from the local [com.dropbox.android.external.store4.impl.SourceOfTruth] and + * memory cache * @param fetch If set to false, then fetcher will not be used */ -data class StoreReadRequest private constructor( - val key: Key, - private val skippedCaches: Int, - val refresh: Boolean = false, - val fallBackToSourceOfTruth: Boolean = false, - val fetch: Boolean = true, +data class StoreReadRequest +private constructor( + val key: Key, + private val skippedCaches: Int, + val refresh: Boolean = false, + val fallBackToSourceOfTruth: Boolean = false, + val fetch: Boolean = true, ) { - internal fun shouldSkipCache(type: CacheType) = skippedCaches.and(type.flag) != 0 + internal fun shouldSkipCache(type: CacheType) = skippedCaches.and(type.flag) != 0 + + /** Factories for common store requests */ + companion object { + private val allCaches = CacheType.values().fold(0) { prev, next -> prev.or(next.flag) } /** - * Factories for common store requests + * Create a [StoreReadRequest] which will skip all caches and hit your fetcher (filling your + * caches). + * + * Note: If the [Fetcher] does not return any data (i.e., the returned + * [kotlinx.coroutines.Flow], when collected, is empty). Then store will fall back to local data + * **even** if you explicitly requested fresh data. See + * https://github.com/dropbox/Store/pull/194 for context. */ - companion object { - private val allCaches = - CacheType.values().fold(0) { prev, next -> - prev.or(next.flag) - } - - /** - * Create a [StoreReadRequest] which will skip all caches and hit your fetcher - * (filling your caches). - * - * Note: If the [Fetcher] does not return any data (i.e., the returned - * [kotlinx.coroutines.Flow], when collected, is empty). Then store will fall back to local - * data **even** if you explicitly requested fresh data. - * See https://github.com/dropbox/Store/pull/194 for context. - */ - fun fresh( - key: Key, - fallBackToSourceOfTruth: Boolean = false, - ) = StoreReadRequest( - key = key, - skippedCaches = allCaches, - refresh = true, - fallBackToSourceOfTruth = fallBackToSourceOfTruth, - ) + fun fresh(key: Key, fallBackToSourceOfTruth: Boolean = false) = + StoreReadRequest( + key = key, + skippedCaches = allCaches, + refresh = true, + fallBackToSourceOfTruth = fallBackToSourceOfTruth, + ) - /** - * Create a [StoreReadRequest] which will return data from memory/disk caches if present, - * otherwise will hit your fetcher (filling your caches). - * @param refresh if true then return fetcher (new) data as well (updating your caches) - */ - fun cached( - key: Key, - refresh: Boolean, - ) = StoreReadRequest( - key = key, - skippedCaches = 0, - refresh = refresh, - ) + /** + * Create a [StoreReadRequest] which will return data from memory/disk caches if present, + * otherwise will hit your fetcher (filling your caches). + * + * @param refresh if true then return fetcher (new) data as well (updating your caches) + */ + fun cached(key: Key, refresh: Boolean) = + StoreReadRequest(key = key, skippedCaches = 0, refresh = refresh) - /** - * Create a [StoreReadRequest] which will return data from memory/disk caches if present, - * otherwise will return [StoreReadResponse.NoNewData] - */ - fun localOnly(key: Key) = - StoreReadRequest( - key = key, - skippedCaches = 0, - fetch = false, - ) + /** + * Create a [StoreReadRequest] which will return data from memory/disk caches if present, + * otherwise will return [StoreReadResponse.NoNewData] + */ + fun localOnly(key: Key) = StoreReadRequest(key = key, skippedCaches = 0, fetch = false) - /** - * Create a [StoreReadRequest] which will return data from disk cache - * @param refresh if true then return fetcher (new) data as well (updating your caches) - */ - fun skipMemory( - key: Key, - refresh: Boolean, - ) = StoreReadRequest( - key = key, - skippedCaches = CacheType.MEMORY.flag, - refresh = refresh, - ) + /** + * Create a [StoreReadRequest] which will return data from disk cache + * + * @param refresh if true then return fetcher (new) data as well (updating your caches) + */ + fun skipMemory(key: Key, refresh: Boolean) = + StoreReadRequest(key = key, skippedCaches = CacheType.MEMORY.flag, refresh = refresh) - /** - * Creates a [StoreReadRequest] skipping all caches and returning data from network on success and data from [SourceOfTruth] on failure. - */ - fun freshWithFallBackToSourceOfTruth(key: Key) = fresh(key, fallBackToSourceOfTruth = true) - } + /** + * Creates a [StoreReadRequest] skipping all caches and returning data from network on success + * and data from [SourceOfTruth] on failure. + */ + fun freshWithFallBackToSourceOfTruth(key: Key) = + fresh(key, fallBackToSourceOfTruth = true) + } } internal enum class CacheType(internal val flag: Int) { - MEMORY(0b01), - DISK(0b10), + MEMORY(0b01), + DISK(0b10), } 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 edef2ce81..c3d944530 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponse.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponse.kt @@ -23,158 +23,133 @@ package org.mobilenativefoundation.store.store5 * so that if there is an observable single source of truth, application can keep observing it. */ sealed class StoreReadResponse { - /** - * Represents the source of the Response. - */ - abstract val origin: StoreReadResponseOrigin + /** Represents the source of the Response. */ + abstract val origin: StoreReadResponseOrigin - object Initial : StoreReadResponse() { - override val origin: StoreReadResponseOrigin = StoreReadResponseOrigin.Initial - } + object Initial : StoreReadResponse() { + override val origin: StoreReadResponseOrigin = StoreReadResponseOrigin.Initial + } - /** - * Loading event dispatched by [Store] to signal the [Fetcher] is in progress. - */ - data class Loading(override val origin: StoreReadResponseOrigin) : StoreReadResponse() - - /** - * Data dispatched by [Store] - */ - data class Data(val value: Output, override val origin: StoreReadResponseOrigin) : - StoreReadResponse() - - /** - * No new data event dispatched by Store to signal the [Fetcher] returned no data (i.e., the - * returned [kotlinx.coroutines.flow.Flow], when collected, was empty). - */ - data class NoNewData(override val origin: StoreReadResponseOrigin) : StoreReadResponse() - - /** - * Error dispatched by a pipeline - */ - sealed class Error : StoreReadResponse() { - data class Exception( - val error: Throwable, - override val origin: StoreReadResponseOrigin, - ) : Error() - - data class Message( - val message: String, - override val origin: StoreReadResponseOrigin, - ) : Error() - - data class Custom( - val error: E, - override val origin: StoreReadResponseOrigin, - ) : Error() - } + /** Loading event dispatched by [Store] to signal the [Fetcher] is in progress. */ + data class Loading(override val origin: StoreReadResponseOrigin) : StoreReadResponse() + + /** Data dispatched by [Store] */ + data class Data(val value: Output, override val origin: StoreReadResponseOrigin) : + StoreReadResponse() + + /** + * No new data event dispatched by Store to signal the [Fetcher] returned no data (i.e., the + * returned [kotlinx.coroutines.flow.Flow], when collected, was empty). + */ + data class NoNewData(override val origin: StoreReadResponseOrigin) : StoreReadResponse() + + /** Error dispatched by a pipeline */ + sealed class Error : StoreReadResponse() { + data class Exception(val error: Throwable, override val origin: StoreReadResponseOrigin) : + Error() - /** - * Returns the available data or throws [NullPointerException] if there is no data. - */ - fun requireData(): Output { - return when (this) { - is Data -> value - is Error -> throw this.doThrow() - else -> throw NullPointerException("there is no data in $this") - } + data class Message(val message: String, override val origin: StoreReadResponseOrigin) : Error() + + data class Custom(val error: E, override val origin: StoreReadResponseOrigin) : + Error() + } + + /** Returns the available data or throws [NullPointerException] if there is no data. */ + fun requireData(): Output { + return when (this) { + is Data -> value + is Error -> throw this.doThrow() + else -> throw NullPointerException("there is no data in $this") + } + } + + /** + * If this [StoreReadResponse] is of type [StoreReadResponse.Error], throws the exception + * Otherwise, does nothing. + */ + fun throwIfError() { + if (this is Error) { + throw this.doThrow() } + } - /** - * If this [StoreReadResponse] is of type [StoreReadResponse.Error], throws the exception - * Otherwise, does nothing. - */ - fun throwIfError() { - if (this is Error) { - throw this.doThrow() - } + /** + * If this [StoreReadResponse] is of type [StoreReadResponse.Error], returns the available error + * from it. Otherwise, returns `null`. + */ + fun errorMessageOrNull(): String? { + return when (this) { + is Error.Message -> message + is Error.Exception -> error.message ?: "exception: ${error::class}" + else -> null } + } - /** - * If this [StoreReadResponse] is of type [StoreReadResponse.Error], returns the available error - * from it. Otherwise, returns `null`. - */ - fun errorMessageOrNull(): String? { - return when (this) { - is Error.Message -> message - is Error.Exception -> error.message ?: "exception: ${error::class}" - else -> null - } + /** If there is data available, returns it; otherwise returns null. */ + fun dataOrNull(): Output? = + when (this) { + is Data -> value + else -> null } - /** - * If there is data available, returns it; otherwise returns null. - */ - fun dataOrNull(): Output? = - when (this) { - is Data -> value - else -> null - } - - private fun errorOrNull(): Throwable? { - if (this is Error.Exception) { - return error - } - - return null + private fun errorOrNull(): Throwable? { + if (this is Error.Exception) { + return error } - /** - * @returns Error if there is one, else null. - */ - @Suppress("UNCHECKED_CAST") - fun errorOrNull(): E? { - if (this is Error.Custom<*>) { - return (this as? Error.Custom)?.error - } + return null + } - return errorOrNull() as? E + /** @returns Error if there is one, else null. */ + @Suppress("UNCHECKED_CAST") + fun errorOrNull(): E? { + if (this is Error.Custom<*>) { + return (this as? Error.Custom)?.error } - @Suppress("UNCHECKED_CAST") - internal fun swapType(): StoreReadResponse = - when (this) { - is Error -> this - is Loading -> this - is NoNewData -> this - is Data -> throw RuntimeException("cannot swap type for StoreResponse.Data") - is Initial -> this - } + return errorOrNull() as? E + } + + @Suppress("UNCHECKED_CAST") + internal fun swapType(): StoreReadResponse = + when (this) { + is Error -> this + is Loading -> this + is NoNewData -> this + is Data -> throw RuntimeException("cannot swap type for StoreResponse.Data") + is Initial -> this + } } -/** - * Represents the origin for a [StoreReadResponse]. - */ +/** Represents the origin for a [StoreReadResponse]. */ sealed class StoreReadResponseOrigin { - /** - * [StoreReadResponse] is sent from the cache - */ - object Cache : StoreReadResponseOrigin() - - /** - * [StoreReadResponse] is sent from the persister - */ - object SourceOfTruth : StoreReadResponseOrigin() - - /** - * [StoreReadResponse] is sent from a fetcher - * @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() + /** [StoreReadResponse] is sent from the cache */ + object Cache : StoreReadResponseOrigin() + + /** [StoreReadResponse] is sent from the persister */ + object SourceOfTruth : StoreReadResponseOrigin() + + /** + * [StoreReadResponse] is sent from a fetcher + * + * @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(): Throwable { - return when (this) { - is StoreReadResponse.Error.Exception -> error - is StoreReadResponse.Error.Message -> RuntimeException(message) - is StoreReadResponse.Error.Custom<*> -> { - if (error is Throwable) { - error - } else { - RuntimeException("Non-throwable custom error: $error") - } - } + return when (this) { + is StoreReadResponse.Error.Exception -> error + is StoreReadResponse.Error.Message -> RuntimeException(message) + is StoreReadResponse.Error.Custom<*> -> { + if (error is Throwable) { + error + } else { + RuntimeException("Non-throwable custom error: $error") + } } + } } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreWriteRequest.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreWriteRequest.kt index 6ce1e2712..9311f5ddf 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreWriteRequest.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreWriteRequest.kt @@ -5,17 +5,18 @@ import org.mobilenativefoundation.store.store5.impl.OnStoreWriteCompletion import org.mobilenativefoundation.store.store5.impl.RealStoreWriteRequest interface StoreWriteRequest { - val key: Key - val value: Output - val created: Long - val onCompletions: List? + val key: Key + val value: Output + val created: Long + val onCompletions: List? - companion object { - fun of( - key: Key, - value: Output, - onCompletions: List? = null, - created: Long = Clock.System.now().toEpochMilliseconds(), - ): StoreWriteRequest = RealStoreWriteRequest(key, value, created, onCompletions) - } + companion object { + fun of( + key: Key, + value: Output, + onCompletions: List? = null, + created: Long = Clock.System.now().toEpochMilliseconds(), + ): StoreWriteRequest = + RealStoreWriteRequest(key, value, created, onCompletions) + } } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreWriteResponse.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreWriteResponse.kt index d0e6e3844..faf8c53c1 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreWriteResponse.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreWriteResponse.kt @@ -1,15 +1,15 @@ package org.mobilenativefoundation.store.store5 sealed class StoreWriteResponse { - sealed class Success : StoreWriteResponse() { - data class Typed(val value: Response) : Success() + sealed class Success : StoreWriteResponse() { + data class Typed(val value: Response) : Success() - data class Untyped(val value: Any) : Success() - } + data class Untyped(val value: Any) : Success() + } - sealed class Error : StoreWriteResponse() { - data class Exception(val error: Throwable) : Error() + sealed class Error : StoreWriteResponse() { + data class Exception(val error: Throwable) : Error() - data class Message(val message: String) : Error() - } + data class Message(val message: String) : Error() + } } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Updater.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Updater.kt index 8913ba619..dfb9eebcf 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Updater.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Updater.kt @@ -4,40 +4,27 @@ typealias PostRequest = suspend (key: Key, value: Output) -> Update /** * Posts data to remote data source. + * * @see [StoreWriteRequest] */ interface Updater { - /** - * Makes HTTP POST request. - */ - suspend fun post( - key: Key, - value: Output, - ): UpdaterResult + /** Makes HTTP POST request. */ + suspend fun post(key: Key, value: Output): UpdaterResult - /** - * Executes on network completion. - */ - val onCompletion: OnUpdaterCompletion? + /** Executes on network completion. */ + val onCompletion: OnUpdaterCompletion? - companion object { - fun by( - post: PostRequest, - onCompletion: OnUpdaterCompletion? = null, - ): Updater = - RealNetworkUpdater( - post, - onCompletion, - ) - } + companion object { + fun by( + post: PostRequest, + onCompletion: OnUpdaterCompletion? = null, + ): Updater = RealNetworkUpdater(post, onCompletion) + } } internal class RealNetworkUpdater( - private val realPost: PostRequest, - override val onCompletion: OnUpdaterCompletion?, + private val realPost: PostRequest, + override val onCompletion: OnUpdaterCompletion?, ) : Updater { - override suspend fun post( - key: Key, - value: Output, - ): UpdaterResult = realPost(key, value) + override suspend fun post(key: Key, value: Output): UpdaterResult = realPost(key, value) } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/UpdaterResult.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/UpdaterResult.kt index 2d6a77614..5d66378f7 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/UpdaterResult.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/UpdaterResult.kt @@ -1,15 +1,15 @@ package org.mobilenativefoundation.store.store5 sealed class UpdaterResult { - sealed class Success : UpdaterResult() { - data class Typed(val value: Response) : Success() + sealed class Success : UpdaterResult() { + data class Typed(val value: Response) : Success() - data class Untyped(val value: Any) : Success() - } + data class Untyped(val value: Any) : Success() + } - sealed class Error : UpdaterResult() { - data class Exception(val error: Throwable) : Error() + sealed class Error : UpdaterResult() { + data class Exception(val error: Throwable) : Error() - data class Message(val message: String) : Error() - } + data class Message(val message: String) : Error() + } } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Validator.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Validator.kt index 274764b12..dc2b8c3e7 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Validator.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Validator.kt @@ -4,17 +4,18 @@ import org.mobilenativefoundation.store.store5.impl.RealValidator /** * Enables custom validation of [Store] items. + * * @see [StoreReadRequest] */ interface Validator { - /** - * Determines whether a [Store] item is valid. - * If invalid, [MutableStore] will get the latest network value using [Fetcher]. - * [MutableStore] will not validate network responses. - */ - suspend fun isValid(item: Output): Boolean + /** + * Determines whether a [Store] item is valid. If invalid, [MutableStore] will get the latest + * network value using [Fetcher]. [MutableStore] will not validate network responses. + */ + suspend fun isValid(item: Output): Boolean - companion object { - fun by(validator: suspend (item: Output) -> Boolean): Validator = RealValidator(validator) - } + companion object { + fun by(validator: suspend (item: Output) -> Boolean): Validator = + RealValidator(validator) + } } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Write.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Write.kt index 9b07fa3f1..cdd05e59c 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Write.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Write.kt @@ -4,11 +4,15 @@ import kotlinx.coroutines.flow.Flow import org.mobilenativefoundation.store.core5.ExperimentalStoreApi interface Write { - @ExperimentalStoreApi - suspend fun write(request: StoreWriteRequest): StoreWriteResponse + @ExperimentalStoreApi + suspend fun write( + request: StoreWriteRequest + ): StoreWriteResponse - interface Stream { - @ExperimentalStoreApi - fun stream(requestStream: Flow>): Flow - } + interface Stream { + @ExperimentalStoreApi + fun stream( + requestStream: Flow> + ): Flow + } } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/DefaultLogger.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/DefaultLogger.kt index 9219683c3..d893f9e98 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/DefaultLogger.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/DefaultLogger.kt @@ -3,24 +3,19 @@ package org.mobilenativefoundation.store.store5.impl import co.touchlab.kermit.CommonWriter import org.mobilenativefoundation.store.store5.Logger -/** - * Default implementation of [Logger] using the Kermit logging library. - */ +/** Default implementation of [Logger] using the Kermit logging library. */ internal class DefaultLogger : Logger { - private val delegate = - co.touchlab.kermit.Logger.apply { - setLogWriters(listOf(CommonWriter())) - setTag("Store") - } - - override fun debug(message: String) { - delegate.d(message) + private val delegate = + co.touchlab.kermit.Logger.apply { + setLogWriters(listOf(CommonWriter())) + setTag("Store") } - override fun error( - message: String, - throwable: Throwable?, - ) { - delegate.e(message, throwable) - } + override fun debug(message: String) { + delegate.d(message) + } + + override fun error(message: String, throwable: Throwable?) { + delegate.e(message, throwable) + } } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/FetcherController.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/FetcherController.kt index 61eb9c96a..5927a757e 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/FetcherController.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/FetcherController.kt @@ -35,130 +35,114 @@ import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin /** * This class maintains one and only 1 fetcher for a given [Key]. * - * Any value emitted by the fetcher is sent into the [sourceOfTruth] before it is dispatched. - * If [sourceOfTruth] is `null`, [enablePiggyback] is set to true by default so that previous - * fetcher requests receives values dispatched by later requests even if they don't share the - * request. + * Any value emitted by the fetcher is sent into the [sourceOfTruth] before it is dispatched. If + * [sourceOfTruth] is `null`, [enablePiggyback] is set to true by default so that previous fetcher + * requests receives values dispatched by later requests even if they don't share the request. */ @Suppress("UNCHECKED_CAST") internal class FetcherController( - /** - * The [CoroutineScope] to use when collecting from the fetcher - */ - private val scope: CoroutineScope, - /** - * The function that provides the actualy fetcher flow when needed - */ - private val realFetcher: Fetcher, - /** - * [SourceOfTruth] to send the data each time fetcher dispatches a value. Can be `null` if - * no [SourceOfTruth] is available. - */ - private val sourceOfTruth: SourceOfTruthWithBarrier?, - private val converter: Converter = - object : - Converter { - override fun fromNetworkToLocal(network: Network): Local { - return network as Local - } + /** The [CoroutineScope] to use when collecting from the fetcher */ + private val scope: CoroutineScope, + /** The function that provides the actualy fetcher flow when needed */ + private val realFetcher: Fetcher, + /** + * [SourceOfTruth] to send the data each time fetcher dispatches a value. Can be `null` if no + * [SourceOfTruth] is available. + */ + private val sourceOfTruth: SourceOfTruthWithBarrier?, + private val converter: Converter = + object : Converter { + override fun fromNetworkToLocal(network: Network): Local { + return network as Local + } - override fun fromOutputToLocal(output: Output): Local { - throw IllegalStateException("Not used") - } - }, + override fun fromOutputToLocal(output: Output): Local { + throw IllegalStateException("Not used") + } + }, ) { - @Suppress("USELESS_CAST", "UNCHECKED_CAST") // needed for multicaster source - private val fetchers = - RefCountedResource( - create = { key: Key -> - Multicaster( - scope = scope, - bufferSize = 0, - source = - flow { emitAll(realFetcher(key)) }.map { - when (it) { - is FetcherResult.Data -> { - StoreReadResponse.Data( - it.value, - origin = StoreReadResponseOrigin.Fetcher(it.origin), - ) as StoreReadResponse - } - - is FetcherResult.Error.Message -> - StoreReadResponse.Error.Message( - it.message, - origin = StoreReadResponseOrigin.Fetcher(), - ) + @Suppress("USELESS_CAST", "UNCHECKED_CAST") // needed for multicaster source + private val fetchers = + RefCountedResource( + create = { key: Key -> + Multicaster( + scope = scope, + bufferSize = 0, + source = + flow { emitAll(realFetcher(key)) } + .map { + when (it) { + is FetcherResult.Data -> { + StoreReadResponse.Data( + it.value, + origin = StoreReadResponseOrigin.Fetcher(it.origin), + ) as StoreReadResponse + } - is FetcherResult.Error.Exception -> - StoreReadResponse.Error.Exception( - it.error, - origin = StoreReadResponseOrigin.Fetcher(), - ) - is FetcherResult.Error.Custom<*> -> - StoreReadResponse.Error.Custom( - it.error, - StoreReadResponseOrigin.Fetcher(), - ) - } - }.onEmpty { - val origin = - StoreReadResponseOrigin.Fetcher() - emit(StoreReadResponse.NoNewData(origin)) - }, - /** - * When enabled, downstream collectors are never closed, instead, they are kept active to - * receive values dispatched by fetchers created after them. This makes [FetcherController] - * act like a [SourceOfTruth] in the lack of a [SourceOfTruth] provided by the developer. - */ - piggybackingDownstream = true, - onEach = { response -> - response.dataOrNull()?.let { network: Network -> - val local: Local = converter.fromNetworkToLocal(network) - sourceOfTruth?.write(key, local) - } - }, - ) - }, - onRelease = { _: Key, multicaster: Multicaster> -> - multicaster.close() - }, - ) + is FetcherResult.Error.Message -> + StoreReadResponse.Error.Message( + it.message, + origin = StoreReadResponseOrigin.Fetcher(), + ) - fun getFetcher( - key: Key, - piggybackOnly: Boolean = false, - ): Flow> { - return flow { - val fetcher = acquireFetcher(key) - try { - emitAll(fetcher.newDownstream(piggybackOnly)) - } finally { - withContext(NonCancellable) { - fetchers.release(key, fetcher) + is FetcherResult.Error.Exception -> + StoreReadResponse.Error.Exception( + it.error, + origin = StoreReadResponseOrigin.Fetcher(), + ) + is FetcherResult.Error.Custom<*> -> + StoreReadResponse.Error.Custom(it.error, StoreReadResponseOrigin.Fetcher()) } + } + .onEmpty { + val origin = StoreReadResponseOrigin.Fetcher() + emit(StoreReadResponse.NoNewData(origin)) + }, + /** + * When enabled, downstream collectors are never closed, instead, they are kept active to + * receive values dispatched by fetchers created after them. This makes + * [FetcherController] act like a [SourceOfTruth] in the lack of a [SourceOfTruth] + * provided by the developer. + */ + piggybackingDownstream = true, + onEach = { response -> + response.dataOrNull()?.let { network: Network -> + val local: Local = converter.fromNetworkToLocal(network) + sourceOfTruth?.write(key, local) } - } + }, + ) + }, + onRelease = { _: Key, multicaster: Multicaster> -> + multicaster.close() + }, + ) + + fun getFetcher(key: Key, piggybackOnly: Boolean = false): Flow> { + return flow { + val fetcher = acquireFetcher(key) + try { + emitAll(fetcher.newDownstream(piggybackOnly)) + } finally { + withContext(NonCancellable) { fetchers.release(key, fetcher) } + } } + } - /** - * This functions goes to great length to prevent capturing the calling context from - * [getFetcher]. The reason being that the [Flow] returned by [getFetcher] is collected on the - * user's context and [acquireFetcher] will, optionally, launch a long running coroutine on the - * [FetcherController]'s [scope]. In order to avoid capturing a reference to the scope we need - * to: - * 1) Not inline this function as that will cause the lambda to capture a reference to the - * surrounding suspend lambda which, in turn, holds a reference to the user's coroutine context. - * 2) Use [async]-[await] instead of - * [kotlinx.coroutines.withContext] as [kotlinx.coroutines.withContext] will also hold onto a - * reference to the caller's context (the LHS parameter of the new context which is used to run - * the operation). - */ - private suspend fun acquireFetcher(key: Key) = - scope.async { - fetchers.acquire(key) - }.await() + /** + * This functions goes to great length to prevent capturing the calling context from [getFetcher]. + * The reason being that the [Flow] returned by [getFetcher] is collected on the user's context + * and [acquireFetcher] will, optionally, launch a long running coroutine on the + * [FetcherController]'s [scope]. In order to avoid capturing a reference to the scope we need to: + * 1) Not inline this function as that will cause the lambda to capture a reference to the + * surrounding suspend lambda which, in turn, holds a reference to the user's coroutine + * context. + * 2) Use [async]-[await] instead of [kotlinx.coroutines.withContext] as + * [kotlinx.coroutines.withContext] will also hold onto a reference to the caller's context + * (the LHS parameter of the new context which is used to run the operation). + */ + private suspend fun acquireFetcher(key: Key) = scope.async { fetchers.acquire(key) }.await() - // visible for testing - internal suspend fun fetcherSize() = fetchers.size() + // visible for testing + internal suspend fun fetcherSize() = fetchers.size() } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/OnStoreWriteCompletion.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/OnStoreWriteCompletion.kt index 3971b847c..9ce9d7c14 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/OnStoreWriteCompletion.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/OnStoreWriteCompletion.kt @@ -3,6 +3,6 @@ package org.mobilenativefoundation.store.store5.impl import org.mobilenativefoundation.store.store5.StoreWriteResponse data class OnStoreWriteCompletion( - val onSuccess: (StoreWriteResponse.Success) -> Unit, - val onFailure: (StoreWriteResponse.Error) -> Unit, + val onSuccess: (StoreWriteResponse.Success) -> Unit, + val onFailure: (StoreWriteResponse.Error) -> Unit, ) diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealBookkeeper.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealBookkeeper.kt index c0ff2dfef..0ec40162a 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealBookkeeper.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealBookkeeper.kt @@ -4,19 +4,17 @@ import org.mobilenativefoundation.store.store5.Bookkeeper import org.mobilenativefoundation.store.store5.internal.definition.Timestamp internal class RealBookkeeper( - private val realGetLastFailedSync: suspend (key: Key) -> Timestamp?, - private val realSetLastFailedSync: suspend (key: Key, timestamp: Timestamp) -> Boolean, - private val realClear: suspend (key: Key) -> Boolean, - private val realClearAll: suspend () -> Boolean, + private val realGetLastFailedSync: suspend (key: Key) -> Timestamp?, + private val realSetLastFailedSync: suspend (key: Key, timestamp: Timestamp) -> Boolean, + private val realClear: suspend (key: Key) -> Boolean, + private val realClearAll: suspend () -> Boolean, ) : Bookkeeper { - override suspend fun getLastFailedSync(key: Key): Long? = realGetLastFailedSync(key) + override suspend fun getLastFailedSync(key: Key): Long? = realGetLastFailedSync(key) - override suspend fun setLastFailedSync( - key: Key, - timestamp: Long, - ): Boolean = realSetLastFailedSync(key, timestamp) + override suspend fun setLastFailedSync(key: Key, timestamp: Long): Boolean = + realSetLastFailedSync(key, timestamp) - override suspend fun clear(key: Key): Boolean = realClear(key) + override suspend fun clear(key: Key): Boolean = realClear(key) - override suspend fun clearAll(): Boolean = realClearAll() + override suspend fun clearAll(): Boolean = realClearAll() } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStore.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStore.kt index 39c2ac833..0b26d0759 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStore.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStore.kt @@ -27,314 +27,314 @@ import org.mobilenativefoundation.store.store5.internal.result.EagerConflictReso @OptIn(ExperimentalStoreApi::class) internal class RealMutableStore( - private val delegate: RealStore, - private val updater: Updater, - private val bookkeeper: Bookkeeper?, - private val logger: Logger = DefaultLogger(), + private val delegate: RealStore, + private val updater: Updater, + private val bookkeeper: Bookkeeper?, + private val logger: Logger = DefaultLogger(), ) : MutableStore, Clear.Key by delegate, Clear.All by delegate { - private val storeLock = Mutex() - private val keyToWriteRequestQueue = mutableMapOf>() - private val keyToThreadSafety = mutableMapOf() - - override fun stream(request: StoreReadRequest): Flow> = - flow { - // Ensure we are ready for this key. - safeInitStore(request.key) - - // Try to eagerly resolve conflicts before pulling from network. - when (val eagerConflictResolutionResult = tryEagerlyResolveConflicts(request.key)) { - // TODO(#678): Many use cases will not want to pull immediately after failing to push local changes. - // We should enable configuration of conflict resolution strategies, such as logging, retrying, canceling. - - is EagerConflictResolutionResult.Error.Exception -> { - logger.error(eagerConflictResolutionResult.error.toString()) - } - - is EagerConflictResolutionResult.Error.Message -> { - logger.error(eagerConflictResolutionResult.message) - } - - is EagerConflictResolutionResult.Success.ConflictsResolved -> { - logger.debug(eagerConflictResolutionResult.value.toString()) - } - - EagerConflictResolutionResult.Success.NoConflicts -> { - logger.debug("No conflicts.") - } - } - - // Now, we can just delegate to the underlying stream. - delegate.stream(request).collect { storeReadResponse -> emit(storeReadResponse) } - } - - @ExperimentalStoreApi - override fun stream(requestStream: Flow>): Flow = - flow { - // Each incoming write request is enqueued. - // Then we try to update the network and delegate. - - requestStream - .onEach { writeRequest -> - // Prepare per-key data structures. - safeInitStore(writeRequest.key) - - // Enqueue the new write request. - addWriteRequestToQueue(writeRequest) - } - .collect { writeRequest -> - val storeWriteResponse = - try { - // Always write to local first. - delegate.write(writeRequest.key, writeRequest.value) - - // Try to sync to network. - val updaterResult = tryUpdateServer(writeRequest) - - // Convert UpdaterResult -> StoreWriteResponse. - when (updaterResult) { - is UpdaterResult.Error.Exception -> StoreWriteResponse.Error.Exception(updaterResult.error) - is UpdaterResult.Error.Message -> StoreWriteResponse.Error.Message(updaterResult.message) - is UpdaterResult.Success.Typed<*> -> { - val typedValue = updaterResult.value as? Response - if (typedValue == null) { - StoreWriteResponse.Success.Untyped(updaterResult.value) - } else { - StoreWriteResponse.Success.Typed(updaterResult.value) - } - } - - is UpdaterResult.Success.Untyped -> StoreWriteResponse.Success.Untyped(updaterResult.value) - } - } catch (throwable: Throwable) { - StoreWriteResponse.Error.Exception(throwable) - } - emit(storeWriteResponse) - } - } - - @ExperimentalStoreApi - override suspend fun write(request: StoreWriteRequest): StoreWriteResponse = - stream(flowOf(request)).first() - - private suspend fun tryUpdateServer(request: StoreWriteRequest): UpdaterResult { - val updaterResult = postLatest(request.key) - - if (updaterResult is UpdaterResult.Success) { - // We successfully synced to network, can now clear out any stale writes. - updateWriteRequestQueue( - key = request.key, - created = request.created, - updaterResult = updaterResult, - ) - bookkeeper?.clear(request.key) - } else { - // Could not sync, need to record a failed timestamp. - bookkeeper?.setLastFailedSync(request.key) - } - - return updaterResult + private val storeLock = Mutex() + private val keyToWriteRequestQueue = mutableMapOf>() + private val keyToThreadSafety = mutableMapOf() + + override fun stream( + request: StoreReadRequest + ): Flow> = flow { + // Ensure we are ready for this key. + safeInitStore(request.key) + + // Try to eagerly resolve conflicts before pulling from network. + when (val eagerConflictResolutionResult = tryEagerlyResolveConflicts(request.key)) { + // TODO(#678): Many use cases will not want to pull immediately after failing to push local + // changes. + // We should enable configuration of conflict resolution strategies, such as logging, + // retrying, canceling. + + is EagerConflictResolutionResult.Error.Exception -> { + logger.error(eagerConflictResolutionResult.error.toString()) + } + + is EagerConflictResolutionResult.Error.Message -> { + logger.error(eagerConflictResolutionResult.message) + } + + is EagerConflictResolutionResult.Success.ConflictsResolved -> { + logger.debug(eagerConflictResolutionResult.value.toString()) + } + + EagerConflictResolutionResult.Success.NoConflicts -> { + logger.debug("No conflicts.") + } } - /** - * Post the very latest write for [key] to the network using [updater]. - */ - private suspend fun postLatest(key: Key): UpdaterResult { - // The "latest" is the last item in the queue for this key. - val writer = getLatestWriteRequest(key) - - return when (val updaterResult = updater.post(key, writer.value)) { - is UpdaterResult.Error.Exception -> UpdaterResult.Error.Exception(updaterResult.error) - is UpdaterResult.Error.Message -> UpdaterResult.Error.Message(updaterResult.message) - is UpdaterResult.Success.Untyped -> UpdaterResult.Success.Untyped(updaterResult.value) - is UpdaterResult.Success.Typed<*> -> { + // Now, we can just delegate to the underlying stream. + delegate.stream(request).collect { storeReadResponse -> emit(storeReadResponse) } + } + + @ExperimentalStoreApi + override fun stream( + requestStream: Flow> + ): Flow = flow { + // Each incoming write request is enqueued. + // Then we try to update the network and delegate. + + requestStream + .onEach { writeRequest -> + // Prepare per-key data structures. + safeInitStore(writeRequest.key) + + // Enqueue the new write request. + addWriteRequestToQueue(writeRequest) + } + .collect { writeRequest -> + val storeWriteResponse = + try { + // Always write to local first. + delegate.write(writeRequest.key, writeRequest.value) + + // Try to sync to network. + val updaterResult = tryUpdateServer(writeRequest) + + // Convert UpdaterResult -> StoreWriteResponse. + when (updaterResult) { + is UpdaterResult.Error.Exception -> + StoreWriteResponse.Error.Exception(updaterResult.error) + is UpdaterResult.Error.Message -> + StoreWriteResponse.Error.Message(updaterResult.message) + is UpdaterResult.Success.Typed<*> -> { val typedValue = updaterResult.value as? Response if (typedValue == null) { - UpdaterResult.Success.Untyped(updaterResult.value) + StoreWriteResponse.Success.Untyped(updaterResult.value) } else { - UpdaterResult.Success.Typed(updaterResult.value) + StoreWriteResponse.Success.Typed(updaterResult.value) } - } - } - } + } - /** - * Remove or keep queue items after a successful network sync. - */ - private suspend fun updateWriteRequestQueue( - key: Key, - created: Long, - updaterResult: UpdaterResult.Success, - ) { - val nextWriteRequestQueue = - withWriteRequestQueueLock(key) { - val remaining = ArrayDeque>() - - for (writeRequest in this) { - if (writeRequest.created <= created) { - // Mark each relevant request as succeeded. - updater.onCompletion?.onSuccess?.invoke(updaterResult) - - val storeWriteResponse = - when (updaterResult) { - is UpdaterResult.Success.Typed<*> -> { - val typedValue = updaterResult.value as? Response - if (typedValue == null) { - StoreWriteResponse.Success.Untyped(updaterResult.value) - } else { - StoreWriteResponse.Success.Typed(updaterResult.value) - } - } - - is UpdaterResult.Success.Untyped -> StoreWriteResponse.Success.Untyped(updaterResult.value) - } - - // Notify each on-completion callback. - writeRequest.onCompletions?.forEach { onStoreWriteCompletion -> - onStoreWriteCompletion.onSuccess(storeWriteResponse) - } - } else { - // Keep requests that happened after created. - remaining.add(writeRequest) - } - } - remaining + is UpdaterResult.Success.Untyped -> + StoreWriteResponse.Success.Untyped(updaterResult.value) } - - // Update the in-memory map outside the queue's mutex. - storeLock.withLock { - keyToWriteRequestQueue[key] = nextWriteRequestQueue - } + } catch (throwable: Throwable) { + StoreWriteResponse.Error.Exception(throwable) + } + emit(storeWriteResponse) + } + } + + @ExperimentalStoreApi + override suspend fun write( + request: StoreWriteRequest + ): StoreWriteResponse = stream(flowOf(request)).first() + + private suspend fun tryUpdateServer( + request: StoreWriteRequest + ): UpdaterResult { + val updaterResult = postLatest(request.key) + + if (updaterResult is UpdaterResult.Success) { + // We successfully synced to network, can now clear out any stale writes. + updateWriteRequestQueue( + key = request.key, + created = request.created, + updaterResult = updaterResult, + ) + bookkeeper?.clear(request.key) + } else { + // Could not sync, need to record a failed timestamp. + bookkeeper?.setLastFailedSync(request.key) } - /** - * Locks the queue for [key] and invokes [block]. - */ - private suspend fun withWriteRequestQueueLock( - key: Key, - block: suspend WriteRequestQueue.() -> Result, - ): Result { - // Acquire the ThreadSafety object for this key without holding storeLock. - val threadSafety = getThreadSafety(key) - - // Now safely lock the queue's own mutex. - threadSafety.writeRequests.lightswitch.lock(threadSafety.writeRequests.mutex) - return try { - val queue = getQueue((key)) - queue.block() - } finally { - threadSafety.writeRequests.lightswitch.unlock(threadSafety.writeRequests.mutex) + return updaterResult + } + + /** Post the very latest write for [key] to the network using [updater]. */ + private suspend fun postLatest(key: Key): UpdaterResult { + // The "latest" is the last item in the queue for this key. + val writer = getLatestWriteRequest(key) + + return when (val updaterResult = updater.post(key, writer.value)) { + is UpdaterResult.Error.Exception -> UpdaterResult.Error.Exception(updaterResult.error) + is UpdaterResult.Error.Message -> UpdaterResult.Error.Message(updaterResult.message) + is UpdaterResult.Success.Untyped -> UpdaterResult.Success.Untyped(updaterResult.value) + is UpdaterResult.Success.Typed<*> -> { + val typedValue = updaterResult.value as? Response + if (typedValue == null) { + UpdaterResult.Success.Untyped(updaterResult.value) + } else { + UpdaterResult.Success.Typed(updaterResult.value) } + } } + } + + /** Remove or keep queue items after a successful network sync. */ + private suspend fun updateWriteRequestQueue( + key: Key, + created: Long, + updaterResult: UpdaterResult.Success, + ) { + val nextWriteRequestQueue = + withWriteRequestQueueLock(key) { + val remaining = ArrayDeque>() + + for (writeRequest in this) { + if (writeRequest.created <= created) { + // Mark each relevant request as succeeded. + updater.onCompletion?.onSuccess?.invoke(updaterResult) + + val storeWriteResponse = + when (updaterResult) { + is UpdaterResult.Success.Typed<*> -> { + val typedValue = updaterResult.value as? Response + if (typedValue == null) { + StoreWriteResponse.Success.Untyped(updaterResult.value) + } else { + StoreWriteResponse.Success.Typed(updaterResult.value) + } + } + + is UpdaterResult.Success.Untyped -> + StoreWriteResponse.Success.Untyped(updaterResult.value) + } - private suspend fun getLatestWriteRequest(key: Key): StoreWriteRequest { - val threadSafety = getThreadSafety(key) - threadSafety.writeRequests.mutex.lock() - return try { - val queue = getQueue(key) - require(queue.isNotEmpty()) { - "No writes found for key=$key." + // Notify each on-completion callback. + writeRequest.onCompletions?.forEach { onStoreWriteCompletion -> + onStoreWriteCompletion.onSuccess(storeWriteResponse) } - queue.last() - } finally { - threadSafety.writeRequests.mutex.unlock() + } else { + // Keep requests that happened after created. + remaining.add(writeRequest) + } } + remaining + } + + // Update the in-memory map outside the queue's mutex. + storeLock.withLock { keyToWriteRequestQueue[key] = nextWriteRequestQueue } + } + + /** Locks the queue for [key] and invokes [block]. */ + private suspend fun withWriteRequestQueueLock( + key: Key, + block: suspend WriteRequestQueue.() -> Result, + ): Result { + // Acquire the ThreadSafety object for this key without holding storeLock. + val threadSafety = getThreadSafety(key) + + // Now safely lock the queue's own mutex. + threadSafety.writeRequests.lightswitch.lock(threadSafety.writeRequests.mutex) + return try { + val queue = getQueue((key)) + queue.block() + } finally { + threadSafety.writeRequests.lightswitch.unlock(threadSafety.writeRequests.mutex) } - - /** - * Checks if we have un-synced writes or a recorded failed sync for [key]. - */ - private suspend fun conflictsMightExist(key: Key): Boolean { - val failed = bookkeeper?.getLastFailedSync(key) - return (failed != null) || !writeRequestsQueueIsEmpty(key) + } + + private suspend fun getLatestWriteRequest(key: Key): StoreWriteRequest { + val threadSafety = getThreadSafety(key) + threadSafety.writeRequests.mutex.lock() + return try { + val queue = getQueue(key) + require(queue.isNotEmpty()) { "No writes found for key=$key." } + queue.last() + } finally { + threadSafety.writeRequests.mutex.unlock() } + } + + /** Checks if we have un-synced writes or a recorded failed sync for [key]. */ + private suspend fun conflictsMightExist(key: Key): Boolean { + val failed = bookkeeper?.getLastFailedSync(key) + return (failed != null) || !writeRequestsQueueIsEmpty(key) + } + + private fun writeRequestsQueueIsEmpty(key: Key): Boolean = + keyToWriteRequestQueue[key].isNullOrEmpty() + + private suspend fun addWriteRequestToQueue( + writeRequest: StoreWriteRequest + ) = withWriteRequestQueueLock(writeRequest.key) { add(writeRequest) } + + private suspend fun tryEagerlyResolveConflicts( + key: Key + ): EagerConflictResolutionResult { + // Acquire the ThreadSafety object for this key without holding storeLock. + val threadSafety = getThreadSafety(key) + + // Lock just long enough to check if conflicts exist. + val (latestValue, conflictsExist) = + threadSafety.readCompletions.mutex.withLock { + val latestValue = delegate.latestOrNull(key) + val conflictsExist = latestValue != null && bookkeeper != null && conflictsMightExist(key) + latestValue to conflictsExist + } + + return if (!conflictsExist || latestValue == null) { + EagerConflictResolutionResult.Success.NoConflicts + } else { + try { + val updaterResult = + updater.post(key, latestValue).also { updaterResult -> + if (updaterResult is UpdaterResult.Success) { + // If it succeeds, we want to remove stale requests and clear the bookkeeper. + updateWriteRequestQueue( + key = key, + created = now(), + updaterResult = updaterResult, + ) - private fun writeRequestsQueueIsEmpty(key: Key): Boolean = keyToWriteRequestQueue[key].isNullOrEmpty() - - private suspend fun addWriteRequestToQueue(writeRequest: StoreWriteRequest) = - withWriteRequestQueueLock(writeRequest.key) { - add(writeRequest) - } + bookkeeper?.clear(key) + } + } - private suspend fun tryEagerlyResolveConflicts(key: Key): EagerConflictResolutionResult { - // Acquire the ThreadSafety object for this key without holding storeLock. - val threadSafety = getThreadSafety(key) + when (updaterResult) { + is UpdaterResult.Error.Exception -> { + EagerConflictResolutionResult.Error.Exception(updaterResult.error) + } - // Lock just long enough to check if conflicts exist. - val (latestValue, conflictsExist) = - threadSafety.readCompletions.mutex.withLock { - val latestValue = delegate.latestOrNull(key) - val conflictsExist = latestValue != null && bookkeeper != null && conflictsMightExist(key) - latestValue to conflictsExist - } + is UpdaterResult.Error.Message -> { + EagerConflictResolutionResult.Error.Message(updaterResult.message) + } - return if (!conflictsExist || latestValue == null) { - EagerConflictResolutionResult.Success.NoConflicts - } else { - try { - val updaterResult = - updater.post(key, latestValue).also { updaterResult -> - if (updaterResult is UpdaterResult.Success) { - // If it succeeds, we want to remove stale requests and clear the bookkeeper. - updateWriteRequestQueue(key = key, created = now(), updaterResult = updaterResult) - - bookkeeper?.clear(key) - } - } - - when (updaterResult) { - is UpdaterResult.Error.Exception -> { - EagerConflictResolutionResult.Error.Exception(updaterResult.error) - } - - is UpdaterResult.Error.Message -> { - EagerConflictResolutionResult.Error.Message(updaterResult.message) - } - - is UpdaterResult.Success -> { - EagerConflictResolutionResult.Success.ConflictsResolved(updaterResult) - } - } - } catch (error: Throwable) { - EagerConflictResolutionResult.Error.Exception(error) - } + is UpdaterResult.Success -> { + EagerConflictResolutionResult.Success.ConflictsResolved(updaterResult) + } } + } catch (error: Throwable) { + EagerConflictResolutionResult.Error.Exception(error) + } } - - /** - * Ensures that [keyToThreadSafety] and [keyToWriteRequestQueue] have entries for [key]. - * We only hold [storeLock] while touching these two maps, then release it immediately. - */ - private suspend fun safeInitStore(key: Key) { - storeLock.withLock { - if (keyToThreadSafety[key] == null) { - keyToThreadSafety[key] = ThreadSafety() - } - if (keyToWriteRequestQueue[key] == null) { - keyToWriteRequestQueue[key] = ArrayDeque() - } - } + } + + /** + * Ensures that [keyToThreadSafety] and [keyToWriteRequestQueue] have entries for [key]. We only + * hold [storeLock] while touching these two maps, then release it immediately. + */ + private suspend fun safeInitStore(key: Key) { + storeLock.withLock { + if (keyToThreadSafety[key] == null) { + keyToThreadSafety[key] = ThreadSafety() + } + if (keyToWriteRequestQueue[key] == null) { + keyToWriteRequestQueue[key] = ArrayDeque() + } } - - /** - * Retrieves the [ThreadSafety] object for [key] without reinitializing it, since [safeInitStore] handles creation. - * We do a quick [storeLock] read then release it without nesting per-key locks inside [storeLock]. - */ - private suspend fun getThreadSafety(key: Key): ThreadSafety { - return storeLock.withLock { - requireNotNull(keyToThreadSafety[key]) { - "ThreadSafety not initialized for key=$key." - } - } + } + + /** + * Retrieves the [ThreadSafety] object for [key] without reinitializing it, since [safeInitStore] + * handles creation. We do a quick [storeLock] read then release it without nesting per-key locks + * inside [storeLock]. + */ + private suspend fun getThreadSafety(key: Key): ThreadSafety { + return storeLock.withLock { + requireNotNull(keyToThreadSafety[key]) { "ThreadSafety not initialized for key=$key." } } + } - /** - * Helper to retrieve the queue for [key] without re-initialization logic. - */ - private suspend fun getQueue(key: Key): WriteRequestQueue { - return storeLock.withLock { - requireNotNull(keyToWriteRequestQueue[key]) { - "No write request queue found for key=$key." - } - } + /** Helper to retrieve the queue for [key] without re-initialization logic. */ + private suspend fun getQueue(key: Key): WriteRequestQueue { + return storeLock.withLock { + requireNotNull(keyToWriteRequestQueue[key]) { "No write request queue found for key=$key." } } + } } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStoreBuilder.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStoreBuilder.kt index ff9696134..e1e60fb7f 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStoreBuilder.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStoreBuilder.kt @@ -19,91 +19,109 @@ import org.mobilenativefoundation.store.store5.impl.extensions.asMutableStore // we don't have a source of truth and can use a dummy converter fun mutableStoreBuilderFromFetcher( - fetcher: Fetcher, - converter: Converter, -): MutableStoreBuilder = RealMutableStoreBuilder(fetcher, converter = converter) + fetcher: Fetcher, + converter: Converter, +): MutableStoreBuilder = + RealMutableStoreBuilder(fetcher, converter = converter) -fun mutableStoreBuilderFromFetcherAndSourceOfTruth( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - converter: Converter, -): MutableStoreBuilder = RealMutableStoreBuilder(fetcher, sourceOfTruth, converter = converter) +fun < + Key : Any, + Network : Any, + Local : Any, + Output : Any, +> mutableStoreBuilderFromFetcherAndSourceOfTruth( + fetcher: Fetcher, + sourceOfTruth: SourceOfTruth, + converter: Converter, +): MutableStoreBuilder = + RealMutableStoreBuilder(fetcher, sourceOfTruth, converter = converter) -fun mutableStoreBuilderFromFetcherSourceOfTruthAndMemoryCache( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - memoryCache: Cache, - converter: Converter, -): MutableStoreBuilder = RealMutableStoreBuilder(fetcher, sourceOfTruth, memoryCache, converter = converter) +fun < + Key : Any, + Network : Any, + Output : Any, + Local : Any, +> mutableStoreBuilderFromFetcherSourceOfTruthAndMemoryCache( + fetcher: Fetcher, + sourceOfTruth: SourceOfTruth, + memoryCache: Cache, + converter: Converter, +): MutableStoreBuilder = + RealMutableStoreBuilder(fetcher, sourceOfTruth, memoryCache, converter = converter) internal class RealMutableStoreBuilder( - private val fetcher: Fetcher, - private val sourceOfTruth: SourceOfTruth? = null, - private val memoryCache: Cache? = null, - private val converter: Converter, + private val fetcher: Fetcher, + private val sourceOfTruth: SourceOfTruth? = null, + private val memoryCache: Cache? = null, + private val converter: Converter, ) : MutableStoreBuilder { - private var scope: CoroutineScope? = null - private var cachePolicy: MemoryPolicy? = StoreDefaults.memoryPolicy - private var validator: Validator? = null + private var scope: CoroutineScope? = null + private var cachePolicy: MemoryPolicy? = StoreDefaults.memoryPolicy + private var validator: Validator? = null - override fun scope(scope: CoroutineScope): MutableStoreBuilder { - this.scope = scope - return this - } + override fun scope(scope: CoroutineScope): MutableStoreBuilder { + this.scope = scope + return this + } - override fun cachePolicy(memoryPolicy: MemoryPolicy?): MutableStoreBuilder { - cachePolicy = memoryPolicy - return this - } + override fun cachePolicy( + memoryPolicy: MemoryPolicy? + ): MutableStoreBuilder { + cachePolicy = memoryPolicy + return this + } - override fun disableCache(): MutableStoreBuilder { - cachePolicy = null - return this - } + override fun disableCache(): MutableStoreBuilder { + cachePolicy = null + return this + } - override fun validator(validator: Validator): MutableStoreBuilder { - this.validator = validator - return this - } + override fun validator( + validator: Validator + ): MutableStoreBuilder { + this.validator = validator + return this + } - fun build(): Store = - RealStore( - scope = scope ?: GlobalScope, - sourceOfTruth = sourceOfTruth, - fetcher = fetcher, - converter = converter, - validator = validator, - memCache = - memoryCache ?: cachePolicy?.let { - CacheBuilder().apply { - if (cachePolicy!!.hasAccessPolicy) { - expireAfterAccess(cachePolicy!!.expireAfterAccess) - } - if (cachePolicy!!.hasWritePolicy) { - expireAfterWrite(cachePolicy!!.expireAfterWrite) - } - if (cachePolicy!!.hasMaxSize) { - maximumSize(cachePolicy!!.maxSize) - } + fun build(): Store = + RealStore( + scope = scope ?: GlobalScope, + sourceOfTruth = sourceOfTruth, + fetcher = fetcher, + converter = converter, + validator = validator, + memCache = + memoryCache + ?: cachePolicy?.let { + CacheBuilder() + .apply { + if (cachePolicy!!.hasAccessPolicy) { + expireAfterAccess(cachePolicy!!.expireAfterAccess) + } + if (cachePolicy!!.hasWritePolicy) { + expireAfterWrite(cachePolicy!!.expireAfterWrite) + } + if (cachePolicy!!.hasMaxSize) { + maximumSize(cachePolicy!!.maxSize) + } - if (cachePolicy!!.hasMaxWeight) { - weigher(cachePolicy!!.maxWeight) { key, value -> - cachePolicy!!.weigher.weigh( - key, - value, - ) - } - } - }.build() - }, - ) + if (cachePolicy!!.hasMaxWeight) { + weigher(cachePolicy!!.maxWeight) { key, value -> + cachePolicy!!.weigher.weigh(key, value) + } + } + } + .build() + }, + ) - override fun build( - updater: Updater, - bookkeeper: Bookkeeper?, - ): MutableStore = - build().asMutableStore( - updater = updater, - bookkeeper = bookkeeper, - ) + override fun build( + updater: Updater, + bookkeeper: Bookkeeper?, + ): MutableStore = + build() + .asMutableStore( + updater = updater, + bookkeeper = bookkeeper, + ) } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealSourceOfTruth.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealSourceOfTruth.kt index 2c50aa6b7..2d4ed5b77 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealSourceOfTruth.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealSourceOfTruth.kt @@ -20,51 +20,44 @@ import kotlinx.coroutines.flow.flow import org.mobilenativefoundation.store.store5.SourceOfTruth internal class PersistentSourceOfTruth( - private val realReader: (Key) -> Flow, - private val realWriter: suspend (Key, Local) -> Unit, - private val realDelete: (suspend (Key) -> Unit)? = null, - private val realDeleteAll: (suspend () -> Unit)? = null, + private val realReader: (Key) -> Flow, + private val realWriter: suspend (Key, Local) -> Unit, + private val realDelete: (suspend (Key) -> Unit)? = null, + private val realDeleteAll: (suspend () -> Unit)? = null, ) : SourceOfTruth { - override fun reader(key: Key): Flow = realReader.invoke(key) + override fun reader(key: Key): Flow = realReader.invoke(key) - override suspend fun write( - key: Key, - value: Local, - ) = realWriter(key, value) + override suspend fun write(key: Key, value: Local) = realWriter(key, value) - override suspend fun delete(key: Key) { - realDelete?.invoke(key) - } + override suspend fun delete(key: Key) { + realDelete?.invoke(key) + } - override suspend fun deleteAll() { - realDeleteAll?.invoke() - } + override suspend fun deleteAll() { + realDeleteAll?.invoke() + } } internal class PersistentNonFlowingSourceOfTruth( - private val realReader: suspend (Key) -> Output?, - private val realWriter: suspend (Key, Local) -> Unit, - private val realDelete: (suspend (Key) -> Unit)? = null, - private val realDeleteAll: (suspend () -> Unit)?, + private val realReader: suspend (Key) -> Output?, + private val realWriter: suspend (Key, Local) -> Unit, + private val realDelete: (suspend (Key) -> Unit)? = null, + private val realDeleteAll: (suspend () -> Unit)?, ) : SourceOfTruth { - override fun reader(key: Key): Flow = - flow { - val sot = realReader(key) - emit(sot) - } - - override suspend fun write( - key: Key, - value: Local, - ) { - return realWriter(key, value) - } - - override suspend fun delete(key: Key) { - realDelete?.invoke(key) - } - - override suspend fun deleteAll() { - realDeleteAll?.invoke() - } + override fun reader(key: Key): Flow = flow { + val sot = realReader(key) + emit(sot) + } + + override suspend fun write(key: Key, value: Local) { + return realWriter(key, value) + } + + override suspend fun delete(key: Key) { + realDelete?.invoke(key) + } + + override suspend fun deleteAll() { + realDeleteAll?.invoke() + } } 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 d0198297c..88bad8334 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 @@ -43,314 +43,293 @@ import org.mobilenativefoundation.store.store5.impl.operators.merge import org.mobilenativefoundation.store.store5.internal.result.StoreDelegateWriteResult internal class RealStore( - scope: CoroutineScope, - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth? = null, - private val converter: Converter, - private val validator: Validator?, - private val memCache: Cache?, + scope: CoroutineScope, + fetcher: Fetcher, + sourceOfTruth: SourceOfTruth? = null, + private val converter: Converter, + private val validator: Validator?, + private val memCache: Cache?, ) : Store { - /** - * This source of truth is either a real database or an in memory source of truth created by - * the builder. - * Whatever is given, we always put a [SourceOfTruthWithBarrier] in front of it so that while - * we write the value from fetcher into the disk, we can block reads to avoid sending new data - * as if it came from the server (the [StoreReadResponse.origin] field). - */ - private val sourceOfTruth: SourceOfTruthWithBarrier? = - sourceOfTruth?.let { - SourceOfTruthWithBarrier(it, converter) - } + /** + * This source of truth is either a real database or an in memory source of truth created by the + * builder. Whatever is given, we always put a [SourceOfTruthWithBarrier] in front of it so that + * while we write the value from fetcher into the disk, we can block reads to avoid sending new + * data as if it came from the server (the [StoreReadResponse.origin] field). + */ + private val sourceOfTruth: SourceOfTruthWithBarrier? = + sourceOfTruth?.let { SourceOfTruthWithBarrier(it, converter) } - /** - * Fetcher controller maintains 1 and only 1 `Multicaster` for a given key to ensure network - * requests are shared. - */ - private val fetcherController = - FetcherController( - scope = scope, - realFetcher = fetcher, - sourceOfTruth = this.sourceOfTruth, - converter = converter, - ) + /** + * Fetcher controller maintains 1 and only 1 `Multicaster` for a given key to ensure network + * requests are shared. + */ + private val fetcherController = + FetcherController( + scope = scope, + realFetcher = fetcher, + sourceOfTruth = this.sourceOfTruth, + converter = converter, + ) - @Suppress("UNCHECKED_CAST") - override fun stream(request: StoreReadRequest): Flow> = - flow { - val cachedToEmit = - if (request.shouldSkipCache(CacheType.MEMORY)) { - null - } else { - val output: Output? = memCache?.getIfPresent(request.key) - val isInvalid = output != null && validator?.isValid(output) == false - when { - output == null || isInvalid -> null - else -> output - } - } - - cachedToEmit?.let { it: Output -> - // if we read a value from cache, dispatch it first - emit(StoreReadResponse.Data(value = it, origin = StoreReadResponseOrigin.Cache)) + @Suppress("UNCHECKED_CAST") + override fun stream(request: StoreReadRequest): Flow> = + flow { + val cachedToEmit = + if (request.shouldSkipCache(CacheType.MEMORY)) { + null + } else { + val output: Output? = memCache?.getIfPresent(request.key) + val isInvalid = output != null && validator?.isValid(output) == false + when { + output == null || isInvalid -> null + else -> output } + } - if (sourceOfTruth == null && !request.fetch) { - if (memCache == null) { - logger.w("Local-only request made with no cache or source of truth configured") - } - emit(StoreReadResponse.NoNewData(origin = StoreReadResponseOrigin.Cache)) - return@flow - } + cachedToEmit?.let { it: Output -> + // if we read a value from cache, dispatch it first + emit(StoreReadResponse.Data(value = it, origin = StoreReadResponseOrigin.Cache)) + } - val stream: Flow> = - if (sourceOfTruth == null) { - // piggyback only if not specified fresh data AND we emitted a value from the cache - val piggybackOnly = !request.refresh && cachedToEmit != null - @Suppress("UNCHECKED_CAST") + if (sourceOfTruth == null && !request.fetch) { + if (memCache == null) { + logger.w("Local-only request made with no cache or source of truth configured") + } + emit(StoreReadResponse.NoNewData(origin = StoreReadResponseOrigin.Cache)) + return@flow + } - createNetworkFlow( - request = request, - networkLock = null, - piggybackOnly = piggybackOnly, - ) as Flow> // when no source of truth Input == Output - } else if (request.fetch) { - diskNetworkCombined(request, sourceOfTruth) - } else { - val diskLock = CompletableDeferred() - diskLock.complete(Unit) - sourceOfTruth.reader(request.key, diskLock).transform { response -> - val data = response.dataOrNull() - if (data == null || validator?.isValid(data) == false) { - emit(StoreReadResponse.NoNewData(origin = response.origin)) - } else { - emit(StoreReadResponse.Data(value = data, origin = response.origin)) - } - } - } - emitAll( - stream.transform { output: StoreReadResponse -> - emit(output) - if (output is StoreReadResponse.NoNewData && cachedToEmit == null) { - // In the special case where fetcher returned no new data we actually want to - // serve cache data (even if the request specified skipping cache and/or SoT) - // - // For stream(Request.cached(key, refresh=true)) we will return: - // Cache - // Source of truth - // Fetcher - > Loading - // Fetcher - > NoNewData - // (future Source of truth updates) - // - // For stream(Request.fresh(key)) we will return: - // Fetcher - > Loading - // Fetcher - > NoNewData - // Cache - // Source of truth - // (future Source of truth updates) - memCache?.getIfPresent(request.key)?.let { - emit( - StoreReadResponse.Data( - value = it, - origin = StoreReadResponseOrigin.Cache, - ), - ) - } - } - }, - ) - }.onEach { - // whenever a value is dispatched, save it to the memory cache - if (it.origin != StoreReadResponseOrigin.Cache) { - it.dataOrNull()?.let { data -> - memCache?.put(request.key, data) - } + val stream: Flow> = + if (sourceOfTruth == null) { + // piggyback only if not specified fresh data AND we emitted a value from the cache + val piggybackOnly = !request.refresh && cachedToEmit != null + @Suppress("UNCHECKED_CAST") + createNetworkFlow(request = request, networkLock = null, piggybackOnly = piggybackOnly) + as Flow> // when no source of truth Input == Output + } else if (request.fetch) { + diskNetworkCombined(request, sourceOfTruth) + } else { + val diskLock = CompletableDeferred() + diskLock.complete(Unit) + sourceOfTruth.reader(request.key, diskLock).transform { response -> + val data = response.dataOrNull() + if (data == null || validator?.isValid(data) == false) { + emit(StoreReadResponse.NoNewData(origin = response.origin)) + } else { + emit(StoreReadResponse.Data(value = data, origin = response.origin)) + } + } + } + emitAll( + stream.transform { output: StoreReadResponse -> + emit(output) + if (output is StoreReadResponse.NoNewData && cachedToEmit == null) { + // In the special case where fetcher returned no new data we actually want to + // serve cache data (even if the request specified skipping cache and/or SoT) + // + // For stream(Request.cached(key, refresh=true)) we will return: + // Cache + // Source of truth + // Fetcher - > Loading + // Fetcher - > NoNewData + // (future Source of truth updates) + // + // For stream(Request.fresh(key)) we will return: + // Fetcher - > Loading + // Fetcher - > NoNewData + // Cache + // Source of truth + // (future Source of truth updates) + memCache?.getIfPresent(request.key)?.let { + emit(StoreReadResponse.Data(value = it, origin = StoreReadResponseOrigin.Cache)) + } } + } + ) + } + .onEach { + // whenever a value is dispatched, save it to the memory cache + if (it.origin != StoreReadResponseOrigin.Cache) { + it.dataOrNull()?.let { data -> memCache?.put(request.key, data) } } + } - override suspend fun clear(key: Key) { - memCache?.invalidate(key) - sourceOfTruth?.delete(key) - } + override suspend fun clear(key: Key) { + memCache?.invalidate(key) + sourceOfTruth?.delete(key) + } - @ExperimentalStoreApi - override suspend fun clear() { - memCache?.invalidateAll() - sourceOfTruth?.deleteAll() - } + @ExperimentalStoreApi + override suspend fun clear() { + memCache?.invalidateAll() + sourceOfTruth?.deleteAll() + } - /** - * We want to stream from disk but also want to refresh. If requested or necessary. - * - * How it works: - * There are two flows: - * Fetcher: The flow we get for the fetching - * Disk: The flow we get from the [SourceOfTruth]. - * Both flows are controlled by a lock for each so that we can start the right one based on - * the request status or values we receive. - * - * Value is always returned from [SourceOfTruth] while the errors are dispatched from both the - * `Fetcher` and [SourceOfTruth]. - * - * There are two initialization paths: - * - * 1) Request wants to skip disk cache: - * In this case, we first start the fetcher flow. When fetcher flow provides something besides - * an error, we enable the disk flow. - * - * 2) Request does not want to skip disk cache: - * In this case, we first start the disk flow. If disk flow returns `null` or - * [StoreReadRequest.refresh] is set to `true`, we enable the fetcher flow. - * This ensures we first get the value from disk and then load from server if necessary. - */ - private fun diskNetworkCombined( - request: StoreReadRequest, - sourceOfTruth: SourceOfTruthWithBarrier, - ): Flow> { - val diskLock = CompletableDeferred() - val networkLock = CompletableDeferred() - val networkFlow = createNetworkFlow(request, networkLock) - val skipDiskCache = request.shouldSkipCache(CacheType.DISK) - if (!skipDiskCache) { - diskLock.complete(Unit) + /** + * We want to stream from disk but also want to refresh. If requested or necessary. + * + * How it works: There are two flows: Fetcher: The flow we get for the fetching Disk: The flow we + * get from the [SourceOfTruth]. Both flows are controlled by a lock for each so that we can start + * the right one based on the request status or values we receive. + * + * Value is always returned from [SourceOfTruth] while the errors are dispatched from both the + * `Fetcher` and [SourceOfTruth]. + * + * There are two initialization paths: + * 1) Request wants to skip disk cache: In this case, we first start the fetcher flow. When + * fetcher flow provides something besides an error, we enable the disk flow. + * 2) Request does not want to skip disk cache: In this case, we first start the disk flow. If + * disk flow returns `null` or [StoreReadRequest.refresh] is set to `true`, we enable the + * fetcher flow. This ensures we first get the value from disk and then load from server if + * necessary. + */ + private fun diskNetworkCombined( + request: StoreReadRequest, + sourceOfTruth: SourceOfTruthWithBarrier, + ): Flow> { + val diskLock = CompletableDeferred() + val networkLock = CompletableDeferred() + val networkFlow = createNetworkFlow(request, networkLock) + val skipDiskCache = request.shouldSkipCache(CacheType.DISK) + if (!skipDiskCache) { + diskLock.complete(Unit) + } + val diskFlow = + sourceOfTruth.reader(request.key, diskLock).onStart { + // wait for disk to latch first to ensure it happens before network triggers. + // after that, if we'll not read from disk, then allow network to continue + if (skipDiskCache) { + networkLock.complete(Unit) } - val diskFlow = - sourceOfTruth.reader(request.key, diskLock).onStart { - // wait for disk to latch first to ensure it happens before network triggers. - // after that, if we'll not read from disk, then allow network to continue - if (skipDiskCache) { - networkLock.complete(Unit) - } - } + } - val requestKeyToFetcherName: MutableMap = mutableMapOf() - // we use a merge implementation that gives the source of the flow so that we can decide - // based on that. - return networkFlow.merge(diskFlow).transform { - // left is Fetcher while right is source of truth - when (it) { - is Either.Left -> { - // left, that is data from network - val responseOrigin = it.value.origin as StoreReadResponseOrigin.Fetcher - requestKeyToFetcherName[request.key] = responseOrigin.name + val requestKeyToFetcherName: MutableMap = mutableMapOf() + // we use a merge implementation that gives the source of the flow so that we can decide + // based on that. + return networkFlow.merge(diskFlow).transform { + // left is Fetcher while right is source of truth + when (it) { + is Either.Left -> { + // left, that is data from network + val responseOrigin = it.value.origin as StoreReadResponseOrigin.Fetcher + requestKeyToFetcherName[request.key] = responseOrigin.name - val fallBackToSourceOfTruth = - it.value is StoreReadResponse.Error && request.fallBackToSourceOfTruth + val fallBackToSourceOfTruth = + it.value is StoreReadResponse.Error && request.fallBackToSourceOfTruth - if (it.value is StoreReadResponse.Data || it.value is StoreReadResponse.NoNewData || fallBackToSourceOfTruth) { - // Unlocking disk only if network sent data or reported no new data - // so that fresh data request never receives new fetcher data after - // cached disk data. - // This means that if the user asked for fresh data but the network returned - // no new data we will still unblock disk. - diskLock.complete(Unit) - } - - if (it.value !is StoreReadResponse.Data) { - emit(it.value.swapType()) - } - } + if ( + it.value is StoreReadResponse.Data || + it.value is StoreReadResponse.NoNewData || + fallBackToSourceOfTruth + ) { + // Unlocking disk only if network sent data or reported no new data + // so that fresh data request never receives new fetcher data after + // cached disk data. + // This means that if the user asked for fresh data but the network returned + // no new data we will still unblock disk. + diskLock.complete(Unit) + } - is Either.Right -> { - // right, that is data from disk - when (val diskData = it.value) { - is StoreReadResponse.Data -> { - val responseOriginWithFetcherName = - diskData.origin.let { origin -> - if (origin is StoreReadResponseOrigin.Fetcher) { - origin.copy(name = requestKeyToFetcherName[request.key]) - } else { - origin - } - } + if (it.value !is StoreReadResponse.Data) { + emit(it.value.swapType()) + } + } - val diskValue = diskData.value - val isValid = - (validator == null && diskValue != null) || - diskData.origin is StoreReadResponseOrigin.Fetcher || - (diskValue != null && validator?.isValid(diskValue) ?: true) + is Either.Right -> { + // right, that is data from disk + when (val diskData = it.value) { + is StoreReadResponse.Data -> { + val responseOriginWithFetcherName = + diskData.origin.let { origin -> + if (origin is StoreReadResponseOrigin.Fetcher) { + origin.copy(name = requestKeyToFetcherName[request.key]) + } else { + origin + } + } - if (isValid) { - @Suppress("UNCHECKED_CAST") - val output = - diskData.copy(origin = responseOriginWithFetcherName) as StoreReadResponse - emit(output) - } - // If the disk value is null - // or refresh was requested - // or the disk value is not valid - // then allow fetcher to start emitting values. - if (request.refresh || diskData.value == null || !isValid) { - networkLock.complete(Unit) - } - } + val diskValue = diskData.value + val isValid = + (validator == null && diskValue != null) || + diskData.origin is StoreReadResponseOrigin.Fetcher || + (diskValue != null && validator?.isValid(diskValue) ?: true) - is StoreReadResponse.Error -> { - // disk sent an error, send it down as well - emit(diskData) + if (isValid) { + @Suppress("UNCHECKED_CAST") + val output = + diskData.copy(origin = responseOriginWithFetcherName) as StoreReadResponse + emit(output) + } + // If the disk value is null + // or refresh was requested + // or the disk value is not valid + // then allow fetcher to start emitting values. + if (request.refresh || diskData.value == null || !isValid) { + networkLock.complete(Unit) + } + } - // If disk sent a read error, we should allow fetcher to start emitting - // values since there is nothing to read from disk. If disk sent a write - // error, we should NOT allow fetcher to start emitting values as we - // should always wait for the read attempt. - if (diskData is StoreReadResponse.Error.Exception && - diskData.error is SourceOfTruth.ReadException - ) { - networkLock.complete(Unit) - } - // for other errors, don't do anything, wait for the read attempt - } + is StoreReadResponse.Error -> { + // disk sent an error, send it down as well + emit(diskData) - is StoreReadResponse.Initial, - is StoreReadResponse.Loading, - is StoreReadResponse.NoNewData, - -> { - } - } - } + // If disk sent a read error, we should allow fetcher to start emitting + // values since there is nothing to read from disk. If disk sent a write + // error, we should NOT allow fetcher to start emitting values as we + // should always wait for the read attempt. + if ( + diskData is StoreReadResponse.Error.Exception && + diskData.error is SourceOfTruth.ReadException + ) { + networkLock.complete(Unit) + } + // for other errors, don't do anything, wait for the read attempt } + + is StoreReadResponse.Initial, + is StoreReadResponse.Loading, + is StoreReadResponse.NoNewData -> {} + } } + } } + } - private fun createNetworkFlow( - request: StoreReadRequest, - networkLock: CompletableDeferred?, - piggybackOnly: Boolean = false, - ): Flow> { - return fetcherController - .getFetcher(request.key, piggybackOnly) - .onStart { - // wait until disk gives us the go - networkLock?.await() - if (!piggybackOnly) { - emit(StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher())) - } - } + private fun createNetworkFlow( + request: StoreReadRequest, + networkLock: CompletableDeferred?, + piggybackOnly: Boolean = false, + ): Flow> { + return fetcherController.getFetcher(request.key, piggybackOnly).onStart { + // wait until disk gives us the go + networkLock?.await() + if (!piggybackOnly) { + emit(StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher())) + } } + } - internal suspend fun write( - key: Key, - value: Output, - ): StoreDelegateWriteResult = - try { - memCache?.put(key, value) - sourceOfTruth?.write(key, converter.fromOutputToLocal(value)) - StoreDelegateWriteResult.Success - } catch (error: Throwable) { - StoreDelegateWriteResult.Error.Exception(error) - } + internal suspend fun write(key: Key, value: Output): StoreDelegateWriteResult = + try { + memCache?.put(key, value) + sourceOfTruth?.write(key, converter.fromOutputToLocal(value)) + StoreDelegateWriteResult.Success + } catch (error: Throwable) { + StoreDelegateWriteResult.Error.Exception(error) + } - internal suspend fun latestOrNull(key: Key): Output? = fromMemCache(key) ?: fromSourceOfTruth(key) + internal suspend fun latestOrNull(key: Key): Output? = fromMemCache(key) ?: fromSourceOfTruth(key) - private suspend fun fromSourceOfTruth(key: Key) = - sourceOfTruth?.reader(key, CompletableDeferred(Unit))?.map { it.dataOrNull() }?.first() + private suspend fun fromSourceOfTruth(key: Key) = + sourceOfTruth?.reader(key, CompletableDeferred(Unit))?.map { it.dataOrNull() }?.first() - private fun fromMemCache(key: Key) = memCache?.getIfPresent(key) + private fun fromMemCache(key: Key) = memCache?.getIfPresent(key) - companion object { - private val logger = - Logger.apply { - setLogWriters(listOf(CommonWriter())) - setTag("Store") - } - } + companion object { + private val logger = + Logger.apply { + setLogWriters(listOf(CommonWriter())) + setTag("Store") + } + } } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreBuilder.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreBuilder.kt index 88b3d7667..d86bc2e5c 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreBuilder.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreBuilder.kt @@ -18,128 +18,137 @@ import org.mobilenativefoundation.store.store5.StoreDefaults import org.mobilenativefoundation.store.store5.Validator fun storeBuilderFromFetcher( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth? = null, + fetcher: Fetcher, + sourceOfTruth: SourceOfTruth? = null, ): StoreBuilder = RealStoreBuilder(fetcher, sourceOfTruth) fun storeBuilderFromFetcherAndSourceOfTruth( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, + fetcher: Fetcher, + sourceOfTruth: SourceOfTruth, ): StoreBuilder = RealStoreBuilder(fetcher, sourceOfTruth) fun storeBuilderFromFetcherSourceOfTruthAndMemoryCache( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - memoryCache: Cache, + fetcher: Fetcher, + sourceOfTruth: SourceOfTruth, + memoryCache: Cache, ): StoreBuilder = RealStoreBuilder(fetcher, sourceOfTruth, memoryCache) -fun storeBuilderFromFetcherSourceOfTruthMemoryCacheAndConverter( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - memoryCache: Cache?, - converter: Converter, +fun < + Key : Any, + Network : Any, + Output : Any, + Local : Any, +> storeBuilderFromFetcherSourceOfTruthMemoryCacheAndConverter( + fetcher: Fetcher, + sourceOfTruth: SourceOfTruth, + memoryCache: Cache?, + converter: Converter, ): StoreBuilder = RealStoreBuilder(fetcher, sourceOfTruth, memoryCache, converter) internal class RealStoreBuilder( - private val fetcher: Fetcher, - private val sourceOfTruth: SourceOfTruth? = null, - private val memoryCache: Cache? = null, - private val converter: Converter? = null, + private val fetcher: Fetcher, + private val sourceOfTruth: SourceOfTruth? = null, + private val memoryCache: Cache? = null, + private val converter: Converter? = null, ) : StoreBuilder { - private var scope: CoroutineScope? = null - private var cachePolicy: MemoryPolicy? = StoreDefaults.memoryPolicy - private var validator: Validator? = null - - override fun scope(scope: CoroutineScope): StoreBuilder { - this.scope = scope - return this - } - - override fun cachePolicy(memoryPolicy: MemoryPolicy?): StoreBuilder { - cachePolicy = memoryPolicy - return this - } - - override fun disableCache(): StoreBuilder { - cachePolicy = null - return this - } - - override fun validator(validator: Validator): StoreBuilder { - this.validator = validator - return this - } - - @OptIn(DelicateCoroutinesApi::class) - override fun build(): Store = - RealStore( - scope = scope ?: GlobalScope, - sourceOfTruth = sourceOfTruth, - fetcher = fetcher, - converter = converter ?: DefaultConverter(), - validator = validator, - memCache = - memoryCache ?: cachePolicy?.let { - CacheBuilder().apply { - if (cachePolicy!!.hasAccessPolicy) { - expireAfterAccess(cachePolicy!!.expireAfterAccess) - } - if (cachePolicy!!.hasWritePolicy) { - expireAfterWrite(cachePolicy!!.expireAfterWrite) - } - if (cachePolicy!!.hasMaxSize) { - maximumSize(cachePolicy!!.maxSize) - } - - if (cachePolicy!!.hasMaxWeight) { - weigher(cachePolicy!!.maxWeight) { key, value -> - cachePolicy!!.weigher.weigh( - key, - value, - ) - } - } - }.build() - }, - ) - - override fun toMutableStoreBuilder( - converter: Converter, - ): MutableStoreBuilder { - fetcher as Fetcher - return if (sourceOfTruth == null && memoryCache == null) { - mutableStoreBuilderFromFetcher(fetcher, converter) - } else if (memoryCache == null) { - mutableStoreBuilderFromFetcherAndSourceOfTruth( - fetcher, - sourceOfTruth as SourceOfTruth, - converter, - ) + private var scope: CoroutineScope? = null + private var cachePolicy: MemoryPolicy? = StoreDefaults.memoryPolicy + private var validator: Validator? = null + + override fun scope(scope: CoroutineScope): StoreBuilder { + this.scope = scope + return this + } + + override fun cachePolicy(memoryPolicy: MemoryPolicy?): StoreBuilder { + cachePolicy = memoryPolicy + return this + } + + override fun disableCache(): StoreBuilder { + cachePolicy = null + return this + } + + override fun validator(validator: Validator): StoreBuilder { + this.validator = validator + return this + } + + @OptIn(DelicateCoroutinesApi::class) + override fun build(): Store = + RealStore( + scope = scope ?: GlobalScope, + sourceOfTruth = sourceOfTruth, + fetcher = fetcher, + converter = converter ?: DefaultConverter(), + validator = validator, + memCache = + memoryCache + ?: cachePolicy?.let { + CacheBuilder() + .apply { + if (cachePolicy!!.hasAccessPolicy) { + expireAfterAccess(cachePolicy!!.expireAfterAccess) + } + if (cachePolicy!!.hasWritePolicy) { + expireAfterWrite(cachePolicy!!.expireAfterWrite) + } + if (cachePolicy!!.hasMaxSize) { + maximumSize(cachePolicy!!.maxSize) + } + + if (cachePolicy!!.hasMaxWeight) { + weigher(cachePolicy!!.maxWeight) { key, value -> + cachePolicy!!.weigher.weigh(key, value) + } + } + } + .build() + }, + ) + + override fun toMutableStoreBuilder( + converter: Converter + ): MutableStoreBuilder { + fetcher as Fetcher + return if (sourceOfTruth == null && memoryCache == null) { + mutableStoreBuilderFromFetcher(fetcher, converter) + } else + if (memoryCache == null) { + mutableStoreBuilderFromFetcherAndSourceOfTruth( + fetcher, + sourceOfTruth as SourceOfTruth, + converter, + ) } else { - mutableStoreBuilderFromFetcherSourceOfTruthAndMemoryCache( - fetcher, - sourceOfTruth as SourceOfTruth, - memoryCache, - converter, - ) - }.apply { - if (this@RealStoreBuilder.scope != null) { - scope(this@RealStoreBuilder.scope!!) - } - - if (this@RealStoreBuilder.cachePolicy != null) { - cachePolicy(this@RealStoreBuilder.cachePolicy) - } - - if (this@RealStoreBuilder.validator != null) { - validator(this@RealStoreBuilder.validator!!) - } + mutableStoreBuilderFromFetcherSourceOfTruthAndMemoryCache( + fetcher, + sourceOfTruth as SourceOfTruth, + memoryCache, + converter, + ) } - } + .apply { + if (this@RealStoreBuilder.scope != null) { + scope(this@RealStoreBuilder.scope!!) + } + + if (this@RealStoreBuilder.cachePolicy != null) { + cachePolicy(this@RealStoreBuilder.cachePolicy) + } + + if (this@RealStoreBuilder.validator != null) { + validator(this@RealStoreBuilder.validator!!) + } + } + } } -private class DefaultConverter : Converter { - override fun fromOutputToLocal(output: Output): Local = throw IllegalStateException("non mutable store never call this function") +private class DefaultConverter : + Converter { + override fun fromOutputToLocal(output: Output): Local = + throw IllegalStateException("non mutable store never call this function") - override fun fromNetworkToLocal(network: Network): Local = network as Local + override fun fromNetworkToLocal(network: Network): Local = network as Local } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreWriteRequest.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreWriteRequest.kt index bbf87fc08..64537853d 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreWriteRequest.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreWriteRequest.kt @@ -3,8 +3,8 @@ package org.mobilenativefoundation.store.store5.impl import org.mobilenativefoundation.store.store5.StoreWriteRequest data class RealStoreWriteRequest( - override val key: Key, - override val value: Output, - override val created: Long, - override val onCompletions: List?, + override val key: Key, + override val value: Output, + override val created: Long, + override val onCompletions: List?, ) : StoreWriteRequest diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealValidator.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealValidator.kt index 4c956a924..a3c5bfe3c 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealValidator.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealValidator.kt @@ -3,7 +3,7 @@ package org.mobilenativefoundation.store.store5.impl import org.mobilenativefoundation.store.store5.Validator internal class RealValidator( - private val realValidator: suspend (item: Output) -> Boolean, + private val realValidator: suspend (item: Output) -> Boolean ) : Validator { - override suspend fun isValid(item: Output): Boolean = realValidator(item) + override suspend fun isValid(item: Output): Boolean = realValidator(item) } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RefCountedResource.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RefCountedResource.kt index d98473292..01a84de33 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RefCountedResource.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RefCountedResource.kt @@ -18,48 +18,32 @@ package org.mobilenativefoundation.store.store5.impl import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -/** - * Simple holder that can ref-count items by a given key. - */ +/** Simple holder that can ref-count items by a given key. */ internal class RefCountedResource( - private val create: suspend (Key) -> T, - private val onRelease: (suspend (Key, T) -> Unit)? = null, + private val create: suspend (Key) -> T, + private val onRelease: (suspend (Key, T) -> Unit)? = null, ) { - private val items = mutableMapOf() - private val lock = Mutex() + private val items = mutableMapOf() + private val lock = Mutex() - suspend fun acquire(key: Key): T = - lock.withLock { - items.getOrPut(key) { - Item(create(key)) - }.also { - it.refCount++ - }.value - } + suspend fun acquire(key: Key): T = + lock.withLock { items.getOrPut(key) { Item(create(key)) }.also { it.refCount++ }.value } - suspend fun release( - key: Key, - value: T, - ) = lock.withLock { - val existing = items[key] - check(existing != null && existing.value === value) { - "inconsistent release, seems like $value was leaked or never acquired" - } - existing.refCount-- - if (existing.refCount < 1) { - items.remove(key) - onRelease?.invoke(key, value) - } + suspend fun release(key: Key, value: T) = + lock.withLock { + val existing = items[key] + check(existing != null && existing.value === value) { + "inconsistent release, seems like $value was leaked or never acquired" + } + existing.refCount-- + if (existing.refCount < 1) { + items.remove(key) + onRelease?.invoke(key, value) + } } - // used in tests - suspend fun size() = - lock.withLock { - items.size - } + // used in tests + suspend fun size() = lock.withLock { items.size } - private inner class Item( - val value: T, - var refCount: Int = 0, - ) + private inner class Item(val value: T, var refCount: Int = 0) } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/SourceOfTruthWithBarrier.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/SourceOfTruthWithBarrier.kt index a0a0953b9..e058b2a5f 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/SourceOfTruthWithBarrier.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/SourceOfTruthWithBarrier.kt @@ -35,182 +35,164 @@ import org.mobilenativefoundation.store.store5.impl.operators.mapIndexed /** * Wraps a [SourceOfTruth] and blocks reads while a write is in progress. * - * Used in the [RealStore] implementation to avoid - * dispatching values to downstream while a write is in progress. + * Used in the [RealStore] implementation to avoid dispatching values to downstream while a write is + * in progress. */ @Suppress("UNCHECKED_CAST") internal class SourceOfTruthWithBarrier( - private val delegate: SourceOfTruth, - private val converter: Converter? = null, + private val delegate: SourceOfTruth, + private val converter: Converter? = null, ) { - /** - * Each key has a barrier so that we can block reads while writing. - */ - private val barriers = - RefCountedResource>( - create = { - MutableStateFlow(BarrierMsg.Open.INITIAL) - }, - ) + /** Each key has a barrier so that we can block reads while writing. */ + private val barriers = + RefCountedResource>( + create = { MutableStateFlow(BarrierMsg.Open.INITIAL) } + ) - /** - * Each message gets dispatched with a version. This ensures we won't accidentally turn on the - * reader flow for a new reader that happens to have arrived while a write is in progress since - * that write should be considered as a disk read for that flow, not fetcher. - */ - private val versionCounter = atomic(0L) + /** + * Each message gets dispatched with a version. This ensures we won't accidentally turn on the + * reader flow for a new reader that happens to have arrived while a write is in progress since + * that write should be considered as a disk read for that flow, not fetcher. + */ + private val versionCounter = atomic(0L) - fun reader( - key: Key, - lock: CompletableDeferred, - ): Flow> { - return flow { - val barrier = barriers.acquire(key) - val readerVersion: Long = versionCounter.incrementAndGet() - try { - lock.await() - emitAll( - barrier - .flatMapLatest { barrierMessage -> - val messageArrivedAfterMe = readerVersion < barrierMessage.version - val writeError = - if (messageArrivedAfterMe && barrierMessage is BarrierMsg.Open) { - barrierMessage.writeError - } else { - null - } - val readFlow: Flow> = - when (barrierMessage) { - is BarrierMsg.Open -> - delegate.reader(key).mapIndexed { index, local: Output? -> - if (index == 0 && messageArrivedAfterMe) { - val firstMsgOrigin = - if (writeError == null) { - // restarted barrier without an error means write succeeded - StoreReadResponseOrigin.Fetcher() - } else { - // when a write fails, we still get a new reader because - // we've disabled the previous reader before starting the - // write operation. But since write has failed, we should - // use the SourceOfTruth as the origin - StoreReadResponseOrigin.SourceOfTruth - } - StoreReadResponse.Data( - origin = firstMsgOrigin, - value = local, - ) - } else { - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.SourceOfTruth, - value = local, - ) as StoreReadResponse - } - }.catch { throwable -> - this.emit( - StoreReadResponse.Error.Exception( - error = - SourceOfTruth.ReadException( - key = key, - cause = throwable.cause ?: throwable, - ), - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - ) - } + fun reader(key: Key, lock: CompletableDeferred): Flow> { + return flow { + val barrier = barriers.acquire(key) + val readerVersion: Long = versionCounter.incrementAndGet() + try { + lock.await() + emitAll( + barrier.flatMapLatest { barrierMessage -> + val messageArrivedAfterMe = readerVersion < barrierMessage.version + val writeError = + if (messageArrivedAfterMe && barrierMessage is BarrierMsg.Open) { + barrierMessage.writeError + } else { + null + } + val readFlow: Flow> = + when (barrierMessage) { + is BarrierMsg.Open -> + delegate + .reader(key) + .mapIndexed { index, local: Output? -> + if (index == 0 && messageArrivedAfterMe) { + val firstMsgOrigin = + if (writeError == null) { + // restarted barrier without an error means write succeeded + StoreReadResponseOrigin.Fetcher() + } else { + // when a write fails, we still get a new reader because + // we've disabled the previous reader before starting the + // write operation. But since write has failed, we should + // use the SourceOfTruth as the origin + StoreReadResponseOrigin.SourceOfTruth + } + StoreReadResponse.Data(origin = firstMsgOrigin, value = local) + } else { + StoreReadResponse.Data( + origin = StoreReadResponseOrigin.SourceOfTruth, + value = local, + ) as StoreReadResponse + } + } + .catch { throwable -> + this.emit( + StoreReadResponse.Error.Exception( + error = + SourceOfTruth.ReadException( + key = key, + cause = throwable.cause ?: throwable, + ), + origin = StoreReadResponseOrigin.SourceOfTruth, + ) + ) + } - is BarrierMsg.Blocked -> { - flowOf() - } - } - readFlow - .onStart { - // if we have a pending error, make sure to dispatch it first. - if (writeError != null) { - emit( - StoreReadResponse.Error.Exception( - origin = StoreReadResponseOrigin.SourceOfTruth, - error = writeError, - ), - ) - } - } - }, + is BarrierMsg.Blocked -> { + flowOf() + } + } + readFlow.onStart { + // if we have a pending error, make sure to dispatch it first. + if (writeError != null) { + emit( + StoreReadResponse.Error.Exception( + origin = StoreReadResponseOrigin.SourceOfTruth, + error = writeError, + ) ) - } finally { - // we are using a finally here instead of onCompletion as there might be a - // possibility where flow gets cancelled right before `emitAll`. - barriers.release(key, barrier) + } } - } + } + ) + } finally { + // we are using a finally here instead of onCompletion as there might be a + // possibility where flow gets cancelled right before `emitAll`. + barriers.release(key, barrier) + } } + } - @Suppress("UNCHECKED_CAST") - suspend fun write( - key: Key, - value: Local, - ) { - val barrier = barriers.acquire(key) + @Suppress("UNCHECKED_CAST") + suspend fun write(key: Key, value: Local) { + val barrier = barriers.acquire(key) + try { + barrier.emit(BarrierMsg.Blocked(versionCounter.incrementAndGet())) + val writeError = try { - barrier.emit(BarrierMsg.Blocked(versionCounter.incrementAndGet())) - val writeError = - try { - delegate.write(key, value) - null - } catch (throwable: Throwable) { - if (throwable !is CancellationException) { - throwable - } else { - null - } - } - - barrier.emit( - BarrierMsg.Open( - version = versionCounter.incrementAndGet(), - writeError = - writeError?.let { - SourceOfTruth.WriteException( - key = key, - value = value, - cause = writeError, - ) - }, - ), - ) - if (writeError is CancellationException) { - // only throw if it failed because of cancelation. - // otherwise, we take care of letting downstream know that there was a write error - throw writeError - } - } finally { - barriers.release(key, barrier) + delegate.write(key, value) + null + } catch (throwable: Throwable) { + if (throwable !is CancellationException) { + throwable + } else { + null + } } - } - suspend fun delete(key: Key) { - delegate.delete(key) + barrier.emit( + BarrierMsg.Open( + version = versionCounter.incrementAndGet(), + writeError = + writeError?.let { + SourceOfTruth.WriteException(key = key, value = value, cause = writeError) + }, + ) + ) + if (writeError is CancellationException) { + // only throw if it failed because of cancelation. + // otherwise, we take care of letting downstream know that there was a write error + throw writeError + } + } finally { + barriers.release(key, barrier) } + } - suspend fun deleteAll() { - delegate.deleteAll() - } + suspend fun delete(key: Key) { + delegate.delete(key) + } - private sealed class BarrierMsg( - val version: Long, - ) { - class Blocked(version: Long) : BarrierMsg(version) + suspend fun deleteAll() { + delegate.deleteAll() + } - class Open(version: Long, val writeError: Throwable? = null) : BarrierMsg(version) { - companion object { - val INITIAL = Open(INITIAL_VERSION) - } - } + private sealed class BarrierMsg(val version: Long) { + class Blocked(version: Long) : BarrierMsg(version) + + class Open(version: Long, val writeError: Throwable? = null) : BarrierMsg(version) { + companion object { + val INITIAL = Open(INITIAL_VERSION) + } } + } - // visible for testing - internal suspend fun barrierCount() = barriers.size() + // visible for testing + internal suspend fun barrierCount() = barriers.size() - companion object { - private const val INITIAL_VERSION = -1L - } + companion object { + private const val INITIAL_VERSION = -1L + } } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/clock.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/clock.kt index 9e2d63bb9..c538debd3 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/clock.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/clock.kt @@ -1,7 +1,7 @@ package org.mobilenativefoundation.store.store5.impl.extensions -import kotlinx.datetime.Clock import kotlin.time.Duration.Companion.hours +import kotlinx.datetime.Clock internal fun now() = Clock.System.now().toEpochMilliseconds() 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 cdf0910c7..3cc66943b 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 @@ -30,18 +30,17 @@ import org.mobilenativefoundation.store.store5.impl.RealStore * @param Output The common representation of the data. */ suspend fun Store.get(key: Key) = - stream(StoreReadRequest.cached(key, refresh = false)) - .filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData } - .first() - .requireData() + stream(StoreReadRequest.cached(key, refresh = false)) + .filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData } + .first() + .requireData() /** * Helper factory that will return fresh data for [key] while updating your caches * - * If the [Fetcher] does not return any data (i.e the returned - * [kotlinx.coroutines.flow.Flow], when collected, is empty). Then store will fall back to local - * data **even** if you explicitly requested fresh data. - * See https://github.com/dropbox/Store/pull/194 for context + * If the [Fetcher] does not return any data (i.e the returned [kotlinx.coroutines.flow.Flow], when + * collected, is empty). Then store will fall back to local data **even** if you explicitly + * requested fresh data. See https://github.com/dropbox/Store/pull/194 for context * * Note: Exceptions will not be handled within this function. * @@ -58,10 +57,10 @@ suspend fun Store.get(key: Key) = * @return The fresh data associated with the key. */ suspend fun Store.fresh(key: Key) = - stream(StoreReadRequest.fresh(key)) - .filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData } - .first() - .requireData() + stream(StoreReadRequest.fresh(key)) + .filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData } + .first() + .requireData() /** * Extension function to convert a [Store] into a [MutableStore]. @@ -85,19 +84,16 @@ suspend fun Store.fresh(key: Key) = */ @OptIn(ExperimentalStoreApi::class) @Suppress("UNCHECKED_CAST") -fun Store.asMutableStore( - updater: Updater, - bookkeeper: Bookkeeper?, +fun Store + .asMutableStore( + updater: Updater, + bookkeeper: Bookkeeper?, ): MutableStore { - val delegate = - this as? RealStore - ?: throw Exception("MutableStore requires Store to be built using StoreBuilder") + val delegate = + this as? RealStore + ?: throw Exception("MutableStore requires Store to be built using StoreBuilder") - return RealMutableStore( - delegate = delegate, - updater = updater, - bookkeeper = bookkeeper, - ) + return RealMutableStore(delegate = delegate, updater = updater, bookkeeper = bookkeeper) } /** @@ -121,10 +117,10 @@ fun Store< */ @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() + stream(StoreReadRequest.cached(key, refresh = false)) + .filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData } + .first() + .requireData() /** * Helper function that returns fresh data for the given [key] while updating your caches. @@ -150,7 +146,7 @@ suspend fun MutableStore. */ @OptIn(ExperimentalStoreApi::class) suspend fun MutableStore.fresh(key: Key) = - stream(StoreReadRequest.fresh(key)) - .filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData } - .first() - .requireData() + stream(StoreReadRequest.fresh(key)) + .filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData } + .first() + .requireData() diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/operators/FlowMerge.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/operators/FlowMerge.kt index 09d4f4cf9..5d31e32e0 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/operators/FlowMerge.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/operators/FlowMerge.kt @@ -21,26 +21,17 @@ import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.launch -/** - * Merge implementation tells downstream what the source is and also uses a rendezvous channel - */ +/** Merge implementation tells downstream what the source is and also uses a rendezvous channel */ internal fun Flow.merge(other: Flow): Flow> { - return channelFlow> { - launch { - this@merge.collect { - send(Either.Left(it)) - } - } - launch { - other.collect { - send(Either.Right(it)) - } - } - }.buffer(Channel.RENDEZVOUS) + return channelFlow> { + launch { this@merge.collect { send(Either.Left(it)) } } + launch { other.collect { send(Either.Right(it)) } } + } + .buffer(Channel.RENDEZVOUS) } internal sealed class Either { - data class Left(val value: T) : Either() + data class Left(val value: T) : Either() - data class Right(val value: R) : Either() + data class Right(val value: R) : Either() } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/operators/MapIndexed.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/operators/MapIndexed.kt index c3644cd0c..ad64ea290 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/operators/MapIndexed.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/operators/MapIndexed.kt @@ -19,9 +19,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectIndexed import kotlinx.coroutines.flow.flow -internal inline fun Flow.mapIndexed(crossinline block: (Int, T) -> R) = - flow { - collectIndexed { index, value -> - emit(block(index, value)) - } - } +internal inline fun Flow.mapIndexed(crossinline block: (Int, T) -> R) = flow { + collectIndexed { index, value -> emit(block(index, value)) } +} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/concurrent/Lightswitch.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/concurrent/Lightswitch.kt index 8f772fcad..658b0a747 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/concurrent/Lightswitch.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/concurrent/Lightswitch.kt @@ -4,30 +4,32 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock /** - * Locks when first reader starts and unlocks when last reader finishes. - * Lightswitch analogy: First one into a room turns on the light (locks the mutex), and the last one out turns off the light (unlocks the mutex). + * Locks when first reader starts and unlocks when last reader finishes. Lightswitch analogy: First + * one into a room turns on the light (locks the mutex), and the last one out turns off the light + * (unlocks the mutex). + * * @property counter Number of readers */ internal class Lightswitch { - private var counter = 0 - private val mutex = Mutex() + private var counter = 0 + private val mutex = Mutex() - suspend fun lock(room: Mutex) { - mutex.withLock { - counter += 1 - if (counter == 1) { - room.lock() - } - } + suspend fun lock(room: Mutex) { + mutex.withLock { + counter += 1 + if (counter == 1) { + room.lock() + } } + } - suspend fun unlock(room: Mutex) { - mutex.withLock { - counter -= 1 - check(counter >= 0) - if (counter == 0) { - room.unlock() - } - } + suspend fun unlock(room: Mutex) { + mutex.withLock { + counter -= 1 + check(counter >= 0) + if (counter == 0) { + room.unlock() + } } + } } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/concurrent/ThreadSafety.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/concurrent/ThreadSafety.kt index aaae49513..79466edd5 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/concurrent/ThreadSafety.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/concurrent/ThreadSafety.kt @@ -3,11 +3,11 @@ package org.mobilenativefoundation.store.store5.internal.concurrent import kotlinx.coroutines.sync.Mutex internal data class ThreadSafety( - val writeRequests: StoreThreadSafety = StoreThreadSafety(), - val readCompletions: StoreThreadSafety = StoreThreadSafety(), + val writeRequests: StoreThreadSafety = StoreThreadSafety(), + val readCompletions: StoreThreadSafety = StoreThreadSafety(), ) internal data class StoreThreadSafety( - val mutex: Mutex = Mutex(), - val lightswitch: Lightswitch = Lightswitch(), + val mutex: Mutex = Mutex(), + val lightswitch: Lightswitch = Lightswitch(), ) diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/definition/WriteRequestQueue.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/definition/WriteRequestQueue.kt index eef08167e..379bedcb0 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/definition/WriteRequestQueue.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/definition/WriteRequestQueue.kt @@ -2,4 +2,5 @@ package org.mobilenativefoundation.store.store5.internal.definition import org.mobilenativefoundation.store.store5.StoreWriteRequest -typealias WriteRequestQueue = ArrayDeque> +typealias WriteRequestQueue = + ArrayDeque> diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult.kt index 3f49c674e..bcde71923 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult.kt @@ -3,15 +3,16 @@ package org.mobilenativefoundation.store.store5.internal.result import org.mobilenativefoundation.store.store5.UpdaterResult sealed class EagerConflictResolutionResult { - sealed class Success : EagerConflictResolutionResult() { - object NoConflicts : Success() + sealed class Success : EagerConflictResolutionResult() { + object NoConflicts : Success() - data class ConflictsResolved(val value: UpdaterResult.Success) : Success() - } + data class ConflictsResolved(val value: UpdaterResult.Success) : + Success() + } - sealed class Error : EagerConflictResolutionResult() { - data class Message(val message: String) : Error() + sealed class Error : EagerConflictResolutionResult() { + data class Message(val message: String) : Error() - data class Exception(val error: Throwable) : Error() - } + data class Exception(val error: Throwable) : Error() + } } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult.kt index 3a760e750..deca1d367 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult.kt @@ -1,11 +1,11 @@ package org.mobilenativefoundation.store.store5.internal.result sealed class StoreDelegateWriteResult { - object Success : StoreDelegateWriteResult() + object Success : StoreDelegateWriteResult() - sealed class Error : StoreDelegateWriteResult() { - data class Message(val error: String) : Error() + sealed class Error : StoreDelegateWriteResult() { + data class Message(val error: String) : Error() - data class Exception(val error: Throwable) : Error() - } + data class Exception(val error: Throwable) : Error() + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ClearAllStoreTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ClearAllStoreTests.kt index a871a0665..3b66177e8 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ClearAllStoreTests.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ClearAllStoreTests.kt @@ -1,5 +1,9 @@ package org.mobilenativefoundation.store.store5 +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.test.TestScope @@ -9,172 +13,127 @@ import org.mobilenativefoundation.store.core5.ExperimentalStoreApi import org.mobilenativefoundation.store.store5.util.InMemoryPersister import org.mobilenativefoundation.store.store5.util.asSourceOfTruth import org.mobilenativefoundation.store.store5.util.getData -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull @FlowPreview @ExperimentalCoroutinesApi @ExperimentalStoreApi class ClearAllStoreTests { - private val testScope = TestScope() + private val testScope = TestScope() - private val key1 = "key1" - private val key2 = "key2" - private val value1 = 1 - private val value2 = 2 + private val key1 = "key1" + private val key2 = "key2" + private val value1 = 1 + private val value2 = 2 - private lateinit var fetcher: Fetcher + private lateinit var fetcher: Fetcher - private lateinit var persister: InMemoryPersister + private lateinit var persister: InMemoryPersister - @BeforeTest - fun before() { - persister = InMemoryPersister() - fetcher = - Fetcher.of { key: String -> - when (key) { - key1 -> value1 - key2 -> value2 - else -> throw IllegalStateException("Unknown key") - } - } - } + @BeforeTest + fun before() { + persister = InMemoryPersister() + fetcher = + Fetcher.of { key: String -> + when (key) { + key1 -> value1 + key2 -> value2 + else -> throw IllegalStateException("Unknown key") + } + } + } - @Test - fun callingClearAllOnStoreWithPersisterAndNoInMemoryCacheDeletesAllEntriesFromThePersister() = - testScope.runTest { - val store = - StoreBuilder.from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ).scope(testScope) - .disableCache() - .build() + @Test + fun callingClearAllOnStoreWithPersisterAndNoInMemoryCacheDeletesAllEntriesFromThePersister() = + testScope.runTest { + val store = + StoreBuilder.from(fetcher = fetcher, sourceOfTruth = persister.asSourceOfTruth()) + .scope(testScope) + .disableCache() + .build() - // should receive data from network first time - val responseOneA = store.getData(key1) - advanceUntilIdle() - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value1, - ), - responseOneA, - ) - val responseTwoA = store.getData(key2) - advanceUntilIdle() - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value2, - ), - responseTwoA, - ) - // should receive data from persister - val responseOneB = store.getData(key1) - advanceUntilIdle() - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.SourceOfTruth, - value = value1, - ), - responseOneB, - ) - val responseTwoB = store.getData(key2) - advanceUntilIdle() - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.SourceOfTruth, - value = value2, - ), - responseTwoB, - ) - // clear all entries in store - store.clear() - assertNull(persister.peekEntry(key1)) - assertNull(persister.peekEntry(key2)) + // should receive data from network first time + val responseOneA = store.getData(key1) + advanceUntilIdle() + assertEquals( + StoreReadResponse.Data(origin = StoreReadResponseOrigin.Fetcher(), value = value1), + responseOneA, + ) + val responseTwoA = store.getData(key2) + advanceUntilIdle() + assertEquals( + StoreReadResponse.Data(origin = StoreReadResponseOrigin.Fetcher(), value = value2), + responseTwoA, + ) + // should receive data from persister + val responseOneB = store.getData(key1) + advanceUntilIdle() + assertEquals( + StoreReadResponse.Data(origin = StoreReadResponseOrigin.SourceOfTruth, value = value1), + responseOneB, + ) + val responseTwoB = store.getData(key2) + advanceUntilIdle() + assertEquals( + StoreReadResponse.Data(origin = StoreReadResponseOrigin.SourceOfTruth, value = value2), + responseTwoB, + ) + // clear all entries in store + store.clear() + assertNull(persister.peekEntry(key1)) + assertNull(persister.peekEntry(key2)) - // should fetch data from network again - val responseOneC = store.getData(key1) - advanceUntilIdle() - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value1, - ), - responseOneC, - ) + // should fetch data from network again + val responseOneC = store.getData(key1) + advanceUntilIdle() + assertEquals( + StoreReadResponse.Data(origin = StoreReadResponseOrigin.Fetcher(), value = value1), + responseOneC, + ) - val responseTwoC = store.getData(key2) - advanceUntilIdle() - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value2, - ), - responseTwoC, - ) - } + val responseTwoC = store.getData(key2) + advanceUntilIdle() + assertEquals( + StoreReadResponse.Data(origin = StoreReadResponseOrigin.Fetcher(), value = value2), + responseTwoC, + ) + } - @Test - fun callingClearAllOnStoreWithInMemoryCacheAndNoPersisterDeletesAllEntriesFromTheInMemoryCache() = - testScope.runTest { - val store = - StoreBuilder.from( - fetcher = fetcher, - ).scope(testScope).build() + @Test + fun callingClearAllOnStoreWithInMemoryCacheAndNoPersisterDeletesAllEntriesFromTheInMemoryCache() = + testScope.runTest { + val store = StoreBuilder.from(fetcher = fetcher).scope(testScope).build() - // should receive data from network first time - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value1, - ), - store.getData(key1), - ) - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value2, - ), - store.getData(key2), - ) + // should receive data from network first time + assertEquals( + StoreReadResponse.Data(origin = StoreReadResponseOrigin.Fetcher(), value = value1), + store.getData(key1), + ) + assertEquals( + StoreReadResponse.Data(origin = StoreReadResponseOrigin.Fetcher(), value = value2), + store.getData(key2), + ) - // should receive data from cache - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Cache, - value = value1, - ), - store.getData(key1), - ) - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Cache, - value = value2, - ), - store.getData(key2), - ) + // should receive data from cache + assertEquals( + StoreReadResponse.Data(origin = StoreReadResponseOrigin.Cache, value = value1), + store.getData(key1), + ) + assertEquals( + StoreReadResponse.Data(origin = StoreReadResponseOrigin.Cache, value = value2), + store.getData(key2), + ) - // clear all entries in store - store.clear() + // clear all entries in store + store.clear() - // should fetch data from network again - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value1, - ), - store.getData(key1), - ) - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value2, - ), - store.getData(key2), - ) - } + // should fetch data from network again + assertEquals( + StoreReadResponse.Data(origin = StoreReadResponseOrigin.Fetcher(), value = value1), + store.getData(key1), + ) + assertEquals( + StoreReadResponse.Data(origin = StoreReadResponseOrigin.Fetcher(), value = value2), + store.getData(key2), + ) + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ClearStoreByKeyTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ClearStoreByKeyTests.kt index e08556b44..b1ad718d4 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ClearStoreByKeyTests.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ClearStoreByKeyTests.kt @@ -1,5 +1,8 @@ package org.mobilenativefoundation.store.store5 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.test.TestScope @@ -8,152 +11,121 @@ import org.mobilenativefoundation.store.store5.StoreReadResponse.Data import org.mobilenativefoundation.store.store5.util.InMemoryPersister import org.mobilenativefoundation.store.store5.util.asSourceOfTruth import org.mobilenativefoundation.store.store5.util.getData -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull @FlowPreview @ExperimentalCoroutinesApi class ClearStoreByKeyTests { - private val testScope = TestScope() - - private val persister = InMemoryPersister() - - @Test - fun callingClearWithKeyOnStoreWithPersisterWithNoInMemoryCacheDeletesTheEntryAssociatedWithTheKeyFromThePersister() = - testScope.runTest { - val key = "key" - val value = 1 - val store = - StoreBuilder.from( - fetcher = Fetcher.of { value }, - sourceOfTruth = persister.asSourceOfTruth(), - ).scope(testScope) - .disableCache() - .build() - - // should receive data from network first time - assertEquals( - Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value, - ), - store.getData(key), - ) - - // should receive data from persister - assertEquals( - Data( - origin = StoreReadResponseOrigin.SourceOfTruth, - value = value, - ), - store.getData(key), - ) - - // clear store entry by key - store.clear(key) - assertNull(persister.peekEntry(key)) - // should fetch data from network again - assertEquals( - Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value, - ), - store.getData(key), - ) - } - - @Test - fun callingClearWithKeyOStoreWithInMemoryCacheNoPersisterDeletesTheEntryAssociatedWithTheKeyFromTheInMemoryCache() = - testScope.runTest { - val key = "key" - val value = 1 - val store = - StoreBuilder.from( - fetcher = Fetcher.of { value }, - ).scope(testScope).build() - - // should receive data from network first time - assertEquals( - Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value, - ), - store.getData(key), - ) - - // should receive data from cache - assertEquals( - Data( - origin = StoreReadResponseOrigin.Cache, - value = value, - ), - store.getData(key), - ) - - // clear store entry by key - store.clear(key) - - // should fetch data from network again - assertEquals( - Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value, - ), - store.getData(key), - ) - } - - @Test - fun callingClearWithKeyOnStoreHasNoEffectOnExistingEntriesAssociatedWithOtherKeysInTheInMemoryCacheOrPersister() = - testScope.runTest { - val key1 = "key1" - val key2 = "key2" - val value1 = 1 - val value2 = 2 - val store = - StoreBuilder.from( - fetcher = - Fetcher.of { key -> - when (key) { - key1 -> value1 - key2 -> value2 - else -> throw IllegalStateException("Unknown key") - } - }, - sourceOfTruth = persister.asSourceOfTruth(), - ).scope(testScope) - .build() - - // get data for both keys - store.getData(key1) - store.getData(key2) - - // clear store entry for key1 - store.clear(key1) - - // entry for key1 is gone - assertNull(persister.peekEntry(key1)) - - // entry for key2 should still exists - assertEquals(value2, persister.peekEntry(key2)) - - // getting data for key1 should hit the network again - assertEquals( - Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value1, - ), - store.getData(key1), - ) - - // getting data for key2 should not hit the network - assertEquals( - Data( - origin = StoreReadResponseOrigin.Cache, - value = value2, - ), - store.getData(key2), - ) - } + private val testScope = TestScope() + + private val persister = InMemoryPersister() + + @Test + fun callingClearWithKeyOnStoreWithPersisterWithNoInMemoryCacheDeletesTheEntryAssociatedWithTheKeyFromThePersister() = + testScope.runTest { + val key = "key" + val value = 1 + val store = + StoreBuilder.from( + fetcher = Fetcher.of { value }, + sourceOfTruth = persister.asSourceOfTruth(), + ) + .scope(testScope) + .disableCache() + .build() + + // should receive data from network first time + assertEquals( + Data(origin = StoreReadResponseOrigin.Fetcher(), value = value), + store.getData(key), + ) + + // should receive data from persister + assertEquals( + Data(origin = StoreReadResponseOrigin.SourceOfTruth, value = value), + store.getData(key), + ) + + // clear store entry by key + store.clear(key) + assertNull(persister.peekEntry(key)) + // should fetch data from network again + assertEquals( + Data(origin = StoreReadResponseOrigin.Fetcher(), value = value), + store.getData(key), + ) + } + + @Test + fun callingClearWithKeyOStoreWithInMemoryCacheNoPersisterDeletesTheEntryAssociatedWithTheKeyFromTheInMemoryCache() = + testScope.runTest { + val key = "key" + val value = 1 + val store = StoreBuilder.from(fetcher = Fetcher.of { value }).scope(testScope).build() + + // should receive data from network first time + assertEquals( + Data(origin = StoreReadResponseOrigin.Fetcher(), value = value), + store.getData(key), + ) + + // should receive data from cache + assertEquals(Data(origin = StoreReadResponseOrigin.Cache, value = value), store.getData(key)) + + // clear store entry by key + store.clear(key) + + // should fetch data from network again + assertEquals( + Data(origin = StoreReadResponseOrigin.Fetcher(), value = value), + store.getData(key), + ) + } + + @Test + fun callingClearWithKeyOnStoreHasNoEffectOnExistingEntriesAssociatedWithOtherKeysInTheInMemoryCacheOrPersister() = + testScope.runTest { + val key1 = "key1" + val key2 = "key2" + val value1 = 1 + val value2 = 2 + val store = + StoreBuilder.from( + fetcher = + Fetcher.of { key -> + when (key) { + key1 -> value1 + key2 -> value2 + else -> throw IllegalStateException("Unknown key") + } + }, + sourceOfTruth = persister.asSourceOfTruth(), + ) + .scope(testScope) + .build() + + // get data for both keys + store.getData(key1) + store.getData(key2) + + // clear store entry for key1 + store.clear(key1) + + // entry for key1 is gone + assertNull(persister.peekEntry(key1)) + + // entry for key2 should still exists + assertEquals(value2, persister.peekEntry(key2)) + + // getting data for key1 should hit the network again + assertEquals( + Data(origin = StoreReadResponseOrigin.Fetcher(), value = value1), + store.getData(key1), + ) + + // getting data for key2 should not hit the network + assertEquals( + Data(origin = StoreReadResponseOrigin.Cache, value = value2), + store.getData(key2), + ) + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FallbackTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FallbackTests.kt index d18cc79db..bcde5e288 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FallbackTests.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FallbackTests.kt @@ -1,5 +1,8 @@ package org.mobilenativefoundation.store.store5 +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.TestScope @@ -9,141 +12,140 @@ import org.mobilenativefoundation.store.store5.util.fake.fallback.Page import org.mobilenativefoundation.store.store5.util.fake.fallback.PagesDatabase import org.mobilenativefoundation.store.store5.util.fake.fallback.PrimaryPagesApi import org.mobilenativefoundation.store.store5.util.fake.fallback.SecondaryPagesApi -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals class FallbackTests { - private val testScope = TestScope() - private lateinit var api: PrimaryPagesApi - private lateinit var secondaryApi: SecondaryPagesApi - private lateinit var hardcodedPages: HardcodedPages - private lateinit var pagesDatabase: PagesDatabase - - @BeforeTest - fun before() { - api = PrimaryPagesApi() - secondaryApi = SecondaryPagesApi() - hardcodedPages = HardcodedPages() - pagesDatabase = PagesDatabase() + private val testScope = TestScope() + private lateinit var api: PrimaryPagesApi + private lateinit var secondaryApi: SecondaryPagesApi + private lateinit var hardcodedPages: HardcodedPages + private lateinit var pagesDatabase: PagesDatabase + + @BeforeTest + fun before() { + api = PrimaryPagesApi() + secondaryApi = SecondaryPagesApi() + hardcodedPages = HardcodedPages() + pagesDatabase = PagesDatabase() + } + + @Test + fun givenEmptyStoreWhenSuccessFromPrimaryApiThenStoreReadResponseOfPrimaryApiResult() = + testScope.runTest { + val ttl = null + val fail = false + + val hardcodedPagesFetcher = Fetcher.of { key -> hardcodedPages.get(key) } + val secondaryApiFetcher = + Fetcher.withFallback(secondaryApi.name, hardcodedPagesFetcher) { key -> + secondaryApi.get(key) + } + + val store = + StoreBuilder.from( + fetcher = + Fetcher.withFallback(api.name, secondaryApiFetcher) { key -> + api.fetch(key, fail, ttl) + }, + sourceOfTruth = + SourceOfTruth.of( + nonFlowReader = { key -> pagesDatabase.get(key) }, + writer = { key, page -> pagesDatabase.put(key, page) }, + delete = null, + deleteAll = null, + ), + ) + .build() + + val responses = store.stream(StoreReadRequest.fresh("1")).take(2).toList() + + assertEquals( + listOf( + StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), + StoreReadResponse.Data(Page.Data("1", null), StoreReadResponseOrigin.Fetcher(api.name)), + ), + responses, + ) } - @Test - fun givenEmptyStoreWhenSuccessFromPrimaryApiThenStoreReadResponseOfPrimaryApiResult() = - testScope.runTest { - val ttl = null - val fail = false - - val hardcodedPagesFetcher = Fetcher.of { key -> hardcodedPages.get(key) } - val secondaryApiFetcher = - Fetcher.withFallback( - secondaryApi.name, - hardcodedPagesFetcher, - ) { key -> secondaryApi.get(key) } - - val store = - StoreBuilder.from( - fetcher = Fetcher.withFallback(api.name, secondaryApiFetcher) { key -> api.fetch(key, fail, ttl) }, - sourceOfTruth = - SourceOfTruth.of( - nonFlowReader = { key -> pagesDatabase.get(key) }, - writer = { key, page -> pagesDatabase.put(key, page) }, - delete = null, - deleteAll = null, - ), - ).build() - - val responses = store.stream(StoreReadRequest.fresh("1")).take(2).toList() - - assertEquals( - listOf( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data(Page.Data("1", null), StoreReadResponseOrigin.Fetcher(api.name)), - ), - responses, - ) - } + @Test + fun givenEmptyStoreWhenFailureFromPrimaryApiThenStoreReadResponseOfSecondaryApiResult() = + testScope.runTest { + val ttl = null + val fail = true - @Test - fun givenEmptyStoreWhenFailureFromPrimaryApiThenStoreReadResponseOfSecondaryApiResult() = - testScope.runTest { - val ttl = null - val fail = true - - val hardcodedPagesFetcher = Fetcher.of { key -> hardcodedPages.get(key) } - val secondaryApiFetcher = - Fetcher.withFallback( - secondaryApi.name, - hardcodedPagesFetcher, - ) { key -> secondaryApi.get(key) } - - val store = - StoreBuilder.from( - fetcher = Fetcher.withFallback(api.name, secondaryApiFetcher) { key -> api.fetch(key, fail, ttl) }, - sourceOfTruth = - SourceOfTruth.of( - nonFlowReader = { key -> pagesDatabase.get(key) }, - writer = { key, page -> pagesDatabase.put(key, page) }, - delete = null, - deleteAll = null, - ), - ).build() - - val responses = store.stream(StoreReadRequest.fresh("1")).take(2).toList() - - assertEquals( - listOf( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data( - Page.Data("1", null), - StoreReadResponseOrigin.Fetcher(secondaryApiFetcher.name), - ), - ), - responses, - ) + val hardcodedPagesFetcher = Fetcher.of { key -> hardcodedPages.get(key) } + val secondaryApiFetcher = + Fetcher.withFallback(secondaryApi.name, hardcodedPagesFetcher) { key -> + secondaryApi.get(key) } - @Test - fun givenEmptyStoreWhenFailureFromPrimaryAndSecondaryApisThenStoreReadResponseOfHardcodedData() = - - testScope.runTest { - val ttl = null - val fail = true - - val hardcodedPagesFetcher = Fetcher.of { key -> hardcodedPages.get(key) } - val throwingSecondaryApiFetcher = - Fetcher.withFallback(secondaryApi.name, hardcodedPagesFetcher) { throw Exception() } - - val store = - StoreBuilder.from( - fetcher = - Fetcher.withFallback(api.name, throwingSecondaryApiFetcher) { key -> - api.fetch( - key, - fail, - ttl, - ) - }, - sourceOfTruth = - SourceOfTruth.of( - nonFlowReader = { key -> pagesDatabase.get(key) }, - writer = { key, page -> pagesDatabase.put(key, page) }, - delete = null, - deleteAll = null, - ), - ).build() - - val responses = store.stream(StoreReadRequest.fresh("1")).take(2).toList() - - assertEquals( - listOf( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data( - Page.Data("1", null), - StoreReadResponseOrigin.Fetcher(hardcodedPagesFetcher.name), - ), - ), - responses, - ) - } + val store = + StoreBuilder.from( + fetcher = + Fetcher.withFallback(api.name, secondaryApiFetcher) { key -> + api.fetch(key, fail, ttl) + }, + sourceOfTruth = + SourceOfTruth.of( + nonFlowReader = { key -> pagesDatabase.get(key) }, + writer = { key, page -> pagesDatabase.put(key, page) }, + delete = null, + deleteAll = null, + ), + ) + .build() + + val responses = store.stream(StoreReadRequest.fresh("1")).take(2).toList() + + assertEquals( + listOf( + StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), + StoreReadResponse.Data( + Page.Data("1", null), + StoreReadResponseOrigin.Fetcher(secondaryApiFetcher.name), + ), + ), + responses, + ) + } + + @Test + fun givenEmptyStoreWhenFailureFromPrimaryAndSecondaryApisThenStoreReadResponseOfHardcodedData() = + testScope.runTest { + val ttl = null + val fail = true + + val hardcodedPagesFetcher = Fetcher.of { key -> hardcodedPages.get(key) } + val throwingSecondaryApiFetcher = + Fetcher.withFallback(secondaryApi.name, hardcodedPagesFetcher) { throw Exception() } + + val store = + StoreBuilder.from( + fetcher = + Fetcher.withFallback(api.name, throwingSecondaryApiFetcher) { key -> + api.fetch(key, fail, ttl) + }, + sourceOfTruth = + SourceOfTruth.of( + nonFlowReader = { key -> pagesDatabase.get(key) }, + writer = { key, page -> pagesDatabase.put(key, page) }, + delete = null, + deleteAll = null, + ), + ) + .build() + + val responses = store.stream(StoreReadRequest.fresh("1")).take(2).toList() + + assertEquals( + listOf( + StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), + StoreReadResponse.Data( + Page.Data("1", null), + StoreReadResponseOrigin.Fetcher(hardcodedPagesFetcher.name), + ), + ), + responses, + ) + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FetcherControllerTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FetcherControllerTests.kt index e8ef19509..6235949c8 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FetcherControllerTests.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FetcherControllerTests.kt @@ -1,5 +1,7 @@ package org.mobilenativefoundation.store.store5 +import kotlin.test.Test +import kotlin.test.assertEquals import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.SupervisorJob @@ -16,126 +18,101 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.mobilenativefoundation.store.store5.StoreReadResponse.Data import org.mobilenativefoundation.store.store5.impl.FetcherController -import kotlin.test.Test -import kotlin.test.assertEquals @ExperimentalCoroutinesApi @FlowPreview class FetcherControllerTests { - private val testScope = TestScope() + private val testScope = TestScope() - @Test - fun simple() = - testScope.runTest { - val fetcherController = - FetcherController( - scope = testScope, - realFetcher = - Fetcher.ofResultFlow { key: Int -> - flow { - emit(FetcherResult.Data(key * key) as FetcherResult) - } - }, - sourceOfTruth = null, - ) - val fetcher = fetcherController.getFetcher(3) - assertEquals(0, fetcherController.fetcherSize()) - val received = - fetcher.onEach { - assertEquals(1, fetcherController.fetcherSize()) - }.first() - assertEquals( - Data( - value = 9, - origin = StoreReadResponseOrigin.Fetcher(), - ), - received, - ) - assertEquals(0, fetcherController.fetcherSize()) - } - - @Test - fun concurrent() = - testScope.runTest { - var createdCnt = 0 - val fetcherController = - FetcherController( - scope = testScope, - realFetcher = - Fetcher.ofResultFlow { key: Int -> - createdCnt++ - flow { - // make sure it takes time, otherwise, we may not share - delay(1) - emit(FetcherResult.Data(key * key) as FetcherResult) - } - }, - sourceOfTruth = null, - ) - val fetcherCount = 20 + @Test + fun simple() = + testScope.runTest { + val fetcherController = + FetcherController( + scope = testScope, + realFetcher = + Fetcher.ofResultFlow { key: Int -> + flow { emit(FetcherResult.Data(key * key) as FetcherResult) } + }, + sourceOfTruth = null, + ) + val fetcher = fetcherController.getFetcher(3) + assertEquals(0, fetcherController.fetcherSize()) + val received = fetcher.onEach { assertEquals(1, fetcherController.fetcherSize()) }.first() + assertEquals(Data(value = 9, origin = StoreReadResponseOrigin.Fetcher()), received) + assertEquals(0, fetcherController.fetcherSize()) + } - fun createFetcher() = - async { - fetcherController.getFetcher(3) - .onEach { - assertEquals(1, fetcherController.fetcherSize()) - }.first() - } + @Test + fun concurrent() = + testScope.runTest { + var createdCnt = 0 + val fetcherController = + FetcherController( + scope = testScope, + realFetcher = + Fetcher.ofResultFlow { key: Int -> + createdCnt++ + flow { + // make sure it takes time, otherwise, we may not share + delay(1) + emit(FetcherResult.Data(key * key) as FetcherResult) + } + }, + sourceOfTruth = null, + ) + val fetcherCount = 20 - val fetchers = - (0 until fetcherCount).map { - createFetcher() - } - fetchers.forEach { - assertEquals( - Data( - value = 9, - origin = StoreReadResponseOrigin.Fetcher(), - ), - it.await(), - ) - } - assertEquals(0, fetcherController.fetcherSize()) - assertEquals(1, createdCnt) - } + fun createFetcher() = async { + fetcherController + .getFetcher(3) + .onEach { assertEquals(1, fetcherController.fetcherSize()) } + .first() + } - @Test - fun concurrent_when_cancelled() = - testScope.runTest { - var createdCnt = 0 - val job = SupervisorJob() - val scope = TestScope(StandardTestDispatcher() + job) - val fetcherController = - FetcherController( - scope = scope, - realFetcher = - Fetcher.ofResultFlow { key: Int -> - createdCnt++ - flow { - // make sure it takes time, otherwise, we may not share - advanceUntilIdle() - emit(FetcherResult.Data(key * key) as FetcherResult) - } - }, - sourceOfTruth = null, - ) - val fetcherCount = 20 + val fetchers = (0 until fetcherCount).map { createFetcher() } + fetchers.forEach { + assertEquals(Data(value = 9, origin = StoreReadResponseOrigin.Fetcher()), it.await()) + } + assertEquals(0, fetcherController.fetcherSize()) + assertEquals(1, createdCnt) + } - fun createFetcher() = - scope.launch { - fetcherController.getFetcher(3) - .onEach { - assertEquals(1, fetcherController.fetcherSize()) - }.first() - } + @Test + fun concurrent_when_cancelled() = + testScope.runTest { + var createdCnt = 0 + val job = SupervisorJob() + val scope = TestScope(StandardTestDispatcher() + job) + val fetcherController = + FetcherController( + scope = scope, + realFetcher = + Fetcher.ofResultFlow { key: Int -> + createdCnt++ + flow { + // make sure it takes time, otherwise, we may not share + advanceUntilIdle() + emit(FetcherResult.Data(key * key) as FetcherResult) + } + }, + sourceOfTruth = null, + ) + val fetcherCount = 20 - (0 until fetcherCount).map { - createFetcher() - } - scope.advanceUntilIdle() - job.cancelChildren() - scope.advanceUntilIdle() - assertEquals(0, fetcherController.fetcherSize()) - assertEquals(1, createdCnt) + fun createFetcher() = + scope.launch { + fetcherController + .getFetcher(3) + .onEach { assertEquals(1, fetcherController.fetcherSize()) } + .first() } + + (0 until fetcherCount).map { createFetcher() } + scope.advanceUntilIdle() + job.cancelChildren() + scope.advanceUntilIdle() + assertEquals(0, fetcherController.fetcherSize()) + assertEquals(1, createdCnt) + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FetcherResponseTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FetcherResponseTests.kt index bc1ad5037..9d8fc2d44 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FetcherResponseTests.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FetcherResponseTests.kt @@ -1,6 +1,9 @@ package org.mobilenativefoundation.store.store5 import app.cash.turbine.test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.flowOf @@ -8,293 +11,232 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith @ExperimentalCoroutinesApi @FlowPreview class FetcherResponseTests { - private val testScope = TestScope() - - @Test - fun givenAFetcherThatThrowsAnExceptionInInvokeWhenStreamingThenTheExceptionsShouldNotBeCaught() = - testScope.runTest { - val store = - StoreBuilder.from( - Fetcher.ofResult { - throw RuntimeException("don't catch me") - }, - ).buildWithTestScope() - - assertFailsWith(message = "don't catch me") { - val result = store.stream(StoreReadRequest.fresh(1)).toList() - assertEquals(0, result.size) - } - } - - @Test - fun givenAFetcherThatEmitsErrorAndDataWhenSteamingThenItCanEmitValueAfterAnError() = - testScope.runTest { - val exception = RuntimeException("first error") - val store = - StoreBuilder.from( - fetcher = - Fetcher.ofResultFlow { key: Int -> - flowOf( - FetcherResult.Error.Exception(exception), - FetcherResult.Data("$key"), - ) - }, - ).buildWithTestScope() - - store.stream(StoreReadRequest.fresh(1)).test { - assertEquals( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Error.Exception(exception, StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data("1", StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - } - } - - @Test - fun givenTransformerWhenRawValueThenUnwrappedValueReturnedAndValueIsCached() = - testScope.runTest { - val fetcher = Fetcher.ofFlow { flowOf(it * it) } - val pipeline = - StoreBuilder - .from(fetcher).buildWithTestScope() - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = 9, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - StoreReadResponse.Data( - value = 9, - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - } - } - - @Test - fun givenTransformerWhenErrorMessageThenErrorReturnedToUserAndErrorIsNotCached() = - testScope.runTest { - var count = 0 - val fetcher = - Fetcher.ofResultFlow { _: Int -> - flowOf(count++).map { - if (it > 0) { - FetcherResult.Data(it) - } else { - FetcherResult.Error.Message("zero") - } - } - } - val pipeline = - StoreBuilder.from(fetcher) - .buildWithTestScope() - - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Error.Message( - message = "zero", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = 1, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) + private val testScope = TestScope() + + @Test + fun givenAFetcherThatThrowsAnExceptionInInvokeWhenStreamingThenTheExceptionsShouldNotBeCaught() = + testScope.runTest { + val store = + StoreBuilder.from(Fetcher.ofResult { throw RuntimeException("don't catch me") }) + .buildWithTestScope() + + assertFailsWith(message = "don't catch me") { + val result = store.stream(StoreReadRequest.fresh(1)).toList() + assertEquals(0, result.size) + } + } + + @Test + fun givenAFetcherThatEmitsErrorAndDataWhenSteamingThenItCanEmitValueAfterAnError() = + testScope.runTest { + val exception = RuntimeException("first error") + val store = + StoreBuilder.from( + fetcher = + Fetcher.ofResultFlow { key: Int -> + flowOf(FetcherResult.Error.Exception(exception), FetcherResult.Data("$key")) + } + ) + .buildWithTestScope() + + store.stream(StoreReadRequest.fresh(1)).test { + assertEquals(StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), awaitItem()) + + assertEquals( + StoreReadResponse.Error.Exception(exception, StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + + assertEquals(StoreReadResponse.Data("1", StoreReadResponseOrigin.Fetcher()), awaitItem()) + } + } + + @Test + fun givenTransformerWhenRawValueThenUnwrappedValueReturnedAndValueIsCached() = + testScope.runTest { + val fetcher = Fetcher.ofFlow { flowOf(it * it) } + val pipeline = StoreBuilder.from(fetcher).buildWithTestScope() + + pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { + assertEquals( + StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + + assertEquals( + StoreReadResponse.Data(value = 9, origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } + + pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { + assertEquals( + StoreReadResponse.Data(value = 9, origin = StoreReadResponseOrigin.Cache), + awaitItem(), + ) + } + } + + @Test + fun givenTransformerWhenErrorMessageThenErrorReturnedToUserAndErrorIsNotCached() = + testScope.runTest { + var count = 0 + val fetcher = + Fetcher.ofResultFlow { _: Int -> + flowOf(count++).map { + if (it > 0) { + FetcherResult.Data(it) + } else { + FetcherResult.Error.Message("zero") } + } } - - @Test - fun givenTransformerWhenErrorExceptionThenErrorReturnedToUserAndErrorIsNotCached() = - testScope.runTest { - val e = Exception() - var count = 0 - val fetcher = - Fetcher.ofResultFlow { _: Int -> - flowOf(count++).map { - if (it > 0) { - FetcherResult.Data(it) - } else { - FetcherResult.Error.Exception(e) - } - } - } - val pipeline = - StoreBuilder - .from(fetcher) - .buildWithTestScope() - - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Error.Exception( - error = e, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = 1, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) + val pipeline = StoreBuilder.from(fetcher).buildWithTestScope() + + pipeline.stream(StoreReadRequest.fresh(3)).test { + assertEquals( + StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + + assertEquals( + StoreReadResponse.Error.Message( + message = "zero", + origin = StoreReadResponseOrigin.Fetcher(), + ), + awaitItem(), + ) + } + + pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { + assertEquals( + StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + + assertEquals( + StoreReadResponse.Data(value = 1, origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } + } + + @Test + fun givenTransformerWhenErrorExceptionThenErrorReturnedToUserAndErrorIsNotCached() = + testScope.runTest { + val e = Exception() + var count = 0 + val fetcher = + Fetcher.ofResultFlow { _: Int -> + flowOf(count++).map { + if (it > 0) { + FetcherResult.Data(it) + } else { + FetcherResult.Error.Exception(e) } + } } - - @Test - fun givenExceptionsAsErrorsWhenExceptionThrownThenErrorReturnedToUserAndErrorIsNotCached() = - testScope.runTest { - var count = 0 - val e = Exception() - val fetcher = - Fetcher.of { - count++ - if (count == 1) { - throw e - } - count - 1 - } - val pipeline = - StoreBuilder - .from(fetcher = fetcher) - .buildWithTestScope() - - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Error.Exception( - error = e, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = 1, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } + val pipeline = StoreBuilder.from(fetcher).buildWithTestScope() + + pipeline.stream(StoreReadRequest.fresh(3)).test { + assertEquals( + StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + + assertEquals( + StoreReadResponse.Error.Exception(error = e, origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } + + pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { + assertEquals( + StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + + assertEquals( + StoreReadResponse.Data(value = 1, origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } + } + + @Test + fun givenExceptionsAsErrorsWhenExceptionThrownThenErrorReturnedToUserAndErrorIsNotCached() = + testScope.runTest { + var count = 0 + val e = Exception() + val fetcher = + Fetcher.of { + count++ + if (count == 1) { + throw e + } + count - 1 } - - @Test - fun givenAFetcherThatEmitsCustomErrorWhenStreamingThenCustomErrorShouldBeEmitted() = - testScope.runTest { - data class TestCustomError(val errorMessage: String) - - val customError = TestCustomError("Test custom error") - - val store = - StoreBuilder.from( - fetcher = - Fetcher.ofResultFlow { _: Int -> - flowOf( - FetcherResult.Error.Custom(customError), - ) - }, - ).buildWithTestScope() - - store.stream(StoreReadRequest.fresh(1)).test { - assertEquals( - StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Error.Custom( - error = customError, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - private fun StoreBuilder.buildWithTestScope() = scope(testScope).build() + val pipeline = StoreBuilder.from(fetcher = fetcher).buildWithTestScope() + + pipeline.stream(StoreReadRequest.fresh(3)).test { + assertEquals( + StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + + assertEquals( + StoreReadResponse.Error.Exception(error = e, origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } + + pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { + assertEquals( + StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + + assertEquals( + StoreReadResponse.Data(value = 1, origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } + } + + @Test + fun givenAFetcherThatEmitsCustomErrorWhenStreamingThenCustomErrorShouldBeEmitted() = + testScope.runTest { + data class TestCustomError(val errorMessage: String) + + val customError = TestCustomError("Test custom error") + + val store = + StoreBuilder.from( + fetcher = + Fetcher.ofResultFlow { _: Int -> flowOf(FetcherResult.Error.Custom(customError)) } + ) + .buildWithTestScope() + + store.stream(StoreReadRequest.fresh(1)).test { + assertEquals( + StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + + assertEquals( + StoreReadResponse.Error.Custom( + error = customError, + origin = StoreReadResponseOrigin.Fetcher(), + ), + awaitItem(), + ) + } + } + + private fun StoreBuilder.buildWithTestScope() = + scope(testScope).build() } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FlowStoreTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FlowStoreTests.kt index f377ef45e..7f3dd0e0b 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FlowStoreTests.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FlowStoreTests.kt @@ -16,6 +16,9 @@ package org.mobilenativefoundation.store.store5 import app.cash.turbine.test +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.async @@ -40,1058 +43,682 @@ import org.mobilenativefoundation.store.store5.util.FakeFlowingFetcher import org.mobilenativefoundation.store.store5.util.InMemoryPersister import org.mobilenativefoundation.store.store5.util.asFlowable import org.mobilenativefoundation.store.store5.util.asSourceOfTruth -import kotlin.test.Test -import kotlin.test.assertContains -import kotlin.test.assertEquals @FlowPreview @ExperimentalCoroutinesApi class FlowStoreTests { - private val testScope = TestScope() - - @Test - fun getAndFresh() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val pipeline = - StoreBuilder - .from(fetcher) - .buildWithTestScope() - - assertEquals( - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).take(2).toList(), - listOf( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - ), - ) - - assertEquals( - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).take(1).toList(), - listOf( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - ), - ) - - assertEquals( - pipeline.stream(StoreReadRequest.fresh(3)).take(2).toList(), - listOf( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - ), - ) - - assertEquals( - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).take(1).toList(), - listOf( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Cache, - ), - ), - ) - } + private val testScope = TestScope() + + @Test + fun getAndFresh() = + testScope.runTest { + val fetcher = FakeFetcher(3 to "three-1", 3 to "three-2") + val pipeline = StoreBuilder.from(fetcher).buildWithTestScope() + + assertEquals( + pipeline.stream(StoreReadRequest.cached(3, refresh = false)).take(2).toList(), + listOf( + Loading(origin = StoreReadResponseOrigin.Fetcher()), + Data(value = "three-1", origin = StoreReadResponseOrigin.Fetcher()), + ), + ) + + assertEquals( + pipeline.stream(StoreReadRequest.cached(3, refresh = false)).take(1).toList(), + listOf(Data(value = "three-1", origin = StoreReadResponseOrigin.Cache)), + ) + + assertEquals( + pipeline.stream(StoreReadRequest.fresh(3)).take(2).toList(), + listOf( + Loading(origin = StoreReadResponseOrigin.Fetcher()), + Data(value = "three-2", origin = StoreReadResponseOrigin.Fetcher()), + ), + ) + + assertEquals( + pipeline.stream(StoreReadRequest.cached(3, refresh = false)).take(1).toList(), + listOf(Data(value = "three-2", origin = StoreReadResponseOrigin.Cache)), + ) + } + + @Test + fun getAndFresh_withPersister() = + testScope.runTest { + val fetcher = FakeFetcher(3 to "three-1", 3 to "three-2") + val persister = InMemoryPersister() + val pipeline = + StoreBuilder.from(fetcher = fetcher, sourceOfTruth = persister.asSourceOfTruth()) + .buildWithTestScope() + + pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { + assertEquals(Loading(origin = StoreReadResponseOrigin.Fetcher()), awaitItem()) + + assertEquals( + Data(value = "three-1", origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } - @Test - fun getAndFresh_withPersister() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val persister = InMemoryPersister() - val pipeline = - StoreBuilder.from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ).buildWithTestScope() - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - - // note that we still get the data from persister as well as we don't listen to - // the persister for the cached items unless there is an active stream, which - // means cache can go out of sync w/ the persister - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - } - } + pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { + assertEquals(Data(value = "three-1", origin = StoreReadResponseOrigin.Cache), awaitItem()) - @Test - fun streamAndFresh_withPersister() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val persister = InMemoryPersister() - - val pipeline = - StoreBuilder.from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ).buildWithTestScope() - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } + // note that we still get the data from persister as well as we don't listen to + // the persister for the cached items unless there is an active stream, which + // means cache can go out of sync w/ the persister - @Test - fun streamAndFresh() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val pipeline = - StoreBuilder.from(fetcher = fetcher) - .buildWithTestScope() - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } + assertEquals( + Data(value = "three-1", origin = StoreReadResponseOrigin.SourceOfTruth), + awaitItem(), + ) + } - @Test - fun skipCache() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val pipeline = - StoreBuilder.from(fetcher = fetcher) - .buildWithTestScope() - - pipeline.stream(StoreReadRequest.skipMemory(3, refresh = false)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.skipMemory(3, refresh = false)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } + pipeline.stream(StoreReadRequest.fresh(3)).test { + assertEquals(Loading(origin = StoreReadResponseOrigin.Fetcher()), awaitItem()) - @Test - fun flowingFetcher() = - testScope.runTest { - val fetcher = - FakeFlowingFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val persister = InMemoryPersister() - - val pipeline = - StoreBuilder.from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .buildWithTestScope() - - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } + assertEquals( + Data(value = "three-2", origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } - @Test - fun diskChangeWhileNetworkIsFlowing_simple() = - testScope.runTest { - val persister = InMemoryPersister().asFlowable() - val pipeline = - StoreBuilder.from( - Fetcher.ofFlow { - flow { - delay(20) - emit("three-1") - } - }, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .buildWithTestScope() - - launch { - delay(10) - persister.flowWriter(3, "local-1") - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "local-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } + pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { + assertEquals(Data(value = "three-2", origin = StoreReadResponseOrigin.Cache), awaitItem()) - @Test - fun diskChangeWhileNetworkIsFlowing_overwrite() = - testScope.runTest { - val persister = InMemoryPersister().asFlowable() - val pipeline = - StoreBuilder.from( - fetcher = - Fetcher.ofFlow { - flow { - delay(10) - emit("three-1") - delay(10) - emit("three-2") - } - }, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .buildWithTestScope() - - launch { - delay(5) - persister.flowWriter(3, "local-1") - delay(10) // go in between two server requests - persister.flowWriter(3, "local-2") - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "local-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "local-2", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } + assertEquals( + Data(value = "three-2", origin = StoreReadResponseOrigin.SourceOfTruth), + awaitItem(), + ) + } + } - @Test - fun errorTest() = - testScope.runTest { - val exception = IllegalArgumentException("wow") - val persister = InMemoryPersister().asFlowable() - val pipeline = - StoreBuilder.from( - Fetcher.of { - throw exception - }, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .buildWithTestScope() - - launch { - delay(10) - persister.flowWriter(3, "local-1") - } - - pipeline.stream(StoreReadRequest.cached(key = 3, refresh = true)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Error.Exception( - error = exception, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "local-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(key = 3, refresh = true)).test { - assertEquals( - Data( - value = "local-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Error.Exception( - error = exception, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } + @Test + fun streamAndFresh_withPersister() = + testScope.runTest { + val fetcher = FakeFetcher(3 to "three-1", 3 to "three-2") + val persister = InMemoryPersister() - @Test - fun givenSourceOfTruthWhenStreamFreshDataReturnsNoDataFromFetcherThenFetchReturnsNoDataAndCachedValuesAreReceived() = - testScope.runTest { - val persister = InMemoryPersister().asFlowable() - val pipeline = - StoreBuilder.from( - fetcher = Fetcher.ofFlow { flow {} }, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .buildWithTestScope() - - persister.flowWriter(3, "local-1") - val firstFetch = pipeline.fresh(3) // prime the cache - assertEquals("local-1", firstFetch) - - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.NoNewData( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "local-1", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "local-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - } - } + val pipeline = + StoreBuilder.from(fetcher = fetcher, sourceOfTruth = persister.asSourceOfTruth()) + .buildWithTestScope() - @Test - fun givenSourceOfTruthWhenStreamCachedDataWithRefreshReturnsNoNewDataThenCachedValuesAreReceivedAndFetchReturnsNoData() = - testScope.runTest { - val persister = InMemoryPersister().asFlowable() - val pipeline = - StoreBuilder.from( - fetcher = Fetcher.ofFlow { flow {} }, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .buildWithTestScope() - - persister.flowWriter(3, "local-1") - val firstFetch = pipeline.fresh(3) // prime the cache - assertEquals("local-1", firstFetch) - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Data( - value = "local-1", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "local-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.NoNewData( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } + pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { + assertEquals(Loading(origin = StoreReadResponseOrigin.Fetcher()), awaitItem()) - @Test - fun givenNoSourceOfTruthWhenStreamFreshDataReturnsNoDataFromFetcherThenFetchReturnsNoDataAndCachedValuesAreReceived() = - testScope.runTest { - var createCount = 0 - val pipeline = - StoreBuilder.from( - fetcher = - Fetcher.ofFlow { - if (createCount++ == 0) { - flowOf("remote-1") - } else { - flowOf() - } - }, - ) - .buildWithTestScope() - - val firstFetch = pipeline.fresh(3) // prime the cache - assertEquals("remote-1", firstFetch) - - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.NoNewData( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "remote-1", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - } - } + assertEquals( + Data(value = "three-1", origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } - @Test - fun givenNoSoTWhenStreamCachedDataWithRefreshReturnsNoNewDataThenCachedValuesAreReceivedAndFetchReturnsNoData() = - testScope.runTest { - var createCount = 0 - val pipeline = - StoreBuilder.from( - fetcher = - Fetcher.ofFlow { - if (createCount++ == 0) { - flowOf("remote-1") - } else { - flowOf() - } - }, - ) - .buildWithTestScope() - - val firstFetch = pipeline.fresh(3) // prime the cache - assertEquals("remote-1", firstFetch) - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Data( - value = "remote-1", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.NoNewData( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } + pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { + assertEquals(Data(value = "three-1", origin = StoreReadResponseOrigin.Cache), awaitItem()) - @Test - fun givenNoSourceOfTruthAndCacheHitWhenStreamCachedDataWithoutRefreshThenNoFetchIsTriggeredAndReceivesFollowingNetworkUpdates() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val store = - StoreBuilder.from(fetcher = fetcher) - .buildWithTestScope() - - val firstFetch = store.fresh(3) - assertEquals("three-1", firstFetch) - val secondCollect = mutableListOf>() - val collection = - launch { - store.stream(StoreReadRequest.cached(3, refresh = false)).collect { - secondCollect.add(it) - } - } - testScope.runCurrent() - assertEquals(1, secondCollect.size) - assertContains( - secondCollect, - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - ) - // trigger another fetch from network - val secondFetch = store.fresh(3) - assertEquals("three-2", secondFetch) - testScope.runCurrent() - // make sure cached also received it - assertEquals(2, secondCollect.size) - - assertContains( - secondCollect, - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - ) - assertContains( - secondCollect, - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - ) - - collection.cancelAndJoin() - } + assertEquals( + Data(value = "three-1", origin = StoreReadResponseOrigin.SourceOfTruth), + awaitItem(), + ) + + assertEquals(Loading(origin = StoreReadResponseOrigin.Fetcher()), awaitItem()) + + assertEquals( + Data(value = "three-2", origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } + } + + @Test + fun streamAndFresh() = + testScope.runTest { + val fetcher = FakeFetcher(3 to "three-1", 3 to "three-2") + val pipeline = StoreBuilder.from(fetcher = fetcher).buildWithTestScope() + + pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { + assertEquals(Loading(origin = StoreReadResponseOrigin.Fetcher()), awaitItem()) + + assertEquals( + Data(value = "three-1", origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } + + pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { + assertEquals(Data(value = "three-1", origin = StoreReadResponseOrigin.Cache), awaitItem()) + + assertEquals(Loading(origin = StoreReadResponseOrigin.Fetcher()), awaitItem()) + + assertEquals( + Data(value = "three-2", origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } + } + + @Test + fun skipCache() = + testScope.runTest { + val fetcher = FakeFetcher(3 to "three-1", 3 to "three-2") + val pipeline = StoreBuilder.from(fetcher = fetcher).buildWithTestScope() + + pipeline.stream(StoreReadRequest.skipMemory(3, refresh = false)).test { + assertEquals(Loading(origin = StoreReadResponseOrigin.Fetcher()), awaitItem()) + + assertEquals( + Data(value = "three-1", origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } + + pipeline.stream(StoreReadRequest.skipMemory(3, refresh = false)).test { + assertEquals(Loading(origin = StoreReadResponseOrigin.Fetcher()), awaitItem()) + + assertEquals( + Data(value = "three-2", origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } + } + + @Test + fun flowingFetcher() = + testScope.runTest { + val fetcher = FakeFlowingFetcher(3 to "three-1", 3 to "three-2") + val persister = InMemoryPersister() + + val pipeline = + StoreBuilder.from(fetcher = fetcher, sourceOfTruth = persister.asSourceOfTruth()) + .disableCache() + .buildWithTestScope() + + pipeline.stream(StoreReadRequest.fresh(3)).test { + assertEquals(Loading(origin = StoreReadResponseOrigin.Fetcher()), awaitItem()) + + assertEquals( + Data(value = "three-1", origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + + assertEquals( + Data(value = "three-2", origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } + + pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { + assertEquals( + Data(value = "three-2", origin = StoreReadResponseOrigin.SourceOfTruth), + awaitItem(), + ) + + assertEquals(Loading(origin = StoreReadResponseOrigin.Fetcher()), awaitItem()) + + assertEquals( + Data(value = "three-1", origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + + assertEquals( + Data(value = "three-2", origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } + } + + @Test + fun diskChangeWhileNetworkIsFlowing_simple() = + testScope.runTest { + val persister = InMemoryPersister().asFlowable() + val pipeline = + StoreBuilder.from( + Fetcher.ofFlow { + flow { + delay(20) + emit("three-1") + } + }, + sourceOfTruth = persister.asSourceOfTruth(), + ) + .disableCache() + .buildWithTestScope() + + launch { + delay(10) + persister.flowWriter(3, "local-1") + } + + pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { + assertEquals(Loading(origin = StoreReadResponseOrigin.Fetcher()), awaitItem()) + + assertEquals( + Data(value = "local-1", origin = StoreReadResponseOrigin.SourceOfTruth), + awaitItem(), + ) - @Test - fun givenSourceOfTruthAndCacheHitWhenStreamCachedDataWithoutRefreshThenNoFetchIsTriggeredAndReceivesFollowingNetworkUpdates() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val persister = InMemoryPersister() - val pipeline = - StoreBuilder.from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ).buildWithTestScope() - - val firstFetch = pipeline.fresh(3) - assertEquals("three-1", firstFetch) - val secondCollect = mutableListOf>() - val collection = - launch { - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).collect { - secondCollect.add(it) - } + assertEquals( + Data(value = "three-1", origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } + } + + @Test + fun diskChangeWhileNetworkIsFlowing_overwrite() = + testScope.runTest { + val persister = InMemoryPersister().asFlowable() + val pipeline = + StoreBuilder.from( + fetcher = + Fetcher.ofFlow { + flow { + delay(10) + emit("three-1") + delay(10) + emit("three-2") } - testScope.runCurrent() - assertEquals(2, secondCollect.size) - - assertContains( - secondCollect, - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - ) - assertContains( - secondCollect, - Data( - value = "three-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - ) - - // trigger another fetch from network - val secondFetch = pipeline.fresh(3) - assertEquals("three-2", secondFetch) - testScope.runCurrent() - // make sure cached also received it - assertEquals(3, secondCollect.size) - - assertContains( - secondCollect, - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - ) - assertContains( - secondCollect, - Data( - value = "three-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - ) - - assertContains( - secondCollect, - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - ) - collection.cancelAndJoin() - } + }, + sourceOfTruth = persister.asSourceOfTruth(), + ) + .disableCache() + .buildWithTestScope() + + launch { + delay(5) + persister.flowWriter(3, "local-1") + delay(10) // go in between two server requests + persister.flowWriter(3, "local-2") + } + + pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { + assertEquals(Loading(origin = StoreReadResponseOrigin.Fetcher()), awaitItem()) + + assertEquals( + Data(value = "local-1", origin = StoreReadResponseOrigin.SourceOfTruth), + awaitItem(), + ) + + assertEquals( + Data(value = "three-1", origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + + assertEquals( + Data(value = "local-2", origin = StoreReadResponseOrigin.SourceOfTruth), + awaitItem(), + ) + + assertEquals( + Data(value = "three-2", origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } + } + + @Test + fun errorTest() = + testScope.runTest { + val exception = IllegalArgumentException("wow") + val persister = InMemoryPersister().asFlowable() + val pipeline = + StoreBuilder.from( + Fetcher.of { throw exception }, + sourceOfTruth = persister.asSourceOfTruth(), + ) + .disableCache() + .buildWithTestScope() + + launch { + delay(10) + persister.flowWriter(3, "local-1") + } + + pipeline.stream(StoreReadRequest.cached(key = 3, refresh = true)).test { + assertEquals(Loading(origin = StoreReadResponseOrigin.Fetcher()), awaitItem()) + + assertEquals( + StoreReadResponse.Error.Exception( + error = exception, + origin = StoreReadResponseOrigin.Fetcher(), + ), + awaitItem(), + ) + + assertEquals( + Data(value = "local-1", origin = StoreReadResponseOrigin.SourceOfTruth), + awaitItem(), + ) + } + + pipeline.stream(StoreReadRequest.cached(key = 3, refresh = true)).test { + assertEquals( + Data(value = "local-1", origin = StoreReadResponseOrigin.SourceOfTruth), + awaitItem(), + ) + + assertEquals(Loading(origin = StoreReadResponseOrigin.Fetcher()), awaitItem()) + + assertEquals( + StoreReadResponse.Error.Exception( + error = exception, + origin = StoreReadResponseOrigin.Fetcher(), + ), + awaitItem(), + ) + } + } + + @Test + fun givenSourceOfTruthWhenStreamFreshDataReturnsNoDataFromFetcherThenFetchReturnsNoDataAndCachedValuesAreReceived() = + testScope.runTest { + val persister = InMemoryPersister().asFlowable() + val pipeline = + StoreBuilder.from( + fetcher = Fetcher.ofFlow { flow {} }, + sourceOfTruth = persister.asSourceOfTruth(), + ) + .buildWithTestScope() + + persister.flowWriter(3, "local-1") + val firstFetch = pipeline.fresh(3) // prime the cache + assertEquals("local-1", firstFetch) + + pipeline.stream(StoreReadRequest.fresh(3)).test { + assertEquals(Loading(origin = StoreReadResponseOrigin.Fetcher()), awaitItem()) + + assertEquals( + StoreReadResponse.NoNewData(origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) - @Test - fun testSlowFirstCollectorGetsAllFetchUpdatesOthersGetCacheAndLatestFetchResult() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - 3 to "three-3", - ) - val pipeline = - StoreBuilder.from( - fetcher = fetcher, - ).buildWithTestScope() - - val fetcher1Collected = mutableListOf>() - val fetcher1Job = - async { - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).collect { - fetcher1Collected.add(it) - delay(1_000) - } + assertEquals(Data(value = "local-1", origin = StoreReadResponseOrigin.Cache), awaitItem()) + + assertEquals( + Data(value = "local-1", origin = StoreReadResponseOrigin.SourceOfTruth), + awaitItem(), + ) + } + } + + @Test + fun givenSourceOfTruthWhenStreamCachedDataWithRefreshReturnsNoNewDataThenCachedValuesAreReceivedAndFetchReturnsNoData() = + testScope.runTest { + val persister = InMemoryPersister().asFlowable() + val pipeline = + StoreBuilder.from( + fetcher = Fetcher.ofFlow { flow {} }, + sourceOfTruth = persister.asSourceOfTruth(), + ) + .buildWithTestScope() + + persister.flowWriter(3, "local-1") + val firstFetch = pipeline.fresh(3) // prime the cache + assertEquals("local-1", firstFetch) + + pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { + assertEquals(Data(value = "local-1", origin = StoreReadResponseOrigin.Cache), awaitItem()) + + assertEquals( + Data(value = "local-1", origin = StoreReadResponseOrigin.SourceOfTruth), + awaitItem(), + ) + + assertEquals(Loading(origin = StoreReadResponseOrigin.Fetcher()), awaitItem()) + + assertEquals( + StoreReadResponse.NoNewData(origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } + } + + @Test + fun givenNoSourceOfTruthWhenStreamFreshDataReturnsNoDataFromFetcherThenFetchReturnsNoDataAndCachedValuesAreReceived() = + testScope.runTest { + var createCount = 0 + val pipeline = + StoreBuilder.from( + fetcher = + Fetcher.ofFlow { + if (createCount++ == 0) { + flowOf("remote-1") + } else { + flowOf() } - testScope.advanceUntilIdle() - assertEquals( - listOf( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-1"), - ), - fetcher1Collected, - ) - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Data(origin = StoreReadResponseOrigin.Cache, value = "three-1"), - awaitItem(), - ) - assertEquals( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - assertEquals( - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-2"), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Data(origin = StoreReadResponseOrigin.Cache, value = "three-2"), - awaitItem(), - ) - - assertEquals( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - assertEquals( - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-3"), - awaitItem(), - ) - } - - testScope.advanceUntilIdle() - assertEquals( - listOf( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-1"), - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-2"), - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-3"), - ), - fetcher1Collected, - ) - - fetcher1Job.cancelAndJoin() - } + } + ) + .buildWithTestScope() + + val firstFetch = pipeline.fresh(3) // prime the cache + assertEquals("remote-1", firstFetch) + + pipeline.stream(StoreReadRequest.fresh(3)).test { + assertEquals(Loading(origin = StoreReadResponseOrigin.Fetcher()), awaitItem()) - @Test - fun testFirstStreamGetsTwoFetchUpdatesSecondGetsCacheAndFetchResult() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val pipeline = - StoreBuilder.from(fetcher = fetcher) - .buildWithTestScope() - - val fetcher1Collected = mutableListOf>() - val fetcher1Job = - async { - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).collect { - fetcher1Collected.add(it) - } + assertEquals( + StoreReadResponse.NoNewData(origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + + assertEquals(Data(value = "remote-1", origin = StoreReadResponseOrigin.Cache), awaitItem()) + } + } + + @Test + fun givenNoSoTWhenStreamCachedDataWithRefreshReturnsNoNewDataThenCachedValuesAreReceivedAndFetchReturnsNoData() = + testScope.runTest { + var createCount = 0 + val pipeline = + StoreBuilder.from( + fetcher = + Fetcher.ofFlow { + if (createCount++ == 0) { + flowOf("remote-1") + } else { + flowOf() } - testScope.runCurrent() - assertEquals( - listOf( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-1"), - ), - fetcher1Collected, - ) - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Data(origin = StoreReadResponseOrigin.Cache, value = "three-1"), - awaitItem(), - ) - - assertEquals( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - assertEquals( - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-2"), - awaitItem(), - ) - } - - testScope.runCurrent() - assertEquals( - listOf( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-1"), - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-2"), - ), - fetcher1Collected, - ) - - fetcher1Job.cancelAndJoin() + } + ) + .buildWithTestScope() + + val firstFetch = pipeline.fresh(3) // prime the cache + assertEquals("remote-1", firstFetch) + + pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { + assertEquals(Data(value = "remote-1", origin = StoreReadResponseOrigin.Cache), awaitItem()) + + assertEquals(Loading(origin = StoreReadResponseOrigin.Fetcher()), awaitItem()) + + assertEquals( + StoreReadResponse.NoNewData(origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } + } + + @Test + fun givenNoSourceOfTruthAndCacheHitWhenStreamCachedDataWithoutRefreshThenNoFetchIsTriggeredAndReceivesFollowingNetworkUpdates() = + testScope.runTest { + val fetcher = FakeFetcher(3 to "three-1", 3 to "three-2") + val store = StoreBuilder.from(fetcher = fetcher).buildWithTestScope() + + val firstFetch = store.fresh(3) + assertEquals("three-1", firstFetch) + val secondCollect = mutableListOf>() + val collection = launch { + store.stream(StoreReadRequest.cached(3, refresh = false)).collect { secondCollect.add(it) } + } + testScope.runCurrent() + assertEquals(1, secondCollect.size) + assertContains(secondCollect, Data(value = "three-1", origin = StoreReadResponseOrigin.Cache)) + // trigger another fetch from network + val secondFetch = store.fresh(3) + assertEquals("three-2", secondFetch) + testScope.runCurrent() + // make sure cached also received it + assertEquals(2, secondCollect.size) + + assertContains(secondCollect, Data(value = "three-1", origin = StoreReadResponseOrigin.Cache)) + assertContains( + secondCollect, + Data(value = "three-2", origin = StoreReadResponseOrigin.Fetcher()), + ) + + collection.cancelAndJoin() + } + + @Test + fun givenSourceOfTruthAndCacheHitWhenStreamCachedDataWithoutRefreshThenNoFetchIsTriggeredAndReceivesFollowingNetworkUpdates() = + testScope.runTest { + val fetcher = FakeFetcher(3 to "three-1", 3 to "three-2") + val persister = InMemoryPersister() + val pipeline = + StoreBuilder.from(fetcher = fetcher, sourceOfTruth = persister.asSourceOfTruth()) + .buildWithTestScope() + + val firstFetch = pipeline.fresh(3) + assertEquals("three-1", firstFetch) + val secondCollect = mutableListOf>() + val collection = launch { + pipeline.stream(StoreReadRequest.cached(3, refresh = false)).collect { + secondCollect.add(it) + } + } + testScope.runCurrent() + assertEquals(2, secondCollect.size) + + assertContains(secondCollect, Data(value = "three-1", origin = StoreReadResponseOrigin.Cache)) + assertContains( + secondCollect, + Data(value = "three-1", origin = StoreReadResponseOrigin.SourceOfTruth), + ) + + // trigger another fetch from network + val secondFetch = pipeline.fresh(3) + assertEquals("three-2", secondFetch) + testScope.runCurrent() + // make sure cached also received it + assertEquals(3, secondCollect.size) + + assertContains(secondCollect, Data(value = "three-1", origin = StoreReadResponseOrigin.Cache)) + assertContains( + secondCollect, + Data(value = "three-1", origin = StoreReadResponseOrigin.SourceOfTruth), + ) + + assertContains( + secondCollect, + Data(value = "three-2", origin = StoreReadResponseOrigin.Fetcher()), + ) + collection.cancelAndJoin() + } + + @Test + fun testSlowFirstCollectorGetsAllFetchUpdatesOthersGetCacheAndLatestFetchResult() = + testScope.runTest { + val fetcher = FakeFetcher(3 to "three-1", 3 to "three-2", 3 to "three-3") + val pipeline = StoreBuilder.from(fetcher = fetcher).buildWithTestScope() + + val fetcher1Collected = mutableListOf>() + val fetcher1Job = async { + pipeline.stream(StoreReadRequest.cached(3, refresh = true)).collect { + fetcher1Collected.add(it) + delay(1_000) } + } + testScope.advanceUntilIdle() + assertEquals( + listOf( + Loading(origin = StoreReadResponseOrigin.Fetcher()), + Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-1"), + ), + fetcher1Collected, + ) + + pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { + assertEquals(Data(origin = StoreReadResponseOrigin.Cache, value = "three-1"), awaitItem()) + assertEquals(Loading(origin = StoreReadResponseOrigin.Fetcher()), awaitItem()) + + assertEquals( + Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-2"), + awaitItem(), + ) + } - suspend fun Store.get(request: StoreReadRequest) = this.stream(request).filter { it.dataOrNull() != null }.first() + pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { + assertEquals(Data(origin = StoreReadResponseOrigin.Cache, value = "three-2"), awaitItem()) - suspend fun Store.get(key: Int) = - get( - StoreReadRequest.cached( - key = key, - refresh = false, - ), + assertEquals(Loading(origin = StoreReadResponseOrigin.Fetcher()), awaitItem()) + assertEquals( + Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-3"), + awaitItem(), ) + } + + testScope.advanceUntilIdle() + assertEquals( + listOf( + Loading(origin = StoreReadResponseOrigin.Fetcher()), + Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-1"), + Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-2"), + Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-3"), + ), + fetcher1Collected, + ) + + fetcher1Job.cancelAndJoin() + } + + @Test + fun testFirstStreamGetsTwoFetchUpdatesSecondGetsCacheAndFetchResult() = + testScope.runTest { + val fetcher = FakeFetcher(3 to "three-1", 3 to "three-2") + val pipeline = StoreBuilder.from(fetcher = fetcher).buildWithTestScope() + + val fetcher1Collected = mutableListOf>() + val fetcher1Job = async { + pipeline.stream(StoreReadRequest.cached(3, refresh = true)).collect { + fetcher1Collected.add(it) + } + } + testScope.runCurrent() + assertEquals( + listOf( + Loading(origin = StoreReadResponseOrigin.Fetcher()), + Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-1"), + ), + fetcher1Collected, + ) + + pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { + assertEquals(Data(origin = StoreReadResponseOrigin.Cache, value = "three-1"), awaitItem()) + + assertEquals(Loading(origin = StoreReadResponseOrigin.Fetcher()), awaitItem()) + + assertEquals( + Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-2"), + awaitItem(), + ) + } + + testScope.runCurrent() + assertEquals( + listOf( + Loading(origin = StoreReadResponseOrigin.Fetcher()), + Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-1"), + Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-2"), + ), + fetcher1Collected, + ) + + fetcher1Job.cancelAndJoin() + } + + suspend fun Store.get(request: StoreReadRequest) = + this.stream(request).filter { it.dataOrNull() != null }.first() + + suspend fun Store.get(key: Int) = + get(StoreReadRequest.cached(key = key, refresh = false)) - private fun StoreBuilder.buildWithTestScope() = scope(testScope).build() + private fun StoreBuilder.buildWithTestScope() = + scope(testScope).build() } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/HotFlowStoreTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/HotFlowStoreTests.kt index 7d3c2e64f..e3d2e510f 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/HotFlowStoreTests.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/HotFlowStoreTests.kt @@ -1,6 +1,8 @@ package org.mobilenativefoundation.store.store5 import app.cash.turbine.test +import kotlin.test.Test +import kotlin.test.assertEquals import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow @@ -8,95 +10,68 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals @ExperimentalCoroutinesApi @FlowPreview class HotFlowStoreTests { - private val testScope = TestScope() - - @Test - fun givenAHotFetcherWhenTwoCachedAndOneFreshCallThenFetcherIsOnlyCalledTwice() = - testScope.runTest { - val fetcher = - FakeFlowFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val pipeline = - StoreBuilder - .from(fetcher) - .scope(testScope) - .build() + private val testScope = TestScope() - val job = - launch { - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) + @Test + fun givenAHotFetcherWhenTwoCachedAndOneFreshCallThenFetcherIsOnlyCalledTwice() = + testScope.runTest { + val fetcher = FakeFlowFetcher(3 to "three-1", 3 to "three-2") + val pipeline = StoreBuilder.from(fetcher).scope(testScope).build() - assertEquals( - StoreReadResponse.Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } + val job = launch { + pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { + assertEquals( + StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) - pipeline.stream( - StoreReadRequest.cached(3, refresh = false), - ).test { - assertEquals( - StoreReadResponse.Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - } + assertEquals( + StoreReadResponse.Data(value = "three-1", origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) + pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { + assertEquals( + StoreReadResponse.Data(value = "three-1", origin = StoreReadResponseOrigin.Cache), + awaitItem(), + ) + } - assertEquals( - StoreReadResponse.Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } + pipeline.stream(StoreReadRequest.fresh(3)).test { + assertEquals( + StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) - job.cancel() + assertEquals( + StoreReadResponse.Data(value = "three-2", origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) } + } + + job.cancel() + } } -private class FakeFlowFetcher( - vararg val responses: Pair, -) : Fetcher { - private var index = 0 - override val name: String? = null +private class FakeFlowFetcher(vararg val responses: Pair) : + Fetcher { + private var index = 0 + override val name: String? = null - override val fallback: Fetcher? = null + override val fallback: Fetcher? = null - override fun invoke(key: Key): Flow> { - if (index >= responses.size) { - throw AssertionError("unexpected fetch request") - } - val pair = responses[index++] - assertEquals(key, pair.first) - return flowOf(FetcherResult.Data(pair.second)) + override fun invoke(key: Key): Flow> { + if (index >= responses.size) { + throw AssertionError("unexpected fetch request") } + val pair = responses[index++] + assertEquals(key, pair.first) + return flowOf(FetcherResult.Data(pair.second)) + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/KeyTrackerTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/KeyTrackerTests.kt index a38fb4d10..0d9991059 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/KeyTrackerTests.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/KeyTrackerTests.kt @@ -1,5 +1,7 @@ package org.mobilenativefoundation.store.store5 +import kotlin.test.Test +import kotlin.test.assertEquals import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.cancel @@ -11,126 +13,99 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.mobilenativefoundation.store.store5.util.KeyTracker -import kotlin.test.Test -import kotlin.test.assertEquals @ExperimentalCoroutinesApi class KeyTrackerTests { - private val scope1 = TestScope() - private val scope2 = TestScope() + private val scope1 = TestScope() + private val scope2 = TestScope() - private val subject = KeyTracker() + private val subject = KeyTracker() - @Test - fun dontSkipInvalidations() = - scope1.runTest { - val collection = - scope2.async { - subject.keyFlow('b') - .take(2) - .toList() - } - scope2.advanceUntilIdle() - assertEquals(1, subject.activeKeyCount()) - scope2.advanceUntilIdle() - subject.invalidate('a') - subject.invalidate('b') - subject.invalidate('c') - scope2.advanceUntilIdle() - assertEquals(true, collection.isCompleted) - assertEquals(0, subject.activeKeyCount()) - } + @Test + fun dontSkipInvalidations() = + scope1.runTest { + val collection = scope2.async { subject.keyFlow('b').take(2).toList() } + scope2.advanceUntilIdle() + assertEquals(1, subject.activeKeyCount()) + scope2.advanceUntilIdle() + subject.invalidate('a') + subject.invalidate('b') + subject.invalidate('c') + scope2.advanceUntilIdle() + assertEquals(true, collection.isCompleted) + assertEquals(0, subject.activeKeyCount()) + } - @Test - fun multipleScopes() = - scope1.runTest { - val keys = 'a'..'z' - val collections = - keys.associate { key -> - key to - scope2.async { - subject.keyFlow(key) - .take(2) - .toList() - } - } - scope2.advanceUntilIdle() - assertEquals(26, subject.activeKeyCount()) + @Test + fun multipleScopes() = + scope1.runTest { + val keys = 'a'..'z' + val collections = + keys.associate { key -> key to scope2.async { subject.keyFlow(key).take(2).toList() } } + scope2.advanceUntilIdle() + assertEquals(26, subject.activeKeyCount()) - scope2.advanceUntilIdle() - keys.forEach { - subject.invalidate(it) - } - scope2.advanceUntilIdle() + scope2.advanceUntilIdle() + keys.forEach { subject.invalidate(it) } + scope2.advanceUntilIdle() - collections.forEach { (_, deferred) -> - assertEquals(true, deferred.isCompleted) - } - assertEquals(0, subject.activeKeyCount()) - } + collections.forEach { (_, deferred) -> assertEquals(true, deferred.isCompleted) } + assertEquals(0, subject.activeKeyCount()) + } - @Test - fun multipleObservers() = - scope1.runTest { - val collections = - (0..4).map { - scope2.async { - subject.keyFlow('b') - .take(2) - .toList() - } - } - scope2.advanceUntilIdle() - assertEquals(1, subject.activeKeyCount()) - scope2.advanceUntilIdle() - subject.invalidate('a') - subject.invalidate('b') - subject.invalidate('c') - scope2.advanceUntilIdle() - collections.forEach { collection -> - assertEquals(true, collection.isCompleted) - } - assertEquals(0, subject.activeKeyCount()) - } + @Test + fun multipleObservers() = + scope1.runTest { + val collections = (0..4).map { scope2.async { subject.keyFlow('b').take(2).toList() } } + scope2.advanceUntilIdle() + assertEquals(1, subject.activeKeyCount()) + scope2.advanceUntilIdle() + subject.invalidate('a') + subject.invalidate('b') + subject.invalidate('c') + scope2.advanceUntilIdle() + collections.forEach { collection -> assertEquals(true, collection.isCompleted) } + assertEquals(0, subject.activeKeyCount()) + } - @Test - fun keyFlow_notCollected_shouldNotBeTracked() = - scope1.runTest { - val flow = subject.keyFlow('b') - assertEquals(0, subject.activeKeyCount()) - scope2.launch { - flow.collectIndexed { index, value -> - assertEquals(1, index) - assertEquals(Unit, value) - assertEquals(1, subject.activeKeyCount()) - cancel() - } - } - assertEquals(0, subject.activeKeyCount()) + @Test + fun keyFlow_notCollected_shouldNotBeTracked() = + scope1.runTest { + val flow = subject.keyFlow('b') + assertEquals(0, subject.activeKeyCount()) + scope2.launch { + flow.collectIndexed { index, value -> + assertEquals(1, index) + assertEquals(Unit, value) + assertEquals(1, subject.activeKeyCount()) + cancel() } + } + assertEquals(0, subject.activeKeyCount()) + } - @Test - fun keyFlow_trackerShouldRefCount() = - scope1.runTest { - val flow = subject.keyFlow('a') - assertEquals(0, subject.activeKeyCount()) - scope2.launch { - flow.collectIndexed { index, value -> - assertEquals(1, index) - assertEquals(Unit, value) - assertEquals(1, subject.activeKeyCount()) - cancel() - } - } - scope2.launch { - flow.collectIndexed { index, value -> - assertEquals(1, index) - assertEquals(Unit, value) - assertEquals(1, subject.activeKeyCount()) - cancel() - } - } - - assertEquals(0, subject.activeKeyCount()) + @Test + fun keyFlow_trackerShouldRefCount() = + scope1.runTest { + val flow = subject.keyFlow('a') + assertEquals(0, subject.activeKeyCount()) + scope2.launch { + flow.collectIndexed { index, value -> + assertEquals(1, index) + assertEquals(Unit, value) + assertEquals(1, subject.activeKeyCount()) + cancel() } + } + scope2.launch { + flow.collectIndexed { index, value -> + assertEquals(1, index) + assertEquals(Unit, value) + assertEquals(1, subject.activeKeyCount()) + cancel() + } + } + + assertEquals(0, subject.activeKeyCount()) + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/LocalOnlyTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/LocalOnlyTests.kt index 632478bda..94503d464 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/LocalOnlyTests.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/LocalOnlyTests.kt @@ -1,5 +1,9 @@ package org.mobilenativefoundation.store.store5 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration import kotlinx.atomicfu.atomic import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.TestScope @@ -7,162 +11,142 @@ import kotlinx.coroutines.test.runTest import org.mobilenativefoundation.store.store5.impl.extensions.get import org.mobilenativefoundation.store.store5.util.InMemoryPersister import org.mobilenativefoundation.store.store5.util.asSourceOfTruth -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import kotlin.time.Duration class LocalOnlyTests { - private val testScope = TestScope() + private val testScope = TestScope() - @Test - fun givenEmptyMemoryCacheThenCacheOnlyRequestReturnsNoNewData() = - testScope.runTest { - val store = - StoreBuilder - .from(Fetcher.of { _: Int -> throw RuntimeException("Fetcher shouldn't be hit") }) - .cachePolicy( - MemoryPolicy - .builder() - .build(), - ) - .build() - val response = store.stream(StoreReadRequest.localOnly(0)).first() - assertEquals(StoreReadResponse.NoNewData(StoreReadResponseOrigin.Cache), response) - } + @Test + fun givenEmptyMemoryCacheThenCacheOnlyRequestReturnsNoNewData() = + testScope.runTest { + val store = + StoreBuilder.from( + Fetcher.of { _: Int -> throw RuntimeException("Fetcher shouldn't be hit") } + ) + .cachePolicy(MemoryPolicy.builder().build()) + .build() + val response = store.stream(StoreReadRequest.localOnly(0)).first() + assertEquals(StoreReadResponse.NoNewData(StoreReadResponseOrigin.Cache), response) + } - @Test - fun givenPrimedMemoryCacheThenCacheOnlyRequestReturnsData() = - testScope.runTest { - val fetcherHitCounter = atomic(0) - val store = - StoreBuilder - .from( - Fetcher.of { _: Int -> - fetcherHitCounter += 1 - "result" - }, - ) - .cachePolicy( - MemoryPolicy - .builder() - .build(), - ) - .build() - val a = store.get(0) - assertEquals("result", a) - assertEquals(1, fetcherHitCounter.value) - val response = store.stream(StoreReadRequest.localOnly(0)).first() - assertEquals("result", response.requireData()) - assertEquals(1, fetcherHitCounter.value) - } + @Test + fun givenPrimedMemoryCacheThenCacheOnlyRequestReturnsData() = + testScope.runTest { + val fetcherHitCounter = atomic(0) + val store = + StoreBuilder.from( + Fetcher.of { _: Int -> + fetcherHitCounter += 1 + "result" + } + ) + .cachePolicy(MemoryPolicy.builder().build()) + .build() + val a = store.get(0) + assertEquals("result", a) + assertEquals(1, fetcherHitCounter.value) + val response = store.stream(StoreReadRequest.localOnly(0)).first() + assertEquals("result", response.requireData()) + assertEquals(1, fetcherHitCounter.value) + } - @Test - fun givenInvalidMemoryCacheThenCacheOnlyRequestReturnsNoNewData() = - testScope.runTest { - val fetcherHitCounter = atomic(0) - val store = - StoreBuilder - .from( - Fetcher.of { _: Int -> - fetcherHitCounter += 1 - "result" - }, - ) - .cachePolicy( - MemoryPolicy - .builder() - .setExpireAfterWrite(Duration.ZERO) - .build(), - ) - .build() - val a = store.get(0) - assertEquals("result", a) - assertEquals(1, fetcherHitCounter.value) - val response = store.stream(StoreReadRequest.localOnly(0)).first() - assertEquals(StoreReadResponse.NoNewData(StoreReadResponseOrigin.Cache), response) - assertEquals(1, fetcherHitCounter.value) - } + @Test + fun givenInvalidMemoryCacheThenCacheOnlyRequestReturnsNoNewData() = + testScope.runTest { + val fetcherHitCounter = atomic(0) + val store = + StoreBuilder.from( + Fetcher.of { _: Int -> + fetcherHitCounter += 1 + "result" + } + ) + .cachePolicy(MemoryPolicy.builder().setExpireAfterWrite(Duration.ZERO).build()) + .build() + val a = store.get(0) + assertEquals("result", a) + assertEquals(1, fetcherHitCounter.value) + val response = store.stream(StoreReadRequest.localOnly(0)).first() + assertEquals(StoreReadResponse.NoNewData(StoreReadResponseOrigin.Cache), response) + assertEquals(1, fetcherHitCounter.value) + } - @Test - fun givenEmptyDiskCacheThenCacheOnlyRequestReturnsNoNewData() = - testScope.runTest { - val persister = InMemoryPersister() - val store = - StoreBuilder - .from( - fetcher = Fetcher.of { _: Int -> throw RuntimeException("Fetcher shouldn't be hit") }, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .build() - val response = store.stream(StoreReadRequest.localOnly(0)).first() - assertEquals(StoreReadResponse.NoNewData(StoreReadResponseOrigin.SourceOfTruth), response) - } + @Test + fun givenEmptyDiskCacheThenCacheOnlyRequestReturnsNoNewData() = + testScope.runTest { + val persister = InMemoryPersister() + val store = + StoreBuilder.from( + fetcher = Fetcher.of { _: Int -> throw RuntimeException("Fetcher shouldn't be hit") }, + sourceOfTruth = persister.asSourceOfTruth(), + ) + .disableCache() + .build() + val response = store.stream(StoreReadRequest.localOnly(0)).first() + assertEquals(StoreReadResponse.NoNewData(StoreReadResponseOrigin.SourceOfTruth), response) + } - @Test - fun givenPrimedDiskCacheThenCacheOnlyRequestReturnsData() = - testScope.runTest { - val fetcherHitCounter = atomic(0) - val persister = InMemoryPersister() - val store = - StoreBuilder - .from( - fetcher = - Fetcher.of { _: Int -> - fetcherHitCounter += 1 - "result" - }, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .build() - val a = store.get(0) - assertEquals("result", a) - assertEquals(1, fetcherHitCounter.value) - val response = store.stream(StoreReadRequest.localOnly(0)).first() - assertEquals("result", response.requireData()) - assertEquals(StoreReadResponseOrigin.SourceOfTruth, response.origin) - assertEquals(1, fetcherHitCounter.value) - } + @Test + fun givenPrimedDiskCacheThenCacheOnlyRequestReturnsData() = + testScope.runTest { + val fetcherHitCounter = atomic(0) + val persister = InMemoryPersister() + val store = + StoreBuilder.from( + fetcher = + Fetcher.of { _: Int -> + fetcherHitCounter += 1 + "result" + }, + sourceOfTruth = persister.asSourceOfTruth(), + ) + .disableCache() + .build() + val a = store.get(0) + assertEquals("result", a) + assertEquals(1, fetcherHitCounter.value) + val response = store.stream(StoreReadRequest.localOnly(0)).first() + assertEquals("result", response.requireData()) + assertEquals(StoreReadResponseOrigin.SourceOfTruth, response.origin) + assertEquals(1, fetcherHitCounter.value) + } - @Test - fun givenInvalidDiskCacheThenCacheOnlyRequestReturnsNoNewData() = - testScope.runTest { - val fetcherHitCounter = atomic(0) - val persister = InMemoryPersister() - persister.write(0, "result") - val store = - StoreBuilder - .from( - fetcher = - Fetcher.of { _: Int -> - fetcherHitCounter += 1 - "result" - }, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .validator(Validator.by { false }) - .build() - val a = store.get(0) - assertEquals("result", a) - assertEquals(1, fetcherHitCounter.value) - val response = store.stream(StoreReadRequest.localOnly(0)).first() - assertEquals(StoreReadResponse.NoNewData(StoreReadResponseOrigin.SourceOfTruth), response) - assertEquals(1, fetcherHitCounter.value) - } + @Test + fun givenInvalidDiskCacheThenCacheOnlyRequestReturnsNoNewData() = + testScope.runTest { + val fetcherHitCounter = atomic(0) + val persister = InMemoryPersister() + persister.write(0, "result") + val store = + StoreBuilder.from( + fetcher = + Fetcher.of { _: Int -> + fetcherHitCounter += 1 + "result" + }, + sourceOfTruth = persister.asSourceOfTruth(), + ) + .disableCache() + .validator(Validator.by { false }) + .build() + val a = store.get(0) + assertEquals("result", a) + assertEquals(1, fetcherHitCounter.value) + val response = store.stream(StoreReadRequest.localOnly(0)).first() + assertEquals(StoreReadResponse.NoNewData(StoreReadResponseOrigin.SourceOfTruth), response) + assertEquals(1, fetcherHitCounter.value) + } - @Test - fun givenNoCacheThenCacheOnlyRequestReturnsNoNewData() = - testScope.runTest { - val store = - StoreBuilder - .from(Fetcher.of { _: Int -> throw RuntimeException("Fetcher shouldn't be hit") }) - .disableCache() - .build() - val response = store.stream(StoreReadRequest.localOnly(0)).first() - assertTrue(response is StoreReadResponse.NoNewData) - assertEquals(StoreReadResponseOrigin.Cache, response.origin) - } + @Test + fun givenNoCacheThenCacheOnlyRequestReturnsNoNewData() = + testScope.runTest { + val store = + StoreBuilder.from( + Fetcher.of { _: Int -> throw RuntimeException("Fetcher shouldn't be hit") } + ) + .disableCache() + .build() + val response = store.stream(StoreReadRequest.localOnly(0)).first() + assertTrue(response is StoreReadResponse.NoNewData) + assertEquals(StoreReadResponseOrigin.Cache, response.origin) + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/MapIndexedTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/MapIndexedTests.kt index da4527c70..4c637cdfd 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/MapIndexedTests.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/MapIndexedTests.kt @@ -1,23 +1,25 @@ package org.mobilenativefoundation.store.store5 import app.cash.turbine.test +import kotlin.test.Test +import kotlin.test.assertEquals import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.mobilenativefoundation.store.store5.impl.operators.mapIndexed -import kotlin.test.Test -import kotlin.test.assertEquals class MapIndexedTests { - private val scope = TestScope() + private val scope = TestScope() - @Test - fun mapIndexed() = - scope.runTest { - flowOf(5, 6).mapIndexed { index, value -> index to value }.test { - assertEquals(0 to 5, awaitItem()) - assertEquals(1 to 6, awaitItem()) - awaitComplete() - } + @Test + fun mapIndexed() = + scope.runTest { + flowOf(5, 6) + .mapIndexed { index, value -> index to value } + .test { + assertEquals(0 to 5, awaitItem()) + assertEquals(1 to 6, awaitItem()) + awaitComplete() } + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruthErrorsTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruthErrorsTests.kt index 79808aaf6..1a8669d11 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruthErrorsTests.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruthErrorsTests.kt @@ -1,6 +1,8 @@ package org.mobilenativefoundation.store.store5 import app.cash.turbine.test +import kotlin.test.Test +import kotlin.test.assertEquals import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.cancelAndJoin @@ -16,438 +18,359 @@ import org.mobilenativefoundation.store.store5.SourceOfTruth.WriteException import org.mobilenativefoundation.store.store5.util.FakeFetcher import org.mobilenativefoundation.store.store5.util.InMemoryPersister import org.mobilenativefoundation.store.store5.util.asSourceOfTruth -import kotlin.test.Test -import kotlin.test.assertEquals @OptIn(ExperimentalCoroutinesApi::class) @FlowPreview class SourceOfTruthErrorsTests { - private val testScope = TestScope() + private val testScope = TestScope() - @Test - fun givenSourceOfTruthWhenWriteFailsThenExceptionShouldBeSendToTheCollector() = - testScope.runTest { - val persister = InMemoryPersister() - val fetcher = - FakeFetcher( - 3 to "a", - 3 to "b", - ) - val pipeline = - StoreBuilder - .from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .scope(testScope) - .build() - persister.preWriteCallback = { _, _ -> - throw TestException("i fail") - } + @Test + fun givenSourceOfTruthWhenWriteFailsThenExceptionShouldBeSendToTheCollector() = + testScope.runTest { + val persister = InMemoryPersister() + val fetcher = FakeFetcher(3 to "a", 3 to "b") + val pipeline = + StoreBuilder.from( + fetcher = fetcher, + sourceOfTruth = persister.asSourceOfTruth(), + ) + .scope(testScope) + .build() + persister.preWriteCallback = { _, _ -> throw TestException("i fail") } - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) + pipeline.stream(StoreReadRequest.fresh(3)).test { + assertEquals(StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), awaitItem()) - assertEquals( - StoreReadResponse.Error.Exception( - error = - WriteException( - key = 3, - value = "a", - cause = TestException("i fail"), - ), - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - } - } + assertEquals( + StoreReadResponse.Error.Exception( + error = WriteException(key = 3, value = "a", cause = TestException("i fail")), + origin = StoreReadResponseOrigin.SourceOfTruth, + ), + awaitItem(), + ) + } + } + + @Test + fun givenSourceOfTruthWhenReadFailsThenExceptionShouldBeSendToTheCollector() = + testScope.runTest { + val persister = InMemoryPersister() + val fetcher = FakeFetcher(3 to "a", 3 to "b") + val pipeline = + StoreBuilder.from( + fetcher = fetcher, + sourceOfTruth = persister.asSourceOfTruth(), + ) + .scope(testScope) + .build() - @Test - fun givenSourceOfTruthWhenReadFailsThenExceptionShouldBeSendToTheCollector() = - testScope.runTest { - val persister = InMemoryPersister() - val fetcher = - FakeFetcher( - 3 to "a", - 3 to "b", - ) - val pipeline = - StoreBuilder - .from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .scope(testScope) - .build() + persister.postReadCallback = { _, value -> throw TestException(value ?: "null") } - persister.postReadCallback = { _, value -> - throw TestException(value ?: "null") - } + pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { + assertEquals( + StoreReadResponse.Error.Exception( + error = ReadException(key = 3, cause = TestException("null")), + origin = StoreReadResponseOrigin.SourceOfTruth, + ), + awaitItem(), + ) - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - StoreReadResponse.Error.Exception( - error = - ReadException( - key = 3, - cause = TestException("null"), - ), - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) + // after disk fails, we should still invoke fetcher + assertEquals( + StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) - // after disk fails, we should still invoke fetcher - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) + // and after fetcher writes the value, it will trigger another read which will also + // fail + assertEquals( + StoreReadResponse.Error.Exception( + error = ReadException(key = 3, cause = TestException("a")), + origin = StoreReadResponseOrigin.SourceOfTruth, + ), + awaitItem(), + ) + } + } - // and after fetcher writes the value, it will trigger another read which will also - // fail - assertEquals( - StoreReadResponse.Error.Exception( - error = - ReadException( - key = 3, - cause = TestException("a"), - ), - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - } + @Test + fun givenSourceOfTruthWhenFirstWriteFailsThenItShouldKeepReadingFromFetcher() = + testScope.runTest { + val persister = InMemoryPersister() + val fetcher = Fetcher.ofFlow { _: Int -> flowOf("a", "b", "c", "d") } + val pipeline = + StoreBuilder.from( + fetcher = fetcher, + sourceOfTruth = persister.asSourceOfTruth(), + ) + .disableCache() + .scope(testScope) + .build() + persister.preWriteCallback = { _, value -> + if (value in listOf("a", "c")) { + throw TestException(value) } + value + } + pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { + assertEquals( + StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + assertEquals( + StoreReadResponse.Error.Exception( + error = WriteException(key = 3, value = "a", cause = TestException("a")), + origin = StoreReadResponseOrigin.SourceOfTruth, + ), + awaitItem(), + ) + + assertEquals( + StoreReadResponse.Data(value = "b", origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) - @Test - fun givenSourceOfTruthWhenFirstWriteFailsThenItShouldKeepReadingFromFetcher() = - testScope.runTest { - val persister = InMemoryPersister() - val fetcher = - Fetcher.ofFlow { _: Int -> - flowOf("a", "b", "c", "d") - } - val pipeline = - StoreBuilder - .from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .scope(testScope) - .build() - persister.preWriteCallback = { _, value -> - if (value in listOf("a", "c")) { - throw TestException(value) - } - value - } - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - assertEquals( - StoreReadResponse.Error.Exception( - error = - WriteException( - key = 3, - value = "a", - cause = TestException("a"), - ), - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) + assertEquals( + StoreReadResponse.Error.Exception( + error = WriteException(key = 3, value = "c", cause = TestException("c")), + origin = StoreReadResponseOrigin.SourceOfTruth, + ), + awaitItem(), + ) - assertEquals( - StoreReadResponse.Data( - value = "b", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) + // disk flow will restart after a failed write (because we stopped it before the + // write attempt starts, so we will get the disk value again). + assertEquals( + StoreReadResponse.Data(value = "b", origin = StoreReadResponseOrigin.SourceOfTruth), + awaitItem(), + ) - assertEquals( - StoreReadResponse.Error.Exception( - error = - WriteException( - key = 3, - value = "c", - cause = TestException("c"), - ), - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) + assertEquals( + StoreReadResponse.Data(value = "d", origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } + } - // disk flow will restart after a failed write (because we stopped it before the - // write attempt starts, so we will get the disk value again). - assertEquals( - StoreReadResponse.Data( - value = "b", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) + // @Test + // fun + // givenSourceOfTruthWithFailingWriteWhenAPassiveReaderArrivesThenItShouldReceiveTheNewWriteError() = testScope.runTest { + // val persister = InMemoryPersister() + // val fetcher = Fetcher.ofFlow { _: Int -> + // flowOf("a", "b", "c", "d") + // } + // val pipeline = StoreBuilder + // .from( + // fetcher = fetcher, + // sourceOfTruth = persister.asSourceOfTruth() + // ) + // .disableCache() + // .scope(testScope) + // .build() + // persister.preWriteCallback = { _, value -> + // if (value in listOf("a", "c")) { + // delay(50) + // throw TestException(value) + // } else { + // delay(10) + // } + // value + // } + // // keep collection hot + // val collector = launch { + // pipeline.stream( + // StoreReadRequest.cached(3, refresh = true) + // ).toList() + // } + // + // // miss writes for a and b and let the write operation for c start such that + // // we'll catch that write error + // delay(70) + // assertEmitsExactly( + // pipeline.stream(StoreReadRequest.cached(3, refresh = true)), + // listOf( + // // we wanted the disk value but write failed so we don't get it + // StoreReadResponse.Error.Exception( + // error = WriteException( + // key = 3, + // value = "c", + // cause = TestException("c") + // ), + // origin = StoreReadResponseOrigin.SourceOfTruth + // ), + // // after the write error, we should get the value on disk + // StoreReadResponse.Data( + // value = "b", + // origin = StoreReadResponseOrigin.SourceOfTruth + // ), + // // now we'll unlock the fetcher after disk is read + // StoreReadResponse.Loading( + // origin = StoreReadResponseOrigin.Fetcher( + // ), + // StoreReadResponse.Data( + // value = "d", + // origin = StoreReadResponseOrigin.Fetcher( + // ) + // ) + // ) + // collector.cancelAndJoin() + // } - assertEquals( - StoreReadResponse.Data( - value = "d", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } + @Test + fun givenSourceOfTruthWithFailingWriteWhenAPassiveReaderArrivesThenItShouldNotGetErrorsHappenedBefore() = + testScope.runTest { + val persister = InMemoryPersister() + val fetcher = + Fetcher.ofFlow { + flow { + emit("a") + emit("b") + emit("c") + // now delay, wait for the new subscriber + delay(100) + emit("d") + } + } + val pipeline = + StoreBuilder.from( + fetcher = fetcher, + sourceOfTruth = persister.asSourceOfTruth(), + ) + .disableCache() + .scope(testScope) + .build() + persister.preWriteCallback = { _, value -> + if (value in listOf("a", "c")) { + throw TestException(value) } + value + } + val collector = launch { + pipeline.stream(StoreReadRequest.cached(3, refresh = true)).toList() // keep collection hot + } -// @Test -// fun givenSourceOfTruthWithFailingWriteWhenAPassiveReaderArrivesThenItShouldReceiveTheNewWriteError() = testScope.runTest { -// val persister = InMemoryPersister() -// val fetcher = Fetcher.ofFlow { _: Int -> -// flowOf("a", "b", "c", "d") -// } -// val pipeline = StoreBuilder -// .from( -// fetcher = fetcher, -// sourceOfTruth = persister.asSourceOfTruth() -// ) -// .disableCache() -// .scope(testScope) -// .build() -// persister.preWriteCallback = { _, value -> -// if (value in listOf("a", "c")) { -// delay(50) -// throw TestException(value) -// } else { -// delay(10) -// } -// value -// } -// // keep collection hot -// val collector = launch { -// pipeline.stream( -// StoreReadRequest.cached(3, refresh = true) -// ).toList() -// } -// -// // miss writes for a and b and let the write operation for c start such that -// // we'll catch that write error -// delay(70) -// assertEmitsExactly( -// pipeline.stream(StoreReadRequest.cached(3, refresh = true)), -// listOf( -// // we wanted the disk value but write failed so we don't get it -// StoreReadResponse.Error.Exception( -// error = WriteException( -// key = 3, -// value = "c", -// cause = TestException("c") -// ), -// origin = StoreReadResponseOrigin.SourceOfTruth -// ), -// // after the write error, we should get the value on disk -// StoreReadResponse.Data( -// value = "b", -// origin = StoreReadResponseOrigin.SourceOfTruth -// ), -// // now we'll unlock the fetcher after disk is read -// StoreReadResponse.Loading( -// origin = StoreReadResponseOrigin.Fetcher( -// ), -// StoreReadResponse.Data( -// value = "d", -// origin = StoreReadResponseOrigin.Fetcher( -// ) -// ) -// ) -// collector.cancelAndJoin() -// } + // miss both failures but arrive before d is fetched + delay(70) - @Test - fun givenSourceOfTruthWithFailingWriteWhenAPassiveReaderArrivesThenItShouldNotGetErrorsHappenedBefore() = - testScope.runTest { - val persister = InMemoryPersister() - val fetcher = - Fetcher.ofFlow { - flow { - emit("a") - emit("b") - emit("c") - // now delay, wait for the new subscriber - delay(100) - emit("d") - } - } - val pipeline = - StoreBuilder - .from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .scope(testScope) - .build() - persister.preWriteCallback = { _, value -> - if (value in listOf("a", "c")) { - throw TestException(value) - } - value - } - val collector = - launch { - pipeline.stream( - StoreReadRequest.cached(3, refresh = true), - ).toList() // keep collection hot - } + pipeline.stream(StoreReadRequest.skipMemory(3, refresh = true)).test { + assertEquals( + StoreReadResponse.Data(value = "b", origin = StoreReadResponseOrigin.SourceOfTruth), + awaitItem(), + ) - // miss both failures but arrive before d is fetched - delay(70) + // don't receive the write exception because technically it started before we + // started reading + assertEquals( + StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) - pipeline.stream(StoreReadRequest.skipMemory(3, refresh = true)).test { - assertEquals( - StoreReadResponse.Data( - value = "b", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) + assertEquals( + StoreReadResponse.Data(value = "d", origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } - // don't receive the write exception because technically it started before we - // started reading - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) + collector.cancelAndJoin() + } - assertEquals( - StoreReadResponse.Data( - value = "d", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } + // @Test + // fun + // givenSourceOfTruthWithFailingWriteWhenAFreshValueReaderArrivesThenItShouldNotGetDiskErrorsFromAPendingWrite() = testScope.runTest { + // val persister = InMemoryPersister() + // val fetcher = Fetcher.ofFlow { + // flowOf("a", "b", "c", "d") + // } + // val pipeline = StoreBuilder + // .from( + // fetcher = fetcher, + // sourceOfTruth = persister.asSourceOfTruth() + // ) + // .disableCache() + // .scope(testScope) + // .build() + // persister.preWriteCallback = { _, value -> + // if (value == "c") { + // // slow down read so that the new reader arrives + // delay(50) + // } + // if (value in listOf("a", "c")) { + // throw TestException(value) + // } + // value + // } + // val collector = launch { + // pipeline.stream( + // StoreReadRequest.cached(3, refresh = true) + // ).toList() // keep collection hot + // } + // // miss both failures but arrive before d is fetched + // delay(20) + // assertEmitsExactly( + // pipeline.stream(StoreReadRequest.fresh(3)), + // listOf( + // StoreReadResponse.Loading( + // origin = StoreReadResponseOrigin.Fetcher( + // ), + // StoreReadResponse.Data( + // value = "d", + // origin = StoreReadResponseOrigin.Fetcher( + // ) + // ) + // ) + // collector.cancelAndJoin() + // } - collector.cancelAndJoin() + @Test + fun givenSourceOfTruthWithReadFailureWhenCachedValueReaderArrivesThenFetcherShouldBeCalledToGetANewValue() { + testScope.runTest { + val persister = InMemoryPersister() + val fetcher = Fetcher.of { _: Int -> "a" } + val pipeline = + StoreBuilder.from( + fetcher = fetcher, + sourceOfTruth = persister.asSourceOfTruth(), + ) + .disableCache() + .scope(testScope) + .build() + persister.postReadCallback = { _, value -> + if (value == null) { + throw TestException("first read") } + value + } + pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { + assertEquals( + StoreReadResponse.Error.Exception( + origin = StoreReadResponseOrigin.SourceOfTruth, + error = ReadException(key = 3, cause = TestException("first read")), + ), + awaitItem(), + ) -// @Test -// fun givenSourceOfTruthWithFailingWriteWhenAFreshValueReaderArrivesThenItShouldNotGetDiskErrorsFromAPendingWrite() = testScope.runTest { -// val persister = InMemoryPersister() -// val fetcher = Fetcher.ofFlow { -// flowOf("a", "b", "c", "d") -// } -// val pipeline = StoreBuilder -// .from( -// fetcher = fetcher, -// sourceOfTruth = persister.asSourceOfTruth() -// ) -// .disableCache() -// .scope(testScope) -// .build() -// persister.preWriteCallback = { _, value -> -// if (value == "c") { -// // slow down read so that the new reader arrives -// delay(50) -// } -// if (value in listOf("a", "c")) { -// throw TestException(value) -// } -// value -// } -// val collector = launch { -// pipeline.stream( -// StoreReadRequest.cached(3, refresh = true) -// ).toList() // keep collection hot -// } -// // miss both failures but arrive before d is fetched -// delay(20) -// assertEmitsExactly( -// pipeline.stream(StoreReadRequest.fresh(3)), -// listOf( -// StoreReadResponse.Loading( -// origin = StoreReadResponseOrigin.Fetcher( -// ), -// StoreReadResponse.Data( -// value = "d", -// origin = StoreReadResponseOrigin.Fetcher( -// ) -// ) -// ) -// collector.cancelAndJoin() -// } - - @Test - fun givenSourceOfTruthWithReadFailureWhenCachedValueReaderArrivesThenFetcherShouldBeCalledToGetANewValue() { - testScope.runTest { - val persister = InMemoryPersister() - val fetcher = Fetcher.of { _: Int -> "a" } - val pipeline = - StoreBuilder - .from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .scope(testScope) - .build() - persister.postReadCallback = { _, value -> - if (value == null) { - throw TestException("first read") - } - value - } - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - StoreReadResponse.Error.Exception( - origin = StoreReadResponseOrigin.SourceOfTruth, - error = - ReadException( - key = 3, - cause = TestException("first read"), - ), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) + assertEquals( + StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) - assertEquals( - StoreReadResponse.Data( - value = "a", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } + assertEquals( + StoreReadResponse.Data(value = "a", origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } } + } - private class TestException(val msg: String) : Exception(msg) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is TestException) return false - return msg == other.msg - } + private class TestException(val msg: String) : Exception(msg) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is TestException) return false + return msg == other.msg + } - override fun hashCode(): Int { - return msg.hashCode() - } + override fun hashCode(): Int { + return msg.hashCode() } + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruthWithBarrierTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruthWithBarrierTests.kt index 656f9b91c..1b5d03f97 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruthWithBarrierTests.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruthWithBarrierTests.kt @@ -16,6 +16,9 @@ package org.mobilenativefoundation.store.store5 import app.cash.turbine.test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview @@ -33,253 +36,194 @@ import org.mobilenativefoundation.store.store5.SourceOfTruth.WriteException import org.mobilenativefoundation.store.store5.impl.PersistentSourceOfTruth import org.mobilenativefoundation.store.store5.impl.SourceOfTruthWithBarrier import org.mobilenativefoundation.store.store5.util.InMemoryPersister -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull @FlowPreview @ExperimentalCoroutinesApi class SourceOfTruthWithBarrierTests { - private val testScope = TestScope() - private val persister = InMemoryPersister() - private val delegate: SourceOfTruth = - PersistentSourceOfTruth( - realReader = { key -> - flow { - emit(persister.read(key)) - } - }, - realWriter = persister::write, - realDelete = persister::deleteByKey, - realDeleteAll = persister::deleteAll, - ) - private val source = - SourceOfTruthWithBarrier( - delegate = delegate, - ) - - @Test - fun simple() = - testScope.runTest { - val collection = mutableListOf>() - - launch { - source.reader(1, CompletableDeferred(Unit)).take(2).collect { - collection.add(it) - } - } - delay(500) - source.write(1, "a") - advanceUntilIdle() - assertEquals( - listOf>( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.SourceOfTruth, - value = null, - ), - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = "a", - ), - ), - collection, - ) - assertEquals(0, source.barrierCount()) - } - - @Test - fun givenASourceOfTruthWhenDeleteIsCalledThenItIsDelegatedToThePersister() = - testScope.runTest { - persister.write(1, "a") - source.delete(1) - assertNull(persister.read(1)) - } + private val testScope = TestScope() + private val persister = InMemoryPersister() + private val delegate: SourceOfTruth = + PersistentSourceOfTruth( + realReader = { key -> flow { emit(persister.read(key)) } }, + realWriter = persister::write, + realDelete = persister::deleteByKey, + realDeleteAll = persister::deleteAll, + ) + private val source = SourceOfTruthWithBarrier(delegate = delegate) + + @Test + fun simple() = + testScope.runTest { + val collection = mutableListOf>() + + launch { source.reader(1, CompletableDeferred(Unit)).take(2).collect { collection.add(it) } } + delay(500) + source.write(1, "a") + advanceUntilIdle() + assertEquals( + listOf>( + StoreReadResponse.Data(origin = StoreReadResponseOrigin.SourceOfTruth, value = null), + StoreReadResponse.Data(origin = StoreReadResponseOrigin.Fetcher(), value = "a"), + ), + collection, + ) + assertEquals(0, source.barrierCount()) + } - @Test - fun givenASourceOfTruthWhenDeleteAllIsCalledThenItIsDelegatedToThePersister() = - testScope.runTest { - persister.write(1, "a") - persister.write(2, "b") - source.deleteAll() - assertNull(persister.read(1)) - assertNull(persister.read(2)) - } + @Test + fun givenASourceOfTruthWhenDeleteIsCalledThenItIsDelegatedToThePersister() = + testScope.runTest { + persister.write(1, "a") + source.delete(1) + assertNull(persister.read(1)) + } - @Test - fun preAndPostWrites() = - testScope.runTest { - val collection = mutableListOf>() - source.write(1, "a") + @Test + fun givenASourceOfTruthWhenDeleteAllIsCalledThenItIsDelegatedToThePersister() = + testScope.runTest { + persister.write(1, "a") + persister.write(2, "b") + source.deleteAll() + assertNull(persister.read(1)) + assertNull(persister.read(2)) + } - launch { - source.reader(1, CompletableDeferred(Unit)).take(2).collect { - collection.add(it) - } - } + @Test + fun preAndPostWrites() = + testScope.runTest { + val collection = mutableListOf>() + source.write(1, "a") - delay(200) + launch { source.reader(1, CompletableDeferred(Unit)).take(2).collect { collection.add(it) } } - source.write(1, "b") + delay(200) - advanceUntilIdle() + source.write(1, "b") - assertEquals( - listOf>( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.SourceOfTruth, - value = "a", - ), - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = "b", - ), - ), - collection, - ) + advanceUntilIdle() - assertEquals(0, source.barrierCount()) - } + assertEquals( + listOf>( + StoreReadResponse.Data(origin = StoreReadResponseOrigin.SourceOfTruth, value = "a"), + StoreReadResponse.Data(origin = StoreReadResponseOrigin.Fetcher(), value = "b"), + ), + collection, + ) - @Test - fun givenSourceOfTruthWhenReadFailsThenErrorShouldPropagate() = - testScope.runTest { - val exception = RuntimeException("read fails") - persister.postReadCallback = { key, value -> - throw exception - } + assertEquals(0, source.barrierCount()) + } - source.reader(1, CompletableDeferred(Unit)).test { - assertEquals( - StoreReadResponse.Error.Exception( - origin = StoreReadResponseOrigin.SourceOfTruth, - error = - ReadException( - key = 1, - cause = exception, - ), - ), - awaitItem(), - ) - } - } + @Test + fun givenSourceOfTruthWhenReadFailsThenErrorShouldPropagate() = + testScope.runTest { + val exception = RuntimeException("read fails") + persister.postReadCallback = { key, value -> throw exception } + + source.reader(1, CompletableDeferred(Unit)).test { + assertEquals( + StoreReadResponse.Error.Exception( + origin = StoreReadResponseOrigin.SourceOfTruth, + error = ReadException(key = 1, cause = exception), + ), + awaitItem(), + ) + } + } - @Test - fun givenSourceOfTruthWhenReadFailsButThenSucceedsThenErrorShouldPropagateButAlsoTheValue() = - testScope.runTest { - var hasThrown = false - val exception = RuntimeException("read fails") - persister.postReadCallback = { _, value -> - if (!hasThrown) { - hasThrown = true - throw exception - } - value - } - val reader = source.reader(1, CompletableDeferred(Unit)) - val collected = mutableListOf>() - val collection = - async { - reader.collect { - collected.add(it) - } - } - advanceUntilIdle() - assertEquals( - StoreReadResponse.Error.Exception( - origin = StoreReadResponseOrigin.SourceOfTruth, - error = - ReadException( - key = 1, - cause = exception, - ), - ), - collected.first(), - ) - // make sure it is not cancelled for the read error - assertEquals(true, collection.isActive) - // now insert another, it should trigger another read and emitted to the reader - source.write(1, "a") - advanceUntilIdle() - assertEquals( - listOf>( - StoreReadResponse.Error.Exception( - origin = StoreReadResponseOrigin.SourceOfTruth, - error = - ReadException( - key = 1, - cause = exception, - ), - ), - StoreReadResponse.Data( - // this is fetcher since we are using the write API - origin = StoreReadResponseOrigin.Fetcher(), - value = "a", - ), - ), - collected, - ) - collection.cancelAndJoin() + @Test + fun givenSourceOfTruthWhenReadFailsButThenSucceedsThenErrorShouldPropagateButAlsoTheValue() = + testScope.runTest { + var hasThrown = false + val exception = RuntimeException("read fails") + persister.postReadCallback = { _, value -> + if (!hasThrown) { + hasThrown = true + throw exception } + value + } + val reader = source.reader(1, CompletableDeferred(Unit)) + val collected = mutableListOf>() + val collection = async { reader.collect { collected.add(it) } } + advanceUntilIdle() + assertEquals( + StoreReadResponse.Error.Exception( + origin = StoreReadResponseOrigin.SourceOfTruth, + error = ReadException(key = 1, cause = exception), + ), + collected.first(), + ) + // make sure it is not cancelled for the read error + assertEquals(true, collection.isActive) + // now insert another, it should trigger another read and emitted to the reader + source.write(1, "a") + advanceUntilIdle() + assertEquals( + listOf>( + StoreReadResponse.Error.Exception( + origin = StoreReadResponseOrigin.SourceOfTruth, + error = ReadException(key = 1, cause = exception), + ), + StoreReadResponse.Data( + // this is fetcher since we are using the write API + origin = StoreReadResponseOrigin.Fetcher(), + value = "a", + ), + ), + collected, + ) + collection.cancelAndJoin() + } - @Test - fun givenSourceOfTruthWhenWriteFailsThenErrorShouldPropagate() { - val failValue = "will fail" - testScope.runTest { - val exception = RuntimeException("write fails") - persister.preWriteCallback = { key, value -> - if (value == failValue) { - throw exception - } - value - } - val reader = source.reader(1, CompletableDeferred(Unit)) - val collected = mutableListOf>() - val collection = - async { - reader.collect { - collected.add(it) - } - } - advanceUntilIdle() - source.write(1, failValue) - advanceUntilIdle() - // make sure collection does not cancel for a write error - assertEquals(true, collection.isActive) - val eventsUntilFailure = - listOf( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.SourceOfTruth, - value = null, - ), - StoreReadResponse.Error.Exception( - origin = StoreReadResponseOrigin.SourceOfTruth, - error = - WriteException( - key = 1, - value = failValue, - cause = exception, - ), - ), - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.SourceOfTruth, - value = null, - ), - ) - assertEquals(eventsUntilFailure, collected) - advanceUntilIdle() - assertEquals(true, collection.isActive) - // send another write that will succeed - source.write(1, "succeed") - advanceUntilIdle() - assertEquals( - eventsUntilFailure + - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = "succeed", - ), - collected, - ) - collection.cancelAndJoin() + @Test + fun givenSourceOfTruthWhenWriteFailsThenErrorShouldPropagate() { + val failValue = "will fail" + testScope.runTest { + val exception = RuntimeException("write fails") + persister.preWriteCallback = { key, value -> + if (value == failValue) { + throw exception } + value + } + val reader = source.reader(1, CompletableDeferred(Unit)) + val collected = mutableListOf>() + val collection = async { reader.collect { collected.add(it) } } + advanceUntilIdle() + source.write(1, failValue) + advanceUntilIdle() + // make sure collection does not cancel for a write error + assertEquals(true, collection.isActive) + val eventsUntilFailure = + listOf( + StoreReadResponse.Data( + origin = StoreReadResponseOrigin.SourceOfTruth, + value = null, + ), + StoreReadResponse.Error.Exception( + origin = StoreReadResponseOrigin.SourceOfTruth, + error = WriteException(key = 1, value = failValue, cause = exception), + ), + StoreReadResponse.Data( + origin = StoreReadResponseOrigin.SourceOfTruth, + value = null, + ), + ) + assertEquals(eventsUntilFailure, collected) + advanceUntilIdle() + assertEquals(true, collection.isActive) + // send another write that will succeed + source.write(1, "succeed") + advanceUntilIdle() + assertEquals( + eventsUntilFailure + + StoreReadResponse.Data( + origin = StoreReadResponseOrigin.Fetcher(), + value = "succeed", + ), + collected, + ) + collection.cancelAndJoin() } + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponseTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponseTests.kt index f1d2c59ff..6aa255ba9 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponseTests.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponseTests.kt @@ -6,47 +6,54 @@ import kotlin.test.assertFailsWith import kotlin.test.assertNull class StoreReadResponseTests { - @Test - fun requireData() { - assertEquals("Foo", StoreReadResponse.Data("Foo", StoreReadResponseOrigin.Fetcher()).requireData()) - - // should throw - assertFailsWith { - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()).requireData() - } + @Test + fun requireData() { + assertEquals( + "Foo", + StoreReadResponse.Data("Foo", StoreReadResponseOrigin.Fetcher()).requireData(), + ) + + // should throw + assertFailsWith { + StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()).requireData() } + } - @Test - fun throwIfErrorException() { - assertFailsWith { - StoreReadResponse.Error.Exception(Exception(), StoreReadResponseOrigin.Fetcher()).throwIfError() - } + @Test + fun throwIfErrorException() { + assertFailsWith { + StoreReadResponse.Error.Exception(Exception(), StoreReadResponseOrigin.Fetcher()) + .throwIfError() } + } - @Test - fun throwIfErrorMessage() { - assertFailsWith { - StoreReadResponse.Error.Message("test error", StoreReadResponseOrigin.Fetcher()).throwIfError() - } + @Test + fun throwIfErrorMessage() { + assertFailsWith { + StoreReadResponse.Error.Message("test error", StoreReadResponseOrigin.Fetcher()) + .throwIfError() } + } - @Test() - fun errorMessageOrNull() { - assertFailsWith(message = Exception::class.toString()) { - StoreReadResponse.Error.Exception(Exception(), StoreReadResponseOrigin.Fetcher()).throwIfError() - } - - assertFailsWith(message = "test error message") { - StoreReadResponse.Error.Message("test error message", StoreReadResponseOrigin.Fetcher()).throwIfError() - } + @Test() + fun errorMessageOrNull() { + assertFailsWith(message = Exception::class.toString()) { + StoreReadResponse.Error.Exception(Exception(), StoreReadResponseOrigin.Fetcher()) + .throwIfError() + } - assertNull(StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()).errorMessageOrNull()) + assertFailsWith(message = "test error message") { + StoreReadResponse.Error.Message("test error message", StoreReadResponseOrigin.Fetcher()) + .throwIfError() } - @Test - fun swapType() { - assertFailsWith { - StoreReadResponse.Data("Foo", StoreReadResponseOrigin.Fetcher()).swapType() - } + assertNull(StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()).errorMessageOrNull()) + } + + @Test + fun swapType() { + assertFailsWith { + StoreReadResponse.Data("Foo", StoreReadResponseOrigin.Fetcher()).swapType() } + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StoreWithInMemoryCacheTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StoreWithInMemoryCacheTests.kt index 119ccd3a0..3fe7faccc 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StoreWithInMemoryCacheTests.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StoreWithInMemoryCacheTests.kt @@ -1,5 +1,8 @@ package org.mobilenativefoundation.store.store5 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.hours import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -11,126 +14,117 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.mobilenativefoundation.store.core5.ExperimentalStoreApi import org.mobilenativefoundation.store.store5.impl.extensions.get -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.time.Duration.Companion.hours @OptIn(ExperimentalStoreApi::class) @FlowPreview @ExperimentalCoroutinesApi class StoreWithInMemoryCacheTests { - private val testScope = TestScope() + private val testScope = TestScope() - @Test - fun storeRequestsCanCompleteWhenInMemoryCacheWithAccessExpiryIsAtTheMaximumSize() = - testScope.runTest { - val store = - StoreBuilder - .from(Fetcher.of { _: Int -> "result" }) - .cachePolicy( - MemoryPolicy - .builder() - .setExpireAfterAccess(1.hours) - .setMaxSize(1) - .build(), - ) - .build() + @Test + fun storeRequestsCanCompleteWhenInMemoryCacheWithAccessExpiryIsAtTheMaximumSize() = + testScope.runTest { + val store = + StoreBuilder.from(Fetcher.of { _: Int -> "result" }) + .cachePolicy( + MemoryPolicy.builder().setExpireAfterAccess(1.hours).setMaxSize(1).build() + ) + .build() - val a = store.get(0) - val b = store.get(0) - val c = store.get(1) - val d = store.get(2) + val a = store.get(0) + val b = store.get(0) + val c = store.get(1) + val d = store.get(2) - assertEquals("result", a) - assertEquals("result", b) - assertEquals("result", c) - assertEquals("result", d) - } + assertEquals("result", a) + assertEquals("result", b) + assertEquals("result", c) + assertEquals("result", d) + } - @Test - fun storeDeadlock() = - runTest { - repeat(100) { - val store: MutableStore = - StoreBuilder - .from( - fetcher = Fetcher.of { key: Int -> "fetcher_$key" }, - sourceOfTruth = - SourceOfTruth.of( - reader = { key: Int -> - flowOf("source_of_truth_$key") - }, - writer = { key: Int, local: String -> }, - ), - ) - .disableCache() - .toMutableStoreBuilder( - converter = - object : Converter { - override fun fromNetworkToLocal(network: String): String = network + @Test + fun storeDeadlock() = runTest { + repeat(100) { + val store: MutableStore = + StoreBuilder.from( + fetcher = Fetcher.of { key: Int -> "fetcher_$key" }, + sourceOfTruth = + SourceOfTruth.of( + reader = { key: Int -> flowOf("source_of_truth_$key") }, + writer = { key: Int, local: String -> }, + ), + ) + .disableCache() + .toMutableStoreBuilder( + converter = + object : Converter { + override fun fromNetworkToLocal(network: String): String = network - override fun fromOutputToLocal(output: String): String = output - }, - ) - .build( - updater = - object : Updater { - var callCount = -1 + override fun fromOutputToLocal(output: String): String = output + } + ) + .build( + updater = + object : Updater { + var callCount = -1 - override suspend fun post( - key: Int, - value: String, - ): UpdaterResult { - callCount += 1 - return if (callCount % 2 == 0) { - throw IllegalArgumentException("$key value: $value") - } else { - UpdaterResult.Success.Untyped("") - } - } + override suspend fun post(key: Int, value: String): UpdaterResult { + callCount += 1 + return if (callCount % 2 == 0) { + throw IllegalArgumentException("$key value: $value") + } else { + UpdaterResult.Success.Untyped("") + } + } - override val onCompletion: OnUpdaterCompletion? = null - }, - ) + override val onCompletion: OnUpdaterCompletion? = null + } + ) - val jobs = mutableListOf() - jobs.add( - store.stream(StoreReadRequest.cached(1, refresh = true)) - .mapNotNull { it.dataOrNull() } - .launchIn(this), - ) - val job1 = - store.stream(StoreReadRequest.cached(0, refresh = true)) - .mapNotNull { it.dataOrNull() } - .launchIn(this) - jobs.add( - store.stream(StoreReadRequest.cached(2, refresh = true)) - .mapNotNull { it.dataOrNull() } - .launchIn(this), - ) - jobs.add( - store.stream(StoreReadRequest.cached(3, refresh = true)) - .mapNotNull { it.dataOrNull() } - .launchIn(this), - ) - job1.cancel() - assertEquals( - expected = "source_of_truth_0", - actual = - store.stream(StoreReadRequest.cached(0, refresh = true)) - .mapNotNull { it.dataOrNull() } - .first(), - ) - jobs.forEach { - it.cancel() - assertEquals( - expected = "source_of_truth_0", - actual = - store.stream(StoreReadRequest.cached(0, refresh = true)) - .mapNotNull { it.dataOrNull() } - .first(), - ) - } - } - } + val jobs = mutableListOf() + jobs.add( + store + .stream(StoreReadRequest.cached(1, refresh = true)) + .mapNotNull { it.dataOrNull() } + .launchIn(this) + ) + val job1 = + store + .stream(StoreReadRequest.cached(0, refresh = true)) + .mapNotNull { it.dataOrNull() } + .launchIn(this) + jobs.add( + store + .stream(StoreReadRequest.cached(2, refresh = true)) + .mapNotNull { it.dataOrNull() } + .launchIn(this) + ) + jobs.add( + store + .stream(StoreReadRequest.cached(3, refresh = true)) + .mapNotNull { it.dataOrNull() } + .launchIn(this) + ) + job1.cancel() + assertEquals( + expected = "source_of_truth_0", + actual = + store + .stream(StoreReadRequest.cached(0, refresh = true)) + .mapNotNull { it.dataOrNull() } + .first(), + ) + jobs.forEach { + it.cancel() + assertEquals( + expected = "source_of_truth_0", + actual = + store + .stream(StoreReadRequest.cached(0, refresh = true)) + .mapNotNull { it.dataOrNull() } + .first(), + ) + } + } + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StreamWithoutSourceOfTruthTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StreamWithoutSourceOfTruthTests.kt index a9a81c1b3..ede080378 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StreamWithoutSourceOfTruthTests.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StreamWithoutSourceOfTruthTests.kt @@ -1,6 +1,8 @@ package org.mobilenativefoundation.store.store5 import app.cash.turbine.test +import kotlin.test.Test +import kotlin.test.assertEquals import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.async @@ -10,120 +12,71 @@ import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.mobilenativefoundation.store.store5.util.FakeFetcher -import kotlin.test.Test -import kotlin.test.assertEquals @FlowPreview @ExperimentalCoroutinesApi class StreamWithoutSourceOfTruthTests { - private val testScope = TestScope() + private val testScope = TestScope() - @Test - fun streamWithoutPersisterAndCacheEnabled() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val pipeline = - StoreBuilder.from(fetcher) - .scope(testScope) - .build() - val twoItemsNoRefresh = - async { - pipeline.stream( - StoreReadRequest.cached(3, refresh = false), - ).take(3).toList() - } - delay(1_000) // make sure the async block starts first - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) + @Test + fun streamWithoutPersisterAndCacheEnabled() = + testScope.runTest { + val fetcher = FakeFetcher(3 to "three-1", 3 to "three-2") + val pipeline = StoreBuilder.from(fetcher).scope(testScope).build() + val twoItemsNoRefresh = async { + pipeline.stream(StoreReadRequest.cached(3, refresh = false)).take(3).toList() + } + delay(1_000) // make sure the async block starts first + pipeline.stream(StoreReadRequest.fresh(3)).test { + assertEquals( + StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) - assertEquals( - StoreReadResponse.Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } + assertEquals( + StoreReadResponse.Data(value = "three-2", origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } - assertEquals( - listOf( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - StoreReadResponse.Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - StoreReadResponse.Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - ), - twoItemsNoRefresh.await(), - ) - } + assertEquals( + listOf( + StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), + StoreReadResponse.Data(value = "three-1", origin = StoreReadResponseOrigin.Fetcher()), + StoreReadResponse.Data(value = "three-2", origin = StoreReadResponseOrigin.Fetcher()), + ), + twoItemsNoRefresh.await(), + ) + } - @Test - fun streamWithoutPersisterAndCacheDisabled() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val pipeline = - StoreBuilder.from(fetcher) - .scope(testScope) - .disableCache() - .build() - val twoItemsNoRefresh = - async { - pipeline.stream( - StoreReadRequest.cached(3, refresh = false), - ).take(3).toList() - } - delay(1_000) // make sure the async block starts first - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) + @Test + fun streamWithoutPersisterAndCacheDisabled() = + testScope.runTest { + val fetcher = FakeFetcher(3 to "three-1", 3 to "three-2") + val pipeline = StoreBuilder.from(fetcher).scope(testScope).disableCache().build() + val twoItemsNoRefresh = async { + pipeline.stream(StoreReadRequest.cached(3, refresh = false)).take(3).toList() + } + delay(1_000) // make sure the async block starts first + pipeline.stream(StoreReadRequest.fresh(3)).test { + assertEquals( + StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) - assertEquals( - StoreReadResponse.Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } + assertEquals( + StoreReadResponse.Data(value = "three-2", origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) + } - assertEquals( - listOf( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - StoreReadResponse.Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - StoreReadResponse.Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - ), - twoItemsNoRefresh.await(), - ) - } + assertEquals( + listOf( + StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), + StoreReadResponse.Data(value = "three-1", origin = StoreReadResponseOrigin.Fetcher()), + StoreReadResponse.Data(value = "three-2", origin = StoreReadResponseOrigin.Fetcher()), + ), + twoItemsNoRefresh.await(), + ) + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/UpdaterTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/UpdaterTests.kt index d7898fee9..cac7d7d30 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/UpdaterTests.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/UpdaterTests.kt @@ -1,6 +1,11 @@ package org.mobilenativefoundation.store.store5 import app.cash.turbine.test +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow @@ -23,270 +28,241 @@ import org.mobilenativefoundation.store.store5.util.model.NetworkNote import org.mobilenativefoundation.store.store5.util.model.NoteData import org.mobilenativefoundation.store.store5.util.model.NotesWriteResponse import org.mobilenativefoundation.store.store5.util.model.OutputNote -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertNotNull @OptIn(ExperimentalCoroutinesApi::class, ExperimentalStoreApi::class) class UpdaterTests { - private val testScope = TestScope() - private lateinit var api: NotesApi - private lateinit var bookkeeping: NotesBookkeeping - private lateinit var notes: NotesDatabase + private val testScope = TestScope() + private lateinit var api: NotesApi + private lateinit var bookkeeping: NotesBookkeeping + private lateinit var notes: NotesDatabase - @BeforeTest - fun before() { - api = NotesApi() - bookkeeping = NotesBookkeeping() - notes = NotesDatabase() - } + @BeforeTest + fun before() { + api = NotesApi() + bookkeeping = NotesBookkeeping() + notes = NotesDatabase() + } - @Test - fun givenNonEmptyMarketWhenWriteThenStoredAndAPIUpdated() = - testScope.runTest { - val ttl = inHours(1) + @Test + fun givenNonEmptyMarketWhenWriteThenStoredAndAPIUpdated() = + testScope.runTest { + val ttl = inHours(1) - val converter = NotesConverterProvider().provide() - val validator = NotesValidator() - val updater = NotesUpdaterProvider(api).provide() - val bookkeeper = - Bookkeeper.by( - getLastFailedSync = bookkeeping::getLastFailedSync, - setLastFailedSync = bookkeeping::setLastFailedSync, - clear = bookkeeping::clear, - clearAll = bookkeeping::clear, - ) + val converter = NotesConverterProvider().provide() + val validator = NotesValidator() + val updater = NotesUpdaterProvider(api).provide() + val bookkeeper = + Bookkeeper.by( + getLastFailedSync = bookkeeping::getLastFailedSync, + setLastFailedSync = bookkeeping::setLastFailedSync, + clear = bookkeeping::clear, + clearAll = bookkeeping::clear, + ) - val store = - MutableStoreBuilder.from( - fetcher = Fetcher.of { key -> api.get(key, ttl = ttl) }, - sourceOfTruth = - SourceOfTruth.of( - nonFlowReader = { key -> notes.get(key) }, - writer = { key, sot: InputNote -> notes.put(key, sot) }, - delete = { key -> notes.clear(key) }, - deleteAll = { notes.clear() }, - ), - converter = converter, - ) - .validator(validator) - .build( - updater = updater, - bookkeeper = bookkeeper, - ) + val store = + MutableStoreBuilder.from( + fetcher = Fetcher.of { key -> api.get(key, ttl = ttl) }, + sourceOfTruth = + SourceOfTruth.of( + nonFlowReader = { key -> notes.get(key) }, + writer = { key, sot: InputNote -> notes.put(key, sot) }, + delete = { key -> notes.clear(key) }, + deleteAll = { notes.clear() }, + ), + converter = converter, + ) + .validator(validator) + .build(updater = updater, bookkeeper = bookkeeper) - val readRequest = StoreReadRequest.fresh(NotesKey.Single(Notes.One.id)) + val readRequest = StoreReadRequest.fresh(NotesKey.Single(Notes.One.id)) - val stream = store.stream(readRequest) + val stream = store.stream(readRequest) - // Read is success - stream.test { - assertEquals( - StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) + // Read is success + stream.test { + assertEquals( + StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) - assertEquals( - StoreReadResponse.Data( - OutputNote(NoteData.Single(Notes.One), ttl = ttl), - StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } + assertEquals( + StoreReadResponse.Data( + OutputNote(NoteData.Single(Notes.One), ttl = ttl), + StoreReadResponseOrigin.Fetcher(), + ), + awaitItem(), + ) + } - val newNote = Notes.One.copy(title = "New Title-1") - val writeRequest = - StoreWriteRequest.of( - key = NotesKey.Single(Notes.One.id), - value = OutputNote(NoteData.Single(newNote), 0), - ) + val newNote = Notes.One.copy(title = "New Title-1") + val writeRequest = + StoreWriteRequest.of( + key = NotesKey.Single(Notes.One.id), + value = OutputNote(NoteData.Single(newNote), 0), + ) - val storeWriteResponse = store.write(writeRequest) + val storeWriteResponse = store.write(writeRequest) - // Write is success - assertEquals( - StoreWriteResponse.Success.Typed( - NotesWriteResponse( - NotesKey.Single(Notes.One.id), - true, - ), - ), - storeWriteResponse, - ) + // Write is success + assertEquals( + StoreWriteResponse.Success.Typed(NotesWriteResponse(NotesKey.Single(Notes.One.id), true)), + storeWriteResponse, + ) - val cachedReadRequest = - StoreReadRequest.cached(NotesKey.Single(Notes.One.id), refresh = false) - val cachedStream = store.stream(cachedReadRequest) + val cachedReadRequest = + StoreReadRequest.cached(NotesKey.Single(Notes.One.id), refresh = false) + val cachedStream = store.stream(cachedReadRequest) - // Cache + SOT are updated - val firstResponse: StoreReadResponse = cachedStream.first() -// assertEquals( -// StoreReadResponse.Data( -// OutputNote(NoteData.Single(newNote), ttl = 0), -// StoreReadResponseOrigin.Cache -// ), - firstResponse -// ) + // Cache + SOT are updated + val firstResponse: StoreReadResponse = cachedStream.first() + // assertEquals( + // StoreReadResponse.Data( + // OutputNote(NoteData.Single(newNote), ttl = 0), + // StoreReadResponseOrigin.Cache + // ), + firstResponse + // ) - val secondResponse = cachedStream.take(2).last() - assertIs>(secondResponse) - val data: NoteData? = secondResponse.value.data - assertIs(data) - assertNotNull(data) - assertEquals(newNote, data.item) - assertEquals(StoreReadResponseOrigin.SourceOfTruth, secondResponse.origin) - assertNotNull(secondResponse.value.ttl) + val secondResponse = cachedStream.take(2).last() + assertIs>(secondResponse) + val data: NoteData? = secondResponse.value.data + assertIs(data) + assertNotNull(data) + assertEquals(newNote, data.item) + assertEquals(StoreReadResponseOrigin.SourceOfTruth, secondResponse.origin) + assertNotNull(secondResponse.value.ttl) - // API is updated - assertEquals( - StoreWriteResponse.Success.Typed( - NotesWriteResponse( - NotesKey.Single(Notes.One.id), - true, - ), - ), - storeWriteResponse, - ) - assertEquals( - NetworkNote(NoteData.Single(newNote), ttl = null), - api.db[NotesKey.Single(Notes.One.id)], - ) - } + // API is updated + assertEquals( + StoreWriteResponse.Success.Typed(NotesWriteResponse(NotesKey.Single(Notes.One.id), true)), + storeWriteResponse, + ) + assertEquals( + NetworkNote(NoteData.Single(newNote), ttl = null), + api.db[NotesKey.Single(Notes.One.id)], + ) + } - @Test - fun givenNonEmptyMarketWithValidatorWhenInvalidThenSuccessOriginatingFromFetcher() = - testScope.runTest { - val ttl = inHours(1) + @Test + fun givenNonEmptyMarketWithValidatorWhenInvalidThenSuccessOriginatingFromFetcher() = + testScope.runTest { + val ttl = inHours(1) - val converter = NotesConverterProvider().provide() - val validator = NotesValidator(expiration = inHours(12)) - val updater = NotesUpdaterProvider(api).provide() - val bookkeeper = - Bookkeeper.by( - getLastFailedSync = bookkeeping::getLastFailedSync, - setLastFailedSync = bookkeeping::setLastFailedSync, - clear = bookkeeping::clear, - clearAll = bookkeeping::clear, - ) + val converter = NotesConverterProvider().provide() + val validator = NotesValidator(expiration = inHours(12)) + val updater = NotesUpdaterProvider(api).provide() + val bookkeeper = + Bookkeeper.by( + getLastFailedSync = bookkeeping::getLastFailedSync, + setLastFailedSync = bookkeeping::setLastFailedSync, + clear = bookkeeping::clear, + clearAll = bookkeeping::clear, + ) - val store = - MutableStoreBuilder.from( - fetcher = Fetcher.of { key -> api.get(key, ttl = ttl) }, - sourceOfTruth = - SourceOfTruth.of( - nonFlowReader = { key -> notes.get(key) }, - writer = { key, sot: InputNote -> notes.put(key, sot) }, - delete = { key -> notes.clear(key) }, - deleteAll = { notes.clear() }, - ), - converter = converter, - ) - .validator(validator) - .build( - updater = updater, - bookkeeper = bookkeeper, - ) + val store = + MutableStoreBuilder.from( + fetcher = Fetcher.of { key -> api.get(key, ttl = ttl) }, + sourceOfTruth = + SourceOfTruth.of( + nonFlowReader = { key -> notes.get(key) }, + writer = { key, sot: InputNote -> notes.put(key, sot) }, + delete = { key -> notes.clear(key) }, + deleteAll = { notes.clear() }, + ), + converter = converter, + ) + .validator(validator) + .build(updater = updater, bookkeeper = bookkeeper) - val readRequest = StoreReadRequest.fresh(NotesKey.Single(Notes.One.id)) + val readRequest = StoreReadRequest.fresh(NotesKey.Single(Notes.One.id)) - val stream = store.stream(readRequest) + val stream = store.stream(readRequest) - // Fetch is success and validator is not used - stream.test { - assertEquals( - StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) + // Fetch is success and validator is not used + stream.test { + assertEquals( + StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), + awaitItem(), + ) - assertEquals( - StoreReadResponse.Data( - OutputNote(NoteData.Single(Notes.One), ttl = ttl), - StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } + assertEquals( + StoreReadResponse.Data( + OutputNote(NoteData.Single(Notes.One), ttl = ttl), + StoreReadResponseOrigin.Fetcher(), + ), + awaitItem(), + ) + } - val cachedReadRequest = - StoreReadRequest.cached(NotesKey.Single(Notes.One.id), refresh = false) - val cachedStream = store.stream(cachedReadRequest) + val cachedReadRequest = + StoreReadRequest.cached(NotesKey.Single(Notes.One.id), refresh = false) + val cachedStream = store.stream(cachedReadRequest) - // Cache + SOT are updated - // But item is invalid - // So we do not emit value in cache or SOT - // Instead we get latest from network even though refresh = false + // Cache + SOT are updated + // But item is invalid + // So we do not emit value in cache or SOT + // Instead we get latest from network even though refresh = false - cachedStream.test { - assertEquals( - StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher(name = null)), - awaitItem(), - ) - assertEquals( - StoreReadResponse.Data( - OutputNote(NoteData.Single(Notes.One), ttl = ttl), - StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } + cachedStream.test { + assertEquals( + StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher(name = null)), + awaitItem(), + ) + assertEquals( + StoreReadResponse.Data( + OutputNote(NoteData.Single(Notes.One), ttl = ttl), + StoreReadResponseOrigin.Fetcher(), + ), + awaitItem(), + ) + } + } - @Test - fun givenEmptyMarketWhenWriteThenSuccessResponsesAndApiUpdated() = - testScope.runTest { - val converter = NotesConverterProvider().provide() - val validator = NotesValidator() - val updater = NotesUpdaterProvider(api).provide() - val bookkeeper = - Bookkeeper.by( - getLastFailedSync = bookkeeping::getLastFailedSync, - setLastFailedSync = bookkeeping::setLastFailedSync, - clear = bookkeeping::clear, - clearAll = bookkeeping::clear, - ) + @Test + fun givenEmptyMarketWhenWriteThenSuccessResponsesAndApiUpdated() = + testScope.runTest { + val converter = NotesConverterProvider().provide() + val validator = NotesValidator() + val updater = NotesUpdaterProvider(api).provide() + val bookkeeper = + Bookkeeper.by( + getLastFailedSync = bookkeeping::getLastFailedSync, + setLastFailedSync = bookkeeping::setLastFailedSync, + clear = bookkeeping::clear, + clearAll = bookkeeping::clear, + ) - val store = - MutableStoreBuilder.from( - fetcher = - Fetcher.ofFlow { key -> - val network = api.get(key) - flow { emit(network) } - }, - sourceOfTruth = - SourceOfTruth.of( - nonFlowReader = { key -> notes.get(key) }, - writer = { key, sot -> notes.put(key, sot) }, - delete = { key -> notes.clear(key) }, - deleteAll = { notes.clear() }, - ), - converter, - ) - .validator(validator) - .build( - updater = updater, - bookkeeper = bookkeeper, - ) + val store = + MutableStoreBuilder.from( + fetcher = + Fetcher.ofFlow { key -> + val network = api.get(key) + flow { emit(network) } + }, + sourceOfTruth = + SourceOfTruth.of( + nonFlowReader = { key -> notes.get(key) }, + writer = { key, sot -> notes.put(key, sot) }, + delete = { key -> notes.clear(key) }, + deleteAll = { notes.clear() }, + ), + converter, + ) + .validator(validator) + .build(updater = updater, bookkeeper = bookkeeper) - val newNote = Notes.One.copy(title = "New Title-1") - val writeRequest = - StoreWriteRequest.of( - key = NotesKey.Single(Notes.One.id), - value = OutputNote(NoteData.Single(newNote), 0), - ) - val storeWriteResponse = store.write(writeRequest) + val newNote = Notes.One.copy(title = "New Title-1") + val writeRequest = + StoreWriteRequest.of( + key = NotesKey.Single(Notes.One.id), + value = OutputNote(NoteData.Single(newNote), 0), + ) + val storeWriteResponse = store.write(writeRequest) - assertEquals( - StoreWriteResponse.Success.Typed( - NotesWriteResponse( - NotesKey.Single(Notes.One.id), - true, - ), - ), - storeWriteResponse, - ) - assertEquals(NetworkNote(NoteData.Single(newNote)), api.db[NotesKey.Single(Notes.One.id)]) - } + assertEquals( + StoreWriteResponse.Success.Typed(NotesWriteResponse(NotesKey.Single(Notes.One.id), true)), + storeWriteResponse, + ) + assertEquals(NetworkNote(NoteData.Single(newNote)), api.db[NotesKey.Single(Notes.One.id)]) + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ValueFetcherTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ValueFetcherTests.kt index 26af2fb1f..512fef70a 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ValueFetcherTests.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ValueFetcherTests.kt @@ -1,69 +1,61 @@ package org.mobilenativefoundation.store.store5 import app.cash.turbine.test +import kotlin.test.Test +import kotlin.test.assertEquals import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals @ExperimentalCoroutinesApi @FlowPreview class ValueFetcherTests { - private val testScope = TestScope() - - @Test - fun givenValueFetcherWhenInvokeThenResultIsWrapped() = - testScope.runTest { - val fetcher = Fetcher.ofFlow { flowOf(it * it) } - - fetcher(3).test { - assertEquals(FetcherResult.Data(value = 9), awaitItem()) - awaitComplete() - } - } - - @Test - fun givenValueFetcherWhenExceptionInFlowThenExceptionReturnedAsResult() = - testScope.runTest { - val e = Exception() - val fetcher = - Fetcher.ofFlow { - flow { - throw e - } - } - fetcher(3).test { - assertEquals(FetcherResult.Error.Exception(e), awaitItem()) - awaitComplete() - } - } - - @Test - fun givenNonFlowValueFetcherWhenInvokeThenResultIsWrapped() = - testScope.runTest { - val fetcher = Fetcher.of { it * it } - - fetcher(3).test { - assertEquals(FetcherResult.Data(value = 9), awaitItem()) - awaitComplete() - } - } - - @Test - fun givenNonFlowValueFetcherWhenExceptionInFlowThenExceptionReturnedAsResult() = - testScope.runTest { - val e = Exception() - val fetcher = - Fetcher.of { - throw e - } - fetcher(3).test { - assertEquals(FetcherResult.Error.Exception(e), awaitItem()) - awaitComplete() - } - } + private val testScope = TestScope() + + @Test + fun givenValueFetcherWhenInvokeThenResultIsWrapped() = + testScope.runTest { + val fetcher = Fetcher.ofFlow { flowOf(it * it) } + + fetcher(3).test { + assertEquals(FetcherResult.Data(value = 9), awaitItem()) + awaitComplete() + } + } + + @Test + fun givenValueFetcherWhenExceptionInFlowThenExceptionReturnedAsResult() = + testScope.runTest { + val e = Exception() + val fetcher = Fetcher.ofFlow { flow { throw e } } + fetcher(3).test { + assertEquals(FetcherResult.Error.Exception(e), awaitItem()) + awaitComplete() + } + } + + @Test + fun givenNonFlowValueFetcherWhenInvokeThenResultIsWrapped() = + testScope.runTest { + val fetcher = Fetcher.of { it * it } + + fetcher(3).test { + assertEquals(FetcherResult.Data(value = 9), awaitItem()) + awaitComplete() + } + } + + @Test + fun givenNonFlowValueFetcherWhenExceptionInFlowThenExceptionReturnedAsResult() = + testScope.runTest { + val e = Exception() + val fetcher = Fetcher.of { throw e } + fetcher(3).test { + assertEquals(FetcherResult.Error.Exception(e), awaitItem()) + awaitComplete() + } + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/RealMutableStoreTest.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/RealMutableStoreTest.kt index 18ea7c72a..8ce469046 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/RealMutableStoreTest.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/RealMutableStoreTest.kt @@ -2,6 +2,13 @@ package org.mobilenativefoundation.store.store5.mutablestore +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow @@ -26,13 +33,6 @@ import org.mobilenativefoundation.store.store5.mutablestore.util.TestSourceOfTru import org.mobilenativefoundation.store.store5.mutablestore.util.TestUpdater import org.mobilenativefoundation.store.store5.mutablestore.util.TestValidator import org.mobilenativefoundation.store.store5.mutablestore.util.testStore -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue private data class Note(val id: String, val content: String) @@ -42,334 +42,317 @@ private data class DatabaseNote(val id: String, val content: String) @OptIn(ExperimentalCoroutinesApi::class, ExperimentalStoreApi::class) class RealMutableStoreTest { - private lateinit var testFetcher: TestFetcher - private lateinit var testConverter: TestConverter - private lateinit var testValidator: TestValidator - private lateinit var testSourceOfTruth: TestSourceOfTruth - private lateinit var testCache: TestCache - - private lateinit var testUpdater: TestUpdater - private lateinit var testBookkeeper: TestInMemoryBookkeeper - private lateinit var testLogger: TestLogger - - private lateinit var delegateStore: RealStore - private lateinit var mutableStore: RealMutableStore - - @BeforeTest - fun setUp() { - testFetcher = TestFetcher() - val defaultLocalValue = DatabaseNote("defaultLocalId", "defaultLocalContent") - testConverter = - TestConverter( - defaultNetworkToLocalConverter = { defaultLocalValue }, - defaultOutputToLocalConverter = { defaultLocalValue }, - ) - testValidator = TestValidator() - testSourceOfTruth = TestSourceOfTruth() - testCache = TestCache() - - testFetcher.whenever("key1") { - flowOf(FetcherResult.Data(NetworkNote("networkId", "networkContent"))) - } - - testUpdater = TestUpdater() - testBookkeeper = TestInMemoryBookkeeper() - testLogger = TestLogger() - - delegateStore = - testStore( - fetcher = testFetcher, - sourceOfTruth = testSourceOfTruth, - converter = testConverter, - validator = testValidator, - memoryCache = testCache, - ) - - mutableStore = - RealMutableStore( - delegate = delegateStore, - updater = testUpdater, - bookkeeper = testBookkeeper, - logger = testLogger, - ) + private lateinit var testFetcher: TestFetcher + private lateinit var testConverter: TestConverter + private lateinit var testValidator: TestValidator + private lateinit var testSourceOfTruth: TestSourceOfTruth + private lateinit var testCache: TestCache + + private lateinit var testUpdater: TestUpdater + private lateinit var testBookkeeper: TestInMemoryBookkeeper + private lateinit var testLogger: TestLogger + + private lateinit var delegateStore: RealStore + private lateinit var mutableStore: RealMutableStore + + @BeforeTest + fun setUp() { + testFetcher = TestFetcher() + val defaultLocalValue = DatabaseNote("defaultLocalId", "defaultLocalContent") + testConverter = + TestConverter( + defaultNetworkToLocalConverter = { defaultLocalValue }, + defaultOutputToLocalConverter = { defaultLocalValue }, + ) + testValidator = TestValidator() + testSourceOfTruth = TestSourceOfTruth() + testCache = TestCache() + + testFetcher.whenever("key1") { + flowOf(FetcherResult.Data(NetworkNote("networkId", "networkContent"))) + } + + testUpdater = TestUpdater() + testBookkeeper = TestInMemoryBookkeeper() + testLogger = TestLogger() + + delegateStore = + testStore( + fetcher = testFetcher, + sourceOfTruth = testSourceOfTruth, + converter = testConverter, + validator = testValidator, + memoryCache = testCache, + ) + + mutableStore = + RealMutableStore( + delegate = delegateStore, + updater = testUpdater, + bookkeeper = testBookkeeper, + logger = testLogger, + ) + } + + @Test + fun stream_givenNoConflicts_whenReading_thenEmitsFromDelegate() = runTest { + // Given + val request = StoreReadRequest.Companion.cached("key1", refresh = true) + + // When + val results = mutableStore.stream(request).take(2).toList() + + // Then + assertTrue(results.size >= 2) + assertIs(results[0]) + assertIs>(results[1]) + } + + @Test + fun stream_givenConflictsAndBookkeeper_whenReading_thenAttemptsEagerConflictResolution() = + runTest { + // Given + val request = StoreReadRequest.Companion.cached("key2", refresh = true) + delegateStore.write("key2", Note("localId", "localContent")) + testBookkeeper.setLastFailedSync("key2") + testUpdater.successValue = NetworkNote("resolvedId", "resolvedContent") + + // When + val results = mutableStore.stream(request).take(2).toList() + + // Then + assertTrue(results.isNotEmpty()) + val foundResolutionLog = + testLogger.debugLogs.any { it.contains("resolvedContent") } || + testLogger.debugLogs.any { it.contains("No conflicts.") } + assertTrue(foundResolutionLog, "Expected conflict resolution attempt in debug logs") + assertEquals(null, testBookkeeper.getLastFailedSync("key2")) + assertIs>(results.last()) + } + + @Test + fun stream_givenConflictResolutionFails_whenReading_thenLogsErrorButContinues() = runTest { + // Given + val request = StoreReadRequest.Companion.cached("key3", refresh = true) + val errorMessage = "Conflict not resolved" + + delegateStore.write("key3", Note("localId3", "localContent3")) + testBookkeeper.setLastFailedSync("key3") + testUpdater.errorMessage = errorMessage + + // When + val results = mutableStore.stream(request).take(2).toList() + + // Then + assertTrue(results.size >= 2) + assertTrue( + testLogger.errorLogs.any { (msg, _) -> msg.contains(errorMessage) }, + "Expected error logs due to conflict resolution failing", + ) + assertNotNull(testBookkeeper.getLastFailedSync("key3")) + } + + @Test + fun stream_givenWriteFlowAndNoConflicts_whenCollecting_thenLocalAndNetworkAreUpdated() = runTest { + // Given + val requestsFlow = MutableSharedFlow>(replay = 1) + + // When + val responsesDeferred = async { mutableStore.stream(requestsFlow).take(1).toList() } + + requestsFlow.emit( + StoreWriteRequest.Companion.of( + key = "writeKey1", + value = Note("localNoteId1", "localNoteContent1"), + created = 1111L, + onCompletions = null, + ) + ) + + val responses = responsesDeferred.await() + assertTrue(responses.first() is StoreWriteResponse.Success) + + // Then + val read = delegateStore.latestOrNull("writeKey1") + assertEquals("localNoteContent1", read?.content) + assertEquals(null, testBookkeeper.getLastFailedSync("writeKey1")) + } + + @Test + fun stream_givenWriteFlowAndNetworkFailure_whenCollecting_thenLocalIsUpdatedButConflictRemains() = + runTest { + // Given + val requestsFlow = MutableSharedFlow>(replay = 1) + testUpdater.errorMessage = "Network failure" + + // When + val responsesDeferred = async { mutableStore.stream(requestsFlow).take(1).toList() } + + requestsFlow.emit( + StoreWriteRequest.Companion.of( + key = "writeKey2", + value = Note("localNoteId2", "localNoteContent2"), + created = 1111L, + onCompletions = null, + ) + ) + + val responses = responsesDeferred.await() + + // Then + val firstResponse = responses.first() + assertTrue(firstResponse is StoreWriteResponse.Error.Message) + assertTrue(firstResponse.message.contains("Network failure")) + val read = delegateStore.latestOrNull("writeKey2") + assertEquals("localNoteContent2", read?.content) + assertNotNull(testBookkeeper.getLastFailedSync("writeKey2")) + } + + @Test + fun stream_givenMultipleWritesForSameKey_whenAllSucceed_thenOlderRequestsAreClearedFromQueue() = + runTest { + // Given + val requestsFlow = MutableSharedFlow>(replay = 2) + testUpdater.successValue = NetworkNote("someNetId", "someNetContent") + val responsesDeferred = async { mutableStore.stream(requestsFlow).take(2).toList() } + + // When + requestsFlow.emit( + StoreWriteRequest.Companion.of( + key = "multiKey", + value = Note("first", "firstContent"), + created = 100, + onCompletions = null, + ) + ) + requestsFlow.emit( + StoreWriteRequest.Companion.of( + key = "multiKey", + value = Note("second", "secondContent"), + created = 200, + onCompletions = null, + ) + ) + + // Then + val responses = responsesDeferred.await() + assertTrue(responses[0] is StoreWriteResponse.Success) + assertTrue(responses[1] is StoreWriteResponse.Success) + val read = delegateStore.latestOrNull("multiKey") + assertEquals("secondContent", read?.content) + assertNull(testBookkeeper.getLastFailedSync("multiKey")) + } + + @Test + fun write_givenSingleRequestAndNoNetworkIssues_whenCalled_thenSucceeds() = runTest { + // Given + val request = + StoreWriteRequest.Companion.of( + key = "singleWriteKey", + value = Note("id", "content"), + created = 9999L, + onCompletions = null, + ) + + // When + val response = mutableStore.write(request) + + // Then + assertIs(response) + assertEquals("content", delegateStore.latestOrNull("singleWriteKey")?.content) + } + + @Test + fun write_givenSingleRequestAndNetworkException_whenCalled_thenFailsButLocalUpdated() = runTest { + // Given + testUpdater.exception = IllegalStateException("Network error!") + val request = + StoreWriteRequest.Companion.of( + key = "exceptionKey", + value = Note("exceptionId", "contentException"), + created = 2222L, + onCompletions = null, + ) + + // When + val response = mutableStore.write(request) + + // Then + assertIs(response) + assertEquals("contentException", delegateStore.latestOrNull("exceptionKey")?.content) + assertNotNull(testBookkeeper.getLastFailedSync("exceptionKey")) + } + + @Test + fun clearAll_givenSomeKeys_whenCalled_thenDelegateIsCleared() = runTest { + // Given + delegateStore.write("clearKey1", Note("id1", "content1")) + delegateStore.write("clearKey2", Note("id2", "content2")) + assertNotNull(delegateStore.latestOrNull("clearKey1")) + assertNotNull(delegateStore.latestOrNull("clearKey2")) + + // When + mutableStore.clear() + + // Then + assertNull(delegateStore.latestOrNull("clearKey1")) + assertNull(delegateStore.latestOrNull("clearKey2")) + } + + @Test + fun clear_givenKey_whenCalled_thenDelegateIsClearedForThatKey() = runTest { + // Given + delegateStore.write("clearKey", Note("idCleared", "contentCleared")) + assertNotNull(delegateStore.latestOrNull("clearKey")) + + // When + mutableStore.clear("clearKey") + + // Then + assertNull(delegateStore.latestOrNull("clearKey")) + } + + @Test + fun stream_givenNoBookkeeper_whenConflictsMightExistIsCalled_thenNoEagerResolutionIsAttempted() = + runTest { + // Given + val storeNoBookkeeper = + RealMutableStore( + delegate = delegateStore, + updater = testUpdater, + bookkeeper = null, + logger = testLogger, + ) + delegateStore.write("keyNoBook", Note("idNoBook", "contentNoBook")) + val request = StoreReadRequest.Companion.cached("keyNoBook", refresh = false) + + // When + val results = storeNoBookkeeper.stream(request).take(2).toList() + + // Then + assertTrue(results.isNotEmpty()) + assertTrue( + testLogger.debugLogs.none { it.contains("ConflictsResolved") }, + "No conflict resolution logs expected because no Bookkeeper", + ) } - @Test - fun stream_givenNoConflicts_whenReading_thenEmitsFromDelegate() = - runTest { - // Given - val request = StoreReadRequest.Companion.cached("key1", refresh = true) - - // When - val results = mutableStore.stream(request).take(2).toList() - - // Then - assertTrue(results.size >= 2) - assertIs(results[0]) - assertIs>(results[1]) - } - - @Test - fun stream_givenConflictsAndBookkeeper_whenReading_thenAttemptsEagerConflictResolution() = - runTest { - // Given - val request = StoreReadRequest.Companion.cached("key2", refresh = true) - delegateStore.write("key2", Note("localId", "localContent")) - testBookkeeper.setLastFailedSync("key2") - testUpdater.successValue = NetworkNote("resolvedId", "resolvedContent") - - // When - val results = mutableStore.stream(request).take(2).toList() - - // Then - assertTrue(results.isNotEmpty()) - val foundResolutionLog = - testLogger.debugLogs.any { it.contains("resolvedContent") } || - testLogger.debugLogs.any { it.contains("No conflicts.") } - assertTrue(foundResolutionLog, "Expected conflict resolution attempt in debug logs") - assertEquals(null, testBookkeeper.getLastFailedSync("key2")) - assertIs>(results.last()) - } - - @Test - fun stream_givenConflictResolutionFails_whenReading_thenLogsErrorButContinues() = - runTest { - // Given - val request = StoreReadRequest.Companion.cached("key3", refresh = true) - val errorMessage = "Conflict not resolved" - - delegateStore.write("key3", Note("localId3", "localContent3")) - testBookkeeper.setLastFailedSync("key3") - testUpdater.errorMessage = errorMessage - - // When - val results = mutableStore.stream(request).take(2).toList() - - // Then - assertTrue(results.size >= 2) - assertTrue( - testLogger.errorLogs.any { (msg, _) -> msg.contains(errorMessage) }, - "Expected error logs due to conflict resolution failing", - ) - assertNotNull(testBookkeeper.getLastFailedSync("key3")) - } - - @Test - fun stream_givenWriteFlowAndNoConflicts_whenCollecting_thenLocalAndNetworkAreUpdated() = - runTest { - // Given - val requestsFlow = MutableSharedFlow>(replay = 1) - - // When - val responsesDeferred = - async { - mutableStore.stream(requestsFlow).take(1).toList() - } - - requestsFlow.emit( - StoreWriteRequest.Companion.of( - key = "writeKey1", - value = Note("localNoteId1", "localNoteContent1"), - created = 1111L, - onCompletions = null, - ), - ) - - val responses = responsesDeferred.await() - assertTrue(responses.first() is StoreWriteResponse.Success) - - // Then - val read = delegateStore.latestOrNull("writeKey1") - assertEquals("localNoteContent1", read?.content) - assertEquals(null, testBookkeeper.getLastFailedSync("writeKey1")) - } - - @Test - fun stream_givenWriteFlowAndNetworkFailure_whenCollecting_thenLocalIsUpdatedButConflictRemains() = - runTest { - // Given - val requestsFlow = MutableSharedFlow>(replay = 1) - testUpdater.errorMessage = "Network failure" - - // When - val responsesDeferred = - async { - mutableStore.stream(requestsFlow).take(1).toList() - } - - requestsFlow.emit( - StoreWriteRequest.Companion.of( - key = "writeKey2", - value = Note("localNoteId2", "localNoteContent2"), - created = 1111L, - onCompletions = null, - ), - ) - - val responses = responsesDeferred.await() - - // Then - val firstResponse = responses.first() - assertTrue(firstResponse is StoreWriteResponse.Error.Message) - assertTrue(firstResponse.message.contains("Network failure")) - val read = delegateStore.latestOrNull("writeKey2") - assertEquals("localNoteContent2", read?.content) - assertNotNull(testBookkeeper.getLastFailedSync("writeKey2")) - } - - @Test - fun stream_givenMultipleWritesForSameKey_whenAllSucceed_thenOlderRequestsAreClearedFromQueue() = - runTest { - // Given - val requestsFlow = MutableSharedFlow>(replay = 2) - testUpdater.successValue = NetworkNote("someNetId", "someNetContent") - val responsesDeferred = - async { - mutableStore.stream(requestsFlow).take(2).toList() - } - - // When - requestsFlow.emit( - StoreWriteRequest.Companion.of( - key = "multiKey", - value = Note("first", "firstContent"), - created = 100, - onCompletions = null, - ), - ) - requestsFlow.emit( - StoreWriteRequest.Companion.of( - key = "multiKey", - value = Note("second", "secondContent"), - created = 200, - onCompletions = null, - ), - ) - - // Then - val responses = responsesDeferred.await() - assertTrue(responses[0] is StoreWriteResponse.Success) - assertTrue(responses[1] is StoreWriteResponse.Success) - val read = delegateStore.latestOrNull("multiKey") - assertEquals("secondContent", read?.content) - assertNull(testBookkeeper.getLastFailedSync("multiKey")) - } - - @Test - fun write_givenSingleRequestAndNoNetworkIssues_whenCalled_thenSucceeds() = - runTest { - // Given - val request = - StoreWriteRequest.Companion.of( - key = "singleWriteKey", - value = Note("id", "content"), - created = 9999L, - onCompletions = null, - ) - - // When - val response = mutableStore.write(request) - - // Then - assertIs(response) - assertEquals("content", delegateStore.latestOrNull("singleWriteKey")?.content) - } - - @Test - fun write_givenSingleRequestAndNetworkException_whenCalled_thenFailsButLocalUpdated() = - runTest { - // Given - testUpdater.exception = IllegalStateException("Network error!") - val request = - StoreWriteRequest.Companion.of( - key = "exceptionKey", - value = Note("exceptionId", "contentException"), - created = 2222L, - onCompletions = null, - ) - - // When - val response = mutableStore.write(request) - - // Then - assertIs(response) - assertEquals("contentException", delegateStore.latestOrNull("exceptionKey")?.content) - assertNotNull(testBookkeeper.getLastFailedSync("exceptionKey")) - } - - @Test - fun clearAll_givenSomeKeys_whenCalled_thenDelegateIsCleared() = - runTest { - // Given - delegateStore.write("clearKey1", Note("id1", "content1")) - delegateStore.write("clearKey2", Note("id2", "content2")) - assertNotNull(delegateStore.latestOrNull("clearKey1")) - assertNotNull(delegateStore.latestOrNull("clearKey2")) - - // When - mutableStore.clear() - - // Then - assertNull(delegateStore.latestOrNull("clearKey1")) - assertNull(delegateStore.latestOrNull("clearKey2")) - } - - @Test - fun clear_givenKey_whenCalled_thenDelegateIsClearedForThatKey() = - runTest { - // Given - delegateStore.write("clearKey", Note("idCleared", "contentCleared")) - assertNotNull(delegateStore.latestOrNull("clearKey")) - - // When - mutableStore.clear("clearKey") - - // Then - assertNull(delegateStore.latestOrNull("clearKey")) - } - - @Test - fun stream_givenNoBookkeeper_whenConflictsMightExistIsCalled_thenNoEagerResolutionIsAttempted() = - runTest { - // Given - val storeNoBookkeeper = - RealMutableStore( - delegate = delegateStore, - updater = testUpdater, - bookkeeper = null, - logger = testLogger, - ) - delegateStore.write("keyNoBook", Note("idNoBook", "contentNoBook")) - val request = StoreReadRequest.Companion.cached("keyNoBook", refresh = false) - - // When - val results = storeNoBookkeeper.stream(request).take(2).toList() - - // Then - assertTrue(results.isNotEmpty()) - assertTrue( - testLogger.debugLogs.none { it.contains("ConflictsResolved") }, - "No conflict resolution logs expected because no Bookkeeper", - ) - } - - @Test - fun write_givenKeyNotInitialized_whenCalled_thenStoreIsSafelyInitialized() = - runTest { - // Given - val request = - StoreWriteRequest.Companion.of( - key = "newKey", - value = Note("someId", "someContent"), - created = 777L, - onCompletions = null, - ) - - // When - val response = mutableStore.write(request) - - // Then - assertIs(response) - assertEquals("someContent", delegateStore.latestOrNull("newKey")?.content) - } + @Test + fun write_givenKeyNotInitialized_whenCalled_thenStoreIsSafelyInitialized() = runTest { + // Given + val request = + StoreWriteRequest.Companion.of( + key = "newKey", + value = Note("someId", "someContent"), + created = 777L, + onCompletions = null, + ) + + // When + val response = mutableStore.write(request) + + // Then + assertIs(response) + assertEquals("someContent", delegateStore.latestOrNull("newKey")?.content) + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestCache.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestCache.kt index 78d22c91f..ad63d0d04 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestCache.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestCache.kt @@ -4,73 +4,67 @@ import org.mobilenativefoundation.store.cache5.Cache @Suppress("UNCHECKED_CAST") class TestCache : Cache { - private val map = HashMap() - var getIfPresentCalls = 0 - var getOrPutCalls = 0 - var getAllPresentCalls = 0 - var putCalls = 0 - var putAllCalls = 0 - var invalidateCalls = 0 - var invalidateAllKeysCalls = 0 - var invalidateAllCalls = 0 - var sizeCalls = 0 + private val map = HashMap() + var getIfPresentCalls = 0 + var getOrPutCalls = 0 + var getAllPresentCalls = 0 + var putCalls = 0 + var putAllCalls = 0 + var invalidateCalls = 0 + var invalidateAllKeysCalls = 0 + var invalidateAllCalls = 0 + var sizeCalls = 0 - override fun getIfPresent(key: Key): Value? { - getIfPresentCalls++ - return map[key] - } + override fun getIfPresent(key: Key): Value? { + getIfPresentCalls++ + return map[key] + } - override fun getOrPut( - key: Key, - valueProducer: () -> Value, - ): Value { - getOrPutCalls++ - return map.getOrPut(key, valueProducer) - } + override fun getOrPut(key: Key, valueProducer: () -> Value): Value { + getOrPutCalls++ + return map.getOrPut(key, valueProducer) + } - override fun getAllPresent(keys: List<*>): Map { - getAllPresentCalls++ - return keys.mapNotNull { it as? Key }.associateWithNotNull { key -> map[key] } - } + override fun getAllPresent(keys: List<*>): Map { + getAllPresentCalls++ + return keys.mapNotNull { it as? Key }.associateWithNotNull { key -> map[key] } + } - override fun put( - key: Key, - value: Value, - ) { - putCalls++ - map[key] = value - } + override fun put(key: Key, value: Value) { + putCalls++ + map[key] = value + } - override fun putAll(map: Map) { - putAllCalls++ - map.forEach { (k, v) -> put(k, v) } - } + override fun putAll(map: Map) { + putAllCalls++ + map.forEach { (k, v) -> put(k, v) } + } - override fun invalidate(key: Key) { - invalidateCalls++ - map.remove(key) - } + override fun invalidate(key: Key) { + invalidateCalls++ + map.remove(key) + } - override fun invalidateAll(keys: List) { - invalidateAllKeysCalls++ - keys.forEach { map.remove(it) } - } + override fun invalidateAll(keys: List) { + invalidateAllKeysCalls++ + keys.forEach { map.remove(it) } + } - override fun invalidateAll() { - invalidateAllCalls++ - map.clear() - } + override fun invalidateAll() { + invalidateAllCalls++ + map.clear() + } - override fun size(): Long { - sizeCalls++ - return map.size.toLong() - } + override fun size(): Long { + sizeCalls++ + return map.size.toLong() + } - private inline fun Iterable.associateWithNotNull(transform: (K) -> V?): Map { - val destination = mutableMapOf() - for (element in this) { - transform(element)?.let { destination[element] = it } - } - return destination + private inline fun Iterable.associateWithNotNull(transform: (K) -> V?): Map { + val destination = mutableMapOf() + for (element in this) { + transform(element)?.let { destination[element] = it } } + return destination + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestConverter.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestConverter.kt index 845dcd439..14c08890a 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestConverter.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestConverter.kt @@ -4,31 +4,29 @@ import org.mobilenativefoundation.store.store5.Converter @Suppress("UNCHECKED_CAST") class TestConverter( - private val defaultNetworkToLocalConverter: ((Network) -> Local)? = null, - private val defaultOutputToLocalConverter: ((Output) -> Local)? = null, + private val defaultNetworkToLocalConverter: ((Network) -> Local)? = null, + private val defaultOutputToLocalConverter: ((Output) -> Local)? = null, ) : Converter { - private val networkToLocalMap: HashMap = HashMap() - private val outputToLocalMap: HashMap = HashMap() + private val networkToLocalMap: HashMap = HashMap() + private val outputToLocalMap: HashMap = HashMap() - fun wheneverNetwork( - network: Network, - block: () -> Local, - ) { - networkToLocalMap[network] = block() - } + fun wheneverNetwork(network: Network, block: () -> Local) { + networkToLocalMap[network] = block() + } - fun wheneverOutput( - output: Output, - block: () -> Local, - ) { - outputToLocalMap[output] = block() - } + fun wheneverOutput(output: Output, block: () -> Local) { + outputToLocalMap[output] = block() + } - override fun fromNetworkToLocal(network: Network): Local { - return networkToLocalMap[network] ?: defaultNetworkToLocalConverter?.invoke(network) ?: network as Local - } + override fun fromNetworkToLocal(network: Network): Local { + return networkToLocalMap[network] + ?: defaultNetworkToLocalConverter?.invoke(network) + ?: network as Local + } - override fun fromOutputToLocal(output: Output): Local { - return outputToLocalMap[output] ?: defaultOutputToLocalConverter?.invoke(output) ?: output as Local - } + override fun fromOutputToLocal(output: Output): Local { + return outputToLocalMap[output] + ?: defaultOutputToLocalConverter?.invoke(output) + ?: output as Local + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestFetcher.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestFetcher.kt index a074424a4..58908c699 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestFetcher.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestFetcher.kt @@ -5,21 +5,16 @@ import org.mobilenativefoundation.store.store5.Fetcher import org.mobilenativefoundation.store.store5.FetcherResult class TestFetcher( - override val name: String? = null, - override val fallback: Fetcher? = null, + override val name: String? = null, + override val fallback: Fetcher? = null, ) : Fetcher { - private val faked = HashMap>>() + private val faked = HashMap>>() - fun whenever( - key: Key, - block: () -> Flow>, - ) { - faked[key] = block() - } + fun whenever(key: Key, block: () -> Flow>) { + faked[key] = block() + } - override operator fun invoke(key: Key): Flow> { - return requireNotNull(faked[key]) { - "No fetcher result provided for key=$key" - } - } + override operator fun invoke(key: Key): Flow> { + return requireNotNull(faked[key]) { "No fetcher result provided for key=$key" } + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestInMemoryBookkeeper.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestInMemoryBookkeeper.kt index 1c159c81e..6bd597938 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestInMemoryBookkeeper.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestInMemoryBookkeeper.kt @@ -3,26 +3,23 @@ package org.mobilenativefoundation.store.store5.mutablestore.util import org.mobilenativefoundation.store.store5.Bookkeeper class TestInMemoryBookkeeper : Bookkeeper { - private val failedSyncMap = mutableMapOf() + private val failedSyncMap = mutableMapOf() - override suspend fun getLastFailedSync(key: Key): Long? { - return failedSyncMap[key] - } + override suspend fun getLastFailedSync(key: Key): Long? { + return failedSyncMap[key] + } - override suspend fun setLastFailedSync( - key: Key, - timestamp: Long, - ): Boolean { - failedSyncMap[key] = timestamp - return true - } + override suspend fun setLastFailedSync(key: Key, timestamp: Long): Boolean { + failedSyncMap[key] = timestamp + return true + } - override suspend fun clear(key: Key): Boolean { - return failedSyncMap.remove(key) != null - } + override suspend fun clear(key: Key): Boolean { + return failedSyncMap.remove(key) != null + } - override suspend fun clearAll(): Boolean { - failedSyncMap.clear() - return true - } + override suspend fun clearAll(): Boolean { + failedSyncMap.clear() + return true + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestLogger.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestLogger.kt index 332ea0023..9e9ea5f7b 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestLogger.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestLogger.kt @@ -3,17 +3,14 @@ package org.mobilenativefoundation.store.store5.mutablestore.util import org.mobilenativefoundation.store.store5.Logger class TestLogger : Logger { - val debugLogs = mutableListOf() - val errorLogs = mutableListOf>() + val debugLogs = mutableListOf() + val errorLogs = mutableListOf>() - override fun debug(message: String) { - debugLogs.add(message) - } + override fun debug(message: String) { + debugLogs.add(message) + } - override fun error( - message: String, - throwable: Throwable?, - ) { - errorLogs.add(message to throwable) - } + override fun error(message: String, throwable: Throwable?) { + errorLogs.add(message to throwable) + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestSourceOfTruth.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestSourceOfTruth.kt index 3b569e8d0..34ea2aded 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestSourceOfTruth.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestSourceOfTruth.kt @@ -8,60 +8,47 @@ import org.mobilenativefoundation.store.store5.SourceOfTruth @Suppress("UNCHECKED_CAST") class TestSourceOfTruth : SourceOfTruth { - private val storage = HashMap() - private val flows = HashMap>() - private var readError: Throwable? = null - private var writeError: Throwable? = null - private var deleteError: Throwable? = null - private var deleteAllError: Throwable? = null - - fun throwOnRead( - key: Key, - block: () -> Throwable, - ) { - readError = block() - } - - fun throwOnWrite( - key: Key, - block: () -> Throwable, - ) { - writeError = block() - } - - fun throwOnDelete( - key: Key?, - block: () -> Throwable, - ) { - if (key != null) deleteError = block() else deleteAllError = block() - } - - override fun reader(key: Key): Flow = - flow { - readError?.let { throw SourceOfTruth.ReadException(key, it) } - val sharedFlow = flows.getOrPut(key) { MutableSharedFlow(replay = 1) } - emit(storage[key] as Output?) - emitAll(sharedFlow) - } - - override suspend fun write( - key: Key, - value: Local, - ) { - writeError?.let { throw SourceOfTruth.WriteException(key, value, it) } - storage[key] = value - flows[key]?.emit(value as Output?) - } - - override suspend fun delete(key: Key) { - deleteError?.let { throw it } - storage.remove(key) - flows.remove(key) - } - - override suspend fun deleteAll() { - deleteAllError?.let { throw it } - storage.clear() - flows.clear() - } + private val storage = HashMap() + private val flows = HashMap>() + private var readError: Throwable? = null + private var writeError: Throwable? = null + private var deleteError: Throwable? = null + private var deleteAllError: Throwable? = null + + fun throwOnRead(key: Key, block: () -> Throwable) { + readError = block() + } + + fun throwOnWrite(key: Key, block: () -> Throwable) { + writeError = block() + } + + fun throwOnDelete(key: Key?, block: () -> Throwable) { + if (key != null) deleteError = block() else deleteAllError = block() + } + + override fun reader(key: Key): Flow = flow { + readError?.let { throw SourceOfTruth.ReadException(key, it) } + val sharedFlow = flows.getOrPut(key) { MutableSharedFlow(replay = 1) } + emit(storage[key] as Output?) + emitAll(sharedFlow) + } + + override suspend fun write(key: Key, value: Local) { + writeError?.let { throw SourceOfTruth.WriteException(key, value, it) } + storage[key] = value + flows[key]?.emit(value as Output?) + } + + override suspend fun delete(key: Key) { + deleteError?.let { throw it } + storage.remove(key) + flows.remove(key) + } + + override suspend fun deleteAll() { + deleteAllError?.let { throw it } + storage.clear() + flows.clear() + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestStore.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestStore.kt index 1a2fb1f5a..3b4e4ba35 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestStore.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestStore.kt @@ -11,19 +11,19 @@ import org.mobilenativefoundation.store.store5.Validator import org.mobilenativefoundation.store.store5.impl.RealStore internal fun testStore( - dispatcher: CoroutineDispatcher = Dispatchers.Default, - scope: CoroutineScope = CoroutineScope(dispatcher), - fetcher: Fetcher = TestFetcher(), - sourceOfTruth: SourceOfTruth = TestSourceOfTruth(), - converter: Converter = TestConverter(), - validator: Validator = TestValidator(), - memoryCache: Cache = TestCache(), + dispatcher: CoroutineDispatcher = Dispatchers.Default, + scope: CoroutineScope = CoroutineScope(dispatcher), + fetcher: Fetcher = TestFetcher(), + sourceOfTruth: SourceOfTruth = TestSourceOfTruth(), + converter: Converter = TestConverter(), + validator: Validator = TestValidator(), + memoryCache: Cache = TestCache(), ): RealStore = - RealStore( - scope = scope, - fetcher = fetcher, - sourceOfTruth = sourceOfTruth, - converter = converter, - validator = validator, - memCache = memoryCache, - ) + RealStore( + scope = scope, + fetcher = fetcher, + sourceOfTruth = sourceOfTruth, + converter = converter, + validator = validator, + memCache = memoryCache, + ) diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestUpdater.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestUpdater.kt index 802faa34d..f5eac3b40 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestUpdater.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestUpdater.kt @@ -5,19 +5,22 @@ import org.mobilenativefoundation.store.store5.Updater import org.mobilenativefoundation.store.store5.UpdaterResult class TestUpdater : Updater { - var exception: Throwable? = null - var errorMessage: String? = null - var successValue: Response? = null + var exception: Throwable? = null + var errorMessage: String? = null + var successValue: Response? = null - override suspend fun post( - key: Key, - value: Output, - ): UpdaterResult { - exception?.let { return UpdaterResult.Error.Exception(it) } - errorMessage?.let { return UpdaterResult.Error.Message(it) } - successValue?.let { return UpdaterResult.Success.Typed(it) } - return UpdaterResult.Success.Untyped(value) + override suspend fun post(key: Key, value: Output): UpdaterResult { + exception?.let { + return UpdaterResult.Error.Exception(it) } + errorMessage?.let { + return UpdaterResult.Error.Message(it) + } + successValue?.let { + return UpdaterResult.Success.Typed(it) + } + return UpdaterResult.Success.Untyped(value) + } - override val onCompletion: OnUpdaterCompletion? = null + override val onCompletion: OnUpdaterCompletion? = null } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestValidator.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestValidator.kt index c6e852429..efef89b49 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestValidator.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestValidator.kt @@ -3,16 +3,13 @@ package org.mobilenativefoundation.store.store5.mutablestore.util import org.mobilenativefoundation.store.store5.Validator class TestValidator : Validator { - private val map: HashMap = HashMap() + private val map: HashMap = HashMap() - fun whenever( - item: Output, - block: () -> Boolean, - ) { - map[item] = block() - } + fun whenever(item: Output, block: () -> Boolean) { + map[item] = block() + } - override suspend fun isValid(item: Output): Boolean { - return map[item] != false - } + override suspend fun isValid(item: Output): Boolean { + return map[item] != false + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/AsFlowable.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/AsFlowable.kt index 60d8bb9ca..9e4e0e9e3 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/AsFlowable.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/AsFlowable.kt @@ -11,122 +11,100 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.mobilenativefoundation.store.store5.SourceOfTruth -/** - * Only used in FlowStoreTest. We should get rid of it eventually. - */ +/** Only used in FlowStoreTest. We should get rid of it eventually. */ class SimplePersisterAsFlowable( - private val reader: suspend (Key) -> Output?, - private val writer: suspend (Key, Output) -> Unit, - private val delete: (suspend (Key) -> Unit)? = null, + private val reader: suspend (Key) -> Output?, + private val writer: suspend (Key, Output) -> Unit, + private val delete: (suspend (Key) -> Unit)? = null, ) { - val supportsDelete: Boolean - get() = delete != null + val supportsDelete: Boolean + get() = delete != null - private val versionTracker = KeyTracker() + private val versionTracker = KeyTracker() - fun flowReader(key: Key): Flow = - flow { - versionTracker.keyFlow(key).collect { - emit(reader(key)) - } - } + fun flowReader(key: Key): Flow = flow { + versionTracker.keyFlow(key).collect { emit(reader(key)) } + } - suspend fun flowWriter( - key: Key, - value: Output, - ) { - writer(key, value) - versionTracker.invalidate(key) - } + suspend fun flowWriter(key: Key, value: Output) { + writer(key, value) + versionTracker.invalidate(key) + } - suspend fun flowDelete(key: Key) { - delete?.let { - it(key) - versionTracker.invalidate(key) - } + suspend fun flowDelete(key: Key) { + delete?.let { + it(key) + versionTracker.invalidate(key) } + } } fun SimplePersisterAsFlowable.asSourceOfTruth() = - SourceOfTruth.of( - reader = ::flowReader, - writer = ::flowWriter, - delete = ::flowDelete.takeIf { supportsDelete }, - ) - -/** - * helper class which provides Flows for Keys that can be tracked. - */ -internal class KeyTracker { - private val lock = Mutex() - - // list of open key flows - private val flows = mutableMapOf() - - // for testing - internal fun activeKeyCount() = flows.size - - /** - * invalidates the given key. If there are flows returned from [keyFlow] for the given [key], - * they'll receive a new emission - */ - suspend fun invalidate(key: Key) { - lock.withLock { - flows[key] - }?.flow?.emit(Unit) - } + SourceOfTruth.of( + reader = ::flowReader, + writer = ::flowWriter, + delete = ::flowDelete.takeIf { supportsDelete }, + ) - /** - * Returns a Flow that emits once and then every time the given [key] is invalidated via - * [invalidate] - */ - suspend fun keyFlow(key: Key): Flow { - // it is important to allocate KeyFlow lazily (ony when the returned flow is collected - // from). Otherwise, we might just create many of them that are never observed hence never - // cleaned up - return flow { - val keyFlow = - lock.withLock { - flows.getOrPut(key) { KeyFlow() }.also { - it.acquire() - } - } - emit(Unit) - try { - emitAll(keyFlow.flow) - } finally { - withContext(NonCancellable) { - lock.withLock { - if (keyFlow.release()) { - flows.remove(key) - } - } - } +/** helper class which provides Flows for Keys that can be tracked. */ +internal class KeyTracker { + private val lock = Mutex() + + // list of open key flows + private val flows = mutableMapOf() + + // for testing + internal fun activeKeyCount() = flows.size + + /** + * invalidates the given key. If there are flows returned from [keyFlow] for the given [key], + * they'll receive a new emission + */ + suspend fun invalidate(key: Key) { + lock.withLock { flows[key] }?.flow?.emit(Unit) + } + + /** + * Returns a Flow that emits once and then every time the given [key] is invalidated via + * [invalidate] + */ + suspend fun keyFlow(key: Key): Flow { + // it is important to allocate KeyFlow lazily (ony when the returned flow is collected + // from). Otherwise, we might just create many of them that are never observed hence never + // cleaned up + return flow { + val keyFlow = lock.withLock { flows.getOrPut(key) { KeyFlow() }.also { it.acquire() } } + emit(Unit) + try { + emitAll(keyFlow.flow) + } finally { + withContext(NonCancellable) { + lock.withLock { + if (keyFlow.release()) { + flows.remove(key) } + } } + } } - - /** - * A data structure to count how many active flows we have on this flow - */ - private class KeyFlow { - val flow = - MutableSharedFlow( - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - private var collectors: Int = 0 - - fun acquire() { - collectors++ - } - - fun release() = (--collectors) == 0 + } + + /** A data structure to count how many active flows we have on this flow */ + private class KeyFlow { + val flow = + MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + private var collectors: Int = 0 + + fun acquire() { + collectors++ } + + fun release() = (--collectors) == 0 + } } fun InMemoryPersister.asFlowable() = - SimplePersisterAsFlowable( - reader = this::read, - writer = this::write, - ) + SimplePersisterAsFlowable(reader = this::read, writer = this::write) diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/FakeFetcher.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/FakeFetcher.kt index dd8c88efe..5d248b10f 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/FakeFetcher.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/FakeFetcher.kt @@ -15,47 +15,44 @@ */ package org.mobilenativefoundation.store.store5.util +import kotlin.test.assertEquals import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import org.mobilenativefoundation.store.store5.Fetcher import org.mobilenativefoundation.store.store5.FetcherResult -import kotlin.test.assertEquals -class FakeFetcher( - private vararg val responses: Pair, -) : Fetcher { - private var index = 0 - override val name: String? = null - override val fallback: Fetcher? = null +class FakeFetcher(private vararg val responses: Pair) : + Fetcher { + private var index = 0 + override val name: String? = null + override val fallback: Fetcher? = null - override fun invoke(key: Key): Flow> { - if (index >= responses.size) { - throw AssertionError("unexpected fetch request") - } - val pair = responses[index++] - assertEquals(pair.first, key) - return flowOf(FetcherResult.Data(pair.second)) + override fun invoke(key: Key): Flow> { + if (index >= responses.size) { + throw AssertionError("unexpected fetch request") } + val pair = responses[index++] + assertEquals(pair.first, key) + return flowOf(FetcherResult.Data(pair.second)) + } } -class FakeFlowingFetcher( - private vararg val responses: Pair, -) : Fetcher { - override val name: String? = null - override val fallback: Fetcher? = null +class FakeFlowingFetcher(private vararg val responses: Pair) : + Fetcher { + override val name: String? = null + override val fallback: Fetcher? = null - override fun invoke(key: Key) = - flow { - responses.filter { - it.first == key - }.forEach { - // we delay here to avoid collapsing fetcher values, otherwise, there is a - // possibility that consumer won't be fast enough to get both values before new - // value overrides the previous one. - delay(1) - emit(FetcherResult.Data(it.second)) - } - } + override fun invoke(key: Key) = flow { + responses + .filter { it.first == key } + .forEach { + // we delay here to avoid collapsing fetcher values, otherwise, there is a + // possibility that consumer won't be fast enough to get both values before new + // value overrides the previous one. + delay(1) + emit(FetcherResult.Data(it.second)) + } + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/InMemoryPersister.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/InMemoryPersister.kt index 0ba8d1f32..70d5251fa 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/InMemoryPersister.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/InMemoryPersister.kt @@ -2,48 +2,46 @@ package org.mobilenativefoundation.store.store5.util import org.mobilenativefoundation.store.store5.SourceOfTruth -/** - * An in-memory non-flowing persister for testing. - */ +/** An in-memory non-flowing persister for testing. */ open class InMemoryPersister { - private val data = mutableMapOf() - var preWriteCallback: (suspend (key: Key, value: Output) -> Output)? = null - var postReadCallback: (suspend (key: Key, value: Output?) -> Output?)? = null - - @Suppress("RedundantSuspendModifier") // for function reference - suspend fun read(key: Key): Output? { - val value = data[key] - postReadCallback?.let { - return it(key, value) - } - return value - } - - @Suppress("RedundantSuspendModifier") // for function reference - open suspend fun write(key: Key, output: Output) { - val value = preWriteCallback?.invoke(key, output) ?: output - data[key] = value - } - - @Suppress("RedundantSuspendModifier") // for function reference - suspend fun deleteByKey(key: Key) { - data.remove(key) - } - - @Suppress("RedundantSuspendModifier") // for function reference - suspend fun deleteAll() { - data.clear() - } - - fun peekEntry(key: Key): Output? { - return data[key] + private val data = mutableMapOf() + var preWriteCallback: (suspend (key: Key, value: Output) -> Output)? = null + var postReadCallback: (suspend (key: Key, value: Output?) -> Output?)? = null + + @Suppress("RedundantSuspendModifier") // for function reference + suspend fun read(key: Key): Output? { + val value = data[key] + postReadCallback?.let { + return it(key, value) } + return value + } + + @Suppress("RedundantSuspendModifier") // for function reference + open suspend fun write(key: Key, output: Output) { + val value = preWriteCallback?.invoke(key, output) ?: output + data[key] = value + } + + @Suppress("RedundantSuspendModifier") // for function reference + suspend fun deleteByKey(key: Key) { + data.remove(key) + } + + @Suppress("RedundantSuspendModifier") // for function reference + suspend fun deleteAll() { + data.clear() + } + + fun peekEntry(key: Key): Output? { + return data[key] + } } fun InMemoryPersister.asSourceOfTruth() = - SourceOfTruth.of( - nonFlowReader = ::read, - writer = ::write, - delete = ::deleteByKey, - deleteAll = ::deleteAll, - ) + SourceOfTruth.of( + nonFlowReader = ::read, + writer = ::write, + delete = ::deleteByKey, + deleteAll = ::deleteAll, + ) diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/TestApi.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/TestApi.kt index 778021e9d..a0050b05b 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/TestApi.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/TestApi.kt @@ -1,15 +1,7 @@ package org.mobilenativefoundation.store.store5.util internal interface TestApi { - fun get( - key: Key, - fail: Boolean = false, - ttl: Long? = null, - ): Network? + fun get(key: Key, fail: Boolean = false, ttl: Long? = null): Network? - fun post( - key: Key, - value: Output, - fail: Boolean = false, - ): Response + fun post(key: Key, value: Output, fail: Boolean = false): Response } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/TestStoreExt.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/TestStoreExt.kt index 87e3f1679..dd22d431a 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/TestStoreExt.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/TestStoreExt.kt @@ -8,16 +8,12 @@ import org.mobilenativefoundation.store.store5.StoreReadResponse import org.mobilenativefoundation.store.store5.impl.operators.mapIndexed /** - * Helper factory that will return [StoreReadResponse.Data] for [key] - * if it is cached otherwise will return fresh/network data (updating your caches) + * Helper factory that will return [StoreReadResponse.Data] for [key] if it is cached otherwise will + * return fresh/network data (updating your caches) */ suspend fun Store.getData(key: Key) = - stream( - StoreReadRequest.cached(key, refresh = false), - ).filterNot { - it is StoreReadResponse.Loading - }.mapIndexed { index, value -> - value - }.first().let { - StoreReadResponse.Data(it.requireData(), it.origin) - } + stream(StoreReadRequest.cached(key, refresh = false)) + .filterNot { it is StoreReadResponse.Loading } + .mapIndexed { index, value -> value } + .first() + .let { StoreReadResponse.Data(it.requireData(), it.origin) } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NoteCollections.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NoteCollections.kt index 8482cff6f..df9e4030a 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NoteCollections.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NoteCollections.kt @@ -3,9 +3,9 @@ package org.mobilenativefoundation.store.store5.util.fake import org.mobilenativefoundation.store.store5.util.model.NoteData internal object NoteCollections { - object Keys { - const val OneAndTwo = "ONE_AND_TWO" - } + object Keys { + const val OneAndTwo = "ONE_AND_TWO" + } - val OneAndTwo = NoteData.Collection(listOf(Notes.One, Notes.Two)) + val OneAndTwo = NoteData.Collection(listOf(Notes.One, Notes.Two)) } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/Notes.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/Notes.kt index cfc4c6e75..28984e205 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/Notes.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/Notes.kt @@ -3,14 +3,14 @@ package org.mobilenativefoundation.store.store5.util.fake import org.mobilenativefoundation.store.store5.util.model.Note internal object Notes { - val One = Note("1", "Title-1", "Content-1") - val Two = Note("2", "Title-2", "Content-2") - val Three = Note("3", "Title-3", "Content-3") - val Four = Note("4", "Title-4", "Content-4") - val Five = Note("5", "Title-5", "Content-5") - val Six = Note("6", "Title-6", "Content-6") - val Seven = Note("7", "Title-7", "Content-7") - val Eight = Note("8", "Title-8", "Content-8") - val Nine = Note("9", "Title-9", "Content-9") - val Ten = Note("10", "Title-10", "Content-10") + val One = Note("1", "Title-1", "Content-1") + val Two = Note("2", "Title-2", "Content-2") + val Three = Note("3", "Title-3", "Content-3") + val Four = Note("4", "Title-4", "Content-4") + val Five = Note("5", "Title-5", "Content-5") + val Six = Note("6", "Title-6", "Content-6") + val Seven = Note("7", "Title-7", "Content-7") + val Eight = Note("8", "Title-8", "Content-8") + val Nine = Note("9", "Title-9", "Content-9") + val Ten = Note("10", "Title-10", "Content-10") } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesApi.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesApi.kt index f7de9e21b..45f4e0634 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesApi.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesApi.kt @@ -7,54 +7,46 @@ import org.mobilenativefoundation.store.store5.util.model.NoteData import org.mobilenativefoundation.store.store5.util.model.NotesWriteResponse internal class NotesApi : TestApi { - internal val db = mutableMapOf() + internal val db = mutableMapOf() - init { - seed() - } + init { + seed() + } - override fun get( - key: NotesKey, - fail: Boolean, - ttl: Long?, - ): NetworkNote { - if (fail) { - throw Exception() - } - - val networkNote = db[key]!! - return if (ttl != null) { - networkNote.copy(ttl = ttl) - } else { - networkNote - } + override fun get(key: NotesKey, fail: Boolean, ttl: Long?): NetworkNote { + if (fail) { + throw Exception() } - override fun post( - key: NotesKey, - value: InputNote, - fail: Boolean, - ): NotesWriteResponse { - if (fail) { - throw Exception() - } - - db[key] = NetworkNote(value.data) - - return NotesWriteResponse(key, true) + val networkNote = db[key]!! + return if (ttl != null) { + networkNote.copy(ttl = ttl) + } else { + networkNote } + } - private fun seed() { - db[NotesKey.Single(Notes.One.id)] = NetworkNote(NoteData.Single(Notes.One)) - db[NotesKey.Single(Notes.Two.id)] = NetworkNote(NoteData.Single(Notes.Two)) - db[NotesKey.Single(Notes.Three.id)] = NetworkNote(NoteData.Single(Notes.Three)) - db[NotesKey.Single(Notes.Four.id)] = NetworkNote(NoteData.Single(Notes.Four)) - db[NotesKey.Single(Notes.Five.id)] = NetworkNote(NoteData.Single(Notes.Five)) - db[NotesKey.Single(Notes.Six.id)] = NetworkNote(NoteData.Single(Notes.Six)) - db[NotesKey.Single(Notes.Seven.id)] = NetworkNote(NoteData.Single(Notes.Seven)) - db[NotesKey.Single(Notes.Eight.id)] = NetworkNote(NoteData.Single(Notes.Eight)) - db[NotesKey.Single(Notes.Nine.id)] = NetworkNote(NoteData.Single(Notes.Nine)) - db[NotesKey.Single(Notes.Ten.id)] = NetworkNote(NoteData.Single(Notes.Ten)) - db[NotesKey.Collection(NoteCollections.Keys.OneAndTwo)] = NetworkNote(NoteCollections.OneAndTwo) + override fun post(key: NotesKey, value: InputNote, fail: Boolean): NotesWriteResponse { + if (fail) { + throw Exception() } + + db[key] = NetworkNote(value.data) + + return NotesWriteResponse(key, true) + } + + private fun seed() { + db[NotesKey.Single(Notes.One.id)] = NetworkNote(NoteData.Single(Notes.One)) + db[NotesKey.Single(Notes.Two.id)] = NetworkNote(NoteData.Single(Notes.Two)) + db[NotesKey.Single(Notes.Three.id)] = NetworkNote(NoteData.Single(Notes.Three)) + db[NotesKey.Single(Notes.Four.id)] = NetworkNote(NoteData.Single(Notes.Four)) + db[NotesKey.Single(Notes.Five.id)] = NetworkNote(NoteData.Single(Notes.Five)) + db[NotesKey.Single(Notes.Six.id)] = NetworkNote(NoteData.Single(Notes.Six)) + db[NotesKey.Single(Notes.Seven.id)] = NetworkNote(NoteData.Single(Notes.Seven)) + db[NotesKey.Single(Notes.Eight.id)] = NetworkNote(NoteData.Single(Notes.Eight)) + db[NotesKey.Single(Notes.Nine.id)] = NetworkNote(NoteData.Single(Notes.Nine)) + db[NotesKey.Single(Notes.Ten.id)] = NetworkNote(NoteData.Single(Notes.Ten)) + db[NotesKey.Collection(NoteCollections.Keys.OneAndTwo)] = NetworkNote(NoteCollections.OneAndTwo) + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesBookkeeping.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesBookkeeping.kt index 61c342820..7adc53f4c 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesBookkeeping.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesBookkeeping.kt @@ -1,47 +1,37 @@ package org.mobilenativefoundation.store.store5.util.fake class NotesBookkeeping { - private val log: MutableMap = mutableMapOf() + private val log: MutableMap = mutableMapOf() - fun setLastFailedSync( - key: NotesKey, - timestamp: Long, - fail: Boolean = false, - ): Boolean { - if (fail) { - throw Exception() - } - log[key] = timestamp - return true + fun setLastFailedSync(key: NotesKey, timestamp: Long, fail: Boolean = false): Boolean { + if (fail) { + throw Exception() } + log[key] = timestamp + return true + } - fun getLastFailedSync( - key: NotesKey, - fail: Boolean = false, - ): Long? { - if (fail) { - throw Exception() - } - - return log[key] + fun getLastFailedSync(key: NotesKey, fail: Boolean = false): Long? { + if (fail) { + throw Exception() } - fun clear( - key: NotesKey, - fail: Boolean = false, - ): Boolean { - if (fail) { - throw Exception() - } - log.remove(key) - return true + return log[key] + } + + fun clear(key: NotesKey, fail: Boolean = false): Boolean { + if (fail) { + throw Exception() } + log.remove(key) + return true + } - fun clear(fail: Boolean = false): Boolean { - if (fail) { - throw Exception() - } - log.clear() - return true + fun clear(fail: Boolean = false): Boolean { + if (fail) { + throw Exception() } + log.clear() + return true + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesConverterProvider.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesConverterProvider.kt index ee89216da..258e3429a 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesConverterProvider.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesConverterProvider.kt @@ -7,14 +7,11 @@ import org.mobilenativefoundation.store.store5.util.model.NetworkNote import org.mobilenativefoundation.store.store5.util.model.OutputNote internal class NotesConverterProvider { - fun provide(): Converter = - Converter.Builder() - .fromOutputToLocal { value -> InputNote(data = value.data, ttl = value.ttl) } - .fromNetworkToLocal { value: NetworkNote -> - InputNote( - data = value.data, - ttl = value.ttl ?: inHours(12), - ) - } - .build() + fun provide(): Converter = + Converter.Builder() + .fromOutputToLocal { value -> InputNote(data = value.data, ttl = value.ttl) } + .fromNetworkToLocal { value: NetworkNote -> + InputNote(data = value.data, ttl = value.ttl ?: inHours(12)) + } + .build() } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesDatabase.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesDatabase.kt index 063a0a3a9..84dc7bdcf 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesDatabase.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesDatabase.kt @@ -4,48 +4,38 @@ import org.mobilenativefoundation.store.store5.util.model.InputNote import org.mobilenativefoundation.store.store5.util.model.OutputNote internal class NotesDatabase { - private val db: MutableMap = mutableMapOf() - - fun put( - key: NotesKey, - input: InputNote, - fail: Boolean = false, - ): Boolean { - if (fail) { - throw Exception() - } - - db[key] = OutputNote(input.data, input.ttl ?: 0) - return true + private val db: MutableMap = mutableMapOf() + + fun put(key: NotesKey, input: InputNote, fail: Boolean = false): Boolean { + if (fail) { + throw Exception() } - fun get( - key: NotesKey, - fail: Boolean = false, - ): OutputNote? { - if (fail) { - throw Exception() - } + db[key] = OutputNote(input.data, input.ttl ?: 0) + return true + } - return db[key] + fun get(key: NotesKey, fail: Boolean = false): OutputNote? { + if (fail) { + throw Exception() } - fun clear( - key: NotesKey, - fail: Boolean = false, - ): Boolean { - if (fail) { - throw Exception() - } - db.remove(key) - return true + return db[key] + } + + fun clear(key: NotesKey, fail: Boolean = false): Boolean { + if (fail) { + throw Exception() } + db.remove(key) + return true + } - fun clear(fail: Boolean = false): Boolean { - if (fail) { - throw Exception() - } - db.clear() - return true + fun clear(fail: Boolean = false): Boolean { + if (fail) { + throw Exception() } + db.clear() + return true + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesKey.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesKey.kt index 25058d3b6..e2c66827b 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesKey.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesKey.kt @@ -1,7 +1,7 @@ package org.mobilenativefoundation.store.store5.util.fake sealed class NotesKey { - data class Single(val id: String) : NotesKey() + data class Single(val id: String) : NotesKey() - data class Collection(val id: String) : NotesKey() + data class Collection(val id: String) : NotesKey() } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesUpdaterProvider.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesUpdaterProvider.kt index 35529028e..f51a7cedd 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesUpdaterProvider.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesUpdaterProvider.kt @@ -7,15 +7,15 @@ import org.mobilenativefoundation.store.store5.util.model.NotesWriteResponse import org.mobilenativefoundation.store.store5.util.model.OutputNote internal class NotesUpdaterProvider(private val api: NotesApi) { - fun provide(): Updater = - Updater.by( - post = { key, input -> - val response = api.post(key, InputNote(input.data, input.ttl ?: 0)) - if (response.ok) { - UpdaterResult.Success.Typed(response) - } else { - UpdaterResult.Error.Message("Failed to sync") - } - }, - ) + fun provide(): Updater = + Updater.by( + post = { key, input -> + val response = api.post(key, InputNote(input.data, input.ttl ?: 0)) + if (response.ok) { + UpdaterResult.Success.Typed(response) + } else { + UpdaterResult.Error.Message("Failed to sync") + } + } + ) } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesValidator.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesValidator.kt index bf7296f22..d08f62b1b 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesValidator.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesValidator.kt @@ -5,9 +5,9 @@ import org.mobilenativefoundation.store.store5.impl.extensions.now import org.mobilenativefoundation.store.store5.util.model.OutputNote internal class NotesValidator(private val expiration: Long = now()) : Validator { - override suspend fun isValid(item: OutputNote): Boolean = - when { - item.ttl == 0L -> true - else -> item.ttl > expiration - } + override suspend fun isValid(item: OutputNote): Boolean = + when { + item.ttl == 0L -> true + else -> item.ttl > expiration + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/HardcodedPages.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/HardcodedPages.kt index d7c7c9441..996c73353 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/HardcodedPages.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/HardcodedPages.kt @@ -1,18 +1,18 @@ package org.mobilenativefoundation.store.store5.util.fake.fallback class HardcodedPages { - val name = "HardcodedPages" - val db = mutableMapOf() + val name = "HardcodedPages" + val db = mutableMapOf() - init { - seed() - } + init { + seed() + } - private fun seed() { - db["1"] = Page.Data("1") - db["2"] = Page.Data("2") - db["3"] = Page.Data("3") - } + private fun seed() { + db["1"] = Page.Data("1") + db["2"] = Page.Data("2") + db["3"] = Page.Data("3") + } - fun get(key: String) = db[key] ?: throw Exception() + fun get(key: String) = db[key] ?: throw Exception() } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/Page.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/Page.kt index ccc456277..62b7ee7b3 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/Page.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/Page.kt @@ -1,10 +1,7 @@ package org.mobilenativefoundation.store.store5.util.fake.fallback sealed class Page { - data class Data( - val title: String, - val ttl: Long? = null, - ) : Page() + data class Data(val title: String, val ttl: Long? = null) : Page() - object Empty : Page() + object Empty : Page() } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/PagesDatabase.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/PagesDatabase.kt index 3c7a0f245..6e5d7e8ba 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/PagesDatabase.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/PagesDatabase.kt @@ -1,15 +1,12 @@ package org.mobilenativefoundation.store.store5.util.fake.fallback class PagesDatabase { - private val db: MutableMap = mutableMapOf() + private val db: MutableMap = mutableMapOf() - fun put( - key: String, - input: Page, - ): Boolean { - db[key] = input - return true - } + fun put(key: String, input: Page): Boolean { + db[key] = input + return true + } - fun get(key: String): Page? = db[key] + fun get(key: String): Page? = db[key] } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/PrimaryPagesApi.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/PrimaryPagesApi.kt index 7c8126702..5fecde1b2 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/PrimaryPagesApi.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/PrimaryPagesApi.kt @@ -1,29 +1,25 @@ package org.mobilenativefoundation.store.store5.util.fake.fallback class PrimaryPagesApi { - val name = "PrimaryPagesApi" + val name = "PrimaryPagesApi" - internal val db = mutableMapOf() + internal val db = mutableMapOf() - init { - seed() - } - - private fun seed() { - db["1"] = Page.Data("1") - db["2"] = Page.Data("2") - db["3"] = Page.Data("3") - } + init { + seed() + } - fun fetch( - key: String, - fail: Boolean, - ttl: Long?, - ): Page { - if (fail) { - throw Exception() - } + private fun seed() { + db["1"] = Page.Data("1") + db["2"] = Page.Data("2") + db["3"] = Page.Data("3") + } - return db[key] ?: Page.Empty + fun fetch(key: String, fail: Boolean, ttl: Long?): Page { + if (fail) { + throw Exception() } + + return db[key] ?: Page.Empty + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/SecondaryPagesApi.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/SecondaryPagesApi.kt index 59c3fb398..d5ba3d8ff 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/SecondaryPagesApi.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/SecondaryPagesApi.kt @@ -1,18 +1,18 @@ package org.mobilenativefoundation.store.store5.util.fake.fallback class SecondaryPagesApi() { - val name: String = "SecondaryPagesApi" - internal val db = mutableMapOf() + val name: String = "SecondaryPagesApi" + internal val db = mutableMapOf() - init { - seed() - } + init { + seed() + } - fun get(key: String) = db[key] ?: throw Exception() + fun get(key: String) = db[key] ?: throw Exception() - private fun seed() { - db["1"] = Page.Data("1") - db["2"] = Page.Data("2") - db["3"] = Page.Data("3") - } + private fun seed() { + db["1"] = Page.Data("1") + db["2"] = Page.Data("2") + db["3"] = Page.Data("3") + } } 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 cce0fc5fa..83c11dca7 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 @@ -3,33 +3,17 @@ package org.mobilenativefoundation.store.store5.util.model import org.mobilenativefoundation.store.store5.util.fake.NotesKey internal sealed class NoteData { - data class Single(val item: Note) : NoteData() + data class Single(val item: Note) : NoteData() - data class Collection(val items: List) : NoteData() + data class Collection(val items: List) : NoteData() } -internal data class NotesWriteResponse( - val key: NotesKey, - val ok: Boolean, -) - -internal data class NetworkNote( - val data: NoteData? = null, - val ttl: Long? = null, -) - -internal data class InputNote( - val data: NoteData? = null, - val ttl: Long? = null, -) - -internal data class OutputNote( - val data: NoteData? = null, - val ttl: Long, -) - -internal data class Note( - val id: String, - val title: String, - val content: String, -) +internal data class NotesWriteResponse(val key: NotesKey, val ok: Boolean) + +internal data class NetworkNote(val data: NoteData? = null, val ttl: Long? = null) + +internal data class InputNote(val data: NoteData? = null, val ttl: Long? = null) + +internal data class OutputNote(val data: NoteData? = null, val ttl: Long) + +internal data class Note(val id: String, val title: String, val content: String) diff --git a/tooling/plugins/build.gradle.kts b/tooling/plugins/build.gradle.kts index 81cc78456..a57e905fd 100644 --- a/tooling/plugins/build.gradle.kts +++ b/tooling/plugins/build.gradle.kts @@ -1,62 +1,62 @@ plugins { - `kotlin-dsl` - alias(libs.plugins.spotless) - alias(libs.plugins.detekt) + `kotlin-dsl` + alias(libs.plugins.spotless) + alias(libs.plugins.detekt) } group = "org.mobilenativefoundation.store" java { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 - toolchain { - languageVersion.set(JavaLanguageVersion.of(11)) - } + toolchain { languageVersion.set(JavaLanguageVersion.of(11)) } } spotless { - kotlin { - ktfmt(libs.versions.ktfmt.get()).googleStyle() - target("src/**/*.kt") - trimTrailingWhitespace() - endWithNewline() - } - - kotlinGradle { - ktfmt(libs.versions.ktfmt.get()).googleStyle() - target("*.kts") - trimTrailingWhitespace() - endWithNewline() - } + kotlin { + ktfmt(libs.versions.ktfmt.get()).googleStyle() + target("src/**/*.kt") + trimTrailingWhitespace() + endWithNewline() + } + + kotlinGradle { + ktfmt(libs.versions.ktfmt.get()).googleStyle() + target("*.kts") + trimTrailingWhitespace() + endWithNewline() + } } detekt { - buildUponDefaultConfig = true - baseline = file("../config/detekt/baseline.xml") - config.setFrom("../../config/detekt/rules.yml") - source.setFrom("src") + buildUponDefaultConfig = true + baseline = file("../config/detekt/baseline.xml") + config.setFrom("../../config/detekt/rules.yml") + source.setFrom("src") } dependencies { - compileOnly(libs.android.gradle.plugin) - compileOnly(libs.kotlin.gradle.plugin) - compileOnly(libs.dokka.gradle.plugin) - compileOnly(libs.maven.publish.plugin) - compileOnly(libs.kmmBridge.gradle.plugin) - compileOnly(libs.atomic.fu.gradle.plugin) + compileOnly(libs.android.gradle.plugin) + compileOnly(libs.kotlin.gradle.plugin) + compileOnly(libs.dokka.gradle.plugin) + compileOnly(libs.maven.publish.plugin) + compileOnly(libs.kmmBridge.gradle.plugin) + compileOnly(libs.atomic.fu.gradle.plugin) } gradlePlugin { - plugins { - register("kotlinMultiplatformConventionPlugin") { - id = "org.mobilenativefoundation.store.multiplatform" - implementationClass = "org.mobilenativefoundation.store.tooling.plugins.KotlinMultiplatformConventionPlugin" - } - - register("androidConventionPlugin") { - id = "org.mobilenativefoundation.store.android" - implementationClass = "org.mobilenativefoundation.store.tooling.plugins.AndroidConventionPlugin" - } + plugins { + register("kotlinMultiplatformConventionPlugin") { + id = "org.mobilenativefoundation.store.multiplatform" + implementationClass = + "org.mobilenativefoundation.store.tooling.plugins.KotlinMultiplatformConventionPlugin" } -} \ No newline at end of file + + register("androidConventionPlugin") { + id = "org.mobilenativefoundation.store.android" + implementationClass = + "org.mobilenativefoundation.store.tooling.plugins.AndroidConventionPlugin" + } + } +} diff --git a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/AndroidConventionPlugin.kt b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/AndroidConventionPlugin.kt index a4cdfcf31..c6cbb7425 100644 --- a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/AndroidConventionPlugin.kt +++ b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/AndroidConventionPlugin.kt @@ -9,45 +9,41 @@ import org.gradle.api.Project import org.gradle.kotlin.dsl.configure class AndroidConventionPlugin : Plugin { - override fun apply(project: Project) = with(project) { - with(pluginManager) { - apply("org.jetbrains.kotlin.android") - apply("com.android.library") - apply("com.vanniktech.maven.publish") - apply("org.jetbrains.dokka") - apply("maven-publish") - apply("org.jetbrains.kotlin.native.cocoapods") - apply("org.jetbrains.kotlinx.binary-compatibility-validator") + override fun apply(project: Project) = + with(project) { + with(pluginManager) { + apply("org.jetbrains.kotlin.android") + apply("com.android.library") + apply("com.vanniktech.maven.publish") + apply("org.jetbrains.dokka") + apply("maven-publish") + apply("org.jetbrains.kotlin.native.cocoapods") + apply("org.jetbrains.kotlinx.binary-compatibility-validator") + } + + extensions.configure { + compileSdk = 34 + + defaultConfig { + minSdk = 24 + targetSdk = 34 } + lint { + disable += "ComposableModifierFactory" + disable += "ModifierFactoryExtensionFunction" + disable += "ModifierFactoryReturnType" + disable += "ModifierFactoryUnreferencedReceiver" + } - extensions.configure { - - compileSdk = 34 - - defaultConfig { - minSdk = 24 - targetSdk = 34 - } - - lint { - disable += "ComposableModifierFactory" - disable += "ModifierFactoryExtensionFunction" - disable += "ModifierFactoryReturnType" - disable += "ModifierFactoryUnreferencedReceiver" - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } + } - configureKotlin() - configureDokka() - configureMavenPublishing() + configureKotlin() + configureDokka() + configureMavenPublishing() } } - - - diff --git a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/KotlinMultiplatformConventionPlugin.kt b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/KotlinMultiplatformConventionPlugin.kt index 29b01be69..eb0c23f8a 100644 --- a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/KotlinMultiplatformConventionPlugin.kt +++ b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/KotlinMultiplatformConventionPlugin.kt @@ -22,191 +22,165 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget class KotlinMultiplatformConventionPlugin : Plugin { - override fun apply(project: Project) = with(project) { - - - with(pluginManager) { - apply("org.jetbrains.kotlin.multiplatform") - apply("org.jetbrains.kotlin.plugin.serialization") - apply("com.android.library") - apply("com.vanniktech.maven.publish") - apply("org.jetbrains.dokka") - apply("co.touchlab.faktory.kmmbridge") - apply("maven-publish") - apply("org.jetbrains.kotlin.native.cocoapods") - apply("kotlinx-atomicfu") - apply("org.jetbrains.kotlinx.binary-compatibility-validator") + override fun apply(project: Project) = + with(project) { + with(pluginManager) { + apply("org.jetbrains.kotlin.multiplatform") + apply("org.jetbrains.kotlin.plugin.serialization") + apply("com.android.library") + apply("com.vanniktech.maven.publish") + apply("org.jetbrains.dokka") + apply("co.touchlab.faktory.kmmbridge") + apply("maven-publish") + apply("org.jetbrains.kotlin.native.cocoapods") + apply("kotlinx-atomicfu") + apply("org.jetbrains.kotlinx.binary-compatibility-validator") + } + + extensions.configure { + applyDefaultHierarchyTemplate() + + androidTarget() + + jvm() + + iosX64() + iosArm64() + iosSimulatorArm64() + + linuxX64() + + js { + browser() + nodejs() } + @OptIn(ExperimentalWasmDsl::class) wasmJs { nodejs() } - extensions.configure { + jvmToolchain(11) - applyDefaultHierarchyTemplate() - - androidTarget() - - jvm() - - iosX64() - iosArm64() - iosSimulatorArm64() - - linuxX64() - - js { - browser() - nodejs() - } - - @OptIn(ExperimentalWasmDsl::class) - wasmJs { - nodejs() - } - - jvmToolchain(11) - - targets.all { - compilations.all { - compilerOptions.configure { - freeCompilerArgs.add("-Xexpect-actual-classes") - } - } - } - - targets.withType().configureEach { - compilations.configureEach { - compilerOptions.configure { - freeCompilerArgs.add("-Xallocator=custom") - freeCompilerArgs.add("-XXLanguage:+ImplicitSignedToUnsignedIntegerConversion") - freeCompilerArgs.add("-Xadd-light-debug=enable") - - freeCompilerArgs.addAll( - "-opt-in=kotlin.RequiresOptIn", - "-opt-in=kotlin.time.ExperimentalTime", - "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", - "-opt-in=kotlinx.coroutines.FlowPreview", - "-opt-in=kotlinx.cinterop.ExperimentalForeignApi", - "-opt-in=kotlinx.cinterop.BetaInteropApi", - ) - } - } - } - - sourceSets.all { - languageSettings.apply { - optIn("kotlin.contracts.ExperimentalContracts") - optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") - optIn("kotlin.RequiresOptIn") - } - } + targets.all { + compilations.all { + compilerOptions.configure { freeCompilerArgs.add("-Xexpect-actual-classes") } + } + } - sourceSets.getByName("commonTest") { - dependencies { - implementation(kotlin("test")) - } + targets.withType().configureEach { + compilations.configureEach { + compilerOptions.configure { + freeCompilerArgs.add("-Xallocator=custom") + freeCompilerArgs.add("-XXLanguage:+ImplicitSignedToUnsignedIntegerConversion") + freeCompilerArgs.add("-Xadd-light-debug=enable") + + freeCompilerArgs.addAll( + "-opt-in=kotlin.RequiresOptIn", + "-opt-in=kotlin.time.ExperimentalTime", + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-opt-in=kotlinx.coroutines.FlowPreview", + "-opt-in=kotlinx.cinterop.ExperimentalForeignApi", + "-opt-in=kotlinx.cinterop.BetaInteropApi", + ) } + } + } - sourceSets.getByName("jvmTest") { - dependencies { - implementation(kotlin("test-junit")) - } - } + sourceSets.all { + languageSettings.apply { + optIn("kotlin.contracts.ExperimentalContracts") + optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") + optIn("kotlin.RequiresOptIn") + } + } - sourceSets.getByName("nativeMain") { - dependsOn(sourceSets.getByName("commonMain")) - } + sourceSets.getByName("commonTest") { dependencies { implementation(kotlin("test")) } } + sourceSets.getByName("jvmTest") { dependencies { implementation(kotlin("test-junit")) } } - } + sourceSets.getByName("nativeMain") { dependsOn(sourceSets.getByName("commonMain")) } + } - configureKotlin() - configureAndroid() - configureDokka() - configureMavenPublishing() - addGithubPackagesRepository() - configureKmmBridge() - configureAtomicFu() + configureKotlin() + configureAndroid() + configureDokka() + configureMavenPublishing() + addGithubPackagesRepository() + configureKmmBridge() + configureAtomicFu() } } - fun Project.configureKotlin() { - configureJava() + configureJava() } fun Project.configureJava() { - java { - toolchain { - languageVersion.set(JavaLanguageVersion.of(11)) - } - } + java { toolchain { languageVersion.set(JavaLanguageVersion.of(11)) } } } fun Project.configureAndroid() { - android { - sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") - compileSdk = Versions.COMPILE_SDK - defaultConfig { - minSdk = Versions.MIN_SDK - targetSdk = Versions.TARGET_SDK - } - lint { - disable += "ComposableModifierFactory" - disable += "ModifierFactoryExtensionFunction" - disable += "ModifierFactoryReturnType" - disable += "ModifierFactoryUnreferencedReceiver" - } + android { + sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") + compileSdk = Versions.COMPILE_SDK + defaultConfig { + minSdk = Versions.MIN_SDK + targetSdk = Versions.TARGET_SDK + } + lint { + disable += "ComposableModifierFactory" + disable += "ModifierFactoryExtensionFunction" + disable += "ModifierFactoryReturnType" + disable += "ModifierFactoryUnreferencedReceiver" + } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } + } } +fun Project.android(action: LibraryExtension.() -> Unit) = + extensions.configure(action) -fun Project.android(action: LibraryExtension.() -> Unit) = extensions.configure(action) - -private fun Project.java(action: JavaPluginExtension.() -> Unit) = extensions.configure(action) - +private fun Project.java(action: JavaPluginExtension.() -> Unit) = + extensions.configure(action) object Versions { - const val COMPILE_SDK = 34 - const val MIN_SDK = 24 - const val TARGET_SDK = 34 - const val STORE = "5.1.0-alpha06" + const val COMPILE_SDK = 34 + const val MIN_SDK = 24 + const val TARGET_SDK = 34 + const val STORE = "5.1.0-alpha06" } - -fun Project.configureMavenPublishing() = extensions.configure { +fun Project.configureMavenPublishing() = + extensions.configure { publishToMavenCentral(S01) signAllPublications() -} - + } -fun Project.configureKmmBridge() = extensions.configure { +fun Project.configureKmmBridge() = + extensions.configure { githubReleaseArtifacts() githubReleaseVersions() versionPrefix.set(Versions.STORE) spm() -} + } fun Project.configureAtomicFu() = - extensions.configure { - transformJvm = false - transformJs = false - } + extensions.configure { + transformJvm = false + transformJs = false + } -fun Project.configureDokka() = tasks.withType().configureEach { +fun Project.configureDokka() = + tasks.withType().configureEach { dokkaSourceSets.configureEach { - reportUndocumented.set(false) - skipDeprecated.set(true) - jdkVersion.set(11) + reportUndocumented.set(false) + skipDeprecated.set(true) + jdkVersion.set(11) } -} + } fun Project.android(name: String) { - android { - namespace = "org.mobilenativefoundation.store.$name" - } + android { namespace = "org.mobilenativefoundation.store.$name" } }