lite_state 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 +7 -0
- data/.rubocop.yml +9 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/README.md +496 -0
- data/Rakefile +12 -0
- data/examples/ecommerce_order.rb +300 -0
- data/examples/employee_lifecycle.rb +131 -0
- data/lib/lite_state/version.rb +5 -0
- data/lib/lite_state.rb +276 -0
- data/sig/lite_state.rbs +4 -0
- metadata +145 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# Example 2: E-Commerce Order (Multiple State Columns)
|
|
2
|
+
#
|
|
3
|
+
# This example demonstrates a sophisticated e-commerce order system using
|
|
4
|
+
# multiple independent state machines to manage different aspects of an order:
|
|
5
|
+
# - Order lifecycle (pending -> processing -> completed -> cancelled)
|
|
6
|
+
# - Payment lifecycle (unpaid -> authorized -> paid -> refunded/failed)
|
|
7
|
+
# - Fulfillment lifecycle (unfulfilled -> preparing -> shipped -> delivered -> returned)
|
|
8
|
+
#
|
|
9
|
+
# Key features demonstrated:
|
|
10
|
+
# - Multiple state columns working independently
|
|
11
|
+
# - Guards that check conditions across different state machines
|
|
12
|
+
# - Callbacks that trigger transitions in other state machines
|
|
13
|
+
# - Complex business rules (refund windows, return policies)
|
|
14
|
+
# - Comprehensive notification system
|
|
15
|
+
|
|
16
|
+
class Order < ApplicationRecord
|
|
17
|
+
include LiteState
|
|
18
|
+
|
|
19
|
+
enum :status, {
|
|
20
|
+
pending: "pending",
|
|
21
|
+
processing: "processing",
|
|
22
|
+
completed: "completed",
|
|
23
|
+
cancelled: "cancelled"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
enum :payment_status, {
|
|
27
|
+
unpaid: "unpaid",
|
|
28
|
+
authorized: "authorized",
|
|
29
|
+
paid: "paid",
|
|
30
|
+
refunded: "refunded",
|
|
31
|
+
failed: "failed"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
enum :fulfillment_status, {
|
|
35
|
+
unfulfilled: "unfulfilled",
|
|
36
|
+
preparing: "preparing",
|
|
37
|
+
shipped: "shipped",
|
|
38
|
+
delivered: "delivered",
|
|
39
|
+
returned: "returned"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Set default for main order lifecycle
|
|
43
|
+
state_column :status
|
|
44
|
+
|
|
45
|
+
# Order lifecycle transitions
|
|
46
|
+
transition :process,
|
|
47
|
+
from: :pending,
|
|
48
|
+
to: :processing,
|
|
49
|
+
timestamp: :processing_at do
|
|
50
|
+
notify_customer(:order_processing)
|
|
51
|
+
allocate_inventory
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
transition :complete,
|
|
55
|
+
from: :processing,
|
|
56
|
+
to: :completed,
|
|
57
|
+
timestamp: :completed_at,
|
|
58
|
+
guard: -> { payment_status == "paid" && fulfillment_status == "delivered" }
|
|
59
|
+
|
|
60
|
+
transition :cancel,
|
|
61
|
+
from: [:pending, :processing],
|
|
62
|
+
to: :cancelled,
|
|
63
|
+
timestamp: :cancelled_at do
|
|
64
|
+
release_inventory
|
|
65
|
+
notify_customer(:order_cancelled)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Payment lifecycle transitions
|
|
69
|
+
transition :authorize_payment,
|
|
70
|
+
from: :unpaid,
|
|
71
|
+
to: :authorized,
|
|
72
|
+
column: :payment_status,
|
|
73
|
+
timestamp: :payment_authorized_at do
|
|
74
|
+
PaymentProcessor.authorize(self)
|
|
75
|
+
notify_customer(:payment_authorized)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
transition :capture_payment,
|
|
79
|
+
from: :authorized,
|
|
80
|
+
to: :paid,
|
|
81
|
+
column: :payment_status,
|
|
82
|
+
timestamp: :paid_at do
|
|
83
|
+
PaymentProcessor.capture(self)
|
|
84
|
+
notify_customer(:payment_captured)
|
|
85
|
+
trigger_fulfillment
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
transition :refund_payment,
|
|
89
|
+
from: :paid,
|
|
90
|
+
to: :refunded,
|
|
91
|
+
column: :payment_status,
|
|
92
|
+
timestamp: :refunded_at,
|
|
93
|
+
guard: :can_refund? do
|
|
94
|
+
PaymentProcessor.refund(self)
|
|
95
|
+
notify_customer(:payment_refunded)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
transition :fail_payment,
|
|
99
|
+
from: [:unpaid, :authorized],
|
|
100
|
+
to: :failed,
|
|
101
|
+
column: :payment_status do
|
|
102
|
+
notify_customer(:payment_failed)
|
|
103
|
+
cancel if pending? || processing?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Fulfillment lifecycle transitions
|
|
107
|
+
transition :prepare_shipment,
|
|
108
|
+
from: :unfulfilled,
|
|
109
|
+
to: :preparing,
|
|
110
|
+
column: :fulfillment_status,
|
|
111
|
+
guard: -> { paid? },
|
|
112
|
+
timestamp: :preparing_at do
|
|
113
|
+
notify_warehouse(:prepare_order, order_id: id)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
transition :ship_order,
|
|
117
|
+
from: :preparing,
|
|
118
|
+
to: :shipped,
|
|
119
|
+
column: :fulfillment_status,
|
|
120
|
+
timestamp: :shipped_at do
|
|
121
|
+
generate_tracking_number
|
|
122
|
+
notify_customer(:order_shipped, tracking_number:)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
transition :deliver_order,
|
|
126
|
+
from: :shipped,
|
|
127
|
+
to: :delivered,
|
|
128
|
+
column: :fulfillment_status,
|
|
129
|
+
timestamp: :delivered_at do
|
|
130
|
+
notify_customer(:order_delivered)
|
|
131
|
+
complete if processing?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
transition :return_order,
|
|
135
|
+
from: [:shipped, :delivered],
|
|
136
|
+
to: :returned,
|
|
137
|
+
column: :fulfillment_status,
|
|
138
|
+
timestamp: :returned_at,
|
|
139
|
+
guard: :can_return? do
|
|
140
|
+
initiate_return_process
|
|
141
|
+
refund_payment if paid?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def can_refund?
|
|
147
|
+
return false if refunded?
|
|
148
|
+
return true if returned?
|
|
149
|
+
paid_at && paid_at >= 90.days.ago
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def can_return?
|
|
153
|
+
delivered_at && delivered_at >= 30.days.ago
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def notify_customer(event, **options)
|
|
157
|
+
OrderMailer.public_send(event, self, **options).deliver_later
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def notify_warehouse(event, **options)
|
|
161
|
+
WarehouseNotification.create!(event:, order: self, metadata: options)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Usage Examples
|
|
166
|
+
# ==============
|
|
167
|
+
|
|
168
|
+
# Example 1: Successful Order Flow
|
|
169
|
+
# ---------------------------------
|
|
170
|
+
|
|
171
|
+
# Create order
|
|
172
|
+
order = Order.create!(
|
|
173
|
+
status: :pending,
|
|
174
|
+
payment_status: :unpaid,
|
|
175
|
+
fulfillment_status: :unfulfilled
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Process order
|
|
179
|
+
order.process
|
|
180
|
+
# => status: :processing, processing_at: 2025-01-15 10:00:00 UTC
|
|
181
|
+
# => Allocates inventory, notifies customer
|
|
182
|
+
|
|
183
|
+
# Authorize payment
|
|
184
|
+
order.authorize_payment
|
|
185
|
+
# => payment_status: :authorized, payment_authorized_at: 2025-01-15 10:01:00 UTC
|
|
186
|
+
# => Processes authorization with payment gateway
|
|
187
|
+
|
|
188
|
+
# Capture payment (triggers fulfillment)
|
|
189
|
+
order.capture_payment
|
|
190
|
+
# => payment_status: :paid, paid_at: 2025-01-15 10:05:00 UTC
|
|
191
|
+
# => Captures payment, notifies customer, triggers fulfillment
|
|
192
|
+
|
|
193
|
+
# Prepare for shipping (guard checks payment is captured)
|
|
194
|
+
order.prepare_shipment
|
|
195
|
+
# => fulfillment_status: :preparing, preparing_at: 2025-01-15 11:00:00 UTC
|
|
196
|
+
# => Notifies warehouse to prepare order
|
|
197
|
+
|
|
198
|
+
# Ship order
|
|
199
|
+
order.ship_order
|
|
200
|
+
# => fulfillment_status: :shipped, shipped_at: 2025-01-16 09:00:00 UTC
|
|
201
|
+
# => Generates tracking number, notifies customer
|
|
202
|
+
|
|
203
|
+
# Deliver order
|
|
204
|
+
order.deliver_order
|
|
205
|
+
# => fulfillment_status: :delivered, delivered_at: 2025-01-18 14:30:00 UTC
|
|
206
|
+
# => Also completes the order (status: :completed)
|
|
207
|
+
|
|
208
|
+
# Final state
|
|
209
|
+
order.reload
|
|
210
|
+
order.status # => "completed"
|
|
211
|
+
order.payment_status # => "paid"
|
|
212
|
+
order.fulfillment_status # => "delivered"
|
|
213
|
+
|
|
214
|
+
# Example 2: Payment Failure Flow
|
|
215
|
+
# --------------------------------
|
|
216
|
+
|
|
217
|
+
order = Order.create!(
|
|
218
|
+
status: :pending,
|
|
219
|
+
payment_status: :unpaid,
|
|
220
|
+
fulfillment_status: :unfulfilled
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
order.process
|
|
224
|
+
order.authorize_payment
|
|
225
|
+
|
|
226
|
+
# Payment capture fails
|
|
227
|
+
order.fail_payment
|
|
228
|
+
# => payment_status: :failed
|
|
229
|
+
# => Also cancels the order (status: :cancelled)
|
|
230
|
+
# => Notifies customer of payment failure
|
|
231
|
+
|
|
232
|
+
# Example 3: Customer Return Flow
|
|
233
|
+
# --------------------------------
|
|
234
|
+
|
|
235
|
+
# Order has been delivered
|
|
236
|
+
order.status # => "completed"
|
|
237
|
+
order.payment_status # => "paid"
|
|
238
|
+
order.fulfillment_status # => "delivered"
|
|
239
|
+
order.delivered_at # => 5 days ago
|
|
240
|
+
|
|
241
|
+
# Customer initiates return (within 30 day window)
|
|
242
|
+
order.can_transition?(:return_order) # => true
|
|
243
|
+
order.return_order
|
|
244
|
+
# => fulfillment_status: :returned
|
|
245
|
+
# => Automatically triggers refund_payment
|
|
246
|
+
# => payment_status: :refunded
|
|
247
|
+
# => Initiates return process, notifies customer
|
|
248
|
+
|
|
249
|
+
# Example 4: Order Cancellation
|
|
250
|
+
# ------------------------------
|
|
251
|
+
|
|
252
|
+
order = Order.create!(
|
|
253
|
+
status: :processing,
|
|
254
|
+
payment_status: :authorized,
|
|
255
|
+
fulfillment_status: :unfulfilled
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
order.cancel
|
|
259
|
+
# => status: :cancelled, cancelled_at: 2025-01-15 15:00:00 UTC
|
|
260
|
+
# => Releases inventory, notifies customer
|
|
261
|
+
|
|
262
|
+
# Example 5: Guard Conditions
|
|
263
|
+
# ----------------------------
|
|
264
|
+
|
|
265
|
+
# Cannot prepare shipment without payment
|
|
266
|
+
order = Order.create!(
|
|
267
|
+
status: :processing,
|
|
268
|
+
payment_status: :unpaid,
|
|
269
|
+
fulfillment_status: :unfulfilled
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
order.can_transition?(:prepare_shipment) # => false (payment not captured)
|
|
273
|
+
order.prepare_shipment # => raises LiteState::TransitionError
|
|
274
|
+
|
|
275
|
+
# Cannot complete order without delivery
|
|
276
|
+
order.update!(payment_status: :paid, fulfillment_status: :shipped)
|
|
277
|
+
order.can_transition?(:complete) # => false (not delivered yet)
|
|
278
|
+
|
|
279
|
+
# Cannot refund after 90 days
|
|
280
|
+
order.update!(paid_at: 100.days.ago)
|
|
281
|
+
order.can_transition?(:refund_payment) # => false
|
|
282
|
+
order.refund_payment # => raises LiteState::TransitionError
|
|
283
|
+
|
|
284
|
+
# Example 6: Cross-State Machine Interactions
|
|
285
|
+
# --------------------------------------------
|
|
286
|
+
|
|
287
|
+
# Payment failure triggers order cancellation
|
|
288
|
+
order.fail_payment
|
|
289
|
+
# => payment_status: :failed
|
|
290
|
+
# => Automatically calls cancel if order is pending/processing
|
|
291
|
+
|
|
292
|
+
# Delivery triggers order completion
|
|
293
|
+
order.deliver_order
|
|
294
|
+
# => fulfillment_status: :delivered
|
|
295
|
+
# => Automatically calls complete if order is processing and paid
|
|
296
|
+
|
|
297
|
+
# Return triggers refund
|
|
298
|
+
order.return_order
|
|
299
|
+
# => fulfillment_status: :returned
|
|
300
|
+
# => Automatically calls refund_payment if paid
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Example 1: Employee Lifecycle (Single State Column)
|
|
2
|
+
#
|
|
3
|
+
# This example demonstrates a complete employee lifecycle management system
|
|
4
|
+
# using a single state column to track the employee's status through various
|
|
5
|
+
# stages: from creation, through invitation, enrollment, suspension, and
|
|
6
|
+
# potential termination or reactivation.
|
|
7
|
+
#
|
|
8
|
+
# Key features demonstrated:
|
|
9
|
+
# - Single state column (state) managing the entire lifecycle
|
|
10
|
+
# - Timestamp tracking for each transition
|
|
11
|
+
# - Guard conditions for business rules (reactivation eligibility)
|
|
12
|
+
# - Callbacks for notifications and system actions
|
|
13
|
+
# - Complex reactivation logic based on termination date
|
|
14
|
+
|
|
15
|
+
class Employee < ApplicationRecord
|
|
16
|
+
include LiteState
|
|
17
|
+
|
|
18
|
+
state_column :state
|
|
19
|
+
|
|
20
|
+
enum :state, {
|
|
21
|
+
created: "created",
|
|
22
|
+
invited: "invited",
|
|
23
|
+
enrolled: "enrolled",
|
|
24
|
+
suspended: "suspended",
|
|
25
|
+
terminated: "terminated",
|
|
26
|
+
reset_pin: "reset_pin"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# Invitation flow
|
|
30
|
+
transition :invite, from: :created, to: :invited, timestamp: :invited_on do
|
|
31
|
+
notify_employee(:employee_invitation)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Suspension
|
|
35
|
+
transition :suspend, from: :enrolled, to: :suspended, timestamp: :suspended_on do
|
|
36
|
+
notify_employee(:employee_suspension)
|
|
37
|
+
disable_access
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Termination
|
|
41
|
+
transition :terminate,
|
|
42
|
+
from: [:enrolled, :suspended],
|
|
43
|
+
to: :terminated,
|
|
44
|
+
timestamp: :terminated_on do
|
|
45
|
+
notify_employee(:employee_termination)
|
|
46
|
+
disable_access
|
|
47
|
+
archive_data
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Reactivation with business rule
|
|
51
|
+
transition :reactivate,
|
|
52
|
+
from: [:suspended, :terminated],
|
|
53
|
+
to: :enrolled,
|
|
54
|
+
timestamp: :enrolled_on,
|
|
55
|
+
guard: :eligible_for_reactivation? do
|
|
56
|
+
notify_employee(:employee_reactivation)
|
|
57
|
+
restore_access
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# PIN reset
|
|
61
|
+
transition :reset_pin, from: [:enrolled, :reset_pin], to: :reset_pin do
|
|
62
|
+
pin_code = generate_otp!
|
|
63
|
+
notify_employee(:employee_reset_password, additional_keywords: {temporary_reset_pin: pin_code})
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def eligible_for_reactivation?
|
|
69
|
+
return true if suspended?
|
|
70
|
+
return true unless terminated_on
|
|
71
|
+
terminated_on >= 90.days.ago.to_date
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def notify_employee(message_type, additional_keywords: {})
|
|
75
|
+
Message.create_message(
|
|
76
|
+
to: person,
|
|
77
|
+
message_type:,
|
|
78
|
+
keywords: {
|
|
79
|
+
first_name: person.first_name,
|
|
80
|
+
employer_name: company.name
|
|
81
|
+
}.merge(additional_keywords)
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Usage Examples
|
|
87
|
+
# ==============
|
|
88
|
+
|
|
89
|
+
# Create and invite an employee
|
|
90
|
+
employee = Employee.create!(state: :created, person: person, company: company)
|
|
91
|
+
employee.invite
|
|
92
|
+
# => state: :invited, invited_on: 2025-01-15 10:00:00 UTC
|
|
93
|
+
# => Sends invitation notification
|
|
94
|
+
|
|
95
|
+
# Enroll the employee (assumes separate enrollment transition defined)
|
|
96
|
+
employee.enroll
|
|
97
|
+
# => state: :enrolled, enrolled_on: 2025-01-16 09:00:00 UTC
|
|
98
|
+
|
|
99
|
+
# Suspend the employee
|
|
100
|
+
employee.suspend
|
|
101
|
+
# => state: :suspended, suspended_on: 2025-02-01 14:30:00 UTC
|
|
102
|
+
# => Sends suspension notification
|
|
103
|
+
# => Disables system access
|
|
104
|
+
|
|
105
|
+
# Reactivate from suspension (guard passes)
|
|
106
|
+
employee.reactivate
|
|
107
|
+
# => state: :enrolled, enrolled_on: 2025-02-10 08:00:00 UTC
|
|
108
|
+
# => Sends reactivation notification
|
|
109
|
+
# => Restores system access
|
|
110
|
+
|
|
111
|
+
# Terminate the employee
|
|
112
|
+
employee.terminate
|
|
113
|
+
# => state: :terminated, terminated_on: 2025-03-01 17:00:00 UTC
|
|
114
|
+
# => Sends termination notification
|
|
115
|
+
# => Disables system access
|
|
116
|
+
# => Archives employee data
|
|
117
|
+
|
|
118
|
+
# Attempt reactivation after 100 days (guard fails)
|
|
119
|
+
employee.update!(terminated_on: 100.days.ago)
|
|
120
|
+
employee.can_transition?(:reactivate) # => false
|
|
121
|
+
employee.reactivate # => raises LiteState::TransitionError
|
|
122
|
+
|
|
123
|
+
# Reactivation within 90 days (guard passes)
|
|
124
|
+
employee.update!(terminated_on: 30.days.ago)
|
|
125
|
+
employee.can_transition?(:reactivate) # => true
|
|
126
|
+
employee.reactivate # => state: :enrolled
|
|
127
|
+
|
|
128
|
+
# PIN reset flow
|
|
129
|
+
employee.reset_pin
|
|
130
|
+
# => state: :reset_pin
|
|
131
|
+
# => Generates OTP and sends notification with temporary PIN
|
data/lib/lite_state.rb
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lite_state/version"
|
|
4
|
+
require "active_support/concern"
|
|
5
|
+
require "active_support/notifications"
|
|
6
|
+
|
|
7
|
+
# LiteState is a lightweight state machine module for ActiveRecord models.
|
|
8
|
+
# It supports state transitions, optional guards, timestamps, and emits
|
|
9
|
+
# ActiveSupport::Notifications events for each transition outcome.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# class Order < ApplicationRecord
|
|
13
|
+
# include LiteState
|
|
14
|
+
#
|
|
15
|
+
# state_column :status
|
|
16
|
+
#
|
|
17
|
+
# enum status: { pending: 0, processing: 1, completed: 2, cancelled: 3 }
|
|
18
|
+
#
|
|
19
|
+
# transition :process, from: :pending, to: :processing, timestamp: true
|
|
20
|
+
# transition :complete, from: :processing, to: :completed, timestamp: :completed_at
|
|
21
|
+
# transition :cancel, from: [:pending, :processing], to: :cancelled
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# order = Order.create!(status: :pending)
|
|
25
|
+
# order.process # => true, updates status to :processing and processing_at timestamp
|
|
26
|
+
# order.complete # => true, updates status to :completed and completed_at timestamp
|
|
27
|
+
#
|
|
28
|
+
# @example With guards and callbacks
|
|
29
|
+
# class Employee < ApplicationRecord
|
|
30
|
+
# include LiteState
|
|
31
|
+
#
|
|
32
|
+
# state_column :state
|
|
33
|
+
#
|
|
34
|
+
# enum state: { created: 0, invited: 1, enrolled: 2, suspended: 3, terminated: 4 }
|
|
35
|
+
#
|
|
36
|
+
# transition :reactivate,
|
|
37
|
+
# from: [:suspended, :terminated],
|
|
38
|
+
# to: :enrolled,
|
|
39
|
+
# timestamp: :enrolled_on,
|
|
40
|
+
# guard: :eligible_for_reactivation? do
|
|
41
|
+
# notify_employee(:reactivated)
|
|
42
|
+
# clear_suspension_reason
|
|
43
|
+
# end
|
|
44
|
+
#
|
|
45
|
+
# def eligible_for_reactivation?
|
|
46
|
+
# return true if suspended?
|
|
47
|
+
# return true unless terminated_on
|
|
48
|
+
# terminated_on >= 90.days.ago.to_date
|
|
49
|
+
# end
|
|
50
|
+
# end
|
|
51
|
+
#
|
|
52
|
+
module LiteState
|
|
53
|
+
class Error < StandardError; end
|
|
54
|
+
|
|
55
|
+
# Raised when an invalid transition is attempted
|
|
56
|
+
class TransitionError < Error
|
|
57
|
+
attr_reader :record, :to, :from, :event
|
|
58
|
+
|
|
59
|
+
# @param record [ActiveRecord::Base] the record being transitioned
|
|
60
|
+
# @param to [Symbol] the target state
|
|
61
|
+
# @param from [Symbol] the current state
|
|
62
|
+
# @param event [Symbol] the transition event
|
|
63
|
+
def initialize(record:, to:, from:, event:)
|
|
64
|
+
@record = record
|
|
65
|
+
@to = to
|
|
66
|
+
@from = from
|
|
67
|
+
@event = event
|
|
68
|
+
super("Invalid transition: #{record.class} ##{record.id} from #{from.inspect} -> #{to.inspect} on #{event}")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
extend ActiveSupport::Concern
|
|
73
|
+
|
|
74
|
+
included do
|
|
75
|
+
class_attribute :lite_state_column, instance_writer: false
|
|
76
|
+
class_attribute :lite_state_transitions, instance_writer: false, default: {}
|
|
77
|
+
|
|
78
|
+
# Performs a state transition
|
|
79
|
+
#
|
|
80
|
+
# @param to [Symbol] target state
|
|
81
|
+
# @param allowed_from [Symbol, Array<Symbol>] states allowed to transition from
|
|
82
|
+
# @param event [Symbol] transition event name
|
|
83
|
+
# @param column [Symbol, nil] column to use for this transition (defaults to lite_state_column)
|
|
84
|
+
# @param timestamp_field [Symbol, true, nil] column to update with current time; true will auto-generate "#{to}_at"
|
|
85
|
+
# @param guard [Symbol, Proc, nil] optional guard method or block that must return true
|
|
86
|
+
# @yield optional block to execute after state update
|
|
87
|
+
# @return [Boolean] true if transition succeeds
|
|
88
|
+
# @raise [TransitionError] if transition is invalid or guard fails
|
|
89
|
+
# @raise [ActiveRecord::RecordInvalid] if update! fails
|
|
90
|
+
def transition_state(to:, allowed_from:, event:, column: nil, timestamp_field: nil, guard: nil, &block)
|
|
91
|
+
allowed_from = Array(allowed_from).map(&:to_sym).freeze
|
|
92
|
+
|
|
93
|
+
# Determine which column to use for this transition
|
|
94
|
+
state_column = column || self.class.lite_state_column
|
|
95
|
+
|
|
96
|
+
raise ArgumentError, "No state column specified. Use 'state_column :column_name' or provide 'column:' parameter" unless state_column
|
|
97
|
+
|
|
98
|
+
current_state_value = public_send(state_column)
|
|
99
|
+
|
|
100
|
+
fail_transition!(to:, from: nil, event:, outcome: :invalid) if current_state_value.nil?
|
|
101
|
+
|
|
102
|
+
current_state = current_state_value.to_sym
|
|
103
|
+
|
|
104
|
+
fail_transition!(to:, from: current_state, event:, outcome: :invalid) unless allowed_from.include?(current_state)
|
|
105
|
+
|
|
106
|
+
if guard
|
|
107
|
+
result = guard.is_a?(Symbol) ? send(guard) : instance_exec(&guard)
|
|
108
|
+
fail_transition!(to:, from: current_state, event:, outcome: :invalid) unless result
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
transaction do
|
|
112
|
+
attrs = {state_column => to}
|
|
113
|
+
|
|
114
|
+
# Simple timestamp support: true => "#{to}_at", or custom column
|
|
115
|
+
if timestamp_field
|
|
116
|
+
timestamp_column = (timestamp_field == true) ? "#{to}_at" : timestamp_field
|
|
117
|
+
attrs[timestamp_column] = Time.current
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
update!(attrs)
|
|
121
|
+
|
|
122
|
+
# Ensure block exceptions are tracked as failures
|
|
123
|
+
begin
|
|
124
|
+
instance_exec(&block) if block
|
|
125
|
+
rescue => e
|
|
126
|
+
publish_state_event(outcome: :failed, to:, from: current_state, event:)
|
|
127
|
+
raise e
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
publish_state_event(outcome: :success, to:, from: current_state, event:)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
true
|
|
134
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
135
|
+
publish_state_event(outcome: :failed, to:, from: current_state, event:)
|
|
136
|
+
raise e
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Checks if the record can perform a given transition
|
|
140
|
+
#
|
|
141
|
+
# @param name [Symbol] transition name
|
|
142
|
+
# @return [Boolean] true if allowed and guard passes
|
|
143
|
+
def can_transition?(name)
|
|
144
|
+
transition = self.class.lite_state_transitions[name.to_sym]
|
|
145
|
+
return false unless transition
|
|
146
|
+
|
|
147
|
+
allowed_from = Array(transition[:from]).map(&:to_sym)
|
|
148
|
+
|
|
149
|
+
# Use the column specified in the transition, or fall back to default
|
|
150
|
+
state_column = transition[:column] || self.class.lite_state_column
|
|
151
|
+
return false unless state_column
|
|
152
|
+
|
|
153
|
+
current_state_value = public_send(state_column)
|
|
154
|
+
return false if current_state_value.nil?
|
|
155
|
+
|
|
156
|
+
current_state = current_state_value.to_sym
|
|
157
|
+
guard = transition[:guard]
|
|
158
|
+
|
|
159
|
+
allowed_from.include?(current_state) &&
|
|
160
|
+
(guard.nil? || (guard.is_a?(Symbol) ? send(guard) : instance_exec(&guard)))
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
# Publishes a failed transition and raises TransitionError
|
|
166
|
+
#
|
|
167
|
+
# @param to [Symbol] target state
|
|
168
|
+
# @param from [Symbol] current state
|
|
169
|
+
# @param event [Symbol] transition event
|
|
170
|
+
# @param outcome [Symbol] event outcome (:invalid, :failed)
|
|
171
|
+
# @raise [TransitionError]
|
|
172
|
+
def fail_transition!(to:, from:, event:, outcome:)
|
|
173
|
+
publish_state_event(outcome:, to:, from:, event:)
|
|
174
|
+
raise TransitionError.new(record: self, to:, from:, event:)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Publishes an ActiveSupport::Notifications event
|
|
178
|
+
#
|
|
179
|
+
# @param outcome [Symbol] :success, :failed, :invalid
|
|
180
|
+
# @param to [Symbol] target state
|
|
181
|
+
# @param from [Symbol] current state
|
|
182
|
+
# @param event [Symbol] transition event
|
|
183
|
+
def publish_state_event(outcome:, to:, from:, event:)
|
|
184
|
+
event_name = [
|
|
185
|
+
self.class.name.underscore,
|
|
186
|
+
event.to_s.underscore,
|
|
187
|
+
outcome.to_s.underscore
|
|
188
|
+
].join(".")
|
|
189
|
+
|
|
190
|
+
ActiveSupport::Notifications.instrument(event_name, {
|
|
191
|
+
record: self,
|
|
192
|
+
record_id: id,
|
|
193
|
+
from_state: from,
|
|
194
|
+
to_state: to,
|
|
195
|
+
event:,
|
|
196
|
+
timestamp: Time.current
|
|
197
|
+
})
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
class_methods do
|
|
202
|
+
# Sets the column used for state
|
|
203
|
+
#
|
|
204
|
+
# @param column_name [Symbol] the database column name for state storage
|
|
205
|
+
# @example
|
|
206
|
+
# state_column :status
|
|
207
|
+
# state_column :state
|
|
208
|
+
def state_column(column_name)
|
|
209
|
+
self.lite_state_column = column_name
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Validates that a state exists in the enum definition
|
|
213
|
+
#
|
|
214
|
+
# @param state [Symbol] the state to validate
|
|
215
|
+
# @param column [Symbol] the column to validate against
|
|
216
|
+
# @raise [ArgumentError] if state is not defined in the enum
|
|
217
|
+
# @api private
|
|
218
|
+
def validate_state_exists!(state, column:)
|
|
219
|
+
return unless column
|
|
220
|
+
|
|
221
|
+
enum_accessor = column.to_s.pluralize
|
|
222
|
+
return unless respond_to?(enum_accessor)
|
|
223
|
+
|
|
224
|
+
valid_states = public_send(enum_accessor).keys.map(&:to_sym)
|
|
225
|
+
unless valid_states.include?(state.to_sym)
|
|
226
|
+
raise ArgumentError, "Invalid state :#{state} for #{column}. Valid states: #{valid_states.join(", ")}"
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Defines a transition method
|
|
231
|
+
#
|
|
232
|
+
# @param name [Symbol] method name for the transition
|
|
233
|
+
# @param to [Symbol] target state
|
|
234
|
+
# @param from [Symbol, Array<Symbol>] allowed source states
|
|
235
|
+
# @param column [Symbol, nil] column to use for this transition (defaults to lite_state_column)
|
|
236
|
+
# @param timestamp [Symbol, true, nil] column to update with current time
|
|
237
|
+
# @param guard [Symbol, Proc, nil] optional guard method/block
|
|
238
|
+
# @yield block executed after state update but within the transaction
|
|
239
|
+
# @example Simple transition
|
|
240
|
+
# transition :activate, from: :pending, to: :active
|
|
241
|
+
# @example With timestamp
|
|
242
|
+
# transition :complete, from: :active, to: :completed, timestamp: true
|
|
243
|
+
# @example With multiple columns
|
|
244
|
+
# transition :pay, from: :unpaid, to: :paid, column: :payment_status
|
|
245
|
+
# @example With guard and callback
|
|
246
|
+
# transition :reactivate, from: :suspended, to: :active, guard: :can_reactivate? do
|
|
247
|
+
# send_notification
|
|
248
|
+
# end
|
|
249
|
+
def transition(name, to:, from:, column: nil, timestamp: nil, guard: nil, &block)
|
|
250
|
+
# Determine which column to validate against
|
|
251
|
+
state_column = column || lite_state_column
|
|
252
|
+
|
|
253
|
+
raise ArgumentError, "No state column specified. Use 'state_column :column_name' or provide 'column:' parameter" unless state_column
|
|
254
|
+
|
|
255
|
+
# Validate that target and source states exist in the enum
|
|
256
|
+
validate_state_exists!(to, column: state_column)
|
|
257
|
+
Array(from).each { |state| validate_state_exists!(state, column: state_column) }
|
|
258
|
+
|
|
259
|
+
self.lite_state_transitions = lite_state_transitions.merge(
|
|
260
|
+
name.to_sym => {to:, from:, column:, timestamp:, guard:, block:}.freeze
|
|
261
|
+
).freeze
|
|
262
|
+
|
|
263
|
+
define_method(name) do
|
|
264
|
+
transition_state(
|
|
265
|
+
to:,
|
|
266
|
+
allowed_from: from,
|
|
267
|
+
event: name,
|
|
268
|
+
column:,
|
|
269
|
+
timestamp_field: timestamp,
|
|
270
|
+
guard:,
|
|
271
|
+
&block
|
|
272
|
+
)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|