statecraft 0.6.0 → 0.7.1

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: 4689cea0dee40236473975f52ea5ccd2ecd303783edebd7626ab1518a1f8c8bd
4
- data.tar.gz: b3713ea5d55ea4881b55b10ceccb38847f2f85aa971d29326fd26e97412d0620
3
+ metadata.gz: b91fc7675cab350149a2e58679893b18ccdfa779cc84e2eb5a7d28c5591c265c
4
+ data.tar.gz: c751611f88af3a85738877185b68de734578a353818aa130c19206d40b498b82
5
5
  SHA512:
6
- metadata.gz: 9690fff2fb62feaaea147a53c63113a77dc93103432ad3f5aa63b5593173d513d2abf83d5d53decf0d6eed7347d94509197f5bb0d03077cdbd5a98f1f20cefd4
7
- data.tar.gz: cbd7e8d84af570a4c2cb1e51582262860b2d6d0cd7d87ed0304528413ca1d34720dbe6c9b4186fab5a68639c6814243ed2fed32f9774d4985a5f4f5408b45ede
6
+ metadata.gz: 42c744386532cf91f06374dc3695c4e2ee3e64602fbb428045e3b588c27dc32843959552dea944be5d98834f28036baf1d712da2c25008dba8b16766ca23310b
7
+ data.tar.gz: 86822e6c1fba14b00e6a8e18efc586a7f92f35280490a168e4d96d063a6d3ecd471eac6fe877c512b9972359d1d14f4d344521dc6f0fea56235075df2bae975b
data/README.md CHANGED
@@ -210,6 +210,74 @@ 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
+ The field comes back from the browser, so it is hostile input: a token that
268
+ cannot be read at all — a tampered `seen=abc`, an array from `seen[]=1` —
269
+ is refused as `StaleTransition` too, with `expected_version: nil` because
270
+ nothing was compared. A broken or forged form never turns into a 500.
271
+ The honest limits: staleness
272
+ exists only where a token was given — without `seen:` a version mismatch
273
+ is the ordinary `TransitionConflict`; the early deterministic check runs
274
+ only under `lock: true`, where the reload holds the row's real version —
275
+ without the lock the CAS itself is the check, since a fresh token can
276
+ outrun a stale in-memory record; and `seen:` on a mounting without
277
+ `versioning:` raises a `CompilationError` naming the fix instead of
278
+ silently skipping the protection. Reads stay join-free: the version lives
279
+ on the parent row, right next to the state.
280
+
213
281
  ## Introspection
214
282
 
215
283
  <!-- illustrative -->
@@ -449,6 +517,11 @@ statecraft's CAS on the state column.
449
517
  Outside Rails, the same five steps work by hand — pair the runbook above
