├── bin ├── hermit.hcl ├── jar ├── java ├── jcmd ├── jdb ├── jfr ├── jjs ├── jmap ├── jmod ├── jps ├── rmic ├── rmid ├── .gradle-8.14.2.pkg ├── .openjdk@11.pkg ├── gradle ├── jaotc ├── jarsigner ├── javac ├── javadoc ├── javap ├── jconsole ├── jdeprscan ├── jdeps ├── jhsdb ├── jimage ├── jinfo ├── jlink ├── jshell ├── jstack ├── jstat ├── jstatd ├── keytool ├── pack200 ├── serialver ├── unpack200 ├── jrunscript ├── rmiregistry ├── README.hermit.md ├── activate-hermit └── hermit ├── .github ├── CODEOWNERS └── workflows │ ├── test.yml │ └── publish.yml ├── lib ├── gradle.properties ├── src │ ├── main │ │ └── kotlin │ │ │ └── app │ │ │ └── cash │ │ │ └── kfsm │ │ │ ├── Effect.kt │ │ │ ├── NoPathToTargetState.kt │ │ │ ├── annotations │ │ │ └── ExperimentalLibraryApi.kt │ │ │ ├── OutboxStatus.kt │ │ │ ├── Invariant.kt │ │ │ ├── States.kt │ │ │ ├── InvalidStateForTransition.kt │ │ │ ├── InvariantDsl.kt │ │ │ ├── EffectPayload.kt │ │ │ ├── NextStateSelector.kt │ │ │ ├── Transition.kt │ │ │ ├── OutboxMessage.kt │ │ │ ├── Value.kt │ │ │ ├── DeferrableEffect.kt │ │ │ ├── package.md │ │ │ ├── OutboxHandler.kt │ │ │ ├── StateMachine.kt │ │ │ ├── TransitionerAsync.kt │ │ │ ├── StateMachineUtilities.kt │ │ │ ├── State.kt │ │ │ ├── Transitioner.kt │ │ │ └── MachineBuilder.kt │ └── test │ │ └── kotlin │ │ └── app │ │ └── cash │ │ └── kfsm │ │ ├── TrafficLight.kt │ │ ├── Letter.kt │ │ ├── TransitionTest.kt │ │ ├── StatesTest.kt │ │ ├── InvalidStateTransitionTest.kt │ │ ├── exemplar │ │ ├── Hamster.kt │ │ ├── HamsterTransitioner.kt │ │ ├── HamsterTransitions.kt │ │ └── PenelopesPerfectDayTest.kt │ │ ├── StateMachineUtilitiesTest.kt │ │ ├── StateTest.kt │ │ ├── MachineBuilderTest.kt │ │ ├── OutboxTest.kt │ │ ├── InvariantTest.kt │ │ ├── StateMachineTest.kt │ │ ├── TransitionerAsyncTest.kt │ │ └── TransitionerTest.kt ├── build.gradle.kts ├── module.md └── api │ └── lib.api ├── lib-guice ├── gradle.properties ├── src │ ├── test │ │ └── kotlin │ │ │ └── app │ │ │ └── cash │ │ │ └── kfsm │ │ │ └── guice │ │ │ └── test │ │ │ ├── TestModule.kt │ │ │ ├── TestValue.kt │ │ │ ├── TestState.kt │ │ │ ├── TestTransitioner.kt │ │ │ ├── TestTransitions.kt │ │ │ └── KfsmGuiceIntegrationTest.kt │ └── main │ │ └── kotlin │ │ └── app │ │ └── cash │ │ └── kfsm │ │ └── guice │ │ ├── annotations │ │ ├── TransitionDefinition.kt │ │ └── TransitionerDefinition.kt │ │ ├── package.md │ │ ├── StateMachine.kt │ │ └── KfsmModule.kt ├── build.gradle.kts ├── module.md ├── api │ └── lib-guice.api ├── README.md └── docs │ └── kfsm-guice-design.md ├── settings.gradle.kts ├── .goosehints ├── .gitignore ├── gradle.properties ├── gradle └── libs.versions.toml ├── CONTRIBUTING.md ├── module.md ├── dokka-docs └── Module.md ├── RELEASING.md ├── CHANGELOG.md ├── docs └── implementation_guide.md └── .editorconfig /bin/hermit.hcl: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bin/jar: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/java: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/jcmd: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/jdb: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/jfr: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/jjs: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/jmap: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/jmod: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/jps: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/rmic: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/rmid: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/.gradle-8.14.2.pkg: -------------------------------------------------------------------------------- 1 | hermit -------------------------------------------------------------------------------- /bin/.openjdk@11.pkg: -------------------------------------------------------------------------------- 1 | hermit -------------------------------------------------------------------------------- /bin/gradle: -------------------------------------------------------------------------------- 1 | .gradle-8.14.2.pkg -------------------------------------------------------------------------------- /bin/jaotc: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/jarsigner: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/javac: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/javadoc: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/javap: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/jconsole: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/jdeprscan: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/jdeps: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/jhsdb: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/jimage: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/jinfo: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/jlink: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/jshell: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/jstack: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/jstat: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/jstatd: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/keytool: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/pack200: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/serialver: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/unpack200: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/jrunscript: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /bin/rmiregistry: -------------------------------------------------------------------------------- 1 | .openjdk@11.pkg -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @block/bitcoin-network-os @mmollaverdi @cjustice @jacksoncook 2 | -------------------------------------------------------------------------------- /lib/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=kfsm 2 | POM_NAME=kFSM 3 | POM_DESCRIPTION=Kotlin Finite State Machine 4 | POM_PACKAGING=jar 5 | -------------------------------------------------------------------------------- /lib-guice/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=kfsm-guice 2 | POM_NAME=kFSM Guice Integration 3 | POM_DESCRIPTION=Guice integration module for kFSM 4 | POM_PACKAGING=jar 5 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/app/cash/kfsm/Effect.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | fun interface Effect, S : State> { 4 | fun apply(v: V): Result 5 | } 6 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | mavenCentral() 5 | } 6 | } 7 | 8 | rootProject.name = "kfsm" 9 | 10 | include(":lib") 11 | include(":lib-guice") 12 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/app/cash/kfsm/NoPathToTargetState.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | class NoPathToTargetState(val value: Value<*, *, *>, val targetState: State<*, *, *>) : Exception( 4 | "No transition path exists from ${value.state} to $targetState for $value" 5 | ) 6 | -------------------------------------------------------------------------------- /bin/README.hermit.md: -------------------------------------------------------------------------------- 1 | # Hermit environment 2 | 3 | This is a [Hermit](https://github.com/cashapp/hermit) bin directory. 4 | 5 | The symlinks in this directory are managed by Hermit and will automatically 6 | download and install Hermit itself as well as packages. These packages are 7 | local to this environment. 8 | -------------------------------------------------------------------------------- /lib-guice/src/test/kotlin/app/cash/kfsm/guice/test/TestModule.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm.guice.test 2 | 3 | import app.cash.kfsm.guice.KfsmModule 4 | 5 | class TestModule : KfsmModule( 6 | types = typeLiteralsFor(String::class.java, TestValue::class.java, TestState::class.java) 7 | ) 8 | -------------------------------------------------------------------------------- /lib-guice/src/test/kotlin/app/cash/kfsm/guice/test/TestValue.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm.guice.test 2 | 3 | import app.cash.kfsm.Value 4 | 5 | data class TestValue( 6 | override val state: TestState, 7 | override val id: String 8 | ) : Value { 9 | override fun update(newState: TestState): TestValue = copy(state = newState) 10 | } -------------------------------------------------------------------------------- /.goosehints: -------------------------------------------------------------------------------- 1 | # These are hints for Goose. 2 | 3 | - When you finish making a change, follow up with `gradle apiDump && gradle build` 4 | - There is no `gradlew`, only `gradle`. If you don't see it, run `. bin/activate-hermit` to set up your environment. This only needs to be done once. 5 | - The base package for the core library is app.cash.kfsm. The base package for the guice supplement is app.cash.kfsm.guice 6 | -------------------------------------------------------------------------------- /lib-guice/src/test/kotlin/app/cash/kfsm/guice/test/TestState.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm.guice.test 2 | 3 | import app.cash.kfsm.State 4 | 5 | sealed class TestState(transitionsFn: () -> Set) : State(transitionsFn) { 6 | data object START : TestState({ setOf(MIDDLE) }) 7 | data object MIDDLE : TestState({ setOf(END) }) 8 | data object END : TestState({ emptySet() }) 9 | } 10 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/app/cash/kfsm/annotations/ExperimentalLibraryApi.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm.annotations 2 | 3 | @RequiresOptIn( 4 | message = "This API is experimental and may change in the future.", 5 | level = RequiresOptIn.Level.WARNING 6 | ) 7 | @Retention(AnnotationRetention.BINARY) 8 | @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY) 9 | annotation class ExperimentalLibraryApi() 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .claude 2 | .gradle 3 | **/out/ 4 | .idea 5 | build/ 6 | *.iml 7 | *.swp 8 | dev/ 9 | docker-sources/ 10 | # This repo uses a hermit-managed gradle, so we don't want any gradle wrapper files checked in. For details, 11 | # see https://docs.google.com/document/d/1waDOogp5oIJ7SzzibG6wUeGySIc5yzmbnP_dF5TfV6I/edit#heading=h.aywctsilnd6x 12 | /gradlew 13 | /gradlew.bat 14 | /gradle/wrapper/ 15 | /.hermit 16 | local.properties 17 | -------------------------------------------------------------------------------- /lib-guice/src/test/kotlin/app/cash/kfsm/guice/test/TestTransitioner.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm.guice.test 2 | 3 | import app.cash.kfsm.Transition 4 | import app.cash.kfsm.Transitioner 5 | import app.cash.kfsm.guice.annotations.TransitionerDefinition 6 | import jakarta.inject.Inject 7 | import jakarta.inject.Singleton 8 | 9 | @TransitionerDefinition 10 | @Singleton 11 | class TestTransitioner @Inject constructor() : Transitioner, TestValue, TestState>() 12 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/app/cash/kfsm/OutboxStatus.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | import app.cash.kfsm.annotations.ExperimentalLibraryApi 4 | 5 | /** 6 | * Represents the processing status of an outbox message. 7 | */ 8 | @ExperimentalLibraryApi 9 | enum class OutboxStatus { 10 | /** The effect has been captured but not yet processed */ 11 | PENDING, 12 | 13 | /** The effect is currently being processed */ 14 | PROCESSING, 15 | 16 | /** The effect has been successfully executed */ 17 | COMPLETED, 18 | 19 | /** The effect execution failed (may be retried) */ 20 | FAILED 21 | } 22 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/app/cash/kfsm/Invariant.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | /** 4 | * Represents an invariant that must hold true for a value in a particular state. 5 | * 6 | * @param V The type of value being validated 7 | * @param S The type of state that this invariant applies to 8 | */ 9 | interface Invariant, S : State> { 10 | /** 11 | * Validates that the given value meets this invariant. 12 | * 13 | * @param value The value to validate 14 | * @return A [Result] containing either the value if the invariant holds, or an error if it doesn't 15 | */ 16 | fun validate(value: V): Result 17 | } 18 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/app/cash/kfsm/States.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | /** A collection of states that is guaranteed to be non-empty. */ 4 | data class States, S : State>(val a: S, val other: Set) { 5 | constructor(first: S, vararg others: S) : this(first, others.toSet()) 6 | 7 | val set: Set = other + a 8 | 9 | companion object { 10 | fun , S : State> Set.toStates(): States = when { 11 | isEmpty() -> throw IllegalArgumentException("Cannot create States from empty set") 12 | else -> toList().let { States(it.first(), it.drop(1).toSet()) } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /lib/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("java-library") 3 | id("org.jetbrains.kotlin.jvm") 4 | id("com.vanniktech.maven.publish") version "0.33.0" 5 | } 6 | 7 | repositories { 8 | mavenCentral() 9 | } 10 | 11 | java { 12 | toolchain { 13 | languageVersion.set(JavaLanguageVersion.of(11)) 14 | } 15 | withSourcesJar() 16 | } 17 | 18 | mavenPublishing { 19 | configure(com.vanniktech.maven.publish.KotlinJvm()) 20 | } 21 | 22 | dependencies { 23 | implementation(libs.kotlinReflect) 24 | 25 | testImplementation(libs.kotestProperty) 26 | testImplementation(libs.kotestAssertions) 27 | testImplementation(libs.kotestJunitRunnerJvm) 28 | testImplementation(libs.mockk) 29 | } 30 | -------------------------------------------------------------------------------- /bin/activate-hermit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This file must be used with "source bin/activate-hermit" from bash or zsh. 3 | # You cannot run it directly 4 | # 5 | # THIS FILE IS GENERATED; DO NOT MODIFY 6 | 7 | if [ "${BASH_SOURCE-}" = "$0" ]; then 8 | echo "You must source this script: \$ source $0" >&2 9 | exit 33 10 | fi 11 | 12 | BIN_DIR="$(dirname "${BASH_SOURCE[0]:-${(%):-%x}}")" 13 | if "${BIN_DIR}/hermit" noop > /dev/null; then 14 | eval "$("${BIN_DIR}/hermit" activate "${BIN_DIR}/..")" 15 | 16 | if [ -n "${BASH-}" ] || [ -n "${ZSH_VERSION-}" ]; then 17 | hash -r 2>/dev/null 18 | fi 19 | 20 | echo "Hermit environment $("${HERMIT_ENV}"/bin/hermit env HERMIT_ENV) activated" 21 | fi 22 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/app/cash/kfsm/InvalidStateForTransition.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | data class InvalidStateForTransition(private val transition: Transition<*, *, *>, val value: Value<*, *, *>) : Exception( 4 | "Value cannot transition ${ 5 | transition.from.set.toList().sortedBy { state -> state.toString() }.joinToString(", ", prefix = "{", postfix = "}") 6 | } to ${transition.to}, because it is currently ${value.state}. [id=${value.id}]" 7 | ) { 8 | /** 9 | * Get the state of the underlying value. 10 | * This reflects the state that could not be transitioned successfully. 11 | */ 12 | inline fun , reified S : State> getState(): S? = value.state as? S 13 | } 14 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/app/cash/kfsm/InvariantDsl.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | /** 4 | * Creates an invariant that checks if the given predicate holds true for the value. 5 | */ 6 | fun , S : State> invariant( 7 | message: String, 8 | predicate: (V) -> Boolean 9 | ): Invariant = object : Invariant { 10 | override fun validate(value: V): Result { 11 | return if (predicate(value)) { 12 | Result.success(value) 13 | } else { 14 | Result.failure(PreconditionNotMet(message)) 15 | } 16 | } 17 | } 18 | 19 | /** 20 | * Exception thrown when a value fails to meet an invariant. 21 | */ 22 | data class PreconditionNotMet(override val message: String) : Exception(message) 23 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/app/cash/kfsm/EffectPayload.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | /** 4 | * Represents the serialized data for a deferrable effect. 5 | * 6 | * This payload contains all the information needed to reconstruct and execute 7 | * an effect at a later time by an outbox processor. 8 | * 9 | * @property effectType A unique identifier for the type of effect (e.g., "send_email", "disable_camera") 10 | * @property data The serialized effect data (JSON, Protocol Buffers, etc.) 11 | * @property metadata Optional key-value pairs for additional context (e.g., correlation IDs, trace IDs) 12 | */ 13 | data class EffectPayload( 14 | val effectType: String, 15 | val data: String, 16 | val metadata: Map = emptyMap() 17 | ) 18 | -------------------------------------------------------------------------------- /lib-guice/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("java-library") 3 | id("org.jetbrains.kotlin.jvm") 4 | id("com.vanniktech.maven.publish") version "0.33.0" 5 | } 6 | 7 | repositories { 8 | mavenCentral() 9 | } 10 | 11 | java { 12 | toolchain { 13 | languageVersion.set(JavaLanguageVersion.of(11)) 14 | } 15 | withSourcesJar() 16 | } 17 | 18 | mavenPublishing { 19 | configure(com.vanniktech.maven.publish.KotlinJvm()) 20 | } 21 | 22 | dependencies { 23 | api(project(":lib")) 24 | implementation(libs.guice) 25 | implementation(libs.reflections) 26 | 27 | testImplementation(libs.kotestAssertions) 28 | testImplementation(libs.kotestJunitRunnerJvm) 29 | testImplementation(libs.kotestProperty) 30 | testImplementation(libs.mockk) 31 | } 32 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | GROUP=app.cash.kfsm 2 | VERSION_NAME=0.12.0 3 | 4 | POM_URL=https://github.com/block/kfsm/ 5 | POM_SCM_URL=https://github.com/block/kfsm/ 6 | POM_SCM_CONNECTION=scm:git:git://github.com/block/kfsm.git 7 | POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/block/kfsm.git 8 | POM_LICENCE_NAME=The Apache Software License, Version 2.0 9 | POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0.txt 10 | POM_LICENCE_DIST=repo 11 | POM_DEVELOPER_ID=cashapp 12 | POM_DEVELOPER_NAME=Cash App 13 | 14 | SONATYPE_HOST=CENTRAL_PORTAL 15 | RELEASE_SIGNING_ENABLED=true 16 | 17 | mavenCentralPublishing=true 18 | mavenCentralAutomaticPublishing=true 19 | signAllPublications=true 20 | 21 | org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled 22 | org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true 23 | -------------------------------------------------------------------------------- /lib/src/test/kotlin/app/cash/kfsm/TrafficLight.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | /** 4 | * A simple state machine modeling a traffic light. 5 | */ 6 | data class TrafficLight(override val state: Colour, override val id: String) : Value { 7 | override fun update(newState: Colour): TrafficLight = copy(state = newState) 8 | } 9 | 10 | sealed class Colour(to: () -> Set) : State(to) { 11 | fun next(count: Int): List = 12 | if (count <= 0) emptyList() 13 | else subsequentStates.filterNot { it == this }.firstOrNull()?.let { listOf(it) + it.next(count - 1) } ?: emptyList() 14 | } 15 | 16 | data object Red : Colour(to = { setOf(Yellow) }) 17 | data object Yellow : Colour(to = { setOf(Green, Red) }) 18 | data object Green : Colour(to = { setOf(Yellow) }) 19 | -------------------------------------------------------------------------------- /lib-guice/src/main/kotlin/app/cash/kfsm/guice/annotations/TransitionDefinition.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm.guice.annotations 2 | 3 | /** 4 | * Marks a class as a transition that should be automatically discovered and bound by the KFSM Guice integration. 5 | * 6 | * This annotation should be applied to classes that implement [app.cash.kfsm.Transition]. When used in conjunction 7 | * with [app.cash.kfsm.guice.KfsmModule], transitions marked with this annotation will be automatically discovered 8 | * and made available for dependency injection. 9 | * 10 | * Example usage: 11 | * ```kotlin 12 | * @TransitionDefinition 13 | * class MyTransition @Inject constructor(deps: MyDeps) : Transition() 14 | * ``` 15 | */ 16 | @Target(AnnotationTarget.CLASS) 17 | @Retention(AnnotationRetention.RUNTIME) 18 | annotation class TransitionDefinition -------------------------------------------------------------------------------- /lib/src/test/kotlin/app/cash/kfsm/Letter.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | /** A simple state machine that represents a letter of the alphabet. */ 4 | data class Letter(override val state: Char, override val id: String) : Value { 5 | override fun update(newState: Char): Letter = copy(state = newState) 6 | } 7 | 8 | sealed class Char(to: () -> Set) : State(to) { 9 | fun next(count: Int): List = 10 | if (count <= 0) emptyList() 11 | else subsequentStates.filterNot { it == this }.firstOrNull()?.let { listOf(it) + it.next(count - 1) } ?: emptyList() 12 | } 13 | 14 | data object A : Char(to = { setOf(B) }) 15 | data object B : Char(to = { setOf(B, C, D) }) 16 | data object C : Char(to = { setOf(D) }) 17 | data object D : Char(to = { setOf(B, E) }) 18 | data object E : Char(to = { emptySet() }) 19 | -------------------------------------------------------------------------------- /lib/src/test/kotlin/app/cash/kfsm/TransitionTest.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | import io.kotest.assertions.throwables.shouldThrow 4 | import io.kotest.core.spec.style.StringSpec 5 | 6 | class TransitionTest : StringSpec({ 7 | 8 | "cannot create an invalid state transition" { 9 | shouldThrow { LetterTransition(A, C) } 10 | } 11 | 12 | "cannot create an invalid state transition from a set of states" { 13 | shouldThrow { LetterTransition(States(B, A), C) } 14 | } 15 | 16 | }) 17 | 18 | open class LetterTransition(from: States, to: app.cash.kfsm.Char): Transition(from, to) { 19 | constructor(from: app.cash.kfsm.Char, to: app.cash.kfsm.Char) : this(States(from), to) 20 | 21 | val specificToThisTransitionType: String = "${from.set} -> $to" 22 | } 23 | 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | env: 10 | ENVIRONMENT: TESTING 11 | TERM: dumb 12 | 13 | jobs: 14 | jvm: 15 | runs-on: ${{ matrix.os }} 16 | 17 | strategy: 18 | matrix: 19 | os: [ubuntu-latest] 20 | cmd: 21 | - bin/gradle clean build -i --scan --no-daemon 22 | 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | 27 | - name: Test 28 | run: ${{ matrix.cmd }} 29 | 30 | - name: Publish Test Report 31 | if: ${{ always() }} 32 | uses: mikepenz/action-junit-report@a83fd2b5d58d4fc702e690c1ea688d702d28d281 33 | with: 34 | check_name: Test Report - ${{ matrix.cmd }} 35 | report_paths: '**/build/test-results/test/TEST-*.xml' 36 | github_token: ${{ secrets.GITHUB_TOKEN }} 37 | -------------------------------------------------------------------------------- /lib-guice/src/test/kotlin/app/cash/kfsm/guice/test/TestTransitions.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm.guice.test 2 | 3 | import app.cash.kfsm.States 4 | import app.cash.kfsm.Transition 5 | import app.cash.kfsm.guice.annotations.TransitionDefinition 6 | import com.google.inject.Inject 7 | 8 | @TransitionDefinition 9 | class StartToMiddle @Inject constructor() : Transition( 10 | from = States(TestState.START), 11 | to = TestState.MIDDLE 12 | ) { 13 | override fun effect(value: TestValue): Result = 14 | Result.success(value.update(TestState.MIDDLE)) 15 | } 16 | 17 | @TransitionDefinition 18 | class MiddleToEnd @Inject constructor() : Transition( 19 | from = States(TestState.MIDDLE), 20 | to = TestState.END 21 | ) { 22 | override fun effect(value: TestValue): Result = 23 | Result.success(value.update(TestState.END)) 24 | } 25 | -------------------------------------------------------------------------------- /lib/src/test/kotlin/app/cash/kfsm/StatesTest.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | import app.cash.kfsm.States.Companion.toStates 4 | import io.kotest.assertions.throwables.shouldThrow 5 | import io.kotest.core.spec.style.StringSpec 6 | import io.kotest.matchers.shouldBe 7 | import io.kotest.property.Arb 8 | import io.kotest.property.arbitrary.element 9 | import io.kotest.property.arbitrary.set 10 | import io.kotest.property.checkAll 11 | 12 | class StatesTest : StringSpec({ 13 | 14 | "vararg constructor" { 15 | checkAll(Arb.set(arbChar, range = 1 .. 5)) { set -> 16 | States(set.first(), set).set shouldBe set 17 | set.toStates().set shouldBe set 18 | } 19 | } 20 | 21 | "fails to create from empty set" { 22 | shouldThrow { 23 | emptySet().toStates() 24 | } 25 | } 26 | }) { 27 | companion object { 28 | val arbChar: Arb = Arb.element(A, B, C, D, E) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/app/cash/kfsm/NextStateSelector.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | /** 4 | * A selector that determines the appropriate next state for a transition based on the current value. 5 | * This allows for dynamic state selection during transitions based on runtime conditions. 6 | * 7 | * @param ID The type of the identifier used in values 8 | * @param V The type of values that can be transitioned, must implement [Value] 9 | * @param S The type of states in the state machine, must implement [State] 10 | */ 11 | fun interface NextStateSelector, S : State> { 12 | /** 13 | * Selects the appropriate next state for a transition based on the current value. 14 | * 15 | * @param value The current value being transitioned 16 | * @return The selected next state 17 | * @throws IllegalStateException if no valid next state can be selected 18 | */ 19 | fun apply(value: V): Result 20 | } 21 | -------------------------------------------------------------------------------- /lib/src/test/kotlin/app/cash/kfsm/InvalidStateTransitionTest.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | 6 | class InvalidStateTransitionTest : StringSpec({ 7 | "with single from-state has correct message" { 8 | InvalidStateForTransition(LetterTransition(States(A), B), Letter(E, id = "my_letter")).message shouldBe 9 | "Value cannot transition {A} to B, because it is currently E. [id=my_letter]" 10 | } 11 | 12 | "with many from-states has correct message" { 13 | InvalidStateForTransition(LetterTransition(States(C, B), D), Letter(E, "my_letter")).message shouldBe 14 | "Value cannot transition {B, C} to D, because it is currently E. [id=my_letter]" 15 | } 16 | 17 | "exposes transition and state" { 18 | val error = InvalidStateForTransition(LetterTransition(States(A), B), Letter(E, "my_letter")) 19 | 20 | error.getState() shouldBe E 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /lib-guice/src/main/kotlin/app/cash/kfsm/guice/annotations/TransitionerDefinition.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm.guice.annotations 2 | 3 | /** 4 | * Annotation used to mark a class as a transitioner that should be automatically discovered and bound by [KfsmModule]. 5 | * 6 | * Classes annotated with this annotation will be automatically discovered and bound to the appropriate 7 | * [Transitioner] type when used with [KfsmModule]. This allows for automatic dependency injection 8 | * of transitioners without manual binding. 9 | * 10 | * Example usage: 11 | * ```kotlin 12 | * @TransitionerDefinition 13 | * class MyTransitioner @Inject constructor(deps: MyDeps): Transitioner { 14 | * override fun transition(value: MyValue, transition: MyTransition): Result { 15 | * // Implementation 16 | * } 17 | * } 18 | * ``` 19 | */ 20 | @Target(AnnotationTarget.CLASS) 21 | @Retention(AnnotationRetention.RUNTIME) 22 | annotation class TransitionerDefinition 23 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/app/cash/kfsm/Transition.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | import app.cash.kfsm.Effect 4 | 5 | open class Transition, S : State>( 6 | val from: States, 7 | val to: S 8 | ) : Effect { 9 | init { 10 | from.set.filterNot { state -> state.canDirectlyTransitionTo(to) }.let { invalidTransitions -> 11 | require(invalidTransitions.isEmpty()) { 12 | "invalid transition(s): ${invalidTransitions.map { fromState -> 13 | "$fromState->$to" 14 | }}" 15 | } 16 | } 17 | } 18 | 19 | constructor(from: S, to: S) : this(States(from), to) 20 | 21 | /** The effect executed when transitioning from [from] to [to]. */ 22 | open fun effect(value: V): Result = Result.success(value) 23 | 24 | override fun apply(v: V): Result = effect(v) 25 | 26 | /** The effect executed when transitioning from [from] to [to], but only when using `TransitionerAsync` */ 27 | open suspend fun effectAsync(value: V): Result = effect(value) 28 | } 29 | -------------------------------------------------------------------------------- /lib-guice/module.md: -------------------------------------------------------------------------------- 1 | # Module lib-guice 2 | 3 | This module provides integration between KFSM and Google Guice for dependency injection. 4 | 5 | ## Key Components 6 | 7 | ### KfsmModule 8 | A Guice module that automatically discovers and binds: 9 | - Transitions marked with @TransitionDefinition 10 | - Transitioners marked with @TransitionerDefinition 11 | - The StateMachine implementation 12 | 13 | ### StateMachine 14 | A Guice-aware implementation that: 15 | - Manages transitions between states 16 | - Provides utilities for finding available transitions 17 | - Handles execution of transitions 18 | 19 | ### Annotations 20 | - @TransitionDefinition - Marks transitions for auto-discovery 21 | - @TransitionerDefinition - Marks transitioners for auto-discovery 22 | 23 | ## Usage 24 | 25 | 1. Create your states, values, and transitions as normal 26 | 2. Annotate your transitions with @TransitionDefinition 27 | 3. Annotate your transitioner with @TransitionerDefinition 28 | 4. Install KfsmModule in your Guice injector 29 | 5. Inject the StateMachine where needed 30 | 31 | See the test package for complete examples. 32 | -------------------------------------------------------------------------------- /lib/src/test/kotlin/app/cash/kfsm/exemplar/Hamster.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm.exemplar 2 | 3 | import app.cash.kfsm.Value 4 | import app.cash.kfsm.State 5 | 6 | data class Hamster( 7 | val name: String, 8 | override val state: State, 9 | ): Value { 10 | override fun update(newState: State): Hamster = this.copy(state = newState) 11 | 12 | override val id : String = name 13 | 14 | fun eat(food: String) { 15 | println("@ (・ェ・´)◞ (eats $food)") 16 | } 17 | 18 | fun sleep() { 19 | println("◟(`・ェ・) ╥━╥ (goes to bed)") 20 | } 21 | 22 | sealed class State( 23 | transitionsFn: () -> Set, 24 | ) : app.cash.kfsm.State(transitionsFn) 25 | 26 | /** Hamster is awake... and hungry! */ 27 | data object Awake : State({ setOf(Eating) }) 28 | 29 | /** Hamster is eating ... what will they do next? */ 30 | data object Eating : State({ setOf(RunningOnWheel, Asleep, Resting) }) 31 | 32 | /** Wheeeeeee! */ 33 | data object RunningOnWheel : State({ setOf(Asleep, Resting) }) 34 | 35 | /** Sits in the corner, chilling */ 36 | data object Resting : State({ setOf(Asleep) }) 37 | 38 | /** Zzzzzzzzz */ 39 | data object Asleep : State({ setOf(Awake) }) 40 | } 41 | -------------------------------------------------------------------------------- /lib/src/test/kotlin/app/cash/kfsm/exemplar/HamsterTransitioner.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm.exemplar 2 | 3 | import app.cash.kfsm.Transitioner 4 | import app.cash.kfsm.exemplar.Hamster.State 5 | 6 | class HamsterTransitioner( 7 | val saves: MutableList = mutableListOf() 8 | ) : Transitioner() { 9 | 10 | val locks = mutableListOf() 11 | val unlocks = mutableListOf() 12 | val notifications = mutableListOf() 13 | 14 | 15 | // Any action you might wish to take prior to transitioning, such as pessimistic locking 16 | override fun preHook(value: Hamster, via: HamsterTransition): Result = runCatching { 17 | locks.add(value) 18 | } 19 | 20 | // This is where you define how to save your updated value to a data store 21 | override fun persist(from: State, value: Hamster, via: HamsterTransition): Result = 22 | Result.success(value.also(saves::add)) 23 | 24 | // Any action you might wish to take after transitioning successfully, such as sending events or notifications 25 | override fun postHook(from: State, value: Hamster, via: HamsterTransition): Result = runCatching { 26 | notifications.add("${value.name} was $from, then began ${via.description} and is now ${via.to}") 27 | unlocks.add(value) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/src/test/kotlin/app/cash/kfsm/StateMachineUtilitiesTest.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.result.shouldBeSuccess 5 | import io.kotest.matchers.shouldBe 6 | 7 | class StateMachineUtilitiesTest : StringSpec({ 8 | "mermaidStateDiagramMarkdown generates correct diagram" { 9 | // Given a machine with multiple transitions 10 | val machine = fsm { 11 | A.becomes { 12 | B.via { it.copy(id = "banana") } 13 | } 14 | B.becomes { 15 | C.via { it.copy(id = "cinnamon") } 16 | D.via { it.copy(id = "durian") } 17 | B.via { it.copy(id = "berry") } 18 | } 19 | D.becomes { 20 | E.via { it.copy(id = "eggplant") } 21 | } 22 | }.getOrThrow() 23 | 24 | // When generating a diagram starting from A 25 | val diagram = machine.mermaidStateDiagramMarkdown(A) 26 | 27 | // Then the diagram contains all expected elements 28 | diagram shouldBe """ 29 | |stateDiagram-v2 30 | | [*] --> A 31 | | A --> B 32 | | B --> B 33 | | B --> C 34 | | B --> D 35 | | D --> E 36 | """.trimMargin() 37 | } 38 | }) 39 | -------------------------------------------------------------------------------- /lib/module.md: -------------------------------------------------------------------------------- 1 | # Module lib 2 | 3 | The core KFSM library provides the fundamental building blocks for creating type-safe finite state machines in Kotlin. 4 | 5 | ## Key Components 6 | 7 | ### State 8 | The base class for defining states in your state machine. Each state knows: 9 | - Which states it can transition to directly 10 | - What invariants must hold while in this state 11 | - How to find paths to other reachable states 12 | 13 | ### Value 14 | An interface for objects that can transition between states. Values: 15 | - Track their current state 16 | - Can be updated to new states through transitions 17 | - Maintain their identity across state changes 18 | 19 | ### Transition 20 | Defines valid movements between states, including: 21 | - Source states that can use this transition 22 | - Target state after the transition 23 | - Optional effects that occur during the transition 24 | 25 | ### Transitioner 26 | Handles the mechanics of moving values between states: 27 | - Validates state changes 28 | - Manages pre/post transition hooks 29 | - Handles persistence if needed 30 | 31 | ### StateMachine 32 | Provides utilities for working with state machines: 33 | - Verification of state machine properties 34 | - Documentation generation 35 | - Path finding between states 36 | 37 | ## Getting Started 38 | 39 | See the [README](../README.md) for setup instructions and basic usage examples. 40 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/app/cash/kfsm/OutboxMessage.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | import app.cash.kfsm.annotations.ExperimentalLibraryApi 4 | 5 | /** 6 | * Represents a message stored in the transactional outbox. 7 | * 8 | * Outbox messages capture side effects that should be executed after a state transition 9 | * has been successfully persisted to the database. By storing these messages in the same 10 | * transaction as the state change, we ensure that effects are never lost and will eventually 11 | * be processed (at-least-once delivery semantics). 12 | * 13 | * @param ID The type of unique identifier for values 14 | * @property id Unique identifier for this outbox message 15 | * @property valueId The ID of the value that was transitioned 16 | * @property effectPayload The serialized effect to be executed 17 | * @property createdAt Timestamp when the message was created (epoch milliseconds) 18 | * @property processedAt Timestamp when the message was processed (epoch milliseconds), null if not yet processed 19 | * @property status Current processing status of the message 20 | * @property attemptCount Number of processing attempts (for retry logic) 21 | * @property lastError Error message from the most recent failed attempt, if any 22 | */ 23 | @ExperimentalLibraryApi 24 | data class OutboxMessage( 25 | val id: String, 26 | val valueId: ID, 27 | val effectPayload: EffectPayload, 28 | val createdAt: Long, 29 | val processedAt: Long? = null, 30 | val status: OutboxStatus = OutboxStatus.PENDING, 31 | val attemptCount: Int = 0, 32 | val lastError: String? = null 33 | ) 34 | -------------------------------------------------------------------------------- /bin/hermit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # THIS FILE IS GENERATED; DO NOT MODIFY 4 | 5 | set -eo pipefail 6 | 7 | export HERMIT_USER_HOME=~ 8 | 9 | if [ -z "${HERMIT_STATE_DIR}" ]; then 10 | case "$(uname -s)" in 11 | Darwin) 12 | export HERMIT_STATE_DIR="${HERMIT_USER_HOME}/Library/Caches/hermit" 13 | ;; 14 | Linux) 15 | export HERMIT_STATE_DIR="${XDG_CACHE_HOME:-${HERMIT_USER_HOME}/.cache}/hermit" 16 | ;; 17 | esac 18 | fi 19 | 20 | export HERMIT_DIST_URL="${HERMIT_DIST_URL:-https://github.com/cashapp/hermit/releases/download/stable}" 21 | HERMIT_CHANNEL="$(basename "${HERMIT_DIST_URL}")" 22 | export HERMIT_CHANNEL 23 | export HERMIT_EXE=${HERMIT_EXE:-${HERMIT_STATE_DIR}/pkg/hermit@${HERMIT_CHANNEL}/hermit} 24 | 25 | if [ ! -x "${HERMIT_EXE}" ]; then 26 | echo "Bootstrapping ${HERMIT_EXE} from ${HERMIT_DIST_URL}" 1>&2 27 | INSTALL_SCRIPT="$(mktemp)" 28 | # This value must match that of the install script 29 | INSTALL_SCRIPT_SHA256="180e997dd837f839a3072a5e2f558619b6d12555cd5452d3ab19d87720704e38" 30 | if [ "${INSTALL_SCRIPT_SHA256}" = "BYPASS" ]; then 31 | curl -fsSL "${HERMIT_DIST_URL}/install.sh" -o "${INSTALL_SCRIPT}" 32 | else 33 | # Install script is versioned by its sha256sum value 34 | curl -fsSL "${HERMIT_DIST_URL}/install-${INSTALL_SCRIPT_SHA256}.sh" -o "${INSTALL_SCRIPT}" 35 | # Verify install script's sha256sum 36 | openssl dgst -sha256 "${INSTALL_SCRIPT}" | \ 37 | awk -v EXPECTED="$INSTALL_SCRIPT_SHA256" \ 38 | '$2!=EXPECTED {print "Install script sha256 " $2 " does not match " EXPECTED; exit 1}' 39 | fi 40 | /bin/bash "${INSTALL_SCRIPT}" 1>&2 41 | fi 42 | 43 | exec "${HERMIT_EXE}" --level=fatal exec "$0" -- "$@" 44 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | guice = "7.0.0" 3 | kotest = "5.8.0" 4 | # @pin 5 | kotlin = "1.9.20" 6 | kotlinBinaryCompatibilityPlugin = "0.13.2" 7 | mockk = "1.13.10" 8 | reflections = "0.10.2" 9 | versionCatalogUpdateGradlePlugin = "0.8.3" 10 | versionsGradlePlugin = "0.50.0" 11 | mavenPublish = "0.33.0" 12 | 13 | [libraries] 14 | guice = { module = "com.google.inject:guice", version.ref = "guice" } 15 | kotestAssertions = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" } 16 | kotestJunitRunnerJvm = { module = "io.kotest:kotest-runner-junit5-jvm", version.ref = "kotest" } 17 | kotestProperty = { module = "io.kotest:kotest-property", version.ref = "kotest" } 18 | kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } 19 | mockk = { module = "io.mockk:mockk", version.ref = "mockk" } 20 | reflections = { module = "org.reflections:reflections", version.ref = "reflections" } 21 | mavenPublishGradlePlugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "mavenPublish" } 22 | 23 | [bundles] 24 | kotest = [ 25 | "kotestAssertions", 26 | "kotestJunitRunnerJvm", 27 | "kotestProperty", 28 | ] 29 | 30 | [plugins] 31 | dokka = "org.jetbrains.dokka:2.0.0" 32 | kotlinBinaryCompatibilityPlugin = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "kotlinBinaryCompatibilityPlugin" } 33 | kotlinGradlePlugin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 34 | mavenPublish = { id = "com.vanniktech.maven.publish.base", version.ref = "mavenPublish" } 35 | versionCatalogUpdateGradlePlugin = { id = "nl.littlerobots.version-catalog-update", version.ref = "versionCatalogUpdateGradlePlugin" } 36 | versionsGradlePlugin = { id = "com.github.ben-manes.versions", version.ref = "versionsGradlePlugin" } 37 | -------------------------------------------------------------------------------- /lib-guice/src/main/kotlin/app/cash/kfsm/guice/package.md: -------------------------------------------------------------------------------- 1 | # Package app.cash.kfsm.guice 2 | 3 | The kFSM Guice integration package provides seamless dependency injection support for kFSM state machines. It enables automatic discovery and injection of transitions and transitioners, making it easier to integrate state machines into Guice-based applications. 4 | 5 | ## Key Components 6 | 7 | ### Module Configuration 8 | 9 | * [KfsmModule] - Guice module that sets up automatic discovery and binding of state machine components. 10 | * [StateMachine] - Extended functionality for Guice-managed state machines. 11 | 12 | ### Annotations 13 | 14 | * [TransitionDefinition] - Marks a class as providing state transitions. 15 | * [TransitionerDefinition] - Configures how a transitioner should be constructed. 16 | 17 | ## Features 18 | 19 | * Automatic discovery of transitions 20 | * Dependency injection for transition implementations 21 | * Support for multiple state machines in one application 22 | * Integration with existing Guice modules 23 | 24 | ## Example Usage 25 | 26 | ```kotlin 27 | // Define your module 28 | class MyAppModule : KfsmModule() { 29 | override fun configure() { 30 | // Basic setup 31 | install(KfsmModule()) 32 | 33 | // Bind your transitions 34 | bind().asEagerSingleton() 35 | } 36 | } 37 | 38 | // Mark your transitions 39 | @TransitionDefinition 40 | class MyTransitions @Inject constructor( 41 | private val dependency: SomeService 42 | ) { 43 | fun getTransitions(): Set> { 44 | // Define your transitions 45 | } 46 | } 47 | 48 | // Use in your application 49 | @Inject 50 | lateinit var transitioner: Transitioner<...> 51 | ``` 52 | 53 | ## Best Practices 54 | 55 | 1. Use `@TransitionDefinition` to mark classes containing transitions 56 | 2. Consider using `@TransitionerDefinition` for custom transitioner configuration 57 | 3. Inject dependencies into your transition classes 58 | 4. Use eager singletons for transition definitions 59 | 5. Configure the KfsmModule early in your application setup 60 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/app/cash/kfsm/Value.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | /** 4 | * Represents a value that can transition between different states in a finite state machine. 5 | * 6 | * This interface defines the core behavior for values that can be managed by a state machine. 7 | * It provides methods to track the current state and update to a new state. 8 | * 9 | * @param V The concrete type of the value implementing this interface 10 | * @param S The type of state that this value can transition between 11 | * 12 | * @see State 13 | * @see Transition 14 | * @see Transitioner 15 | * 16 | * Example usage: 17 | * ```kotlin 18 | * enum class Color { RED, YELLOW, GREEN } 19 | * 20 | * data class Light(override val state: Color, override val id: String) : Value { 21 | * override fun update(newState: Color): Light = copy(state = newState) 22 | * } 23 | * ``` 24 | */ 25 | interface Value, S : State> { 26 | /** 27 | * The current state of this value in the state machine. 28 | * 29 | * This property represents the value's current position in the state machine's state graph. 30 | * It is used by the state machine to determine valid transitions and track the value's progress. 31 | */ 32 | val state: S 33 | 34 | /** 35 | * Updates this value to a new state. 36 | * 37 | * This method creates a new instance of the value with the specified state. 38 | * The implementation should ensure that the new state is valid according to the state machine's rules. 39 | * 40 | * @param newState The state to transition to 41 | * @return A new instance of the value with the updated state 42 | */ 43 | fun update(newState: S): V 44 | 45 | /** 46 | * Returns a unique identifier for this value. 47 | * 48 | * This method is used to identify the value within the state machine. 49 | * By default, it uses the value's string representation, but implementations 50 | * should override this to provide a more succinct & specific identifier. 51 | * 52 | * @return A string that uniquely identifies this value 53 | */ 54 | val id: ID 55 | } 56 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/app/cash/kfsm/DeferrableEffect.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | import app.cash.kfsm.annotations.ExperimentalLibraryApi 4 | 5 | /** 6 | * Represents an effect that can be deferred and stored in an outbox for later execution. 7 | * 8 | * This interface enables the transactional outbox pattern, where side effects are captured 9 | * during a state transition and persisted in the same database transaction as the state change. 10 | * The effects are then executed asynchronously by a separate processor. 11 | * 12 | * Example: 13 | * ```kotlin 14 | * class SendEmail( 15 | * from: OrderState, 16 | * to: OrderState, 17 | * private val recipient: String 18 | * ) : OrderTransition(from, to), DeferrableEffect { 19 | * 20 | * override fun effect(value: Order): Result { 21 | * // This will be executed later by the outbox processor 22 | * emailService.send(recipient, "Order ${value.id} status changed") 23 | * return Result.success(value) 24 | * } 25 | * 26 | * override fun serialize(): EffectPayload = EffectPayload( 27 | * effectType, 28 | * data = Json.encodeToString(mapOf("recipient" to recipient, "orderId" to value.id)) 29 | * ) 30 | * 31 | * override val effectType = "send_email" 32 | * } 33 | * ``` 34 | * 35 | * @param ID The type of unique identifier for values 36 | * @param V The type of value being transitioned 37 | * @param S The type of state 38 | */ 39 | @ExperimentalLibraryApi 40 | interface DeferrableEffect, S : State> : Effect { 41 | /** 42 | * Serialize this effect for storage in the outbox. 43 | * 44 | * The serialized payload should contain all information necessary to execute the effect later. 45 | * Common serialization formats include JSON, Protocol Buffers, or Avro. 46 | * 47 | * @return The serialized effect payload 48 | */ 49 | fun serialize(value: V): Result 50 | 51 | /** 52 | * A unique identifier for this type of effect. 53 | * 54 | * This is used by the effect executor to determine how to deserialize and execute the effect. 55 | * Should be a stable, descriptive string (e.g., "send_email", "disable_camera"). 56 | */ 57 | val effectType: String 58 | } 59 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/app/cash/kfsm/package.md: -------------------------------------------------------------------------------- 1 | # Package app.cash.kfsm 2 | 3 | The core kFSM package provides a robust, type-safe implementation of finite state machines in Kotlin. It is designed to help you model complex state transitions while maintaining compile-time safety and verification. 4 | 5 | ## Key Components 6 | 7 | ### State Management 8 | 9 | * [State] - Base class for defining states in your state machine. States know their subsequent states and can validate invariants. 10 | * [States] - Utility object for working with collections of states. 11 | * [Value] - Interface for entities that can transition between states. 12 | 13 | ### Transitions 14 | 15 | * [Transition] - Defines how entities move between states, including validation and effects. 16 | * [Transitioner] - Manages state transitions and ensures they follow defined rules. 17 | * [TransitionerAsync] - Coroutine-based version of Transitioner for asynchronous operations. 18 | 19 | ### Validation & Safety 20 | 21 | * [Invariant] - Defines conditions that must hold true for a state. 22 | * [InvariantDsl] - DSL for creating state invariants. 23 | * [InvalidStateTransition] - Exception thrown when an invalid transition is attempted. 24 | * [NoPathToTargetState] - Exception thrown when no valid path exists to reach a target state. 25 | 26 | ### Utilities 27 | 28 | * [StateMachine] - Provides utilities for verifying state machine completeness and generating documentation. 29 | 30 | ## Example Usage 31 | 32 | ```kotlin 33 | // Define your states 34 | sealed class TrafficLightState : State 35 | object Red : TrafficLightState() 36 | object Yellow : TrafficLightState() 37 | object Green : TrafficLightState() 38 | 39 | // Define your value type 40 | class TrafficLight : Value 41 | 42 | // Create transitions 43 | val redToGreen = Transition(Red, Green) { light -> 44 | // Validation and effects here 45 | Result.success(light) 46 | } 47 | 48 | // Use the transitioner 49 | val transitioner = Transitioner(setOf(redToGreen)) 50 | val light = TrafficLight() 51 | transitioner.transition(light, Red, Green) 52 | ``` 53 | 54 | ## Best Practices 55 | 56 | 1. Make your states a sealed class hierarchy 57 | 2. Use value objects that are immutable 58 | 3. Define clear invariants for each state 59 | 4. Verify your state machine using `StateMachine.verify()` 60 | 5. Generate documentation using `StateMachine.mermaid()` 61 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Maven Central 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | jobs: 14 | publish: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Check if tag is on main branch 24 | id: check-branch 25 | run: | 26 | if git branch -r --contains ${{ github.ref }} | grep -q "origin/main"; then 27 | echo "tag_on_main=true" >> $GITHUB_OUTPUT 28 | else 29 | echo "tag_on_main=false" >> $GITHUB_OUTPUT 30 | fi 31 | 32 | - name: Set up JDK 33 | if: steps.check-branch.outputs.tag_on_main == 'true' 34 | uses: actions/setup-java@v4 35 | with: 36 | java-version: '11' 37 | distribution: 'temurin' 38 | 39 | - name: Extract version from tag 40 | if: steps.check-branch.outputs.tag_on_main == 'true' 41 | id: version 42 | run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT 43 | 44 | - name: Publish to Maven Central 45 | if: steps.check-branch.outputs.tag_on_main == 'true' 46 | env: 47 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_CENTRAL_USERNAME }} 48 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_CENTRAL_PASSWORD }} 49 | ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_SECRET_KEY }} 50 | ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_SECRET_PASSPHRASE }} 51 | ORG_GRADLE_PROJECT_VERSION_NAME: ${{ steps.version.outputs.version }} 52 | run: bin/gradle publishToMavenCentral 53 | 54 | - name: Build HTML 55 | if: steps.check-branch.outputs.tag_on_main == 'true' 56 | run: bin/gradle dokkaGeneratePublicationHtml --no-daemon --stacktrace 57 | 58 | - name: Upload HTML 59 | if: steps.check-branch.outputs.tag_on_main == 'true' 60 | uses: actions/upload-pages-artifact@v3 61 | with: 62 | path: build/dokka/html 63 | name: 'github-pages' 64 | 65 | - name: Deploy GitHub Pages site 66 | if: steps.check-branch.outputs.tag_on_main == 'true' 67 | uses: actions/deploy-pages@v4 68 | with: 69 | artifact_name: github-pages 70 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | If you would like to contribute code to this project you can do so through GitHub by forking the repository and sending 4 | a pull request. 5 | 6 | When submitting code, please make every effort to follow existing conventions and style in order to keep the code as 7 | readable as possible. 8 | 9 | Before your code can be accepted into the project you must also sign the 10 | [Individual Contributor License Agreement (CLA)][1]. 11 | 12 | ## Building 13 | 14 | KFSM uses CashApp's [Hermit](https://cashapp.github.io/hermit/). Hermit ensures that your team, your contributors, 15 | and your CI have the same consistent tooling. Here are the [installation instructions](https://cashapp.github.io/hermit/usage/get-started/#installing-hermit). 16 | 17 | [Activate Hermit](https://cashapp.github.io/hermit/usage/get-started/#activating-an-environment) either 18 | by [enabling the shell hooks](https://cashapp.github.io/hermit/usage/shell/) (one-time only, recommended) or manually 19 | sourcing the env with `. ./bin/activate-hermit`. 20 | 21 | Use gradle to run all tests 22 | 23 | ```shell 24 | gradle build 25 | ``` 26 | 27 | ## Breaking changes 28 | 29 | We use the [Kotlin binary compatibility validator][2] to check for API changes. If a change contains an API change and 30 | breaks the build, run the `:apiDump` task and commit the resulting changes to the `.api` files. `.api` files should not 31 | have removals and additions in the same change so that downstream apps do not immediately run into 32 | backwards-compatibility issues. 33 | 34 | ## Upgrading dependencies 35 | 36 | Dependency versions are listed in the Gradle catalog file: gradle/libs.versions.toml. 37 | 38 | To check for dependencies to update: 39 | 40 | ```shell 41 | gradle dependencyUpdates -Drevision=release 42 | ``` 43 | 44 | Dependencies can be updated by editing the catalog file. 45 | 46 | The version catalog update plugin can also format and update the catalog file. See the documentation for more detail. 47 | 48 | Updating all available dependencies: 49 | 50 | ```shell 51 | gradle versionCatalogUpdate 52 | ``` 53 | 54 | Gradle Versions Plugin: https://github.com/ben-manes/gradle-versions-plugin 55 | Version Catalog Update Plugin: https://github.com/littlerobots/version-catalog-update-plugin 56 | 57 | [1]: https://spreadsheets.google.com/spreadsheet/viewform?formkey=dDViT2xzUHAwRkI3X3k5Z0lQM091OGc6MQ&ndplr=1 58 | 59 | [2]: https://github.com/Kotlin/binary-compatibility-validator 60 | 61 | -------------------------------------------------------------------------------- /lib/src/test/kotlin/app/cash/kfsm/StateTest.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | 6 | class StateTest : StringSpec({ 7 | 8 | "state knows which states it can directly transition to" { 9 | A.subsequentStates shouldBe setOf(B) 10 | B.subsequentStates shouldBe setOf(B, C, D) 11 | C.subsequentStates shouldBe setOf(D) 12 | D.subsequentStates shouldBe setOf(B, E) 13 | E.subsequentStates shouldBe emptySet() 14 | } 15 | 16 | "state knows which states it can eventually transition to" { 17 | A.reachableStates shouldBe setOf(B, C, D, E) 18 | B.reachableStates shouldBe setOf(B, C, D, E) 19 | C.reachableStates shouldBe setOf(B, C, D, E) 20 | D.reachableStates shouldBe setOf(B, C, D, E) 21 | E.reachableStates shouldBe emptySet() 22 | } 23 | 24 | "state reports that it can transition to another state" { 25 | A.canDirectlyTransitionTo(B) shouldBe true 26 | B.canDirectlyTransitionTo(B) shouldBe true // self 27 | } 28 | 29 | "state reports that it cannot transition to another state" { 30 | A.canDirectlyTransitionTo(A) shouldBe false 31 | A.canDirectlyTransitionTo(C) shouldBe false 32 | A.canDirectlyTransitionTo(D) shouldBe false 33 | A.canDirectlyTransitionTo(E) shouldBe false 34 | 35 | C.canDirectlyTransitionTo(B) shouldBe false // reverse 36 | C.canDirectlyTransitionTo(C) shouldBe false // self 37 | } 38 | 39 | "state reports if it can eventually transition to another state" { 40 | A.canEventuallyTransitionTo(B) shouldBe true 41 | A.canEventuallyTransitionTo(C) shouldBe true 42 | A.canEventuallyTransitionTo(D) shouldBe true 43 | A.canEventuallyTransitionTo(E) shouldBe true 44 | } 45 | 46 | "state reports if it can eventually transition to another state via a cycle" { 47 | C.canEventuallyTransitionTo(B) shouldBe true 48 | C.canEventuallyTransitionTo(C) shouldBe true 49 | } 50 | 51 | "state reports if it cannot eventually transition to another state" { 52 | A.canEventuallyTransitionTo(A) shouldBe false 53 | C.canEventuallyTransitionTo(A) shouldBe false 54 | E.canEventuallyTransitionTo(C) shouldBe false 55 | E.canEventuallyTransitionTo(E) shouldBe false 56 | } 57 | 58 | "can define and use custom methods on a state" { 59 | A.next(2) shouldBe listOf(B, C) 60 | E.next(2) shouldBe emptyList() 61 | } 62 | 63 | "returns shortest path" { 64 | Red.shortestPathTo(Green) shouldBe listOf(Red, Yellow, Green) 65 | Red.shortestPathTo(Yellow) shouldBe listOf(Red, Yellow) 66 | Green.shortestPathTo(Red) shouldBe listOf(Green, Yellow, Red) 67 | } 68 | }) 69 | -------------------------------------------------------------------------------- /lib-guice/api/lib-guice.api: -------------------------------------------------------------------------------- 1 | public abstract class app/cash/kfsm/guice/KfsmModule : com/google/inject/AbstractModule { 2 | public static final field Companion Lapp/cash/kfsm/guice/KfsmModule$Companion; 3 | public fun (Lapp/cash/kfsm/guice/KfsmModule$Companion$KfsmMachineTypes;Ljava/lang/String;)V 4 | public synthetic fun (Lapp/cash/kfsm/guice/KfsmModule$Companion$KfsmMachineTypes;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V 5 | protected fun configure ()V 6 | } 7 | 8 | public final class app/cash/kfsm/guice/KfsmModule$Companion { 9 | public final fun typeLiteralsFor (Ljava/lang/Class;Ljava/lang/Class;Ljava/lang/Class;)Lapp/cash/kfsm/guice/KfsmModule$Companion$KfsmMachineTypes; 10 | } 11 | 12 | public final class app/cash/kfsm/guice/KfsmModule$Companion$KfsmMachineTypes { 13 | public fun (Lcom/google/inject/TypeLiteral;Lcom/google/inject/TypeLiteral;Lcom/google/inject/TypeLiteral;)V 14 | public final fun component1 ()Lcom/google/inject/TypeLiteral; 15 | public final fun component2 ()Lcom/google/inject/TypeLiteral; 16 | public final fun component3 ()Lcom/google/inject/TypeLiteral; 17 | public final fun copy (Lcom/google/inject/TypeLiteral;Lcom/google/inject/TypeLiteral;Lcom/google/inject/TypeLiteral;)Lapp/cash/kfsm/guice/KfsmModule$Companion$KfsmMachineTypes; 18 | public static synthetic fun copy$default (Lapp/cash/kfsm/guice/KfsmModule$Companion$KfsmMachineTypes;Lcom/google/inject/TypeLiteral;Lcom/google/inject/TypeLiteral;Lcom/google/inject/TypeLiteral;ILjava/lang/Object;)Lapp/cash/kfsm/guice/KfsmModule$Companion$KfsmMachineTypes; 19 | public fun equals (Ljava/lang/Object;)Z 20 | public final fun getStateMachine ()Lcom/google/inject/TypeLiteral; 21 | public final fun getTransition ()Lcom/google/inject/TypeLiteral; 22 | public final fun getTransitioner ()Lcom/google/inject/TypeLiteral; 23 | public fun hashCode ()I 24 | public fun toString ()Ljava/lang/String; 25 | } 26 | 27 | public final class app/cash/kfsm/guice/StateMachine { 28 | public fun (Ljava/util/Set;Lapp/cash/kfsm/Transitioner;)V 29 | public final fun execute-gIAlu-s (Lapp/cash/kfsm/Value;Lapp/cash/kfsm/Transition;)Ljava/lang/Object; 30 | public final fun getAvailableTransitions (Lapp/cash/kfsm/State;)Ljava/util/Set; 31 | public final fun getTransition (Lkotlin/reflect/KClass;)Lapp/cash/kfsm/Transition; 32 | public final fun transitionToState-gIAlu-s (Lapp/cash/kfsm/Value;Lapp/cash/kfsm/State;)Ljava/lang/Object; 33 | } 34 | 35 | public abstract interface annotation class app/cash/kfsm/guice/annotations/TransitionDefinition : java/lang/annotation/Annotation { 36 | } 37 | 38 | public abstract interface annotation class app/cash/kfsm/guice/annotations/TransitionerDefinition : java/lang/annotation/Annotation { 39 | } 40 | 41 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/app/cash/kfsm/OutboxHandler.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | import app.cash.kfsm.annotations.ExperimentalLibraryApi 4 | 5 | /** 6 | * Handles the capture of deferrable effects for storage in the transactional outbox. 7 | * 8 | * The outbox handler is responsible for creating outbox messages when deferrable effects 9 | * are encountered during state transitions. These messages are then passed to the 10 | * `persistWithOutbox` method to be stored in the same database transaction as the state change. 11 | * 12 | * Example implementation: 13 | * ```kotlin 14 | * class DatabaseOutboxHandler, S : State> : OutboxHandler { 15 | * 16 | * private val pendingMessages = mutableListOf>() 17 | * 18 | * override fun captureEffect( 19 | * value: V, 20 | * effect: DeferrableEffect 21 | * ): Result { 22 | * val message = OutboxMessage( 23 | * id = UUID.randomUUID().toString(), 24 | * valueId = value.id, 25 | * effectPayload = effect.serialize(), 26 | * createdAt = System.currentTimeMillis() 27 | * ) 28 | * pendingMessages.add(message) 29 | * return Result.success(value) 30 | * } 31 | * 32 | * override fun getPendingMessages(): List> = pendingMessages.toList() 33 | * 34 | * override fun clearPending() { 35 | * pendingMessages.clear() 36 | * } 37 | * } 38 | * ``` 39 | * 40 | * @param ID The type of unique identifier for values 41 | * @param V The type of value being transitioned 42 | * @param S The type of state 43 | */ 44 | @ExperimentalLibraryApi 45 | interface OutboxHandler, S : State> { 46 | /** 47 | * Capture an effect for later execution. 48 | * 49 | * This method is called during a state transition when a deferrable effect is encountered. 50 | * Instead of executing the effect immediately, it should be serialized and stored for 51 | * later processing. 52 | * 53 | * @param value The value being transitioned 54 | * @param effect The deferrable effect to capture 55 | * @return The value unchanged (effect is not executed) 56 | */ 57 | fun captureEffect(value: V, effect: DeferrableEffect): Result 58 | 59 | /** 60 | * Get all pending outbox messages that have been captured since the last clear. 61 | * 62 | * This is called by the transitioner to retrieve messages that should be persisted 63 | * in the same transaction as the state change. 64 | * 65 | * @return List of pending outbox messages 66 | */ 67 | fun getPendingMessages(): List> 68 | 69 | /** 70 | * Clear the pending messages after they have been persisted. 71 | * 72 | * This should be called after a successful transaction to reset the handler state. 73 | */ 74 | fun clearPending() 75 | } 76 | -------------------------------------------------------------------------------- /lib/src/test/kotlin/app/cash/kfsm/exemplar/HamsterTransitions.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm.exemplar 2 | 3 | import app.cash.kfsm.States 4 | import app.cash.kfsm.States.Companion.toStates 5 | import app.cash.kfsm.Transition 6 | import app.cash.kfsm.exemplar.Hamster.Asleep 7 | import app.cash.kfsm.exemplar.Hamster.Awake 8 | import app.cash.kfsm.exemplar.Hamster.Eating 9 | import app.cash.kfsm.exemplar.Hamster.Resting 10 | import app.cash.kfsm.exemplar.Hamster.RunningOnWheel 11 | 12 | // Create your own base transition class in order to extend your transition collection with common functionality 13 | abstract class HamsterTransition( 14 | from: States, 15 | to: Hamster.State 16 | ) : Transition(from, to) { 17 | // Convenience constructor for when the from set has only one value 18 | constructor(from: Hamster.State, to: Hamster.State) : this(States(from), to) 19 | 20 | // Convenience constructor for the deprecated variant that takes a set instead of States 21 | constructor(from: Set, to: Hamster.State) : this(from.toStates(), to) 22 | 23 | // Demonstrates how you can add base behaviour to transitions for use in pre and post hooks. 24 | open val description: String = "" 25 | } 26 | 27 | class EatBreakfast(private val food: String) : HamsterTransition(from = Awake, to = Eating) { 28 | override fun effect(value: Hamster): Result = 29 | when (food) { 30 | "broccoli" -> { 31 | value.eat(food) 32 | Result.success(value) 33 | } 34 | 35 | "cheese" -> Result.failure(LactoseIntoleranceTroubles(food)) 36 | else -> Result.success(value) 37 | } 38 | 39 | override val description = "eating $food for breakfast" 40 | } 41 | 42 | object RunOnWheel : HamsterTransition(from = Eating, to = RunningOnWheel) { 43 | override fun effect(value: Hamster): Result { 44 | // This println represents a side-effect 45 | println("$value moves to the wheel") 46 | return Result.success(value) 47 | } 48 | 49 | override val description = "running on the wheel" 50 | } 51 | 52 | object GoToBed : HamsterTransition(from = setOf(Eating, RunningOnWheel, Resting), to = Asleep) { 53 | override fun effect(value: Hamster): Result { 54 | value.sleep() 55 | return Result.success(value) 56 | } 57 | 58 | override val description = "going to bed" 59 | } 60 | 61 | object FlakeOut : HamsterTransition(from = setOf(Eating, RunningOnWheel), to = Resting) { 62 | override fun effect(value: Hamster): Result { 63 | println("$value has had enough and is sitting cute") 64 | return Result.success(value) 65 | } 66 | 67 | override val description = "tapping out" 68 | } 69 | 70 | object WakeUp : HamsterTransition(from = Asleep, to = Awake) { 71 | override fun effect(value: Hamster): Result { 72 | println("$value opens her eyes") 73 | return Result.success(value) 74 | } 75 | 76 | override val description = "waking up" 77 | } 78 | 79 | data class LactoseIntoleranceTroubles(val consumed: String) : Exception("Hamster tummy troubles eating $consumed") 80 | -------------------------------------------------------------------------------- /lib-guice/src/test/kotlin/app/cash/kfsm/guice/test/KfsmGuiceIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm.guice.test 2 | 3 | import app.cash.kfsm.NoPathToTargetState 4 | import app.cash.kfsm.guice.StateMachine 5 | import com.google.inject.Guice 6 | import com.google.inject.Key 7 | import com.google.inject.TypeLiteral 8 | import io.kotest.core.spec.style.StringSpec 9 | import io.kotest.matchers.collections.shouldHaveSize 10 | import io.kotest.matchers.result.shouldBeFailure 11 | import io.kotest.matchers.shouldBe 12 | import io.kotest.matchers.types.shouldBeInstanceOf 13 | 14 | class KfsmGuiceIntegrationTest : StringSpec({ 15 | val injector = Guice.createInjector(TestModule()) 16 | val stateMachine = injector.getInstance( 17 | Key.get(object : TypeLiteral>() {}) 18 | ) 19 | val startValue = TestValue(id = "test_value_01", state = TestState.START) 20 | 21 | "START state should have exactly one available transition of type StartToMiddle" { 22 | val transitions = stateMachine.getAvailableTransitions(startValue.state) 23 | transitions shouldHaveSize 1 24 | transitions.first().shouldBeInstanceOf() 25 | } 26 | 27 | "executing StartToMiddle transition should result in MIDDLE state" { 28 | val middleValue = stateMachine.execute(startValue, stateMachine.getTransition()).getOrThrow() 29 | middleValue.state shouldBe TestState.MIDDLE 30 | } 31 | 32 | "MIDDLE state should have exactly one available transition of type MiddleToEnd" { 33 | val middleValue = stateMachine.execute(startValue, stateMachine.getTransition()).getOrThrow() 34 | val transitions = stateMachine.getAvailableTransitions(middleValue.state) 35 | transitions shouldHaveSize 1 36 | transitions.first().shouldBeInstanceOf() 37 | } 38 | 39 | "executing MiddleToEnd transition should result in END state" { 40 | val middleValue = stateMachine.execute(startValue, stateMachine.getTransition()).getOrThrow() 41 | val endValue = stateMachine.execute(middleValue, stateMachine.getTransition()).getOrThrow() 42 | endValue.state shouldBe TestState.END 43 | } 44 | 45 | "END state should have no available transitions" { 46 | val middleValue = stateMachine.execute(startValue, stateMachine.getTransition()).getOrThrow() 47 | val endValue = stateMachine.execute(middleValue, stateMachine.getTransition()).getOrThrow() 48 | val transitions = stateMachine.getAvailableTransitions(endValue.state) 49 | transitions shouldHaveSize 0 50 | } 51 | 52 | "transitionToState should succeed when transitioning to a directly reachable state" { 53 | stateMachine.transitionToState(startValue, TestState.MIDDLE).getOrThrow().state shouldBe TestState.MIDDLE 54 | } 55 | 56 | "transitionToState should fail when transitioning to a non-directly reachable state" { 57 | stateMachine.transitionToState(startValue, TestState.END).shouldBeFailure() 58 | } 59 | 60 | "transitionToState should fail when transitioning to the same state" { 61 | stateMachine.transitionToState(startValue, TestState.START).shouldBeFailure() 62 | } 63 | }) 64 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/app/cash/kfsm/StateMachine.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | class StateMachine, S : State>( 4 | val transitionMap: Map>>, 5 | private val selectors: Map>, 6 | private val transitioner: Transitioner, V, S> 7 | ) { 8 | /** 9 | * Returns all available transitions from a given state. 10 | * 11 | * @param state The current state 12 | * @return Set of all possible transitions from the given state 13 | */ 14 | fun getAvailableTransitions(state: S): Set> = transitionMap[state]?.values?.toSet() ?: emptySet() 15 | 16 | /** 17 | * Transitions a value to the target state if a valid transition exists. 18 | * 19 | * @param value The current value to transition 20 | * @param targetState The desired target state 21 | * @return Result containing the new value after transition, or failure if transition is invalid 22 | */ 23 | fun transitionTo( 24 | value: V, 25 | targetState: S 26 | ): Result = 27 | transitionMap[value.state]?.get(targetState)?.let { transition -> 28 | transitioner.transition(value, transition) 29 | } ?: Result.failure(NoPathToTargetState(value, targetState)) 30 | 31 | /** 32 | * Advances the state machine to the next state based on the current value. 33 | * 34 | * This method uses a [NextStateSelector] to determine the next appropriate state and then 35 | * performs the transition. The selector must be defined for the current state, and both 36 | * the selection and transition must be valid for the operation to succeed. 37 | * 38 | * @param value The current value to advance to its next state 39 | * @return Result containing the new value after transition, or failure if: 40 | * - No selector is defined for the current state 41 | * - The selector fails to determine a valid next state 42 | * - The transition to the selected state is invalid 43 | */ 44 | fun advance(value: V): Result = 45 | runCatching { 46 | val selector = selectors[value.state] ?: throw IllegalStateException("No selector for state ${value.state}") 47 | val to = selector.apply(value).getOrThrow() 48 | transitionTo(value, to).getOrThrow() 49 | } 50 | 51 | /** 52 | * Generates a Mermaid markdown state diagram representation of this state machine. 53 | * 54 | * The diagram shows all states and their possible transitions, making it easy to 55 | * visualize and document the state machine's structure. 56 | * 57 | * Example output: 58 | * ```mermaid 59 | * stateDiagram-v2 60 | * [*] --> InitialState 61 | * InitialState --> NextState 62 | * NextState --> FinalState 63 | * ``` 64 | * 65 | * @param initialState The initial state to mark as the entry point 66 | * @return A string containing the Mermaid markdown diagram 67 | */ 68 | fun mermaidStateDiagramMarkdown(initialState: S): String { 69 | val transitions = 70 | transitionMap 71 | .flatMap { (fromState, targets) -> 72 | targets.keys.map { toState -> 73 | "${fromState::class.simpleName} --> ${toState::class.simpleName}" 74 | } 75 | }.distinct() 76 | .sorted() 77 | 78 | return listOf( 79 | "stateDiagram-v2", 80 | "[*] --> ${initialState::class.simpleName}" 81 | ).plus(transitions).joinToString("\n ") 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/app/cash/kfsm/TransitionerAsync.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | import app.cash.kfsm.annotations.ExperimentalLibraryApi 4 | 5 | abstract class TransitionerAsync, V : Value, S : State> { 6 | 7 | /** 8 | * Optional outbox handler for capturing deferrable effects. 9 | * 10 | * When set, transitions implementing [DeferrableEffect] will have their effects captured 11 | * and stored in the outbox instead of being executed immediately. This enables the 12 | * transactional outbox pattern where effects are persisted in the same transaction 13 | * as the state change. 14 | */ 15 | @ExperimentalLibraryApi 16 | open val outboxHandler: OutboxHandler? = null 17 | 18 | open suspend fun preHook(value: V, via: T): Result = Result.success(Unit) 19 | 20 | open suspend fun persist(from: S, value: V, via: T): Result = Result.success(value) 21 | 22 | /** 23 | * Will be executed after the transition effect to persist the value along with outbox messages. 24 | * 25 | * Override this method when using the transactional outbox pattern to persist both the state 26 | * change and the captured effects in a single transaction. 27 | * 28 | * Example: 29 | * ```kotlin 30 | * override suspend fun persistWithOutbox( 31 | * from: S, 32 | * value: V, 33 | * via: T, 34 | * outboxMessages: List> 35 | * ): Result = runCatching { 36 | * database.transaction { 37 | * database.update(value).getOrThrow() 38 | * outboxMessages.forEach { database.insertOutbox(it) } 39 | * value 40 | * } 41 | * } 42 | * ``` 43 | * 44 | * @param from The previous state 45 | * @param value The value with the new state 46 | * @param via The transition being applied 47 | * @param outboxMessages List of captured effects to persist in the same transaction 48 | * @return The persisted value 49 | */ 50 | @ExperimentalLibraryApi 51 | open suspend fun persistWithOutbox( 52 | from: S, 53 | value: V, 54 | via: T, 55 | outboxMessages: List> 56 | ): Result = persist(from, value, via) 57 | 58 | open suspend fun postHook(from: S, value: V, via: T): Result = Result.success(Unit) 59 | 60 | suspend fun transition( 61 | value: V, 62 | transition: T 63 | ): Result = when { 64 | transition.from.set.contains(value.state) -> doTheTransition(value, transition) 65 | // Self-cycled transitions will be effected by the first case. 66 | // If we still see a transition to self then this is a no-op. 67 | transition.to == value.state -> ignoreAlreadyCompletedTransition(value, transition) 68 | else -> Result.failure(InvalidStateForTransition(transition, value)) 69 | } 70 | 71 | private suspend fun doTheTransition( 72 | value: V, 73 | transition: T 74 | ): Result = 75 | runCatching { preHook(value, transition).getOrThrow() } 76 | .mapCatching { executeOrCaptureEffectAsync(value, transition).getOrThrow() } 77 | .map { it.update(transition.to) } 78 | .mapCatching { persistWithCapture(value.state, it, transition).getOrThrow() } 79 | .mapCatching { it.also { postHook(value.state, it, transition).getOrThrow() } } 80 | 81 | private suspend fun executeOrCaptureEffectAsync(value: V, transition: T): Result { 82 | val handler = outboxHandler 83 | return if (handler != null && transition is DeferrableEffect<*, *, *>) { 84 | // Capture the effect for later execution 85 | @Suppress("UNCHECKED_CAST") 86 | handler.captureEffect(value, transition as DeferrableEffect) 87 | } else { 88 | // Execute immediately (current behavior) 89 | transition.effectAsync(value) 90 | } 91 | } 92 | 93 | private suspend fun persistWithCapture(from: S, value: V, transition: T): Result { 94 | val handler = outboxHandler 95 | return if (handler != null) { 96 | val messages = handler.getPendingMessages() 97 | persistWithOutbox(from, value, transition, messages) 98 | .also { handler.clearPending() } 99 | } else { 100 | persist(from, value, transition) 101 | } 102 | } 103 | 104 | private fun ignoreAlreadyCompletedTransition( 105 | value: V, 106 | transition: T 107 | ): Result = Result.success(value.update(transition.to)) 108 | } 109 | 110 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/app/cash/kfsm/StateMachineUtilities.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | import kotlin.reflect.KClass 4 | import kotlin.reflect.full.allSuperclasses 5 | import kotlin.reflect.full.superclasses 6 | 7 | /** 8 | * Provides utilities for working with state machines as a whole, including verification 9 | * and documentation generation. 10 | * 11 | * This object offers two main capabilities: 12 | * 1. Verifying that a state machine is complete (covers all possible states) 13 | * 2. Generating Mermaid markdown diagrams for documentation 14 | * 15 | * Example usage: 16 | * ```kotlin 17 | * // Verify your state machine 18 | * StateMachine.verify(initialState).getOrThrow() 19 | * 20 | * // Generate a Mermaid diagram 21 | * val diagram = StateMachine.mermaid(initialState).getOrThrow() 22 | * ``` 23 | */ 24 | object StateMachineUtilities { 25 | /** 26 | * Verifies that a state machine covers all possible states. 27 | * 28 | * This method checks that all subtypes of the state's sealed class are reachable 29 | * from the given head state. It helps ensure that your state machine is complete 30 | * and that no states are accidentally unreachable. 31 | * 32 | * @param head The initial state to start verification from 33 | * @return A Result containing the set of all reachable states if verification succeeds 34 | * @throws InvalidStateMachine if any states are unreachable 35 | */ 36 | fun , S : State> verify(head: S): Result>> = 37 | verify(head, baseType(head)) 38 | 39 | /** 40 | * Generates a Mermaid markdown diagram representing the state machine. 41 | * 42 | * The diagram shows all states and their possible transitions, making it easy to 43 | * visualize and document your state machine's structure. 44 | * 45 | * Example output: 46 | * ```mermaid 47 | * stateDiagram-v2 48 | * [*] --> InitialState 49 | * InitialState --> NextState 50 | * NextState --> FinalState 51 | * ``` 52 | * 53 | * @param head The initial state to start diagram generation from 54 | * @return A Result containing the Mermaid markdown string 55 | */ 56 | fun , S : State> mermaid(head: S): Result = 57 | walkTree(head).map { states -> 58 | listOf("stateDiagram-v2", "[*] --> ${head::class.simpleName}") 59 | .plus( 60 | states 61 | .toSet() 62 | .flatMap { from -> 63 | from.subsequentStates.map { to -> "${from::class.simpleName} --> ${to::class.simpleName}" } 64 | }.toList() 65 | .sorted() 66 | ).joinToString("\n") 67 | } 68 | 69 | private fun , S : State> verify( 70 | head: S, 71 | type: KClass 72 | ): Result>> = 73 | walkTree(head).mapCatching { seen -> 74 | val notSeen = 75 | type.sealedSubclasses 76 | .minus(seen.map { it::class }.toSet()) 77 | .toList() 78 | .sortedBy { it.simpleName } 79 | when { 80 | notSeen.isEmpty() -> seen 81 | else -> throw InvalidStateMachine( 82 | "Did not encounter [${notSeen.map { it.simpleName }.joinToString(", ")}]" 83 | ) 84 | } 85 | } 86 | 87 | private fun , S : State> walkTree( 88 | current: S, 89 | statesSeen: Set = emptySet() 90 | ): Result> = 91 | runCatching { 92 | when { 93 | statesSeen.contains(current) -> statesSeen 94 | current.subsequentStates.isEmpty() -> statesSeen.plus(current) 95 | else -> 96 | current.subsequentStates 97 | .flatMap { 98 | walkTree(it, statesSeen.plus(current)).getOrThrow() 99 | }.toSet() 100 | } 101 | } 102 | 103 | @Suppress("UNCHECKED_CAST") 104 | private fun , S : State> baseType(s: S): KClass = 105 | s::class 106 | .allSuperclasses 107 | .find { it.superclasses.contains(State::class) }!! as KClass 108 | } 109 | 110 | /** 111 | * Exception thrown when a state machine is found to be invalid during verification. 112 | * 113 | * This typically occurs when there are unreachable states in a sealed class hierarchy. 114 | * 115 | * @property message A description of why the state machine is invalid 116 | */ 117 | data class InvalidStateMachine( 118 | override val message: String 119 | ) : Exception(message) 120 | -------------------------------------------------------------------------------- /module.md: -------------------------------------------------------------------------------- 1 | # kFSM: Type-Safe Finite State Machines for Kotlin 2 | 3 | kFSM provides a robust, type-safe implementation of finite state machines in Kotlin, with optional Guice integration for dependency injection support. 4 | 5 | ## Core Concepts 6 | 7 | kFSM is built around four fundamental concepts: 8 | 9 | 1. **States** - Represent the possible conditions of your entity 10 | 2. **Values** - The entities that move between states 11 | 3. **Transitions** - Rules and effects for moving between states 12 | 4. **Transitioners** - Orchestrate and validate state changes 13 | 14 | ## Modules 15 | 16 | ### Core Library (`lib`) 17 | 18 | The core module provides the fundamental state machine implementation with these key features: 19 | 20 | * Type-safe state transitions with compile-time verification 21 | * Flexible state validation through invariants 22 | * Path finding between states 23 | * Mermaid diagram generation for documentation 24 | * Both synchronous and asynchronous transition support 25 | * Comprehensive testing utilities 26 | 27 | Key types: 28 | ```kotlin 29 | // Define states 30 | sealed class MyState : State 31 | 32 | // Define values that transition between states 33 | class MyValue : Value 34 | 35 | // Create transitions 36 | val transition = Transition(FromState, ToState) { value -> 37 | // Validation and effects 38 | Result.success(value) 39 | } 40 | 41 | // Use the transitioner 42 | val transitioner = Transitioner(transitions) 43 | transitioner.transition(value, fromState, toState) 44 | ``` 45 | 46 | ### Guice Integration (`lib-guice`) 47 | 48 | The Guice module adds dependency injection support with these features: 49 | 50 | * Automatic discovery of transitions 51 | * Annotation-based configuration 52 | * Integration with existing Guice modules 53 | * Support for multiple state machines 54 | 55 | Example usage: 56 | ```kotlin 57 | class MyModule : KfsmModule() { 58 | override fun configure() { 59 | install(KfsmModule()) 60 | bind().asEagerSingleton() 61 | } 62 | } 63 | 64 | @TransitionDefinition 65 | class MyTransitions @Inject constructor( 66 | private val service: MyService 67 | ) { 68 | fun getTransitions(): Set> = setOf( 69 | // Your transitions here 70 | ) 71 | } 72 | ``` 73 | 74 | ## Key Features 75 | 76 | ### Type Safety 77 | 78 | kFSM leverages Kotlin's type system to ensure: 79 | * States are properly defined and sealed 80 | * Transitions are between compatible states 81 | * Values match their state machine's type parameters 82 | 83 | ### Validation 84 | 85 | Multiple levels of validation ensure correctness: 86 | * Compile-time type checking 87 | * Runtime state machine verification 88 | * Custom state invariants 89 | * Transition-specific validation 90 | 91 | ### Documentation 92 | 93 | Built-in documentation support: 94 | * Mermaid diagram generation 95 | * State reachability analysis 96 | * Path finding between states 97 | 98 | ### Testing 99 | 100 | Comprehensive testing support: 101 | * State machine verification 102 | * Transition testing utilities 103 | * Path validation 104 | * Invariant checking 105 | 106 | ## Best Practices 107 | 108 | 1. **State Definition** 109 | * Use sealed class hierarchies for states 110 | * Keep states immutable 111 | * Define clear invariants 112 | 113 | 2. **Transitions** 114 | * Make transitions single-purpose 115 | * Include proper validation 116 | * Handle errors gracefully 117 | 118 | 3. **Values** 119 | * Keep values immutable 120 | * Include only essential state 121 | * Use proper type parameters 122 | 123 | 4. **Integration** 124 | * Verify state machines at startup 125 | * Generate documentation 126 | * Use dependency injection when available 127 | 128 | ## Example 129 | 130 | A simple traffic light implementation: 131 | 132 | ```kotlin 133 | sealed class TrafficLightState : State 134 | object Red : TrafficLightState() 135 | object Yellow : TrafficLightState() 136 | object Green : TrafficLightState() 137 | 138 | class TrafficLight : Value 139 | 140 | val redToGreen = Transition(Red, Green) { light -> 141 | Result.success(light) 142 | } 143 | 144 | val transitioner = Transitioner(setOf(redToGreen)) 145 | val light = TrafficLight() 146 | transitioner.transition(light, Red, Green) 147 | ``` 148 | 149 | ## Additional Resources 150 | 151 | * See the test files for working examples 152 | * Check the package documentation for detailed API information 153 | * Review the Mermaid diagrams for visual state machine representations 154 | -------------------------------------------------------------------------------- /dokka-docs/Module.md: -------------------------------------------------------------------------------- 1 | # kFSM: Type-Safe Finite State Machines for Kotlin 2 | 3 | kFSM provides a robust, type-safe implementation of finite state machines in Kotlin, with optional Guice integration for dependency injection support. 4 | 5 | ## Core Concepts 6 | 7 | kFSM is built around four fundamental concepts: 8 | 9 | 1. **States** - Represent the possible conditions of your entity 10 | 2. **Values** - The entities that move between states 11 | 3. **Transitions** - Rules and effects for moving between states 12 | 4. **Transitioners** - Orchestrate and validate state changes 13 | 14 | ## Modules 15 | 16 | ### Core Library (`lib`) 17 | 18 | The core module provides the fundamental state machine implementation with these key features: 19 | 20 | * Type-safe state transitions with compile-time verification 21 | * Flexible state validation through invariants 22 | * Path finding between states 23 | * Mermaid diagram generation for documentation 24 | * Both synchronous and asynchronous transition support 25 | * Comprehensive testing utilities 26 | 27 | Key types: 28 | ```kotlin 29 | // Define states 30 | sealed class MyState : State 31 | 32 | // Define values that transition between states 33 | class MyValue : Value 34 | 35 | // Create transitions 36 | val transition = Transition(FromState, ToState) { value -> 37 | // Validation and effects 38 | Result.success(value) 39 | } 40 | 41 | // Use the transitioner 42 | val transitioner = Transitioner(transitions) 43 | transitioner.transition(value, fromState, toState) 44 | ``` 45 | 46 | ### Guice Integration (`lib-guice`) 47 | 48 | The Guice module adds dependency injection support with these features: 49 | 50 | * Automatic discovery of transitions 51 | * Annotation-based configuration 52 | * Integration with existing Guice modules 53 | * Support for multiple state machines 54 | 55 | Example usage: 56 | ```kotlin 57 | class MyModule : KfsmModule() { 58 | override fun configure() { 59 | install(KfsmModule()) 60 | bind().asEagerSingleton() 61 | } 62 | } 63 | 64 | @TransitionDefinition 65 | class MyTransitions @Inject constructor( 66 | private val service: MyService 67 | ) { 68 | fun getTransitions(): Set> = setOf( 69 | // Your transitions here 70 | ) 71 | } 72 | ``` 73 | 74 | ## Key Features 75 | 76 | ### Type Safety 77 | 78 | kFSM leverages Kotlin's type system to ensure: 79 | * States are properly defined and sealed 80 | * Transitions are between compatible states 81 | * Values match their state machine's type parameters 82 | 83 | ### Validation 84 | 85 | Multiple levels of validation ensure correctness: 86 | * Compile-time type checking 87 | * Runtime state machine verification 88 | * Custom state invariants 89 | * Transition-specific validation 90 | 91 | ### Documentation 92 | 93 | Built-in documentation support: 94 | * Mermaid diagram generation 95 | * State reachability analysis 96 | * Path finding between states 97 | 98 | ### Testing 99 | 100 | Comprehensive testing support: 101 | * State machine verification 102 | * Transition testing utilities 103 | * Path validation 104 | * Invariant checking 105 | 106 | ## Best Practices 107 | 108 | 1. **State Definition** 109 | * Use sealed class hierarchies for states 110 | * Keep states immutable 111 | * Define clear invariants 112 | 113 | 2. **Transitions** 114 | * Make transitions single-purpose 115 | * Include proper validation 116 | * Handle errors gracefully 117 | 118 | 3. **Values** 119 | * Keep values immutable 120 | * Include only essential state 121 | * Use proper type parameters 122 | 123 | 4. **Integration** 124 | * Verify state machines at startup 125 | * Generate documentation 126 | * Use dependency injection when available 127 | 128 | ## Example 129 | 130 | A simple traffic light implementation: 131 | 132 | ```kotlin 133 | sealed class TrafficLightState : State 134 | object Red : TrafficLightState() 135 | object Yellow : TrafficLightState() 136 | object Green : TrafficLightState() 137 | 138 | class TrafficLight : Value 139 | 140 | val redToGreen = Transition(Red, Green) { light -> 141 | Result.success(light) 142 | } 143 | 144 | val transitioner = Transitioner(setOf(redToGreen)) 145 | val light = TrafficLight() 146 | transitioner.transition(light, Red, Green) 147 | ``` 148 | 149 | ## Additional Resources 150 | 151 | * See the test files for working examples 152 | * Check the package documentation for detailed API information 153 | * Review the Mermaid diagrams for visual state machine representations 154 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/app/cash/kfsm/State.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | /** 4 | * Base class for defining states in a finite state machine. 5 | * 6 | * States are the foundation of kFSM. Each state: 7 | * - Knows which states it can transition to directly 8 | * - Can validate invariants that must hold while in this state 9 | * - Can determine paths to other reachable states 10 | * 11 | * Example: 12 | * ```kotlin 13 | * sealed class TrafficLightState : State 14 | * object Red : TrafficLightState(() -> setOf(Green)) 15 | * object Yellow : TrafficLightState(() -> setOf(Red)) 16 | * object Green : TrafficLightState(() -> setOf(Yellow)) 17 | * ``` 18 | * 19 | * @param ID The type used to identify values (often String or Long) 20 | * @param V The type of values that can be in this state 21 | * @param S The sealed class type representing all possible states 22 | * @property transitionsFn Function that returns the set of states this state can transition to 23 | * @property invariants List of conditions that must hold true while in this state 24 | */ 25 | open class State, S : State>( 26 | transitionsFn: () -> Set, 27 | private val invariants: List> = emptyList() 28 | ) { 29 | /** 30 | * The set of states that can be reached directly from this state through a single transition. 31 | */ 32 | val subsequentStates: Set by lazy { transitionsFn() } 33 | 34 | /** 35 | * The set of all states that can eventually be reached from this state through any number of transitions. 36 | */ 37 | val reachableStates: Set by lazy { expand() } 38 | 39 | /** 40 | * Checks if this state can transition directly to another state in a single step. 41 | * 42 | * @param other The state to check if we can transition to 43 | * @return true if a direct transition is possible, false otherwise 44 | */ 45 | open fun canDirectlyTransitionTo(other: S): Boolean = subsequentStates.contains(other) 46 | 47 | /** 48 | * Checks if this state can eventually reach another state through any number of transitions. 49 | * 50 | * @param other The state to check if we can eventually reach 51 | * @return true if the state is reachable, false otherwise 52 | */ 53 | open fun canEventuallyTransitionTo(other: S): Boolean = reachableStates.contains(other) 54 | 55 | /** 56 | * Validates that a value meets all invariants defined for this state. 57 | * 58 | * Invariants are conditions that must hold true while a value is in this state. 59 | * This method checks all invariants and returns the first failure encountered, 60 | * or success if all invariants pass. 61 | * 62 | * @param value The value to validate against this state's invariants 63 | * @return A Result containing the value if valid, or the first failure encountered 64 | */ 65 | fun validate(value: V): Result = 66 | invariants 67 | .map { it.validate(value) } 68 | .firstOrNull { it.isFailure } 69 | ?: Result.success(value) 70 | 71 | /** 72 | * Finds the shortest path to reach a target state from this state. 73 | * 74 | * Uses a breadth-first search algorithm to find the shortest sequence of states 75 | * that leads from this state to the target state. The path includes both the 76 | * starting and ending states. 77 | * 78 | * Example: 79 | * ```kotlin 80 | * // Given states A -> B -> C 81 | * val path = A.shortestPathTo(C) // Returns [A, B, C] 82 | * ``` 83 | * 84 | * @param to The target state to reach 85 | * @return A list of states representing the shortest path, or empty if no path exists 86 | */ 87 | @Suppress("UNCHECKED_CAST") 88 | fun shortestPathTo(to: S): List { 89 | val start = this as S 90 | 91 | if (this == to) return listOf(start) 92 | if (!canEventuallyTransitionTo(to)) return emptyList() 93 | 94 | val initialQueue = ArrayDeque(listOf(start)) 95 | val predecessor = mutableMapOf(start to null) 96 | 97 | tailrec fun bfs(queue: ArrayDeque): List { 98 | if (queue.isEmpty()) return emptyList() 99 | 100 | val current = queue.removeFirst() 101 | 102 | if (current == to) { 103 | val path = 104 | generateSequence(to) { predecessor[it] } 105 | .toList() 106 | .asReversed() 107 | return path 108 | } 109 | 110 | current.subsequentStates.forEach { next -> 111 | if (next !in predecessor) { 112 | predecessor[next] = current 113 | queue += next 114 | } 115 | } 116 | 117 | return bfs(queue) 118 | } 119 | 120 | return bfs(initialQueue) 121 | } 122 | 123 | private fun expand(found: Set = emptySet()): Set = 124 | subsequentStates 125 | .minus(found) 126 | .flatMap { 127 | it.expand(subsequentStates + found) + it 128 | }.toSet() 129 | .plus(found) 130 | } 131 | -------------------------------------------------------------------------------- /lib-guice/src/main/kotlin/app/cash/kfsm/guice/StateMachine.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm.guice 2 | 3 | import app.cash.kfsm.NoPathToTargetState 4 | import app.cash.kfsm.State 5 | import app.cash.kfsm.Transition 6 | import app.cash.kfsm.Transitioner 7 | import app.cash.kfsm.Value 8 | import com.google.inject.Inject 9 | import com.google.inject.Singleton 10 | import kotlin.reflect.KClass 11 | 12 | /** 13 | * A Guice-managed wrapper around KFSM's [Transitioner] that provides convenient access to discovered transitions. 14 | * 15 | * This class is responsible for: 16 | * 1. Managing the set of available transitions 17 | * 2. Providing type-safe access to specific transitions 18 | * 3. Executing transitions using the underlying [Transitioner] 19 | * 20 | * @param V The type of value being managed by the state machine 21 | * @param S The type of state, must extend [State] 22 | * @property transitions The set of all available transitions, injected by Guice 23 | * @property transitioner The underlying KFSM transitioner that executes the transitions 24 | * 25 | * Example usage: 26 | * ```kotlin 27 | * @Inject 28 | * lateinit var stateMachine: StateMachine 29 | * 30 | * fun processTransition(value: MyValue) { 31 | * // Get available transitions for current state 32 | * val transitions = stateMachine.getAvailableTransitions(value.state) 33 | * 34 | * // Get a specific transition by its type 35 | * val transition = stateMachine.getTransition() 36 | * 37 | * // Execute a transition 38 | * val result = stateMachine.execute(value, transition) 39 | * } 40 | * ``` 41 | */ 42 | @Singleton 43 | class StateMachine, S : State> 44 | @Inject 45 | constructor( 46 | private val transitions: Set>, 47 | private val transitioner: Transitioner, V, S> 48 | ) { 49 | // Cache transitions by their class for faster lookup 50 | private val transitionsByType: Map>, Transition> = 51 | transitions.associateBy { it::class.java } 52 | 53 | /** 54 | * Returns all transitions that are valid for the given state. 55 | * 56 | * A transition is considered valid if its 'from' states include the current state. 57 | * 58 | * @param state The current state to check transitions for 59 | * @return Set of valid transitions for the given state 60 | */ 61 | fun getAvailableTransitions(state: S): Set> = 62 | transitions 63 | .filter { transition -> 64 | transition.from.set.contains(state) 65 | }.toSet() 66 | 67 | /** 68 | * Gets a specific transition by its type. 69 | * 70 | * @param T The specific transition type to retrieve 71 | * @return The requested transition instance 72 | * @throws IllegalArgumentException if no transition of the specified type is found 73 | */ 74 | inline fun > getTransition(): T = getTransition(T::class) 75 | 76 | /** 77 | * Gets a specific transition by its [KClass]. 78 | * 79 | * @param klass The [KClass] of the transition to retrieve 80 | * @return The requested transition instance 81 | * @throws IllegalArgumentException if no transition of the specified type is found 82 | */ 83 | @Suppress("UNCHECKED_CAST") 84 | fun > getTransition(klass: KClass): T = 85 | transitionsByType[klass.java] as? T 86 | ?: error("No transition found for type ${klass.simpleName}") 87 | 88 | /** 89 | * Executes the given transition on the provided value. 90 | * 91 | * @param value The value to transition 92 | * @param transition The transition to execute 93 | * @return A [Result] containing either the transitioned value or an error 94 | */ 95 | fun execute( 96 | value: V, 97 | transition: Transition 98 | ): Result = transitioner.transition(value, transition) 99 | 100 | /** 101 | * Attempts to transition the given value to the specified state. 102 | * 103 | * This function will only succeed if there exists a transition that can take the value 104 | * from its current state to the target state in a single step. 105 | * 106 | * @param value The value to transition 107 | * @param targetState The state to transition to 108 | * @return A [Result] containing either the transitioned value or an error 109 | */ 110 | fun transitionToState( 111 | value: V, 112 | targetState: S 113 | ): Result = 114 | when { 115 | value.state.canDirectlyTransitionTo(targetState) -> 116 | getAvailableTransitions(value.state) 117 | .find { it.to == targetState } 118 | ?.let { execute(value, it) } ?: Result.failure(NoPathToTargetState(value, targetState)) 119 | else -> Result.failure(NoPathToTargetState(value, targetState)) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /lib-guice/src/main/kotlin/app/cash/kfsm/guice/KfsmModule.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm.guice 2 | 3 | import app.cash.kfsm.State 4 | import app.cash.kfsm.Transition 5 | import app.cash.kfsm.Transitioner 6 | import app.cash.kfsm.Value 7 | import app.cash.kfsm.guice.annotations.TransitionDefinition 8 | import app.cash.kfsm.guice.annotations.TransitionerDefinition 9 | import com.google.inject.AbstractModule 10 | import com.google.inject.TypeLiteral 11 | import com.google.inject.multibindings.Multibinder 12 | import com.google.inject.util.Types 13 | import org.reflections.Reflections 14 | import org.reflections.scanners.Scanners 15 | import org.reflections.util.ConfigurationBuilder 16 | 17 | /** 18 | * Base Guice module for KFSM integration that automatically discovers and binds transitions. 19 | * 20 | * This module scans the specified package for classes annotated with [TransitionDefinition] 21 | * and binds them to a set of transitions that can be injected into the [StateMachine]. 22 | * If no package is specified, it will scan the package of the concrete module class. 23 | * 24 | * @param ID The type of the ID for the value 25 | * @param V The type of value being managed by the state machine 26 | * @param S The type of state, must extend [State] 27 | * @property basePackage The base package to scan for transitions. If not provided, uses the package of the concrete module class. 28 | * @property types The type literals for the state machine, transitions, and transitioner 29 | * 30 | * Example usage: 31 | * ```kotlin 32 | * // With explicit package 33 | * class MyStateMachineModule : KfsmModule( 34 | * basePackage = "com.example.myapp", 35 | * types = typeLiteralsFor(MyValue::class.java, MyState::class.java) 36 | * ) 37 | * 38 | * // Using default package (scans the package of MyStateMachineModule) 39 | * class MyStateMachineModule : KfsmModule( 40 | * types = typeLiteralsFor(MyValue::class.java, MyState::class.java) 41 | * ) 42 | * ``` 43 | */ 44 | abstract class KfsmModule, S : State>( 45 | private val types: KfsmMachineTypes, 46 | private val basePackage: String? = null, 47 | ) : AbstractModule() { 48 | 49 | @Suppress("UNCHECKED_CAST") 50 | override fun configure() { 51 | // Use the concrete module's package if basePackage is not provided 52 | val packageToScan = basePackage ?: this::class.java.`package`.name 53 | 54 | // Create a multibinder for the transition set 55 | val transitionBinder = Multibinder.newSetBinder(binder(), types.transition) 56 | 57 | // Configure and create the reflections instance for scanning 58 | val reflections = Reflections( 59 | ConfigurationBuilder() 60 | .forPackages(packageToScan) 61 | .setScanners(Scanners.TypesAnnotated) 62 | ) 63 | 64 | // Find and bind all transitions 65 | reflections 66 | .getTypesAnnotatedWith(TransitionDefinition::class.java) 67 | .asSequence() 68 | .filter { clazz -> Transition::class.java.isAssignableFrom(clazz) } 69 | .map { it as Class> } 70 | .forEach { transitionClass -> 71 | transitionBinder.addBinding().to(transitionClass) 72 | } 73 | 74 | // Find and bind the transitioner 75 | reflections.getTypesAnnotatedWith(TransitionerDefinition::class.java) 76 | .asSequence() 77 | .filter { clazz -> Transitioner::class.java.isAssignableFrom(clazz) } 78 | .map { it as Class> } 79 | .forEach { transitionerClass -> 80 | bind(types.transitioner) 81 | .to(transitionerClass as Class, V, S>>) 82 | } 83 | 84 | // Bind the state machine 85 | bind(types.stateMachine) 86 | } 87 | 88 | companion object { 89 | 90 | data class KfsmMachineTypes, S : State>( 91 | val stateMachine: TypeLiteral>, 92 | val transition: TypeLiteral>, 93 | val transitioner: TypeLiteral, V, S>>, 94 | ) 95 | 96 | @Suppress("UNCHECKED_CAST") 97 | fun , S : State> typeLiteralsFor( 98 | idType: Class, 99 | valueType: Class, 100 | stateType: Class 101 | ): KfsmMachineTypes { 102 | val stateMachineType = Types.newParameterizedType(StateMachine::class.java, idType, valueType, stateType) 103 | val transitionType = Types.newParameterizedType(Transition::class.java, idType, valueType, stateType) 104 | val transitionerType = Types.newParameterizedType(Transitioner::class.java, idType, transitionType, valueType, stateType) 105 | 106 | return KfsmMachineTypes( 107 | TypeLiteral.get(stateMachineType) as TypeLiteral>, 108 | TypeLiteral.get(transitionType) as TypeLiteral>, 109 | TypeLiteral.get(transitionerType) as TypeLiteral, V, S>>, 110 | ) 111 | } 112 | } 113 | } 114 | 115 | 116 | -------------------------------------------------------------------------------- /lib/src/test/kotlin/app/cash/kfsm/MachineBuilderTest.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.collections.shouldContainOnly 5 | import io.kotest.matchers.result.shouldBeFailure 6 | import io.kotest.matchers.result.shouldBeSuccess 7 | import io.kotest.matchers.should 8 | import io.kotest.matchers.shouldBe 9 | import kotlin.String 10 | import kotlin.runCatching 11 | 12 | class MachineBuilderTest : 13 | StringSpec({ 14 | "an empty machine" { 15 | fsm {} 16 | .getOrThrow() 17 | .transitionMap shouldBe emptyMap() 18 | } 19 | 20 | "a self loop" { 21 | fsm { 22 | B becomes { 23 | B via { it } 24 | } 25 | }.getOrThrow().transitionMap should { 26 | it.keys shouldContainOnly setOf(B) 27 | it[B]?.keys shouldContainOnly setOf(B) 28 | } 29 | } 30 | 31 | "mix of effect, transition and function values" { 32 | fsm { 33 | B becomes { 34 | B via { it } 35 | C via Effect { runCatching { it } } 36 | D via 37 | object : Transition(B, D) { } 38 | } 39 | }.getOrThrow().transitionMap should { 40 | it.keys shouldContainOnly setOf(B) 41 | it[B]?.keys shouldContainOnly setOf(B, C, D) 42 | } 43 | } 44 | 45 | "a full machine" { 46 | val machine = 47 | fsm { 48 | A.becomes { 49 | B.via { it } 50 | } 51 | B.becomes { 52 | B.via { it } 53 | C.via { it } 54 | D.via { it.copy(id = "dave") } 55 | } 56 | C.becomes { 57 | D.via { it } 58 | } 59 | D.becomes { 60 | B.via { it } 61 | E.via { it } 62 | } 63 | }.getOrThrow() 64 | 65 | machine.transitionTo(Letter(B, "barry"), D).getOrThrow() shouldBe Letter(D, "dave") 66 | } 67 | 68 | "disallows becomes block with no targets" { 69 | fsm { 70 | B.becomes {} 71 | }.shouldBeFailure().message shouldBe 72 | "State B defines a `becomes` block with no transitions" 73 | } 74 | 75 | "disallows redeclaration of from state" { 76 | fsm { 77 | B.becomes { 78 | C.via { it } 79 | } 80 | B.becomes { 81 | D.via { it } 82 | } 83 | }.shouldBeFailure().message shouldBe "State B has multiple `becomes` blocks defined" 84 | } 85 | 86 | "disallows redeclaration of to state" { 87 | fsm { 88 | B.becomes { 89 | C.via { it } 90 | C.via { it } 91 | } 92 | }.shouldBeFailure().message shouldBe "State C already has a transition defined from B" 93 | } 94 | 95 | "disallows transitions between states that do not permit them" { 96 | fsm { 97 | C.becomes { 98 | B.via { it } 99 | } 100 | }.shouldBeFailure().message shouldBe "State C declares that it cannot transition to B. " + 101 | "Either the fsm declaration or the State is incorrect" 102 | } 103 | 104 | "can optionally define selectors" { 105 | val machine = 106 | fsm { 107 | A.becomes { 108 | B.via { it.copy(id = "bedford") } 109 | } 110 | B.becomes(selector = { Result.success(C) }) { 111 | B.via { it } 112 | C.via { it.copy(id = "citroën") } 113 | D.via { it } 114 | } 115 | C.becomes { 116 | D.via { it.copy(id = "datsun") } 117 | } 118 | D.becomes { 119 | B.via { it.copy(id = "bristol") } 120 | E.via { it } 121 | } 122 | }.getOrThrow() 123 | 124 | val a = Letter(A, "audi") 125 | val b = Letter(B, "bedford") 126 | val c = Letter(C, "citroën") 127 | val d = Letter(D, "datsun") 128 | val e = Letter(E, "eagle") 129 | 130 | machine.advance(a).shouldBeSuccess(b) 131 | machine.advance(b).shouldBeSuccess(c) 132 | machine.advance(c).shouldBeSuccess(d) 133 | machine.advance(d).shouldBeFailure().message shouldBe 134 | "State D has multiple subsequent states, but no NextStateSelector was provided" 135 | machine.transitionTo(d, B).shouldBeSuccess(b.copy(id = "bristol")) 136 | machine.advance(e).shouldBeFailure().message shouldBe "No selector for state E" 137 | } 138 | 139 | "can access from and to states in inline transitions" { 140 | val machine = 141 | fsm { 142 | A.becomes { 143 | B.via { it.copy(id = "was $from is now $to") } 144 | } 145 | }.getOrThrow() 146 | 147 | machine.advance(Letter(A, "alice")).getOrThrow() shouldBe Letter(B, "was A is now B") 148 | } 149 | }) 150 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | ## Overview 4 | 5 | kFSM uses automated publishing to Maven Central via GitHub Actions. The release process is triggered by creating a tag in semver format (e.g., `v0.10.4`) on the main branch. 6 | 7 | ## Prerequisites 8 | 9 | Before releasing, ensure you have: 10 | - Write access to the repository 11 | - Access to the required GitHub secrets: 12 | - `SONATYPE_CENTRAL_USERNAME` 13 | - `SONATYPE_CENTRAL_PASSWORD` 14 | - `GPG_SECRET_KEY` 15 | - `GPG_SECRET_PASSPHRASE` 16 | 17 | ## Release Steps 18 | 19 | ### 1. Prepare the Release 20 | 21 | 1. Set the release version: 22 | 23 | ```sh 24 | export RELEASE_VERSION=A.B.C 25 | ``` 26 | 27 | 2. Create a release branch: 28 | 29 | ```sh 30 | git checkout -b release/$RELEASE_VERSION 31 | ``` 32 | 33 | 3. Update `CHANGELOG.md` with changes since the last release. Follow the existing `CHANGELOG.md` format, which is derived from [this guide](https://keepachangelog.com/en/1.0.0/) 34 | 35 | 4. Update the version in `gradle.properties`: 36 | 37 | ```sh 38 | sed -i "" \ 39 | "s/VERSION_NAME=.*/VERSION_NAME=$RELEASE_VERSION/g" \ 40 | gradle.properties 41 | ``` 42 | 43 | 5. Commit and push the release branch: 44 | 45 | ```sh 46 | git add . 47 | git commit -m "Prepare for release $RELEASE_VERSION" 48 | git push origin release/$RELEASE_VERSION 49 | ``` 50 | 51 | 6. Create a pull request to merge the release branch into main: 52 | 53 | ```sh 54 | gh pr create --title "Release $RELEASE_VERSION" --body "Release version $RELEASE_VERSION" 55 | ``` 56 | 57 | 7. Review and merge the pull request to main 58 | 59 | ### 2. Create and Push the Release Tag 60 | 61 | Once the release PR is merged to main: 62 | 63 | 1. Pull the latest changes from main: 64 | 65 | ```sh 66 | git checkout main 67 | git pull origin main 68 | ``` 69 | 70 | 2. Create a tag in semver format (must start with "v"): 71 | 72 | ```sh 73 | git tag -a v$RELEASE_VERSION -m "Release version $RELEASE_VERSION" 74 | git push origin v$RELEASE_VERSION 75 | ``` 76 | 77 | ### 3. Automated Publishing 78 | 79 | Once the tag is pushed, the [Publish to Maven Central](https://github.com/cashapp/kfsm/actions/workflows/publish.yml) workflow will automatically: 80 | 81 | 1. Build both artifacts: 82 | - `app.cash.kfsm:kfsm:$version` (core library) 83 | - `app.cash.kfsm:kfsm-guice:$version` (Guice integration) 84 | 85 | 2. Sign the artifacts with GPG 86 | 87 | 3. Publish to Maven Central via Sonatype 88 | 89 | 4. Generate and publish documentation to GitHub Pages 90 | 91 | **Note**: It can take 10-30 minutes for artifacts to appear on Maven Central after successful publishing. 92 | 93 | ### 4. Create GitHub Release 94 | 95 | 1. Go to [GitHub Releases](https://github.com/cashapp/kfsm/releases/new) 96 | 2. Select the tag you just created (`v$RELEASE_VERSION`) 97 | 3. Copy the release notes from `CHANGELOG.md` into the release description 98 | 4. Publish the release 99 | 100 | ### 5. Prepare for Next Development Version 101 | 102 | 1. Create a new branch for the next development version: 103 | 104 | ```sh 105 | export NEXT_VERSION=A.B.D-SNAPSHOT 106 | git checkout -b next-version/$NEXT_VERSION 107 | ``` 108 | 109 | 2. Update the version in `gradle.properties` to the next snapshot version: 110 | 111 | ```sh 112 | sed -i "" \ 113 | "s/VERSION_NAME=.*/VERSION_NAME=$NEXT_VERSION/g" \ 114 | gradle.properties 115 | ``` 116 | 117 | 3. Commit and push the changes: 118 | 119 | ```sh 120 | git add . 121 | git commit -m "Prepare next development version" 122 | git push origin next-version/$NEXT_VERSION 123 | ``` 124 | 125 | 4. Create a pull request to merge the next version branch into main: 126 | 127 | ```sh 128 | gh pr create --title "Prepare next development version" --body "Update version to $NEXT_VERSION" 129 | ``` 130 | 131 | 5. Review and merge the pull request 132 | 133 | ## Troubleshooting 134 | 135 | ### Publishing Failures 136 | 137 | - If the GitHub Action fails, check the workflow logs for specific error messages 138 | - Common issues include: 139 | - Invalid GPG key or passphrase 140 | - Incorrect Sonatype credentials 141 | - Version conflicts (if the version was already published) 142 | - Network connectivity issues 143 | 144 | ### Manual Intervention 145 | 146 | If the automated publishing fails and you need to manually intervene: 147 | 148 | 1. Check the [Sonatype Nexus](https://oss.sonatype.org/) staging repository 149 | 2. Drop any failed artifacts from the staging repository 150 | 3. Fix the issue and re-tag the release (delete the old tag first) 151 | 4. Re-run the workflow 152 | 153 | ### Access Issues 154 | 155 | If you don't have access to the required secrets or Sonatype account, contact the project maintainers. 156 | 157 | ## Release Artifacts 158 | 159 | Each release includes: 160 | 161 | - **Core Library**: `app.cash.kfsm:kfsm:$version` 162 | - Main JAR with compiled classes 163 | - Sources JAR 164 | - Javadoc JAR 165 | - POM file 166 | 167 | - **Guice Integration**: `app.cash.kfsm:kfsm-guice:$version` 168 | - Main JAR with compiled classes 169 | - Sources JAR 170 | - Javadoc JAR 171 | - POM file 172 | 173 | All artifacts are signed with GPG and published to Maven Central. 174 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/app/cash/kfsm/Transitioner.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | import app.cash.kfsm.annotations.ExperimentalLibraryApi 4 | 5 | abstract class Transitioner, V : Value, S : State> { 6 | 7 | /** 8 | * Optional outbox handler for capturing deferrable effects. 9 | * 10 | * When set, transitions implementing [DeferrableEffect] will have their effects captured 11 | * and stored in the outbox as part of the same transaction, instead of being executed 12 | * immediately. This enables the transactional outbox pattern where the effects are only 13 | * executed once all database operations are successful and committed. 14 | */ 15 | @ExperimentalLibraryApi 16 | open val outboxHandler: OutboxHandler? = null 17 | 18 | /** Will be executed prior to the transition effect. Failure here will terminate the transition */ 19 | open fun preHook(value: V, via: T): Result = Result.success(Unit) 20 | 21 | /** Will be executed after the transition effect. Use this to persist the value. */ 22 | open fun persist(from: S, value: V, via: T): Result = Result.success(value) 23 | 24 | /** 25 | * Will be executed after the transition effect to persist the value along with outbox messages. 26 | * 27 | * Override this method when using the transactional outbox pattern to persist both the state 28 | * change and the captured effects in a single transaction. 29 | * 30 | * Example: 31 | * ```kotlin 32 | * override fun persistWithOutbox( 33 | * from: S, 34 | * value: V, 35 | * via: T, 36 | * outboxMessages: List> 37 | * ): Result = runCatching { 38 | * database.runInTransaction { 39 | * database.update(value).getOrThrow() 40 | * outboxMessages.forEach { database.insertOutbox(it) } 41 | * value 42 | * } 43 | * } 44 | * ``` 45 | * 46 | * @param from The previous state 47 | * @param value The value with the new state 48 | * @param via The transition being applied 49 | * @param outboxMessages List of captured effects to persist in the same transaction 50 | * @return The persisted value 51 | */ 52 | @ExperimentalLibraryApi 53 | open fun persistWithOutbox( 54 | from: S, 55 | value: V, 56 | via: T, 57 | outboxMessages: List> 58 | ): Result = persist(from, value, via) 59 | 60 | /** Will be executed after the transition effect & value persistence. Use this to perform side effects such as notifications. */ 61 | open fun postHook(from: S, value: V, via: T): Result = Result.success(Unit) 62 | 63 | /** 64 | * Execute the given transition on the given value. 65 | * 66 | * If the target state is already present, then this is a no-op. 67 | * If the provided transition cannot apply to the value's state, then this is a failure. 68 | * Otherwise, the transition is applied and the state is updated in the returned value. 69 | */ 70 | fun transition( 71 | value: V, 72 | transition: T 73 | ): Result = when { 74 | transition.from.set.contains(value.state) -> doTheTransition(value, transition) 75 | // Self-cycled transitions will be effected by the first case. 76 | // If we still see a transition to self then this is a no-op. 77 | transition.to == value.state -> ignoreAlreadyCompletedTransition(value, transition) 78 | else -> Result.failure(InvalidStateForTransition(transition, value)) 79 | } 80 | 81 | private fun , S : State> Value 82 | .validateAndUpdate(newState: S): Result = newState.validate(update(newState)) 83 | 84 | private fun doTheTransition( 85 | value: V, 86 | transition: T 87 | ): Result = 88 | runCatching { preHook(value, transition).getOrThrow() } 89 | .mapCatching { executeOrCaptureEffect(value, transition).getOrThrow() } 90 | .mapCatching { it.validateAndUpdate(transition.to).getOrThrow() } 91 | .mapCatching { persistWithCapture(value.state, it, transition).getOrThrow() } 92 | .mapCatching { it.also { postHook(value.state, it, transition).getOrThrow() } } 93 | 94 | private fun executeOrCaptureEffect(value: V, transition: T): Result { 95 | val handler = outboxHandler 96 | return if (handler != null && transition is DeferrableEffect<*, *, *>) { 97 | // Safe cast: transition is of type T which is bound by Transition, 98 | // so the DeferrableEffect type parameters must match ID, V, S at runtime 99 | @Suppress("UNCHECKED_CAST") 100 | val typedEffect = transition as DeferrableEffect 101 | handler.captureEffect(value, typedEffect) 102 | } else { 103 | transition.effect(value) 104 | } 105 | } 106 | 107 | private fun persistWithCapture(from: S, value: V, transition: T): Result { 108 | val handler = outboxHandler 109 | return if (handler != null) { 110 | val messages = handler.getPendingMessages() 111 | persistWithOutbox(from, value, transition, messages) 112 | .also { handler.clearPending() } 113 | } else { 114 | persist(from, value, transition) 115 | } 116 | } 117 | 118 | private fun ignoreAlreadyCompletedTransition( 119 | value: V, 120 | transition: T 121 | ): Result = Result.success(value.update(transition.to)) 122 | } 123 | 124 | -------------------------------------------------------------------------------- /lib-guice/README.md: -------------------------------------------------------------------------------- 1 | # KFSM Guice Integration 2 | 3 | This module provides [Google Guice](https://github.com/google/guice) integration for KFSM (Kotlin Finite State Machine), enabling automatic discovery and dependency injection of state machine components. 4 | 5 | ## Features 6 | 7 | - Automatic discovery and binding of transitions using annotations 8 | - Automatic discovery and binding of transitioners 9 | - Type-safe state machine injection 10 | - Seamless integration with existing Guice modules 11 | 12 | ## Installation 13 | 14 | Add the dependency to your project: 15 | 16 | ```kotlin 17 | dependencies { 18 | implementation("app.cash.kfsm:kfsm-guice:$version") 19 | } 20 | ``` 21 | 22 | ## Usage 23 | 24 | ### 1. Define Your States and Values 25 | 26 | ```kotlin 27 | enum class OrderState : State { 28 | PENDING, PROCESSING, COMPLETED, CANCELLED 29 | } 30 | 31 | data class Order( 32 | override val state: OrderState, 33 | val id: String, 34 | val items: List 35 | ) : Value { 36 | override fun update(newState: OrderState): Order = copy(state = newState) 37 | } 38 | ``` 39 | 40 | ### 2. Create Transitions 41 | 42 | Annotate your transitions with `@TransitionDefinition`: 43 | 44 | ```kotlin 45 | @TransitionDefinition 46 | class ProcessOrder @Inject constructor( 47 | private val orderProcessor: OrderProcessor 48 | ) : Transition( 49 | from = States(OrderState.PENDING), 50 | to = OrderState.PROCESSING 51 | ) { 52 | override fun effect(value: Order): Result = 53 | orderProcessor.process(value) 54 | .map { it.update(OrderState.PROCESSING) } 55 | } 56 | ``` 57 | 58 | ### 3. Create a Transitioner (Optional) 59 | 60 | If you need custom transition handling, create a transitioner and annotate it with `@TransitionerDefinition`: 61 | 62 | ```kotlin 63 | @TransitionerDefinition 64 | class OrderTransitioner @Inject constructor() : 65 | Transitioner, Order, OrderState> { 66 | override fun transition( 67 | value: Order, 68 | transition: Transition 69 | ): Result = transition.effect(value) 70 | } 71 | ``` 72 | 73 | ### 4. Create a Guice Module 74 | 75 | Extend `KfsmModule` to automatically discover and bind your transitions: 76 | 77 | ```kotlin 78 | class OrderModule : KfsmModule( 79 | types = typeLiteralsFor(Order::class.java, OrderState::class.java) 80 | ) 81 | ``` 82 | 83 | The module will automatically: 84 | - Scan its package for transitions annotated with `@TransitionDefinition` 85 | - Scan for transitioners annotated with `@TransitionerDefinition` 86 | - Bind the state machine and all its dependencies 87 | 88 | You can also specify a custom package to scan: 89 | 90 | ```kotlin 91 | class OrderModule : KfsmModule( 92 | basePackage = "com.example.orders", 93 | types = typeLiteralsFor(Order::class.java, OrderState::class.java) 94 | ) 95 | ``` 96 | 97 | ### 5. Use the State Machine 98 | 99 | Inject and use the state machine in your code: 100 | 101 | ```kotlin 102 | class OrderService @Inject constructor( 103 | private val stateMachine: StateMachine 104 | ) { 105 | fun processOrder(order: Order) { 106 | // Get available transitions 107 | val transitions = stateMachine.getAvailableTransitions(order.state) 108 | 109 | // Get a specific transition 110 | val processTransition = stateMachine.getTransition() 111 | 112 | // Execute the transition 113 | val result = stateMachine.execute(order, processTransition) 114 | } 115 | } 116 | ``` 117 | 118 | ## Testing 119 | 120 | The module provides easy testing capabilities. Here's an example using Kotest: 121 | 122 | ```kotlin 123 | class OrderStateMachineTest : StringSpec({ 124 | val injector = Guice.createInjector(OrderModule()) 125 | val stateMachine = injector.getInstance( 126 | Key.get(object : TypeLiteral>() {}) 127 | ) 128 | 129 | "PENDING order should transition to PROCESSING" { 130 | val order = Order(OrderState.PENDING, "123", emptyList()) 131 | val result = stateMachine.execute( 132 | order, 133 | stateMachine.getTransition() 134 | ).getOrThrow() 135 | result.state shouldBe OrderState.PROCESSING 136 | } 137 | }) 138 | ``` 139 | 140 | ## Best Practices 141 | 142 | 1. Keep transitions focused and single-purpose 143 | 2. Use dependency injection for transition dependencies 144 | 3. Place related transitions in the same package 145 | 4. Use meaningful names for transitions that reflect their purpose 146 | 5. Handle transition failures appropriately in your transitioner 147 | 148 | ## License 149 | 150 | ``` 151 | Copyright 2024 Square, Inc. 152 | 153 | Licensed under the Apache License, Version 2.0 (the "License"); 154 | you may not use this file except in compliance with the License. 155 | You may obtain a copy of the License at 156 | 157 | http://www.apache.org/licenses/LICENSE-2.0 158 | 159 | Unless required by applicable law or agreed to in writing, software 160 | distributed under the License is distributed on an "AS IS" BASIS, 161 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 162 | See the License for the specific language governing permissions and 163 | limitations under the License. 164 | ``` -------------------------------------------------------------------------------- /lib/src/test/kotlin/app/cash/kfsm/exemplar/PenelopesPerfectDayTest.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm.exemplar 2 | 3 | import app.cash.kfsm.StateMachineUtilities 4 | import app.cash.kfsm.exemplar.Hamster.Asleep 5 | import app.cash.kfsm.exemplar.Hamster.Awake 6 | import app.cash.kfsm.exemplar.Hamster.Eating 7 | import app.cash.kfsm.exemplar.Hamster.RunningOnWheel 8 | import io.kotest.core.spec.IsolationMode 9 | import io.kotest.core.spec.style.StringSpec 10 | import io.kotest.matchers.collections.shouldBeEmpty 11 | import io.kotest.matchers.result.shouldBeFailure 12 | import io.kotest.matchers.result.shouldBeSuccess 13 | import io.kotest.matchers.shouldBe 14 | 15 | class PenelopesPerfectDayTest : 16 | StringSpec({ 17 | isolationMode = IsolationMode.InstancePerTest 18 | 19 | val hamster = Hamster(name = "Penelope", state = Awake) 20 | 21 | // In this example we extend the transitioner with our own type `HamsterTransitioner` in order to define 22 | // hooks that will be executed before each transition and after each successful transition. 23 | val transitioner = HamsterTransitioner() 24 | 25 | "a newly woken hamster eats broccoli" { 26 | val result = transitioner.transition(hamster, EatBreakfast("broccoli")).shouldBeSuccess() 27 | result.state shouldBe Eating 28 | transitioner.locks shouldBe listOf(hamster) 29 | transitioner.unlocks shouldBe listOf(result) 30 | transitioner.saves shouldBe listOf(result) 31 | transitioner.notifications shouldBe 32 | listOf("Penelope was Awake, then began eating broccoli for breakfast and is now Eating") 33 | } 34 | 35 | "the hamster has trouble eating cheese" { 36 | transitioner.transition(hamster, EatBreakfast("cheese")) shouldBeFailure 37 | LactoseIntoleranceTroubles("cheese") 38 | transitioner.locks shouldBe listOf(hamster) 39 | transitioner.unlocks.shouldBeEmpty() 40 | transitioner.saves.shouldBeEmpty() 41 | transitioner.notifications.shouldBeEmpty() 42 | } 43 | 44 | "a sleeping hamster can awaken yet again" { 45 | transitioner 46 | .transition(hamster, EatBreakfast("broccoli")) 47 | .map { transitioner.transition(it, RunOnWheel).getOrThrow() } 48 | .map { transitioner.transition(it, GoToBed).getOrThrow() } 49 | .map { transitioner.transition(it, WakeUp).getOrThrow() } 50 | .map { transitioner.transition(it, EatBreakfast("broccoli")).getOrThrow() } 51 | .shouldBeSuccess() 52 | .state shouldBe Eating 53 | transitioner.locks shouldBe 54 | listOf( 55 | hamster, 56 | hamster.copy(state = Eating), 57 | hamster.copy(state = RunningOnWheel), 58 | hamster.copy(state = Asleep), 59 | hamster.copy(state = Awake) 60 | ) 61 | transitioner.unlocks shouldBe transitioner.saves 62 | transitioner.saves shouldBe 63 | listOf( 64 | hamster.copy(state = Eating), 65 | hamster.copy(state = RunningOnWheel), 66 | hamster.copy(state = Asleep), 67 | hamster.copy(state = Awake), 68 | hamster.copy(state = Eating) 69 | ) 70 | transitioner.notifications shouldBe 71 | listOf( 72 | "Penelope was Awake, then began eating broccoli for breakfast and is now Eating", 73 | "Penelope was Eating, then began running on the wheel and is now RunningOnWheel", 74 | "Penelope was RunningOnWheel, then began going to bed and is now Asleep", 75 | "Penelope was Asleep, then began waking up and is now Awake", 76 | "Penelope was Awake, then began eating broccoli for breakfast and is now Eating" 77 | ) 78 | } 79 | 80 | "a sleeping hamster cannot immediately start running on the wheel" { 81 | transitioner.transition(hamster.copy(state = Asleep), RunOnWheel).shouldBeFailure() 82 | transitioner.locks.shouldBeEmpty() 83 | transitioner.unlocks.shouldBeEmpty() 84 | transitioner.saves.shouldBeEmpty() 85 | transitioner.notifications.shouldBeEmpty() 86 | } 87 | 88 | "an eating hamster who wants to eat twice as hard will just keep eating" { 89 | val eatingHamster = hamster.copy(state = Eating) 90 | transitioner 91 | .transition(eatingHamster, EatBreakfast("broccoli")) 92 | .shouldBeSuccess(eatingHamster) 93 | transitioner.locks.shouldBeEmpty() 94 | transitioner.unlocks.shouldBeEmpty() 95 | transitioner.saves.shouldBeEmpty() 96 | transitioner.notifications.shouldBeEmpty() 97 | } 98 | 99 | // Add a test like this to ensure you don't have states that cannot be reached 100 | "the state machine is hunky dory" { 101 | StateMachineUtilities.verify(Awake).shouldBeSuccess() 102 | } 103 | 104 | // Use this method to create mermaid diagrams in your markdown. 105 | // TODO(jem) - add a custom kotest matcher for ensuring the markdown is in a specific project file. 106 | "the mermaid diagram should be correct" { 107 | StateMachineUtilities.mermaid(Awake).shouldBeSuccess( 108 | """ 109 | stateDiagram-v2 110 | [*] --> Awake 111 | Asleep --> Awake 112 | Awake --> Eating 113 | Eating --> Asleep 114 | Eating --> Resting 115 | Eating --> RunningOnWheel 116 | Resting --> Asleep 117 | RunningOnWheel --> Asleep 118 | RunningOnWheel --> Resting 119 | """.trimIndent() 120 | ) 121 | } 122 | }) 123 | -------------------------------------------------------------------------------- /lib/src/test/kotlin/app/cash/kfsm/OutboxTest.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.collections.shouldHaveSize 5 | import io.kotest.matchers.result.shouldBeSuccess 6 | import io.kotest.matchers.shouldBe 7 | 8 | class OutboxTest : StringSpec({ 9 | 10 | "captures deferrable effects in outbox instead of executing immediately" { 11 | val outboxHandler = InMemoryOutboxHandler() 12 | val transition = DeferrableLetterTransition(from = A, to = B, effectData = "test-effect") 13 | 14 | // Variable to capture messages during persistence 15 | var capturedMessages: List>? = null 16 | 17 | val transitioner = object : Transitioner() { 18 | override val outboxHandler = outboxHandler 19 | 20 | override fun persist(from: Char, value: Letter, via: LetterTransition): Result = 21 | Result.success(value) 22 | 23 | override fun persistWithOutbox( 24 | from: Char, 25 | value: Letter, 26 | via: LetterTransition, 27 | outboxMessages: List> 28 | ): Result { 29 | // Capture the messages for verification 30 | capturedMessages = outboxMessages 31 | // In a real implementation, you would persist both the value and outbox messages in a transaction 32 | return Result.success(value) 33 | } 34 | } 35 | 36 | val letter = Letter(A, "letter-1") 37 | val result = transitioner.transition(letter, transition) 38 | 39 | // Verify transition succeeded 40 | result shouldBeSuccess Letter(B, "letter-1") 41 | 42 | // Verify effect was NOT executed immediately 43 | transition.effectExecuted shouldBe false 44 | 45 | // Verify effect was captured in outbox 46 | capturedMessages!! shouldHaveSize 1 47 | 48 | val message = capturedMessages!!.first() 49 | message.valueId shouldBe "letter-1" 50 | message.effectPayload.effectType shouldBe "letter-transition" 51 | message.effectPayload.data shouldBe "test-effect" 52 | message.status shouldBe OutboxStatus.PENDING 53 | } 54 | 55 | "executes regular effects immediately when not deferrable" { 56 | val outboxHandler = InMemoryOutboxHandler() 57 | val transition = RegularLetterTransition(from = A, to = B) 58 | 59 | // Variable to capture messages during persistence 60 | var capturedMessages: List>? = null 61 | 62 | val transitioner = object : Transitioner() { 63 | override val outboxHandler = outboxHandler 64 | 65 | override fun persist(from: Char, value: Letter, via: LetterTransition): Result = 66 | Result.success(value) 67 | 68 | override fun persistWithOutbox( 69 | from: Char, 70 | value: Letter, 71 | via: LetterTransition, 72 | outboxMessages: List> 73 | ): Result { 74 | capturedMessages = outboxMessages 75 | return Result.success(value) 76 | } 77 | } 78 | 79 | val letter = Letter(A, "letter-2") 80 | val result = transitioner.transition(letter, transition) 81 | 82 | // Verify transition succeeded 83 | result shouldBeSuccess Letter(B, "letter-2") 84 | 85 | // Verify effect WAS executed immediately 86 | transition.effectExecuted shouldBe true 87 | 88 | // Verify nothing was captured in outbox 89 | capturedMessages!! shouldHaveSize 0 90 | } 91 | }) 92 | 93 | // Test transitions 94 | private class DeferrableLetterTransition( 95 | from: Char, 96 | to: Char, 97 | private val effectData: String 98 | ) : LetterTransition(from, to), DeferrableEffect { 99 | 100 | var effectExecuted = false 101 | 102 | override fun effect(value: Letter): Result { 103 | effectExecuted = true 104 | return Result.success(value.update(to)) 105 | } 106 | 107 | override fun serialize(value: Letter): Result = Result.success( 108 | EffectPayload( 109 | effectType = "letter-transition", 110 | data = effectData 111 | ) 112 | ) 113 | 114 | override val effectType = "letter-transition" 115 | } 116 | 117 | private class RegularLetterTransition( 118 | from: Char, 119 | to: Char 120 | ) : LetterTransition(from, to) { 121 | 122 | var effectExecuted = false 123 | 124 | override fun effect(value: Letter): Result { 125 | effectExecuted = true 126 | return Result.success(value.update(to)) 127 | } 128 | } 129 | 130 | // Simple in-memory outbox handler for testing 131 | private class InMemoryOutboxHandler, S : State> : OutboxHandler { 132 | 133 | private val messages = mutableListOf>() 134 | 135 | override fun captureEffect(value: V, effect: DeferrableEffect): Result { 136 | val payload = effect.serialize(value).getOrElse { return Result.failure(it) } 137 | val message = OutboxMessage( 138 | id = "msg-${messages.size + 1}", 139 | valueId = value.id, 140 | effectPayload = payload, 141 | createdAt = System.currentTimeMillis() 142 | ) 143 | messages.add(message) 144 | return Result.success(value) 145 | } 146 | 147 | override fun getPendingMessages(): List> = messages.toList() 148 | 149 | override fun clearPending() { 150 | messages.clear() 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /lib/src/test/kotlin/app/cash/kfsm/InvariantTest.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.result.shouldBeFailure 5 | import io.kotest.matchers.result.shouldBeSuccess 6 | import io.kotest.matchers.shouldBe 7 | import java.math.BigDecimal 8 | 9 | data class Order( 10 | override val state: OrderState, 11 | override val id: String, 12 | val items: List, 13 | val total: BigDecimal, 14 | val shippingAddress: String? = null 15 | ) : Value { 16 | override fun update(newState: OrderState): Order = copy(state = newState) 17 | } 18 | 19 | data class Item(val name: String, val price: BigDecimal) 20 | 21 | sealed class OrderState( 22 | transitionsFn: () -> Set, 23 | invariants: List> = emptyList() 24 | ) : State(transitionsFn, invariants) { 25 | object Draft : OrderState( 26 | transitionsFn = { setOf(Submitted) }, 27 | invariants = listOf( 28 | invariant("Order must have at least one item") { it.items.isNotEmpty() }, 29 | invariant("Order total must be positive") { it.total > BigDecimal.ZERO } 30 | ) 31 | ) 32 | 33 | object Submitted : OrderState( 34 | transitionsFn = { setOf(Processing) }, 35 | invariants = listOf( 36 | invariant("Order must have a shipping address") { it.shippingAddress != null } 37 | ) 38 | ) 39 | 40 | object Processing : OrderState( 41 | transitionsFn = { setOf(Shipped) } 42 | ) 43 | 44 | object Shipped : OrderState( 45 | transitionsFn = { setOf(Delivered) } 46 | ) 47 | 48 | object Delivered : OrderState( 49 | transitionsFn = { emptySet() } 50 | ) 51 | } 52 | 53 | class InvariantTest : StringSpec({ 54 | class OrderTransition : Transition(OrderState.Draft, OrderState.Submitted) { 55 | override fun effect(value: Order): Result = Result.success(value) 56 | } 57 | 58 | class TestTransitioner : Transitioner() 59 | 60 | val transitioner = TestTransitioner() 61 | val transition = OrderTransition() 62 | 63 | "Draft state should validate order with items and positive total" { 64 | val order = Order( 65 | state = OrderState.Draft, 66 | id = "order-1", 67 | items = listOf(Item("Widget", BigDecimal("10.00"))), 68 | total = BigDecimal("10.00") 69 | ) 70 | OrderState.Draft.validate(order).shouldBeSuccess() 71 | } 72 | 73 | "Draft state should fail validation for empty order" { 74 | val order = Order( 75 | state = OrderState.Draft, 76 | id = "order-1", 77 | items = emptyList(), 78 | total = BigDecimal("10.00") 79 | ) 80 | OrderState.Draft.validate(order).shouldBeFailure() 81 | } 82 | 83 | "Draft state should fail validation for negative total" { 84 | val order = Order( 85 | state = OrderState.Draft, 86 | id = "order-1", 87 | items = listOf(Item("Widget", BigDecimal("10.00"))), 88 | total = BigDecimal("-10.00") 89 | ) 90 | OrderState.Draft.validate(order).shouldBeFailure() 91 | } 92 | 93 | "Submitted state should validate order with shipping address" { 94 | val order = Order( 95 | state = OrderState.Submitted, 96 | id = "order-1", 97 | items = listOf(Item("Widget", BigDecimal("10.00"))), 98 | total = BigDecimal("10.00"), 99 | shippingAddress = "123 Main St" 100 | ) 101 | OrderState.Submitted.validate(order).shouldBeSuccess() 102 | } 103 | 104 | "Submitted state should fail validation without shipping address" { 105 | val order = Order( 106 | state = OrderState.Submitted, 107 | id = "order-1", 108 | items = listOf(Item("Widget", BigDecimal("10.00"))), 109 | total = BigDecimal("10.00") 110 | ) 111 | OrderState.Submitted.validate(order).shouldBeFailure() 112 | } 113 | 114 | "Transitioner should validate invariants during transition" { 115 | val draftOrder = Order( 116 | state = OrderState.Draft, 117 | id = "order-1", 118 | items = listOf(Item("Widget", BigDecimal("10.00"))), 119 | total = BigDecimal("10.00") 120 | ) 121 | 122 | val result = transitioner.transition(draftOrder, transition) 123 | result.shouldBeFailure() 124 | } 125 | 126 | "Transitioner should succeed when all invariants are met" { 127 | val draftOrder = Order( 128 | state = OrderState.Draft, 129 | id = "order-1", 130 | items = listOf(Item("Widget", BigDecimal("10.00"))), 131 | total = BigDecimal("10.00"), 132 | shippingAddress = "123 Main St" 133 | ) 134 | 135 | val result = transitioner.transition(draftOrder, transition) 136 | result.shouldBeSuccess() 137 | result.getOrThrow().state shouldBe OrderState.Submitted 138 | } 139 | 140 | "InvalidInvariant should contain the correct error message" { 141 | val order = Order( 142 | state = OrderState.Draft, 143 | id = "order-1", 144 | items = emptyList(), 145 | total = BigDecimal("10.00") 146 | ) 147 | 148 | val result = OrderState.Draft.validate(order) 149 | result.shouldBeFailure() 150 | result.exceptionOrNull()?.message shouldBe "Order must have at least one item" 151 | } 152 | 153 | "Multiple invariant failures should show the first failure message" { 154 | val order = Order( 155 | state = OrderState.Draft, 156 | id = "order-1", 157 | items = emptyList(), 158 | total = BigDecimal("-10.00") 159 | ) 160 | 161 | val result = OrderState.Draft.validate(order) 162 | result.shouldBeFailure() 163 | result.exceptionOrNull()?.message shouldBe "Order must have at least one item" 164 | } 165 | }) 166 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [Unreleased] 4 | 5 | ## [0.12.0] 6 | 7 | * Added experimental support for `DefferableEffect`, a type of effect that can be used to implement the transactional outbox pattern directly in KFSM. 8 | 9 | ## [0.11.2] 10 | 11 | ### Fix 12 | 13 | * Restored exception in cases of invalid transition attempts to be NoPathToTargetState 14 | 15 | ## [0.11.1] 16 | 17 | ### Added 18 | * Added `StateMachine::advance` method to automatically progress to the next state using a state selector, 19 | enabling dynamic state transitions without explicitly specifying the target state. 20 | * Exposed the state values $to and $from in the inline effect syntax. e.g. 21 | ```kotlin 22 | A becomes { 23 | B via { it.copy(message = "from $from to $to" ) } 24 | } 25 | ``` 26 | 27 | ## [0.11.0] 28 | 29 | ### Added 30 | * Introduced MachineBuilder DSL for creating type-safe state machines with a more intuitive syntax 31 | 32 | ### Breaking 33 | * In order to make room for a new StateMachine type, the existing object StateMachine, which was a short collection of 34 | static utilities, has been renamed to StateMachineUtilities. 35 | 36 | ## [0.10.3] 37 | 38 | Modified maven publishing configuration again. It now works with the new maven central portal. 39 | 40 | ## [0.10.2] 41 | 42 | Modified maven publishing configuration. 43 | 44 | ## [0.10.1] 45 | 46 | ### Added 47 | * Adds `shortestPathTo` method on `State` 48 | 49 | ## [0.10.0] 50 | 51 | ### Breaking 52 | * Added `from` parameter to `persist` method in `Transitioner` and `TransitionerAsync` to provide the previous state during persistence operations. This allows for more context-aware persistence operations that can take into account both the previous and new state. 53 | 54 | ### Added 55 | * Added support for state invariants, allowing you to define conditions that must hold true for values in specific states. This includes: 56 | * A new `invariant` DSL function for defining state-specific conditions 57 | * Automatic validation of invariants during state transitions 58 | * Support for custom error messages when invariants are violated 59 | * Integration with the existing state machine validation system 60 | 61 | ## [0.9.0] 62 | 63 | ### Breaking 64 | * Renamed `InvalidStateTransition` to `InvalidStateForTransition` to better reflect its purpose. This is a breaking change that requires updating any code that catches or references this exception type. 65 | 66 | ### Added 67 | * Added `transitionToState` function to `StateMachine` that allows transitioning to a specific state if it's immediately reachable. This provides a simpler API for cases where the caller knows the target state and doesn't need to specify a particular transition. 68 | 69 | ## [0.8.3] 70 | 71 | * Fixed dependency configuration in order to fix a runtime failure with lib-guice. 72 | 73 | ## [0.8.2] 74 | 75 | * Fixed gradle properties for lib-guice to enable publishing to the remote repository. 76 | 77 | ## [0.8.0] 78 | 79 | ### Breaking 80 | 81 | * Added ID type parameter to Value interface to support custom identifier types. This is a breaking change that requires: 82 | * Adding an ID type parameter to all Value implementations 83 | * Adding an ID type parameter to all Transition, Transitioner, and related classes 84 | * Implementing the `id` property in all Value implementations 85 | * Updating all type references to include the ID type parameter 86 | 87 | ## [0.7.5] 88 | 89 | * Added id(): String function to Value. 90 | 91 | ## [0.7.4] 92 | 93 | * Exposes state on `InvalidStateTransition` 94 | * Added additional information to invalid transition error messages. 95 | 96 | ## [0.7.0] 97 | 98 | ### Breaking 99 | 100 | * Added type argument to State to allow for the ability to add customer behaviour to your states. This is a breaking 101 | change as it will require you to update your state classes to include the type argument. 102 | 103 | ## [0.6.0] 104 | 105 | ### Breaking 106 | 107 | * Converted the default usages of the library to be non-suspending. Added the suspending variants back as `*Async`. 108 | * Added the transition to the persist function and moved persist to be an open method instead of function injection 109 | constructor argument. 110 | 111 | ## [0.5.1] 112 | 113 | ### Breaking 114 | 115 | * As promised with the 0.5 release, the Arrow specific library has been removed. Please migrate to `lib`. 116 | * Replace `NonEmptySet` with `States`. 117 | * Replace ErrorOr/Either with `Result`. 118 | * Removed the v0.3 API. Please migrate to the new API. 119 | 120 | ## [0.5.0] 121 | 122 | ### Breaking 123 | 124 | * Introduced States as a proxy for NonEmptySet when defining Transitions. This allows for safer transition definitions 125 | in the non-Arrow library. 126 | * The Arrow specific library will eventually be removed, as the non-Arrow presenting API has equivalent semantics. 127 | 128 | 129 | ## [0.4.0] 130 | 131 | ### Breaking 132 | 133 | * Upon request, introduced a new API that uses kotlin native types and does not include Arrow as a dependency. 134 | The original lib is renamed `lib-arrow`. 135 | 136 | ## [0.3.0] 137 | 138 | ### Breaking 139 | 140 | * Refined type aliases on Transitioner so that implementations are free to define a base transition type that may 141 | implement common functionality. For example, a new base transition type can define a common way to execute 142 | side-effects that occur in pre and post hook transitioner functions. See TransitionerTest use of 143 | `specificToThisTransitionType` for an example. 144 | 145 | ## [0.2.0] 146 | 147 | ### Breaking 148 | 149 | * Changes to new API's method signatures and types required to integrate with its first real project. 150 | 151 | ## [0.1.0] 152 | 153 | ### Breaking 154 | 155 | * `StateMachine.verify` no longer requires a second argument to declare the base type of the state machine. This is now 156 | inferred from the first argument.` 157 | 158 | ### Added 159 | 160 | * `StateMachine.mermaid` is a new utility that will generate mermaid diagram from your state machine. 161 | * New simplified API is being introduced to make it easier to use the library. This can be found in the package 162 | `app.cash.kfsm`. It is not yet ready for production use, but we are looking for feedback on the new API. 163 | 164 | 165 | ## [0.0.2] - 2023-09-11 166 | 167 | ### Added 168 | 169 | * Initial release from internal 170 | -------------------------------------------------------------------------------- /lib/api/lib.api: -------------------------------------------------------------------------------- 1 | public final class app/cash/kfsm/InvalidStateForTransition : java/lang/Exception { 2 | public fun (Lapp/cash/kfsm/Transition;Lapp/cash/kfsm/Value;)V 3 | public final fun component2 ()Lapp/cash/kfsm/Value; 4 | public final fun copy (Lapp/cash/kfsm/Transition;Lapp/cash/kfsm/Value;)Lapp/cash/kfsm/InvalidStateForTransition; 5 | public static synthetic fun copy$default (Lapp/cash/kfsm/InvalidStateForTransition;Lapp/cash/kfsm/Transition;Lapp/cash/kfsm/Value;ILjava/lang/Object;)Lapp/cash/kfsm/InvalidStateForTransition; 6 | public fun equals (Ljava/lang/Object;)Z 7 | public final fun getValue ()Lapp/cash/kfsm/Value; 8 | public fun hashCode ()I 9 | public fun toString ()Ljava/lang/String; 10 | } 11 | 12 | public final class app/cash/kfsm/InvalidStateMachine : java/lang/Exception { 13 | public fun (Ljava/lang/String;)V 14 | public final fun component1 ()Ljava/lang/String; 15 | public final fun copy (Ljava/lang/String;)Lapp/cash/kfsm/InvalidStateMachine; 16 | public static synthetic fun copy$default (Lapp/cash/kfsm/InvalidStateMachine;Ljava/lang/String;ILjava/lang/Object;)Lapp/cash/kfsm/InvalidStateMachine; 17 | public fun equals (Ljava/lang/Object;)Z 18 | public fun getMessage ()Ljava/lang/String; 19 | public fun hashCode ()I 20 | public fun toString ()Ljava/lang/String; 21 | } 22 | 23 | public abstract interface class app/cash/kfsm/Invariant { 24 | public abstract fun validate-IoAF18A (Lapp/cash/kfsm/Value;)Ljava/lang/Object; 25 | } 26 | 27 | public final class app/cash/kfsm/InvariantDslKt { 28 | public static final fun invariant (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lapp/cash/kfsm/Invariant; 29 | } 30 | 31 | public final class app/cash/kfsm/NoPathToTargetState : java/lang/Exception { 32 | public fun (Lapp/cash/kfsm/Value;Lapp/cash/kfsm/State;)V 33 | public final fun getTargetState ()Lapp/cash/kfsm/State; 34 | public final fun getValue ()Lapp/cash/kfsm/Value; 35 | } 36 | 37 | public final class app/cash/kfsm/PreconditionNotMet : java/lang/Exception { 38 | public fun (Ljava/lang/String;)V 39 | public final fun component1 ()Ljava/lang/String; 40 | public final fun copy (Ljava/lang/String;)Lapp/cash/kfsm/PreconditionNotMet; 41 | public static synthetic fun copy$default (Lapp/cash/kfsm/PreconditionNotMet;Ljava/lang/String;ILjava/lang/Object;)Lapp/cash/kfsm/PreconditionNotMet; 42 | public fun equals (Ljava/lang/Object;)Z 43 | public fun getMessage ()Ljava/lang/String; 44 | public fun hashCode ()I 45 | public fun toString ()Ljava/lang/String; 46 | } 47 | 48 | public class app/cash/kfsm/State { 49 | public fun (Lkotlin/jvm/functions/Function0;Ljava/util/List;)V 50 | public synthetic fun (Lkotlin/jvm/functions/Function0;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V 51 | public fun canDirectlyTransitionTo (Lapp/cash/kfsm/State;)Z 52 | public fun canEventuallyTransitionTo (Lapp/cash/kfsm/State;)Z 53 | public final fun getReachableStates ()Ljava/util/Set; 54 | public final fun getSubsequentStates ()Ljava/util/Set; 55 | public final fun shortestPathTo (Lapp/cash/kfsm/State;)Ljava/util/List; 56 | public final fun validate-IoAF18A (Lapp/cash/kfsm/Value;)Ljava/lang/Object; 57 | } 58 | 59 | public final class app/cash/kfsm/StateMachine { 60 | public static final field INSTANCE Lapp/cash/kfsm/StateMachine; 61 | public final fun mermaid-IoAF18A (Lapp/cash/kfsm/State;)Ljava/lang/Object; 62 | public final fun verify-IoAF18A (Lapp/cash/kfsm/State;)Ljava/lang/Object; 63 | } 64 | 65 | public final class app/cash/kfsm/States { 66 | public static final field Companion Lapp/cash/kfsm/States$Companion; 67 | public fun (Lapp/cash/kfsm/State;Ljava/util/Set;)V 68 | public fun (Lapp/cash/kfsm/State;[Lapp/cash/kfsm/State;)V 69 | public final fun component1 ()Lapp/cash/kfsm/State; 70 | public final fun component2 ()Ljava/util/Set; 71 | public final fun copy (Lapp/cash/kfsm/State;Ljava/util/Set;)Lapp/cash/kfsm/States; 72 | public static synthetic fun copy$default (Lapp/cash/kfsm/States;Lapp/cash/kfsm/State;Ljava/util/Set;ILjava/lang/Object;)Lapp/cash/kfsm/States; 73 | public fun equals (Ljava/lang/Object;)Z 74 | public final fun getA ()Lapp/cash/kfsm/State; 75 | public final fun getOther ()Ljava/util/Set; 76 | public final fun getSet ()Ljava/util/Set; 77 | public fun hashCode ()I 78 | public fun toString ()Ljava/lang/String; 79 | } 80 | 81 | public final class app/cash/kfsm/States$Companion { 82 | public final fun toStates (Ljava/util/Set;)Lapp/cash/kfsm/States; 83 | } 84 | 85 | public class app/cash/kfsm/Transition { 86 | public fun (Lapp/cash/kfsm/State;Lapp/cash/kfsm/State;)V 87 | public fun (Lapp/cash/kfsm/States;Lapp/cash/kfsm/State;)V 88 | public fun effect-IoAF18A (Lapp/cash/kfsm/Value;)Ljava/lang/Object; 89 | public fun effectAsync-gIAlu-s (Lapp/cash/kfsm/Value;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 90 | public final fun getFrom ()Lapp/cash/kfsm/States; 91 | public final fun getTo ()Lapp/cash/kfsm/State; 92 | } 93 | 94 | public abstract class app/cash/kfsm/Transitioner { 95 | public fun ()V 96 | public fun persist-0E7RQCE (Lapp/cash/kfsm/State;Lapp/cash/kfsm/Value;Lapp/cash/kfsm/Transition;)Ljava/lang/Object; 97 | public fun postHook-0E7RQCE (Lapp/cash/kfsm/State;Lapp/cash/kfsm/Value;Lapp/cash/kfsm/Transition;)Ljava/lang/Object; 98 | public fun preHook-gIAlu-s (Lapp/cash/kfsm/Value;Lapp/cash/kfsm/Transition;)Ljava/lang/Object; 99 | public final fun transition-gIAlu-s (Lapp/cash/kfsm/Value;Lapp/cash/kfsm/Transition;)Ljava/lang/Object; 100 | } 101 | 102 | public abstract class app/cash/kfsm/TransitionerAsync { 103 | public fun ()V 104 | public fun persist-BWLJW6A (Lapp/cash/kfsm/State;Lapp/cash/kfsm/Value;Lapp/cash/kfsm/Transition;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 105 | public fun postHook-BWLJW6A (Lapp/cash/kfsm/State;Lapp/cash/kfsm/Value;Lapp/cash/kfsm/Transition;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 106 | public fun preHook-0E7RQCE (Lapp/cash/kfsm/Value;Lapp/cash/kfsm/Transition;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 107 | public final fun transition-0E7RQCE (Lapp/cash/kfsm/Value;Lapp/cash/kfsm/Transition;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 108 | } 109 | 110 | public abstract interface class app/cash/kfsm/Value { 111 | public abstract fun getId ()Ljava/lang/Object; 112 | public abstract fun getState ()Lapp/cash/kfsm/State; 113 | public abstract fun update (Lapp/cash/kfsm/State;)Lapp/cash/kfsm/Value; 114 | } 115 | 116 | -------------------------------------------------------------------------------- /docs/implementation_guide.md: -------------------------------------------------------------------------------- 1 | # State Machine Implementation Guide 2 | 3 | ## Overview 4 | 5 | Many services orchestrate complex workflows best modeled as state machines. To make 6 | this easier, we created the open-source [KFSM](https://github.com/block/kfsm) library. Because there are multiple valid 7 | ways to design a state machine, this guide documents our current __best practices__ for modeling, implementing, and 8 | operating them. 9 | 10 | ## Definitions and naming conventions 11 | 12 | ### States 13 | 14 | __States__ are the named situations a machine can be in over time. While in a state, certain __invariants__ hold, certain __events__ 15 | are accepted, and optional __behaviours__ may run. A __transition__ moves the machine to another state when a __trigger__ occurs and 16 | __guards__ pass. 17 | 18 | States often represent that: 19 | - the system is __waiting for input__ from a user, 20 | - the system is __waiting for data__ from another service, or 21 | - the system must __perform work__ whose result determines where to go next. 22 | 23 | #### Naming 24 | 25 | - In-progress: use __present-participle__ verb phrases, e.g. `CHECKING_ELIGIBILITY`, `COLLECTING_INFO`. 26 | - Terminal / outcome: use __past-participle__ or adjectival forms, e.g. `SANCTIONED`, `FAILED`, `CONFIRMED_COMPLETE`. 27 | 28 | ### Transitions (triggers → state changes) 29 | 30 | A __transition__ is the state change from a __source__ state to a __target__ state, taken when a __trigger/event__ 31 | happens and the __guard__ is true. While crossing it, the machine may run __effects__ (actions). 32 | 33 | #### Naming 34 | 35 | - Name transitions after the event that causes the change, e.g. `SanctionsDecisionFreeze`, `Eligible`, `OverLimit`. 36 | 37 | ### Effects (actions) 38 | 39 | An effect is the side-effect or computation the machine performs as part of a transition (or on state entry/exit). 40 | Effects do not choose the branch for the current trigger. Effects should be idempotent and quick; long work 41 | should be modelled as async calls that publish a completion/failure event. 42 | 43 | #### Naming 44 | 45 | - Name effects using the naming conventions of functions in code (e.g. createTransaction(), voidTransaction(), freezeFunds()). 46 | 47 | ## Implementation Notes 48 | 49 | ### Effect types 50 | 51 | #### Request/response effects (need a return value) 52 | Example: creating a ledger transaction and receiving a transaction token needed later to confirm/void. 53 | - Implement as RPC; make it idempotent (idempotency key) so the transition can be retried safely. 54 | - Persist any returned identifiers in the machine’s context. 55 | 56 | #### Fire-and-forget effects (no return value required) 57 | Example: voiding a transaction, sending a notification. 58 | - Prefer the transactional outbox pattern to guarantee delivery. kFSM provides the core interfaces (`DeferrableEffect`, `OutboxHandler`) while you implement the message processing logic based on your infrastructure needs. 59 | 60 | ### Error handling 61 | 62 | Aim for a balance between safety and customer experience: 63 | 64 | - While still interacting with the user (e.g., information collection), prefer failing fast on unexpected errors. 65 | - After a submission (no user in the loop), prefer retrying operations and avoiding terminal failure unless business rules require it. 66 | 67 | When exposing a state machine via the [Domain API framework](https://github.com/block/domain-api), error handling typically lives in [Controller](https://github.com/block/domain-api/blob/main/lib/src/main/kotlin/xyz/block/domainapi/util/Controller.kt) classes, through handleFailure(...). Use it to: 68 | 69 | - decide whether to fail the business process now, or 70 | - log and continue if the process can make progress later (e.g., via retries/outbox). 71 | 72 | ## Example 73 | 74 | The following is a simplified example of a state machine for a Bitcoin on-chain withdrawal that follows the conventions in this guide: 75 | 76 | ```mermaid 77 | stateDiagram-v2 78 | [*] --> COLLECTING_INFO 79 | CHECKING_ELIGIBILITY : CHECKING_ELIGIBILITY 80 | CHECKING_RISK : CHECKING_RISK 81 | CHECKING_SANCTIONS : CHECKING_SANCTIONS 82 | CHECKING_TRAVEL_RULE : CHECKING_TRAVEL_RULE 83 | COLLECTING_INFO : COLLECTING_INFO 84 | COLLECTING_SANCTIONS_INFO : COLLECTING_SANCTIONS_INFO 85 | COLLECTING_SELF_ATTESTATION : COLLECTING_SELF_ATTESTATION 86 | CONFIRMED_COMPLETE : CONFIRMED_COMPLETE 87 | FAILED : FAILED 88 | SANCTIONED : SANCTIONED 89 | WAITING_FOR_CONFIRMED_ON_CHAIN_STATUS : WAITING_FOR_CONFIRMED_ON_CHAIN_STATUS 90 | WAITING_FOR_PENDING_CONFIRMATION_STATUS : WAITING_FOR_PENDING_CONFIRMATION_STATUS 91 | WAITING_FOR_SANCTIONS_HELD_DECISION : WAITING_FOR_SANCTIONS_HELD_DECISION 92 | 93 | CHECKING_ELIGIBILITY --> FAILED : Ineligible / voidTransaction() 94 | CHECKING_ELIGIBILITY --> CHECKING_LIMITS : Eligible 95 | CHECKING_LIMITS --> FAILED : OverLimit / voidTransaction() 96 | CHECKING_LIMITS --> WAITING_FOR_PENDING_CONFIRMATION_STATUS : UnderLimit / submitWithdrawal() 97 | CHECKING_RISK --> CHECKING_TRAVEL_RULE : RiskApprove 98 | CHECKING_RISK --> FAILED: RiskBlock 99 | CHECKING_SANCTIONS --> CHECKING_RISK: SanctionsApprove 100 | CHECKING_SANCTIONS --> COLLECTING_SANCTIONS_INFO : SanctionsHold 101 | CHECKING_TRAVEL_RULE --> CHECKING_ELIGIBILITY: NoSelfAttestationNeeded 102 | CHECKING_TRAVEL_RULE --> COLLECTING_SELF_ATTESTATION: SelfAttestationNeeded 103 | COLLECTING_INFO --> CHECKING_SANCTIONS : InfoComplete / createTransaction() 104 | COLLECTING_INFO --> FAILED : Fail 105 | COLLECTING_SANCTIONS_INFO --> WAITING_FOR_SANCTIONS_HELD_DECISION: InfoComplete 106 | COLLECTING_SELF_ATTESTATION --> CHECKING_ELIGIBILITY: InfoComplete 107 | WAITING_FOR_CONFIRMED_ON_CHAIN_STATUS --> CONFIRMED_COMPLETE : ConfirmedOnChain / confirmTransaction() 108 | WAITING_FOR_CONFIRMED_ON_CHAIN_STATUS --> FAILED : FailedOnChain / voidTransaction() 109 | WAITING_FOR_PENDING_CONFIRMATION_STATUS --> WAITING_FOR_CONFIRMED_ON_CHAIN_STATUS : ObservedInMempool 110 | WAITING_FOR_PENDING_CONFIRMATION_STATUS --> CONFIRMED_COMPLETE : ConfirmedOnChain / confirmTransaction() 111 | WAITING_FOR_PENDING_CONFIRMATION_STATUS --> FAILED : FailedOnChain / voidTransaction() 112 | WAITING_FOR_SANCTIONS_HELD_DECISION --> CHECKING_ELIGIBILITY: SanctionsDecisionApprove 113 | WAITING_FOR_SANCTIONS_HELD_DECISION --> FAILED : SanctionsDecisionFail / voidTransaction() 114 | WAITING_FOR_SANCTIONS_HELD_DECISION --> SANCTIONED : SanctionsDecisionFreeze / freezeFunds() 115 | ``` 116 | 117 | 118 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | # noinspection EditorConfigKeyCorrectness 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | max_line_length = 120 11 | tab_width = 2 12 | trailing-comma-on-call-site = true 13 | trailing-comma-on-declaration-site = true 14 | # ktLint-specific rules: 15 | disabled_rules=import-ordering 16 | # IntelliJ-specific rules: 17 | ij_continuation_indent_size = 2 18 | ij_formatter_off_tag = @formatter:off 19 | ij_formatter_on_tag = @formatter:on 20 | ij_formatter_tags_enabled = false 21 | ij_smart_tabs = false 22 | ij_visual_guides = 120 23 | ij_wrap_on_typing = false 24 | 25 | [.editorconfig] 26 | ij_visual_guides = none 27 | ij_editorconfig_align_group_field_declarations = false 28 | ij_editorconfig_space_after_colon = false 29 | ij_editorconfig_space_after_comma = true 30 | ij_editorconfig_space_before_colon = false 31 | ij_editorconfig_space_before_comma = false 32 | ij_editorconfig_spaces_around_assignment_operators = true 33 | 34 | [{*.kt,*.kts}] 35 | ij_continuation_indent_size = 2 36 | ij_kotlin_align_in_columns_case_branch = false 37 | ij_kotlin_align_multiline_binary_operation = false 38 | ij_kotlin_align_multiline_extends_list = false 39 | ij_kotlin_align_multiline_method_parentheses = false 40 | ij_kotlin_align_multiline_parameters = false 41 | ij_kotlin_align_multiline_parameters_in_calls = false 42 | ij_kotlin_allow_trailing_comma = false 43 | ij_kotlin_allow_trailing_comma_on_call_site = false 44 | ij_kotlin_assignment_wrap = normal 45 | ij_kotlin_blank_lines_after_class_header = 0 46 | ij_kotlin_blank_lines_around_block_when_branches = 0 47 | ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1 48 | ij_kotlin_block_comment_add_space = false 49 | ij_kotlin_block_comment_at_first_column = true 50 | ij_kotlin_call_parameters_new_line_after_left_paren = true 51 | ij_kotlin_call_parameters_right_paren_on_new_line = true 52 | ij_kotlin_call_parameters_wrap = on_every_item 53 | ij_kotlin_catch_on_new_line = false 54 | ij_kotlin_class_annotation_wrap = split_into_lines 55 | ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL 56 | ij_kotlin_continuation_indent_for_chained_calls = false 57 | ij_kotlin_continuation_indent_for_expression_bodies = false 58 | ij_kotlin_continuation_indent_in_argument_lists = false 59 | ij_kotlin_continuation_indent_in_elvis = false 60 | ij_kotlin_continuation_indent_in_if_conditions = false 61 | ij_kotlin_continuation_indent_in_parameter_lists = false 62 | ij_kotlin_continuation_indent_in_supertype_lists = false 63 | ij_kotlin_else_on_new_line = false 64 | ij_kotlin_enum_constants_wrap = split_into_lines 65 | ij_kotlin_extends_list_wrap = normal 66 | ij_kotlin_field_annotation_wrap = normal 67 | ij_kotlin_finally_on_new_line = false 68 | ij_kotlin_if_rparen_on_new_line = true 69 | ij_kotlin_import_nested_classes = false 70 | ij_kotlin_imports_layout = *,java.**,javax.**,kotlin.**,^ 71 | ij_kotlin_insert_whitespaces_in_simple_one_line_method = true 72 | ij_kotlin_keep_blank_lines_before_right_brace = 0 73 | ij_kotlin_keep_blank_lines_in_code = 1 74 | ij_kotlin_keep_blank_lines_in_declarations = 1 75 | ij_kotlin_keep_first_column_comment = true 76 | ij_kotlin_keep_indents_on_empty_lines = false 77 | ij_kotlin_keep_line_breaks = true 78 | ij_kotlin_lbrace_on_next_line = false 79 | ij_kotlin_line_break_after_multiline_when_entry = true 80 | ij_kotlin_line_comment_add_space = true 81 | ij_kotlin_line_comment_add_space_on_reformat = false 82 | ij_kotlin_line_comment_at_first_column = false 83 | ij_kotlin_method_annotation_wrap = normal 84 | ij_kotlin_method_call_chain_wrap = normal 85 | ij_kotlin_method_parameters_new_line_after_left_paren = true 86 | ij_kotlin_method_parameters_right_paren_on_new_line = true 87 | ij_kotlin_method_parameters_wrap = on_every_item 88 | ij_kotlin_name_count_to_use_star_import = 2147483647 89 | ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 90 | ij_kotlin_parameter_annotation_wrap = off 91 | ij_kotlin_space_after_comma = true 92 | ij_kotlin_space_after_extend_colon = true 93 | ij_kotlin_space_after_type_colon = true 94 | ij_kotlin_space_before_catch_parentheses = true 95 | ij_kotlin_space_before_comma = false 96 | ij_kotlin_space_before_extend_colon = true 97 | ij_kotlin_space_before_for_parentheses = true 98 | ij_kotlin_space_before_if_parentheses = true 99 | ij_kotlin_space_before_lambda_arrow = true 100 | ij_kotlin_space_before_type_colon = false 101 | ij_kotlin_space_before_when_parentheses = true 102 | ij_kotlin_space_before_while_parentheses = true 103 | ij_kotlin_spaces_around_additive_operators = true 104 | ij_kotlin_spaces_around_assignment_operators = true 105 | ij_kotlin_spaces_around_equality_operators = true 106 | ij_kotlin_spaces_around_function_type_arrow = true 107 | ij_kotlin_spaces_around_logical_operators = true 108 | ij_kotlin_spaces_around_multiplicative_operators = true 109 | ij_kotlin_spaces_around_range = false 110 | ij_kotlin_spaces_around_relational_operators = true 111 | ij_kotlin_spaces_around_unary_operator = false 112 | ij_kotlin_spaces_around_when_arrow = true 113 | ij_kotlin_variable_annotation_wrap = off 114 | ij_kotlin_while_on_new_line = false 115 | ij_kotlin_wrap_elvis_expressions = 1 116 | ij_kotlin_wrap_expression_body_functions = 1 117 | ij_kotlin_wrap_first_method_in_call_chain = false 118 | 119 | [{*.markdown,*.md}] 120 | indent_size = 4 121 | tab_width = 4 122 | ij_continuation_indent_size = 8 123 | ij_markdown_force_one_space_after_blockquote_symbol = true 124 | ij_markdown_force_one_space_after_header_symbol = true 125 | ij_markdown_force_one_space_after_list_bullet = true 126 | ij_markdown_force_one_space_between_words = true 127 | ij_markdown_insert_quote_arrows_on_wrap = true 128 | ij_markdown_keep_indents_on_empty_lines = false 129 | ij_markdown_keep_line_breaks_inside_text_blocks = true 130 | ij_markdown_max_lines_around_block_elements = 1 131 | ij_markdown_max_lines_around_header = 1 132 | ij_markdown_max_lines_between_paragraphs = 1 133 | ij_markdown_min_lines_around_block_elements = 1 134 | ij_markdown_min_lines_around_header = 1 135 | ij_markdown_min_lines_between_paragraphs = 1 136 | ij_markdown_wrap_text_if_long = true 137 | ij_markdown_wrap_text_inside_blockquotes = true 138 | 139 | [{*.yaml,*.yml}] 140 | ij_visual_guides = none 141 | ij_yaml_align_values_properties = do_not_align 142 | ij_yaml_autoinsert_sequence_marker = true 143 | ij_yaml_block_mapping_on_new_line = false 144 | ij_yaml_indent_sequence_value = true 145 | ij_yaml_keep_indents_on_empty_lines = false 146 | ij_yaml_keep_line_breaks = true 147 | ij_yaml_sequence_on_new_line = false 148 | ij_yaml_space_before_colon = false 149 | ij_yaml_spaces_within_braces = true 150 | ij_yaml_spaces_within_brackets = true 151 | -------------------------------------------------------------------------------- /lib-guice/docs/kfsm-guice-design.md: -------------------------------------------------------------------------------- 1 | # KFSM Guice Integration Design 2 | 3 | ## Overview 4 | 5 | The KFSM Guice integration module provides automatic discovery and dependency injection for KFSM components using Google Guice. It enables developers to define state machine transitions and transitioners using annotations, which are then automatically discovered and bound by the framework. 6 | 7 | ## Core Components 8 | 9 | ### 1. Annotations 10 | 11 | #### `@TransitionDefinition` 12 | - Marks a class as a transition that should be automatically discovered and bound 13 | - Applied to classes that extend `Transition` 14 | - Used by `KfsmModule` to discover and bind transitions 15 | 16 | #### `@TransitionerDefinition` 17 | - Marks a class as a transitioner that should be automatically discovered and bound 18 | - Applied to classes that extend `Transitioner, V, S>` 19 | - Used by `KfsmModule` to discover and bind transitioners 20 | 21 | ### 2. KfsmModule 22 | 23 | The core module that handles automatic discovery and binding of state machine components: 24 | 25 | ```kotlin 26 | abstract class KfsmModule, S : State>( 27 | private val basePackage: String? = null, 28 | private val types: KfsmMachineTypes, 29 | ) : AbstractModule() 30 | ``` 31 | 32 | Key features: 33 | - Automatically discovers and binds transitions annotated with `@TransitionDefinition` 34 | - Automatically discovers and binds transitioners annotated with `@TransitionerDefinition` 35 | - Uses package scanning to find components 36 | - Provides type-safe binding through `KfsmMachineTypes` 37 | 38 | ### 3. StateMachine 39 | 40 | A Guice-managed wrapper around KFSM's `Transitioner` that provides convenient access to discovered transitions: 41 | 42 | ```kotlin 43 | @Singleton 44 | class StateMachine, S : State> @Inject constructor( 45 | private val transitions: Set>, 46 | private val transitioner: Transitioner, V, S> 47 | ) 48 | ``` 49 | 50 | Features: 51 | - Manages the set of available transitions 52 | - Provides type-safe access to specific transitions 53 | - Executes transitions using the underlying transitioner 54 | 55 | ## Usage Flow 56 | 57 | 1. **Define States and Values** 58 | - Create state and value types that implement `State` and `Value` interfaces 59 | - Example: `OrderState` enum and `Order` data class 60 | 61 | 2. **Create Transitions** 62 | - Define transition classes extending `Transition` 63 | - Annotate with `@TransitionDefinition` 64 | - Use dependency injection for transition dependencies 65 | - Example: `ProcessOrder` transition 66 | 67 | 3. **Create Transitioner (Optional)** 68 | - Define transitioner class extending `Transitioner, V, S>` 69 | - Annotate with `@TransitionerDefinition` 70 | - Example: `OrderTransitioner` 71 | 72 | 4. **Create Guice Module** 73 | - Extend `KfsmModule` with your types 74 | - Optionally specify base package for scanning 75 | - Example: `OrderModule` 76 | 77 | 5. **Use State Machine** 78 | - Inject `StateMachine` into your services 79 | - Use type-safe methods to access transitions 80 | - Execute transitions with proper error handling 81 | 82 | ## Implementation Details 83 | 84 | ### Package Scanning 85 | 86 | The module uses the Reflections library to scan for annotated classes: 87 | 88 | ```kotlin 89 | val reflections = Reflections( 90 | ConfigurationBuilder() 91 | .forPackages(packageToScan) 92 | .setScanners(Scanners.TypesAnnotated) 93 | ) 94 | ``` 95 | 96 | ### Type Safety 97 | 98 | Type safety is ensured through: 99 | - Generic type parameters in `KfsmModule` 100 | - `KfsmMachineTypes` for type-safe binding 101 | - Type-safe transition access in `StateMachine` 102 | 103 | ### Dependency Injection 104 | 105 | Components are bound using Guice's multibinder: 106 | 107 | ```kotlin 108 | val transitionBinder = Multibinder.newSetBinder(binder(), types.transition) 109 | ``` 110 | 111 | ## Testing 112 | 113 | The module is designed to be easily testable: 114 | - Components can be tested in isolation 115 | - Transitions can be tested independently 116 | - Integration tests can verify the full state machine flow 117 | - Kotest provides excellent support for testing state machines 118 | 119 | ## Future Enhancements 120 | 121 | ### 1. Transition Groups 122 | ```kotlin 123 | @Target(AnnotationTarget.CLASS) 124 | @Retention(AnnotationRetention.RUNTIME) 125 | annotation class TransitionGroup(val name: String) 126 | 127 | @TransitionDefinition 128 | @TransitionGroup("withdrawal") 129 | class MyTransition : Transition 130 | ``` 131 | 132 | ### 2. Transition Priority 133 | ```kotlin 134 | @Target(AnnotationTarget.CLASS) 135 | @Retention(AnnotationRetention.RUNTIME) 136 | annotation class TransitionPriority(val priority: Int) 137 | 138 | @TransitionDefinition 139 | @TransitionPriority(1) 140 | class HighPriorityTransition : Transition 141 | ``` 142 | 143 | ### 3. Conditional Transitions 144 | ```kotlin 145 | @Target(AnnotationTarget.CLASS) 146 | @Retention(AnnotationRetention.RUNTIME) 147 | annotation class ConditionalTransition(val condition: String) 148 | 149 | @TransitionDefinition 150 | @ConditionalTransition("feature.enabled") 151 | class FeatureGatedTransition : Transition 152 | ``` 153 | 154 | ### 4. Transition Metadata 155 | ```kotlin 156 | @Target(AnnotationTarget.CLASS) 157 | @Retention(AnnotationRetention.RUNTIME) 158 | annotation class TransitionMetadata( 159 | val description: String = "", 160 | val tags: Array = [], 161 | val version: String = "1.0" 162 | ) 163 | ``` 164 | 165 | ### 5. State Machine Visualization 166 | Add support for generating state machine diagrams in formats like PlantUML or Mermaid. 167 | 168 | ### 6. Testing Support 169 | ```kotlin 170 | class KfsmTestModule> : AbstractModule() { 171 | private val mockTransitions = mutableSetOf>() 172 | 173 | fun addMockTransition(transition: Transition) { 174 | mockTransitions.add(transition) 175 | } 176 | 177 | override fun configure() { 178 | val binder = Multibinder.newSetBinder( 179 | binder(), 180 | typeOf>() 181 | ) 182 | mockTransitions.forEach { transition -> 183 | binder.addBinding().toInstance(transition) 184 | } 185 | } 186 | } 187 | ``` 188 | 189 | ## Implementation Strategy 190 | 191 | 1. Start with core components: 192 | - TransitionDefinition annotation 193 | - KfsmModule base class 194 | - StateMachine wrapper 195 | 196 | 2. Add basic testing support 197 | 198 | 3. Add enhancement features in order of value: 199 | - Transition Groups (helps organization) 200 | - Transition Priority (helps control flow) 201 | - Visualization (helps documentation) 202 | - Metadata (helps maintenance) 203 | - Conditional Transitions (helps feature control) 204 | 205 | ## Key Principles 206 | 207 | 1. Maintain KFSM's type safety 208 | 2. Keep core functionality simple 209 | 3. Make enhancements opt-in 210 | 4. Preserve existing KFSM API compatibility 211 | 5. Focus on compile-time safety where possible 212 | 213 | ## Dependencies Required 214 | 215 | - Guice 216 | - Reflections library 217 | - Kotlin reflection 218 | 219 | ## Notes for Implementation 220 | 221 | - Consider making the StateMachine wrapper interface-based for better testing 222 | - Add proper error handling for reflection failures 223 | - Consider performance implications of reflection at startup 224 | - Add comprehensive documentation and examples 225 | - Include migration guide for existing KFSM users 226 | - Add proper nullability annotations 227 | - Consider multi-module support for transition discovery 228 | - Add proper logging for transition discovery and binding 229 | 230 | This design allows for gradual implementation while maintaining KFSM's core values of type safety and simplicity. 231 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/app/cash/kfsm/MachineBuilder.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | /** 4 | * Default do-nothing transitioner that can be used when no special transition behavior is needed. 5 | */ 6 | class DefaultTransitioner, V : Value, S : State> : 7 | Transitioner() 8 | 9 | /** 10 | * Builder class for creating state machines with type-safe transitions. 11 | * 12 | * @param ID The type of the identifier used in the state machine 13 | * @param V The type of value being transformed, must implement [Value] 14 | * @param S The type of state in the state machine, must implement [State] 15 | */ 16 | class MachineBuilder, S : State> { 17 | private val transitions = mutableMapOf>>() 18 | private val selectors = mutableMapOf>() 19 | 20 | /** 21 | * Builder class for defining transitions from a specific state. 22 | * 23 | * @param ID The type of the identifier used in the state machine 24 | * @param V The type of value being transformed 25 | * @param S The type of state in the state machine 26 | * @property from The state from which transitions are being defined 27 | */ 28 | class TransitionBuilder internal constructor( 29 | val from: S 30 | ) 31 | where V : Value, 32 | S : State { 33 | private val transitions = mutableMapOf>() 34 | 35 | class ToBuilder internal constructor( 36 | val to: S 37 | ) 38 | 39 | /** 40 | * Defines a transition to a target state with a given effect. 41 | * 42 | * @param effect The effect to apply during the transition 43 | * @throws IllegalStateException if a transition to this state is already defined 44 | * @throws IllegalStateException if the from state cannot transition to this state 45 | */ 46 | infix fun S.via(effect: Effect) { 47 | if (this@via in transitions) { 48 | throw IllegalStateException("State $this already has a transition defined from $from") 49 | } 50 | if (!from.canDirectlyTransitionTo(this@via)) { 51 | throw IllegalStateException( 52 | "State $from declares that it cannot transition to $this. Either the fsm declaration or the State is incorrect" 53 | ) 54 | } 55 | transitions[this@via] = EffectTransition(from, this@via, effect) 56 | } 57 | 58 | /** 59 | * Defines a transition to a target state with a simple value transformation function. 60 | * 61 | * @param effect A function that transforms the value during the transition 62 | */ 63 | infix fun S.via(effect: ToBuilder.(V) -> V) { 64 | via( 65 | Effect { 66 | runCatching { 67 | effect(ToBuilder(this@via), it) 68 | } 69 | } 70 | ) 71 | } 72 | 73 | /** 74 | * Defines a transition using a predefined [Transition] instance. 75 | * 76 | * @param transition The transition instance to use 77 | * @throws IllegalArgumentException if the transition's from/to states don't match the current context 78 | */ 79 | infix fun S.via(transition: Transition) { 80 | require(transition.from.set.contains(from) && transition.to == this@via) { 81 | "Expected a transition allowing $from to ${this@via}, but got $transition, " + 82 | "which allows only ${transition.from.set} to ${transition.to}" 83 | } 84 | transitions[this@via] = transition 85 | } 86 | 87 | internal fun build(): Map> = transitions 88 | } 89 | 90 | /** 91 | * Defines transitions from a given state using a builder block. 92 | * 93 | * @param selector Given a value, select the next appropriate states 94 | * @param block A builder block that defines the possible transitions from this state 95 | * @throws IllegalStateException if transitions for this state have already been defined 96 | */ 97 | fun S.becomes( 98 | selector: NextStateSelector, 99 | block: TransitionBuilder.() -> Unit 100 | ) { 101 | if (this@becomes in transitions) { 102 | throw IllegalStateException("State $this has multiple `becomes` blocks defined") 103 | } 104 | val transitionMap = TransitionBuilder(this@becomes).apply(block).build() 105 | if (transitionMap.isEmpty()) { 106 | throw IllegalStateException("State $this defines a `becomes` block with no transitions") 107 | } 108 | 109 | transitions[this@becomes] = TransitionBuilder(this@becomes).apply(block).build() 110 | selectors[this@becomes] = selector 111 | } 112 | 113 | /** 114 | * Defines transitions from a given state using a builder block. 115 | * 116 | * @param block A builder block that defines the possible transitions from this state 117 | * @throws IllegalStateException if transitions for this state have already been defined 118 | */ 119 | infix fun S.becomes(block: TransitionBuilder.() -> Unit) { 120 | if (this@becomes in transitions) { 121 | throw IllegalStateException("State $this has multiple `becomes` blocks defined") 122 | } 123 | val transitionMap = TransitionBuilder(this@becomes).apply(block).build() 124 | val selector: NextStateSelector = 125 | when (transitionMap.size) { 126 | // It's an error to define a becomes block with no subsequent states 127 | 0 -> throw IllegalStateException("State $this defines a `becomes` block with no transitions") 128 | // If there's only one subsequent states, then always select that states if using a selector 129 | 1 -> NextStateSelector { Result.success(transitionMap.values.first().to) } 130 | // If there are multiple subsequent states, then fail if attempting automatic next state selection, 131 | // while still allowing direct transitions via `StateMachine::transitionTo`. i.e it's not an error 132 | // to define a block with multiple subsequent states and no selector. 133 | else -> 134 | NextStateSelector { 135 | Result.failure( 136 | IllegalStateException( 137 | "State $this has multiple subsequent states, but no NextStateSelector was provided" 138 | ) 139 | ) 140 | } 141 | } 142 | 143 | transitions[this@becomes] = TransitionBuilder(this@becomes).apply(block).build() 144 | selectors[this@becomes] = selector 145 | } 146 | 147 | fun build(transitioner: Transitioner, V, S>): StateMachine = 148 | StateMachine(transitions, selectors, transitioner) 149 | } 150 | 151 | /** 152 | * Creates a new state machine using a builder DSL. 153 | * 154 | * @param ID The type of the identifier used in the state machine 155 | * @param V The type of value being transformed 156 | * @param S The type of state in the state machine 157 | * @param transitioner The transitioner to use for state transitions. If not provided, a default do-nothing transitioner will be used. 158 | * @param block A builder block that defines the state machine's transitions 159 | * @return A new [StateMachine] instance 160 | */ 161 | inline fun , S : State> fsm( 162 | transitioner: Transitioner, V, S> = DefaultTransitioner(), 163 | noinline block: MachineBuilder.() -> Unit 164 | ): Result> = runCatching { MachineBuilder().apply(block).build(transitioner) } 165 | 166 | /** 167 | * A transition between two states with an associated effect. This internal class exists so that it is possible to store 168 | * a simple Effect as a Transition inside the state machine. 169 | */ 170 | internal class EffectTransition, S : State>( 171 | from: S, 172 | to: S, 173 | private val effect: Effect 174 | ) : Transition(from, to) { 175 | override fun effect(value: V): Result = effect.apply(value) 176 | } 177 | -------------------------------------------------------------------------------- /lib/src/test/kotlin/app/cash/kfsm/StateMachineTest.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.result.shouldBeFailure 5 | import io.kotest.matchers.result.shouldBeSuccess 6 | import io.kotest.matchers.should 7 | import io.kotest.matchers.shouldBe 8 | 9 | class StateMachineTest : 10 | StringSpec({ 11 | val transitioner = object : Transitioner, Letter, Char>() {} 12 | 13 | "mermaidStateDiagramMarkdown generates correct diagram" { 14 | // Given a machine with multiple transitions 15 | val machine = 16 | fsm(transitioner) { 17 | A.becomes { 18 | B.via { it.copy(id = "banana") } 19 | } 20 | B.becomes { 21 | C.via { it.copy(id = "cinnamon") } 22 | D.via { it.copy(id = "durian") } 23 | B.via { it.copy(id = "berry") } 24 | } 25 | D.becomes { 26 | E.via { it.copy(id = "eggplant") } 27 | } 28 | }.getOrThrow() 29 | 30 | // When generating a diagram starting from A 31 | val diagram = machine.mermaidStateDiagramMarkdown(A) 32 | 33 | // Then the diagram contains all expected elements 34 | diagram shouldBe 35 | """ 36 | |stateDiagram-v2 37 | | [*] --> A 38 | | A --> B 39 | | B --> B 40 | | B --> C 41 | | B --> D 42 | | D --> E 43 | """.trimMargin() 44 | } 45 | 46 | "getAvailableTransitions returns all possible transitions from a state" { 47 | // Given a machine with multiple transitions from state B 48 | val machine = 49 | fsm(transitioner) { 50 | A.becomes { 51 | B.via { it.copy(id = "banana") } 52 | } 53 | B.becomes { 54 | C.via { it.copy(id = "cinnamon") } 55 | D.via { it.copy(id = "durian") } 56 | B.via { it.copy(id = "berry") } 57 | } 58 | D.becomes { 59 | E.via { it.copy(id = "eggplant") } 60 | } 61 | }.getOrThrow() 62 | 63 | // When getting available transitions from state B 64 | val transitions = machine.getAvailableTransitions(B) 65 | 66 | // Then all transitions from B are returned 67 | transitions.size shouldBe 3 68 | transitions.map { it.to }.toSet() shouldBe setOf(B, C, D) 69 | } 70 | 71 | "getAvailableTransitions returns empty set for state with no transitions" { 72 | // Given a machine with no transitions from state E 73 | val machine = 74 | fsm(transitioner) { 75 | A.becomes { 76 | B.via { it.copy(id = "banana") } 77 | } 78 | B.becomes { 79 | C.via { it.copy(id = "cinnamon") } 80 | } 81 | }.getOrThrow() 82 | 83 | // When getting available transitions from state E 84 | val transitions = machine.getAvailableTransitions(E) 85 | 86 | // Then an empty set is returned 87 | transitions shouldBe emptySet() 88 | } 89 | 90 | "valid transition succeeds" { 91 | // Given a machine that allows A -> B 92 | val machine = 93 | fsm(transitioner) { 94 | A.becomes { 95 | B.via { it.copy(id = "beetroot") } 96 | } 97 | }.getOrThrow() 98 | 99 | // When transitioning from A to B 100 | val result = machine.transitionTo(Letter(A, "avocado"), B).getOrThrow() 101 | 102 | // Then the transition succeeds 103 | result shouldBe Letter(B, "beetroot") 104 | } 105 | 106 | "invalid transition fails" { 107 | // Given a machine that allows A -> B 108 | val machine = 109 | fsm(transitioner) { 110 | A.becomes { 111 | B.via { it.update(B) } 112 | } 113 | }.getOrThrow() 114 | 115 | // When attempting an invalid transition A -> C 116 | val result = machine.transitionTo(Letter(A, "test"), C) 117 | 118 | // Then the transition fails 119 | result.shouldBeFailure() should { 120 | it.value shouldBe Letter(A, "test") 121 | it.targetState shouldBe C 122 | } 123 | } 124 | 125 | "transition from undefined state fails" { 126 | // Given a machine with no transitions from C 127 | val machine = 128 | fsm(transitioner) { 129 | A.becomes { 130 | B.via { it.copy(id = "banana") } 131 | } 132 | }.getOrThrow() 133 | 134 | // When attempting to transition from C 135 | val result = machine.transitionTo(Letter(C, "apple"), D) 136 | 137 | // Then the transition fails 138 | result.shouldBeFailure() should { 139 | it.value shouldBe Letter(C, "apple") 140 | it.targetState shouldBe D 141 | } 142 | } 143 | 144 | "self-transition succeeds" { 145 | // Given a machine that allows B -> B 146 | val machine = 147 | fsm(transitioner) { 148 | B.becomes { 149 | B.via { it } 150 | } 151 | }.getOrThrow() 152 | 153 | // When performing a self-transition 154 | val value = Letter(B, "test") 155 | val result = machine.transitionTo(value, B) 156 | 157 | // Then the transition succeeds with the same value 158 | result.shouldBeSuccess() shouldBe value 159 | } 160 | 161 | "complex transition chain works" { 162 | // Given a machine with multiple transitions 163 | val machine = 164 | fsm(transitioner) { 165 | A.becomes { 166 | B.via { it.copy(id = "banana") } 167 | } 168 | B.becomes { 169 | C.via { it.copy(id = "cinnamon") } 170 | D.via { it.copy(id = "durian") } 171 | } 172 | D.becomes { 173 | E.via { it.copy(id = "eggplant") } 174 | } 175 | }.getOrThrow() 176 | 177 | // When performing multiple transitions 178 | val startValue = Letter(A, "avocado") 179 | val result1 = machine.transitionTo(startValue, B).getOrThrow() 180 | val result2 = machine.transitionTo(result1, D).getOrThrow() 181 | val result3 = machine.transitionTo(result2, E).getOrThrow() 182 | 183 | // Then each transition succeeds 184 | result1 shouldBe Letter(B, "banana") 185 | result2 shouldBe Letter(D, "durian") 186 | result3 shouldBe Letter(E, "eggplant") 187 | } 188 | 189 | "failed effect is propagated" { 190 | // Given a machine with a failing effect 191 | val expectedError = RuntimeException("Test error") 192 | val machine = 193 | fsm(transitioner) { 194 | A.becomes { 195 | B.via { throw expectedError } 196 | } 197 | }.getOrThrow() 198 | 199 | // When transitioning 200 | val result = machine.transitionTo(Letter(A, "test"), B) 201 | 202 | // Then the effect's error is propagated 203 | result.shouldBeFailure(expectedError) 204 | } 205 | 206 | "hooks are called in order" { 207 | var callOrder = mutableListOf() 208 | val hookTransitioner = 209 | object : Transitioner, Letter, Char>() { 210 | override fun preHook( 211 | value: Letter, 212 | via: Transition 213 | ): Result { 214 | callOrder.add("pre") 215 | return super.preHook(value, via) 216 | } 217 | 218 | override fun persist( 219 | from: Char, 220 | value: Letter, 221 | via: Transition 222 | ): Result { 223 | callOrder.add("persist") 224 | return super.persist(from, value, via) 225 | } 226 | 227 | override fun postHook( 228 | from: Char, 229 | value: Letter, 230 | via: Transition 231 | ): Result { 232 | callOrder.add("post") 233 | return super.postHook(from, value, via) 234 | } 235 | } 236 | 237 | // Given a machine with a transitioner that tracks hook calls 238 | val machine = 239 | fsm(hookTransitioner) { 240 | A.becomes { 241 | B.via { it.update(B) } 242 | } 243 | }.getOrThrow() 244 | 245 | // When transitioning 246 | machine.transitionTo(Letter(A, "test"), B) 247 | 248 | // Then hooks are called in the expected order 249 | callOrder shouldBe listOf("pre", "persist", "post") 250 | } 251 | 252 | "failed hook propagates error" { 253 | val hookError = RuntimeException("Hook error") 254 | val failingTransitioner = 255 | object : Transitioner, Letter, Char>() { 256 | override fun preHook( 257 | value: Letter, 258 | via: Transition 259 | ): Result = Result.failure(hookError) 260 | } 261 | 262 | // Given a machine with a failing hook 263 | val machine = 264 | fsm(failingTransitioner) { 265 | A.becomes { 266 | B.via { it.update(B) } 267 | } 268 | }.getOrThrow() 269 | 270 | // When transitioning 271 | val result = machine.transitionTo(Letter(A, "test"), B) 272 | 273 | // Then the hook error is propagated 274 | result.shouldBeFailure() shouldBe hookError 275 | } 276 | }) 277 | -------------------------------------------------------------------------------- /lib/src/test/kotlin/app/cash/kfsm/TransitionerAsyncTest.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.result.shouldBeFailure 5 | import io.kotest.matchers.result.shouldBeSuccess 6 | import io.kotest.matchers.shouldBe 7 | import io.kotest.matchers.throwable.shouldHaveMessage 8 | 9 | class TransitionerAsyncTest : StringSpec({ 10 | 11 | fun transitioner( 12 | pre: (Letter, LetterTransition) -> Result = { _, _ -> Result.success(Unit) }, 13 | post: (Char, Letter, LetterTransition) -> Result = { _, _, _ -> Result.success(Unit) }, 14 | persist: (Char, Letter, LetterTransition) -> Result = { _, value, _ -> Result.success(value) }, 15 | ) = object : TransitionerAsync() { 16 | var preHookExecuted = 0 17 | var postHookExecuted = 0 18 | 19 | override suspend fun preHook(value: Letter, via: LetterTransition): Result = 20 | pre(value, via).also { preHookExecuted += 1 } 21 | 22 | override suspend fun persist(from: Char, value: Letter, via: LetterTransition): Result = 23 | persist(from, value, via) 24 | 25 | override suspend fun postHook(from: Char, value: Letter, via: LetterTransition): Result = 26 | post(from, value, via).also { postHookExecuted += 1 } 27 | } 28 | 29 | fun transition(from: Char = A, to: Char = B) = object : LetterTransition(from, to) { 30 | var effected = 0 31 | override suspend fun effectAsync(value: Letter): Result { 32 | effected += 1 33 | return Result.success(value.update(to)) 34 | } 35 | } 36 | 37 | "effects valid transition" { 38 | val transition = transition(from = A, to = B) 39 | val transitioner = transitioner() 40 | 41 | transitioner.transition(Letter(A, "my_letter"), transition) shouldBeSuccess Letter(B, "my_letter") 42 | 43 | transitioner.preHookExecuted shouldBe 1 44 | transition.effected shouldBe 1 45 | transitioner.postHookExecuted shouldBe 1 46 | } 47 | 48 | "ignores completed transition" { 49 | val transition = transition(from = A, to = B) 50 | val transitioner = transitioner() 51 | 52 | transitioner.transition(Letter(B, "letter_02"), transition) shouldBeSuccess Letter(B, "letter_02") 53 | 54 | transitioner.preHookExecuted shouldBe 0 55 | transition.effected shouldBe 0 56 | transitioner.postHookExecuted shouldBe 0 57 | } 58 | 59 | "returns error on invalid transition" { 60 | val transition = transition(from = A, to = B) 61 | val transitioner = transitioner() 62 | 63 | transitioner.transition(Letter(C, "letter_03"), transition).shouldBeFailure() 64 | .shouldHaveMessage("Value cannot transition {A} to B, because it is currently C. [id=letter_03]") 65 | 66 | transitioner.preHookExecuted shouldBe 0 67 | transition.effected shouldBe 0 68 | transitioner.postHookExecuted shouldBe 0 69 | } 70 | 71 | "returns error when preHook errors" { 72 | val error = RuntimeException("preHook error") 73 | 74 | val transition = transition(from = A, to = B) 75 | val transitioner = transitioner(pre = { _, _ -> Result.failure(error) }) 76 | 77 | transitioner.transition(Letter(A, "letter_04"), transition) shouldBeFailure error 78 | 79 | transitioner.preHookExecuted shouldBe 1 80 | transition.effected shouldBe 0 81 | transitioner.postHookExecuted shouldBe 0 82 | } 83 | 84 | "returns error when effect errors" { 85 | val error = RuntimeException("effect error") 86 | 87 | val transition = object : LetterTransition(A, B) { 88 | override suspend fun effectAsync(value: Letter): Result = Result.failure(error) 89 | } 90 | val transitioner = transitioner() 91 | 92 | transitioner.transition(Letter(A, "letter_05"), transition) shouldBeFailure error 93 | 94 | transitioner.preHookExecuted shouldBe 1 95 | transitioner.postHookExecuted shouldBe 0 96 | } 97 | 98 | "returns error when postHook errors" { 99 | val error = RuntimeException("postHook error") 100 | 101 | val transition = transition(from = A, to = B) 102 | val transitioner = transitioner(post = { _, _, _ -> Result.failure(error) }) 103 | 104 | transitioner.transition(Letter(A, "letter_06"), transition) shouldBeFailure error 105 | 106 | transition.effected shouldBe 1 107 | transitioner.preHookExecuted shouldBe 1 108 | transitioner.postHookExecuted shouldBe 1 109 | } 110 | 111 | "returns error when preHook throws" { 112 | val error = RuntimeException("preHook error") 113 | 114 | val transition = transition(from = A, to = B) 115 | val transitioner = transitioner(pre = { _, _ -> throw error }) 116 | 117 | transitioner.transition(Letter(A, "letter_07"), transition) shouldBeFailure error 118 | 119 | transition.effected shouldBe 0 120 | transitioner.postHookExecuted shouldBe 0 121 | } 122 | 123 | "returns error when effect throws" { 124 | val error = RuntimeException("effect error") 125 | 126 | val transition = object : LetterTransition(A, B) { 127 | override fun effect(value: Letter): Result = throw error 128 | } 129 | val transitioner = transitioner() 130 | 131 | transitioner.transition(Letter(A, "letter_08"), transition) shouldBeFailure error 132 | 133 | transitioner.preHookExecuted shouldBe 1 134 | transitioner.postHookExecuted shouldBe 0 135 | } 136 | 137 | "returns error when postHook throws" { 138 | val error = RuntimeException("postHook error") 139 | 140 | val transition = transition(from = A, to = B) 141 | val transitioner = transitioner(post = { _, _, _ -> throw error }) 142 | 143 | transitioner.transition(Letter(A, "letter_09"), transition) shouldBeFailure error 144 | 145 | transition.effected shouldBe 1 146 | transitioner.preHookExecuted shouldBe 1 147 | } 148 | 149 | "returns error when persist fails" { 150 | val error = RuntimeException("persist error") 151 | 152 | val transition = transition(from = A, to = B) 153 | val transitioner = transitioner(persist = { _, _, _ -> Result.failure(error) }) 154 | 155 | transitioner.transition(Letter(A, "letter_0a"), transition) shouldBeFailure error 156 | 157 | transitioner.preHookExecuted shouldBe 1 158 | transition.effected shouldBe 1 159 | transitioner.postHookExecuted shouldBe 0 160 | } 161 | 162 | "returns error when persist throws" { 163 | val error = RuntimeException("persist error") 164 | 165 | val transition = transition(from = A, to = B) 166 | val transitioner = transitioner(persist = { _, _, _ -> throw error }) 167 | 168 | transitioner.transition(Letter(A, "letter_0b"), transition) shouldBeFailure error 169 | 170 | transitioner.preHookExecuted shouldBe 1 171 | transition.effected shouldBe 1 172 | transitioner.postHookExecuted shouldBe 0 173 | } 174 | 175 | "can transition multiple times" { 176 | val aToB = transition(A, B) 177 | val bToC = transition(B, C) 178 | val cToD = transition(C, D) 179 | val dToE = transition(D, E) 180 | val transitioner = transitioner() 181 | 182 | transitioner.transition(Letter(A, "letter_0c"), aToB) 183 | .mapCatching { transitioner.transition(it, bToC).getOrThrow() } 184 | .mapCatching { transitioner.transition(it, cToD).getOrThrow() } 185 | .mapCatching { transitioner.transition(it, dToE).getOrThrow() } shouldBeSuccess Letter(E, "letter_0c") 186 | 187 | transitioner.preHookExecuted shouldBe 4 188 | aToB.effected shouldBe 1 189 | bToC.effected shouldBe 1 190 | cToD.effected shouldBe 1 191 | dToE.effected shouldBe 1 192 | transitioner.postHookExecuted shouldBe 4 193 | } 194 | 195 | "can transition in a 3+ party loop" { 196 | val aToB = transition(A, B) 197 | val bToC = transition(B, C) 198 | val cToD = transition(C, D) 199 | val dToB = transition(D, B) 200 | val dToE = transition(D, E) 201 | val transitioner = transitioner() 202 | 203 | transitioner.transition(Letter(A, "letter_0d"), aToB) 204 | .mapCatching { transitioner.transition(it, bToC).getOrThrow() } 205 | .mapCatching { transitioner.transition(it, cToD).getOrThrow() } 206 | .mapCatching { transitioner.transition(it, dToB).getOrThrow() } 207 | .mapCatching { transitioner.transition(it, bToC).getOrThrow() } 208 | .mapCatching { transitioner.transition(it, cToD).getOrThrow() } 209 | .mapCatching { transitioner.transition(it, dToE).getOrThrow() } shouldBeSuccess Letter(E, "letter_0d") 210 | 211 | transitioner.preHookExecuted shouldBe 7 212 | aToB.effected shouldBe 1 213 | bToC.effected shouldBe 2 214 | cToD.effected shouldBe 2 215 | dToB.effected shouldBe 1 216 | dToE.effected shouldBe 1 217 | transitioner.postHookExecuted shouldBe 7 218 | } 219 | 220 | "can transition in a 2-party loop" { 221 | val aToB = transition(A, B) 222 | val bToD = transition(B, D) 223 | val dToB = transition(D, B) 224 | val dToE = transition(D, E) 225 | val transitioner = transitioner() 226 | 227 | transitioner.transition(Letter(A, "letter_0e"), aToB) 228 | .mapCatching { transitioner.transition(it, bToD).getOrThrow() } 229 | .mapCatching { transitioner.transition(it, dToB).getOrThrow() } 230 | .mapCatching { transitioner.transition(it, bToD).getOrThrow() } 231 | .mapCatching { transitioner.transition(it, dToE).getOrThrow() } shouldBeSuccess Letter(E, "letter_0e") 232 | 233 | transitioner.preHookExecuted shouldBe 5 234 | aToB.effected shouldBe 1 235 | bToD.effected shouldBe 2 236 | dToB.effected shouldBe 1 237 | dToE.effected shouldBe 1 238 | transitioner.postHookExecuted shouldBe 5 239 | } 240 | 241 | "can transition to self" { 242 | val aToB = transition(A, B) 243 | val bToB = transition(B, B) 244 | val bToC = transition(B, C) 245 | val transitioner = transitioner() 246 | 247 | transitioner.transition(Letter(A, "letter_0f"), aToB) 248 | .mapCatching { transitioner.transition(it, bToB).getOrThrow() } 249 | .mapCatching { transitioner.transition(it, bToB).getOrThrow() } 250 | .mapCatching { transitioner.transition(it, bToB).getOrThrow() } 251 | .mapCatching { transitioner.transition(it, bToC).getOrThrow() } shouldBeSuccess Letter(C, "letter_0f") 252 | 253 | transitioner.preHookExecuted shouldBe 5 254 | aToB.effected shouldBe 1 255 | bToB.effected shouldBe 3 256 | bToC.effected shouldBe 1 257 | transitioner.postHookExecuted shouldBe 5 258 | } 259 | 260 | "pre hook contains the correct from value and transition" { 261 | val transition = transition(from = A, to = B) 262 | val transitioner = transitioner( 263 | pre = { value, t -> 264 | value shouldBe Letter(A, "letter_10") 265 | t shouldBe transition 266 | t.specificToThisTransitionType shouldBe "[A] -> B" 267 | Result.success(Unit) 268 | } 269 | ) 270 | 271 | transitioner.transition(Letter(A, "letter_10"), transition).shouldBeSuccess() 272 | } 273 | 274 | "post hook contains the correct from state, post value and transition" { 275 | val transition = transition(from = B, to = C) 276 | val transitioner = transitioner( 277 | post = { from, value, t -> 278 | from shouldBe B 279 | value shouldBe Letter(C, "letter_11") 280 | t shouldBe transition 281 | t.specificToThisTransitionType shouldBe "[B] -> C" 282 | Result.success(Unit) 283 | } 284 | ) 285 | 286 | transitioner.transition(Letter(B, "letter_11"), transition).shouldBeSuccess() 287 | } 288 | }) 289 | 290 | -------------------------------------------------------------------------------- /lib/src/test/kotlin/app/cash/kfsm/TransitionerTest.kt: -------------------------------------------------------------------------------- 1 | package app.cash.kfsm 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.result.shouldBeFailure 5 | import io.kotest.matchers.result.shouldBeSuccess 6 | import io.kotest.matchers.shouldBe 7 | import io.kotest.matchers.throwable.shouldHaveMessage 8 | 9 | class TransitionerTest : StringSpec({ 10 | 11 | fun transitioner( 12 | pre: (Letter, LetterTransition) -> Result = { _, _ -> Result.success(Unit) }, 13 | post: (Char, Letter, LetterTransition) -> Result = { _, _, _ -> Result.success(Unit) }, 14 | persist: (Char, Letter, LetterTransition) -> Result = { _, value, _ -> Result.success(value) }, 15 | ) = object : Transitioner() { 16 | var preHookExecuted = 0 17 | var postHookExecuted = 0 18 | 19 | override fun preHook(value: Letter, via: LetterTransition): Result = 20 | pre(value, via).also { preHookExecuted += 1 } 21 | 22 | override fun persist(from: Char, value: Letter, via: LetterTransition): Result = 23 | persist(from, value, via) 24 | 25 | override fun postHook(from: Char, value: Letter, via: LetterTransition): Result = 26 | post(from, value, via).also { postHookExecuted += 1 } 27 | } 28 | 29 | fun transition(from: Char = A, to: Char = B) = object : LetterTransition(from, to) { 30 | var effected = 0 31 | override fun effect(value: Letter): Result { 32 | effected += 1 33 | return Result.success(value.update(to)) 34 | } 35 | } 36 | 37 | "effects valid transition" { 38 | val transition = transition(from = A, to = B) 39 | val transitioner = transitioner() 40 | 41 | transitioner.transition(Letter(A, "my_letter_01"), transition) shouldBeSuccess Letter(B, "my_letter_01") 42 | 43 | transitioner.preHookExecuted shouldBe 1 44 | transition.effected shouldBe 1 45 | transitioner.postHookExecuted shouldBe 1 46 | } 47 | 48 | "ignores completed transition" { 49 | val transition = transition(from = A, to = B) 50 | val transitioner = transitioner() 51 | 52 | transitioner.transition(Letter(B, "my_letter_02"), transition) shouldBeSuccess Letter(B, "my_letter_02") 53 | 54 | transitioner.preHookExecuted shouldBe 0 55 | transition.effected shouldBe 0 56 | transitioner.postHookExecuted shouldBe 0 57 | } 58 | 59 | "returns error on invalid transition" { 60 | val transition = transition(from = A, to = B) 61 | val transitioner = transitioner() 62 | 63 | transitioner.transition(Letter(C, "my_letter_03"), transition).shouldBeFailure() 64 | .shouldHaveMessage("Value cannot transition {A} to B, because it is currently C. [id=my_letter_03]") 65 | 66 | transitioner.preHookExecuted shouldBe 0 67 | transition.effected shouldBe 0 68 | transitioner.postHookExecuted shouldBe 0 69 | } 70 | 71 | "returns error when preHook errors" { 72 | val error = RuntimeException("preHook error") 73 | 74 | val transition = transition(from = A, to = B) 75 | val transitioner = transitioner(pre = { _, _ -> Result.failure(error) }) 76 | 77 | transitioner.transition(Letter(A, "my_letter_04"), transition) shouldBeFailure error 78 | 79 | transitioner.preHookExecuted shouldBe 1 80 | transition.effected shouldBe 0 81 | transitioner.postHookExecuted shouldBe 0 82 | } 83 | 84 | "returns error when effect errors" { 85 | val error = RuntimeException("effect error") 86 | 87 | val transition = object : LetterTransition(A, B) { 88 | override fun effect(value: Letter): Result = Result.failure(error) 89 | } 90 | val transitioner = transitioner() 91 | 92 | transitioner.transition(Letter(A, "my_letter_05"), transition) shouldBeFailure error 93 | 94 | transitioner.preHookExecuted shouldBe 1 95 | transitioner.postHookExecuted shouldBe 0 96 | } 97 | 98 | "returns error when postHook errors" { 99 | val error = RuntimeException("postHook error") 100 | 101 | val transition = transition(from = A, to = B) 102 | val transitioner = transitioner(post = { _, _, _ -> Result.failure(error) }) 103 | 104 | transitioner.transition(Letter(A, "my_letter_06"), transition) shouldBeFailure error 105 | 106 | transition.effected shouldBe 1 107 | transitioner.preHookExecuted shouldBe 1 108 | transitioner.postHookExecuted shouldBe 1 109 | } 110 | 111 | "returns error when preHook throws" { 112 | val error = RuntimeException("preHook error") 113 | 114 | val transition = transition(from = A, to = B) 115 | val transitioner = transitioner(pre = { _, _ -> throw error }) 116 | 117 | transitioner.transition(Letter(A, "my_letter_07"), transition) shouldBeFailure error 118 | 119 | transition.effected shouldBe 0 120 | transitioner.postHookExecuted shouldBe 0 121 | } 122 | 123 | "returns error when effect throws" { 124 | val error = RuntimeException("effect error") 125 | 126 | val transition = object : LetterTransition(A, B) { 127 | override fun effect(value: Letter): Result = throw error 128 | } 129 | val transitioner = transitioner() 130 | 131 | transitioner.transition(Letter(A, "my_letter_08"), transition) shouldBeFailure error 132 | 133 | transitioner.preHookExecuted shouldBe 1 134 | transitioner.postHookExecuted shouldBe 0 135 | } 136 | 137 | "returns error when postHook throws" { 138 | val error = RuntimeException("postHook error") 139 | 140 | val transition = transition(from = A, to = B) 141 | val transitioner = transitioner(post = { _, _, _ -> throw error }) 142 | 143 | transitioner.transition(Letter(A, "my_letter_09"), transition) shouldBeFailure error 144 | 145 | transition.effected shouldBe 1 146 | transitioner.preHookExecuted shouldBe 1 147 | } 148 | 149 | "returns error when persist fails" { 150 | val error = RuntimeException("persist error") 151 | 152 | val transition = transition(from = A, to = B) 153 | val transitioner = transitioner(persist = { _, _, _ -> Result.failure(error) }) 154 | 155 | transitioner.transition(Letter(A, "my_letter_0a"), transition) shouldBeFailure error 156 | 157 | transitioner.preHookExecuted shouldBe 1 158 | transition.effected shouldBe 1 159 | transitioner.postHookExecuted shouldBe 0 160 | } 161 | 162 | "returns error when persist throws" { 163 | val error = RuntimeException("persist error") 164 | 165 | val transition = transition(from = A, to = B) 166 | val transitioner = transitioner(persist = { _, _, _ -> throw error }) 167 | 168 | transitioner.transition(Letter(A, "my_letter_0b"), transition) shouldBeFailure error 169 | 170 | transitioner.preHookExecuted shouldBe 1 171 | transition.effected shouldBe 1 172 | transitioner.postHookExecuted shouldBe 0 173 | } 174 | 175 | "can transition multiple times" { 176 | val aToB = transition(A, B) 177 | val bToC = transition(B, C) 178 | val cToD = transition(C, D) 179 | val dToE = transition(D, E) 180 | val transitioner = transitioner() 181 | 182 | transitioner.transition(Letter(A, "my_letter_0c"), aToB) 183 | .mapCatching { transitioner.transition(it, bToC).getOrThrow() } 184 | .mapCatching { transitioner.transition(it, cToD).getOrThrow() } 185 | .mapCatching { transitioner.transition(it, dToE).getOrThrow() } shouldBeSuccess Letter(E, "my_letter_0c") 186 | 187 | transitioner.preHookExecuted shouldBe 4 188 | aToB.effected shouldBe 1 189 | bToC.effected shouldBe 1 190 | cToD.effected shouldBe 1 191 | dToE.effected shouldBe 1 192 | transitioner.postHookExecuted shouldBe 4 193 | } 194 | 195 | "can transition in a 3+ party loop" { 196 | val aToB = transition(A, B) 197 | val bToC = transition(B, C) 198 | val cToD = transition(C, D) 199 | val dToB = transition(D, B) 200 | val dToE = transition(D, E) 201 | val transitioner = transitioner() 202 | 203 | transitioner.transition(Letter(A, "my_letter_0d"), aToB) 204 | .mapCatching { transitioner.transition(it, bToC).getOrThrow() } 205 | .mapCatching { transitioner.transition(it, cToD).getOrThrow() } 206 | .mapCatching { transitioner.transition(it, dToB).getOrThrow() } 207 | .mapCatching { transitioner.transition(it, bToC).getOrThrow() } 208 | .mapCatching { transitioner.transition(it, cToD).getOrThrow() } 209 | .mapCatching { transitioner.transition(it, dToE).getOrThrow() } shouldBeSuccess Letter(E, "my_letter_0d") 210 | 211 | transitioner.preHookExecuted shouldBe 7 212 | aToB.effected shouldBe 1 213 | bToC.effected shouldBe 2 214 | cToD.effected shouldBe 2 215 | dToB.effected shouldBe 1 216 | dToE.effected shouldBe 1 217 | transitioner.postHookExecuted shouldBe 7 218 | } 219 | 220 | "can transition in a 2-party loop" { 221 | val aToB = transition(A, B) 222 | val bToD = transition(B, D) 223 | val dToB = transition(D, B) 224 | val dToE = transition(D, E) 225 | val transitioner = transitioner() 226 | 227 | transitioner.transition(Letter(A, "my_letter_0e"), aToB) 228 | .mapCatching { transitioner.transition(it, bToD).getOrThrow() } 229 | .mapCatching { transitioner.transition(it, dToB).getOrThrow() } 230 | .mapCatching { transitioner.transition(it, bToD).getOrThrow() } 231 | .mapCatching { transitioner.transition(it, dToE).getOrThrow() } shouldBeSuccess Letter(E, "my_letter_0e") 232 | 233 | transitioner.preHookExecuted shouldBe 5 234 | aToB.effected shouldBe 1 235 | bToD.effected shouldBe 2 236 | dToB.effected shouldBe 1 237 | dToE.effected shouldBe 1 238 | transitioner.postHookExecuted shouldBe 5 239 | } 240 | 241 | "can transition to self" { 242 | val aToB = transition(A, B) 243 | val bToB = transition(B, B) 244 | val bToC = transition(B, C) 245 | val transitioner = transitioner() 246 | 247 | transitioner.transition(Letter(A, "my_letter_0f"), aToB) 248 | .mapCatching { transitioner.transition(it, bToB).getOrThrow() } 249 | .mapCatching { transitioner.transition(it, bToB).getOrThrow() } 250 | .mapCatching { transitioner.transition(it, bToB).getOrThrow() } 251 | .mapCatching { transitioner.transition(it, bToC).getOrThrow() } shouldBeSuccess Letter(C, "my_letter_0f") 252 | 253 | transitioner.preHookExecuted shouldBe 5 254 | aToB.effected shouldBe 1 255 | bToB.effected shouldBe 3 256 | bToC.effected shouldBe 1 257 | transitioner.postHookExecuted shouldBe 5 258 | } 259 | 260 | "pre hook contains the correct from value and transition" { 261 | val transition = transition(from = A, to = B) 262 | val transitioner = transitioner( 263 | pre = { value, t -> 264 | value shouldBe Letter(A, "my_letter_10") 265 | t shouldBe transition 266 | t.specificToThisTransitionType shouldBe "[A] -> B" 267 | Result.success(Unit) 268 | } 269 | ) 270 | 271 | transitioner.transition(Letter(A, "my_letter_10"), transition).shouldBeSuccess() 272 | } 273 | 274 | "post hook contains the correct from state, post value and transition" { 275 | val transition = transition(from = B, to = C) 276 | val transitioner = transitioner( 277 | post = { from, value, t -> 278 | from shouldBe B 279 | value shouldBe Letter(C, "my_letter_11") 280 | t shouldBe transition 281 | t.specificToThisTransitionType shouldBe "[B] -> C" 282 | Result.success(Unit) 283 | } 284 | ) 285 | 286 | transitioner.transition(Letter(B, "my_letter_11"), transition).shouldBeSuccess() 287 | } 288 | }) 289 | 290 | --------------------------------------------------------------------------------