stateless 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 25117fce1aea228233e79070743a0477c8c454ce6c66a36124569d8320a2545f
4
+ data.tar.gz: ed44fcd9f53019acc59b947a488707b9828de883ae2cc451019e184b1562c18d
5
+ SHA512:
6
+ metadata.gz: 4fdf205c5fe179d7291568ed0f5caceac3f0f06d0a28614dba98de3bebdc720b52b6085e7b4f1a3c2c938f6416fdf217228db2b2ce093fbfb2b633a304080a5a
7
+ data.tar.gz: 167dfc34f8a63afc98f6fc5d38a43d5740ed305d6c3e141e704e6f996f062755e5d184d1cc93d3252d606ea87387beb5d5e52a9be6c381c3d7c9735ee6af8e8e
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-07-11
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Aliexandr Andrade
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,178 @@
1
+ # Stateless
2
+
3
+ `Stateless` is a tiny Ruby finite state machine engine.
4
+
5
+ It keeps transition rules in plain Ruby objects and leaves model state management
6
+ to your application. The engine can answer whether a transition is possible and
7
+ what the next state would be, but it does not update your model, run callbacks,
8
+ or assume where your current state is stored.
9
+
10
+ That makes transition logic easy to reuse in service objects, transactions,
11
+ background jobs, and other places where model-coupled state machine callbacks can
12
+ be too restrictive.
13
+
14
+ ## Installation
15
+
16
+ Install the gem and add to the application's Gemfile by executing:
17
+
18
+ ```bash
19
+ bundle add stateless
20
+ ```
21
+
22
+ If bundler is not being used to manage dependencies, install the gem by executing:
23
+
24
+ ```bash
25
+ gem install stateless
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ Define transitions as data:
31
+
32
+ ```ruby
33
+ transitions = [
34
+ Stateless::Transition.new(event_name: :prepare, from: %i[draft], to: :prepared),
35
+ Stateless::Transition.new(event_name: :schedule, from: %i[provisioned], to: :scheduled),
36
+ Stateless::Transition.new(event_name: :cancel, from: %i[draft scheduled], to: :canceled)
37
+ ]
38
+
39
+ state_machine_flow = Stateless::Engine.new(transitions)
40
+ ```
41
+
42
+ Ask whether an event can be applied from the current state:
43
+
44
+ ```ruby
45
+ transition_possible = state_machine_flow.can_transit_by_event?(order.state, :schedule)
46
+ ```
47
+
48
+ Ask which state the event would produce:
49
+
50
+ ```ruby
51
+ new_state = state_machine_flow.transition_state_by_event(order.state, :schedule)
52
+ order.assign_attributes(state: new_state)
53
+ ```
54
+
55
+ You can also query transitions by target state:
56
+
57
+ ```ruby
58
+ state_machine_flow.can_transit_to_state?(order.state, :scheduled)
59
+ state_machine_flow.transition_event(order.state, :scheduled)
60
+ ```
61
+
62
+ ## Why Stateless?
63
+
64
+ `Stateless` is useful when you want transition rules without model-owned state
65
+ management.
66
+
67
+ With callback-heavy state machine gems, transition logic is usually tied to the
68
+ model instance and its current state. That can make it harder to choose where
69
+ transition side effects run, especially when some work must happen inside a
70
+ database transaction and other work must happen after the transaction commits or
71
+ inside a background job.
72
+
73
+ With `Stateless`, the engine only answers questions:
74
+
75
+ - Can this event be applied from this state?
76
+ - What state would this event produce?
77
+ - Can this state move to that state?
78
+ - Which event moves this state to that state?
79
+
80
+ Your application decides what to do with those answers.
81
+
82
+ ## Decoupled Transition Handling
83
+
84
+ Because the next state is returned instead of written automatically, transition
85
+ handling can stay explicit:
86
+
87
+ ```ruby
88
+ TRANSITION_MAP = {
89
+ schedule: OnScheduleHandler
90
+ }.freeze
91
+
92
+ ANOTHER_TRANSITION_MAP = {
93
+ schedule: AnotherOnScheduleHandler
94
+ }.freeze
95
+
96
+ transition_event = :schedule
97
+ transition_possible = state_machine_flow.can_transit_by_event?(order.state, transition_event)
98
+ new_state = state_machine_flow.transition_state_by_event(order.state, transition_event)
99
+
100
+ if transition_possible
101
+ ActiveRecord::Base.transaction do
102
+ order.update!(state: new_state)
103
+ TRANSITION_MAP[transition_event].new(order).call
104
+ end
105
+
106
+ ANOTHER_TRANSITION_MAP[transition_event].new(order).call
107
+ SomeSidekiqJob.perform_later(transition_event)
108
+ end
109
+ ```
110
+
111
+ This keeps the transition table separate from state persistence and side effects.
112
+ The current state can live in a database column, in memory, or anywhere else your
113
+ application needs it. `Stateless` does not care.
114
+
115
+ The result is plain Ruby:
116
+
117
+ - no model callbacks
118
+ - no hidden persistence
119
+ - no automatic state mutation
120
+ - service objects can return values
121
+ - background jobs can handle follow-up work
122
+ - transaction boundaries stay under your control
123
+
124
+ ## API
125
+
126
+ ### `can_transit_by_event?(from, event_name)`
127
+
128
+ Returns `true` when `event_name` can be applied from `from`.
129
+
130
+ ```ruby
131
+ state_machine_flow.can_transit_by_event?(order.state, :schedule)
132
+ ```
133
+
134
+ ### `transition_state_by_event(from, event_name)`
135
+
136
+ Returns the next state after `event_name` is applied from `from`.
137
+
138
+ ```ruby
139
+ new_state = state_machine_flow.transition_state_by_event(order.state, :schedule)
140
+ order.assign_attributes(state: new_state)
141
+ ```
142
+
143
+ ### `can_transit_to_state?(from, to)`
144
+
145
+ Returns `true` when any configured transition can move from `from` to `to`.
146
+
147
+ ```ruby
148
+ state_machine_flow.can_transit_to_state?(order.state, :scheduled)
149
+ ```
150
+
151
+ ### `transition_event(from, to)`
152
+
153
+ Returns the event name that moves from `from` to `to`.
154
+
155
+ ```ruby
156
+ event_name = state_machine_flow.transition_event(order.state, :scheduled)
157
+ ```
158
+
159
+ ## Sample Order State Machine
160
+
161
+ See `lib/samples/order_state_machine.rb` for a larger example of an order flow.
162
+ It defines transitions such as `prepare`, `provision`, `schedule`, `cancel`,
163
+ `reset`, and `reopen`, then delegates the public query methods to
164
+ `Stateless::Engine`.
165
+
166
+ ## Development
167
+
168
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
169
+
170
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
171
+
172
+ ## Contributing
173
+
174
+ Bug reports and pull requests are welcome.
175
+
176
+ ## License
177
+
178
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,59 @@
1
+ module Samples
2
+ class OrderStateMachine
3
+ COMPLETE_STATES = %i[
4
+ draft on_hold declined prepared dispatched assigned accepted provisioned scheduled invoiced canceled partial_payment
5
+ ].freeze
6
+ CANCELABLE_STATES = %i[
7
+ accepted assigned declined draft on_hold provisioned invoiced scheduled partial_payment
8
+ ].freeze
9
+ RESET_STATES = %i[
10
+ draft prepared dispatched assigned accepted provisioned scheduled invoiced canceled
11
+ ].freeze
12
+ UNDISPATCH_STATES = %i[
13
+ draft on_hold prepared dispatched assigned accepted provisioned scheduled invoiced canceled
14
+ ].freeze
15
+ ALL_STATES = %i[
16
+ assigned invoiced completed accepted draft provisioned scheduled prepared confirmed unassigned
17
+ dispatched canceled declined partial_payment deposit_requested partially_refunded refunded on_hold
18
+ ]
19
+
20
+ attr_reader :engine
21
+
22
+ delegate :can_transit_to_state?, :transition_event, :can_transit_by_event?, :transition_state_by_event, to: :engine
23
+
24
+ def initialize
25
+ @engine = ::Stateless::Engine.new(transitions)
26
+ end
27
+
28
+ private
29
+
30
+ def transitions
31
+ [
32
+ new_transitions(event_name: :accept, from: %i[draft on_hold assigned dispatched], to: :accepted),
33
+ new_transitions(event_name: :assign, from: %i[prepared draft on_hold], to: :assigned),
34
+ new_transitions(event_name: :cancel, from: CANCELABLE_STATES, to: :canceled),
35
+ new_transitions(event_name: :complete, from: COMPLETE_STATES, to: :completed),
36
+ new_transitions(event_name: :decline, from: %i[provisioned], to: :declined),
37
+ new_transitions(event_name: :dispatch, from: ALL_STATES, to: :dispatched),
38
+ new_transitions(event_name: :draft, from: ALL_STATES, to: :draft),
39
+ new_transitions(event_name: :hold, from: ALL_STATES, to: :on_hold),
40
+ new_transitions(event_name: :invoice, from: ALL_STATES, to: :invoiced),
41
+ new_transitions(event_name: :pay_partially, from: ALL_STATES, to: :partial_payment),
42
+ new_transitions(event_name: :prepare, from: %i[draft], to: :prepared),
43
+ new_transitions(event_name: :provision, from: %i[accepted], to: :provisioned),
44
+ new_transitions(event_name: :reject, from: %i[assigned], to: :draft),
45
+ new_transitions(event_name: :reopen, from: %i[completed], to: :accepted),
46
+ new_transitions(event_name: :reprovision, from: %i[declined scheduled], to: :provisioned),
47
+ new_transitions(event_name: :request_deposit, from: ALL_STATES, to: :deposit_requested),
48
+ new_transitions(event_name: :reschedule, from: %i[scheduled], to: :provisioned),
49
+ new_transitions(event_name: :reset, from: RESET_STATES, to: :unassigned),
50
+ new_transitions(event_name: :schedule, from: %i[provisioned], to: :scheduled),
51
+ new_transitions(event_name: :undispatch, from: UNDISPATCH_STATES, to: :draft)
52
+ ]
53
+ end
54
+
55
+ def new_transitions(event_name:, from:, to:)
56
+ ::Stateless::Transition.new(event_name:, from:, to:)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,41 @@
1
+ module Stateless
2
+ class Engine
3
+ attr_reader :transitions
4
+
5
+ def initialize(transitions)
6
+ @transitions = transitions
7
+ end
8
+
9
+ def can_transit_to_state?(from, to)
10
+ transitions.any? { |transition| target_transition_by_state?(transition, from, to) }
11
+ end
12
+
13
+ def transition_event(from, to)
14
+ transition = transitions.detect { |transition| target_transition_by_state?(transition, from, to) }
15
+ transition&.event_name
16
+ end
17
+
18
+ def can_transit_by_event?(from, event_name)
19
+ transitions.any? { |transition| target_transition_by_event?(transition, from, event_name) }
20
+ end
21
+
22
+ def transition_state_by_event(from, event_name)
23
+ transition = transitions.detect { |transition| target_transition_by_event?(transition, from, event_name) }
24
+ transition&.to
25
+ end
26
+
27
+ private
28
+
29
+ def target_transition_by_state?(transition, from, to)
30
+ transition.from.include?(from.to_sym) &&
31
+ transition.to == to.to_sym &&
32
+ (transition.guard.nil? || transition.guard.call)
33
+ end
34
+
35
+ def target_transition_by_event?(transition, from, event_name)
36
+ transition.event_name.to_sym == event_name.to_sym &&
37
+ transition.from.include?(from.to_sym) &&
38
+ (transition.guard.nil? || transition.guard.call)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,3 @@
1
+ module Stateless
2
+ class Error < StandardError; end
3
+ end
@@ -0,0 +1,12 @@
1
+ require 'dry-struct'
2
+
3
+ module Stateless
4
+ Types = Dry.Types()
5
+
6
+ class Transition < Dry::Struct
7
+ attribute :event_name, Types::Coercible::Symbol
8
+ attribute :from, Types::Array.of(Types::Coercible::Symbol)
9
+ attribute :to, Types::Coercible::Symbol
10
+ attribute :guard, Types::Instance(Proc).optional.default(nil)
11
+ end
12
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stateless
4
+ VERSION = "0.1.0"
5
+ end
data/lib/stateless.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "stateless/version"
4
+ require_relative "stateless/engine"
5
+ require_relative "stateless/transition"
6
+
7
+ module Stateless
8
+ end
data/sig/stateless.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Stateless
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,54 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: stateless
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Aliexandr Andrade
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ email:
13
+ - veimar.94@gmail.com
14
+ executables: []
15
+ extensions: []
16
+ extra_rdoc_files: []
17
+ files:
18
+ - CHANGELOG.md
19
+ - LICENSE.txt
20
+ - README.md
21
+ - Rakefile
22
+ - lib/samples/order_state_machine.rb
23
+ - lib/stateless.rb
24
+ - lib/stateless/engine.rb
25
+ - lib/stateless/error.rb
26
+ - lib/stateless/transition.rb
27
+ - lib/stateless/version.rb
28
+ - sig/stateless.rbs
29
+ homepage: https://github.com/Alexander-Andrade/stateless
30
+ licenses:
31
+ - MIT
32
+ metadata:
33
+ allowed_push_host: https://rubygems.org
34
+ homepage_uri: https://github.com/Alexander-Andrade/stateless
35
+ source_code_uri: https://github.com/Alexander-Andrade/stateless
36
+ changelog_uri: https://github.com/Alexander-Andrade/stateless
37
+ rdoc_options: []
38
+ require_paths:
39
+ - lib
40
+ required_ruby_version: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: 3.2.0
45
+ required_rubygems_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '0'
50
+ requirements: []
51
+ rubygems_version: 4.0.8
52
+ specification_version: 4
53
+ summary: "`Stateless` is a tiny Ruby finite state machine engine."
54
+ test_files: []