Statesman Ruby State Machine

repository·master·Indexed 24 days ago

https://github.com/gocardless/statesman

An opinionated state machine library for Ruby designed for robust audit trails and data integrity. Statesman decouples state logic from models, allowing for easy composition and persistence of state transitions. It features support for guards, hooks, and an ActiveRecord adapter for persisting transition history with JSON metadata.

Tokens
7K
Snippets
18
Records
40
Agent score
83%

What's inside Statesman

  1. Understand Statesman compatibility and deprecation policy

    master

    Statesman aims to support all Ruby (CRuby/MRI) and Rails versions that are currently receiving official support.

    Compatibility Criteria:

    • Ruby: Any version that has not received an End of Life (EOL) notice.
    • Rails: Any version listed as currently supported on the official Rails EOL page.

    Deprecation Process: When a version of Ruby or Rails reaches End of Life, Statesman will:

    1. Update its build matrix to remove the unsupported version.
    2. Release a new major version of Statesman to signal the break in compatibility.

    Note that even if a newer version of Statesman technically functions on an unsupported Ruby/Rails version, the major version bump serves as the official indicator that compatibility is no longer guaranteed or supported.

  2. How Statesman state machines work

    master

    Statesman uses an opinionated design that decouples state machine logic from your data models:

    1. State Machine Class: You define states, transitions, guards, and hooks in a class that includes Statesman::Machine. This class is independent of your models.
    2. Model Integration: You instantiate the state machine within your model (e.g., an Order model) and pass the model instance to the machine.
    3. Transition Model: Transitions are represented by a separate class (e.g., OrderTransition) which can be persisted to a database to provide a full audit trail, including JSON metadata.

    This separation allows for easy composition and robust data integrity through database-level transaction protection.

  3. Link a state machine to an ActiveRecord model

    master

    To use a state machine within an ActiveRecord model, include Statesman::Adapters::ActiveRecordQueries and implement a state_machine method.

    class Order < ActiveRecord::Base
      has_many :order_transitions, autosave: false
    
      include Statesman::Adapters::ActiveRecordQueries[
        transition_class: OrderTransition,
        initial_state: :pending
      ]
    
      def state_machine
        @state_machine ||= OrderStateMachine.new(self, transition_class: OrderTransition)
      end
    end
  4. Configure the ActiveRecord storage adapter

    master

    To persist transitions to a database using ActiveRecord, configure the global storage_adapter in an initializer (e.g., config/initializers/statesman.rb):

    Statesman.configure do
      storage_adapter(Statesman::Adapters::ActiveRecord)
    end

    Then, generate the transition model using the Statesman generator:

    rails g statesman:active_record_transition Order OrderTransition
  5. Use PostgreSQL JSON columns for transition metadata

    master

    If you are using PostgreSQL and want to use native json or jsonb columns for metadata instead of ActiveRecord's serialize (which uses text), follow these steps:

    1. In your migration, set the metadata column type to json or jsonb:

      • Rails 4: t.json :metadata, default: "{}"
      • Rails 5: t.json :metadata, default: {}
    2. Remove include Statesman::Adapters::ActiveRecordTransition from your transition model. This prevents ActiveRecord from attempting to serialize the column.

    3. If you need to customize the updated timestamp column (since you removed the module that provides this), define a .updated_timestamp_column method on your transition class:

    def updated_timestamp_column
      :updated_on # or nil to disable
    end
  6. Ensure Type Safety with Sorbet

    master

    Including ActiveRecordQueries can cause issues with type checkers like Sorbet because it uses dynamic includes. To avoid this, include Statesman::Adapters::TypeSafeActiveRecordQueries instead and provide the necessary configuration.

    class Order < ActiveRecord::Base
      has_many :order_transitions, autosave: false
    
      include Statesman::Adapters::TypeSafeActiveRecordQueries
    
      configure_state_machine transition_class: OrderTransition,
                              initial_state: :pending
    
      def state_machine
        @state_machine ||= OrderStateMachine.new(self, transition_class: OrderTransition)
      end
    end
  7. How Statesman callbacks match state transitions

    master

    Statesman uses Callback objects to trigger logic during state transitions. A callback is defined with a from state, a to state (or an array of states), and a callback object that responds to .call.

    When checking if a callback applies to a transition using applies_to?(from: ..., to: ...), the following matching logic is used:

    1. All transitions: If both from and to are nil or empty in the callback definition, it matches every transition.
    2. From state only: If only from is specified in the callback, it matches any transition starting from that state.
    3. To state only: If only to is specified in the callback, it matches any transition ending in one of the specified to states.
    4. Both states: If both from and to are specified, it matches only transitions that move from the specific from state to one of the specified to states.
  8. Define a state machine using Statesman::Machine

    master

    To use Statesman, include Statesman::Machine in your class. You define states using the state method and transitions using the transition method. You can designate an initial state by passing initial: true to the state method.

    States are automatically converted to uppercase constants within the class (e.g., state :pending creates a PENDING constant).

  9. Configure Statesman

    master

    Use Statesman.configure to set global configuration for the library. This is typically used to define the default storage_adapter and enable specific features like MySQL gaplock protection. The configuration block is executed within the context of a Statesman::Config instance.

    Statesman.configure do
      storage_adapter Statesman::ActiveRecordAdapter
      enable_mysql_gaplock_protection
    end
  10. Troubleshoot Statesman errors

    master

    Initialization Errors

    • InvalidStateError: Occurs if a transition lacks a to state, uses a non-existent state, or defines multiple initial states.
    • InvalidTransitionError: Occurs if a callback is defined for a terminal state, an unreachable initial state, or a non-existent transition path.
    • InvalidCallbackError: Occurs if a callback is defined without a block.
    • UnserializedMetadataError: Occurs if ActiveRecord is not configured to serialize the metadata attribute.
    • IncompatibleSerializationError: Occurs if there is a mismatch between the database column type for metadata and the model.
    • MissingTransitionAssociation: Occurs if the model lacks the required has_many association to the transition_class.

    Runtime Errors (raised by transition_to!)

    • GuardFailedError: Raised when a guard returns a falsey value. Access the failing model via e.object.
    • TransitionFailedError: Raised when the requested transition is not a valid path in the state machine.
    • TransitionConflictError: Raised during database conflicts (e.g., sort_key collisions). Use retry_conflicts to handle these.
  11. Test Guards in Statesman

    master

    To verify that guards are correctly preventing or allowing transitions, assert that calling transition_to! raises a Statesman::GuardFailedError when a transition is invalid, or does not raise an error when a transition is valid.

    describe "guards" do
      it "cannot transition from state foo to state bar" do
        expect { some_model.transition_to!(:bar) }.to raise_error(Statesman::GuardFailedError)
      end
    
      it "can transition from state foo to state baz" do
        expect { some_model.transition_to!(:baz) }.to_not raise_error
      end
    end