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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 207eda16ee51c42a787b0e3ba1a37c297242316edfe742c6be75b5dbca1166c0
4
- data.tar.gz: efe7505f2499fdcd1175b35be3c454a9143b7afb2824dc3a6f0e2f98a8429a5f
3
+ metadata.gz: f809e9a1769e457d33d4b526703e4c3c7beb4ca09f8a7550cbbd31c97b07ba12
4
+ data.tar.gz: 8193837939f39dd0de633330b7bfc36d7adbb821221072ce1cdf30890f3488d0
5
5
  SHA512:
6
- metadata.gz: 29d2cd130932273c9469dcbecf03f2a0fb20d33fc8b776efba5f850cd21bb447c5032de1dc10375e5dc9718d993001f6e0b279a5aa2e72c7be0cd1b890408710
7
- data.tar.gz: c0856de0dde4ff10884ac2f567848b01b8949b514a2a171971957161d88348994d8f207e0f1e2f5d10fb1137a2b750b7f9f08e183537759dd2947be8c57911f4
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` means exactly "a guard said no or the edge is not
117
- declared" (`GuardFailed` / `InvalidTransition`). Everything else including
118
- `TransitionConflict` always raises, in both variants.
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
@@ -40,24 +40,28 @@ module Statecraft
40
40
  end
41
41
 
42
42
  def self.unary?(callable)
43
- callable.arity == 1
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), to: to && Array(to), event: event && Array(event)
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).select { |handler| handler.is_a?(Symbol) }
286
+ (from_edges + from_callbacks).grep(Symbol)
272
287
  end
273
288
 
274
289
  def deep_freeze_edges(edges)
@@ -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
@@ -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.delete(frame)
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.connection.adapter_name.match?(/sqlite/i)
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],
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Statecraft
4
- VERSION = "0.1.2"
4
+ VERSION = "0.1.3"
5
5
  end
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.2
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