flow_state 0.1.5 → 0.2.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 +4 -4
- data/MIGRATION_0_1_to_0_2.md +87 -0
- data/README.md +120 -177
- data/lib/flow_state/base.rb +76 -31
- data/lib/flow_state/version.rb +1 -1
- data/lib/generators/flow_state/templates/create_flow_state_flows.rb +4 -1
- metadata +10 -3
    
        checksums.yaml
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            ---
         | 
| 2 2 | 
             
            SHA256:
         | 
| 3 | 
            -
              metadata.gz:  | 
| 4 | 
            -
              data.tar.gz:  | 
| 3 | 
            +
              metadata.gz: d474458f9e1405cd005acba075a3c0bcf21497f086f874171dbe47fe8497aac2
         | 
| 4 | 
            +
              data.tar.gz: 56b3ab22b7a5b46bcaa79dad827861123dc24d31ecc91fc1f66f7b85b503545d
         | 
| 5 5 | 
             
            SHA512:
         | 
| 6 | 
            -
              metadata.gz:  | 
| 7 | 
            -
              data.tar.gz:  | 
| 6 | 
            +
              metadata.gz: 84668ec5878cadacbf156e30290ec0225cebc1f8460141872a51eaeead892a4b533e9532386c4c02fd69c0a43e8ea8fd744afaeff089291e62d7e363fb03c88a
         | 
| 7 | 
            +
              data.tar.gz: e2f992dfee8d35e15c55fcd7036721092e5cda2f282e251fdacea44d12de21b3c8e423db94addbde62987b2f9fd181fe8a67d160e5a6e5b55a8ecdfed3fb6bf9
         | 
| @@ -0,0 +1,87 @@ | |
| 1 | 
            +
            # Flow State 0.1 → 0.2 Migration Guide
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            ---
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            ## 1. Database migration
         | 
| 6 | 
            +
             | 
| 7 | 
            +
            ```ruby
         | 
| 8 | 
            +
            # db/migrate/xxxxxxxxxxxxxx_flow_state_02_upgrade.rb
         | 
| 9 | 
            +
            class FlowState02Upgrade < ActiveRecord::Migration[6.1]
         | 
| 10 | 
            +
              def change
         | 
| 11 | 
            +
                rename_column :flow_state_flows, :payload, :props
         | 
| 12 | 
            +
                add_column    :flow_state_flows, :type,    :string
         | 
| 13 | 
            +
                add_column    :flow_state_flows, :completed_at,    :datetime
         | 
| 14 | 
            +
                add_column    :flow_state_flows, :last_errored_at, :datetime
         | 
| 15 | 
            +
              end
         | 
| 16 | 
            +
            end
         | 
| 17 | 
            +
             | 
| 18 | 
            +
            ```
         | 
| 19 | 
            +
             | 
| 20 | 
            +
            Run the migration and bump your gem version to `0.2.0`.
         | 
| 21 | 
            +
             | 
| 22 | 
            +
            ---
         | 
| 23 | 
            +
             | 
| 24 | 
            +
            ## 2. Required changes
         | 
| 25 | 
            +
             | 
| 26 | 
            +
            | Area               | Old (≤ 0.1)                            | New (0.2)                                                  |
         | 
| 27 | 
            +
            | ------------------ | -------------------------------------- | ---------------------------------------------------------- |
         | 
| 28 | 
            +
            | Flow setup         | `initial_state` optional               | `initial_state` and `completed_state` **required**         |
         | 
| 29 | 
            +
            | Completed handling | manual `after_transition { destroy! }` | `destroy_on_complete` handles cleanup automatically        |
         | 
| 30 | 
            +
            | Column rename      | `payload`                              | `props`, used via `flow.props["key"]` only                 |
         | 
| 31 | 
            +
            | Prop accessors     | auto-generated methods                 | removed – use `props["key"]`                               |
         | 
| 32 | 
            +
            | Macro rename       | `persist :foo, Hash`                   | `persists :foo, Hash`                                      |
         | 
| 33 | 
            +
            | Transition keyword | `persists:`                            | `persist:`                                                 |
         | 
| 34 | 
            +
            | Timestamps         | —                                      | `completed_at` and `last_errored_at` tracked automatically |
         | 
| 35 | 
            +
             | 
| 36 | 
            +
            ---
         | 
| 37 | 
            +
             | 
| 38 | 
            +
            ## 3. Example refactor
         | 
| 39 | 
            +
             | 
| 40 | 
            +
            ### Flow definition
         | 
| 41 | 
            +
             | 
| 42 | 
            +
            ```ruby
         | 
| 43 | 
            +
            class SignupFlow < FlowState::Base
         | 
| 44 | 
            +
              state :draft
         | 
| 45 | 
            +
              state :processing
         | 
| 46 | 
            +
              state :failed,    error: true
         | 
| 47 | 
            +
              state :completed
         | 
| 48 | 
            +
             | 
| 49 | 
            +
              initial_state   :draft
         | 
| 50 | 
            +
              completed_state :completed
         | 
| 51 | 
            +
              destroy_on_complete
         | 
| 52 | 
            +
             | 
| 53 | 
            +
              prop     :user_id, Integer
         | 
| 54 | 
            +
              persists :external_response, Hash
         | 
| 55 | 
            +
            end
         | 
| 56 | 
            +
            ```
         | 
