statecraft 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f62004e4153d3351bda79f2cf1f09db95e42b83302d016909cbe7d8d081ef3ba
4
- data.tar.gz: e005456aca83c931019db903ff6a860efff9111da2de5bbe35fe7021192e1f28
3
+ metadata.gz: 4689cea0dee40236473975f52ea5ccd2ecd303783edebd7626ab1518a1f8c8bd
4
+ data.tar.gz: b3713ea5d55ea4881b55b10ceccb38847f2f85aa971d29326fd26e97412d0620
5
5
  SHA512:
6
- metadata.gz: 8c8bd36f37d3c0392cfc4c17562f071f66e8e3252253e2237bc84283418dea222db32327e30aab4f3cd10b408e37852f6cddf3f93171a026194f1a6787f26c83
7
- data.tar.gz: f1a14781c925c5bc72da7e7120fc8e1e0a4809b17f36f03f8e55b72e6a8a1163056b730804bb871d0961d1139428bd1084cffa5437f03847674e1dd49c989c9d
6
+ metadata.gz: 9690fff2fb62feaaea147a53c63113a77dc93103432ad3f5aa63b5593173d513d2abf83d5d53decf0d6eed7347d94509197f5bb0d03077cdbd5a98f1f20cefd4
7
+ data.tar.gz: cbd7e8d84af570a4c2cb1e51582262860b2d6d0cd7d87ed0304528413ca1d34720dbe6c9b4186fab5a68639c6814243ed2fed32f9774d4985a5f4f5408b45ede
data/README.md CHANGED
@@ -260,6 +260,51 @@ belong to the application's presentation layer, not to the machine.
260
260
  A guard that reads metadata makes `may_*?` depend on the metadata you pass —
261
261
  pass the same metadata to `may_*?` that you will collect for `fire!`.
262
262
 
263
+ ## RSpec matchers
264
+
265
+ One opt-in require gives your specs matchers over the whole introspection
266
+ surface — RSpec never becomes a runtime dependency of the gem:
267
+
268
+ <!-- illustrative -->
269
+ ```ruby
270
+ # spec_helper.rb, after rspec itself is loaded
271
+ require "statecraft/rspec"
272
+ ```
273
+
274
+ <!-- illustrative -->
275
+ ```ruby
276
+ # The record-level questions consult the guards, with the same metadata
277
+ # your production call will carry:
278
+ expect(order).to allow_event(:pay).with_metadata("amount" => 100)
279
+ expect(order).to allow_transition_to(:cancelled).via(:cancel)
280
+ expect(order).to allow_transition_to(:archived).directly
281
+ expect(order).to have_transitioned_to(:paid) # strictly log-based
282
+
283
+ # The refusal with its reason — guard names come from refusals_for:
284
+ expect(order).to refuse_event(:cancel).because_of(:customer_cancellable?)
285
+
286
+ # The class-level pair answers the graph's shape; guards stay untouched:
287
+ expect(OrderFlow).to have_edge(:pending, :cancelled).via(:cancel)
288
+ expect(OrderFlow).to have_initial_state(:pending)
289
+
290
+ # The transition itself: the state move AND the appended log row,
291
+ # asserted in one expression around fire!/transition_to!:
292
+ expect { order.fire!(:pay, metadata: { "amount" => 100 }) }
293
+ .to transition(order).from(:pending).to(:paid)
294
+ .via_event(:pay).with_metadata("amount" => 100)
295
+ ```
296
+
297
+ A failing matcher explains itself with the same introspection the pipeline
298
+ consults: the current state, the edges reachable from it, and the refusing
299
+ guard with its layer. A non-bang call that returned `false` fails the
300
+ `transition` matcher the same way; exceptions of the bang forms fly through
301
+ like with `change` — assert refusals with `refuse_event` or `raise_error`,
302
+ not with the block matcher.
303
+
304
+ `because_of` carries the same honest limit as `refusals_for` underneath it:
305
+ it names record-layer guards only. An input-reading `guard:` has no name
306
+ there, and the failure message says so instead of guessing.
307
+
263
308
  ## Metadata
264
309
 
265
310
  Metadata is normalized on pipeline entry with a full JSON round-trip —
