statecraft 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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +354 -0
- data/lib/generators/statecraft/machine/machine_generator.rb +105 -0
- data/lib/statecraft/errors.rb +120 -0
- data/lib/statecraft/instrumentation.rb +47 -0
- data/lib/statecraft/introspection.rb +78 -0
- data/lib/statecraft/machine.rb +315 -0
- data/lib/statecraft/metadata.rb +51 -0
- data/lib/statecraft/mounting.rb +184 -0
- data/lib/statecraft/pipeline.rb +326 -0
- data/lib/statecraft/version.rb +5 -0
- data/lib/statecraft/warnings.rb +30 -0
- data/lib/statecraft.rb +18 -0
- metadata +96 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
# Executes one transition through the protocol pipeline:
|
|
5
|
+
#
|
|
6
|
+
# persisted? -> metadata normalize+freeze -> edge resolution ->
|
|
7
|
+
# dirty check (lock only) -> transaction(requires_new: true) [
|
|
8
|
+
# lock+reload -> re-resolve edge from fresh state -> guards ->
|
|
9
|
+
# before_transition -> CAS UPDATE -> log INSERT (insert path) ->
|
|
10
|
+
# after_transition
|
|
11
|
+
# ] -> after_commit registration on the outermost real commit
|
|
12
|
+
#
|
|
13
|
+
# The CAS statement and the log insert run against base_class; the log row
|
|
14
|
+
# is written via the insert path, past the log model's validations and
|
|
15
|
+
# callbacks — guards and machine callbacks are the single channel of truth.
|
|
16
|
+
class Pipeline
|
|
17
|
+
Transition = Struct.new(:from, :to, :event, :metadata, :log_record, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
# One frame per pipeline currently running in this execution context. The
|
|
20
|
+
# key identifies the record across instances — (base_class name, id,
|
|
21
|
+
# machine) — so re-entering the same record's pipeline between its start
|
|
22
|
+
# and the completion of CAS (guards, before_transition) is caught even
|
|
23
|
+
# through a freshly loaded instance. cas_done flips right after the CAS
|
|
24
|
+
# statement: chains launched from after_transition are legal by
|
|
25
|
+
# construction.
|
|
26
|
+
Frame = Struct.new(:key, :label, :cas_done, keyword_init: true)
|
|
27
|
+
|
|
28
|
+
STACK_KEY = :statecraft_transition_stack
|
|
29
|
+
MAX_CHAIN_DEPTH = 16
|
|
30
|
+
|
|
31
|
+
def self.transition_stack
|
|
32
|
+
ActiveSupport::IsolatedExecutionState[STACK_KEY] ||= []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The record-facing API mixed into the model at mounting time. Bang
|
|
36
|
+
# variants return the created log record; non-bang variants return it too,
|
|
37
|
+
# or false on GuardFailed / InvalidTransition. Programmer errors and
|
|
38
|
+
# TransitionConflict always raise.
|
|
39
|
+
module Surface
|
|
40
|
+
def transition_to!(to_state, metadata: {}, bypass_events: false)
|
|
41
|
+
Pipeline.new(self).direct(to_state, metadata: metadata, bypass_events: bypass_events)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def transition_to(to_state, metadata: {}, bypass_events: false)
|
|
45
|
+
transition_to!(to_state, metadata: metadata, bypass_events: bypass_events)
|
|
46
|
+
rescue GuardFailed, InvalidTransition
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def fire!(event_name, metadata: {})
|
|
51
|
+
Pipeline.new(self).fire(event_name, metadata: metadata)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def fire(event_name, metadata: {})
|
|
55
|
+
fire!(event_name, metadata: metadata)
|
|
56
|
+
rescue GuardFailed, InvalidTransition
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def initialize(record)
|
|
62
|
+
@record = record
|
|
63
|
+
@configuration = record.class.statecraft_mounting
|
|
64
|
+
@graph = @configuration.machine_class.finalize!
|
|
65
|
+
@machine_instance = @configuration.machine_class.new
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def direct(to_state, metadata:, bypass_events:)
|
|
69
|
+
run(metadata) do |current_state|
|
|
70
|
+
edge = resolve_direct_edge(current_state, to_state.to_sym, bypass_events)
|
|
71
|
+
[edge, nil, bypass_events]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def fire(event_name, metadata:)
|
|
76
|
+
run(metadata) do |current_state|
|
|
77
|
+
edge = resolve_event_edge(current_state, event_name.to_sym)
|
|
78
|
+
[edge, event_name.to_sym, false]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
attr_reader :record, :configuration, :graph, :machine_instance
|
|
85
|
+
|
|
86
|
+
def run(raw_metadata, &edge_resolver)
|
|
87
|
+
raise UnsavedRecordError.new(record: record) unless record.persisted?
|
|
88
|
+
|
|
89
|
+
metadata = Metadata.normalize(raw_metadata)
|
|
90
|
+
started_at = Time.current
|
|
91
|
+
begin
|
|
92
|
+
edge, event, bypass = edge_resolver.call(current_state)
|
|
93
|
+
assert_clean_when_locked(edge)
|
|
94
|
+
frame = open_frame(edge, event)
|
|
95
|
+
begin
|
|
96
|
+
log_record = execute_transaction(edge, event, bypass, metadata, edge_resolver, frame)
|
|
97
|
+
ensure
|
|
98
|
+
Pipeline.transition_stack.delete(frame)
|
|
99
|
+
end
|
|
100
|
+
rescue GuardFailed => guard_error
|
|
101
|
+
publish_failure(started_at, :guard_failed, guard: guard_error.guard)
|
|
102
|
+
raise
|
|
103
|
+
rescue InvalidTransition => invalid_error
|
|
104
|
+
publish_failure(started_at, :invalid_transition, requested: invalid_error.requested)
|
|
105
|
+
raise
|
|
106
|
+
rescue TransitionConflict => conflict_error
|
|
107
|
+
publish_failure(started_at, :conflict, expected_from: conflict_error.expected_from)
|
|
108
|
+
raise
|
|
109
|
+
end
|
|
110
|
+
Instrumentation.publish_transition(
|
|
111
|
+
started_at: started_at, record: record,
|
|
112
|
+
machine_class: configuration.machine_class, log_record: log_record
|
|
113
|
+
)
|
|
114
|
+
register_after_commit_callbacks(log_record, metadata)
|
|
115
|
+
log_record
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def publish_failure(started_at, reason, details)
|
|
119
|
+
Instrumentation.publish_failure(
|
|
120
|
+
started_at: started_at, record: record,
|
|
121
|
+
machine_class: configuration.machine_class, reason: reason, details: details
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def execute_transaction(edge, event, bypass, metadata, edge_resolver, frame)
|
|
126
|
+
base_class.transaction(requires_new: true) do
|
|
127
|
+
if edge.lock
|
|
128
|
+
warn_when_row_locking_unavailable
|
|
129
|
+
record.reload(lock: true)
|
|
130
|
+
edge, event, bypass = edge_resolver.call(current_state)
|
|
131
|
+
end
|
|
132
|
+
transition_time = Time.current
|
|
133
|
+
run_guards(edge, event, bypass, metadata)
|
|
134
|
+
context = Transition.new(
|
|
135
|
+
from: edge.from, to: edge.to, event: event, metadata: metadata, log_record: nil
|
|
136
|
+
)
|
|
137
|
+
run_callbacks(:before_transition, context)
|
|
138
|
+
cas_update!(edge, transition_time)
|
|
139
|
+
frame.cas_done = true
|
|
140
|
+
context.log_record = insert_log_row(edge, event, metadata, transition_time)
|
|
141
|
+
sync_record(edge, transition_time)
|
|
142
|
+
run_callbacks(:after_transition, context)
|
|
143
|
+
context.log_record
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def open_frame(edge, event)
|
|
148
|
+
stack = Pipeline.transition_stack
|
|
149
|
+
frame_key = [base_class.name, record.id, configuration.machine_class]
|
|
150
|
+
pending_same_record = stack.find { |open| open.key == frame_key && !open.cas_done }
|
|
151
|
+
raise NestedTransitionError.new(record: record) if pending_same_record
|
|
152
|
+
|
|
153
|
+
if stack.length >= MAX_CHAIN_DEPTH
|
|
154
|
+
chain = stack.map(&:label) + [frame_label(edge, event)]
|
|
155
|
+
raise ChainDepthExceeded.new(chain: chain)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
frame = Frame.new(key: frame_key, label: frame_label(edge, event), cas_done: false)
|
|
159
|
+
stack.push(frame)
|
|
160
|
+
frame
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def frame_label(edge, event)
|
|
164
|
+
via = event ? " (#{event})" : ""
|
|
165
|
+
"#{base_class.name}##{record.id}: #{edge.from} -> #{edge.to}#{via}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def current_state
|
|
169
|
+
record[configuration.column].to_s.to_sym
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def resolve_direct_edge(current, to_state, bypass_events)
|
|
173
|
+
edge = graph.edges[[current, to_state]]
|
|
174
|
+
raise_invalid_transition(current, to_state) if edge.nil?
|
|
175
|
+
guarding_events = edge.event_names.select { |name| edge.event_guards[name].any? }
|
|
176
|
+
if guarding_events.any? && !bypass_events
|
|
177
|
+
raise InvalidTransition.new(
|
|
178
|
+
record: record, from: current, requested: to_state,
|
|
179
|
+
allowed: allowed_targets(current),
|
|
180
|
+
message: "direct transition #{current} -> #{to_state} is guarded by " \
|
|
181
|
+
"event#{"s" if guarding_events.length > 1} #{guarding_events.join(", ")}; " \
|
|
182
|
+
"call fire!(:#{guarding_events.first}) or pass bypass_events: true"
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
edge
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def resolve_event_edge(current, event_name)
|
|
189
|
+
branches = graph.events[event_name]
|
|
190
|
+
raise_invalid_transition(current, event_name) if branches.nil?
|
|
191
|
+
edge = branches[current]
|
|
192
|
+
raise_invalid_transition(current, event_name) if edge.nil?
|
|
193
|
+
edge
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def raise_invalid_transition(current, requested)
|
|
197
|
+
raise InvalidTransition.new(
|
|
198
|
+
record: record, from: current, requested: requested,
|
|
199
|
+
allowed: allowed_targets(current)
|
|
200
|
+
)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def allowed_targets(current)
|
|
204
|
+
graph.edges.keys.select { |from, _to| from == current }.map(&:last)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def assert_clean_when_locked(edge)
|
|
208
|
+
return unless edge.lock && record.changed?
|
|
209
|
+
|
|
210
|
+
raise DirtyRecordError.new(record: record, changed_attributes: record.changed)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def run_guards(edge, event, bypass, metadata)
|
|
214
|
+
guards = edge.edge_guards.dup
|
|
215
|
+
guards.concat(edge.event_guards.fetch(event, [])) if event && !bypass
|
|
216
|
+
guards.each do |guard|
|
|
217
|
+
next if Machine::Handlers.invoke(machine_instance, guard, record, metadata)
|
|
218
|
+
|
|
219
|
+
raise GuardFailed.new(
|
|
220
|
+
record: record, guard: guard.is_a?(Symbol) ? guard : guard.inspect,
|
|
221
|
+
from: edge.from, to: edge.to, event: event
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def run_callbacks(phase, context)
|
|
227
|
+
matching_callbacks(phase, context).each do |callback|
|
|
228
|
+
Machine::Handlers.invoke(machine_instance, callback.handler, record, context)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def matching_callbacks(phase, context)
|
|
233
|
+
graph.callbacks.fetch(phase).select do |callback|
|
|
234
|
+
(callback.from.nil? || callback.from.include?(context.from)) &&
|
|
235
|
+
(callback.to.nil? || callback.to.include?(context.to)) &&
|
|
236
|
+
(callback.event.nil? || (context.event && callback.event.include?(context.event)))
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def cas_update!(edge, transition_time)
|
|
241
|
+
affected_rows = base_class.unscoped
|
|
242
|
+
.where(base_class.primary_key => record.id,
|
|
243
|
+
configuration.column => edge.from.to_s)
|
|
244
|
+
.update_all(cas_updates(edge, transition_time))
|
|
245
|
+
raise TransitionConflict.new(record: record, expected_from: edge.from) if affected_rows.zero?
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def cas_updates(edge, transition_time)
|
|
249
|
+
updates = { configuration.column => edge.to.to_s }
|
|
250
|
+
updates[:updated_at] = transition_time if touch_updated_at?
|
|
251
|
+
updates[changed_at_column] = transition_time if changed_at_column
|
|
252
|
+
updates
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def insert_log_row(edge, event, metadata, transition_time)
|
|
256
|
+
log_class = configuration.log_class
|
|
257
|
+
attributes = {
|
|
258
|
+
configuration.log_foreign_key => record.id,
|
|
259
|
+
from_state: edge.from.to_s,
|
|
260
|
+
to_state: edge.to.to_s,
|
|
261
|
+
event: event&.to_s,
|
|
262
|
+
metadata: metadata,
|
|
263
|
+
created_at: transition_time
|
|
264
|
+
}
|
|
265
|
+
result = log_class.insert!(attributes, returning: [log_class.primary_key.to_sym])
|
|
266
|
+
log_id = result.rows.first&.first
|
|
267
|
+
log_class.find(log_id)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def sync_record(edge, transition_time)
|
|
271
|
+
synced_columns = [configuration.column]
|
|
272
|
+
record[configuration.column] = edge.to.to_s
|
|
273
|
+
if touch_updated_at?
|
|
274
|
+
record[:updated_at] = transition_time
|
|
275
|
+
synced_columns << :updated_at
|
|
276
|
+
end
|
|
277
|
+
if changed_at_column
|
|
278
|
+
record[changed_at_column] = transition_time
|
|
279
|
+
synced_columns << changed_at_column
|
|
280
|
+
end
|
|
281
|
+
record.clear_attribute_changes(synced_columns)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def register_after_commit_callbacks(log_record, metadata)
|
|
285
|
+
context = Transition.new(
|
|
286
|
+
from: log_record.from_state.to_sym, to: log_record.to_state.to_sym,
|
|
287
|
+
event: log_record.event&.to_sym, metadata: metadata, log_record: log_record
|
|
288
|
+
)
|
|
289
|
+
matching_callbacks(:after_commit, context).each do |callback|
|
|
290
|
+
ActiveRecord.after_all_transactions_commit do
|
|
291
|
+
Machine::Handlers.invoke(machine_instance, callback.handler, record, context)
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def touch_updated_at?
|
|
297
|
+
configuration.touch && record.class.column_names.include?("updated_at")
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def changed_at_column
|
|
301
|
+
column = configuration.changed_at_column
|
|
302
|
+
return nil unless column
|
|
303
|
+
|
|
304
|
+
unless record.class.column_names.include?(column.to_s)
|
|
305
|
+
raise CompilationError,
|
|
306
|
+
"changed_at column #{column} does not exist on #{record.class.table_name}; " \
|
|
307
|
+
"add the column or drop the changed_at option"
|
|
308
|
+
end
|
|
309
|
+
column
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def warn_when_row_locking_unavailable
|
|
313
|
+
return unless base_class.connection.adapter_name.match?(/sqlite/i)
|
|
314
|
+
|
|
315
|
+
Statecraft.warn(
|
|
316
|
+
[configuration.machine_class.name, :sqlite_row_lock],
|
|
317
|
+
"row locking unavailable on sqlite (machine #{configuration.machine_class.name}); " \
|
|
318
|
+
"the reload still runs, but lock: true guarantees require PostgreSQL"
|
|
319
|
+
)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def base_class
|
|
323
|
+
record.class.base_class
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
# The single internal warning funnel. Speaks to the developer at the
|
|
5
|
+
# keyboard through Kernel#warn (stderr) — never through Rails.logger, which
|
|
6
|
+
# may not exist, may be unconfigured at mounting time, and hides test-run
|
|
7
|
+
# warnings in a log file nobody reads. Deduplicates once-per-key-per-process
|
|
8
|
+
# and is deliberately not configurable: a "where do warnings go" knob would
|
|
9
|
+
# be the first global setting of a gem that has none.
|
|
10
|
+
def self.warn(key, message)
|
|
11
|
+
WARNING_DEDUP_MUTEX.synchronize do
|
|
12
|
+
return if emitted_warning_keys.include?(key)
|
|
13
|
+
|
|
14
|
+
emitted_warning_keys << key
|
|
15
|
+
end
|
|
16
|
+
Kernel.warn("[statecraft] #{message}")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
WARNING_DEDUP_MUTEX = Mutex.new
|
|
20
|
+
|
|
21
|
+
def self.emitted_warning_keys
|
|
22
|
+
@emitted_warning_keys ||= Set.new
|
|
23
|
+
end
|
|
24
|
+
private_class_method :emitted_warning_keys
|
|
25
|
+
|
|
26
|
+
def self.reset_warning_dedup!
|
|
27
|
+
WARNING_DEDUP_MUTEX.synchronize { emitted_warning_keys.clear }
|
|
28
|
+
end
|
|
29
|
+
private_class_method :reset_warning_dedup!
|
|
30
|
+
end
|
data/lib/statecraft.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_record"
|
|
5
|
+
|
|
6
|
+
require_relative "statecraft/version"
|
|
7
|
+
require_relative "statecraft/errors"
|
|
8
|
+
require_relative "statecraft/warnings"
|
|
9
|
+
require_relative "statecraft/instrumentation"
|
|
10
|
+
require_relative "statecraft/machine"
|
|
11
|
+
require_relative "statecraft/metadata"
|
|
12
|
+
require_relative "statecraft/pipeline"
|
|
13
|
+
require_relative "statecraft/introspection"
|
|
14
|
+
require_relative "statecraft/mounting"
|
|
15
|
+
|
|
16
|
+
ActiveSupport.on_load(:active_record) do
|
|
17
|
+
extend Statecraft::Mounting
|
|
18
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: statecraft
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Igor Pugachev
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.2'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '9'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '7.2'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '9'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: activesupport
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '7.2'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '9'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '7.2'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '9'
|
|
52
|
+
description: 'State machine on top of ActiveRecord: the current state lives in a column
|
|
53
|
+
guarded by CAS updates, history is an append-only per-model log with write-once
|
|
54
|
+
metadata, guards are event-aware, and bypass is explicit.'
|
|
55
|
+
email:
|
|
56
|
+
- ipugachev84@gmail.com
|
|
57
|
+
executables: []
|
|
58
|
+
extensions: []
|
|
59
|
+
extra_rdoc_files: []
|
|
60
|
+
files:
|
|
61
|
+
- LICENSE.txt
|
|
62
|
+
- README.md
|
|
63
|
+
- lib/generators/statecraft/machine/machine_generator.rb
|
|
64
|
+
- lib/statecraft.rb
|
|
65
|
+
- lib/statecraft/errors.rb
|
|
66
|
+
- lib/statecraft/instrumentation.rb
|
|
67
|
+
- lib/statecraft/introspection.rb
|
|
68
|
+
- lib/statecraft/machine.rb
|
|
69
|
+
- lib/statecraft/metadata.rb
|
|
70
|
+
- lib/statecraft/mounting.rb
|
|
71
|
+
- lib/statecraft/pipeline.rb
|
|
72
|
+
- lib/statecraft/version.rb
|
|
73
|
+
- lib/statecraft/warnings.rb
|
|
74
|
+
licenses:
|
|
75
|
+
- MIT
|
|
76
|
+
metadata:
|
|
77
|
+
rubygems_mfa_required: 'true'
|
|
78
|
+
rdoc_options: []
|
|
79
|
+
require_paths:
|
|
80
|
+
- lib
|
|
81
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
82
|
+
requirements:
|
|
83
|
+
- - ">="
|
|
84
|
+
- !ruby/object:Gem::Version
|
|
85
|
+
version: '3.3'
|
|
86
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
87
|
+
requirements:
|
|
88
|
+
- - ">="
|
|
89
|
+
- !ruby/object:Gem::Version
|
|
90
|
+
version: '0'
|
|
91
|
+
requirements: []
|
|
92
|
+
rubygems_version: 3.6.9
|
|
93
|
+
specification_version: 4
|
|
94
|
+
summary: Concurrency-safe state machine for ActiveRecord with an append-only transition
|
|
95
|
+
log
|
|
96
|
+
test_files: []
|