| 57 | 
            +
             | 
| 58 | 
            +
            ### Usage
         | 
| 59 | 
            +
             | 
| 60 | 
            +
            ```ruby
         | 
| 61 | 
            +
            flow = SignupFlow.create!(props: { "user_id" => 42 })
         | 
| 62 | 
            +
             | 
| 63 | 
            +
            flow.transition!(from: :draft, to: :processing)
         | 
| 64 | 
            +
             | 
| 65 | 
            +
            flow.transition!(
         | 
| 66 | 
            +
              from:   :processing,
         | 
| 67 | 
            +
              to:     :completed,
         | 
| 68 | 
            +
              persist: :external_response
         | 
| 69 | 
            +
            ) { { status: 200 } }
         | 
| 70 | 
            +
             | 
| 71 | 
            +
            # If destroy_on_complete was set:
         | 
| 72 | 
            +
            #   flow.destroyed?      # => true
         | 
| 73 | 
            +
            # Otherwise:
         | 
| 74 | 
            +
            #   flow.completed_at    # => Time
         | 
| 75 | 
            +
            #   flow.last_errored_at # => nil (unless failed state was hit)
         | 
| 76 | 
            +
            ```
         | 
| 77 | 
            +
             | 
| 78 | 
            +
            ---
         | 
| 79 | 
            +
             | 
| 80 | 
            +
            ## 4. Cleanup tips
         | 
| 81 | 
            +
             | 
| 82 | 
            +
            - Remove any `after_transition { destroy! }` logic and replace with `destroy_on_complete`.
         | 
| 83 | 
            +
            - Stop calling dynamic prop getters like `flow.name`; use `flow.props["name"]` instead.
         | 
| 84 | 
            +
            - Rename all usages of `persist` (macro) to `persists`.
         | 
| 85 | 
            +
            - Update any `persists:` keyword args to `persist:` in your `transition!` calls.
         | 
| 86 | 
            +
             | 
| 87 | 
            +
            You're now on **Flow State 0.2.0**.
         | 
    
        data/README.md
    CHANGED
    
    | @@ -4,251 +4,194 @@ | |
| 4 4 |  | 
| 5 5 | 
             
            ---
         | 
| 6 6 |  | 
| 7 | 
            -
            **FlowState**  | 
| 7 | 
            +
            **FlowState** is a small gem for Rails, for building *state-machine–style* workflows that persist every step, artefact and decision to your database.
         | 
| 8 | 
            +
            Everything is explicit – no metaprogramming, no hidden callbacks, no magic helpers.
         | 
| 8 9 |  | 
| 9 | 
            -
             | 
| 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.
         | 
| 10 | 
            +
            Use it when you need to:
         | 
| 12 11 |  | 
| 13 | 
            -
             | 
| 14 | 
            -
             | 
| 15 | 
            -
             | 
| 16 | 
            -
             | 
| 17 | 
            -
            - Store and type-check arbitrary payloads (artefacts) between steps
         | 
| 18 | 
            -
            - Avoid race conditions via database locks and explicit guards
         | 
| 12 | 
            +
            * orchestrate multi-step jobs that call external services
         | 
| 13 | 
            +
            * restart safely after crashes or retries
         | 
| 14 | 
            +
            * inspect an audit trail of *what happened, when and why*
         | 
| 15 | 
            +
            * attach typed artefacts (payloads) to a given transition
         | 
| 19 16 |  | 
| 20 17 | 
             
            ---
         | 
| 21 18 |  | 
| 22 | 
            -
            ##  | 
| 19 | 
            +
            ## What’s new in 0.2
         | 
| 23 20 |  | 
| 24 | 
            -
             | 
| 25 | 
            -
             | 
| 26 | 
            -
             | 
| 27 | 
            -
             | 
| 28 | 
            -
             | 
| 29 | 
            -
             | 
| 30 | 
            -
             | 
| 31 | 
            -
            - **No Magic** — No metaprogramming, no dynamic method generation, no `method_missing` tricks.  
         | 
| 21 | 
            +
            | Change                                                | Why it matters                                                                            |
         | 
| 22 | 
            +
            | ----------------------------------------------------- | ----------------------------------------------------------------------------------------- |
         | 
| 23 | 
            +
            | **`initial_state` & `completed_state` are mandatory** | Keeps definitions explicit and prevents silent mis-configuration.                         |
         | 
| 24 | 
            +
            | **`destroy_on_complete` macro**                       | One-liner to delete finished flows – replaces manual `after_transition { destroy! }`.     |
         | 
| 25 | 
            +
            | **`payload` → `props` column**                        | Aligns storage with the `prop` DSL (`flow.props["key"]`). No more auto-generated getters. |
         | 
| 26 | 
            +
            | **`persist` macro → `persists`**                      | Reads better, matches the transition keyword (`persist:`).                                |
         | 
| 27 | 
            +
            | **`completed_at` & `last_errored_at` timestamps**     | Easier querying: `where(completed_at: ..)` or `where.not(last_errored_at: nil)`.          |
         | 
