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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b9f0a6cac349045859dd7966a325ae9d0a4b27175b846e8f43bd924b298576b6
4
- data.tar.gz: a148de67af8ddd2bb976ae5ed03db6a38122ebd795013f9aae6c5803e45f7120
3
+ metadata.gz: f809e9a1769e457d33d4b526703e4c3c7beb4ca09f8a7550cbbd31c97b07ba12
4
+ data.tar.gz: 8193837939f39dd0de633330b7bfc36d7adbb821221072ce1cdf30890f3488d0
5
5
  SHA512:
6
- metadata.gz: a48dbba9f2e06f23bbcffc0fc632f17f6915954737c2f104c132fa8763823bb9f07a0e754dabf4eb95d14b21b97c912885d0d1efa252c7e3ce64cedeeb206a22
7
- data.tar.gz: 8256c0463e14d6ddf19d2a757647f81bae4bfa73ecc37796f905adc82d397dbdcb730c45861d23b172711e75aff224482de7dbbdc94a07fbbc9c4cca95fd4c37
6
+ metadata.gz: a5da582f65eb60cd2f76fef2ec213ad6c09166b288f44d80f7065a943894a66c5f36134f6c094fd873e50562976a447d0183b3e0e47b5545bce707d63f965d83
7
+ data.tar.gz: f1247259b3bc97de6663d9545e00015e1ea443b9e9f0ac33af6a179c09ba534140b0037863ddd2fa64e4992234a5fe14ea0d43e4a9ddc30d1018927c0d3158c5
data/README.md CHANGED
@@ -1,7 +1,11 @@
1
1
  # statecraft
2
2
 
3
+ [![Gem Version](https://img.shields.io/gem/v/statecraft.svg)](https://rubygems.org/gems/statecraft)
4
+ [![CI](https://github.com/supostat/statecraft/actions/workflows/ci.yml/badge.svg)](https://github.com/supostat/statecraft/actions/workflows/ci.yml)
3
5
  [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](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
- (with edge re-resolution from the fresh state), guards, `before_transition`
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` means exactly "a guard said no or the edge is not
112
- declared" (`GuardFailed` / `InvalidTransition`). Everything else including
113
- `TransitionConflict` always raises, in both variants.
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 that connection's migration path. Mounting verifies connection
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 of
14
- # the model's connection owner, so multi-database apps get it next to
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, "app/models/#{file_name}.rb"))
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/#{file_name}_flow.rb"
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/#{file_name}_transition.rb"
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 "app/models/#{file_name}.rb", class_name, mounting_line
51
+ inject_into_class model_file, mounting_target_class, mounting_line
43
52
  else
44
- template "model.rb.tt", "app/models/#{file_name}.rb"
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_#{file_name}_state_machine.rb"
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
- specification_name = model_connection_specification_name
66
- return "db/migrate" if specification_name.nil? || specification_name == "ActiveRecord::Base"
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
- "db/#{specification_name.underscore.tr("/", "_")}_migrate"
96
+ def model_class
97
+ class_name.safe_constantize
69
98
  end
70
99
 
71
- def model_connection_specification_name
72
- model_class = class_name.safe_constantize
73
- model_class&.connection_specification_name
74
- rescue StandardError
75
- nil
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 = model_connection_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#{class_name}StateMachine"
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 -%>
@@ -40,24 +40,28 @@ module Statecraft
40
40
  end
41
41
 
42
42
  def self.unary?(callable)
43
- callable.arity == 1
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), to: to && Array(to), event: event && Array(event)
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).select { |handler| handler.is_a?(Symbol) }
286
+ (from_edges + from_callbacks).grep(Symbol)
272
287
  end
273
288
 
274
289
  def deep_freeze_edges(edges)
@@ -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, Float, true, false, nil then value
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
@@ -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
@@ -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 -> re-resolve edge from fresh state -> guards ->
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, edge_resolver, frame)
73
+ log_record = execute_transaction(edge, event, bypass, metadata, frame)
97
74
  ensure
98
- Pipeline.transition_stack.delete(frame)
75
+ Pipeline.transition_stack.delete_if { |open| open.equal?(frame) }
99
76
  end
100
- rescue GuardFailed => guard_error
101
- publish_failure(started_at, :guard_failed, guard: guard_error.guard)
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, reason, details)
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, edge_resolver, frame)
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, event, bypass = edge_resolver.call(current_state)
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
- run_callbacks(:after_transition, context)
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
- def resolve_direct_edge(current, to_state, bypass_events)
173
- edge = graph.edges[[current, to_state]]
174
- raise_invalid_transition(current, to_state) if edge.nil?
175
- guarding_events = edge.event_names.select { |name| edge.event_guards[name].any? }
176
- if guarding_events.any? && !bypass_events
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
- def allowed_targets(current)
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
- synced_columns = [configuration.column]
272
- record[configuration.column] = edge.to.to_s
273
- if touch_updated_at?
274
- record[:updated_at] = transition_time
275
- synced_columns << :updated_at
276
- end
277
- if changed_at_column
278
- record[changed_at_column] = transition_time
279
- synced_columns << changed_at_column
280
- end
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.connection.adapter_name.match?(/sqlite/i)
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],
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Statecraft
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.3"
5
5
  end
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.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/