statecraft 0.4.0 → 0.5.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: c0c939e1b5d335a23df4ee4bdd3fc9f5ac5a1d42e53c1bec2d8b5b8c3bc8dde8
4
- data.tar.gz: 2dc1c3391139562722cd1b6cfed0e7827582b06254fec0b9b8e24680b2d16e37
3
+ metadata.gz: 55f4ecb6760d01b042ad158351ec8735c3962c70c3a674d3af9d6ba2ecf26fd9
4
+ data.tar.gz: ecc0c3ac410d67431226ccc8b361ac5d2708c276425153561b0f732ef3aefa53
5
5
  SHA512:
6
- metadata.gz: 40f3727eb9fc1acddcff7c95c9dce6783633175b0ee2bc7b78013ed5d5f45556b8cb9cff1cecbd54916449d38a2071796b28fbfc4f2dea40d7af4fb51019c743
7
- data.tar.gz: ad2778897f1c4609f6c01b3c3e8129fa329da6dcbc131641ccc153f21bfc94a261754c142b086941d3a9a57aca68eafa03f4039ca1a2ad1fe23f1e06706b8649
6
+ metadata.gz: caf25d1b5349c441aa79573e1994b2298ef64fcc4b4b9f6e6b5958827ca03a9bf8275366cf1b128a4cf39f690e6ba9bcad0327b040a51effcf42f5fff9853098
7
+ data.tar.gz: 7acc79611c73b980a2a932c53d87723068adb1da7ddc61b590477cf84540a904750e9fa7d550d126aa9626f9edcc34538f6aed4aacb611894907148d80e07b63
data/README.md CHANGED
@@ -351,38 +351,48 @@ bin/rails generate statecraft:from_statesman Order
351
351
  The generator reads the live statesman machine through reflection (pass the
352
352
  class as a second argument when it is not `OrderStateMachine`; point at the
353
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
354
+ three conversion migrations, a machine skeleton, and the `state_machine`
355
+ mounting line. Two things it honestly cannot write: statesman has no events,
356
+ so every edge arrives as a bare `transition` for you to name, and guard
357
+ bodies are anonymous blocks — each becomes a TODO comment carrying the
358
+ original `file:line`.
359
+
360
+ Three migrations, because the obvious single one is a production incident:
361
+ `add_column` takes an ACCESS EXCLUSIVE lock that PostgreSQL holds until the
362
+ **end of the transaction**, and a full-table backfill inside that same
363
+ transaction keeps both tables unreadable even for SELECTs for its whole
364
+ duration. The conversion splits along the lock boundaries instead, and the
365
+ runbook is five steps:
366
+
367
+ 1. **Run `_ddl`** any time: nullable columns only on PostgreSQL 11+ its
368
+ lock lasts milliseconds.
369
+ 2. **Run `_backfill`** any time, even mid-day: batches over parent-id
370
+ ranges outside any DDL transaction, touching only rows still NULL. The
371
+ model's state comes from the **last transition by `sort_key`**
372
+ deliberately not `most_recent`, which drifts often enough that statesman
373
+ ships a repair task for it — `state_changed_at` from that transition's
374
+ `created_at`, and `from_state` from a `LAG` window along the chain.
375
+ 3. **Switch the code**: port the skeleton, deploy statecraft in place of
376
+ statesman.
377
+ 4. **Catch up**: the backfill is idempotent (`WHERE ... IS NULL`), so run
378
+ its class once more to pick up rows statesman wrote between steps 2
379
+ and 3 — `bin/rails runner` with a `load` of the migration file and
380
+ `.new.up` does it without touching `schema_migrations`.
381
+ 5. **Run `_finalize`**: NOT NULL lands through the `NOT VALID`
382
+ `VALIDATE CONSTRAINT` pair (readers never blocked), the `state` and
383
+ `[foreign_key, id]` indexes build `CONCURRENTLY`, the cascade FK is
384
+ validated the same way, and the statesman columns are dropped last.
385
+ Honest limit: a text `metadata` column is rewritten under a lock here —
386
+ unavoidable within this recipe; for very large tables the shadow-column
387
+ dance is the escape.
388
+
389
+ The `_ddl` migration's header names two pre-flight checks on live data —
390
+ that the id order agrees with the `sort_key` order (statecraft reads history
391
+ by id), and that a text `metadata` column holds valid JSON in every row
392
+ (rows written by raw SQL may not survive the cast). Run both before
393
+ starting.
394
+
395
+ After finalize, finish by hand (the generator prints this list): drop
386
396
  `Statesman::Adapters::ActiveRecordTransition` and the
387
397
  `after_destroy :update_most_recent` callback from the transition model — they
388
398
  read dropped columns — plus `ActiveRecordQueries` from the model and the
@@ -391,7 +401,7 @@ events in the skeleton. One guarantee moves rather than disappears: the
391
401
  race safety statesman derived from its unique `(parent, sort_key)` index is
392
402
  statecraft's CAS on the state column.
393
403
 
394
- Outside Rails, the same steps work by hand — pair the migration order above
404
+ Outside Rails, the same five steps work by hand — pair the runbook above
395
405
  with the reference schema in [Outside Rails](#outside-rails).
396
406
 
397
407
  ## 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Statecraft
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.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.0
4
+ version: 0.5.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
@@ -1,142 +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
-
36
- # The current state is the to_state of the LAST transition by sort_key —
37
- # deliberately not most_recent, which statesman itself ships a repair
38
- # rake task for; rows without transitions are in the initial state.
39
- execute <<~SQL
40
- UPDATE <%= table_name %> SET 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
- SQL
47
-
48
- change_column_default :<%= table_name %>, :state, "<%= initial_state %>"
49
- change_column_null :<%= table_name %>, :state, false
50
-
51
- return unless connection.supports_check_constraints?
52
-
53
- # add new states here when the machine grows
54
- if postgresql?
55
- add_check_constraint :<%= table_name %>, state_check_expression,
56
- name: "<%= table_name %>_state_check", validate: false
57
- validate_check_constraint :<%= table_name %>, name: "<%= table_name %>_state_check"
58
- else
59
- add_check_constraint :<%= table_name %>, state_check_expression,
60
- name: "<%= table_name %>_state_check"
61
- end
62
- end
63
-
64
- def convert_transitions_table
65
- add_column :<%= log_table_name %>, :from_state, :string
66
- add_column :<%= log_table_name %>, :event, :string
67
-
68
- # from_state is the previous to_state along the sort_key chain; the
69
- # chain's first hop starts from the initial state. Runs while sort_key
70
- # is still alive — order matters.
71
- execute <<~SQL
72
- UPDATE <%= log_table_name %> SET from_state = COALESCE(prev.prev_state, '<%= initial_state %>')
73
- FROM (SELECT id, LAG(to_state) OVER (
74
- PARTITION BY <%= foreign_key_column %> ORDER BY sort_key
75
- ) AS prev_state
76
- FROM <%= log_table_name %>) prev
77
- WHERE prev.id = <%= log_table_name %>.id
78
- SQL
79
-
80
- change_column_null :<%= log_table_name %>, :from_state, false
81
- # event stays NULL for the imported history: statesman had no events, and
82
- # statecraft reads NULL as a direct transition.
83
- end
84
-
85
- # Statesman's default schema serializes metadata into a text column; the
86
- # statecraft pipeline wants native json(b). Converting the type and
87
- # removing `serialize :metadata` from the transition model are ONE move —
88
- # neither library works in the half-converted state.
89
- def convert_metadata_column
90
- metadata_type = connection.columns(:<%= log_table_name %>).find { |column| column.name == "metadata" }&.sql_type.to_s
91
- return unless metadata_type.match?(/text|char/i)
92
-
93
- if postgresql?
94
- change_column_default :<%= log_table_name %>, :metadata, nil
95
- execute "ALTER TABLE <%= log_table_name %> ALTER COLUMN metadata TYPE jsonb USING metadata::jsonb"
96
- execute "UPDATE <%= log_table_name %> SET metadata = '{}'::jsonb WHERE metadata IS NULL"
97
- change_column_default :<%= log_table_name %>, :metadata, {}
98
- change_column_null :<%= log_table_name %>, :metadata, false
99
- else
100
- # SQLite types dynamically and serialize already stored JSON text —
101
- # the change is declarative (a table rebuild under the hood).
102
- change_column :<%= log_table_name %>, :metadata, :json, null: false, default: {}
103
- end
104
- end
105
-
106
- def rebuild_indexes_and_foreign_key
107
- # If your foreign key column is NOT named <%= foreign_key_column %>
108
- # (check the has_many on your model), rename it first — statecraft
109
- # derives the name from the model class and offers no override:
110
- # rename_column :<%= log_table_name %>, :your_column, :<%= foreign_key_column %>
111
-
112
- remove_index :<%= log_table_name %>, column: %i[<%= foreign_key_column %> sort_key], if_exists: true
113
- remove_index :<%= log_table_name %>, column: %i[<%= foreign_key_column %> most_recent], if_exists: true
114
- add_index :<%= log_table_name %>, %i[<%= foreign_key_column %> id]
115
-
116
- # An audit without its subject is not an audit: the statecraft contract
117
- # is ON DELETE CASCADE (statesman's default foreign key, when present,
118
- # carries no action).
119
- if foreign_key_exists?(:<%= log_table_name %>, :<%= table_name %>)
120
- remove_foreign_key :<%= log_table_name %>, :<%= table_name %>
121
- end
122
- add_foreign_key :<%= log_table_name %>, :<%= table_name %>,
123
- column: :<%= foreign_key_column %>, on_delete: :cascade
124
- end
125
-
126
- # sort_key and most_recent are NOT NULL without defaults — the first
127
- # statecraft insert would die on them; updated_at is merely absent from
128
- # the statecraft schema.
129
- def drop_statesman_columns
130
- remove_column :<%= log_table_name %>, :sort_key
131
- remove_column :<%= log_table_name %>, :most_recent if column_exists?(:<%= log_table_name %>, :most_recent)
132
- remove_column :<%= log_table_name %>, :updated_at if column_exists?(:<%= log_table_name %>, :updated_at)
133
- end
134
-
135
- def state_check_expression
136
- "state IN ('<%= initial_state %>'<% (states - [initial_state]).each do |state_name| %>, '<%= state_name %>'<% end %>)"
137
- end
138
-
139
- def postgresql?
140
- connection.adapter_name.match?(/postg/i)
141
- end
142
- end