statecraft 0.5.0 → 0.7.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 +113 -4
- data/lib/generators/statecraft/machine/USAGE +5 -0
- data/lib/generators/statecraft/machine/machine_generator.rb +15 -1
- data/lib/generators/statecraft/machine/templates/add_migration.rb.tt +3 -0
- data/lib/generators/statecraft/machine/templates/create_migration.rb.tt +3 -0
- data/lib/generators/statecraft/machine/templates/model.rb.tt +1 -1
- data/lib/statecraft/errors.rb +18 -3
- data/lib/statecraft/mounting.rb +26 -8
- data/lib/statecraft/pipeline/surface.rb +9 -9
- data/lib/statecraft/pipeline/versioning.rb +68 -0
- data/lib/statecraft/pipeline.rb +19 -10
- data/lib/statecraft/rspec/allow_event.rb +48 -0
- data/lib/statecraft/rspec/allow_transition_to.rb +71 -0
- data/lib/statecraft/rspec/have_edge.rb +50 -0
- data/lib/statecraft/rspec/have_initial_state.rb +30 -0
- data/lib/statecraft/rspec/have_transitioned_to.rb +44 -0
- data/lib/statecraft/rspec/matchers.rb +41 -0
- data/lib/statecraft/rspec/refuse_event.rb +69 -0
- data/lib/statecraft/rspec/state_report.rb +67 -0
- data/lib/statecraft/rspec/transition.rb +120 -0
- data/lib/statecraft/rspec.rb +24 -0
- data/lib/statecraft/version.rb +1 -1
- data/lib/statecraft.rb +1 -0
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5e98ec25e4cfbb6cec172cd1f54ac9362937c967eff450ccda67d414e2dce6d3
|
|
4
|
+
data.tar.gz: e8f112727ef007ad0d6d3e91e67c122298bbf1d403f09aa9e2aeda56e43545da
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ed3978c9e0098cfc1ed4a19b933ce2a134e8841385e0e9542c267e43b6095f81c2b97b230a40d04605d0618717b3af289716157c3ab029f85b30886c54a11cb5
|
|
7
|
+
data.tar.gz: 626c429ccc91fc9f40401e58ab6142b81544684d57890fb0f51cec79d901e6280d06e2a14afb0400bb6d592faf33e5dbab5ef7c09045e4435b8557dd2162fb73
|
data/README.md
CHANGED
|
@@ -210,6 +210,70 @@ through untouched: those errors mean "restart the whole transaction", a
|
|
|
210
210
|
different protocol than the conflict's "continue from clean state", and the
|
|
211
211
|
retry policy belongs to whoever chose the isolation level.
|
|
212
212
|
|
|
213
|
+
## Stale transitions: versioning against ABA
|
|
214
|
+
|
|
215
|
+
The CAS above compares the state's *value* — so a state that went away and
|
|
216
|
+
came back (`pending → paid → … → pending`) passes for the state an open
|
|
217
|
+
page rendered minutes ago. That is the ABA problem, and `versioning: true`
|
|
218
|
+
closes it:
|
|
219
|
+
|
|
220
|
+
<!-- illustrative -->
|
|
221
|
+
```ruby
|
|
222
|
+
class Order < ApplicationRecord
|
|
223
|
+
state_machine OrderFlow, versioning: true # the state_version column
|
|
224
|
+
end
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
With the option every transition compares-and-swaps on the *pair* of state
|
|
228
|
+
and version and increments the version in the same UPDATE — a returned
|
|
229
|
+
state no longer matches, even without any token. `true` names the column
|
|
230
|
+
`<column>_version`; a symbol overrides the name. The column is a plain
|
|
231
|
+
`bigint NOT NULL DEFAULT 0`: `rails g statecraft:machine Order --versioning`
|
|
232
|
+
generates it, and for an existing table one step is enough — on
|
|
233
|
+
PostgreSQL 11+ an `add_column` with a constant default is metadata-only,
|
|
234
|
+
no table rewrite:
|
|
235
|
+
|
|
236
|
+
<!-- illustrative -->
|
|
237
|
+
```ruby
|
|
238
|
+
add_column :orders, :state_version, :bigint, null: false, default: 0
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
The user-facing half is the `seen:` token: render the version into the
|
|
242
|
+
form, send it back, and the pipeline refuses the action when the row has
|
|
243
|
+
moved on — `Statecraft::StaleTransition`, a `TransitionConflict` subclass
|
|
244
|
+
carrying `expected_version` and `seen`, published as reason `:stale`:
|
|
245
|
+
|
|
246
|
+
<!-- illustrative -->
|
|
247
|
+
```erb
|
|
248
|
+
<input type="hidden" name="seen" value="<%= order.state_version %>">
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
<!-- illustrative -->
|
|
252
|
+
```ruby
|
|
253
|
+
def cancel
|
|
254
|
+
order = Order.find(params[:id])
|
|
255
|
+
order.cancel!(metadata: cancel_metadata, seen: params[:seen])
|
|
256
|
+
redirect_to order_path(order), notice: "Cancelled."
|
|
257
|
+
rescue Statecraft::StaleTransition
|
|
258
|
+
head :conflict # the API mapping: 409
|
|
259
|
+
# a form flow redirects instead:
|
|
260
|
+
# redirect_to order_path(order),
|
|
261
|
+
# alert: "This order changed while the page was open."
|
|
262
|
+
end
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
`seen:` rides all four surface forms and the helper verbs. A string token
|
|
266
|
+
straight from params is fine — the pipeline normalizes it with `Integer()`,
|
|
267
|
+
and garbage raises `ArgumentError` loudly. The honest limits: staleness
|
|
268
|
+
exists only where a token was given — without `seen:` a version mismatch
|
|
269
|
+
is the ordinary `TransitionConflict`; the early deterministic check runs
|
|
270
|
+
only under `lock: true`, where the reload holds the row's real version —
|
|
271
|
+
without the lock the CAS itself is the check, since a fresh token can
|
|
272
|
+
outrun a stale in-memory record; and `seen:` on a mounting without
|
|
273
|
+
`versioning:` raises a `CompilationError` naming the fix instead of
|
|
274
|
+
silently skipping the protection. Reads stay join-free: the version lives
|
|
275
|
+
on the parent row, right next to the state.
|
|
276
|
+
|
|
213
277
|
## Introspection
|
|
214
278
|
|
|
215
279
|
<!-- illustrative -->
|
|
@@ -260,6 +324,51 @@ belong to the application's presentation layer, not to the machine.
|
|
|
260
324
|
A guard that reads metadata makes `may_*?` depend on the metadata you pass —
|
|
261
325
|
pass the same metadata to `may_*?` that you will collect for `fire!`.
|
|
262
326
|
|
|
327
|
+
## RSpec matchers
|
|
328
|
+
|
|
329
|
+
One opt-in require gives your specs matchers over the whole introspection
|
|
330
|
+
surface — RSpec never becomes a runtime dependency of the gem:
|
|
331
|
+
|
|
332
|
+
<!-- illustrative -->
|
|
333
|
+
```ruby
|
|
334
|
+
# spec_helper.rb, after rspec itself is loaded
|
|
335
|
+
require "statecraft/rspec"
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
<!-- illustrative -->
|
|
339
|
+
```ruby
|
|
340
|
+
# The record-level questions consult the guards, with the same metadata
|
|
341
|
+
# your production call will carry:
|
|
342
|
+
expect(order).to allow_event(:pay).with_metadata("amount" => 100)
|
|
343
|
+
expect(order).to allow_transition_to(:cancelled).via(:cancel)
|
|
344
|
+
expect(order).to allow_transition_to(:archived).directly
|
|
345
|
+
expect(order).to have_transitioned_to(:paid) # strictly log-based
|
|
346
|
+
|
|
347
|
+
# The refusal with its reason — guard names come from refusals_for:
|
|
348
|
+
expect(order).to refuse_event(:cancel).because_of(:customer_cancellable?)
|
|
349
|
+
|
|
350
|
+
# The class-level pair answers the graph's shape; guards stay untouched:
|
|
351
|
+
expect(OrderFlow).to have_edge(:pending, :cancelled).via(:cancel)
|
|
352
|
+
expect(OrderFlow).to have_initial_state(:pending)
|
|
353
|
+
|
|
354
|
+
# The transition itself: the state move AND the appended log row,
|
|
355
|
+
# asserted in one expression around fire!/transition_to!:
|
|
356
|
+
expect { order.fire!(:pay, metadata: { "amount" => 100 }) }
|
|
357
|
+
.to transition(order).from(:pending).to(:paid)
|
|
358
|
+
.via_event(:pay).with_metadata("amount" => 100)
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
A failing matcher explains itself with the same introspection the pipeline
|
|
362
|
+
consults: the current state, the edges reachable from it, and the refusing
|
|
363
|
+
guard with its layer. A non-bang call that returned `false` fails the
|
|
364
|
+
`transition` matcher the same way; exceptions of the bang forms fly through
|
|
365
|
+
like with `change` — assert refusals with `refuse_event` or `raise_error`,
|
|
366
|
+
not with the block matcher.
|
|
367
|
+
|
|
368
|
+
`because_of` carries the same honest limit as `refusals_for` underneath it:
|
|
369
|
+
it names record-layer guards only. An input-reading `guard:` has no name
|
|
370
|
+
there, and the failure message says so instead of guessing.
|
|
371
|
+
|
|
263
372
|
## Metadata
|
|
264
373
|
|
|
265
374
|
Metadata is normalized on pipeline entry with a full JSON round-trip —
|
|
@@ -599,7 +708,7 @@ actions over services, guard refusals local to their form:
|
|
|
599
708
|
@order = Order.find(params[:id])
|
|
600
709
|
authorize! event_name, @order
|
|
601
710
|
@metadata = submitted_metadata
|
|
602
|
-
@order.fire!(event_name, metadata: @metadata)
|
|
711
|
+
@order.fire!(event_name, metadata: @metadata, seen: params[:seen].presence)
|
|
603
712
|
redirect_to admin_order_path(@order),
|
|
604
713
|
notice: "#{event_name} fired: the order is now #{@order[:state]}."
|
|
605
714
|
rescue Statecraft::GuardFailed => error
|
|
@@ -623,6 +732,7 @@ possibility-times-permission intersection:
|
|
|
623
732
|
<!-- readme: preview-pattern -->
|
|
624
733
|
```erb
|
|
625
734
|
<%= form_with url: preview_admin_order_path(@order), method: :post, local: true do %>
|
|
735
|
+
<input type="hidden" name="seen" value="<%= @order[:state_version] %>">
|
|
626
736
|
<fieldset>
|
|
627
737
|
<legend>Metadata for the next action</legend>
|
|
628
738
|
<label>
|
|
@@ -660,13 +770,12 @@ ActiveSupport::Notifications.subscribe("transition.statecraft") do |_name, _star
|
|
|
660
770
|
)
|
|
661
771
|
end
|
|
662
772
|
|
|
773
|
+
# The failure payload names the record, the machine and the reason — it
|
|
774
|
+
# carries no from/to/event keys: the transition never happened.
|
|
663
775
|
ActiveSupport::Notifications.subscribe("transition_failed.statecraft") do |_name, _started, _finished, _id, payload|
|
|
664
776
|
OperationEntry.create!(
|
|
665
777
|
record_class: payload[:record_class],
|
|
666
778
|
record_id: payload[:record_id].to_s,
|
|
667
|
-
from_state: payload[:from],
|
|
668
|
-
to_state: payload[:to],
|
|
669
|
-
event_name: payload[:event],
|
|
670
779
|
outcome: "refused",
|
|
671
780
|
reason: payload[:reason].to_s
|
|
672
781
|
)
|
|
@@ -13,8 +13,13 @@ Description:
|
|
|
13
13
|
migration lands in the migrations_paths configured for that database
|
|
14
14
|
(db/migrate when none is configured).
|
|
15
15
|
|
|
16
|
+
With --versioning the migration also adds a state_version column and
|
|
17
|
+
the mounting carries versioning: true — a tagged CAS that refuses
|
|
18
|
+
stale seen: tokens with Statecraft::StaleTransition.
|
|
19
|
+
|
|
16
20
|
Example:
|
|
17
21
|
bin/rails generate statecraft:machine Order
|
|
22
|
+
bin/rails generate statecraft:machine Order --versioning
|
|
18
23
|
bin/rails generate statecraft:machine Shop::Order
|
|
19
24
|
|
|
20
25
|
This will create:
|
|
@@ -18,6 +18,10 @@ module Statecraft
|
|
|
18
18
|
|
|
19
19
|
source_root File.expand_path("templates", __dir__)
|
|
20
20
|
|
|
21
|
+
class_option :versioning, type: :boolean, default: false,
|
|
22
|
+
desc: "Add a state_version column and mount with versioning: " \
|
|
23
|
+
"true — a tagged CAS that refuses stale seen: tokens"
|
|
24
|
+
|
|
21
25
|
def detect_model_presence
|
|
22
26
|
@existing_model = File.exist?(File.join(destination_root, model_file))
|
|
23
27
|
end
|
|
@@ -71,7 +75,17 @@ module Statecraft
|
|
|
71
75
|
end
|
|
72
76
|
|
|
73
77
|
def mounting_line
|
|
74
|
-
" state_machine #{class_name}Flow,
|
|
78
|
+
" state_machine #{class_name}Flow, #{mounting_options}\n"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def mounting_options
|
|
82
|
+
options_list = ["changed_at: true"]
|
|
83
|
+
options_list << "versioning: true" if options[:versioning]
|
|
84
|
+
(options_list + ["helpers: true", "scopes: true"]).join(", ")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def versioning?
|
|
88
|
+
options[:versioning]
|
|
75
89
|
end
|
|
76
90
|
|
|
77
91
|
# inject_into_class matches the literal `class <name>` line, so the name
|
|
@@ -5,6 +5,9 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi
|
|
|
5
5
|
add_column :<%= table_name %>, :state, :string, null: false, default: "pending"
|
|
6
6
|
add_index :<%= table_name %>, :state
|
|
7
7
|
add_column :<%= table_name %>, :state_changed_at, :datetime
|
|
8
|
+
<% if versioning? -%>
|
|
9
|
+
add_column :<%= table_name %>, :state_version, :bigint, null: false, default: 0
|
|
10
|
+
<% end -%>
|
|
8
11
|
|
|
9
12
|
# The existing table may carry legacy values, so no CHECK constraint is
|
|
10
13
|
# generated here. To add one later without blocking writes:
|
|
@@ -5,6 +5,9 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi
|
|
|
5
5
|
create_table :<%= table_name %> do |t|
|
|
6
6
|
t.string :state, null: false, default: "pending", index: true
|
|
7
7
|
t.datetime :state_changed_at
|
|
8
|
+
<% if versioning? -%>
|
|
9
|
+
t.bigint :state_version, null: false, default: 0
|
|
10
|
+
<% end -%>
|
|
8
11
|
t.timestamps null: false
|
|
9
12
|
end
|
|
10
13
|
|
data/lib/statecraft/errors.rb
CHANGED
|
@@ -38,11 +38,26 @@ module Statecraft
|
|
|
38
38
|
class TransitionConflict < Error
|
|
39
39
|
attr_reader :record, :expected_from
|
|
40
40
|
|
|
41
|
-
def initialize(record:, expected_from:)
|
|
41
|
+
def initialize(record:, expected_from:, message: nil)
|
|
42
42
|
@record = record
|
|
43
43
|
@expected_from = expected_from
|
|
44
|
-
super("concurrent update detected for #{record.class.name}##{record.id}: " \
|
|
45
|
-
|
|
44
|
+
super(message || "concurrent update detected for #{record.class.name}##{record.id}: " \
|
|
45
|
+
"expected state #{expected_from.inspect}, another writer got there first")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The refusal of a seen: token: the snapshot the caller acted on is no
|
|
50
|
+
# longer the row's version. A subclass of TransitionConflict, so existing
|
|
51
|
+
# rescues keep catching it; controllers map it to 409 specifically.
|
|
52
|
+
class StaleTransition < TransitionConflict
|
|
53
|
+
attr_reader :expected_version, :seen
|
|
54
|
+
|
|
55
|
+
def initialize(record:, expected_from:, expected_version:, seen:)
|
|
56
|
+
@expected_version = expected_version
|
|
57
|
+
@seen = seen
|
|
58
|
+
super(record: record, expected_from: expected_from,
|
|
59
|
+
message: "stale transition for #{record.class.name}##{record.id}: " \
|
|
60
|
+
"the caller saw version #{seen.inspect}, but the row has moved on")
|
|
46
61
|
end
|
|
47
62
|
end
|
|
48
63
|
|
data/lib/statecraft/mounting.rb
CHANGED
|
@@ -10,16 +10,17 @@ module Statecraft
|
|
|
10
10
|
# in_state?). Everything internal goes through base_class.
|
|
11
11
|
module Mounting
|
|
12
12
|
Configuration = Struct.new(
|
|
13
|
-
:machine_class, :log_class, :column, :changed_at_column, :
|
|
14
|
-
:log_foreign_key,
|
|
13
|
+
:machine_class, :log_class, :column, :changed_at_column, :version_column, :touch,
|
|
14
|
+
:helpers, :scopes, :log_foreign_key,
|
|
15
15
|
keyword_init: true
|
|
16
16
|
)
|
|
17
17
|
|
|
18
|
-
def state_machine(machine_class, log: nil, column: :state, changed_at: false,
|
|
19
|
-
helpers: false, scopes: false)
|
|
18
|
+
def state_machine(machine_class, log: nil, column: :state, changed_at: false,
|
|
19
|
+
versioning: false, touch: true, helpers: false, scopes: false)
|
|
20
20
|
Builder.new(
|
|
21
21
|
model: self, machine_class: machine_class, log: log, column: column,
|
|
22
|
-
changed_at: changed_at,
|
|
22
|
+
changed_at: changed_at, versioning: versioning, touch: touch,
|
|
23
|
+
helpers: helpers, scopes: scopes
|
|
23
24
|
).mount
|
|
24
25
|
end
|
|
25
26
|
|
|
@@ -27,16 +28,20 @@ module Statecraft
|
|
|
27
28
|
# class hierarchies only — never on the schema and never on a live
|
|
28
29
|
# connection, so mounting is safe at load time.
|
|
29
30
|
class Builder
|
|
30
|
-
|
|
31
|
+
# rubocop:disable Metrics/ParameterLists -- mirrors state_machine's kwargs one-to-one
|
|
32
|
+
def initialize(model:, machine_class:, log:, column:, changed_at:, versioning:, touch:,
|
|
33
|
+
helpers:, scopes:)
|
|
31
34
|
@model = model
|
|
32
35
|
@machine_class = machine_class
|
|
33
36
|
@log_option = log
|
|
34
37
|
@column = column
|
|
35
38
|
@changed_at = changed_at
|
|
39
|
+
@versioning = versioning
|
|
36
40
|
@touch = touch
|
|
37
41
|
@helpers = helpers
|
|
38
42
|
@scopes = scopes
|
|
39
43
|
end
|
|
44
|
+
# rubocop:enable Metrics/ParameterLists
|
|
40
45
|
|
|
41
46
|
def mount
|
|
42
47
|
assert_not_mounted
|
|
@@ -132,6 +137,7 @@ module Statecraft
|
|
|
132
137
|
log_class: log_class,
|
|
133
138
|
column: @column.to_sym,
|
|
134
139
|
changed_at_column: resolve_changed_at_column,
|
|
140
|
+
version_column: resolve_version_column,
|
|
135
141
|
touch: @touch,
|
|
136
142
|
helpers: @helpers,
|
|
137
143
|
scopes: @scopes,
|
|
@@ -147,6 +153,14 @@ module Statecraft
|
|
|
147
153
|
end
|
|
148
154
|
end
|
|
149
155
|
|
|
156
|
+
def resolve_version_column
|
|
157
|
+
case @versioning
|
|
158
|
+
when false then nil
|
|
159
|
+
when true then :"#{@column}_version"
|
|
160
|
+
else @versioning.to_sym
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
150
164
|
def store_configuration(configuration)
|
|
151
165
|
model.define_singleton_method(:statecraft_mounting) { configuration }
|
|
152
166
|
end
|
|
@@ -163,8 +177,12 @@ module Statecraft
|
|
|
163
177
|
def define_verbs(graph)
|
|
164
178
|
verbs = Module.new do
|
|
165
179
|
graph.events.each_key do |event_name|
|
|
166
|
-
define_method("#{event_name}!")
|
|
167
|
-
|
|
180
|
+
define_method("#{event_name}!") do |metadata: {}, seen: nil|
|
|
181
|
+
fire!(event_name, metadata: metadata, seen: seen)
|
|
182
|
+
end
|
|
183
|
+
define_method(event_name) do |metadata: {}, seen: nil|
|
|
184
|
+
fire(event_name, metadata: metadata, seen: seen)
|
|
185
|
+
end
|
|
168
186
|
define_method("may_#{event_name}?") { |metadata: {}| can_fire?(event_name, metadata: metadata) }
|
|
169
187
|
end
|
|
170
188
|
end
|
|
@@ -5,24 +5,24 @@ module Statecraft
|
|
|
5
5
|
# The record-facing API mixed into the model at mounting time. Bang
|
|
6
6
|
# variants return the created log record; non-bang variants return it too,
|
|
7
7
|
# or false on GuardFailed / InvalidTransition. Programmer errors and
|
|
8
|
-
# TransitionConflict always raise.
|
|
8
|
+
# TransitionConflict — StaleTransition included — always raise.
|
|
9
9
|
module Surface
|
|
10
|
-
def transition_to!(to_state, metadata: {}, bypass_events: false)
|
|
11
|
-
Pipeline.new(self).direct(to_state, metadata: metadata, bypass_events: bypass_events)
|
|
10
|
+
def transition_to!(to_state, metadata: {}, bypass_events: false, seen: nil)
|
|
11
|
+
Pipeline.new(self).direct(to_state, metadata: metadata, bypass_events: bypass_events, seen: seen)
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
def transition_to(to_state, metadata: {}, bypass_events: false)
|
|
15
|
-
transition_to!(to_state, metadata: metadata, bypass_events: bypass_events)
|
|
14
|
+
def transition_to(to_state, metadata: {}, bypass_events: false, seen: nil)
|
|
15
|
+
transition_to!(to_state, metadata: metadata, bypass_events: bypass_events, seen: seen)
|
|
16
16
|
rescue GuardFailed, InvalidTransition
|
|
17
17
|
false
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def fire!(event_name, metadata: {})
|
|
21
|
-
Pipeline.new(self).fire(event_name, metadata: metadata)
|
|
20
|
+
def fire!(event_name, metadata: {}, seen: nil)
|
|
21
|
+
Pipeline.new(self).fire(event_name, metadata: metadata, seen: seen)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def fire(event_name, metadata: {})
|
|
25
|
-
fire!(event_name, metadata: metadata)
|
|
24
|
+
def fire(event_name, metadata: {}, seen: nil)
|
|
25
|
+
fire!(event_name, metadata: metadata, seen: seen)
|
|
26
26
|
rescue GuardFailed, InvalidTransition
|
|
27
27
|
false
|
|
28
28
|
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
class Pipeline
|
|
5
|
+
# The tagged half of the CAS: when the mounting carries versioning, every
|
|
6
|
+
# transition compares-and-swaps on (state, version) and increments the
|
|
7
|
+
# version in the same UPDATE. A seen: token substitutes the compared
|
|
8
|
+
# value with what the caller's form actually rendered, and its refusal is
|
|
9
|
+
# StaleTransition — the 409 of the pipeline.
|
|
10
|
+
module Versioning
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def version_column
|
|
14
|
+
column = configuration.version_column
|
|
15
|
+
return nil unless column
|
|
16
|
+
|
|
17
|
+
unless record.class.column_names.include?(column.to_s)
|
|
18
|
+
raise CompilationError,
|
|
19
|
+
"versioning column #{column} does not exist on #{record.class.table_name}; " \
|
|
20
|
+
"add the column or drop the versioning option"
|
|
21
|
+
end
|
|
22
|
+
column
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def normalize_seen(raw_seen)
|
|
26
|
+
return nil if raw_seen.nil?
|
|
27
|
+
|
|
28
|
+
unless configuration.version_column
|
|
29
|
+
raise CompilationError,
|
|
30
|
+
"seen: requires versioning: true on the mounting of #{record.class.name}; " \
|
|
31
|
+
"add the option or drop the token"
|
|
32
|
+
end
|
|
33
|
+
Integer(raw_seen)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# What the CAS requires the row's version to be: the caller's seen:
|
|
37
|
+
# token when given, the in-memory value otherwise — fresh after a lock
|
|
38
|
+
# reload, synced by the previous link inside a chain.
|
|
39
|
+
def expected_version
|
|
40
|
+
@seen || record[version_column]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# With a seen: token any versioned refusal is staleness — the caller
|
|
44
|
+
# acted on a snapshot; without one it is the ordinary conflict of two
|
|
45
|
+
# writers.
|
|
46
|
+
def cas_refusal(edge)
|
|
47
|
+
if @seen
|
|
48
|
+
StaleTransition.new(record: record, expected_from: edge.from,
|
|
49
|
+
expected_version: expected_version, seen: @raw_seen)
|
|
50
|
+
else
|
|
51
|
+
TransitionConflict.new(record: record, expected_from: edge.from)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The same early observation as assert_lock_saw_expected_state, for the
|
|
56
|
+
# seen: token: the reload holds the row's real version under the lock,
|
|
57
|
+
# so a mismatched token is a stale snapshot detected deterministically,
|
|
58
|
+
# before any write.
|
|
59
|
+
def assert_lock_saw_expected_version(edge)
|
|
60
|
+
return unless @seen && version_column
|
|
61
|
+
return if record[version_column] == @seen
|
|
62
|
+
|
|
63
|
+
raise StaleTransition.new(record: record, expected_from: edge.from,
|
|
64
|
+
expected_version: @seen, seen: @raw_seen)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/statecraft/pipeline.rb
CHANGED
|
@@ -29,6 +29,7 @@ module Statecraft
|
|
|
29
29
|
MAX_CHAIN_DEPTH = 16
|
|
30
30
|
|
|
31
31
|
include EdgeResolution
|
|
32
|
+
include Versioning
|
|
32
33
|
|
|
33
34
|
def self.transition_stack
|
|
34
35
|
ActiveSupport::IsolatedExecutionState[STACK_KEY] ||= []
|
|
@@ -41,15 +42,15 @@ module Statecraft
|
|
|
41
42
|
@machine_instance = @configuration.machine_class.new
|
|
42
43
|
end
|
|
43
44
|
|
|
44
|
-
def direct(to_state, metadata:, bypass_events:)
|
|
45
|
-
run(metadata) do |current_state|
|
|
45
|
+
def direct(to_state, metadata:, bypass_events:, seen: nil)
|
|
46
|
+
run(metadata, seen: seen) do |current_state|
|
|
46
47
|
edge = resolve_direct_edge(current_state, to_state.to_sym, bypass_events)
|
|
47
48
|
[edge, nil, bypass_events]
|
|
48
49
|
end
|
|
49
50
|
end
|
|
50
51
|
|
|
51
|
-
def fire(event_name, metadata:)
|
|
52
|
-
run(metadata) do |current_state|
|
|
52
|
+
def fire(event_name, metadata:, seen: nil)
|
|
53
|
+
run(metadata, seen: seen) do |current_state|
|
|
53
54
|
edge = resolve_event_edge(current_state, event_name.to_sym)
|
|
54
55
|
[edge, event_name.to_sym, false]
|
|
55
56
|
end
|
|
@@ -59,10 +60,12 @@ module Statecraft
|
|
|
59
60
|
|
|
60
61
|
attr_reader :record, :configuration, :graph, :machine_instance
|
|
61
62
|
|
|
62
|
-
def run(raw_metadata, &edge_resolver)
|
|
63
|
+
def run(raw_metadata, seen: nil, &edge_resolver)
|
|
63
64
|
raise UnsavedRecordError.new(record: record) unless record.persisted?
|
|
64
65
|
|
|
65
66
|
assert_single_column_primary_key
|
|
67
|
+
@raw_seen = seen
|
|
68
|
+
@seen = normalize_seen(seen)
|
|
66
69
|
metadata = Metadata.normalize(raw_metadata)
|
|
67
70
|
started_at = Time.current
|
|
68
71
|
begin
|
|
@@ -91,6 +94,7 @@ module Statecraft
|
|
|
91
94
|
case error
|
|
92
95
|
when GuardFailed then [:guard_failed, { guard: error.guard }]
|
|
93
96
|
when InvalidTransition then [:invalid_transition, { requested: error.requested }]
|
|
97
|
+
when StaleTransition then [:stale, { expected_version: error.expected_version, seen: error.seen }]
|
|
94
98
|
when TransitionConflict then [:conflict, { expected_from: error.expected_from }]
|
|
95
99
|
end
|
|
96
100
|
Instrumentation.publish_failure(
|
|
@@ -105,6 +109,7 @@ module Statecraft
|
|
|
105
109
|
warn_when_row_locking_unavailable
|
|
106
110
|
record.reload(lock: true)
|
|
107
111
|
assert_lock_saw_expected_state(edge)
|
|
112
|
+
assert_lock_saw_expected_version(edge)
|
|
108
113
|
end
|
|
109
114
|
transition_time = Time.current
|
|
110
115
|
run_guards(edge, event, bypass, metadata)
|
|
@@ -204,17 +209,21 @@ module Statecraft
|
|
|
204
209
|
end
|
|
205
210
|
|
|
206
211
|
def cas_update!(edge, transition_time)
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
+
scope = base_class.unscoped
|
|
213
|
+
.where(base_class.primary_key => record.id,
|
|
214
|
+
configuration.column => edge.from.to_s)
|
|
215
|
+
scope = scope.where(version_column => expected_version) if version_column
|
|
216
|
+
affected_rows = scope.update_all(cas_updates(edge, transition_time))
|
|
217
|
+
raise cas_refusal(edge) if affected_rows.zero?
|
|
212
218
|
end
|
|
213
219
|
|
|
214
220
|
def cas_updates(edge, transition_time)
|
|
215
221
|
updates = { configuration.column => edge.to.to_s }
|
|
216
222
|
updates[:updated_at] = transition_time if touch_updated_at?
|
|
217
223
|
updates[changed_at_column] = transition_time if changed_at_column
|
|
224
|
+
# The literal is correct by construction: the CAS WHERE guarantees the
|
|
225
|
+
# row held exactly expected_version, so the memory mirror stays exact.
|
|
226
|
+
updates[version_column] = expected_version + 1 if version_column
|
|
218
227
|
updates
|
|
219
228
|
end
|
|
220
229
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
module RSpec
|
|
5
|
+
# expect(record).to allow_event(:pay).with_metadata("amount" => 5)
|
|
6
|
+
#
|
|
7
|
+
# The can_fire? question, asked with the same metadata the production
|
|
8
|
+
# call will carry. The failure message walks the layers the pipeline
|
|
9
|
+
# walks: is the event declared from this state, and if so, which
|
|
10
|
+
# guards said no.
|
|
11
|
+
class AllowEvent
|
|
12
|
+
def initialize(event_name)
|
|
13
|
+
@event_name = event_name.to_sym
|
|
14
|
+
@metadata = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def with_metadata(metadata)
|
|
18
|
+
@metadata = metadata
|
|
19
|
+
self
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def matches?(record)
|
|
23
|
+
@record = record
|
|
24
|
+
record.can_fire?(@event_name, metadata: @metadata)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def failure_message
|
|
28
|
+
lines = ["expected #{StateReport.standing(@record)} to allow event #{@event_name.inspect}, but it was refused"]
|
|
29
|
+
if StateReport.event_declared?(@record, @event_name)
|
|
30
|
+
lines << StateReport.refusal(@record, @event_name, @metadata)
|
|
31
|
+
else
|
|
32
|
+
lines << "event #{@event_name.inspect} is not declared from #{StateReport.current_state(@record).inspect}"
|
|
33
|
+
lines << StateReport.declared_shape_of(@record)
|
|
34
|
+
end
|
|
35
|
+
lines.join("\n ")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def failure_message_when_negated
|
|
39
|
+
"expected #{StateReport.standing(@record)} not to allow event #{@event_name.inspect}, " \
|
|
40
|
+
"but the guards passed with metadata #{@metadata.inspect}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def description
|
|
44
|
+
"allow event #{@event_name.inspect}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
module RSpec
|
|
5
|
+
# expect(record).to allow_transition_to(:paid).via(:pay)
|
|
6
|
+
# expect(record).to allow_transition_to(:archived).directly
|
|
7
|
+
#
|
|
8
|
+
# The available_transitions prediction for one target: not only
|
|
9
|
+
# whether the record can go there, but HOW — via lists the events
|
|
10
|
+
# whose guards pass, directly asserts the guard-free direct way.
|
|
11
|
+
class AllowTransitionTo
|
|
12
|
+
def initialize(target_state)
|
|
13
|
+
@target_state = target_state.to_sym
|
|
14
|
+
@expected_via = []
|
|
15
|
+
@directly = false
|
|
16
|
+
@metadata = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def via(*event_names)
|
|
20
|
+
@expected_via = event_names.map(&:to_sym)
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def directly
|
|
25
|
+
@directly = true
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def with_metadata(metadata)
|
|
30
|
+
@metadata = metadata
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def matches?(record)
|
|
35
|
+
@record = record
|
|
36
|
+
@availability = record.available_transitions(metadata: @metadata)
|
|
37
|
+
.find { |availability| availability.to == @target_state }
|
|
38
|
+
return false unless @availability
|
|
39
|
+
|
|
40
|
+
missing_ways.empty?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def failure_message
|
|
44
|
+
unless @availability
|
|
45
|
+
return ["expected #{StateReport.standing(@record)} to reach #{@target_state.inspect}, but it cannot",
|
|
46
|
+
StateReport.reachable(@record, @metadata),
|
|
47
|
+
StateReport.declared_shape_of(@record)].join("\n ")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
"expected the way to #{@target_state.inspect} to include #{missing_ways.map(&:inspect).join(", ")}, " \
|
|
51
|
+
"but it is reachable via #{@availability.via.inspect}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def failure_message_when_negated
|
|
55
|
+
"expected #{StateReport.standing(@record)} not to reach #{@target_state.inspect}, " \
|
|
56
|
+
"but it is reachable via #{@availability.via.inspect}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def description
|
|
60
|
+
"allow a transition to #{@target_state.inspect}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def missing_ways
|
|
66
|
+
expected = @expected_via + (@directly ? [:direct] : [])
|
|
67
|
+
expected - @availability.via
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
module RSpec
|
|
5
|
+
# expect(OrderFlow).to have_edge(:pending, :cancelled).via(:cancel)
|
|
6
|
+
#
|
|
7
|
+
# The class-level shape question of transitions_from: no guards are
|
|
8
|
+
# consulted, exactly like the method itself. via asserts that the
|
|
9
|
+
# named events ride the edge; a bare edge simply has none.
|
|
10
|
+
class HaveEdge
|
|
11
|
+
def initialize(from_state, to_state)
|
|
12
|
+
@from_state = from_state.to_sym
|
|
13
|
+
@to_state = to_state.to_sym
|
|
14
|
+
@expected_events = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def via(*event_names)
|
|
18
|
+
@expected_events = event_names.map(&:to_sym)
|
|
19
|
+
self
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def matches?(machine_class)
|
|
23
|
+
@machine_class = machine_class
|
|
24
|
+
@edge = machine_class.transitions_from(@from_state)
|
|
25
|
+
.find { |descriptor| descriptor[:to] == @to_state }
|
|
26
|
+
@edge && (@expected_events - @edge[:events]).empty?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def failure_message
|
|
30
|
+
unless @edge
|
|
31
|
+
return ["expected #{@machine_class.name} to declare an edge #{@from_state.inspect} -> #{@to_state.inspect}",
|
|
32
|
+
StateReport.declared_shape(@machine_class, @from_state)].join("\n ")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
missing = (@expected_events - @edge[:events]).map(&:inspect).join(", ")
|
|
36
|
+
"expected the edge #{@from_state.inspect} -> #{@to_state.inspect} to carry #{missing}, " \
|
|
37
|
+
"but its events are #{@edge[:events].inspect}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def failure_message_when_negated
|
|
41
|
+
"expected #{@machine_class.name} not to declare the edge #{@from_state.inspect} -> #{@to_state.inspect}, " \
|
|
42
|
+
"but it does (events: #{@edge[:events].inspect})"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def description
|
|
46
|
+
"have an edge #{@from_state.inspect} -> #{@to_state.inspect}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
module RSpec
|
|
5
|
+
# expect(OrderFlow).to have_initial_state(:pending)
|
|
6
|
+
class HaveInitialState
|
|
7
|
+
def initialize(state_name)
|
|
8
|
+
@state_name = state_name.to_sym
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def matches?(machine_class)
|
|
12
|
+
@machine_class = machine_class
|
|
13
|
+
machine_class.initial_state == @state_name
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def failure_message
|
|
17
|
+
"expected the initial state of #{@machine_class.name} to be #{@state_name.inspect}, " \
|
|
18
|
+
"but it is #{@machine_class.initial_state.inspect}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def failure_message_when_negated
|
|
22
|
+
"expected the initial state of #{@machine_class.name} not to be #{@state_name.inspect}, but it is"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def description
|
|
26
|
+
"have the initial state #{@state_name.inspect}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
module RSpec
|
|
5
|
+
# expect(record).to have_transitioned_to(:paid)
|
|
6
|
+
#
|
|
7
|
+
# Strictly log-based, like transitioned_to? itself: the question is
|
|
8
|
+
# about history, never about the current state. The failure message
|
|
9
|
+
# shows what the log actually holds.
|
|
10
|
+
class HaveTransitionedTo
|
|
11
|
+
def initialize(state_name)
|
|
12
|
+
@state_name = state_name.to_sym
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def matches?(record)
|
|
16
|
+
@record = record
|
|
17
|
+
record.transitioned_to?(@state_name)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def failure_message
|
|
21
|
+
"expected the log of #{StateReport.standing(@record)} to hold a transition to #{@state_name.inspect}, " \
|
|
22
|
+
"but #{log_contents}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def failure_message_when_negated
|
|
26
|
+
"expected the log of #{StateReport.standing(@record)} to hold no transition to #{@state_name.inspect}, " \
|
|
27
|
+
"but it does"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def description
|
|
31
|
+
"have transitioned to #{@state_name.inspect}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def log_contents
|
|
37
|
+
to_states = @record.history.map(&:to_state)
|
|
38
|
+
return "the log is empty" if to_states.empty?
|
|
39
|
+
|
|
40
|
+
"the log holds transitions to: #{to_states.join(", ")}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
module RSpec
|
|
5
|
+
# The example-facing surface: requiring statecraft/rspec includes this
|
|
6
|
+
# module into every example group, so specs call the factories bare.
|
|
7
|
+
# Record-level matchers consult guards (a prediction with the metadata
|
|
8
|
+
# you pass); class-level matchers answer the graph's shape only.
|
|
9
|
+
# rubocop:disable Naming/PredicatePrefix -- have_* is RSpec's matcher idiom, not a predicate
|
|
10
|
+
module Matchers
|
|
11
|
+
def allow_event(event_name)
|
|
12
|
+
AllowEvent.new(event_name)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def refuse_event(event_name)
|
|
16
|
+
RefuseEvent.new(event_name)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def allow_transition_to(target_state)
|
|
20
|
+
AllowTransitionTo.new(target_state)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def have_transitioned_to(state_name)
|
|
24
|
+
HaveTransitionedTo.new(state_name)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def have_edge(from_state, to_state)
|
|
28
|
+
HaveEdge.new(from_state, to_state)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def have_initial_state(state_name)
|
|
32
|
+
HaveInitialState.new(state_name)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def transition(record)
|
|
36
|
+
Transition.new(record)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
# rubocop:enable Naming/PredicatePrefix
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
module RSpec
|
|
5
|
+
# expect(record).to refuse_event(:cancel).because_of(:customer_cancellable?)
|
|
6
|
+
#
|
|
7
|
+
# The named negation of allow_event: the refusal itself, and — through
|
|
8
|
+
# because_of — WHO refused. Guard names come from refusals_for, so
|
|
9
|
+
# because_of sees record-layer guards only; an input-reading guard:
|
|
10
|
+
# has no name there, and the failure message says so instead of
|
|
11
|
+
# pretending otherwise.
|
|
12
|
+
class RefuseEvent
|
|
13
|
+
def initialize(event_name)
|
|
14
|
+
@event_name = event_name.to_sym
|
|
15
|
+
@expected_guards = []
|
|
16
|
+
@metadata = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def because_of(*guard_names)
|
|
20
|
+
@expected_guards = guard_names.map(&:to_sym)
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def with_metadata(metadata)
|
|
25
|
+
@metadata = metadata
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def matches?(record)
|
|
30
|
+
@record = record
|
|
31
|
+
@allowed = record.can_fire?(@event_name, metadata: @metadata)
|
|
32
|
+
return false if @allowed
|
|
33
|
+
|
|
34
|
+
@refusing_guards = record.refusals_for(@event_name).map(&:guard)
|
|
35
|
+
(@expected_guards - @refusing_guards).empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def failure_message
|
|
39
|
+
if @allowed
|
|
40
|
+
return "expected #{StateReport.standing(@record)} to refuse event #{@event_name.inspect}, " \
|
|
41
|
+
"but the guards passed with metadata #{@metadata.inspect}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
missing = (@expected_guards - @refusing_guards).map(&:inspect).join(", ")
|
|
45
|
+
"expected the refusal of #{@event_name.inspect} to come from #{missing}\n #{actual_refusers}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def failure_message_when_negated
|
|
49
|
+
"expected #{StateReport.standing(@record)} to allow event #{@event_name.inspect}, but it was refused\n " +
|
|
50
|
+
StateReport.refusal(@record, @event_name, @metadata)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def description
|
|
54
|
+
"refuse event #{@event_name.inspect}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def actual_refusers
|
|
60
|
+
if @refusing_guards.empty?
|
|
61
|
+
"but no record-layer guard refused: refusals_for names record-layer guards only, " \
|
|
62
|
+
"and this refusal came from an input-reading guard: or an undeclared branch"
|
|
63
|
+
else
|
|
64
|
+
"but the refusing record-layer guards were: #{@refusing_guards.map(&:inspect).join(", ")}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
module RSpec
|
|
5
|
+
# The shared sentences of every failure message: where the record
|
|
6
|
+
# stands, what the graph declares from there, what is reachable right
|
|
7
|
+
# now, and which guards refused. Built strictly on the public
|
|
8
|
+
# introspection surface — the matchers add words, never new answers.
|
|
9
|
+
module StateReport
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def current_state(record)
|
|
13
|
+
record[record.class.statecraft_mounting.column]&.to_sym
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def standing(record)
|
|
17
|
+
"#{record.class.name} in state #{current_state(record).inspect}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def machine(record)
|
|
21
|
+
record.class.statecraft_mounting.machine_class
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def event_declared?(record, event_name)
|
|
25
|
+
machine(record).transitions_from(current_state(record))
|
|
26
|
+
.any? { |descriptor| descriptor[:events].include?(event_name) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def declared_shape(machine_class, state)
|
|
30
|
+
descriptors = machine_class.transitions_from(state)
|
|
31
|
+
return "no edges are declared from #{state.inspect}" if descriptors.empty?
|
|
32
|
+
|
|
33
|
+
rendered = descriptors.map do |descriptor|
|
|
34
|
+
if descriptor[:events].empty?
|
|
35
|
+
"to #{descriptor[:to].inspect} (direct)"
|
|
36
|
+
else
|
|
37
|
+
"to #{descriptor[:to].inspect} via #{descriptor[:events].inspect}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
"declared from #{state.inspect}: #{rendered.join("; ")}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def declared_shape_of(record)
|
|
44
|
+
declared_shape(machine(record), current_state(record))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def reachable(record, metadata)
|
|
48
|
+
transitions = record.available_transitions(metadata: metadata)
|
|
49
|
+
state = current_state(record).inspect
|
|
50
|
+
return "nothing is reachable from #{state} right now" if transitions.empty?
|
|
51
|
+
|
|
52
|
+
rendered = transitions.map { |availability| "to #{availability.to.inspect} via #{availability.via.inspect}" }
|
|
53
|
+
"reachable from #{state}: #{rendered.join("; ")}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def refusal(record, event_name, metadata)
|
|
57
|
+
refusals = record.refusals_for(event_name)
|
|
58
|
+
if refusals.empty?
|
|
59
|
+
"no record-layer guard refused — an input-reading guard: said no to metadata #{metadata.inspect}"
|
|
60
|
+
else
|
|
61
|
+
named = refusals.map { |entry| "#{entry.guard.inspect} (#{entry.layer})" }
|
|
62
|
+
"refused by record-layer guards: #{named.join(", ")}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
module RSpec
|
|
5
|
+
# expect { order.fire!(:pay) }.to transition(order)
|
|
6
|
+
# .from(:pending).to(:paid).via_event(:pay).with_metadata("k" => "v")
|
|
7
|
+
#
|
|
8
|
+
# The transition through the eyes of a test: the state column moved to
|
|
9
|
+
# the target AND exactly one log row was appended with the matching
|
|
10
|
+
# from/to/event/metadata. A non-bang call that returned false leaves
|
|
11
|
+
# both untouched — the matcher fails and explains why, from the same
|
|
12
|
+
# introspection the pipeline consulted. Exceptions of the bang forms
|
|
13
|
+
# fly through, like with the change matcher: refusals are asserted
|
|
14
|
+
# with refuse_event or raise_error, not here.
|
|
15
|
+
class Transition
|
|
16
|
+
def initialize(record)
|
|
17
|
+
@record = record
|
|
18
|
+
@failures = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def from(state_name)
|
|
22
|
+
@from_state = state_name.to_sym
|
|
23
|
+
self
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to(state_name)
|
|
27
|
+
@to_state = state_name.to_sym
|
|
28
|
+
self
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def via_event(event_name)
|
|
32
|
+
@event_name = event_name.to_sym
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def with_metadata(metadata)
|
|
37
|
+
@metadata = metadata
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def supports_block_expectations?
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def matches?(block)
|
|
46
|
+
raise ArgumentError, "transition(record).to(:state) — the .to target is required" unless @to_state
|
|
47
|
+
|
|
48
|
+
@before_state = StateReport.current_state(@record)
|
|
49
|
+
appended_before = @record.history.count
|
|
50
|
+
block.call
|
|
51
|
+
@after_state = StateReport.current_state(@record)
|
|
52
|
+
@appended = @record.history.offset(appended_before).to_a
|
|
53
|
+
collect_failures
|
|
54
|
+
@failures.empty?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def failure_message
|
|
58
|
+
expected_event = " via event #{@event_name.inspect}" if @event_name
|
|
59
|
+
header = "expected the block to transition #{@record.class.name} " \
|
|
60
|
+
"#{@before_state.inspect} -> #{@to_state.inspect}#{expected_event}"
|
|
61
|
+
([header] + @failures).join("\n ")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def failure_message_when_negated
|
|
65
|
+
row = @appended.last
|
|
66
|
+
written_by = " by event #{row.event.inspect}" if row.event
|
|
67
|
+
"expected the block not to transition #{@record.class.name} to #{@to_state.inspect}, " \
|
|
68
|
+
"but it did: #{row.from_state.inspect} -> #{row.to_state.inspect}#{written_by}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def description
|
|
72
|
+
"transition #{@record.class.name} to #{@to_state.inspect}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def collect_failures
|
|
78
|
+
return collect_missing_transition if @appended.empty?
|
|
79
|
+
|
|
80
|
+
if @appended.size > 1
|
|
81
|
+
@failures << "expected exactly one appended log row, but the block appended #{@appended.size}"
|
|
82
|
+
end
|
|
83
|
+
if @after_state != @to_state
|
|
84
|
+
@failures << "the record ended in #{@after_state.inspect}, not #{@to_state.inspect}"
|
|
85
|
+
end
|
|
86
|
+
collect_row_mismatches(@appended.last)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def collect_row_mismatches(row)
|
|
90
|
+
if row.to_state != @to_state.to_s
|
|
91
|
+
@failures << "the log row went to #{row.to_state.inspect}, not #{@to_state.inspect}"
|
|
92
|
+
end
|
|
93
|
+
if @from_state && row.from_state != @from_state.to_s
|
|
94
|
+
@failures << "the transition started from #{row.from_state.inspect}, not #{@from_state.inspect}"
|
|
95
|
+
end
|
|
96
|
+
if @event_name && row.event != @event_name.to_s
|
|
97
|
+
@failures << "the transition was written by event #{row.event.inspect}, not #{@event_name.inspect}"
|
|
98
|
+
end
|
|
99
|
+
collect_metadata_mismatch(row)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def collect_missing_transition
|
|
103
|
+
@failures << "no transition happened: the record stayed in #{@after_state.inspect}"
|
|
104
|
+
@failures << StateReport.reachable(@record, @metadata || {})
|
|
105
|
+
return unless @event_name && StateReport.event_declared?(@record, @event_name)
|
|
106
|
+
|
|
107
|
+
@failures << StateReport.refusal(@record, @event_name, @metadata || {})
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def collect_metadata_mismatch(row)
|
|
111
|
+
return unless @metadata
|
|
112
|
+
|
|
113
|
+
expected = @metadata.deep_stringify_keys
|
|
114
|
+
return if row.metadata == expected
|
|
115
|
+
|
|
116
|
+
@failures << "the log row carries metadata #{row.metadata.inspect}, not #{expected.inspect}"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "statecraft"
|
|
4
|
+
|
|
5
|
+
# Deliberately not required by lib/statecraft.rb: the matchers exist only
|
|
6
|
+
# where RSpec does, and the gem's runtime must not know about test
|
|
7
|
+
# frameworks. This file is the one opt-in door.
|
|
8
|
+
unless defined?(RSpec)
|
|
9
|
+
raise Statecraft::Error,
|
|
10
|
+
"statecraft/rspec builds RSpec matchers, so RSpec must be loaded first: " \
|
|
11
|
+
"require statecraft/rspec from your spec helper, after rspec itself"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
require_relative "rspec/state_report"
|
|
15
|
+
require_relative "rspec/allow_event"
|
|
16
|
+
require_relative "rspec/refuse_event"
|
|
17
|
+
require_relative "rspec/allow_transition_to"
|
|
18
|
+
require_relative "rspec/have_transitioned_to"
|
|
19
|
+
require_relative "rspec/have_edge"
|
|
20
|
+
require_relative "rspec/have_initial_state"
|
|
21
|
+
require_relative "rspec/transition"
|
|
22
|
+
require_relative "rspec/matchers"
|
|
23
|
+
|
|
24
|
+
RSpec.configure { |config| config.include Statecraft::RSpec::Matchers }
|
data/lib/statecraft/version.rb
CHANGED
data/lib/statecraft.rb
CHANGED
|
@@ -10,6 +10,7 @@ require_relative "statecraft/instrumentation"
|
|
|
10
10
|
require_relative "statecraft/machine"
|
|
11
11
|
require_relative "statecraft/metadata"
|
|
12
12
|
require_relative "statecraft/pipeline/edge_resolution"
|
|
13
|
+
require_relative "statecraft/pipeline/versioning"
|
|
13
14
|
require_relative "statecraft/pipeline"
|
|
14
15
|
require_relative "statecraft/pipeline/surface"
|
|
15
16
|
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.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Igor Pugachev
|
|
@@ -87,6 +87,17 @@ files:
|
|
|
87
87
|
- lib/statecraft/pipeline.rb
|
|
88
88
|
- lib/statecraft/pipeline/edge_resolution.rb
|
|
89
89
|
- lib/statecraft/pipeline/surface.rb
|
|
90
|
+
- lib/statecraft/pipeline/versioning.rb
|
|
91
|
+
- lib/statecraft/rspec.rb
|
|
92
|
+
- lib/statecraft/rspec/allow_event.rb
|
|
93
|
+
- lib/statecraft/rspec/allow_transition_to.rb
|
|
94
|
+
- lib/statecraft/rspec/have_edge.rb
|
|
95
|
+
- lib/statecraft/rspec/have_initial_state.rb
|
|
96
|
+
- lib/statecraft/rspec/have_transitioned_to.rb
|
|
97
|
+
- lib/statecraft/rspec/matchers.rb
|
|
98
|
+
- lib/statecraft/rspec/refuse_event.rb
|
|
99
|
+
- lib/statecraft/rspec/state_report.rb
|
|
100
|
+
- lib/statecraft/rspec/transition.rb
|
|
90
101
|
- lib/statecraft/version.rb
|
|
91
102
|
- lib/statecraft/warnings.rb
|
|
92
103
|
homepage: https://supostat.github.io/statecraft/
|