├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── LICENSE.txt ├── README.md ├── build.gradle.kts ├── compose ├── build.gradle.kts ├── gradle.properties └── src │ ├── commonMain │ └── kotlin │ │ └── io │ │ └── github │ │ └── kevincianfarini │ │ └── monarch │ │ └── state.kt │ └── commonTest │ └── kotlin │ └── io │ └── github │ └── kevincianfarini │ └── monarch │ └── StateOfTest.kt ├── core ├── build.gradle.kts ├── gradle.properties └── src │ ├── commonMain │ └── kotlin │ │ └── io │ │ └── github │ │ └── kevincianfarini │ │ └── monarch │ │ ├── FeatureFlag.kt │ │ ├── FeatureFlagDataStore.kt │ │ ├── FeatureFlagManager.kt │ │ ├── FeatureFlagManagerMixin.kt │ │ ├── InMemoryFeatureFlagDataStoreOverride.kt │ │ ├── MixinFeatureFlagManager.kt │ │ ├── ObservableFeatureFlagDataStore.kt │ │ ├── ObservableFeatureFlagManager.kt │ │ ├── ObservableFeatureFlagManagerMixin.kt │ │ └── ObservableMixinFeatureFlagManager.kt │ └── commonTest │ └── kotlin │ └── io │ └── github │ └── kevincianfarini │ └── monarch │ ├── InMemoryFeatureFlagDataStoreOverrideTest.kt │ ├── MixinFeatureFlagManagerTest.kt │ ├── ObservableMixinFeatureFlagManagerTest.kt │ └── flags.kt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── integrations ├── environment-variable │ ├── build.gradle.kts │ ├── gradle.properties │ └── src │ │ ├── commonMain │ │ └── kotlin │ │ │ └── io │ │ │ └── github │ │ │ └── kevincianfarini │ │ │ └── monarch │ │ │ └── environment │ │ │ ├── EnvironmentVariableFeatureFlagDataStore.kt │ │ │ └── environment.kt │ │ ├── commonTest │ │ └── kotlin │ │ │ └── io │ │ │ └── github │ │ │ └── kevincianfarini │ │ │ └── monarch │ │ │ └── environment │ │ │ └── EnvironmentVariableFeatureFlagDataStoreTest.kt │ │ ├── jsMain │ │ └── kotlin │ │ │ └── io │ │ │ └── github │ │ │ └── kevincianfarini │ │ │ └── monarch │ │ │ └── environment │ │ │ └── environment.js.kt │ │ ├── jvmMain │ │ └── kotlin │ │ │ └── io │ │ │ └── github │ │ │ └── kevincianfarini │ │ │ └── monarch │ │ │ └── environment │ │ │ └── environment.jvm.kt │ │ └── nativeMain │ │ └── kotlin │ │ └── io │ │ └── github │ │ └── kevincianfarini │ │ └── monarch │ │ └── environment │ │ └── environment.native.kt └── launch-darkly │ ├── build.gradle.kts │ ├── gradle.properties │ └── src │ ├── androidMain │ └── kotlin │ │ └── io │ │ └── github │ │ └── kevincianfarini │ │ └── monarch │ │ └── launchdarkly │ │ └── LaunchDarklyFeatureFlagDataStore.kt │ ├── androidUnitTest │ └── kotlin │ │ └── io │ │ └── github │ │ └── kevincianfarini │ │ └── monarch │ │ └── launchdarkly │ │ └── LaunchDarklyFeatureFlagDataStoreTest.android.kt │ ├── appleMain │ └── kotlin │ │ └── io │ │ └── github │ │ └── kevincianfarini │ │ └── monarch │ │ └── launchdarkly │ │ ├── LaunchDarklyClientShim.kt │ │ └── LaunchDarklyFeatureFlagDataStore.kt │ ├── appleTest │ └── kotlin │ │ └── io │ │ └── github │ │ └── kevincianfarini │ │ └── monarch │ │ └── launchdarkly │ │ └── LaunchDarklyFeatureFlagDataStoreTest.apple.kt │ └── commonTest │ └── kotlin │ └── io │ └── github │ └── kevincianfarini │ └── monarch │ └── launchdarkly │ ├── LaunchDarklyFeatureFlagDataStoreTest.kt │ └── MutableLDClientInterface.kt ├── kotlin-js-store └── yarn.lock ├── mixins ├── README.md └── kotlinx-serialization-json │ ├── build.gradle.kts │ ├── gradle.properties │ └── src │ ├── commonMain │ └── kotlin │ │ └── io │ │ └── github │ │ └── kevincianfarini │ │ └── monarch │ │ └── mixins │ │ ├── JsonFeatureFlag.kt │ │ ├── JsonFeatureFlagManagerMixin.kt │ │ └── ObservableJsonFeatureFlagManagerMixin.kt │ └── commonTest │ └── kotlin │ └── io │ └── github │ └── kevincianfarini │ └── monarch │ └── mixins │ ├── JsonFeatureFlagManagerMixinTest.kt │ └── JsonFeatureFlagTest.kt ├── renovate.json ├── settings.gradle.kts └── test ├── build.gradle.kts ├── gradle.properties └── src ├── commonMain └── kotlin │ └── io │ └── github │ └── kevincianfarini │ └── monarch │ ├── InMemoryFeatureFlagDataStore.kt │ └── InMemoryFeatureFlagManager.kt └── commonTest └── kotlin └── io └── github └── kevincianfarini └── monarch └── FakeFeatureFlagManagerTest.kt /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: 6 | - trunk 7 | 8 | env: 9 | GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -Dorg.gradle.daemon=false -Dkotlin.incremental=false" 10 | 11 | jobs: 12 | publish: 13 | runs-on: macos-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-java@v4.5.0 18 | with: 19 | distribution: 'zulu' 20 | java-version: 17 21 | 22 | - name: Build and publish artifacts 23 | env: 24 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.ORG_GRADLE_PROJECT_MAVENCENTRALUSERNAME }} 25 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.ORG_GRADLE_PROJECT_MAVENCENTRALPASSWORD }} 26 | ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.ORG_GRADLE_PROJECT_SIGNINGINMEMORYKEY }} 27 | run: ./gradlew dokkaHtmlMultiModule publish 28 | 29 | - name: Deploy docs to website 30 | uses: JamesIves/github-pages-deploy-action@releases/v3 31 | with: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | BRANCH: site 34 | FOLDER: build/dokka/htmlMultiModule 35 | TARGET_FOLDER: docs/0.x/ 36 | CLEAN: true -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Unit Test 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - '*.md' 7 | 8 | jobs: 9 | build: 10 | strategy: 11 | matrix: 12 | os: [ macOS-latest, ubuntu-latest, windows-latest ] 13 | job: [allTests] 14 | 15 | runs-on: ${{matrix.os}} 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-java@v4.5.0 20 | with: 21 | distribution: 'zulu' 22 | java-version: 17 23 | 24 | - run: ./gradlew -p . ${{matrix.job}} 25 | 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .gradle 3 | **/build/ 4 | local.properties 5 | .kotlin -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Monarch 🦋 2 | 3 | Monarch is a small, flexible, type safe, and multiplatform abstraction for feature flags. 4 | 5 | > In chaos theory, the butterfly effect is the sensitive dependence on initial conditions in which a small change in one state of a deterministic nonlinear system can result in large differences in a later state. 6 | > 7 | > — [Wikipedia](https://en.wikipedia.org/wiki/Butterfly_effect) 8 | 9 | ## Download 10 | 11 | ```toml 12 | [versions] 13 | monarch = "0.2.2" 14 | 15 | [libraries] 16 | monarch-compose = { module = "io.github.kevincianfarini.monarch:compose", version.ref = "monarch" } 17 | monarch-core = { module = "io.github.kevincianfarini.monarch:core", version.ref = "monarch" } 18 | monarch-integration-environment = { module = "io.github.kevincianfarini.monarch:environment-integration", version.ref = "monarch" } 19 | monarch-integration-launchdarkly = { module = "io.github.kevincianfarini.monarch:launch-darkly-integration", version.ref = "monarch" } 20 | monarch-mixin-kotlinxjson = { module = "io.github.kevincianfarini.monarch:kotlinx-serialization-mixin", version.ref = "monarch" } 21 | monarch-test = { module = "io.github.kevincianfarini.monarch:test", version.ref = "monarch" } 22 | ``` 23 | 24 | ## Usage 25 | 26 | ### Defining flags 27 | 28 | Monarch provides compile time safety for the feature flags you define and consume. 29 | Flag keys are bound to a type and a default value. 30 | 31 | ```kt 32 | object FancyFeatureEnabled : BooleanFeatureFlag( 33 | key = "fancy_feature_enabled", 34 | default = false, 35 | ) 36 | ``` 37 | 38 | The `FancyFeatureEnabled` flag can later be referenced in code and checked by the compiler. 39 | 40 | Monarch provides several feature flag types in the 'core' artifact. 41 | 42 | * `BooleanFeatureFlag` 43 | * `LongFeatureFlag` 44 | * `DoubleFeatureFlag` 45 | * `StringFeatureFlag` 46 | 47 | ### Obtaining values 48 | 49 | Values can be obtained from a `FeatureFlagManager` for a given feature flag. 50 | 51 | ```kt 52 | fun showFeature(manager: FeatureFlagManager) { 53 | if (manager.currentValueOf(FancyFeatureEnabled)) { 54 | showFancyFeature() 55 | } else { 56 | showBoringFeature() 57 | } 58 | } 59 | ``` 60 | 61 | ### Observing value changes as a Flow 62 | 63 | Some third party SDKs, like LaunchDarkly, provide callbacks for flags when their values change. 64 | Monarch provides an additional abstraction for this, called `ObservableFeatureFlagManager`, 65 | which exposes these changes as a `Flow`. 66 | 67 | ```kt 68 | suspend fun showFeature(manager: ObservableFeatureFlagManager) { 69 | manager.valuesOf(FancyFeatureEnabled).collect { enabled -> 70 | if (enabled) { 71 | showFancyFeature() 72 | } else { 73 | showBoringFeature() 74 | } 75 | } 76 | } 77 | ``` 78 | 79 | ### Observing value changes as a Compose State 80 | 81 | Monarch offers a companion artifact that makes observing flag values as Compose State simple. 82 | 83 | ```kotlin 84 | @Composable 85 | fun ShowFeature(manager: ObservableFeatureFlagManager) { 86 | val enabled by manager.stateOf(FancyFeatureEnabled) 87 | if (enabled) { 88 | FancyFeature() 89 | } else { 90 | BoringFeature() 91 | } 92 | } 93 | ``` 94 | 95 | ### Testing 96 | 97 | Monarch provides an out-of-the-box test implementation of an `ObservableFeatureFlagManager` called `InMemoryFeatureFlagManager`. 98 | It can be mutated under test to exercise specific branches of code dictated by your flags. 99 | 100 | ```kotlin 101 | @Test 102 | fun test_flag_changes() = runTest { 103 | val manager = InMemoryFeatureFlagManager() 104 | manager.valuesOf(FancyFeatureEnabled).test { 105 | assertFalse(awaitItem()) 106 | manager.setCurrentValueOf(FancyFeatureEnabled, true) 107 | assertTrue(awaitItem()) 108 | } 109 | } 110 | ``` 111 | 112 | ### Supplying a FeatureFlagManager 113 | 114 | Monarch's built-in implementations of `FeatureFlagManager` take lists of 115 | `FeatureFlagManagerMixin` and a `FeatureFlagDataStore`. 116 | 117 | A `FeatureFlagDataStore` is the entity closely tied to the underlying feature flagging SDK. 118 | It's unlikely you will implement this unless you're integrating with a feature flagging platform 119 | that Monarch doesn't currently support. 120 | 121 | Typical usage of `FeatureFlagManager` implementations expects that the `FeatureFlagDataStore`, 122 | all `FeatureFlagManagerMixin` instances, and the `FeatureFlagManager` itself will be provided 123 | as part of your dependency graph. Below is a sample with Dagger. 124 | 125 | ```kt 126 | @Module object FeatureFlaggingModule { 127 | 128 | @Provides 129 | fun providesDataStore(): FeatureFlagDataStore { /* omitted */ } 130 | 131 | @Provides @IntoSet 132 | fun providesJsonMixin( 133 | json: Json 134 | ): FeatureFlagManagerMixin = JsonFeatureFlagManagerMixin(json) 135 | 136 | @Provides 137 | fun providesManager( 138 | dataStore: FeatureFlagDataStore, 139 | mixins: Set, 140 | ): FeatureFlagManager = MixinFeatureFlagManager( 141 | store = dataStore, 142 | mixins = mixins.toList(), 143 | ) 144 | } 145 | ``` -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.gradle.api.tasks.testing.logging.TestLogEvent 2 | 3 | plugins { 4 | alias(libs.plugins.android.library) apply false 5 | alias(libs.plugins.dokka) 6 | alias(libs.plugins.kotlin.multiplatform) apply false 7 | alias(libs.plugins.kotlin.serialization) apply false 8 | alias(libs.plugins.publish) apply false 9 | } 10 | 11 | allprojects { 12 | repositories { 13 | mavenCentral() 14 | google() 15 | } 16 | } 17 | 18 | val jvmVersion: Provider = providers.gradleProperty("kotlin.jvm.target") 19 | 20 | subprojects { 21 | plugins.withType().configureEach { 22 | extensions.findByType()?.apply { 23 | jvmVersion.map { JavaVersion.toVersion(it) }.orNull?.let { 24 | compileOptions { 25 | sourceCompatibility = it 26 | targetCompatibility = it 27 | } 28 | } 29 | } 30 | } 31 | // Apply kotlinOptions.jvmTarget to subprojects 32 | tasks.withType().configureEach { 33 | kotlinOptions { 34 | if (jvmVersion.isPresent) jvmTarget = jvmVersion.get() 35 | } 36 | } 37 | 38 | tasks.withType { 39 | testLogging { 40 | exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL 41 | showStandardStreams = true 42 | showStackTraces = true 43 | events(TestLogEvent.FAILED, TestLogEvent.PASSED, TestLogEvent.SKIPPED) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /compose/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.dokka) 3 | alias(libs.plugins.kotlin.compose) 4 | alias(libs.plugins.kotlin.multiplatform) 5 | alias(libs.plugins.publish) 6 | } 7 | 8 | kotlin { 9 | 10 | explicitApi() 11 | 12 | iosArm64() 13 | iosSimulatorArm64() 14 | iosX64() 15 | jvm() 16 | linuxArm64() 17 | linuxX64() 18 | macosArm64() 19 | macosX64() 20 | mingwX64() 21 | tvosArm64() 22 | tvosSimulatorArm64() 23 | tvosX64() 24 | watchosArm32() 25 | watchosArm64() 26 | watchosSimulatorArm64() 27 | watchosX64() 28 | 29 | sourceSets { 30 | commonMain.dependencies { 31 | api(libs.compose.runtime) 32 | api(project(":core")) 33 | } 34 | commonTest.dependencies { 35 | implementation(libs.kotlin.test) 36 | implementation(libs.kotlinx.coroutines.test) 37 | implementation(libs.molecule) 38 | implementation(libs.turbine) 39 | implementation(project(":test")) 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /compose/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=compose 2 | POM_NAME=Monarch Jetpack Compose Integration 3 | POM_DESCRIPTION=Collect feature flags as Jetpack Compose State -------------------------------------------------------------------------------- /compose/src/commonMain/kotlin/io/github/kevincianfarini/monarch/state.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.State 5 | import androidx.compose.runtime.collectAsState 6 | import androidx.compose.runtime.remember 7 | 8 | /** 9 | * Acquire a [State] of [flag] that updates according to the underlying flag's value. The initial value of the returned 10 | * state will be the flag's current value. 11 | */ 12 | @Composable 13 | public fun ObservableFeatureFlagManager.stateOf(flag: FeatureFlag): State { 14 | return remember(this, flag) { valuesOf(flag) }.collectAsState(currentValueOf(flag)) 15 | } -------------------------------------------------------------------------------- /compose/src/commonTest/kotlin/io/github/kevincianfarini/monarch/StateOfTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch 2 | 3 | import app.cash.molecule.RecompositionMode 4 | import app.cash.molecule.moleculeFlow 5 | import app.cash.turbine.test 6 | import kotlinx.coroutines.test.runTest 7 | import kotlin.test.Test 8 | import kotlin.test.assertFalse 9 | import kotlin.test.assertTrue 10 | 11 | class StateOfTest { 12 | 13 | @Test 14 | fun stateOf_starts_with_current_value() = runTest { 15 | val manager = InMemoryFeatureFlagManager().apply { 16 | setCurrentValueOf(SomeBooleanFlag, true) 17 | } 18 | val flow = moleculeFlow(RecompositionMode.Immediate) { 19 | manager.stateOf(SomeBooleanFlag).value 20 | } 21 | flow.test { assertTrue(awaitItem()) } 22 | } 23 | 24 | @Test 25 | fun stateOf_updates_values() = runTest { 26 | val manager = InMemoryFeatureFlagManager() 27 | val flow = moleculeFlow(RecompositionMode.Immediate) { 28 | manager.stateOf(SomeBooleanFlag).value 29 | } 30 | flow.test { 31 | assertFalse(awaitItem()) 32 | manager.setCurrentValueOf(SomeBooleanFlag, true) 33 | assertTrue(awaitItem()) 34 | } 35 | } 36 | } 37 | 38 | private object SomeBooleanFlag : BooleanFeatureFlag("blah", false) -------------------------------------------------------------------------------- /core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.dokka) 3 | alias(libs.plugins.kotlin.multiplatform) 4 | alias(libs.plugins.publish) 5 | } 6 | 7 | kotlin { 8 | 9 | explicitApi() 10 | 11 | iosArm64() 12 | iosSimulatorArm64() 13 | iosX64() 14 | jvm() 15 | js { 16 | nodejs { 17 | testTask { 18 | useMocha { 19 | timeout = "5s" 20 | } 21 | } 22 | } 23 | } 24 | linuxArm64() 25 | linuxX64() 26 | macosArm64() 27 | macosX64() 28 | mingwX64() 29 | tvosArm64() 30 | tvosSimulatorArm64() 31 | tvosX64() 32 | watchosArm32() 33 | watchosArm64() 34 | watchosDeviceArm64() 35 | watchosSimulatorArm64() 36 | watchosX64() 37 | 38 | sourceSets { 39 | commonMain.dependencies { 40 | api(libs.kotlinx.coroutines.core) 41 | } 42 | commonTest.dependencies { 43 | implementation(libs.kotlin.test) 44 | implementation(libs.kotlinx.coroutines.test) 45 | implementation(libs.turbine) 46 | implementation(project(":test")) 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /core/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=core 2 | POM_NAME=Monarch core feature flagging 3 | POM_DESCRIPTION=Multiplatform core abstraction over feature flags -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/io/github/kevincianfarini/monarch/FeatureFlag.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch 2 | 3 | /** 4 | * A strongly typed representation of a feature flag that couples a [key], a [default] value, and a data type. 5 | * Feature flag instances can be used to acquire the value that flag represents from a [FeatureFlagManager] 6 | * or [ObservableFeatureFlagManager] implementation. 7 | */ 8 | public interface FeatureFlag { 9 | 10 | /** 11 | * The value that's used to extract this feature flag value in the underlying storage mechanism. 12 | */ 13 | public val key: String 14 | 15 | /** 16 | * The default option to be assumed when no backing value is present. 17 | */ 18 | public val default: OptionType 19 | } 20 | 21 | /** 22 | * A simple [Boolean] feature flag. 23 | */ 24 | public abstract class BooleanFeatureFlag( 25 | public override val key: String, 26 | public override val default: Boolean, 27 | ) : FeatureFlag 28 | 29 | /** 30 | * A simple [String] feature flag. 31 | */ 32 | public abstract class StringFeatureFlag( 33 | public override val key: String, 34 | public override val default: String, 35 | ) : FeatureFlag 36 | 37 | /** 38 | * A simple [Double] feature flag. 39 | */ 40 | public abstract class DoubleFeatureFlag( 41 | public override val key: String, 42 | public override val default: Double, 43 | ) : FeatureFlag 44 | 45 | /** 46 | * A simple [Long] feature flag. 47 | */ 48 | public abstract class LongFeatureFlag( 49 | public override val key: String, 50 | public override val default: Long, 51 | ) : FeatureFlag 52 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/io/github/kevincianfarini/monarch/FeatureFlagDataStore.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch 2 | 3 | /** 4 | * An underlying store of raw, primitive feature flag values. 5 | */ 6 | public interface FeatureFlagDataStore { 7 | 8 | /** 9 | * Get the [Boolean] value associated with [key] if present. Otherwise, return [default]. 10 | */ 11 | public fun getBoolean(key: String, default: Boolean): Boolean 12 | 13 | /** 14 | * Get the [String] value associated with [key] if present. Otherwise, return [default]. 15 | */ 16 | public fun getString(key: String, default: String): String 17 | 18 | /** 19 | * Get the [Double] value associated with [key] if present. Otherwise, return [default]. 20 | */ 21 | public fun getDouble(key: String, default: Double): Double 22 | 23 | /** 24 | * Get the [Long] value associated with [key] if present. Otherwise, return [default]. 25 | */ 26 | public fun getLong(key: String, default: Long): Long 27 | } -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/io/github/kevincianfarini/monarch/FeatureFlagManager.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch 2 | 3 | /** 4 | * Acquire values from [FeatureFlag] instances. 5 | */ 6 | public interface FeatureFlagManager { 7 | 8 | /** 9 | * Get the current value of [flag]. 10 | */ 11 | public fun currentValueOf(flag: FeatureFlag): T 12 | } 13 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/io/github/kevincianfarini/monarch/FeatureFlagManagerMixin.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch 2 | 3 | /** 4 | * A supplement to [MixinFeatureFlagManager] that allows extension via [FeatureFlagManagerMixin.currentValueOfOrNull]. 5 | * 6 | * Implementations of this interface can opt to handle 1 or more [FeatureFlag] types. For example, if we want to add a 7 | * mixin that supports JSON, we could do so with the following code. 8 | * 9 | * ```kt 10 | * class JsonFeatureFlagMixin : FeatureFlagMixin { 11 | * 12 | * override fun currentValueOfOrNull( 13 | * flag: FeatureFlag, 14 | * store: FeatureFlagDataStore, 15 | * ): T? = when (flag) { 16 | * is SomeJsonFeatureFlag -> decodeJson(store.getString(flag.key)) 17 | * else -> null 18 | * } 19 | * 20 | * abstract fun decodeJson(flag: FeatureFlag): T 21 | * } 22 | * ``` 23 | */ 24 | public interface FeatureFlagManagerMixin { 25 | 26 | /** 27 | * Get the current value for [flag] as [T] from [store]. This function will return null if this 28 | * [FeatureFlagManagerMixin] does not handle [flag]. 29 | */ 30 | public fun currentValueOfOrNull( 31 | flag: FeatureFlag, 32 | store: FeatureFlagDataStore, 33 | ): T? 34 | } -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/io/github/kevincianfarini/monarch/InMemoryFeatureFlagDataStoreOverride.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch 2 | 3 | import kotlinx.coroutines.ExperimentalCoroutinesApi 4 | import kotlinx.coroutines.flow.* 5 | 6 | /** 7 | * An implementation of [ObservableFeatureFlagDataStore] that allows overriding values produced by [delegate] with 8 | * values held in memory. 9 | * 10 | * Values held in memory within instances of this class will be returned in favor of their counterparts returned from 11 | * [delegate]. When a specific key is not overriden in memory, then the underlying value from [delegate] is returned. 12 | */ 13 | @ExperimentalCoroutinesApi 14 | public class InMemoryFeatureFlagDataStoreOverride( 15 | /** 16 | * The underlying data store which this class overrides. 17 | */ 18 | private val delegate: ObservableFeatureFlagDataStore, 19 | ) : ObservableFeatureFlagDataStore { 20 | 21 | private val cache: MutableStateFlow> = MutableStateFlow(emptyMap()) 22 | 23 | public override fun getBoolean(key: String, default: Boolean): Boolean { 24 | return cache.getCachedValue(key) ?: delegate.getBoolean(key, default) 25 | } 26 | 27 | public override fun getString(key: String, default: String): String { 28 | return cache.getCachedValue(key) ?: delegate.getString(key, default) 29 | } 30 | 31 | public override fun getDouble(key: String, default: Double): Double { 32 | return cache.getCachedValue(key) ?: delegate.getDouble(key, default) 33 | } 34 | 35 | public override fun getLong(key: String, default: Long): Long { 36 | return cache.getCachedValue(key) ?: delegate.getLong(key, default) 37 | } 38 | 39 | public override fun observeBoolean(key: String, default: Boolean): Flow { 40 | return cache.observeCachedValue(key).flatMapLatest { cachedValue -> 41 | when (cachedValue) { 42 | null -> delegate.observeBoolean(key, default) 43 | else -> flowOf(cachedValue) 44 | } 45 | } 46 | } 47 | 48 | public override fun observeString(key: String, default: String): Flow { 49 | return cache.observeCachedValue(key).flatMapLatest { cachedValue -> 50 | when (cachedValue) { 51 | null -> delegate.observeString(key, default) 52 | else -> flowOf(cachedValue) 53 | } 54 | } 55 | } 56 | 57 | public override fun observeDouble(key: String, default: Double): Flow { 58 | return cache.observeCachedValue(key).flatMapLatest { cachedValue -> 59 | when (cachedValue) { 60 | null -> delegate.observeDouble(key, default) 61 | else -> flowOf(cachedValue) 62 | } 63 | } 64 | } 65 | 66 | public override fun observeLong(key: String, default: Long): Flow { 67 | return cache.observeCachedValue(key).flatMapLatest { cachedValue -> 68 | when (cachedValue) { 69 | null -> delegate.observeLong(key, default) 70 | else -> flowOf(cachedValue) 71 | } 72 | } 73 | } 74 | 75 | public fun setBoolean(key: String, value: Boolean) { 76 | cache.update { map -> map + Pair(key, value) } 77 | } 78 | 79 | public fun setString(key: String, value: String) { 80 | cache.update { map -> map + Pair(key, value) } 81 | } 82 | 83 | public fun setDouble(key: String, value: Double) { 84 | cache.update { map -> map + Pair(key, value) } 85 | } 86 | 87 | public fun setLong(key: String, value: Long) { 88 | cache.update { map -> map + Pair(key, value) } 89 | } 90 | } 91 | 92 | private inline fun StateFlow>.getCachedValue(key: String): T? { 93 | return value[key] as T? 94 | } 95 | 96 | private inline fun StateFlow>.observeCachedValue(key: String): Flow { 97 | return map { map -> map[key] as T? }.distinctUntilChanged() 98 | } 99 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/io/github/kevincianfarini/monarch/MixinFeatureFlagManager.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch 2 | 3 | /** 4 | * A [FeatureFlagManager] implementation that allows extension via [mixins]. 5 | */ 6 | public class MixinFeatureFlagManager( 7 | /** 8 | * The datastore which contains raw feature flag data. 9 | */ 10 | private val store: FeatureFlagDataStore, 11 | 12 | /** 13 | * A list of [FeatureFlagManagerMixin] that are queried when a flag is not a simple primitive type. 14 | */ 15 | private val mixins: List = emptyList(), 16 | ) : FeatureFlagManager { 17 | 18 | @Suppress("UNCHECKED_CAST") 19 | public override fun currentValueOf(flag: FeatureFlag): T = when (flag) { 20 | is BooleanFeatureFlag -> store.getBoolean(flag.key, flag.default) as T 21 | is StringFeatureFlag -> store.getString(flag.key, flag.default) as T 22 | is DoubleFeatureFlag -> store.getDouble(flag.key, flag.default) as T 23 | is LongFeatureFlag -> store.getLong(flag.key, flag.default) as T 24 | else -> mixins.firstNotNullOfOrNull { delegate -> 25 | delegate.currentValueOfOrNull(flag, store) 26 | } ?: throw IllegalArgumentException("$flag is not a recognized feature flag.") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/io/github/kevincianfarini/monarch/ObservableFeatureFlagDataStore.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | 5 | /** 6 | * An underlying store of raw, primitive feature flag values that supports observability via [Flow]. 7 | */ 8 | public interface ObservableFeatureFlagDataStore : FeatureFlagDataStore { 9 | 10 | /** 11 | * Return a [Flow] which emits updates to a [String] value associated with [key] if present. When no value is 12 | * present for [key], the returned flow will emit [default]. The returned flow initially emits the value at the 13 | * time of collection, and will emit again on each subsequent update for [key]. 14 | */ 15 | public fun observeString(key: String, default: String): Flow 16 | 17 | /** 18 | * Return a [Flow] which emits updates to a [Boolean] value associated with [key] if present. When no value is 19 | * present for [key], the returned flow will emit [default]. The returned flow initially emits the value at the 20 | * time of collection, and will emit again on each subsequent update for [key]. 21 | */ 22 | public fun observeBoolean(key: String, default: Boolean): Flow 23 | 24 | /** 25 | * Return a [Flow] which emits updates to a [Double] value associated with [key] if present. When no value is 26 | * present for [key], the returned flow will emit [default]. The returned flow initially emits the value at the 27 | * time of collection, and will emit again on each subsequent update for [key]. 28 | */ 29 | public fun observeDouble(key: String, default: Double): Flow 30 | 31 | /** 32 | * Return a [Flow] which emits updates to a [Long] value associated with [key] if present. When no value is 33 | * present for [key], the returned flow will emit [default]. The returned flow initially emits the value at the 34 | * time of collection, and will emit again on each subsequent update for [key]. 35 | */ 36 | public fun observeLong(key: String, default: Long): Flow 37 | } -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/io/github/kevincianfarini/monarch/ObservableFeatureFlagManager.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | 5 | /** 6 | * Acquire a [Flow] of values from [FeatureFlag] instances. 7 | */ 8 | public interface ObservableFeatureFlagManager : FeatureFlagManager { 9 | 10 | /** 11 | * Return a [Flow] which emits when the value of [flag] changes. 12 | */ 13 | public fun valuesOf(flag: FeatureFlag): Flow 14 | } 15 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/io/github/kevincianfarini/monarch/ObservableFeatureFlagManagerMixin.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | 5 | /** 6 | * A supplement to [ObservableMixinFeatureFlagManager] that allows extension via 7 | * [ObservableFeatureFlagManagerMixin.valuesOfOrNull]. 8 | * 9 | * Implementations of this interface can opt to handle 1 or more [FeatureFlag] types. For example, if we want to add a 10 | * mixin that supports JSON, we could do so with the following code. 11 | * 12 | * ```kt 13 | * class JsonFeatureFlagMixin : ObservableFeatureFlagMixin { 14 | * 15 | * override suspend fun valuesOrNull( 16 | * flag: FeatureFlag, 17 | * store: ObservableFeatureFlagDataStore, 18 | * ): T? = when (flag) { 19 | * is SomeJsonFeatureFlag -> store.stringFlow(flag.key).map { decodeJson(it) } 20 | * else -> null 21 | * } 22 | * 23 | * abstract fun decodeJson(flag: FeatureFlag): T 24 | * } 25 | * ``` 26 | */ 27 | public interface ObservableFeatureFlagManagerMixin : FeatureFlagManagerMixin { 28 | 29 | /** 30 | * Observes values for [flag] as [Flow] from [store]. This function will return null if this 31 | * [ObservableFeatureFlagManagerMixin] does not handle [flag]. 32 | */ 33 | public fun valuesOfOrNull(flag: FeatureFlag, store: ObservableFeatureFlagDataStore): Flow? 34 | } -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/io/github/kevincianfarini/monarch/ObservableMixinFeatureFlagManager.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | 5 | /** 6 | * A [ObservableFeatureFlagManager] implementation that allows extension via [mixins]. 7 | */ 8 | public class ObservableMixinFeatureFlagManager( 9 | /** 10 | * The datastore which contains raw feature flag data. 11 | */ 12 | private val store: ObservableFeatureFlagDataStore, 13 | 14 | /** 15 | * A list of [FeatureFlagManagerMixin] that can be queried when this manager is unfit to handle the 16 | * feature flag query. 17 | */ 18 | private val mixins: List = emptyList(), 19 | ) : ObservableFeatureFlagManager, FeatureFlagManager by MixinFeatureFlagManager(store, mixins) { 20 | 21 | @Suppress("UNCHECKED_CAST") 22 | public override fun valuesOf(flag: FeatureFlag): Flow = when (flag) { 23 | is BooleanFeatureFlag -> store.observeBoolean(flag.key, flag.default) as Flow 24 | is StringFeatureFlag -> store.observeString(flag.key, flag.default) as Flow 25 | is DoubleFeatureFlag -> store.observeDouble(flag.key, flag.default) as Flow 26 | is LongFeatureFlag -> store.observeLong(flag.key, flag.default) as Flow 27 | else -> mixins.firstNotNullOfOrNull { delegate -> 28 | delegate.valuesOfOrNull(flag, store) 29 | } ?: throw IllegalArgumentException("$flag is not a recognized feature flag.") 30 | } 31 | } -------------------------------------------------------------------------------- /core/src/commonTest/kotlin/io/github/kevincianfarini/monarch/InMemoryFeatureFlagDataStoreOverrideTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch 2 | 3 | import app.cash.turbine.test 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlinx.coroutines.test.runTest 7 | import kotlin.test.Test 8 | import kotlin.test.assertEquals 9 | import kotlin.test.assertNotEquals 10 | 11 | @OptIn(ExperimentalCoroutinesApi::class) 12 | class InMemoryFeatureFlagDataStoreOverrideTest { 13 | 14 | @Test fun store_cache_overrides_delegate_synchronous() { 15 | listOf Any?>>( 16 | Triple(true, false) { getBoolean(it, false) }, 17 | Triple("correct", "incorrect") { getString(it, "incorrect") }, 18 | Triple(1.5, 3.0) { getDouble(it, 3.0) }, 19 | Triple(1L, 2L) { getLong(it, 2L) }, 20 | ).forEach { (overrideValue, delegateValue, produceFn) -> 21 | testCacheOverridesDelegateSynchronousParameterized(overrideValue, delegateValue, produceFn) 22 | } 23 | } 24 | 25 | private fun testCacheOverridesDelegateSynchronousParameterized( 26 | overrideValue: Any, 27 | delegateValue: Any, 28 | produceValue: InMemoryFeatureFlagDataStoreOverride.(String) -> Any? 29 | ) { 30 | val key = "foo" 31 | val delegate = InMemoryFeatureFlagDataStore().apply { setValue(key, delegateValue) } 32 | val storeOverride = InMemoryFeatureFlagDataStoreOverride(delegate = delegate).apply { 33 | when (overrideValue) { 34 | is String -> setString(key, overrideValue) 35 | is Boolean -> setBoolean(key, overrideValue) 36 | is Long -> setLong(key, overrideValue) 37 | is Double -> setDouble(key, overrideValue) 38 | else -> error("Invalid value.") 39 | } 40 | } 41 | 42 | assertEquals( 43 | expected = overrideValue, 44 | actual = storeOverride.produceValue(key), 45 | ) 46 | } 47 | 48 | @Test fun store_cache_falls_back_to_delegate_synchronous() { 49 | listOf Any?>>( 50 | Pair(false) { getBoolean(it, true) }, 51 | Pair("correct") { getString(it, "incorrect") }, 52 | Pair(3.0) { getDouble(it, 1.5) }, 53 | Pair(2L) { getLong(it, 1L) }, 54 | ).forEach { (delegateValue, produceFn) -> 55 | testCacheFallsBackToDelegateSynchronousParameterized(delegateValue, produceFn) 56 | } 57 | } 58 | 59 | private fun testCacheFallsBackToDelegateSynchronousParameterized( 60 | delegateValue: Any, 61 | produceValue: InMemoryFeatureFlagDataStoreOverride.(String) -> Any? 62 | ) { 63 | val key = "foo" 64 | val delegate = InMemoryFeatureFlagDataStore().apply { setValue(key, delegateValue) } 65 | val storeOverride = InMemoryFeatureFlagDataStoreOverride(delegate = delegate) 66 | 67 | assertEquals( 68 | expected = delegateValue, 69 | actual = storeOverride.produceValue(key), 70 | ) 71 | } 72 | 73 | @Test fun store_cache_overrides_delegate_flow() { 74 | listOf Flow<*>>>( 75 | Triple(true, false) { observeBoolean(it, false) }, 76 | Triple("correct", "incorrect") { observeString(it, "incorrect") }, 77 | Triple(1.5, 3.0) { observeDouble(it, 3.0) }, 78 | Triple(1L, 2L) { observeLong(it, 2L) }, 79 | ).forEach { (overrideValue, delegateValue, produceFn) -> 80 | storeCacheOverridesDelegateFlowParameterized(overrideValue, delegateValue, produceFn) 81 | } 82 | } 83 | 84 | private fun storeCacheOverridesDelegateFlowParameterized( 85 | overrideValue: Any, 86 | delegateValue: Any, 87 | produceFlow: InMemoryFeatureFlagDataStoreOverride.(String) -> Flow<*> 88 | ) = runTest { 89 | val key = "foo" 90 | val delegate = InMemoryFeatureFlagDataStore().apply { setValue(key, delegateValue) } 91 | val storeOverride = InMemoryFeatureFlagDataStoreOverride(delegate).apply { 92 | when (overrideValue) { 93 | is String -> setString(key, overrideValue) 94 | is Boolean -> setBoolean(key, overrideValue) 95 | is Long -> setLong(key, overrideValue) 96 | is Double -> setDouble(key, overrideValue) 97 | else -> error("Invalid value.") 98 | } 99 | } 100 | 101 | storeOverride.produceFlow(key).test { 102 | assertEquals( 103 | expected = overrideValue, 104 | actual = awaitItem(), 105 | ) 106 | } 107 | } 108 | 109 | @Test fun store_cache_falls_back_to_delegate_flow() { 110 | listOf Flow<*>>>( 111 | Pair(false) { observeBoolean(it, true) }, 112 | Pair("correct") { observeString(it, "incorrect") }, 113 | Pair(3.0) { observeDouble(it, 1.5) }, 114 | Pair(2L) { observeLong(it, 1L) }, 115 | ).forEach { (delegateValue, produceFn) -> 116 | storeCacheFallsBackToDelegateFlowParameterized(delegateValue, produceFn) 117 | } 118 | } 119 | 120 | private fun storeCacheFallsBackToDelegateFlowParameterized( 121 | delegateValue: Any, 122 | produceFlow: InMemoryFeatureFlagDataStoreOverride.(String) -> Flow<*> 123 | ) = runTest { 124 | val key = "foo" 125 | val delegate = InMemoryFeatureFlagDataStore().apply { setValue(key, delegateValue) } 126 | val storeOverride = InMemoryFeatureFlagDataStoreOverride(delegate = delegate) 127 | 128 | storeOverride.produceFlow(key).test { 129 | assertEquals( 130 | expected = delegateValue, 131 | actual = awaitItem(), 132 | ) 133 | } 134 | } 135 | 136 | @Test fun writing_to_store_cache_emits_new_value_in_active_flows() { 137 | listOf Unit, InMemoryFeatureFlagDataStoreOverride.(String) -> Flow<*>>>( 138 | Triple(true, { setBoolean(it, false) }) { observeBoolean(it, true) }, 139 | Triple("correct", { setString(it, "also correct") }) { observeString(it, "incorrect") }, 140 | Triple(1.5, { setDouble(it, 3.0) }) { observeDouble(it, 4.5) }, 141 | Triple(1L, { setLong(it, 3L) }) { observeLong(it, 2L) }, 142 | ).forEach { (initialValue, newValue, produceFn) -> 143 | writingToStoreCacheEmitsNewValueParameterized(initialValue, newValue, produceFn) 144 | } 145 | } 146 | 147 | private fun writingToStoreCacheEmitsNewValueParameterized( 148 | initialValue: Any, 149 | setNewValue: InMemoryFeatureFlagDataStoreOverride.(String) -> Unit, 150 | produceFlow: InMemoryFeatureFlagDataStoreOverride.(String) -> Flow<*> 151 | ) = runTest { 152 | val key = "foo" 153 | val storeOverride = InMemoryFeatureFlagDataStoreOverride(InMemoryFeatureFlagDataStore()).apply { 154 | when (initialValue) { 155 | is String -> setString(key, initialValue) 156 | is Boolean -> setBoolean(key, initialValue) 157 | is Long -> setLong(key, initialValue) 158 | is Double -> setDouble(key, initialValue) 159 | else -> error("Invalid value.") 160 | } 161 | } 162 | storeOverride.produceFlow(key).test { 163 | assertEquals( 164 | expected = initialValue, 165 | actual = awaitItem(), 166 | ) 167 | storeOverride.setNewValue(key) 168 | assertNotEquals(illegal = initialValue, actual = awaitItem()) 169 | } 170 | } 171 | } -------------------------------------------------------------------------------- /core/src/commonTest/kotlin/io/github/kevincianfarini/monarch/MixinFeatureFlagManagerTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch 2 | 3 | import kotlin.test.* 4 | 5 | class MixinFeatureFlagManagerTest { 6 | 7 | @Test fun manager_gets_string_value() { 8 | val store = InMemoryFeatureFlagDataStore().apply { setValue("foo", "bar") } 9 | assertEquals( 10 | expected = "bar", 11 | actual = manager(store).currentValueOf(StringFeature), 12 | ) 13 | } 14 | 15 | @Test fun manager_gets_default_string_value() = assertEquals( 16 | expected = "blah", 17 | actual = manager().currentValueOf(StringFeature), 18 | ) 19 | 20 | @Test fun manager_gets_boolean_value() { 21 | val store = InMemoryFeatureFlagDataStore().apply { setValue("bool", true) } 22 | assertTrue(manager(store).currentValueOf(BooleanFeature)) 23 | } 24 | 25 | @Test fun manager_gets_default_boolean_value() = assertFalse(manager().currentValueOf(BooleanFeature)) 26 | 27 | @Test fun manager_gets_double_value() { 28 | val store = InMemoryFeatureFlagDataStore().apply { setValue("double", 15.7) } 29 | assertEquals( 30 | expected = 15.7, 31 | actual = manager(store).currentValueOf(DoubleFeature), 32 | absoluteTolerance = 0.05, 33 | ) 34 | } 35 | 36 | @Test fun manager_gets_default_double_value() = assertEquals( 37 | expected = 1.5, 38 | actual = manager().currentValueOf(DoubleFeature), 39 | absoluteTolerance = 0.05, 40 | ) 41 | 42 | @Test fun manager_gets_long_value() { 43 | val store = InMemoryFeatureFlagDataStore().apply { setValue("long", 27L) } 44 | assertEquals( 45 | expected = 27L, 46 | actual = manager(store).currentValueOf(LongFeature), 47 | ) 48 | } 49 | 50 | @Test fun manager_gets_default_long_value() = assertEquals( 51 | expected = 1027L, 52 | actual = manager().currentValueOf(LongFeature), 53 | ) 54 | 55 | @Test fun manager_gets_mixin_value() { 56 | val store = InMemoryFeatureFlagDataStore().apply { setValue("some_int", "1") } 57 | assertEquals( 58 | expected = 1, 59 | actual = manager(store, listOf(ObservableIntDecodingMixin)).currentValueOf(IntFeatureFlag), 60 | ) 61 | } 62 | 63 | @Test fun manager_errors_with_unrecognized_flag_type() { 64 | // the below IS NOT a `BooleanOption` and therefore will go unrecognized 65 | val someRandomFlag = object : FeatureFlag { 66 | override val key: String = "random_key" 67 | override val default = false 68 | } 69 | 70 | assertFailsWith { 71 | manager().currentValueOf(someRandomFlag) 72 | } 73 | } 74 | 75 | private fun manager( 76 | store: FeatureFlagDataStore = InMemoryFeatureFlagDataStore(), 77 | mixins: List = emptyList(), 78 | ) = MixinFeatureFlagManager(store, mixins) 79 | } 80 | -------------------------------------------------------------------------------- /core/src/commonTest/kotlin/io/github/kevincianfarini/monarch/ObservableMixinFeatureFlagManagerTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch 2 | 3 | import app.cash.turbine.test 4 | import kotlinx.coroutines.test.runTest 5 | import kotlin.test.* 6 | 7 | class ObservableMixinFeatureFlagManagerTest { 8 | 9 | @Test fun manager_gets_string_value() { 10 | runTest { 11 | val store = InMemoryFeatureFlagDataStore().apply { setValue("foo", "bar") } 12 | manager(store).valuesOf(StringFeature).test { 13 | assertEquals("bar", awaitItem()) 14 | cancelAndIgnoreRemainingEvents() 15 | } 16 | } 17 | } 18 | 19 | @Test fun manager_gets_default_string_value() { 20 | runTest { 21 | manager().valuesOf(StringFeature).test { 22 | assertEquals("blah", awaitItem()) 23 | cancelAndIgnoreRemainingEvents() 24 | } 25 | } 26 | } 27 | 28 | @Test fun manager_gets_boolean_value() { 29 | runTest { 30 | val store = InMemoryFeatureFlagDataStore().apply { setValue("bool", true) } 31 | manager(store).valuesOf(BooleanFeature).test { 32 | assertTrue(awaitItem()) 33 | cancelAndIgnoreRemainingEvents() 34 | } 35 | } 36 | } 37 | 38 | @Test fun manager_gets_default_boolean_value() { 39 | runTest { 40 | manager().valuesOf(BooleanFeature).test { 41 | assertFalse(awaitItem()) 42 | cancelAndIgnoreRemainingEvents() 43 | } 44 | } 45 | } 46 | 47 | @Test fun manager_gets_double_value() { 48 | runTest { 49 | val store = InMemoryFeatureFlagDataStore().apply { setValue("double", 15.7) } 50 | manager(store).valuesOf(DoubleFeature).test { 51 | assertEquals(expected = 15.7, actual = awaitItem(), absoluteTolerance = 0.05) 52 | cancelAndIgnoreRemainingEvents() 53 | } 54 | } 55 | } 56 | 57 | @Test fun manager_gets_default_double_value() { 58 | runTest { 59 | manager().valuesOf(DoubleFeature).test { 60 | assertEquals(expected = 1.5, actual = awaitItem(), absoluteTolerance = 0.05) 61 | cancelAndIgnoreRemainingEvents() 62 | } 63 | } 64 | } 65 | 66 | @Test fun manager_gets_long_value() { 67 | runTest { 68 | val store = InMemoryFeatureFlagDataStore().apply { setValue("long", 27L) } 69 | manager(store).valuesOf(LongFeature).test { 70 | assertEquals(expected = 27L, actual = awaitItem()) 71 | cancelAndIgnoreRemainingEvents() 72 | } 73 | } 74 | } 75 | 76 | @Test fun manager_gets_default_long_value() { 77 | runTest { 78 | manager().valuesOf(LongFeature).test { 79 | assertEquals(expected = 1027L, actual = awaitItem()) 80 | cancelAndIgnoreRemainingEvents() 81 | } 82 | } 83 | } 84 | 85 | @Test fun manager_gets_mixin_value() { 86 | runTest { 87 | val store = InMemoryFeatureFlagDataStore().apply { setValue("some_int", "1") } 88 | manager(store, listOf(ObservableIntDecodingMixin)).valuesOf(IntFeatureFlag).test { 89 | assertEquals(expected = 1, actual = awaitItem()) 90 | cancelAndIgnoreRemainingEvents() 91 | } 92 | } 93 | } 94 | 95 | @Test fun manager_errors_with_unrecognized_flag_type() { 96 | runTest { 97 | // the below IS NOT a `BooleanOption` and therefore will go unrecognized 98 | val someRandomFlag = object : FeatureFlag { 99 | override val key: String = "random_key" 100 | override val default = false 101 | } 102 | 103 | assertFailsWith { 104 | manager().valuesOf(someRandomFlag) 105 | } 106 | } 107 | } 108 | 109 | private fun manager( 110 | store: ObservableFeatureFlagDataStore = InMemoryFeatureFlagDataStore(), 111 | mixins: List = emptyList(), 112 | ) = ObservableMixinFeatureFlagManager(store, mixins) 113 | } 114 | -------------------------------------------------------------------------------- /core/src/commonTest/kotlin/io/github/kevincianfarini/monarch/flags.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("UNCHECKED_CAST") 2 | 3 | package io.github.kevincianfarini.monarch 4 | 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlinx.coroutines.flow.map 7 | 8 | object StringFeature : StringFeatureFlag(key = "foo", default = "blah") 9 | object BooleanFeature : BooleanFeatureFlag(key = "bool", default = false) 10 | object DoubleFeature : DoubleFeatureFlag(key = "double", default = 1.5) 11 | object LongFeature : LongFeatureFlag(key = "long", default = 1027L) 12 | object IntFeatureFlag : FeatureFlag { 13 | override val key: String get() = "some_int" 14 | override val default = -1 15 | } 16 | 17 | 18 | // FeatureFlagManager mixin that handles IntFeatureFlags, returns Flow 19 | object ObservableIntDecodingMixin : ObservableFeatureFlagManagerMixin { 20 | override fun valuesOfOrNull( 21 | flag: FeatureFlag, 22 | store: ObservableFeatureFlagDataStore, 23 | ): Flow? = when (flag) { 24 | is IntFeatureFlag -> store.observeString(flag.key, flag.default.toString()).map { str -> 25 | str.toInt() 26 | } as Flow 27 | else -> null 28 | } 29 | 30 | override fun currentValueOfOrNull( 31 | flag: FeatureFlag, 32 | store: FeatureFlagDataStore 33 | ): T? = when (flag) { 34 | is IntFeatureFlag -> store.getString(flag.key, flag.default.toString()).toInt() as T 35 | else -> null 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | kotlin.jvm.target=11 3 | 4 | GROUP=io.github.kevincianfarini.monarch 5 | VERSION_NAME=0.2.4 6 | 7 | POM_INCEPTION_YEAR=2024 8 | 9 | POM_URL=https://github.com/kevincianfarini/monarch 10 | POM_SCM_URL=https://github.com/kevincianfarini/monarch 11 | POM_SCM_CONNECTION=scm:git:git://github.com/kevincianfarini/monarch.git 12 | POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/kevincianfarini/monarch.git 13 | 14 | POM_LICENCE_NAME=The Apache Software License, Version 2.0 15 | POM_LICENCE_URL=https://www.apache.org/licenses/LICENSE-2.0.txt 16 | POM_LICENCE_DIST=repo 17 | 18 | POM_DEVELOPER_ID=kevincianfarini 19 | POM_DEVELOPER_NAME=Kevin Cianfarini 20 | POM_DEVELOPER_URL=https://github.com/kevincianfarini 21 | 22 | SONATYPE_CONNECT_TIMEOUT_SECONDS=120 23 | SONATYPE_CLOSE_TIMEOUT_SECONDS=900 24 | SONATYPE_AUTOMATIC_RELEASE=true 25 | SONATYPE_HOST=S01 26 | RELEASE_SIGNING_ENABLED=true 27 | 28 | android.useAndroidX=true 29 | 30 | org.gradle.parallel=true 31 | org.gradle.jvmargs=-Xmx10g -Xms256m -XX:+HeapDumpOnOutOfMemoryError -XX:+UseParallelGC 32 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | agp = "8.8.0" 3 | android-compileSdk = "34" 4 | android-minSdk = "21" 5 | compose = "1.7.3" 6 | dokka = "1.9.20" 7 | kotlin = "2.0.21" 8 | kotlin-nodejs = "18.16.12-pre.634" 9 | kotlinx-coroutines = "1.9.0" 10 | kotlinx-serialization = "1.7.3" 11 | launchdarkly-android = "5.6.1" 12 | molecule = "2.0.0" 13 | publish = "0.30.0" 14 | turbine = "1.2.0" 15 | 16 | [libraries] 17 | compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose" } 18 | kotlin-nodejs = { module = "org.jetbrains.kotlin-wrappers:kotlin-node", version.ref = "kotlin-nodejs" } 19 | kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } 20 | kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } 21 | kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } 22 | kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } 23 | kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } 24 | launchdarkly-android = { module = "com.launchdarkly:launchdarkly-android-client-sdk", version.ref = "launchdarkly-android" } 25 | molecule = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } 26 | turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } 27 | 28 | [plugins] 29 | android-library = { id = "com.android.library", version.ref = "agp" } 30 | dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } 31 | kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 32 | kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } 33 | kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } 34 | publish = { id = "com.vanniktech.maven.publish", version.ref = "publish" } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevincianfarini/monarch/9f54d6d0a7b2d8261019ef89bc60cd45d4edf474/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | org.gradle.wrapper.GradleWrapperMain \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /integrations/environment-variable/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.dokka) 3 | alias(libs.plugins.kotlin.multiplatform) 4 | alias(libs.plugins.publish) 5 | } 6 | 7 | kotlin { 8 | 9 | explicitApi() 10 | 11 | iosArm64() 12 | iosSimulatorArm64() 13 | iosX64() 14 | jvm() 15 | js { 16 | nodejs { 17 | testTask { 18 | useMocha { 19 | timeout = "5s" 20 | } 21 | } 22 | } 23 | } 24 | linuxArm64() 25 | linuxX64() 26 | macosArm64() 27 | macosX64() 28 | mingwX64() 29 | tvosArm64() 30 | tvosSimulatorArm64() 31 | tvosX64() 32 | watchosArm32() 33 | watchosArm64() 34 | watchosDeviceArm64() 35 | watchosSimulatorArm64() 36 | watchosX64() 37 | 38 | sourceSets { 39 | commonMain.dependencies { 40 | api(project(":core")) 41 | } 42 | commonTest.dependencies { 43 | implementation(libs.kotlin.test) 44 | } 45 | jsMain.dependencies { 46 | implementation(libs.kotlin.nodejs) 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /integrations/environment-variable/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=environment-variable-integration 2 | POM_NAME=Monarch System Environment Variable Integration 3 | POM_DESCRIPTION=Multiplatform integration with environment variables -------------------------------------------------------------------------------- /integrations/environment-variable/src/commonMain/kotlin/io/github/kevincianfarini/monarch/environment/EnvironmentVariableFeatureFlagDataStore.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch.environment 2 | 3 | import io.github.kevincianfarini.monarch.FeatureFlagDataStore 4 | 5 | /** 6 | * A [FeatureFlagDataStore] implementation that provides values from environment variables. 7 | */ 8 | public class EnvironmentVariableFeatureFlagDataStore internal constructor( 9 | private val strictlyTyped: Boolean = true, 10 | private val getEnvironmentVariable: (String) -> String?, 11 | ) : FeatureFlagDataStore { 12 | 13 | /** 14 | * Create an [EnvironmentVariableFeatureFlagDataStore] which reads from the environment. 15 | * If [strictlyTyped] is true, this store will throw exceptions when the raw string value 16 | * of the environment variable cannot be coerced to a specific type. Otherwise, this store 17 | * will return the default value. 18 | */ 19 | public constructor(strictlyTyped: Boolean = true) : this(strictlyTyped, ::getSystemEnvVar) 20 | 21 | override fun getBoolean(key: String, default: Boolean): Boolean { 22 | val env = getEnvironmentVariable(key) 23 | val boolean = if (strictlyTyped) env?.toBooleanStrict() else env?.toBooleanStrictOrNull() 24 | return boolean ?: default 25 | } 26 | 27 | override fun getString(key: String, default: String): String { 28 | return getEnvironmentVariable(key) ?: default 29 | } 30 | 31 | override fun getDouble(key: String, default: Double): Double { 32 | val env = getEnvironmentVariable(key) 33 | val double = if (strictlyTyped) env?.toDouble() else env?.toDoubleOrNull() 34 | return double ?: default 35 | } 36 | 37 | override fun getLong(key: String, default: Long): Long { 38 | val env = getEnvironmentVariable(key) 39 | val long = if (strictlyTyped) env?.toLong() else env?.toLongOrNull() 40 | return long ?: default 41 | } 42 | } -------------------------------------------------------------------------------- /integrations/environment-variable/src/commonMain/kotlin/io/github/kevincianfarini/monarch/environment/environment.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch.environment 2 | 3 | internal expect fun getSystemEnvVar(key: String): String? -------------------------------------------------------------------------------- /integrations/environment-variable/src/commonTest/kotlin/io/github/kevincianfarini/monarch/environment/EnvironmentVariableFeatureFlagDataStoreTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch.environment 2 | 3 | import kotlin.test.* 4 | 5 | class EnvironmentVariableFeatureFlagDataStoreTest { 6 | 7 | @Test 8 | fun strictly_typed_boolean_returns_value() { 9 | val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = true) { "true" } 10 | assertTrue(store.getBoolean(key = "key", default = false)) 11 | } 12 | 13 | @Test 14 | fun strictly_typed_boolean_fails() { 15 | val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = true) { "tRuE" } 16 | assertFailsWith { 17 | store.getBoolean(key = "key", default = false) 18 | } 19 | } 20 | 21 | @Test 22 | fun loosely_typed_boolean_returns_default_on_failure() { 23 | val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = false) { "tRuE" } 24 | assertFalse(store.getBoolean(key = "key", default = false)) 25 | } 26 | 27 | @Test 28 | fun no_underlying_value_boolean_returns_default() { 29 | val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = false) { null } 30 | assertFalse(store.getBoolean(key = "key", default = false)) 31 | } 32 | 33 | @Test 34 | fun string_returns_environment_variable() { 35 | val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = true) { "some string" } 36 | assertEquals("some string", store.getString(key = "key", default = "default")) 37 | } 38 | 39 | @Test 40 | fun string_returns_default_when_no_environment_variable() { 41 | val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = true) { null } 42 | assertEquals("default", store.getString(key = "key", default = "default")) 43 | } 44 | 45 | @Test 46 | fun strictly_typed_double_returns_value() { 47 | val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = true) { "1.2" } 48 | assertEquals(1.2, store.getDouble(key = "key", default = 0.0)) 49 | } 50 | 51 | @Test 52 | fun strictly_typed_double_fails() { 53 | val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = true) { "Not a number" } 54 | assertFailsWith { 55 | store.getDouble(key = "key", default = 0.0) 56 | } 57 | } 58 | 59 | @Test 60 | fun loosely_typed_double_returns_default_on_failure() { 61 | val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = false) { "Not a number" } 62 | assertEquals(0.0, store.getDouble(key = "key", default = 0.0)) 63 | } 64 | 65 | @Test 66 | fun no_underlying_value_double_returns_default() { 67 | val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = true) { null } 68 | assertEquals(0.0, store.getDouble(key = "key", default = 0.0)) 69 | } 70 | 71 | @Test 72 | fun strictly_typed_long_returns_value() { 73 | val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = true) { "1000" } 74 | assertEquals(1000, store.getLong(key = "key", default = 0)) 75 | } 76 | 77 | @Test 78 | fun strictly_typed_long_fails() { 79 | val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = true) { "Not a number" } 80 | assertFailsWith { 81 | store.getLong(key = "key", default = 0) 82 | } 83 | } 84 | 85 | @Test 86 | fun loosely_typed_long_returns_default_on_failure() { 87 | val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = false) { "Not a number" } 88 | assertEquals(0, store.getLong(key = "key", default = 0)) 89 | } 90 | 91 | @Test 92 | fun no_underlying_value_long_returns_default() { 93 | val store = EnvironmentVariableFeatureFlagDataStore(strictlyTyped = true) { null } 94 | assertEquals(0, store.getLong(key = "key", default = 0)) 95 | } 96 | } -------------------------------------------------------------------------------- /integrations/environment-variable/src/jsMain/kotlin/io/github/kevincianfarini/monarch/environment/environment.js.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch.environment 2 | 3 | import node.process.process 4 | 5 | internal actual fun getSystemEnvVar(key: String): String? { 6 | return process.env[key] 7 | } -------------------------------------------------------------------------------- /integrations/environment-variable/src/jvmMain/kotlin/io/github/kevincianfarini/monarch/environment/environment.jvm.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch.environment 2 | 3 | internal actual fun getSystemEnvVar(key: String): String? { 4 | return System.getenv(key) 5 | } -------------------------------------------------------------------------------- /integrations/environment-variable/src/nativeMain/kotlin/io/github/kevincianfarini/monarch/environment/environment.native.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch.environment 2 | 3 | import kotlinx.cinterop.ExperimentalForeignApi 4 | import kotlinx.cinterop.toKString 5 | import platform.posix.getenv 6 | 7 | @OptIn(ExperimentalForeignApi::class) 8 | internal actual fun getSystemEnvVar(key: String): String? { 9 | return getenv(key)?.toKString() 10 | } -------------------------------------------------------------------------------- /integrations/launch-darkly/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.library) 3 | alias(libs.plugins.dokka) 4 | alias(libs.plugins.kotlin.multiplatform) 5 | alias(libs.plugins.publish) 6 | } 7 | 8 | android { 9 | compileSdk = libs.versions.android.compileSdk.get().toInt() 10 | namespace = "io.github.kevincianfarini.monarch.launchdarkly" 11 | defaultConfig { 12 | minSdk = libs.versions.android.minSdk.get().toInt() 13 | } 14 | buildFeatures { 15 | buildConfig = false 16 | } 17 | } 18 | 19 | kotlin { 20 | explicitApi() 21 | jvmToolchain(17) 22 | 23 | iosArm64() 24 | iosSimulatorArm64() 25 | iosX64() 26 | macosArm64() 27 | macosX64() 28 | tvosArm64() 29 | tvosSimulatorArm64() 30 | tvosX64() 31 | watchosArm32() 32 | watchosArm64() 33 | watchosDeviceArm64() 34 | watchosSimulatorArm64() 35 | watchosX64() 36 | 37 | androidTarget { 38 | publishLibraryVariants("release") 39 | } 40 | 41 | sourceSets { 42 | commonMain.dependencies { 43 | api(project(":core")) 44 | } 45 | commonTest.dependencies { 46 | implementation(libs.kotlin.test) 47 | implementation(libs.kotlinx.coroutines.test) 48 | implementation(libs.turbine) 49 | } 50 | androidMain.dependencies { 51 | api(libs.launchdarkly.android) 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /integrations/launch-darkly/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=launch-darkly-integration 2 | POM_NAME=Monarch Launch Darkly Integration 3 | POM_DESCRIPTION=Multiplatform integration with Launch Darkly feature flag SDKs -------------------------------------------------------------------------------- /integrations/launch-darkly/src/androidMain/kotlin/io/github/kevincianfarini/monarch/launchdarkly/LaunchDarklyFeatureFlagDataStore.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch.launchdarkly 2 | 3 | import com.launchdarkly.sdk.android.LDClientInterface 4 | import io.github.kevincianfarini.monarch.ObservableFeatureFlagDataStore 5 | import kotlinx.coroutines.channels.awaitClose 6 | import kotlinx.coroutines.flow.Flow 7 | import kotlinx.coroutines.flow.callbackFlow 8 | import kotlinx.coroutines.flow.conflate 9 | 10 | /** 11 | * Represent this [LDClientInterface] as an [ObservableFeatureFlagDataStore]. 12 | */ 13 | public fun LDClientInterface.asFeatureFlagDataStore(): ObservableFeatureFlagDataStore { 14 | return LaunchDarklyFeatureFlagDataStore(this) 15 | } 16 | 17 | private class LaunchDarklyFeatureFlagDataStore( 18 | private val client: LDClientInterface 19 | ) : ObservableFeatureFlagDataStore { 20 | 21 | override fun getBoolean(key: String, default: Boolean): Boolean { 22 | return client.getValue(key, default) 23 | } 24 | 25 | override fun getString(key: String, default: String): String { 26 | return client.getValue(key, default) 27 | } 28 | 29 | override fun getDouble(key: String, default: Double): Double { 30 | return client.getValue(key, default) 31 | } 32 | 33 | override fun getLong(key: String, default: Long): Long { 34 | return client.getValue(key, default) 35 | } 36 | 37 | override fun observeString(key: String, default: String): Flow = client.observeValue(key, default) 38 | 39 | override fun observeBoolean(key: String, default: Boolean): Flow = client.observeValue(key, default) 40 | 41 | override fun observeDouble(key: String, default: Double): Flow = client.observeValue(key, default) 42 | 43 | override fun observeLong(key: String, default: Long): Flow = client.observeValue(key, default) 44 | } 45 | 46 | private inline fun LDClientInterface.observeValue(key: String, default: T): Flow { 47 | return callbackFlow { 48 | trySend(getValue(key, default)) 49 | val callback: (String) -> Unit = { key: String -> trySend(getValue(key, default)) } 50 | registerFeatureFlagListener(key, callback) 51 | awaitClose { unregisterFeatureFlagListener(key, callback) } 52 | }.conflate() 53 | } 54 | 55 | private inline fun LDClientInterface.getValue(key: String, default: T): T { 56 | return when (val clazz = T::class) { 57 | Boolean::class -> boolVariation(key, default as Boolean) as T 58 | String::class -> stringVariation(key, default as String) as T 59 | Double::class -> doubleVariation(key, default as Double) as T 60 | Long::class -> intVariation(key, (default as Long).toInt()).toLong() as T 61 | else -> throw IllegalArgumentException("Illegal type for getValue: $clazz") 62 | } 63 | } 64 | 65 | -------------------------------------------------------------------------------- /integrations/launch-darkly/src/androidUnitTest/kotlin/io/github/kevincianfarini/monarch/launchdarkly/LaunchDarklyFeatureFlagDataStoreTest.android.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch.launchdarkly 2 | 3 | import com.launchdarkly.sdk.EvaluationDetail 4 | import com.launchdarkly.sdk.LDContext 5 | import com.launchdarkly.sdk.LDValue 6 | import com.launchdarkly.sdk.android.ConnectionInformation 7 | import com.launchdarkly.sdk.android.FeatureFlagChangeListener 8 | import com.launchdarkly.sdk.android.LDAllFlagsListener 9 | import com.launchdarkly.sdk.android.LDClientInterface 10 | import com.launchdarkly.sdk.android.LDStatusListener 11 | import io.github.kevincianfarini.monarch.ObservableFeatureFlagDataStore 12 | import java.util.concurrent.ConcurrentHashMap 13 | import java.util.concurrent.Future 14 | 15 | actual fun sut(): Pair { 16 | val client = FakeLDClient() 17 | return Pair(client.asFeatureFlagDataStore(), client) 18 | } 19 | 20 | private class FakeLDClient : LDClientInterface, MutableLDClientInterface { 21 | 22 | private val flagValues = ConcurrentHashMap() 23 | private val listeners = ConcurrentHashMap>() 24 | 25 | override fun boolVariation(p0: String, p1: Boolean): Boolean { 26 | return (flagValues[p0] as? Boolean) ?: p1 27 | } 28 | 29 | override fun intVariation(p0: String, p1: Int): Int { 30 | return (flagValues[p0] as? Int) ?: p1 31 | } 32 | 33 | override fun doubleVariation(p0: String, p1: Double): Double { 34 | return (flagValues[p0] as? Double) ?: p1 35 | } 36 | 37 | override fun stringVariation(p0: String, p1: String): String { 38 | return (flagValues[p0] as? String) ?: p1 39 | } 40 | 41 | override fun jsonValueVariation(p0: String?, p1: LDValue): LDValue { 42 | return (flagValues[p0] as? LDValue) ?: p1 43 | } 44 | 45 | override fun registerFeatureFlagListener(p0: String, p1: FeatureFlagChangeListener) { 46 | val currentListeners = listeners[p0] ?: emptySet() 47 | listeners[p0] = currentListeners + p1 48 | } 49 | 50 | override fun unregisterFeatureFlagListener(p0: String, p1: FeatureFlagChangeListener) { 51 | val currentListeners = listeners[p0] ?: emptySet() 52 | listeners[p0] = currentListeners - p1 53 | } 54 | 55 | override fun setVariation(flagKey: String, value: Boolean) { 56 | flagValues[flagKey] = value 57 | listeners[flagKey]?.forEach { it.onFeatureFlagChange(flagKey) } 58 | } 59 | 60 | override fun setVariation(flagKey: String, value: String) { 61 | flagValues[flagKey] = value 62 | listeners[flagKey]?.forEach { it.onFeatureFlagChange(flagKey) } 63 | } 64 | 65 | override fun setVariation(flagKey: String, value: Double) { 66 | flagValues[flagKey] = value 67 | listeners[flagKey]?.forEach { it.onFeatureFlagChange(flagKey) } 68 | } 69 | 70 | override fun setVariation(flagKey: String, value: Int) { 71 | flagValues[flagKey] = value 72 | listeners[flagKey]?.forEach { it.onFeatureFlagChange(flagKey) } 73 | } 74 | 75 | override fun boolVariationDetail(p0: String?, p1: Boolean): EvaluationDetail { 76 | TODO("Not yet implemented") 77 | } 78 | 79 | override fun stringVariationDetail(p0: String?, p1: String?): EvaluationDetail { 80 | TODO("Not yet implemented") 81 | } 82 | 83 | override fun doubleVariationDetail(p0: String?, p1: Double): EvaluationDetail { 84 | TODO("Not yet implemented") 85 | } 86 | 87 | override fun intVariationDetail(p0: String?, p1: Int): EvaluationDetail { 88 | TODO("Not yet implemented") 89 | } 90 | 91 | override fun close() { 92 | TODO("Not yet implemented") 93 | } 94 | 95 | override fun isInitialized(): Boolean { 96 | TODO("Not yet implemented") 97 | } 98 | 99 | override fun isOffline(): Boolean { 100 | TODO("Not yet implemented") 101 | } 102 | 103 | override fun setOffline() { 104 | TODO("Not yet implemented") 105 | } 106 | 107 | override fun setOnline() { 108 | TODO("Not yet implemented") 109 | } 110 | 111 | override fun trackMetric(p0: String?, p1: LDValue?, p2: Double) { 112 | TODO("Not yet implemented") 113 | } 114 | 115 | override fun trackData(p0: String?, p1: LDValue?) { 116 | TODO("Not yet implemented") 117 | } 118 | 119 | override fun track(p0: String?) { 120 | TODO("Not yet implemented") 121 | } 122 | 123 | override fun identify(p0: LDContext?): Future { 124 | TODO("Not yet implemented") 125 | } 126 | 127 | override fun flush() { 128 | TODO("Not yet implemented") 129 | } 130 | 131 | override fun allFlags(): MutableMap { 132 | TODO("Not yet implemented") 133 | } 134 | 135 | override fun jsonValueVariationDetail(p0: String?, p1: LDValue?): EvaluationDetail { 136 | TODO("Not yet implemented") 137 | } 138 | 139 | override fun getConnectionInformation(): ConnectionInformation { 140 | TODO("Not yet implemented") 141 | } 142 | 143 | override fun unregisterStatusListener(p0: LDStatusListener?) { 144 | TODO("Not yet implemented") 145 | } 146 | 147 | override fun registerStatusListener(p0: LDStatusListener?) { 148 | TODO("Not yet implemented") 149 | } 150 | 151 | override fun registerAllFlagsListener(p0: LDAllFlagsListener?) { 152 | TODO("Not yet implemented") 153 | } 154 | 155 | override fun unregisterAllFlagsListener(p0: LDAllFlagsListener?) { 156 | TODO("Not yet implemented") 157 | } 158 | 159 | override fun isDisableBackgroundPolling(): Boolean { 160 | TODO("Not yet implemented") 161 | } 162 | 163 | override fun getVersion(): String { 164 | TODO("Not yet implemented") 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /integrations/launch-darkly/src/appleMain/kotlin/io/github/kevincianfarini/monarch/launchdarkly/LaunchDarklyClientShim.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch.launchdarkly 2 | 3 | /** 4 | * A temporary, experimental shim to allow iOS consumers of Monarch to wire their own LDClient 5 | * as a data store using [LaunchDarklyClientShim.asFeatureFlagDataStore]. This interface will be 6 | * removed in future versions of this library when future, first-party support of LaunchDarkly 7 | * is available. 8 | */ 9 | public interface LaunchDarklyClientShim { 10 | 11 | /** 12 | * Return a [Boolean] value [forKey], or [default] if no value exists. 13 | */ 14 | public fun boolVariation(forKey: String, default: Boolean): Boolean 15 | 16 | /** 17 | * Return an [Int] value [forKey], or [default] if no value exists. 18 | */ 19 | public fun intVariation(forKey: String, default: Int): Int 20 | 21 | /** 22 | * Return a [Double] value [forKey], or [default] if no value exists. 23 | */ 24 | public fun doubleVariation(forKey: String, default: Double): Double 25 | 26 | /** 27 | * Return a [String] value [forKey], or [default] if no value exists. 28 | */ 29 | public fun stringVariation(forKey: String, default: String): String 30 | 31 | /** 32 | * Register a [handler] to be invoked when the value associated with [key] changes, scoped 33 | * to [owner]. 34 | */ 35 | public fun observe(key: String, owner: ObserverOwner, handler: () -> Unit) 36 | 37 | /** 38 | * Unregister all observers scoped to [owner]. 39 | */ 40 | public fun stopObserving(owner: ObserverOwner) 41 | } 42 | 43 | /** 44 | * A marker object used in [LaunchDarklyClientShim.observe] and 45 | * [LaunchDarklyClientShim.stopObserving]. 46 | */ 47 | public class ObserverOwner internal constructor() -------------------------------------------------------------------------------- /integrations/launch-darkly/src/appleMain/kotlin/io/github/kevincianfarini/monarch/launchdarkly/LaunchDarklyFeatureFlagDataStore.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch.launchdarkly 2 | 3 | import io.github.kevincianfarini.monarch.ObservableFeatureFlagDataStore 4 | import kotlinx.cinterop.ExperimentalForeignApi 5 | import kotlinx.cinterop.pin 6 | import kotlinx.coroutines.channels.awaitClose 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.flow.callbackFlow 9 | import kotlinx.coroutines.flow.conflate 10 | 11 | /** 12 | * Represent this [LaunchDarklyClientShim] as an [ObservableFeatureFlagDataStore]. 13 | */ 14 | public fun LaunchDarklyClientShim.asFeatureFlagDataStore(): ObservableFeatureFlagDataStore { 15 | return LaunchDarklyFeatureFlagDataStore(this) 16 | } 17 | 18 | private class LaunchDarklyFeatureFlagDataStore( 19 | private val shim: LaunchDarklyClientShim 20 | ) : ObservableFeatureFlagDataStore { 21 | 22 | override fun getBoolean(key: String, default: Boolean): Boolean { 23 | return shim.getValue(key, default) 24 | } 25 | 26 | override fun getString(key: String, default: String): String { 27 | return shim.getValue(key, default) 28 | } 29 | 30 | override fun getDouble(key: String, default: Double): Double { 31 | return shim.getValue(key, default) 32 | } 33 | 34 | override fun getLong(key: String, default: Long): Long { 35 | return shim.getValue(key, default) 36 | } 37 | 38 | override fun observeString(key: String, default: String): Flow { 39 | return shim.observeValue(key, default) 40 | } 41 | 42 | override fun observeBoolean(key: String, default: Boolean): Flow { 43 | return shim.observeValue(key, default) 44 | } 45 | 46 | override fun observeDouble(key: String, default: Double): Flow { 47 | return shim.observeValue(key, default) 48 | } 49 | 50 | override fun observeLong(key: String, default: Long): Flow { 51 | return shim.observeValue(key, default) 52 | } 53 | } 54 | 55 | @OptIn(ExperimentalForeignApi::class) 56 | private inline fun LaunchDarklyClientShim.observeValue(key: String, default: T): Flow { 57 | return callbackFlow { 58 | trySend(getValue(key, default)) 59 | val owner = ObserverOwner().pin() 60 | observe(key, owner.get()) { trySend(getValue(key, default)) } 61 | awaitClose { 62 | stopObserving(owner.get()) 63 | owner.unpin() 64 | } 65 | }.conflate() 66 | } 67 | 68 | private inline fun LaunchDarklyClientShim.getValue(key: String, default: T): T { 69 | return when (val clazz = T::class) { 70 | Boolean::class -> boolVariation(key, default as Boolean) as T 71 | String::class -> stringVariation(key, default as String) as T 72 | Double::class -> doubleVariation(key, default as Double) as T 73 | Long::class -> intVariation(key, (default as Long).toInt()).toLong() as T 74 | else -> throw IllegalArgumentException("Illegal type for getValue: $clazz") 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /integrations/launch-darkly/src/appleTest/kotlin/io/github/kevincianfarini/monarch/launchdarkly/LaunchDarklyFeatureFlagDataStoreTest.apple.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch.launchdarkly 2 | 3 | import io.github.kevincianfarini.monarch.ObservableFeatureFlagDataStore 4 | 5 | actual fun sut(): Pair { 6 | val client = FakeLDShim() 7 | return Pair(client.asFeatureFlagDataStore(), client) 8 | } 9 | 10 | private class FakeLDShim : LaunchDarklyClientShim, MutableLDClientInterface { 11 | 12 | private val flagValues = mutableMapOf() 13 | private val listeners = mutableSetOf() 14 | 15 | override fun setVariation(flagKey: String, value: Boolean) { 16 | flagValues[flagKey] = value 17 | listeners.filter { it.key == flagKey }.forEach { it.handler() } 18 | } 19 | 20 | override fun setVariation(flagKey: String, value: String) { 21 | flagValues[flagKey] = value 22 | listeners.filter { it.key == flagKey }.forEach { it.handler() } 23 | } 24 | 25 | override fun setVariation(flagKey: String, value: Double) { 26 | flagValues[flagKey] = value 27 | listeners.filter { it.key == flagKey }.forEach { it.handler() } 28 | } 29 | 30 | override fun setVariation(flagKey: String, value: Int) { 31 | flagValues[flagKey] = value 32 | listeners.filter { it.key == flagKey }.forEach { it.handler() } 33 | } 34 | 35 | override fun boolVariation(forKey: String, default: Boolean): Boolean { 36 | return (flagValues[forKey] as? Boolean) ?: default 37 | } 38 | 39 | override fun intVariation(forKey: String, default: Int): Int { 40 | return (flagValues[forKey] as? Int) ?: default 41 | } 42 | 43 | override fun doubleVariation(forKey: String, default: Double): Double { 44 | return (flagValues[forKey] as? Double) ?: default 45 | } 46 | 47 | override fun stringVariation(forKey: String, default: String): String { 48 | return (flagValues[forKey] as? String) ?: default 49 | } 50 | 51 | override fun observe(key: String, owner: ObserverOwner, handler: () -> Unit) { 52 | listeners.add(FlagListener(key, owner, handler)) 53 | } 54 | 55 | override fun stopObserving(owner: ObserverOwner) { 56 | listeners.removeAll { it.owner === owner } 57 | } 58 | } 59 | 60 | private data class FlagListener( 61 | val key: String, 62 | val owner: ObserverOwner, 63 | val handler: () -> Unit, 64 | ) 65 | -------------------------------------------------------------------------------- /integrations/launch-darkly/src/commonTest/kotlin/io/github/kevincianfarini/monarch/launchdarkly/LaunchDarklyFeatureFlagDataStoreTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch.launchdarkly 2 | 3 | import app.cash.turbine.test 4 | import io.github.kevincianfarini.monarch.ObservableFeatureFlagDataStore 5 | import kotlinx.coroutines.cancelAndJoin 6 | import kotlinx.coroutines.channels.Channel 7 | import kotlinx.coroutines.flow.launchIn 8 | import kotlinx.coroutines.launch 9 | import kotlinx.coroutines.test.runTest 10 | import kotlin.test.* 11 | 12 | class LaunchDarklyFeatureFlagDataStoreTest { 13 | 14 | @Test fun unpopulated_boolean_returns_default() { 15 | val (dataStore, _) = sut() 16 | assertFalse(dataStore.getBoolean("key", false)) 17 | } 18 | 19 | @Test fun populated_boolean_returns_value() { 20 | val (dataStore, mutate) = sut() 21 | mutate.setVariation("key", true) 22 | assertTrue(dataStore.getBoolean("key", false)) 23 | } 24 | 25 | @Test fun unpopulated_double_returns_default() { 26 | val (dataStore, _) = sut() 27 | assertEquals(0.0, dataStore.getDouble("key", 0.0)) 28 | } 29 | 30 | @Test fun populated_double_returns_value() { 31 | val (dataStore, mutate) = sut() 32 | mutate.setVariation("key", 4.1) 33 | assertEquals(4.1, dataStore.getDouble("key", 0.0)) 34 | } 35 | 36 | @Test fun unpopulated_long_returns_default() { 37 | val (dataStore, _) = sut() 38 | assertEquals(0L, dataStore.getLong("key", 0L)) 39 | } 40 | 41 | @Test fun populated_long_returns_value() { 42 | val (dataStore, mutate) = sut() 43 | mutate.setVariation("key", 4) 44 | assertEquals(4, dataStore.getLong("key", 0L)) 45 | } 46 | 47 | @Test fun unpopulated_string_returns_default() { 48 | val (dataStore, _) = sut() 49 | assertEquals("default", dataStore.getString("key", "default")) 50 | } 51 | 52 | @Test fun populated_string_returns_value() { 53 | val (dataStore, mutate) = sut() 54 | mutate.setVariation("key", "non_default") 55 | assertEquals("non_default", dataStore.getString("key", "default")) 56 | } 57 | 58 | @Test fun observing_string_coerces_initial_default_to_null() = runTest { 59 | val (dataStore, _) = sut() 60 | dataStore.observeString("key", "default").test { 61 | assertEquals("default", awaitItem()) 62 | } 63 | } 64 | 65 | @Test fun observing_string_emits_non_default_current_value() = runTest { 66 | val (dataStore, mutate) = sut() 67 | mutate.setVariation("key", "non_default") 68 | dataStore.observeString("key", "default").test { 69 | assertEquals("non_default", awaitItem()) 70 | } 71 | } 72 | 73 | @Test fun observing_string_emits_value_updates() = runTest { 74 | val (dataStore, mutate) = sut() 75 | dataStore.observeString("key", "default").test { 76 | assertEquals("default", awaitItem()) 77 | mutate.setVariation("key", "non_default") 78 | assertEquals("non_default", awaitItem()) 79 | } 80 | } 81 | 82 | @Test fun observing_boolean_coerces_initial_default_to_null() = runTest { 83 | val (dataStore, _) = sut() 84 | dataStore.observeBoolean("key", false).test { 85 | assertFalse(awaitItem()) 86 | } 87 | } 88 | 89 | @Test fun observing_boolean_emits_non_default_current_value() = runTest { 90 | val (dataStore, mutate) = sut() 91 | mutate.setVariation("key", true) 92 | dataStore.observeBoolean("key", false).test { 93 | assertTrue(awaitItem()) 94 | } 95 | } 96 | 97 | @Test fun observing_boolean_emits_value_updates() = runTest { 98 | val (dataStore, mutate) = sut() 99 | dataStore.observeBoolean("key", false).test { 100 | assertFalse(awaitItem()) 101 | mutate.setVariation("key", true) 102 | assertTrue(awaitItem()) 103 | } 104 | } 105 | 106 | @Test fun observing_double_coerces_initial_default_to_null() = runTest { 107 | val (dataStore, _) = sut() 108 | dataStore.observeDouble("key", -1.8).test { 109 | assertEquals(-1.8, awaitItem()) 110 | } 111 | } 112 | 113 | @Test fun observing_double_emits_non_default_current_value() = runTest { 114 | val (dataStore, mutate) = sut() 115 | mutate.setVariation("key", 3.5) 116 | dataStore.observeDouble("key", -1.8).test { 117 | assertEquals(3.5, awaitItem()) 118 | } 119 | } 120 | 121 | @Test fun observing_double_emits_value_updates() = runTest { 122 | val (dataStore, mutate) = sut() 123 | dataStore.observeDouble("key", -1.8).test { 124 | assertEquals(-1.8, awaitItem()) 125 | mutate.setVariation("key", 3.5) 126 | assertEquals(3.5, awaitItem()) 127 | } 128 | } 129 | 130 | @Test fun observing_long_coerces_initial_default_to_null() = runTest { 131 | val (dataStore, _) = sut() 132 | dataStore.observeLong("key", -1L).test { 133 | assertEquals(-1L, awaitItem()) 134 | } 135 | } 136 | 137 | @Test fun observing_long_emits_non_default_current_value() = runTest { 138 | val (dataStore, mutate) = sut() 139 | mutate.setVariation("key", 3) 140 | dataStore.observeLong("key", -1L).test { 141 | assertEquals(3L, awaitItem()) 142 | } 143 | } 144 | 145 | @Test fun observing_long_emits_value_updates() = runTest { 146 | val (dataStore, mutate) = sut() 147 | dataStore.observeLong("key", -1L).test { 148 | assertEquals(-1L, awaitItem()) 149 | mutate.setVariation("key", 3) 150 | assertEquals(3L, awaitItem()) 151 | } 152 | } 153 | 154 | @Test fun closed_cancelled_channel_does_not_throw() = runTest { 155 | val (dataStore, mutate) = sut() 156 | val trigger = Channel(Channel.RENDEZVOUS) 157 | val job = launch { 158 | dataStore.observeLong("foo", 1L).launchIn(this) 159 | trigger.send(Unit) 160 | } 161 | trigger.receive() 162 | job.cancelAndJoin() 163 | mutate.setVariation("foo", 2) 164 | } 165 | } 166 | 167 | expect fun sut(): Pair 168 | -------------------------------------------------------------------------------- /integrations/launch-darkly/src/commonTest/kotlin/io/github/kevincianfarini/monarch/launchdarkly/MutableLDClientInterface.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch.launchdarkly 2 | 3 | interface MutableLDClientInterface { 4 | fun setVariation(flagKey: String, value: Boolean) 5 | fun setVariation(flagKey: String, value: String) 6 | fun setVariation(flagKey: String, value: Double) 7 | fun setVariation(flagKey: String, value: Int) 8 | } 9 | 10 | -------------------------------------------------------------------------------- /kotlin-js-store/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | ansi-colors@^4.1.3: 6 | version "4.1.3" 7 | resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" 8 | integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw== 9 | 10 | ansi-regex@^5.0.1: 11 | version "5.0.1" 12 | resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" 13 | integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== 14 | 15 | ansi-styles@^4.0.0, ansi-styles@^4.1.0: 16 | version "4.3.0" 17 | resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" 18 | integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== 19 | dependencies: 20 | color-convert "^2.0.1" 21 | 22 | anymatch@~3.1.2: 23 | version "3.1.3" 24 | resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" 25 | integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== 26 | dependencies: 27 | normalize-path "^3.0.0" 28 | picomatch "^2.0.4" 29 | 30 | argparse@^2.0.1: 31 | version "2.0.1" 32 | resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" 33 | integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== 34 | 35 | balanced-match@^1.0.0: 36 | version "1.0.2" 37 | resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" 38 | integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== 39 | 40 | binary-extensions@^2.0.0: 41 | version "2.2.0" 42 | resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" 43 | integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== 44 | 45 | brace-expansion@^2.0.1: 46 | version "2.0.1" 47 | resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" 48 | integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== 49 | dependencies: 50 | balanced-match "^1.0.0" 51 | 52 | braces@~3.0.2: 53 | version "3.0.2" 54 | resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" 55 | integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== 56 | dependencies: 57 | fill-range "^7.0.1" 58 | 59 | browser-stdout@^1.3.1: 60 | version "1.3.1" 61 | resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" 62 | integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== 63 | 64 | buffer-from@^1.0.0: 65 | version "1.1.2" 66 | resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" 67 | integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== 68 | 69 | camelcase@^6.0.0: 70 | version "6.3.0" 71 | resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" 72 | integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== 73 | 74 | chalk@^4.1.0: 75 | version "4.1.2" 76 | resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" 77 | integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== 78 | dependencies: 79 | ansi-styles "^4.1.0" 80 | supports-color "^7.1.0" 81 | 82 | chokidar@^3.5.3: 83 | version "3.6.0" 84 | resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" 85 | integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== 86 | dependencies: 87 | anymatch "~3.1.2" 88 | braces "~3.0.2" 89 | glob-parent "~5.1.2" 90 | is-binary-path "~2.1.0" 91 | is-glob "~4.0.1" 92 | normalize-path "~3.0.0" 93 | readdirp "~3.6.0" 94 | optionalDependencies: 95 | fsevents "~2.3.2" 96 | 97 | cliui@^7.0.2: 98 | version "7.0.4" 99 | resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" 100 | integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== 101 | dependencies: 102 | string-width "^4.2.0" 103 | strip-ansi "^6.0.0" 104 | wrap-ansi "^7.0.0" 105 | 106 | color-convert@^2.0.1: 107 | version "2.0.1" 108 | resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" 109 | integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== 110 | dependencies: 111 | color-name "~1.1.4" 112 | 113 | color-name@~1.1.4: 114 | version "1.1.4" 115 | resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" 116 | integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== 117 | 118 | debug@^4.3.5: 119 | version "4.3.6" 120 | resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.6.tgz#2ab2c38fbaffebf8aa95fdfe6d88438c7a13c52b" 121 | integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg== 122 | dependencies: 123 | ms "2.1.2" 124 | 125 | decamelize@^4.0.0: 126 | version "4.0.0" 127 | resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837" 128 | integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ== 129 | 130 | diff@^5.2.0: 131 | version "5.2.0" 132 | resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" 133 | integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A== 134 | 135 | emoji-regex@^8.0.0: 136 | version "8.0.0" 137 | resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" 138 | integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== 139 | 140 | escalade@^3.1.1: 141 | version "3.1.2" 142 | resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.2.tgz#54076e9ab29ea5bf3d8f1ed62acffbb88272df27" 143 | integrity sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA== 144 | 145 | escape-string-regexp@^4.0.0: 146 | version "4.0.0" 147 | resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" 148 | integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== 149 | 150 | fill-range@^7.0.1: 151 | version "7.0.1" 152 | resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" 153 | integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== 154 | dependencies: 155 | to-regex-range "^5.0.1" 156 | 157 | find-up@^5.0.0: 158 | version "5.0.0" 159 | resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" 160 | integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== 161 | dependencies: 162 | locate-path "^6.0.0" 163 | path-exists "^4.0.0" 164 | 165 | flat@^5.0.2: 166 | version "5.0.2" 167 | resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" 168 | integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== 169 | 170 | format-util@^1.0.5: 171 | version "1.0.5" 172 | resolved "https://registry.yarnpkg.com/format-util/-/format-util-1.0.5.tgz#1ffb450c8a03e7bccffe40643180918cc297d271" 173 | integrity sha512-varLbTj0e0yVyRpqQhuWV+8hlePAgaoFRhNFj50BNjEIrw1/DphHSObtqwskVCPWNgzwPoQrZAbfa/SBiicNeg== 174 | 175 | fs.realpath@^1.0.0: 176 | version "1.0.0" 177 | resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" 178 | integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== 179 | 180 | fsevents@~2.3.2: 181 | version "2.3.3" 182 | resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" 183 | integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== 184 | 185 | get-caller-file@^2.0.5: 186 | version "2.0.5" 187 | resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" 188 | integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== 189 | 190 | glob-parent@~5.1.2: 191 | version "5.1.2" 192 | resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" 193 | integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== 194 | dependencies: 195 | is-glob "^4.0.1" 196 | 197 | glob@^8.1.0: 198 | version "8.1.0" 199 | resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e" 200 | integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== 201 | dependencies: 202 | fs.realpath "^1.0.0" 203 | inflight "^1.0.4" 204 | inherits "2" 205 | minimatch "^5.0.1" 206 | once "^1.3.0" 207 | 208 | has-flag@^4.0.0: 209 | version "4.0.0" 210 | resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" 211 | integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== 212 | 213 | he@^1.2.0: 214 | version "1.2.0" 215 | resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" 216 | integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== 217 | 218 | inflight@^1.0.4: 219 | version "1.0.6" 220 | resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" 221 | integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== 222 | dependencies: 223 | once "^1.3.0" 224 | wrappy "1" 225 | 226 | inherits@2: 227 | version "2.0.4" 228 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" 229 | integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== 230 | 231 | is-binary-path@~2.1.0: 232 | version "2.1.0" 233 | resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" 234 | integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== 235 | dependencies: 236 | binary-extensions "^2.0.0" 237 | 238 | is-extglob@^2.1.1: 239 | version "2.1.1" 240 | resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" 241 | integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== 242 | 243 | is-fullwidth-code-point@^3.0.0: 244 | version "3.0.0" 245 | resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" 246 | integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== 247 | 248 | is-glob@^4.0.1, is-glob@~4.0.1: 249 | version "4.0.3" 250 | resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" 251 | integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== 252 | dependencies: 253 | is-extglob "^2.1.1" 254 | 255 | is-number@^7.0.0: 256 | version "7.0.0" 257 | resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" 258 | integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== 259 | 260 | is-plain-obj@^2.1.0: 261 | version "2.1.0" 262 | resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" 263 | integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== 264 | 265 | is-unicode-supported@^0.1.0: 266 | version "0.1.0" 267 | resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" 268 | integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== 269 | 270 | js-yaml@^4.1.0: 271 | version "4.1.0" 272 | resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" 273 | integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== 274 | dependencies: 275 | argparse "^2.0.1" 276 | 277 | locate-path@^6.0.0: 278 | version "6.0.0" 279 | resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" 280 | integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== 281 | dependencies: 282 | p-locate "^5.0.0" 283 | 284 | log-symbols@^4.1.0: 285 | version "4.1.0" 286 | resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" 287 | integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== 288 | dependencies: 289 | chalk "^4.1.0" 290 | is-unicode-supported "^0.1.0" 291 | 292 | minimatch@^5.0.1, minimatch@^5.1.6: 293 | version "5.1.6" 294 | resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" 295 | integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== 296 | dependencies: 297 | brace-expansion "^2.0.1" 298 | 299 | mocha@10.7.0: 300 | version "10.7.0" 301 | resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.7.0.tgz#9e5cbed8fa9b37537a25bd1f7fb4f6fc45458b9a" 302 | integrity sha512-v8/rBWr2VO5YkspYINnvu81inSz2y3ODJrhO175/Exzor1RcEZZkizgE2A+w/CAXXoESS8Kys5E62dOHGHzULA== 303 | dependencies: 304 | ansi-colors "^4.1.3" 305 | browser-stdout "^1.3.1" 306 | chokidar "^3.5.3" 307 | debug "^4.3.5" 308 | diff "^5.2.0" 309 | escape-string-regexp "^4.0.0" 310 | find-up "^5.0.0" 311 | glob "^8.1.0" 312 | he "^1.2.0" 313 | js-yaml "^4.1.0" 314 | log-symbols "^4.1.0" 315 | minimatch "^5.1.6" 316 | ms "^2.1.3" 317 | serialize-javascript "^6.0.2" 318 | strip-json-comments "^3.1.1" 319 | supports-color "^8.1.1" 320 | workerpool "^6.5.1" 321 | yargs "^16.2.0" 322 | yargs-parser "^20.2.9" 323 | yargs-unparser "^2.0.0" 324 | 325 | ms@2.1.2: 326 | version "2.1.2" 327 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" 328 | integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== 329 | 330 | ms@^2.1.3: 331 | version "2.1.3" 332 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" 333 | integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== 334 | 335 | normalize-path@^3.0.0, normalize-path@~3.0.0: 336 | version "3.0.0" 337 | resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" 338 | integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== 339 | 340 | once@^1.3.0: 341 | version "1.4.0" 342 | resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" 343 | integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== 344 | dependencies: 345 | wrappy "1" 346 | 347 | p-limit@^3.0.2: 348 | version "3.1.0" 349 | resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" 350 | integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== 351 | dependencies: 352 | yocto-queue "^0.1.0" 353 | 354 | p-locate@^5.0.0: 355 | version "5.0.0" 356 | resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" 357 | integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== 358 | dependencies: 359 | p-limit "^3.0.2" 360 | 361 | path-exists@^4.0.0: 362 | version "4.0.0" 363 | resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" 364 | integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== 365 | 366 | picomatch@^2.0.4, picomatch@^2.2.1: 367 | version "2.3.1" 368 | resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" 369 | integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== 370 | 371 | randombytes@^2.1.0: 372 | version "2.1.0" 373 | resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" 374 | integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== 375 | dependencies: 376 | safe-buffer "^5.1.0" 377 | 378 | readdirp@~3.6.0: 379 | version "3.6.0" 380 | resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" 381 | integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== 382 | dependencies: 383 | picomatch "^2.2.1" 384 | 385 | require-directory@^2.1.1: 386 | version "2.1.1" 387 | resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" 388 | integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== 389 | 390 | safe-buffer@^5.1.0: 391 | version "5.2.1" 392 | resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" 393 | integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== 394 | 395 | serialize-javascript@^6.0.2: 396 | version "6.0.2" 397 | resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" 398 | integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== 399 | dependencies: 400 | randombytes "^2.1.0" 401 | 402 | source-map-support@0.5.21: 403 | version "0.5.21" 404 | resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" 405 | integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== 406 | dependencies: 407 | buffer-from "^1.0.0" 408 | source-map "^0.6.0" 409 | 410 | source-map@^0.6.0: 411 | version "0.6.1" 412 | resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" 413 | integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== 414 | 415 | string-width@^4.1.0, string-width@^4.2.0: 416 | version "4.2.3" 417 | resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" 418 | integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== 419 | dependencies: 420 | emoji-regex "^8.0.0" 421 | is-fullwidth-code-point "^3.0.0" 422 | strip-ansi "^6.0.1" 423 | 424 | strip-ansi@^6.0.0, strip-ansi@^6.0.1: 425 | version "6.0.1" 426 | resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" 427 | integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== 428 | dependencies: 429 | ansi-regex "^5.0.1" 430 | 431 | strip-json-comments@^3.1.1: 432 | version "3.1.1" 433 | resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" 434 | integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== 435 | 436 | supports-color@^7.1.0: 437 | version "7.2.0" 438 | resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" 439 | integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== 440 | dependencies: 441 | has-flag "^4.0.0" 442 | 443 | supports-color@^8.1.1: 444 | version "8.1.1" 445 | resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" 446 | integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== 447 | dependencies: 448 | has-flag "^4.0.0" 449 | 450 | to-regex-range@^5.0.1: 451 | version "5.0.1" 452 | resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" 453 | integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== 454 | dependencies: 455 | is-number "^7.0.0" 456 | 457 | typescript@5.5.4: 458 | version "5.5.4" 459 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba" 460 | integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q== 461 | 462 | workerpool@^6.5.1: 463 | version "6.5.1" 464 | resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.5.1.tgz#060f73b39d0caf97c6db64da004cd01b4c099544" 465 | integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== 466 | 467 | wrap-ansi@^7.0.0: 468 | version "7.0.0" 469 | resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" 470 | integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== 471 | dependencies: 472 | ansi-styles "^4.0.0" 473 | string-width "^4.1.0" 474 | strip-ansi "^6.0.0" 475 | 476 | wrappy@1: 477 | version "1.0.2" 478 | resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" 479 | integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== 480 | 481 | y18n@^5.0.5: 482 | version "5.0.8" 483 | resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" 484 | integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== 485 | 486 | yargs-parser@^20.2.2, yargs-parser@^20.2.9: 487 | version "20.2.9" 488 | resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" 489 | integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== 490 | 491 | yargs-unparser@^2.0.0: 492 | version "2.0.0" 493 | resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb" 494 | integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== 495 | dependencies: 496 | camelcase "^6.0.0" 497 | decamelize "^4.0.0" 498 | flat "^5.0.2" 499 | is-plain-obj "^2.1.0" 500 | 501 | yargs@^16.2.0: 502 | version "16.2.0" 503 | resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" 504 | integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== 505 | dependencies: 506 | cliui "^7.0.2" 507 | escalade "^3.1.1" 508 | get-caller-file "^2.0.5" 509 | require-directory "^2.1.1" 510 | string-width "^4.2.0" 511 | y18n "^5.0.5" 512 | yargs-parser "^20.2.2" 513 | 514 | yocto-queue@^0.1.0: 515 | version "0.1.0" 516 | resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" 517 | integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== 518 | -------------------------------------------------------------------------------- /mixins/README.md: -------------------------------------------------------------------------------- 1 | # Extending with mixins 2 | 3 | The core flag types may not sufficiently cover your needs. Custom flag types, 4 | like ones encoded with JSON, are common in complex programs. Define custom flags 5 | by implementing the `FeatureFlag` interface. 6 | 7 | ```kt 8 | abstract class JsonFeatureFlag( 9 | override val key: String, 10 | override val default: EncodedType, 11 | ) : FeatureFlag { 12 | 13 | abstract fun deserialize(raw: String, json: Json): EncodedType 14 | } 15 | 16 | @Serializable class OneValue(val value: Int) 17 | @Serializable class TwoValue(val firstValue: Int, val secondValue: Int) 18 | 19 | object OneValueFlag : JsonFeatureFlag( 20 | key = "one_value", 21 | default = OneValue(1), 22 | ) { /* deserialization omitted */ } 23 | 24 | object TwoValueFlag : JsonFeatureFlag( 25 | key = "two_value", 26 | default = TwoValue(1, 2), 27 | ) { /* deserialization omitted */ } 28 | ``` 29 | 30 | When used in conjunction with a `FeatureFlagManagerMixin`, Monarch can be extended to support 31 | any arbitrary feature flag format. `FeatureFlagManagerMixin` instances should return a value when invoked if it handles a given 32 | `FeatureFlag` type, otherwise it should return `null`. A `FeatureFlagManager` will 33 | exhaustively check all of its mixins until the requested flag is handled. 34 | 35 | ```kt 36 | class JsonFeatureFlagManagerMixin(private val json: Json) : FeatureFlagManagerMixin { 37 | 38 | override suspend fun currentValueForOrNull( 39 | flag: FeatureFlag, 40 | store: FeatureFlagDataStore, 41 | ): T? = when (flag) { 42 | is JsonFeatureFlag -> store.getString(flag.key, flag.serializedDefault(json)).let { string -> 43 | flag.deserialize(string, json) 44 | } 45 | else -> null // This mixin doesn't handle this flag. 46 | } 47 | } 48 | ``` 49 | 50 | The above sample mixin implementation will handle _all_ instances of `JsonFeatureFlag`, and therefore both 51 | `OneValueFeatureFlag` and `TwoValueFeatureFlag` would be handled. 52 | 53 | Observing custom implementations of `FeatureFlag` requires implementing `ObservableFeatureFlagManagerMixin`. 54 | 55 | ```kt 56 | public class ObservableJsonFeatureFlagManagerMixin( 57 | private val json: Json, 58 | ) : ObservableFeatureFlagManagerMixin { 59 | 60 | public override fun currentValueOrNull( 61 | flag: FeatureFlag, 62 | store: ObservableFeatureFlagDataStore 63 | ): T? { /* omitted */ } 64 | 65 | public override fun valuesOrNull( 66 | flag: FeatureFlag, 67 | store: ObservableFeatureFlagDataStore 68 | ): Flow? = when (flag) { 69 | is JsonFeatureFlag -> store.observeString(flag.key, flag.serializedDefault(json)).map { string -> 70 | flag.deserialize(string, json) 71 | } 72 | else -> null 73 | } 74 | } 75 | ``` 76 | -------------------------------------------------------------------------------- /mixins/kotlinx-serialization-json/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.dokka) 3 | alias(libs.plugins.kotlin.multiplatform) 4 | alias(libs.plugins.kotlin.serialization) 5 | alias(libs.plugins.publish) 6 | } 7 | 8 | kotlin { 9 | explicitApi() 10 | 11 | iosArm64() 12 | iosSimulatorArm64() 13 | iosX64() 14 | jvm() 15 | js { 16 | nodejs { 17 | testTask { 18 | useMocha { 19 | timeout = "5s" 20 | } 21 | } 22 | } 23 | } 24 | linuxArm64() 25 | linuxX64() 26 | macosArm64() 27 | macosX64() 28 | mingwX64() 29 | tvosArm64() 30 | tvosSimulatorArm64() 31 | tvosX64() 32 | watchosArm32() 33 | watchosArm64() 34 | watchosDeviceArm64() 35 | watchosSimulatorArm64() 36 | watchosX64() 37 | 38 | sourceSets { 39 | commonMain.dependencies { 40 | api(libs.kotlinx.serialization.json) 41 | api(project(":core")) 42 | } 43 | commonTest.dependencies { 44 | implementation(libs.kotlin.test) 45 | implementation(project(":test")) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /mixins/kotlinx-serialization-json/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=kotlinx-serialization-mixin 2 | POM_NAME=Monarch kotlinx-serialzation Mixin 3 | POM_DESCRIPTION=Multiplatform mixin that decode JSON feature flags -------------------------------------------------------------------------------- /mixins/kotlinx-serialization-json/src/commonMain/kotlin/io/github/kevincianfarini/monarch/mixins/JsonFeatureFlag.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch.mixins 2 | 3 | import io.github.kevincianfarini.monarch.FeatureFlag 4 | import kotlinx.serialization.KSerializer 5 | import kotlinx.serialization.json.Json 6 | 7 | /** 8 | * An implementation of [FeatureFlag] that supports deserializing JSON strings to [OptionType]. 9 | */ 10 | public abstract class JsonFeatureFlag( 11 | public override val key: String, 12 | public override val default: OptionType, 13 | 14 | /** 15 | * The [KSerializer] used to marshall objects between their JSON string representation and their object 16 | * representation. 17 | */ 18 | private val serializer: KSerializer, 19 | ) : FeatureFlag { 20 | 21 | internal fun deserialize(raw: String, json: Json): OptionType { 22 | return json.decodeFromString(serializer, raw) 23 | } 24 | 25 | internal fun serializedDefault(json: Json): String { 26 | return json.encodeToString(serializer, default) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /mixins/kotlinx-serialization-json/src/commonMain/kotlin/io/github/kevincianfarini/monarch/mixins/JsonFeatureFlagManagerMixin.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch.mixins 2 | 3 | import io.github.kevincianfarini.monarch.FeatureFlag 4 | import io.github.kevincianfarini.monarch.FeatureFlagDataStore 5 | import io.github.kevincianfarini.monarch.FeatureFlagManagerMixin 6 | import kotlinx.serialization.json.Json 7 | 8 | /** 9 | * An implementation of [FeatureFlagManagerMixin] that adds support for [JsonFeatureFlag] instances. 10 | */ 11 | public class JsonFeatureFlagManagerMixin( 12 | /** 13 | * The [Json] instance used to serialize and deserialze feature flag values. This value should adhere to the JSON 14 | * requirements imposed by your [FeatureFlagDataStore] implementation's underlying data source. 15 | */ 16 | private val json: Json, 17 | ) : FeatureFlagManagerMixin { 18 | 19 | public override fun currentValueOfOrNull( 20 | flag: FeatureFlag, 21 | store: FeatureFlagDataStore, 22 | ): T? = when (flag) { 23 | is JsonFeatureFlag -> store.getString(flag.key, flag.serializedDefault(json)).let { string -> 24 | flag.deserialize(string, json) 25 | } 26 | else -> null 27 | } 28 | } -------------------------------------------------------------------------------- /mixins/kotlinx-serialization-json/src/commonMain/kotlin/io/github/kevincianfarini/monarch/mixins/ObservableJsonFeatureFlagManagerMixin.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch.mixins 2 | 3 | import io.github.kevincianfarini.monarch.* 4 | import kotlinx.coroutines.flow.Flow 5 | import kotlinx.coroutines.flow.map 6 | import kotlinx.serialization.json.Json 7 | 8 | /** 9 | * An implementation of [ObservableFeatureFlagManagerMixin] that adds support for [JsonFeatureFlag] instances. 10 | */ 11 | public class ObservableJsonFeatureFlagManagerMixin( 12 | /** 13 | * The [Json] instance used to serialize and deserialze feature flag values. This value should adhere to the JSON 14 | * requirements imposed by your [FeatureFlagDataStore] implementation's underlying data source. 15 | */ 16 | private val json: Json, 17 | ) : ObservableFeatureFlagManagerMixin, FeatureFlagManagerMixin by JsonFeatureFlagManagerMixin(json) { 18 | 19 | public override fun valuesOfOrNull( 20 | flag: FeatureFlag, 21 | store: ObservableFeatureFlagDataStore 22 | ): Flow? = when (flag) { 23 | is JsonFeatureFlag -> store.observeString(flag.key, flag.serializedDefault(json)).map { string -> 24 | flag.deserialize(string, json) 25 | } 26 | else -> null 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /mixins/kotlinx-serialization-json/src/commonTest/kotlin/io/github/kevincianfarini/monarch/mixins/JsonFeatureFlagManagerMixinTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch.mixins 2 | 3 | import io.github.kevincianfarini.monarch.BooleanFeatureFlag 4 | import io.github.kevincianfarini.monarch.InMemoryFeatureFlagDataStore 5 | import kotlinx.serialization.json.Json 6 | import kotlin.test.Test 7 | import kotlin.test.assertEquals 8 | import kotlin.test.assertNull 9 | 10 | class JsonFeatureFlagManagerMixinTest { 11 | 12 | @Test fun returns_null_on_unhandled_feature_flag() = assertNull( 13 | mixin().currentValueOfOrNull( 14 | flag = NotJson, 15 | store = InMemoryFeatureFlagDataStore(), 16 | ) 17 | ) 18 | 19 | @Test fun returns_default_on_null_value_result() = assertEquals( 20 | expected = SomeJsonFlag.default, 21 | actual = mixin().currentValueOfOrNull( 22 | flag = SomeJsonFlag, 23 | store = InMemoryFeatureFlagDataStore(), 24 | ) 25 | ) 26 | 27 | @Test fun returns_deserialized_value() { 28 | val store = InMemoryFeatureFlagDataStore().apply { 29 | setValue( 30 | key = SomeJsonFlag.key, 31 | value = """{"bar":2}""", 32 | ) 33 | } 34 | assertEquals( 35 | expected = Foo(2), 36 | actual = mixin().currentValueOfOrNull( 37 | flag = SomeJsonFlag, 38 | store = store, 39 | ) 40 | ) 41 | } 42 | 43 | private fun mixin(json: Json = Json.Default) = JsonFeatureFlagManagerMixin(json) 44 | } 45 | 46 | object NotJson : BooleanFeatureFlag( 47 | key = "not_json", 48 | default = false, 49 | ) -------------------------------------------------------------------------------- /mixins/kotlinx-serialization-json/src/commonTest/kotlin/io/github/kevincianfarini/monarch/mixins/JsonFeatureFlagTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch.mixins 2 | 3 | import kotlinx.serialization.Serializable 4 | import kotlinx.serialization.json.Json 5 | import kotlin.test.Test 6 | import kotlin.test.assertEquals 7 | 8 | class JsonFeatureFlagTest { 9 | 10 | @Test fun deserializes_with_supplied_serializer() { 11 | val jsonString = """{"bar":2}""" 12 | assertEquals( 13 | expected = Foo(2), 14 | actual = SomeJsonFlag.deserialize(jsonString, Json.Default) 15 | ) 16 | } 17 | } 18 | 19 | object SomeJsonFlag : JsonFeatureFlag( 20 | key = "some_flag", 21 | default = Foo(1), 22 | serializer = Foo.serializer(), 23 | ) 24 | 25 | @Serializable 26 | data class Foo(val bar: Int) -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ], 6 | "reviewers": ["kevincianfarini"] 7 | } 8 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | mavenCentral() 4 | google() 5 | gradlePluginPortal() 6 | } 7 | } 8 | 9 | rootProject.name = "monarch" 10 | 11 | include(":compose") 12 | include(":core") 13 | include(":integrations") 14 | include(":integrations:environment-variable") 15 | include(":integrations:launch-darkly") 16 | include(":mixins") 17 | include(":mixins:kotlinx-serialization-json") 18 | include(":test") 19 | 20 | -------------------------------------------------------------------------------- /test/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.dokka) 3 | alias(libs.plugins.kotlin.multiplatform) 4 | alias(libs.plugins.publish) 5 | } 6 | 7 | kotlin { 8 | 9 | explicitApi() 10 | 11 | iosArm64() 12 | iosSimulatorArm64() 13 | iosX64() 14 | jvm() 15 | js { 16 | nodejs { 17 | testTask { 18 | useMocha { 19 | timeout = "5s" 20 | } 21 | } 22 | } 23 | } 24 | linuxArm64() 25 | linuxX64() 26 | macosArm64() 27 | macosX64() 28 | mingwX64() 29 | tvosArm64() 30 | tvosSimulatorArm64() 31 | tvosX64() 32 | watchosArm32() 33 | watchosArm64() 34 | watchosDeviceArm64() 35 | watchosSimulatorArm64() 36 | watchosX64() 37 | 38 | sourceSets { 39 | commonMain.dependencies { 40 | api(project(":core")) 41 | } 42 | commonTest.dependencies { 43 | implementation(libs.kotlin.test) 44 | implementation(libs.kotlinx.coroutines.test) 45 | implementation(libs.turbine) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /test/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=test 2 | POM_NAME=Monarch Testing Support 3 | POM_DESCRIPTION=Monarch testing facilities -------------------------------------------------------------------------------- /test/src/commonMain/kotlin/io/github/kevincianfarini/monarch/InMemoryFeatureFlagDataStore.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch 2 | 3 | import kotlinx.coroutines.flow.* 4 | 5 | public class InMemoryFeatureFlagDataStore : ObservableFeatureFlagDataStore { 6 | 7 | private val store = MutableStateFlow>(emptyMap()) 8 | 9 | public override fun observeString(key: String, default: String): Flow { 10 | return store.observeValue(key, default) 11 | } 12 | 13 | public override fun observeBoolean(key: String, default: Boolean): Flow { 14 | return store.observeValue(key, default) 15 | } 16 | 17 | public override fun observeDouble(key: String, default: Double): Flow { 18 | return store.observeValue(key, default) 19 | } 20 | 21 | public override fun observeLong(key: String, default: Long): Flow { 22 | return store.observeValue(key, default) 23 | } 24 | 25 | public override fun getBoolean(key: String, default: Boolean): Boolean { 26 | return store.getValue(key, default) 27 | } 28 | 29 | public override fun getString(key: String, default: String): String { 30 | return store.getValue(key, default) 31 | } 32 | 33 | public override fun getDouble(key: String, default: Double): Double { 34 | return store.getValue(key, default) 35 | } 36 | 37 | public override fun getLong(key: String, default: Long): Long { 38 | return store.getValue(key, default) 39 | } 40 | 41 | public fun setValue(key: String, value: Any?) { 42 | store.update { map -> 43 | map.plus(key to value) 44 | } 45 | } 46 | } 47 | 48 | private inline fun StateFlow>.getValue(key: String, default: T): T { 49 | return (value[key] as T?) ?: default 50 | } 51 | 52 | private inline fun StateFlow>.observeValue(key: String, default: T): Flow { 53 | return map { map -> (map[key] as T?) ?: default }.distinctUntilChanged() 54 | } 55 | -------------------------------------------------------------------------------- /test/src/commonMain/kotlin/io/github/kevincianfarini/monarch/InMemoryFeatureFlagManager.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.MutableStateFlow 5 | import kotlinx.coroutines.flow.map 6 | import kotlinx.coroutines.flow.update 7 | 8 | /** 9 | * An implementation of [ObservableFeatureFlagManager] that allows mutating the value of flags in-memory. 10 | */ 11 | public class InMemoryFeatureFlagManager : ObservableFeatureFlagManager { 12 | 13 | private val store = MutableStateFlow>(emptyMap()) 14 | 15 | @Suppress("UNCHECKED_CAST") 16 | public override fun currentValueOf(flag: FeatureFlag): T { 17 | return (store.value[flag.key] ?: flag.default) as T 18 | } 19 | 20 | @Suppress("UNCHECKED_CAST") 21 | public override fun valuesOf(flag: FeatureFlag): Flow { 22 | return store.map { map -> (map[flag.key] ?: flag.default) as T } 23 | } 24 | 25 | /** 26 | * Set the value of [flag] in this manager instance to [option]. 27 | */ 28 | public fun setCurrentValueOf(flag: FeatureFlag, option: T) { 29 | store.update { map -> map.plus(flag.key to option) } 30 | } 31 | } -------------------------------------------------------------------------------- /test/src/commonTest/kotlin/io/github/kevincianfarini/monarch/FakeFeatureFlagManagerTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.kevincianfarini.monarch 2 | 3 | import app.cash.turbine.test 4 | import kotlin.test.Test 5 | import kotlin.test.assertEquals 6 | import kotlinx.coroutines.flow.first 7 | import kotlinx.coroutines.test.runTest 8 | 9 | class FakeFeatureFlagManagerTest { 10 | 11 | @Test fun returns_default_value() { 12 | runTest { 13 | assertEquals( 14 | expected = SomeFlag.default, 15 | actual = InMemoryFeatureFlagManager().currentValueOf(SomeFlag) 16 | ) 17 | } 18 | } 19 | 20 | @Test fun returns_explicitly_set_value() { 21 | runTest { 22 | val manager = InMemoryFeatureFlagManager().apply { 23 | setCurrentValueOf(SomeFlag, 1L) 24 | } 25 | assertEquals( 26 | expected = 1L, 27 | actual = manager.currentValueOf(SomeFlag) 28 | ) 29 | } 30 | } 31 | 32 | @Test fun observing_returns_default_value() { 33 | runTest { 34 | assertEquals( 35 | expected = SomeFlag.default, 36 | actual = InMemoryFeatureFlagManager() 37 | .valuesOf(SomeFlag) 38 | .first(), 39 | ) 40 | } 41 | } 42 | 43 | @Test fun observing_emits_updates_to_flags() { 44 | runTest { 45 | val manager = InMemoryFeatureFlagManager() 46 | manager.valuesOf(SomeFlag).test { 47 | assertEquals( 48 | expected = SomeFlag.default, 49 | actual = awaitItem(), 50 | ) 51 | manager.setCurrentValueOf(SomeFlag, 1L) 52 | assertEquals( 53 | expected = 1L, 54 | actual = awaitItem(), 55 | ) 56 | expectNoEvents() 57 | cancelAndIgnoreRemainingEvents() 58 | } 59 | } 60 | } 61 | } 62 | 63 | private object SomeFlag : LongFeatureFlag( 64 | key = "some_flag", 65 | default = -1L, 66 | ) 67 | --------------------------------------------------------------------------------