• Stars
    star
    151
  • Rank 237,107 (Top 5 %)
  • Language
    Elixir
  • License
    ISC License
  • Created almost 4 years ago
  • Updated 8 months ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

A Finite-state machine implementation in Elixir, with opt-in Ecto friendliness

Fsmx

A Finite-state machine implementation in Elixir, with opt-in Ecto friendliness.

Highlights:

  • Plays nicely with both bare Elixir structs and Ecto changesets
  • Ability to wrap transitions inside an Ecto.Multi for atomic updates
  • Guides you in the right direction when it comes to side effects

Installation

Add fsmx to your list of dependencies in mix.exs:

def deps do
  [
    {:fsmx, "~> 0.2.0"}
  ]
end

Usage

Simple state machine

defmodule App.StateMachine do
  defstruct [:state, :data]

  use Fsmx.Struct, transitions: %{
    "one" => ["two", "three"],
    "two" => ["three", "four"],
    "three" => "four",
    "four" => :*, # can transition to any state
    "*" => ["five"] # can transition from any state to "five"
  }
end

Use it via the Fsmx.transition/2 function:

struct = %App.StateMachine{state: "one", data: nil}

Fsmx.transition(struct, "two")
# {:ok, %App.StateMachine{state: "two"}}

Fsmx.transition(struct, "four")
# {:error, "invalid transition from one to four"}

Callbacks before transitions

You can implement a before_transition/3 callback to mutate the struct when before a transition happens. You only need to pattern-match on the scenarios you want to catch. No need to add a catch-all/do-nothing function at the end (the library already does that for you).

defmodule App.StateMachine do
  # ...

  def before_transition(struct, "two", _destination_state) do
    {:ok, %{struct | data: %{foo: :bar}}}
  end
end

Usage:

struct = %App.StateMachine{state: "two", data: nil}

Fsmx.transition(struct, "three")
# {:ok, %App.StateMachine{state: "three", data: %{foo: :bar}}

Validating transitions

The same before_transition/3 callback can be used to add custom validation logic, by returning an {:error, _} tuple when needed:

defmodule App.StateMachine do
  # ...


  def before_transition(%{data: nil}, _initial_state, "four") do
    {:error, "cannot reach state four without data"}
  end
end

Usage:

struct = %App.StateMachine{state: "two", data: nil}

Fsmx.transition(struct, "four")
# {:error, "cannot reach state four without data"}

Decoupling logic from data

Since logic can grow a lot, and fall out of scope in your structs/schemas, it's often useful to separate all that business logic into a separate module:

defmodule App.StateMachine do
  defstruct [:state]

  use Fsmx.Struct, fsm: App.BusinessLogic
end

defmodule App.BusinessLogic do
  use Fsmx.Fsm, transitions: %{
    "one" => ["two", "three"],
    "two" => ["three", "four"],
    "three" => "four"
  }

  # callbacks go here now
  def before_transition(struct, "two", _destination_state) do
    {:ok, %{struct | data: %{foo: :bar}}}
  end

  def before_transition(%{data: nil}, _initial_state, "four") do
    {:error, "cannot reach state four without data"}
  end
end

Ecto support

Support for Ecto is built in, as long as ecto is in your mix.exs dependencies. With it, you get the ability to define state machines using Ecto schemas, and the Fsmx.Ecto module:

defmodule App.StateMachineSchema do
  use Ecto.Schema

  schema "state_machine" do
    field :state, :string, default: "one"
    field :data, :map
  end

  use Fsmx.Struct, transitions: %{
    "one" => ["two", "three"],
    "two" => ["three", "four"],
    "three" => "four"
  }
end

You can then mutate your state machine in one of two ways:

1. Transition changesets

Returns a changeset that mutates the :state field (or {:error, _} if the transition is invalid).

{:ok, schema} = %App.StateMachineSchema{state: "one"} |> Repo.insert()

Fsmx.transition_changeset(schema, "two")
# #Ecto.Changeset<changes: %{state: "two"}>

You can customize the changeset function, and again pattern match on specific transitions, and additional params:

