statecraft 0.3.0 → 0.4.1

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: 1a49958ae3b97e963ada9ee0cb6f719bd0fa5a784704d7f1479f0b045a921ec1
4
- data.tar.gz: e6fe7115b639e25d33e752800af3324822497413865236be7f53e01dc56674bb
3
+ metadata.gz: f62004e4153d3351bda79f2cf1f09db95e42b83302d016909cbe7d8d081ef3ba
4
+ data.tar.gz: e005456aca83c931019db903ff6a860efff9111da2de5bbe35fe7021192e1f28
5
5
  SHA512:
6
- metadata.gz: 92730425ebca1937db4f9e53bac01020102a7c6c90bc76fd5518697394bec18720cb70dd826017ca9b36d4e873cd0817a6399399565098e0dfc687db853ad68b
7
- data.tar.gz: f7511de223aa42b0f44c5fc0b14a0be53965887262b01bb62c9a1954abb9e4e98b4a2b5688ee967ca672cd19514ddc2357fbcd796139074612ee2ac588975357
6
+ metadata.gz: 8c8bd36f37d3c0392cfc4c17562f071f66e8e3252253e2237bc84283418dea222db32327e30aab4f3cd10b408e37852f6cddf3f93171a026194f1a6787f26c83
7
+ data.tar.gz: f1a14781c925c5bc72da7e7120fc8e1e0a4809b17f36f03f8e55b72e6a8a1163056b730804bb871d0961d1139428bd1084cffa5437f03847674e1dd49c989c9d
data/README.md CHANGED
@@ -337,6 +337,63 @@ rolling rename recipe on the column:
337
337
  after the cleanup.
338
338
  3. Drop the old state and its edges; narrow the CHECK back.
339
339
 
