flow_state 0.1.4 → 0.1.5
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 +4 -4
- data/README.md +113 -66
- data/lib/flow_state/base.rb +74 -24
- data/lib/flow_state/flow_transition.rb +6 -0
- data/lib/flow_state/transition_artefact.rb +13 -0
- data/lib/flow_state/version.rb +1 -1
- data/lib/generators/flow_state/install_generator.rb +2 -0
- data/lib/generators/flow_state/templates/create_flow_state_transition_artefacts.rb +13 -0
- metadata +4 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: cd321404b1377586159f944e0216958b388ea12c6c908b451b7c91fdf2c182f7
|
4
|
+
data.tar.gz: a277e49169368efd99c369a9997c05137e09a1e938e4f06d46ec91435861d18a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 6be0cf9921d7f9325b2f8b1a21a9dff8096820f1463b3a9f3dc5994f8fbf9a041aff8f14cd759401509fa160059bb8b1852e8b84d5e57466e0afabfcf79844fe
|
7
|
+
data.tar.gz: 54d6ebe4873cadf6919e210daf15ede20363e197e22be3923c376a52dcc58ab3c9fef8245f71465b87a42d4743dc4f6f45f9ab8158c26f6784c8db67070cb296
|
data/README.md
CHANGED
@@ -1,31 +1,34 @@
|
|
1
1
|
# FlowState
|
2
2
|
|
3
|
-
> **Model workflows
|
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
|
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
|
-
|
11
|
-
Every transition is
|
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
|
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
|
-
- **
|
28
|
-
- **
|
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:
|
52
|
+
## Example: Saving a third party API response to local database
|
50
53
|
|
51
54
|
Suppose you want to build a workflow that:
|
52
|
-
-
|
53
|
-
-
|
54
|
-
-
|
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
|
62
|
-
prop :
|
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 :
|
67
|
-
state :
|
68
|
-
state :
|
69
|
-
state :
|
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
|
-
|
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
|
87
|
+
from: %i[pending],
|
80
88
|
to: :picked,
|
81
|
-
after_transition: -> {
|
89
|
+
after_transition: -> { enqueue_fetch }
|
82
90
|
)
|
83
91
|
end
|
84
92
|
|
85
|
-
def
|
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:
|
92
|
-
|
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
|
97
|
-
transition!(
|
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
|
101
|
-
transition!(
|
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
|
124
|
+
def finish_record_save!
|
105
125
|
transition!(
|
106
|
-
from: :
|
126
|
+
from: :saving_my_record,
|
127
|
+
to: :saved_my_record,
|
107
128
|
after_transition: -> { complete! }
|
108
129
|
)
|
109
130
|
end
|
110
131
|
|
111
|
-
def
|
112
|
-
transition!(
|
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: :
|
140
|
+
transition!(from: :saved_my_record, to: :completed, after_transition: -> { destroy })
|
117
141
|
end
|
118
142
|
|
119
143
|
private
|
120
144
|
|
121
|
-
def
|
122
|
-
|
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
|
130
|
-
|
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
|
-
**
|
163
|
+
**Create and start the flow**
|
144
164
|
|
145
165
|
```ruby
|
146
|
-
|
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.
|
187
|
+
flow.start_third_party_api_request!
|
151
188
|
|
152
|
-
|
189
|
+
response = ThirdPartyApiRequest.new(id: flow.third_party_id).to_h
|
153
190
|
|
154
|
-
flow.
|
191
|
+
flow.finish_third_party_api_request!(response)
|
155
192
|
rescue
|
156
|
-
flow.
|
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 ||=
|
200
|
+
@flow ||= SyncThirdPartyApiFlow.find(@flow_id)
|
164
201
|
end
|
165
202
|
end
|
166
203
|
```
|
167
204
|
|
168
205
|
---
|
169
206
|
|
170
|
-
**
|
207
|
+
**Save Result to Local Database**
|
171
208
|
|
172
209
|
```ruby
|
173
|
-
class
|
210
|
+
class SaveLocalRecordJob < ApplicationJob
|
174
211
|
def perform(flow_id:)
|
175
212
|
@flow_id = flow_id
|
176
213
|
|
177
|
-
flow.
|
214
|
+
flow.start_record_save!
|
178
215
|
|
179
|
-
|
216
|
+
record.update!(payload: third_party_payload)
|
180
217
|
|
181
|
-
flow.
|
218
|
+
flow.finish_record_save!
|
182
219
|
rescue
|
183
|
-
flow.
|
220
|
+
flow.fail_record_save!
|
184
221
|
raise
|
185
222
|
end
|
186
223
|
|
187
224
|
private
|
188
225
|
|
189
226
|
def flow
|
190
|
-
@flow ||=
|
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
|
```
|
data/lib/flow_state/base.rb
CHANGED
@@ -1,14 +1,17 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module FlowState
|
4
|
-
# Base Model to be extended by app
|
5
|
-
class Base < ActiveRecord::Base
|
6
|
-
class UnknownStateError
|
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
|
-
|
20
|
-
def state(name)
|
21
|
-
all_states << name.to_sym
|
22
|
-
end
|
22
|
+
has_many :flow_artefacts, through: :flow_transitions
|
23
23
|
|
24
|
-
|
25
|
-
|
26
|
-
|
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,6 +52,10 @@ 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
|
@@ -53,32 +63,72 @@ module FlowState
|
|
53
63
|
|
54
64
|
after_initialize { self.current_state ||= resolve_initial_state }
|
55
65
|
|
56
|
-
def transition!(from:, to:,
|
57
|
-
from
|
58
|
-
to
|
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
|
-
|
72
|
+
def errored?
|
73
|
+
self.class.error_states.include?(current_state&.to_sym)
|
74
|
+
end
|
61
75
|
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
76
|
+
private
|
77
|
+
|
78
|
+
def setup_transition!(from, to, guard, persists, &block)
|
79
|
+
@from_states = Array(from).map(&:to_sym)
|
80
|
+
@to_state = to.to_sym
|
66
81
|
|
82
|
+
ensure_known_states!(@from_states + [@to_state])
|
83
|
+
run_guard!(guard) if guard
|
84
|
+
@artefact_name, @artefact_data = load_artefact(persists, &block) if persists
|
85
|
+
end
|
86
|
+
|
87
|
+
def perform_transition!(to, persists) # rubocop:disable Metrics/MethodLength
|
88
|
+
with_lock do
|
89
|
+
ensure_valid_from_state!(@from_states, to)
|
67
90
|
transaction do
|
68
|
-
flow_transitions.create!(
|
91
|
+
@tr = flow_transitions.create!(
|
69
92
|
transitioned_from: current_state,
|
70
93
|
transitioned_to: to
|
71
94
|
)
|
72
95
|
update!(current_state: to)
|
96
|
+
persist_artefact! if persists
|
73
97
|
end
|
74
98
|
end
|
99
|
+
end
|
75
100
|
|
76
|
-
|
101
|
+
def run_guard!(guard)
|
102
|
+
raise GuardFailedError, "guard failed for #{@to_state}" unless instance_exec(&guard)
|
77
103
|
end
|
78
104
|
|
79
|
-
def
|
105
|
+
def load_artefact(persists)
|
106
|
+
name = persists.to_sym
|
107
|
+
schema = self.class.artefact_schema
|
108
|
+
raise UnknownArtefactError, "#{name} not declared" unless schema.key?(name)
|
80
109
|
|
81
|
-
|
110
|
+
data = yield
|
111
|
+
[name, data]
|
112
|
+
end
|
113
|
+
|
114
|
+
def ensure_valid_from_state!(from_states, to)
|
115
|
+
return if from_states.include?(current_state&.to_sym)
|
116
|
+
|
117
|
+
raise InvalidTransitionError,
|
118
|
+
"state #{current_state} not in #{from_states.inspect} -> #{to.inspect}"
|
119
|
+
end
|
120
|
+
|
121
|
+
def persist_artefact!
|
122
|
+
expected = self.class.artefact_schema[@artefact_name]
|
123
|
+
unless @artefact_data.is_a?(expected)
|
124
|
+
raise PayloadValidationError, "artefact #{@artefact_name} must be #{expected}"
|
125
|
+
end
|
126
|
+
|
127
|
+
@tr.flow_artefacts.create!(
|
128
|
+
name: @artefact_name.to_s,
|
129
|
+
payload: @artefact_data
|
130
|
+
)
|
131
|
+
end
|
82
132
|
|
83
133
|
def resolve_initial_state
|
84
134
|
init = self.class.initial_state || self.class.all_states.first
|
@@ -97,7 +147,7 @@ module FlowState
|
|
97
147
|
|
98
148
|
schema.each do |key, klass|
|
99
149
|
v = payload&.dig(key.to_s)
|
100
|
-
raise PayloadValidationError, "#{key} missing"
|
150
|
+
raise PayloadValidationError, "#{key} missing" unless v
|
101
151
|
raise PayloadValidationError, "#{key} must be #{klass}" unless v.is_a?(klass)
|
102
152
|
end
|
103
153
|
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
|
data/lib/flow_state/version.rb
CHANGED
@@ -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
|
+
version: 0.1.5
|
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-
|
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:
|