statecraft 0.7.0 → 0.8.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: 5e98ec25e4cfbb6cec172cd1f54ac9362937c967eff450ccda67d414e2dce6d3
4
- data.tar.gz: e8f112727ef007ad0d6d3e91e67c122298bbf1d403f09aa9e2aeda56e43545da
3
+ metadata.gz: 70f23cb88e677af1d36d2acf31fc39a1688f90a46c99d6eebef3e76c49ca4f8e
4
+ data.tar.gz: f38e777eed1a3fa3109da01a9f56eeb09f8c181c9d13e899ab33e3334c372d8b
5
5
  SHA512:
6
- metadata.gz: ed3978c9e0098cfc1ed4a19b933ce2a134e8841385e0e9542c267e43b6095f81c2b97b230a40d04605d0618717b3af289716157c3ab029f85b30886c54a11cb5
7
- data.tar.gz: 626c429ccc91fc9f40401e58ab6142b81544684d57890fb0f51cec79d901e6280d06e2a14afb0400bb6d592faf33e5dbab5ef7c09045e4435b8557dd2162fb73
6
+ metadata.gz: 636c11b89dabc3d977f13d503ea7cc311c59dd4c677bfea917b333de36f37c045b1b1ddfec2a1f4643131c0f160dad33651ebf4371c32a365783b8e4c7f1df5f
7
+ data.tar.gz: e09b76e0ca7552f014259429ee4f16ce5ee64b75f4cd829dbffa391ec0604901ba2d9b5a23dd3375bc79f95dc3abe4ca4a77c483634de0343ed2601694bde538
data/README.md CHANGED
@@ -263,8 +263,12 @@ end
263
263
  ```
264
264
 
265
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
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
268
272
  exists only where a token was given — without `seen:` a version mismatch
269
273
  is the ordinary `TransitionConflict`; the early deterministic check runs
270
274
  only under `lock: true`, where the reload holds the row's real version —
@@ -369,6 +373,20 @@ not with the block matcher.
369
373
  it names record-layer guards only. An input-reading `guard:` has no name
370
374
  there, and the failure message says so instead of guessing.
371
375
 
376
+ Under [`versioning:`](#stale-transitions-versioning-against-aba) the block
377
+ matcher folds the version into the same assertion — a matching transition
378
+ must also have incremented the version column — and every failure message
379
+ prints the record's standing with its version
380
+ (`Order in state :pending (state_version 3)`). Staleness stays an
381
+ exception, exactly as in production: assert the refusal of a stale token
382
+ with `raise_error`, not with a prediction matcher —
383
+
384
+ <!-- illustrative -->
385
+ ```ruby
386
+ expect { order.cancel!(seen: stale_token) }
387
+ .to raise_error(Statecraft::StaleTransition)
388
+ ```
389
+
372
390
  ## Metadata
373
391
 
374
392
  Metadata is normalized on pipeline entry with a full JSON round-trip —
@@ -513,6 +531,11 @@ statecraft's CAS on the state column.
513
531
  Outside Rails, the same five steps work by hand — pair the runbook above
514
532
  with the reference schema in [Outside Rails](#outside-rails).
515
533
 
534
+ Once the conversion has settled, one more `add_column` buys protection
535
+ statesman never had: see [Stale transitions](#stale-transitions-versioning-against-aba)
536
+ — the version column is a constant default, so adding it costs a
537
+ metadata-only migration on PostgreSQL 11+.
538
+
516
539
  ## PII and erasure
517
540
 
518
541
  Metadata is the only place personal data can live — `from_state`, `to_state`
@@ -732,7 +755,7 @@ possibility-times-permission intersection:
732
755
  <!-- readme: preview-pattern -->
733
756
  ```erb
734
757
  <%= form_with url: preview_admin_order_path(@order), method: :post, local: true do %>
735
- <input type="hidden" name="seen" value="<%= @order[:state_version] %>">
758
+ <input type="hidden" name="seen" value="<%= @order.state_version %>">
736
759
  <fieldset>
737
760
  <legend>Metadata for the next action</legend>
738
761
  <label>