@@ -351,38 +396,48 @@ bin/rails generate statecraft:from_statesman Order
351
396
  The generator reads the live statesman machine through reflection (pass the
352
397
  class as a second argument when it is not `OrderStateMachine`; point at the
353
398
  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
399
+ three conversion migrations, a machine skeleton, and the `state_machine`
400
+ mounting line. Two things it honestly cannot write: statesman has no events,
401
+ so every edge arrives as a bare `transition` for you to name, and guard
402
+ bodies are anonymous blocks — each becomes a TODO comment carrying the
403
+ original `file:line`.
404
+
405
+ Three migrations, because the obvious single one is a production incident:
406
+ `add_column` takes an ACCESS EXCLUSIVE lock that PostgreSQL holds until the
407
+ **end of the transaction**, and a full-table backfill inside that same
408
+ transaction keeps both tables unreadable even for SELECTs for its whole
409
+ duration. The conversion splits along the lock boundaries instead, and the
410
+ runbook is five steps:
411
+
412
+ 1. **Run `_ddl`** any time: nullable columns only on PostgreSQL 11+ its
413
+ lock lasts milliseconds.
414
+ 2. **Run `_backfill`** any time, even mid-day: batches over parent-id
415
+ ranges outside any DDL transaction, touching only rows still NULL. The
416
+ model's state comes from the **last transition by `sort_key`**
417
+ deliberately not `most_recent`, which drifts often enough that statesman
418
+ ships a repair task for it — `state_changed_at` from that transition's
419
+ `created_at`, and `from_state` from a `LAG` window along the chain.
420
+ 3. **Switch the code**: port the skeleton, deploy statecraft in place of
421
+ statesman.
422
+ 4. **Catch up**: the backfill is idempotent (`WHERE ... IS NULL`), so run
423
+ its class once more to pick up rows statesman wrote between steps 2
424
+ and 3 — `bin/rails runner` with a `load` of the migration file and
425
+ `.new.up` does it without touching `schema_migrations`.
426
+ 5. **Run `_finalize`**: NOT NULL lands through the `NOT VALID`
427
+ `VALIDATE CONSTRAINT` pair (readers never blocked), the `state` and
428
+ `[foreign_key, id]` indexes build `CONCURRENTLY`, the cascade FK is
429
+ validated the same way, and the statesman columns are dropped last.
430
+ Honest limit: a text `metadata` column is rewritten under a lock here —
431
+ unavoidable within this recipe; for very large tables the shadow-column
432
+ dance is the escape.
433
+
434
+ The `_ddl` migration's header names two pre-flight checks on live data —
435
+ that the id order agrees with the `sort_key` order (statecraft reads history
436
+ by id), and that a text `metadata` column holds valid JSON in every row
437
+ (rows written by raw SQL may not survive the cast). Run both before
438
+ starting.
439
+
440
+ After finalize, finish by hand (the generator prints this list): drop
386
441
  `Statesman::Adapters::ActiveRecordTransition` and the
387
442
  `after_destroy :update_most_recent` callback from the transition model — they
388
443
  read dropped columns — plus `ActiveRecordQueries` from the model and the
@@ -391,7 +446,7 @@ events in the skeleton. One guarantee moves rather than disappears: the
391
446
  race safety statesman derived from its unique `(parent, sort_key)` index is
392
447
  statecraft's CAS on the state column.
393
448
 
394
- Outside Rails, the same steps work by hand — pair the migration order above
449
+ Outside Rails, the same five steps work by hand — pair the runbook above
395
450
  with the reference schema in [Outside Rails](#outside-rails).
396
451
 
397
452
  ## PII and erasure
@@ -50,13 +50,24 @@ module Statecraft
50
50
  inject_into_class model_file, class_name, mounting_line
51
51
  end
52
52
 
53
- def create_conversion_migration
54
- migration_template "convert_transitions_migration.rb.tt",
55
- "#{migration_directory}/convert_#{migration_slug}_transitions_to_statecraft.rb"
53
+ # Three migrations, strong-migrations style: instant DDL, a batched
54
+ # backfill outside any DDL transaction, and a short-lock finalize.
55
+ # migration_template numbers them monotonically, so they run in order.
56
+ def create_conversion_migrations
57
+ migration_template "convert_transitions_ddl.rb.tt",
58
+ "#{migration_directory}/convert_#{migration_slug}_transitions_ddl.rb"
59
+ migration_template "convert_transitions_backfill.rb.tt",
60
+ "#{migration_directory}/convert_#{migration_slug}_transitions_backfill.rb"
61
+ migration_template "convert_transitions_finalize.rb.tt",
62
+ "#{migration_directory}/convert_#{migration_slug}_transitions_finalize.rb"
56
63
  end
57
64
 
58
65
  def print_cleanup_instructions
59
- say "\nAfter running the migration, finish the move by hand:", :green
66
+ say "\nThe conversion is three migrations — run them per the README runbook:", :green
67
+ say " 1. _ddl now (instant); 2. _backfill any time (batched, no long locks;"
68
+ say " rerun it to catch up rows statesman wrote in between);"
69
+ say " 3. _finalize after switching the code to statecraft."
70
+ say "\nAfter finalize, finish the move by hand:", :green
60
71
  say " * #{log_class_name}: drop `include Statesman::Adapters::ActiveRecordTransition` and"
61
72
  say " the `after_destroy :update_most_recent` callback (they read dropped columns);"
62
73
  say " consider adding `def readonly? = persisted?` — the pipeline inserts around it."
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Step 2 of 3: the batched backfill, outside any DDL transaction. Batches
4
+ # walk ranges of PARENT ids — both updates are partitioned by the parent,
5
+ # so the LAG window never tears at a batch boundary — and every statement
6
+ # touches only rows still NULL, which makes the whole migration idempotent:
7
+ # rerun it any time to catch up with rows statesman wrote in between (the
8
+ # runbook's catch-up step after switching the code).
9
+ class Convert<%= migration_slug.camelize %>TransitionsBackfill < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
10
+ disable_ddl_transaction!
11
+
12
+ BATCH_SIZE = 10_000
13
+
14
+ def up
15
+ bounds = select_rows("SELECT MIN(id), MAX(id) FROM <%= table_name %>").first
16
+ return if bounds.nil? || bounds.first.nil?
17
+
18
+ first_id, last_id = bounds.map(&:to_i)
19
+ first_id.step(last_id, BATCH_SIZE) do |batch_start|
20
+ batch_end = batch_start + BATCH_SIZE - 1
21
+ backfill_parent_rows(batch_start, batch_end)
22
+ backfill_transition_rows(batch_start, batch_end)
23
+ end
24
+ end
25
+
26
+ def down
27
+ raise ActiveRecord::IrreversibleMigration
28
+ end
29
+
30
+ private
31
+
32
+ # The current state is the to_state of the LAST transition by sort_key —
33
+ # deliberately not most_recent, which statesman itself ships a repair rake
34
+ # task for; rows without transitions are in the initial state. The moment
35
+ # of the last transition fills state_changed_at; never-transitioned rows
36
+ # stay NULL, exactly like a freshly created record.
37
+ def backfill_parent_rows(batch_start, batch_end)
38
+ execute <<~SQL
39
+ UPDATE <%= table_name %> SET
40
+ state = COALESCE(
41
+ (SELECT t.to_state FROM <%= log_table_name %> t
42
+ WHERE t.<%= foreign_key_column %> = <%= table_name %>.id
43
+ ORDER BY t.sort_key DESC LIMIT 1),
44
+ '<%= initial_state %>'
45
+ ),
46
+ state_changed_at =
47
+ (SELECT MAX(t.created_at) FROM <%= log_table_name %> t
48
+ WHERE t.<%= foreign_key_column %> = <%= table_name %>.id)
49
+ WHERE <%= table_name %>.id BETWEEN #{batch_start} AND #{batch_end}
50
+ AND <%= table_name %>.state IS NULL
51
+ SQL
52
+ end
53
+
54
+ # from_state is the previous to_state along the sort_key chain; the
55
+ # chain's first hop starts from the initial state. Runs while sort_key is
56
+ # still alive — the finalize migration drops it, so backfill comes first.
57
+ def backfill_transition_rows(batch_start, batch_end)
58
+ execute <<~SQL
59
+ UPDATE <%= log_table_name %> SET from_state = COALESCE(prev.prev_state, '<%= initial_state %>')
60
+ FROM (SELECT id, LAG(to_state) OVER (
61
+ PARTITION BY <%= foreign_key_column %> ORDER BY sort_key
62
+ ) AS prev_state
63
+ FROM <%= log_table_name %>
64
+ WHERE <%= foreign_key_column %> BETWEEN #{batch_start} AND #{batch_end}) prev
65
+ WHERE prev.id = <%= log_table_name %>.id
66
+ AND <%= log_table_name %>.from_state IS NULL
67
+ SQL
68
+ end
69
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Step 1 of 3: instant DDL. Adds the nullable columns and nothing else —
4
+ # on PostgreSQL 11+ a nullable add_column without a default takes its
5
+ # ACCESS EXCLUSIVE lock for milliseconds. The long work lives in the
6
+ # batched backfill (step 2) and the finalize migration (step 3); run them
7
+ # in that order, per the README's "Migrating from statesman" runbook.
8
+ #
9
+ # BEFORE STARTING THE CONVERSION — two checks on live data, both cheap:
10
+ #
11
+ # 1. statecraft reads history ordered by id, statesman ordered by sort_key.
12
+ # They almost always agree; prove it for your data (must return 0 rows):
13
+ #
14
+ # SELECT t.<%= foreign_key_column %> FROM <%= log_table_name %> t
15
+ # JOIN <%= log_table_name %> later ON later.<%= foreign_key_column %> = t.<%= foreign_key_column %>
16
+ # AND later.sort_key > t.sort_key AND later.id < t.id LIMIT 1;
17
+ #
18
+ # 2. If your metadata column is text, every row must hold valid JSON (rows
19
+ # written by raw SQL may not) — the jsonb cast in step 3 dies on the
20
+ # first invalid one.
21
+ class Convert<%= migration_slug.camelize %>TransitionsDdl < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
22
+ def change
23
+ add_column :<%= table_name %>, :state, :string
24
+ add_column :<%= table_name %>, :state_changed_at, :datetime
25
+ add_column :<%= log_table_name %>, :from_state, :string
26
+ add_column :<%= log_table_name %>, :event, :string
27
+ end
28
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Step 3 of 3: finalize with short locks, after the backfill has caught up
4
+ # and the code has switched to statecraft (statesman columns are dropped
5
+ # here, so run this last). Every long validation goes through the
6
+ # PostgreSQL NOT VALID -> VALIDATE pair, which never blocks readers;
7
+ # indexes build CONCURRENTLY.
8
+ class Convert<%= migration_slug.camelize %>TransitionsFinalize < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
9
+ disable_ddl_transaction!
10
+
11
+ def up
12
+ enforce_not_null(:<%= table_name %>, :state)
13
+ enforce_not_null(:<%= log_table_name %>, :from_state)
14
+ add_state_default_and_check
15
+ build_indexes
16
+ rebuild_foreign_key
17
+ convert_metadata_column
18
+ drop_statesman_columns
19
+ end
20
+
21
+ def down
22
+ raise ActiveRecord::IrreversibleMigration
23
+ end
24
+
25
+ private
26
+
27
+ # NOT NULL without a full-table lock: a NOT VALID check validates while
28
+ # readers and writers keep working; SET NOT NULL then reuses the
29
+ # validated constraint and the helper check is dropped.
30
+ def enforce_not_null(table, column)
31
+ if postgresql?
32
+ helper_name = "#{table}_#{column}_null_check"
33
+ execute "ALTER TABLE #{table} ADD CONSTRAINT #{helper_name} CHECK (#{column} IS NOT NULL) NOT VALID"
34
+ execute "ALTER TABLE #{table} VALIDATE CONSTRAINT #{helper_name}"
35
+ change_column_null table, column, false
36
+ execute "ALTER TABLE #{table} DROP CONSTRAINT #{helper_name}"
37
+ else
38
+ change_column_null table, column, false
39
+ end
40
+ end
41
+
42
+ def add_state_default_and_check
43
+ change_column_default :<%= table_name %>, :state, "<%= initial_state %>"
44
+
45
+ return unless connection.supports_check_constraints?
46
+
47
+ # add new states here when the machine grows
48
+ if postgresql?
49
+ add_check_constraint :<%= table_name %>, state_check_expression,
50
+ name: "<%= table_name %>_state_check", validate: false
51
+ validate_check_constraint :<%= table_name %>, name: "<%= table_name %>_state_check"
52
+ else
53
+ add_check_constraint :<%= table_name %>, state_check_expression,
54
+ name: "<%= table_name %>_state_check"
55
+ end
56
+ end
57
+
58
+ # The state index is the reference schema's promise (scopes and
59
+ # where(state:) read it); [fk, id] serves the history reads.
60
+ def build_indexes
61
+ if postgresql?
62
+ add_index :<%= table_name %>, :state, algorithm: :concurrently, if_not_exists: true
63
+ add_index :<%= log_table_name %>, %i[<%= foreign_key_column %> id],
64
+ algorithm: :concurrently, if_not_exists: true
65
+ else
66
+ add_index :<%= table_name %>, :state, if_not_exists: true
67
+ add_index :<%= log_table_name %>, %i[<%= foreign_key_column %> id], if_not_exists: true
68
+ end
69
+
70
+ remove_index :<%= log_table_name %>, column: %i[<%= foreign_key_column %> sort_key], if_exists: true
71
+ remove_index :<%= log_table_name %>, column: %i[<%= foreign_key_column %> most_recent], if_exists: true
72
+ end
73
+
74
+ # An audit without its subject is not an audit: the statecraft contract is
75
+ # ON DELETE CASCADE (statesman's default foreign key, when present,
76
+ # carries no action). If your foreign key column is NOT named
77
+ # <%= foreign_key_column %> (check the has_many on your model), rename it
78
+ # first — statecraft derives the name from the model class:
79
+ # rename_column :<%= log_table_name %>, :your_column, :<%= foreign_key_column %>
80
+ def rebuild_foreign_key
81
+ if foreign_key_exists?(:<%= log_table_name %>, :<%= table_name %>)
82
+ remove_foreign_key :<%= log_table_name %>, :<%= table_name %>
83
+ end
84
+
85
+ if postgresql?
86
+ add_foreign_key :<%= log_table_name %>, :<%= table_name %>,
87
+ column: :<%= foreign_key_column %>, on_delete: :cascade, validate: false
88
+ validate_foreign_key :<%= log_table_name %>, :<%= table_name %>
89
+ else
90
+ add_foreign_key :<%= log_table_name %>, :<%= table_name %>,
91
+ column: :<%= foreign_key_column %>, on_delete: :cascade
92
+ end
93
+ end
94
+
95
+ # Statesman's default schema serializes metadata into a text column; the
96
+ # statecraft pipeline wants native json(b). Converting the type and
97
+ # removing `serialize :metadata` from the transition model are ONE move —
98
+ # neither library works in the half-converted state. Honest limit: the
99
+ # type change rewrites the table under a lock; for very large tables the
100
+ # shadow-column dance is the escape, outside this recipe.
101
+ def convert_metadata_column
102
+ metadata_type = connection.columns(:<%= log_table_name %>).find { |column| column.name == "metadata" }&.sql_type.to_s
103
+ return unless metadata_type.match?(/text|char/i)
104
+
105
+ if postgresql?
106
+ change_column_default :<%= log_table_name %>, :metadata, nil
107
+ execute "ALTER TABLE <%= log_table_name %> ALTER COLUMN metadata TYPE jsonb USING metadata::jsonb"
108
+ execute "UPDATE <%= log_table_name %> SET metadata = '{}'::jsonb WHERE metadata IS NULL"
109
+ change_column_default :<%= log_table_name %>, :metadata, {}
110
+ change_column_null :<%= log_table_name %>, :metadata, false
111
+ else
112
+ change_column :<%= log_table_name %>, :metadata, :json, null: false, default: {}
113
+ end
114
+ end
115
+
116
+ # sort_key and most_recent are NOT NULL without defaults — the first
117
+ # statecraft insert would die on them; updated_at is merely absent from
118
+ # the statecraft schema. Dropped last: the backfill needs sort_key alive.
119
+ def drop_statesman_columns
120
+ remove_column :<%= log_table_name %>, :sort_key
121
+ remove_column :<%= log_table_name %>, :most_recent if column_exists?(:<%= log_table_name %>, :most_recent)
122
+ remove_column :<%= log_table_name %>, :updated_at if column_exists?(:<%= log_table_name %>, :updated_at)
123
+ end
124
+
125
+ def state_check_expression
126
+ "state IN ('<%= initial_state %>'<% (states - [initial_state]).each do |state_name| %>, '<%= state_name %>'<% end %>)"
127
+ end
128
+
129
+ def postgresql?
130
+ connection.adapter_name.match?(/postg/i)
131
+ end
132
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statecraft
4
+ module RSpec
5
+ # expect(record).to allow_event(:pay).with_metadata("amount" => 5)
6
+ #
7
+ # The can_fire? question, asked with the same metadata the production
8
+ # call will carry. The failure message walks the layers the pipeline
9
+ # walks: is the event declared from this state, and if so, which
10
+ # guards said no.
11
+ class AllowEvent
12
+ def initialize(event_name)
13
+ @event_name = event_name.to_sym
14
+ @metadata = {}
15
+ end
16
+
17
+ def with_metadata(metadata)
18
+ @metadata = metadata
19
+ self
20
+ end
21
+
22
+ def matches?(record)
23
+ @record = record
24
+ record.can_fire?(@event_name, metadata: @metadata)
25
+ end
26
+
27
+ def failure_message
28
+ lines = ["expected #{StateReport.standing(@record)} to allow event #{@event_name.inspect}, but it was refused"]
29
+ if StateReport.event_declared?(@record, @event_name)
30
+ lines << StateReport.refusal(@record, @event_name, @metadata)
31
+ else
32
+ lines << "event #{@event_name.inspect} is not declared from #{StateReport.current_state(@record).inspect}"
33
+ lines << StateReport.declared_shape_of(@record)
34
+ end
35
+ lines.join("\n ")
36
+ end
37
+
38
+ def failure_message_when_negated
39
+ "expected #{StateReport.standing(@record)} not to allow event #{@event_name.inspect}, " \
40
+ "but the guards passed with metadata #{@metadata.inspect}"
41
+ end
42
+
43
+ def description
44
+ "allow event #{@event_name.inspect}"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statecraft
4
+ module RSpec
5
+ # expect(record).to allow_transition_to(:paid).via(:pay)
6
+ # expect(record).to allow_transition_to(:archived).directly
7
+ #
8
+ # The available_transitions prediction for one target: not only
9
+ # whether the record can go there, but HOW — via lists the events
10
+ # whose guards pass, directly asserts the guard-free direct way.
11
+ class AllowTransitionTo
12
+ def initialize(target_state)
13
+ @target_state = target_state.to_sym
14
+ @expected_via = []
15
+ @directly = false
16
+ @metadata = {}
17
+ end
18
+
19
+ def via(*event_names)
20
+ @expected_via = event_names.map(&:to_sym)
21
+ self
22
+ end
23
+
24
+ def directly
25
+ @directly = true
26
+ self
27
+ end
28
+
29
+ def with_metadata(metadata)
30
+ @metadata = metadata
31
+ self
32
+ end
33
+
34
+ def matches?(record)
35
+ @record = record
36
+ @availability = record.available_transitions(metadata: @metadata)
37
+ .find { |availability| availability.to == @target_state }
38
+ return false unless @availability
39
+
40
+ missing_ways.empty?
41
+ end
42
+
43
+ def failure_message
44
+ unless @availability
45
+ return ["expected #{StateReport.standing(@record)} to reach #{@target_state.inspect}, but it cannot",
46
+ StateReport.reachable(@record, @metadata),
47
+ StateReport.declared_shape_of(@record)].join("\n ")
48
+ end
49
+
50
+ "expected the way to #{@target_state.inspect} to include #{missing_ways.map(&:inspect).join(", ")}, " \
51
+ "but it is reachable via #{@availability.via.inspect}"
52
+ end
53
+
54
+ def failure_message_when_negated
55
+ "expected #{StateReport.standing(@record)} not to reach #{@target_state.inspect}, " \
56
+ "but it is reachable via #{@availability.via.inspect}"
57
+ end
58
+
59
+ def description
60
+ "allow a transition to #{@target_state.inspect}"
61
+ end
62
+
63
+ private
64
+
65
+ def missing_ways
66
+ expected = @expected_via + (@directly ? [:direct] : [])
67
+ expected - @availability.via
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statecraft
4
+ module RSpec
5
+ # expect(OrderFlow).to have_edge(:pending, :cancelled).via(:cancel)
6
+ #
7
+ # The class-level shape question of transitions_from: no guards are
8
+ # consulted, exactly like the method itself. via asserts that the
9
+ # named events ride the edge; a bare edge simply has none.
10
+ class HaveEdge
11
+ def initialize(from_state, to_state)
12
+ @from_state = from_state.to_sym
13
+ @to_state = to_state.to_sym
14
+ @expected_events = []
15
+ end
16
+
17
+ def via(*event_names)
18
+ @expected_events = event_names.map(&:to_sym)
19
+ self
20
+ end
21
+
22
+ def matches?(machine_class)
23
+ @machine_class = machine_class
24
+ @edge = machine_class.transitions_from(@from_state)
25
+ .find { |descriptor| descriptor[:to] == @to_state }
26
+ @edge && (@expected_events - @edge[:events]).empty?
27
+ end
28
+
29
+ def failure_message
30
+ unless @edge
31
+ return ["expected #{@machine_class.name} to declare an edge #{@from_state.inspect} -> #{@to_state.inspect}",
32
+ StateReport.declared_shape(@machine_class, @from_state)].join("\n ")
33
+ end
34
+
35
+ missing = (@expected_events - @edge[:events]).map(&:inspect).join(", ")
36
+ "expected the edge #{@from_state.inspect} -> #{@to_state.inspect} to carry #{missing}, " \
37
+ "but its events are #{@edge[:events].inspect}"
38
+ end
39
+
40
+ def failure_message_when_negated
41
+ "expected #{@machine_class.name} not to declare the edge #{@from_state.inspect} -> #{@to_state.inspect}, " \
42
+ "but it does (events: #{@edge[:events].inspect})"
43
+ end
44
+
45
+ def description
46
+ "have an edge #{@from_state.inspect} -> #{@to_state.inspect}"
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statecraft
4
+ module RSpec
5
+ # expect(OrderFlow).to have_initial_state(:pending)
6
+ class HaveInitialState
7
+ def initialize(state_name)
8
+ @state_name = state_name.to_sym
9
+ end
10
+
11
+ def matches?(machine_class)
12
+ @machine_class = machine_class
13
+ machine_class.initial_state == @state_name
14
+ end
15
+
16
+ def failure_message
17
+ "expected the initial state of #{@machine_class.name} to be #{@state_name.inspect}, " \
18
+ "but it is #{@machine_class.initial_state.inspect}"
19
+ end
20
+
21
+ def failure_message_when_negated
22
+ "expected the initial state of #{@machine_class.name} not to be #{@state_name.inspect}, but it is"
23
+ end
24
+
25
+ def description
26
+ "have the initial state #{@state_name.inspect}"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statecraft
4
+ module RSpec
5
+ # expect(record).to have_transitioned_to(:paid)
6
+ #
7
+ # Strictly log-based, like transitioned_to? itself: the question is
8
+ # about history, never about the current state. The failure message
9
+ # shows what the log actually holds.
10
+ class HaveTransitionedTo
11
+ def initialize(state_name)
12
+ @state_name = state_name.to_sym
13
+ end
14
+
15
+ def matches?(record)
16
+ @record = record
17
+ record.transitioned_to?(@state_name)
18
+ end
19
+
20
+ def failure_message
21
+ "expected the log of #{StateReport.standing(@record)} to hold a transition to #{@state_name.inspect}, " \
22
+ "but #{log_contents}"
23
+ end
24
+
25
+ def failure_message_when_negated
26
+ "expected the log of #{StateReport.standing(@record)} to hold no transition to #{@state_name.inspect}, " \
27
+ "but it does"
28
+ end
29
+
30
+ def description
31
+ "have transitioned to #{@state_name.inspect}"
32
+ end
33
+
34
+ private
35
+
36
+ def log_contents
37
+ to_states = @record.history.map(&:to_state)
38
+ return "the log is empty" if to_states.empty?
39
+
40
+ "the log holds transitions to: #{to_states.join(", ")}"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statecraft
4
+ module RSpec
5
+ # The example-facing surface: requiring statecraft/rspec includes this
6
+ # module into every example group, so specs call the factories bare.
7
+ # Record-level matchers consult guards (a prediction with the metadata
8
+ # you pass); class-level matchers answer the graph's shape only.
9
+ # rubocop:disable Naming/PredicatePrefix -- have_* is RSpec's matcher idiom, not a predicate
10
+ module Matchers
11
+ def allow_event(event_name)
12
+ AllowEvent.new(event_name)
13
+ end
14
+
15
+ def refuse_event(event_name)
16
+ RefuseEvent.new(event_name)
17
+ end
18
+
19
+ def allow_transition_to(target_state)
20
+ AllowTransitionTo.new(target_state)
21
+ end
22
+
23
+ def have_transitioned_to(state_name)
24
+ HaveTransitionedTo.new(state_name)
25
+ end
26
+
27
+ def have_edge(from_state, to_state)
28
+ HaveEdge.new(from_state, to_state)
29
+ end
30
+
31
+ def have_initial_state(state_name)
32
+ HaveInitialState.new(state_name)
33
+ end
34
+
35
+ def transition(record)
36
+ Transition.new(record)
37
+ end
38
+ end
39
+ # rubocop:enable Naming/PredicatePrefix
40
+ end
41
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statecraft
4
+ module RSpec
5
+ # expect(record).to refuse_event(:cancel).because_of(:customer_cancellable?)
6
+ #
7
+ # The named negation of allow_event: the refusal itself, and — through
8
+ # because_of — WHO refused. Guard names come from refusals_for, so
9
+ # because_of sees record-layer guards only; an input-reading guard:
10
+ # has no name there, and the failure message says so instead of
11
+ # pretending otherwise.
12
+ class RefuseEvent
13
+ def initialize(event_name)
14
+ @event_name = event_name.to_sym
15
+ @expected_guards = []
16
+ @metadata = {}
17
+ end
18
+
19
+ def because_of(*guard_names)
20
+ @expected_guards = guard_names.map(&:to_sym)
21
+ self
22
+ end
23
+
24
+ def with_metadata(metadata)
25
+ @metadata = metadata
26
+ self
27
+ end
28
+
29
+ def matches?(record)
30
+ @record = record
31
+ @allowed = record.can_fire?(@event_name, metadata: @metadata)
32
+ return false if @allowed
33
+
34
+ @refusing_guards = record.refusals_for(@event_name).map(&:guard)
35
+ (@expected_guards - @refusing_guards).empty?
36
+ end
37
+
38
+ def failure_message
39
+ if @allowed
40
+ return "expected #{StateReport.standing(@record)} to refuse event #{@event_name.inspect}, " \
41
+ "but the guards passed with metadata #{@metadata.inspect}"
42
+ end
43
+
44
+ missing = (@expected_guards - @refusing_guards).map(&:inspect).join(", ")
45
+ "expected the refusal of #{@event_name.inspect} to come from #{missing}\n #{actual_refusers}"
46
+ end
47
+
48
+ def failure_message_when_negated
49
+ "expected #{StateReport.standing(@record)} to allow event #{@event_name.inspect}, but it was refused\n " +
50
+ StateReport.refusal(@record, @event_name, @metadata)
51
+ end
52
+
53
+ def description
54
+ "refuse event #{@event_name.inspect}"
55
+ end
56
+
57
+ private
58
+
59
+ def actual_refusers
60
+ if @refusing_guards.empty?
61
+ "but no record-layer guard refused: refusals_for names record-layer guards only, " \
62
+ "and this refusal came from an input-reading guard: or an undeclared branch"
63
+ else
64
+ "but the refusing record-layer guards were: #{@refusing_guards.map(&:inspect).join(", ")}"
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statecraft
4
+ module RSpec
5
+ # The shared sentences of every failure message: where the record
6
+ # stands, what the graph declares from there, what is reachable right
7
+ # now, and which guards refused. Built strictly on the public
8
+ # introspection surface — the matchers add words, never new answers.
9
+ module StateReport
10
+ module_function
11
+
12
+ def current_state(record)
13
+ record[record.class.statecraft_mounting.column]&.to_sym
14
+ end
15
+
16
+ def standing(record)
17
+ "#{record.class.name} in state #{current_state(record).inspect}"
18
+ end
19
+
20
+ def machine(record)
21
+ record.class.statecraft_mounting.machine_class
22
+ end
23
+
24
+ def event_declared?(record, event_name)
25
+ machine(record).transitions_from(current_state(record))
26
+ .any? { |descriptor| descriptor[:events].include?(event_name) }
27
+ end
28
+
29
+ def declared_shape(machine_class, state)
30
+ descriptors = machine_class.transitions_from(state)
31
+ return "no edges are declared from #{state.inspect}" if descriptors.empty?
32
+
33
+ rendered = descriptors.map do |descriptor|
34
+ if descriptor[:events].empty?
35
+ "to #{descriptor[:to].inspect} (direct)"
36
+ else
37
+ "to #{descriptor[:to].inspect} via #{descriptor[:events].inspect}"
38
+ end
39
+ end
40
+ "declared from #{state.inspect}: #{rendered.join("; ")}"
41
+ end
42
+
43
+ def declared_shape_of(record)
44
+ declared_shape(machine(record), current_state(record))
45
+ end
46
+
47
+ def reachable(record, metadata)
48
+ transitions = record.available_transitions(metadata: metadata)
49
+ state = current_state(record).inspect
50
+ return "nothing is reachable from #{state} right now" if transitions.empty?
51
+
52
+ rendered = transitions.map { |availability| "to #{availability.to.inspect} via #{availability.via.inspect}" }
53
+ "reachable from #{state}: #{rendered.join("; ")}"
54
+ end
55
+
56
+ def refusal(record, event_name, metadata)
57
+ refusals = record.refusals_for(event_name)
58
+ if refusals.empty?
59
+ "no record-layer guard refused — an input-reading guard: said no to metadata #{metadata.inspect}"
60
+ else
61
+ named = refusals.map { |entry| "#{entry.guard.inspect} (#{entry.layer})" }
62
+ "refused by record-layer guards: #{named.join(", ")}"
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statecraft
4
+ module RSpec
5
+ # expect { order.fire!(:pay) }.to transition(order)
6
+ # .from(:pending).to(:paid).via_event(:pay).with_metadata("k" => "v")
7
+ #
8
+ # The transition through the eyes of a test: the state column moved to
9
+ # the target AND exactly one log row was appended with the matching
10
+ # from/to/event/metadata. A non-bang call that returned false leaves
11
+ # both untouched — the matcher fails and explains why, from the same
12
+ # introspection the pipeline consulted. Exceptions of the bang forms
13
+ # fly through, like with the change matcher: refusals are asserted
14
+ # with refuse_event or raise_error, not here.
15
+ class Transition
16
+ def initialize(record)
17
+ @record = record
18
+ @failures = []
19
+ end
20
+
21
+ def from(state_name)
22
+ @from_state = state_name.to_sym
23
+ self
24
+ end
25
+
26
+ def to(state_name)
27
+ @to_state = state_name.to_sym
28
+ self
29
+ end
30
+
31
+ def via_event(event_name)
32
+ @event_name = event_name.to_sym
33
+ self
34
+ end
35
+
36
+ def with_metadata(metadata)
37
+ @metadata = metadata
38
+ self
39
+ end
40
+
41
+ def supports_block_expectations?
42
+ true
43
+ end
44
+
45
+ def matches?(block)
46
+ raise ArgumentError, "transition(record).to(:state) — the .to target is required" unless @to_state
47
+
48
+ @before_state = StateReport.current_state(@record)
49
+ appended_before = @record.history.count
50
+ block.call
51
+ @after_state = StateReport.current_state(@record)
52
+ @appended = @record.history.offset(appended_before).to_a
53
+ collect_failures
54
+ @failures.empty?
55
+ end
56
+
57
+ def failure_message
58
+ expected_event = " via event #{@event_name.inspect}" if @event_name
59
+ header = "expected the block to transition #{@record.class.name} " \
60
+ "#{@before_state.inspect} -> #{@to_state.inspect}#{expected_event}"
61
+ ([header] + @failures).join("\n ")
62
+ end
63
+
64
+ def failure_message_when_negated
65
+ row = @appended.last
66
+ written_by = " by event #{row.event.inspect}" if row.event
67
+ "expected the block not to transition #{@record.class.name} to #{@to_state.inspect}, " \
68
+ "but it did: #{row.from_state.inspect} -> #{row.to_state.inspect}#{written_by}"
69
+ end
70
+
71
+ def description
72
+ "transition #{@record.class.name} to #{@to_state.inspect}"
73
+ end
74
+
75
+ private
76
+
77
+ def collect_failures
78
+ return collect_missing_transition if @appended.empty?
79
+
80
+ if @appended.size > 1
81
+ @failures << "expected exactly one appended log row, but the block appended #{@appended.size}"
82
+ end
83
+ if @after_state != @to_state
84
+ @failures << "the record ended in #{@after_state.inspect}, not #{@to_state.inspect}"
85
+ end
86
+ collect_row_mismatches(@appended.last)
87
+ end
88
+
89
+ def collect_row_mismatches(row)
90
+ if row.to_state != @to_state.to_s
91
+ @failures << "the log row went to #{row.to_state.inspect}, not #{@to_state.inspect}"
92
+ end
93
+ if @from_state && row.from_state != @from_state.to_s
94
+ @failures << "the transition started from #{row.from_state.inspect}, not #{@from_state.inspect}"
95
+ end
96
+ if @event_name && row.event != @event_name.to_s
97
+ @failures << "the transition was written by event #{row.event.inspect}, not #{@event_name.inspect}"
98
+ end
99
+ collect_metadata_mismatch(row)
100
+ end
101
+
102
+ def collect_missing_transition
103
+ @failures << "no transition happened: the record stayed in #{@after_state.inspect}"
104
+ @failures << StateReport.reachable(@record, @metadata || {})
105
+ return unless @event_name && StateReport.event_declared?(@record, @event_name)
106
+
107
+ @failures << StateReport.refusal(@record, @event_name, @metadata || {})
108
+ end
109
+
110
+ def collect_metadata_mismatch(row)
111
+ return unless @metadata
112
+
113
+ expected = @metadata.deep_stringify_keys
114
+ return if row.metadata == expected
115
+
116
+ @failures << "the log row carries metadata #{row.metadata.inspect}, not #{expected.inspect}"
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "statecraft"
4
+
5
+ # Deliberately not required by lib/statecraft.rb: the matchers exist only
6
+ # where RSpec does, and the gem's runtime must not know about test
7
+ # frameworks. This file is the one opt-in door.
8
+ unless defined?(RSpec)
9
+ raise Statecraft::Error,
10
+ "statecraft/rspec builds RSpec matchers, so RSpec must be loaded first: " \
11
+ "require statecraft/rspec from your spec helper, after rspec itself"
12
+ end
13
+
14
+ require_relative "rspec/state_report"
15
+ require_relative "rspec/allow_event"
16
+ require_relative "rspec/refuse_event"
17
+ require_relative "rspec/allow_transition_to"
18
+ require_relative "rspec/have_transitioned_to"
19
+ require_relative "rspec/have_edge"
20
+ require_relative "rspec/have_initial_state"
21
+ require_relative "rspec/transition"
22
+ require_relative "rspec/matchers"
23
+
24
+ RSpec.configure { |config| config.include Statecraft::RSpec::Matchers }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Statecraft
4
- VERSION = "0.4.1"
4
+ VERSION = "0.6.0"
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.4.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor Pugachev
@@ -63,7 +63,9 @@ files:
63
63
  - lib/generators/statecraft/from_statesman/USAGE
64
64
  - lib/generators/statecraft/from_statesman/from_statesman_generator.rb
65
65
  - lib/generators/statecraft/from_statesman/templates/application_machine.rb.tt
66
- - lib/generators/statecraft/from_statesman/templates/convert_transitions_migration.rb.tt
66
+ - lib/generators/statecraft/from_statesman/templates/convert_transitions_backfill.rb.tt
67
+ - lib/generators/statecraft/from_statesman/templates/convert_transitions_ddl.rb.tt
68
+ - lib/generators/statecraft/from_statesman/templates/convert_transitions_finalize.rb.tt
67
69
  - lib/generators/statecraft/from_statesman/templates/machine_from_statesman.rb.tt
68
70
  - lib/generators/statecraft/machine/USAGE
69
71
  - lib/generators/statecraft/machine/machine_generator.rb
@@ -85,6 +87,16 @@ files:
85
87
  - lib/statecraft/pipeline.rb
86
88
  - lib/statecraft/pipeline/edge_resolution.rb
87
89
  - lib/statecraft/pipeline/surface.rb
90
+ - lib/statecraft/rspec.rb
91
+ - lib/statecraft/rspec/allow_event.rb
92
+ - lib/statecraft/rspec/allow_transition_to.rb
93
+ - lib/statecraft/rspec/have_edge.rb
94
+ - lib/statecraft/rspec/have_initial_state.rb
95
+ - lib/statecraft/rspec/have_transitioned_to.rb
96
+ - lib/statecraft/rspec/matchers.rb
97
+ - lib/statecraft/rspec/refuse_event.rb
98
+ - lib/statecraft/rspec/state_report.rb
99
+ - lib/statecraft/rspec/transition.rb
88
100
  - lib/statecraft/version.rb
89
101
  - lib/statecraft/warnings.rb
90
102
  homepage: https://supostat.github.io/statecraft/
@@ -1,153 +0,0 @@
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