From 8383c22c0cd23536c49e92e4162acde68fe9a393 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 26 Mar 2023 17:28:51 +0200 Subject: [PATCH 01/51] docs: Created a migration guide for future breaking changes --- README.md | 1 + docs/MIGRATION_GUIDE.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 docs/MIGRATION_GUIDE.md diff --git a/README.md b/README.md index e6a2c11..63c6029 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ If you are interested in another platform, we encourage contributions that add t - [Release list](https://gitlab.com/opensavvy/pedestal/-/releases) - [Artifact list](https://gitlab.com/opensavvy/pedestal/-/packages) +- [Breaking changes migration guide](docs/MIGRATION_GUIDE.md) ## Development diff --git a/docs/MIGRATION_GUIDE.md b/docs/MIGRATION_GUIDE.md new file mode 100644 index 0000000..a3f3339 --- /dev/null +++ b/docs/MIGRATION_GUIDE.md @@ -0,0 +1 @@ +# Migration guide -- 2.51.2 From 42cac1b7feaffe80985cd50f619d389b507ae659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 26 Mar 2023 17:49:42 +0200 Subject: [PATCH 02/51] build(gradle): Create the modules 'progress' and 'progress-coroutines' --- README.md | 5 ++- progress-coroutines/build.gradle.kts | 60 ++++++++++++++++++++++++++++ progress/build.gradle.kts | 57 ++++++++++++++++++++++++++ settings.gradle.kts | 2 + 4 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 progress-coroutines/build.gradle.kts create mode 100644 progress/build.gradle.kts diff --git a/README.md b/README.md index 63c6029..4c56686 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,10 @@ The release notes and changelogs are [available here](https://gitlab.com/opensav This project contains the following modules: - [Pedestal Logger](logger/README.md) is a simple logger implementation for multiplatform projects. -- [Pedestal State](state/README.md) is a state management library based on KotlinX.Coroutines and Arrow to represent values that change over time, domain failures, and progression of long-running tasks. +- [Pedestal Progress](progress/README.md) models asynchronous task progression ( + compatibility: [KotlinX.Coroutines](progress-coroutines/README.md)). +- [Pedestal State](state/README.md) is a state management library based on KotlinX.Coroutines and Arrow to represent + values that change over time, domain failures, and progression of long-running tasks. - [Pedestal Cache](cache/README.md) is a collection of cache implementations for Pedestal State which helps with reducing network traffic on the entire stack. - [Pedestal Backbone](backbone/README.md) helps with exposing multiple implementations of the same API transparently, facilitating architectural modifications with automatic caching and state management for performant reactive applications. - [Pedestal Spine](spine/README.md) (experimental) declares typesafe Kotlin API endpoints that can be easily used from any web server or client. diff --git a/progress-coroutines/build.gradle.kts b/progress-coroutines/build.gradle.kts new file mode 100644 index 0000000..688b9a7 --- /dev/null +++ b/progress-coroutines/build.gradle.kts @@ -0,0 +1,60 @@ +@file:Suppress("UNUSED_VARIABLE") + +import java.net.URL + +plugins { + id("opensavvy.gradle.library") +} + +kotlin { + jvm() + js(IR) { + browser() + nodejs() + } + iosSimulatorArm64() + iosArm64() + iosX64() + + sourceSets { + val commonMain by getting { + dependencies { + api(projects.progress) + api(KotlinX.coroutines.core) + + implementation(projects.logger) + } + } + + val commonTest by getting { + dependencies { + implementation(projects.tester) + + api(KotlinX.coroutines.test) + } + } + } +} + +kover { + verify { + rule { + name = "Minimal code coverage" + bound { + minValue = 80 + } + } + } +} + +tasks.withType().configureEach { + dokkaSourceSets.configureEach { + includes.from("${project.projectDir}/README.md") + + sourceLink { + localDirectory.set(file("src")) + remoteUrl.set(URL("https://gitlab.com/opensavvy/pedestal/-/blob/main/progress-coroutines/src")) + remoteLineSuffix.set("#L") + } + } +} diff --git a/progress/build.gradle.kts b/progress/build.gradle.kts new file mode 100644 index 0000000..340dc08 --- /dev/null +++ b/progress/build.gradle.kts @@ -0,0 +1,57 @@ +@file:Suppress("UNUSED_VARIABLE") + +import java.net.URL + +plugins { + id("opensavvy.gradle.library") +} + +kotlin { + jvm() + js(IR) { + browser() + nodejs() + } + iosSimulatorArm64() + iosArm64() + iosX64() + + sourceSets { + val commonMain by getting { + dependencies { + implementation(projects.logger) + } + } + + val commonTest by getting { + dependencies { + implementation(projects.tester) + + api(KotlinX.coroutines.test) + } + } + } +} + +kover { + verify { + rule { + name = "Minimal code coverage" + bound { + minValue = 80 + } + } + } +} + +tasks.withType().configureEach { + dokkaSourceSets.configureEach { + includes.from("${project.projectDir}/README.md") + + sourceLink { + localDirectory.set(file("src")) + remoteUrl.set(URL("https://gitlab.com/opensavvy/pedestal/-/blob/main/progress/src")) + remoteLineSuffix.set("#L") + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 2af5fbc..a011e01 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,6 +11,8 @@ plugins { include( "state", + "progress", + "progress-coroutines", "cache", "backbone", "spine", -- 2.51.2 From 5a8857e9f6be9e67b5dbcb860fcbe11b80cff95e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 26 Mar 2023 19:03:14 +0200 Subject: [PATCH 03/51] feat(progress): Extract the Progress interface into the progress library --- progress/src/commonMain/kotlin/Progression.kt | 108 ++++++++++++++++++ progress/src/commonTest/kotlin/DoneTest.kt | 12 ++ .../kotlin/UnquantifiedLoadingTest.kt | 13 +++ 3 files changed, 133 insertions(+) create mode 100644 progress/src/commonMain/kotlin/Progression.kt create mode 100644 progress/src/commonTest/kotlin/DoneTest.kt create mode 100644 progress/src/commonTest/kotlin/UnquantifiedLoadingTest.kt diff --git a/progress/src/commonMain/kotlin/Progression.kt b/progress/src/commonMain/kotlin/Progression.kt new file mode 100644 index 0000000..8f6dda6 --- /dev/null +++ b/progress/src/commonMain/kotlin/Progression.kt @@ -0,0 +1,108 @@ +package opensavvy.progress + +import opensavvy.progress.Progress.Done +import opensavvy.progress.Progress.Loading +import opensavvy.progress.Progress.Loading.Quantified +import opensavvy.progress.Progress.Loading.Unquantified + +/** + * Is work currently happening? + * + * Values of this type can be in two different states: + * - [Done]: no work is happening. + * - [Loading]: work is happening. + * + * For ease of use, the factory functions [done] and [loading] are provided. + * + * ### Implementation notes + * + * All implementations of [Progress] must be deeply immutable. + * To communicate the advancement of progress over time, report multiple progress objects. + */ +sealed interface Progress { + + /** + * No work is currently happening. + * + * For ease of use, see the factory [done]. + */ + object Done : Progress { + override fun toString() = "Done" + } + + /** + * Work is currently happening. + * + * The ongoing work can be [quantified][Quantified] or [unquantified][Unquantified]. + * Unquantified work gives no information on its progress, whereas quantified work is able to return some information to the user. + */ + sealed interface Loading : Progress { + + /** + * Work is currently happening, but we have no information on its progression. + * + * For ease of use, see the factory [loading]. + */ + object Unquantified : Loading { + override fun toString() = "Loading" + } + + /** + * Work is currently happening, and we have some information on its progression. + * + * By default, the progression is stored as the ratio of how much work has been done so far and the total amount + * of work expected. This ratio is available in two formats, [normalized] and [percent]. If this ratio of work + * is enough for your usage, see [loading]. + * + * New implementations of this interface can be provided by downstream users to provide more information + * (e.g. bandwidth, estimated time of completion…). + */ + interface Quantified : Loading { + + /** + * The normalized ratio of the amount of work done to the total amount of work to be done. + * + * Allowed values are between `0.0` and `1.0`, both inclusive. + * - `0.0` means the work has not started (nothing was done out of the total), + * - `1.0` means the work has finished (everything was done out of the total). + * + * Although `1.0` is semantically the same as [Progress.Done], it is still a legal value to simplify usage. + * + * @see percent + */ + val normalized: Double + + /** + * The percentage of the amount of work done and the total amount of work to be done. + * + * Allowed values are between `0` and `100`, both inclusive. + * - `0` means the work has not started (nothing was done out of the total), + * - `100` means the work has finished (everything was done out of the total). + * + * Although `100` is semantically the same as [Progress.Done], it is still a legal value to simplify usage. + * + * @see normalized + */ + val percent: Int + get() = (normalized * 100).toInt() + } + } + + companion object +} + +/** + * All scheduled work for this task has been done. + * + * This method is a convenience factory for [Progress.Done]. + * See also [loading]. + */ +fun done() = Done + +/** + * Some scheduled work is ongoing, but we have no information on how much is left. + * + * This method is a convenience factory for [Progress.Loading.Unquantified]. + * See also [Done]. + */ +fun loading() = Unquantified diff --git a/progress/src/commonTest/kotlin/DoneTest.kt b/progress/src/commonTest/kotlin/DoneTest.kt new file mode 100644 index 0000000..9ce7095 --- /dev/null +++ b/progress/src/commonTest/kotlin/DoneTest.kt @@ -0,0 +1,12 @@ +package opensavvy.progress + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DoneTest { + + @Test + fun string() { + assertEquals("Done", done().toString()) + } +} diff --git a/progress/src/commonTest/kotlin/UnquantifiedLoadingTest.kt b/progress/src/commonTest/kotlin/UnquantifiedLoadingTest.kt new file mode 100644 index 0000000..53f41f6 --- /dev/null +++ b/progress/src/commonTest/kotlin/UnquantifiedLoadingTest.kt @@ -0,0 +1,13 @@ +package opensavvy.progress + +import kotlin.test.Test +import kotlin.test.assertEquals + +class UnquantifiedLoadingTest { + + @Test + fun string() { + assertEquals("Loading", loading().toString()) + } + +} -- 2.51.2 From d35b9eae70dbcb9cd46a7ade2f1362b2f0f012fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 26 Mar 2023 19:03:53 +0200 Subject: [PATCH 04/51] feat(progress): Expose a simple quantified loading implementation --- .../kotlin/SimpleLoadingImplementation.kt | 41 ++++++++++++++++ .../kotlin/SimpleLoadingImplementationTest.kt | 49 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 progress/src/commonMain/kotlin/SimpleLoadingImplementation.kt create mode 100644 progress/src/commonTest/kotlin/SimpleLoadingImplementationTest.kt diff --git a/progress/src/commonMain/kotlin/SimpleLoadingImplementation.kt b/progress/src/commonMain/kotlin/SimpleLoadingImplementation.kt new file mode 100644 index 0000000..584d2fe --- /dev/null +++ b/progress/src/commonMain/kotlin/SimpleLoadingImplementation.kt @@ -0,0 +1,41 @@ +package opensavvy.pedestal.progress + +private class SimpleLoadingImplementation( + override val normalized: Double, +) : Progress.Loading.Quantified { + + init { + require(normalized in 0.0..1.0) { "The normalized progression should be a value between 0 and 1, found $normalized" } + } + + //region Equals & hashCode + //They both use the percent value instead of the normalized value to avoid floating-point precision errors + + override fun hashCode(): Int { + return percent.hashCode() + } + + override fun equals(other: Any?): Boolean { + if (other === null) return false + if (other === this) return true + + if (other !is SimpleLoadingImplementation) return false + + return other.percent == percent + } + + //endregion + + override fun toString() = "Loading($percent%)" +} + +/** + * Some scheduled work is ongoing, and we can estimate the ratio of what has been done compared to what's left to do. + * + * See also [done]. + * + * @param progress A normalized progress, see [Progress.Loading.Quantified.normalized]. + */ +fun loading( + progress: Double, +): Progress.Loading.Quantified = SimpleLoadingImplementation(progress) diff --git a/progress/src/commonTest/kotlin/SimpleLoadingImplementationTest.kt b/progress/src/commonTest/kotlin/SimpleLoadingImplementationTest.kt new file mode 100644 index 0000000..3c5ed1a --- /dev/null +++ b/progress/src/commonTest/kotlin/SimpleLoadingImplementationTest.kt @@ -0,0 +1,49 @@ +package opensavvy.pedestal.progress + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails + +class SimpleLoadingImplementationTest { + + @Test + fun normalized0() { + assertEquals(0.0, loading(0.0).normalized) + } + + @Test + fun normalizedThird() { + assertEquals(0.33, loading(0.33).normalized) + } + + @Test + fun normalized1() { + assertEquals(1.0, loading(1.0).normalized) + } + + @Test + fun normalizedIllegalValues() { + assertFails { loading(-1.0) } + assertFails { loading(1.01) } + assertFails { loading(1.0000001) } + assertFails { loading(-0.000001) } + assertFails { loading(Double.MAX_VALUE) } + assertFails { loading(Double.NEGATIVE_INFINITY) } + assertFails { loading(Double.POSITIVE_INFINITY) } + } + + @Test + fun percent0() { + assertEquals(0, loading(0.0).percent) + } + + @Test + fun percentThird() { + assertEquals(33, loading(0.33).percent) + } + + @Test + fun percent100() { + assertEquals(100, loading(1.0).percent) + } +} -- 2.51.2 From 7a6e0d9504a1121ca0259566f5001d47a2a23980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 1 Apr 2023 19:14:17 +0200 Subject: [PATCH 05/51] feat(progress): Extract the Progress interface into the progress library --- progress/src/commonMain/kotlin/SimpleLoadingImplementation.kt | 2 +- .../src/commonTest/kotlin/SimpleLoadingImplementationTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/progress/src/commonMain/kotlin/SimpleLoadingImplementation.kt b/progress/src/commonMain/kotlin/SimpleLoadingImplementation.kt index 584d2fe..92560fa 100644 --- a/progress/src/commonMain/kotlin/SimpleLoadingImplementation.kt +++ b/progress/src/commonMain/kotlin/SimpleLoadingImplementation.kt @@ -1,4 +1,4 @@ -package opensavvy.pedestal.progress +package opensavvy.progress private class SimpleLoadingImplementation( override val normalized: Double, diff --git a/progress/src/commonTest/kotlin/SimpleLoadingImplementationTest.kt b/progress/src/commonTest/kotlin/SimpleLoadingImplementationTest.kt index 3c5ed1a..4cd4273 100644 --- a/progress/src/commonTest/kotlin/SimpleLoadingImplementationTest.kt +++ b/progress/src/commonTest/kotlin/SimpleLoadingImplementationTest.kt @@ -1,4 +1,4 @@ -package opensavvy.pedestal.progress +package opensavvy.progress import kotlin.test.Test import kotlin.test.assertEquals -- 2.51.2 From 6534300b5e2b356931ccad7ea77bb0f85ac64a29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 26 Mar 2023 19:04:09 +0200 Subject: [PATCH 06/51] docs(progress): Document the progress module --- progress/README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 progress/README.md diff --git a/progress/README.md b/progress/README.md new file mode 100644 index 0000000..c1e1a1b --- /dev/null +++ b/progress/README.md @@ -0,0 +1,28 @@ +# Module progress + +Micro-library to represent task progress. + +The `progress-coroutines` companion library allows Kotlin coroutines to report progress to their callers. + +### Usage + +```kotlin +import opensavvy.progress.* + +println("Current progress: ${done()}") // Current progress: Done +println("Current progress: ${loading()}") // Current progress: Loading +println("Current progress: ${loading(0.5)}") // Current progress: Loading(50%) +``` + +For more information, see [done][opensavvy.progress.done] and [loading][opensavvy.progress.loading]. + +### Introduction + +Progress information is organized around the [Progress][opensavvy.progress.Progress] interface. + +- [Done][opensavvy.progress.Progress.Done] (singleton): the work is over +- [Loading][opensavvy.progress.Progress.Loading]: the work is ongoing + - [Unquantified][opensavvy.progress.Progress.Loading.Unquantified] (singleton): we have no knowledge of its + progress + - [Quantified][opensavvy.progress.Progress.Loading.Quantified]: we have some knowledge of its progress. You + can implement this interface to add new information. -- 2.51.2 From 5e665005d5ffefc3eae3d0bbb71926e314ab771a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 26 Mar 2023 20:53:18 +0200 Subject: [PATCH 07/51] feat(progress): Declare the ProgressReporter interface --- progress/README.md | 4 +++ .../kotlin/report/ProgressReporter.kt | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 progress/src/commonMain/kotlin/report/ProgressReporter.kt diff --git a/progress/README.md b/progress/README.md index c1e1a1b..b6a1b22 100644 --- a/progress/README.md +++ b/progress/README.md @@ -26,3 +26,7 @@ Progress information is organized around the [Progress][opensavvy.progress.Progr progress - [Quantified][opensavvy.progress.Progress.Loading.Quantified]: we have some knowledge of its progress. You can implement this interface to add new information. + +The SAM interface [ProgressReporter][opensavvy.progress.report.ProgressReporter] can be injected into a +sub-task to report progress information to a parent task, for example using context receivers, or through the coroutine +context via the companion library `progress-coroutines`. diff --git a/progress/src/commonMain/kotlin/report/ProgressReporter.kt b/progress/src/commonMain/kotlin/report/ProgressReporter.kt new file mode 100644 index 0000000..9d1991e --- /dev/null +++ b/progress/src/commonMain/kotlin/report/ProgressReporter.kt @@ -0,0 +1,26 @@ +package opensavvy.progress.report + +import opensavvy.progress.Progress + +/** + * SAM interface to communicate progress to a caller. + * + * Instances of this interface can be created by a caller and passed to a downstream user. + * The downstream user can then call the [report] function to communicate its progress to the caller. + */ +fun interface ProgressReporter { + + /** + * Reports that the current task has reached [progress]. + */ + fun report(progress: Progress) + + companion object + + /** + * A [ProgressReporter] implementation that does nothing. + */ + object NoOp : ProgressReporter { + override fun report(progress: Progress) {} + } +} -- 2.51.2 From b2a0e21bb16bb4b3bdcec811ce32bcc848230ce9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 26 Mar 2023 20:53:47 +0200 Subject: [PATCH 08/51] feat(progress): Create a progress reporter mapper --- .../kotlin/report/MapProgressReporter.kt | 21 ++++++++++++ .../kotlin/report/MapProgressReporterTest.kt | 32 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 progress/src/commonMain/kotlin/report/MapProgressReporter.kt create mode 100644 progress/src/commonTest/kotlin/report/MapProgressReporterTest.kt diff --git a/progress/src/commonMain/kotlin/report/MapProgressReporter.kt b/progress/src/commonMain/kotlin/report/MapProgressReporter.kt new file mode 100644 index 0000000..a1f7ba9 --- /dev/null +++ b/progress/src/commonMain/kotlin/report/MapProgressReporter.kt @@ -0,0 +1,21 @@ +package opensavvy.progress.report + +import opensavvy.progress.Progress + +private class MapProgressReporter( + private val upstream: ProgressReporter, + private val transform: (Progress) -> Progress, +) : ProgressReporter { + + override fun report(progress: Progress) { + upstream.report(transform(progress)) + } + + override fun toString() = "Mapped($upstream)" +} + +/** + * Creates a new reporter that applies [transform] to each progress event it receives. + */ +fun ProgressReporter.map(transform: (Progress) -> Progress): ProgressReporter = + MapProgressReporter(this, transform) diff --git a/progress/src/commonTest/kotlin/report/MapProgressReporterTest.kt b/progress/src/commonTest/kotlin/report/MapProgressReporterTest.kt new file mode 100644 index 0000000..3854237 --- /dev/null +++ b/progress/src/commonTest/kotlin/report/MapProgressReporterTest.kt @@ -0,0 +1,32 @@ +package opensavvy.progress.report + +import opensavvy.progress.Progress +import opensavvy.progress.done +import opensavvy.progress.loading +import kotlin.test.Test +import kotlin.test.assertEquals + +class MapProgressReporterTest { + + @Test + fun replaceByDone() { + var value: Progress? = null + + val reporter = ProgressReporter { value = it } + .map { done() } + + reporter.report(loading(0.5)) + assertEquals(done(), value) + } + + @Test + fun addOne() { + var value: Progress? = null + + val reporter = ProgressReporter { value = it } + .map { it as Progress.Loading.Quantified; loading(it.normalized + 0.2) } + + reporter.report(loading(0.2)) + assertEquals(loading(0.4), value) + } +} -- 2.51.2 From 30289d59b287c518da17e5c147b49554defbfab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 26 Mar 2023 21:06:46 +0200 Subject: [PATCH 09/51] feat(progress): Create a progress reporter reducer for sub-tasks --- .../report/IntervalReduceProgressReporter.kt | 63 ++++++++++++++ .../IntervalReduceProgressReporterTest.kt | 86 +++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 progress/src/commonMain/kotlin/report/IntervalReduceProgressReporter.kt create mode 100644 progress/src/commonTest/kotlin/report/IntervalReduceProgressReporterTest.kt diff --git a/progress/src/commonMain/kotlin/report/IntervalReduceProgressReporter.kt b/progress/src/commonMain/kotlin/report/IntervalReduceProgressReporter.kt new file mode 100644 index 0000000..f25ba68 --- /dev/null +++ b/progress/src/commonMain/kotlin/report/IntervalReduceProgressReporter.kt @@ -0,0 +1,63 @@ +package opensavvy.progress.report + +import opensavvy.progress.Progress +import opensavvy.progress.loading +import opensavvy.progress.report.IntervalReduceProgressReporter.Companion.defaultDone +import opensavvy.progress.report.IntervalReduceProgressReporter.Companion.defaultUnquantified + +private class IntervalReduceProgressReporter( + private val upstream: ProgressReporter, + private val min: Double, + private val max: Double, + private val convertDone: Progress.Loading.Quantified, + private val convertUnquantified: Progress.Loading.Quantified, +) : ProgressReporter { + + init { + require(min < max) { "The minimum ($min) should be strictly inferior to the maximum ($max)" } + } + + private val width = max - min + + override fun report(progress: Progress) { + val received = when (progress) { + is Progress.Loading.Quantified -> progress + Progress.Loading.Unquantified -> convertUnquantified + Progress.Done -> convertDone + } + + upstream.report(loading(min + (received.normalized * width))) + } + + companion object { + val defaultDone = loading(1.0) + val defaultUnquantified = loading(0.5) + } +} + +/** + * Creates a new reporter that proportionally confines progress events to the [min]..[max] range. + * + * @param treatDoneAs When a [Progress.Done] is received, it will be treated as if that value was received. + * @param treatUnquantifiedAs When a [Progress.Loading.Unquantified] is received, it will be treated as if that value was received. + */ +fun ProgressReporter.reduceToInterval( + min: Double, + max: Double, + treatDoneAs: Progress.Loading.Quantified = defaultDone, + treatUnquantifiedAs: Progress.Loading.Quantified = defaultUnquantified, +): ProgressReporter = + IntervalReduceProgressReporter(this, min, max, treatDoneAs, treatUnquantifiedAs) + +/** + * Creates a new reporter that proportionally confines progress events to [interval]. + * + * @param treatDoneAs When a [Progress.Done] is received, it will be treated as if that value was received. + * @param treatUnquantifiedAs When a [Progress.Loading.Unquantified] is received, it will be treated as if that value was received. + */ +fun ProgressReporter.reduceToInterval( + interval: ClosedFloatingPointRange, + treatDoneAs: Progress.Loading.Quantified = defaultDone, + treatUnquantifiedAs: Progress.Loading.Quantified = defaultUnquantified, +) = + reduceToInterval(interval.start, interval.endInclusive, treatDoneAs, treatUnquantifiedAs) diff --git a/progress/src/commonTest/kotlin/report/IntervalReduceProgressReporterTest.kt b/progress/src/commonTest/kotlin/report/IntervalReduceProgressReporterTest.kt new file mode 100644 index 0000000..682d850 --- /dev/null +++ b/progress/src/commonTest/kotlin/report/IntervalReduceProgressReporterTest.kt @@ -0,0 +1,86 @@ +package opensavvy.progress.report + +import opensavvy.progress.Progress +import opensavvy.progress.done +import opensavvy.progress.loading +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails + +class IntervalReduceProgressReporterTest { + + @Test + fun cannotCreateInvalidRange() { + assertFails { + ProgressReporter { } + .reduceToInterval(0.7, 0.2) + } + } + + @Test + fun reduceInterval() { + var value: Progress? = null + + val reporter = ProgressReporter { value = it } + .reduceToInterval(0.2..0.4) + + reporter.report(loading(0.1)) + assertEquals(loading(0.22), value) + } + + @Test + fun reduceMinMax() { + var value: Progress? = null + + val reporter = ProgressReporter { value = it } + .reduceToInterval(0.2, 0.4) + + reporter.report(loading(0.1)) + assertEquals(loading(0.22), value) + } + + @Test + fun reduceDone() { + var value: Progress? = null + + val reporter = ProgressReporter { value = it } + .reduceToInterval(0.2..0.4) + + reporter.report(done()) + assertEquals(loading(0.4), value) + } + + @Test + fun reduceUnquantified() { + var value: Progress? = null + + val reporter = ProgressReporter { value = it } + .reduceToInterval(0.2..0.4) + + reporter.report(loading()) + assertEquals(loading(0.3), value) + } + + @Test + fun reduceZero() { + var value: Progress? = null + + val reporter = ProgressReporter { value = it } + .reduceToInterval(0.2..0.4) + + reporter.report(loading(0.0)) + assertEquals(loading(0.2), value) + } + + @Test + fun reduceOne() { + var value: Progress? = null + + val reporter = ProgressReporter { value = it } + .reduceToInterval(0.2..0.4) + + reporter.report(loading(1.0)) + assertEquals(loading(0.4), value) + } + +} -- 2.51.2 From a39423a9a7dd71c34cba678f97a4545aff51a8ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 26 Mar 2023 21:11:04 +0200 Subject: [PATCH 10/51] feat(progress-coroutines): Store the progress reporter in a CoroutineContext --- .../kotlin/CoroutineProgressReporter.kt | 53 +++++++++++++++++++ .../kotlin/CoroutineProgressReporterTest.kt | 44 +++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 progress-coroutines/src/commonMain/kotlin/CoroutineProgressReporter.kt create mode 100644 progress-coroutines/src/commonTest/kotlin/CoroutineProgressReporterTest.kt diff --git a/progress-coroutines/src/commonMain/kotlin/CoroutineProgressReporter.kt b/progress-coroutines/src/commonMain/kotlin/CoroutineProgressReporter.kt new file mode 100644 index 0000000..3d607f7 --- /dev/null +++ b/progress-coroutines/src/commonMain/kotlin/CoroutineProgressReporter.kt @@ -0,0 +1,53 @@ +package opensavvy.progress.coroutines + +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.withContext +import opensavvy.progress.Progress +import opensavvy.progress.report.ProgressReporter +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext + +/** + * Captures progress information about the currently running task. + * + * KotlinX.Coroutines uses a [CoroutineContext] object throughout `suspend` functions to store information about the + * currently-running task. To report the current [Progress] of a coroutine, [CoroutineProgressReporter] is added to its context. + * + * + */ +class CoroutineProgressReporter( + private val reporter: ProgressReporter, +) : AbstractCoroutineContextElement(Key), + ProgressReporter by reporter { + + override fun toString() = reporter.toString() + + object Key : CoroutineContext.Key +} + +/** + * Wraps this [ProgressReporter] into a [CoroutineProgressReporter] so it can be inserted in a [CoroutineContext]. + */ +fun ProgressReporter.asCoroutineContext() = + CoroutineProgressReporter(this) + +/** + * Reports [progress] to the progress reporter stored in the [currentCoroutineContext]. + * + * If there is no progress reporter in the current context, this function does nothing. + * + * @see CoroutineProgressReporter + */ +suspend fun report(progress: Progress) { + val reporter = currentCoroutineContext()[CoroutineProgressReporter.Key] ?: return + reporter.report(progress) +} + +/** + * Captures all calls to [report] in [block] and transmits them to [onProgress]. + */ +suspend fun reportProgress(onProgress: (Progress) -> Unit, block: suspend () -> Unit) { + withContext(ProgressReporter { onProgress(it) }.asCoroutineContext()) { + block() + } +} diff --git a/progress-coroutines/src/commonTest/kotlin/CoroutineProgressReporterTest.kt b/progress-coroutines/src/commonTest/kotlin/CoroutineProgressReporterTest.kt new file mode 100644 index 0000000..a7bbe19 --- /dev/null +++ b/progress-coroutines/src/commonTest/kotlin/CoroutineProgressReporterTest.kt @@ -0,0 +1,44 @@ +package opensavvy.progress.coroutines + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import opensavvy.progress.Progress +import opensavvy.progress.loading +import opensavvy.progress.report.ProgressReporter +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class CoroutineProgressReporterTest { + + @Test + fun report() = runTest { + var value: Progress? = null + + val reporter = ProgressReporter { value = it } + .asCoroutineContext() + + withContext(reporter) { + report(loading(0.2)) + } + + assertEquals(loading(0.2), value) + } + + @Test + fun reportWithoutReporter() = runTest { + report(loading(0.2)) // does nothing, doesn't crash + } + + @Test + fun withReporter() = runTest { + var value: Progress? = null + + reportProgress({ value = it }) { + report(loading(0.2)) + } + + assertEquals(loading(0.2), value) + } +} -- 2.51.2 From f56bbd38504d108137c08c2438b734b4e6a25cec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 26 Mar 2023 21:36:21 +0200 Subject: [PATCH 11/51] feat(progress-coroutines): Create the mapProgressTo helper --- .../kotlin/ProgressReportTransformer.kt | 42 +++++++++++++++++++ .../kotlin/ProgressReportTransformerTest.kt | 38 +++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 progress-coroutines/src/commonMain/kotlin/ProgressReportTransformer.kt create mode 100644 progress-coroutines/src/commonTest/kotlin/ProgressReportTransformerTest.kt diff --git a/progress-coroutines/src/commonMain/kotlin/ProgressReportTransformer.kt b/progress-coroutines/src/commonMain/kotlin/ProgressReportTransformer.kt new file mode 100644 index 0000000..c75fb95 --- /dev/null +++ b/progress-coroutines/src/commonMain/kotlin/ProgressReportTransformer.kt @@ -0,0 +1,42 @@ +package opensavvy.progress.coroutines + +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.withContext +import opensavvy.progress.loading +import opensavvy.progress.report.ProgressReporter +import opensavvy.progress.report.reduceToInterval + +/** + * Passes the current [ProgressReporter] to [createChildReporter] to create a new child reporter of the current coroutine, + * and adds it to [block]'s context. + */ +suspend fun transformProgress( + createChildReporter: (ProgressReporter) -> ProgressReporter, + block: suspend () -> R +): R { + val upstreamReporter = currentCoroutineContext()[CoroutineProgressReporter.Key] + ?: ProgressReporter.NoOp + + val reporter = createChildReporter(upstreamReporter) + .asCoroutineContext() + + return withContext(reporter) { + block() + } +} + +/** + * Reduces progress events emitted in [block] to fit into [progressInterval]. + * + * @see reduceToInterval + * @see transformProgress + */ +suspend fun mapProgressTo(progressInterval: ClosedFloatingPointRange, block: suspend () -> R): R = + transformProgress({ it.reduceToInterval(progressInterval) }) { + try { + report(loading(0.0)) + block() + } finally { + report(loading(1.0)) + } + } diff --git a/progress-coroutines/src/commonTest/kotlin/ProgressReportTransformerTest.kt b/progress-coroutines/src/commonTest/kotlin/ProgressReportTransformerTest.kt new file mode 100644 index 0000000..1cdf288 --- /dev/null +++ b/progress-coroutines/src/commonTest/kotlin/ProgressReportTransformerTest.kt @@ -0,0 +1,38 @@ +package opensavvy.progress.coroutines + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import opensavvy.progress.Progress +import opensavvy.progress.loading +import opensavvy.progress.report.ProgressReporter +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class ProgressReportTransformerTest { + + @Test + fun test() = runTest { + var value: Progress? = null + + withContext(ProgressReporter { value = it }.asCoroutineContext()) { + report(loading(0.1)) + assertEquals(loading(0.1), value) + + mapProgressTo(0.2..0.5) { + report(loading(0.0)) + assertEquals(loading(0.2), value) + + report(loading(0.5)) + assertEquals(loading(0.35), value) + + report(loading(1.0)) + assertEquals(loading(0.5), value) + } + + report(loading(0.9)) + assertEquals(loading(0.9), value) + } + } +} -- 2.51.2 From 7a867a08a95130a55f91454afec372a3da43eafd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 26 Mar 2023 21:44:15 +0200 Subject: [PATCH 12/51] feat(progress-coroutines): Create a StateFlow-based progress reporter --- .../kotlin/StateFlowProgressReporter.kt | 22 ++++++++++ .../kotlin/StateFlowProgressReporterTest.kt | 43 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 progress-coroutines/src/commonMain/kotlin/StateFlowProgressReporter.kt create mode 100644 progress-coroutines/src/commonTest/kotlin/StateFlowProgressReporterTest.kt diff --git a/progress-coroutines/src/commonMain/kotlin/StateFlowProgressReporter.kt b/progress-coroutines/src/commonMain/kotlin/StateFlowProgressReporter.kt new file mode 100644 index 0000000..49ef436 --- /dev/null +++ b/progress-coroutines/src/commonMain/kotlin/StateFlowProgressReporter.kt @@ -0,0 +1,22 @@ +package opensavvy.progress.coroutines + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import opensavvy.progress.Progress +import opensavvy.progress.loading +import opensavvy.progress.report.ProgressReporter + +/** + * [ProgressReporter] implementation which stores the latest progress information in [progress], a [StateFlow]. + */ +class StateFlowProgressReporter : ProgressReporter { + private val state = MutableStateFlow(loading(0.0)) + + val progress = state as StateFlow + + override fun report(progress: Progress) { + state.value = progress + } + + override fun toString() = state.value.toString() +} diff --git a/progress-coroutines/src/commonTest/kotlin/StateFlowProgressReporterTest.kt b/progress-coroutines/src/commonTest/kotlin/StateFlowProgressReporterTest.kt new file mode 100644 index 0000000..fa32072 --- /dev/null +++ b/progress-coroutines/src/commonTest/kotlin/StateFlowProgressReporterTest.kt @@ -0,0 +1,43 @@ +package opensavvy.progress.coroutines + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.flow.transformWhile +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import opensavvy.progress.done +import opensavvy.progress.loading +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class StateFlowProgressReporterTest { + + @Test + fun test() = runTest { + val reporter = StateFlowProgressReporter() + + launch { + reporter.report(loading(0.2)) + yield() + + reporter.report(loading(0.3)) + yield() + + assertEquals("Loading(30%)", reporter.toString()) + + reporter.report(done()) + yield() + } + + val expected = listOf( + loading(0.0), + loading(0.2), + loading(0.3), + done(), + ) + + assertEquals(expected, reporter.progress.transformWhile { emit(it); it != done() }.toList()) + } +} -- 2.51.2 From 30d65a1d31baaa4596d3220235a42885f6643f23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 26 Mar 2023 22:13:24 +0200 Subject: [PATCH 13/51] docs(progress-coroutines): Document the progress-coroutines module --- progress-coroutines/README.md | 118 ++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 progress-coroutines/README.md diff --git a/progress-coroutines/README.md b/progress-coroutines/README.md new file mode 100644 index 0000000..be7112c --- /dev/null +++ b/progress-coroutines/README.md @@ -0,0 +1,118 @@ +# Module progress-coroutines + +Compatibility layer for `progress` and KotlinX.coroutines. + +Using the utility functions declared in this project, it is possible to report progress events from any coroutine +without changing its signature. Simply call the [report][opensavvy.progress.coroutines.report] function: + +```kotlin +import opensavvy.progress.coroutines.* + +suspend fun foo() { + report(loading(0.0)) // 0% loading + + someExpensiveComputation() + report(loading(0.5)) // 50% loading + + someOtherExpensiveComputation() + report(loading(1.0)) // 100% loading +} +``` + +Reporting progression events only has an effect if a parent coroutine is listening to them. Otherwise, the `report` +function does nothing—ensuring the behavior of your program is not changed when you introduce progress reporting +step-by-step in your codebase. + +Any [ProgressReporter][opensavvy.progress.report.ProgressReporter] can listen to `report` calls, via +the [asCoroutineContext][opensavvy.progress.coroutines.asCoroutineContext] conversion function which converts +it into a [CoroutineContext][kotlin.coroutines.CoroutineContext] element: + +```kotlin +suspend fun main() { + val reporter = ProgressReporter { println("Progress: $it") } + .asCoroutineContext() + + withContext(reporter) { + task() + } +} + +suspend fun task() { + report(loading(0.2)) + delay(1000) + report(loading(0.7)) +} +``` + +```text +Progress: Loading(20%) +Progress: Loading(70%) +``` + +The same result can be achieved using the +convenience [reportProgress][opensavvy.progress.coroutines.reportProgress] function: + +```kotlin +suspend fun main() { + reportProgress({ println("Progress: $it") }) { + task() + } +} +``` + +Instead of reacting with a callback on progress events, it is also possible to collect events into +a [StateFlow][kotlinx.coroutines.flow.StateFlow] +using [StateFlowReporter][opensavvy.progress.coroutines.StateFlowReporter]. + +Finally, sub-tasks can be represented with the help +of [mapProgressTo][opensavvy.progress.coroutines.mapProgressTo]: + +```kotlin +suspend fun main() { + reportProgress({ println("Progress: $it") }) { + task() + } +} + +suspend fun task() { + report(loading(0.0)) + + mapProgressTo(0.2..0.5) { + println("Task 1") + task1() + } + + mapProgressTo(0.5..1.0) { + println("Task 2") + task2() + } + + report(done()) +} + +suspend fun task1() { + report(loading(0.0)) + delay(1000) + report(done()) +} + +suspend fun task2() { + report(loading(0.0)) + delay(500) + report(loading(0.5)) + delay(500) + report(done()) +} +``` + +```text +Progress: Loading(0%) +Task 1 +Progress: Loading(20%) +Progress: Loading(50%) +Task 2 +Progress: Loading(50%) +Progress: Loading(75%) +Progress: Loading(100%) +Progress: Done +``` -- 2.51.2 From 35bb3c29c73bd5f3765a5f7c5fb092c6da1fbaf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 1 Apr 2023 18:20:48 +0200 Subject: [PATCH 14/51] ci(gitlab): Test the progress and progress-coroutines projects --- .gitlab-ci.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5419ad4..5927c71 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -133,6 +133,32 @@ logger:convert-coverage: variables: module: logger +progress:test:jvm: + extends: [ .test-jvm ] + variables: + module: progress + +progress:convert-coverage: + extends: [ .test-jvm-convert-coverage ] + needs: + - job: progress:test:jvm + artifacts: true + variables: + module: progress + +progress-coroutines:test:jvm: + extends: [ .test-jvm ] + variables: + module: progress-coroutines + +progress-coroutines:convert-coverage: + extends: [ .test-jvm-convert-coverage ] + needs: + - job: progress-coroutines:test:jvm + artifacts: true + variables: + module: progress-coroutines + state:test:jvm: extends: [ .test-jvm ] variables: -- 2.51.2 From e7f7d33ec66399d7aa2f1b155b55667521e92386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 1 Apr 2023 18:21:25 +0200 Subject: [PATCH 15/51] ci(gitlab): Publish the progress and progress-coroutines projects --- .gitlab-ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5927c71..eb6c66f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -41,6 +41,8 @@ publish: - > ./gradlew logger:publishAllPublicationsToGitLabRepository + progress:publishAllPublicationsToGitLabRepository + progress-coroutines:publishAllPublicationsToGitLabRepository state:publishAllPublicationsToGitLabRepository cache:publishAllPublicationsToGitLabRepository backbone:publishAllPublicationsToGitLabRepository @@ -60,6 +62,8 @@ publish:ios: - > ./gradlew logger:publishIosArm64PublicationToGitLabRepository + progress:publishIosArm64PublicationToGitLabRepository + progress-coroutines:publishIosArm64PublicationToGitLabRepository state:publishIosArm64PublicationToGitLabRepository cache:publishIosArm64PublicationToGitLabRepository backbone:publishIosArm64PublicationToGitLabRepository @@ -67,6 +71,8 @@ publish:ios: - > ./gradlew logger:publishIosSimulatorArm64PublicationToGitLabRepository + progress:publishIosSimulatorArm64PublicationToGitLabRepository + progress-coroutines:publishIosSimulatorArm64PublicationToGitLabRepository state:publishIosSimulatorArm64PublicationToGitLabRepository cache:publishIosSimulatorArm64PublicationToGitLabRepository backbone:publishIosSimulatorArm64PublicationToGitLabRepository @@ -74,6 +80,8 @@ publish:ios: - > ./gradlew logger:publishIosX64PublicationToGitLabRepository + progress:publishIosX64PublicationToGitLabRepository + progress-coroutines:publishIosX64PublicationToGitLabRepository state:publishIosX64PublicationToGitLabRepository cache:publishIosX64PublicationToGitLabRepository backbone:publishIosX64PublicationToGitLabRepository -- 2.51.2 From 595462e63a5c49a85cb6510512d892bd2992791f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 1 Apr 2023 18:26:42 +0200 Subject: [PATCH 16/51] refactor: Replace the old Progression by the new Progress --- .../src/commonMain/kotlin/ExpirationCache.kt | 4 +- cache/src/commonTest/kotlin/CacheTest.kt | 28 +-- .../src/commonMain/kotlin/Client.kt | 68 ++++--- state/build.gradle.kts | 3 + state/src/commonMain/kotlin/Progression.kt | 134 ------------- .../commonMain/kotlin/ProgressionReporter.kt | 176 ------------------ .../kotlin/progressive/Accessors.kt | 9 +- .../commonMain/kotlin/progressive/Builder.kt | 19 +- .../commonMain/kotlin/progressive/Capture.kt | 28 ++- .../kotlin/progressive/Conditional.kt | 12 +- .../kotlin/progressive/Progressive.kt | 14 +- state/src/commonTest/kotlin/OutcomeTest.kt | 19 +- .../kotlin/ProgressionReporterTest.kt | 29 ++- .../src/commonTest/kotlin/ProgressionTest.kt | 2 +- .../kotlin/ProgressiveOutcomeTest.kt | 43 ++--- 15 files changed, 147 insertions(+), 441 deletions(-) delete mode 100644 state/src/commonMain/kotlin/Progression.kt delete mode 100644 state/src/commonMain/kotlin/ProgressionReporter.kt diff --git a/cache/src/commonMain/kotlin/ExpirationCache.kt b/cache/src/commonMain/kotlin/ExpirationCache.kt index 7f7476f..e63f89b 100644 --- a/cache/src/commonMain/kotlin/ExpirationCache.kt +++ b/cache/src/commonMain/kotlin/ExpirationCache.kt @@ -10,7 +10,7 @@ import kotlinx.datetime.Instant import opensavvy.cache.ExpirationCache.Companion.expireAfter import opensavvy.logger.Logger.Companion.trace import opensavvy.logger.loggerFor -import opensavvy.state.Progression +import opensavvy.progress.done import opensavvy.state.progressive.ProgressiveOutcome import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext @@ -84,7 +84,7 @@ class ExpirationCache( override fun get(id: I): Flow> = upstream[id] .onEach { - if (it.progress == Progression.Done) + if (it.progress == done()) markAsUpdatedNow(id) // else: it's still loading, no need to count it as done } diff --git a/cache/src/commonTest/kotlin/CacheTest.kt b/cache/src/commonTest/kotlin/CacheTest.kt index 643b640..26313bd 100644 --- a/cache/src/commonTest/kotlin/CacheTest.kt +++ b/cache/src/commonTest/kotlin/CacheTest.kt @@ -3,7 +3,8 @@ package opensavvy.cache import kotlinx.coroutines.* -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.test.runTest import opensavvy.cache.BatchingCacheAdapter.Companion.batchingCache import opensavvy.cache.CacheAdapter.Companion.cache @@ -13,10 +14,11 @@ import opensavvy.logger.LogLevel import opensavvy.logger.Logger.Companion.debug import opensavvy.logger.Logger.Companion.info import opensavvy.logger.loggerFor -import opensavvy.state.* -import opensavvy.state.Progression.Companion.loading -import opensavvy.state.ProgressionReporter.Companion.report -import opensavvy.state.outcome.* +import opensavvy.progress.Progress +import opensavvy.progress.coroutines.report +import opensavvy.progress.loading +import opensavvy.state.outcome.ensureValid +import opensavvy.state.outcome.orThrow import opensavvy.state.progressive.ProgressiveOutcome import opensavvy.state.progressive.firstValue import kotlin.test.Test @@ -48,22 +50,22 @@ class CacheTest { val one = cache[IntId(1)] val minus = cache[IntId(-1)] - assertEquals(0, zero.firstValue().orNull()) - assertEquals(1, one.firstValue().orNull()) - assertEquals(null, minus.firstValue().orNull()) + assertEquals(0, zero.firstValue().getOrNull()) + assertEquals(1, one.firstValue().getOrNull()) + assertEquals(null, minus.firstValue().getOrNull()) } private suspend fun testUpdateExpire(cache: Cache) { log.info { "Checking normal behavior" } - assertEquals(0, cache[IntId(0)].firstValue().orNull()) + assertEquals(0, cache[IntId(0)].firstValue().getOrNull()) log.info { "Overwriting with a different value" } cache.update(IntId(0), 5) - assertEquals(5, cache[IntId(0)].firstValue().orNull()) + assertEquals(5, cache[IntId(0)].firstValue().getOrNull()) log.info { "Expiring the value re-downloads and replaces our fake value" } cache.expire(IntId(0)) - assertEquals(0, cache[IntId(0)].firstValue().orNull()) + assertEquals(0, cache[IntId(0)].firstValue().getOrNull()) } private suspend fun testAutoExpiration(cache: Cache) { @@ -236,7 +238,7 @@ class CacheTest { log.info { "Forcing an update with an incorrect value" } cache.update(IntId(1), 5) // Wait for the cache to update - while (result == ProgressiveOutcome.Success(1) || result.progress !is Progression.Done) { + while (result == ProgressiveOutcome.Success(1) || result.progress !is Progress.Done) { yield() } assertEquals(ProgressiveOutcome.Success(5), result) @@ -244,7 +246,7 @@ class CacheTest { log.info { "Expiring the value to see the cache fix itself" } cache.expire(IntId(1)) // Wait for the cache to update - while (result == ProgressiveOutcome.Success(5) || result.progress !is Progression.Done) { + while (result == ProgressiveOutcome.Success(5) || result.progress !is Progress.Done) { yield() } assertEquals(ProgressiveOutcome.Success(1), result) diff --git a/spine-ktor/spine-ktor-client/src/commonMain/kotlin/Client.kt b/spine-ktor/spine-ktor-client/src/commonMain/kotlin/Client.kt index 3f5ea74..81068af 100644 --- a/spine-ktor/spine-ktor-client/src/commonMain/kotlin/Client.kt +++ b/spine-ktor/spine-ktor-client/src/commonMain/kotlin/Client.kt @@ -5,6 +5,7 @@ import io.ktor.client.call.* import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* +import opensavvy.progress.coroutines.mapProgressTo import opensavvy.spine.Id import opensavvy.spine.Operation import opensavvy.spine.Parameters @@ -12,9 +13,6 @@ import opensavvy.spine.ResourceGroup.AbstractResource import opensavvy.spine.ktor.toHttp import opensavvy.spine.ktor.toSpine import opensavvy.state.Failure -import opensavvy.state.Progression.Companion.loading -import opensavvy.state.ProgressionReporter.Companion.report -import opensavvy.state.ProgressionReporter.Companion.transformQuantifiedProgress import opensavvy.state.outcome.out /** @@ -52,52 +50,50 @@ suspend inline fun Unit = {}, crossinline configuration: HttpRequestBuilder.() -> Unit = {}, ) = out { - report(loading(0.0)) - - transformQuantifiedProgress({ loading(it.normalized / 10) }) { + mapProgressTo(0.0..0.1) { operation.validate(id, input, parameters, context).bind() } - report(loading(0.1)) - - val result = request { - method = operation.kind.toHttp() + val result = mapProgressTo(0.1..0.9) { + request { + method = operation.kind.toHttp() - url { - // {baseUrl}/{service}/{path-to-resource}/{path-to-method} + url { + // {baseUrl}/{service}/{path-to-resource}/{path-to-method} - // /{service} - appendPathSegments(id.service.segment) + // /{service} + appendPathSegments(id.service.segment) - // /{path-to-resource} - appendPathSegments(id.resource.segments.map { it.segment }) + // /{path-to-resource} + appendPathSegments(id.resource.segments.map { it.segment }) - // /{path-to-method} (if present) - operation.route?.let { route -> - appendPathSegments(route.segments.map { it.segment }) + // /{path-to-method} (if present) + operation.route?.let { route -> + appendPathSegments(route.segments.map { it.segment }) + } } - } - for ((name, value) in parameters.data) - parameter(name, value) + for ((name, value) in parameters.data) + parameter(name, value) - contentType(contentType) - setBody(input) + contentType(contentType) + setBody(input) - configuration() + configuration() + } } - report(loading(0.90)) - - onResponse(result) - - report(loading(0.95)) + mapProgressTo(0.9..0.95) { + onResponse(result) + } - if (result.status.isSuccess()) { - result.body() - } else { - val body = result.body().ifBlank { "${result.status} with no provided body" } - val kind = result.status.toSpine() - shift(Failure(kind, body)) + mapProgressTo(0.95..1.0) { + if (result.status.isSuccess()) { + result.body() + } else { + val body = result.body().ifBlank { "${result.status} with no provided body" } + val kind = result.status.toSpine() + shift(Failure(kind, body)) + } } } diff --git a/state/build.gradle.kts b/state/build.gradle.kts index e350bf3..f259527 100644 --- a/state/build.gradle.kts +++ b/state/build.gradle.kts @@ -22,6 +22,9 @@ kotlin { api(KotlinX.coroutines.core) api("io.arrow-kt:arrow-core:_") + api(projects.progress) + api(projects.progressCoroutines) + implementation(projects.logger) } } diff --git a/state/src/commonMain/kotlin/Progression.kt b/state/src/commonMain/kotlin/Progression.kt deleted file mode 100644 index 60a2100..0000000 --- a/state/src/commonMain/kotlin/Progression.kt +++ /dev/null @@ -1,134 +0,0 @@ -package opensavvy.state - -/** - * Is work currently happening? - * - * Values of this type can be in two different states: - * - [Done]: no work is happening. - * - [Loading]: work is happening, more information may be available depending on the subtype. - * - * For ease of use, the factory functions [done] and [loading] are provided. - */ -sealed interface Progression { - - /** - * No work is currently happening. - * - * For ease of use, see the factory [done]. - */ - object Done : Progression { - override fun toString() = "Done" - } - - /** - * Work is currently happening. - * - * [Unquantified] work is work for which no progression information is available. - * It is built with the factory [loading()][loading]. - * - * [Quantified] work is work for which progression information is available. - * By default, the [loading(Double)][loading] factory stores progression as a percentage point of the work to be done. - * If more information is available (e.g. bandwidth, bitrate, estimated time of completion…), it is possible to create - * your own class which implements [Quantified]. - */ - sealed interface Loading : Progression { - - /** - * Work is currently happening, but we have no information on its progression. - * - * For ease of use, see the factory [loading()][loading]. - */ - object Unquantified : Loading { - override fun toString() = "Loading" - } - - /** - * Work is currently happening, and we have some information on its progression. - * - * By default, the progression is stored as the ratio of how much work has been done so far to the total amount - * of work expected. - * This ratio is available in two formats, see [normalized] and [percent]. - * If this ratio of work is enough for your usage, see the factory [loading(Double)][loading]. - * - * To store more information (e.g. bandwidth, estimated time of completion…), implement this interface for your - * own objects. - */ - interface Quantified : Loading { - - /** - * The normalized ratio of the amount of work done and the total amount of work to be done. - * - * Allowed values are between `0.0` and `1.0`, both inclusive. - * - `0.0` means the work has not started (nothing was done out of the total), - * - `1.0` means the work has finished (everything was done out of the total). - * - * Although `1.0` is semantically the same as [Progression.Done], it is still a legal value to simplify usage. - * - * @see percent - */ - val normalized: Double - - /** - * The percentage of the amount of work done and the total amount of work to be done. - * - * Allowed values are between `0` and `100`, both inclusive. - * - `0` means the work has not started (nothing was done out of the total), - * - `100` means the work has finished (everything was done out of the total). - * - * Although `100` is semantically the same as [Progression.Done], it is still a legal value to simplify usage. - * - * @see normalized - */ - val percent: Int - get() = (normalized * 100).toInt() - - } - - /** - * Basic implementation of [Quantified]. - * - * You should rarely need to interact with this class. - * To create an instance, see [loading]. - * - * When downcasting to access information about the progression, we recommend downcasting to [Quantified] instead - * of to this class. This will let your codebase handle other implementations of [Quantified] transparently. - */ - // The coding style says 'Implementation' should not appear in class names. - // This class is more or less for internal use, it shouldn't appear in user's code anyway. - data class QuantifiedImplementation(override val normalized: Double) : Quantified { - - init { - require(normalized in 0.0..1.0) { "The progression should be a value between 0 and 1, found $normalized" } - } - - override fun toString() = "Loading($percent%)" - } - } - - companion object { - /** - * Returns [Done]. - * - * @see loading - */ - fun done() = Done - - /** - * Returns a [Loading] instance with no particular progression information. - * - * @see done - * @see Loading.Unquantified - */ - fun loading() = Loading.Unquantified - - /** - * Returns a [Loading] instance with a given [progression]. - * - * @param progression Progression information, between `0.0` (just started, no progression made so far) and `1.0` (completely finished). - * @see done - * @see Loading.Quantified - * @see Loading.Quantified.normalized - */ - fun loading(progression: Double) = Loading.QuantifiedImplementation(progression) - } -} diff --git a/state/src/commonMain/kotlin/ProgressionReporter.kt b/state/src/commonMain/kotlin/ProgressionReporter.kt deleted file mode 100644 index 09d54dd..0000000 --- a/state/src/commonMain/kotlin/ProgressionReporter.kt +++ /dev/null @@ -1,176 +0,0 @@ -package opensavvy.state - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.withContext -import opensavvy.state.Progression.Companion.loading -import opensavvy.state.ProgressionReporter.Companion.progressionReporter -import opensavvy.state.ProgressionReporter.Companion.report -import opensavvy.state.ProgressionReporter.Companion.transformProgress -import opensavvy.state.ProgressionReporter.Companion.transformQuantifiedProgress -import opensavvy.state.ProgressionReporter.StateFlowReporter -import kotlin.contracts.ExperimentalContracts -import kotlin.contracts.InvocationKind -import kotlin.contracts.contract -import kotlin.coroutines.AbstractCoroutineContextElement -import kotlin.coroutines.CoroutineContext - -/** - * Stores progression information about the currently running task. - * - * Coroutines pass a [CoroutineContext] object around which stores information about the currently-running process. - * To report the current [Progression] of a coroutine, [ProgressionReporter] can be added to the context of a coroutine. - * It is then possible to access the current progression using [progress][StateFlowReporter.progress]. - * ```kotlin - * val scope = CoroutineScope() - * val reporter = progressionReporter() - * - * scope.launch { - * withContext(reporter) { - * repeat(100) { - * delay(10) - * report(loading(it / 100.0)) - * } - * } - * } - * - * reporter.progress.collect { - * println("Current progress: $it") - * } - * ``` - * - * To instantiate a progression reporter, see [progressionReporter]. - * To report the current loading, see [report]. - * To manage subtasks, see [transformProgress] and [transformQuantifiedProgress]. - */ -abstract class ProgressionReporter : AbstractCoroutineContextElement(Key) { - - /** - * Notifies this reporter of the current [progression]. - */ - abstract suspend fun emit(progression: Progression) - - /** - * Implementation of [ProgressionReporter] using a [StateFlow]. - * - * The internal StateFlow is exposed as [progress]. - */ - class StateFlowReporter : ProgressionReporter() { - private val state = MutableStateFlow(loading()) - - /** - * The current reported progress. - */ - val progress = state as StateFlow - - override suspend fun emit(progression: Progression) { - state.value = progression - } - - override fun toString() = state.value.toString() - } - - private class ChildReporter( - private val parent: ProgressionReporter, - private val transform: (Progression) -> Progression, - ) : ProgressionReporter() { - override suspend fun emit(progression: Progression) { - parent.emit(transform(progression)) - } - } - - object Key : CoroutineContext.Key - - companion object { - - //region Builders - - /** - * Creates a default [ProgressionReporter]. - */ - fun progressionReporter() = StateFlowReporter() - - /** - * Creates a [ProgressionReporter] instance that calls the provided [callback] every time a new progression - * event is received. - */ - fun callbackReporter( - callback: suspend (Progression) -> Unit, - ) = object : ProgressionReporter() { - override suspend fun emit(progression: Progression) { - callback(progression) - } - - override fun toString() = "ProgressionReporter.Callback@${hashCode().toString(16)}" - } - - /** - * Declares [block] as a subtask of the current coroutine. - * - * Inside [block], all progression events are passed to [transform] before being transmitted to the parent coroutine. - */ - @OptIn(ExperimentalContracts::class) - suspend fun transformProgress( - transform: (Progression) -> Progression, - block: suspend CoroutineScope.() -> Unit, - ) { - contract { - callsInPlace(block, InvocationKind.AT_MOST_ONCE) - } - - val reporter = currentCoroutineContext()[Key] - - if (reporter != null) - withContext(ChildReporter(reporter, transform), block) - } - - /** - * Declares [block] as a subtask of the current coroutine. - * - * Inside [block], loading events are processed by this function. - * - if the coroutine reports a [quantified loading][Progression.Loading.Quantified], it is passed through - * [transform] and emitted to the parent coroutine, - * - if the coroutine reports an [unquantified loading][Progression.Loading.Unquantified], it is passed through - * [transform] as a quantified loading of 50% and emitted to the parent coroutine, - * - if the coroutine reports a [done][Progression.Done], it is passed through unchanged to the parent. - */ - @OptIn(ExperimentalContracts::class) - suspend fun transformQuantifiedProgress( - transform: (Progression.Loading.Quantified) -> Progression, - block: suspend CoroutineScope.() -> Unit, - ) { - contract { - callsInPlace(block, InvocationKind.AT_MOST_ONCE) - } - - val reporter = currentCoroutineContext()[Key] - - if (reporter != null) - withContext( - ChildReporter(reporter) { - when (it) { - is Progression.Loading.Quantified -> transform(it) - is Progression.Loading.Unquantified -> transform(loading(0.5)) - Progression.Done -> it - } - }, - block - ) - } - - //endregion - - /** - * Report [progression] to the [ProgressionReporter] in the current coroutine context. - * - * If there is currently no [ProgressionReporter], this function does nothing. - */ - @Suppress("MemberVisibilityCanBePrivate") - suspend fun report(progression: Progression) { - val reporter = currentCoroutineContext()[Key] ?: return - reporter.emit(progression) - } - } -} diff --git a/state/src/commonMain/kotlin/progressive/Accessors.kt b/state/src/commonMain/kotlin/progressive/Accessors.kt index 89bcb24..94f111c 100644 --- a/state/src/commonMain/kotlin/progressive/Accessors.kt +++ b/state/src/commonMain/kotlin/progressive/Accessors.kt @@ -3,8 +3,9 @@ package opensavvy.state.progressive import arrow.core.left import arrow.core.right import kotlinx.coroutines.flow.* -import opensavvy.state.Progression -import opensavvy.state.ProgressionReporter.Companion.report +import opensavvy.progress.Progress +import opensavvy.progress.coroutines.report +import opensavvy.progress.done import opensavvy.state.outcome.Outcome import opensavvy.state.progressive.ProgressiveOutcome.* import opensavvy.state.progressive.ProgressiveOutcome.Companion.component1 @@ -48,7 +49,7 @@ fun ProgressiveOutcome.asOutcome(): Outcome? = when (this) { * All progress information is re-emitted in the calling flow. */ fun Flow>.filterDone() = onEach { report(it.progress) } - .filter { it.progress == Progression.done() } + .filter { it.progress == done() } .mapNotNull { it.asOutcome() } /** @@ -64,7 +65,7 @@ suspend fun Flow>.firstValue() = filterDone() /** * Splits this progressive outcome into its outcome and progress information. */ -fun Flow>.asOutcomeAndProgress(): Flow?, Progression>> = +fun Flow>.asOutcomeAndProgress(): Flow?, Progress>> = map { (out, progress) -> out to progress } //endregion diff --git a/state/src/commonMain/kotlin/progressive/Builder.kt b/state/src/commonMain/kotlin/progressive/Builder.kt index 4e7333a..f555493 100644 --- a/state/src/commonMain/kotlin/progressive/Builder.kt +++ b/state/src/commonMain/kotlin/progressive/Builder.kt @@ -4,9 +4,10 @@ import arrow.core.continuations.EffectScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.flow +import opensavvy.progress.Progress +import opensavvy.progress.done +import opensavvy.progress.report.ProgressReporter import opensavvy.state.Failure -import opensavvy.state.Progression -import opensavvy.state.ProgressionReporter import opensavvy.state.outcome.Outcome import opensavvy.state.outcome.out @@ -15,7 +16,7 @@ import opensavvy.state.outcome.out * * Because regular outcomes cannot be unfinished, this function never returns [ProgressiveOutcome.Empty]. */ -fun Outcome.withProgress(progress: Progression = Progression.done()) = fold( +fun Outcome.withProgress(progress: Progress = done()) = fold( ifLeft = { ProgressiveOutcome.Failure(it, progress) }, ifRight = { ProgressiveOutcome.Success(it, progress) }, ) @@ -23,7 +24,7 @@ fun Outcome.withProgress(progress: Progression = Progression.done()) = fo /** * Replaces the [progress] information from this progressive outcome. */ -fun ProgressiveOutcome.copy(progress: Progression.Loading) = when (this) { +fun ProgressiveOutcome.copy(progress: Progress.Loading) = when (this) { is ProgressiveOutcome.Empty -> ProgressiveOutcome.Empty(progress) is ProgressiveOutcome.Failure -> ProgressiveOutcome.Failure(failure, progress) is ProgressiveOutcome.Success -> ProgressiveOutcome.Success(value, progress) @@ -32,10 +33,10 @@ fun ProgressiveOutcome.copy(progress: Progression.Loading) = when (this) /** * Performs some calculation which may fail, capturing all progression events in the process. * - * For performance reasons, the [ProgressionReporter.report] function is shadowed by [ProgressiveOutcomeCollector.report], which - * bypasses [ProgressionReporter] and directly pushes the event into the resulting flow. - * Calls to [ProgressionReporter.report] are not affected by this function (they pass through to the closest - * parent [ProgressionReporter]). If you wish to capture them, use [captureProgress]. + * For performance reasons, the [ProgressReporter.report] function is shadowed by [ProgressiveOutcomeCollector.report], which + * bypasses [ProgressReporter] and directly pushes the event into the resulting flow. + * Calls to [ProgressReporter.report] are not affected by this function (they pass through to the closest + * parent [ProgressReporter]). If you wish to capture them, use [captureProgress]. */ fun progressive(block: suspend ProgressiveOutcomeCollector.() -> T): Flow> = flow { emit( @@ -50,7 +51,7 @@ class ProgressiveOutcomeCollector( private val effectScope: EffectScope, ) : EffectScope by effectScope { - suspend fun report(progress: Progression.Loading) { + suspend fun report(progress: Progress.Loading) { flowCollector.emit(ProgressiveOutcome.Empty(progress)) } } diff --git a/state/src/commonMain/kotlin/progressive/Capture.kt b/state/src/commonMain/kotlin/progressive/Capture.kt index 4f6305e..a0b8dba 100644 --- a/state/src/commonMain/kotlin/progressive/Capture.kt +++ b/state/src/commonMain/kotlin/progressive/Capture.kt @@ -3,20 +3,30 @@ package opensavvy.state.progressive import kotlinx.coroutines.channels.ProducerScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.withContext -import opensavvy.state.Progression -import opensavvy.state.ProgressionReporter +import opensavvy.logger.Logger.Companion.warn +import opensavvy.logger.loggerFor +import opensavvy.progress.Progress +import opensavvy.progress.coroutines.CoroutineProgressReporter import opensavvy.state.outcome.Outcome -private val ProducerScope>.progressExtractor: ProgressionReporter - get() = ProgressionReporter.callbackReporter { - if (it is Progression.Loading) - send(ProgressiveOutcome.Empty(it)) +private val ProducerScope>.progressExtractor: CoroutineProgressReporter + get() = CoroutineProgressReporter { + if (it is Progress.Loading) { + val result = trySend(ProgressiveOutcome.Empty(it)) + + if (result.isFailure) { + loggerFor(this).warn( + it, + result + ) { "Could not send the progression event to the channel, it has been ignored" } + } + } // else: the action has reported that it's over, but the results haven't reached us yet, they'll probably - // arrive just afterwards + // arrive just afterward } /** - * Captures the progress information of the current operation using [ProgressionReporter]. + * Captures the progress information of the current operation using [CoroutineProgressReporter]. * * Because flows cannot emit from multiple coroutines, the implementation of this function requires the usage * of channels, which are more expensive. @@ -31,7 +41,7 @@ fun Flow>.captureProgress() = channelFlow { }.buffer(1) /** - * Captures the progress information of [block] using [ProgressionReporter]. + * Captures the progress information of [block] using [CoroutineProgressReporter]. * * Because flows cannot emit from multiple coroutines, the implementation of this function requires the usage * of channels, which are more expensive. diff --git a/state/src/commonMain/kotlin/progressive/Conditional.kt b/state/src/commonMain/kotlin/progressive/Conditional.kt index 0ba7253..2636c2f 100644 --- a/state/src/commonMain/kotlin/progressive/Conditional.kt +++ b/state/src/commonMain/kotlin/progressive/Conditional.kt @@ -2,7 +2,7 @@ package opensavvy.state.progressive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import opensavvy.state.Progression +import opensavvy.progress.Progress import opensavvy.state.progressive.ProgressiveOutcome.* //region Actions @@ -28,18 +28,18 @@ inline fun ProgressiveOutcome.onFailure(block: (opensavvy.state.Failure) } /** - * Executes [block] if this outcome is loading (its [ProgressiveOutcome.progress] is [Progression.Loading]). + * Executes [block] if this outcome is loading (its [ProgressiveOutcome.progress] is [Progress.Loading]). * * Note that this isn't synonymous with this outcome being in the [Empty] state: successful or failed outcomes may * still be loading. For more information, see [ProgressiveOutcome]. * * Otherwise, does nothing. */ -inline fun ProgressiveOutcome.onLoading(block: (Progression.Loading) -> Unit) { - val progression = progress +inline fun ProgressiveOutcome.onLoading(block: (Progress.Loading) -> Unit) { + val progression = progress - if (progression is Progression.Loading) - block(progression) + if (progression is Progress.Loading) + block(progression) } //endregion diff --git a/state/src/commonMain/kotlin/progressive/Progressive.kt b/state/src/commonMain/kotlin/progressive/Progressive.kt index 6a22fef..b2437a9 100644 --- a/state/src/commonMain/kotlin/progressive/Progressive.kt +++ b/state/src/commonMain/kotlin/progressive/Progressive.kt @@ -1,11 +1,13 @@ package opensavvy.state.progressive -import opensavvy.state.Progression +import opensavvy.progress.Progress +import opensavvy.progress.done +import opensavvy.progress.loading import opensavvy.state.outcome.Outcome import opensavvy.state.progressive.ProgressiveOutcome.* /** - * A [Outcome] with integrated [Progression] management. + * A [Outcome] with integrated [Progress] management. * * There are three possible cases: * - [Empty] if the task has started but no value is currently available, @@ -33,13 +35,13 @@ sealed class ProgressiveOutcome { * * For more information, see [ProgressiveOutcome]. */ - abstract val progress: Progression + abstract val progress: Progress /** * The operation is ongoing, but we do not know if it will be successful or a failure. */ data class Empty( - override val progress: Progression.Loading = Progression.loading(), + override val progress: Progress.Loading = loading(), ) : ProgressiveOutcome() /** @@ -50,7 +52,7 @@ sealed class ProgressiveOutcome { */ data class Success( val value: T, - override val progress: Progression = Progression.done(), + override val progress: Progress = done(), ) : ProgressiveOutcome() /** @@ -61,7 +63,7 @@ sealed class ProgressiveOutcome { */ data class Failure( val failure: opensavvy.state.Failure, - override val progress: Progression = Progression.done(), + override val progress: Progress = done(), ) : ProgressiveOutcome() companion object { diff --git a/state/src/commonTest/kotlin/OutcomeTest.kt b/state/src/commonTest/kotlin/OutcomeTest.kt index 455ef21..fadf9ec 100644 --- a/state/src/commonTest/kotlin/OutcomeTest.kt +++ b/state/src/commonTest/kotlin/OutcomeTest.kt @@ -9,10 +9,12 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest -import opensavvy.state.Progression.Companion.done -import opensavvy.state.Progression.Companion.loading -import opensavvy.state.ProgressionReporter.Companion.progressionReporter -import opensavvy.state.ProgressionReporter.Companion.report +import opensavvy.progress.Progress +import opensavvy.progress.coroutines.StateFlowProgressReporter +import opensavvy.progress.coroutines.asCoroutineContext +import opensavvy.progress.coroutines.report +import opensavvy.progress.done +import opensavvy.progress.loading import opensavvy.state.outcome.* import kotlin.test.Test import kotlin.test.assertEquals @@ -55,14 +57,13 @@ class OutcomeTest { @Test fun successful() = runTest { val id = IntId(18) - val reporter = progressionReporter() + val reporter = StateFlowProgressReporter() - val actualAsync = async(reporter) { + val actualAsync = async(reporter.asCoroutineContext()) { id.request().also { report(done()) } } val expectedLoading = listOf( - loading(), loading(0.0), loading(0.2), loading(0.4), @@ -70,11 +71,11 @@ class OutcomeTest { loading(0.8), ) - val actualLoading = reporter.progress.takeWhile { it !is Progression.Done }.toList() + val actualLoading = reporter.progress.takeWhile { it !is Progress.Done }.toList() val actual = actualAsync.await() assertEquals(18.right(), actual) - assertEquals(18, actual.orNull()) + assertEquals(18, actual.getOrNull()) assertEquals(18, actual.orThrow()) assertEquals(expectedLoading, actualLoading) diff --git a/state/src/commonTest/kotlin/ProgressionReporterTest.kt b/state/src/commonTest/kotlin/ProgressionReporterTest.kt index f468f8f..cba0300 100644 --- a/state/src/commonTest/kotlin/ProgressionReporterTest.kt +++ b/state/src/commonTest/kotlin/ProgressionReporterTest.kt @@ -1,26 +1,25 @@ package opensavvy.state -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.withContext -import kotlinx.coroutines.yield -import opensavvy.state.Progression.Companion.done -import opensavvy.state.Progression.Companion.loading -import opensavvy.state.ProgressionReporter.Companion.progressionReporter -import opensavvy.state.ProgressionReporter.Companion.report -import opensavvy.state.ProgressionReporter.Companion.transformQuantifiedProgress +import opensavvy.progress.coroutines.StateFlowProgressReporter +import opensavvy.progress.coroutines.asCoroutineContext +import opensavvy.progress.coroutines.mapProgressTo +import opensavvy.progress.coroutines.report +import opensavvy.progress.done +import opensavvy.progress.loading import kotlin.test.Test import kotlin.test.assertEquals +@OptIn(ExperimentalCoroutinesApi::class) class ProgressionReporterTest { @Test fun simple() = runTest { - val reporter = progressionReporter() + val reporter = StateFlowProgressReporter() launch { - withContext(reporter) { + withContext(reporter.asCoroutineContext()) { report(loading(0.0)) delay(100) report(loading(0.5)) @@ -40,18 +39,18 @@ class ProgressionReporterTest { @Test fun nested() = runTest { - val reporter = progressionReporter() + val reporter = StateFlowProgressReporter() launch { - withContext(reporter) { - transformQuantifiedProgress({ loading(it.normalized / 2) }) { + withContext(reporter.asCoroutineContext()) { + mapProgressTo(0.0..0.5) { report(loading()) delay(10) report(done()) delay(10) report(loading(0.5)) + delay(100) } - delay(100) report(loading(0.5)) } } diff --git a/state/src/commonTest/kotlin/ProgressionTest.kt b/state/src/commonTest/kotlin/ProgressionTest.kt index 43ed166..03d7f65 100644 --- a/state/src/commonTest/kotlin/ProgressionTest.kt +++ b/state/src/commonTest/kotlin/ProgressionTest.kt @@ -1,6 +1,6 @@ package opensavvy.state -import opensavvy.state.Progression.Companion.loading +import opensavvy.progress.loading import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFails diff --git a/state/src/commonTest/kotlin/ProgressiveOutcomeTest.kt b/state/src/commonTest/kotlin/ProgressiveOutcomeTest.kt index bf54bcc..58936b3 100644 --- a/state/src/commonTest/kotlin/ProgressiveOutcomeTest.kt +++ b/state/src/commonTest/kotlin/ProgressiveOutcomeTest.kt @@ -5,7 +5,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest -import opensavvy.state.ProgressionReporter.Companion.report +import opensavvy.progress.coroutines.report +import opensavvy.progress.loading import opensavvy.state.outcome.failed import opensavvy.state.outcome.successful import opensavvy.state.progressive.* @@ -19,36 +20,36 @@ class ProgressiveOutcomeTest { fun convertSuccess() { val out = successful(5) - val progressive = out.withProgress(Progression.loading(0.7)) + val progressive = out.withProgress(loading(0.7)) assertEquals(out, progressive.asOutcome()) assertEquals(5, progressive.valueOrNull) - assertEquals(Progression.loading(0.7), progressive.progress) + assertEquals(loading(0.7), progressive.progress) } @Test fun convertFailure() { val out = failed("Error", Failure.Kind.NotFound) - val progressive = out.withProgress(Progression.loading(0.7)) + val progressive = out.withProgress(loading(0.7)) assertEquals(out, progressive.asOutcome()) assertEquals(Failure(Failure.Kind.NotFound, "Error"), progressive.failureOrNull) - assertEquals(Progression.loading(0.7), progressive.progress) + assertEquals(loading(0.7), progressive.progress) } @Test fun convertFlow() = runTest { assertEquals( listOf( - ProgressiveOutcome.Empty(Progression.loading(0.2)), - ProgressiveOutcome.Empty(Progression.loading(0.7)), + ProgressiveOutcome.Empty(loading(0.2)), + ProgressiveOutcome.Empty(loading(0.7)), ProgressiveOutcome.Success(5), ), flow { - report(Progression.loading(0.2)) + report(loading(0.2)) delay(100) - report(Progression.loading(0.7)) + report(loading(0.7)) delay(200) emit(successful(5)) }.captureProgress() @@ -60,14 +61,14 @@ class ProgressiveOutcomeTest { fun captureFromBlock() = runTest { assertEquals( listOf( - ProgressiveOutcome.Empty(Progression.loading(0.2)), - ProgressiveOutcome.Empty(Progression.loading(0.7)), + ProgressiveOutcome.Empty(loading(0.2)), + ProgressiveOutcome.Empty(loading(0.7)), ProgressiveOutcome.Success(5), ), captureProgress { - report(Progression.loading(0.2)) + report(loading(0.2)) delay(100) - report(Progression.loading(0.7)) + report(loading(0.7)) delay(200) successful(5) }.toList() @@ -78,14 +79,14 @@ class ProgressiveOutcomeTest { fun captureBuilder() = runTest { assertEquals( listOf( - ProgressiveOutcome.Empty(Progression.loading(0.2)), - ProgressiveOutcome.Empty(Progression.loading(0.7)), + ProgressiveOutcome.Empty(loading(0.2)), + ProgressiveOutcome.Empty(loading(0.7)), ProgressiveOutcome.Success(5), ), progressive { - report(Progression.loading(0.2)) + report(loading(0.2)) delay(100) - report(Progression.loading(0.7)) + report(loading(0.7)) delay(200) 5 }.toList() @@ -97,9 +98,9 @@ class ProgressiveOutcomeTest { assertEquals( successful(5), progressive { - report(Progression.loading(0.2)) + report(loading(0.2)) delay(100) - report(Progression.loading(0.7)) + report(loading(0.7)) delay(200) 5 }.firstValue() @@ -111,10 +112,10 @@ class ProgressiveOutcomeTest { val tests = mapOf( // to success, failure, loading ProgressiveOutcome.Success(5) to Triple(true, false, false), - ProgressiveOutcome.Success(5, Progression.loading()) to Triple(true, false, true), + ProgressiveOutcome.Success(5, loading()) to Triple(true, false, true), ProgressiveOutcome.Empty() to Triple(false, false, true), ProgressiveOutcome.Failure(Failure(Failure.Kind.NotFound, "Error")) to Triple(false, true, false), - ProgressiveOutcome.Failure(Failure(Failure.Kind.NotFound, "Error"), Progression.loading()) to Triple( + ProgressiveOutcome.Failure(Failure(Failure.Kind.NotFound, "Error"), loading()) to Triple( false, true, true -- 2.51.2 From a16d7be1a03dd3186432f8f38b5758b6f00fae35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 7 Apr 2023 23:31:34 +0200 Subject: [PATCH 17/51] build(state): Temporarily decrease the minimal code coverage --- state/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/state/build.gradle.kts b/state/build.gradle.kts index f259527..fdc5ba8 100644 --- a/state/build.gradle.kts +++ b/state/build.gradle.kts @@ -44,7 +44,7 @@ kover { rule { name = "Minimal code coverage" bound { - minValue = 80 + minValue = 70 // until it is rewritten } } } -- 2.51.2 From 524506b968fa219d92cf2b0579521401cccd7994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 1 Apr 2023 19:28:13 +0200 Subject: [PATCH 18/51] build(gradle): Create the state-arrow and state-coroutines projects --- settings.gradle.kts | 2 + state-arrow/build.gradle.kts | 60 +++++++++++++++++++++++++++++ state-coroutines/README.md | 3 ++ state-coroutines/build.gradle.kts | 63 +++++++++++++++++++++++++++++++ state/build.gradle.kts | 6 +-- 5 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 state-arrow/build.gradle.kts create mode 100644 state-coroutines/README.md create mode 100644 state-coroutines/build.gradle.kts diff --git a/settings.gradle.kts b/settings.gradle.kts index a011e01..840e22b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,6 +11,8 @@ plugins { include( "state", + "state-arrow", + "state-coroutines", "progress", "progress-coroutines", "cache", diff --git a/state-arrow/build.gradle.kts b/state-arrow/build.gradle.kts new file mode 100644 index 0000000..d46e1f1 --- /dev/null +++ b/state-arrow/build.gradle.kts @@ -0,0 +1,60 @@ +@file:Suppress("UNUSED_VARIABLE") + +import java.net.URL + +plugins { + id("opensavvy.gradle.library") +} + +kotlin { + jvm() + js(IR) { + browser() + nodejs() + } + iosSimulatorArm64() + iosArm64() + iosX64() + + sourceSets { + val commonMain by getting { + dependencies { + api(projects.state) + api("io.arrow-kt:arrow-core:_") + + implementation(projects.logger) + } + } + + val commonTest by getting { + dependencies { + implementation(projects.tester) + + api(KotlinX.coroutines.test) + } + } + } +} + +kover { + verify { + rule { + name = "Minimal code coverage" + bound { + minValue = 80 + } + } + } +} + +tasks.withType().configureEach { + dokkaSourceSets.configureEach { + includes.from("${project.projectDir}/README.md") + + sourceLink { + localDirectory.set(file("src")) + remoteUrl.set(URL("https://gitlab.com/opensavvy/pedestal/-/blob/main/state-arrow/src")) + remoteLineSuffix.set("#L") + } + } +} diff --git a/state-coroutines/README.md b/state-coroutines/README.md new file mode 100644 index 0000000..27ffb0f --- /dev/null +++ b/state-coroutines/README.md @@ -0,0 +1,3 @@ +# Module state-coroutines + +Compatibility layer for `state` and KotlinX.Coroutines. diff --git a/state-coroutines/build.gradle.kts b/state-coroutines/build.gradle.kts new file mode 100644 index 0000000..39dfcbc --- /dev/null +++ b/state-coroutines/build.gradle.kts @@ -0,0 +1,63 @@ +@file:Suppress("UNUSED_VARIABLE") + +import java.net.URL + +plugins { + id("opensavvy.gradle.library") +} + +kotlin { + jvm() + js(IR) { + browser() + nodejs() + } + iosSimulatorArm64() + iosArm64() + iosX64() + + sourceSets { + val commonMain by getting { + dependencies { + api(projects.state) + api(KotlinX.coroutines.core) + + api(projects.progress) + api(projects.progressCoroutines) + + implementation(projects.logger) + } + } + + val commonTest by getting { + dependencies { + implementation(projects.tester) + + api(KotlinX.coroutines.test) + } + } + } +} + +kover { + verify { + rule { + name = "Minimal code coverage" + bound { + minValue = 80 + } + } + } +} + +tasks.withType().configureEach { + dokkaSourceSets.configureEach { + includes.from("${project.projectDir}/README.md") + + sourceLink { + localDirectory.set(file("src")) + remoteUrl.set(URL("https://gitlab.com/opensavvy/pedestal/-/blob/main/state-coroutines/src")) + remoteLineSuffix.set("#L") + } + } +} diff --git a/state/build.gradle.kts b/state/build.gradle.kts index fdc5ba8..a578a4b 100644 --- a/state/build.gradle.kts +++ b/state/build.gradle.kts @@ -19,11 +19,7 @@ kotlin { sourceSets { val commonMain by getting { dependencies { - api(KotlinX.coroutines.core) - api("io.arrow-kt:arrow-core:_") - api(projects.progress) - api(projects.progressCoroutines) implementation(projects.logger) } @@ -33,6 +29,8 @@ kotlin { dependencies { implementation(projects.tester) + implementation(projects.stateArrow) + api(KotlinX.coroutines.test) } } -- 2.51.2 From d81fd77dfb9a5bb064da0d90b6f7518a43e3ed9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 1 Apr 2023 19:28:55 +0200 Subject: [PATCH 19/51] ci(gitlab): Execute the state-arrow and state-coroutines tests --- .gitlab-ci.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index eb6c66f..9a41a67 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -180,6 +180,32 @@ state:convert-coverage: variables: module: state +state-arrow:test:jvm: + extends: [ .test-jvm ] + variables: + module: state-arrow + +state-arrow:convert-coverage: + extends: [ .test-jvm-convert-coverage ] + needs: + - job: state-arrow:test:jvm + artifacts: true + variables: + module: state-arrow + +state-coroutines:test:jvm: + extends: [ .test-jvm ] + variables: + module: state-coroutines + +state-coroutines:convert-coverage: + extends: [ .test-jvm-convert-coverage ] + needs: + - job: state-coroutines:test:jvm + artifacts: true + variables: + module: state-coroutines + cache:test:jvm: extends: [ .test-jvm ] variables: -- 2.51.2 From db97b114db44ec3e23be3f928175193687eaea6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 1 Apr 2023 19:29:37 +0200 Subject: [PATCH 20/51] ci(gitlab): Publish state-arrow and state-coroutines --- .gitlab-ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9a41a67..32eef62 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -44,6 +44,8 @@ publish: progress:publishAllPublicationsToGitLabRepository progress-coroutines:publishAllPublicationsToGitLabRepository state:publishAllPublicationsToGitLabRepository + state-arrow:publishAllPublicationsToGitLabRepository + state-coroutines:publishAllPublicationsToGitLabRepository cache:publishAllPublicationsToGitLabRepository backbone:publishAllPublicationsToGitLabRepository logger:publishAllPublicationsToGitLabRepository @@ -65,6 +67,8 @@ publish:ios: progress:publishIosArm64PublicationToGitLabRepository progress-coroutines:publishIosArm64PublicationToGitLabRepository state:publishIosArm64PublicationToGitLabRepository + state-arrow:publishIosArm64PublicationToGitLabRepository + state-coroutines:publishIosArm64PublicationToGitLabRepository cache:publishIosArm64PublicationToGitLabRepository backbone:publishIosArm64PublicationToGitLabRepository logger:publishIosArm64PublicationToGitLabRepository @@ -74,6 +78,8 @@ publish:ios: progress:publishIosSimulatorArm64PublicationToGitLabRepository progress-coroutines:publishIosSimulatorArm64PublicationToGitLabRepository state:publishIosSimulatorArm64PublicationToGitLabRepository + state-arrow:publishIosSimulatorArm64PublicationToGitLabRepository + state-coroutines:publishIosSimulatorArm64PublicationToGitLabRepository cache:publishIosSimulatorArm64PublicationToGitLabRepository backbone:publishIosSimulatorArm64PublicationToGitLabRepository logger:publishIosSimulatorArm64PublicationToGitLabRepository @@ -83,6 +89,8 @@ publish:ios: progress:publishIosX64PublicationToGitLabRepository progress-coroutines:publishIosX64PublicationToGitLabRepository state:publishIosX64PublicationToGitLabRepository + state-arrow:publishIosX64PublicationToGitLabRepository + state-coroutines:publishIosX64PublicationToGitLabRepository cache:publishIosX64PublicationToGitLabRepository backbone:publishIosX64PublicationToGitLabRepository logger:publishIosX64PublicationToGitLabRepository -- 2.51.2 From 6950beec551b3047308025e8366af9ac67e13060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Mon, 3 Apr 2023 22:20:42 +0200 Subject: [PATCH 21/51] docs: Rewrote the State documentation --- state-arrow/README.md | 7 ++++ state/README.md | 77 ++++++++++++++++--------------------------- 2 files changed, 35 insertions(+), 49 deletions(-) create mode 100644 state-arrow/README.md diff --git a/state-arrow/README.md b/state-arrow/README.md new file mode 100644 index 0000000..e02e15b --- /dev/null +++ b/state-arrow/README.md @@ -0,0 +1,7 @@ +# Module state-arrow + +Compatibility layer for `state` and [Arrow Core](https://arrow-kt.io/docs/core/). + +The `state` project is an error management framework for immutable data with progress management using `progress`. +Arrow Core is particularly great at representing business failures, and `state` was created to be completely compatible. +We encourage any project that has the option to use Arrow Core alongside `state`, using this compatibility module. diff --git a/state/README.md b/state/README.md index 1591597..44cc19f 100644 --- a/state/README.md +++ b/state/README.md @@ -1,67 +1,46 @@ # Module state -Small library for state management based on KotlinX.Coroutines and Kotlin Arrow Core. +Small library for value-based outcome representation. -Pedestal State is a centralized way of handling: +Using `state`, it is possible to model a successful operation, a failed operation, as well as the intermediate progress +states of an ongoing operation (using `progress`). -- error management using [Either][arrow.core.Either], with integrated API failure reasons, -- reporting the progress of ongoing operations to their callers, entirely through coroutines. +## Representing a computation's outcome -Example usage: +In +Kotlin, [exceptions are not an idiomatic way to represent domain failures](https://elizarov.medium.com/kotlin-and-exceptions-8062f589d07). +They should only be used for programming errors (for example, broken invariants, using `IllegalArgumentException`), +issues that may arise anywhere in a program and thus shouldn't be handled everywhere they appear (for +example, `OutOfMemoryError`) or to encapsulate errors outside our control (for example, `NetworkError`). Instead, we +should represent the outcome of a domain operation using +a [sealed class hierarchy](https://kotlinlang.org/docs/sealed-classes.html). -```kotlin -suspend fun setPassword( - userId: String, - oldPassword: String, - repeatOldPassword: String, - newPassword: String, -) = out { - // The 'out' builder is essentially the same as the 'either {}' block, - // except it intercepts some exceptions used in idiomatic Kotlin - // (IllegalArgumentException…) +Multiple libraries have been created to facilitate using these sealed class hierarchies, most +notably [Arrow](https://arrow-kt.io/docs/patterns/error_handling/). These libraries provide enhanced semantics and +syntax sugar for this style of error management, but they lack a representation for intermediate values (for example, +the current progress of an information). - // We can easily report the progress of our function - // The progress is sent directly to the caller using CoroutineContext - // (does nothing if the caller has not registered a listener) - // Progress can thus be reported in any suspending function, even - // without the 'out' builder - report(loading(0.1)) // 10% +Originally, `state` was built upon Arrow. Today, `state` is independent of Arrow, but it doesn't duplicate all the nice +tooling provided by Arrow: we recommend using our provided `state-arrow` compatibility layer to use in your +projects. `state` itself is kept as small as possible to help interoperability with projects that do not use Arrow. - // Use APIs inspired by the standard library's 'require' - // to check preconditions/invariants - // The added information can be used for example by the Pedestal Spine module - // to automatically generate the appropriate HTTP status codes - ensureAuthenticated(userService.currentUser() == userId) { "You cannot edit the password of another user" } - ensureValid(oldPassword == repeatOldPassword) { "The two provided passwords are different" } - ensureValid(oldPassword != newPassword) { "The new password cannot be the same password as the old one" } +There are multiple recommended ways to represent outcomes: - // The goal of the progress reporting API is that you can add - // reporting points after your application is developed, as your usage - // dictates they become useful, without any impact to your APIs - report(loading(0.5)) // 50% - - // Within the block, all progress events go through the provided computation - transformQuantifiedProgress({ loading(it.normalized / 2.0 + 0.5) }) { - report(loading(0.1)) // 10%, becomes 55% due to the transformation block - - // We're still inside the Arrow 'either {}' block, so we can use 'bind' - // and all other utilities - userService.setPassword(userId, newPassword).bind() - - report(loading(0.8)) // 80%, becomes 90% due to the transformation block - } -} -``` - -You can also create your own progress type to store any kind of additional information (e.g. bandwidth speed, estimated time of end, amount of files impacted…) by implementing the [Progression.Quantified][opensavvy.state.Progression.Quantified] interface. +- success, failures and progress in a wrapper object ( + see [ProgressiveOutcome][opensavvy.state.progressive.ProgressiveOutcome]), +- success and failures in a wrapper object (see `state-arrow`'s `Outcome`) and progress by asynchronous context calls ( + see `progress-coroutines`'s `CoroutineProgressReporter`), +- success as a regular return type, failures as a context receiver (see Arrow 2.0's `Raise` interface) and progress as a + context receiver (using `progress`' `ProgressReporter`). # Package opensavvy.state -Centralized [Failure][opensavvy.state.Failure] management as well as [Progression][opensavvy.state.Progression] APIs to represent ongoing operations and their current state. +Centralized [Failure][opensavvy.state.Failure] management. # Package opensavvy.state.outcome -Utilities for the [Outcome][opensavvy.state.outcome.Outcome] type, allowing to embed typed error management directly into the API without using exceptions. +Utilities for the [Outcome][opensavvy.state.outcome.Outcome] type, allowing to embed typed error management directly +into the API without using exceptions. # Package opensavvy.state.progressive -- 2.51.2 From 1da3a80086e697d814235f0c9cbf9f7c6ac5c597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Tue, 4 Apr 2023 20:22:26 +0200 Subject: [PATCH 22/51] refactor(state): Remove Flow.firstValue --- state/src/commonMain/kotlin/State.kt | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 state/src/commonMain/kotlin/State.kt diff --git a/state/src/commonMain/kotlin/State.kt b/state/src/commonMain/kotlin/State.kt deleted file mode 100644 index 221207b..0000000 --- a/state/src/commonMain/kotlin/State.kt +++ /dev/null @@ -1,9 +0,0 @@ -package opensavvy.state - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.firstOrNull -import opensavvy.state.outcome.Outcome -import opensavvy.state.outcome.failed - -suspend fun Flow>.firstValue() = firstOrNull() - ?: failed("The flow terminated before emitting a value", Failure.Kind.NotFound) -- 2.51.2 From 52e3a14315e371f19845b8725eea48aa32b6d5c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 7 Apr 2023 21:32:25 +0200 Subject: [PATCH 23/51] upgrade: Arrow 1.2.0-RC --- versions.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/versions.properties b/versions.properties index e7df190..92c982e 100644 --- a/versions.properties +++ b/versions.properties @@ -7,7 +7,7 @@ #### suppress inspection "SpellCheckingInspection" for whole file #### suppress inspection "UnusedProperty" for whole file -version.arrow=1.1.5 +version.arrow=1.2.0-RC version.ch.qos.logback..logback-classic=1.4.5 -- 2.51.2 From 20d2073705d9573efeb274be67af8caaf22a5868 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 7 Apr 2023 21:41:28 +0200 Subject: [PATCH 24/51] refactor(state): Failure rework --- state/src/commonMain/kotlin/Failure.kt | 67 ---- state/src/commonMain/kotlin/State.kt | 3 + .../commonMain/kotlin/failure/BasicFailure.kt | 25 ++ .../kotlin/failure/CustomFailure.kt | 35 ++ .../kotlin/failure/ExceptionFailure.kt | 14 + .../src/commonMain/kotlin/failure/Failure.kt | 72 ++++ .../kotlin/failure/StandardFailures.kt | 83 +++++ .../kotlin/failure/BasicFailureTest.kt | 49 +++ .../kotlin/failure/ExceptionFailureTest.kt | 31 ++ .../kotlin/failure/FailureEndToEndTest.kt | 352 ++++++++++++++++++ .../kotlin/failure/StandardFailuresTest.kt | 39 ++ 11 files changed, 703 insertions(+), 67 deletions(-) delete mode 100644 state/src/commonMain/kotlin/Failure.kt create mode 100644 state/src/commonMain/kotlin/State.kt create mode 100644 state/src/commonMain/kotlin/failure/BasicFailure.kt create mode 100644 state/src/commonMain/kotlin/failure/CustomFailure.kt create mode 100644 state/src/commonMain/kotlin/failure/ExceptionFailure.kt create mode 100644 state/src/commonMain/kotlin/failure/Failure.kt create mode 100644 state/src/commonMain/kotlin/failure/StandardFailures.kt create mode 100644 state/src/commonTest/kotlin/failure/BasicFailureTest.kt create mode 100644 state/src/commonTest/kotlin/failure/ExceptionFailureTest.kt create mode 100644 state/src/commonTest/kotlin/failure/FailureEndToEndTest.kt create mode 100644 state/src/commonTest/kotlin/failure/StandardFailuresTest.kt diff --git a/state/src/commonMain/kotlin/Failure.kt b/state/src/commonMain/kotlin/Failure.kt deleted file mode 100644 index 4065a28..0000000 --- a/state/src/commonMain/kotlin/Failure.kt +++ /dev/null @@ -1,67 +0,0 @@ -package opensavvy.state - -import kotlinx.coroutines.CopyableThrowable -import kotlinx.coroutines.ExperimentalCoroutinesApi - -/** - * Common failure reasons. - * - * Encoding these reasons at all levels of the project allows APIs (such as Spine) to encode them during communication - * with other services, for example as HTTP status codes. - */ -data class Failure( - val kind: Kind, - val message: String, - val cause: Throwable? = null, -) { - - override fun toString() = "$kind: $message" - - fun toException() = FailureException(this, kind, message, cause) - - @OptIn(ExperimentalCoroutinesApi::class) - class FailureException(val failure: Failure, val kind: Kind, message: String, cause: Throwable? = null) : - RuntimeException(message, cause), CopyableThrowable { - - @ExperimentalCoroutinesApi - override fun createCopy(): FailureException = FailureException( - failure = failure, - kind = kind, - message = message ?: "Copy of a FailureException without message, which should not be possible", - cause = this, - ) - } - - enum class Kind { - /** - * The request is not valid. - * - * All prerequisites are correct (the user is correctly authenticated and authorized…) but the - * request payload is invalid. - */ - Invalid, - - /** - * The implementation could not determine which user is making the request. - */ - Unauthenticated, - - /** - * The implementation could determine which user is making the request, but they do not have - * sufficient rights to make the request. - */ - Unauthorized, - - /** - * The request is well-formed and the user is allowed to make it, - * but the resource it should apply to could not be found. - */ - NotFound, - - /** - * The implementation caught an unknown error. - */ - Unknown, - ; - } -} diff --git a/state/src/commonMain/kotlin/State.kt b/state/src/commonMain/kotlin/State.kt new file mode 100644 index 0000000..d0b9cfa --- /dev/null +++ b/state/src/commonMain/kotlin/State.kt @@ -0,0 +1,3 @@ +package opensavvy.state + +// Root package marker diff --git a/state/src/commonMain/kotlin/failure/BasicFailure.kt b/state/src/commonMain/kotlin/failure/BasicFailure.kt new file mode 100644 index 0000000..c2f9d61 --- /dev/null +++ b/state/src/commonMain/kotlin/failure/BasicFailure.kt @@ -0,0 +1,25 @@ +package opensavvy.state.failure + +/** + * Basic implementation of [Failure], used to create your own implementation through interface delegation. + */ +data class BasicFailure( + override val key: Failure.Key, + override val message: String, + override val cause: Failure? = null, +) : Failure { + + override fun toString() = buildString { + append(key.toString()) + append(": ") + + append(message) + + if (cause != null) { + appendLine() + + append("Caused by ") + append(cause.toString()) + } + } +} diff --git a/state/src/commonMain/kotlin/failure/CustomFailure.kt b/state/src/commonMain/kotlin/failure/CustomFailure.kt new file mode 100644 index 0000000..ffc9eaf --- /dev/null +++ b/state/src/commonMain/kotlin/failure/CustomFailure.kt @@ -0,0 +1,35 @@ +package opensavvy.state.failure + +/** + * Convenience class to implement [Failure] by delegating all methods to [BasicFailure], + * including [equals], [hashCode] and [toString] (which are not delegated by Kotlin's `by` keyword). + */ +abstract class CustomFailure(private val failure: BasicFailure) : Failure by failure { + + constructor( + key: Failure.Key, + message: String, + cause: Failure? = null, + ) : this(BasicFailure(key, message, cause)) + + constructor( + failure: CustomFailure, + ) : this(failure.failure) + + //region equals & hashCode + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is CustomFailure) return false + + return failure == other.failure + } + + override fun hashCode(): Int { + return failure.hashCode() + } + + //endregion + + override fun toString() = failure.toString() +} diff --git a/state/src/commonMain/kotlin/failure/ExceptionFailure.kt b/state/src/commonMain/kotlin/failure/ExceptionFailure.kt new file mode 100644 index 0000000..d0050dd --- /dev/null +++ b/state/src/commonMain/kotlin/failure/ExceptionFailure.kt @@ -0,0 +1,14 @@ +package opensavvy.state.failure + +class ExceptionFailure( + override val key: Failure.Key, + val exception: Throwable, + override val cause: Failure? = null, +) : Failure { + + override val message: String get() = exception.message ?: "No message available" + + override fun toString() = exception.stackTraceToString() +} + +fun Throwable.asFailure(key: Failure.Key, cause: Failure? = null) = ExceptionFailure(key, this, cause) diff --git a/state/src/commonMain/kotlin/failure/Failure.kt b/state/src/commonMain/kotlin/failure/Failure.kt new file mode 100644 index 0000000..81bfa54 --- /dev/null +++ b/state/src/commonMain/kotlin/failure/Failure.kt @@ -0,0 +1,72 @@ +package opensavvy.state.failure + +/** + * Common failure interface. + * + * Exceptions have notorious downsides to performance and readability. + * Instead, we encourage using this [Failure] supertype. + * It is our goal that this interface is implemented most of the time via delegation to [BasicFailure] (or another similar type). + * + * The semantic type of failure is represented by a failure's [Key]. + */ +interface Failure { + + /** + * The [Key] to this [Failure]. + */ + val key: Key + + /** + * A user-readable representation of this failure. + */ + val message: String + + /** + * Another failure which caused this one. + * + * If this property is `null`, then no known failure caused this one. + */ + val cause: Failure? + + /** + * The key of a [Failure], most commonly accessed by [Failure.key]. + * + * The key represents the *semantic type* of failure. + * The semantic type of failure is different from the Kotlin type of the failure class because of delegation: + * to facilitate implementation, it is possible to delegate to an existing implementation, providing it with a unique key. + * + * It is also possible to wrap failures into other failure implementations without losing the semantic type information, + * as each wrapper can delegate accesses to the key to the underlying implementation. + * + * Creating your own failure hierarchies thus becomes simple: + * ```kotlin + * sealed interface UserFailure { + * // Delegate to an existing failure and its key + * class NotFound(id: UserId) : Failure by NotFound("user $id") + * + * // Create your own failure type + * object InvalidUserId(id: String) : Failure by BasicFailure(Companion, "The identifier '$id' is invalid") { + * + * // Custom key for this failure + * companion object : Failure.Key + * } + * } + * ``` + * + * Programs should reuse the existing keys as much as possible, to allow frameworks to extract meaning from errors. + * This can be done either by reusing an existing failure type (as seen in the previous example), or giving an existing + * key to a new failure type, for example to [encapsulate exceptions][asFailure]: + * ```kotlin + * fun foo(): Outcome { + * try { + * return someExpensiveComputation().success() + * } catch (e: NoSuchElementException) { + * return e.asFailure(NotFound) + * } + * } + * ``` + */ + interface Key + + companion object +} diff --git a/state/src/commonMain/kotlin/failure/StandardFailures.kt b/state/src/commonMain/kotlin/failure/StandardFailures.kt new file mode 100644 index 0000000..95e8e7e --- /dev/null +++ b/state/src/commonMain/kotlin/failure/StandardFailures.kt @@ -0,0 +1,83 @@ +package opensavvy.state.failure + +private fun createMessage(title: String, additional: String? = null) = + if (additional != null) "$title. $additional" + else title + +/** + * The requested element could not be found. + */ +class NotFound( + /** + * The identifier of the resource that could not be found. + */ + val identifier: Any, + + /** + * See [Failure.message]. + */ + message: String? = null, + + /** + * See [Failure.cause]. + */ + cause: Failure? = null, +) : CustomFailure( + Companion, + createMessage("Could not find resource '$identifier'", message), + cause, +) { + + companion object : Failure.Key { + override fun toString() = "NotFound" + } +} + +/** + * The current user is not authenticated, yet authentication is required to access this information. + */ +class Unauthenticated( + /** + * See [Failure.message]. + */ + message: String? = null, + + /** + * See [Failure.cause]. + */ + cause: Failure? = null, +) : CustomFailure( + Companion, + createMessage("Authentication is required to access this resource", message), + cause, +) { + + companion object : Failure.Key { + override fun toString() = "Unauthenticated" + } +} + +/** + * The current user's identity has been recognized by the service, but the service decided they do not hold sufficient + * rights to proceed. + */ +class Unauthorized( + /** + * See [Failure.message]. + */ + message: String? = null, + + /** + * See [Failure.cause]. + */ + cause: Failure? = null, +) : CustomFailure( + Companion, + createMessage("You are not authorized to access this information", message), + cause, +) { + + companion object : Failure.Key { + override fun toString() = "Unauthorized" + } +} diff --git a/state/src/commonTest/kotlin/failure/BasicFailureTest.kt b/state/src/commonTest/kotlin/failure/BasicFailureTest.kt new file mode 100644 index 0000000..9c94fee --- /dev/null +++ b/state/src/commonTest/kotlin/failure/BasicFailureTest.kt @@ -0,0 +1,49 @@ +package opensavvy.state.failure + +import kotlin.test.Test +import kotlin.test.assertEquals + +class BasicFailureTest { + + @Test + fun minimalEquals() { + assertEquals( + BasicFailure(NotFound, "message"), + BasicFailure(NotFound, "message"), + ) + } + + @Test + fun causeEquals() { + val cause = Unauthenticated() + + assertEquals( + BasicFailure(NotFound, "message", cause), + BasicFailure(NotFound, "message", cause), + ) + } + + @Test + fun minimalToString() { + assertEquals( + "NotFound: this is the message".trimIndent(), + BasicFailure(NotFound, "this is the message").toString(), + ) + } + + @Test + fun causeToString() { + assertEquals( + """ + NotFound: this is the message + Caused by Unauthenticated: original message + """.trimIndent(), + BasicFailure( + NotFound, + "this is the message", + cause = BasicFailure(Unauthenticated, "original message") + ).toString(), + ) + } + +} diff --git a/state/src/commonTest/kotlin/failure/ExceptionFailureTest.kt b/state/src/commonTest/kotlin/failure/ExceptionFailureTest.kt new file mode 100644 index 0000000..74292c2 --- /dev/null +++ b/state/src/commonTest/kotlin/failure/ExceptionFailureTest.kt @@ -0,0 +1,31 @@ +package opensavvy.state.failure + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ExceptionFailureTest { + + @Test + fun missingMessage() { + assertEquals( + "No message available", + ExceptionFailure(NotFound, RuntimeException()).message, + ) + } + + @Test + fun providedMessage() { + assertEquals( + "The selected message", + ExceptionFailure(NotFound, RuntimeException("The selected message")).message, + ) + } + + @Test + fun exception() { + assertEquals( + RuntimeException("The selected message").message, + RuntimeException("The selected message").asFailure(NotFound).exception.message, + ) + } +} diff --git a/state/src/commonTest/kotlin/failure/FailureEndToEndTest.kt b/state/src/commonTest/kotlin/failure/FailureEndToEndTest.kt new file mode 100644 index 0000000..65f262c --- /dev/null +++ b/state/src/commonTest/kotlin/failure/FailureEndToEndTest.kt @@ -0,0 +1,352 @@ +package opensavvy.state.failure + +import arrow.core.raise.ensure +import arrow.core.raise.ensureNotNull +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.test.runTest +import opensavvy.state.arrow.out +import opensavvy.state.outcome.Outcome +import opensavvy.state.outcome.failed +import opensavvy.state.outcome.success +import kotlin.jvm.JvmInline +import kotlin.random.Random +import kotlin.test.* + +// This class simulates a small business domain, to check the usability of our various patterns. +// Here, a user can create multiple counters and increment/decrement them at will. +// A user can also share their counters with other users, which will be able to see them (but not edit or share them). + +@OptIn(ExperimentalCoroutinesApi::class) +class FailureEndToEndTest { + + private data class User( + // In real life, this would be a data class+Id+Service + val name: String, + ) + + private data class Context( + val user: User?, + ) + + /** + * A counter has a [value]. + * + * Only its [owner] can change the [value], and select the users the counter is [shared with][canRead]. + */ + private data class Counter( + val owner: User, + val value: Int, + val canRead: Set, + ) { + + fun readableBy(user: User) = user == owner || user in canRead + + @JvmInline + value class Id(val id: Int) + + // In real life, this would be an interface with multiple implementations + class Service { + private val lock = Mutex() + private val data = HashMap() + + suspend fun create(context: Context) = out { + ensureNotNull(context.user) { Failures.Unauthenticated } + + val newId = Id(Random.nextInt()) + + lock.withLock { + data[newId] = Counter(context.user, 0, emptySet()) + } + + newId + } + + suspend fun list(context: Context) = out> { + ensureNotNull(context.user) { Failures.Unauthenticated } + + lock.withLock { + val user: User = context.user + + data + .asSequence() + .filter { (_, counter) -> counter.readableBy(user) } + .map { it.key } + .toList() + } + } + + suspend fun get(context: Context, id: Id) = out { + ensureNotNull(context.user) { Failures.Unauthenticated } + + val counter = lock.withLock { data[id] } + ensureNotNull(counter) { Failures.NotFound(id) } + ensure(counter.readableBy(context.user)) { Failures.NotFound(id) } // Do not tell the user why they cannot see it + + counter + } + + suspend fun increment(context: Context, id: Id) = out { + val counter = get(context, id).bind() + + ensure(context.user == counter.owner) { Failures.NotTheOwner(id) } + + // Possible data race here, but it's an imaginary example, so it's not a big deal + lock.withLock { data[id] = counter.copy(value = counter.value + 1) } + } + + // Imagine there is also a 'decrement' method + + suspend fun share(context: Context, id: Id, user: User) = out { + val counter = get(context, id).bind() + + ensure(context.user == counter.owner) { Failures.NotTheOwner(id) } + + // Possible data race here, but it's an imaginary example, so it's not a big deal + lock.withLock { data[id] = counter.copy(canRead = counter.canRead + user) } + } + + // Imagine there is also a 'unshare' method + + // Now, we're listing all possible error cases of this service + sealed interface Failures : Failure { + + // Declare the various operations this service can do + + sealed interface Create : Failures + sealed interface List : Failures + sealed interface Get : Failures, Edit, Share // <- Edit implies all failures of Get + sealed interface Edit : Failures + sealed interface Share : Failures + + // Declare the various errors and which operations they can be created by + + data class NotFound(val id: Id) : CustomFailure(NotFound("Could not find user with ID $id")), + Failures, + Get, + Edit, + Share + + data class NotTheOwner(val id: Id) : CustomFailure(Unauthorized("Only the owner can access $id")), + Failures, + Edit, + Share + + object Unauthenticated : + CustomFailure(Unauthenticated("Authentication is required to access counters")), + Failures, + Create, + List, + Get, + Edit, + Share + } + } + } + + private val guest: User? = null + private val user1 = User("User 1") + private val user2 = User("User 2") + + private val guestContext = Context(guest) + private val user1Context = Context(user1) + private val user2Context = Context(user2) + + // Make test easier + private fun Outcome<*, T>.getOrThrow() = when (this) { + is Outcome.Failure -> error(failure.toString()) + is Outcome.Success -> value + } + + @Test + fun guestsCannotCreateACounter() = runTest { + val service = Counter.Service() + + assertEquals( + Counter.Service.Failures.Unauthenticated.failed(), + service.create(guestContext) + ) + } + + @Test + fun guestsCannotListCounters() = runTest { + val service = Counter.Service() + + assertEquals( + Counter.Service.Failures.Unauthenticated.failed(), + service.list(guestContext) + ) + } + + @Test + fun guestsCannotGetCounters() = runTest { + val service = Counter.Service() + + val id = service.create(user1Context).getOrThrow() + + assertEquals( + Counter.Service.Failures.Unauthenticated.failed(), + service.get(guestContext, id), + ) + } + + @Test + fun guestsCannotIncrementCounters() = runTest { + val service = Counter.Service() + + val id = service.create(user1Context).getOrThrow() + + assertEquals( + Counter.Service.Failures.Unauthenticated.failed(), + service.increment(guestContext, id), + ) + } + + @Test + fun usersCanCreateCounters() = runTest { + val service = Counter.Service() + + val id = service.create(user1Context) + assertIs>(id) + } + + @Test + fun usersListTheirOwnCounters() = runTest { + val service = Counter.Service() + + val id = service.create(user1Context).getOrThrow() + + val results = service.list(user1Context) + assertIs>>(results) + assertContains(results.value, id) + } + + @Test + fun usersCannotListUnsharedCounters() = runTest { + val service = Counter.Service() + + val id = service.create(user1Context).getOrThrow() + + val results = service.list(user2Context) + assertIs>>(results) + assertFalse(id in results.value) + } + + @Test + fun usersCanGetTheirOwnCounters() = runTest { + val service = Counter.Service() + + val id = service.create(user1Context).getOrThrow() + + assertEquals( + Counter(user1, 0, emptySet()).success(), + service.get(user1Context, id), + ) + } + + @Test + fun usersCannotGetUnsharedCounters() = runTest { + val service = Counter.Service() + + val id = service.create(user1Context).getOrThrow() + + assertEquals( + Counter.Service.Failures.NotFound(id).failed(), + service.get(user2Context, id), + ) + } + + @Test + fun usersCannotGetACounterThatDoesntExist() = runTest { + val service = Counter.Service() + + val id = Counter.Id(2) + + assertEquals( + Counter.Service.Failures.NotFound(id).failed(), + service.get(user2Context, id), + ) + } + + @Test + fun usersCanIncrementTheirOwnCounters() = runTest { + val service = Counter.Service() + + val id = service.create(user1Context).getOrThrow() + + val result = service.increment(user1Context, id) + assertIs>(result) + + assertEquals( + Counter(user1, 1, emptySet()).success(), + service.get(user1Context, id), + ) + } + + @Test + fun usersCanShareTheirOwnCounters() = runTest { + val service = Counter.Service() + + val id = service.create(user1Context).getOrThrow() + + val result = service.share(user1Context, id, user2) + assertIs>(result) + + assertEquals( + Counter(user1, 0, setOf(user2)).success(), + service.get(user1Context, id), + ) + } + + @Test + fun usersCanListCountersSharedWithThem() = runTest { + val service = Counter.Service() + + val id = service.create(user1Context).getOrThrow() + service.share(user1Context, id, user2).getOrThrow() + + val results = service.list(user2Context) + assertIs>>(results) + assertContains(results.value, id) + } + + @Test + fun usersCanGetCountersSharedWithThem() = runTest { + val service = Counter.Service() + + val id = service.create(user1Context).getOrThrow() + service.share(user1Context, id, user2).getOrThrow() + + assertEquals( + Counter(user1, 0, setOf(user2)).success(), + service.get(user2Context, id), + ) + } + + @Test + fun usersCannotIncrementSharedCounters() = runTest { + val service = Counter.Service() + + val id = service.create(user1Context).getOrThrow() + service.share(user1Context, id, user2).getOrThrow() + + assertEquals( + Counter.Service.Failures.NotTheOwner(id).failed(), + service.increment(user2Context, id), + ) + } + + @Test + fun usersCannotShareSharedCounters() = runTest { + val service = Counter.Service() + + val id = service.create(user1Context).getOrThrow() + service.share(user1Context, id, user2).getOrThrow() + + assertEquals( + Counter.Service.Failures.NotTheOwner(id).failed(), + service.share(user2Context, id, user1), + ) + } +} diff --git a/state/src/commonTest/kotlin/failure/StandardFailuresTest.kt b/state/src/commonTest/kotlin/failure/StandardFailuresTest.kt new file mode 100644 index 0000000..6fb4e5b --- /dev/null +++ b/state/src/commonTest/kotlin/failure/StandardFailuresTest.kt @@ -0,0 +1,39 @@ +package opensavvy.state.failure + +import kotlin.test.Test +import kotlin.test.assertEquals + +class StandardFailuresTest { + + @Test + fun notFoundIdentifier() { + assertEquals( + 5, + NotFound(5).identifier, + ) + } + + @Test + fun notFoundMessage() { + assertEquals( + "Could not find resource '5'", + NotFound(5).message, + ) + } + + @Test + fun unauthenticatedMessage() { + assertEquals( + "Authentication is required to access this resource", + Unauthenticated().message, + ) + } + + @Test + fun unauthorizedMessage() { + assertEquals( + "You are not authorized to access this information", + Unauthorized().message, + ) + } +} -- 2.51.2 From 410c6117fbce767c95259094b9f1a7430464b5f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 7 Apr 2023 21:52:08 +0200 Subject: [PATCH 25/51] refactor(state): Outcome & ProgressiveOutcome rework --- .../commonMain/kotlin/outcome/Accessors.kt | 20 +-- .../src/commonMain/kotlin/outcome/Builder.kt | 23 +-- .../commonMain/kotlin/outcome/Factories.kt | 15 -- .../src/commonMain/kotlin/outcome/Outcome.kt | 34 ++++ state/src/commonMain/kotlin/outcome/Slice.kt | 12 -- .../kotlin/progressive/Accessors.kt | 45 ++---- .../commonMain/kotlin/progressive/Builder.kt | 50 ++---- .../kotlin/progressive/Progressive.kt | 32 ++-- .../kotlin/ProgressionReporterTest.kt | 74 --------- .../kotlin/ProgressiveOutcomeTest.kt | 150 ------------------ .../src/commonTest/kotlin/StateBuilderTest.kt | 71 --------- .../kotlin/outcome/AccessorsTest.kt | 42 +++++ .../kotlin/progressive/AccessorsTest.kt | 72 +++++++++ .../kotlin/progressive/BuilderTest.kt | 26 +++ 14 files changed, 225 insertions(+), 441 deletions(-) delete mode 100644 state/src/commonMain/kotlin/outcome/Factories.kt create mode 100644 state/src/commonMain/kotlin/outcome/Outcome.kt delete mode 100644 state/src/commonMain/kotlin/outcome/Slice.kt delete mode 100644 state/src/commonTest/kotlin/ProgressionReporterTest.kt delete mode 100644 state/src/commonTest/kotlin/ProgressiveOutcomeTest.kt delete mode 100644 state/src/commonTest/kotlin/StateBuilderTest.kt create mode 100644 state/src/commonTest/kotlin/outcome/AccessorsTest.kt create mode 100644 state/src/commonTest/kotlin/progressive/AccessorsTest.kt create mode 100644 state/src/commonTest/kotlin/progressive/BuilderTest.kt diff --git a/state/src/commonMain/kotlin/outcome/Accessors.kt b/state/src/commonMain/kotlin/outcome/Accessors.kt index dd4301a..6bf02d5 100644 --- a/state/src/commonMain/kotlin/outcome/Accessors.kt +++ b/state/src/commonMain/kotlin/outcome/Accessors.kt @@ -1,15 +1,15 @@ package opensavvy.state.outcome -import arrow.core.Either -import opensavvy.state.Failure +import opensavvy.state.failure.Failure /** - * Returns the value of this outcome, or throws the [Failure] if it isn't successful. - * - * This function breaks the functional error handling paradigm, it shouldn't be used in regular code. - * It is useful in tests. + * Returns [Success.value][Outcome.Success.value], or `null` if this outcome is not successful. */ -fun Outcome.orThrow(): T = when (this) { - is Either.Left -> throw value.toException() - is Either.Right -> value -} +val Outcome<*, T>.valueOrNull: T? + get() = (this as? Outcome.Success)?.value + +/** + * Returns [Failure.failure][Outcome.Failure.failure], or `null` if this outcome is not a failure. + */ +val Outcome.failureOrNull: F? + get() = (this as? Outcome.Failure)?.failure diff --git a/state/src/commonMain/kotlin/outcome/Builder.kt b/state/src/commonMain/kotlin/outcome/Builder.kt index 5ca4253..ff7ca94 100644 --- a/state/src/commonMain/kotlin/outcome/Builder.kt +++ b/state/src/commonMain/kotlin/outcome/Builder.kt @@ -1,22 +1,7 @@ package opensavvy.state.outcome -import arrow.core.continuations.EffectScope -import arrow.core.continuations.either -import arrow.core.left -import opensavvy.state.Failure +import opensavvy.state.failure.Failure -/** - * Performs some calculation which may fail. - */ -suspend fun out(block: suspend EffectScope.() -> T): Outcome = - try { - either(block) - } catch (e: Failure.FailureException) { - e.failure.left() - } catch (e: IllegalArgumentException) { - failed(e.message ?: "IllegalArgumentException without a message", Failure.Kind.Invalid, cause = e) - } catch (e: IllegalStateException) { - failed(e.message ?: "IllegalStateException without a message", Failure.Kind.Invalid, cause = e) - } catch (e: NoSuchElementException) { - failed(e.message ?: "NoSuchElementException without a message", Failure.Kind.NotFound, cause = e) - } +fun T.success() = Outcome.Success(this) + +fun F.failed() = Outcome.Failure(this) diff --git a/state/src/commonMain/kotlin/outcome/Factories.kt b/state/src/commonMain/kotlin/outcome/Factories.kt deleted file mode 100644 index f7f4f37..0000000 --- a/state/src/commonMain/kotlin/outcome/Factories.kt +++ /dev/null @@ -1,15 +0,0 @@ -package opensavvy.state.outcome - -import arrow.core.left -import arrow.core.right -import opensavvy.state.Failure - -fun successful(value: T): Outcome = - value.right() - -fun failed( - message: String, - kind: Failure.Kind = Failure.Kind.Unknown, - cause: RuntimeException? = null, -): Outcome = - Failure(kind, message, cause).left() diff --git a/state/src/commonMain/kotlin/outcome/Outcome.kt b/state/src/commonMain/kotlin/outcome/Outcome.kt new file mode 100644 index 0000000..9f843e5 --- /dev/null +++ b/state/src/commonMain/kotlin/outcome/Outcome.kt @@ -0,0 +1,34 @@ +package opensavvy.state.outcome + +import opensavvy.state.outcome.Outcome.Failure +import opensavvy.state.outcome.Outcome.Success +import opensavvy.state.progressive.ProgressiveOutcome +import opensavvy.state.failure.Failure as FailureSupertype + +/** + * The result of an operation. + * + * To store progress information as well as the result of the operation, please see [ProgressiveOutcome]. + * + * There are two possible cases: + * - [Success] if a successful result is available (see [Success.value]), + * - [Failure] if a failed result is available (see [Failure.failure]). + * + * To create outcomes from computations, use the [success] and [failed] factories. + */ +sealed class Outcome { + + /** + * The latest known result of the operation was a success, available as [value]. + */ + data class Success( + val value: T, + ) : Outcome() + + /** + * The latest known result of the operation was a failure, available as [failure]. + */ + data class Failure( + val failure: F, + ) : Outcome() +} diff --git a/state/src/commonMain/kotlin/outcome/Slice.kt b/state/src/commonMain/kotlin/outcome/Slice.kt deleted file mode 100644 index f5ed5ea..0000000 --- a/state/src/commonMain/kotlin/outcome/Slice.kt +++ /dev/null @@ -1,12 +0,0 @@ -package opensavvy.state.outcome - -import arrow.core.Either -import opensavvy.state.Failure - -/** - * The result of a calculation that may have failed. - * - * [Outcome] is an implementation of the [Functional Error Handling](https://arrow-kt.io/docs/patterns/error_handling/) - * pattern recommended by Arrow. - */ -typealias Outcome = Either diff --git a/state/src/commonMain/kotlin/progressive/Accessors.kt b/state/src/commonMain/kotlin/progressive/Accessors.kt index 94f111c..012c37b 100644 --- a/state/src/commonMain/kotlin/progressive/Accessors.kt +++ b/state/src/commonMain/kotlin/progressive/Accessors.kt @@ -1,28 +1,20 @@ package opensavvy.state.progressive -import arrow.core.left -import arrow.core.right -import kotlinx.coroutines.flow.* -import opensavvy.progress.Progress -import opensavvy.progress.coroutines.report -import opensavvy.progress.done import opensavvy.state.outcome.Outcome import opensavvy.state.progressive.ProgressiveOutcome.* -import opensavvy.state.progressive.ProgressiveOutcome.Companion.component1 -import opensavvy.state.progressive.ProgressiveOutcome.Companion.component2 //region Regular /** * Returns [Success.value], or `null` if this outcome is not successful. */ -val ProgressiveOutcome.valueOrNull: T? +val ProgressiveOutcome<*, T>.valueOrNull: T? get() = (this as? Success)?.value /** * Returns [Failure.failure], or `null` if this outcome is not a failure. */ -val ProgressiveOutcome<*>.failureOrNull: opensavvy.state.Failure? +val ProgressiveOutcome.failureOrNull: F? get() = (this as? Failure)?.failure /** @@ -34,38 +26,23 @@ val ProgressiveOutcome<*>.failureOrNull: opensavvy.state.Failure? * val (outcome, progression) = /* ProgressiveOutcome */ * ``` */ -fun ProgressiveOutcome.asOutcome(): Outcome? = when (this) { - is Empty -> null - is Success -> value.right() - is Failure -> failure.left() +fun ProgressiveOutcome.asOutcome() = when (this) { + is Success -> Outcome.Success(value) + is Failure -> Outcome.Failure(failure) + is Incomplete -> null } //endregion -//region Flow +//region Destructuration /** - * Filters out all the [Empty] and loading values from this flow. - * - * All progress information is re-emitted in the calling flow. - */ -fun Flow>.filterDone() = onEach { report(it.progress) } - .filter { it.progress == done() } - .mapNotNull { it.asOutcome() } - -/** - * Suspends until the first finished value is available (success or failure). - * - * All progress information is re-emitted in the calling coroutine. - * - * @throws NoSuchElementException if the flow has no finished elements. + * Syntax sugar for [asOutcome]. */ -suspend fun Flow>.firstValue() = filterDone() - .first() +operator fun ProgressiveOutcome.component1() = asOutcome() /** - * Splits this progressive outcome into its outcome and progress information. + * Syntax sugar for [ProgressiveOutcome.progress]. */ -fun Flow>.asOutcomeAndProgress(): Flow?, Progress>> = - map { (out, progress) -> out to progress } +operator fun ProgressiveOutcome<*, *>.component2() = progress //endregion diff --git a/state/src/commonMain/kotlin/progressive/Builder.kt b/state/src/commonMain/kotlin/progressive/Builder.kt index f555493..fbd7af9 100644 --- a/state/src/commonMain/kotlin/progressive/Builder.kt +++ b/state/src/commonMain/kotlin/progressive/Builder.kt @@ -1,57 +1,35 @@ package opensavvy.state.progressive -import arrow.core.continuations.EffectScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.FlowCollector -import kotlinx.coroutines.flow.flow import opensavvy.progress.Progress import opensavvy.progress.done -import opensavvy.progress.report.ProgressReporter -import opensavvy.state.Failure +import opensavvy.state.failure.Failure import opensavvy.state.outcome.Outcome -import opensavvy.state.outcome.out /** * Adds [progress] information to this outcome to make it a [ProgressiveOutcome]. * - * Because regular outcomes cannot be unfinished, this function never returns [ProgressiveOutcome.Empty]. + * Because regular outcomes cannot be unfinished, this function never returns [ProgressiveOutcome.Incomplete]. */ -fun Outcome.withProgress(progress: Progress = done()) = fold( - ifLeft = { ProgressiveOutcome.Failure(it, progress) }, - ifRight = { ProgressiveOutcome.Success(it, progress) }, -) +fun Outcome.withProgress(progress: Progress = done()) = when (this) { + is Outcome.Success -> ProgressiveOutcome.Success(value, progress) + is Outcome.Failure -> ProgressiveOutcome.Failure(failure, progress) +} /** * Replaces the [progress] information from this progressive outcome. */ -fun ProgressiveOutcome.copy(progress: Progress.Loading) = when (this) { - is ProgressiveOutcome.Empty -> ProgressiveOutcome.Empty(progress) +fun ProgressiveOutcome.copy(progress: Progress.Loading) = when (this) { + is ProgressiveOutcome.Incomplete -> ProgressiveOutcome.Incomplete(progress) is ProgressiveOutcome.Failure -> ProgressiveOutcome.Failure(failure, progress) is ProgressiveOutcome.Success -> ProgressiveOutcome.Success(value, progress) } /** - * Performs some calculation which may fail, capturing all progression events in the process. - * - * For performance reasons, the [ProgressReporter.report] function is shadowed by [ProgressiveOutcomeCollector.report], which - * bypasses [ProgressReporter] and directly pushes the event into the resulting flow. - * Calls to [ProgressReporter.report] are not affected by this function (they pass through to the closest - * parent [ProgressReporter]). If you wish to capture them, use [captureProgress]. + * Convenience function to instantiate a [ProgressiveOutcome.Success]. */ -fun progressive(block: suspend ProgressiveOutcomeCollector.() -> T): Flow> = flow { - emit( - out { - block(ProgressiveOutcomeCollector(this@flow, this@out)) - }.withProgress() - ) -} - -class ProgressiveOutcomeCollector( - private val flowCollector: FlowCollector>, - private val effectScope: EffectScope, -) : EffectScope by effectScope { +fun T.success(progress: Progress = done()) = ProgressiveOutcome.Success(this, progress) - suspend fun report(progress: Progress.Loading) { - flowCollector.emit(ProgressiveOutcome.Empty(progress)) - } -} +/** + * Convenience function to instantiate a [ProgressiveOutcome.Failure]. + */ +fun F.failed(progress: Progress = done()) = ProgressiveOutcome.Failure(this, progress) diff --git a/state/src/commonMain/kotlin/progressive/Progressive.kt b/state/src/commonMain/kotlin/progressive/Progressive.kt index b2437a9..4c7332e 100644 --- a/state/src/commonMain/kotlin/progressive/Progressive.kt +++ b/state/src/commonMain/kotlin/progressive/Progressive.kt @@ -5,30 +5,29 @@ import opensavvy.progress.done import opensavvy.progress.loading import opensavvy.state.outcome.Outcome import opensavvy.state.progressive.ProgressiveOutcome.* +import opensavvy.state.failure.Failure as FailureSupertype /** * A [Outcome] with integrated [Progress] management. * * There are three possible cases: - * - [Empty] if the task has started but no value is currently available, + * - [Incomplete] if the task has started but no value is currently available, * - [Success] if a successful result is available (see [Success.value]), * - [Failure] if a failed result is available (see [Failure.failure]). * * Note that in cases, a progressive outcome may be currently loading. - * - [Empty] must be loading, + * - [Incomplete] must be loading, * - [Success] may be loading if the task is currently querying a newer value than the one it stores, * - [Failure] may be loading if the task is currently retrying the operation. * - * To create a progressive outcome from a function returning a regular outcome, use [captureProgress]. - * - * To access the inner outcome and progression, you can use [asOutcomeAndProgress] or the destructuring operator: + * To access the inner outcome and progression, you can use the destructuring operator: * ```kotlin * val (out, progression) = /* ProgressiveOutcome */ * ``` * - * To create progressive outcomes from computations, use the [progressive] or [captureProgress] builders. + * To create progressive outcomes from computations, use the [success] and [failed] factories. */ -sealed class ProgressiveOutcome { +sealed class ProgressiveOutcome { /** * The current progression of this outcome. @@ -40,9 +39,9 @@ sealed class ProgressiveOutcome { /** * The operation is ongoing, but we do not know if it will be successful or a failure. */ - data class Empty( + data class Incomplete( override val progress: Progress.Loading = loading(), - ) : ProgressiveOutcome() + ) : ProgressiveOutcome() /** * The latest known result of the operation was a success, available as [value]. @@ -53,7 +52,7 @@ sealed class ProgressiveOutcome { data class Success( val value: T, override val progress: Progress = done(), - ) : ProgressiveOutcome() + ) : ProgressiveOutcome() /** * The latest known result of the operation was a failure, available as [failure]. @@ -61,16 +60,9 @@ sealed class ProgressiveOutcome { * If [progress] is loading, this means the operation has been retried in an attempt to access a more up-to-date * version. */ - data class Failure( - val failure: opensavvy.state.Failure, + data class Failure( + val failure: F, override val progress: Progress = done(), - ) : ProgressiveOutcome() - - companion object { - - operator fun ProgressiveOutcome.component1() = asOutcome() - operator fun ProgressiveOutcome.component2() = progress - - } + ) : ProgressiveOutcome() } diff --git a/state/src/commonTest/kotlin/ProgressionReporterTest.kt b/state/src/commonTest/kotlin/ProgressionReporterTest.kt deleted file mode 100644 index cba0300..0000000 --- a/state/src/commonTest/kotlin/ProgressionReporterTest.kt +++ /dev/null @@ -1,74 +0,0 @@ -package opensavvy.state - -import kotlinx.coroutines.* -import kotlinx.coroutines.test.runTest -import opensavvy.progress.coroutines.StateFlowProgressReporter -import opensavvy.progress.coroutines.asCoroutineContext -import opensavvy.progress.coroutines.mapProgressTo -import opensavvy.progress.coroutines.report -import opensavvy.progress.done -import opensavvy.progress.loading -import kotlin.test.Test -import kotlin.test.assertEquals - -@OptIn(ExperimentalCoroutinesApi::class) -class ProgressionReporterTest { - - @Test - fun simple() = runTest { - val reporter = StateFlowProgressReporter() - - launch { - withContext(reporter.asCoroutineContext()) { - report(loading(0.0)) - delay(100) - report(loading(0.5)) - delay(100) - report(loading(1.0)) - } - } - - yield() - - assertEquals(loading(0.0), reporter.progress.value) - delay(150) - assertEquals(loading(0.5), reporter.progress.value) - delay(150) - assertEquals(loading(1.0), reporter.progress.value) - } - - @Test - fun nested() = runTest { - val reporter = StateFlowProgressReporter() - - launch { - withContext(reporter.asCoroutineContext()) { - mapProgressTo(0.0..0.5) { - report(loading()) - delay(10) - report(done()) - delay(10) - report(loading(0.5)) - delay(100) - } - report(loading(0.5)) - } - } - - yield() - - delay(50) - assertEquals(loading(0.25), reporter.progress.value) - delay(150) - assertEquals(loading(0.5), reporter.progress.value) - } - - @Test - fun noReporter() = runTest { - // There is no ProgressionReporter in this coroutine, so reporting progress should do nothing - report(loading()) - report(loading(0.5)) - report(done()) - } - -} diff --git a/state/src/commonTest/kotlin/ProgressiveOutcomeTest.kt b/state/src/commonTest/kotlin/ProgressiveOutcomeTest.kt deleted file mode 100644 index 58936b3..0000000 --- a/state/src/commonTest/kotlin/ProgressiveOutcomeTest.kt +++ /dev/null @@ -1,150 +0,0 @@ -package opensavvy.state - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.runTest -import opensavvy.progress.coroutines.report -import opensavvy.progress.loading -import opensavvy.state.outcome.failed -import opensavvy.state.outcome.successful -import opensavvy.state.progressive.* -import kotlin.test.Test -import kotlin.test.assertEquals - -@OptIn(ExperimentalCoroutinesApi::class) -class ProgressiveOutcomeTest { - - @Test - fun convertSuccess() { - val out = successful(5) - - val progressive = out.withProgress(loading(0.7)) - - assertEquals(out, progressive.asOutcome()) - assertEquals(5, progressive.valueOrNull) - assertEquals(loading(0.7), progressive.progress) - } - - @Test - fun convertFailure() { - val out = failed("Error", Failure.Kind.NotFound) - - val progressive = out.withProgress(loading(0.7)) - - assertEquals(out, progressive.asOutcome()) - assertEquals(Failure(Failure.Kind.NotFound, "Error"), progressive.failureOrNull) - assertEquals(loading(0.7), progressive.progress) - } - - @Test - fun convertFlow() = runTest { - assertEquals( - listOf( - ProgressiveOutcome.Empty(loading(0.2)), - ProgressiveOutcome.Empty(loading(0.7)), - ProgressiveOutcome.Success(5), - ), - flow { - report(loading(0.2)) - delay(100) - report(loading(0.7)) - delay(200) - emit(successful(5)) - }.captureProgress() - .toList() - ) - } - - @Test - fun captureFromBlock() = runTest { - assertEquals( - listOf( - ProgressiveOutcome.Empty(loading(0.2)), - ProgressiveOutcome.Empty(loading(0.7)), - ProgressiveOutcome.Success(5), - ), - captureProgress { - report(loading(0.2)) - delay(100) - report(loading(0.7)) - delay(200) - successful(5) - }.toList() - ) - } - - @Test - fun captureBuilder() = runTest { - assertEquals( - listOf( - ProgressiveOutcome.Empty(loading(0.2)), - ProgressiveOutcome.Empty(loading(0.7)), - ProgressiveOutcome.Success(5), - ), - progressive { - report(loading(0.2)) - delay(100) - report(loading(0.7)) - delay(200) - 5 - }.toList() - ) - } - - @Test - fun filters() = runTest { - assertEquals( - successful(5), - progressive { - report(loading(0.2)) - delay(100) - report(loading(0.7)) - delay(200) - 5 - }.firstValue() - ) - } - - @Test - fun conditionals() = runTest { - val tests = mapOf( - // to success, failure, loading - ProgressiveOutcome.Success(5) to Triple(true, false, false), - ProgressiveOutcome.Success(5, loading()) to Triple(true, false, true), - ProgressiveOutcome.Empty() to Triple(false, false, true), - ProgressiveOutcome.Failure(Failure(Failure.Kind.NotFound, "Error")) to Triple(false, true, false), - ProgressiveOutcome.Failure(Failure(Failure.Kind.NotFound, "Error"), loading()) to Triple( - false, - true, - true - ), - ) - - for ((out, expected) in tests) { - val (expectedSuccess, expectedFailure, expectedLoading) = expected - - var didSuccessRun = false - var didFailureRun = false - var didLoadingRun = false - - out.onSuccess { - didSuccessRun = true - } - - out.onFailure { - didFailureRun = true - } - - out.onLoading { - didLoadingRun = true - } - - assertEquals(expectedSuccess, didSuccessRun) - assertEquals(expectedFailure, didFailureRun) - assertEquals(expectedLoading, didLoadingRun) - } - } - -} diff --git a/state/src/commonTest/kotlin/StateBuilderTest.kt b/state/src/commonTest/kotlin/StateBuilderTest.kt deleted file mode 100644 index 618a26c..0000000 --- a/state/src/commonTest/kotlin/StateBuilderTest.kt +++ /dev/null @@ -1,71 +0,0 @@ -@file:OptIn(ExperimentalCoroutinesApi::class) - -package opensavvy.state - -import arrow.core.Either -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runTest -import opensavvy.state.outcome.ensureValid -import opensavvy.state.outcome.failed -import opensavvy.state.outcome.out -import opensavvy.state.outcome.successful -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs - -class StateBuilderTest { - - @Test - fun success() = runTest { - val data = out { 0 } - - assertEquals(successful(0), data) - } - - @Test - fun ensure() = runTest { - val data = out { - ensureValid(false) { "this is always invalid" } - 0 - } - - assertEquals( - failed("this is always invalid", Failure.Kind.Invalid), - data - ) - } - - @Test - fun throwStandard() = runTest { - val data = out { - throw Failure(Failure.Kind.Invalid, "this is always invalid").toException() - @Suppress("UNREACHABLE_CODE") 0 - } - - assertEquals( - failed("this is always invalid", Failure.Kind.Invalid), - data - ) - } - - @Test - fun throwIllegalArgumentException() = runTest { - val data = out { - require(false) { "some error" } - 0 - } - - assertIs>(data) - } - - @Test - fun throwIllegalStateException() = runTest { - val data = out { - check(false) { "some error" } - 0 - } - - assertIs>(data) - } - -} diff --git a/state/src/commonTest/kotlin/outcome/AccessorsTest.kt b/state/src/commonTest/kotlin/outcome/AccessorsTest.kt new file mode 100644 index 0000000..e1693d5 --- /dev/null +++ b/state/src/commonTest/kotlin/outcome/AccessorsTest.kt @@ -0,0 +1,42 @@ +package opensavvy.state.outcome + +import opensavvy.state.failure.NotFound +import kotlin.test.Test +import kotlin.test.assertEquals + +class AccessorsTest { + + @Test + fun valueOnSuccess() { + assertEquals( + 5, + 5.success().valueOrNull, + ) + } + + @Suppress("IMPLICIT_NOTHING_TYPE_ARGUMENT_IN_RETURN_POSITION") // it's the goal of the test! + @Test + fun valueOnFailure() { + assertEquals( + null, + NotFound("").failed().valueOrNull, + ) + } + + @Suppress("IMPLICIT_NOTHING_TYPE_ARGUMENT_IN_RETURN_POSITION") // it's the goal of the test! + @Test + fun failureOnSuccess() { + assertEquals( + null, + 5.success().failureOrNull, + ) + } + + @Test + fun failureOnFailure() { + assertEquals( + NotFound(""), + NotFound("").failed().failureOrNull, + ) + } +} diff --git a/state/src/commonTest/kotlin/progressive/AccessorsTest.kt b/state/src/commonTest/kotlin/progressive/AccessorsTest.kt new file mode 100644 index 0000000..8be875b --- /dev/null +++ b/state/src/commonTest/kotlin/progressive/AccessorsTest.kt @@ -0,0 +1,72 @@ +package opensavvy.state.progressive + +import opensavvy.progress.loading +import opensavvy.state.failure.NotFound +import opensavvy.state.outcome.Outcome +import kotlin.test.Test +import kotlin.test.assertEquals + + +class AccessorsTest { + + @Test + fun valueOnSuccess() { + assertEquals( + 5, + 5.success().valueOrNull, + ) + } + + @Suppress("IMPLICIT_NOTHING_TYPE_ARGUMENT_IN_RETURN_POSITION") // it's the goal of the test! + @Test + fun valueOnFailure() { + assertEquals( + null, + NotFound("").failed().valueOrNull, + ) + } + + @Suppress("IMPLICIT_NOTHING_TYPE_ARGUMENT_IN_RETURN_POSITION") // it's the goal of the test! + @Test + fun failureOnSuccess() { + assertEquals( + null, + 5.success().failureOrNull, + ) + } + + @Test + fun failureOnFailure() { + assertEquals( + NotFound(""), + NotFound("").failed().failureOrNull, + ) + } + + @Test + fun destructurationOnSuccess() { + val value: ProgressiveOutcome<*, Int> = ProgressiveOutcome.Success(5, loading(0.23)) + val (outcome, progress) = value + + assertEquals(Outcome.Success(5), outcome) + assertEquals(loading(0.23), progress) + } + + @Test + fun destructurationOnFailure() { + val value: ProgressiveOutcome<*, Int> = ProgressiveOutcome.Failure(NotFound("id"), loading(0.23)) + val (outcome, progress) = value + + assertEquals(Outcome.Failure(NotFound("id")), outcome) + assertEquals(loading(0.23), progress) + } + + @Test + fun destructurationOnIncomplete() { + val value: ProgressiveOutcome<*, Int> = ProgressiveOutcome.Incomplete(loading(0.23)) + val (outcome, progress) = value + + assertEquals(null, outcome) + assertEquals(loading(0.23), progress) + } +} diff --git a/state/src/commonTest/kotlin/progressive/BuilderTest.kt b/state/src/commonTest/kotlin/progressive/BuilderTest.kt new file mode 100644 index 0000000..9219afa --- /dev/null +++ b/state/src/commonTest/kotlin/progressive/BuilderTest.kt @@ -0,0 +1,26 @@ +package opensavvy.state.progressive + +import opensavvy.progress.loading +import opensavvy.state.failure.NotFound +import opensavvy.state.outcome.Outcome +import kotlin.test.Test +import kotlin.test.assertEquals + +class BuilderTest { + + @Test + fun withProgressSuccess() { + assertEquals( + ProgressiveOutcome.Success(5, loading(0.57)), + Outcome.Success(5).withProgress(loading(0.57)), + ) + } + + @Test + fun withProgressFailure() { + assertEquals( + ProgressiveOutcome.Failure(NotFound("test"), loading(0.57)), + Outcome.Failure(NotFound("test")).withProgress(loading(0.57)), + ) + } +} -- 2.51.2 From f72de5d52910732b7ea3e5171977c748adb1f1e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 7 Apr 2023 22:17:02 +0200 Subject: [PATCH 26/51] refactor(state-coroutines): Move captureProgress to state-coroutines --- .../src/commonMain/kotlin/CaptureProgress.kt | 58 +++++++++++++++++++ .../commonTest/kotlin/CaptureProgressTest.kt | 56 ++++++++++++++++++ .../commonMain/kotlin/progressive/Capture.kt | 54 ----------------- 3 files changed, 114 insertions(+), 54 deletions(-) create mode 100644 state-coroutines/src/commonMain/kotlin/CaptureProgress.kt create mode 100644 state-coroutines/src/commonTest/kotlin/CaptureProgressTest.kt delete mode 100644 state/src/commonMain/kotlin/progressive/Capture.kt diff --git a/state-coroutines/src/commonMain/kotlin/CaptureProgress.kt b/state-coroutines/src/commonMain/kotlin/CaptureProgress.kt new file mode 100644 index 0000000..e15ad25 --- /dev/null +++ b/state-coroutines/src/commonMain/kotlin/CaptureProgress.kt @@ -0,0 +1,58 @@ +package opensavvy.state.coroutines + +import kotlinx.coroutines.channels.ProducerScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.withContext +import opensavvy.logger.Logger.Companion.warn +import opensavvy.logger.loggerFor +import opensavvy.progress.Progress +import opensavvy.progress.coroutines.CoroutineProgressReporter +import opensavvy.state.failure.Failure +import opensavvy.state.outcome.Outcome +import opensavvy.state.progressive.ProgressiveOutcome +import opensavvy.state.progressive.failed +import opensavvy.state.progressive.withProgress + +private fun ProducerScope>.progressExtractor() = CoroutineProgressReporter { + if (it is Progress.Loading) { + val result = trySend(ProgressiveOutcome.Incomplete(it)) + + if (result.isFailure) { + loggerFor(this).warn( + it, + result + ) { "Could not send the progression event to the channel, it has been ignored" } + } + } + // else: the action has reported that it's over, but the results haven't reached us yet, they'll probably + // arrive just afterward +} + +/** + * Captures the progress information of the current operation using [CoroutineProgressReporter]. + * + * Because flows cannot emit from multiple coroutines, the implementation of this function requires the usage + * of channels, which are more expensive. + * If possible, prefer using the [failed] builder. + */ +@Suppress("RemoveExplicitTypeArguments") // IDEA bug, they are necessary here +fun Flow>.captureProgress(): Flow> = channelFlow { + this@captureProgress + .flowOn(progressExtractor()) + .map { it.withProgress() } + .onEach { send(it) } + .collect() +}.buffer(1) + +/** + * Captures the progress information of [block] using [CoroutineProgressReporter]. + * + * Because flows cannot emit from multiple coroutines, the implementation of this function requires the usage + * of channels, which are more expensive. + * If possible, prefer using the [failed] builder. + */ +fun captureProgress(block: suspend () -> Outcome): Flow> = channelFlow { + withContext(progressExtractor()) { + send(block().withProgress()) + } +} diff --git a/state-coroutines/src/commonTest/kotlin/CaptureProgressTest.kt b/state-coroutines/src/commonTest/kotlin/CaptureProgressTest.kt new file mode 100644 index 0000000..d165ddb --- /dev/null +++ b/state-coroutines/src/commonTest/kotlin/CaptureProgressTest.kt @@ -0,0 +1,56 @@ +package opensavvy.state.coroutines + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import opensavvy.progress.coroutines.report +import opensavvy.progress.loading +import opensavvy.state.failure.Unauthorized +import opensavvy.state.outcome.Outcome +import opensavvy.state.progressive.ProgressiveOutcome +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class CaptureProgressTest { + + @Test + fun captureFromFlow() = runTest { + val actual = flow { + report(loading(0.2)) + report(loading(0.5)) + emit(Outcome.Failure(Unauthorized())) + report(loading(0.7)) + emit(Outcome.Success(5)) + }.captureProgress() + .toList() + + val expect = listOf( + ProgressiveOutcome.Incomplete(loading(0.2)), + ProgressiveOutcome.Incomplete(loading(0.5)), + ProgressiveOutcome.Failure(Unauthorized()), + ProgressiveOutcome.Incomplete(loading(0.7)), + ProgressiveOutcome.Success(5), + ) + + assertEquals(expect, actual) + } + + @Test + fun captureFromBlock() = runTest { + val actual = captureProgress { + report(loading(0.2)) + report(loading(0.7)) + Outcome.Success(5) + }.toList() + + val expect = listOf( + ProgressiveOutcome.Incomplete(loading(0.2)), + ProgressiveOutcome.Incomplete(loading(0.7)), + ProgressiveOutcome.Success(5), + ) + + assertEquals(expect, actual) + } +} diff --git a/state/src/commonMain/kotlin/progressive/Capture.kt b/state/src/commonMain/kotlin/progressive/Capture.kt deleted file mode 100644 index a0b8dba..0000000 --- a/state/src/commonMain/kotlin/progressive/Capture.kt +++ /dev/null @@ -1,54 +0,0 @@ -package opensavvy.state.progressive - -import kotlinx.coroutines.channels.ProducerScope -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.withContext -import opensavvy.logger.Logger.Companion.warn -import opensavvy.logger.loggerFor -import opensavvy.progress.Progress -import opensavvy.progress.coroutines.CoroutineProgressReporter -import opensavvy.state.outcome.Outcome - -private val ProducerScope>.progressExtractor: CoroutineProgressReporter - get() = CoroutineProgressReporter { - if (it is Progress.Loading) { - val result = trySend(ProgressiveOutcome.Empty(it)) - - if (result.isFailure) { - loggerFor(this).warn( - it, - result - ) { "Could not send the progression event to the channel, it has been ignored" } - } - } - // else: the action has reported that it's over, but the results haven't reached us yet, they'll probably - // arrive just afterward - } - -/** - * Captures the progress information of the current operation using [CoroutineProgressReporter]. - * - * Because flows cannot emit from multiple coroutines, the implementation of this function requires the usage - * of channels, which are more expensive. - * If possible, prefer using the [progressive] builder. - */ -fun Flow>.captureProgress() = channelFlow { - this@captureProgress - .flowOn(progressExtractor) - .map { it.withProgress() } - .onEach { send(it) } - .collect() -}.buffer(1) - -/** - * Captures the progress information of [block] using [CoroutineProgressReporter]. - * - * Because flows cannot emit from multiple coroutines, the implementation of this function requires the usage - * of channels, which are more expensive. - * If possible, prefer using the [progressive] builder. - */ -fun captureProgress(block: suspend () -> Outcome) = channelFlow { - withContext(progressExtractor) { - send(block().withProgress()) - } -} -- 2.51.2 From 5c2842e12ea0ee76484952b1b2f7a707fabf754f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 7 Apr 2023 22:36:51 +0200 Subject: [PATCH 27/51] refactor(state): Rewrote the mapping functions --- .../src/commonMain/kotlin/outcome/Mapping.kt | 13 +++++++ .../commonMain/kotlin/progressive/Mapping.kt | 14 ++++++++ .../commonTest/kotlin/outcome/MappingTest.kt | 26 ++++++++++++++ .../kotlin/progressive/MappingTest.kt | 35 +++++++++++++++++++ 4 files changed, 88 insertions(+) create mode 100644 state/src/commonMain/kotlin/outcome/Mapping.kt create mode 100644 state/src/commonMain/kotlin/progressive/Mapping.kt create mode 100644 state/src/commonTest/kotlin/outcome/MappingTest.kt create mode 100644 state/src/commonTest/kotlin/progressive/MappingTest.kt diff --git a/state/src/commonMain/kotlin/outcome/Mapping.kt b/state/src/commonMain/kotlin/outcome/Mapping.kt new file mode 100644 index 0000000..ca4c14c --- /dev/null +++ b/state/src/commonMain/kotlin/outcome/Mapping.kt @@ -0,0 +1,13 @@ +package opensavvy.state.outcome + +import opensavvy.state.failure.Failure + +/** + * Replaces the value of this outcome if it is successful using [transform]. + * + * If this outcome isn't successful, does nothing. + */ +inline fun Outcome.map(transform: (T) -> U) = when (this) { + is Outcome.Failure -> this + is Outcome.Success -> Outcome.Success(transform(this.value)) +} diff --git a/state/src/commonMain/kotlin/progressive/Mapping.kt b/state/src/commonMain/kotlin/progressive/Mapping.kt new file mode 100644 index 0000000..3afd6f0 --- /dev/null +++ b/state/src/commonMain/kotlin/progressive/Mapping.kt @@ -0,0 +1,14 @@ +package opensavvy.state.progressive + +import opensavvy.state.failure.Failure + +/** + * Replaces the value of this outcome if it is successful using [transform]. + * + * If this outcome isn't successful, does nothing. + */ +inline fun ProgressiveOutcome.map(transform: (T) -> U) = when (this) { + is ProgressiveOutcome.Incomplete -> this + is ProgressiveOutcome.Failure -> this + is ProgressiveOutcome.Success -> ProgressiveOutcome.Success(transform(this.value), this.progress) +} diff --git a/state/src/commonTest/kotlin/outcome/MappingTest.kt b/state/src/commonTest/kotlin/outcome/MappingTest.kt new file mode 100644 index 0000000..775d0b4 --- /dev/null +++ b/state/src/commonTest/kotlin/outcome/MappingTest.kt @@ -0,0 +1,26 @@ +package opensavvy.state.outcome + +import opensavvy.state.failure.NotFound +import kotlin.test.Test +import kotlin.test.assertEquals + +class MappingTest { + + @Test + fun success_map() { + assertEquals( + Outcome.Success("5"), + Outcome.Success(5).map { it.toString() }, + ) + } + + @Suppress("UNREACHABLE_CODE") // it's the purpose of the test! + @Test + fun failure_map() { + assertEquals( + Outcome.Failure(NotFound(5)), + Outcome.Failure(NotFound(5)).map { it.toString() }, + ) + } + +} diff --git a/state/src/commonTest/kotlin/progressive/MappingTest.kt b/state/src/commonTest/kotlin/progressive/MappingTest.kt new file mode 100644 index 0000000..35cca53 --- /dev/null +++ b/state/src/commonTest/kotlin/progressive/MappingTest.kt @@ -0,0 +1,35 @@ +package opensavvy.state.progressive + +import opensavvy.state.failure.NotFound +import kotlin.test.Test +import kotlin.test.assertEquals + +class MappingTest { + + @Test + fun success_map() { + assertEquals( + ProgressiveOutcome.Success("5"), + ProgressiveOutcome.Success(5).map { it.toString() }, + ) + } + + @Suppress("UNREACHABLE_CODE") // it's the purpose of the test! + @Test + fun failure_map() { + assertEquals( + ProgressiveOutcome.Failure(NotFound(5)), + ProgressiveOutcome.Failure(NotFound(5)).map { it.toString() }, + ) + } + + @Suppress("UNREACHABLE_CODE") // it's the purpose of the test! + @Test + fun incomplete_map() { + assertEquals( + ProgressiveOutcome.Incomplete(), + ProgressiveOutcome.Incomplete().map { it.toString() }, + ) + } + +} -- 2.51.2 From 10e242885dd11f4c556216f6b04a866ba20bb942 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 7 Apr 2023 22:39:20 +0200 Subject: [PATCH 28/51] refactor(state): Rewrote the conditional accessors --- .../commonMain/kotlin/outcome/Conditional.kt | 26 ++++ .../kotlin/progressive/Conditional.kt | 49 ++---- .../kotlin/outcome/ConditionalTest.kt | 53 +++++++ .../kotlin/progressive/ConditionalTest.kt | 143 ++++++++++++++++++ 4 files changed, 238 insertions(+), 33 deletions(-) create mode 100644 state/src/commonMain/kotlin/outcome/Conditional.kt create mode 100644 state/src/commonTest/kotlin/outcome/ConditionalTest.kt create mode 100644 state/src/commonTest/kotlin/progressive/ConditionalTest.kt diff --git a/state/src/commonMain/kotlin/outcome/Conditional.kt b/state/src/commonMain/kotlin/outcome/Conditional.kt new file mode 100644 index 0000000..62422d3 --- /dev/null +++ b/state/src/commonMain/kotlin/outcome/Conditional.kt @@ -0,0 +1,26 @@ +package opensavvy.state.outcome + +import opensavvy.state.outcome.Outcome.Failure +import opensavvy.state.outcome.Outcome.Success +import opensavvy.state.failure.Failure as FailureSupertype + + +/** + * Executes [block] if this outcome is [successful][Success]. + * + * Otherwise, does nothing. + */ +inline fun Outcome<*, T>.onSuccess(block: (T) -> Unit) { + if (this is Success) + block(this.value) +} + +/** + * Executes [block] if this outcome is a [failure][Failure]. + * + * Otherwise, does nothing. + */ +inline fun Outcome.onFailure(block: (F) -> Unit) { + if (this is Failure) + block(this.failure) +} diff --git a/state/src/commonMain/kotlin/progressive/Conditional.kt b/state/src/commonMain/kotlin/progressive/Conditional.kt index 2636c2f..6e274ad 100644 --- a/state/src/commonMain/kotlin/progressive/Conditional.kt +++ b/state/src/commonMain/kotlin/progressive/Conditional.kt @@ -1,18 +1,15 @@ package opensavvy.state.progressive -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map import opensavvy.progress.Progress import opensavvy.state.progressive.ProgressiveOutcome.* - -//region Actions +import opensavvy.state.failure.Failure as FailureSupertype /** * Executes [block] if this outcome is [successful][Success]. * * Otherwise, does nothing. */ -inline fun ProgressiveOutcome.onSuccess(block: (T) -> Unit) { +inline fun ProgressiveOutcome<*, T>.onSuccess(block: (T) -> Unit) { if (this is Success) block(this.value) } @@ -22,46 +19,32 @@ inline fun ProgressiveOutcome.onSuccess(block: (T) -> Unit) { * * Otherwise, does nothing. */ -inline fun ProgressiveOutcome.onFailure(block: (opensavvy.state.Failure) -> Unit) { +inline fun ProgressiveOutcome.onFailure(block: (F) -> Unit) { if (this is Failure) block(this.failure) } /** - * Executes [block] if this outcome is loading (its [ProgressiveOutcome.progress] is [Progress.Loading]). - * - * Note that this isn't synonymous with this outcome being in the [Empty] state: successful or failed outcomes may - * still be loading. For more information, see [ProgressiveOutcome]. + * Executes [block] if this outcome is [incomplete][Incomplete]. * * Otherwise, does nothing. */ -inline fun ProgressiveOutcome.onLoading(block: (Progress.Loading) -> Unit) { - val progression = progress - - if (progression is Progress.Loading) - block(progression) +inline fun ProgressiveOutcome<*, *>.onIncomplete(block: () -> Unit) { + if (this is Incomplete) + block() } -//endregion -//region Combinations - /** - * Replaces the value of this outcome if it is successful using [transform]. + * Executes [block] if this outcome is loading (its [ProgressiveOutcome.progress] is [Progress.Loading]). * - * If this outcome isn't successful, does nothing. - */ -inline fun ProgressiveOutcome.map(transform: (T) -> U) = when (this) { - is Empty -> this - is Failure -> this - is Success -> Success(transform(this.value), this.progress) -} - -/** - * Replaces the successful values using [transform]. + * Note that this isn't synonymous with this outcome being in the [Incomplete] state: successful or failed outcomes may + * still be loading. For more information, see [ProgressiveOutcome]. * - * Values that are not successful are unchanged. + * Otherwise, does nothing. */ -inline fun Flow>.mapSuccess(crossinline transform: (T) -> U) = this - .map { it.map(transform) } +inline fun ProgressiveOutcome<*, *>.onLoading(block: (Progress.Loading) -> Unit) { + val progression = progress -//endregion + if (progression is Progress.Loading) + block(progression) +} diff --git a/state/src/commonTest/kotlin/outcome/ConditionalTest.kt b/state/src/commonTest/kotlin/outcome/ConditionalTest.kt new file mode 100644 index 0000000..ecdc3c3 --- /dev/null +++ b/state/src/commonTest/kotlin/outcome/ConditionalTest.kt @@ -0,0 +1,53 @@ +package opensavvy.state.outcome + +import opensavvy.state.failure.NotFound +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ConditionalTest { + + @Test + fun success_onSuccess() { + var test = false + + Outcome.Success(5).onSuccess { + test = true + } + + assertTrue(test) + } + + @Test + fun failure_onSuccess() { + var test = false + + Outcome.Failure(NotFound(5)).onSuccess { + test = true + } + + assertFalse(test) + } + + @Test + fun success_onFailure() { + var test = false + + Outcome.Success(5).onFailure { + test = true + } + + assertFalse(test) + } + + @Test + fun failure_onFailure() { + var test = false + + Outcome.Failure(NotFound(5)).onFailure { + test = true + } + + assertTrue(test) + } +} diff --git a/state/src/commonTest/kotlin/progressive/ConditionalTest.kt b/state/src/commonTest/kotlin/progressive/ConditionalTest.kt new file mode 100644 index 0000000..76afc40 --- /dev/null +++ b/state/src/commonTest/kotlin/progressive/ConditionalTest.kt @@ -0,0 +1,143 @@ +package opensavvy.state.progressive + +import opensavvy.progress.loading +import opensavvy.state.failure.NotFound +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ConditionalTest { + + @Test + fun success_onSuccess() { + var test = false + + ProgressiveOutcome.Success(5).onSuccess { + test = true + } + + assertTrue(test) + } + + @Test + fun failure_onSuccess() { + var test = false + + ProgressiveOutcome.Failure(NotFound(5)).onSuccess { + test = true + } + + assertFalse(test) + } + + @Test + fun incomplete_onSuccess() { + var test = false + + ProgressiveOutcome.Incomplete().onSuccess { + test = true + } + + assertFalse(test) + } + + @Test + fun success_onFailure() { + var test = false + + ProgressiveOutcome.Success(5).onFailure { + test = true + } + + assertFalse(test) + } + + @Test + fun failure_onFailure() { + var test = false + + ProgressiveOutcome.Failure(NotFound(5)).onFailure { + test = true + } + + assertTrue(test) + } + + @Test + fun incomplete_onFailure() { + var test = false + + ProgressiveOutcome.Incomplete().onFailure { + test = true + } + + assertFalse(test) + } + + @Test + fun success_onIncomplete() { + var test = false + + ProgressiveOutcome.Success(5).onIncomplete { + test = true + } + + assertFalse(test) + } + + @Test + fun failure_onIncomplete() { + var test = false + + ProgressiveOutcome.Failure(NotFound(5)).onIncomplete { + test = true + } + + assertFalse(test) + } + + @Test + fun incomplete_onIncomplete() { + var test = false + + ProgressiveOutcome.Incomplete().onIncomplete { + test = true + } + + assertTrue(test) + } + + @Test + fun done_onLoading() { + var test = false + + ProgressiveOutcome.Success(5).onLoading { + test = true + } + + assertFalse(test) + } + + @Test + fun loading_onLoading() { + var test = false + + ProgressiveOutcome.Failure(NotFound(5), loading(0.2)).onLoading { + test = true + } + + assertTrue(test) + } + + @Test + fun incomplete_onLoading() { + var test = false + + ProgressiveOutcome.Incomplete().onLoading { + test = true + } + + assertTrue(test) + } + +} -- 2.51.2 From 0bd727362fb7d5369c389dc01cd187decc2b54fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 7 Apr 2023 22:47:56 +0200 Subject: [PATCH 29/51] feat(state-arrow): Converters from Outcome/ProgressiveOutcome to Either --- .../src/commonMain/kotlin/Converter.kt | 29 ++++++++++ .../src/commonTest/kotlin/ConverterTest.kt | 55 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 state-arrow/src/commonMain/kotlin/Converter.kt create mode 100644 state-arrow/src/commonTest/kotlin/ConverterTest.kt diff --git a/state-arrow/src/commonMain/kotlin/Converter.kt b/state-arrow/src/commonMain/kotlin/Converter.kt new file mode 100644 index 0000000..8802c49 --- /dev/null +++ b/state-arrow/src/commonMain/kotlin/Converter.kt @@ -0,0 +1,29 @@ +package opensavvy.state.arrow + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import opensavvy.state.failure.Failure +import opensavvy.state.outcome.Outcome +import opensavvy.state.progressive.ProgressiveOutcome + +/** + * Converts an [Outcome] into a typed [Either]. + * + * The [out] DSL is also useful to work with [Outcome] in the Arrow world. + */ +fun Outcome.toEither() = when (this) { + is Outcome.Success -> value.right() + is Outcome.Failure -> failure.left() +} + +/** + * Converts a [ProgressiveOutcome] into a typed [Either]. + * + * If the outcome is [incomplete][ProgressiveOutcome.Incomplete], `null` is returned. + */ +fun ProgressiveOutcome.toEither() = when (this) { + is ProgressiveOutcome.Success -> value.right() + is ProgressiveOutcome.Failure -> failure.left() + is ProgressiveOutcome.Incomplete -> null +} diff --git a/state-arrow/src/commonTest/kotlin/ConverterTest.kt b/state-arrow/src/commonTest/kotlin/ConverterTest.kt new file mode 100644 index 0000000..3675416 --- /dev/null +++ b/state-arrow/src/commonTest/kotlin/ConverterTest.kt @@ -0,0 +1,55 @@ +package opensavvy.state.arrow + +import arrow.core.left +import arrow.core.right +import opensavvy.state.failure.NotFound +import opensavvy.state.progressive.ProgressiveOutcome +import kotlin.test.Test +import kotlin.test.assertEquals +import opensavvy.state.outcome.failed as outcomeFailed +import opensavvy.state.outcome.success as outcomeSuccess +import opensavvy.state.progressive.failed as progressiveFailed +import opensavvy.state.progressive.success as progressiveSuccess + +class ConverterTest { + + @Test + fun outcomeSuccess() { + assertEquals( + 5.right(), + 5.outcomeSuccess().toEither() + ) + } + + @Test + fun outcomeFailure() { + assertEquals( + NotFound(5).left(), + NotFound(5).outcomeFailed().toEither() + ) + } + + @Test + fun progressiveSuccess() { + assertEquals( + 5.right(), + 5.progressiveSuccess().toEither(), + ) + } + + @Test + fun progressiveFailure() { + assertEquals( + NotFound(5).left(), + NotFound(5).progressiveFailed().toEither(), + ) + } + + @Test + fun progressiveIncomplete() { + assertEquals( + null, + ProgressiveOutcome.Incomplete().toEither(), + ) + } +} -- 2.51.2 From d22b40168dde7b303f663ad9961f54e1cf375712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 7 Apr 2023 22:52:39 +0200 Subject: [PATCH 30/51] feat(state-arrow): Custom DSL to generate an Outcome --- .../src/commonMain/kotlin/OutcomeDsl.kt | 32 ++++ .../src/commonTest/kotlin/OutcomeDslTest.kt | 64 ++++++++ .../commonMain/kotlin/outcome/Predicates.kt | 62 -------- state/src/commonTest/kotlin/OutcomeTest.kt | 145 ------------------ 4 files changed, 96 insertions(+), 207 deletions(-) create mode 100644 state-arrow/src/commonMain/kotlin/OutcomeDsl.kt create mode 100644 state-arrow/src/commonTest/kotlin/OutcomeDslTest.kt delete mode 100644 state/src/commonMain/kotlin/outcome/Predicates.kt delete mode 100644 state/src/commonTest/kotlin/OutcomeTest.kt diff --git a/state-arrow/src/commonMain/kotlin/OutcomeDsl.kt b/state-arrow/src/commonMain/kotlin/OutcomeDsl.kt new file mode 100644 index 0000000..7f451e0 --- /dev/null +++ b/state-arrow/src/commonMain/kotlin/OutcomeDsl.kt @@ -0,0 +1,32 @@ +package opensavvy.state.arrow + +import arrow.core.raise.Raise +import arrow.core.raise.RaiseDSL +import arrow.core.raise.recover +import opensavvy.state.failure.Failure +import opensavvy.state.outcome.Outcome +import kotlin.experimental.ExperimentalTypeInference +import kotlin.jvm.JvmInline + +@JvmInline +@RaiseDSL +value class OutcomeDsl(private val raise: Raise) : + Raise by raise { + + @RaiseDSL + fun Outcome.bind(): T = when (this) { + is Outcome.Success -> value + is Outcome.Failure -> raise.raise(failure) + } +} + +/** + * Arrow-style DSL to execute a [Raise]-based computation to generate an [Outcome]. + */ +@OptIn(ExperimentalTypeInference::class) +@RaiseDSL +inline fun out(@BuilderInference block: OutcomeDsl.() -> T): Outcome = + recover( + block = { Outcome.Success(block(OutcomeDsl(this))) }, + recover = { e: F -> Outcome.Failure(e) }, + ) diff --git a/state-arrow/src/commonTest/kotlin/OutcomeDslTest.kt b/state-arrow/src/commonTest/kotlin/OutcomeDslTest.kt new file mode 100644 index 0000000..c63b1e8 --- /dev/null +++ b/state-arrow/src/commonTest/kotlin/OutcomeDslTest.kt @@ -0,0 +1,64 @@ +package opensavvy.state.arrow + +import arrow.core.left +import arrow.core.raise.either +import opensavvy.state.failure.Failure +import opensavvy.state.failure.NotFound +import opensavvy.state.outcome.Outcome +import opensavvy.state.outcome.failed +import opensavvy.state.outcome.success +import kotlin.test.Test +import kotlin.test.assertEquals + +class OutcomeDslTest { + + @Test + fun success() { + val success = out { 2 } + + assertEquals(2.success(), success) + } + + @Test + fun failure() { + val failure = out { + raise(NotFound("test")) + } + + assertEquals(NotFound("test").failed(), failure) + } + + @Test + fun bindSuccess() { + val success = out { + Outcome.Success(2).bind() + } + + assertEquals(2.success(), success) + } + + @Suppress("IMPLICIT_NOTHING_TYPE_ARGUMENT_IN_RETURN_POSITION") // that's the purpose of the test! + @Test + fun bindFailure() { + val failure = out { + Outcome.Failure(NotFound("test")).bind() + } + + assertEquals(NotFound("test").failed(), failure) + } + + @Suppress("IMPLICIT_NOTHING_TYPE_ARGUMENT_IN_RETURN_POSITION") // that's the purpose of the test! + @Test + fun toEither() { + val result = either { + out { + raise(NotFound("test")) + }.toEither().bind() + } + + assertEquals( + NotFound("test").left(), + result, + ) + } +} diff --git a/state/src/commonMain/kotlin/outcome/Predicates.kt b/state/src/commonMain/kotlin/outcome/Predicates.kt deleted file mode 100644 index aa384e2..0000000 --- a/state/src/commonMain/kotlin/outcome/Predicates.kt +++ /dev/null @@ -1,62 +0,0 @@ -package opensavvy.state.outcome - -import arrow.core.continuations.EffectScope -import opensavvy.state.Failure -import kotlin.contracts.ExperimentalContracts -import kotlin.contracts.InvocationKind -import kotlin.contracts.contract - -@OptIn(ExperimentalContracts::class) -suspend inline fun EffectScope.ensureOrShift( - condition: Boolean, - kind: Failure.Kind = Failure.Kind.Unknown, - lazyMessage: () -> String, -) { - contract { - returns() implies condition - callsInPlace(lazyMessage, InvocationKind.AT_MOST_ONCE) - } - - if (!condition) - shift(Failure(kind, lazyMessage())) -} - -@OptIn(ExperimentalContracts::class) -suspend inline fun EffectScope.ensureValid(condition: Boolean, lazyMessage: () -> String) { - contract { - returns() implies condition - callsInPlace(lazyMessage, InvocationKind.AT_MOST_ONCE) - } - - ensureOrShift(condition, Failure.Kind.Invalid, lazyMessage) -} - -@OptIn(ExperimentalContracts::class) -suspend inline fun EffectScope.ensureAuthenticated(condition: Boolean, lazyMessage: () -> String) { - contract { - returns() implies condition - callsInPlace(lazyMessage, InvocationKind.AT_MOST_ONCE) - } - - ensureOrShift(condition, Failure.Kind.Unauthenticated, lazyMessage) -} - -@OptIn(ExperimentalContracts::class) -suspend inline fun EffectScope.ensureAuthorized(condition: Boolean, lazyMessage: () -> String) { - contract { - returns() implies condition - callsInPlace(lazyMessage, InvocationKind.AT_MOST_ONCE) - } - - ensureOrShift(condition, Failure.Kind.Unauthorized, lazyMessage) -} - -@OptIn(ExperimentalContracts::class) -suspend inline fun EffectScope.ensureFound(condition: Boolean, lazyMessage: () -> String) { - contract { - returns() implies condition - callsInPlace(lazyMessage, InvocationKind.AT_MOST_ONCE) - } - - ensureOrShift(condition, Failure.Kind.NotFound, lazyMessage) -} diff --git a/state/src/commonTest/kotlin/OutcomeTest.kt b/state/src/commonTest/kotlin/OutcomeTest.kt deleted file mode 100644 index fadf9ec..0000000 --- a/state/src/commonTest/kotlin/OutcomeTest.kt +++ /dev/null @@ -1,145 +0,0 @@ -@file:OptIn(ExperimentalCoroutinesApi::class) - -package opensavvy.state - -import arrow.core.right -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.takeWhile -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.runTest -import opensavvy.progress.Progress -import opensavvy.progress.coroutines.StateFlowProgressReporter -import opensavvy.progress.coroutines.asCoroutineContext -import opensavvy.progress.coroutines.report -import opensavvy.progress.done -import opensavvy.progress.loading -import opensavvy.state.outcome.* -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals - -class OutcomeTest { - - private data class IntId(val id: Int) { - - suspend fun request() = out { - val id = this@IntId - - report(loading(0.0)) - delay(20) - - // Just imagine the conditions are related to what we're testing - ensureValid(id.id > 0) { "The number should be greater than 0: ${id.id}" } - report(loading(0.2)) - delay(20) - - ensureAuthenticated(id.id < 100) { "The number should be lesser than 100: ${id.id}" } - report(loading(0.4)) - delay(20) - - ensureAuthorized(id.id % 2 == 0) { "The number should be even: ${id.id}" } - report(loading(0.6)) - delay(20) - - ensureFound(id.id % 9 == 0) { "The number should be a multiple of 9: ${id.id}" } - report(loading(0.8)) - delay(20) - - id.id - } - - override fun toString() = "Id($id)" - - } - - @Test - fun successful() = runTest { - val id = IntId(18) - val reporter = StateFlowProgressReporter() - - val actualAsync = async(reporter.asCoroutineContext()) { - id.request().also { report(done()) } - } - - val expectedLoading = listOf( - loading(0.0), - loading(0.2), - loading(0.4), - loading(0.6), - loading(0.8), - ) - - val actualLoading = reporter.progress.takeWhile { it !is Progress.Done }.toList() - - val actual = actualAsync.await() - assertEquals(18.right(), actual) - assertEquals(18, actual.getOrNull()) - assertEquals(18, actual.orThrow()) - - assertEquals(expectedLoading, actualLoading) - } - - @Test - fun invalid() = runTest { - val id = IntId(-50) - - val actual = id - .request() - - val expected = failed("The number should be greater than 0: -50", Failure.Kind.Invalid) - - assertEquals(expected, actual) - } - - @Test - fun unauthenticated() = runTest { - val id = IntId(167) - - val actual = id.request() - - val expected = failed("The number should be lesser than 100: 167", Failure.Kind.Unauthenticated) - - assertEquals(expected, actual) - } - - @Test - fun unauthorized() = runTest { - val id = IntId(5) - - val actual = id.request() - - val expected = failed("The number should be even: 5", Failure.Kind.Unauthorized) - - assertEquals(expected, actual) - } - - @Test - fun notFound() = runTest { - val id = IntId(8) - - val actual = id.request() - - val expected = failed("The number should be a multiple of 9: 8", Failure.Kind.NotFound) - - assertEquals(expected, actual) - } - - @Test - fun identity() { - val success0 = successful(0) - val success1 = successful(1) - assertEquals(successful(0), success0) - assertEquals(successful(0).hashCode(), success0.hashCode()) - assertEquals(successful(1), success1) - assertNotEquals(success0, success1) - - assertEquals("Either.Right(0)", success0.toString()) - assertEquals("Either.Right(1)", success1.toString()) - assertEquals("Loading", loading().toString()) - assertEquals("Loading(50%)", loading(0.5).toString()) - assertEquals("Loading(33%)", loading(0.33).toString()) - } - -} -- 2.51.2 From 25ee33ceeb372df23b18d2e9b228f3a9afbbab70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 7 Apr 2023 23:01:48 +0200 Subject: [PATCH 31/51] feat(state-coroutines): Convenience flow accessors --- .../kotlin/ProgressiveFlowAccesors.kt | 30 +++++++++++++ .../kotlin/ProgressiveFlowAccessorsTest.kt | 43 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 state-coroutines/src/commonMain/kotlin/ProgressiveFlowAccesors.kt create mode 100644 state-coroutines/src/commonTest/kotlin/ProgressiveFlowAccessorsTest.kt diff --git a/state-coroutines/src/commonMain/kotlin/ProgressiveFlowAccesors.kt b/state-coroutines/src/commonMain/kotlin/ProgressiveFlowAccesors.kt new file mode 100644 index 0000000..7203dc1 --- /dev/null +++ b/state-coroutines/src/commonMain/kotlin/ProgressiveFlowAccesors.kt @@ -0,0 +1,30 @@ +package opensavvy.state.coroutines + +import kotlinx.coroutines.flow.* +import opensavvy.progress.Progress +import opensavvy.progress.coroutines.report +import opensavvy.progress.done +import opensavvy.state.failure.Failure +import opensavvy.state.progressive.ProgressiveOutcome +import opensavvy.state.progressive.asOutcome + +/** + * Filters out all the [ProgressiveOutcome.Incomplete] values from this flow. + * + * All [progress information][Progress] is re-emitted to the calling flow. + */ +fun Flow>.filterComplete() = this + .onEach { report(it.progress) } + .filter { it.progress == done() } + .mapNotNull { it.asOutcome() } + +/** + * Suspends until the first complete value is available (success or failure). + * + * All [progress information][Progress] is re-emitted in the calling coroutine. + * + * @throws NoSuchElementException if the flow has no complete elements. + */ +suspend fun Flow>.firstValue() = this + .filterComplete() + .first() diff --git a/state-coroutines/src/commonTest/kotlin/ProgressiveFlowAccessorsTest.kt b/state-coroutines/src/commonTest/kotlin/ProgressiveFlowAccessorsTest.kt new file mode 100644 index 0000000..2b5f919 --- /dev/null +++ b/state-coroutines/src/commonTest/kotlin/ProgressiveFlowAccessorsTest.kt @@ -0,0 +1,43 @@ +package opensavvy.state.coroutines + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import opensavvy.progress.loading +import opensavvy.state.failure.NotFound +import opensavvy.state.outcome.Outcome +import opensavvy.state.progressive.ProgressiveOutcome +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class ProgressiveFlowAccessorsTest { + + @Test + fun failure() = runTest { + val input = flowOf( + ProgressiveOutcome.Incomplete(loading(0.3)), + ProgressiveOutcome.Failure(NotFound(2), loading(0.4)), + ProgressiveOutcome.Failure(NotFound(2)), + ) + + assertEquals( + Outcome.Failure(NotFound(2)), + input.firstValue(), + ) + } + + @Test + fun success() = runTest { + val input = flowOf( + ProgressiveOutcome.Incomplete(loading(0.3)), + ProgressiveOutcome.Failure(NotFound(2), loading(0.4)), + ProgressiveOutcome.Success(2), + ) + + assertEquals( + Outcome.Success(2), + input.firstValue(), + ) + } +} -- 2.51.2 From a844cb5571851ed30a2f4c72d142b6fb34669654 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 7 Apr 2023 23:36:45 +0200 Subject: [PATCH 32/51] build(progress): Increase the minimal coverage to 90% --- progress-coroutines/build.gradle.kts | 2 +- progress/build.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/progress-coroutines/build.gradle.kts b/progress-coroutines/build.gradle.kts index 688b9a7..48af315 100644 --- a/progress-coroutines/build.gradle.kts +++ b/progress-coroutines/build.gradle.kts @@ -41,7 +41,7 @@ kover { rule { name = "Minimal code coverage" bound { - minValue = 80 + minValue = 90 } } } diff --git a/progress/build.gradle.kts b/progress/build.gradle.kts index 340dc08..052b103 100644 --- a/progress/build.gradle.kts +++ b/progress/build.gradle.kts @@ -38,7 +38,7 @@ kover { rule { name = "Minimal code coverage" bound { - minValue = 80 + minValue = 90 } } } -- 2.51.2 From f62466ff4e020f51bb2414481b1a9d441cd20e35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 7 Apr 2023 23:39:44 +0200 Subject: [PATCH 33/51] build(state): Increase the minimal coverage to 90% --- state/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/state/build.gradle.kts b/state/build.gradle.kts index a578a4b..4bb5150 100644 --- a/state/build.gradle.kts +++ b/state/build.gradle.kts @@ -42,7 +42,7 @@ kover { rule { name = "Minimal code coverage" bound { - minValue = 70 // until it is rewritten + minValue = 90 } } } -- 2.51.2 From 7da76b00435b627eacfd10691b6f408118aa8961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 7 Apr 2023 23:43:08 +0200 Subject: [PATCH 34/51] build(gradle): Execute tests in parallel if possible --- .../structure/src/main/kotlin/structure.gradle.kts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/gradle/conventions/structure/src/main/kotlin/structure.gradle.kts b/gradle/conventions/structure/src/main/kotlin/structure.gradle.kts index e4dc0cb..338eb8f 100644 --- a/gradle/conventions/structure/src/main/kotlin/structure.gradle.kts +++ b/gradle/conventions/structure/src/main/kotlin/structure.gradle.kts @@ -53,3 +53,7 @@ publishing { } } } + +tasks.withType().configureEach { + maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).takeIf { it > 0 } ?: 1 +} -- 2.51.2 From 4334177d2fb6ccd71e127bbca8d1c5717f9b5b2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 9 Apr 2023 10:28:30 +0200 Subject: [PATCH 35/51] refactor(cache): Use the new failures --- cache/build.gradle.kts | 2 + .../commonMain/kotlin/BatchingCacheAdapter.kt | 32 +++--- cache/src/commonMain/kotlin/Cache.kt | 5 +- cache/src/commonMain/kotlin/CacheAdapter.kt | 18 ++-- .../src/commonMain/kotlin/ExpirationCache.kt | 11 ++- cache/src/commonMain/kotlin/MemoryCache.kt | 15 +-- cache/src/commonTest/kotlin/CacheTest.kt | 99 +++++++++++-------- .../src/commonTest/kotlin/PassThroughTest.kt | 10 +- 8 files changed, 108 insertions(+), 84 deletions(-) diff --git a/cache/build.gradle.kts b/cache/build.gradle.kts index 7e6a97e..866ed1c 100644 --- a/cache/build.gradle.kts +++ b/cache/build.gradle.kts @@ -19,6 +19,7 @@ kotlin { val commonMain by getting { dependencies { api(projects.state) + api(projects.stateCoroutines) api(KotlinX.datetime) implementation(projects.logger) @@ -29,6 +30,7 @@ kotlin { dependencies { implementation(projects.tester) implementation(KotlinX.coroutines.test) + implementation(projects.stateArrow) } } } diff --git a/cache/src/commonMain/kotlin/BatchingCacheAdapter.kt b/cache/src/commonMain/kotlin/BatchingCacheAdapter.kt index 5980952..7d9ce02 100644 --- a/cache/src/commonMain/kotlin/BatchingCacheAdapter.kt +++ b/cache/src/commonMain/kotlin/BatchingCacheAdapter.kt @@ -10,11 +10,12 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import opensavvy.logger.Logger.Companion.error import opensavvy.logger.loggerFor +import opensavvy.state.failure.Failure import opensavvy.state.progressive.ProgressiveOutcome import kotlin.coroutines.CoroutineContext import kotlin.coroutines.coroutineContext -private typealias CacheStorage = CompletableDeferred>> +private typealias CacheStorage = CompletableDeferred>> /** * Cache implementation aimed to be the first link in a cache chain. @@ -24,7 +25,7 @@ private typealias CacheStorage = CompletableDeferred( +class BatchingCacheAdapter( context: CoroutineContext, /** * The number of workers batching the requests. @@ -35,17 +36,17 @@ class BatchingCacheAdapter( * Increasing the number of workers may increase latency. */ workers: Int = 1, - val queryBatch: (Set) -> Flow>>, -) : Cache { + val queryBatch: (Set) -> Flow>>, +) : Cache { private val log = loggerFor(this) - private val requests: SendChannel>> + private val requests: SendChannel>> init { require(workers > 0) { "There must be at least 1 worker: found $workers" } - val requests = Channel>>() + val requests = Channel>>() this.requests = requests val scope = CoroutineScope(context) @@ -56,14 +57,14 @@ class BatchingCacheAdapter( } } - private suspend fun worker(requests: ReceiveChannel>>) { + private suspend fun worker(requests: ReceiveChannel>>) { while (coroutineContext.isActive) { val batch = HashSet() // Store the results // We have to store lists of Deferred in case multiple requests to the same Ref happen to be in the same // batch. - val results = HashMap>>() + val results = HashMap>>() run { // Suspend until a first request arrives @@ -84,11 +85,12 @@ class BatchingCacheAdapter( .add(promise) } - val states = HashMap>>() + val states = HashMap>>() // Tell all clients that their request is starting for ((id, promises) in results) { - val state: MutableStateFlow> = MutableStateFlow(ProgressiveOutcome.Empty()) + val state: MutableStateFlow> = + MutableStateFlow(ProgressiveOutcome.Incomplete()) for (promise in promises) { promise.complete(state) @@ -113,8 +115,8 @@ class BatchingCacheAdapter( } } - override fun get(id: I): Flow> = flow { - val promise = CompletableDeferred>>() + override fun get(id: I): Flow> = flow { + val promise = CompletableDeferred>>() requests.send(id to promise) @@ -134,11 +136,11 @@ class BatchingCacheAdapter( } companion object { - fun batchingCache( + fun batchingCache( context: CoroutineContext, workers: Int = 1, - transform: suspend FlowCollector>>.(Set) -> Unit, - ) = BatchingCacheAdapter(context, workers) { ids -> + transform: suspend FlowCollector>>.(Set) -> Unit, + ) = BatchingCacheAdapter(context, workers) { ids -> flow { transform(ids) } diff --git a/cache/src/commonMain/kotlin/Cache.kt b/cache/src/commonMain/kotlin/Cache.kt index af374b4..987b794 100644 --- a/cache/src/commonMain/kotlin/Cache.kt +++ b/cache/src/commonMain/kotlin/Cache.kt @@ -1,6 +1,7 @@ package opensavvy.cache import kotlinx.coroutines.flow.Flow +import opensavvy.state.failure.Failure import opensavvy.state.progressive.ProgressiveOutcome /** @@ -34,7 +35,7 @@ import opensavvy.state.progressive.ProgressiveOutcome * The first element of the chain, and therefore the one responsible for actually starting the request, is [CacheAdapter] or [BatchingCacheAdapter]. * Note that both have a few implementation differences, it is not recommended to use them directly without chaining under another implementation. */ -interface Cache { +interface Cache { /** * Gets the value associated with an [id] in this cache. @@ -43,7 +44,7 @@ interface Cache { * such as inside the body of a UI component. * You can then subscribe to the [Flow] to access the actual values. */ - operator fun get(id: I): Flow> + operator fun get(id: I): Flow> /** * Forces the cache to accept [value] as a more recent value for the given [id] than whatever it was previously storing. diff --git a/cache/src/commonMain/kotlin/CacheAdapter.kt b/cache/src/commonMain/kotlin/CacheAdapter.kt index 97b7613..3740204 100644 --- a/cache/src/commonMain/kotlin/CacheAdapter.kt +++ b/cache/src/commonMain/kotlin/CacheAdapter.kt @@ -1,12 +1,10 @@ package opensavvy.cache -import arrow.core.continuations.EffectScope -import arrow.core.continuations.either import kotlinx.coroutines.flow.Flow -import opensavvy.state.Failure +import opensavvy.state.coroutines.captureProgress +import opensavvy.state.failure.Failure import opensavvy.state.outcome.Outcome import opensavvy.state.progressive.ProgressiveOutcome -import opensavvy.state.progressive.captureProgress /** * Cache implementation aimed to be the first link in a cache chain. @@ -14,11 +12,11 @@ import opensavvy.state.progressive.captureProgress * This is not a valid implementation of a cache (it doesn't do any caching), and only serves as a link between caches * and the underlying network APIs. */ -class CacheAdapter( - val query: suspend (I) -> Outcome, -) : Cache { +class CacheAdapter( + val query: suspend (I) -> Outcome, +) : Cache { - override fun get(id: I): Flow> = captureProgress { query(id) } + override fun get(id: I): Flow> = captureProgress { query(id) } override suspend fun update(values: Collection>) { // This cache layer has no state, nothing to do @@ -33,7 +31,7 @@ class CacheAdapter( } companion object { - fun cache(transform: suspend EffectScope.(I) -> T) = - CacheAdapter { either { transform(it) } } + fun cache(transform: suspend (I) -> Outcome) = + CacheAdapter { transform(it) } } } diff --git a/cache/src/commonMain/kotlin/ExpirationCache.kt b/cache/src/commonMain/kotlin/ExpirationCache.kt index e63f89b..59e5a5b 100644 --- a/cache/src/commonMain/kotlin/ExpirationCache.kt +++ b/cache/src/commonMain/kotlin/ExpirationCache.kt @@ -11,6 +11,7 @@ import opensavvy.cache.ExpirationCache.Companion.expireAfter import opensavvy.logger.Logger.Companion.trace import opensavvy.logger.loggerFor import opensavvy.progress.done +import opensavvy.state.failure.Failure import opensavvy.state.progressive.ProgressiveOutcome import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext @@ -26,11 +27,11 @@ import kotlin.time.Duration.Companion.minutes * .expireAfter(5.minutes, Job()) * ``` */ -class ExpirationCache( +class ExpirationCache( /** * The previous cache layer, from which values will be expired. */ - private val upstream: Cache, + private val upstream: Cache, /** * After how much time should the values from the previous cache layer be expired. * @@ -44,7 +45,7 @@ class ExpirationCache( * Cancelling this job will cancel the expiration job, after which this cache will stop expiring data. */ context: CoroutineContext = EmptyCoroutineContext, -) : Cache { +) : Cache { private val log = loggerFor(this) private val lastUpdate = HashMap() @@ -82,7 +83,7 @@ class ExpirationCache( } } - override fun get(id: I): Flow> = upstream[id] + override fun get(id: I): Flow> = upstream[id] .onEach { if (it.progress == done()) markAsUpdatedNow(id) @@ -116,7 +117,7 @@ class ExpirationCache( * * @see ExpirationCache */ - fun Cache.expireAfter(duration: Duration, context: CoroutineContext) = + fun Cache.expireAfter(duration: Duration, context: CoroutineContext) = ExpirationCache(this, duration, context) } } diff --git a/cache/src/commonMain/kotlin/MemoryCache.kt b/cache/src/commonMain/kotlin/MemoryCache.kt index 13a9b01..61498f9 100644 --- a/cache/src/commonMain/kotlin/MemoryCache.kt +++ b/cache/src/commonMain/kotlin/MemoryCache.kt @@ -8,6 +8,7 @@ import opensavvy.cache.MemoryCache.Companion.cachedInMemory import opensavvy.cache.PassThroughContext.Companion.onlyPassThrough import opensavvy.logger.Logger.Companion.trace import opensavvy.logger.loggerFor +import opensavvy.state.failure.Failure import opensavvy.state.progressive.ProgressiveOutcome import opensavvy.state.progressive.copy import kotlin.coroutines.CoroutineContext @@ -30,10 +31,10 @@ import kotlin.coroutines.EmptyCoroutineContext * .expireAfter(2.minutes) * ``` */ -class MemoryCache( - private val upstream: Cache, +class MemoryCache( + private val upstream: Cache, context: CoroutineContext = EmptyCoroutineContext, -) : Cache { +) : Cache { private val log = loggerFor(this) @@ -48,7 +49,7 @@ class MemoryCache( * - 'expire' removed the cached value */ - private val cache = HashMap?>>() + private val cache = HashMap?>>() private val cacheLock = Semaphore(1) private val jobs = HashMap() @@ -60,7 +61,7 @@ class MemoryCache( /** **UNSAFE**: only call when owning the [cacheLock] */ private fun getUnsafe(id: I) = cache.getOrPut(id) { MutableStateFlow(null) } - override fun get(id: I): Flow> = flow { + override fun get(id: I): Flow> = flow { val cached = cacheLock.withPermit { getUnsafe(id) } .onEach { out -> if (out == null) { @@ -97,7 +98,7 @@ class MemoryCache( // If the previous cache layer says it's a new value, but we remember what the previous // result was, we return the previous value with the new progress information - if (it is ProgressiveOutcome.Empty && previousValue != null) + if (it is ProgressiveOutcome.Incomplete && previousValue != null) previousValue.copy(progress = it.progress) else it @@ -186,6 +187,6 @@ class MemoryCache( } companion object { - fun Cache.cachedInMemory(context: CoroutineContext) = MemoryCache(this, context) + fun Cache.cachedInMemory(context: CoroutineContext) = MemoryCache(this, context) } } diff --git a/cache/src/commonTest/kotlin/CacheTest.kt b/cache/src/commonTest/kotlin/CacheTest.kt index 26313bd..19c2fa5 100644 --- a/cache/src/commonTest/kotlin/CacheTest.kt +++ b/cache/src/commonTest/kotlin/CacheTest.kt @@ -2,6 +2,7 @@ package opensavvy.cache +import arrow.core.raise.ensure import kotlinx.coroutines.* import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.onEach @@ -17,10 +18,12 @@ import opensavvy.logger.loggerFor import opensavvy.progress.Progress import opensavvy.progress.coroutines.report import opensavvy.progress.loading -import opensavvy.state.outcome.ensureValid -import opensavvy.state.outcome.orThrow +import opensavvy.state.arrow.out +import opensavvy.state.coroutines.firstValue +import opensavvy.state.failure.CustomFailure +import opensavvy.state.failure.Failure +import opensavvy.state.outcome.valueOrNull import opensavvy.state.progressive.ProgressiveOutcome -import opensavvy.state.progressive.firstValue import kotlin.test.Test import kotlin.test.assertEquals import kotlin.time.Duration.Companion.seconds @@ -33,54 +36,66 @@ class CacheTest { private data class IntId(val id: Int) { override fun toString() = "Id($id)" + + sealed interface Failures : Failure { + data class Negative(val id: Int) : + CustomFailure(Companion, "Only positive integers are allowed, found $id"), + Failures { + companion object : Failure.Key { + override fun toString() = "Negative" + } + } + } } - private fun adapter() = cache { - log.debug(it) { "Requesting" } - delay(100) - report(loading(0.2)) - delay(10) - ensureValid(it.id >= 0) { "Only positive integers are allowed: found ${it.id}" } - it.id + private fun adapter() = cache { + out { + log.debug(it) { "Requesting" } + delay(100) + report(loading(0.2)) + delay(10) + ensure(it.id >= 0) { IntId.Failures.Negative(it.id) } + it.id + } } - private suspend fun testCache(cache: Cache) { + private suspend fun testCache(cache: Cache) { log.info { "Regular access" } val zero = cache[IntId(0)] val one = cache[IntId(1)] val minus = cache[IntId(-1)] - assertEquals(0, zero.firstValue().getOrNull()) - assertEquals(1, one.firstValue().getOrNull()) - assertEquals(null, minus.firstValue().getOrNull()) + assertEquals(0, zero.firstValue().valueOrNull) + assertEquals(1, one.firstValue().valueOrNull) + assertEquals(null, minus.firstValue().valueOrNull) } - private suspend fun testUpdateExpire(cache: Cache) { + private suspend fun testUpdateExpire(cache: Cache) { log.info { "Checking normal behavior" } - assertEquals(0, cache[IntId(0)].firstValue().getOrNull()) + assertEquals(0, cache[IntId(0)].firstValue().valueOrNull) log.info { "Overwriting with a different value" } cache.update(IntId(0), 5) - assertEquals(5, cache[IntId(0)].firstValue().getOrNull()) + assertEquals(5, cache[IntId(0)].firstValue().valueOrNull) log.info { "Expiring the value re-downloads and replaces our fake value" } cache.expire(IntId(0)) - assertEquals(0, cache[IntId(0)].firstValue().getOrNull()) + assertEquals(0, cache[IntId(0)].firstValue().valueOrNull) } - private suspend fun testAutoExpiration(cache: Cache) { + private suspend fun testAutoExpiration(cache: Cache) { log.info { "Adding 5 to the cache to make updates visible" } - assertEquals(0, cache[IntId(0)].firstValue().orThrow()) + assertEquals(0, cache[IntId(0)].firstValue().valueOrNull) cache.update(IntId(0), 5) - assertEquals(5, cache[IntId(0)].firstValue().orThrow()) + assertEquals(5, cache[IntId(0)].firstValue().valueOrNull) log.info { "Waiting for the cache to correct itself" } assertEquals(0, - cache[IntId(0)] - .onEach { log.debug(it) { "Found new value" } } - .drop(1) // Skip the bad value we inserted - .firstValue() - .orThrow() + cache[IntId(0)] + .onEach { log.debug(it) { "Found new value" } } + .drop(1) // Skip the bad value we inserted + .firstValue() + .valueOrNull ) } @@ -163,30 +178,30 @@ class CacheTest { log.info { "Initial values" } val id0 = IntId(0) val id1 = IntId(1) - assertEquals(0, cache[id0].firstValue().orThrow()) - assertEquals(1, cache[id1].firstValue().orThrow()) + assertEquals(0, cache[id0].firstValue().valueOrNull) + assertEquals(1, cache[id1].firstValue().valueOrNull) log.info { "Adding 5" } cache.update( id0 to 5, id1 to 6, ) - assertEquals(5, cache[id0].firstValue().orThrow()) - assertEquals(6, cache[id1].firstValue().orThrow()) + assertEquals(5, cache[id0].firstValue().valueOrNull) + assertEquals(6, cache[id1].firstValue().valueOrNull) log.info { "Expiring all values" } cache.expireAll() - assertEquals(0, cache[id0].firstValue().orThrow()) - assertEquals(1, cache[id1].firstValue().orThrow()) + assertEquals(0, cache[id0].firstValue().valueOrNull) + assertEquals(1, cache[id1].firstValue().valueOrNull) currentCoroutineContext().cancelChildren() } @Test fun batching() = runTest { - val cache = batchingCache(coroutineContext) { ids -> + val cache = batchingCache(coroutineContext) { ids -> for (ref in ids) { - emit(ref to ProgressiveOutcome.Empty(loading())) + emit(ref to ProgressiveOutcome.Incomplete(loading())) delay(10) emit(ref to ProgressiveOutcome.Success(ref.id)) } @@ -197,21 +212,21 @@ class CacheTest { log.info { "Initial values" } val id0 = IntId(0) val id1 = IntId(1) - assertEquals(0, cache[id0].firstValue().orThrow()) - assertEquals(1, cache[id1].firstValue().orThrow()) + assertEquals(0, cache[id0].firstValue().valueOrNull) + assertEquals(1, cache[id1].firstValue().valueOrNull) log.info { "Adding 5" } cache.update( id0 to 5, id1 to 6, ) - assertEquals(5, cache[id0].firstValue().orThrow()) - assertEquals(6, cache[id1].firstValue().orThrow()) + assertEquals(5, cache[id0].firstValue().valueOrNull) + assertEquals(6, cache[id1].firstValue().valueOrNull) log.info { "Expiring all values" } cache.expireAll() - assertEquals(0, cache[id0].firstValue().orThrow()) - assertEquals(1, cache[id1].firstValue().orThrow()) + assertEquals(0, cache[id0].firstValue().valueOrNull) + assertEquals(1, cache[id1].firstValue().valueOrNull) currentCoroutineContext().cancelChildren() } @@ -221,7 +236,7 @@ class CacheTest { val cache = adapter() .cachedInMemory(coroutineContext) - var result: ProgressiveOutcome = ProgressiveOutcome.Empty() + var result: ProgressiveOutcome = ProgressiveOutcome.Incomplete() log.info { "Subscribing…" } launch { @@ -230,7 +245,7 @@ class CacheTest { } // Wait for the first cache read to finish delay(1000) - while (result is ProgressiveOutcome.Empty) { + while (result is ProgressiveOutcome.Incomplete) { yield() } assertEquals(ProgressiveOutcome.Success(1), result) diff --git a/cache/src/commonTest/kotlin/PassThroughTest.kt b/cache/src/commonTest/kotlin/PassThroughTest.kt index 5a1b863..f5397d9 100644 --- a/cache/src/commonTest/kotlin/PassThroughTest.kt +++ b/cache/src/commonTest/kotlin/PassThroughTest.kt @@ -8,6 +8,8 @@ import kotlinx.coroutines.withContext import opensavvy.cache.CacheAdapter.Companion.cache import opensavvy.cache.ExpirationCache.Companion.expireAfter import opensavvy.cache.MemoryCache.Companion.cachedInMemory +import opensavvy.state.arrow.out +import opensavvy.state.failure.Failure import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext import kotlin.test.Test @@ -30,9 +32,11 @@ class PassThroughTest { val shouldNot = ShouldNotPassThrough() val should = ShouldPassThrough() - val cache = cache { - assertEquals(null, currentCoroutineContext()[ShouldNotPassThrough]) - assertEquals(should, currentCoroutineContext()[ShouldPassThrough]) + val cache = cache { + out { + assertEquals(null, currentCoroutineContext()[ShouldNotPassThrough]) + assertEquals(should, currentCoroutineContext()[ShouldPassThrough]) + } } .cachedInMemory(backgroundScope.coroutineContext) .expireAfter(2.minutes, backgroundScope.coroutineContext) -- 2.51.2 From de25a7d63b7c2eea06ef446d92e188be295fd396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 9 Apr 2023 15:40:51 +0200 Subject: [PATCH 36/51] refactor(cache): Coroutine integration rework Instead of always accepting CoroutineContext, the cacher layers are now more precise: - They accept CoroutineScope if they start maintenance tasks at cache start - They accept Job is they start a task when a request is made Also changed the coroutine name for these tasks, to ease debugging. --- .../commonMain/kotlin/BatchingCacheAdapter.kt | 15 ++---- .../src/commonMain/kotlin/ExpirationCache.kt | 13 ++--- cache/src/commonMain/kotlin/MemoryCache.kt | 17 +++---- .../commonMain/kotlin/PassThroughContext.kt | 22 --------- cache/src/commonTest/kotlin/CacheTest.kt | 48 +++++++----------- .../kotlin/PassThroughContextTest.kt | 40 +++++++++++++++ .../src/commonTest/kotlin/PassThroughTest.kt | 49 ------------------- 7 files changed, 72 insertions(+), 132 deletions(-) delete mode 100644 cache/src/commonMain/kotlin/PassThroughContext.kt create mode 100644 cache/src/commonTest/kotlin/PassThroughContextTest.kt delete mode 100644 cache/src/commonTest/kotlin/PassThroughTest.kt diff --git a/cache/src/commonMain/kotlin/BatchingCacheAdapter.kt b/cache/src/commonMain/kotlin/BatchingCacheAdapter.kt index 7d9ce02..64cc54b 100644 --- a/cache/src/commonMain/kotlin/BatchingCacheAdapter.kt +++ b/cache/src/commonMain/kotlin/BatchingCacheAdapter.kt @@ -1,18 +1,14 @@ package opensavvy.cache -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.flow.* -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch import opensavvy.logger.Logger.Companion.error import opensavvy.logger.loggerFor import opensavvy.state.failure.Failure import opensavvy.state.progressive.ProgressiveOutcome -import kotlin.coroutines.CoroutineContext import kotlin.coroutines.coroutineContext private typealias CacheStorage = CompletableDeferred>> @@ -26,7 +22,7 @@ private typealias CacheStorage = CompletableDeferred( - context: CoroutineContext, + scope: CoroutineScope, /** * The number of workers batching the requests. * @@ -49,9 +45,8 @@ class BatchingCacheAdapter( val requests = Channel>>() this.requests = requests - val scope = CoroutineScope(context) repeat(workers) { - scope.launch { + scope.launch(CoroutineName("$this($it/$workers)")) { worker(requests) } } @@ -137,10 +132,10 @@ class BatchingCacheAdapter( companion object { fun batchingCache( - context: CoroutineContext, + scope: CoroutineScope, workers: Int = 1, transform: suspend FlowCollector>>.(Set) -> Unit, - ) = BatchingCacheAdapter(context, workers) { ids -> + ) = BatchingCacheAdapter(scope, workers) { ids -> flow { transform(ids) } diff --git a/cache/src/commonMain/kotlin/ExpirationCache.kt b/cache/src/commonMain/kotlin/ExpirationCache.kt index 59e5a5b..6bcba44 100644 --- a/cache/src/commonMain/kotlin/ExpirationCache.kt +++ b/cache/src/commonMain/kotlin/ExpirationCache.kt @@ -13,8 +13,6 @@ import opensavvy.logger.loggerFor import opensavvy.progress.done import opensavvy.state.failure.Failure import opensavvy.state.progressive.ProgressiveOutcome -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes @@ -44,18 +42,15 @@ class ExpirationCache( * * Cancelling this job will cancel the expiration job, after which this cache will stop expiring data. */ - context: CoroutineContext = EmptyCoroutineContext, + expirationScope: CoroutineScope, ) : Cache { private val log = loggerFor(this) private val lastUpdate = HashMap() private val lock = Semaphore(1) - private val job = Job(context[Job]) - private val scope = CoroutineScope(job + CoroutineName("ExpirationCache task")) - init { - scope.launch { + expirationScope.launch(CoroutineName("$this")) { while (isActive) { delay(expireAfter) @@ -117,7 +112,7 @@ class ExpirationCache( * * @see ExpirationCache */ - fun Cache.expireAfter(duration: Duration, context: CoroutineContext) = - ExpirationCache(this, duration, context) + fun Cache.expireAfter(duration: Duration, scope: CoroutineScope) = + ExpirationCache(this, duration, scope) } } diff --git a/cache/src/commonMain/kotlin/MemoryCache.kt b/cache/src/commonMain/kotlin/MemoryCache.kt index 61498f9..85d849f 100644 --- a/cache/src/commonMain/kotlin/MemoryCache.kt +++ b/cache/src/commonMain/kotlin/MemoryCache.kt @@ -5,14 +5,11 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import opensavvy.cache.MemoryCache.Companion.cachedInMemory -import opensavvy.cache.PassThroughContext.Companion.onlyPassThrough import opensavvy.logger.Logger.Companion.trace import opensavvy.logger.loggerFor import opensavvy.state.failure.Failure import opensavvy.state.progressive.ProgressiveOutcome import opensavvy.state.progressive.copy -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext /** * In-memory [Cache] implementation. @@ -33,7 +30,7 @@ import kotlin.coroutines.EmptyCoroutineContext */ class MemoryCache( private val upstream: Cache, - context: CoroutineContext = EmptyCoroutineContext, + private val job: Job = SupervisorJob(), ) : Cache { private val log = loggerFor(this) @@ -55,9 +52,6 @@ class MemoryCache( private val jobs = HashMap() private val jobsLock = Semaphore(1) - private val subscribeJob = SupervisorJob(context[Job]) - private val scope = CoroutineScope(subscribeJob) - /** **UNSAFE**: only call when owning the [cacheLock] */ private fun getUnsafe(id: I) = cache.getOrPut(id) { MutableStateFlow(null) } @@ -83,10 +77,11 @@ class MemoryCache( if (job == null || !job.isActive) { // No one is currently making the request, I'm taking the responsibility to do it - val childContext = currentCoroutineContext().onlyPassThrough() + - CoroutineName("$this for $id") + val childContext = currentCoroutineContext() + + CoroutineName("$this(for = $id)") + + this.job - jobs[id] = scope.launch(childContext) { + jobs[id] = CoroutineScope(childContext).launch { log.trace(id) { "Subscribing to the previous layer for" } val state = cacheLock.withPermit { getUnsafe(id) } @@ -187,6 +182,6 @@ class MemoryCache( } companion object { - fun Cache.cachedInMemory(context: CoroutineContext) = MemoryCache(this, context) + fun Cache.cachedInMemory(job: Job) = MemoryCache(this, job) } } diff --git a/cache/src/commonMain/kotlin/PassThroughContext.kt b/cache/src/commonMain/kotlin/PassThroughContext.kt deleted file mode 100644 index 674f940..0000000 --- a/cache/src/commonMain/kotlin/PassThroughContext.kt +++ /dev/null @@ -1,22 +0,0 @@ -package opensavvy.cache - -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext - -/** - * Marker interface for coroutine context elements that should pass through the cache. - * - * On a cache miss, a cache may start a new request. - * In that case, all coroutine context elements implementing this interface will be retained for the real request. - */ -interface PassThroughContext : CoroutineContext { - - companion object { - - internal fun CoroutineContext.onlyPassThrough() = fold(EmptyCoroutineContext as CoroutineContext) { acc, it -> - if (it is PassThroughContext) acc + it - else acc - } - - } -} diff --git a/cache/src/commonTest/kotlin/CacheTest.kt b/cache/src/commonTest/kotlin/CacheTest.kt index 19c2fa5..20636a3 100644 --- a/cache/src/commonTest/kotlin/CacheTest.kt +++ b/cache/src/commonTest/kotlin/CacheTest.kt @@ -109,17 +109,15 @@ class CacheTest { @Test fun infiniteMemoryCache() = runTest { val cache = adapter() - .cachedInMemory(coroutineContext) + .cachedInMemory(coroutineContext.job) testCache(cache) - - currentCoroutineContext().cancelChildren() } @Test fun infiniteMemoryCacheUpdateExpire() = runTest { val cache = adapter() - .cachedInMemory(coroutineContext) + .cachedInMemory(coroutineContext.job) testUpdateExpire(cache) @@ -129,51 +127,43 @@ class CacheTest { @Test fun expiringDefaultCache() = runTest { val cache = adapter() - .expireAfter(1.seconds, coroutineContext) + .expireAfter(1.seconds, backgroundScope) testCache(cache) - - currentCoroutineContext().cancelChildren() } @Test fun expiringMemoryCache() = runTest { val cache = adapter() - .cachedInMemory(coroutineContext) - .expireAfter(1.seconds, coroutineContext) + .cachedInMemory(coroutineContext.job) + .expireAfter(1.seconds, backgroundScope) testCache(cache) - - currentCoroutineContext().cancelChildren() } @Test fun expiringMemoryCacheUpdateExpire() = runTest { val cache = adapter() - .cachedInMemory(coroutineContext) - .expireAfter(1.seconds, coroutineContext) + .cachedInMemory(coroutineContext.job) + .expireAfter(1.seconds, backgroundScope) testUpdateExpire(cache) - - currentCoroutineContext().cancelChildren() } @Test fun expiringMemoryCacheExpirationLayer() = runTest { val cache = adapter() - .cachedInMemory(coroutineContext) - .expireAfter(1.seconds, coroutineContext) + .cachedInMemory(coroutineContext.job) + .expireAfter(1.seconds, backgroundScope) testAutoExpiration(cache) - - currentCoroutineContext().cancelChildren() } @Test fun expireAll() = runTest { val cache = adapter() - .cachedInMemory(coroutineContext) - .expireAfter(1.seconds, coroutineContext) + .cachedInMemory(coroutineContext.job) + .expireAfter(1.seconds, backgroundScope) log.info { "Initial values" } val id0 = IntId(0) @@ -193,21 +183,19 @@ class CacheTest { cache.expireAll() assertEquals(0, cache[id0].firstValue().valueOrNull) assertEquals(1, cache[id1].firstValue().valueOrNull) - - currentCoroutineContext().cancelChildren() } @Test fun batching() = runTest { - val cache = batchingCache(coroutineContext) { ids -> + val cache = batchingCache(backgroundScope) { ids -> for (ref in ids) { emit(ref to ProgressiveOutcome.Incomplete(loading())) delay(10) emit(ref to ProgressiveOutcome.Success(ref.id)) } } - .cachedInMemory(coroutineContext) - .expireAfter(1.seconds, coroutineContext) + .cachedInMemory(coroutineContext.job) + .expireAfter(1.seconds, backgroundScope) log.info { "Initial values" } val id0 = IntId(0) @@ -227,19 +215,17 @@ class CacheTest { cache.expireAll() assertEquals(0, cache[id0].firstValue().valueOrNull) assertEquals(1, cache[id1].firstValue().valueOrNull) - - currentCoroutineContext().cancelChildren() } @Test fun concurrent() = runTest { val cache = adapter() - .cachedInMemory(coroutineContext) + .cachedInMemory(coroutineContext.job) var result: ProgressiveOutcome = ProgressiveOutcome.Incomplete() log.info { "Subscribing…" } - launch { + val subscriber = launch { cache[IntId(1)] .collect { result = it } } @@ -266,7 +252,7 @@ class CacheTest { } assertEquals(ProgressiveOutcome.Success(1), result) - currentCoroutineContext().cancelChildren() + subscriber.cancel() } } diff --git a/cache/src/commonTest/kotlin/PassThroughContextTest.kt b/cache/src/commonTest/kotlin/PassThroughContextTest.kt new file mode 100644 index 0000000..57c3646 --- /dev/null +++ b/cache/src/commonTest/kotlin/PassThroughContextTest.kt @@ -0,0 +1,40 @@ +package opensavvy.cache + +import kotlinx.coroutines.* +import kotlinx.coroutines.test.runTest +import opensavvy.cache.MemoryCache.Companion.cachedInMemory +import opensavvy.state.failure.Failure +import opensavvy.state.outcome.Outcome +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +class PassThroughContextTest { + + class CustomContext : AbstractCoroutineContextElement(Companion) { + companion object : CoroutineContext.Key + } + + @Test + fun memoryCache() = runTest { + val cache = CacheAdapter.cache { + println(currentCoroutineContext()) + if (it) { + assertNotNull(currentCoroutineContext()[CustomContext]) + } else { + assertNull(currentCoroutineContext()[CustomContext]) + } + Outcome.Success(Unit) + }.cachedInMemory(backgroundScope.coroutineContext.job) + + withContext(CustomContext() + CoroutineName("The context should be present")) { + cache[true] + } + withContext(CoroutineName("The context should not be present")) { + cache[false] + } + } +} diff --git a/cache/src/commonTest/kotlin/PassThroughTest.kt b/cache/src/commonTest/kotlin/PassThroughTest.kt deleted file mode 100644 index f5397d9..0000000 --- a/cache/src/commonTest/kotlin/PassThroughTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -package opensavvy.cache - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.withContext -import opensavvy.cache.CacheAdapter.Companion.cache -import opensavvy.cache.ExpirationCache.Companion.expireAfter -import opensavvy.cache.MemoryCache.Companion.cachedInMemory -import opensavvy.state.arrow.out -import opensavvy.state.failure.Failure -import kotlin.coroutines.AbstractCoroutineContextElement -import kotlin.coroutines.CoroutineContext -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.time.Duration.Companion.minutes - -class ShouldNotPassThrough : AbstractCoroutineContextElement(ShouldNotPassThrough) { - companion object : CoroutineContext.Key -} - -class ShouldPassThrough : AbstractCoroutineContextElement(ShouldPassThrough), PassThroughContext { - companion object : CoroutineContext.Key -} - -@OptIn(ExperimentalCoroutinesApi::class) -class PassThroughTest { - - @Test - fun passThroughTest() = runTest { - val shouldNot = ShouldNotPassThrough() - val should = ShouldPassThrough() - - val cache = cache { - out { - assertEquals(null, currentCoroutineContext()[ShouldNotPassThrough]) - assertEquals(should, currentCoroutineContext()[ShouldPassThrough]) - } - } - .cachedInMemory(backgroundScope.coroutineContext) - .expireAfter(2.minutes, backgroundScope.coroutineContext) - - withContext(should + shouldNot) { - cache[Unit].first() - } - } - -} -- 2.51.2 From 99c7053acdaa5ec6858a845c885af457bcc2122b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 9 Apr 2023 15:49:46 +0200 Subject: [PATCH 37/51] tests(cache): Log the cache response --- cache/src/commonTest/kotlin/CacheTest.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cache/src/commonTest/kotlin/CacheTest.kt b/cache/src/commonTest/kotlin/CacheTest.kt index 20636a3..515074d 100644 --- a/cache/src/commonTest/kotlin/CacheTest.kt +++ b/cache/src/commonTest/kotlin/CacheTest.kt @@ -1,5 +1,3 @@ -@file:OptIn(ExperimentalCoroutinesApi::class) - package opensavvy.cache import arrow.core.raise.ensure @@ -28,6 +26,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.time.Duration.Companion.seconds +@OptIn(ExperimentalCoroutinesApi::class) class CacheTest { private val log = loggerFor(this).apply { @@ -56,7 +55,7 @@ class CacheTest { delay(10) ensure(it.id >= 0) { IntId.Failures.Negative(it.id) } it.id - } + }.also { log.debug(it) { "Response:" } } } private suspend fun testCache(cache: Cache) { -- 2.51.2 From 6ea4f487fe3adfb85140d888cc86fe2963246903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 9 Apr 2023 15:50:24 +0200 Subject: [PATCH 38/51] tests(cache): Fix the tests being incorrectly cached --- cache/src/commonTest/kotlin/CacheTest.kt | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/cache/src/commonTest/kotlin/CacheTest.kt b/cache/src/commonTest/kotlin/CacheTest.kt index 515074d..48ae52b 100644 --- a/cache/src/commonTest/kotlin/CacheTest.kt +++ b/cache/src/commonTest/kotlin/CacheTest.kt @@ -230,28 +230,29 @@ class CacheTest { } // Wait for the first cache read to finish delay(1000) - while (result is ProgressiveOutcome.Incomplete) { - yield() - } + yieldUntil { result !is ProgressiveOutcome.Incomplete } assertEquals(ProgressiveOutcome.Success(1), result) log.info { "Forcing an update with an incorrect value" } cache.update(IntId(1), 5) // Wait for the cache to update - while (result == ProgressiveOutcome.Success(1) || result.progress !is Progress.Done) { - yield() - } + yieldUntil { result != ProgressiveOutcome.Success(1) && result.progress is Progress.Done } assertEquals(ProgressiveOutcome.Success(5), result) log.info { "Expiring the value to see the cache fix itself" } cache.expire(IntId(1)) // Wait for the cache to update - while (result == ProgressiveOutcome.Success(5) || result.progress !is Progress.Done) { - yield() - } + yieldUntil { result != ProgressiveOutcome.Success(5) && result.progress is Progress.Done } assertEquals(ProgressiveOutcome.Success(1), result) subscriber.cancel() } } + +private suspend fun yieldUntil(predicate: () -> Boolean) { + while (!predicate()) { + delay(100) + yield() + } +} -- 2.51.2 From 24ef8e720be4cc8d6637e3a810a2dbfa77699999 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 9 Apr 2023 15:54:13 +0200 Subject: [PATCH 39/51] refactor(cache): Move the cache() and batchingCache() builders to the top-level --- .../commonMain/kotlin/BatchingCacheAdapter.kt | 25 +++++++++++-------- cache/src/commonMain/kotlin/CacheAdapter.kt | 13 +++++++--- cache/src/commonTest/kotlin/CacheTest.kt | 2 -- .../kotlin/PassThroughContextTest.kt | 2 +- 4 files changed, 25 insertions(+), 17 deletions(-) diff --git a/cache/src/commonMain/kotlin/BatchingCacheAdapter.kt b/cache/src/commonMain/kotlin/BatchingCacheAdapter.kt index 64cc54b..c6ad156 100644 --- a/cache/src/commonMain/kotlin/BatchingCacheAdapter.kt +++ b/cache/src/commonMain/kotlin/BatchingCacheAdapter.kt @@ -130,15 +130,20 @@ class BatchingCacheAdapter( // This cache layer has no state, nothing to do } - companion object { - fun batchingCache( - scope: CoroutineScope, - workers: Int = 1, - transform: suspend FlowCollector>>.(Set) -> Unit, - ) = BatchingCacheAdapter(scope, workers) { ids -> - flow { - transform(ids) - } - } + companion object +} + +/** + * Creates a cache layer that batches cache requests and executes them at once. + * + * See [BatchingCacheAdapter]. + */ +fun batchingCache( + scope: CoroutineScope, + workers: Int = 1, + transform: suspend FlowCollector>>.(Set) -> Unit, +) = BatchingCacheAdapter(scope, workers) { ids -> + flow { + transform(ids) } } diff --git a/cache/src/commonMain/kotlin/CacheAdapter.kt b/cache/src/commonMain/kotlin/CacheAdapter.kt index 3740204..81828ef 100644 --- a/cache/src/commonMain/kotlin/CacheAdapter.kt +++ b/cache/src/commonMain/kotlin/CacheAdapter.kt @@ -30,8 +30,13 @@ class CacheAdapter( // This cache layer has no state, nothing to do } - companion object { - fun cache(transform: suspend (I) -> Outcome) = - CacheAdapter { transform(it) } - } + companion object } + +/** + * Creates a cache layer that intercepts requests. + * + * See [CacheAdapter]. + */ +fun cache(transform: suspend (I) -> Outcome) = + CacheAdapter { transform(it) } diff --git a/cache/src/commonTest/kotlin/CacheTest.kt b/cache/src/commonTest/kotlin/CacheTest.kt index 48ae52b..9a2fd3d 100644 --- a/cache/src/commonTest/kotlin/CacheTest.kt +++ b/cache/src/commonTest/kotlin/CacheTest.kt @@ -5,8 +5,6 @@ import kotlinx.coroutines.* import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.test.runTest -import opensavvy.cache.BatchingCacheAdapter.Companion.batchingCache -import opensavvy.cache.CacheAdapter.Companion.cache import opensavvy.cache.ExpirationCache.Companion.expireAfter import opensavvy.cache.MemoryCache.Companion.cachedInMemory import opensavvy.logger.LogLevel diff --git a/cache/src/commonTest/kotlin/PassThroughContextTest.kt b/cache/src/commonTest/kotlin/PassThroughContextTest.kt index 57c3646..cee4c65 100644 --- a/cache/src/commonTest/kotlin/PassThroughContextTest.kt +++ b/cache/src/commonTest/kotlin/PassThroughContextTest.kt @@ -20,7 +20,7 @@ class PassThroughContextTest { @Test fun memoryCache() = runTest { - val cache = CacheAdapter.cache { + val cache = cache { println(currentCoroutineContext()) if (it) { assertNotNull(currentCoroutineContext()[CustomContext]) -- 2.51.2 From 8672e377a36c3026a9c1cc752b37924b5f832b92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 9 Apr 2023 16:05:03 +0200 Subject: [PATCH 40/51] refactor(backbone): Use the new failures --- backbone/build.gradle.kts | 1 + backbone/src/commonMain/kotlin/Backbone.kt | 11 +++--- backbone/src/commonMain/kotlin/Ref.kt | 17 +++++---- backbone/src/commonMain/kotlin/RefCache.kt | 13 ++++--- .../commonTest/kotlin/BackboneCacheTest.kt | 37 ++++++++++--------- 5 files changed, 43 insertions(+), 36 deletions(-) diff --git a/backbone/build.gradle.kts b/backbone/build.gradle.kts index f873c50..f0b1258 100644 --- a/backbone/build.gradle.kts +++ b/backbone/build.gradle.kts @@ -30,6 +30,7 @@ kotlin { val commonTest by getting { dependencies { implementation(projects.tester) + implementation(projects.stateArrow) api(KotlinX.coroutines.test) } diff --git a/backbone/src/commonMain/kotlin/Backbone.kt b/backbone/src/commonMain/kotlin/Backbone.kt index b781d20..180f690 100644 --- a/backbone/src/commonMain/kotlin/Backbone.kt +++ b/backbone/src/commonMain/kotlin/Backbone.kt @@ -1,5 +1,6 @@ package opensavvy.backbone +import opensavvy.state.failure.Failure import opensavvy.state.outcome.Outcome /** @@ -9,14 +10,14 @@ import opensavvy.state.outcome.Outcome * Each method represents a different endpoint to implement actions on the relevant objects (list all objects, * create a new one, etc). */ -interface Backbone { +interface Backbone { /** * A cache used to store previous results of [directRequest]. * * As a convenience, you can use [request] to launch a request through the cache. */ - val cache: RefCache + val cache: RefCache /** * Fetches the value associated with a [ref] in an external media (e.g. a remote server, a database). @@ -24,7 +25,7 @@ interface Backbone { * This function completely bypasses the [cache]: a request will be sent everytime it is called. * To avoid sending unnecessary requests, use [request] instead. */ - suspend fun directRequest(ref: Ref): Outcome + suspend fun directRequest(ref: Ref): Outcome /** * Fetches the value associated with all [refs] in an external media (e.g. a remote server, a database). @@ -39,7 +40,7 @@ interface Backbone { * * The default implementation simply calls [directRequest] sequentially. */ - fun batchRequests(refs: Set>): Map, suspend () -> Outcome> = refs + fun batchRequests(refs: Set>): Map, suspend () -> Outcome> = refs .map { it to suspend { directRequest(it) } } .associate { it } @@ -50,6 +51,6 @@ interface Backbone { * This function takes the [cache] into account, it will return a previous value if there is one stored. * To force sending a request, use [directRequest] instead. */ - fun Backbone.request(ref: Ref) = cache[ref] + fun Backbone.request(ref: Ref) = cache[ref] } } diff --git a/backbone/src/commonMain/kotlin/Ref.kt b/backbone/src/commonMain/kotlin/Ref.kt index c1d13de..2776d51 100644 --- a/backbone/src/commonMain/kotlin/Ref.kt +++ b/backbone/src/commonMain/kotlin/Ref.kt @@ -3,7 +3,8 @@ package opensavvy.backbone import opensavvy.backbone.Backbone.Companion.request import opensavvy.backbone.Ref.Companion.directRequest import opensavvy.backbone.Ref.Companion.request -import opensavvy.state.progressive.firstValue +import opensavvy.state.coroutines.firstValue +import opensavvy.state.failure.Failure /** * A reference to a specific [object][O]. @@ -17,19 +18,19 @@ import opensavvy.state.progressive.firstValue * * @param O The object this reference refers to. */ -interface Ref { +interface Ref { /** * The [Backbone] responsible for this reference. * * This property should always return the same [Backbone] instance for a given [Ref]. */ - val backbone: Backbone + val backbone: Backbone /** * Most simple implementation of [Ref], which identifies objects with an [id]. */ - data class Basic(val id: String, override val backbone: Backbone) : Ref + data class Basic(val id: String, override val backbone: Backbone) : Ref companion object { /** @@ -37,14 +38,14 @@ interface Ref { * * This is a convenience method around [Backbone.directRequest]. */ - suspend fun Ref.directRequest() = backbone.directRequest(this) + suspend fun Ref.directRequest() = backbone.directRequest(this) /** * Requests the referenced data, returning a value from the cache if one is stored. * * This is a convenience method around [Backbone.request]. */ - fun Ref.request() = backbone.request(this) + fun Ref.request() = backbone.request(this) /** * Requests the referenced data, returning the first value returned by the cache. @@ -55,14 +56,14 @@ interface Ref { * notified of new values is important (e.g. in a UI). * This function is intended for non-reactive environments (e.g. server requests, tests…). */ - suspend fun Ref.now() = request().firstValue() + suspend fun Ref.now() = request().firstValue() /** * Forces the cache to forget anything it might remember about this reference. * * The next time [request] is called, a new request will be started. */ - suspend fun Ref.expire() { + suspend fun Ref.expire() { backbone.cache.expire(this) } } diff --git a/backbone/src/commonMain/kotlin/RefCache.kt b/backbone/src/commonMain/kotlin/RefCache.kt index d3d64fb..c2488a3 100644 --- a/backbone/src/commonMain/kotlin/RefCache.kt +++ b/backbone/src/commonMain/kotlin/RefCache.kt @@ -1,5 +1,6 @@ package opensavvy.backbone +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map @@ -7,15 +8,15 @@ import opensavvy.backbone.Ref.Companion.directRequest import opensavvy.cache.BatchingCacheAdapter import opensavvy.cache.Cache import opensavvy.cache.CacheAdapter -import opensavvy.state.progressive.captureProgress -import kotlin.coroutines.CoroutineContext +import opensavvy.state.coroutines.captureProgress +import opensavvy.state.failure.Failure -typealias RefCache = Cache, O> +typealias RefCache = Cache, F, O> -fun defaultRefCache() = CacheAdapter, O> { it.directRequest() } +fun defaultRefCache() = CacheAdapter, F, O> { it.directRequest() } -fun batchingRefCache(context: CoroutineContext, workers: Int = 1) = - BatchingCacheAdapter, O>(context, workers) { requests -> +fun batchingRefCache(scope: CoroutineScope, workers: Int = 1) = + BatchingCacheAdapter, F, O>(scope, workers) { requests -> val backbones = requests .groupBy { it.backbone } diff --git a/backbone/src/commonTest/kotlin/BackboneCacheTest.kt b/backbone/src/commonTest/kotlin/BackboneCacheTest.kt index e237fe5..f393a55 100644 --- a/backbone/src/commonTest/kotlin/BackboneCacheTest.kt +++ b/backbone/src/commonTest/kotlin/BackboneCacheTest.kt @@ -1,31 +1,34 @@ -@file:OptIn(ExperimentalCoroutinesApi::class) - package opensavvy.backbone +import arrow.core.raise.ensure import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.test.runTest import opensavvy.backbone.Ref.Companion.expire import opensavvy.backbone.Ref.Companion.request -import opensavvy.state.outcome.ensureValid -import opensavvy.state.outcome.orThrow -import opensavvy.state.outcome.out -import opensavvy.state.progressive.firstValue +import opensavvy.state.arrow.out +import opensavvy.state.coroutines.firstValue +import opensavvy.state.failure.CustomFailure +import opensavvy.state.failure.Failure +import opensavvy.state.outcome.valueOrNull import kotlin.test.Test import kotlin.test.assertEquals +@OptIn(ExperimentalCoroutinesApi::class) class BackboneCacheTest { // Id("12") -> 12 - private class Bone(override val cache: RefCache) : Backbone { - override suspend fun directRequest(ref: Ref) = out { - ensureValid(ref is Ref.Basic) { "Only basic references are accepted by ${this@Bone}" } + private class Bone(override val cache: RefCache) : Backbone { + override suspend fun directRequest(ref: Ref) = out { + ensure(ref is Ref.Basic) { Invalid } val int = ref.id.toIntOrNull() - ensureValid(int != null) { "The reference $ref does not refer to a valid integer" } + ensure(int != null) { Invalid } int } fun of(int: Int) = Ref.Basic(int.toString(), this) + + object Invalid : CustomFailure(Invalid, "Invalid"), Failure.Key } @Test @@ -34,26 +37,26 @@ class BackboneCacheTest { val id5 = bone.of(5) val id2 = bone.of(2) - assertEquals(5, id5.request().firstValue().orThrow()) - assertEquals(2, id2.request().firstValue().orThrow()) + assertEquals(5, id5.request().firstValue().valueOrNull) + assertEquals(2, id2.request().firstValue().valueOrNull) id2.expire() - assertEquals(2, id2.request().firstValue().orThrow()) + assertEquals(2, id2.request().firstValue().valueOrNull) } @Test fun batching() = runTest { val job = Job() - val bone = Bone(batchingRefCache(coroutineContext + job)) + val bone = Bone(batchingRefCache(backgroundScope)) val id5 = bone.of(5) val id2 = bone.of(2) - assertEquals(5, id5.request().firstValue().orThrow()) - assertEquals(2, id2.request().firstValue().orThrow()) + assertEquals(5, id5.request().firstValue().valueOrNull) + assertEquals(2, id2.request().firstValue().valueOrNull) id2.expire() - assertEquals(2, id2.request().firstValue().orThrow()) + assertEquals(2, id2.request().firstValue().valueOrNull) job.cancel() } -- 2.51.2 From de41caf862551393947fea89b1d7782aa40a976b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 9 Apr 2023 18:45:52 +0200 Subject: [PATCH 41/51] docs: Announce the depreciation of Spine --- README.md | 1 - spine/README.md | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 4c56686..5b4a23f 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,6 @@ This project contains the following modules: values that change over time, domain failures, and progression of long-running tasks. - [Pedestal Cache](cache/README.md) is a collection of cache implementations for Pedestal State which helps with reducing network traffic on the entire stack. - [Pedestal Backbone](backbone/README.md) helps with exposing multiple implementations of the same API transparently, facilitating architectural modifications with automatic caching and state management for performant reactive applications. -- [Pedestal Spine](spine/README.md) (experimental) declares typesafe Kotlin API endpoints that can be easily used from any web server or client. Pedestal focuses heavily on Kotlin and depends on some KotlinX libraries (e.g. Coroutines). Apart from that, Pedestal tries to be as agnostic of the technology as possible: for example, Spine APIs can be implemented with any web client or server framework (the Ktor implementation is available out of the box). diff --git a/spine/README.md b/spine/README.md index b19c3ca..3a63f83 100644 --- a/spine/README.md +++ b/spine/README.md @@ -1,6 +1,5 @@ # Pedestal Spine -> Pedestal Spine is currently experimental. -> The APIs may change without warning. +> Pedestal Spine is deprecated, and will be removed in the future. Pedestal Spine is a framework allowing the declaration of fullstack Kotlin APIs and data validation in common code, which can later be used to call endpoints on the client-side or implement them on the server-side with type safety. -- 2.51.2 From 381e3739764e3b3b883a9d7e4928bb6e83422c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 9 Apr 2023 18:46:35 +0200 Subject: [PATCH 42/51] docs: Clarify our dependency policies --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5b4a23f..73f2422 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ This project contains the following modules: - [Pedestal Cache](cache/README.md) is a collection of cache implementations for Pedestal State which helps with reducing network traffic on the entire stack. - [Pedestal Backbone](backbone/README.md) helps with exposing multiple implementations of the same API transparently, facilitating architectural modifications with automatic caching and state management for performant reactive applications. -Pedestal focuses heavily on Kotlin and depends on some KotlinX libraries (e.g. Coroutines). -Apart from that, Pedestal tries to be as agnostic of the technology as possible: for example, Spine APIs can be implemented with any web client or server framework (the Ktor implementation is available out of the box). +Pedestal focuses heavily on Kotlin and attempts to depend on as few external dependencies as possible. +Where possible, compatibility modules are provided to interoperate with other ecosystems. ## In the wild -- 2.51.2 From 37587af008ce50aab26106aa0d29d90b0d35cd0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 9 Apr 2023 18:33:37 +0200 Subject: [PATCH 43/51] refactor(spine): Use the new failures --- .../src/commonMain/kotlin/Client.kt | 6 +- .../commonMain/kotlin/ResponseStateBuilder.kt | 8 +- .../src/commonMain/kotlin/Server.kt | 20 +-- .../src/commonTest/kotlin/ServerTest.kt | 27 ++-- .../src/commonMain/kotlin/StatusCodes.kt | 25 ++-- spine/build.gradle.kts | 13 +- spine/src/commonMain/kotlin/Operation.kt | 9 +- spine/src/commonMain/kotlin/Resource.kt | 47 +++++-- spine/src/commonMain/kotlin/SpineFailure.kt | 22 +++ spine/src/commonTest/kotlin/ServiceTest.kt | 133 +----------------- 10 files changed, 102 insertions(+), 208 deletions(-) create mode 100644 spine/src/commonMain/kotlin/SpineFailure.kt diff --git a/spine-ktor/spine-ktor-client/src/commonMain/kotlin/Client.kt b/spine-ktor/spine-ktor-client/src/commonMain/kotlin/Client.kt index 81068af..c783683 100644 --- a/spine-ktor/spine-ktor-client/src/commonMain/kotlin/Client.kt +++ b/spine-ktor/spine-ktor-client/src/commonMain/kotlin/Client.kt @@ -10,10 +10,10 @@ import opensavvy.spine.Id import opensavvy.spine.Operation import opensavvy.spine.Parameters import opensavvy.spine.ResourceGroup.AbstractResource +import opensavvy.spine.SpineFailure import opensavvy.spine.ktor.toHttp import opensavvy.spine.ktor.toSpine -import opensavvy.state.Failure -import opensavvy.state.outcome.out +import opensavvy.state.arrow.out /** * Executes a [HttpClient] request, with the information declared in an [Operation]. @@ -93,7 +93,7 @@ suspend inline fun ().ifBlank { "${result.status} with no provided body" } val kind = result.status.toSpine() - shift(Failure(kind, body)) + raise(SpineFailure(kind, body)) } } } diff --git a/spine-ktor/spine-ktor-server/src/commonMain/kotlin/ResponseStateBuilder.kt b/spine-ktor/spine-ktor-server/src/commonMain/kotlin/ResponseStateBuilder.kt index 8478d76..97920fa 100644 --- a/spine-ktor/spine-ktor-server/src/commonMain/kotlin/ResponseStateBuilder.kt +++ b/spine-ktor/spine-ktor-server/src/commonMain/kotlin/ResponseStateBuilder.kt @@ -1,16 +1,16 @@ package opensavvy.spine.ktor.server -import arrow.core.continuations.EffectScope +import arrow.core.raise.Raise import io.ktor.server.application.* import opensavvy.spine.Id import opensavvy.spine.Parameters -import opensavvy.state.Failure +import opensavvy.spine.SpineFailure /** * Information available in [route]. */ class ResponseStateBuilder( - builder: EffectScope, + builder: Raise, /** * The identifier of the resource being requested. @@ -36,4 +36,4 @@ class ResponseStateBuilder( * The current request's context. */ val context: Context, -) : EffectScope by builder +) : Raise by builder diff --git a/spine-ktor/spine-ktor-server/src/commonMain/kotlin/Server.kt b/spine-ktor/spine-ktor-server/src/commonMain/kotlin/Server.kt index 0736fdb..cc11f0d 100644 --- a/spine-ktor/spine-ktor-server/src/commonMain/kotlin/Server.kt +++ b/spine-ktor/spine-ktor-server/src/commonMain/kotlin/Server.kt @@ -1,5 +1,6 @@ package opensavvy.spine.ktor.server +import arrow.core.raise.either import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* @@ -9,7 +10,7 @@ import opensavvy.logger.loggerFor import opensavvy.spine.Operation import opensavvy.spine.Parameters import opensavvy.spine.ktor.toHttp -import opensavvy.state.outcome.out +import opensavvy.state.arrow.toEither import kotlin.collections.component1 import kotlin.collections.component2 import kotlin.collections.set @@ -92,24 +93,15 @@ inline fun = 0) { "Could not find user $id" } + ensure(userIndex >= 0) { SpineFailure(SpineFailure.Type.NotFound, "Could not find user $id") } val user = users.removeAt(userIndex) users.add(user.copy(archived = true)) } route(api.users.id.unarchive, context) { val userIndex = users.indexOfFirst { it.id == id } - ensureFound(userIndex >= 0) { "Could not find user $id" } + ensure(userIndex >= 0) { SpineFailure(SpineFailure.Type.NotFound, "Could not find user $id") } val user = users.removeAt(userIndex) users.add(user.copy(archived = false)) } route(api.users.id.delete, context) { val userIndex = users.indexOfFirst { it.id == id } - ensureFound(userIndex >= 0) { "Could not find user $id" } + ensure(userIndex >= 0) { SpineFailure(SpineFailure.Type.NotFound, "Could not find user $id") } users.removeAt(userIndex) } } @@ -113,7 +110,7 @@ class ServerTest { val params = User.SearchParams().apply { includeArchived = true } val results = client.request(api.users.get, api.users.get.idOf(), Unit, params, Unit) - assertEquals(successful(emptyList()), results) + assertEquals(emptyList().success(), results) } log.info { "Step 2: creating two users" } @@ -121,18 +118,18 @@ class ServerTest { run { val first = client.request(api.users.create, api.users.create.idOf(), User.New("first"), Parameters.Empty, Unit) - .orThrow() + .valueOrNull!! val second = client.request(api.users.create, api.users.create.idOf(), User.New("second"), Parameters.Empty, Unit) - .orThrow() + .valueOrNull!! assertEquals(User(Id("test", Route / "users" / "0"), "first", archived = false), first.value) assertEquals(User(Id("test", Route / "users" / "1"), "second", archived = false), second.value) val params = User.SearchParams().apply { includeArchived = true } val results = client.request(api.users.get, api.users.get.idOf(), Unit, params, Unit) - .orThrow() + .valueOrNull assertEquals(listOf(first.id, second.id), results) } diff --git a/spine-ktor/src/commonMain/kotlin/StatusCodes.kt b/spine-ktor/src/commonMain/kotlin/StatusCodes.kt index 34247a0..9f716cf 100644 --- a/spine-ktor/src/commonMain/kotlin/StatusCodes.kt +++ b/spine-ktor/src/commonMain/kotlin/StatusCodes.kt @@ -1,20 +1,21 @@ package opensavvy.spine.ktor import io.ktor.http.* -import opensavvy.state.Failure +import opensavvy.spine.SpineFailure fun HttpStatusCode.toSpine() = when (this) { - HttpStatusCode.NotFound -> Failure.Kind.NotFound - HttpStatusCode.Unauthorized -> Failure.Kind.Unauthenticated - HttpStatusCode.Forbidden -> Failure.Kind.Unauthorized - HttpStatusCode.UnprocessableEntity -> Failure.Kind.Invalid - else -> Failure.Kind.Unknown + HttpStatusCode.NotFound -> SpineFailure.Type.NotFound + HttpStatusCode.Unauthorized -> SpineFailure.Type.Unauthenticated + HttpStatusCode.Forbidden -> SpineFailure.Type.Unauthorized + HttpStatusCode.UnprocessableEntity -> SpineFailure.Type.InvalidRequest + HttpStatusCode.Conflict -> SpineFailure.Type.InvalidState + else -> error("Unexpected error code: $this") } -fun Failure.Kind.toHttp() = when (this) { - Failure.Kind.Invalid -> HttpStatusCode.UnprocessableEntity - Failure.Kind.Unauthenticated -> HttpStatusCode.Unauthorized - Failure.Kind.Unauthorized -> HttpStatusCode.Forbidden - Failure.Kind.NotFound -> HttpStatusCode.NotFound - Failure.Kind.Unknown -> HttpStatusCode.InternalServerError +fun SpineFailure.Type.toHttp() = when (this) { + SpineFailure.Type.InvalidRequest -> HttpStatusCode.UnprocessableEntity + SpineFailure.Type.Unauthenticated -> HttpStatusCode.Unauthorized + SpineFailure.Type.Unauthorized -> HttpStatusCode.Forbidden + SpineFailure.Type.NotFound -> HttpStatusCode.NotFound + SpineFailure.Type.InvalidState -> HttpStatusCode.InternalServerError } diff --git a/spine/build.gradle.kts b/spine/build.gradle.kts index 6cd2ba2..b539083 100644 --- a/spine/build.gradle.kts +++ b/spine/build.gradle.kts @@ -16,6 +16,8 @@ kotlin { val commonMain by getting { dependencies { api(projects.state) + api(projects.stateArrow) + api(projects.stateCoroutines) api(KotlinX.serialization.core) implementation(projects.logger) @@ -33,14 +35,3 @@ kotlin { } } } - -kover { - verify { - rule { - name = "Minimal code coverage" - bound { - minValue = 80 - } - } - } -} diff --git a/spine/src/commonMain/kotlin/Operation.kt b/spine/src/commonMain/kotlin/Operation.kt index c20f7f3..02a1162 100644 --- a/spine/src/commonMain/kotlin/Operation.kt +++ b/spine/src/commonMain/kotlin/Operation.kt @@ -1,8 +1,7 @@ package opensavvy.spine -import arrow.core.continuations.EffectScope -import opensavvy.state.Failure -import opensavvy.state.outcome.out +import arrow.core.raise.Raise +import opensavvy.state.arrow.out import kotlin.js.JsName typealias OperationValidator = suspend Operation.ValidatorScope.() -> Unit @@ -69,7 +68,7 @@ class Operation internal constructor( - private val scope: EffectScope, + private val scope: Raise, val id: Id, @@ -78,5 +77,5 @@ class Operation by scope + ) : Raise by scope } diff --git a/spine/src/commonMain/kotlin/Resource.kt b/spine/src/commonMain/kotlin/Resource.kt index d91a9e8..9251014 100644 --- a/spine/src/commonMain/kotlin/Resource.kt +++ b/spine/src/commonMain/kotlin/Resource.kt @@ -1,10 +1,9 @@ package opensavvy.spine -import arrow.core.continuations.EffectScope +import arrow.core.raise.Raise +import arrow.core.raise.ensure import opensavvy.spine.ResourceGroup.AbstractResource import opensavvy.spine.Route.Companion.div -import opensavvy.state.Failure -import opensavvy.state.outcome.ensureValid /** * Common ancestor of [Service] and [AbstractResource]. @@ -81,26 +80,41 @@ sealed class ResourceGroup { /** * Validates that [id] identifies this resource. */ - suspend fun EffectScope.validateCorrectId(id: Id) { - ensureValid( + fun Raise.validateCorrectId(id: Id) { + ensure( id.service == service.name - ) { "The passed identifier refers to the service '${id.service}', but this resource belongs to the service '${service.name}'" } + ) { + SpineFailure( + SpineFailure.Type.InvalidState, + "The passed identifier refers to the service '${id.service}', but this resource belongs to the service '${service.name}'" + ) + } // Let's check that the resource designated by the ID matches with this resource var resource: ResourceGroup = this@AbstractResource var index = id.resource.segments.lastIndex while (resource is AbstractResource<*, *>) { val segment = id.resource.segments.getOrNull(index) - ensureValid( + ensure( segment != null - ) { "The passed identifier's URI length is too short for this resource: '$id' for resource '${this@AbstractResource}'" } + ) { + SpineFailure( + SpineFailure.Type.InvalidState, + "The passed identifier's URI length is too short for this resource: '$id' for resource '${this@AbstractResource}'" + ) + } @Suppress("NAME_SHADOWING") // necessary for smart cast because 'resource' is mutable when (val resource: AbstractResource<*, *> = resource) { is StaticResource<*, *, *> -> { - ensureValid( + ensure( segment == resource.route - ) { "The passed identifier's segment #$index doesn't match the resource; expected '${resource.route}' but found '$segment'" } + ) { + SpineFailure( + SpineFailure.Type.InvalidState, + "The passed identifier's segment #$index doesn't match the resource; expected '${resource.route}' but found '$segment'" + ) + } } is DynamicResource<*, *> -> { @@ -115,9 +129,14 @@ sealed class ResourceGroup { index-- } - ensureValid( + ensure( index == -1 - ) { "The passed identifier's URI length is too long for this resource: '$id' for resource '${this@AbstractResource}'" } + ) { + SpineFailure( + SpineFailure.Type.InvalidState, + "The passed identifier's URI length is too long for this resource: '$id' for resource '${this@AbstractResource}'" + ) + } } /** @@ -126,7 +145,7 @@ sealed class ResourceGroup { * For example, you can override this function to check access rights for read operations. * By default, this function does nothing. */ - open suspend fun EffectScope.validateId(id: Id, context: Context) {} + open suspend fun Raise.validateId(id: Id, context: Context) {} protected fun create( route: Route? = null, @@ -210,7 +229,7 @@ sealed class ResourceGroup { * * You should override this function if the parameters impact the access rights. */ - open suspend fun EffectScope.validateGetParams(id: Id, params: GetParams, context: Context) {} + open suspend fun Raise.validateGetParams(id: Id, params: GetParams, context: Context) {} @Suppress("LeakingThis") // Not dangerous because Operation's constructor does nothing val get = diff --git a/spine/src/commonMain/kotlin/SpineFailure.kt b/spine/src/commonMain/kotlin/SpineFailure.kt new file mode 100644 index 0000000..bcc8524 --- /dev/null +++ b/spine/src/commonMain/kotlin/SpineFailure.kt @@ -0,0 +1,22 @@ +package opensavvy.spine + +import opensavvy.state.failure.CustomFailure +import opensavvy.state.failure.Failure + +class SpineFailure( + val type: Type, + message: String, + cause: Failure? = null, +) : CustomFailure(Companion, "$type: $message", cause) { + + enum class Type { + Unauthenticated, + Unauthorized, + NotFound, + InvalidRequest, + InvalidState, + ; + } + + companion object : Failure.Key +} diff --git a/spine/src/commonTest/kotlin/ServiceTest.kt b/spine/src/commonTest/kotlin/ServiceTest.kt index b90906f..e36e18b 100644 --- a/spine/src/commonTest/kotlin/ServiceTest.kt +++ b/spine/src/commonTest/kotlin/ServiceTest.kt @@ -4,16 +4,8 @@ package opensavvy.spine import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.test.runTest -import opensavvy.backbone.Backbone import opensavvy.backbone.Ref -import opensavvy.backbone.Ref.Companion.request -import opensavvy.backbone.defaultRefCache import opensavvy.spine.Route.Companion.div -import opensavvy.state.* -import opensavvy.state.outcome.* -import opensavvy.state.progressive.firstValue import kotlin.test.Test import kotlin.test.assertEquals @@ -43,7 +35,7 @@ private data class User(val name: String, val admin: Boolean) { data class Rename(val name: String) } -private class Context(val user: Ref) +private class Context(val user: Ref) private class Api : Service("v2") { inner class Departments : StaticResource, Department.SearchParams, Context>("departments") { @@ -64,18 +56,12 @@ private class Api : Service("v2") { val leave = action(Route / "leave") - val rename = edit(Route / "name") { - ensureValid(body.name.isNotBlank()) { "A user's name may not be empty: '${body.name}'" } - } + val rename = edit(Route / "name") val departments = Departments() } - val create = create { - ensureValid(body.name.isNotBlank()) { "A user's name may not be empty: '${body.name}'" } - ensureValid(body.name.length < 100) { "A user's name may not be longer than 100 characters, found ${body.name.length} characters: '${body.name}'" } - ensureAuthorized(context.user.request().firstValue().bind().admin) { "Only admins can create new users" } - } + val create = create() val id = Unique() } @@ -104,117 +90,4 @@ class ServiceTest { assertEquals(expected, routes) } - private class UserBone : Backbone { - override val cache = defaultRefCache() - - private val users = HashMap() - - init { - users["0"] = User("Employee", admin = false) - users["1"] = User("Admin", admin = true) - } - - override suspend fun directRequest(ref: Ref) = out { - ensureValid(ref is Ref.Basic) { "The reference type ${ref::class} is not supported by UserBone" } - - val result = users[ref.id] - ensureFound(result != null) { "No user has the ID $ref" } - - result - } - } - - @Test - fun getUser() = runTest { - val api = Api() - val endpoint = api.users.id.get - val bone = UserBone() - - val employee = Context(Ref.Basic("0", bone)) - - // Scenario 1: an employee accesses themselves - - val id1 = endpoint.idOf("0") - assertEquals( - successful(User("Employee", false)), - out { - endpoint.validate(id1, Unit, Parameters.Empty, employee).bind() - employee.user.request().firstValue().bind() - } - ) - - // Scenario 2: access with an invalid service ID - - val id2 = Id("this-is-not-the-correct-service-name", Route / "users" / "0") - assertEquals( - failed( - "The passed identifier refers to the service 'this-is-not-the-correct-service-name', but this resource belongs to the service 'v2'", - Failure.Kind.Invalid - ), - endpoint.validate(id2, Unit, Parameters.Empty, employee) - ) - - // Scenario 3: access with an invalid ID (too short) - - val id3 = Id("v2", Route / "users") // should be /users/0 - assertEquals( - failed( - "The passed identifier's URI length is too short for this resource: 'v2/users' for resource 'v2/users/{user}'", - Failure.Kind.Invalid - ), - endpoint.validate(id3, Unit, Parameters.Empty, employee) - ) - - // Scenario 4: access with an invalid ID (wrong resource) - - val id4 = Id("v2", Route / "departments" / "0") // should be /users/0 - assertEquals( - failed( - "The passed identifier's segment #0 doesn't match the resource; expected 'users' but found 'departments'", - Failure.Kind.Invalid - ), - endpoint.validate(id4, Unit, Parameters.Empty, employee) - ) - - // Scenario 5: access with an invalid ID (too long) - - val id5 = Id("v2", Route / "departments" / "users" / "0") // should be /users/0 - assertEquals( - failed( - "The passed identifier's URI length is too long for this resource: 'v2/departments/users/0' for resource 'v2/users/{user}'", - Failure.Kind.Invalid - ), - endpoint.validate(id5, Unit, Parameters.Empty, employee) - ) - } - - @Test - fun createUser() = runTest { - val api = Api() - val endpoint = api.users.create - val bone = UserBone() - - val admin = Context(Ref.Basic("1", bone)) - - assertEquals( - successful(Unit), - endpoint.validate(endpoint.idOf(), User.New("Third user"), Parameters.Empty, admin) - ) - } - - @Test - fun editUser() = runTest { - val api = Api() - val endpoint = api.users.id.rename - val bone = UserBone() - - val admin = Context(Ref.Basic("1", bone)) - - val id = endpoint.idOf("0") - assertEquals( - successful(Unit), - endpoint.validate(id, User.Rename("Another name"), Parameters.Empty, admin) - ) - } - } -- 2.51.2 From 7a54faa7fc59225842244f3bcc0b744ef90ea65c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 9 Apr 2023 19:05:04 +0200 Subject: [PATCH 44/51] tests(progress): Test SimpleLoadingImplementation.toString, equals & hashCode --- .../kotlin/SimpleLoadingImplementationTest.kt | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/progress/src/commonTest/kotlin/SimpleLoadingImplementationTest.kt b/progress/src/commonTest/kotlin/SimpleLoadingImplementationTest.kt index 4cd4273..c008a36 100644 --- a/progress/src/commonTest/kotlin/SimpleLoadingImplementationTest.kt +++ b/progress/src/commonTest/kotlin/SimpleLoadingImplementationTest.kt @@ -1,6 +1,7 @@ package opensavvy.progress import kotlin.test.Test +import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFails @@ -46,4 +47,29 @@ class SimpleLoadingImplementationTest { fun percent100() { assertEquals(100, loading(1.0).percent) } + + @Test + fun string() { + assertEquals("Loading(0%)", loading(0.0).toString()) + assertEquals("Loading(20%)", loading(0.2).toString()) + assertEquals("Loading(99%)", loading(0.99).toString()) + assertEquals("Loading(100%)", loading(1.0).toString()) + } + + @Test + fun hash() { + val set = hashSetOf( + done(), + loading(0.0), + loading(0.7), + loading(0.9), + loading(1.0), + ) + + assertContains(set, done()) + assertContains(set, loading(0.0)) + assertContains(set, loading(0.7)) + assertContains(set, loading(0.9)) + assertContains(set, loading(1.0)) + } } -- 2.51.2 From 6f603c01d705194db635c83a6c6cd4bf0c7b4da2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 1 Apr 2023 19:21:38 +0200 Subject: [PATCH 45/51] ci(gitlab): Expose Dokka as a review app --- .gitlab-ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index eb6c66f..750aae9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -252,6 +252,11 @@ dokka: paths: - documentation expose_as: "Documentation" + environment: + name: review/$CI_COMMIT_REF_SLUG/documentation + url: https://$CI_PROJECT_NAMESPACE.$CI_PAGES_DOMAIN/-/$CI_PROJECT_NAME/-/jobs/$CI_JOB_ID/artifacts/documentation/index.html + deployment_tier: development + auto_stop_in: 1 week interruptible: true pages: -- 2.51.2 From f843df4ab827e4a202a2c91282212c4c1b85e051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 1 Apr 2023 19:22:10 +0200 Subject: [PATCH 46/51] build(git): Ignore gradle/conventions/.gradle --- gradle/conventions/.gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 gradle/conventions/.gitignore diff --git a/gradle/conventions/.gitignore b/gradle/conventions/.gitignore new file mode 100644 index 0000000..08a55c0 --- /dev/null +++ b/gradle/conventions/.gitignore @@ -0,0 +1 @@ +.gradle -- 2.51.2 From 6c8078c16df8ba6792adb37736d84b369dc295a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 26 Mar 2023 17:26:29 +0200 Subject: [PATCH 47/51] build(gradle): Changed the Maven coordinates group to opensavvy.pedestal --- README.md | 2 +- gradle/conventions/library/build.gradle.kts | 4 +++- gradle/conventions/structure/build.gradle.kts | 4 +++- gradle/conventions/versioning/build.gradle.kts | 4 +++- .../versioning/src/main/kotlin/versioning.gradle.kts | 5 ++++- 5 files changed, 14 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 73f2422..d5d258a 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ repositories { // You can now add a dependency on the various modules: dependencies { - implementation("opensavvy:backbone:") + implementation("opensavvy.pedestal:backbone:") } ``` diff --git a/gradle/conventions/library/build.gradle.kts b/gradle/conventions/library/build.gradle.kts index 43846e7..8e31a17 100644 --- a/gradle/conventions/library/build.gradle.kts +++ b/gradle/conventions/library/build.gradle.kts @@ -1,8 +1,10 @@ +import org.gradle.kotlin.dsl.`kotlin-dsl` + plugins { `kotlin-dsl` } -group = "opensavvy" +group = "opensavvy.pedestal" dependencies { implementation(project(":versioning")) diff --git a/gradle/conventions/structure/build.gradle.kts b/gradle/conventions/structure/build.gradle.kts index 7bce9f8..9539677 100644 --- a/gradle/conventions/structure/build.gradle.kts +++ b/gradle/conventions/structure/build.gradle.kts @@ -1,8 +1,10 @@ +import org.gradle.kotlin.dsl.`kotlin-dsl` + plugins { `kotlin-dsl` } -group = "opensavvy" +group = "opensavvy.pedestal" dependencies { implementation(project(":versioning")) diff --git a/gradle/conventions/versioning/build.gradle.kts b/gradle/conventions/versioning/build.gradle.kts index 774ac21..73da9ca 100644 --- a/gradle/conventions/versioning/build.gradle.kts +++ b/gradle/conventions/versioning/build.gradle.kts @@ -1,8 +1,10 @@ +import org.gradle.kotlin.dsl.`kotlin-dsl` + plugins { `kotlin-dsl` } -group = "opensavvy" +group = "opensavvy.pedestal" dependencies { implementation("com.palantir.gradle.gitversion:gradle-git-version:_") diff --git a/gradle/conventions/versioning/src/main/kotlin/versioning.gradle.kts b/gradle/conventions/versioning/src/main/kotlin/versioning.gradle.kts index 2a13164..6cf1061 100644 --- a/gradle/conventions/versioning/src/main/kotlin/versioning.gradle.kts +++ b/gradle/conventions/versioning/src/main/kotlin/versioning.gradle.kts @@ -1,5 +1,8 @@ package opensavvy.gradle +import org.gradle.kotlin.dsl.extra +import org.gradle.kotlin.dsl.invoke + plugins { id("com.palantir.git-version") } @@ -14,5 +17,5 @@ fun calculateVersion(): String { "${details.lastTag}-post.${details.commitDistance}+${details.gitHash}" } -group = "opensavvy" +group = "opensavvy.pedestal" version = calculateVersion() -- 2.51.2 From 5b1768d13a85f80ebb6babb0c0d032df516ecd65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 15 Apr 2023 13:04:07 +0200 Subject: [PATCH 48/51] refactor(backbone): Remove Ref.Basic It is unused and pollutes the API, users can create their own versions if they want. --- backbone/src/commonMain/kotlin/Ref.kt | 5 ----- backbone/src/commonTest/kotlin/BackboneCacheTest.kt | 6 ++++-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/backbone/src/commonMain/kotlin/Ref.kt b/backbone/src/commonMain/kotlin/Ref.kt index 2776d51..a8d642a 100644 --- a/backbone/src/commonMain/kotlin/Ref.kt +++ b/backbone/src/commonMain/kotlin/Ref.kt @@ -27,11 +27,6 @@ interface Ref { */ val backbone: Backbone - /** - * Most simple implementation of [Ref], which identifies objects with an [id]. - */ - data class Basic(val id: String, override val backbone: Backbone) : Ref - companion object { /** * Requests the referenced data (without taking into account the cache). diff --git a/backbone/src/commonTest/kotlin/BackboneCacheTest.kt b/backbone/src/commonTest/kotlin/BackboneCacheTest.kt index f393a55..5f7a7d8 100644 --- a/backbone/src/commonTest/kotlin/BackboneCacheTest.kt +++ b/backbone/src/commonTest/kotlin/BackboneCacheTest.kt @@ -17,16 +17,18 @@ import kotlin.test.assertEquals @OptIn(ExperimentalCoroutinesApi::class) class BackboneCacheTest { + data class BasicRef(val id: String, override val backbone: Backbone) : Ref + // Id("12") -> 12 private class Bone(override val cache: RefCache) : Backbone { override suspend fun directRequest(ref: Ref) = out { - ensure(ref is Ref.Basic) { Invalid } + ensure(ref is BasicRef) { Invalid } val int = ref.id.toIntOrNull() ensure(int != null) { Invalid } int } - fun of(int: Int) = Ref.Basic(int.toString(), this) + fun of(int: Int) = BasicRef(int.toString(), this) object Invalid : CustomFailure(Invalid, "Invalid"), Failure.Key } -- 2.51.2 From eb312f21ababc8c9406ef2b8a0610208c772fc7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 15 Apr 2023 13:12:46 +0200 Subject: [PATCH 49/51] refactor(state-coroutines): Rework the ProgressiveFlow API --- backbone/src/commonMain/kotlin/Ref.kt | 4 +- .../commonTest/kotlin/BackboneCacheTest.kt | 14 +++--- cache/src/commonTest/kotlin/CacheTest.kt | 44 ++++++++-------- .../src/commonMain/kotlin/ProgressiveFlow.kt | 50 +++++++++++++++++++ .../kotlin/ProgressiveFlowAccesors.kt | 30 ----------- .../kotlin/ProgressiveFlowAccessorsTest.kt | 4 +- 6 files changed, 83 insertions(+), 63 deletions(-) create mode 100644 state-coroutines/src/commonMain/kotlin/ProgressiveFlow.kt delete mode 100644 state-coroutines/src/commonMain/kotlin/ProgressiveFlowAccesors.kt diff --git a/backbone/src/commonMain/kotlin/Ref.kt b/backbone/src/commonMain/kotlin/Ref.kt index a8d642a..70f6d54 100644 --- a/backbone/src/commonMain/kotlin/Ref.kt +++ b/backbone/src/commonMain/kotlin/Ref.kt @@ -3,7 +3,7 @@ package opensavvy.backbone import opensavvy.backbone.Backbone.Companion.request import opensavvy.backbone.Ref.Companion.directRequest import opensavvy.backbone.Ref.Companion.request -import opensavvy.state.coroutines.firstValue +import opensavvy.state.coroutines.now import opensavvy.state.failure.Failure /** @@ -51,7 +51,7 @@ interface Ref { * notified of new values is important (e.g. in a UI). * This function is intended for non-reactive environments (e.g. server requests, tests…). */ - suspend fun Ref.now() = request().firstValue() + suspend fun Ref.now() = request().now() /** * Forces the cache to forget anything it might remember about this reference. diff --git a/backbone/src/commonTest/kotlin/BackboneCacheTest.kt b/backbone/src/commonTest/kotlin/BackboneCacheTest.kt index 5f7a7d8..57e2548 100644 --- a/backbone/src/commonTest/kotlin/BackboneCacheTest.kt +++ b/backbone/src/commonTest/kotlin/BackboneCacheTest.kt @@ -7,7 +7,7 @@ import kotlinx.coroutines.test.runTest import opensavvy.backbone.Ref.Companion.expire import opensavvy.backbone.Ref.Companion.request import opensavvy.state.arrow.out -import opensavvy.state.coroutines.firstValue +import opensavvy.state.coroutines.now import opensavvy.state.failure.CustomFailure import opensavvy.state.failure.Failure import opensavvy.state.outcome.valueOrNull @@ -39,11 +39,11 @@ class BackboneCacheTest { val id5 = bone.of(5) val id2 = bone.of(2) - assertEquals(5, id5.request().firstValue().valueOrNull) - assertEquals(2, id2.request().firstValue().valueOrNull) + assertEquals(5, id5.request().now().valueOrNull) + assertEquals(2, id2.request().now().valueOrNull) id2.expire() - assertEquals(2, id2.request().firstValue().valueOrNull) + assertEquals(2, id2.request().now().valueOrNull) } @Test @@ -54,11 +54,11 @@ class BackboneCacheTest { val id5 = bone.of(5) val id2 = bone.of(2) - assertEquals(5, id5.request().firstValue().valueOrNull) - assertEquals(2, id2.request().firstValue().valueOrNull) + assertEquals(5, id5.request().now().valueOrNull) + assertEquals(2, id2.request().now().valueOrNull) id2.expire() - assertEquals(2, id2.request().firstValue().valueOrNull) + assertEquals(2, id2.request().now().valueOrNull) job.cancel() } diff --git a/cache/src/commonTest/kotlin/CacheTest.kt b/cache/src/commonTest/kotlin/CacheTest.kt index 9a2fd3d..800a081 100644 --- a/cache/src/commonTest/kotlin/CacheTest.kt +++ b/cache/src/commonTest/kotlin/CacheTest.kt @@ -15,7 +15,7 @@ import opensavvy.progress.Progress import opensavvy.progress.coroutines.report import opensavvy.progress.loading import opensavvy.state.arrow.out -import opensavvy.state.coroutines.firstValue +import opensavvy.state.coroutines.now import opensavvy.state.failure.CustomFailure import opensavvy.state.failure.Failure import opensavvy.state.outcome.valueOrNull @@ -62,36 +62,36 @@ class CacheTest { val one = cache[IntId(1)] val minus = cache[IntId(-1)] - assertEquals(0, zero.firstValue().valueOrNull) - assertEquals(1, one.firstValue().valueOrNull) - assertEquals(null, minus.firstValue().valueOrNull) + assertEquals(0, zero.now().valueOrNull) + assertEquals(1, one.now().valueOrNull) + assertEquals(null, minus.now().valueOrNull) } private suspend fun testUpdateExpire(cache: Cache) { log.info { "Checking normal behavior" } - assertEquals(0, cache[IntId(0)].firstValue().valueOrNull) + assertEquals(0, cache[IntId(0)].now().valueOrNull) log.info { "Overwriting with a different value" } cache.update(IntId(0), 5) - assertEquals(5, cache[IntId(0)].firstValue().valueOrNull) + assertEquals(5, cache[IntId(0)].now().valueOrNull) log.info { "Expiring the value re-downloads and replaces our fake value" } cache.expire(IntId(0)) - assertEquals(0, cache[IntId(0)].firstValue().valueOrNull) + assertEquals(0, cache[IntId(0)].now().valueOrNull) } private suspend fun testAutoExpiration(cache: Cache) { log.info { "Adding 5 to the cache to make updates visible" } - assertEquals(0, cache[IntId(0)].firstValue().valueOrNull) + assertEquals(0, cache[IntId(0)].now().valueOrNull) cache.update(IntId(0), 5) - assertEquals(5, cache[IntId(0)].firstValue().valueOrNull) + assertEquals(5, cache[IntId(0)].now().valueOrNull) log.info { "Waiting for the cache to correct itself" } assertEquals(0, cache[IntId(0)] .onEach { log.debug(it) { "Found new value" } } .drop(1) // Skip the bad value we inserted - .firstValue() + .now() .valueOrNull ) } @@ -165,21 +165,21 @@ class CacheTest { log.info { "Initial values" } val id0 = IntId(0) val id1 = IntId(1) - assertEquals(0, cache[id0].firstValue().valueOrNull) - assertEquals(1, cache[id1].firstValue().valueOrNull) + assertEquals(0, cache[id0].now().valueOrNull) + assertEquals(1, cache[id1].now().valueOrNull) log.info { "Adding 5" } cache.update( id0 to 5, id1 to 6, ) - assertEquals(5, cache[id0].firstValue().valueOrNull) - assertEquals(6, cache[id1].firstValue().valueOrNull) + assertEquals(5, cache[id0].now().valueOrNull) + assertEquals(6, cache[id1].now().valueOrNull) log.info { "Expiring all values" } cache.expireAll() - assertEquals(0, cache[id0].firstValue().valueOrNull) - assertEquals(1, cache[id1].firstValue().valueOrNull) + assertEquals(0, cache[id0].now().valueOrNull) + assertEquals(1, cache[id1].now().valueOrNull) } @Test @@ -197,21 +197,21 @@ class CacheTest { log.info { "Initial values" } val id0 = IntId(0) val id1 = IntId(1) - assertEquals(0, cache[id0].firstValue().valueOrNull) - assertEquals(1, cache[id1].firstValue().valueOrNull) + assertEquals(0, cache[id0].now().valueOrNull) + assertEquals(1, cache[id1].now().valueOrNull) log.info { "Adding 5" } cache.update( id0 to 5, id1 to 6, ) - assertEquals(5, cache[id0].firstValue().valueOrNull) - assertEquals(6, cache[id1].firstValue().valueOrNull) + assertEquals(5, cache[id0].now().valueOrNull) + assertEquals(6, cache[id1].now().valueOrNull) log.info { "Expiring all values" } cache.expireAll() - assertEquals(0, cache[id0].firstValue().valueOrNull) - assertEquals(1, cache[id1].firstValue().valueOrNull) + assertEquals(0, cache[id0].now().valueOrNull) + assertEquals(1, cache[id1].now().valueOrNull) } @Test diff --git a/state-coroutines/src/commonMain/kotlin/ProgressiveFlow.kt b/state-coroutines/src/commonMain/kotlin/ProgressiveFlow.kt new file mode 100644 index 0000000..1ce0776 --- /dev/null +++ b/state-coroutines/src/commonMain/kotlin/ProgressiveFlow.kt @@ -0,0 +1,50 @@ +package opensavvy.state.coroutines + +import kotlinx.coroutines.flow.* +import opensavvy.progress.Progress +import opensavvy.progress.coroutines.report +import opensavvy.progress.done +import opensavvy.state.failure.Failure +import opensavvy.state.progressive.ProgressiveOutcome +import opensavvy.state.progressive.asOutcome + +/** + * Successive values of the same object as time passes. + * + * Using this type alias instead of a regular flow communicates the intent that this flow contains successive values of + * a unique target, and thus that each value emitted by the flow is more recent than any other previous values. + * + * To ignore loading values, see [filterNotLoading]. To suspend until the first value is available, use [now]. + */ +typealias ProgressiveFlow = Flow> + +/** + * Filters out all the values with a non-[done][done] [progress][ProgressiveOutcome.progress] from this flow. + * + * All [progress information][Progress] from filtered-out values is re-emitted to the calling flow. + * + * @see now Return only the first non-loading value, instead of returning a flow. + */ +fun ProgressiveFlow.filterNotLoading() = this + .onEach { report(it.progress) } + .filter { it.progress == done() } + .mapNotNull { it.asOutcome() } + +/** + * Suspends until the first complete value is available (success or failure). + * + * This function assumes that the flow represents subsequent values of the same object as time passes. + * In this interpretation, the first complete value is the current value; hence the name `now`. + * + * Because this function only returns a single value, it exits the reactive model. It is therefore discouraged to use + * this function in UI code, as the calling code will not be made aware of new values arriving in the future. + * + * A complete value is a value which has a [progress][ProgressiveOutcome.progress] of [done]. + * All [progress information][Progress] from previous values is re-emitted in the calling coroutine. + * + * @throws NoSuchElementException In case the flow terminates before emitting a complete element. + * @see filterNotLoading Return all complete elements instead of just the first one. + */ +suspend fun ProgressiveFlow.now() = this + .filterNotLoading() + .first() diff --git a/state-coroutines/src/commonMain/kotlin/ProgressiveFlowAccesors.kt b/state-coroutines/src/commonMain/kotlin/ProgressiveFlowAccesors.kt deleted file mode 100644 index 7203dc1..0000000 --- a/state-coroutines/src/commonMain/kotlin/ProgressiveFlowAccesors.kt +++ /dev/null @@ -1,30 +0,0 @@ -package opensavvy.state.coroutines - -import kotlinx.coroutines.flow.* -import opensavvy.progress.Progress -import opensavvy.progress.coroutines.report -import opensavvy.progress.done -import opensavvy.state.failure.Failure -import opensavvy.state.progressive.ProgressiveOutcome -import opensavvy.state.progressive.asOutcome - -/** - * Filters out all the [ProgressiveOutcome.Incomplete] values from this flow. - * - * All [progress information][Progress] is re-emitted to the calling flow. - */ -fun Flow>.filterComplete() = this - .onEach { report(it.progress) } - .filter { it.progress == done() } - .mapNotNull { it.asOutcome() } - -/** - * Suspends until the first complete value is available (success or failure). - * - * All [progress information][Progress] is re-emitted in the calling coroutine. - * - * @throws NoSuchElementException if the flow has no complete elements. - */ -suspend fun Flow>.firstValue() = this - .filterComplete() - .first() diff --git a/state-coroutines/src/commonTest/kotlin/ProgressiveFlowAccessorsTest.kt b/state-coroutines/src/commonTest/kotlin/ProgressiveFlowAccessorsTest.kt index 2b5f919..f55282d 100644 --- a/state-coroutines/src/commonTest/kotlin/ProgressiveFlowAccessorsTest.kt +++ b/state-coroutines/src/commonTest/kotlin/ProgressiveFlowAccessorsTest.kt @@ -23,7 +23,7 @@ class ProgressiveFlowAccessorsTest { assertEquals( Outcome.Failure(NotFound(2)), - input.firstValue(), + input.now(), ) } @@ -37,7 +37,7 @@ class ProgressiveFlowAccessorsTest { assertEquals( Outcome.Success(2), - input.firstValue(), + input.now(), ) } } -- 2.51.2 From e6333babc13d6ef8891c0237d9da531c7a8df2f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 15 Apr 2023 14:54:08 +0200 Subject: [PATCH 50/51] refactor(cache): Use the ProgressiveFlow API --- cache/src/commonMain/kotlin/BatchingCacheAdapter.kt | 3 ++- cache/src/commonMain/kotlin/Cache.kt | 4 ++-- cache/src/commonMain/kotlin/CacheAdapter.kt | 5 ++--- cache/src/commonMain/kotlin/ExpirationCache.kt | 5 ++--- cache/src/commonMain/kotlin/MemoryCache.kt | 3 ++- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/cache/src/commonMain/kotlin/BatchingCacheAdapter.kt b/cache/src/commonMain/kotlin/BatchingCacheAdapter.kt index c6ad156..604293f 100644 --- a/cache/src/commonMain/kotlin/BatchingCacheAdapter.kt +++ b/cache/src/commonMain/kotlin/BatchingCacheAdapter.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.flow.* import opensavvy.logger.Logger.Companion.error import opensavvy.logger.loggerFor +import opensavvy.state.coroutines.ProgressiveFlow import opensavvy.state.failure.Failure import opensavvy.state.progressive.ProgressiveOutcome import kotlin.coroutines.coroutineContext @@ -110,7 +111,7 @@ class BatchingCacheAdapter( } } - override fun get(id: I): Flow> = flow { + override fun get(id: I): ProgressiveFlow = flow { val promise = CompletableDeferred>>() requests.send(id to promise) diff --git a/cache/src/commonMain/kotlin/Cache.kt b/cache/src/commonMain/kotlin/Cache.kt index 987b794..b69cf8a 100644 --- a/cache/src/commonMain/kotlin/Cache.kt +++ b/cache/src/commonMain/kotlin/Cache.kt @@ -1,8 +1,8 @@ package opensavvy.cache import kotlinx.coroutines.flow.Flow +import opensavvy.state.coroutines.ProgressiveFlow import opensavvy.state.failure.Failure -import opensavvy.state.progressive.ProgressiveOutcome /** * Stores information temporarily to avoid unneeded network requests. @@ -44,7 +44,7 @@ interface Cache { * such as inside the body of a UI component. * You can then subscribe to the [Flow] to access the actual values. */ - operator fun get(id: I): Flow> + operator fun get(id: I): ProgressiveFlow /** * Forces the cache to accept [value] as a more recent value for the given [id] than whatever it was previously storing. diff --git a/cache/src/commonMain/kotlin/CacheAdapter.kt b/cache/src/commonMain/kotlin/CacheAdapter.kt index 81828ef..b73ef95 100644 --- a/cache/src/commonMain/kotlin/CacheAdapter.kt +++ b/cache/src/commonMain/kotlin/CacheAdapter.kt @@ -1,10 +1,9 @@ package opensavvy.cache -import kotlinx.coroutines.flow.Flow +import opensavvy.state.coroutines.ProgressiveFlow import opensavvy.state.coroutines.captureProgress import opensavvy.state.failure.Failure import opensavvy.state.outcome.Outcome -import opensavvy.state.progressive.ProgressiveOutcome /** * Cache implementation aimed to be the first link in a cache chain. @@ -16,7 +15,7 @@ class CacheAdapter( val query: suspend (I) -> Outcome, ) : Cache { - override fun get(id: I): Flow> = captureProgress { query(id) } + override fun get(id: I): ProgressiveFlow = captureProgress { query(id) } override suspend fun update(values: Collection>) { // This cache layer has no state, nothing to do diff --git a/cache/src/commonMain/kotlin/ExpirationCache.kt b/cache/src/commonMain/kotlin/ExpirationCache.kt index 6bcba44..3b9fc00 100644 --- a/cache/src/commonMain/kotlin/ExpirationCache.kt +++ b/cache/src/commonMain/kotlin/ExpirationCache.kt @@ -1,7 +1,6 @@ package opensavvy.cache import kotlinx.coroutines.* -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit @@ -11,8 +10,8 @@ import opensavvy.cache.ExpirationCache.Companion.expireAfter import opensavvy.logger.Logger.Companion.trace import opensavvy.logger.loggerFor import opensavvy.progress.done +import opensavvy.state.coroutines.ProgressiveFlow import opensavvy.state.failure.Failure -import opensavvy.state.progressive.ProgressiveOutcome import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes @@ -78,7 +77,7 @@ class ExpirationCache( } } - override fun get(id: I): Flow> = upstream[id] + override fun get(id: I): ProgressiveFlow = upstream[id] .onEach { if (it.progress == done()) markAsUpdatedNow(id) diff --git a/cache/src/commonMain/kotlin/MemoryCache.kt b/cache/src/commonMain/kotlin/MemoryCache.kt index 85d849f..eaaa4c6 100644 --- a/cache/src/commonMain/kotlin/MemoryCache.kt +++ b/cache/src/commonMain/kotlin/MemoryCache.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.sync.withPermit import opensavvy.cache.MemoryCache.Companion.cachedInMemory import opensavvy.logger.Logger.Companion.trace import opensavvy.logger.loggerFor +import opensavvy.state.coroutines.ProgressiveFlow import opensavvy.state.failure.Failure import opensavvy.state.progressive.ProgressiveOutcome import opensavvy.state.progressive.copy @@ -55,7 +56,7 @@ class MemoryCache( /** **UNSAFE**: only call when owning the [cacheLock] */ private fun getUnsafe(id: I) = cache.getOrPut(id) { MutableStateFlow(null) } - override fun get(id: I): Flow> = flow { + override fun get(id: I): ProgressiveFlow = flow { val cached = cacheLock.withPermit { getUnsafe(id) } .onEach { out -> if (out == null) { -- 2.51.2 From 104359f9efbb35d6830623dbc7ff197241678bf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 15 Apr 2023 19:27:01 +0200 Subject: [PATCH 51/51] refactor(backbone): Rework of Backbone to hide the cache Hiding the cache makes the codebase more flexible. --- backbone/README.md | 268 ++++++++++++------ backbone/src/commonMain/kotlin/Backbone.kt | 53 +--- backbone/src/commonMain/kotlin/Ref.kt | 66 ++--- backbone/src/commonMain/kotlin/RefCache.kt | 39 --- .../commonTest/kotlin/BackboneCacheTest.kt | 51 ++-- 5 files changed, 252 insertions(+), 225 deletions(-) delete mode 100644 backbone/src/commonMain/kotlin/RefCache.kt diff --git a/backbone/README.md b/backbone/README.md index bf99e21..278a59c 100644 --- a/backbone/README.md +++ b/backbone/README.md @@ -1,25 +1,102 @@ # Module backbone +Lightweight pattern to integrate reactive caching in all layers of a multiplatform application. + +## The problem + The core of modern applications is often sending data between machines. However, most applications do this inefficiently: -- data is sent recursively, sending objects the client likely already knows, -- caching is near impossible, because caching is not taken into account at the design stage, -- as a result, it is necessary to adopt a pessimistic approach to development, where each function will typically query the newest value of an object, modify edit, and push it the persistence layer. -- integration with reactive UI frameworks is also difficult: an object has no way to tell the framework when a new value is available. +- **Data is sent recursively**, sending large objects which contain sub-objects the client likely already knows ( + example: sending a list of comments under a post, which all repeat the author information as a small number of users + are talking together). + +- **Caching is difficult**, because it is not taken into account at the design stage; the app doesn't know which methods + may invalidate the values. + As a consequence, it is common to create pessimistic codebases, in which most functions query the newest value of the + object, in case something else has changed it, wasting database performance. + Another common approach is to make all object modifications server-side in the service layer, which makes server + replication impossible. + +- **Sharing code between the client and server is difficult**: what should the server do? What should the client do? It + is common to duplicate the service layer between platforms. + +- **Integration with reactive UI frameworks is difficult**: an object has no way to tell the framework when a new value + is available. Pedestal Backbone is an opinionated way of building APIs that attempts to solve these problems. -It builds upon Pedestal State and Pedestal Cache to integrate reactive caching in all levels of the API. +It builds upon Pedestal Progress, Pedestal State and Pedestal Cache to integrate reactive caching in all levels of an +application. +These projects are kept as independent building blocks to allow you to use them even if you do not adopt the Backbone +pattern. + +## Existing patterns + +Traditionally, code reuse has been based on inheritance. +However, distributed applications (such as client–server applications) must share data between an I/O boundary, making +inheritance impossible. + +To integrate correctly with reactive frameworks, and to limit bugs in concurrent environments, it is recommended to work +with immutable data—contradicting the OOP model in which objects mutate themselves. + +The functional programming paradigm proposes a solution: represent the values as simple immutable record types, and have +an accompanying class which manages mutations by returning new values (often called the Service layer). This approach +has been growing in popularity, for example in Hexagonal Architecture or Clean Architecture. + +However, having a class manage mutation has limitations: + +- code sharing between client and server is difficult, as the class should do different things, +- concrete values tend to be passed throughout the codebase, which makes it hard to know which value is the most recent, + and thus makes integration with reactive frameworks difficult, +- working with heterogeneous implementations requires using a different pattern (e.g. a calendar app in which some + events come from an in-house implementation and others come from third-party services using a different protocol). + +## The Backbone pattern + +The Backbone pattern represents domain objects as a triple: the domain object itself, a reference, and a manager. + +The domain object itself is represented as an immutable final class (most often a regular `class`, a `data class` or +a `value class`, but `enum class` and `sealed class` are also possible). The domain object is responsible for data +validation (via its constructor·s) and computed properties. + +Instead of directly using the domain object, most of the code should pass the reference between functions instead. When +code needs to access the value, it can request it via the reference's [request][opensavvy.backbone.Ref.request] +function. Values are cached by the manager, ensuring two subsequent reads with no write are cheap. The rest of the +codebase is thus free of wondering whether the value has been modified or not. + +The manager is responsible for implementing the different actions, and managing the cache and its invalidation. The rest +of the codebase thus interacts with the manager to access references to new objects, and then passes those references +around, accessing their value when necessary. + +Thanks to the cache, all functions except [request][opensavvy.backbone.Ref.request] should deal with references (either +as a parameter, or a return value). This ensures modifications do not depend on the external state and do not need a +prior read operation, and ensures all read operations are appropriately by the cache. Using this pattern, all read +operations are non-suspending and return a [ProgressiveFlow][opensavvy.state.coroutines.ProgressiveFlow]. All write +operations suspend and return a non-flow value (most often `Unit`, but they could also return a list of affected +references, or any other value). + +Note that we purposefully do not call the manager a "Service": depending on your use case, you may use this pattern to +implement your Repository pattern, or any other class that manages changes to another class. -## Concept +### In multiplatform applications -The main concept is to separate all domain objects into three objects: +In a multiplatform application, the domain object is placed in the common module. The reference and manager are +interfaces declared in the common module, which respectively implement [Ref][opensavvy.backbone.Ref] +and [Backbone][opensavvy.backbone.Backbone]. -- The domain object itself, which should be immutable (e.g. a regular `data class` or `value class`), -- A reference provides a typesafe way to address the object throughout its lifetime and mutations. Accesses to the object through the reference are cached to avoid unnecessary request (implements the [Ref][opensavvy.backbone.Ref] interface), -- A Backbone provides methods operating on references to communicate with the external resource (implements the [Backbone][opensavvy.backbone.Backbone] interface). Traditionally, this would be a service or a DAO/Repository. Backbone is not tainted by any role, allowing it to be used in any layer of the application. +Each implementation implements the reference and the manager (e.g. a client-side implementation which calls the API, and +a server-side implementation which calls the database). Each implementation benefits from the benefits of the cache, and +exposes the same API, making development easy (the service layer has the same signature client-side and server-side). -This architecture creates powerful patterns for application design, especially when coupled with reactive frameworks (such as React or Compose) or with the Kotlin Multiplatform technology. +### Testing with fakes and spies + +Because the manager is an interface and not a concrete class, it is easy to create an in-memory implementation for use +in tests. It is also easy to create an implementation that logs every call while delegating to another implementation. + +These [test doubles](https://martinfowler.com/bliki/TestDouble.html) are respectively called fakes and spies. Together, +they offer the same power as mocks, and are much easier to use and debug as they are regular code. + +## Example ### 1. Common module @@ -38,27 +115,29 @@ data class Score( ) { // We can now declare our service. - // I personally prefer declaring the service as a nested interface + // We personally prefer declaring the service as a nested interface // (so it as addressed as Score.Service) instead of its own top-level // interface (e.g. ScoreService), but this is entirely up to you. - // The service implements the Backbone interface, which adds a - // 'directRequest' method to dereference a value, as well as the - // cache management. - interface Service : Backbone { + // The service implements the Backbone interface. + interface Service : Backbone { + + // The Backbone interface adds a 'request' method + // to access the value behind a reference. // Methods should: // - only accept/return references, and not actual values // (this is necessary to ensure the cache catches all requests, - // the 'directRequest' method should be the only one which returns + // the 'request' method should be the only one which returns // a real object). // - return an Outcome or ProgressiveOutcome instance for error management // (see the documentation of Pedestal State). - suspend fun increment(score: Ref, amount: Int = 1): Outcome + // ProgressiveFlow is used for cacheable read operations. + suspend fun increment(score: Ref, amount: Int = 1): Outcome // Using the same rules, we see that search operations // return references instead of returning the value directly. - suspend fun listMine(): Outcome> + fun listMine(): ProgressiveFlow } // We can now declare references to a specific score. @@ -69,41 +148,50 @@ data class Score( // The reference should store enough information for the service // implementations to find which object is referenced. // This could mean anything you want. - - // The reference doesn't necessarily have to be in the common module. - // For example, you may want to have a different reference for the HTTP API - // and the Repository layer. - data class Ref( - // For this example, the ID is a simple int, but it could be any type. - // It could even be multiple fields, Backbone places no restrictions - // on the way values are identified… - val id: Int, - // …except that each reference should know which Backbone - // implementation is responsible for the operations executed on it. - override val backbone: Service, - ) : BackboneRef { + interface Ref : BackboneRef { // Because we will always use references in our entire application, // it can be convenient to expose shorthands to execute operations. - suspend fun increment(amount: Int = 1) = backbone.increment(this, amount) + suspend fun increment(amount: Int = 1): Outcome + } + + // Following the best practice from Pedestal State, + // we expose a sealed class of the various failure cases. + // This avoids hardcoding the textual representation of errors, + // making internationalization and error recovery. + sealed class Failures : opensavvy.state.failure.Failure { + // Declare your failure cases as data classes or objects } } ``` -All that is left to do is to implement the `Score.Service` interface in our various modules (HTTP API, repository layer…). -Notice that the amount of code written is very similar to the amount of code necessary for a traditional approach, however, +All that is left to do is to implement the `Score.Service` interface in our various modules (HTTP API, repository +layer…). +Notice how the amount of code written is very similar to the amount of code necessary for a traditional approach, +however, as we will see in the next sections, this pattern is much more powerful. ### 2. Implementations and testing -Here is a sample client-side implementation of the interface, using a Ktor-inspired HTTP client: +Here is a sample client-side implementation of the interface, using a Ktor-inspired HTTP client. This example uses the +optional `arrow-state` dependency to profit from Arrow's typed error DSL. ```kotlin -import kotlin.coroutines.CoroutineContext +import sun.jvm.hotspot.oops.CellTypeState.refimport java.sql.Refimport kotlin.coroutines.CoroutineScope + +class ClientScoreRef( + internal val id: String, + override val backbone: ClientScores, +) : Score.Ref { + + // Provide convenience functions to act on this reference. + override fun request() = backbone.request(this) + override fun increment(amount: Int = 1) = backbone.increment(this, amount) +} class ClientScores( private val client: HttpClient, - coroutineContext: CoroutineContext, + cacheScope: CoroutineScope, ) : Score.Service { // First, we must select our caching strategy. @@ -111,20 +199,26 @@ class ClientScores( // Here, we cache the values in RAM for a maximum of 15 minutes // (after which the values are either re-requested if they are // still needed, or forgotten otherwise). - override val cache: RefCache = defaultRefCache() - .cachedInMemory(coroutineContext) - .expireAfter(15.minutes, coroutineContext) - - override suspend fun increment(score: Score.Ref, amount: Int) = out { - // This is an imaginary HTTP client - // Of course, Pedestal does not care what you use to make your request. - // This could be anything you like. + private val cache = cache { + out { + // This is an imaginary HTTP client. + // Of course, this could be any library you like. + client.get("http://localhost:8080/${ref.id}") + } + } + .cachedInMemory(cacheScope.job) + .expireAfter(15.minutes, cacheScope) + + override fun request(ref: ClientScoreRef) = cache[ref] + + override suspend fun increment(score: ClientScoreRef, amount: Int) = out { client.post("http://localhost:8080/${score.id}?amount=$amount") // We know the score was just modified, we thus clean the cache. // It will decide by itself whether it's better to re-query the value // or just to delete it. - // It will also automatically notify all UI components. + // It will also automatically notify all UI components that + // display this score. cache.expire(score) // If we were in a situation where the server returned the updated @@ -137,47 +231,41 @@ class ClientScores( // operation would have been necessary to call this function. } - override suspend fun listMine() = out { + override fun listMine() = out { client.get>("http://localhost:8080/myScores") // convert the IDs to references linked to this // backbone implementation - .map { Score.Ref(it, this@ClientScores) } + .map { ClientScoreRef(it, this@ClientScores) } // Here, no value is modified, so we do not need to inform the // cache of anything. // If this endpoint returned full values instead of just their IDs, - // we could update them in the cache to avoid future dereference + // we could call update here to avoid future dereference // requests. - } - - // As mentioned previously, we must implement a way to access the value - // in case of cache miss: - override suspend fun directRequest(ref: Ref): out { - // Due to a limitation of our API, we must cast the reference - ensureValid(ref is Score.Ref) { "Found an unexpected reference type: $ref" } - - client.get("http://localhost:8080/${ref.id}") - - // There is no need to notify the cache here, because this function - // is called by the cache in case of a cache miss, it will update - // itself using the returned value. - } + }.withProgress() + .let { flowOf(it) } // Satisfy the super interface without caching. + // If we later decide that caching would be beneficial, it is easy to add. } ``` -This example was a bit simplified as it doesn't use DTOs, but I believe it does show that the Backbone library creates very little code overhead compared to a traditional approach: essentially the cache configuration, as well as notifying the cache of the side effects of the various methods. +This example was a bit simplified as it doesn't use DTOs, but we believe it does show that the Backbone library creates +very little code overhead compared to a traditional approach: essentially the cache configuration, as well as notifying +the cache of the side effects of the various methods. -Because we configured the cache to expire values automatically after some time, forgetting to expire the cache in some function is not a major issue. +Because we configured the cache to expire values automatically after some time, forgetting to expire the cache in some +function is not a major issue. It may cause users to see outdated values until the expiration timeout ends. Simple unit tests will catch these mistakes. In the above example, we used an HTTP client, but it could have been anything. -In practice, I like to take advantage of Kotlin Multiplatform by: +In practice, we like to take advantage of Kotlin Multiplatform by: - declaring the objects in a common module, -- creating an in-memory fake implementation that I can write quickly in parallel of writing unit tests for the interface, -- creating the server-side implementation, that queries the database, using the tests written for the fake implementation to validate it, +- creating an in-memory fake implementation that can be written quickly in parallel of writing unit tests for the + interface, +- creating the server-side implementation, that queries the database, using the tests written for the fake + implementation to validate it, - creating the client-side implementation, calling a fake server which responds using the fake implementation, again using the tests written for the fake to validate it. This approach allows to: @@ -186,26 +274,39 @@ This approach allows to: - work on the UI using the fake implementation before the client and server implementations are written, - work on the API using a fake as a repository before the persistence layer is implemented, - write unit tests once for the fake, the client and the server, since they all conform to the same interface, -- unit test each implementation by always using the fake to replace other services used, -- because the Pedestal Cache library is used on all platforms, it is trivial to configure a server-side cache that caches over the database, and a client-side cache that caches over the HTTP requests, dramatically reducing network traffic and average latency. - -Some schools of thought prefer mocking to faking, but you will still see that having a single interface client- and server-side helps with testing. +- test each implementation by using the fake to replace its dependencies, +- because the Pedestal Cache library is used on all platforms, it is trivial to configure a server-side cache that + caches over the database, and a client-side cache that caches over the HTTP requests, dramatically reducing network + traffic and average latency. ### 3. Reactive UIs When writing reactive UIs, we often want to separate concerns over multiple components. -Ideally, each component would just know the ID of the object it needs to display, and would manage requesting new values completely by itself… in practice, however, doing this would mean each component in a single page would start their own dereference request! +Ideally, each component would just know the ID of the object it needs to display, and would manage requesting new values +completely by itself… in practice, however, doing this would mean each component in a single page would start their own +dereference request. -Thanks to the aggressive caching in place with Backbone, this pattern has very little downsides. -Here is an example with a Compose-inspired syntax (but since all the magic is implemented with [Flow][kotlinx.coroutines.flow.Flow], it can easily be put in place with any reactive framework): +Thanks to the aggressive caching Backbone encourages, only a single request will be started for the entire application. +Here is an example with a Compose-inspired syntax. The reactivity is implemented +using [Flow][kotlinx.coroutines.flow.Flow], and therefore works with any reactive framework). ```kotlin @Composable fun ListScores(scores: Score.Service) { val mine by remember(scores) { scores.listMine().collectAsState(null) } - for (score in mine ?: emptyList()) { - Score(score) + mine.onSuccess { + for (score in it ?: emptyList()) { + Score(score) + } + } + + mine.onFailure { + Error("Couldn't access your scores: $it") + } + + mine.onIncomplete { + ListSkeleton() } } @@ -218,7 +319,7 @@ fun Score(score: Score.Ref) { } value.onFailure { - Text("Couldn't access the score: $it", color = Color.Red) + Error("Couldn't access the score: $it") } value.onLoading { @@ -234,5 +335,10 @@ fun Score(score: Score.Ref) { The important things to notice in this snippet are: - The UI subscribes to a value using `Score.Ref.request()`, -- `Score.Ref.request()` can be called multiple times per screen, or in multiple screens, and it will only start a single request, -- When calling `score.increase()`, we do not have to do anything to update the various UI components in the entirety of our application. They will automatically reflect the newest value, even if they are in a different screen. +- `Score.Ref.request()` can be called multiple times per screen, or in multiple screens, and it will only start a single + request, +- When calling `score.increase()`, we do not have to do anything to update the various UI components in the entirety of + our application. They will automatically reflect the newest value, even if they are in a different screen, +- We + use [onSuccess][opensavvy.state.progressive.onSuccess], [onFailure][opensavvy.state.progressive.onFailure], [onLoading][opensavvy.state.progressive.onLoading] + and [onIncomplete][opensavvy.state.progressive.onIncomplete] to manage the different possible results. diff --git a/backbone/src/commonMain/kotlin/Backbone.kt b/backbone/src/commonMain/kotlin/Backbone.kt index 180f690..d53520e 100644 --- a/backbone/src/commonMain/kotlin/Backbone.kt +++ b/backbone/src/commonMain/kotlin/Backbone.kt @@ -1,56 +1,25 @@ package opensavvy.backbone +import opensavvy.state.coroutines.ProgressiveFlow import opensavvy.state.failure.Failure -import opensavvy.state.outcome.Outcome /** - * A common interface for API endpoints. + * The parent interface for an implementation of the Backbone pattern. * - * A [Backbone] represents all endpoints for a specific data type. - * Each method represents a different endpoint to implement actions on the relevant objects (list all objects, - * create a new one, etc). + * For more information on the Backbone pattern, please read the module-level documentation. + * + * @param O The object this backbone manages. + * @param F Failures that may be returned when calling [request]. + * @param R The reference responsible for the object [O]. */ -interface Backbone { - - /** - * A cache used to store previous results of [directRequest]. - * - * As a convenience, you can use [request] to launch a request through the cache. - */ - val cache: RefCache +interface Backbone, F : Failure, O> { /** * Fetches the value associated with a [ref] in an external media (e.g. a remote server, a database). * - * This function completely bypasses the [cache]: a request will be sent everytime it is called. - * To avoid sending unnecessary requests, use [request] instead. - */ - suspend fun directRequest(ref: Ref): Outcome - - /** - * Fetches the value associated with all [refs] in an external media (e.g. a remote server, a database). - * - * This function completely bypasses the [cache]: a request will be sent everytime this is called. - * To avoid sending unnecessary requests, use [request] instead. - * - * The returned flow is **short-lived**: it is closed after the request finishes. - * The updates regarding the various references are not ordered between each other. - * - * To fetch a single value, see [directRequest]. - * - * The default implementation simply calls [directRequest] sequentially. + * This function may return cached results. */ - fun batchRequests(refs: Set>): Map, suspend () -> Outcome> = refs - .map { it to suspend { directRequest(it) } } - .associate { it } + fun request(ref: R): ProgressiveFlow - companion object { - /** - * Fetches the value associated with a [ref] in an external media (e.g. a remote server, a database). - * - * This function takes the [cache] into account, it will return a previous value if there is one stored. - * To force sending a request, use [directRequest] instead. - */ - fun Backbone.request(ref: Ref) = cache[ref] - } + companion object } diff --git a/backbone/src/commonMain/kotlin/Ref.kt b/backbone/src/commonMain/kotlin/Ref.kt index 70f6d54..b4efbbc 100644 --- a/backbone/src/commonMain/kotlin/Ref.kt +++ b/backbone/src/commonMain/kotlin/Ref.kt @@ -1,8 +1,6 @@ package opensavvy.backbone -import opensavvy.backbone.Backbone.Companion.request -import opensavvy.backbone.Ref.Companion.directRequest -import opensavvy.backbone.Ref.Companion.request +import opensavvy.state.coroutines.ProgressiveFlow import opensavvy.state.coroutines.now import opensavvy.state.failure.Failure @@ -10,56 +8,38 @@ import opensavvy.state.failure.Failure * A reference to a specific [object][O]. * * A reference is a small object that allows to pass around an object from an API without querying it. + * A reference should always be immutable. + * Each reference has a matching [Backbone] object responsible for managing it. * * [Ref] implementation should ensure that their [equals] and [hashCode] functions are correct. - * The [backbone] field for a particular reference should always return the same [Backbone] object. * - * To access the value behind a reference, use [directRequest] or [request]. + * To access the value behind a reference, use [request]. + * + * ### Note for implementors + * + * When implementing this interface, it is common to provide functions to all mutating methods from the matching + * [Backbone] as wrappers to it. This makes using the reference easier. * * @param O The object this reference refers to. + * @param F Failures that may be returned when calling [request]. */ interface Ref { /** - * The [Backbone] responsible for this reference. + * Requests the referenced data, returning a value from the cache if one is stored. * - * This property should always return the same [Backbone] instance for a given [Ref]. + * It is common to implement this method by calling [Backbone.request]. */ - val backbone: Backbone - - companion object { - /** - * Requests the referenced data (without taking into account the cache). - * - * This is a convenience method around [Backbone.directRequest]. - */ - suspend fun Ref.directRequest() = backbone.directRequest(this) - - /** - * Requests the referenced data, returning a value from the cache if one is stored. - * - * This is a convenience method around [Backbone.request]. - */ - fun Ref.request() = backbone.request(this) - - /** - * Requests the referenced data, returning the first value returned by the cache. - * - * This is a shorthand to `ref.request().firstValue()`. - * - * This function returns a single value and not a subscription, it is not recommended to use it when being - * notified of new values is important (e.g. in a UI). - * This function is intended for non-reactive environments (e.g. server requests, tests…). - */ - suspend fun Ref.now() = request().now() + fun request(): ProgressiveFlow - /** - * Forces the cache to forget anything it might remember about this reference. - * - * The next time [request] is called, a new request will be started. - */ - suspend fun Ref.expire() { - backbone.cache.expire(this) - } - } + companion object } + +/** + * Requests the referenced data, returning the first value returned by the cache. + * + * This function returns a single value and not a subscription, it is not recommended to use it when being notified of + * new values is important (e.g. in a UI). This function is intended for non-reactive environments + * (e.g. server requests, tests…). + */ +suspend fun Ref.now() = request().now() diff --git a/backbone/src/commonMain/kotlin/RefCache.kt b/backbone/src/commonMain/kotlin/RefCache.kt deleted file mode 100644 index c2488a3..0000000 --- a/backbone/src/commonMain/kotlin/RefCache.kt +++ /dev/null @@ -1,39 +0,0 @@ -package opensavvy.backbone - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import opensavvy.backbone.Ref.Companion.directRequest -import opensavvy.cache.BatchingCacheAdapter -import opensavvy.cache.Cache -import opensavvy.cache.CacheAdapter -import opensavvy.state.coroutines.captureProgress -import opensavvy.state.failure.Failure - -typealias RefCache = Cache, F, O> - -fun defaultRefCache() = CacheAdapter, F, O> { it.directRequest() } - -fun batchingRefCache(scope: CoroutineScope, workers: Int = 1) = - BatchingCacheAdapter, F, O>(scope, workers) { requests -> - val backbones = requests - .groupBy { it.backbone } - - flow { - // This implementation is sequential - // Because the actual network request is batched, we assume that the sequential reading of the results is not - // an issue. - // If it ever becomes one, this function should be rewritten to wait for the results in parallel. - - for ((backbone, refs) in backbones) { - val results = backbone.batchRequests(refs.toHashSet()) - for ((ref, result) in results) { - emitAll( - captureProgress { result() } - .map { ref to it } - ) - } - } - } - } diff --git a/backbone/src/commonTest/kotlin/BackboneCacheTest.kt b/backbone/src/commonTest/kotlin/BackboneCacheTest.kt index 57e2548..8741b6e 100644 --- a/backbone/src/commonTest/kotlin/BackboneCacheTest.kt +++ b/backbone/src/commonTest/kotlin/BackboneCacheTest.kt @@ -4,10 +4,8 @@ import arrow.core.raise.ensure import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.test.runTest -import opensavvy.backbone.Ref.Companion.expire -import opensavvy.backbone.Ref.Companion.request +import opensavvy.cache.cache import opensavvy.state.arrow.out -import opensavvy.state.coroutines.now import opensavvy.state.failure.CustomFailure import opensavvy.state.failure.Failure import opensavvy.state.outcome.valueOrNull @@ -17,17 +15,23 @@ import kotlin.test.assertEquals @OptIn(ExperimentalCoroutinesApi::class) class BackboneCacheTest { - data class BasicRef(val id: String, override val backbone: Backbone) : Ref + data class BasicRef(val id: String, val backbone: Bone) : Ref { + + override fun request() = backbone.request(this) + } // Id("12") -> 12 - private class Bone(override val cache: RefCache) : Backbone { - override suspend fun directRequest(ref: Ref) = out { - ensure(ref is BasicRef) { Invalid } - val int = ref.id.toIntOrNull() - ensure(int != null) { Invalid } - int + class Bone : Backbone { + val cache = cache { + out { + val int = it.id.toIntOrNull() + ensure(int != null) { Invalid } + int + } } + override fun request(ref: BasicRef) = cache[ref] + fun of(int: Int) = BasicRef(int.toString(), this) object Invalid : CustomFailure(Invalid, "Invalid"), Failure.Key @@ -35,31 +39,38 @@ class BackboneCacheTest { @Test fun default() = runTest { - val bone = Bone(defaultRefCache()) + val bone = Bone() val id5 = bone.of(5) val id2 = bone.of(2) - assertEquals(5, id5.request().now().valueOrNull) - assertEquals(2, id2.request().now().valueOrNull) + assertEquals(5, id5.now().valueOrNull) + assertEquals(2, id2.now().valueOrNull) - id2.expire() - assertEquals(2, id2.request().now().valueOrNull) + bone.cache.expire(id2) + assertEquals(2, id2.now().valueOrNull) } @Test fun batching() = runTest { val job = Job() - val bone = Bone(batchingRefCache(backgroundScope)) + val bone = Bone() val id5 = bone.of(5) val id2 = bone.of(2) - assertEquals(5, id5.request().now().valueOrNull) - assertEquals(2, id2.request().now().valueOrNull) + assertEquals(5, id5.now().valueOrNull) + assertEquals(2, id2.now().valueOrNull) - id2.expire() - assertEquals(2, id2.request().now().valueOrNull) + bone.cache.expire(id2) + assertEquals(2, id2.now().valueOrNull) job.cancel() } + + @Test + fun companions() { + println(Ref) + println(Backbone) + println() + } } -- 2.51.2