flow_state 0.1.4 → 0.1.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 11233810ce45b5add65fcb35de1f0901466389b450cf27d59f6d0485d5db08df
4
- data.tar.gz: '083a5548ec54d0d065ecd8012a136687c0cb9c58934147df92cc84f989b3a7b9'
3
+ metadata.gz: 957aaac94425fb52e1566d1e89c8084c01f021b5fefff56a9899ee69d06ff8fc
4
+ data.tar.gz: 8b5949635034e88ed15fe31ae1c3c4e6cd6343785a823c9251e2c88911d7520a
5
5
  SHA512:
6
- metadata.gz: 360d49e72a7d73b0a216d55300a7c0b312f796a6cb85bdaf3fedfdd6b8eab6c0736a7ad8ae72e98f95fc9043fcb9e5865315f718b23671a7cc7108a7d542414b
7
- data.tar.gz: 7bd3457d03d875056a8a701b917caddf975432100fb77b4bf0b434679a33adf649e4bcaa409aaa540a441693d70b3e26515da4d92251d0608bbdee78fe01161f
6
+ metadata.gz: 8798ed03aa73c9120d27641692a0165d7a82c4e8d9b4531b74c1483ab87fb5e896fa7e323611cce81f8d120030bc5c94df79887337befdb7208dcde8190e4d09
7
+ data.tar.gz: 499e2d7f6da573ce36e95e491275d42e2846affe7d8def6c02c9db5013417fc495b639eb223bd96a789f9e92b6af4a152a0d8281038b34719fca1490e5663518
data/README.md CHANGED
@@ -1,31 +1,34 @@
1
1
  # FlowState
2
2
 
3
- > **Model workflows without magic.**
3
+ > **Model workflows cleanly, explicitly, and with durable persistence between steps.**
4
4
 
5
5
  ---
6
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.
7
+ **FlowState** provides a clean, Rails-native way to model **stepped workflows** as explicit, durable workflows, with support for persisting arbitrary artefacts between transitions. It lets you define each step, move between states safely, track execution history, and persist payloads ("artefacts") in a type-safe way — without using metaprogramming, `method_missing`, or other hidden magic.
9
8
 
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.
9
+ Perfect for workflows that rely on third party resources and integrations.
10
+ Every workflow instance, transition and artefact is persisted to the database.
11
+ Every change happens through clear, intention-revealing methods that you define yourself.
13
12
 
14
13
  Built for real-world systems where you need to:
15
14
  - Track complex, multi-step processes
16
- - Handle failures gracefully
17
- - Persist state safely across asynchronous jobs
15
+ - Handle failures gracefully with error states and retries
16
+ - Persist state and interim data across asynchronous jobs
17
+ - Store and type-check arbitrary payloads (artefacts) between steps
18
+ - Avoid race conditions via database locks and explicit guards
18
19
 
19
20
  ---
20
21
 
21
22
  ## Key Features
22
23
 
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.
24
+ - **Explicit transitions** — Every state change is triggered manually via a method you define.
25
+ - **Full execution history** — Every transition is recorded with timestamps and a history table.
26
+ - **Error recovery** — Model and track failures directly with error states.
27
+ - **Typed payloads** — Strongly-typed metadata attached to every workflow.
28
+ - **Artefact persistence** — Declare named and typed artefacts to persist between specific transitions.
29
+ - **Guard clauses** — Protect transitions with guards that raise if conditions aren’t met.
30
+ - **Persistence-first** — Workflow state and payloads are stored in your database, not memory.
31
+ - **No Magic** — No metaprogramming, no dynamic method generation, no `method_missing` tricks.
29
32
 
30
33
  ---
31
34
 
@@ -46,88 +49,105 @@ bin/rails db:migrate
46
49
 
47
50
  ---
48
51
 
49
- ## Example: Syncing song data with Soundcharts
52
+ ## Example: Saving a third party API response to local database
50
53
 
51
54
  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
+ - Fetches a response from a third party API
56
+ - Allows for retrying the fetch on failure
57
+ - And persists the response to the workflow
58
+ - Then saves the persisted response to the database
59
+ - As two separate, encapsulated jobs
60
+ - Tracking each step, while protecting against race conditions
55
61
 
