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 +4 -4
- data/README.md +26 -3
- data/lib/statecraft/errors.rb +12 -5
- data/lib/statecraft/pipeline/versioning.rb +11 -1
- data/lib/statecraft/pipeline.rb +3 -1
- data/lib/statecraft/rspec/state_report.rb +6 -1
- data/lib/statecraft/rspec/transition.rb +14 -1
- data/lib/statecraft/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 70f23cb88e677af1d36d2acf31fc39a1688f90a46c99d6eebef3e76c49ca4f8e
|
|
4
|
+
data.tar.gz: f38e777eed1a3fa3109da01a9f56eeb09f8c181c9d13e899ab33e3334c372d8b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
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>
|
data/lib/statecraft/errors.rb
CHANGED
|
@@ -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
|
|
50
|
-
#
|
|
51
|
-
#
|
|
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
|
-
|
|
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:
|
data/lib/statecraft/pipeline.rb
CHANGED
|
@@ -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
|
|
10
|
+
# from/to/event/metadata — and under versioning: the version column
|
|
11
|
+
# incremented together with the state. A non-bang call that returned false leaves
|
|
11
12
|
# both untouched — the matcher fails and explains why, from the same
|
|
12
13
|
# introspection the pipeline consulted. Exceptions of the bang forms
|
|
13
14
|
# fly through, like with the change matcher: refusals are asserted
|
|
@@ -45,10 +46,13 @@ module Statecraft
|
|
|
45
46
|
def matches?(block)
|
|
46
47
|
raise ArgumentError, "transition(record).to(:state) — the .to target is required" unless @to_state
|
|
47
48
|
|
|
49
|
+
@version_column = @record.class.statecraft_mounting.version_column
|
|
48
50
|
@before_state = StateReport.current_state(@record)
|
|
51
|
+
@before_version = @record[@version_column] if @version_column
|
|
49
52
|
appended_before = @record.history.count
|
|
50
53
|
block.call
|
|
51
54
|
@after_state = StateReport.current_state(@record)
|
|
55
|
+
@after_version = @record[@version_column] if @version_column
|
|
52
56
|
@appended = @record.history.offset(appended_before).to_a
|
|
53
57
|
collect_failures
|
|
54
58
|
@failures.empty?
|
|
@@ -84,6 +88,15 @@ module Statecraft
|
|
|
84
88
|
@failures << "the record ended in #{@after_state.inspect}, not #{@to_state.inspect}"
|
|
85
89
|
end
|
|
86
90
|
collect_row_mismatches(@appended.last)
|
|
91
|
+
collect_version_mismatch
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def collect_version_mismatch
|
|
95
|
+
return unless @version_column
|
|
96
|
+
return if @after_version == @before_version + 1
|
|
97
|
+
|
|
98
|
+
@failures << "the #{@version_column} column went from #{@before_version} to #{@after_version}, " \
|
|
99
|
+
"expected #{@before_version + 1}: a versioned transition increments the version with the state"
|
|
87
100
|
end
|
|
88
101
|
|
|
89
102
|
def collect_row_mismatches(row)
|
data/lib/statecraft/version.rb
CHANGED