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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 70f23cb88e677af1d36d2acf31fc39a1688f90a46c99d6eebef3e76c49ca4f8e
4
- data.tar.gz: f38e777eed1a3fa3109da01a9f56eeb09f8c181c9d13e899ab33e3334c372d8b
3
+ metadata.gz: 8e51b28a602d49b1cf5f71183e5c8b2080bd3e9f4e3a857147a65a7f5529373c
4
+ data.tar.gz: 2068032e1f963037d4521fbca8a02e53d5594bb5ce9902b9c809d1448ba7d9bd
5
5
  SHA512:
6
- metadata.gz: 636c11b89dabc3d977f13d503ea7cc311c59dd4c677bfea917b333de36f37c045b1b1ddfec2a1f4643131c0f160dad33651ebf4371c32a365783b8e4c7f1df5f
7
- data.tar.gz: e09b76e0ca7552f014259429ee4f16ce5ee64b75f4cd829dbffa391ec0604901ba2d9b5a23dd3375bc79f95dc3abe4ca4a77c483634de0343ed2601694bde538
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 required fields are enforced by
409
- guards — and the shape evolves by convention: carry a `v:` key when you need
410
- versioned readers.
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 (only fire! and the panel see it).
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?, guard: :reason_present?
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
@@ -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
- statecraft_guards_pass?(edge, event_name.to_sym, Metadata.normalize(metadata))
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
- normalized = Metadata.normalize(metadata)
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
- next unless edge
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
- normalized = Metadata.normalize(metadata)
37
- statecraft_graph.edges.filter_map do |(from, _to), edge|
38
- next unless from == statecraft_current_state
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 = {
@@ -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 both layers in
11
- # record-then-input order — execution and the prediction run them as one
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; nothing but the offering introspection reads them.
14
- Edge = Struct.new(:from, :to, :lock, :edge_guards, :edge_record_guards,
15
- :event_names, :event_guards, :event_record_guards, keyword_init: true)
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, lock: lock)
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
- event_names: [], event_guards: {}, event_record_guards: {}
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
@@ -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
@@ -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}?") { |metadata: {}| can_fire?(event_name, metadata: metadata) }
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Statecraft
4
- VERSION = "0.8.0"
4
+ VERSION = "0.10.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: statecraft
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.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