56
62
  ---
57
63
 
58
64
  ### Define your Flow
59
65
 
60
66
  ```ruby
61
- class SyncSoundchartsFlow < FlowState::Base
62
- prop :song_id, String
67
+ class SyncThirdPartyApiFlow < FlowState::Base
68
+ prop :my_record_id, String
69
+ prop :third_party_id, String
63
70
 
64
71
  state :pending
65
72
  state :picked
66
- state :syncing_song_metadata
67
- state :synced_song_metadata
68
- state :syncing_audience_data
69
- state :synced_audience_data
73
+ state :fetching_third_party_api
74
+ state :fetched_third_party_api
75
+ state :failed_to_fetch_third_party_api, error: true
76
+ state :saving_my_record
77
+ state :saved_my_record
78
+ state :failed_to_save_my_record, error: true
70
79
  state :completed
71
80
 
72
- error_state :failed_to_sync_song_metadata
73
- error_state :failed_to_sync_audience_data
81
+ persist :third_party_api_response
74
82
 
75
83
  initial_state :pending
76
84
 
77
85
  def pick!
78
86
  transition!(
79
- from: %i[pending completed failed_to_sync_song_metadata failed_to_sync_audience_data],
87
+ from: %i[pending],
80
88
  to: :picked,
81
- after_transition: -> { sync_song_metadata }
89
+ after_transition: -> { enqueue_fetch }
82
90
  )
83
91
  end
84
92
 
85
- def start_song_metadata_sync!
86
- transition!(from: :picked, to: :syncing_song_metadata)
87
- end
88
-
89
- def finish_song_metadata_sync!
93
+ def start_third_party_api_request!
90
94
  transition!(
91
- from: :syncing_song_metadata, to: :synced_song_metadata,
92
- after_transition: -> { sync_audience_data }
95
+ from: %i[picked failed_to_fetch_third_party_api],
96
+ to: :fetching_third_party_api
93
97
  )
94
98
  end
95
99
 
96
- def fail_song_metadata_sync!
97
- transition!(from: :syncing_song_metadata, to: :failed_to_sync_song_metadata)
100
+ def finish_third_party_api_request!(result)
101
+ transition!(
102
+ from: :fetching_third_party_api,
103
+ to: :fetched_third_party_api,
104
+ persists: :third_party_api_response,
105
+ after_transition: -> { enqueue_save }
106
+ ) { result }
98
107
  end
99
108
 
100
- def start_audience_data_sync!
101
- transition!(from: :synced_song_metadata, to: :syncing_audience_data)
109
+ def fail_third_party_api_request!
110
+ transition!(
111
+ from: :fetching_third_party_api,
112
+ to: :failed_to_fetch_third_party_api
113
+ )
114
+ end
115
+
116
+ def start_record_save!
117
+ transition!(
118
+ from: %i[fetched_third_party_api failed_to_save_my_record],
119
+ to: :saving_my_record,
120
+ guard: -> { flow_artefacts.where(name: 'third_party_api_response').exists? }
121
+ )
102
122
  end
103
123
 
104
- def finish_audience_data_sync!
124
+ def finish_record_save!
105
125
  transition!(
106
- from: :syncing_audience_data, to: :synced_audience_data,
126
+ from: :saving_my_record,
127
+ to: :saved_my_record,
107
128
  after_transition: -> { complete! }
108
129
  )
109
130
  end
110
131
 
111
- def fail_audience_data_sync!
112
- transition!(from: :syncing_audience_data, to: :failed_to_sync_audience_data)
132
+ def fail_record_save!
133
+ transition!(
134
+ from: :saving_my_record,
135
+ to: :failed_to_save_my_record
136
+ )
113
137
  end
114
138
 
115
139
  def complete!
116
- transition!(from: :synced_audience_data, to: :completed, after_transition: -> { destroy })
140
+ transition!(from: :saved_my_record, to: :completed, after_transition: -> { destroy })
117
141
  end
118
142
 
119
143
  private
120
144
 
121
- def song
122
- @song ||= Song.find(song_id)
123
- end
124
-
125
- def sync_song_metadata
126
- SyncSoundchartsSongJob.perform_later(flow_id: id)
145
+ def enqueue_fetch
146
+ FetchThirdPartyJob.perform_later(flow_id: id)
127
147
  end
128
148
 
129
- def sync_audience_data
130
- SyncSoundchartsAudienceJob.perform_later(flow_id: id)
149
+ def enqueue_save
150
+ SaveLocalRecordJob.perform_later(flow_id: id)
131
151
  end
132
152
  end
133
153
  ```