340
+ ## Migrating from statesman
341
+
342
+ The names already match: statesman's `order_transitions` table and
343
+ `OrderTransition` class are exactly what statecraft's log convention expects,
344
+ so the move is an in-place conversion of the table you already have — history
345
+ stays where it is, nothing is copied.
346
+
347
+ ```sh
348
+ bin/rails generate statecraft:from_statesman Order
349
+ ```
350
+
351
+ The generator reads the live statesman machine through reflection (pass the
352
+ class as a second argument when it is not `OrderStateMachine`; point at the
353
+ class that declares the DSL — statesman graphs are not inherited) and writes
354
+ three things: the conversion migration, a machine skeleton, and the
355
+ `state_machine` mounting line. Two things it honestly cannot write: statesman
356
+ has no events, so every edge arrives as a bare `transition` for you to name,
357
+ and guard bodies are anonymous blocks — each becomes a TODO comment carrying
358
+ the original `file:line`.
359
+
360
+ The migration converts in this order:
361
+
362
+ 1. The model gains its `state` column, backfilled from the **last transition
363
+ by `sort_key`** — deliberately not `most_recent`, which drifts out of sync
364
+ often enough that statesman ships a repair task for it. Rows with no
365
+ transitions get the initial state; a CHECK constraint pins the value set.
366
+ 2. The transitions table gains `from_state` (a `LAG` window along the
367
+ `sort_key` chain, the first hop starting from the initial state) and a
368
+ nullable `event` — imported history reads as direct transitions, which is
369
+ the honest description of what statesman recorded.
370
+ 3. A text `metadata` column becomes native json(b) in one indivisible move
371
+ with removing `serialize` from the model: neither library works in the
372
+ half-converted state, so the type change and the code change ship
373
+ together.
374
+ 4. The foreign key is re-created with `ON DELETE CASCADE` (statesman's
375
+ default one carries no action), statesman's unique indexes go, and the
376
+ `[foreign_key, id]` index that serves statecraft's history reads arrives.
377
+ 5. `sort_key`, `most_recent` and `updated_at` are dropped last — the first
378
+ two would break the log INSERT outright, being NOT NULL without defaults.
379
+
380
+ The migration's header names two pre-flight checks on live data — that the
381
+ id order agrees with the `sort_key` order (statecraft reads history by id),
382
+ and that a text `metadata` column holds valid JSON in every row (rows written
383
+ by raw SQL may not survive the cast). Run both before migrating.
384
+
385
+ After the migration, finish by hand (the generator prints this list): drop
386
+ `Statesman::Adapters::ActiveRecordTransition` and the
387
+ `after_destroy :update_most_recent` callback from the transition model — they
388
+ read dropped columns — plus `ActiveRecordQueries` from the model and the
389
+ `Statesman.configure` initializer once no machine is left; then name your
390
+ events in the skeleton. One guarantee moves rather than disappears: the
391
+ race safety statesman derived from its unique `(parent, sort_key)` index is
392
+ statecraft's CAS on the state column.
393
+
394
+ Outside Rails, the same steps work by hand — pair the migration order above
395
+ with the reference schema in [Outside Rails](#outside-rails).
396
+
340
397
  ## PII and erasure
341
398
 
342
399
  Metadata is the only place personal data can live — `from_state`, `to_state`
@@ -649,6 +706,8 @@ AR_VERSION=7.2 RUBY_VERSION=3.3 docker compose run --rm test-postgres
649
706
  ## Links
650
707
 
651
708
  - Landing page: <https://supostat.github.io/statecraft/>
709
+ - Benchmarks vs statesman/aasm: <https://supostat.github.io/statecraft/compare.html>
710
+ (reproducible scripts in [`benchmark/`](benchmark/))
652
711
  - RubyGems: <https://rubygems.org/gems/statecraft>
653
712
  - Issues: <https://github.com/supostat/statecraft/issues>
654
713
 
@@ -0,0 +1,30 @@
1
+ Description:
2
+ Migrates a model off statesman: converts its transitions table into the
3
+ statecraft log IN PLACE (the names already match the gem's convention),
4
+ gives the model its state column with a backfill from the last
5
+ transition, generates a machine skeleton from the statesman class's
6
+ reflection, and mounts the machine.
7
+
8
+ The statesman machine is read through reflection: states, the initial
9
+ state and the transition graph are copied; events and guard bodies
10
+ cannot be — statesman has no events (name them yourself in the skeleton)
11
+ and guard bodies are anonymous blocks (each becomes a TODO comment with
12
+ its original source location).
13
+
14
+ The second argument names the statesman machine class; without it the
15
+ <Model>StateMachine convention is assumed. Statesman graphs are not
16
+ inherited — point at the class that declares the DSL.
17
+
18
+ Example:
19
+ bin/rails generate statecraft:from_statesman Order
20
+
21
+ Creates (when missing) and edits:
22
+ app/state_machines/application_machine.rb
23
+ app/state_machines/order_flow.rb (skeleton: graph + TODOs)
24
+ app/models/order.rb (state_machine mounting line)
25
+ db/migrate/XXX_convert_order_transitions_to_statecraft.rb
26
+
27
+ The migration header lists two pre-flight checks on live data (id order
28
+ vs sort_key order; text metadata holding valid JSON) — read it before
29
+ running. After migrating, follow the printed cleanup list: drop the
30
+ statesman includes, the configure initializer, and name your events.
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Statecraft
7
+ module Generators
8
+ # `rails generate statecraft:from_statesman Order [OrderStateMachine]` —
9
+ # the migration path off statesman. Reads the live statesman machine
10
+ # through reflection (states, initial state, transition graph, guard
11
+ # locations), generates the in-place conversion migration that turns the
12
+ # statesman transitions table into this gem's log, a machine skeleton
13
+ # whose events and guards are left for the human, and mounts the machine
14
+ # into the model. The statesman class itself is never loaded by name
15
+ # magic beyond the optional second argument's default.
16
+ class FromStatesmanGenerator < Rails::Generators::NamedBase
17
+ include ActiveRecord::Generators::Migration
18
+
19
+ source_root File.expand_path("templates", __dir__)
20
+
21
+ argument :statesman_machine_name, type: :string, required: false,
22
+ desc: "The statesman machine class (default: <Model>StateMachine)"
23
+
24
+ def load_statesman_machine
25
+ @statesman_machine = resolve_statesman_machine
26
+ @initial_state = @statesman_machine.initial_state
27
+ return unless @initial_state.nil?
28
+
29
+ raise Thor::Error, "#{statesman_class_name} declares no initial state; " \
30
+ "statecraft requires exactly one — declare `state ..., initial: true` first"
31
+ end
32
+
33
+ def create_application_machine
34
+ application_machine_path = "app/state_machines/application_machine.rb"
35
+ return if File.exist?(File.join(destination_root, application_machine_path))
36
+
37
+ template "application_machine.rb.tt", application_machine_path
38
+ end
39
+
40
+ def create_machine_skeleton
41
+ template "machine_from_statesman.rb.tt", "app/state_machines/#{file_path}_flow.rb"
42
+ end
43
+
44
+ def mount_model
45
+ unless File.exist?(File.join(destination_root, model_file))
46
+ say_status :skip, "#{model_file} not found — mount the machine yourself: #{mounting_line.strip}", :yellow
47
+ return
48
+ end
49
+
50
+ inject_into_class model_file, class_name, mounting_line
51
+ end
52
+
53
+ def create_conversion_migration
54
+ migration_template "convert_transitions_migration.rb.tt",
55
+ "#{migration_directory}/convert_#{migration_slug}_transitions_to_statecraft.rb"
56
+ end
57
+
58
+ def print_cleanup_instructions
59
+ say "\nAfter running the migration, finish the move by hand:", :green
60
+ say " * #{log_class_name}: drop `include Statesman::Adapters::ActiveRecordTransition` and"
61
+ say " the `after_destroy :update_most_recent` callback (they read dropped columns);"
62
+ say " consider adding `def readonly? = persisted?` — the pipeline inserts around it."
63
+ say " * #{class_name}: drop `include Statesman::Adapters::ActiveRecordQueries` and the"
64
+ say " state_machine/transition helper methods that wrapped statesman."
65
+ say " * Delete the `Statesman.configure` initializer once no machine is left."
66
+ say " * Name your events in app/state_machines/#{file_path}_flow.rb — statesman had none."
67
+ end
68
+
69
+ private
70
+
71
+ def resolve_statesman_machine
72
+ machine_class = statesman_class_name.safe_constantize
73
+ if machine_class.nil?
74
+ raise Thor::Error, "statesman machine class #{statesman_class_name} not found — " \
75
+ "pass it explicitly: rails g statecraft:from_statesman #{name} YourMachineClass"
76
+ end
77
+
78
+ unless machine_class.respond_to?(:states) && machine_class.respond_to?(:successors)
79
+ raise Thor::Error, "#{statesman_class_name} does not look like a statesman machine " \
80
+ "(no .states/.successors); note that statesman graphs are not inherited — " \
81
+ "point at the class that declares the DSL"
82
+ end
83
+
84
+ machine_class
85
+ end
86
+
87
+ def statesman_class_name
88
+ statesman_machine_name || "#{class_name}StateMachine"
89
+ end
90
+
91
+ attr_reader :initial_state
92
+
93
+ def states
94
+ @statesman_machine.states.map(&:to_s)
95
+ end
96
+
97
+ # statesman accumulates successors without deduplication and statecraft
98
+ # refuses a duplicate edge at compile time, so uniq is load-bearing.
99
+ def edges
100
+ @statesman_machine.successors.flat_map do |from, destinations|
101
+ Array(destinations).map { |to| [from.to_s, to.to_s] }
102
+ end.uniq
103
+ end
104
+
105
+ def guard_notes
106
+ return [] unless @statesman_machine.respond_to?(:callbacks)
107
+
108
+ Array(@statesman_machine.callbacks[:guards]).map do |guard|
109
+ origin = guard.callback.respond_to?(:source_location) ? guard.callback.source_location&.join(":") : nil
110
+ from_label = guard.from || "any"
111
+ to_label = Array(guard.to).empty? ? "any" : Array(guard.to).join("/")
112
+ note = "#{from_label} -> #{to_label}"
113
+ origin ? "#{note} (defined at #{origin})" : note
114
+ end
115
+ end
116
+
117
+ def mounting_line
118
+ " state_machine #{class_name}Flow, changed_at: true, helpers: true, scopes: true\n"
119
+ end
120
+
121
+ def model_file
122
+ "app/models/#{file_path}.rb"
123
+ end
124
+
125
+ def model_class
126
+ class_name.safe_constantize
127
+ end
128
+
129
+ def table_name
130
+ @table_name ||= model_class.respond_to?(:table_name) ? model_class.table_name : super
131
+ end
132
+
133
+ def log_table_name
134
+ "#{table_name.singularize}_transitions"
135
+ end
136
+
137
+ def log_class_name
138
+ "#{class_name}Transition"
139
+ end
140
+
141
+ def foreign_key_column
142
+ "#{file_name}_id"
143
+ end
144
+
145
+ def migration_directory
146
+ db_config = model_class.respond_to?(:connection_db_config) && model_class.connection_db_config
147
+ configured = db_config && Array(db_config.migrations_paths).first
148
+ configured || "db/migrate"
149
+ end
150
+
151
+ def migration_slug
152
+ file_path.tr("/", "_")
153
+ end
154
+
155
+ def migration_class_name
156
+ "Convert#{migration_slug.camelize}TransitionsToStatecraft"
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared home for private guard helpers used by several machines. Keep it
4
+ # free of DSL declarations (states, transitions, events): machine definitions
5
+ # are NOT inherited — each machine declares its own graph.
6
+ class ApplicationMachine
7
+ include Statecraft::Machine
8
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Converts the statesman transitions table <%= log_table_name %> into the
4
+ # statecraft log IN PLACE and gives <%= table_name %> its state column.
5
+ #
6
+ # BEFORE RUNNING — two checks on live data, both cheap:
7
+ #
8
+ # 1. statecraft reads history ordered by id, statesman ordered by sort_key.
9
+ # They almost always agree; prove it for your data (must return 0 rows):
10
+ #
11
+ # SELECT t.<%= foreign_key_column %> FROM <%= log_table_name %> t
12
+ # JOIN <%= log_table_name %> later ON later.<%= foreign_key_column %> = t.<%= foreign_key_column %>
13
+ # AND later.sort_key > t.sort_key AND later.id < t.id LIMIT 1;
14
+ #
15
+ # 2. If your metadata column is text, every row must hold valid JSON (rows
16
+ # written by raw SQL may not) — the jsonb cast below dies on the first
17
+ # invalid one.
18
+ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
19
+ def up
20
+ convert_parent_table
21
+ convert_transitions_table
22
+ convert_metadata_column
23
+ rebuild_indexes_and_foreign_key
24
+ drop_statesman_columns
25
+ end
26
+
27
+ def down
28
+ raise ActiveRecord::IrreversibleMigration
29
+ end
30
+
31
+ private
32
+
33
+ def convert_parent_table
34
+ add_column :<%= table_name %>, :state, :string
35
+ add_column :<%= table_name %>, :state_changed_at, :datetime
36
+
37
+ # The current state is the to_state of the LAST transition by sort_key —
38
+ # deliberately not most_recent, which statesman itself ships a repair
39
+ # rake task for; rows without transitions are in the initial state.
40
+ execute <<~SQL
41
+ UPDATE <%= table_name %> SET state = COALESCE(
42
+ (SELECT t.to_state FROM <%= log_table_name %> t
43
+ WHERE t.<%= foreign_key_column %> = <%= table_name %>.id
44
+ ORDER BY t.sort_key DESC LIMIT 1),
45
+ '<%= initial_state %>'
46
+ )
47
+ SQL
48
+
49
+ # The mounting says changed_at: true, so the column must exist — and the
50
+ # conversion knows the honest value: the moment of the last recorded
51
+ # transition. Rows that never transitioned stay NULL, exactly like a
52
+ # freshly created record.
53
+ execute <<~SQL
54
+ UPDATE <%= table_name %> SET state_changed_at =
55
+ (SELECT MAX(t.created_at) FROM <%= log_table_name %> t
56
+ WHERE t.<%= foreign_key_column %> = <%= table_name %>.id)
57
+ SQL
58
+
59
+ change_column_default :<%= table_name %>, :state, "<%= initial_state %>"
60
+ change_column_null :<%= table_name %>, :state, false
61
+
62
+ return unless connection.supports_check_constraints?
63
+
64
+ # add new states here when the machine grows
65
+ if postgresql?
66
+ add_check_constraint :<%= table_name %>, state_check_expression,
67
+ name: "<%= table_name %>_state_check", validate: false
68
+ validate_check_constraint :<%= table_name %>, name: "<%= table_name %>_state_check"
69
+ else
70
+ add_check_constraint :<%= table_name %>, state_check_expression,
71
+ name: "<%= table_name %>_state_check"
72
+ end
73
+ end
74
+
75
+ def convert_transitions_table
76
+ add_column :<%= log_table_name %>, :from_state, :string
77
+ add_column :<%= log_table_name %>, :event, :string
78
+
79
+ # from_state is the previous to_state along the sort_key chain; the
80
+ # chain's first hop starts from the initial state. Runs while sort_key
81
+ # is still alive — order matters.
82
+ execute <<~SQL
83
+ UPDATE <%= log_table_name %> SET from_state = COALESCE(prev.prev_state, '<%= initial_state %>')
84
+ FROM (SELECT id, LAG(to_state) OVER (
85
+ PARTITION BY <%= foreign_key_column %> ORDER BY sort_key
86
+ ) AS prev_state
87
+ FROM <%= log_table_name %>) prev
88
+ WHERE prev.id = <%= log_table_name %>.id
89
+ SQL
90
+
91
+ change_column_null :<%= log_table_name %>, :from_state, false
92
+ # event stays NULL for the imported history: statesman had no events, and
93
+ # statecraft reads NULL as a direct transition.
94
+ end
95
+
96
+ # Statesman's default schema serializes metadata into a text column; the
97
+ # statecraft pipeline wants native json(b). Converting the type and
98
+ # removing `serialize :metadata` from the transition model are ONE move —
99
+ # neither library works in the half-converted state.
100
+ def convert_metadata_column
101
+ metadata_type = connection.columns(:<%= log_table_name %>).find { |column| column.name == "metadata" }&.sql_type.to_s
102
+ return unless metadata_type.match?(/text|char/i)
103
+
104
+ if postgresql?
105
+ change_column_default :<%= log_table_name %>, :metadata, nil
106
+ execute "ALTER TABLE <%= log_table_name %> ALTER COLUMN metadata TYPE jsonb USING metadata::jsonb"
107
+ execute "UPDATE <%= log_table_name %> SET metadata = '{}'::jsonb WHERE metadata IS NULL"
108
+ change_column_default :<%= log_table_name %>, :metadata, {}
109
+ change_column_null :<%= log_table_name %>, :metadata, false
110
+ else
111
+ # SQLite types dynamically and serialize already stored JSON text —
112
+ # the change is declarative (a table rebuild under the hood).
113
+ change_column :<%= log_table_name %>, :metadata, :json, null: false, default: {}
114
+ end
115
+ end
116
+
117
+ def rebuild_indexes_and_foreign_key
118
+ # If your foreign key column is NOT named <%= foreign_key_column %>
119
+ # (check the has_many on your model), rename it first — statecraft
120
+ # derives the name from the model class and offers no override:
121
+ # rename_column :<%= log_table_name %>, :your_column, :<%= foreign_key_column %>
122
+
123
+ remove_index :<%= log_table_name %>, column: %i[<%= foreign_key_column %> sort_key], if_exists: true
124
+ remove_index :<%= log_table_name %>, column: %i[<%= foreign_key_column %> most_recent], if_exists: true
125
+ add_index :<%= log_table_name %>, %i[<%= foreign_key_column %> id]
126
+
127
+ # An audit without its subject is not an audit: the statecraft contract
128
+ # is ON DELETE CASCADE (statesman's default foreign key, when present,
129
+ # carries no action).
130
+ if foreign_key_exists?(:<%= log_table_name %>, :<%= table_name %>)
131
+ remove_foreign_key :<%= log_table_name %>, :<%= table_name %>
132
+ end
133
+ add_foreign_key :<%= log_table_name %>, :<%= table_name %>,
134
+ column: :<%= foreign_key_column %>, on_delete: :cascade
135
+ end
136
+
137
+ # sort_key and most_recent are NOT NULL without defaults — the first
138
+ # statecraft insert would die on them; updated_at is merely absent from
139
+ # the statecraft schema.
140
+ def drop_statesman_columns
141
+ remove_column :<%= log_table_name %>, :sort_key
142
+ remove_column :<%= log_table_name %>, :most_recent if column_exists?(:<%= log_table_name %>, :most_recent)
143
+ remove_column :<%= log_table_name %>, :updated_at if column_exists?(:<%= log_table_name %>, :updated_at)
144
+ end
145
+
146
+ def state_check_expression
147
+ "state IN ('<%= initial_state %>'<% (states - [initial_state]).each do |state_name| %>, '<%= state_name %>'<% end %>)"
148
+ end
149
+
150
+ def postgresql?
151
+ connection.adapter_name.match?(/postg/i)
152
+ end
153
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Skeleton generated from <%= statesman_class_name %>: the graph is copied,
4
+ # the semantics are yours to port.
5
+ #
6
+ # - statesman has no events, so every edge below is a bare transition. Where
7
+ # an edge is really a command in your domain, name it:
8
+ # event :pay, from: :pending, to: :paid
9
+ # - statesman guard bodies cannot be extracted; each is listed as a TODO with
10
+ # its original location. Port a guard that judges the record alone as
11
+ # record_guard: (arity 1), one that reads the input as guard:.
12
+ class <%= class_name %>Flow < ApplicationMachine
13
+ <% states.each do |state_name| -%>
14
+ state :<%= state_name %><%= state_name == initial_state ? ", initial: true" : "" %>
15
+ <% end -%>
16
+
17
+ <% edges.each do |from, to| -%>
18
+ transition from: :<%= from %>, to: :<%= to %>
19
+ <% end -%>
20
+ <% unless guard_notes.empty? -%>
21
+
22
+ <% guard_notes.each do |note| -%>
23
+ # TODO(guard): <%= note %>
24
+ <% end -%>
25
+ <% end -%>
26
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Statecraft
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: statecraft
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor Pugachev
@@ -60,6 +60,11 @@ extra_rdoc_files: []
60
60
  files:
61
61
  - LICENSE.txt
62
62
  - README.md
63
+ - lib/generators/statecraft/from_statesman/USAGE
64
+ - lib/generators/statecraft/from_statesman/from_statesman_generator.rb
65
+ - lib/generators/statecraft/from_statesman/templates/application_machine.rb.tt
66
+ - lib/generators/statecraft/from_statesman/templates/convert_transitions_migration.rb.tt
67
+ - lib/generators/statecraft/from_statesman/templates/machine_from_statesman.rb.tt
63
68
  - lib/generators/statecraft/machine/USAGE
64
69
  - lib/generators/statecraft/machine/machine_generator.rb
65
70
  - lib/generators/statecraft/machine/templates/add_migration.rb.tt