defmodule App.StateMachineSchema do
  # ...

  # only include sent data on transitions from "one" to "two"
  def transition_changeset(changeset, "one", "two", params) do
    # changeset already includes a :state field change
    changeset
    |> cast(params, [:data])
    |> validate_required([:data])
  end

Usage:

{:ok, schema} = %App.StateMachineSchema{state: "one"} |> Repo.insert()

Fsmx.transition_changeset(schema, "two", %{"data"=> %{foo: :bar}})
# #Ecto.Changeset<changes: %{state: "two", data: %{foo: :bar}>

2. Transition with Ecto.Multi

Note: Please read a note on side effects first. Your future self will thank you.

If a state transition is part of a larger operation, and you want to guarantee atomicity of the whole operation, you can plug a state transition into an Ecto.Multi. The same changeset seen above will be used here:

{:ok, schema} = %App.StateMachineSchema{state: "one"} |> Repo.insert()

Ecto.Multi.new()
|> Fsmx.transition_multi(schema, "transition-id", "two", %{"data" => %{foo: :bar}})
|> Repo.transaction()

When using Ecto.Multi, you also get an additional after_transition_multi/3 callback, where you can append additional operations the resulting transaction, such as dealing with side effects (but again, please know that side effects are tricky)

defmodule App.StateMachineSchema do
  def after_transition_multi(schema, _from, "four") do
    Mailer.notify_admin(schema)
    |> Bamboo.deliver_later()

    {:ok, nil}
  end
end

Note that after_transition_multi/3 callbacks still run inside the database transaction, so be careful with expensive operations. In this example Bamboo.deliver_later/1 (from the awesome Bamboo package) doesn't spend time sending the actual email, it just spawns a task to do it asynchronously.

A note on side effects

Side effects are tricky. Database transactions are meant to guarantee atomicity, but side effects often touch beyond the database. Sending emails when a task is complete is a straight-forward example.

When you run side effects within an Ecto.Multi you need to be aware that, should the transaction later be rolled back, there's no way to un-send that email.

If the side effect is the last operation within your Ecto.Multi, you're probably 99% fine, which works for a lot of cases. But if you have more complex transactions, or if you do need 99.9999% consistency guarantees (because, let's face it, 100% is a pipe dream), then this simple library might not be for you.

Consider looking at Sage, for instance.

# this is *probably* fine
Ecto.Multi.new()
|> Fsmx.transition_multi(schema, "transition-id", "two", %{"data" => %{foo: :bar}})
|> Repo.transaction()

# this is dangerous, because your transition callback
# will run before the whole database transaction has run
Ecto.Multi.new()
|> Fsmx.transition_multi(schema, "transition-id", "two", %{"data" => %{foo: :bar}})
|> Ecto.Multi.update(:update, a_very_unreliable_changeset())
|> Repo.transaction()

Contributing

Feel free to contribute. Either by opening an issue, a Pull Request, or contacting the team directly

If you found a bug, please open an issue. You can also open a PR for bugs or new features. PRs will be reviewed and subject to our style guide and linters.

About

Fsmx is maintained by Subvisual.

Subvisual logo

More Repositories

1

meteor-bender

Animations in page transitions
CoffeeScript
92
star
2

tripl.it

A mobile application for sharing trip expenses with friends. Made with Meteor.
CoffeeScript
89
star
3

dictator

Dictates what your users see. Plug-based authorization.
Elixir
77
star
4

guides

Elixir
26
star
5

notedown

TypeScript
20
star
6

uphold-sdk-ruby

A wrapper for the Uphold API
Ruby
18
star
7

http_stream

A tiny tiny Elixir library to stream big big files
Elixir
16
star
8

secrets.finiam.com

A simple web app that transmits E2E encrypted messages safely.
Elixir
10
star
9

wc.subvisual.co

WC Ocupancy Detectorâ„¢, aka the one thing we all needed in our office. Patent pending
Python
9
star
10

dora-the-tipset-explorer

Dora, the TipsetExplorer. A The Graph-like indexer for the FEVM.
JavaScript
8
star
11

subvisual.com

Subvisual Website
JavaScript
7
star
12

theshelf-angular

An AngularJS web client for The Shelf
JavaScript
6
star
13

omniauth-paymill

