statecraft 0.1.1 → 0.1.2

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: 207eda16ee51c42a787b0e3ba1a37c297242316edfe742c6be75b5dbca1166c0
4
+ data.tar.gz: efe7505f2499fdcd1175b35be3c454a9143b7afb2824dc3a6f0e2f98a8429a5f
5
5
  SHA512:
6
- metadata.gz: a48dbba9f2e06f23bbcffc0fc632f17f6915954737c2f104c132fa8763823bb9f07a0e754dabf4eb95d14b21b97c912885d0d1efa252c7e3ce64cedeeb206a22
7
- data.tar.gz: 8256c0463e14d6ddf19d2a757647f81bae4bfa73ecc37796f905adc82d397dbdcb730c45861d23b172711e75aff224482de7dbbdc94a07fbbc9c4cca95fd4c37
6
+ metadata.gz: 29d2cd130932273c9469dcbecf03f2a0fb20d33fc8b776efba5f850cd21bb447c5032de1dc10375e5dc9718d993001f6e0b279a5aa2e72c7be0cd1b890408710
7
+ data.tar.gz: c0856de0dde4ff10884ac2f567848b01b8949b514a2a171971957161d88348994d8f207e0f1e2f5d10fb1137a2b750b7f9f08e183537759dd2947be8c57911f4
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
@@ -309,7 +314,8 @@ The log lives next to its model, always — a cascade FK cannot cross
309
314
  databases, so this is a definition, not a restriction. The generated log
310
315
  class inherits the model's connection-owning ancestor (base, roles and
311
316
  horizontal shards follow automatically), and the generator drops the
312
- migration into that connection's migration path. Mounting verifies connection
317
+ migration into the migration path configured for that connection's database
318
+ (`db/migrate` when none is configured). Mounting verifies connection
313
319
  identity — same pool, same per-thread connection, one real transaction — and
314
320
  raises `Statecraft::ConnectionMismatch` with a fix hint otherwise. Two
315
321
  `connects_to` blocks pointing at one physical database are still two pools:
@@ -369,6 +375,12 @@ docker compose run --rm test-postgres # PostgreSQL 16
369
375
  AR_VERSION=7.2 RUBY_VERSION=3.3 docker compose run --rm test-postgres