@@ -46,18 +46,25 @@ module Statecraft
46
46
  end
47
47
  end
48
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.
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.
52
54
  class StaleTransition < TransitionConflict
53
55
  attr_reader :expected_version, :seen
54
56
 
55
57
  def initialize(record:, expected_from:, expected_version:, seen:)
56
58
  @expected_version = expected_version
57
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
58
66
  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")
67
+ message: "stale transition for #{record.class.name}##{record.id}: #{reason}")
61
68
  end
62
69
  end
63
70
 
@@ -22,6 +22,10 @@ module Statecraft
22
22
  column
23
23
  end
24
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.
25
29
  def normalize_seen(raw_seen)
26
30
  return nil if raw_seen.nil?
27
31
 
@@ -30,7 +34,13 @@ module Statecraft
30
34
  "seen: requires versioning: true on the mounting of #{record.class.name}; " \
31
35
  "add the option or drop the token"
32
36
  end
33
- Integer(raw_seen)
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
34
44
  end
35
45
 
36
46
  # What the CAS requires the row's version to be: the caller's seen:
@@ -65,10 +65,12 @@ module Statecraft
65
65
 
66
66
  assert_single_column_primary_key
67
67
  @raw_seen = seen
68
- @seen = normalize_seen(seen)
69
68
  metadata = Metadata.normalize(raw_metadata)
70
69
  started_at = Time.current
71
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)
72
74
  edge, event, bypass = edge_resolver.call(current_state)
73
75
  assert_clean_when_locked(edge)
74
76
  frame = open_frame(edge, event)
@@ -14,7 +14,12 @@ module Statecraft
14
14
  end
15
15
 
16
16
  def standing(record)
17
- "#{record.class.name} in state #{current_state(record).inspect}"
17
+ "#{record.class.name} in state #{current_state(record).inspect}#{version_standing(record)}"
18
+ end
19
+
20
+ def version_standing(record)
21
+ column = record.class.statecraft_mounting.version_column
22
+ column ? " (#{column} #{record[column]})" : ""
18
23
  end
19
24
 
20
25
  def machine(record)
@@ -7,7 +7,8 @@ module Statecraft
7
7
  #
8
8
  # The transition through the eyes of a test: the state column moved to
9
9
  # the target AND exactly one log row was appended with the matching
10
- # from/to/event/metadata. A non-bang call that returned false leaves
10
+ # from/to/event/metadata and under versioning: the version column
11
+ # incremented together with the state. A non-bang call that returned false leaves
11
12
  # both untouched — the matcher fails and explains why, from the same
12
13
  # introspection the pipeline consulted. Exceptions of the bang forms
13
14
  # fly through, like with the change matcher: refusals are asserted
@@ -45,10 +46,13 @@ module Statecraft
45
46
  def matches?(block)
46
47
  raise ArgumentError, "transition(record).to(:state) — the .to target is required" unless @to_state
47
48
 
49
+ @version_column = @record.class.statecraft_mounting.version_column
48
50
  @before_state = StateReport.current_state(@record)
51
+ @before_version = @record[@version_column] if @version_column
49
52
  appended_before = @record.history.count
50
53
  block.call
51
54
  @after_state = StateReport.current_state(@record)
55
+ @after_version = @record[@version_column] if @version_column
52
56
  @appended = @record.history.offset(appended_before).to_a
53
57
  collect_failures
54
58
  @failures.empty?
@@ -84,6 +88,15 @@ module Statecraft
84
88
  @failures << "the record ended in #{@after_state.inspect}, not #{@to_state.inspect}"
85
89
  end
86
90
  collect_row_mismatches(@appended.last)
91
+ collect_version_mismatch
92
+ end
93
+
94
+ def collect_version_mismatch
95
+ return unless @version_column
96
+ return if @after_version == @before_version + 1
97
+
98
+ @failures << "the #{@version_column} column went from #{@before_version} to #{@after_version}, " \
99
+ "expected #{@before_version + 1}: a versioned transition increments the version with the state"
87
100
  end
88
101
 
89
102
  def collect_row_mismatches(row)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Statecraft
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
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.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor Pugachev