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 +4 -4
- data/README.md +43 -33
- data/lib/generators/statecraft/from_statesman/from_statesman_generator.rb +15 -4
- data/lib/generators/statecraft/from_statesman/templates/convert_transitions_backfill.rb.tt +69 -0
- data/lib/generators/statecraft/from_statesman/templates/convert_transitions_ddl.rb.tt +28 -0
- data/lib/generators/statecraft/from_statesman/templates/convert_transitions_finalize.rb.tt +132 -0
- data/lib/statecraft/version.rb +1 -1
- metadata +4 -2
- data/lib/generators/statecraft/from_statesman/templates/convert_transitions_migration.rb.tt +0 -142
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 55f4ecb6760d01b042ad158351ec8735c3962c70c3a674d3af9d6ba2ecf26fd9
|
|
4
|
+
data.tar.gz: ecc0c3ac410d67431226ccc8b361ac5d2708c276425153561b0f732ef3aefa53
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 "\
|
|
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
|
data/lib/statecraft/version.rb
CHANGED
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
|
+
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/
|
|
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
|