370
376
  ```
371
377
 
378
+ ## Links
379
+
380
+ - Landing page: <https://supostat.github.io/statecraft/>
381
+ - RubyGems: <https://rubygems.org/gems/statecraft>
382
+ - Issues: <https://github.com/supostat/statecraft/issues>
383
+
372
384
  ## License
373
385
 
374
386
  MIT. See [LICENSE.txt](LICENSE.txt).
@@ -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 -%>
@@ -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
 
@@ -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
@@ -32,32 +32,6 @@ module Statecraft
32
32
  ActiveSupport::IsolatedExecutionState[STACK_KEY] ||= []
33
33
  end
34
34
 
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
35
  def initialize(record)
62
36
  @record = record
63
37
  @configuration = record.class.statecraft_mounting
@@ -86,6 +60,7 @@ module Statecraft
86
60
  def run(raw_metadata, &edge_resolver)
87
61
  raise UnsavedRecordError.new(record: record) unless record.persisted?
88
62
 
63
+ assert_single_column_primary_key
89
64
  metadata = Metadata.normalize(raw_metadata)
90
65
  started_at = Time.current
91
66
  begin
@@ -93,18 +68,12 @@ module Statecraft
93
68
  assert_clean_when_locked(edge)
94
69
  frame = open_frame(edge, event)
95
70
  begin
96
- log_record = execute_transaction(edge, event, bypass, metadata, edge_resolver, frame)
71
+ log_record = execute_transaction(edge, event, bypass, metadata, frame)
97
72
  ensure
98
73
  Pipeline.transition_stack.delete(frame)
99
74
  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)
75
+ rescue GuardFailed, InvalidTransition, TransitionConflict => transition_error
76
+ publish_failure(started_at, transition_error)
108
77
  raise
109
78
  end
110
79
  Instrumentation.publish_transition(
@@ -115,19 +84,25 @@ module Statecraft
115
84
  log_record
116
85
  end
117
86
 
118
- def publish_failure(started_at, reason, details)
87
+ def publish_failure(started_at, error)
88
+ reason, details =
89
+ case error
90
+ when GuardFailed then [:guard_failed, { guard: error.guard }]
91
+ when InvalidTransition then [:invalid_transition, { requested: error.requested }]
92
+ when TransitionConflict then [:conflict, { expected_from: error.expected_from }]
93
+ end
119
94
  Instrumentation.publish_failure(
120
95
  started_at: started_at, record: record,
121
96
  machine_class: configuration.machine_class, reason: reason, details: details
122
97
  )
123
98
  end
124
99
 
125
- def execute_transaction(edge, event, bypass, metadata, edge_resolver, frame)
100
+ def execute_transaction(edge, event, bypass, metadata, frame)
126
101
  base_class.transaction(requires_new: true) do
127
102
  if edge.lock
128
103
  warn_when_row_locking_unavailable
129
104
  record.reload(lock: true)
130
- edge, event, bypass = edge_resolver.call(current_state)
105
+ assert_lock_saw_expected_state(edge)
131
106
  end
132
107
  transition_time = Time.current
133
108
  run_guards(edge, event, bypass, metadata)
@@ -138,8 +113,13 @@ module Statecraft
138
113
  cas_update!(edge, transition_time)
139
114
  frame.cas_done = true
140
115
  context.log_record = insert_log_row(edge, event, metadata, transition_time)
141
- sync_record(edge, transition_time)
142
- run_callbacks(:after_transition, context)
116
+ synced_snapshot = sync_record(edge, transition_time)
117
+ begin
118
+ run_callbacks(:after_transition, context)
119
+ 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
120
+ restore_synced_attributes(synced_snapshot)
121
+ raise
122
+ end
143
123
  context.log_record
144
124
  end
145
125
  end
@@ -204,12 +184,31 @@ module Statecraft
204
184
  graph.edges.keys.select { |from, _to| from == current }.map(&:last)
205
185
  end
206
186
 
187
+ # Checked at the first transition, not at mounting time: resolving an
188
+ # implicit primary key goes through the schema cache, and mounting must
189
+ # stay safe without a database connection.
190
+ def assert_single_column_primary_key
191
+ return unless base_class.primary_key.is_a?(Array)
192
+
193
+ raise CompositePrimaryKeyUnsupported.new(model: base_class)
194
+ end
195
+
207
196
  def assert_clean_when_locked(edge)
208
197
  return unless edge.lock && record.changed?
209
198
 
210
199
  raise DirtyRecordError.new(record: record, changed_attributes: record.changed)
211
200
  end
212
201
 
202
+ # The lock reload can reveal a state that no longer matches the edge the
203
+ # caller validated. That is a concurrent write observed early: the same
204
+ # conflict CAS would report, so it raises the same error instead of
205
+ # silently resolving a different edge from the fresh state.
206
+ def assert_lock_saw_expected_state(edge)
207
+ return if current_state == edge.from
208
+
209
+ raise TransitionConflict.new(record: record, expected_from: edge.from)
210
+ end
211
+
213
212
  def run_guards(edge, event, bypass, metadata)
214
213
  guards = edge.edge_guards.dup
215
214
  guards.concat(edge.event_guards.fetch(event, [])) if event && !bypass
@@ -267,18 +266,20 @@ module Statecraft
267
266
  log_class.find(log_id)
268
267
  end
269
268
 
269
+ # Mirrors exactly what the CAS statement wrote into the row, without a
270
+ # dirty mark, and returns the prior values so a failing after_transition
271
+ # can roll the memory back alongside the savepoint.
270
272
  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)
273
+ updates = cas_updates(edge, transition_time)
274
+ snapshot = updates.to_h { |column, _value| [column, record[column]] }
275
+ updates.each { |column, value| record[column] = value }
276
+ record.clear_attribute_changes(updates.keys)
277
+ snapshot
278
+ end
279
+
280
+ def restore_synced_attributes(snapshot)
281
+ snapshot.each { |column, value| record[column] = value }
282
+ record.clear_attribute_changes(snapshot.keys)
282
283
  end
283
284
 
284
285
  def register_after_commit_callbacks(log_record, metadata)
@@ -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.2"
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"
13
+ require_relative "statecraft/pipeline/surface"
13
14
  require_relative "statecraft/introspection"
14
15
  require_relative "statecraft/mounting"
15
16
 
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.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor Pugachev
@@ -67,6 +67,7 @@ files:
67
67
  - lib/generators/statecraft/machine/templates/log_model.rb.tt
68
68
  - lib/generators/statecraft/machine/templates/machine.rb.tt
69
69
  - lib/generators/statecraft/machine/templates/model.rb.tt
70
+ - lib/generators/statecraft/machine/templates/namespace_module.rb.tt
70
71
  - lib/statecraft.rb
71
72
  - lib/statecraft/errors.rb
72
73
  - lib/statecraft/instrumentation.rb
@@ -75,6 +76,7 @@ files:
75
76
  - lib/statecraft/metadata.rb
76
77
  - lib/statecraft/mounting.rb
77
78
  - lib/statecraft/pipeline.rb
79
+ - lib/statecraft/pipeline/surface.rb
78
80
  - lib/statecraft/version.rb
79
81
  - lib/statecraft/warnings.rb
80
82
  homepage: https://supostat.github.io/statecraft/