statecraft 0.1.2 → 0.1.3
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/README.md +12 -4
- data/lib/generators/statecraft/machine/USAGE +26 -0
- data/lib/statecraft/machine.rb +21 -6
- data/lib/statecraft/mounting.rb +15 -0
- data/lib/statecraft/pipeline/edge_resolution.rb +68 -0
- data/lib/statecraft/pipeline.rb +4 -37
- data/lib/statecraft/version.rb +1 -1
- data/lib/statecraft.rb +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f809e9a1769e457d33d4b526703e4c3c7beb4ca09f8a7550cbbd31c97b07ba12
|
|
4
|
+
data.tar.gz: 8193837939f39dd0de633330b7bfc36d7adbb821221072ce1cdf30890f3488d0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a5da582f65eb60cd2f76fef2ec213ad6c09166b288f44d80f7065a943894a66c5f36134f6c094fd873e50562976a447d0183b3e0e47b5545bce707d63f965d83
|
|
7
|
+
data.tar.gz: f1247259b3bc97de6663d9545e00015e1ea443b9e9f0ac33af6a179c09ba534140b0037863ddd2fa64e4992234a5fe14ea0d43e4a9ddc30d1018927c0d3158c5
|
data/README.md
CHANGED
|
@@ -113,9 +113,10 @@ do not run, and unsaved changes on other attributes are neither saved nor
|
|
|
113
113
|
callbacks are the transition's callbacks.
|
|
114
114
|
|
|
115
115
|
Bang variants return the created log record. Non-bang variants return it too,
|
|
116
|
-
or `false` — and `false`
|
|
117
|
-
declared
|
|
118
|
-
|
|
116
|
+
or `false` — and `false` covers exactly three refusals: a guard said no, the
|
|
117
|
+
edge is not declared, or the bypass policy refused a direct transition over
|
|
118
|
+
an event-guarded edge (`GuardFailed` / `InvalidTransition`). Everything
|
|
119
|
+
else — including `TransitionConflict` — always raises, in both variants.
|
|
119
120
|
|
|
120
121
|
## Guards, events and the bypass policy
|
|
121
122
|
|
|
@@ -217,7 +218,14 @@ symbol keys and values become strings, times become ISO-8601 strings — and
|
|
|
217
218
|
then deep-frozen: **the guards see exactly what the log will store**, and a
|
|
218
219
|
guard that mutates metadata dies with `FrozenError` in a transition and in a
|
|
219
220
|
check alike. Unserializable values (a `Proc`, a model instance) fail
|
|
220
|
-
instantly at the entrance, not inside the transaction
|
|
221
|
+
instantly at the entrance, not inside the transaction — and so do `NaN` and
|
|
222
|
+
`Infinity`, which JSON cannot represent.
|
|
223
|
+
|
|
224
|
+
`BigDecimal` is rejected deliberately, not by omission: jsonb would hand it
|
|
225
|
+
back as a string or a float depending on the reader, silently breaking the
|
|
226
|
+
"what the guards checked is what the log stored" promise. Pass money and
|
|
227
|
+
other exact decimals as strings (`metadata: { price: order.total.to_s }`)
|
|
228
|
+
and parse them in the guard.
|
|
221
229
|
|
|
222
230
|
Facts of the transition moment (a price snapshot, a rules version) are
|
|
223
231
|
collected by the caller: `order.pay!(metadata: { price: order.total })`.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Creates everything one model needs for a statecraft state machine: the
|
|
3
|
+
migration (state column with a CHECK constraint for a fresh table, the
|
|
4
|
+
state_changed_at column, and the append-only per-model log table with a
|
|
5
|
+
cascade FK), the machine class, the readonly log model, the model itself
|
|
6
|
+
when it does not exist yet (an existing model gets the state_machine
|
|
7
|
+
mounting injected instead), and — once per application — the shared
|
|
8
|
+
ApplicationMachine parent.
|
|
9
|
+
|
|
10
|
+
Namespaced models are fully supported: every file lands by the full
|
|
11
|
+
path, and a table_name_prefix module is generated the way the Rails
|
|
12
|
+
model generator does it. For a model on a non-primary database the
|
|
13
|
+
migration lands in the migrations_paths configured for that database
|
|
14
|
+
(db/migrate when none is configured).
|
|
15
|
+
|
|
16
|
+
Example:
|
|
17
|
+
bin/rails generate statecraft:machine Order
|
|
18
|
+
bin/rails generate statecraft:machine Shop::Order
|
|
19
|
+
|
|
20
|
+
This will create:
|
|
21
|
+
app/state_machines/application_machine.rb (first run only)
|
|
22
|
+
app/state_machines/shop/order_flow.rb
|
|
23
|
+
app/models/shop.rb (namespace prefix module)
|
|
24
|
+
app/models/shop/order_transition.rb
|
|
25
|
+
app/models/shop/order.rb (or mounting injection)
|
|
26
|
+
db/migrate/XXXXXXXXXXXXXX_create_shop_order_state_machine.rb
|
data/lib/statecraft/machine.rb
CHANGED
|
@@ -40,24 +40,28 @@ module Statecraft
|
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
def self.unary?(callable)
|
|
43
|
-
callable.arity
|
|
43
|
+
arity = callable.respond_to?(:arity) ? callable.arity : callable.method(:call).arity
|
|
44
|
+
arity == 1
|
|
44
45
|
end
|
|
45
46
|
end
|
|
46
47
|
|
|
47
48
|
# Class-level DSL collected declaratively and compiled by finalize!.
|
|
49
|
+
# Names arrive as symbols or strings interchangeably (the ActiveRecord
|
|
50
|
+
# idiom) and are normalized to symbols at the declaration line.
|
|
48
51
|
module ClassMethods
|
|
49
52
|
def state(name, initial: false)
|
|
50
|
-
declared_states << { name: name, initial: initial }
|
|
53
|
+
declared_states << { name: name.to_sym, initial: initial }
|
|
51
54
|
end
|
|
52
55
|
|
|
53
56
|
def transition(from:, to:, guard: nil, lock: false)
|
|
54
57
|
declared_edges << {
|
|
55
|
-
from: from, to: to, guards: Array(guard), lock: lock || current_event_lock,
|
|
58
|
+
from: from.to_sym, to: to.to_sym, guards: Array(guard), lock: lock || current_event_lock,
|
|
56
59
|
event: current_event_name
|
|
57
60
|
}
|
|
58
61
|
end
|
|
59
62
|
|
|
60
63
|
def event(name, from: nil, to: nil, guard: nil, lock: false, &declarations)
|
|
64
|
+
name = name.to_sym
|
|
61
65
|
declared_event_names << name
|
|
62
66
|
if declarations
|
|
63
67
|
if from || to
|
|
@@ -89,7 +93,9 @@ module Statecraft
|
|
|
89
93
|
|
|
90
94
|
declared_callbacks[phase] << Callback.new(
|
|
91
95
|
handler: callback_handler,
|
|
92
|
-
from: from && Array(from)
|
|
96
|
+
from: from && Array(from).map(&:to_sym),
|
|
97
|
+
to: to && Array(to).map(&:to_sym),
|
|
98
|
+
event: event && Array(event).map(&:to_sym)
|
|
93
99
|
)
|
|
94
100
|
end
|
|
95
101
|
end
|
|
@@ -114,8 +120,11 @@ module Statecraft
|
|
|
114
120
|
!@statecraft_compiled_graph.nil?
|
|
115
121
|
end
|
|
116
122
|
|
|
123
|
+
# Compilation memoizes the graph and freezes the declaration lists, so a
|
|
124
|
+
# reopened machine class fails loudly on any late state/transition/event
|
|
125
|
+
# instead of silently ignoring it (callbacks already freeze in compile).
|
|
117
126
|
def finalize!
|
|
118
|
-
@statecraft_compiled_graph ||= Compiler.new(self).compile
|
|
127
|
+
@statecraft_compiled_graph ||= Compiler.new(self).compile.tap { freeze_declarations }
|
|
119
128
|
end
|
|
120
129
|
|
|
121
130
|
def declared_states
|
|
@@ -136,6 +145,12 @@ module Statecraft
|
|
|
136
145
|
|
|
137
146
|
private
|
|
138
147
|
|
|
148
|
+
def freeze_declarations
|
|
149
|
+
declared_states.freeze
|
|
150
|
+
declared_edges.freeze
|
|
151
|
+
declared_event_names.freeze
|
|
152
|
+
end
|
|
153
|
+
|
|
139
154
|
def current_event_name
|
|
140
155
|
@statecraft_current_event && @statecraft_current_event[:name]
|
|
141
156
|
end
|
|
@@ -268,7 +283,7 @@ module Statecraft
|
|
|
268
283
|
from_callbacks = machine_class.declared_callbacks.each_value.flat_map do |callbacks|
|
|
269
284
|
callbacks.map(&:handler)
|
|
270
285
|
end
|
|
271
|
-
(from_edges + from_callbacks).
|
|
286
|
+
(from_edges + from_callbacks).grep(Symbol)
|
|
272
287
|
end
|
|
273
288
|
|
|
274
289
|
def deep_freeze_edges(edges)
|
data/lib/statecraft/mounting.rb
CHANGED
|
@@ -82,11 +82,26 @@ module Statecraft
|
|
|
82
82
|
raise ConnectionMismatch.new(model: model, log_class: log_class)
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
+
# Methods this mounting itself defines, so a verb check runs BEFORE the
|
|
86
|
+
# includes and still sees them: an event named fire or history would
|
|
87
|
+
# otherwise silently shadow the gem's own surface.
|
|
88
|
+
def mounted_surface_methods
|
|
89
|
+
Pipeline::Surface.instance_methods +
|
|
90
|
+
Introspection.instance_methods +
|
|
91
|
+
%i[history last_transition in_state?]
|
|
92
|
+
end
|
|
93
|
+
|
|
85
94
|
def assert_no_verb_conflicts(graph)
|
|
86
95
|
return unless @helpers
|
|
87
96
|
|
|
97
|
+
surface_methods = mounted_surface_methods
|
|
88
98
|
graph.events.each_key do |event_name|
|
|
89
99
|
verb_names(event_name).each do |verb|
|
|
100
|
+
if surface_methods.include?(verb.to_sym)
|
|
101
|
+
raise CompilationError,
|
|
102
|
+
"helper #{verb} for event #{event_name.inspect} conflicts with the " \
|
|
103
|
+
"#{verb} method statecraft itself mounts; rename the event"
|
|
104
|
+
end
|
|
90
105
|
next unless model.method_defined?(verb) || model.private_method_defined?(verb)
|
|
91
106
|
|
|
92
107
|
raise CompilationError,
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
class Pipeline
|
|
5
|
+
# Resolves the requested edge from the compiled graph and raises
|
|
6
|
+
# InvalidTransition with a diagnosis that names what actually failed:
|
|
7
|
+
# an undeclared edge, a bypass-policy refusal, an unknown event, or an
|
|
8
|
+
# event with no branch from the current state.
|
|
9
|
+
module EdgeResolution
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def resolve_direct_edge(current, to_state, bypass_events)
|
|
13
|
+
edge = graph.edges[[current, to_state]]
|
|
14
|
+
raise_invalid_transition(current, to_state) if edge.nil?
|
|
15
|
+
guarding_events = edge.event_names.select { |name| edge.event_guards[name].any? }
|
|
16
|
+
if guarding_events.any? && !bypass_events
|
|
17
|
+
raise InvalidTransition.new(
|
|
18
|
+
record: record, from: current, requested: to_state,
|
|
19
|
+
allowed: allowed_targets(current),
|
|
20
|
+
message: "direct transition #{current} -> #{to_state} is guarded by " \
|
|
21
|
+
"event#{"s" if guarding_events.length > 1} #{guarding_events.join(", ")}; " \
|
|
22
|
+
"call fire!(:#{guarding_events.first}) or pass bypass_events: true"
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
edge
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def resolve_event_edge(current, event_name)
|
|
29
|
+
branches = graph.events[event_name]
|
|
30
|
+
raise_unknown_event(current, event_name) if branches.nil?
|
|
31
|
+
edge = branches[current]
|
|
32
|
+
raise_event_without_branch(current, event_name, branches) if edge.nil?
|
|
33
|
+
edge
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def raise_unknown_event(current, event_name)
|
|
37
|
+
known_events = graph.events.keys
|
|
38
|
+
raise InvalidTransition.new(
|
|
39
|
+
record: record, from: current, requested: event_name,
|
|
40
|
+
allowed: allowed_targets(current),
|
|
41
|
+
message: "unknown event #{event_name.inspect} for #{configuration.machine_class.name}; " \
|
|
42
|
+
"events: #{known_events.empty? ? "none" : known_events.map(&:inspect).join(", ")}"
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def raise_event_without_branch(current, event_name, branches)
|
|
47
|
+
declared_branches = branches.map { |from, edge| "#{from} -> #{edge.to}" }.join(", ")
|
|
48
|
+
raise InvalidTransition.new(
|
|
49
|
+
record: record, from: current, requested: event_name,
|
|
50
|
+
allowed: allowed_targets(current),
|
|
51
|
+
message: "event #{event_name.inspect} has no branch from #{current} for " \
|
|
52
|
+
"#{record.class.name}; branches: #{declared_branches}"
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def raise_invalid_transition(current, requested)
|
|
57
|
+
raise InvalidTransition.new(
|
|
58
|
+
record: record, from: current, requested: requested,
|
|
59
|
+
allowed: allowed_targets(current)
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def allowed_targets(current)
|
|
64
|
+
graph.edges.keys.select { |from, _to| from == current }.map(&:last)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/statecraft/pipeline.rb
CHANGED
|
@@ -28,6 +28,8 @@ module Statecraft
|
|
|
28
28
|
STACK_KEY = :statecraft_transition_stack
|
|
29
29
|
MAX_CHAIN_DEPTH = 16
|
|
30
30
|
|
|
31
|
+
include EdgeResolution
|
|
32
|
+
|
|
31
33
|
def self.transition_stack
|
|
32
34
|
ActiveSupport::IsolatedExecutionState[STACK_KEY] ||= []
|
|
33
35
|
end
|
|
@@ -70,7 +72,7 @@ module Statecraft
|
|
|
70
72
|
begin
|
|
71
73
|
log_record = execute_transaction(edge, event, bypass, metadata, frame)
|
|
72
74
|
ensure
|
|
73
|
-
Pipeline.transition_stack.
|
|
75
|
+
Pipeline.transition_stack.delete_if { |open| open.equal?(frame) }
|
|
74
76
|
end
|
|
75
77
|
rescue GuardFailed, InvalidTransition, TransitionConflict => transition_error
|
|
76
78
|
publish_failure(started_at, transition_error)
|
|
@@ -149,41 +151,6 @@ module Statecraft
|
|
|
149
151
|
record[configuration.column].to_s.to_sym
|
|
150
152
|
end
|
|
151
153
|
|
|
152
|
-
def resolve_direct_edge(current, to_state, bypass_events)
|
|
153
|
-
edge = graph.edges[[current, to_state]]
|
|
154
|
-
raise_invalid_transition(current, to_state) if edge.nil?
|
|
155
|
-
guarding_events = edge.event_names.select { |name| edge.event_guards[name].any? }
|
|
156
|
-
if guarding_events.any? && !bypass_events
|
|
157
|
-
raise InvalidTransition.new(
|
|
158
|
-
record: record, from: current, requested: to_state,
|
|
159
|
-
allowed: allowed_targets(current),
|
|
160
|
-
message: "direct transition #{current} -> #{to_state} is guarded by " \
|
|
161
|
-
"event#{"s" if guarding_events.length > 1} #{guarding_events.join(", ")}; " \
|
|
162
|
-
"call fire!(:#{guarding_events.first}) or pass bypass_events: true"
|
|
163
|
-
)
|
|
164
|
-
end
|
|
165
|
-
edge
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
def resolve_event_edge(current, event_name)
|
|
169
|
-
branches = graph.events[event_name]
|
|
170
|
-
raise_invalid_transition(current, event_name) if branches.nil?
|
|
171
|
-
edge = branches[current]
|
|
172
|
-
raise_invalid_transition(current, event_name) if edge.nil?
|
|
173
|
-
edge
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
def raise_invalid_transition(current, requested)
|
|
177
|
-
raise InvalidTransition.new(
|
|
178
|
-
record: record, from: current, requested: requested,
|
|
179
|
-
allowed: allowed_targets(current)
|
|
180
|
-
)
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
def allowed_targets(current)
|
|
184
|
-
graph.edges.keys.select { |from, _to| from == current }.map(&:last)
|
|
185
|
-
end
|
|
186
|
-
|
|
187
154
|
# Checked at the first transition, not at mounting time: resolving an
|
|
188
155
|
# implicit primary key goes through the schema cache, and mounting must
|
|
189
156
|
# stay safe without a database connection.
|
|
@@ -311,7 +278,7 @@ module Statecraft
|
|
|
311
278
|
end
|
|
312
279
|
|
|
313
280
|
def warn_when_row_locking_unavailable
|
|
314
|
-
return unless base_class.
|
|
281
|
+
return unless base_class.connection_db_config.adapter.match?(/sqlite/i)
|
|
315
282
|
|
|
316
283
|
Statecraft.warn(
|
|
317
284
|
[configuration.machine_class.name, :sqlite_row_lock],
|
data/lib/statecraft/version.rb
CHANGED
data/lib/statecraft.rb
CHANGED
|
@@ -9,6 +9,7 @@ require_relative "statecraft/warnings"
|
|
|
9
9
|
require_relative "statecraft/instrumentation"
|
|
10
10
|
require_relative "statecraft/machine"
|
|
11
11
|
require_relative "statecraft/metadata"
|
|
12
|
+
require_relative "statecraft/pipeline/edge_resolution"
|
|
12
13
|
require_relative "statecraft/pipeline"
|
|
13
14
|
require_relative "statecraft/pipeline/surface"
|
|
14
15
|
require_relative "statecraft/introspection"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: statecraft
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Igor Pugachev
|
|
@@ -60,6 +60,7 @@ extra_rdoc_files: []
|
|
|
60
60
|
files:
|
|
61
61
|
- LICENSE.txt
|
|
62
62
|
- README.md
|
|
63
|
+
- lib/generators/statecraft/machine/USAGE
|
|
63
64
|
- lib/generators/statecraft/machine/machine_generator.rb
|
|
64
65
|
- lib/generators/statecraft/machine/templates/add_migration.rb.tt
|
|
65
66
|
- lib/generators/statecraft/machine/templates/application_machine.rb.tt
|
|
@@ -76,6 +77,7 @@ files:
|
|
|
76
77
|
- lib/statecraft/metadata.rb
|
|
77
78
|
- lib/statecraft/mounting.rb
|
|
78
79
|
- lib/statecraft/pipeline.rb
|
|
80
|
+
- lib/statecraft/pipeline/edge_resolution.rb
|
|
79
81
|
- lib/statecraft/pipeline/surface.rb
|
|
80
82
|
- lib/statecraft/version.rb
|
|
81
83
|
- lib/statecraft/warnings.rb
|