| 32 28 |  | 
| 33 | 
            -
             | 
| 34 | 
            -
             | 
| 35 | 
            -
            ## Installation
         | 
| 36 | 
            -
             | 
| 37 | 
            -
            Add to your bundle:
         | 
| 38 | 
            -
             | 
| 39 | 
            -
            ```bash
         | 
| 40 | 
            -
            bundle add flow_state
         | 
| 41 | 
            -
            ```
         | 
| 42 | 
            -
             | 
| 43 | 
            -
            Generate the tables:
         | 
| 44 | 
            -
             | 
| 45 | 
            -
            ```bash
         | 
| 46 | 
            -
            bin/rails generate flow_state:install
         | 
| 47 | 
            -
            bin/rails db:migrate
         | 
| 48 | 
            -
            ```
         | 
| 29 | 
            +
            See the [migration guide](./MIGRATION_0_1_to_0_2.md) for a drop-in migration.
         | 
| 49 30 |  | 
| 50 31 | 
             
            ---
         | 
| 51 32 |  | 
| 52 | 
            -
            ##  | 
| 53 | 
            -
             | 
| 54 | 
            -
            Suppose you want to build a workflow that:
         | 
| 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
         | 
| 33 | 
            +
            ## Quick example – syncing an API and saving the result
         | 
| 61 34 |  | 
| 62 | 
            -
             | 
| 63 | 
            -
             | 
| 64 | 
            -
            ### Define your Flow
         | 
| 35 | 
            +
            ### 1  Define the flow
         | 
| 65 36 |  | 
| 66 37 | 
             
            ```ruby
         | 
| 67 | 
            -
            class  | 
| 68 | 
            -
               | 
| 69 | 
            -
              prop : | 
| 38 | 
            +
            class SyncApiFlow < FlowState::Base
         | 
| 39 | 
            +
              # typed metadata saved in the JSON `props` column
         | 
| 40 | 
            +
              prop :record_id,      String
         | 
| 41 | 
            +
              prop :remote_api_id,  String
         | 
| 70 42 |  | 
| 43 | 
            +
              # states
         | 
| 71 44 | 
             
              state :pending
         | 
| 72 | 
            -
              state : | 
| 73 | 
            -
              state : | 
| 74 | 
            -
              state : | 
| 75 | 
            -
              state : | 
| 76 | 
            -
              state : | 
| 77 | 
            -
              state : | 
| 78 | 
            -
              state : | 
| 79 | 
            -
              state :completed
         | 
| 45 | 
            +
              state :fetching
         | 
| 46 | 
            +
              state :fetched
         | 
| 47 | 
            +
              state :saving
         | 
| 48 | 
            +
              state :saved
         | 
| 49 | 
            +
              state :failed_fetch, error: true
         | 
| 50 | 
            +
              state :failed_save,  error: true
         | 
| 51 | 
            +
              state :done
         | 
| 80 52 |  | 
| 81 | 
            -
               | 
| 53 | 
            +
              # mandatory
         | 
| 54 | 
            +
              initial_state   :pending
         | 
| 55 | 
            +
              completed_state :done
         | 
| 56 | 
            +
              destroy_on_complete          # <— remove if you prefer to keep rows
         | 
| 82 57 |  | 
| 83 | 
            -
               | 
| 58 | 
            +
              # artefacts persisted at runtime
         | 
| 59 | 
            +
              persists :api_response, Hash
         | 
| 84 60 |  | 
| 85 | 
            -
               | 
| 86 | 
            -
                transition!(
         | 
| 87 | 
            -
                  from: %i[pending],
         | 
| 88 | 
            -
                  to: :picked,
         | 
| 89 | 
            -
                  after_transition: -> { enqueue_fetch }
         | 
| 90 | 
            -
                )
         | 
| 91 | 
            -
              end
         | 
| 61 | 
            +
              # public API ---------------------------------------------------------
         | 
| 92 62 |  | 
| 93 | 
            -
              def  | 
| 94 | 
            -
                transition!(
         | 
| 95 | 
            -
                  from: %i[picked failed_to_fetch_third_party_api], 
         | 
| 96 | 
            -
                  to: :fetching_third_party_api
         | 
| 97 | 
            -
                )
         | 
| 63 | 
            +
              def start_fetch!
         | 
| 64 | 
            +
                transition!(from: :pending, to: :fetching)
         | 
| 98 65 | 
             
              end
         | 
| 99 66 |  | 
| 100 | 
            -
              def  | 
| 67 | 
            +
              def finish_fetch!(response)
         | 
| 101 68 | 
             
                transition!(
         | 
| 102 | 
            -
                  from: | 
| 103 | 
            -
                  to: | 
| 104 | 
            -
                   | 
| 105 | 
            -
                  after_transition: -> {  | 
| 106 | 
            -
                ) {  | 
| 69 | 
            +
                  from:   :fetching,
         | 
| 70 | 
            +
                  to:     :fetched,
         | 
| 71 | 
            +
                  persist: :api_response,
         | 
| 72 | 
            +
                  after_transition: -> { SaveJob.perform_later(id) }
         | 
| 73 | 
            +
                ) { response }
         | 
| 107 74 | 
             
              end
         | 
| 108 75 |  | 
| 109 | 
            -
              def  | 
| 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 | 
            -
                )
         | 
| 76 | 
            +
              def fail_fetch!
         | 
| 77 | 
            +
                transition!(from: :fetching, to: :failed_fetch)
         | 
| 122 78 | 
             
              end
         | 
| 123 79 |  | 
| 124 | 
            -
              def  | 
| 125 | 
            -
                transition!(
         | 
| 126 | 
            -
                  from: :saving_my_record, 
         | 
| 127 | 
            -
                  to: :saved_my_record,
         | 
| 128 | 
            -
                  after_transition: -> { complete! }
         | 
| 129 | 
            -
                )
         | 
| 80 | 
            +
              def start_save!
         | 
| 81 | 
            +
                transition!(from: :fetched, to: :saving)
         | 
| 130 82 | 
             
              end
         | 
| 131 83 |  | 
| 132 | 
            -
              def  | 
| 133 | 
            -
                transition!(
         | 
| 134 | 
            -
                  from: :saving_my_record, 
         | 
| 135 | 
            -
                  to: :failed_to_save_my_record
         | 
| 136 | 
            -
                )
         | 
| 84 | 
            +
              def finish_save!
         | 
| 85 | 
            +
                transition!(from: :saving, to: :saved, after_transition: -> { complete! })
         | 
| 137 86 | 
             
              end
         | 
| 138 87 |  | 
| 139 | 
            -
              def  | 
| 140 | 
            -
                transition!(from: : | 
| 141 | 
            -
              end
         | 
| 142 | 
            -
             | 
| 143 | 
            -
              private
         | 
| 144 | 
            -
             | 
| 145 | 
            -
              def enqueue_fetch
         | 
| 146 | 
            -
                FetchThirdPartyJob.perform_later(flow_id: id)
         | 
| 88 | 
            +
              def fail_save!
         | 
| 89 | 
            +
                transition!(from: :saving, to: :failed_save)
         | 
| 147 90 | 
             
              end
         | 
| 148 91 |  | 
| 149 | 
            -
              def  | 
| 150 | 
            -
                 | 
| 92 | 
            +
              def complete!
         | 
| 93 | 
            +
                transition!(from: :saved, to: :done)
         | 
| 151 94 | 
             
              end
         | 
| 152 95 | 
             
            end
         | 
| 153 96 | 
             
            ```
         | 
