Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

update readme for new api #28

Merged
merged 1 commit into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 107 additions & 120 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,193 +1,180 @@
KFSM is Finite State Machinery for Kotlin.
kFSM is Finite State Machinery for Kotlin.

[<img src="https://img.shields.io/nexus/r/app.cash.kfsm/kfsm.svg?label=latest%20release&server=https%3A%2F%2Foss.sonatype.org"/>](https://central.sonatype.com/namespace/app.cash.kfsm)


## How to use

See [lib/src/test/kotlin/com/squareup/cash/kfsm/exemplar](https://github.com/cashapp/kfsm/tree/main/lib/src/test/kotlin/com/squareup/cash/kfsm/exemplar)
for an example of how to use this library.
There are four key components to building your state machine.

In a KFSM state machine, the states are defined as classes that extend `State` and declare the states that can be
transitioned to. Transitions are defined as instances of `Transition` and encapsulate the effect of transitioning.
1. The nodes representing different states in the machine - `State`
2. The type to be transitioned through the machine - `Value`
3. The effects that are defined by transitioning from one state to the next - `Transition`
4. A transitioner, which can be customised when you need to define pre and post transition hooks - `Transitioner`

Take this state machine, for example:
Let's build a state machine for a traffic light.

```mermaid
---
title: Hamster States of Being
---
stateDiagram-v2
[*] --> Asleep
Asleep --> Awake
Awake --> Eating
Eating --> Asleep
Eating --> Resting
Eating --> RunningOnWheel
Resting --> Asleep
RunningOnWheel --> Asleep
RunningOnWheel --> Resting
[*] --> Green
Amber --> Red
Green --> Amber
Red --> Green
```

### The states
### State

Your state machine is defined as a set of sealed objects extending State. Each State must declare zero or more other
states that it can transition to.
The states are a collection of related classes that define a distinct state that the value can be in. They also define
which states are valid next states.

```kotlin
/**
* Base class for all the states a Hamster can embody.
*/
sealed class HamsterState(vararg to: HamsterState) : State(to.toSet())

/** Hamster is awake... and hungry! */
object Awake : HamsterState(Eating)

/** Hamster is eating ... what will they do next? */
object Eating : HamsterState(RunningOnWheel, Asleep, Resting)

/** Wheeeeeee! */
object RunningOnWheel : HamsterState(Asleep, Resting)

/** Sits in the corner, chilling */
object Resting : HamsterState(Asleep)

/** Zzzzzzzzz */
object Asleep : HamsterState(Awake)
sealed class Color(to: () -> Set<Color>) : app.cash.kfsm.State(to)
data object Green : Color({ setOf(Amber) })
data object Amber : Color({ setOf(Red) })
data object Red : Color({ setOf(Green) })
```

#### Test your state machine
### Value

The utility `StateMachine.verify` will assert that a defined state machine is valid - i.e. that all states are visited
from a given starting state.
The value is responsible for knowing and updating its current state.

```kotlin
StateMachine.verify(Awake) shouldBeRight true
data class Light(override val state: Color) : Value<Light, Color> {
override fun update(newState: Color): Light = this.copy(state = newState)
}
```

#### Document your state machine
### Transition

The utility `StateMachine.mermaid` will generate a mermaid diagram of your state machine. This can be rendered in markdown.
The diagram of HamsterStates above was created using this utility.
Types that provide the required side-effects that define a transition in the machine.

```kotlin
StateMachine.mermaid(Asleep) shouldBeRight """stateDiagram-v2
| [*] --> Asleep
| Asleep --> Awake
| Awake --> Eating
| Eating --> Asleep
| Eating --> Resting
| Eating --> RunningOnWheel
| Resting --> Asleep
| RunningOnWheel --> Asleep
| RunningOnWheel --> Resting
""".trimMargin()
```

abstract class ColorChange(
from: NonEmptySet<Color>,
to: Color
) : Transition<Light, Color>(from, to) {
// Convenience constructor for when the from set has only one value
constructor(from: Color, to: Color) : this(nonEmptySetOf(from), to)
}

### The value
class Go(private val camera: Camera) : ColorChange(from = Red, to = Green) {
override suspend fun effect(value: Light) = camera.disable()
}

Your transitionable value must extend `Transitionable`. This simply means it has a property `state`.
object Slow : ColorChange(from = Green, to = Amber)

```kotlin
data class Hamster(override val state: HamsterState) : Transitionable<HamsterState>
class Stop(private val camera: Camera) : ColorChange(from = Amber, to = Red) {
override suspend fun effect(value: Light) = camera.enable()
}
```

### Transitioner

### The transitions

For a value to move from one state to another it must be transitioned. Declare each of the valid transitions separately,
starting with a base class.
Moving a value from one state to another is done by the transitioner. We provide it with a function that declares how to
persist values.

```kotlin
abstract class HamsterTransition : Transition<Hamster, HamsterState, HamsterFailure> {
class LightTransitioner(
private val database: Database
) : Transitioner<LightTransitioner, Light, Color>(
persist = { it.also(database::update).right() }
)
```

constructor(from: HamsterState, to: HamsterState) : super(from, to)
constructor(from: Set<HamsterState>, to: HamsterState) : super(from, to)
Each time a transition is successful, the persist function will be called.

override fun makeFailure(
value: Hamster,
effectCompleted: Boolean,
updateCompleted: Boolean,
cause: Throwable
): HamsterFailure = InternalHamsterError(cause)
}
```
#### Pre and Post Transition Hooks

Transitions must define the side-effects that take place as part of the transition.
It is sometimes necessary to execute effects before and after a transition. These can be defined on the transitioner.

```kotlin
class EatBreakfast(private val food: String) : HamsterTransition(from = Awake, to = Eating) {
class LightTransitioner ... {
override suspend fun preHook(value: V, via: T): ErrorOr<Unit> = Either.catch {
globalLock.lock(value)
}

override suspend fun effect(value: Hamster): Either<HamsterFailure, Hamster> =
when (food) {
"broccoli" -> value.eat(food).right()
"cheese" -> LactoseIntoleranceTroubles(food).left()
else -> value.eat(food).right()
override suspend fun postHook(from: S, value: V, via: T): ErrorOr<Unit> = Either.catch {
globalLock.unlock(value)
notificationService.send(via.successNotifications())
}
}
```

Transitions must also define how to create a failure object, given some important context such as whether the effect has
completed successfully (there are steps after the effect which might fail).
### Transitioning

With the state machine and transitioner defined, we can progress any value through the machine by using the
transitioner.

```kotlin
override fun makeFailure(
value: Hamster,
effectCompleted: Boolean,
updateCompleted: Boolean,
cause: Throwable
): HamsterFailure = InternalHamsterError(cause)
val transitioner = LightTransitioner(database)
val greenLight: ErrorOr<Light> = transitioner.transition(redLight, Go)
```

Any attempt to define a transition where the `from` state cannot directly reach the `to` state will be an invariant
failure and result in exceptions when wiring your application.
### More examples

See [lib/src/test/kotlin/app/cash/kfsm/exemplar](https://github.com/cashapp/kfsm/tree/main/lib/src/test/kotlin/app/cash/kfsm/exemplar)
for a full example of how to use this library.

## Safety

How does kFSM help validate the correctness of your state machine and your values?

### The transitioner
1. It is impossible to define a Transition that does not comply with the transitions defined in the States. For example,
a transition that attempts to define an arrow between `Red` and `Amber` will fail at construction.
2. If a value has already transitioned to the target state, then a subsequent request will not execute the transition a
second time. The result will be success. I.e. it is a no-op.
1. (unless you have defined a circular/self-transition, in which case it will)
3. If a value is in a state unrelated to the executed transition, then the result will be an error and no effect will be
executed.

The StateTransitioner is the machine that applies a transition to a value. In the constructor, you must define how to
update the value (e.g. write to DB) and how to notify of the successful transition (e.g. emit an event)
### Testing your state machine

The utility `StateMachine.verify` will assert that a defined state machine is valid - i.e. that all states are visited
from a given starting state.

```kotlin
val transitioner = StateTransitioner<Hamster, HamsterState, HamsterFailure>(
update = { h, t -> h.copy(state = t.to) },
notifyOnSuccess = { h, t -> println("updated: $h via $t") }
)
StateMachine.verify(Green) shouldBeRight true
```

Then you can transition.

### Document your state machine

The utility `StateMachine.mermaid` will generate a mermaid diagram of your state machine. This can be rendered in markdown.
The diagram of `Color` above was created using this utility.

```kotlin
transitioner.transition(hamster, EatBreakfast("broccoli"))
StateMachine.mermaid(Green) shouldBeRight """stateDiagram-v2
[*] --> Green
Amber --> Red
Green --> Amber
Red --> Green
""".trimMargin()
```

## Documentation

The API documentation is published with each release
at [https://cashapp.github.io/kfsm](https://cashapp.github.io/kfsm)

See a list of changes in each release in the [CHANGELOG](CHANGELOG.md).

## Contributing

For details on contributing, see the [CONTRIBUTING](CONTRIBUTING.md) guide.

## Building
### Building

> ℹ️ kfsm uses [Hermit](https://cashapp.github.io/hermit/).
> ℹ️ kFSM uses [Hermit](https://cashapp.github.io/hermit/).
>
>> Hermit ensures that your team, your contributors, and your CI have the same consistent tooling. Here are the [installation instructions](https://cashapp.github.io/hermit/usage/get-started/#installing-hermit).
>>
>> Hermit ensures that your team, your contributors, and your CI have the same consistent tooling. Here are
> > the [installation instructions](https://cashapp.github.io/hermit/usage/get-started/#installing-hermit).
>>
>> [Activate Hermit](https://cashapp.github.io/hermit/usage/get-started/#activating-an-environment) either
by [enabling the shell hooks](https://cashapp.github.io/hermit/usage/shell/) (one-time only, recommended) or manually
sourcing the env with `. ./bin/activate-hermit`.
> > by [enabling the shell hooks](https://cashapp.github.io/hermit/usage/shell/) (one-time only, recommended) or
> > manually
> > sourcing the env with `. ./bin/activate-hermit`.

Use gradle to run all tests

```shell
gradle build
```

## Changelog

See a list of changes in each release in the [CHANGELOG](CHANGELOG.md).

## Contributing

For details on contributing, see the [CONTRIBUTING](CONTRIBUTING.md) guide.
2 changes: 1 addition & 1 deletion lib/api/lib.api
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public class app/cash/kfsm/Transition {
public final fun getTo ()Lapp/cash/kfsm/State;
}

public class app/cash/kfsm/Transitioner {
public abstract class app/cash/kfsm/Transitioner {
public fun <init> ()V
public fun <init> (Lkotlin/jvm/functions/Function2;)V
public synthetic fun <init> (Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
Expand Down
2 changes: 1 addition & 1 deletion lib/src/main/kotlin/app/cash/kfsm/Transitioner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import arrow.core.left
import arrow.core.raise.either
import arrow.core.right

open class Transitioner<T : Transition<V, S>, V: Value<V, S>, S : State>(
abstract class Transitioner<T : Transition<V, S>, V: Value<V, S>, S : State>(
private val persist: suspend (V) -> ErrorOr<V> = { it.right() }
) {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package app.cash.kfsm.exemplar

import app.cash.kfsm.Transitioner
import app.cash.kfsm.exemplar.Hamster.State
import app.cash.quiver.extensions.ErrorOr
import arrow.core.Either
import arrow.core.right

class HamsterTransitioner(
val saves: MutableList<Hamster> = mutableListOf()
) : Transitioner<HamsterTransition, Hamster, State>(
// This is where you define how to save your updated value to a data store
persist = { it.also(saves::add).right() }
) {

val locks = mutableListOf<Hamster>()
val unlocks = mutableListOf<Hamster>()
val notifications = mutableListOf<String>()


// Any action you might wish to take prior to transitioning, such as pessimistic locking
override suspend fun preHook(value: Hamster, via: HamsterTransition): ErrorOr<Unit> = Either.catch {
locks.add(value)
}

// Any action you might wish to take after transitioning successfully, such as sending events or notifications
override suspend fun postHook(from: State, value: Hamster, via: HamsterTransition): ErrorOr<Unit> = Either.catch {
notifications.add("${value.name} was $from, then began ${via.description} and is now ${via.to}")
unlocks.add(value)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ abstract class HamsterTransition(
from: NonEmptySet<Hamster.State>,
to: Hamster.State
) : Transition<Hamster, Hamster.State>(from, to) {
// Convenience constructor for when the from set has only one value
constructor(from: Hamster.State, to: Hamster.State) : this(nonEmptySetOf(from), to)

// Demonstrates how you can add base behaviour to transitions for use in pre and post hooks.
Expand Down
Loading
Loading