statecraft 0.8.0 → 0.10.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 +131 -6
- data/lib/generators/statecraft/from_aasm/USAGE +34 -0
- data/lib/generators/statecraft/from_aasm/from_aasm_generator.rb +250 -0
- data/lib/generators/statecraft/from_aasm/templates/application_machine.rb.tt +8 -0
- data/lib/generators/statecraft/from_aasm/templates/create_log_table.rb.tt +40 -0
- data/lib/generators/statecraft/from_aasm/templates/machine_from_aasm.rb.tt +35 -0
- data/lib/statecraft/errors.rb +17 -0
- data/lib/statecraft/introspection.rb +37 -12
- data/lib/statecraft/machine.rb +80 -13
- data/lib/statecraft/metadata.rb +5 -0
- data/lib/statecraft/mounting.rb +3 -1
- data/lib/statecraft/version.rb +1 -1
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8e51b28a602d49b1cf5f71183e5c8b2080bd3e9f4e3a857147a65a7f5529373c
|
|
4
|
+
data.tar.gz: 2068032e1f963037d4521fbca8a02e53d5594bb5ce9902b9c809d1448ba7d9bd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4588eac402af35b053ce88660b39efb3a26694fdb9a5f3935844dedfff102f8d687f52af1c3b5da422cd7be99052bc55ef83e3f318d918746ef761bfba49a187
|
|
7
|
+
data.tar.gz: a995c7b056c6a2dd0beae23de1a06ced70f6ba3fbccce223375ea974f0fad506defd0bbf96426f10a3d2c95106be02c37c4075d569d1d8d342199bab8f8a6306
|
data/README.md
CHANGED
|
@@ -145,12 +145,52 @@ A good `record_guard:` is a one-line delegation to a domain predicate on the
|
|
|
145
145
|
model (`def customer_cancellable?(record) = record.customer_cancellable?`):
|
|
146
146
|
the machine keeps the registry "event → predicate", the model keeps the fact.
|
|
147
147
|
|
|
148
|
+
The third nature is `input_guard:` — a guard declaring that its answer
|
|
149
|
+
*without* the input would be a false "no". Its handler must take exactly
|
|
150
|
+
`(record, metadata)` (the compiler refuses any other arity), execution runs
|
|
151
|
+
it after the record and plain layers, and the question surface honors the
|
|
152
|
+
declaration: a `can_fire?` / `may_*?` / `available_*` asked with metadata
|
|
153
|
+
omitted entirely raises `Statecraft::MetadataRequired` naming the guard,
|
|
154
|
+
instead of feeding it an empty hash and predicting a refusal that the real
|
|
155
|
+
call would not hit. An explicit `metadata: {}` states "my input is empty"
|
|
156
|
+
and is always a legal question.
|
|
157
|
+
|
|
148
158
|
Calling `transition_to!` directly over an edge that carries event guards is
|
|
149
159
|
refused — the guards would be silently skipped. The escape hatch is explicit:
|
|
150
160
|
`transition_to!(:paid, bypass_events: true)` skips event guards (edge guards
|
|
151
161
|
still run) and records `event: nil` in the log, so audited bypasses stay
|
|
152
162
|
visible.
|
|
153
163
|
|
|
164
|
+
## Which rules belong in a guard
|
|
165
|
+
|
|
166
|
+
Not every rule about a transition is a guard, and the three slots above are
|
|
167
|
+
not an invitation to move form validation into the machine. One question
|
|
168
|
+
draws the line: **would the rule still have to hold if the transition came
|
|
169
|
+
from a job, a console or an ETL run instead of a form?**
|
|
170
|
+
|
|
171
|
+
A rule about the record — this order is on credit, so it is not cancellable
|
|
172
|
+
— always answers yes: it is a fact of the domain, it must hold for every
|
|
173
|
+
caller, and it is checked inside the transaction where nothing can change
|
|
174
|
+
under it. That is a `record_guard:`, and it is the case guards were built
|
|
175
|
+
for.
|
|
176
|
+
|
|
177
|
+
A rule that reads the input earns its place in two situations. The first is
|
|
178
|
+
a rule that compares the input *against the record* — a refund amount that
|
|
179
|
+
must not exceed the order total. One side of that comparison can change
|
|
180
|
+
between a caller's check and the write, so the check belongs inside the
|
|
181
|
+
transaction. The second is a rule protecting the log: metadata is write-once
|
|
182
|
+
and the log is append-only, so a cancellation recorded without its reason
|
|
183
|
+
stays useless forever. The last gate before something becomes permanent is
|
|
184
|
+
worth having in the pipeline, whoever opened it.
|
|
185
|
+
|
|
186
|
+
Everything else is the caller's job. A rule that reads only the input,
|
|
187
|
+
never touches the record, and does not decide whether the log row makes
|
|
188
|
+
sense — a minimum length, a format, a spelling — gains nothing from the
|
|
189
|
+
machine but centralization, and it pays for that with a poorer contract: a
|
|
190
|
+
guard answers with one boolean and a symbol, while a form object answers
|
|
191
|
+
with several errors, attributed to fields, in the user's language. Validate
|
|
192
|
+
the form in the form; hand the machine input it has already accepted.
|
|
193
|
+
|
|
154
194
|
## Callbacks and chains
|
|
155
195
|
|
|
156
196
|
`before_transition`, `after_transition` and `after_commit` accept `from:`,
|
|
@@ -326,7 +366,36 @@ or a missing branch. It carries names, never words: human-readable reasons
|
|
|
326
366
|
belong to the application's presentation layer, not to the machine.
|
|
327
367
|
|
|
328
368
|
A guard that reads metadata makes `may_*?` depend on the metadata you pass —
|
|
329
|
-
pass the same metadata to `may_*?` that you will collect for `fire!`.
|
|
369
|
+
pass the same metadata to `may_*?` that you will collect for `fire!`. Mark
|
|
370
|
+
such a guard `input_guard:` and the machine holds you to it: a question
|
|
371
|
+
asked without any `metadata:` raises `Statecraft::MetadataRequired` with
|
|
372
|
+
the guard's name instead of answering a false "no" (an explicit
|
|
373
|
+
`metadata: {}` stays legal — it states the input is genuinely empty). A
|
|
374
|
+
plain `guard:` keeps the old behavior: a bare question consults it with an
|
|
375
|
+
empty hash.
|
|
376
|
+
|
|
377
|
+
## Strict mode
|
|
378
|
+
|
|
379
|
+
By default an unreachable state compiles silently — the column is written
|
|
380
|
+
by more than the gem (legacy rows, the console, external systems), so a
|
|
381
|
+
state without inbound edges is not necessarily a mistake. A machine that
|
|
382
|
+
claims its graph is closed opts in:
|
|
383
|
+
|
|
384
|
+
<!-- illustrative -->
|
|
385
|
+
```ruby
|
|
386
|
+
class OrderFlow < ApplicationMachine
|
|
387
|
+
strict!
|
|
388
|
+
|
|
389
|
+
state :pending, initial: true
|
|
390
|
+
# every state below must now be reachable from :pending through edges
|
|
391
|
+
end
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
With `strict!` compilation additionally requires every declared state to be
|
|
395
|
+
reachable from the initial one, walking edges only — guards are not
|
|
396
|
+
consulted, exactly like `transitions_from`. Dead ends stay legal in strict
|
|
397
|
+
mode too: terminal states are the norm, and an accidental one is surfaced
|
|
398
|
+
by the first test as `InvalidTransition` with the list of allowed targets.
|
|
330
399
|
|
|
331
400
|
## RSpec matchers
|
|
332
401
|
|
|
@@ -405,9 +474,18 @@ and parse them in the guard.
|
|
|
405
474
|
|
|
406
475
|
Facts of the transition moment (a price snapshot, a rules version) are
|
|
407
476
|
collected by the caller: `order.pay!(metadata: { price: order.total })`.
|
|
408
|
-
There is no metadata schema mechanism
|
|
409
|
-
|
|
410
|
-
|
|
477
|
+
There is no metadata schema mechanism, and the shape evolves by convention:
|
|
478
|
+
carry a `v:` key when you need versioned readers.
|
|
479
|
+
|
|
480
|
+
A guard can require a key, but that is a narrow licence rather than the
|
|
481
|
+
schema mechanism in disguise: the reason to enforce a field here is that
|
|
482
|
+
the row is about to become permanent — metadata is write-once and the log
|
|
483
|
+
is append-only, so a field missing at insert time is missing for good. Note
|
|
484
|
+
what such a guard does *not* buy: metadata is frozen at the pipeline
|
|
485
|
+
entrance, before the transaction opens, so checking it inside a guard is no
|
|
486
|
+
more atomic than checking it in the caller. Shape and format belong to
|
|
487
|
+
whoever assembled the hash — see
|
|
488
|
+
[which rules belong in a guard](#which-rules-belong-in-a-guard).
|
|
411
489
|
|
|
412
490
|
## Initial state is not a transition
|
|
413
491
|
|
|
@@ -536,6 +614,52 @@ statesman never had: see [Stale transitions](#stale-transitions-versioning-again
|
|
|
536
614
|
— the version column is a constant default, so adding it costs a
|
|
537
615
|
metadata-only migration on PostgreSQL 11+.
|
|
538
616
|
|
|
617
|
+
## Migrating from aasm
|
|
618
|
+
|
|
619
|
+
aasm already keeps the current state in a column, so this move is the
|
|
620
|
+
opposite shape of the statesman one: nothing is backfilled, and the
|
|
621
|
+
conversion adds the history aasm never had.
|
|
622
|
+
|
|
623
|
+
```sh
|
|
624
|
+
bin/rails generate statecraft:from_aasm Order
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
The generator reads the live aasm machine off the model (pass a second
|
|
628
|
+
argument to name one machine of a model that declares several) and writes
|
|
629
|
+
the machine skeleton, the log-table migration and the mounting line. More
|
|
630
|
+
survives the trip than from statesman: aasm has real event names, and its
|
|
631
|
+
state column arrives as the mounting's `column:`.
|
|
632
|
+
|
|
633
|
+
Guards come over by nature. An aasm guard judges the record and never sees
|
|
634
|
+
what the caller submitted, which is exactly statecraft's `record_guard:` —
|
|
635
|
+
so a symbol `guard:`/`if:` becomes a `record_guard:` plus a generated
|
|
636
|
+
one-line delegate (`def payable?(record) = record.payable?`, because a
|
|
637
|
+
guard symbol resolves on the machine here, not on the record), and
|
|
638
|
+
`unless: :locked?` becomes a negating delegate. A lambda guard carries its
|
|
639
|
+
own closure and cannot be moved: it arrives as a TODO with its `file:line`.
|
|
640
|
+
|
|
641
|
+
One graph shape is refused rather than guessed. aasm lets one event branch
|
|
642
|
+
from a single state and picks the first transition whose guard passes;
|
|
643
|
+
statecraft makes an event a partial function, so `from` is unique within an
|
|
644
|
+
event. The generator stops, names the branching events, and asks you to
|
|
645
|
+
split them in aasm first — the `pay` / `fail_payment` pattern. Naming the
|
|
646
|
+
halves is a domain decision, and a bad name would outlive the migration.
|
|
647
|
+
|
|
648
|
+
The migration creates the log table and adds an index on the existing state
|
|
649
|
+
column. It deliberately does **not** tighten that column: `NOT NULL`, a
|
|
650
|
+
default and a `CHECK` on a live table mean long locks and an explosion on
|
|
651
|
+
any legacy row outside the state list. The recipe for doing it later, in
|
|
652
|
+
the `NOT VALID` → `VALIDATE CONSTRAINT` style, sits in the migration's own
|
|
653
|
+
header.
|
|
654
|
+
|
|
655
|
+
Then finish by hand (the generator prints the list): drop `include AASM`
|
|
656
|
+
and the `aasm do ... end` block. Event calls keep their names under
|
|
657
|
+
`helpers: true`, but their semantics sharpen — a lost race now raises
|
|
658
|
+
`TransitionConflict` where aasm quietly answered `false` or, without an
|
|
659
|
+
explicit lock, silently overwrote the column. aasm's state predicates
|
|
660
|
+
(`order.paid?`) are not generated: use `in_state?(:paid)` or the `paid`
|
|
661
|
+
scope.
|
|
662
|
+
|
|
539
663
|
## PII and erasure
|
|
540
664
|
|
|
541
665
|
Metadata is the only place personal data can live — `from_state`, `to_state`
|
|
@@ -635,9 +759,10 @@ class OrderFlow
|
|
|
635
759
|
# event and the bypass path all share pending -> cancelled — the log
|
|
636
760
|
# records HOW, not only WHAT. The cancel guards split by nature: the
|
|
637
761
|
# record layer judges the order (and the offering may ask it), the input
|
|
638
|
-
# layer judges what the operator typed
|
|
762
|
+
# layer judges what the operator typed — and is marked input_guard:, so a
|
|
763
|
+
# question asked without metadata raises instead of predicting a false no.
|
|
639
764
|
event :cancel, from: :pending, to: :cancelled,
|
|
640
|
-
record_guard: :customer_cancellable?,
|
|
765
|
+
record_guard: :customer_cancellable?, input_guard: :reason_present?
|
|
641
766
|
event :admin_override, from: :pending, to: :cancelled
|
|
642
767
|
|
|
643
768
|
private
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Migrates a model off aasm. aasm already keeps the current state in a
|
|
3
|
+
column, so nothing is backfilled: the conversion adds the append-only
|
|
4
|
+
history aasm never had, indexes the existing column and generates a
|
|
5
|
+
machine skeleton from the live aasm reflection.
|
|
6
|
+
|
|
7
|
+
Copied over: the states, the initial state, the real event names (unlike
|
|
8
|
+
statesman, aasm has them) and the state column, which becomes the
|
|
9
|
+
mounting's `column:`. Symbol guards (guard:/if:/unless:) come over as
|
|
10
|
+
record_guard: with a generated one-line delegate — aasm calls a guard on
|
|
11
|
+
the record, and statecraft resolves a guard symbol on the machine.
|
|
12
|
+
Lambda guards carry their own closure and cannot be moved: each becomes
|
|
13
|
+
a TODO comment with its source location.
|
|
14
|
+
|
|
15
|
+
The second argument names an aasm machine when the model declares
|
|
16
|
+
several (`aasm(:shipment)`); without it the unnamed machine is read.
|
|
17
|
+
An event that branches from one state (aasm picks the first transition
|
|
18
|
+
whose guard passes) is refused: statecraft makes an event a partial
|
|
19
|
+
function, so split it into two named events in aasm first.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
bin/rails generate statecraft:from_aasm Order
|
|
23
|
+
|
|
24
|
+
Creates (when missing) and edits:
|
|
25
|
+
app/state_machines/application_machine.rb
|
|
26
|
+
app/state_machines/order_flow.rb (skeleton: states, events, guards)
|
|
27
|
+
app/models/order.rb (state_machine mounting line)
|
|
28
|
+
db/migrate/XXX_create_order_transitions.rb
|
|
29
|
+
|
|
30
|
+
The migration creates the log table and indexes the state column; the
|
|
31
|
+
column is otherwise left alone, with the NOT NULL / default / CHECK
|
|
32
|
+
recipe in its header. After running it, follow the printed cleanup list:
|
|
33
|
+
drop `include AASM` and the aasm block, and replace aasm's state
|
|
34
|
+
predicates (`order.paid?`) with `in_state?(:paid)` or the scope.
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Statecraft
|
|
7
|
+
module Generators
|
|
8
|
+
# `rails generate statecraft:from_aasm Order [machine_name]` — the door
|
|
9
|
+
# off aasm. Reads the live aasm machine through reflection (states, the
|
|
10
|
+
# initial state, real event names, the state column and the symbol
|
|
11
|
+
# guards), generates the machine skeleton, the log table migration and
|
|
12
|
+
# the mounting line. aasm keeps state in a column already, so nothing is
|
|
13
|
+
# backfilled: the conversion adds the history aasm never had.
|
|
14
|
+
class FromAasmGenerator < Rails::Generators::NamedBase
|
|
15
|
+
include ActiveRecord::Generators::Migration
|
|
16
|
+
|
|
17
|
+
source_root File.expand_path("templates", __dir__)
|
|
18
|
+
|
|
19
|
+
argument :aasm_machine_name, type: :string, required: false,
|
|
20
|
+
desc: "The named aasm machine (default: the unnamed one)"
|
|
21
|
+
|
|
22
|
+
def load_aasm_machine
|
|
23
|
+
@aasm_machine = resolve_aasm_machine
|
|
24
|
+
assert_graph_present
|
|
25
|
+
assert_no_branching_events
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def create_application_machine
|
|
29
|
+
application_machine_path = "app/state_machines/application_machine.rb"
|
|
30
|
+
return if File.exist?(File.join(destination_root, application_machine_path))
|
|
31
|
+
|
|
32
|
+
template "application_machine.rb.tt", application_machine_path
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def create_machine_skeleton
|
|
36
|
+
template "machine_from_aasm.rb.tt", "app/state_machines/#{file_path}_flow.rb"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def mount_model
|
|
40
|
+
unless File.exist?(File.join(destination_root, model_file))
|
|
41
|
+
say_status :skip, "#{model_file} not found — mount the machine yourself: #{mounting_line.strip}", :yellow
|
|
42
|
+
return
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
inject_into_class model_file, class_name, mounting_line
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def create_log_migration
|
|
49
|
+
migration_template "create_log_table.rb.tt",
|
|
50
|
+
"#{migration_directory}/create_#{migration_slug}_transitions.rb"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def print_cleanup_instructions
|
|
54
|
+
say "\nOne migration: it creates the log table aasm never had and indexes " \
|
|
55
|
+
"the existing #{state_column} column.", :green
|
|
56
|
+
say "The column itself is left alone — NOT NULL, a default and a CHECK on a live"
|
|
57
|
+
say "table are locks and legacy-row explosions; the migration header carries the recipe."
|
|
58
|
+
say "\nAfter it runs, finish the move by hand:", :green
|
|
59
|
+
say " * #{class_name}: drop `include AASM` and the whole `aasm do ... end` block."
|
|
60
|
+
say " * Event calls keep their names with helpers: true (`order.pay!`), but a losing"
|
|
61
|
+
say " race now raises TransitionConflict instead of quietly answering false."
|
|
62
|
+
say " * aasm state predicates (`order.paid?`) are not generated: use `in_state?(:paid)`"
|
|
63
|
+
say " or the `#{class_name}.paid` scope."
|
|
64
|
+
say " * Guards that were lambdas are TODO comments in the skeleton — port them by hand."
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def resolve_aasm_machine
|
|
70
|
+
unless model_class.respond_to?(:aasm)
|
|
71
|
+
raise Thor::Error, "#{class_name} does not respond to .aasm — " \
|
|
72
|
+
"point the generator at the model that includes AASM"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
machine = aasm_machine_name ? model_class.aasm(aasm_machine_name.to_sym) : model_class.aasm
|
|
76
|
+
unless machine.respond_to?(:states) && machine.respond_to?(:events)
|
|
77
|
+
raise Thor::Error, "#{class_name}.aasm does not look like an aasm machine (no .states/.events)"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
machine
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def model_class
|
|
84
|
+
@model_class ||= class_name.safe_constantize ||
|
|
85
|
+
raise(Thor::Error, "model class #{class_name} not found — " \
|
|
86
|
+
"the generator reads the live aasm machine off it")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A model with named machines answers the unnamed `.aasm` with the
|
|
90
|
+
# :default machine, which holds no states at all — a silently empty
|
|
91
|
+
# graph. Name the machines instead of generating a skeleton of nothing.
|
|
92
|
+
def assert_graph_present
|
|
93
|
+
return unless states.empty?
|
|
94
|
+
|
|
95
|
+
raise Thor::Error, "#{class_name}'s #{machine_label} declares no states#{named_machines_hint}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def named_machines_hint
|
|
99
|
+
names = declared_machine_names - ["default"]
|
|
100
|
+
return "" if names.empty?
|
|
101
|
+
|
|
102
|
+
" — it declares named machines: #{names.join(", ")}; " \
|
|
103
|
+
"pass one: rails g statecraft:from_aasm #{name} #{names.first}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def declared_machine_names
|
|
107
|
+
store = defined?(AASM::StateMachineStore) && AASM::StateMachineStore.fetch(model_class)
|
|
108
|
+
store.respond_to?(:machine_names) ? store.machine_names.map(&:to_s) : []
|
|
109
|
+
rescue StandardError
|
|
110
|
+
[]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# aasm picks the first transition of an event whose guard passes, so one
|
|
114
|
+
# event may branch from a single state; statecraft compiles an event as a
|
|
115
|
+
# partial function (from is unique within an event) and refuses such a
|
|
116
|
+
# graph. The split is a domain decision, so the door stops here.
|
|
117
|
+
def assert_no_branching_events
|
|
118
|
+
branching = event_descriptors.filter_map do |event|
|
|
119
|
+
froms = event[:transitions].map { |transition| transition[:from] }
|
|
120
|
+
duplicated = froms.tally.select { |_from, count| count > 1 }.keys
|
|
121
|
+
"#{event[:name]} (from #{duplicated.join(", ")})" unless duplicated.empty?
|
|
122
|
+
end
|
|
123
|
+
return if branching.empty?
|
|
124
|
+
|
|
125
|
+
raise Thor::Error,
|
|
126
|
+
"these aasm events branch from one state: #{branching.join("; ")}. " \
|
|
127
|
+
"statecraft makes an event a partial function — within one event, from is unique. " \
|
|
128
|
+
"Split each into two named events (the pay / fail_payment pattern) in aasm first, " \
|
|
129
|
+
"then rerun this generator."
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def event_descriptors
|
|
133
|
+
@event_descriptors ||= @aasm_machine.events.map do |event|
|
|
134
|
+
{
|
|
135
|
+
name: event.name,
|
|
136
|
+
transitions: event.transitions.map { |transition| describe_transition(event, transition) }
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def describe_transition(event, transition)
|
|
142
|
+
options = transition.respond_to?(:opts) ? transition.opts : {}
|
|
143
|
+
{ from: transition.from, to: transition.to, guard: guard_descriptor(event, transition, options) }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# aasm spells the record-level guard three ways and calls it on the
|
|
147
|
+
# record; statecraft's record_guard: is the same nature (arity 1) but
|
|
148
|
+
# resolves on the machine, so every symbol becomes a one-line delegate.
|
|
149
|
+
# A lambda carries its own closure and cannot be moved — it is reported.
|
|
150
|
+
def guard_descriptor(event, transition, options)
|
|
151
|
+
positive = options[:guard] || options[:if]
|
|
152
|
+
negative = options[:unless]
|
|
153
|
+
return symbol_guard(positive, negated: false) if positive.is_a?(Symbol)
|
|
154
|
+
return symbol_guard(negative, negated: true) if negative.is_a?(Symbol)
|
|
155
|
+
|
|
156
|
+
lambda_note(event, transition, positive || negative)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def symbol_guard(name, negated:)
|
|
160
|
+
{ kind: :delegate, source: name, method_name: negated ? "not_#{name.to_s.delete_suffix("?")}?" : name.to_s,
|
|
161
|
+
negated: negated }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def lambda_note(event, transition, callable)
|
|
165
|
+
return nil if callable.nil?
|
|
166
|
+
|
|
167
|
+
location = callable.respond_to?(:source_location) ? callable.source_location&.join(":") : nil
|
|
168
|
+
origin = location ? " — defined at #{location}" : ""
|
|
169
|
+
{ kind: :todo, note: "#{event.name} (#{transition.from} -> #{transition.to})#{origin}" }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def states
|
|
173
|
+
@states ||= @aasm_machine.states.map { |state| state.name.to_s }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def initial_state
|
|
177
|
+
@aasm_machine.initial_state.to_s
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def events
|
|
181
|
+
event_descriptors.flat_map do |event|
|
|
182
|
+
event[:transitions].map do |transition|
|
|
183
|
+
{ name: event[:name], from: transition[:from], to: transition[:to],
|
|
184
|
+
guard: delegate_guard(transition) }
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def guard_delegates
|
|
190
|
+
events.filter_map { |event| event[:guard] }.uniq { |guard| guard[:method_name] }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def guard_todos
|
|
194
|
+
all_guards.filter_map { |guard| guard[:note] if guard[:kind] == :todo }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def delegate_guard(transition)
|
|
198
|
+
guard = transition[:guard]
|
|
199
|
+
guard if guard && guard[:kind] == :delegate
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def all_guards
|
|
203
|
+
event_descriptors.flat_map { |event| event[:transitions].filter_map { |t| t[:guard] } }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def state_column
|
|
207
|
+
@aasm_machine.respond_to?(:attribute_name) ? @aasm_machine.attribute_name.to_s : "state"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def machine_label
|
|
211
|
+
aasm_machine_name ? "aasm machine #{aasm_machine_name}" : "unnamed aasm machine"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def mounting_line
|
|
215
|
+
column = state_column == "state" ? "" : "column: :#{state_column}, "
|
|
216
|
+
" state_machine #{class_name}Flow, #{column}changed_at: true, helpers: true, scopes: true\n"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def model_file
|
|
220
|
+
"app/models/#{file_path}.rb"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def table_name
|
|
224
|
+
@table_name ||= model_class.respond_to?(:table_name) ? model_class.table_name : super
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def log_table_name
|
|
228
|
+
"#{table_name.singularize}_transitions"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def foreign_key_column
|
|
232
|
+
"#{file_name}_id"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def migration_directory
|
|
236
|
+
db_config = model_class.respond_to?(:connection_db_config) && model_class.connection_db_config
|
|
237
|
+
configured = db_config && Array(db_config.migrations_paths).first
|
|
238
|
+
configured || "db/migrate"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def migration_slug
|
|
242
|
+
file_path.tr("/", "_")
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def migration_class_name
|
|
246
|
+
"Create#{migration_slug.camelize}Transitions"
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Shared home for private guard helpers used by several machines. Keep it
|
|
4
|
+
# free of DSL declarations (states, transitions, events): machine definitions
|
|
5
|
+
# are NOT inherited — each machine declares its own graph.
|
|
6
|
+
class ApplicationMachine
|
|
7
|
+
include Statecraft::Machine
|
|
8
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# aasm kept the state in <%= table_name %>.<%= state_column %> and kept no history at
|
|
4
|
+
# all, so this migration adds only what the gem needs: the append-only log and
|
|
5
|
+
# an index on the column the scopes will read.
|
|
6
|
+
#
|
|
7
|
+
# The column itself is deliberately left as aasm made it. To tighten it later
|
|
8
|
+
# without blocking writes on a live table:
|
|
9
|
+
# ALTER TABLE <%= table_name %> ALTER COLUMN <%= state_column %> SET DEFAULT '<%= initial_state %>';
|
|
10
|
+
# -- backfill NULLs, then --
|
|
11
|
+
# ALTER TABLE <%= table_name %> ALTER COLUMN <%= state_column %> SET NOT NULL;
|
|
12
|
+
# ALTER TABLE <%= table_name %> ADD CONSTRAINT <%= table_name %>_<%= state_column %>_check
|
|
13
|
+
# CHECK (<%= state_column %> IN (<%= states.map { |state| "'#{state}'" }.join(", ") %>)) NOT VALID;
|
|
14
|
+
# -- clean the data --
|
|
15
|
+
# ALTER TABLE <%= table_name %> VALIDATE CONSTRAINT <%= table_name %>_<%= state_column %>_check;
|
|
16
|
+
class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
17
|
+
def change
|
|
18
|
+
create_table :<%= log_table_name.pluralize %> do |t|
|
|
19
|
+
t.references :<%= file_name %>,
|
|
20
|
+
null: false, index: false,
|
|
21
|
+
foreign_key: { to_table: :<%= table_name %>, on_delete: :cascade }
|
|
22
|
+
t.string :from_state, null: false
|
|
23
|
+
t.string :to_state, null: false
|
|
24
|
+
t.string :event
|
|
25
|
+
t.column :metadata, metadata_column_type, null: false, default: {}
|
|
26
|
+
t.datetime :created_at, null: false
|
|
27
|
+
t.index %i[<%= foreign_key_column %> id]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
add_index :<%= table_name %>, :<%= state_column %> unless index_exists?(:<%= table_name %>, :<%= state_column %>)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# jsonb where the database has it, json elsewhere: the adapter is known
|
|
36
|
+
# only when the migration actually runs, never at generation time.
|
|
37
|
+
def metadata_column_type
|
|
38
|
+
connection.adapter_name.match?(/postg/i) ? :jsonb : :json
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Skeleton generated from <%= class_name %>'s <%= machine_label %>: the graph and
|
|
4
|
+
# the event names are copied, the semantics are yours to port.
|
|
5
|
+
#
|
|
6
|
+
# - aasm guards judge the record and see no input, so every symbol guard came
|
|
7
|
+
# over as record_guard: with a one-line delegate below. A guard that reads
|
|
8
|
+
# what the caller submits belongs on guard: (arity 2) instead.
|
|
9
|
+
# - lambda guards carry their own closure and cannot be moved; each is a TODO
|
|
10
|
+
# with its original location.
|
|
11
|
+
class <%= class_name %>Flow < ApplicationMachine
|
|
12
|
+
<% states.each do |state_name| -%>
|
|
13
|
+
state :<%= state_name %><%= state_name == initial_state ? ", initial: true" : "" %>
|
|
14
|
+
<% end -%>
|
|
15
|
+
|
|
16
|
+
<% events.each do |event| -%>
|
|
17
|
+
event :<%= event[:name] %>, from: :<%= event[:from] %>, to: :<%= event[:to] %><%= event[:guard] ? ", record_guard: :#{event[:guard][:method_name]}" : "" %>
|
|
18
|
+
<% end -%>
|
|
19
|
+
<% unless guard_todos.empty? -%>
|
|
20
|
+
|
|
21
|
+
<% guard_todos.each do |note| -%>
|
|
22
|
+
# TODO(guard): <%= note %>
|
|
23
|
+
<% end -%>
|
|
24
|
+
<% end -%>
|
|
25
|
+
<% unless guard_delegates.empty? -%>
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# The machine keeps the registry "event -> predicate" and delegates the
|
|
30
|
+
# domain facts to the record, exactly where aasm called them.
|
|
31
|
+
<% guard_delegates.each do |guard| -%>
|
|
32
|
+
def <%= guard[:method_name] %>(record) = <%= guard[:negated] ? "!record.#{guard[:source]}" : "record.#{guard[:source]}" %>
|
|
33
|
+
<% end -%>
|
|
34
|
+
<% end -%>
|
|
35
|
+
end
|
data/lib/statecraft/errors.rb
CHANGED
|
@@ -139,4 +139,21 @@ module Statecraft
|
|
|
139
139
|
"make the log model inherit from the model's connection-owning ancestor")
|
|
140
140
|
end
|
|
141
141
|
end
|
|
142
|
+
|
|
143
|
+
# A question (can_fire?, may_*?, available_*) was asked without metadata
|
|
144
|
+
# while an input_guard stands on the consulted path — its answer without
|
|
145
|
+
# the input would be a false "no", so the question refuses loudly instead.
|
|
146
|
+
# An explicit `metadata: {}` states "my input is empty" and is legal.
|
|
147
|
+
class MetadataRequired < Error
|
|
148
|
+
attr_reader :record, :question, :guards
|
|
149
|
+
|
|
150
|
+
def initialize(record:, question:, guards:)
|
|
151
|
+
@record = record
|
|
152
|
+
@question = question
|
|
153
|
+
@guards = guards
|
|
154
|
+
super("#{question} on #{record.class.name} consults input guards " \
|
|
155
|
+
"#{guards.map(&:inspect).join(", ")} — pass metadata: to answer with input " \
|
|
156
|
+
"(an explicit metadata: {} means the input is empty)")
|
|
157
|
+
end
|
|
158
|
+
end
|
|
142
159
|
end
|
|
@@ -11,7 +11,7 @@ module Statecraft
|
|
|
11
11
|
Availability = Struct.new(:to, :via, keyword_init: true)
|
|
12
12
|
Refusal = Struct.new(:event, :guard, :layer, keyword_init: true)
|
|
13
13
|
|
|
14
|
-
def can_fire?(event_name, metadata:
|
|
14
|
+
def can_fire?(event_name, metadata: Metadata::OMITTED)
|
|
15
15
|
graph = statecraft_graph
|
|
16
16
|
branches = graph.events[event_name.to_sym]
|
|
17
17
|
return false unless branches
|
|
@@ -19,24 +19,32 @@ module Statecraft
|
|
|
19
19
|
edge = branches[statecraft_current_state]
|
|
20
20
|
return false unless edge
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
normalized = statecraft_question_metadata(metadata, [[edge, event_name.to_sym]],
|
|
23
|
+
question: "can_fire?(#{event_name.inspect})")
|
|
24
|
+
statecraft_guards_pass?(edge, event_name.to_sym, normalized)
|
|
23
25
|
end
|
|
24
26
|
|
|
25
|
-
def available_events(metadata:
|
|
26
|
-
|
|
27
|
-
statecraft_graph.events.filter_map do |event_name, branches|
|
|
27
|
+
def available_events(metadata: Metadata::OMITTED)
|
|
28
|
+
consulted = statecraft_graph.events.filter_map do |event_name, branches|
|
|
28
29
|
edge = branches[statecraft_current_state]
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
[edge, event_name] if edge
|
|
31
|
+
end
|
|
32
|
+
normalized = statecraft_question_metadata(metadata, consulted, question: "available_events")
|
|
33
|
+
consulted.filter_map do |edge, event_name|
|
|
31
34
|
event_name if statecraft_guards_pass?(edge, event_name, normalized)
|
|
32
35
|
end
|
|
33
36
|
end
|
|
34
37
|
|
|
35
|
-
def available_transitions(metadata:
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
def available_transitions(metadata: Metadata::OMITTED)
|
|
39
|
+
outgoing = statecraft_graph.edges.filter_map do |(from, _to), edge|
|
|
40
|
+
edge if from == statecraft_current_state
|
|
41
|
+
end
|
|
42
|
+
consulted = outgoing.flat_map do |edge|
|
|
43
|
+
pairs = edge.event_names.map { |event_name| [edge, event_name] }
|
|
44
|
+
statecraft_direct_legal?(edge) ? pairs + [[edge, nil]] : pairs
|
|
45
|
+
end
|
|
46
|
+
normalized = statecraft_question_metadata(metadata, consulted, question: "available_transitions")
|
|
47
|
+
outgoing.filter_map do |edge|
|
|
40
48
|
via = statecraft_passable_via(edge, normalized)
|
|
41
49
|
Availability.new(to: edge.to, via: via) unless via.empty?
|
|
42
50
|
end
|
|
@@ -73,6 +81,23 @@ module Statecraft
|
|
|
73
81
|
|
|
74
82
|
private
|
|
75
83
|
|
|
84
|
+
# The single funnel for a question's metadata. Omitted with no input
|
|
85
|
+
# guard on the consulted path degrades to an empty hash; omitted with
|
|
86
|
+
# one raises MetadataRequired — the guard declared that its answer
|
|
87
|
+
# without input would be a false "no". An explicit hash always passes.
|
|
88
|
+
def statecraft_question_metadata(metadata, consulted_pairs, question:)
|
|
89
|
+
return Metadata.normalize(metadata) unless metadata.equal?(Metadata::OMITTED)
|
|
90
|
+
|
|
91
|
+
input_guards = consulted_pairs.flat_map { |edge, event_name| statecraft_input_guards(edge, event_name) }
|
|
92
|
+
return Metadata.normalize({}) if input_guards.empty?
|
|
93
|
+
|
|
94
|
+
raise MetadataRequired.new(record: self, question: question, guards: input_guards.uniq)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def statecraft_input_guards(edge, event_name)
|
|
98
|
+
edge.edge_input_guards + (event_name ? edge.event_input_guards.fetch(event_name, []) : [])
|
|
99
|
+
end
|
|
100
|
+
|
|
76
101
|
def statecraft_record_refusals(edge, event_name)
|
|
77
102
|
machine_instance = self.class.statecraft_mounting.machine_class.new
|
|
78
103
|
layers = {
|
data/lib/statecraft/machine.rb
CHANGED
|
@@ -7,12 +7,15 @@ module Statecraft
|
|
|
7
7
|
# Guard and callback symbols resolve to instance methods of the machine
|
|
8
8
|
# class; callables are honored with a plain `call` and arity dispatch.
|
|
9
9
|
module Machine
|
|
10
|
-
# edge_guards / event_guards carry the FULL lists of
|
|
11
|
-
# record-
|
|
10
|
+
# edge_guards / event_guards carry the FULL lists of all layers in
|
|
11
|
+
# record-plain-input order — execution and the prediction run them as one
|
|
12
12
|
# sequence. The parallel *_record_guards fields hold only the record
|
|
13
|
-
# layer
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
# layer (read by the offering introspection); the *_input_guards fields
|
|
14
|
+
# hold only the marked input layer (read by the question surface to
|
|
15
|
+
# refuse a question asked without metadata).
|
|
16
|
+
Edge = Struct.new(:from, :to, :lock, :edge_guards, :edge_record_guards, :edge_input_guards,
|
|
17
|
+
:event_names, :event_guards, :event_record_guards, :event_input_guards,
|
|
18
|
+
keyword_init: true)
|
|
16
19
|
Callback = Struct.new(:handler, :from, :to, :event, keyword_init: true)
|
|
17
20
|
CompiledGraph = Struct.new(
|
|
18
21
|
:states, :initial_state, :edges, :events, :callbacks,
|
|
@@ -59,16 +62,17 @@ module Statecraft
|
|
|
59
62
|
declared_states << { name: name.to_sym, initial: initial }
|
|
60
63
|
end
|
|
61
64
|
|
|
62
|
-
def transition(from:, to:, guard: nil, record_guard: nil, lock: false)
|
|
65
|
+
def transition(from:, to:, guard: nil, record_guard: nil, input_guard: nil, lock: false)
|
|
63
66
|
declared_edges << {
|
|
64
67
|
from: from.to_sym, to: to.to_sym,
|
|
65
68
|
guards: Array(guard), record_guards: Array(record_guard),
|
|
69
|
+
input_guards: Array(input_guard),
|
|
66
70
|
lock: lock || current_event_lock,
|
|
67
71
|
event: current_event_name
|
|
68
72
|
}
|
|
69
73
|
end
|
|
70
74
|
|
|
71
|
-
def event(name, from: nil, to: nil, guard: nil, record_guard: nil, lock: false, &declarations)
|
|
75
|
+
def event(name, from: nil, to: nil, guard: nil, record_guard: nil, input_guard: nil, lock: false, &declarations)
|
|
72
76
|
name = name.to_sym
|
|
73
77
|
declared_event_names << name
|
|
74
78
|
if declarations
|
|
@@ -87,7 +91,8 @@ module Statecraft
|
|
|
87
91
|
|
|
88
92
|
@statecraft_current_event = { name: name, lock: false }
|
|
89
93
|
begin
|
|
90
|
-
transition(from: from, to: to, guard: guard, record_guard: record_guard,
|
|
94
|
+
transition(from: from, to: to, guard: guard, record_guard: record_guard,
|
|
95
|
+
input_guard: input_guard, lock: lock)
|
|
91
96
|
ensure
|
|
92
97
|
@statecraft_current_event = nil
|
|
93
98
|
end
|
|
@@ -108,6 +113,16 @@ module Statecraft
|
|
|
108
113
|
end
|
|
109
114
|
end
|
|
110
115
|
|
|
116
|
+
# Opt-in shape strictness: with strict! the compiler additionally
|
|
117
|
+
# requires every declared state to be reachable from the initial one.
|
|
118
|
+
# Off by default — the column is written by more than the gem, so an
|
|
119
|
+
# unconnected state is legal unless the machine claims a closed graph.
|
|
120
|
+
def strict!
|
|
121
|
+
@statecraft_strict = true
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def strict? = @statecraft_strict == true
|
|
125
|
+
|
|
111
126
|
def states
|
|
112
127
|
compiled_graph.states
|
|
113
128
|
end
|
|
@@ -197,6 +212,8 @@ module Statecraft
|
|
|
197
212
|
events = compile_events(edges)
|
|
198
213
|
resolve_symbols(edges)
|
|
199
214
|
assert_record_guards_unary(edges)
|
|
215
|
+
assert_input_guards_binary(edges)
|
|
216
|
+
assert_states_reachable(states, initial, edges) if machine_class.strict?
|
|
200
217
|
CompiledGraph.new(
|
|
201
218
|
states: states.freeze,
|
|
202
219
|
initial_state: initial,
|
|
@@ -249,9 +266,10 @@ module Statecraft
|
|
|
249
266
|
def build_edge(declaration)
|
|
250
267
|
Edge.new(
|
|
251
268
|
from: declaration[:from], to: declaration[:to], lock: declaration[:lock],
|
|
252
|
-
edge_guards: declaration[:record_guards] + declaration[:guards],
|
|
269
|
+
edge_guards: declaration[:record_guards] + declaration[:guards] + declaration[:input_guards],
|
|
253
270
|
edge_record_guards: declaration[:record_guards],
|
|
254
|
-
|
|
271
|
+
edge_input_guards: declaration[:input_guards],
|
|
272
|
+
event_names: [], event_guards: {}, event_record_guards: {}, event_input_guards: {}
|
|
255
273
|
)
|
|
256
274
|
end
|
|
257
275
|
|
|
@@ -259,16 +277,18 @@ module Statecraft
|
|
|
259
277
|
event_name = declaration[:event]
|
|
260
278
|
edge ||= Edge.new(
|
|
261
279
|
from: declaration[:from], to: declaration[:to], lock: false,
|
|
262
|
-
edge_guards: [], edge_record_guards: [],
|
|
263
|
-
event_names: [], event_guards: {}, event_record_guards: {}
|
|
280
|
+
edge_guards: [], edge_record_guards: [], edge_input_guards: [],
|
|
281
|
+
event_names: [], event_guards: {}, event_record_guards: {}, event_input_guards: {}
|
|
264
282
|
)
|
|
265
283
|
if edge.event_names.include?(event_name)
|
|
266
284
|
raise CompilationError, "event #{event_name.inspect} declares edge #{pair_name(pair)} twice"
|
|
267
285
|
end
|
|
268
286
|
|
|
269
287
|
edge.event_names << event_name
|
|
270
|
-
edge.event_guards[event_name] = declaration[:record_guards] + declaration[:guards]
|
|
288
|
+
edge.event_guards[event_name] = declaration[:record_guards] + declaration[:guards] +
|
|
289
|
+
declaration[:input_guards]
|
|
271
290
|
edge.event_record_guards[event_name] = declaration[:record_guards]
|
|
291
|
+
edge.event_input_guards[event_name] = declaration[:input_guards]
|
|
272
292
|
edge.lock ||= declaration[:lock]
|
|
273
293
|
edge
|
|
274
294
|
end
|
|
@@ -334,15 +354,62 @@ module Statecraft
|
|
|
334
354
|
guard.respond_to?(:arity) ? guard.arity : guard.method(:call).arity
|
|
335
355
|
end
|
|
336
356
|
|
|
357
|
+
# An input guard promises that its answer is meaningless without the
|
|
358
|
+
# input, and the promise is held by shape: it must accept exactly the
|
|
359
|
+
# record and the metadata, so a question asked without metadata can be
|
|
360
|
+
# refused instead of answered falsely.
|
|
361
|
+
def assert_input_guards_binary(edges)
|
|
362
|
+
edges.each_value do |edge|
|
|
363
|
+
input_guards = edge.edge_input_guards + edge.event_input_guards.values.flatten
|
|
364
|
+
input_guards.each do |guard|
|
|
365
|
+
arity = record_guard_arity(guard)
|
|
366
|
+
next if arity == 2
|
|
367
|
+
|
|
368
|
+
label = guard.is_a?(Symbol) ? guard.inspect : "the callable"
|
|
369
|
+
raise CompilationError,
|
|
370
|
+
"input_guard #{label} must take the record and the metadata (arity 2), got arity #{arity}"
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Strict reachability is a shape check, like transitions_from: edges
|
|
376
|
+
# only, no guards. Dead ends stay legal — terminal states are the norm.
|
|
377
|
+
def assert_states_reachable(states, initial, edges)
|
|
378
|
+
unreachable = states - reachable_states(initial, edges)
|
|
379
|
+
return if unreachable.empty?
|
|
380
|
+
|
|
381
|
+
raise CompilationError,
|
|
382
|
+
"strict!: unreachable from the initial #{initial.inspect}: " \
|
|
383
|
+
"#{unreachable.map(&:inspect).join(", ")} — connect with edges or drop strict!"
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def reachable_states(initial, edges)
|
|
387
|
+
reached = [initial]
|
|
388
|
+
queue = [initial]
|
|
389
|
+
until queue.empty?
|
|
390
|
+
from = queue.shift
|
|
391
|
+
edges.each_key do |(edge_from, edge_to)|
|
|
392
|
+
next if edge_from != from || reached.include?(edge_to)
|
|
393
|
+
|
|
394
|
+
reached << edge_to
|
|
395
|
+
queue << edge_to
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
reached
|
|
399
|
+
end
|
|
400
|
+
|
|
337
401
|
def deep_freeze_edges(edges)
|
|
338
402
|
edges.each_value do |edge|
|
|
339
403
|
edge.edge_guards.freeze
|
|
340
404
|
edge.edge_record_guards.freeze
|
|
405
|
+
edge.edge_input_guards.freeze
|
|
341
406
|
edge.event_names.freeze
|
|
342
407
|
edge.event_guards.each_value(&:freeze)
|
|
343
408
|
edge.event_guards.freeze
|
|
344
409
|
edge.event_record_guards.each_value(&:freeze)
|
|
345
410
|
edge.event_record_guards.freeze
|
|
411
|
+
edge.event_input_guards.each_value(&:freeze)
|
|
412
|
+
edge.event_input_guards.freeze
|
|
346
413
|
edge.freeze
|
|
347
414
|
end
|
|
348
415
|
edges.freeze
|
data/lib/statecraft/metadata.rb
CHANGED
|
@@ -7,6 +7,11 @@ module Statecraft
|
|
|
7
7
|
# strings, and anything that JSON cannot represent fails instantly at the
|
|
8
8
|
# pipeline entrance instead of inside the transaction.
|
|
9
9
|
module Metadata
|
|
10
|
+
# The question surface's default: distinguishes "asked without metadata"
|
|
11
|
+
# from an explicit empty hash, so a question that would consult an
|
|
12
|
+
# input_guard can refuse loudly instead of answering from a void.
|
|
13
|
+
OMITTED = Object.new.freeze
|
|
14
|
+
|
|
10
15
|
def self.normalize(raw_metadata)
|
|
11
16
|
deep_freeze(round_trip(raw_metadata))
|
|
12
17
|
end
|
data/lib/statecraft/mounting.rb
CHANGED
|
@@ -183,7 +183,9 @@ module Statecraft
|
|
|
183
183
|
define_method(event_name) do |metadata: {}, seen: nil|
|
|
184
184
|
fire(event_name, metadata: metadata, seen: seen)
|
|
185
185
|
end
|
|
186
|
-
define_method("may_#{event_name}?")
|
|
186
|
+
define_method("may_#{event_name}?") do |metadata: Metadata::OMITTED|
|
|
187
|
+
can_fire?(event_name, metadata: metadata)
|
|
188
|
+
end
|
|
187
189
|
end
|
|
188
190
|
end
|
|
189
191
|
model.include(verbs)
|
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.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Igor Pugachev
|
|
@@ -60,6 +60,11 @@ extra_rdoc_files: []
|
|
|
60
60
|
files:
|
|
61
61
|
- LICENSE.txt
|
|
62
62
|
- README.md
|
|
63
|
+
- lib/generators/statecraft/from_aasm/USAGE
|
|
64
|
+
- lib/generators/statecraft/from_aasm/from_aasm_generator.rb
|
|
65
|
+
- lib/generators/statecraft/from_aasm/templates/application_machine.rb.tt
|
|
66
|
+
- lib/generators/statecraft/from_aasm/templates/create_log_table.rb.tt
|
|
67
|
+
- lib/generators/statecraft/from_aasm/templates/machine_from_aasm.rb.tt
|
|
63
68
|
- lib/generators/statecraft/from_statesman/USAGE
|
|
64
69
|
- lib/generators/statecraft/from_statesman/from_statesman_generator.rb
|
|
65
70
|
- lib/generators/statecraft/from_statesman/templates/application_machine.rb.tt
|