| 154 97 |  | 
| 155 | 
            -
             | 
| 156 | 
            -
             | 
| 157 | 
            -
            ### Background Jobs
         | 
| 158 | 
            -
             | 
| 159 | 
            -
            Each job moves the flow through the correct states, step-by-step.
         | 
| 160 | 
            -
             | 
| 161 | 
            -
            ---
         | 
| 162 | 
            -
             | 
| 163 | 
            -
            **Create and start the flow**
         | 
| 98 | 
            +
            ### 2  Kick it off
         | 
| 164 99 |  | 
| 165 100 | 
             
            ```ruby
         | 
| 166 | 
            -
            flow =  | 
| 167 | 
            -
               | 
| 168 | 
            -
               | 
| 169 | 
            -
            )
         | 
| 101 | 
            +
            flow = SyncApiFlow.create!(props: {
         | 
| 102 | 
            +
              "record_id"     => record.id,
         | 
| 103 | 
            +
              "remote_api_id" => remote_id
         | 
| 104 | 
            +
            })
         | 
| 170 105 |  | 
| 171 | 
            -
            flow. | 
| 106 | 
            +
            flow.start_fetch!
         | 
| 107 | 
            +
            FetchJob.perform_later(flow.id)
         | 
| 172 108 | 
             
            ```
         | 
| 173 109 |  | 
| 174 | 
            -
             | 
| 175 | 
            -
             | 
| 176 | 
            -
            **Fetch Third Party API Response**
         | 
| 110 | 
            +
            ### 3  Jobs move the flow
         | 