@@ -140,54 +160,81 @@ Each job moves the flow through the correct states, step-by-step.
140
160
 
141
161
  ---
142
162
 
143
- **Sync song metadata**
163
+ **Create and start the flow**
144
164
 
145
165
  ```ruby
146
- class SyncSoundchartsSongJob < ApplicationJob
166
+ flow = SyncThirdPartyApiFlow.create(
167
+ my_record_id: "my_local_record_id",
168
+ third_party_id: "some_service_id"
169
+ )
170
+
171
+ flow.pick!
172
+ ```
173
+
174
+ ---
175
+
176
+ **Fetch Third Party API Response**
177
+
178
+ ```ruby
179
+ class FetchThirdPartyJob < ApplicationJob
180
+ retry_on StandardError,
181
+ wait: ->(executions) { 10.seconds * (2**executions) },
182
+ attempts: 3
183
+
147
184
  def perform(flow_id:)
148
185
  @flow_id = flow_id
149
186
 
150
- flow.start_song_metadata_sync!
187
+ flow.start_third_party_api_request!
151
188
 
152
- # Fetch song metadata from Soundcharts etc
189
+ response = ThirdPartyApiRequest.new(id: flow.third_party_id).to_h
153
190
 
154
- flow.finish_song_metadata_sync!
191
+ flow.finish_third_party_api_request!(response)
155
192
  rescue
156
- flow.fail_song_metadata_sync!
193
+ flow.fail_third_party_api_request!
157
194
  raise
158
195
  end
159
196
 
160
197
  private
161
198
 
162
199
  def flow
163
- @flow ||= SyncSoundchartsFlow.find(@flow_id)
200
+ @flow ||= SyncThirdPartyApiFlow.find(@flow_id)
164
201
  end
165
202
  end
166
203
  ```
167
204
 
168
205
  ---
169
206
 
170
- **Sync audience data**
207
+ **Save Result to Local Database**
171
208
 
172
209
  ```ruby
173
- class SyncSoundchartsAudienceJob < ApplicationJob
210
+ class SaveLocalRecordJob < ApplicationJob
174
211
  def perform(flow_id:)
175
212
  @flow_id = flow_id
176
213
 
177
- flow.start_audience_data_sync!
214
+ flow.start_record_save!
178
215
 
179
- # Fetch audience data from Soundcharts etc
216
+ record.update!(payload: third_party_payload)
180
217
 
181
- flow.finish_audience_data_sync!
218
+ flow.finish_record_save!
182
219
  rescue
183
- flow.fail_audience_data_sync!
220
+ flow.fail_record_save!
184
221
  raise
185
222
  end
186
223
 
187
224
  private
188
225
 
189
226
  def flow
190
- @flow ||= SyncSoundchartsFlow.find(@flow_id)
227
+ @flow ||= SyncThirdPartyApiFlow.find(@flow_id)
228
+ end
229
+
230
+ def third_party_payload
231
+ flow.flow_artefacts
232
+ .find_by!(name: 'third_party_api_response')
233
+ .payload
234
+ end
235
+
236
+ def record
237
+ @record ||= MyRecord.find(flow.my_record_id)
191
238
  end
192
239
  end
193
240
  ```
@@ -1,14 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FlowState
4
- # Base Model to be extended by app models
5
- class Base < ActiveRecord::Base
6
- class UnknownStateError < StandardError; end
4
+ # Base Model to be extended by app flows
5
+ class Base < ActiveRecord::Base # rubocop:disable Metrics/ClassLength
6
+ class UnknownStateError < StandardError; end
7
7
  class InvalidTransitionError < StandardError; end
8
8
  class PayloadValidationError < StandardError; end
