statecraft 0.7.1 → 0.9.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 +4 -4
- data/README.md +57 -3
- data/lib/statecraft/errors.rb +17 -0
- data/lib/statecraft/introspection.rb +37 -12
- data/lib/statecraft/machine.rb +80 -13
- data/lib/statecraft/metadata.rb +5 -0
- data/lib/statecraft/mounting.rb +3 -1
- data/lib/statecraft/rspec/state_report.rb +6 -1
- data/lib/statecraft/rspec/transition.rb +14 -1
- data/lib/statecraft/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c8c55553745396ae74c5ffdac6de4dfad3df8ed075e26ef06ac627084aa87216
|
|
4
|
+
data.tar.gz: 995e759e6a394619f991b1f6921065219a9259928f4797aa4a340f66c601e0b7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 96794cd2d550849d99ccef2d9e6d0e12d12b790e585d7b8734a04a36ba55321f98bee6db237e9199e4c8ff02875900809070bc8fd295fb22dab7f5d41e71c87b
|
|
7
|
+
data.tar.gz: 6d5400fd71f2448099d9fc5c60ac71b663bbaed19b2c2d811e25172fa75148f970b9336b21fde31c09865f525dd428e2703314234bfb3e052c4dc1c5b2708085
|
data/README.md
CHANGED
|
@@ -145,6 +145,16 @@ A good `record_guard:` is a one-line delegation to a domain predicate on the
|
|
|
145
145
|
model (`def customer_cancellable?(record) = record.customer_cancellable?`):
|
|
146
146
|
the machine keeps the registry "event → predicate", the model keeps the fact.
|
|
147
147
|
|
|
148
|
+
The third nature is `input_guard:` — a guard declaring that its answer
|
|
149
|
+
*without* the input would be a false "no". Its handler must take exactly
|
|
150
|
+
`(record, metadata)` (the compiler refuses any other arity), execution runs
|
|
151
|
+
it after the record and plain layers, and the question surface honors the
|
|
152
|
+
declaration: a `can_fire?` / `may_*?` / `available_*` asked with metadata
|
|
153
|
+
omitted entirely raises `Statecraft::MetadataRequired` naming the guard,
|
|
154
|
+
instead of feeding it an empty hash and predicting a refusal that the real
|
|
155
|
+
call would not hit. An explicit `metadata: {}` states "my input is empty"
|
|
156
|
+
and is always a legal question.
|
|
157
|
+
|
|
148
158
|
Calling `transition_to!` directly over an edge that carries event guards is
|
|
149
159
|
refused — the guards would be silently skipped. The escape hatch is explicit:
|
|
150
160
|
`transition_to!(:paid, bypass_events: true)` skips event guards (edge guards
|
|
@@ -326,7 +336,36 @@ or a missing branch. It carries names, never words: human-readable reasons
|
|
|
326
336
|
belong to the application's presentation layer, not to the machine.
|
|
327
337
|
|
|
328
338
|
A guard that reads metadata makes `may_*?` depend on the metadata you pass —
|
|
329
|
-
pass the same metadata to `may_*?` that you will collect for `fire!`.
|
|
339
|
+
pass the same metadata to `may_*?` that you will collect for `fire!`. Mark
|
|
340
|
+
such a guard `input_guard:` and the machine holds you to it: a question
|
|
341
|
+
asked without any `metadata:` raises `Statecraft::MetadataRequired` with
|
|
342
|
+
the guard's name instead of answering a false "no" (an explicit
|
|
343
|
+
`metadata: {}` stays legal — it states the input is genuinely empty). A
|
|
344
|
+
plain `guard:` keeps the old behavior: a bare question consults it with an
|
|
345
|
+
empty hash.
|
|
346
|
+
|
|
347
|
+
## Strict mode
|
|
348
|
+
|
|
349
|
+
By default an unreachable state compiles silently — the column is written
|
|
350
|
+
by more than the gem (legacy rows, the console, external systems), so a
|
|
351
|
+
state without inbound edges is not necessarily a mistake. A machine that
|
|
352
|
+
claims its graph is closed opts in:
|
|
353
|
+
|
|
354
|
+
<!-- illustrative -->
|
|
355
|
+
```ruby
|
|
356
|
+
class OrderFlow < ApplicationMachine
|
|
357
|
+
strict!
|
|
358
|
+
|
|
359
|
+
state :pending, initial: true
|
|
360
|
+
# every state below must now be reachable from :pending through edges
|
|
361
|
+
end
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
With `strict!` compilation additionally requires every declared state to be
|
|
365
|
+
reachable from the initial one, walking edges only — guards are not
|
|
366
|
+
consulted, exactly like `transitions_from`. Dead ends stay legal in strict
|
|
367
|
+
mode too: terminal states are the norm, and an accidental one is surfaced
|
|
368
|
+
by the first test as `InvalidTransition` with the list of allowed targets.
|
|
330
369
|
|
|
331
370
|
## RSpec matchers
|
|
332
371
|
|
|
@@ -373,6 +412,20 @@ not with the block matcher.
|
|
|
373
412
|
it names record-layer guards only. An input-reading `guard:` has no name
|
|
374
413
|
there, and the failure message says so instead of guessing.
|
|
375
414
|
|
|
415
|
+
Under [`versioning:`](#stale-transitions-versioning-against-aba) the block
|
|
416
|
+
matcher folds the version into the same assertion — a matching transition
|
|
417
|
+
must also have incremented the version column — and every failure message
|
|
418
|
+
prints the record's standing with its version
|
|
419
|
+
(`Order in state :pending (state_version 3)`). Staleness stays an
|
|
420
|
+
exception, exactly as in production: assert the refusal of a stale token
|
|
421
|
+
with `raise_error`, not with a prediction matcher —
|
|
422
|
+
|
|
423
|
+
<!-- illustrative -->
|
|
424
|
+
```ruby
|
|
425
|
+
expect { order.cancel!(seen: stale_token) }
|
|
426
|
+
.to raise_error(Statecraft::StaleTransition)
|
|
427
|
+
```
|
|
428
|
+
|
|
376
429
|
## Metadata
|
|
377
430
|
|
|
378
431
|
Metadata is normalized on pipeline entry with a full JSON round-trip —
|
|
@@ -621,9 +674,10 @@ class OrderFlow
|
|
|
621
674
|
# event and the bypass path all share pending -> cancelled — the log
|
|
622
675
|
# records HOW, not only WHAT. The cancel guards split by nature: the
|
|
623
676
|
# record layer judges the order (and the offering may ask it), the input
|
|
624
|
-
# layer judges what the operator typed
|
|
677
|
+
# layer judges what the operator typed — and is marked input_guard:, so a
|
|
678
|
+
# question asked without metadata raises instead of predicting a false no.
|
|
625
679
|
event :cancel, from: :pending, to: :cancelled,
|
|
626
|
-
record_guard: :customer_cancellable?,
|
|
680
|
+
record_guard: :customer_cancellable?, input_guard: :reason_present?
|
|
627
681
|
event :admin_override, from: :pending, to: :cancelled
|
|
628
682
|
|
|
629
683
|
private
|
data/lib/statecraft/errors.rb
CHANGED
|
@@ -139,4 +139,21 @@ module Statecraft
|
|
|
139
139
|
"make the log model inherit from the model's connection-owning ancestor")
|
|
140
140
|
end
|
|
141
141
|
end
|
|
142
|
+
|
|
143
|
+
# A question (can_fire?, may_*?, available_*) was asked without metadata
|
|
144
|
+
# while an input_guard stands on the consulted path — its answer without
|
|
145
|
+
# the input would be a false "no", so the question refuses loudly instead.
|
|
146
|
+
# An explicit `metadata: {}` states "my input is empty" and is legal.
|
|
147
|
+
class MetadataRequired < Error
|
|
148
|
+
attr_reader :record, :question, :guards
|
|
149
|
+
|
|
150
|
+
def initialize(record:, question:, guards:)
|
|
151
|
+
@record = record
|
|
152
|
+
@question = question
|
|
153
|
+
@guards = guards
|
|
154
|
+
super("#{question} on #{record.class.name} consults input guards " \
|
|
155
|
+
"#{guards.map(&:inspect).join(", ")} — pass metadata: to answer with input " \
|
|
156
|
+
"(an explicit metadata: {} means the input is empty)")
|
|
157
|
+
end
|
|
158
|
+
end
|
|
142
159
|
end
|
|
@@ -11,7 +11,7 @@ module Statecraft
|
|
|
11
11
|
Availability = Struct.new(:to, :via, keyword_init: true)
|
|
12
12
|
Refusal = Struct.new(:event, :guard, :layer, keyword_init: true)
|
|
13
13
|
|
|
14
|
-
def can_fire?(event_name, metadata:
|
|
14
|
+
def can_fire?(event_name, metadata: Metadata::OMITTED)
|
|
15
15
|
graph = statecraft_graph
|
|
16
16
|
branches = graph.events[event_name.to_sym]
|
|
17
17
|
return false unless branches
|
|
@@ -19,24 +19,32 @@ module Statecraft
|
|
|
19
19
|
edge = branches[statecraft_current_state]
|
|
20
20
|
return false unless edge
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
normalized = statecraft_question_metadata(metadata, [[edge, event_name.to_sym]],
|
|
23
|
+
question: "can_fire?(#{event_name.inspect})")
|
|
24
|
+
statecraft_guards_pass?(edge, event_name.to_sym, normalized)
|
|
23
25
|
end
|
|
24
26
|
|
|
25
|
-
def available_events(metadata:
|
|
26
|
-
|
|
27
|
-
statecraft_graph.events.filter_map do |event_name, branches|
|
|
27
|
+
def available_events(metadata: Metadata::OMITTED)
|
|
28
|
+
consulted = statecraft_graph.events.filter_map do |event_name, branches|
|
|
28
29
|
edge = branches[statecraft_current_state]
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
[edge, event_name] if edge
|
|
31
|
+
end
|
|
32
|
+
normalized = statecraft_question_metadata(metadata, consulted, question: "available_events")
|
|
33
|
+
consulted.filter_map do |edge, event_name|
|
|
31
34
|
event_name if statecraft_guards_pass?(edge, event_name, normalized)
|
|
32
35
|
end
|
|
33
36
|
end
|
|
34
37
|
|
|
35
|
-
def available_transitions(metadata:
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
def available_transitions(metadata: Metadata::OMITTED)
|
|
39
|
+
outgoing = statecraft_graph.edges.filter_map do |(from, _to), edge|
|
|
40
|
+
edge if from == statecraft_current_state
|
|
41
|
+
end
|
|
42
|
+
consulted = outgoing.flat_map do |edge|
|
|
43
|
+
pairs = edge.event_names.map { |event_name| [edge, event_name] }
|
|
44
|
+
statecraft_direct_legal?(edge) ? pairs + [[edge, nil]] : pairs
|
|
45
|
+
end
|
|
46
|
+
normalized = statecraft_question_metadata(metadata, consulted, question: "available_transitions")
|
|
47
|
+
outgoing.filter_map do |edge|
|
|
40
48
|
via = statecraft_passable_via(edge, normalized)
|
|
41
49
|
Availability.new(to: edge.to, via: via) unless via.empty?
|
|
42
50
|
end
|
|
@@ -73,6 +81,23 @@ module Statecraft
|
|
|
73
81
|
|
|
74
82
|
private
|
|
75
83
|
|
|
84
|
+
# The single funnel for a question's metadata. Omitted with no input
|
|
85
|
+
# guard on the consulted path degrades to an empty hash; omitted with
|
|
86
|
+
# one raises MetadataRequired — the guard declared that its answer
|
|
87
|
+
# without input would be a false "no". An explicit hash always passes.
|
|
88
|
+
def statecraft_question_metadata(metadata, consulted_pairs, question:)
|
|
89
|
+
return Metadata.normalize(metadata) unless metadata.equal?(Metadata::OMITTED)
|
|
90
|
+
|
|
91
|
+
input_guards = consulted_pairs.flat_map { |edge, event_name| statecraft_input_guards(edge, event_name) }
|
|
92
|
+
return Metadata.normalize({}) if input_guards.empty?
|
|
93
|
+
|
|
94
|
+
raise MetadataRequired.new(record: self, question: question, guards: input_guards.uniq)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def statecraft_input_guards(edge, event_name)
|
|
98
|
+
edge.edge_input_guards + (event_name ? edge.event_input_guards.fetch(event_name, []) : [])
|
|
99
|
+
end
|
|
100
|
+
|
|
76
101
|
def statecraft_record_refusals(edge, event_name)
|
|
77
102
|
machine_instance = self.class.statecraft_mounting.machine_class.new
|
|
78
103
|
layers = {
|
data/lib/statecraft/machine.rb
CHANGED
|
@@ -7,12 +7,15 @@ module Statecraft
|
|
|
7
7
|
# Guard and callback symbols resolve to instance methods of the machine
|
|
8
8
|
# class; callables are honored with a plain `call` and arity dispatch.
|
|
9
9
|
module Machine
|
|
10
|
-
# edge_guards / event_guards carry the FULL lists of
|
|
11
|
-
# record-
|
|
10
|
+
# edge_guards / event_guards carry the FULL lists of all layers in
|
|
11
|
+
# record-plain-input order — execution and the prediction run them as one
|
|
12
12
|
# sequence. The parallel *_record_guards fields hold only the record
|
|
13
|
-
# layer
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
# layer (read by the offering introspection); the *_input_guards fields
|
|
14
|
+
# hold only the marked input layer (read by the question surface to
|
|
15
|
+
# refuse a question asked without metadata).
|
|
16
|
+
Edge = Struct.new(:from, :to, :lock, :edge_guards, :edge_record_guards, :edge_input_guards,
|
|
17
|
+
:event_names, :event_guards, :event_record_guards, :event_input_guards,
|
|
18
|
+
keyword_init: true)
|
|
16
19
|
Callback = Struct.new(:handler, :from, :to, :event, keyword_init: true)
|
|
17
20
|
CompiledGraph = Struct.new(
|
|
18
21
|
:states, :initial_state, :edges, :events, :callbacks,
|
|
@@ -59,16 +62,17 @@ module Statecraft
|
|
|
59
62
|
declared_states << { name: name.to_sym, initial: initial }
|
|
60
63
|
end
|
|
61
64
|
|
|
62
|
-
def transition(from:, to:, guard: nil, record_guard: nil, lock: false)
|
|
65
|
+
def transition(from:, to:, guard: nil, record_guard: nil, input_guard: nil, lock: false)
|
|
63
66
|
declared_edges << {
|
|
64
67
|
from: from.to_sym, to: to.to_sym,
|
|
65
68
|
guards: Array(guard), record_guards: Array(record_guard),
|
|
69
|
+
input_guards: Array(input_guard),
|
|
66
70
|
lock: lock || current_event_lock,
|
|
67
71
|
event: current_event_name
|
|
68
72
|
}
|
|
69
73
|
end
|
|
70
74
|
|
|
71
|
-
def event(name, from: nil, to: nil, guard: nil, record_guard: nil, lock: false, &declarations)
|
|
75
|
+
def event(name, from: nil, to: nil, guard: nil, record_guard: nil, input_guard: nil, lock: false, &declarations)
|
|
72
76
|
name = name.to_sym
|
|
73
77
|
declared_event_names << name
|
|
74
78
|
if declarations
|
|
@@ -87,7 +91,8 @@ module Statecraft
|
|
|
87
91
|
|
|
88
92
|
@statecraft_current_event = { name: name, lock: false }
|
|
89
93
|
begin
|
|
90
|
-
transition(from: from, to: to, guard: guard, record_guard: record_guard,
|
|
94
|
+
transition(from: from, to: to, guard: guard, record_guard: record_guard,
|
|
95
|
+
input_guard: input_guard, lock: lock)
|
|
91
96
|
ensure
|
|
92
97
|
@statecraft_current_event = nil
|
|
93
98
|
end
|
|
@@ -108,6 +113,16 @@ module Statecraft
|
|
|
108
113
|
end
|
|
109
114
|
end
|
|
110
115
|
|
|
116
|
+
# Opt-in shape strictness: with strict! the compiler additionally
|
|
117
|
+
# requires every declared state to be reachable from the initial one.
|
|
118
|
+
# Off by default — the column is written by more than the gem, so an
|
|
119
|
+
# unconnected state is legal unless the machine claims a closed graph.
|
|
120
|
+
def strict!
|
|
121
|
+
@statecraft_strict = true
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def strict? = @statecraft_strict == true
|
|
125
|
+
|
|
111
126
|
def states
|
|
112
127
|
compiled_graph.states
|
|
113
128
|
end
|
|
@@ -197,6 +212,8 @@ module Statecraft
|
|
|
197
212
|
events = compile_events(edges)
|
|
198
213
|
resolve_symbols(edges)
|
|
199
214
|
assert_record_guards_unary(edges)
|
|
215
|
+
assert_input_guards_binary(edges)
|
|
216
|
+
assert_states_reachable(states, initial, edges) if machine_class.strict?
|
|
200
217
|
CompiledGraph.new(
|
|
201
218
|
states: states.freeze,
|
|
202
219
|
initial_state: initial,
|
|
@@ -249,9 +266,10 @@ module Statecraft
|
|
|
249
266
|
def build_edge(declaration)
|
|
250
267
|
Edge.new(
|
|
251
268
|
from: declaration[:from], to: declaration[:to], lock: declaration[:lock],
|
|
252
|
-
edge_guards: declaration[:record_guards] + declaration[:guards],
|
|
269
|
+
edge_guards: declaration[:record_guards] + declaration[:guards] + declaration[:input_guards],
|
|
253
270
|
edge_record_guards: declaration[:record_guards],
|
|
254
|
-
|
|
271
|
+
edge_input_guards: declaration[:input_guards],
|
|
272
|
+
event_names: [], event_guards: {}, event_record_guards: {}, event_input_guards: {}
|
|
255
273
|
)
|
|
256
274
|
end
|
|
257
275
|
|
|
@@ -259,16 +277,18 @@ module Statecraft
|
|
|
259
277
|
event_name = declaration[:event]
|
|
260
278
|
edge ||= Edge.new(
|
|
261
279
|
from: declaration[:from], to: declaration[:to], lock: false,
|
|
262
|
-
edge_guards: [], edge_record_guards: [],
|
|
263
|
-
event_names: [], event_guards: {}, event_record_guards: {}
|
|
280
|
+
edge_guards: [], edge_record_guards: [], edge_input_guards: [],
|
|
281
|
+
event_names: [], event_guards: {}, event_record_guards: {}, event_input_guards: {}
|
|
264
282
|
)
|
|
265
283
|
if edge.event_names.include?(event_name)
|
|
266
284
|
raise CompilationError, "event #{event_name.inspect} declares edge #{pair_name(pair)} twice"
|
|
267
285
|
end
|
|
268
286
|
|
|
269
287
|
edge.event_names << event_name
|
|
270
|
-
edge.event_guards[event_name] = declaration[:record_guards] + declaration[:guards]
|
|
288
|
+
edge.event_guards[event_name] = declaration[:record_guards] + declaration[:guards] +
|
|
289
|
+
declaration[:input_guards]
|
|
271
290
|
edge.event_record_guards[event_name] = declaration[:record_guards]
|
|
291
|
+
edge.event_input_guards[event_name] = declaration[:input_guards]
|
|
272
292
|
edge.lock ||= declaration[:lock]
|
|
273
293
|
edge
|
|
274
294
|
end
|
|
@@ -334,15 +354,62 @@ module Statecraft
|
|
|
334
354
|
guard.respond_to?(:arity) ? guard.arity : guard.method(:call).arity
|
|
335
355
|
end
|
|
336
356
|
|
|
357
|
+
# An input guard promises that its answer is meaningless without the
|
|
358
|
+
# input, and the promise is held by shape: it must accept exactly the
|
|
359
|
+
# record and the metadata, so a question asked without metadata can be
|
|
360
|
+
# refused instead of answered falsely.
|
|
361
|
+
def assert_input_guards_binary(edges)
|
|
362
|
+
edges.each_value do |edge|
|
|
363
|
+
input_guards = edge.edge_input_guards + edge.event_input_guards.values.flatten
|
|
364
|
+
input_guards.each do |guard|
|
|
365
|
+
arity = record_guard_arity(guard)
|
|
366
|
+
next if arity == 2
|
|
367
|
+
|
|
368
|
+
label = guard.is_a?(Symbol) ? guard.inspect : "the callable"
|
|
369
|
+
raise CompilationError,
|
|
370
|
+
"input_guard #{label} must take the record and the metadata (arity 2), got arity #{arity}"
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Strict reachability is a shape check, like transitions_from: edges
|
|
376
|
+
# only, no guards. Dead ends stay legal — terminal states are the norm.
|
|
377
|
+
def assert_states_reachable(states, initial, edges)
|
|
378
|
+
unreachable = states - reachable_states(initial, edges)
|
|
379
|
+
return if unreachable.empty?
|
|
380
|
+
|
|
381
|
+
raise CompilationError,
|
|
382
|
+
"strict!: unreachable from the initial #{initial.inspect}: " \
|
|
383
|
+
"#{unreachable.map(&:inspect).join(", ")} — connect with edges or drop strict!"
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def reachable_states(initial, edges)
|
|
387
|
+
reached = [initial]
|
|
388
|
+
queue = [initial]
|
|
389
|
+
until queue.empty?
|
|
390
|
+
from = queue.shift
|
|
391
|
+
edges.each_key do |(edge_from, edge_to)|
|
|
392
|
+
next if edge_from != from || reached.include?(edge_to)
|
|
393
|
+
|
|
394
|
+
reached << edge_to
|
|
395
|
+
queue << edge_to
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
reached
|
|
399
|
+
end
|
|
400
|
+
|
|
337
401
|
def deep_freeze_edges(edges)
|
|
338
402
|
edges.each_value do |edge|
|
|
339
403
|
edge.edge_guards.freeze
|
|
340
404
|
edge.edge_record_guards.freeze
|
|
405
|
+
edge.edge_input_guards.freeze
|
|
341
406
|
edge.event_names.freeze
|
|
342
407
|
edge.event_guards.each_value(&:freeze)
|
|
343
408
|
edge.event_guards.freeze
|
|
344
409
|
edge.event_record_guards.each_value(&:freeze)
|
|
345
410
|
edge.event_record_guards.freeze
|
|
411
|
+
edge.event_input_guards.each_value(&:freeze)
|
|
412
|
+
edge.event_input_guards.freeze
|
|
346
413
|
edge.freeze
|
|
347
414
|
end
|
|
348
415
|
edges.freeze
|
data/lib/statecraft/metadata.rb
CHANGED
|
@@ -7,6 +7,11 @@ module Statecraft
|
|
|
7
7
|
# strings, and anything that JSON cannot represent fails instantly at the
|
|
8
8
|
# pipeline entrance instead of inside the transaction.
|
|
9
9
|
module Metadata
|
|
10
|
+
# The question surface's default: distinguishes "asked without metadata"
|
|
11
|
+
# from an explicit empty hash, so a question that would consult an
|
|
12
|
+
# input_guard can refuse loudly instead of answering from a void.
|
|
13
|
+
OMITTED = Object.new.freeze
|
|
14
|
+
|
|
10
15
|
def self.normalize(raw_metadata)
|
|
11
16
|
deep_freeze(round_trip(raw_metadata))
|
|
12
17
|
end
|
data/lib/statecraft/mounting.rb
CHANGED
|
@@ -183,7 +183,9 @@ module Statecraft
|
|
|
183
183
|
define_method(event_name) do |metadata: {}, seen: nil|
|
|
184
184
|
fire(event_name, metadata: metadata, seen: seen)
|
|
185
185
|
end
|
|
186
|
-
define_method("may_#{event_name}?")
|
|
186
|
+
define_method("may_#{event_name}?") do |metadata: Metadata::OMITTED|
|
|
187
|
+
can_fire?(event_name, metadata: metadata)
|
|
188
|
+
end
|
|
187
189
|
end
|
|
188
190
|
end
|
|
189
191
|
model.include(verbs)
|
|
@@ -14,7 +14,12 @@ module Statecraft
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def standing(record)
|
|
17
|
-
"#{record.class.name} in state #{current_state(record).inspect}"
|
|
17
|
+
"#{record.class.name} in state #{current_state(record).inspect}#{version_standing(record)}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def version_standing(record)
|
|
21
|
+
column = record.class.statecraft_mounting.version_column
|
|
22
|
+
column ? " (#{column} #{record[column]})" : ""
|
|
18
23
|
end
|
|
19
24
|
|
|
20
25
|
def machine(record)
|
|
@@ -7,7 +7,8 @@ module Statecraft
|
|
|
7
7
|
#
|
|
8
8
|
# The transition through the eyes of a test: the state column moved to
|
|
9
9
|
# the target AND exactly one log row was appended with the matching
|
|
10
|
-
# from/to/event/metadata
|
|
10
|
+
# from/to/event/metadata — and under versioning: the version column
|
|
11
|
+
# incremented together with the state. A non-bang call that returned false leaves
|
|
11
12
|
# both untouched — the matcher fails and explains why, from the same
|
|
12
13
|
# introspection the pipeline consulted. Exceptions of the bang forms
|
|
13
14
|
# fly through, like with the change matcher: refusals are asserted
|
|
@@ -45,10 +46,13 @@ module Statecraft
|
|
|
45
46
|
def matches?(block)
|
|
46
47
|
raise ArgumentError, "transition(record).to(:state) — the .to target is required" unless @to_state
|
|
47
48
|
|
|
49
|
+
@version_column = @record.class.statecraft_mounting.version_column
|
|
48
50
|
@before_state = StateReport.current_state(@record)
|
|
51
|
+
@before_version = @record[@version_column] if @version_column
|
|
49
52
|
appended_before = @record.history.count
|
|
50
53
|
block.call
|
|
51
54
|
@after_state = StateReport.current_state(@record)
|
|
55
|
+
@after_version = @record[@version_column] if @version_column
|
|
52
56
|
@appended = @record.history.offset(appended_before).to_a
|
|
53
57
|
collect_failures
|
|
54
58
|
@failures.empty?
|
|
@@ -84,6 +88,15 @@ module Statecraft
|
|
|
84
88
|
@failures << "the record ended in #{@after_state.inspect}, not #{@to_state.inspect}"
|
|
85
89
|
end
|
|
86
90
|
collect_row_mismatches(@appended.last)
|
|
91
|
+
collect_version_mismatch
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def collect_version_mismatch
|
|
95
|
+
return unless @version_column
|
|
96
|
+
return if @after_version == @before_version + 1
|
|
97
|
+
|
|
98
|
+
@failures << "the #{@version_column} column went from #{@before_version} to #{@after_version}, " \
|
|
99
|
+
"expected #{@before_version + 1}: a versioned transition increments the version with the state"
|
|
87
100
|
end
|
|
88
101
|
|
|
89
102
|
def collect_row_mismatches(row)
|
data/lib/statecraft/version.rb
CHANGED