statecraft 0.1.1 → 0.1.3
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 -6
- data/lib/generators/statecraft/machine/USAGE +26 -0
- data/lib/generators/statecraft/machine/machine_generator.rb +53 -19
- data/lib/generators/statecraft/machine/templates/namespace_module.rb.tt +11 -0
- data/lib/statecraft/machine.rb +21 -6
- data/lib/statecraft/metadata.rb +10 -1
- data/lib/statecraft/mounting.rb +15 -5
- data/lib/statecraft/pipeline/edge_resolution.rb +68 -0
- data/lib/statecraft/pipeline/surface.rb +31 -0
- data/lib/statecraft/pipeline.rb +54 -86
- data/lib/statecraft/version.rb +1 -1
- data/lib/statecraft.rb +2 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f809e9a1769e457d33d4b526703e4c3c7beb4ca09f8a7550cbbd31c97b07ba12
|
|
4
|
+
data.tar.gz: 8193837939f39dd0de633330b7bfc36d7adbb821221072ce1cdf30890f3488d0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a5da582f65eb60cd2f76fef2ec213ad6c09166b288f44d80f7065a943894a66c5f36134f6c094fd873e50562976a447d0183b3e0e47b5545bce707d63f965d83
|
|
7
|
+
data.tar.gz: f1247259b3bc97de6663d9545e00015e1ea443b9e9f0ac33af6a179c09ba534140b0037863ddd2fa64e4992234a5fe14ea0d43e4a9ddc30d1018927c0d3158c5
|
data/README.md
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# statecraft
|
|
2
2
|
|
|
3
|
+
[](https://rubygems.org/gems/statecraft)
|
|
4
|
+
[](https://github.com/supostat/statecraft/actions/workflows/ci.yml)
|
|
3
5
|
[](LICENSE.txt)
|
|
4
6
|
|
|
7
|
+
**[Website →](https://supostat.github.io/statecraft/)** · [RubyGems](https://rubygems.org/gems/statecraft) · [Issues](https://github.com/supostat/statecraft/issues)
|
|
8
|
+
|
|
5
9
|
A state machine for ActiveRecord where the current state lives in a column as
|
|
6
10
|
the single source of truth, history is an append-only per-model log with
|
|
7
11
|
write-once metadata, and every transition is guarded by a compare-and-swap
|
|
@@ -96,7 +100,8 @@ generator turns them on for new code).
|
|
|
96
100
|
reload.
|
|
97
101
|
5. `transaction(requires_new: true)` — a savepoint inside your transaction,
|
|
98
102
|
a real transaction otherwise: optional `SELECT ... FOR UPDATE` + reload
|
|
99
|
-
(
|
|
103
|
+
(a state that changed under the lock raises
|
|
104
|
+
`Statecraft::TransitionConflict`), guards, `before_transition`
|
|
100
105
|
callbacks, the CAS update (touching `updated_at` and the `changed_at`
|
|
101
106
|
column in the same statement), the log INSERT, `after_transition`.
|
|
102
107
|
6. `after_commit` callbacks are registered on the outermost real
|
|
@@ -108,9 +113,10 @@ do not run, and unsaved changes on other attributes are neither saved nor
|
|
|
108
113
|
callbacks are the transition's callbacks.
|
|
109
114
|
|
|
110
115
|
Bang variants return the created log record. Non-bang variants return it too,
|
|
111
|
-
or `false` — and `false`
|
|
112
|
-
declared
|
|
113
|
-
|
|
116
|
+
or `false` — and `false` covers exactly three refusals: a guard said no, the
|
|
117
|
+
edge is not declared, or the bypass policy refused a direct transition over
|
|
118
|
+
an event-guarded edge (`GuardFailed` / `InvalidTransition`). Everything
|
|
119
|
+
else — including `TransitionConflict` — always raises, in both variants.
|
|
114
120
|
|
|
115
121
|
## Guards, events and the bypass policy
|
|
116
122
|
|
|
@@ -212,7 +218,14 @@ symbol keys and values become strings, times become ISO-8601 strings — and
|
|
|
212
218
|
then deep-frozen: **the guards see exactly what the log will store**, and a
|
|
213
219
|
guard that mutates metadata dies with `FrozenError` in a transition and in a
|
|
214
220
|
check alike. Unserializable values (a `Proc`, a model instance) fail
|
|
215
|
-
instantly at the entrance, not inside the transaction
|
|
221
|
+
instantly at the entrance, not inside the transaction — and so do `NaN` and
|
|
222
|
+
`Infinity`, which JSON cannot represent.
|
|
223
|
+
|
|
224
|
+
`BigDecimal` is rejected deliberately, not by omission: jsonb would hand it
|
|
225
|
+
back as a string or a float depending on the reader, silently breaking the
|
|
226
|
+
"what the guards checked is what the log stored" promise. Pass money and
|
|
227
|
+
other exact decimals as strings (`metadata: { price: order.total.to_s }`)
|
|
228
|
+
and parse them in the guard.
|
|
216
229
|
|
|
217
230
|
Facts of the transition moment (a price snapshot, a rules version) are
|
|
218
231
|
collected by the caller: `order.pay!(metadata: { price: order.total })`.
|
|
@@ -309,7 +322,8 @@ The log lives next to its model, always — a cascade FK cannot cross
|
|
|
309
322
|
databases, so this is a definition, not a restriction. The generated log
|
|
310
323
|
class inherits the model's connection-owning ancestor (base, roles and
|
|
311
324
|
horizontal shards follow automatically), and the generator drops the
|
|
312
|
-
migration into
|
|
325
|
+
migration into the migration path configured for that connection's database
|
|
326
|
+
(`db/migrate` when none is configured). Mounting verifies connection
|
|
313
327
|
identity — same pool, same per-thread connection, one real transaction — and
|
|
314
328
|
raises `Statecraft::ConnectionMismatch` with a fix hint otherwise. Two
|
|
315
329
|
`connects_to` blocks pointing at one physical database are still two pools:
|
|
@@ -369,6 +383,12 @@ docker compose run --rm test-postgres # PostgreSQL 16
|
|
|
369
383
|
AR_VERSION=7.2 RUBY_VERSION=3.3 docker compose run --rm test-postgres
|
|
370
384
|
```
|
|
371
385
|
|
|
386
|
+
## Links
|
|
387
|
+
|
|
388
|
+
- Landing page: <https://supostat.github.io/statecraft/>
|
|
389
|
+
- RubyGems: <https://rubygems.org/gems/statecraft>
|
|
390
|
+
- Issues: <https://github.com/supostat/statecraft/issues>
|
|
391
|
+
|
|
372
392
|
## License
|
|
373
393
|
|
|
374
394
|
MIT. See [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Creates everything one model needs for a statecraft state machine: the
|
|
3
|
+
migration (state column with a CHECK constraint for a fresh table, the
|
|
4
|
+
state_changed_at column, and the append-only per-model log table with a
|
|
5
|
+
cascade FK), the machine class, the readonly log model, the model itself
|
|
6
|
+
when it does not exist yet (an existing model gets the state_machine
|
|
7
|
+
mounting injected instead), and — once per application — the shared
|
|
8
|
+
ApplicationMachine parent.
|
|
9
|
+
|
|
10
|
+
Namespaced models are fully supported: every file lands by the full
|
|
11
|
+
path, and a table_name_prefix module is generated the way the Rails
|
|
12
|
+
model generator does it. For a model on a non-primary database the
|
|
13
|
+
migration lands in the migrations_paths configured for that database
|
|
14
|
+
(db/migrate when none is configured).
|
|
15
|
+
|
|
16
|
+
Example:
|
|
17
|
+
bin/rails generate statecraft:machine Order
|
|
18
|
+
bin/rails generate statecraft:machine Shop::Order
|
|
19
|
+
|
|
20
|
+
This will create:
|
|
21
|
+
app/state_machines/application_machine.rb (first run only)
|
|
22
|
+
app/state_machines/shop/order_flow.rb
|
|
23
|
+
app/models/shop.rb (namespace prefix module)
|
|
24
|
+
app/models/shop/order_transition.rb
|
|
25
|
+
app/models/shop/order.rb (or mounting injection)
|
|
26
|
+
db/migrate/XXXXXXXXXXXXXX_create_shop_order_state_machine.rb
|
|
@@ -10,16 +10,16 @@ module Statecraft
|
|
|
10
10
|
# the per-model log table with a cascade FK and a CHECK constraint for a
|
|
11
11
|
# freshly created table), the machine class, the readonly log model, the
|
|
12
12
|
# model mounting with helpers and scopes on, and lazily the shared
|
|
13
|
-
# ApplicationMachine parent. The migration lands in the migration path
|
|
14
|
-
# the model's
|
|
15
|
-
# their model.
|
|
13
|
+
# ApplicationMachine parent. The migration lands in the migration path
|
|
14
|
+
# configured for the model's database, so multi-database apps get it next
|
|
15
|
+
# to their model.
|
|
16
16
|
class MachineGenerator < Rails::Generators::NamedBase
|
|
17
17
|
include ActiveRecord::Generators::Migration
|
|
18
18
|
|
|
19
19
|
source_root File.expand_path("templates", __dir__)
|
|
20
20
|
|
|
21
21
|
def detect_model_presence
|
|
22
|
-
@existing_model = File.exist?(File.join(destination_root,
|
|
22
|
+
@existing_model = File.exist?(File.join(destination_root, model_file))
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def create_application_machine
|
|
@@ -29,26 +29,35 @@ module Statecraft
|
|
|
29
29
|
template "application_machine.rb.tt", application_machine_path
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
def create_namespace_module
|
|
33
|
+
return if class_path.empty? || existing_model?
|
|
34
|
+
|
|
35
|
+
module_file = "app/models/#{class_path.join("/")}.rb"
|
|
36
|
+
return if File.exist?(File.join(destination_root, module_file))
|
|
37
|
+
|
|
38
|
+
template "namespace_module.rb.tt", module_file
|
|
39
|
+
end
|
|
40
|
+
|
|
32
41
|
def create_machine_class
|
|
33
|
-
template "machine.rb.tt", "app/state_machines/#{
|
|
42
|
+
template "machine.rb.tt", "app/state_machines/#{file_path}_flow.rb"
|
|
34
43
|
end
|
|
35
44
|
|
|
36
45
|
def create_log_model
|
|
37
|
-
template "log_model.rb.tt", "app/models/#{
|
|
46
|
+
template "log_model.rb.tt", "app/models/#{file_path}_transition.rb"
|
|
38
47
|
end
|
|
39
48
|
|
|
40
49
|
def create_or_mount_model
|
|
41
50
|
if existing_model?
|
|
42
|
-
inject_into_class
|
|
51
|
+
inject_into_class model_file, mounting_target_class, mounting_line
|
|
43
52
|
else
|
|
44
|
-
template "model.rb.tt",
|
|
53
|
+
template "model.rb.tt", model_file
|
|
45
54
|
end
|
|
46
55
|
end
|
|
47
56
|
|
|
48
57
|
def create_migration_file
|
|
49
58
|
migration_source = existing_model? ? "add_migration.rb.tt" : "create_migration.rb.tt"
|
|
50
59
|
migration_template migration_source,
|
|
51
|
-
"#{migration_directory}/create_#{
|
|
60
|
+
"#{migration_directory}/create_#{migration_slug}_state_machine.rb"
|
|
52
61
|
end
|
|
53
62
|
|
|
54
63
|
private
|
|
@@ -57,22 +66,43 @@ module Statecraft
|
|
|
57
66
|
@existing_model
|
|
58
67
|
end
|
|
59
68
|
|
|
69
|
+
def model_file
|
|
70
|
+
"app/models/#{file_path}.rb"
|
|
71
|
+
end
|
|
72
|
+
|
|
60
73
|
def mounting_line
|
|
61
74
|
" state_machine #{class_name}Flow, changed_at: true, helpers: true, scopes: true\n"
|
|
62
75
|
end
|
|
63
76
|
|
|
77
|
+
# inject_into_class matches the literal `class <name>` line, so the name
|
|
78
|
+
# must follow the style the model file actually uses: the full constant
|
|
79
|
+
# for the compact `class Shop::Order` style, the demodulized one for
|
|
80
|
+
# `module Shop / class Order` nesting.
|
|
81
|
+
def mounting_target_class
|
|
82
|
+
return class_name if class_path.empty?
|
|
83
|
+
|
|
84
|
+
model_source = File.read(File.join(destination_root, model_file))
|
|
85
|
+
model_source.match?(/class #{class_name}\b/) ? class_name : class_name.demodulize
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Without a configured migrations_paths, `rails db:migrate` reads
|
|
89
|
+
# db/migrate for every database — so that is the honest fallback.
|
|
64
90
|
def migration_directory
|
|
65
|
-
|
|
66
|
-
|
|
91
|
+
db_config = model_class&.connection_db_config
|
|
92
|
+
configured = db_config && Array(db_config.migrations_paths).first
|
|
93
|
+
configured || "db/migrate"
|
|
94
|
+
end
|
|
67
95
|
|
|
68
|
-
|
|
96
|
+
def model_class
|
|
97
|
+
class_name.safe_constantize
|
|
69
98
|
end
|
|
70
99
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
100
|
+
# The generated model's table follows the loaded model when one exists;
|
|
101
|
+
# otherwise the Rails naming convention for the argument, namespace
|
|
102
|
+
# included (`Shop::Order` -> shop_orders, matching the generated
|
|
103
|
+
# namespace module's table_name_prefix).
|
|
104
|
+
def table_name
|
|
105
|
+
@table_name ||= model_class ? model_class.table_name : super
|
|
76
106
|
end
|
|
77
107
|
|
|
78
108
|
def log_table_name
|
|
@@ -80,7 +110,7 @@ module Statecraft
|
|
|
80
110
|
end
|
|
81
111
|
|
|
82
112
|
def parent_class_name
|
|
83
|
-
specification_name =
|
|
113
|
+
specification_name = model_class&.connection_specification_name
|
|
84
114
|
return "ApplicationRecord" if specification_name.nil? || specification_name == "ActiveRecord::Base"
|
|
85
115
|
|
|
86
116
|
specification_name
|
|
@@ -90,8 +120,12 @@ module Statecraft
|
|
|
90
120
|
"#{file_name}_id"
|
|
91
121
|
end
|
|
92
122
|
|
|
123
|
+
def migration_slug
|
|
124
|
+
file_path.tr("/", "_")
|
|
125
|
+
end
|
|
126
|
+
|
|
93
127
|
def migration_class_name
|
|
94
|
-
"Create#{
|
|
128
|
+
"Create#{migration_slug.camelize}StateMachine"
|
|
95
129
|
end
|
|
96
130
|
end
|
|
97
131
|
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
<% class_path.each_with_index do |part, depth| -%>
|
|
4
|
+
<%= " " * depth %>module <%= part.camelize %>
|
|
5
|
+
<% end -%>
|
|
6
|
+
<%= " " * class_path.length %>def self.table_name_prefix
|
|
7
|
+
<%= " " * class_path.length %> "<%= class_path.join("_") %>_"
|
|
8
|
+
<%= " " * class_path.length %>end
|
|
9
|
+
<% (class_path.length - 1).downto(0) do |depth| -%>
|
|
10
|
+
<%= " " * depth %>end
|
|
11
|
+
<% end -%>
|
data/lib/statecraft/machine.rb
CHANGED
|
@@ -40,24 +40,28 @@ module Statecraft
|
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
def self.unary?(callable)
|
|
43
|
-
callable.arity
|
|
43
|
+
arity = callable.respond_to?(:arity) ? callable.arity : callable.method(:call).arity
|
|
44
|
+
arity == 1
|
|
44
45
|
end
|
|
45
46
|
end
|
|
46
47
|
|
|
47
48
|
# Class-level DSL collected declaratively and compiled by finalize!.
|
|
49
|
+
# Names arrive as symbols or strings interchangeably (the ActiveRecord
|
|
50
|
+
# idiom) and are normalized to symbols at the declaration line.
|
|
48
51
|
module ClassMethods
|
|
49
52
|
def state(name, initial: false)
|
|
50
|
-
declared_states << { name: name, initial: initial }
|
|
53
|
+
declared_states << { name: name.to_sym, initial: initial }
|
|
51
54
|
end
|
|
52
55
|
|
|
53
56
|
def transition(from:, to:, guard: nil, lock: false)
|
|
54
57
|
declared_edges << {
|
|
55
|
-
from: from, to: to, guards: Array(guard), lock: lock || current_event_lock,
|
|
58
|
+
from: from.to_sym, to: to.to_sym, guards: Array(guard), lock: lock || current_event_lock,
|
|
56
59
|
event: current_event_name
|
|
57
60
|
}
|
|
58
61
|
end
|
|
59
62
|
|
|
60
63
|
def event(name, from: nil, to: nil, guard: nil, lock: false, &declarations)
|
|
64
|
+
name = name.to_sym
|
|
61
65
|
declared_event_names << name
|
|
62
66
|
if declarations
|
|
63
67
|
if from || to
|
|
@@ -89,7 +93,9 @@ module Statecraft
|
|
|
89
93
|
|
|
90
94
|
declared_callbacks[phase] << Callback.new(
|
|
91
95
|
handler: callback_handler,
|
|
92
|
-
from: from && Array(from)
|
|
96
|
+
from: from && Array(from).map(&:to_sym),
|
|
97
|
+
to: to && Array(to).map(&:to_sym),
|
|
98
|
+
event: event && Array(event).map(&:to_sym)
|
|
93
99
|
)
|
|
94
100
|
end
|
|
95
101
|
end
|
|
@@ -114,8 +120,11 @@ module Statecraft
|
|
|
114
120
|
!@statecraft_compiled_graph.nil?
|
|
115
121
|
end
|
|
116
122
|
|
|
123
|
+
# Compilation memoizes the graph and freezes the declaration lists, so a
|
|
124
|
+
# reopened machine class fails loudly on any late state/transition/event
|
|
125
|
+
# instead of silently ignoring it (callbacks already freeze in compile).
|
|
117
126
|
def finalize!
|
|
118
|
-
@statecraft_compiled_graph ||= Compiler.new(self).compile
|
|
127
|
+
@statecraft_compiled_graph ||= Compiler.new(self).compile.tap { freeze_declarations }
|
|
119
128
|
end
|
|
120
129
|
|
|
121
130
|
def declared_states
|
|
@@ -136,6 +145,12 @@ module Statecraft
|
|
|
136
145
|
|
|
137
146
|
private
|
|
138
147
|
|
|
148
|
+
def freeze_declarations
|
|
149
|
+
declared_states.freeze
|
|
150
|
+
declared_edges.freeze
|
|
151
|
+
declared_event_names.freeze
|
|
152
|
+
end
|
|
153
|
+
|
|
139
154
|
def current_event_name
|
|
140
155
|
@statecraft_current_event && @statecraft_current_event[:name]
|
|
141
156
|
end
|
|
@@ -268,7 +283,7 @@ module Statecraft
|
|
|
268
283
|
from_callbacks = machine_class.declared_callbacks.each_value.flat_map do |callbacks|
|
|
269
284
|
callbacks.map(&:handler)
|
|
270
285
|
end
|
|
271
|
-
(from_edges + from_callbacks).
|
|
286
|
+
(from_edges + from_callbacks).grep(Symbol)
|
|
272
287
|
end
|
|
273
288
|
|
|
274
289
|
def deep_freeze_edges(edges)
|
data/lib/statecraft/metadata.rb
CHANGED
|
@@ -19,7 +19,8 @@ module Statecraft
|
|
|
19
19
|
value.map { |element| round_trip(element) }
|
|
20
20
|
when String then value.dup
|
|
21
21
|
when Symbol then value.to_s
|
|
22
|
-
when Integer,
|
|
22
|
+
when Integer, true, false, nil then value
|
|
23
|
+
when Float then round_trip_float(value)
|
|
23
24
|
when Time, Date, DateTime then value.iso8601
|
|
24
25
|
else
|
|
25
26
|
raise ArgumentError,
|
|
@@ -28,6 +29,14 @@ module Statecraft
|
|
|
28
29
|
end
|
|
29
30
|
end
|
|
30
31
|
|
|
32
|
+
def self.round_trip_float(value)
|
|
33
|
+
return value if value.finite?
|
|
34
|
+
|
|
35
|
+
raise ArgumentError,
|
|
36
|
+
"metadata value #{value.inspect} is not JSON-serializable; " \
|
|
37
|
+
"jsonb has no NaN or Infinity"
|
|
38
|
+
end
|
|
39
|
+
|
|
31
40
|
def self.round_trip_key(key)
|
|
32
41
|
case key
|
|
33
42
|
when String then key.dup
|
data/lib/statecraft/mounting.rb
CHANGED
|
@@ -40,7 +40,6 @@ module Statecraft
|
|
|
40
40
|
|
|
41
41
|
def mount
|
|
42
42
|
assert_not_mounted
|
|
43
|
-
assert_single_column_primary_key
|
|
44
43
|
log_class = resolve_log_class
|
|
45
44
|
assert_shared_connection_class(log_class)
|
|
46
45
|
graph = machine_class.finalize!
|
|
@@ -64,10 +63,6 @@ module Statecraft
|
|
|
64
63
|
raise AlreadyMounted.new(model: model) if model.respond_to?(:statecraft_mounting)
|
|
65
64
|
end
|
|
66
65
|
|
|
67
|
-
def assert_single_column_primary_key
|
|
68
|
-
raise CompositePrimaryKeyUnsupported.new(model: model) if model.primary_key.is_a?(Array)
|
|
69
|
-
end
|
|
70
|
-
|
|
71
66
|
def resolve_log_class
|
|
72
67
|
return @log_option if @log_option
|
|
73
68
|
|
|
@@ -87,11 +82,26 @@ module Statecraft
|
|
|
87
82
|
raise ConnectionMismatch.new(model: model, log_class: log_class)
|
|
88
83
|
end
|
|
89
84
|
|
|
85
|
+
# Methods this mounting itself defines, so a verb check runs BEFORE the
|
|
86
|
+
# includes and still sees them: an event named fire or history would
|
|
87
|
+
# otherwise silently shadow the gem's own surface.
|
|
88
|
+
def mounted_surface_methods
|
|
89
|
+
Pipeline::Surface.instance_methods +
|
|
90
|
+
Introspection.instance_methods +
|
|
91
|
+
%i[history last_transition in_state?]
|
|
92
|
+
end
|
|
93
|
+
|
|
90
94
|
def assert_no_verb_conflicts(graph)
|
|
91
95
|
return unless @helpers
|
|
92
96
|
|
|
97
|
+
surface_methods = mounted_surface_methods
|
|
93
98
|
graph.events.each_key do |event_name|
|
|
94
99
|
verb_names(event_name).each do |verb|
|
|
100
|
+
if surface_methods.include?(verb.to_sym)
|
|
101
|
+
raise CompilationError,
|
|
102
|
+
"helper #{verb} for event #{event_name.inspect} conflicts with the " \
|
|
103
|
+
"#{verb} method statecraft itself mounts; rename the event"
|
|
104
|
+
end
|
|
95
105
|
next unless model.method_defined?(verb) || model.private_method_defined?(verb)
|
|
96
106
|
|
|
97
107
|
raise CompilationError,
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
class Pipeline
|
|
5
|
+
# Resolves the requested edge from the compiled graph and raises
|
|
6
|
+
# InvalidTransition with a diagnosis that names what actually failed:
|
|
7
|
+
# an undeclared edge, a bypass-policy refusal, an unknown event, or an
|
|
8
|
+
# event with no branch from the current state.
|
|
9
|
+
module EdgeResolution
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def resolve_direct_edge(current, to_state, bypass_events)
|
|
13
|
+
edge = graph.edges[[current, to_state]]
|
|
14
|
+
raise_invalid_transition(current, to_state) if edge.nil?
|
|
15
|
+
guarding_events = edge.event_names.select { |name| edge.event_guards[name].any? }
|
|
16
|
+
if guarding_events.any? && !bypass_events
|
|
17
|
+
raise InvalidTransition.new(
|
|
18
|
+
record: record, from: current, requested: to_state,
|
|
19
|
+
allowed: allowed_targets(current),
|
|
20
|
+
message: "direct transition #{current} -> #{to_state} is guarded by " \
|
|
21
|
+
"event#{"s" if guarding_events.length > 1} #{guarding_events.join(", ")}; " \
|
|
22
|
+
"call fire!(:#{guarding_events.first}) or pass bypass_events: true"
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
edge
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def resolve_event_edge(current, event_name)
|
|
29
|
+
branches = graph.events[event_name]
|
|
30
|
+
raise_unknown_event(current, event_name) if branches.nil?
|
|
31
|
+
edge = branches[current]
|
|
32
|
+
raise_event_without_branch(current, event_name, branches) if edge.nil?
|
|
33
|
+
edge
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def raise_unknown_event(current, event_name)
|
|
37
|
+
known_events = graph.events.keys
|
|
38
|
+
raise InvalidTransition.new(
|
|
39
|
+
record: record, from: current, requested: event_name,
|
|
40
|
+
allowed: allowed_targets(current),
|
|
41
|
+
message: "unknown event #{event_name.inspect} for #{configuration.machine_class.name}; " \
|
|
42
|
+
"events: #{known_events.empty? ? "none" : known_events.map(&:inspect).join(", ")}"
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def raise_event_without_branch(current, event_name, branches)
|
|
47
|
+
declared_branches = branches.map { |from, edge| "#{from} -> #{edge.to}" }.join(", ")
|
|
48
|
+
raise InvalidTransition.new(
|
|
49
|
+
record: record, from: current, requested: event_name,
|
|
50
|
+
allowed: allowed_targets(current),
|
|
51
|
+
message: "event #{event_name.inspect} has no branch from #{current} for " \
|
|
52
|
+
"#{record.class.name}; branches: #{declared_branches}"
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def raise_invalid_transition(current, requested)
|
|
57
|
+
raise InvalidTransition.new(
|
|
58
|
+
record: record, from: current, requested: requested,
|
|
59
|
+
allowed: allowed_targets(current)
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def allowed_targets(current)
|
|
64
|
+
graph.edges.keys.select { |from, _to| from == current }.map(&:last)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
class Pipeline
|
|
5
|
+
# The record-facing API mixed into the model at mounting time. Bang
|
|
6
|
+
# variants return the created log record; non-bang variants return it too,
|
|
7
|
+
# or false on GuardFailed / InvalidTransition. Programmer errors and
|
|
8
|
+
# TransitionConflict always raise.
|
|
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)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def transition_to(to_state, metadata: {}, bypass_events: false)
|
|
15
|
+
transition_to!(to_state, metadata: metadata, bypass_events: bypass_events)
|
|
16
|
+
rescue GuardFailed, InvalidTransition
|
|
17
|
+
false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def fire!(event_name, metadata: {})
|
|
21
|
+
Pipeline.new(self).fire(event_name, metadata: metadata)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def fire(event_name, metadata: {})
|
|
25
|
+
fire!(event_name, metadata: metadata)
|
|
26
|
+
rescue GuardFailed, InvalidTransition
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/statecraft/pipeline.rb
CHANGED
|
@@ -5,7 +5,7 @@ module Statecraft
|
|
|
5
5
|
#
|
|
6
6
|
# persisted? -> metadata normalize+freeze -> edge resolution ->
|
|
7
7
|
# dirty check (lock only) -> transaction(requires_new: true) [
|
|
8
|
-
# lock+reload ->
|
|
8
|
+
# lock+reload -> conflict check against the resolved edge -> guards ->
|
|
9
9
|
# before_transition -> CAS UPDATE -> log INSERT (insert path) ->
|
|
10
10
|
# after_transition
|
|
11
11
|
# ] -> after_commit registration on the outermost real commit
|
|
@@ -28,36 +28,12 @@ module Statecraft
|
|
|
28
28
|
STACK_KEY = :statecraft_transition_stack
|
|
29
29
|
MAX_CHAIN_DEPTH = 16
|
|
30
30
|
|
|
31
|
+
include EdgeResolution
|
|
32
|
+
|
|
31
33
|
def self.transition_stack
|
|
32
34
|
ActiveSupport::IsolatedExecutionState[STACK_KEY] ||= []
|
|
33
35
|
end
|
|
34
36
|
|
|
35
|
-
# The record-facing API mixed into the model at mounting time. Bang
|
|
36
|
-
# variants return the created log record; non-bang variants return it too,
|
|
37
|
-
# or false on GuardFailed / InvalidTransition. Programmer errors and
|
|
38
|
-
# TransitionConflict always raise.
|
|
39
|
-
module Surface
|
|
40
|
-
def transition_to!(to_state, metadata: {}, bypass_events: false)
|
|
41
|
-
Pipeline.new(self).direct(to_state, metadata: metadata, bypass_events: bypass_events)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def transition_to(to_state, metadata: {}, bypass_events: false)
|
|
45
|
-
transition_to!(to_state, metadata: metadata, bypass_events: bypass_events)
|
|
46
|
-
rescue GuardFailed, InvalidTransition
|
|
47
|
-
false
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def fire!(event_name, metadata: {})
|
|
51
|
-
Pipeline.new(self).fire(event_name, metadata: metadata)
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def fire(event_name, metadata: {})
|
|
55
|
-
fire!(event_name, metadata: metadata)
|
|
56
|
-
rescue GuardFailed, InvalidTransition
|
|
57
|
-
false
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
|
|
61
37
|
def initialize(record)
|
|
62
38
|
@record = record
|
|
63
39
|
@configuration = record.class.statecraft_mounting
|
|
@@ -86,6 +62,7 @@ module Statecraft
|
|
|
86
62
|
def run(raw_metadata, &edge_resolver)
|
|
87
63
|
raise UnsavedRecordError.new(record: record) unless record.persisted?
|
|
88
64
|
|
|
65
|
+
assert_single_column_primary_key
|
|
89
66
|
metadata = Metadata.normalize(raw_metadata)
|
|
90
67
|
started_at = Time.current
|
|
91
68
|
begin
|
|
@@ -93,18 +70,12 @@ module Statecraft
|
|
|
93
70
|
assert_clean_when_locked(edge)
|
|
94
71
|
frame = open_frame(edge, event)
|
|
95
72
|
begin
|
|
96
|
-
log_record = execute_transaction(edge, event, bypass, metadata,
|
|
73
|
+
log_record = execute_transaction(edge, event, bypass, metadata, frame)
|
|
97
74
|
ensure
|
|
98
|
-
Pipeline.transition_stack.
|
|
75
|
+
Pipeline.transition_stack.delete_if { |open| open.equal?(frame) }
|
|
99
76
|
end
|
|
100
|
-
rescue GuardFailed =>
|
|
101
|
-
publish_failure(started_at,
|
|
102
|
-
raise
|
|
103
|
-
rescue InvalidTransition => invalid_error
|
|
104
|
-
publish_failure(started_at, :invalid_transition, requested: invalid_error.requested)
|
|
105
|
-
raise
|
|
106
|
-
rescue TransitionConflict => conflict_error
|
|
107
|
-
publish_failure(started_at, :conflict, expected_from: conflict_error.expected_from)
|
|
77
|
+
rescue GuardFailed, InvalidTransition, TransitionConflict => transition_error
|
|
78
|
+
publish_failure(started_at, transition_error)
|
|
108
79
|
raise
|
|
109
80
|
end
|
|
110
81
|
Instrumentation.publish_transition(
|
|
@@ -115,19 +86,25 @@ module Statecraft
|
|
|
115
86
|
log_record
|
|
116
87
|
end
|
|
117
88
|
|
|
118
|
-
def publish_failure(started_at,
|
|
89
|
+
def publish_failure(started_at, error)
|
|
90
|
+
reason, details =
|
|
91
|
+
case error
|
|
92
|
+
when GuardFailed then [:guard_failed, { guard: error.guard }]
|
|
93
|
+
when InvalidTransition then [:invalid_transition, { requested: error.requested }]
|
|
94
|
+
when TransitionConflict then [:conflict, { expected_from: error.expected_from }]
|
|
95
|
+
end
|
|
119
96
|
Instrumentation.publish_failure(
|
|
120
97
|
started_at: started_at, record: record,
|
|
121
98
|
machine_class: configuration.machine_class, reason: reason, details: details
|
|
122
99
|
)
|
|
123
100
|
end
|
|
124
101
|
|
|
125
|
-
def execute_transaction(edge, event, bypass, metadata,
|
|
102
|
+
def execute_transaction(edge, event, bypass, metadata, frame)
|
|
126
103
|
base_class.transaction(requires_new: true) do
|
|
127
104
|
if edge.lock
|
|
128
105
|
warn_when_row_locking_unavailable
|
|
129
106
|
record.reload(lock: true)
|
|
130
|
-
edge
|
|
107
|
+
assert_lock_saw_expected_state(edge)
|
|
131
108
|
end
|
|
132
109
|
transition_time = Time.current
|
|
133
110
|
run_guards(edge, event, bypass, metadata)
|
|
@@ -138,8 +115,13 @@ module Statecraft
|
|
|
138
115
|
cas_update!(edge, transition_time)
|
|
139
116
|
frame.cas_done = true
|
|
140
117
|
context.log_record = insert_log_row(edge, event, metadata, transition_time)
|
|
141
|
-
sync_record(edge, transition_time)
|
|
142
|
-
|
|
118
|
+
synced_snapshot = sync_record(edge, transition_time)
|
|
119
|
+
begin
|
|
120
|
+
run_callbacks(:after_transition, context)
|
|
121
|
+
rescue Exception # rubocop:disable Lint/RescueException -- the savepoint rolls the database back on ANY exception, so the in-memory sync must roll back with it
|
|
122
|
+
restore_synced_attributes(synced_snapshot)
|
|
123
|
+
raise
|
|
124
|
+
end
|
|
143
125
|
context.log_record
|
|
144
126
|
end
|
|
145
127
|
end
|
|
@@ -169,39 +151,13 @@ module Statecraft
|
|
|
169
151
|
record[configuration.column].to_s.to_sym
|
|
170
152
|
end
|
|
171
153
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
raise InvalidTransition.new(
|
|
178
|
-
record: record, from: current, requested: to_state,
|
|
179
|
-
allowed: allowed_targets(current),
|
|
180
|
-
message: "direct transition #{current} -> #{to_state} is guarded by " \
|
|
181
|
-
"event#{"s" if guarding_events.length > 1} #{guarding_events.join(", ")}; " \
|
|
182
|
-
"call fire!(:#{guarding_events.first}) or pass bypass_events: true"
|
|
183
|
-
)
|
|
184
|
-
end
|
|
185
|
-
edge
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
def resolve_event_edge(current, event_name)
|
|
189
|
-
branches = graph.events[event_name]
|
|
190
|
-
raise_invalid_transition(current, event_name) if branches.nil?
|
|
191
|
-
edge = branches[current]
|
|
192
|
-
raise_invalid_transition(current, event_name) if edge.nil?
|
|
193
|
-
edge
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
def raise_invalid_transition(current, requested)
|
|
197
|
-
raise InvalidTransition.new(
|
|
198
|
-
record: record, from: current, requested: requested,
|
|
199
|
-
allowed: allowed_targets(current)
|
|
200
|
-
)
|
|
201
|
-
end
|
|
154
|
+
# Checked at the first transition, not at mounting time: resolving an
|
|
155
|
+
# implicit primary key goes through the schema cache, and mounting must
|
|
156
|
+
# stay safe without a database connection.
|
|
157
|
+
def assert_single_column_primary_key
|
|
158
|
+
return unless base_class.primary_key.is_a?(Array)
|
|
202
159
|
|
|
203
|
-
|
|
204
|
-
graph.edges.keys.select { |from, _to| from == current }.map(&:last)
|
|
160
|
+
raise CompositePrimaryKeyUnsupported.new(model: base_class)
|
|
205
161
|
end
|
|
206
162
|
|
|
207
163
|
def assert_clean_when_locked(edge)
|
|
@@ -210,6 +166,16 @@ module Statecraft
|
|
|
210
166
|
raise DirtyRecordError.new(record: record, changed_attributes: record.changed)
|
|
211
167
|
end
|
|
212
168
|
|
|
169
|
+
# The lock reload can reveal a state that no longer matches the edge the
|
|
170
|
+
# caller validated. That is a concurrent write observed early: the same
|
|
171
|
+
# conflict CAS would report, so it raises the same error instead of
|
|
172
|
+
# silently resolving a different edge from the fresh state.
|
|
173
|
+
def assert_lock_saw_expected_state(edge)
|
|
174
|
+
return if current_state == edge.from
|
|
175
|
+
|
|
176
|
+
raise TransitionConflict.new(record: record, expected_from: edge.from)
|
|
177
|
+
end
|
|
178
|
+
|
|
213
179
|
def run_guards(edge, event, bypass, metadata)
|
|
214
180
|
guards = edge.edge_guards.dup
|
|
215
181
|
guards.concat(edge.event_guards.fetch(event, [])) if event && !bypass
|
|
@@ -267,18 +233,20 @@ module Statecraft
|
|
|
267
233
|
log_class.find(log_id)
|
|
268
234
|
end
|
|
269
235
|
|
|
236
|
+
# Mirrors exactly what the CAS statement wrote into the row, without a
|
|
237
|
+
# dirty mark, and returns the prior values so a failing after_transition
|
|
238
|
+
# can roll the memory back alongside the savepoint.
|
|
270
239
|
def sync_record(edge, transition_time)
|
|
271
|
-
|
|
272
|
-
record[
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
record.clear_attribute_changes(synced_columns)
|
|
240
|
+
updates = cas_updates(edge, transition_time)
|
|
241
|
+
snapshot = updates.to_h { |column, _value| [column, record[column]] }
|
|
242
|
+
updates.each { |column, value| record[column] = value }
|
|
243
|
+
record.clear_attribute_changes(updates.keys)
|
|
244
|
+
snapshot
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def restore_synced_attributes(snapshot)
|
|
248
|
+
snapshot.each { |column, value| record[column] = value }
|
|
249
|
+
record.clear_attribute_changes(snapshot.keys)
|
|
282
250
|
end
|
|
283
251
|
|
|
284
252
|
def register_after_commit_callbacks(log_record, metadata)
|
|
@@ -310,7 +278,7 @@ module Statecraft
|
|
|
310
278
|
end
|
|
311
279
|
|
|
312
280
|
def warn_when_row_locking_unavailable
|
|
313
|
-
return unless base_class.
|
|
281
|
+
return unless base_class.connection_db_config.adapter.match?(/sqlite/i)
|
|
314
282
|
|
|
315
283
|
Statecraft.warn(
|
|
316
284
|
[configuration.machine_class.name, :sqlite_row_lock],
|
data/lib/statecraft/version.rb
CHANGED
data/lib/statecraft.rb
CHANGED
|
@@ -9,7 +9,9 @@ require_relative "statecraft/warnings"
|
|
|
9
9
|
require_relative "statecraft/instrumentation"
|
|
10
10
|
require_relative "statecraft/machine"
|
|
11
11
|
require_relative "statecraft/metadata"
|
|
12
|
+
require_relative "statecraft/pipeline/edge_resolution"
|
|
12
13
|
require_relative "statecraft/pipeline"
|
|
14
|
+
require_relative "statecraft/pipeline/surface"
|
|
13
15
|
require_relative "statecraft/introspection"
|
|
14
16
|
require_relative "statecraft/mounting"
|
|
15
17
|
|
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.1.
|
|
4
|
+
version: 0.1.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Igor Pugachev
|
|
@@ -60,6 +60,7 @@ extra_rdoc_files: []
|
|
|
60
60
|
files:
|
|
61
61
|
- LICENSE.txt
|
|
62
62
|
- README.md
|
|
63
|
+
- lib/generators/statecraft/machine/USAGE
|
|
63
64
|
- lib/generators/statecraft/machine/machine_generator.rb
|
|
64
65
|
- lib/generators/statecraft/machine/templates/add_migration.rb.tt
|
|
65
66
|
- lib/generators/statecraft/machine/templates/application_machine.rb.tt
|
|
@@ -67,6 +68,7 @@ files:
|
|
|
67
68
|
- lib/generators/statecraft/machine/templates/log_model.rb.tt
|
|
68
69
|
- lib/generators/statecraft/machine/templates/machine.rb.tt
|
|
69
70
|
- lib/generators/statecraft/machine/templates/model.rb.tt
|
|
71
|
+
- lib/generators/statecraft/machine/templates/namespace_module.rb.tt
|
|
70
72
|
- lib/statecraft.rb
|
|
71
73
|
- lib/statecraft/errors.rb
|
|
72
74
|
- lib/statecraft/instrumentation.rb
|
|
@@ -75,6 +77,8 @@ files:
|
|
|
75
77
|
- lib/statecraft/metadata.rb
|
|
76
78
|
- lib/statecraft/mounting.rb
|
|
77
79
|
- lib/statecraft/pipeline.rb
|
|
80
|
+
- lib/statecraft/pipeline/edge_resolution.rb
|
|
81
|
+
- lib/statecraft/pipeline/surface.rb
|
|
78
82
|
- lib/statecraft/version.rb
|
|
79
83
|
- lib/statecraft/warnings.rb
|
|
80
84
|
homepage: https://supostat.github.io/statecraft/
|