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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 55f4ecb6760d01b042ad158351ec8735c3962c70c3a674d3af9d6ba2ecf26fd9
4
- data.tar.gz: ecc0c3ac410d67431226ccc8b361ac5d2708c276425153561b0f732ef3aefa53
3
+ metadata.gz: 5e98ec25e4cfbb6cec172cd1f54ac9362937c967eff450ccda67d414e2dce6d3
4
+ data.tar.gz: e8f112727ef007ad0d6d3e91e67c122298bbf1d403f09aa9e2aeda56e43545da
5
5
  SHA512:
6
- metadata.gz: caf25d1b5349c441aa79573e1994b2298ef64fcc4b4b9f6e6b5958827ca03a9bf8275366cf1b128a4cf39f690e6ba9bcad0327b040a51effcf42f5fff9853098
7
- data.tar.gz: 7acc79611c73b980a2a932c53d87723068adb1da7ddc61b590477cf84540a904750e9fa7d550d126aa9626f9edcc34538f6aed4aacb611894907148d80e07b63
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, changed_at: true, helpers: true, scopes: true\n"
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class <%= class_name %> < ApplicationRecord
4
- state_machine <%= class_name %>Flow, changed_at: true, helpers: true, scopes: true
4
+ state_machine <%= class_name %>Flow, <%= mounting_options %>
5
5
  end
@@ -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
- "expected state #{expected_from.inspect}, another writer got there first")
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
 
@@ -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, :touch, :helpers, :scopes,
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, touch: true,
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, touch: touch, helpers: helpers, scopes: scopes
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
- def initialize(model:, machine_class:, log:, column:, changed_at:, touch:, helpers:, scopes:)
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}!") { |metadata: {}| fire!(event_name, metadata: metadata) }
167
- define_method(event_name) { |metadata: {}| fire(event_name, metadata: metadata) }
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
@@ -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
- affected_rows = base_class.unscoped
208
- .where(base_class.primary_key => record.id,
209
- configuration.column => edge.from.to_s)
210
- .update_all(cas_updates(edge, transition_time))
211
- raise TransitionConflict.new(record: record, expected_from: edge.from) if affected_rows.zero?
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 }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Statecraft
4
- VERSION = "0.5.0"
4
+ VERSION = "0.7.0"
5
5
  end
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.5.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/