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 +4 -4
- data/README.md +88 -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/rspec/allow_event.rb +48 -0
- data/lib/statecraft/rspec/allow_transition_to.rb +71 -0
- data/lib/statecraft/rspec/have_edge.rb +50 -0
- data/lib/statecraft/rspec/have_initial_state.rb +30 -0
- data/lib/statecraft/rspec/have_transitioned_to.rb +44 -0
- data/lib/statecraft/rspec/matchers.rb +41 -0
- data/lib/statecraft/rspec/refuse_event.rb +69 -0
- data/lib/statecraft/rspec/state_report.rb +67 -0
- data/lib/statecraft/rspec/transition.rb +120 -0
- data/lib/statecraft/rspec.rb +24 -0
- data/lib/statecraft/version.rb +1 -1
- metadata +14 -2
- data/lib/generators/statecraft/from_statesman/templates/convert_transitions_migration.rb.tt +0 -153
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4689cea0dee40236473975f52ea5ccd2ecd303783edebd7626ab1518a1f8c8bd
|
|
4
|
+
data.tar.gz: b3713ea5d55ea4881b55b10ceccb38847f2f85aa971d29326fd26e97412d0620
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
@@ -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 }
|
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.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/
|
|
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
|