450
518
  with the reference schema in [Outside Rails](#outside-rails).
451
519
 
520
+ Once the conversion has settled, one more `add_column` buys protection
521
+ statesman never had: see [Stale transitions](#stale-transitions-versioning-against-aba)
522
+ — the version column is a constant default, so adding it costs a
523
+ metadata-only migration on PostgreSQL 11+.
524
+
452
525
  ## PII and erasure
453
526
 
454
527
  Metadata is the only place personal data can live — `from_state`, `to_state`
@@ -644,7 +717,7 @@ actions over services, guard refusals local to their form:
644
717
  @order = Order.find(params[:id])
645
718
  authorize! event_name, @order
646
719
  @metadata = submitted_metadata
647
- @order.fire!(event_name, metadata: @metadata)
720
+ @order.fire!(event_name, metadata: @metadata, seen: params[:seen].presence)
648
721
  redirect_to admin_order_path(@order),
649
722
  notice: "#{event_name} fired: the order is now #{@order[:state]}."
650
723
  rescue Statecraft::GuardFailed => error
@@ -668,6 +741,7 @@ possibility-times-permission intersection:
668
741
  <!-- readme: preview-pattern -->
669
742
  ```erb
670
743
  <%= form_with url: preview_admin_order_path(@order), method: :post, local: true do %>
744
+ <input type="hidden" name="seen" value="<%= @order.state_version %>">
671
745
  <fieldset>
672
746
  <legend>Metadata for the next action</legend>
673
747
  <label>
@@ -705,13 +779,12 @@ ActiveSupport::Notifications.subscribe("transition.statecraft") do |_name, _star
705
779
  )
706
780
  end
707
781
 
782
+ # The failure payload names the record, the machine and the reason — it
783
+ # carries no from/to/event keys: the transition never happened.
708
784
  ActiveSupport::Notifications.subscribe("transition_failed.statecraft") do |_name, _started, _finished, _id, payload|
709
785
  OperationEntry.create!(
710
786
  record_class: payload[:record_class],
711
787
  record_id: payload[:record_id].to_s,
712
- from_state: payload[:from],
713
- to_state: payload[:to],
714
- event_name: payload[:event],
715
788
  outcome: "refused",
716
789
  reason: payload[:reason].to_s
717
790
  )
@@ -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,33 @@ 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 not
50
+ # the row's version — or could not be read at all, which is the same
51
+ # answer with nothing to compare (expected_version stays nil). A subclass
52
+ # of TransitionConflict, so existing rescues keep catching it;
53
+ # controllers map it to 409 specifically.
54
+ class StaleTransition < TransitionConflict
55
+ attr_reader :expected_version, :seen
56
+
57
+ def initialize(record:, expected_from:, expected_version:, seen:)
58
+ @expected_version = expected_version
59
+ @seen = seen
60
+ reason =
61
+ if expected_version.nil?
62
+ "the token #{seen.inspect} is not a readable version"
63
+ else
64
+ "the caller saw version #{seen.inspect}, but the row has moved on"
65
+ end
66
+ super(record: record, expected_from: expected_from,
67
+ message: "stale transition for #{record.class.name}##{record.id}: #{reason}")
46
68
  end
47
69
  end
48
70
 
@@ -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,78 @@
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
+ # The token arrives from a form field, so it is hostile input: a
26
+ # tampered or broken form must not escape the gem's own hierarchy.
27
+ # An unreadable token IS an invalid snapshot — the same refusal a
28
+ # stale one gets, with no version to compare against.
29
+ def normalize_seen(raw_seen)
30
+ return nil if raw_seen.nil?
31
+
32
+ unless configuration.version_column
33
+ raise CompilationError,
34
+ "seen: requires versioning: true on the mounting of #{record.class.name}; " \
35
+ "add the option or drop the token"
36
+ end
37
+
38
+ begin
39
+ Integer(raw_seen)
40
+ rescue ArgumentError, TypeError
41
+ raise StaleTransition.new(record: record, expected_from: current_state,
42
+ expected_version: nil, seen: raw_seen)
43
+ end
44
+ end
45
+
46
+ # What the CAS requires the row's version to be: the caller's seen:
47
+ # token when given, the in-memory value otherwise — fresh after a lock
48
+ # reload, synced by the previous link inside a chain.
49
+ def expected_version
50
+ @seen || record[version_column]
51
+ end
52
+
53
+ # With a seen: token any versioned refusal is staleness — the caller
54
+ # acted on a snapshot; without one it is the ordinary conflict of two
55
+ # writers.
56
+ def cas_refusal(edge)
57
+ if @seen
58
+ StaleTransition.new(record: record, expected_from: edge.from,
59
+ expected_version: expected_version, seen: @raw_seen)
60
+ else
61
+ TransitionConflict.new(record: record, expected_from: edge.from)
62
+ end
63
+ end
64
+
65
+ # The same early observation as assert_lock_saw_expected_state, for the
66
+ # seen: token: the reload holds the row's real version under the lock,
67
+ # so a mismatched token is a stale snapshot detected deterministically,
68
+ # before any write.
69
+ def assert_lock_saw_expected_version(edge)
70
+ return unless @seen && version_column
71
+ return if record[version_column] == @seen
72
+
73
+ raise StaleTransition.new(record: record, expected_from: edge.from,
74
+ expected_version: @seen, seen: @raw_seen)
75
+ end
76
+ end
77
+ end
78
+ 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,13 +60,17 @@ 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
66
68
  metadata = Metadata.normalize(raw_metadata)
67
69
  started_at = Time.current
68
70
  begin
71
+ # Inside the rescue: an unreadable token is a refusal like any
72
+ # other and belongs in the failure telemetry.
73
+ @seen = normalize_seen(seen)
69
74
  edge, event, bypass = edge_resolver.call(current_state)
70
75
  assert_clean_when_locked(edge)
71
76
  frame = open_frame(edge, event)
@@ -91,6 +96,7 @@ module Statecraft
91
96
  case error
92
97
  when GuardFailed then [:guard_failed, { guard: error.guard }]
93
98
  when InvalidTransition then [:invalid_transition, { requested: error.requested }]
99
+ when StaleTransition then [:stale, { expected_version: error.expected_version, seen: error.seen }]
94
100
  when TransitionConflict then [:conflict, { expected_from: error.expected_from }]
95
101
  end
96
102
  Instrumentation.publish_failure(
@@ -105,6 +111,7 @@ module Statecraft
105
111
  warn_when_row_locking_unavailable
106
112
  record.reload(lock: true)
107
113
  assert_lock_saw_expected_state(edge)
114
+ assert_lock_saw_expected_version(edge)
108
115
  end
109
116
  transition_time = Time.current
110
117
  run_guards(edge, event, bypass, metadata)
@@ -204,17 +211,21 @@ module Statecraft
204
211
  end
205
212
 
206
213
  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?
214
+ scope = base_class.unscoped
215
+ .where(base_class.primary_key => record.id,
216
+ configuration.column => edge.from.to_s)
217
+ scope = scope.where(version_column => expected_version) if version_column
218
+ affected_rows = scope.update_all(cas_updates(edge, transition_time))
219
+ raise cas_refusal(edge) if affected_rows.zero?
212
220
  end
213
221
 
214
222
  def cas_updates(edge, transition_time)
215
223
  updates = { configuration.column => edge.to.to_s }
216
224
  updates[:updated_at] = transition_time if touch_updated_at?
217
225
  updates[changed_at_column] = transition_time if changed_at_column
226
+ # The literal is correct by construction: the CAS WHERE guarantees the
227
+ # row held exactly expected_version, so the memory mirror stays exact.
228
+ updates[version_column] = expected_version + 1 if version_column
218
229
  updates
219
230
  end
220
231
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Statecraft
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.1"
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.6.0
4
+ version: 0.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor Pugachev
@@ -87,6 +87,7 @@ 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
90
91
  - lib/statecraft/rspec.rb
91
92
  - lib/statecraft/rspec/allow_event.rb
92
93
  - lib/statecraft/rspec/allow_transition_to.rb