| 177 111 |  | 
| 178 112 | 
             
            ```ruby
         | 
| 179 | 
            -
            class  | 
| 180 | 
            -
               | 
| 181 | 
            -
             | 
| 182 | 
            -
             | 
| 113 | 
            +
            class FetchJob < ApplicationJob
         | 
| 114 | 
            +
              def perform(flow_id)
         | 
| 115 | 
            +
                flow = SyncApiFlow.find(flow_id)
         | 
| 116 | 
            +
             | 
| 117 | 
            +
                response = ThirdParty::Client.new(flow.props["remote_api_id"]).get
         | 
| 118 | 
            +
                flow.finish_fetch!(response)
         | 
| 119 | 
            +
              rescue StandardError => e
         | 
| 120 | 
            +
                begin
         | 
| 121 | 
            +
                  flow.fail_fetch!
         | 
| 122 | 
            +
                rescue StandardError
         | 
| 123 | 
            +
                  nil
         | 
| 124 | 
            +
                end
         | 
| 125 | 
            +
                raise e
         | 
| 126 | 
            +
              end
         | 
| 127 | 
            +
            end
         | 
| 183 128 |  | 
| 184 | 
            -
             | 
| 185 | 
            -
             | 
| 129 | 
            +
            class SaveJob < ApplicationJob
         | 
| 130 | 
            +
              def perform(flow_id)
         | 
| 131 | 
            +
                flow = SyncApiFlow.find(flow_id)
         | 
| 186 132 |  | 
| 187 | 
            -
                flow. | 
| 133 | 
            +
                flow.start_save!
         | 
| 188 134 |  | 
| 189 | 
            -
                 | 
| 135 | 
            +
                MyRecord.find(flow.props["record_id"]).update!(payload: artefact(flow, :api_response))
         | 
| 190 136 |  | 
| 191 | 
            -
                flow. | 
| 192 | 
            -
              rescue
         | 
| 193 | 
            -
                 | 
| 194 | 
            -
             | 
| 137 | 
            +
                flow.finish_save!
         | 
| 138 | 
            +
              rescue StandardError => e
         | 
| 139 | 
            +
                begin
         | 
| 140 | 
            +
                  flow.fail_save!
         | 
| 141 | 
            +
                rescue StandardError
         | 
| 142 | 
            +
                  nil
         | 
| 143 | 
            +
                end
         | 
| 144 | 
            +
                raise e
         | 
| 145 | 
            +
              end
         | 
| 195 146 | 
             
              end
         | 
| 196 147 |  | 
| 197 148 | 
             
              private
         | 
| 198 149 |  | 
| 199 | 
            -
              def flow
         | 
| 200 | 
            -
                 | 
| 150 | 
            +
              def artefact(flow, name)
         | 
| 151 | 
            +
                flow.flow_artefacts.find_by!(name: name.to_s).payload
         | 
| 201 152 | 
             
              end
         | 
| 202 153 | 
             
            end
         | 
| 203 154 | 
             
            ```
         | 
| 204 155 |  | 
| 205 | 
            -
             | 
| 156 | 
            +
            That’s it – every step, timestamp, artefact and error is stored automatically.
         | 
| 206 157 |  | 
| 207 | 
            -
             | 
| 158 | 
            +
            ---
         | 
| 208 159 |  | 
| 209 | 
            -
             | 
| 210 | 
            -
            class SaveLocalRecordJob < ApplicationJob
         | 
| 211 | 
            -
              def perform(flow_id:)
         | 
| 212 | 
            -
                @flow_id = flow_id
         | 
| 160 | 
            +
            ## API reference
         | 
| 213 161 |  | 
| 214 | 
            -
             | 
| 162 | 
            +
            ### DSL macros
         | 
| 215 163 |  | 
| 216 | 
            -
             | 
| 164 | 
            +
            | Macro                             | Description                                                           |
         | 
| 165 | 
            +
            | --------------------------------- | --------------------------------------------------------------------- |
         | 
| 166 | 
            +
            | `state :name, error: false`       | Declare a state. `error: true` marks it as a failure state.           |
         | 
| 167 | 
            +
            | `initial_state :name`             | **Required.** First state assigned to new flows.                      |
         | 
| 168 | 
            +
            | `completed_state :name`           | **Required.** Terminal state that marks the flow as finished.         |
         | 
| 169 | 
            +
            | `destroy_on_complete(flag: true)` | Delete the row automatically once the flow reaches `completed_state`. |
         | 
| 170 | 
            +
            | `prop :key, Type`                 | Typed key stored in JSONB `props`. Access via `flow.props["key"]`.    |
         | 
| 171 | 
            +
            | `persists :name, Type`            | Declare an artefact that can be saved during a transition.            |
         | 
| 217 172 |  | 
| 218 | 
            -
             | 
| 219 | 
            -
              rescue
         | 
| 220 | 
            -
                flow.fail_record_save!
         | 
| 221 | 
            -
                raise
         | 
| 222 | 
            -
              end
         | 
| 173 | 
            +
            ### Instance helpers
         | 
| 223 174 |  | 
| 224 | 
            -
             | 
| 175 | 
            +
            | Method                                                                             | Use                                                                            |
         | 
| 176 | 
            +
            | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
         | 
| 177 | 
            +
            | `transition!(from:, to:, guard: nil, persist: nil, after_transition: nil) { ... }` | Perform a state change with optional guard, artefact persistence and callback. |
         | 
| 178 | 
            +
            | `completed?`                                                                       | `true` if `current_state == completed_state`.                                  |
         | 
| 179 | 
            +
            | `errored?`                                                                         | `true` if the current state is marked `error: true`.                           |
         | 
| 225 180 |  | 
| 226 | 
            -
             | 
| 227 | 
            -
                @flow ||= SyncThirdPartyApiFlow.find(@flow_id)
         | 
| 228 | 
            -
              end
         | 
| 181 | 
            +
            ---
         | 
| 229 182 |  | 
| 230 | 
            -
             | 
| 231 | 
            -
                flow.flow_artefacts
         | 
| 232 | 
            -
                    .find_by!(name: 'third_party_api_response')
         | 
| 233 | 
            -
                    .payload
         | 
| 234 | 
            -
              end
         | 
| 183 | 
            +
            ## Installation
         | 
| 235 184 |  | 
| 236 | 
            -
             | 
| 237 | 
            -
             | 
| 238 | 
            -
             | 