This gem contains the Paymill Connect strategy for OmniAuth2
Ruby
4
star
14

meteor-reactive-form

Meteor package to help with complex forms
CoffeeScript
4
star
15

money-uphold-bank

Ruby
3
star
16

2018.mirrorconf.com

Landing page for MirrorConf's 2018 edition
JavaScript
3
star
17

tetrominos

C#
3
star
18

theshelf-api

API version of The Shelf.
Ruby
3
star
19

blog.subvisual.co

Our blog
Ruby
3
star
20

ethamsterdam-detris

Monorepo for the 2022 ETHAmsterdam DeTris project
JavaScript
3
star
21

docker-deploy-demo

Ruby
2
star
22

webcomic

JavaScript
2
star
23

2017.mirrorconf.com

MirrorConf landing page
JavaScript
2
star
24

mirror-pong

The Mighty Mirror Conf 2018 Pong Game
Elixir
2
star
25

hipaa

A survey to check if your product is HIPAA compliant.
JavaScript
2
star
26

subvisual-puppet

GB Puppet module
Puppet
2
star
27

firestarter

Firestarter is the base Rails application used at Subvisual.
Ruby
2
star
28

ChickenBonds

Chicken Bonds go to starknet
Cairo
2
star
29

le-jack-tracker

A calendar view of Jack Niewold's 2023 crypto conferences spreadsheet
TypeScript
2
star
30

dotfiles

A set of vim, zsh and git configuration files
Shell
2
star
31

react_starter

An application boilerplate for React using Parcel and Subvisual's conventions.
JavaScript
2
star
32

CosmicWave-fe

TypeScript
2
star
33

the-gathering

Submissions for a small unconference-like event preceding RubyConfPT 2015
2
star
34

gb-dashboard

A beautiful dashboard that provides an overview of the whole company. (We need a TV for this. :P)
JavaScript
2
star
35

summer_camp_jam

Svelte
2
star
36

discoveryDAO

TypeScript
1
star
37

scrawl

Svelte
1
star
38

dora-landing

Landing page for Dora, the TipsetExplorer
Svelte
1
star
39

conference-wall

JavaScript
1
star
40

landing-page-template

GB Landing Pages Template
1
star
41

ruby-radix-encoding

Binary-to-text encoding using any power-of-2 base and/or alphabet.
Ruby
1
star
42

solidity-course

TypeScript
1
star
43

alchemy_conf

Something is brewing in Portugal...
Elixir
1
star
44

2016.mirrorconf.com

HTML
1
star
45

gb-puppet-test-app

Ruby
1
star
46

gb-academy

Ruby
1
star
47

blue

Subvisual's UI Kit
CSS
1
star
48

detris-contracts

Contracts for the detris hackathon project - ETH Amsterdam 2022
Solidity
1
star
49

alchemyconf.com

Brewing magic with Elixir. Virtually.
SCSS
1
star
50

versominus

JavaScript
1
star
51

codegen-cranelift-toolchain

Github Action for rustc_codegen_cranelift
1
star
52

credentials

Simple script to generate credentials for conferences
JavaScript
1
star
53

space-warp-hackathon

Cosmic Chicken repo for the FVM Space Warp hackathon
Solidity
1
star
54

cosmic-chicken-frontend

Cosmic Chicken frontend repo for the FVM Space Warp hackathon
TypeScript
1
star
55

notes-app

This is a simple note taking app that makes the most of crypto wallets to anonymously store notes
TypeScript
1
star
56

le-better-dapp

le-better-dapp
TypeScript
1
star
57

blog.finiam.com

Finiam's Next blog
TypeScript
1
star
58

svarknet

A Starknet starter dapp built with Svelte and Vite
TypeScript
1
star
59

newsletter.subvisual.co

Landing page for the Subvisual newsletter
HTML
1
star
60

dash.subvisual.co

Our dashboard (or at least an initial attempt at it)
JavaScript
1
star
61

apprenticeship-app

An internal application to manage the apprenticeship program.
CSS
1
star
62

project-agnus

Awesome platform to track our team happiness
JavaScript
1
star
63

react-styleguide-generator

A styleguide generator for React components
JavaScript
1
star