MetaStates

MetaStates is a flexible state management gem for Ruby on Rails that allows you to add multiple state machines to your models. It provides a simple way to track state transitions, add metadata, and execute callbacks.

Features

  • Multiple state types per model
  • Model-specific state configurations
  • JSON metadata storage for each state
  • Configurable callbacks with conditions
  • Limited execution callbacks
  • Automatic scope generation
  • Simple state transition tracking

Installation

Add this line to your application's Gemfile:

gem 'stateful_models'

Then execute:

$ bundle install

Generate the required migration and initializer:

$ rails generate meta_states:install

Finally, run the migration:

$ rails db:migrate

Configuration

Configure your models and their state types in config/initializers/meta_states.rb:

MetaStates.configure do |config|
  # Configure states on any model
  config.configure_model User do |model|
    # Define state type and its allowed statuses
    model.state_type :kyc do |type|
      type.statuses = [
        'pending',              # Initial state
        'documents_required',   # Waiting for documents
        'under_review',        # Documents being reviewed
        'approved',            # KYC completed successfully
        'rejected'             # KYC failed
      ]
    end

    # Define multiple state types per model with different statuses
    model.state_type :onboarding do |type|
      type.statuses = [
        'pending',          # Just started
        'email_verified',   # Email verification complete
        'completed'         # Onboarding finished
      ]
    end
  end

  # Configure multiple models
  config.configure_model Company do |model|
    model.state_type :verification do |type|
      type.statuses = ['pending', 'verified', 'rejected']
    end
  end
end

Usage

Basic State Management

user = User.create!(name: 'John')
# Add a new state
state = user.add_state('kyc', status: 'pending', metadata: {
  documents: ['passport', 'utility_bill'],
  notes: 'Awaiting document submission'
})

# Load state(s) also by name as methods on the record
current_kyc = user.current_state('kyc')
current_kyc = user.kyc # Returns most recent state of type kyc if multiple states of the same type exist
all_kyc = user.kycs

# Predicate methods are generated for every status.
current_kyc.pending?  # => true
current_kyc.approved? # => false

# Update state
current_kyc.update!(status: 'under_review')

# Check state for record 
user.kyc_pending? # => true
user.kyc_completed? # => false

# See all states for record
user.states # => [#<MetaStates::State...>]

Working with Metadata

Each state can store arbitrary metadata as JSON:

# Store complex metadata
state = user.add_state('kyc', metadata: {
  documents: {
    passport: { 
      status: 'verified',
      verified_at: Time.current,
      verified_by: '[email protected]'
    },
    utility_bill: { 
      status: 'rejected',
      reason: 'Document expired'
    }
  },
  risk_score: 85,
  notes: ['Requires additional verification', 'High-risk jurisdiction']
})

# Access metadata
state.['documents']['passport']['status'] # => "verified"
state.['risk_score'] # => 85

Metadata Validations

You can define a JSON Schema for validating the metadata of states. This allows you to ensure that the metadata follows a specific structure and contains required fields, leveraging the power of JSON Schema.

MetaStates.configure do |config|
  config.configure_model User do |model|
    model.state_type :kyc do |type|
      type.statuses = [
        'pending',         
        'approved',    
        'rejected'  
      ]

      type. = {
        type: :object,
        properties: {
          name: { type: :string },
          age: { 
            type: :integer,
            minimum: 18
          },
        },
        required: [:name, :age],
        additionalProperties: false,
      }
    end
  end
end

user = User.create!

# Invalid metadata (too young < 18)
user.add_state('kyc', status: 'pending', metadata: { name: 'John Doe', age: 17 })
# ActiveRecord::RecordInvalid: Validation failed: Metadata is invalid (minimum value of 18)

# Valid metadata
user.add_state('kyc', status: 'pending', metadata: { name: 'John Doe', age: 25 })
# => #<MetaStates::State...>

State Limits

You can optionally limit the number of states a record can have for a specific state type:

MetaStates.configure do |config|
  config.configure_model User do |model|
    model.state_type :kyc do |type|
      type.statuses = %w[pending completed]
      type.limit = 1  # Limit to only one KYC state per user
    end
  end
end

When set, the limit is checked when a new state is added. If the limit is exceeded, an ActiveRecord::RecordInvalid error is raised.

Callbacks

Register callbacks that execute when states change:

MetaStates.configure do |config|
  # Basic callback
  config.on(:kyc, to: 'completed') do |state|
    UserMailer.kyc_completed(state.stateable).deliver_later
  end

  # Callback with custom ID for easy removal
  config.on(:kyc, id: :notify_admin, to: 'rejected') do |state|
    AdminNotifier.kyc_rejected(state)
  end

  # Callback that runs only once
  config.on(:onboarding, to: 'completed', times: 1) do |state|
    WelcomeMailer.send_welcome(state.stateable)
  end

  # Callback with from/to conditions
  config.on(:kyc, from: 'pending', to: 'under_review') do |state|
    NotificationService.notify_review_started(state)
  end
end

# Remove callbacks
MetaStates.configuration.off(:notify_admin)  # Remove by ID
MetaStates.configuration.off(callback)       # Remove by callback object

Scopes

MetaStates automatically generates scopes for your state types:

MetaStates::State.kyc              # All KYC states
MetaStates::State.onboarding      # All onboarding states

Class Inheritance

MetaStates lets you inherit from the MetaStates::Base class to create custom state classes. This makes validations and custom methods on specific state types easy.

class MyState < MetaStates::Base
  # Add validations or methods

  def do_something
    # Custom method on state
  end
end

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/meta_states.

License

The gem is available as open source under the terms of the MIT License.