| 239 | 
            -
             | 
| 185 | 
            +
            ```bash
         | 
| 186 | 
            +
            bundle add flow_state
         | 
| 187 | 
            +
            bin/rails generate flow_state:install
         | 
| 188 | 
            +
            bin/rails db:migrate
         | 
| 240 189 | 
             
            ```
         | 
| 241 190 |  | 
| 242 | 
            -
             | 
| 243 | 
            -
             | 
| 244 | 
            -
            ## Why use FlowState?
         | 
| 245 | 
            -
             | 
| 246 | 
            -
            Because it enables you to model workflows explicitly,
         | 
| 247 | 
            -
            and track real-world execution reliably —  
         | 
| 248 | 
            -
            **without any magic**.
         | 
| 191 | 
            +
            Follow the [migration guide](./MIGRATION_0_1_to_0_2.md) if you’re upgrading from 0.1.
         | 
| 249 192 |  | 
| 250 193 | 
             
            ---
         | 
| 251 194 |  | 
| 252 195 | 
             
            ## License
         | 
| 253 196 |  | 
| 254 | 
            -
            MIT | 
| 197 | 
            +
            MIT
         | 
    
        data/lib/flow_state/base.rb
    CHANGED
    
    | @@ -3,11 +3,14 @@ | |
| 3 3 | 
             
            module FlowState
         | 
| 4 4 | 
             
              # Base Model to be extended by app flows
         | 
| 5 5 | 
             
              class Base < ActiveRecord::Base # rubocop:disable Metrics/ClassLength
         | 
| 6 | 
            -
                class UnknownStateError | 
| 6 | 
            +
                class UnknownStateError < StandardError; end
         | 
| 7 7 | 
             
                class InvalidTransitionError < StandardError; end
         | 
| 8 8 | 
             
                class PayloadValidationError < StandardError; end
         | 
| 9 | 
            -
                class  | 
| 10 | 
            -
                class  | 
| 9 | 
            +
                class PropsValidationError < StandardError; end
         | 
| 10 | 
            +
                class GuardFailedError < StandardError; end
         | 
| 11 | 
            +
                class UnknownArtefactError < StandardError; end
         | 
| 12 | 
            +
                class MissingInitialStateError   < StandardError; end
         | 
| 13 | 
            +
                class MissingCompletedStateError < StandardError; end
         | 
| 11 14 |  | 
| 12 15 | 
             
                DEPRECATOR = ActiveSupport::Deprecation.new(FlowState::VERSION, 'FlowState')
         | 
| 13 16 |  | 
| @@ -32,12 +35,23 @@ module FlowState | |
| 32 35 | 
             
                    name ? @initial_state = name.to_sym : @initial_state
         | 
| 33 36 | 
             
                  end
         | 
| 34 37 |  | 
| 38 | 
            +
                  def completed_state(name = nil)
         | 
| 39 | 
            +
                    name ? @completed_state = name.to_sym : @completed_state
         | 
| 40 | 
            +
                  end
         | 
| 41 | 
            +
             | 
| 42 | 
            +
                  def destroy_on_complete(flag: true)
         | 
| 43 | 
            +
                    @destroy_on_complete = flag
         | 
| 44 | 
            +
                  end
         | 
| 45 | 
            +
             | 
| 46 | 
            +
                  def destroy_on_complete?
         | 
| 47 | 
            +
                    !!@destroy_on_complete
         | 
| 48 | 
            +
                  end
         | 
| 49 | 
            +
             | 
| 35 50 | 
             
                  def prop(name, type)
         | 
| 36 | 
            -
                     | 
| 37 | 
            -
                    define_method(name) { payload&.dig(name.to_s) }
         | 
| 51 | 
            +
                    props_schema[name.to_sym] = type
         | 
| 38 52 | 
             
                  end
         | 
| 39 53 |  | 
| 40 | 
            -
                  def  | 
| 54 | 
            +
                  def persists(name, type)
         | 
| 41 55 | 
             
                    artefact_schema[name.to_sym] = type
         | 
| 42 56 | 
             
                  end
         | 
| 43 57 |  | 
| @@ -49,8 +63,8 @@ module FlowState | |
| 49 63 | 
             
                    @error_states ||= []
         | 
| 50 64 | 
             
                  end
         | 
| 51 65 |  | 
| 52 | 
            -
                  def  | 
| 53 | 
            -
                    @ | 
| 66 | 
            +
                  def props_schema
         | 
| 67 | 
            +
                    @props_schema ||= {}
         | 
| 54 68 | 
             
                  end
         | 
| 55 69 |  | 
| 56 70 | 
             
                  def artefact_schema
         | 
| @@ -59,13 +73,15 @@ module FlowState | |
| 59 73 | 
             
                end
         | 
| 60 74 |  | 
| 61 75 | 
             
                validates :current_state, presence: true
         | 
| 62 | 
            -
                validate : | 
| 76 | 
            +
                validate :validate_props
         | 
| 77 | 
            +
                after_commit :handle_completion, on: :update
         | 
| 63 78 |  | 
| 64 | 
            -
                after_initialize  | 
| 79 | 
            +
                after_initialize :validate_initial_states!, if: :new_record?
         | 
