hybrid-state-model 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ccb639c4ad5854bb0d7d81dd251af7d7bff899112e2a19588b004b2a5c62d4b7
4
+ data.tar.gz: 00a6d76e7b17bca056d3cc61cc3a77d0cd37aadb1246bff82c6c1c1c4becf3a6
5
+ SHA512:
6
+ metadata.gz: 869f995dd5b782d748a2c64ee1f6c6419fa73a906897ea9579d1d8872ba3ba3ce415a8f5ee1b1a19ebaf6e6be56d7c04850a8bd9afda82a3e6c778e8d1e9694f
7
+ data.tar.gz: 29f58c5169ee5c1444931632799e118647387016bab884b91bfffd592de5ce6f2d02b83d83db5c54c7f054dd5f9de4aa82f1e951e91e3eac8e9b4908824a9611
data/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2024-01-01
9
+
10
+ ### Added
11
+ - Initial release of hybrid-state-model
12
+ - Two-layer state system (primary state + micro state)
13
+ - DSL for defining states and mappings
14
+ - Transition methods: `promote!`, `advance!`, `reset_micro!`, `transition!`
15
+ - Query scopes: `in_primary`, `in_micro`, `with_primary_and_micro`, `with_micro`, `without_micro`
16
+ - Automatic state validation
17
+ - Callbacks: `before_primary_transition`, `after_primary_transition`, `before_micro_transition`, `after_micro_transition`
18
+ - Optional metrics tracking for time spent in states
19
+ - Support for auto-resetting micro state when primary state changes
20
+
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
data/README.md ADDED
@@ -0,0 +1,308 @@
1
+ # Hybrid State Model
2
+
3
+ A revolutionary two-layer hierarchical state system for Ruby models. Perfect for complex workflows that are too complex for flat state machines but don't need full orchestration engines.
4
+
5
+ ## 🌟 The Big Idea
6
+
7
+ **hybrid-state-model** introduces a two-layer state system:
8
+
9
+ - **Primary State** — high-level lifecycle (e.g., `pending`, `active`, `shipped`, `delivered`)
10
+ - **Secondary Micro-State** — small step within the primary state (e.g., `verifying_email`, `awaiting_payment`, `in_transit`, `out_for_delivery`)
11
+
12
+ This creates a simple but powerful hierarchical state system that reduces complexity instead of adding it.
13
+
14
+ ## ✨ Features
15
+
16
+ - 🎯 **Two-layer state system** — Primary states with nested micro-states
17
+ - 🔒 **Automatic constraints** — Micro-states are validated against their primary state
18
+ - 🚀 **Flexible transitions** — `promote!`, `advance!`, `reset_micro!`, and `transition!`
19
+ - 🔍 **Querying capabilities** — `in_primary`, `in_micro`, `with_primary_and_micro` scopes
20
+ - 📊 **Metrics tracking** — Track time spent in each state (optional)
21
+ - 🎛️ **Callbacks** — `before_primary_transition`, `after_primary_transition`, etc.
22
+ - ✅ **ActiveRecord integration** — Works seamlessly with Rails models
23
+
24
+ ## 📦 Installation
25
+
26
+ Add this line to your application's Gemfile:
27
+
28
+ ```ruby
29
+ gem 'hybrid-state-model'
30
+ ```
31
+
32
+ And then execute:
33
+
34
+ ```bash
35
+ $ bundle install
36
+ ```
37
+
38
+ Or install it yourself as:
39
+
40
+ ```bash
41
+ $ gem install hybrid-state-model
42
+ ```
43
+
44
+ ## 🚀 Quick Start
45
+
46
+ ### 1. Create your migration
47
+
48
+ ```ruby
49
+ class CreateOrders < ActiveRecord::Migration[7.0]
50
+ def change
51
+ create_table :orders do |t|
52
+ t.string :status # Primary state
53
+ t.string :sub_status # Micro state
54
+ t.text :state_metrics # Optional: for metrics tracking
55
+
56
+ t.timestamps
57
+ end
58
+ end
59
+ end
60
+ ```
61
+
62
+ ### 2. Define your model
63
+
64
+ ```ruby
65
+ class Order < ActiveRecord::Base
66
+ include HybridStateModel
67
+
68
+ hybrid_state do
69
+ # Define primary state field and possible values
70
+ primary :status, %i[pending processing shipped delivered returned]
71
+
72
+ # Define micro state field and possible values
73
+ micro :sub_status, %i[
74
+ awaiting_payment
75
+ fraud_check_passed
76
+ fraud_check_failed
77
+ ready_to_pack
78
+ packing
79
+ assigning_carrier
80
+ waiting_for_pickup
81
+ in_transit
82
+ out_for_delivery
83
+ inspection
84
+ return_processing
85
+ return_complete
86
+ ]
87
+
88
+ # Map which micro-states are allowed for each primary state
89
+ map status: :pending, sub_status: %i[awaiting_payment]
90
+ map status: :processing, sub_status: %i[fraud_check_passed fraud_check_failed ready_to_pack packing assigning_carrier]
91
+ map status: :shipped, sub_status: %i[waiting_for_pickup in_transit out_for_delivery]
92
+ map status: :returned, sub_status: %i[inspection return_processing return_complete]
93
+
94
+ # Optional: Reset micro state when primary state changes
95
+ when_primary_changes reset_micro: true
96
+
97
+ # Optional: Callbacks
98
+ before_primary_transition :shipped do
99
+ raise "Cannot ship without payment" unless paid?
100
+ end
101
+
102
+ after_primary_transition :delivered do
103
+ send_delivery_confirmation_email
104
+ end
105
+ end
106
+ end
107
+ ```
108
+
109
+ ### 3. Use it!
110
+
111
+ ```ruby
112
+ # Create an order
113
+ order = Order.create!(status: :pending, sub_status: :awaiting_payment)
114
+
115
+ # Promote to primary state (moves to next major state)
116
+ order.promote!(:processing)
117
+ # => status: :processing, sub_status: nil (reset because of when_primary_changes)
118
+
119
+ # Advance micro state (moves within current primary state)
120
+ order.advance!(:ready_to_pack)
121
+ # => status: :processing, sub_status: :ready_to_pack
122
+
123
+ # Transition both at once
124
+ order.transition!(primary: :shipped, micro: :waiting_for_pickup)
125
+ # => status: :shipped, sub_status: :waiting_for_pickup
126
+
127
+ # Advance through micro states
128
+ order.advance!(:in_transit)
129
+ order.advance!(:out_for_delivery)
130
+
131
+ # Promote to final state
132
+ order.promote!(:delivered)
133
+
134
+ # Querying
135
+ Order.in_primary(:shipped)
136
+ Order.in_micro(:in_transit)
137
+ Order.with_primary_and_micro(primary: :shipped, micro: :out_for_delivery)
138
+ Order.with_micro # Orders that have a micro state
139
+ Order.without_micro # Orders without a micro state
140
+
141
+ # Validation
142
+ order.status = :delivered
143
+ order.sub_status = :assigning_carrier # ❌ Invalid! Will fail validation
144
+ ```
145
+
146
+ ## 📚 API Reference
147
+
148
+ ### DSL Methods
149
+
150
+ #### `primary(field_name, states)`
151
+ Defines the primary state field and its possible values.
152
+
153
+ ```ruby
154
+ primary :status, %i[pending active inactive]
155
+ ```
156
+
157
+ #### `micro(field_name, states)`
158
+ Defines the micro state field and its possible values.
159
+
160
+ ```ruby
161
+ micro :sub_status, %i[verifying_email awaiting_approval]
162
+ ```
163
+
164
+ #### `map(primary_state:, micro_states:)`
165
+ Maps which micro-states are allowed for a specific primary state.
166
+
167
+ ```ruby
168
+ map status: :active, sub_status: %i[verifying_email awaiting_approval]
169
+ ```
170
+
171
+ #### `when_primary_changes(reset_micro: true)`
172
+ Automatically resets the micro state when the primary state changes.
173
+
174
+ #### `before_primary_transition(states, &block)`
175
+ Runs a callback before transitioning to the specified primary state(s).
176
+
177
+ ```ruby
178
+ before_primary_transition :shipped do
179
+ validate_shipping_address
180
+ end
181
+ ```
182
+
183
+ #### `after_primary_transition(states, &block)`
184
+ Runs a callback after transitioning to the specified primary state(s).
185
+
186
+ #### `before_micro_transition(states, &block)`
187
+ Runs a callback before transitioning to the specified micro state(s).
188
+
189
+ #### `after_micro_transition(states, &block)`
190
+ Runs a callback after transitioning to the specified micro state(s).
191
+
192
+ ### Instance Methods
193
+
194
+ #### `promote!(new_primary_state, options = {})`
195
+ Transitions to a new primary state. Automatically resets micro state if configured.
196
+
197
+ ```ruby
198
+ order.promote!(:shipped)
199
+ order.promote!(:delivered, skip_save: true) # Don't save immediately
200
+ ```
201
+
202
+ #### `advance!(new_micro_state, options = {})`
203
+ Transitions to a new micro state within the current primary state.
204
+
205
+ ```ruby
206
+ order.advance!(:in_transit)
207
+ ```
208
+
209
+ #### `reset_micro!(options = {})`
210
+ Resets the micro state to `nil`.
211
+
212
+ ```ruby
213
+ order.reset_micro!
214
+ ```
215
+
216
+ #### `transition!(primary:, micro:, options = {})`
217
+ Transitions both primary and micro states at once.
218
+
219
+ ```ruby
220
+ order.transition!(primary: :shipped, micro: :waiting_for_pickup)
221
+ ```
222
+
223
+ #### `can_transition_to_primary?(state)`
224
+ Checks if the record can transition to the specified primary state.
225
+
226
+ #### `can_transition_to_micro?(state)`
227
+ Checks if the record can transition to the specified micro state.
228
+
229
+ ### Query Scopes
230
+
231
+ #### `in_primary(*states)`
232
+ Finds records with the specified primary state(s).
233
+
234
+ ```ruby
235
+ Order.in_primary(:shipped, :delivered)
236
+ ```
237
+
238
+ #### `in_micro(*states)`
239
+ Finds records with the specified micro state(s).
240
+
241
+ ```ruby
242
+ Order.in_micro(:in_transit, :out_for_delivery)
243
+ ```
244
+
245
+ #### `with_primary_and_micro(primary:, micro:)`
246
+ Finds records with both the specified primary and micro states.
247
+
248
+ ```ruby
249
+ Order.with_primary_and_micro(primary: :shipped, micro: :in_transit)
250
+ ```
251
+
252
+ #### `with_micro`
253
+ Finds records that have a micro state set.
254
+
255
+ #### `without_micro`
256
+ Finds records that don't have a micro state set.
257
+
258
+ ## 📊 Metrics Tracking
259
+
260
+ If you add a `state_metrics` text/json column to your table, the gem will automatically track time spent in each state:
261
+
262
+ ```ruby
263
+ # Migration
264
+ add_column :orders, :state_metrics, :text
265
+
266
+ # Usage
267
+ order.state_metrics
268
+ # => {
269
+ # "pending" => {"entered_at" => "...", "duration" => 120.5},
270
+ # "processing" => {"entered_at" => "...", "duration" => 300.0},
271
+ # "processing:ready_to_pack" => {"entered_at" => "...", "duration" => 60.0}
272
+ # }
273
+
274
+ order.time_in_primary_state(:processing)
275
+ # => 300.0 (seconds)
276
+
277
+ order.current_state_duration
278
+ # => 45.2 (seconds in current state)
279
+ ```
280
+
281
+ ## 🎯 Use Cases
282
+
283
+ ### Logistics & Shipping
284
+ ```ruby
285
+ primary :status, %i[pending processing shipped delivered]
286
+ micro :sub_status, %i[ready_to_pack packing waiting_for_pickup in_transit out_for_delivery]
287
+ ```
288
+
289
+ ### Payment & Billing
290
+ ```ruby
291
+ primary :status, %i[active suspended canceled]
292
+ micro :sub_status, %i[verifying_card awaiting_payment retrying_charge]
293
+ ```
294
+
295
+ ### User Onboarding
296
+ ```ruby
297
+ primary :status, %i[active pending]
298
+ micro :sub_status, %i[verifying_email uploading_documents awaiting_approval]
299
+ ```
300
+
301
+ ## 🤝 Contributing
302
+
303
+ Bug reports and pull requests are welcome on GitHub at https://github.com/afshmini/hybrid-state-model.
304
+
305
+ ## 📝 License
306
+
307
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
308
+
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
9
+
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Example: Order model with hybrid state
4
+ #
5
+ # This example demonstrates how to use hybrid-state-model
6
+ # for an e-commerce order workflow
7
+
8
+ require "active_record"
9
+ require_relative "../lib/hybrid_state_model"
10
+
11
+ # Setup database (in a real app, this would be a migration)
12
+ ActiveRecord::Base.establish_connection(
13
+ adapter: "sqlite3",
14
+ database: ":memory:"
15
+ )
16
+
17
+ ActiveRecord::Schema.define do
18
+ create_table :orders do |t|
19
+ t.string :status
20
+ t.string :sub_status
21
+ t.text :state_metrics
22
+ t.boolean :paid, default: false
23
+ t.timestamps
24
+ end
25
+ end
26
+
27
+ class Order < ActiveRecord::Base
28
+ include HybridStateModel
29
+
30
+ hybrid_state do
31
+ # Define primary state (high-level lifecycle)
32
+ primary :status, %i[pending processing shipped delivered returned]
33
+
34
+ # Define micro state (steps within primary state)
35
+ micro :sub_status, %i[
36
+ awaiting_payment
37
+ fraud_check_passed
38
+ fraud_check_failed
39
+ ready_to_pack
40
+ packing
41
+ assigning_carrier
42
+ waiting_for_pickup
43
+ in_transit
44
+ out_for_delivery
45
+ inspection
46
+ return_processing
47
+ return_complete
48
+ ]
49
+
50
+ # Map which micro-states are allowed for each primary state
51
+ map status: :pending, sub_status: %i[awaiting_payment]
52
+ map status: :processing, sub_status: %i[fraud_check_passed fraud_check_failed ready_to_pack packing assigning_carrier]
53
+ map status: :shipped, sub_status: %i[waiting_for_pickup in_transit out_for_delivery]
54
+ map status: :returned, sub_status: %i[inspection return_processing return_complete]
55
+
56
+ # Automatically reset micro state when primary state changes
57
+ when_primary_changes reset_micro: true
58
+
59
+ # Callbacks
60
+ before_primary_transition :shipped do
61
+ raise "Cannot ship unpaid order" unless paid?
62
+ end
63
+
64
+ after_primary_transition :delivered do
65
+ puts "Order #{id} has been delivered!"
66
+ end
67
+ end
68
+ end
69
+
70
+ # Example usage
71
+ if __FILE__ == $PROGRAM_NAME
72
+ puts "=== Hybrid State Model Example ===\n\n"
73
+
74
+ # Create an order
75
+ order = Order.create!(status: :pending, sub_status: :awaiting_payment, paid: false)
76
+ puts "1. Created order: #{order.status} / #{order.sub_status}"
77
+
78
+ # Mark as paid and move to processing
79
+ order.update!(paid: true)
80
+ order.promote!(:processing)
81
+ puts "2. Promoted to: #{order.status} / #{order.sub_status}"
82
+
83
+ # Advance through micro states
84
+ order.advance!(:ready_to_pack)
85
+ puts "3. Advanced to: #{order.status} / #{order.sub_status}"
86
+
87
+ order.advance!(:packing)
88
+ puts "4. Advanced to: #{order.status} / #{order.sub_status}"
89
+
90
+ # Transition both at once
91
+ order.transition!(primary: :shipped, micro: :waiting_for_pickup)
92
+ puts "5. Transitioned to: #{order.status} / #{order.sub_status}"
93
+
94
+ # Continue through shipping micro states
95
+ order.advance!(:in_transit)
96
+ puts "6. Advanced to: #{order.status} / #{order.sub_status}"
97
+
98
+ order.advance!(:out_for_delivery)
99
+ puts "7. Advanced to: #{order.status} / #{order.sub_status}"
100
+
101
+ # Final state
102
+ order.promote!(:delivered)
103
+ puts "8. Final state: #{order.status} / #{order.sub_status}"
104
+
105
+ # Query examples
106
+ puts "\n=== Query Examples ==="
107
+ puts "Orders in 'shipped' state: #{Order.in_primary(:shipped).count}"
108
+ puts "Orders with micro state 'in_transit': #{Order.in_micro(:in_transit).count}"
109
+
110
+ puts "\n=== Example Complete ==="
111
+ end
112
+
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/hybrid_state_model/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "hybrid-state-model"
7
+ spec.version = HybridStateModel::VERSION
8
+ spec.authors = ["Your Name"]
9
+ spec.email = ["your.email@example.com"]
10
+
11
+ spec.summary = "A two-layer hierarchical state system for Ruby models"
12
+ spec.description = <<~DESC
13
+ hybrid-state-model introduces a revolutionary two-layer state system:
14
+ Primary State (high-level lifecycle) and Secondary Micro-State (small steps within primary state).
15
+ Perfect for complex workflows that are too complex for flat state machines but don't need full orchestration.
16
+ DESC
17
+ spec.homepage = "https://github.com/afshmini/hybrid-state-model"
18
+ spec.license = "MIT"
19
+ spec.required_ruby_version = ">= 2.7.0"
20
+
21
+ spec.metadata["homepage_uri"] = spec.homepage
22
+ spec.metadata["source_code_uri"] = spec.homepage
23
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
24
+
25
+ # Specify which files should be added to the gem when it is released.
26
+ spec.files = Dir.chdir(__dir__) do
27
+ `git ls-files -z`.split("\x0").reject do |f|
28
+ (File.expand_path(f) == __FILE__) ||
29
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
30
+ end
31
+ end
32
+ spec.bindir = "exe"
33
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
34
+ spec.require_paths = ["lib"]
35
+
36
+ spec.add_dependency "activerecord", ">= 5.2.0"
37
+ spec.add_development_dependency "rspec", "~> 3.12"
38
+ spec.add_development_dependency "sqlite3", "~> 1.6"
39
+ spec.add_development_dependency "rake", "~> 13.0"
40
+ spec.add_development_dependency "rubocop", "~> 1.21"
41
+ end
42
+
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HybridStateModel
4
+ module Core
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
7
+ end
8
+
9
+ module ClassMethods
10
+ def hybrid_state_model?
11
+ true
12
+ end
13
+ end
14
+
15
+ def primary_state
16
+ state_machine.primary_field
17
+ end
18
+
19
+ def micro_state
20
+ state_machine.micro_field
21
+ end
22
+
23
+ def state_machine
24
+ self.class.state_machine
25
+ end
26
+
27
+ def primary_state_value
28
+ send(primary_state)
29
+ end
30
+
31
+ def micro_state_value
32
+ send(micro_state)
33
+ end
34
+
35
+ def valid_primary_state?(state)
36
+ state_machine.valid_primary_state?(state)
37
+ end
38
+
39
+ def valid_micro_state?(state)
40
+ state_machine.valid_micro_state?(state, primary_state_value)
41
+ end
42
+
43
+ def can_transition_to_primary?(new_state)
44
+ state_machine.can_transition_to_primary?(self, new_state)
45
+ end
46
+
47
+ def can_transition_to_micro?(new_micro_state)
48
+ state_machine.can_transition_to_micro?(self, new_micro_state)
49
+ end
50
+ end
51
+ end
52
+
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module HybridStateModel
6
+ module Metrics
7
+ def self.included(base)
8
+ base.extend(ClassMethods)
9
+ end
10
+
11
+ module ClassMethods
12
+ def has_state_metrics?
13
+ column_names.include?("state_metrics")
14
+ end
15
+ end
16
+
17
+ def track_state_changes
18
+ return unless persisted?
19
+
20
+ @state_changed = primary_state_changed? || micro_state_changed?
21
+ @old_primary = primary_state_was if primary_state_changed?
22
+ @old_micro = micro_state_was if micro_state_changed?
23
+ end
24
+
25
+ def record_state_metrics
26
+ return unless @state_changed
27
+
28
+ metrics = state_metrics_hash
29
+ current_time = Time.now
30
+
31
+ # Record exit time for old state
32
+ if @old_primary
33
+ metrics[@old_primary.to_s] ||= {}
34
+ metrics[@old_primary.to_s]["exited_at"] ||= current_time.iso8601
35
+ metrics[@old_primary.to_s]["duration"] ||= 0
36
+ if metrics[@old_primary.to_s]["entered_at"]
37
+ duration = current_time - Time.parse(metrics[@old_primary.to_s]["entered_at"])
38
+ metrics[@old_primary.to_s]["duration"] += duration
39
+ end
40
+ end
41
+
42
+ # Record entry time for new state
43
+ new_primary = primary_state_value
44
+ if new_primary
45
+ metrics[new_primary.to_s] ||= {}
46
+ metrics[new_primary.to_s]["entered_at"] = current_time.iso8601
47
+ metrics[new_primary.to_s]["duration"] ||= 0
48
+ end
49
+
50
+ # Record micro state metrics
51
+ if @old_micro && @old_primary
52
+ micro_key = "#{@old_primary}:#{@old_micro}"
53
+ metrics[micro_key] ||= {}
54
+ metrics[micro_key]["exited_at"] ||= current_time.iso8601
55
+ if metrics[micro_key]["entered_at"]
56
+ duration = current_time - Time.parse(metrics[micro_key]["entered_at"])
57
+ metrics[micro_key]["duration"] ||= 0
58
+ metrics[micro_key]["duration"] += duration
59
+ end
60
+ end
61
+
62
+ if micro_state_value && new_primary
63
+ micro_key = "#{new_primary}:#{micro_state_value}"
64
+ metrics[micro_key] ||= {}
65
+ metrics[micro_key]["entered_at"] = current_time.iso8601
66
+ metrics[micro_key]["duration"] ||= 0
67
+ end
68
+
69
+ # Store metrics if column exists
70
+ if self.class.has_state_metrics?
71
+ self.state_metrics = metrics.to_json
72
+ end
73
+
74
+ @state_changed = false
75
+ end
76
+
77
+ def state_metrics
78
+ return {} unless self.class.has_state_metrics?
79
+
80
+ json = read_attribute(:state_metrics)
81
+ return {} if json.blank?
82
+
83
+ JSON.parse(json)
84
+ rescue JSON::ParserError
85
+ {}
86
+ end
87
+
88
+ def state_metrics_hash
89
+ state_metrics
90
+ end
91
+
92
+ def time_in_primary_state(state = nil)
93
+ state ||= primary_state_value
94
+ metrics = state_metrics_hash[state.to_s] || {}
95
+ metrics["duration"] || 0
96
+ end
97
+
98
+ def time_in_micro_state(primary_state, micro_state)
99
+ key = "#{primary_state}:#{micro_state}"
100
+ metrics = state_metrics_hash[key] || {}
101
+ metrics["duration"] || 0
102
+ end
103
+
104
+ def current_state_duration
105
+ return 0 unless persisted?
106
+
107
+ current_primary = primary_state_value
108
+ return 0 unless current_primary
109
+
110
+ metrics = state_metrics_hash[current_primary.to_s] || {}
111
+ entered_at = metrics["entered_at"]
112
+
113
+ return 0 unless entered_at
114
+
115
+ base_duration = metrics["duration"] || 0
116
+ time_since_entry = Time.now - Time.parse(entered_at)
117
+ base_duration + time_since_entry
118
+ end
119
+
120
+ private
121
+
122
+ def primary_state_changed?
123
+ send("#{primary_state}_changed?")
124
+ end
125
+
126
+ def micro_state_changed?
127
+ send("#{micro_state}_changed?")
128
+ end
129
+
130
+ def primary_state_was
131
+ send("#{primary_state}_was")
132
+ end
133
+
134
+ def micro_state_was
135
+ send("#{micro_state}_was")
136
+ end
137
+ end
138
+ end
139
+
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HybridStateModel
4
+ module QueryMethods
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
7
+ end
8
+
9
+ module ClassMethods
10
+ def in_primary(*states)
11
+ field = state_machine.primary_field
12
+ where(field => states.map(&:to_sym))
13
+ end
14
+
15
+ def in_micro(*states)
16
+ field = state_machine.micro_field
17
+ where(field => states.map(&:to_sym))
18
+ end
19
+
20
+ def with_primary_and_micro(primary:, micro:)
21
+ in_primary(primary).in_micro(micro)
22
+ end
23
+
24
+ def without_micro
25
+ field = state_machine.micro_field
26
+ where(field => nil)
27
+ end
28
+
29
+ def with_micro
30
+ field = state_machine.micro_field
31
+ where.not(field => nil)
32
+ end
33
+ end
34
+ end
35
+ end
36
+
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HybridStateModel
4
+ class StateMachine
5
+ attr_reader :model_class, :primary_field, :micro_field, :primary_states, :micro_states,
6
+ :state_mappings, :callbacks, :auto_reset_micro
7
+
8
+ def initialize(model_class)
9
+ @model_class = model_class
10
+ @primary_states = []
11
+ @micro_states = []
12
+ @state_mappings = {}
13
+ @callbacks = {
14
+ before_primary_transition: {},
15
+ after_primary_transition: {},
16
+ before_micro_transition: {},
17
+ after_micro_transition: {}
18
+ }
19
+ @auto_reset_micro = false
20
+ end
21
+
22
+ def primary(field_name, states)
23
+ @primary_field = field_name.to_sym
24
+ @primary_states = states.map(&:to_sym)
25
+ end
26
+
27
+ def micro(field_name, states)
28
+ @micro_field = field_name.to_sym
29
+ @micro_states = states.map(&:to_sym)
30
+ end
31
+
32
+ def map(primary_state:, micro_states:)
33
+ primary_key = primary_state.to_sym
34
+ micro_list = Array(micro_states).map(&:to_sym)
35
+ @state_mappings[primary_key] = micro_list
36
+ end
37
+
38
+ def before_primary_transition(states, &block)
39
+ Array(states).each do |state|
40
+ @callbacks[:before_primary_transition][state.to_sym] ||= []
41
+ @callbacks[:before_primary_transition][state.to_sym] << block
42
+ end
43
+ end
44
+
45
+ def after_primary_transition(states, &block)
46
+ Array(states).each do |state|
47
+ @callbacks[:after_primary_transition][state.to_sym] ||= []
48
+ @callbacks[:after_primary_transition][state.to_sym] << block
49
+ end
50
+ end
51
+
52
+ def before_micro_transition(states, &block)
53
+ Array(states).each do |state|
54
+ @callbacks[:before_micro_transition][state.to_sym] ||= []
55
+ @callbacks[:before_micro_transition][state.to_sym] << block
56
+ end
57
+ end
58
+
59
+ def after_micro_transition(states, &block)
60
+ Array(states).each do |state|
61
+ @callbacks[:after_micro_transition][state.to_sym] ||= []
62
+ @callbacks[:after_micro_transition][state.to_sym] << block
63
+ end
64
+ end
65
+
66
+ def when_primary_changes(reset_micro: false)
67
+ @auto_reset_micro = reset_micro
68
+ end
69
+
70
+ def setup!
71
+ raise Error, "Primary state field must be defined" unless @primary_field
72
+ raise Error, "Micro state field must be defined" unless @micro_field
73
+ raise Error, "Primary states must be defined" if @primary_states.empty?
74
+
75
+ @model_class.include(Core)
76
+ @model_class.include(TransitionMethods)
77
+ @model_class.include(QueryMethods)
78
+ @model_class.include(Metrics)
79
+
80
+ setup_validations
81
+ setup_callbacks
82
+ end
83
+
84
+ def valid_primary_state?(state)
85
+ @primary_states.include?(state.to_sym)
86
+ end
87
+
88
+ def valid_micro_state?(micro_state, primary_state)
89
+ return true if micro_state.nil?
90
+
91
+ primary_key = primary_state.to_sym
92
+ allowed_micros = @state_mappings[primary_key]
93
+
94
+ return true if allowed_micros.nil? || allowed_micros.empty?
95
+
96
+ allowed_micros.include?(micro_state.to_sym)
97
+ end
98
+
99
+ def can_transition_to_primary?(record, new_state)
100
+ return false unless valid_primary_state?(new_state)
101
+
102
+ # Check callbacks
103
+ callbacks = @callbacks[:before_primary_transition][new_state.to_sym] || []
104
+ callbacks.all? { |cb| record.instance_exec(&cb) != false }
105
+ end
106
+
107
+ def can_transition_to_micro?(record, new_micro_state)
108
+ return false if new_micro_state.nil?
109
+
110
+ current_primary = record.send(@primary_field)
111
+ return false unless valid_micro_state?(new_micro_state, current_primary)
112
+
113
+ # Check callbacks
114
+ callbacks = @callbacks[:before_micro_transition][new_micro_state.to_sym] || []
115
+ callbacks.all? { |cb| record.instance_exec(&cb) != false }
116
+ end
117
+
118
+ private
119
+
120
+ def setup_validations
121
+ @model_class.validate :validate_hybrid_state
122
+
123
+ @model_class.define_method(:validate_hybrid_state) do
124
+ current_primary = send(state_machine.primary_field)
125
+ current_micro = send(state_machine.micro_field)
126
+
127
+ unless state_machine.valid_primary_state?(current_primary)
128
+ errors.add(state_machine.primary_field, "is not a valid primary state")
129
+ end
130
+
131
+ unless state_machine.valid_micro_state?(current_micro, current_primary)
132
+ errors.add(state_machine.micro_field, "is not valid for primary state #{current_primary}")
133
+ end
134
+ end
135
+ end
136
+
137
+ def setup_callbacks
138
+ @model_class.before_save :track_state_changes
139
+ @model_class.after_save :record_state_metrics
140
+ end
141
+ end
142
+ end
143
+
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HybridStateModel
4
+ module TransitionMethods
5
+ def promote!(new_primary_state, options = {})
6
+ new_state = new_primary_state.to_sym
7
+
8
+ unless can_transition_to_primary?(new_state)
9
+ raise InvalidTransitionError, "Cannot transition to primary state: #{new_state}"
10
+ end
11
+
12
+ old_primary = primary_state_value
13
+ old_micro = micro_state_value
14
+
15
+ # Run before callbacks
16
+ run_callbacks(:before_primary_transition, new_state)
17
+
18
+ # Update primary state
19
+ send("#{primary_state}=", new_state)
20
+
21
+ # Reset micro state if configured or if new state doesn't allow current micro
22
+ if state_machine.auto_reset_micro || !valid_micro_state?(old_micro, new_state)
23
+ send("#{micro_state}=", nil) unless options[:keep_micro]
24
+ end
25
+
26
+ # Save if not already in a transaction
27
+ save! unless options[:skip_save]
28
+
29
+ # Run after callbacks
30
+ run_callbacks(:after_primary_transition, new_state)
31
+
32
+ self
33
+ end
34
+
35
+ def advance!(new_micro_state, options = {})
36
+ new_state = new_micro_state.to_sym
37
+
38
+ unless can_transition_to_micro?(new_state)
39
+ raise InvalidTransitionError, "Cannot transition to micro state: #{new_state}"
40
+ end
41
+
42
+ # Run before callbacks
43
+ run_callbacks(:before_micro_transition, new_state)
44
+
45
+ # Update micro state
46
+ send("#{micro_state}=", new_state)
47
+
48
+ # Save if not already in a transaction
49
+ save! unless options[:skip_save]
50
+
51
+ # Run after callbacks
52
+ run_callbacks(:after_micro_transition, new_state)
53
+
54
+ self
55
+ end
56
+
57
+ def reset_micro!(options = {})
58
+ send("#{micro_state}=", nil)
59
+ save! unless options[:skip_save]
60
+ self
61
+ end
62
+
63
+ def transition!(primary: nil, micro: nil, options = {})
64
+ if primary && primary != primary_state_value
65
+ promote!(primary, options.merge(skip_save: true))
66
+ end
67
+
68
+ if micro && micro != micro_state_value
69
+ advance!(micro, options)
70
+ elsif primary && !micro
71
+ save! unless options[:skip_save]
72
+ end
73
+
74
+ self
75
+ end
76
+
77
+ private
78
+
79
+ def run_callbacks(callback_type, state)
80
+ callbacks = state_machine.callbacks[callback_type][state] || []
81
+ callbacks.each { |cb| instance_exec(&cb) }
82
+ end
83
+ end
84
+ end
85
+
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HybridStateModel
4
+ VERSION = "0.1.0"
5
+ end
6
+
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hybrid_state_model/version"
4
+ require_relative "hybrid_state_model/core"
5
+ require_relative "hybrid_state_model/state_machine"
6
+ require_relative "hybrid_state_model/query_methods"
7
+ require_relative "hybrid_state_model/metrics"
8
+ require_relative "hybrid_state_model/transition_methods"
9
+
10
+ module HybridStateModel
11
+ class Error < StandardError; end
12
+ class InvalidTransitionError < Error; end
13
+ class InvalidStateError < Error; end
14
+ class InvalidMicroStateError < Error; end
15
+
16
+ def self.included(base)
17
+ base.extend(ClassMethods)
18
+ end
19
+
20
+ module ClassMethods
21
+ def hybrid_state(&block)
22
+ @state_machine = StateMachine.new(self)
23
+ @state_machine.instance_eval(&block)
24
+ @state_machine.setup!
25
+ end
26
+
27
+ def state_machine
28
+ @state_machine
29
+ end
30
+ end
31
+ end
32
+
metadata ADDED
@@ -0,0 +1,132 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hybrid-state-model
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Your Name
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-11-20 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 5.2.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 5.2.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.12'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.12'
41
+ - !ruby/object:Gem::Dependency
42
+ name: sqlite3
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.6'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.6'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '13.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '13.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubocop
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.21'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.21'
83
+ description: |
84
+ hybrid-state-model introduces a revolutionary two-layer state system:
85
+ Primary State (high-level lifecycle) and Secondary Micro-State (small steps within primary state).
86
+ Perfect for complex workflows that are too complex for flat state machines but don't need full orchestration.
87
+ email:
88
+ - your.email@example.com
89
+ executables: []
90
+ extensions: []
91
+ extra_rdoc_files: []
92
+ files:
93
+ - CHANGELOG.md
94
+ - LICENSE.txt
95
+ - README.md
96
+ - Rakefile
97
+ - examples/order_example.rb
98
+ - hybrid-state-model.gemspec
99
+ - lib/hybrid_state_model.rb
100
+ - lib/hybrid_state_model/core.rb
101
+ - lib/hybrid_state_model/metrics.rb
102
+ - lib/hybrid_state_model/query_methods.rb
103
+ - lib/hybrid_state_model/state_machine.rb
104
+ - lib/hybrid_state_model/transition_methods.rb
105
+ - lib/hybrid_state_model/version.rb
106
+ homepage: https://github.com/afshmini/hybrid-state-model
107
+ licenses:
108
+ - MIT
109
+ metadata:
110
+ homepage_uri: https://github.com/afshmini/hybrid-state-model
111
+ source_code_uri: https://github.com/afshmini/hybrid-state-model
112
+ changelog_uri: https://github.com/afshmini/hybrid-state-model/blob/main/CHANGELOG.md
113
+ post_install_message:
114
+ rdoc_options: []
115
+ require_paths:
116
+ - lib
117
+ required_ruby_version: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: 2.7.0
122
+ required_rubygems_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: '0'
127
+ requirements: []
128
+ rubygems_version: 3.5.20
129
+ signing_key:
130
+ specification_version: 4
131
+ summary: A two-layer hierarchical state system for Ruby models
132
+ test_files: []