9
+ class GuardFailedError < StandardError; end
10
+ class UnknownArtefactError < StandardError; end
11
+
12
+ DEPRECATOR = ActiveSupport::Deprecation.new(FlowState::VERSION, 'FlowState')
9
13
 
10
14
  self.table_name = 'flow_state_flows'
11
- # self.abstract_class = true - this stops Rails respecting STI
12
15
 
13
16
  has_many :flow_transitions,
14
17
  class_name: 'FlowState::FlowTransition',
@@ -16,14 +19,13 @@ module FlowState
16
19
  inverse_of: :flow,
17
20
  dependent: :destroy
18
21
 
19
- class << self
20
- def state(name)
21
- all_states << name.to_sym
22
- end
22
+ has_many :flow_artefacts, through: :flow_transitions
23
23
 
24
- def error_state(name)
25
- error_states << name.to_sym
26
- state(name)
24
+ class << self
25
+ def state(name, error: false)
26
+ name = name.to_sym
27
+ all_states << name
28
+ error_states << name if error
27
29
  end
28
30
 
29
31
  def initial_state(name = nil)
@@ -35,6 +37,10 @@ module FlowState
35
37
  define_method(name) { payload&.dig(name.to_s) }
36
38
  end
37
39
 
40
+ def persist(name, type)
41
+ artefact_schema[name.to_sym] = type
42
+ end
43
+
38
44
  def all_states
39
45
  @all_states ||= []
40
46
  end
@@ -46,39 +52,88 @@ module FlowState
46
52
  def payload_schema
47
53
  @payload_schema ||= {}
48
54
  end
55
+
56
+ def artefact_schema
57
+ @artefact_schema ||= {}
58
+ end
49
59
  end
50
60
 
51
61
  validates :current_state, presence: true
52
62
  validate :validate_payload
53
63
 
54
- after_initialize { self.current_state ||= resolve_initial_state }
64
+ after_initialize :assign_initial_state, if: :new_record?
55
65
 
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
66
+ def transition!(from:, to:, guard: nil, persists: nil, after_transition: nil, &block)
67
+ setup_transition!(from, to, guard, persists, &block)
68
+ perform_transition!(to, persists)
69
+ after_transition&.call
70
+ end
59
71
 
60
- ensure_known_states!(from + [to])
72
+ def errored?
73
+ self.class.error_states.include?(current_state&.to_sym)
74
+ end
61
75
 
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
76
+ private
77
+
78
+ def assign_initial_state
79
+ self.current_state ||= resolve_initial_state
80
+ end
66
81
 
