From c822be554bc6e2ea50592ccd5b93d99b61223e46 Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Fri, 9 May 2025 14:26:35 +0100 Subject: [PATCH 01/10] Add missing type field to generate migrations --- lib/generators/flow_state/templates/create_flow_state_flows.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/generators/flow_state/templates/create_flow_state_flows.rb b/lib/generators/flow_state/templates/create_flow_state_flows.rb index 9fe15a2..9726b95 100644 --- a/lib/generators/flow_state/templates/create_flow_state_flows.rb +++ b/lib/generators/flow_state/templates/create_flow_state_flows.rb @@ -4,6 +4,7 @@ class CreateFlowStateFlows < ActiveRecord::Migration[8.0] def change create_table :flow_state_flows do |t| + t.string :type, null: false t.string :current_state, null: false t.json :payload t.timestamps From a4279b19773750165b90ae208bb96247bb633ab9 Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Fri, 9 May 2025 14:33:39 +0100 Subject: [PATCH 02/10] Rename payload as props --- lib/flow_state/base.rb | 34 ++++++++++--------- .../templates/create_flow_state_flows.rb | 2 +- spec/flow_state_spec.rb | 2 +- spec/spec_helper.rb | 2 +- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/lib/flow_state/base.rb b/lib/flow_state/base.rb index b329c59..a80cdf1 100644 --- a/lib/flow_state/base.rb +++ b/lib/flow_state/base.rb @@ -3,11 +3,12 @@ module FlowState # Base Model to be extended by app flows class Base < ActiveRecord::Base # rubocop:disable Metrics/ClassLength - class UnknownStateError < StandardError; end + class UnknownStateError < StandardError; end class InvalidTransitionError < StandardError; end class PayloadValidationError < StandardError; end - class GuardFailedError < StandardError; end - class UnknownArtefactError < StandardError; end + class PropsValidationError < StandardError; end + class GuardFailedError < StandardError; end + class UnknownArtefactError < StandardError; end DEPRECATOR = ActiveSupport::Deprecation.new(FlowState::VERSION, 'FlowState') @@ -33,8 +34,8 @@ def initial_state(name = nil) end def prop(name, type) - payload_schema[name.to_sym] = type - define_method(name) { payload&.dig(name.to_s) } + props_schema[name.to_sym] = type + define_method(name) { props&.dig(name.to_s) } end def persist(name, type) @@ -49,8 +50,8 @@ def error_states @error_states ||= [] end - def payload_schema - @payload_schema ||= {} + def props_schema + @props_schema ||= {} end def artefact_schema @@ -59,7 +60,7 @@ def artefact_schema end validates :current_state, presence: true - validate :validate_payload + validate :validate_props after_initialize :assign_initial_state, if: :new_record? @@ -126,7 +127,8 @@ def ensure_valid_from_state!(from_states, to) def persist_artefact! expected = self.class.artefact_schema[@artefact_name] unless @artefact_data.is_a?(expected) - raise PayloadValidationError, "artefact #{@artefact_name} must be #{expected}" + raise PayloadValidationError, + "artefact #{@artefact_name} must be #{expected}" end @tr.flow_artefacts.create!( @@ -146,17 +148,17 @@ def ensure_known_states!(states) raise UnknownStateError, "unknown #{unknown.join(', ')}" if unknown.any? end - def validate_payload - schema = self.class.payload_schema + def validate_props + schema = self.class.props_schema return if schema.empty? schema.each do |key, klass| - v = payload&.dig(key.to_s) - raise PayloadValidationError, "#{key} missing" unless v - raise PayloadValidationError, "#{key} must be #{klass}" unless v.is_a?(klass) + v = props&.dig(key.to_s) + raise PropsValidationError, "#{key} missing" unless v + raise PropsValidationError, "#{key} must be #{klass}" unless v.is_a?(klass) end - rescue PayloadValidationError => e - errors.add(:payload, e.message) + rescue PropsValidationError => e + errors.add(:props, e.message) end end end diff --git a/lib/generators/flow_state/templates/create_flow_state_flows.rb b/lib/generators/flow_state/templates/create_flow_state_flows.rb index 9726b95..5ce07e6 100644 --- a/lib/generators/flow_state/templates/create_flow_state_flows.rb +++ b/lib/generators/flow_state/templates/create_flow_state_flows.rb @@ -6,7 +6,7 @@ def change create_table :flow_state_flows do |t| t.string :type, null: false t.string :current_state, null: false - t.json :payload + t.json :props t.timestamps end end diff --git a/spec/flow_state_spec.rb b/spec/flow_state_spec.rb index b904428..5a5ca6b 100644 --- a/spec/flow_state_spec.rb +++ b/spec/flow_state_spec.rb @@ -16,7 +16,7 @@ end) end - let!(:flow) { Flow.create!(payload: { name: 'Example' }) } + let!(:flow) { Flow.create!(props: { name: 'Example' }) } describe 'artefact persistence' do # rubocop:disable Metrics/BlockLength it 'raises if you pass an unknown persists name' do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index bb96ca0..71541fe 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -16,7 +16,7 @@ ActiveRecord::Schema.define do create_table :flow_state_flows, force: true do |t| t.string :current_state, null: false - t.json :payload + t.json :props t.timestamps end From 3948d32c40ec1e561e8151062d201dcc5d46907e Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Fri, 9 May 2025 14:38:33 +0100 Subject: [PATCH 03/10] Get persists naming right --- lib/flow_state/base.rb | 8 ++++---- spec/flow_state_spec.rb | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/flow_state/base.rb b/lib/flow_state/base.rb index a80cdf1..5618228 100644 --- a/lib/flow_state/base.rb +++ b/lib/flow_state/base.rb @@ -38,7 +38,7 @@ def prop(name, type) define_method(name) { props&.dig(name.to_s) } end - def persist(name, type) + def persists(name, type) artefact_schema[name.to_sym] = type end @@ -64,9 +64,9 @@ def artefact_schema after_initialize :assign_initial_state, if: :new_record? - def transition!(from:, to:, guard: nil, persists: nil, after_transition: nil, &block) - setup_transition!(from, to, guard, persists, &block) - perform_transition!(to, persists) + def transition!(from:, to:, guard: nil, persist: nil, after_transition: nil, &block) + setup_transition!(from, to, guard, persist, &block) + perform_transition!(to, persist) after_transition&.call end diff --git a/spec/flow_state_spec.rb b/spec/flow_state_spec.rb index 5a5ca6b..f69ca5e 100644 --- a/spec/flow_state_spec.rb +++ b/spec/flow_state_spec.rb @@ -12,7 +12,7 @@ prop :name, String - persist :third_party_api_response, Hash + persists :third_party_api_response, Hash end) end @@ -21,13 +21,13 @@ describe 'artefact persistence' do # rubocop:disable Metrics/BlockLength it 'raises if you pass an unknown persists name' do expect do - flow.transition!(from: :draft, to: :review, persists: :nope) { {} } + flow.transition!(from: :draft, to: :review, persist: :nope) { {} } end.to raise_error(FlowState::Base::UnknownArtefactError) end it 'raises if the block returns wrong type' do expect do - flow.transition!(from: :draft, to: :review, persists: :third_party_api_response) { 'not a hash' } + flow.transition!(from: :draft, to: :review, persist: :third_party_api_response) { 'not a hash' } end.to raise_error(FlowState::Base::PayloadValidationError, /must be Hash/) end @@ -36,7 +36,7 @@ flow.transition!( from: :draft, to: :review, - persists: :third_party_api_response, + persist: :third_party_api_response, after_transition: lambda { expect(flow.flow_transitions.last .flow_artefacts From ed8f7262eda2bdd8136df13da51fc3f166b958d1 Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Fri, 9 May 2025 14:58:16 +0100 Subject: [PATCH 04/10] Add requirement for initial and completed states --- lib/flow_state/base.rb | 26 +++++++++++++++++++------- lib/flow_state/version.rb | 2 +- spec/flow_state_spec.rb | 1 + 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/lib/flow_state/base.rb b/lib/flow_state/base.rb index 5618228..64287db 100644 --- a/lib/flow_state/base.rb +++ b/lib/flow_state/base.rb @@ -9,6 +9,8 @@ class PayloadValidationError < StandardError; end class PropsValidationError < StandardError; end class GuardFailedError < StandardError; end class UnknownArtefactError < StandardError; end + class MissingInitialStateError < StandardError; end + class MissingCompletedStateError < StandardError; end DEPRECATOR = ActiveSupport::Deprecation.new(FlowState::VERSION, 'FlowState') @@ -33,6 +35,10 @@ def initial_state(name = nil) name ? @initial_state = name.to_sym : @initial_state end + def completed_state(name = nil) + name ? @completed_state = name.to_sym : @completed_state + end + def prop(name, type) props_schema[name.to_sym] = type define_method(name) { props&.dig(name.to_s) } @@ -62,6 +68,7 @@ def artefact_schema validates :current_state, presence: true validate :validate_props + after_initialize :validate_initial_states!, if: :new_record? after_initialize :assign_initial_state, if: :new_record? def transition!(from:, to:, guard: nil, persist: nil, after_transition: nil, &block) @@ -76,8 +83,19 @@ def errored? private + def validate_initial_states! + init_state = self.class.initial_state + comp_state = self.class.completed_state + + raise MissingInitialStateError, "#{self.class} must declare initial_state" unless init_state + raise MissingCompletedStateError, "#{self.class} must declare completed_state" unless comp_state + + unknown = [init_state, comp_state] - self.class.all_states + raise UnknownStateError, "unknown #{unknown.join(', ')}" if unknown.any? + end + def assign_initial_state - self.current_state ||= resolve_initial_state + self.current_state ||= self.class.initial_state end def setup_transition!(from, to, guard, persists, &block) @@ -137,12 +155,6 @@ def persist_artefact! ) end - def resolve_initial_state - init = self.class.initial_state || self.class.all_states.first - ensure_known_states!([init]) if init - init - end - def ensure_known_states!(states) unknown = states - self.class.all_states raise UnknownStateError, "unknown #{unknown.join(', ')}" if unknown.any? diff --git a/lib/flow_state/version.rb b/lib/flow_state/version.rb index cb7cfbe..dfc1aa2 100644 --- a/lib/flow_state/version.rb +++ b/lib/flow_state/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module FlowState - VERSION = '0.1.6' + VERSION = '0.2.0' end diff --git a/spec/flow_state_spec.rb b/spec/flow_state_spec.rb index f69ca5e..8f5ca50 100644 --- a/spec/flow_state_spec.rb +++ b/spec/flow_state_spec.rb @@ -9,6 +9,7 @@ state :review state :failed, error: true initial_state :draft + completed_state :draft prop :name, String From 617a9b1acc8dd242e6d6cefb6e6b3154db18929b Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Fri, 9 May 2025 15:10:26 +0100 Subject: [PATCH 05/10] Add destroy on complete option --- lib/flow_state/base.rb | 19 +++++++++++ spec/flow_state_spec.rb | 74 ++++++++++++++++++++++++++++------------- 2 files changed, 70 insertions(+), 23 deletions(-) diff --git a/lib/flow_state/base.rb b/lib/flow_state/base.rb index 64287db..f9696fc 100644 --- a/lib/flow_state/base.rb +++ b/lib/flow_state/base.rb @@ -39,6 +39,14 @@ def completed_state(name = nil) name ? @completed_state = name.to_sym : @completed_state end + def destroy_on_complete(flag) + @destroy_on_complete = flag || false + end + + def destroy_on_complete? + !!@destroy_on_complete + end + def prop(name, type) props_schema[name.to_sym] = type define_method(name) { props&.dig(name.to_s) } @@ -67,6 +75,7 @@ def artefact_schema validates :current_state, presence: true validate :validate_props + after_commit :destroy_if_complete, on: :update after_initialize :validate_initial_states!, if: :new_record? after_initialize :assign_initial_state, if: :new_record? @@ -81,6 +90,16 @@ def errored? self.class.error_states.include?(current_state&.to_sym) end + def completed? + self.class.completed_state && current_state&.to_sym == self.class.completed_state + end + + def destroy_if_complete + return unless self.class.destroy_on_complete? + + destroy! if completed? + end + private def validate_initial_states! diff --git a/spec/flow_state_spec.rb b/spec/flow_state_spec.rb index 8f5ca50..bd07aed 100644 --- a/spec/flow_state_spec.rb +++ b/spec/flow_state_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - RSpec.describe FlowState::Base do # rubocop:disable Metrics/BlockLength before do stub_const('Flow', Class.new(FlowState::Base) do @@ -12,18 +10,16 @@ completed_state :draft prop :name, String - persists :third_party_api_response, Hash end) end let!(:flow) { Flow.create!(props: { name: 'Example' }) } - describe 'artefact persistence' do # rubocop:disable Metrics/BlockLength + describe 'artefact persistence' do it 'raises if you pass an unknown persists name' do - expect do - flow.transition!(from: :draft, to: :review, persist: :nope) { {} } - end.to raise_error(FlowState::Base::UnknownArtefactError) + expect { flow.transition!(from: :draft, to: :review, persist: :nope) { {} } } + .to raise_error(FlowState::Base::UnknownArtefactError) end it 'raises if the block returns wrong type' do @@ -38,12 +34,7 @@ from: :draft, to: :review, persist: :third_party_api_response, - after_transition: lambda { - expect(flow.flow_transitions.last - .flow_artefacts - .find_by(name: 'third_party_api_response')).to be_present - flag = true - } + after_transition: -> { flag = true } ) { { foo: 'bar' } } expect(flag).to be true @@ -56,21 +47,58 @@ describe 'guards' do it 'raises if guard block returns false' do expect do - flow.transition!( - from: :draft, - to: :review, - guard: -> { false } - ) + flow.transition!(from: :draft, to: :review, guard: -> { false }) end.to raise_error(FlowState::Base::GuardFailedError) end it 'allows transition when guard is true' do - flow.transition!( - from: :draft, - to: :review, - guard: -> { true } - ) + flow.transition!(from: :draft, to: :review, guard: -> { true }) expect(flow.current_state).to eq('review') end end + + describe 'destroy_on_complete' do # rubocop:disable Metrics/BlockLength + context 'when destroy_on_complete is set' do + before do + stub_const('AutoPurgeFlow', Class.new(FlowState::Base) do + self.table_name = 'flow_state_flows' + + state :draft + state :complete + initial_state :draft + completed_state :complete + + destroy_on_complete true + end) + end + + it 'destroys the record after reaching the completed state' do + f = AutoPurgeFlow.create! + expect { f.transition!(from: :draft, to: :complete) } + .to change { AutoPurgeFlow.count }.by(-1) + expect { f.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'when destroy_on_complete is not set' do + before do + stub_const('KeepFlow', Class.new(FlowState::Base) do + self.table_name = 'flow_state_flows' + + state :draft + state :complete + initial_state :draft + completed_state :complete + end) + end + + it 'retains the record after reaching the completed state' do + f = KeepFlow.create! + expect { f.transition!(from: :draft, to: :complete) } + .not_to(change { KeepFlow.count }) + expect(f.reload.current_state).to eq('complete') + expect(f.reload.completed?).to be true + end + end + end end From 764671f9ab83568af9a41924867b981d617c660c Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Fri, 9 May 2025 15:12:21 +0100 Subject: [PATCH 06/10] Tweaks --- lib/flow_state/base.rb | 4 ++-- spec/flow_state_spec.rb | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/flow_state/base.rb b/lib/flow_state/base.rb index f9696fc..b890a0d 100644 --- a/lib/flow_state/base.rb +++ b/lib/flow_state/base.rb @@ -39,8 +39,8 @@ def completed_state(name = nil) name ? @completed_state = name.to_sym : @completed_state end - def destroy_on_complete(flag) - @destroy_on_complete = flag || false + def destroy_on_complete(flag: true) + @destroy_on_complete = flag end def destroy_on_complete? diff --git a/spec/flow_state_spec.rb b/spec/flow_state_spec.rb index bd07aed..e3a03f1 100644 --- a/spec/flow_state_spec.rb +++ b/spec/flow_state_spec.rb @@ -1,4 +1,4 @@ -RSpec.describe FlowState::Base do # rubocop:disable Metrics/BlockLength +RSpec.describe FlowState::Base do before do stub_const('Flow', Class.new(FlowState::Base) do self.table_name = 'flow_state_flows' @@ -67,8 +67,7 @@ state :complete initial_state :draft completed_state :complete - - destroy_on_complete true + destroy_on_complete end) end From 45348f90b96578b7469a8184fcffcc597d3478a0 Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Fri, 9 May 2025 15:16:54 +0100 Subject: [PATCH 07/10] Add completed_at for convenience --- lib/flow_state/base.rb | 12 ++++++++---- .../flow_state/templates/create_flow_state_flows.rb | 2 ++ spec/flow_state_spec.rb | 6 ++++-- spec/spec_helper.rb | 2 ++ 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/lib/flow_state/base.rb b/lib/flow_state/base.rb index b890a0d..1c3a519 100644 --- a/lib/flow_state/base.rb +++ b/lib/flow_state/base.rb @@ -75,7 +75,7 @@ def artefact_schema validates :current_state, presence: true validate :validate_props - after_commit :destroy_if_complete, on: :update + after_commit :handle_completion, on: :update after_initialize :validate_initial_states!, if: :new_record? after_initialize :assign_initial_state, if: :new_record? @@ -94,10 +94,14 @@ def completed? self.class.completed_state && current_state&.to_sym == self.class.completed_state end - def destroy_if_complete - return unless self.class.destroy_on_complete? + def handle_completion + return unless completed? - destroy! if completed? + if self.class.destroy_on_complete? + destroy! + elsif completed_at.nil? + update_column(:completed_at, Time.current) + end end private diff --git a/lib/generators/flow_state/templates/create_flow_state_flows.rb b/lib/generators/flow_state/templates/create_flow_state_flows.rb index 5ce07e6..03f3d77 100644 --- a/lib/generators/flow_state/templates/create_flow_state_flows.rb +++ b/lib/generators/flow_state/templates/create_flow_state_flows.rb @@ -6,6 +6,8 @@ def change create_table :flow_state_flows do |t| t.string :type, null: false t.string :current_state, null: false + t.datetime :completed_at + t.datetime :last_errored_at t.json :props t.timestamps end diff --git a/spec/flow_state_spec.rb b/spec/flow_state_spec.rb index e3a03f1..42bd478 100644 --- a/spec/flow_state_spec.rb +++ b/spec/flow_state_spec.rb @@ -95,8 +95,10 @@ f = KeepFlow.create! expect { f.transition!(from: :draft, to: :complete) } .not_to(change { KeepFlow.count }) - expect(f.reload.current_state).to eq('complete') - expect(f.reload.completed?).to be true + f.reload + expect(f.current_state).to eq('complete') + expect(f.completed?).to be true + expect(f.completed_at).to be_a_kind_of(Time) end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 71541fe..66281db 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -17,6 +17,8 @@ create_table :flow_state_flows, force: true do |t| t.string :current_state, null: false t.json :props + t.datetime :completed_at + t.datetime :last_errored_at t.timestamps end From 85cea2f9b326f1dd3f96ae3d4b62e4b7a13e225e Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Fri, 9 May 2025 15:40:49 +0100 Subject: [PATCH 08/10] Add upgrade guide --- lib/flow_state/base.rb | 7 ++- spec/flow_state_spec.rb | 28 +++++++++++ upgrade_guides/0.1.0-0.2.0.md | 87 +++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 upgrade_guides/0.1.0-0.2.0.md diff --git a/lib/flow_state/base.rb b/lib/flow_state/base.rb index 1c3a519..0060c8d 100644 --- a/lib/flow_state/base.rb +++ b/lib/flow_state/base.rb @@ -49,7 +49,6 @@ def destroy_on_complete? def prop(name, type) props_schema[name.to_sym] = type - define_method(name) { props&.dig(name.to_s) } end def persists(name, type) @@ -139,7 +138,11 @@ def perform_transition!(to, persists) # rubocop:disable Metrics/MethodLength transitioned_from: current_state, transitioned_to: to ) - update!(current_state: to) + + attrs = { current_state: to } + attrs[:last_errored_at] = (Time.current if self.class.error_states.include?(to.to_sym)) + update!(attrs) + persist_artefact! if persists end end diff --git a/spec/flow_state_spec.rb b/spec/flow_state_spec.rb index 42bd478..81ef5d6 100644 --- a/spec/flow_state_spec.rb +++ b/spec/flow_state_spec.rb @@ -102,4 +102,32 @@ end end end + + describe 'last_errored_at' do + before do + stub_const('ErrorFlow', Class.new(FlowState::Base) do + self.table_name = 'flow_state_flows' + state :ok + state :fail, error: true + state :ok_again + initial_state :ok + completed_state :ok + end) + end + + it 'sets the timestamp when entering an error state' do + f = ErrorFlow.create! + expect { f.transition!(from: :ok, to: :fail) } + .to change { f.reload.last_errored_at }.from(nil) + expect(f.last_errored_at).to be_a_kind_of(Time) + end + + it 'clears the timestamp when leaving the error state' do + f = ErrorFlow.create! + + f.transition!(from: :ok, to: :fail) + expect { f.transition!(from: :fail, to: :ok_again) } + .to change { f.reload.last_errored_at }.to(nil) + end + end end diff --git a/upgrade_guides/0.1.0-0.2.0.md b/upgrade_guides/0.1.0-0.2.0.md new file mode 100644 index 0000000..ec12aca --- /dev/null +++ b/upgrade_guides/0.1.0-0.2.0.md @@ -0,0 +1,87 @@ +# Flow State 0.1 → 0.2 Migration Guide + +--- + +## 1. Database migration + +```ruby +# db/migrate/xxxxxxxxxxxxxx_flow_state_02_upgrade.rb +class FlowState02Upgrade < ActiveRecord::Migration[6.1] + def change + rename_column :flow_state_flows, :payload, :props + add_column :flow_state_flows, :type, :string + add_column :flow_state_flows, :completed_at, :datetime + add_column :flow_state_flows, :last_errored_at, :datetime + end +end + +``` + +Run the migration and bump your gem version to `0.2.0`. + +--- + +## 2. Required changes + +| Area | Old (≤ 0.1) | New (0.2) | +| ------------------ | -------------------------------------- | ---------------------------------------------------------- | +| Flow setup | `initial_state` optional | `initial_state` and `completed_state` **required** | +| Completed handling | manual `after_transition { destroy! }` | `destroy_on_complete` handles cleanup automatically | +| Column rename | `payload` | `props`, used via `flow.props["key"]` only | +| Prop accessors | auto-generated methods | removed – use `props["key"]` | +| Macro rename | `persist :foo, Hash` | `persists :foo, Hash` | +| Transition keyword | `persists:` | `persist:` | +| Timestamps | — | `completed_at` and `last_errored_at` tracked automatically | + +--- + +## 3. Example refactor + +### Flow definition + +```ruby +class SignupFlow < FlowState::Base + state :draft + state :processing + state :failed, error: true + state :completed + + initial_state :draft + completed_state :completed + destroy_on_complete + + prop :user_id, Integer + persists :external_response, Hash +end +``` + +### Usage + +```ruby +flow = SignupFlow.create!(props: { "user_id" => 42 }) + +flow.transition!(from: :draft, to: :processing) + +flow.transition!( + from: :processing, + to: :completed, + persist: :external_response +) { { status: 200 } } + +# If destroy_on_complete was set: +# flow.destroyed? # => true +# Otherwise: +# flow.completed_at # => Time +# flow.last_errored_at # => nil (unless failed state was hit) +``` + +--- + +## 4. Cleanup tips + +- Remove any `after_transition { destroy! }` logic and replace with `destroy_on_complete`. +- Stop calling dynamic prop getters like `flow.name`; use `flow.props["name"]` instead. +- Rename all usages of `persist` (macro) to `persists`. +- Update any `persists:` keyword args to `persist:` in your `transition!` calls. + +You're now on **Flow State 0.2.0**. From da69debc58fba6480065f5262befd620e990db68 Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Fri, 9 May 2025 15:47:44 +0100 Subject: [PATCH 09/10] Add README --- .../0.1.0-0.2.0.md => MIGRATION_0_1_to_0_2.md | 0 README.md | 297 +++++++----------- flow_state.gemspec | 9 + 3 files changed, 129 insertions(+), 177 deletions(-) rename upgrade_guides/0.1.0-0.2.0.md => MIGRATION_0_1_to_0_2.md (100%) diff --git a/upgrade_guides/0.1.0-0.2.0.md b/MIGRATION_0_1_to_0_2.md similarity index 100% rename from upgrade_guides/0.1.0-0.2.0.md rename to MIGRATION_0_1_to_0_2.md diff --git a/README.md b/README.md index c340aff..a06e0f9 100644 --- a/README.md +++ b/README.md @@ -4,251 +4,194 @@ --- -**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. +**FlowState** is a small gem for Rails, for building *state-machine–style* workflows that persist every step, artefact and decision to your database. +Everything is explicit – no metaprogramming, no hidden callbacks, no magic helpers. -Perfect for workflows that rely on third party resources and integrations. -Every workflow instance, transition and artefact is persisted to the database. -Every change happens through clear, intention-revealing methods that you define yourself. +Use it when you need to: -Built for real-world systems where you need to: -- Track complex, multi-step processes -- Handle failures gracefully with error states and retries -- Persist state and interim data across asynchronous jobs -- Store and type-check arbitrary payloads (artefacts) between steps -- Avoid race conditions via database locks and explicit guards +* orchestrate multi-step jobs that call external services +* restart safely after crashes or retries +* inspect an audit trail of *what happened, when and why* +* attach typed artefacts (payloads) to a given transition --- -## Key Features +## What’s new in 0.2 -- **Explicit transitions** — Every state change is triggered manually via a method you define. -- **Full execution history** — Every transition is recorded with timestamps and a history table. -- **Error recovery** — Model and track failures directly with error states. -- **Typed payloads** — Strongly-typed metadata attached to every workflow. -- **Artefact persistence** — Declare named and typed artefacts to persist between specific transitions. -- **Guard clauses** — Protect transitions with guards that raise if conditions aren’t met. -- **Persistence-first** — Workflow state and payloads are stored in your database, not memory. -- **No Magic** — No metaprogramming, no dynamic method generation, no `method_missing` tricks. +| Change | Why it matters | +| ----------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| **`initial_state` & `completed_state` are mandatory** | Keeps definitions explicit and prevents silent mis-configuration. | +| **`destroy_on_complete` macro** | One-liner to delete finished flows – replaces manual `after_transition { destroy! }`. | +| **`payload` → `props` column** | Aligns storage with the `prop` DSL (`flow.props["key"]`). No more auto-generated getters. | +| **`persist` macro → `persists`** | Reads better, matches the transition keyword (`persist:`). | +| **`completed_at` & `last_errored_at` timestamps** | Easier querying: `where(completed_at: ..)` or `where.not(last_errored_at: nil)`. | ---- - -## Installation - -Add to your bundle: - -```bash -bundle add flow_state -``` - -Generate the tables: - -```bash -bin/rails generate flow_state:install -bin/rails db:migrate -``` +See the [migration guide](./MIGRATION_0_1_to_0_2.md) for a drop-in migration. --- -## Example: Saving a third party API response to local database - -Suppose you want to build a workflow that: -- Fetches a response from a third party API -- Allows for retrying the fetch on failure -- And persists the response to the workflow -- Then saves the persisted response to the database -- As two separate, encapsulated jobs -- Tracking each step, while protecting against race conditions +## Quick example – syncing an API and saving the result ---- - -### Define your Flow +### 1 Define the flow ```ruby -class SyncThirdPartyApiFlow < FlowState::Base - prop :my_record_id, String - prop :third_party_id, String +class SyncApiFlow < FlowState::Base + # typed metadata saved in the JSON `props` column + prop :record_id, String + prop :remote_api_id, String + # states state :pending - state :picked - state :fetching_third_party_api - state :fetched_third_party_api - state :failed_to_fetch_third_party_api, error: true - state :saving_my_record - state :saved_my_record - state :failed_to_save_my_record, error: true - state :completed + state :fetching + state :fetched + state :saving + state :saved + state :failed_fetch, error: true + state :failed_save, error: true + state :done - persist :third_party_api_response + # mandatory + initial_state :pending + completed_state :done + destroy_on_complete # <— remove if you prefer to keep rows - initial_state :pending + # artefacts persisted at runtime + persists :api_response, Hash - def pick! - transition!( - from: %i[pending], - to: :picked, - after_transition: -> { enqueue_fetch } - ) - end + # public API --------------------------------------------------------- - def start_third_party_api_request! - transition!( - from: %i[picked failed_to_fetch_third_party_api], - to: :fetching_third_party_api - ) + def start_fetch! + transition!(from: :pending, to: :fetching) end - def finish_third_party_api_request!(result) + def finish_fetch!(response) transition!( - from: :fetching_third_party_api, - to: :fetched_third_party_api, - persists: :third_party_api_response, - after_transition: -> { enqueue_save } - ) { result } + from: :fetching, + to: :fetched, + persist: :api_response, + after_transition: -> { SaveJob.perform_later(id) } + ) { response } end - def fail_third_party_api_request! - transition!( - from: :fetching_third_party_api, - to: :failed_to_fetch_third_party_api - ) - end - - def start_record_save! - transition!( - from: %i[fetched_third_party_api failed_to_save_my_record], - to: :saving_my_record, - guard: -> { flow_artefacts.where(name: 'third_party_api_response').exists? } - ) + def fail_fetch! + transition!(from: :fetching, to: :failed_fetch) end - def finish_record_save! - transition!( - from: :saving_my_record, - to: :saved_my_record, - after_transition: -> { complete! } - ) + def start_save! + transition!(from: :fetched, to: :saving) end - def fail_record_save! - transition!( - from: :saving_my_record, - to: :failed_to_save_my_record - ) + def finish_save! + transition!(from: :saving, to: :saved, after_transition: -> { complete! }) end - def complete! - transition!(from: :saved_my_record, to: :completed, after_transition: -> { destroy }) - end - - private - - def enqueue_fetch - FetchThirdPartyJob.perform_later(flow_id: id) + def fail_save! + transition!(from: :saving, to: :failed_save) end - def enqueue_save - SaveLocalRecordJob.perform_later(flow_id: id) + def complete! + transition!(from: :saved, to: :done) end end ``` ---- - -### Background Jobs - -Each job moves the flow through the correct states, step-by-step. - ---- - -**Create and start the flow** +### 2 Kick it off ```ruby -flow = SyncThirdPartyApiFlow.create( - my_record_id: "my_local_record_id", - third_party_id: "some_service_id" -) +flow = SyncApiFlow.create!(props: { + "record_id" => record.id, + "remote_api_id" => remote_id +}) -flow.pick! +flow.start_fetch! +FetchJob.perform_later(flow.id) ``` ---- - -**Fetch Third Party API Response** +### 3 Jobs move the flow ```ruby -class FetchThirdPartyJob < ApplicationJob - retry_on StandardError, - wait: ->(executions) { 10.seconds * (2**executions) }, - attempts: 3 +class FetchJob < ApplicationJob + def perform(flow_id) + flow = SyncApiFlow.find(flow_id) + + response = ThirdParty::Client.new(flow.props["remote_api_id"]).get + flow.finish_fetch!(response) + rescue StandardError => e + begin + flow.fail_fetch! + rescue StandardError + nil + end + raise e + end +end - def perform(flow_id:) - @flow_id = flow_id +class SaveJob < ApplicationJob + def perform(flow_id) + flow = SyncApiFlow.find(flow_id) - flow.start_third_party_api_request! + flow.start_save! - response = ThirdPartyApiRequest.new(id: flow.third_party_id).to_h + MyRecord.find(flow.props["record_id"]).update!(payload: artefact(flow, :api_response)) - flow.finish_third_party_api_request!(response) - rescue - flow.fail_third_party_api_request! - raise + flow.finish_save! + rescue StandardError => e + begin + flow.fail_save! + rescue StandardError + nil + end + raise e + end end private - def flow - @flow ||= SyncThirdPartyApiFlow.find(@flow_id) + def artefact(flow, name) + flow.flow_artefacts.find_by!(name: name.to_s).payload end end ``` ---- +That’s it – every step, timestamp, artefact and error is stored automatically. -**Save Result to Local Database** +--- -```ruby -class SaveLocalRecordJob < ApplicationJob - def perform(flow_id:) - @flow_id = flow_id +## API reference - flow.start_record_save! +### DSL macros - record.update!(payload: third_party_payload) +| Macro | Description | +| --------------------------------- | --------------------------------------------------------------------- | +| `state :name, error: false` | Declare a state. `error: true` marks it as a failure state. | +| `initial_state :name` | **Required.** First state assigned to new flows. | +| `completed_state :name` | **Required.** Terminal state that marks the flow as finished. | +| `destroy_on_complete(flag: true)` | Delete the row automatically once the flow reaches `completed_state`. | +| `prop :key, Type` | Typed key stored in JSONB `props`. Access via `flow.props["key"]`. | +| `persists :name, Type` | Declare an artefact that can be saved during a transition. | - flow.finish_record_save! - rescue - flow.fail_record_save! - raise - end +### Instance helpers - private +| Method | Use | +| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| `transition!(from:, to:, guard: nil, persist: nil, after_transition: nil) { ... }` | Perform a state change with optional guard, artefact persistence and callback. | +| `completed?` | `true` if `current_state == completed_state`. | +| `errored?` | `true` if the current state is marked `error: true`. | - def flow - @flow ||= SyncThirdPartyApiFlow.find(@flow_id) - end +--- - def third_party_payload - flow.flow_artefacts - .find_by!(name: 'third_party_api_response') - .payload - end +## Installation - def record - @record ||= MyRecord.find(flow.my_record_id) - end -end +```bash +bundle add flow_state +bin/rails generate flow_state:install +bin/rails db:migrate ``` ---- - -## Why use FlowState? - -Because it enables you to model workflows explicitly, -and track real-world execution reliably — -**without any magic**. +Follow the [migration guide](./MIGRATION_0_1_to_0_2.md) if you’re upgrading from 0.1. --- ## License -MIT. +MIT diff --git a/flow_state.gemspec b/flow_state.gemspec index 7c314bb..3366975 100644 --- a/flow_state.gemspec +++ b/flow_state.gemspec @@ -37,4 +37,13 @@ Gem::Specification.new do |spec| # For more information and examples about making a new gem, check out our # guide at: https://bundler.io/guides/creating_gem.html spec.metadata['rubygems_mfa_required'] = 'true' + + spec.post_install_message = <<~MSG + **FlowState 0.2 contains breaking changes.** + + If you are upgrading from any 0.1.x release, + read the migration guide first: + + https://github.com/hyperlaunch/flow_state/blob/main/MIGRATION_0_1_to_0_2.md + MSG end From af1e60e020fcecfd8a12c1f2b0dacfac1a2055cc Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Fri, 9 May 2025 15:49:46 +0100 Subject: [PATCH 10/10] rubocop --- spec/flow_state_spec.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/flow_state_spec.rb b/spec/flow_state_spec.rb index 81ef5d6..94ffb82 100644 --- a/spec/flow_state_spec.rb +++ b/spec/flow_state_spec.rb @@ -1,4 +1,6 @@ -RSpec.describe FlowState::Base do +# frozen_string_literal: true + +RSpec.describe FlowState::Base do # rubocop:disable Metrics/BlockLength before do stub_const('Flow', Class.new(FlowState::Base) do self.table_name = 'flow_state_flows'