| 80 | 
            +
                after_initialize :assign_initial_state, if: :new_record?
         | 
| 65 81 |  | 
| 66 | 
            -
                def transition!(from:, to:, guard: nil,  | 
| 67 | 
            -
                  setup_transition!(from, to, guard,  | 
| 68 | 
            -
                  perform_transition!(to,  | 
| 82 | 
            +
                def transition!(from:, to:, guard: nil, persist: nil, after_transition: nil, &block)
         | 
| 83 | 
            +
                  setup_transition!(from, to, guard, persist, &block)
         | 
| 84 | 
            +
                  perform_transition!(to, persist)
         | 
| 69 85 | 
             
                  after_transition&.call
         | 
| 70 86 | 
             
                end
         | 
| 71 87 |  | 
| @@ -73,8 +89,37 @@ module FlowState | |
| 73 89 | 
             
                  self.class.error_states.include?(current_state&.to_sym)
         | 
| 74 90 | 
             
                end
         | 
| 75 91 |  | 
| 92 | 
            +
                def completed?
         | 
| 93 | 
            +
                  self.class.completed_state && current_state&.to_sym == self.class.completed_state
         | 
| 94 | 
            +
                end
         | 
| 95 | 
            +
             | 
| 96 | 
            +
                def handle_completion
         | 
| 97 | 
            +
                  return unless completed?
         | 
| 98 | 
            +
             | 
| 99 | 
            +
                  if self.class.destroy_on_complete?
         | 
| 100 | 
            +
                    destroy!
         | 
| 101 | 
            +
                  elsif completed_at.nil?
         | 
| 102 | 
            +
                    update_column(:completed_at, Time.current)
         | 
| 103 | 
            +
                  end
         | 
| 104 | 
            +
                end
         | 
| 105 | 
            +
             | 
| 76 106 | 
             
                private
         | 
| 77 107 |  | 
| 108 | 
            +
                def validate_initial_states!
         | 
| 109 | 
            +
                  init_state = self.class.initial_state
         | 
| 110 | 
            +
                  comp_state = self.class.completed_state
         | 
| 111 | 
            +
             | 
| 112 | 
            +
                  raise MissingInitialStateError,   "#{self.class} must declare initial_state"   unless init_state
         | 
| 113 | 
            +
                  raise MissingCompletedStateError, "#{self.class} must declare completed_state" unless comp_state
         | 
| 114 | 
            +
             | 
| 115 | 
            +
                  unknown = [init_state, comp_state] - self.class.all_states
         | 
| 116 | 
            +
                  raise UnknownStateError, "unknown #{unknown.join(', ')}" if unknown.any?
         | 
| 117 | 
            +
                end
         | 
| 118 | 
            +
             | 
| 119 | 
            +
                def assign_initial_state
         | 
| 120 | 
            +
                  self.current_state ||= self.class.initial_state
         | 
| 121 | 
            +
                end
         | 
| 122 | 
            +
             | 
| 78 123 | 
             
                def setup_transition!(from, to, guard, persists, &block)
         | 
| 79 124 | 
             
                  @from_states = Array(from).map(&:to_sym)
         | 
| 80 125 | 
             
                  @to_state    = to.to_sym
         | 
| @@ -85,14 +130,19 @@ module FlowState | |
| 85 130 | 
             
                end
         | 
| 86 131 |  | 
| 87 132 | 
             
                def perform_transition!(to, persists) # rubocop:disable Metrics/MethodLength
         | 
| 88 | 
            -
                   | 
| 89 | 
            -
                     | 
| 90 | 
            -
                     | 
| 133 | 
            +
                  transaction do
         | 
| 134 | 
            +
                    save! if changed?
         | 
| 135 | 
            +
                    with_lock do
         | 
| 136 | 
            +
                      ensure_valid_from_state!(@from_states, to)
         | 
| 91 137 | 
             
                      @tr = flow_transitions.create!(
         | 
| 92 138 | 
             
                        transitioned_from: current_state,
         | 
| 93 139 | 
             
                        transitioned_to: to
         | 
| 94 140 | 
             
                      )
         | 
| 95 | 
            -
             | 
| 141 | 
            +
             | 
| 142 | 
            +
                      attrs = { current_state: to }
         | 
| 143 | 
            +
                      attrs[:last_errored_at] = (Time.current if self.class.error_states.include?(to.to_sym))
         | 
| 144 | 
            +
                      update!(attrs)
         | 
| 145 | 
            +
             | 
| 96 146 | 
             
                      persist_artefact! if persists
         | 
| 97 147 | 
             
                    end
         | 
| 98 148 | 
             
                  end
         | 
| @@ -121,7 +171,8 @@ module FlowState | |
| 121 171 | 
             
                def persist_artefact!
         | 
| 122 172 | 
             
                  expected = self.class.artefact_schema[@artefact_name]
         | 
| 123 173 | 
             
                  unless @artefact_data.is_a?(expected)
         | 
| 124 | 
            -
                    raise PayloadValidationError, | 
| 174 | 
            +
                    raise PayloadValidationError,
         | 
| 175 | 
            +
                          "artefact #{@artefact_name} must be #{expected}"
         | 
| 125 176 | 
             
                  end
         | 
| 126 177 |  | 
| 127 178 | 
             
                  @tr.flow_artefacts.create!(
         | 
| @@ -130,28 +181,22 @@ module FlowState | |
| 130 181 | 
             
                  )
         | 
| 131 182 | 
             
                end
         | 
| 132 183 |  | 
| 133 | 
            -
                def resolve_initial_state
         | 
| 134 | 
            -
                  init = self.class.initial_state || self.class.all_states.first
         | 
| 135 | 
            -
                  ensure_known_states!([init]) if init
         | 
| 136 | 
            -
                  init
         | 
| 137 | 
            -
                end
         | 
| 138 | 
            -
             | 
| 139 184 | 
             
                def ensure_known_states!(states)
         | 
| 140 185 | 
             
                  unknown = states - self.class.all_states
         | 
| 141 186 | 
             
                  raise UnknownStateError, "unknown #{unknown.join(', ')}" if unknown.any?
         | 
| 142 187 | 
             
                end
         | 
| 143 188 |  | 
| 144 | 
            -
                def  | 
| 145 | 
            -
                  schema = self.class. | 
| 189 | 
            +
                def validate_props
         | 
| 190 | 
            +
                  schema = self.class.props_schema
         | 
| 146 191 | 
             
                  return if schema.empty?
         | 
| 147 192 |  | 
| 148 193 | 
             
                  schema.each do |key, klass|
         | 
| 149 | 
            -
                    v =  | 
| 150 | 
            -
                    raise  | 
| 151 | 
            -
                    raise  | 
| 194 | 
            +
                    v = props&.dig(key.to_s)
         | 
| 195 | 
            +
                    raise PropsValidationError, "#{key} missing" unless v
         | 
| 196 | 
            +
                    raise PropsValidationError, "#{key} must be #{klass}" unless v.is_a?(klass)
         | 
| 152 197 | 
             
                  end
         | 
| 153 | 
            -
                rescue  | 
| 154 | 
            -
                  errors.add(: | 
| 198 | 
            +
                rescue PropsValidationError => e
         | 
| 199 | 
            +
                  errors.add(:props, e.message)
         | 
| 155 200 | 
             
                end
         | 
| 156 201 | 
             
              end
         | 
| 157 202 | 
             
            end
         | 
    
        data/lib/flow_state/version.rb
    CHANGED
    
    
| @@ -4,8 +4,11 @@ | |
| 4 4 | 
             
            class CreateFlowStateFlows < ActiveRecord::Migration[8.0]
         | 
| 5 5 | 
             
              def change
         | 
| 6 6 | 
             
                create_table :flow_state_flows do |t|
         | 
| 7 | 
            +
                  t.string :type, null: false
         | 
| 7 8 | 
             
                  t.string :current_state, null: false
         | 
| 8 | 
            -
                  t. | 
| 9 | 
            +
                  t.datetime :completed_at
         | 
| 10 | 
            +
                  t.datetime :last_errored_at
         | 
| 11 | 
            +
                  t.json :props
         | 
| 9 12 | 
             
                  t.timestamps
         | 
| 10 13 | 
             
                end
         | 
| 11 14 | 
             
              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. | 
| 4 | 
            +
              version: 0.2.0
         | 
| 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- | 
| 11 | 
            +
            date: 2025-05-09 00:00:00.000000000 Z
         | 
| 12 12 | 
             
            dependencies:
         | 
| 13 13 | 
             
            - !ruby/object:Gem::Dependency
         | 
| 14 14 | 
             
              name: rails
         | 
| @@ -37,6 +37,7 @@ files: | |
| 37 37 | 
             
            - ".rubocop.yml"
         | 
| 38 38 | 
             
            - CHANGELOG.md
         | 
| 39 39 | 
             
            - LICENSE.txt
         | 
| 40 | 
            +
            - MIGRATION_0_1_to_0_2.md
         | 
| 40 41 | 
             
            - README.md
         | 
| 41 42 | 
             
            - Rakefile
         | 
| 42 43 | 
             
            - lib/flow_state.rb
         | 
| @@ -58,7 +59,13 @@ metadata: | |
| 58 59 | 
             
              source_code_uri: https://github.com/hyperlaunch/flow-state
         | 
| 59 60 | 
             
              changelog_uri: https://github.com/hyperlaunch/flow-state/changelog.md
         | 
| 60 61 | 
             
              rubygems_mfa_required: 'true'
         | 
| 61 | 
            -
            post_install_message:
         | 
| 62 | 
            +
            post_install_message: |
         | 
| 63 | 
            +
              **FlowState 0.2 contains breaking changes.**
         | 
| 64 | 
            +
             | 
| 65 | 
            +
              If you are upgrading from any 0.1.x release,
         | 
| 66 | 
            +
              read the migration guide first:
         | 
| 67 | 
            +
             | 
| 68 | 
            +
              https://github.com/hyperlaunch/flow_state/blob/main/MIGRATION_0_1_to_0_2.md
         | 
| 62 69 | 
             
            rdoc_options: []
         | 
| 63 70 | 
             
            require_paths:
         | 
| 64 71 | 
             
            - lib
         |