67
- transaction do
68
- flow_transitions.create!(
82
+ def setup_transition!(from, to, guard, persists, &block)
83
+ @from_states = Array(from).map(&:to_sym)
84
+ @to_state = to.to_sym
85
+
86
+ ensure_known_states!(@from_states + [@to_state])
87
+ run_guard!(guard) if guard
88
+ @artefact_name, @artefact_data = load_artefact(persists, &block) if persists
89
+ end
90
+
91
+ def perform_transition!(to, persists) # rubocop:disable Metrics/MethodLength
92
+ transaction do
93
+ save! if changed?
94
+ with_lock do
95
+ ensure_valid_from_state!(@from_states, to)
96
+ @tr = flow_transitions.create!(
69
97
  transitioned_from: current_state,
70
98
  transitioned_to: to
71
99
  )
72
100
  update!(current_state: to)
101
+ persist_artefact! if persists
73
102
  end
74
103
  end
104
+ end
75
105
 
76
- after_transition&.call
106
+ def run_guard!(guard)
107
+ raise GuardFailedError, "guard failed for #{@to_state}" unless instance_exec(&guard)
77
108
  end
78
109
 
79
- def errored? = self.class.error_states.include?(current_state&.to_sym)
110
+ def load_artefact(persists)
111
+ name = persists.to_sym
112
+ schema = self.class.artefact_schema
113
+ raise UnknownArtefactError, "#{name} not declared" unless schema.key?(name)
80
114
 
81
- private
115
+ data = yield
116
+ [name, data]
117
+ end
118
+
119
+ def ensure_valid_from_state!(from_states, to)
120
+ return if from_states.include?(current_state&.to_sym)
121
+
122
+ raise InvalidTransitionError,
123
+ "state #{current_state} not in #{from_states.inspect} -> #{to.inspect}"
124
+ end
125
+
126
+ def persist_artefact!
127
+ expected = self.class.artefact_schema[@artefact_name]
128
+ unless @artefact_data.is_a?(expected)
129
+ raise PayloadValidationError, "artefact #{@artefact_name} must be #{expected}"
130
+ end
131
+
132
+ @tr.flow_artefacts.create!(
133
+ name: @artefact_name.to_s,
134
+ payload: @artefact_data
135
+ )
136
+ end
82
137
 
83
138
  def resolve_initial_state
84
139
  init = self.class.initial_state || self.class.all_states.first
@@ -97,7 +152,7 @@ module FlowState
97
152
 
98
153
  schema.each do |key, klass|
99
154
  v = payload&.dig(key.to_s)
100
- raise PayloadValidationError, "#{key} missing" if v.nil?
155
+ raise PayloadValidationError, "#{key} missing" unless v
101
156
  raise PayloadValidationError, "#{key} must be #{klass}" unless v.is_a?(klass)
102
157
  end
103
158
  rescue PayloadValidationError => e
@@ -9,5 +9,11 @@ module FlowState
9
9
  class_name: 'FlowState::Base',
10
10
  foreign_key: :flow_id,
11
11
  inverse_of: :flow_transitions
12
+
13
+ has_many :flow_artefacts,
14
+ class_name: 'FlowState::TransitionArtefact',
15
+ foreign_key: :transition_id,
16
+ inverse_of: :transition,
17
+ dependent: :destroy
12
18
  end
13
19
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowState
4
+ # Model for logging transition changes to
5
+ class TransitionArtefact < ActiveRecord::Base
6
+ self.table_name = 'flow_state_transition_artefacts'
7
+
8
+ belongs_to :transition,
9
+ class_name: 'FlowState::FlowTransition',
10
+ foreign_key: :transition_id,
11
+ inverse_of: :flow_artefacts
12
+ end
13
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FlowState
4
- VERSION = '0.1.4'
4
+ VERSION = '0.1.6'
5
5
  end
@@ -16,6 +16,8 @@ module FlowState
16
16
  def create_migrations
17
17
  migration_template 'create_flow_state_flows.rb', 'db/migrate/create_flow_state_flows.rb'
18
18
  migration_template 'create_flow_state_flow_transitions.rb', 'db/migrate/create_flow_state_flow_transitions.rb'
19
+ migration_template 'create_flow_state_transition_artefacts.rb',
20
+ 'db/migrate/create_flow_state_transition_artefacts.rb'
19
21
  end
20
22
 
21
23
  def self.next_migration_number(_dirname)
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Tbale for flow transition changes
4
+ class CreateFlowStateTransitionArtefacts < ActiveRecord::Migration[8.0]
5
+ def change
6
+ create_table :flow_state_transition_artefacts do |t|
7
+ t.references :transition, null: false, foreign_key: { to_table: :flow_state_flow_transitions }
8
+ t.string :name, null: false
9
+ t.json :payload
10
+ t.timestamps
11
+ end
12
+ end
13
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flow_state
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Garrett
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-04-18 00:00:00.000000000 Z
11
+ date: 2025-04-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -43,10 +43,12 @@ files:
43
43
  - lib/flow_state/base.rb
44
44
  - lib/flow_state/flow_transition.rb
45
45
  - lib/flow_state/railtie.rb
46
+ - lib/flow_state/transition_artefact.rb
46
47
  - lib/flow_state/version.rb
47
48
  - lib/generators/flow_state/install_generator.rb
48
49
  - lib/generators/flow_state/templates/create_flow_state_flow_transitions.rb
49
50
  - lib/generators/flow_state/templates/create_flow_state_flows.rb
51
+ - lib/generators/flow_state/templates/create_flow_state_transition_artefacts.rb
50
52
  - sig/flow_state.rbs
51
53
  homepage: https://www.chrsgrrtt.com/flow-state-gem
52
54
  licenses: