flow_state 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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +3 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +207 -0
- data/Rakefile +12 -0
- data/lib/flow_state/base.rb +107 -0
- data/lib/flow_state/flow_transition.rb +13 -0
- data/lib/flow_state/railtie.rb +16 -0
- data/lib/flow_state/version.rb +5 -0
- data/lib/flow_state.rb +13 -0
- data/lib/generators/flow_state/install_generator.rb +29 -0
- data/lib/generators/flow_state/templates/create_flow_state_flow_transitions.rb +13 -0
- data/lib/generators/flow_state/templates/create_flow_state_flows.rb +12 -0
- data/sig/flow_state.rbs +4 -0
- metadata +78 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 987d83f1bb6e470334d46a18cc0888230089f592d87a86a8fe94e17adf54f597
|
4
|
+
data.tar.gz: d6c9c7803c1623beef1b18d00cc7794c3cdea3c4eed6c65d515f04556cccd644
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: f430b9d565089198251bdcae35eb3e6d3946e8995c208e88098aad1e1112665c71824ad6d3773571489c3e1972de002181765217ad998bab4f38b7d53e80b2b8
|
7
|
+
data.tar.gz: 95ef87a1d39a3d1831e40b98f023c3f4ade454ad69d8298a0c9012e6bb236ee3636d6e35b4c2b6550ac86ceaebdf05cd9e8912621b082999011cdfcd46634196
|
data/.rspec
ADDED
data/.rubocop.yml
ADDED
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2025 Chris Garrett
|
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,207 @@
|
|
1
|
+
# FlowState
|
2
|
+
|
3
|
+
> **Model workflows without magic.**
|
4
|
+
|
5
|
+
---
|
6
|
+
|
7
|
+
**FlowState** provides a clean, Rails-native way to model **stepped workflows** as explicit, durable state machines.
|
8
|
+
It lets you define each step, move between states deliberately, and track execution — without relying on metaprogramming, `method_missing`, or hidden magic.
|
9
|
+
|
10
|
+
Every workflow instance is persisted to the database.
|
11
|
+
Every transition is logged.
|
12
|
+
Every change happens through clear, intention-revealing methods you define yourself.
|
13
|
+
|
14
|
+
Built for real-world systems where you need to:
|
15
|
+
- Track complex, multi-step processes
|
16
|
+
- Handle failures gracefully
|
17
|
+
- Persist state safely across asynchronous jobs
|
18
|
+
|
19
|
+
---
|
20
|
+
|
21
|
+
## Key Features
|
22
|
+
|
23
|
+
- **Explicit transitions** — Every state change is triggered manually via a method you define.
|
24
|
+
- **Full execution history** — Every transition is recorded with timestamps and a history table.
|
25
|
+
- **Error recovery** — Model and track failures directly with error states.
|
26
|
+
- **Typed payloads** — Strongly-typed metadata attached to every workflow.
|
27
|
+
- **Persistence-first** — Workflow state is stored in your database, not memory.
|
28
|
+
- **No Magic** — No metaprogramming, no dynamic method generation, no `method_missing` tricks.
|
29
|
+
|
30
|
+
---
|
31
|
+
|
32
|
+
## Installation
|
33
|
+
|
34
|
+
Add to your bundle:
|
35
|
+
|
36
|
+
```bash
|
37
|
+
bundle add flow_state
|
38
|
+
```
|
39
|
+
|
40
|
+
Generate the tables:
|
41
|
+
|
42
|
+
```bash
|
43
|
+
bin/rails generate flow_state:install
|
44
|
+
bin/rails db:migrate
|
45
|
+
```
|
46
|
+
|
47
|
+
---
|
48
|
+
|
49
|
+
## Example: Syncing song data with Soundcharts
|
50
|
+
|
51
|
+
Suppose you want to build a workflow that:
|
52
|
+
- Gets song metadata from Soundcharts
|
53
|
+
- Then fetches audience data
|
54
|
+
- Tracks each step and handles retries on failure
|
55
|
+
|
56
|
+
---
|
57
|
+
|
58
|
+
### Define your Flow
|
59
|
+
|
60
|
+
```ruby
|
61
|
+
class SyncSoundchartsFlow < FlowState::Base
|
62
|
+
prop :song_id, String
|
63
|
+
|
64
|
+
state :pending
|
65
|
+
state :picked
|
66
|
+
state :syncing_song_metadata
|
67
|
+
state :synced_song_metadata
|
68
|
+
state :syncing_audience_data
|
69
|
+
state :synced_audience_data
|
70
|
+
state :completed
|
71
|
+
|
72
|
+
error_state :failed_to_sync_song_metadata
|
73
|
+
error_state :failed_to_sync_audience_data
|
74
|
+
|
75
|
+
initial_state :pending
|
76
|
+
|
77
|
+
def pick!
|
78
|
+
transition!(
|
79
|
+
from: %i[pending completed failed_to_sync_song_metadata failed_to_sync_audience_data],
|
80
|
+
to: :picked,
|
81
|
+
after_transition: -> { sync_song_metadata }
|
82
|
+
)
|
83
|
+
end
|
84
|
+
|
85
|
+
def start_song_metadata_sync!
|
86
|
+
transition!(from: :picked, to: :syncing_song_metadata)
|
87
|
+
end
|
88
|
+
|
89
|
+
def finish_song_metadata_sync!
|
90
|
+
transition!(
|
91
|
+
from: :syncing_song_metadata, to: :synced_song_metadata,
|
92
|
+
after_transition: -> { sync_audience_data }
|
93
|
+
)
|
94
|
+
end
|
95
|
+
|
96
|
+
def fail_song_metadata_sync!
|
97
|
+
transition!(from: :syncing_song_metadata, to: :failed_to_sync_song_metadata)
|
98
|
+
end
|
99
|
+
|
100
|
+
def start_audience_data_sync!
|
101
|
+
transition!(from: :synced_song_metadata, to: :syncing_audience_data)
|
102
|
+
end
|
103
|
+
|
104
|
+
def finish_audience_data_sync!
|
105
|
+
transition!(
|
106
|
+
from: :syncing_audience_data, to: :synced_audience_data,
|
107
|
+
after_transition: -> { complete! }
|
108
|
+
)
|
109
|
+
end
|
110
|
+
|
111
|
+
def fail_audience_data_sync!
|
112
|
+
transition!(from: :syncing_audience_data, to: :failed_to_sync_audience_data)
|
113
|
+
end
|
114
|
+
|
115
|
+
def complete!
|
116
|
+
transition!(from: :synced_audience_data, to: :completed, after_transition: -> { destroy })
|
117
|
+
end
|
118
|
+
|
119
|
+
private
|
120
|
+
|
121
|
+
def song
|
122
|
+
@song ||= Song.find(song_id)
|
123
|
+
end
|
124
|
+
|
125
|
+
def sync_song_metadata
|
126
|
+
SyncSoundchartsSongJob.perform_later(flow_id: id)
|
127
|
+
end
|
128
|
+
|
129
|
+
def sync_audience_data
|
130
|
+
SyncSoundchartsAudienceJob.perform_later(flow_id: id)
|
131
|
+
end
|
132
|
+
end
|
133
|
+
```
|
134
|
+
|
135
|
+
---
|
136
|
+
|
137
|
+
### Background Jobs
|
138
|
+
|
139
|
+
Each job moves the flow through the correct states, step-by-step.
|
140
|
+
|
141
|
+
---
|
142
|
+
|
143
|
+
**Sync song metadata**
|
144
|
+
|
145
|
+
```ruby
|
146
|
+
class SyncSoundchartsSongJob < ApplicationJob
|
147
|
+
def perform(flow_id:)
|
148
|
+
@flow_id = flow_id
|
149
|
+
|
150
|
+
flow.start_song_metadata_sync!
|
151
|
+
|
152
|
+
# Fetch song metadata from Soundcharts etc
|
153
|
+
|
154
|
+
flow.finish_song_metadata_sync!
|
155
|
+
rescue
|
156
|
+
flow.fail_song_metadata_sync!
|
157
|
+
raise
|
158
|
+
end
|
159
|
+
|
160
|
+
private
|
161
|
+
|
162
|
+
def flow
|
163
|
+
@flow ||= SyncSoundchartsFlow.find(@flow_id)
|
164
|
+
end
|
165
|
+
end
|
166
|
+
```
|
167
|
+
|
168
|
+
---
|
169
|
+
|
170
|
+
**Sync audience data**
|
171
|
+
|
172
|
+
```ruby
|
173
|
+
class SyncSoundchartsAudienceJob < ApplicationJob
|
174
|
+
def perform(flow_id:)
|
175
|
+
@flow_id = flow_id
|
176
|
+
|
177
|
+
flow.start_audience_data_sync!
|
178
|
+
|
179
|
+
# Fetch audience data from Soundcharts etc
|
180
|
+
|
181
|
+
flow.finish_audience_data_sync!
|
182
|
+
rescue
|
183
|
+
flow.fail_audience_data_sync!
|
184
|
+
raise
|
185
|
+
end
|
186
|
+
|
187
|
+
private
|
188
|
+
|
189
|
+
def flow
|
190
|
+
@flow ||= SyncSoundchartsFlow.find(@flow_id)
|
191
|
+
end
|
192
|
+
end
|
193
|
+
```
|
194
|
+
|
195
|
+
---
|
196
|
+
|
197
|
+
## Why use FlowState?
|
198
|
+
|
199
|
+
Because it enables you to model workflows explicitly,
|
200
|
+
and track real-world execution reliably —
|
201
|
+
**without any magic**.
|
202
|
+
|
203
|
+
---
|
204
|
+
|
205
|
+
## License
|
206
|
+
|
207
|
+
MIT.
|
data/Rakefile
ADDED
@@ -0,0 +1,107 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FlowState
|
4
|
+
# Base Model to be extended by app models
|
5
|
+
class Base < ActiveRecord::Base
|
6
|
+
class UnknownStateError < StandardError; end
|
7
|
+
class InvalidTransitionError < StandardError; end
|
8
|
+
class PayloadValidationError < StandardError; end
|
9
|
+
|
10
|
+
self.table_name = 'flow_state_flows'
|
11
|
+
# self.abstract_class = true - this stops Rails respecting STI
|
12
|
+
|
13
|
+
has_many :flow_transitions,
|
14
|
+
class_name: 'FlowState::FlowTransition',
|
15
|
+
foreign_key: :flow_id,
|
16
|
+
inverse_of: :flow,
|
17
|
+
dependent: :destroy
|
18
|
+
|
19
|
+
class << self
|
20
|
+
def state(name)
|
21
|
+
all_states << name.to_sym
|
22
|
+
end
|
23
|
+
|
24
|
+
def error_state(name)
|
25
|
+
error_states << name.to_sym
|
26
|
+
state(name)
|
27
|
+
end
|
28
|
+
|
29
|
+
def initial_state(name = nil)
|
30
|
+
name ? @initial_state = name.to_sym : @initial_state
|
31
|
+
end
|
32
|
+
|
33
|
+
def prop(name, type)
|
34
|
+
payload_schema[name.to_sym] = type
|
35
|
+
define_method(name) { payload&.dig(name.to_s) }
|
36
|
+
end
|
37
|
+
|
38
|
+
def all_states
|
39
|
+
@all_states ||= []
|
40
|
+
end
|
41
|
+
|
42
|
+
def error_states
|
43
|
+
@error_states ||= []
|
44
|
+
end
|
45
|
+
|
46
|
+
def payload_schema
|
47
|
+
@payload_schema ||= {}
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
validates :current_state, presence: true
|
52
|
+
validate :validate_payload
|
53
|
+
|
54
|
+
after_initialize { self.current_state ||= resolve_initial_state }
|
55
|
+
|
56
|
+
def transition!(from:, to:, after_transition: nil) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
57
|
+
from = Array(from).map(&:to_sym)
|
58
|
+
to = to.to_sym
|
59
|
+
|
60
|
+
ensure_known_states!(from + [to])
|
61
|
+
|
62
|
+
with_lock do
|
63
|
+
unless from.include?(current_state&.to_sym)
|
64
|
+
raise InvalidTransitionError, "state #{current_state} not in #{from} (#{from.inspect}->#{to.inspect}"
|
65
|
+
end
|
66
|
+
|
67
|
+
transaction do
|
68
|
+
flow_transitions.create!(
|
69
|
+
transitioned_from: current_state,
|
70
|
+
transitioned_to: to
|
71
|
+
)
|
72
|
+
update!(current_state: to)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
after_transition&.call
|
77
|
+
end
|
78
|
+
|
79
|
+
def errored? = self.class.error_states.include?(current_state&.to_sym)
|
80
|
+
|
81
|
+
private
|
82
|
+
|
83
|
+
def resolve_initial_state
|
84
|
+
init = self.class.initial_state || self.class.all_states.first
|
85
|
+
ensure_known_states!([init]) if init
|
86
|
+
init
|
87
|
+
end
|
88
|
+
|
89
|
+
def ensure_known_states!(states)
|
90
|
+
unknown = states - self.class.all_states
|
91
|
+
raise UnknownStateError, "unknown #{unknown.join(', ')}" if unknown.any?
|
92
|
+
end
|
93
|
+
|
94
|
+
def validate_payload
|
95
|
+
schema = self.class.payload_schema
|
96
|
+
return if schema.empty?
|
97
|
+
|
98
|
+
schema.each do |key, klass|
|
99
|
+
v = payload&.dig(key.to_s)
|
100
|
+
raise PayloadValidationError, "#{key} missing" if v.nil?
|
101
|
+
raise PayloadValidationError, "#{key} must be #{klass}" unless v.is_a?(klass)
|
102
|
+
end
|
103
|
+
rescue PayloadValidationError => e
|
104
|
+
errors.add(:payload, e.message)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FlowState
|
4
|
+
# Model for logging transition changes to
|
5
|
+
class FlowTransition < ActiveRecord::Base
|
6
|
+
self.table_name = 'flow_state_flow_transitions'
|
7
|
+
|
8
|
+
belongs_to :flow,
|
9
|
+
class_name: 'FlowState::Base',
|
10
|
+
foreign_key: :flow_id,
|
11
|
+
inverse_of: :flow_transitions
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails/railtie'
|
4
|
+
|
5
|
+
module FlowState
|
6
|
+
# Auto-load our generators etc
|
7
|
+
class Railtie < Rails::Railtie
|
8
|
+
generators do
|
9
|
+
require_relative '../generators/flow_state/install_generator'
|
10
|
+
end
|
11
|
+
|
12
|
+
initializer 'flow_state.configure' do
|
13
|
+
Rails.logger&.info '[FlowState] Loaded'
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
data/lib/flow_state.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'zeitwerk'
|
4
|
+
|
5
|
+
loader = Zeitwerk::Loader.for_gem
|
6
|
+
loader.ignore("#{__dir__}/generators")
|
7
|
+
loader.setup
|
8
|
+
|
9
|
+
require 'flow_state/railtie' if defined?(Rails::Railtie)
|
10
|
+
|
11
|
+
# FlowState library
|
12
|
+
module FlowState
|
13
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails/generators'
|
4
|
+
require 'rails/generators/migration'
|
5
|
+
|
6
|
+
module FlowState
|
7
|
+
module Generators
|
8
|
+
# Generates migrations etc for FlowState
|
9
|
+
class InstallGenerator < Rails::Generators::Base
|
10
|
+
include Rails::Generators::Migration
|
11
|
+
|
12
|
+
source_root File.expand_path('templates', __dir__)
|
13
|
+
|
14
|
+
def create_migrations
|
15
|
+
migration_template 'create_flow_state_flows.rb', 'db/migrate/create_flow_state_flows.rb'
|
16
|
+
migration_template 'create_flow_state_flow_transitions.rb', 'db/migrate/create_flow_state_flow_transitions.rb'
|
17
|
+
end
|
18
|
+
|
19
|
+
# Ensures migration filenames are unique
|
20
|
+
def self.next_migration_number(dirname)
|
21
|
+
if ActiveRecord::Base.timestamped_migrations
|
22
|
+
Time.now.utc.strftime('%Y%m%d%H%M%S')
|
23
|
+
else
|
24
|
+
format('%.3d', (current_migration_number(dirname) + 1))
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Tbale for flow transition changes
|
4
|
+
class CreateFlowStateFlowTransitions < ActiveRecord::Migration[8.0]
|
5
|
+
def change
|
6
|
+
create_table :flow_state_flow_transitions do |t|
|
7
|
+
t.references :flow, null: false, foreign_key: { to_table: :flow_state_flows }
|
8
|
+
t.string :transitioned_from, null: false
|
9
|
+
t.string :transitioned_to, null: false
|
10
|
+
t.timestamps
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Table for flow runs
|
4
|
+
class CreateFlowStateFlows < ActiveRecord::Migration[8.0]
|
5
|
+
def change
|
6
|
+
create_table :flow_state_flows do |t|
|
7
|
+
t.string :current_state, null: false
|
8
|
+
t.jsonb :payload
|
9
|
+
t.timestamps
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
data/sig/flow_state.rbs
ADDED
metadata
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: flow_state
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Chris Garrett
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2025-04-18 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rails
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '8.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '8.0'
|
27
|
+
description: FlowState is a minimal, database-backed state machine for Rails. It tracks
|
28
|
+
transitions across multi-step workflows. Built for real-world workflows where state
|
29
|
+
spans multiple jobs.
|
30
|
+
email:
|
31
|
+
- chris@c8va.com
|
32
|
+
executables: []
|
33
|
+
extensions: []
|
34
|
+
extra_rdoc_files: []
|
35
|
+
files:
|
36
|
+
- ".rspec"
|
37
|
+
- ".rubocop.yml"
|
38
|
+
- CHANGELOG.md
|
39
|
+
- LICENSE.txt
|
40
|
+
- README.md
|
41
|
+
- Rakefile
|
42
|
+
- lib/flow_state.rb
|
43
|
+
- lib/flow_state/base.rb
|
44
|
+
- lib/flow_state/flow_transition.rb
|
45
|
+
- lib/flow_state/railtie.rb
|
46
|
+
- lib/flow_state/version.rb
|
47
|
+
- lib/generators/flow_state/install_generator.rb
|
48
|
+
- lib/generators/flow_state/templates/create_flow_state_flow_transitions.rb
|
49
|
+
- lib/generators/flow_state/templates/create_flow_state_flows.rb
|
50
|
+
- sig/flow_state.rbs
|
51
|
+
homepage: https://www.chrsgrrtt.com/flow-state-gem
|
52
|
+
licenses:
|
53
|
+
- MIT
|
54
|
+
metadata:
|
55
|
+
homepage_uri: https://www.chrsgrrtt.com/flow-state-gem
|
56
|
+
source_code_uri: https://github.com/hyperlaunch/flow-state
|
57
|
+
changelog_uri: https://github.com/hyperlaunch/flow-state/changelog.md
|
58
|
+
rubygems_mfa_required: 'true'
|
59
|
+
post_install_message:
|
60
|
+
rdoc_options: []
|
61
|
+
require_paths:
|
62
|
+
- lib
|
63
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - ">="
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: 3.0.0
|
68
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
69
|
+
requirements:
|
70
|
+
- - ">="
|
71
|
+
- !ruby/object:Gem::Version
|
72
|
+
version: '0'
|
73
|
+
requirements: []
|
74
|
+
rubygems_version: 3.5.22
|
75
|
+
signing_key:
|
76
|
+
specification_version: 4
|
77
|
+
summary: Active Record backed State Machine for Rails.
|
78
|
+
test_files: []
|