statecraft 0.1.2 → 0.2.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: 207eda16ee51c42a787b0e3ba1a37c297242316edfe742c6be75b5dbca1166c0
4
- data.tar.gz: efe7505f2499fdcd1175b35be3c454a9143b7afb2824dc3a6f0e2f98a8429a5f
3
+ metadata.gz: 6000e276c5fbe7a0ba8b33724c8cf6deea30dc0e8378931166e5123f799ee2a4
4
+ data.tar.gz: 40ab4510c265cb13a811faac8a3c04003222f1f57644233860a6f5d6031f8b12
5
5
  SHA512:
6
- metadata.gz: 29d2cd130932273c9469dcbecf03f2a0fb20d33fc8b776efba5f850cd21bb447c5032de1dc10375e5dc9718d993001f6e0b279a5aa2e72c7be0cd1b890408710
7
- data.tar.gz: c0856de0dde4ff10884ac2f567848b01b8949b514a2a171971957161d88348994d8f207e0f1e2f5d10fb1137a2b750b7f9f08e183537759dd2947be8c57911f4
6
+ metadata.gz: 7067033a04b74f602366186bbf5536070d21ab8f2151cc955529db3a4d522cfbbd07d1586f7ed50c2ab766081ead669a490d0f76a4e20c61bc939d9d6a1669b6
7
+ data.tar.gz: 808b7b6a2c2d790636954a2da78a884355568487e43470885db340f75459cea23bc98e72839e3aa072fa29e45dbccc3b6c44e4efddfea27445405b8a71125b96
data/README.md CHANGED
@@ -29,6 +29,7 @@ the generator as a progressive enhancement.
29
29
 
30
30
  ## Installation
31
31
 
32
+ <!-- illustrative -->
32
33
  ```ruby
33
34
  gem "statecraft"
34
35
  ```
@@ -48,6 +49,7 @@ log table with a cascade FK — and a CHECK constraint when the table is
48
49
  freshly created), the machine class, the readonly log model, and mounts the
49
50
  machine into the model. By hand it looks like this:
50
51
 
52
+ <!-- illustrative -->
51
53
  ```ruby
52
54
  class OrderFlow < ApplicationMachine
53
55
  state :pending, initial: true
@@ -86,6 +88,13 @@ Mounting options: `log:` (defaults to the `<Model>Transition` convention),
86
88
  the same CAS update), `helpers:` and `scopes:` (both off by default; the
87
89
  generator turns them on for new code).
88
90
 
91
+ ## Example app
92
+
93
+ A complete, working web store lives in [`example/`](example/): a storefront
94
+ in human words over a two-zone Rails app, with the gem's full mechanics on
95
+ the operator side and an e2e suite on top. Inside it:
96
+ `bundle install && bin/rails db:setup && bin/rails s` — PostgreSQL only.
97
+
89
98
  ## The transition pipeline
90
99
 
91
100
  `transition_to!` / `fire!` run one strict order:
@@ -113,9 +122,10 @@ do not run, and unsaved changes on other attributes are neither saved nor
113
122
  callbacks are the transition's callbacks.
114
123
 
115
124
  Bang variants return the created log record. Non-bang variants return it too,
116
- or `false` — and `false` means exactly "a guard said no or the edge is not
117
- declared" (`GuardFailed` / `InvalidTransition`). Everything else including
118
- `TransitionConflict` always raises, in both variants.
125
+ or `false` — and `false` covers exactly three refusals: a guard said no, the
126
+ edge is not declared, or the bypass policy refused a direct transition over
127
+ an event-guarded edge (`GuardFailed` / `InvalidTransition`). Everything
128
+ else — including `TransitionConflict` — always raises, in both variants.
119
129
 
120
130
  ## Guards, events and the bypass policy
121
131
 
@@ -125,6 +135,16 @@ one event, `from` is unique — an event is a partial function from state to
125
135
  edge, so `fire!` is structurally deterministic. Branching by outcome means
126
136
  two events (`pay` and `fail_payment`), not one event with two branches.
127
137
 
138
+ A guard also declares its nature. `guard:` judges the input: it receives
139
+ `(record, metadata)` and belongs to execution and prediction. `record_guard:`
140
+ judges the record alone: its handler must take exactly one argument — the
141
+ compiler refuses any other arity — so it physically cannot read the input.
142
+ Execution runs both layers, record first; the split exists for the offering
143
+ introspection below, which may ask the record layer before any input exists.
144
+ A good `record_guard:` is a one-line delegation to a domain predicate on the
145
+ model (`def customer_cancellable?(record) = record.customer_cancellable?`):
146
+ the machine keeps the registry "event → predicate", the model keeps the fact.
147
+
128
148
  Calling `transition_to!` directly over an edge that carries event guards is
129
149
  refused — the guards would be silently skipped. The escape hatch is explicit:
130
150
  `transition_to!(:paid, bypass_events: true)` skips event guards (edge guards
@@ -192,23 +212,39 @@ retry policy belongs to whoever chose the isolation level.
192
212
 
193
213
  ## Introspection
194
214
 
215
+ <!-- illustrative -->
195
216
  ```ruby
196
217
  order.can_fire?(:pay, metadata: { amount: 100 }) # would the guards pass right now?
197
218
  order.may_pay?(metadata: { amount: 100 }) # alias, with helpers: true
198
219
  order.available_events(metadata: { amount: 100 }) # => [:pay]
199
220
  order.available_transitions(metadata: {}) # => [#<to: :cancelled, via: [:direct]>]
221
+ order.offerable_events # => [:pay, :cancel] — graph × record layer
222
+ order.refusals_for(:cancel) # => [#<event: :cancel, guard: :customer_cancellable?, layer: :event_record>]
200
223
  order.transitioned_to?(:paid) # strictly log-based
224
+ OrderFlow.transitions_from(:pending) # => [{ to: :paid, events: [:pay] }, ...]
201
225
  ```
202
226
 
227
+ `transitions_from` is class-level and answers the graph's shape, not a
228
+ prediction: no guards are consulted, a bare edge carries an empty `events`
229
+ list, and a state outside the graph owns no edges. Pair it with
230
+ `available_transitions` for the prediction.
231
+
203
232
  `available_transitions` tells you not only *where* you can go but *how*:
204
233
  `via` lists the events whose guards pass, plus `:direct` when the edge is
205
234
  free of event guards and its edge guards pass. Every answer is a snapshot —
206
235
  CAS may still reject the transition a moment later.
207
236
 
208
- A guard that reads metadata makes `may_*?` depend on the metadata you pass.
209
- For a UI "is this button available" question, either do not hang input
210
- validation on a guard, or pass the same metadata to `may_*?` that you will
211
- collect for `fire!`.
237
+ For a UI "is this button available" question, ask `offerable_events`: the
238
+ graph filtered by the record layer only. An input-reading `guard:` never
239
+ hides a button hiding it would hide the form its input arrives through
240
+ while a `record_guard:` honestly strips an event this record is not offered.
241
+ `refusals_for(:event)` returns the refusing record-layer guards as frozen
242
+ structures (event, guard name, layer) and answers `[]` for an unknown event
243
+ or a missing branch. It carries names, never words: human-readable reasons
244
+ belong to the application's presentation layer, not to the machine.
245
+
246
+ A guard that reads metadata makes `may_*?` depend on the metadata you pass —
247
+ pass the same metadata to `may_*?` that you will collect for `fire!`.
212
248
 
213
249
  ## Metadata
214
250
 
@@ -217,7 +253,14 @@ symbol keys and values become strings, times become ISO-8601 strings — and
217
253
  then deep-frozen: **the guards see exactly what the log will store**, and a
218
254
  guard that mutates metadata dies with `FrozenError` in a transition and in a
219
255
  check alike. Unserializable values (a `Proc`, a model instance) fail
220
- instantly at the entrance, not inside the transaction.
256
+ instantly at the entrance, not inside the transaction — and so do `NaN` and
257
+ `Infinity`, which JSON cannot represent.
258
+
259
+ `BigDecimal` is rejected deliberately, not by omission: jsonb would hand it
260
+ back as a string or a float depending on the reader, silently breaking the
261
+ "what the guards checked is what the log stored" promise. Pass money and
262
+ other exact decimals as strings (`metadata: { price: order.total.to_s }`)
263
+ and parse them in the guard.
221
264
 
222
265
  Facts of the transition moment (a price snapshot, a rules version) are
223
266
  collected by the caller: `order.pay!(metadata: { price: order.total })`.
@@ -326,6 +369,7 @@ that also fails the check, correctly.
326
369
  No railties at runtime — the hygiene is enforced by a test, not a promise.
327
370
  Without the generator, create the schema by hand; the reference shape:
328
371
 
372
+ <!-- illustrative -->
329
373
  ```ruby
330
374
  create_table :orders do |t|
331
375
  t.string :state, null: false, default: "pending", index: true
@@ -355,6 +399,219 @@ degrades — the locking clause is dropped, the reload still runs, and
355
399
  statecraft warns once per machine per process that row-locking guarantees
356
400
  require PostgreSQL.
357
401
 
402
+ ## Example app patterns
403
+
404
+ These blocks are copied verbatim from the example store and locked by
405
+ `example/script/readme_drift_check.rb` — the code is right, the README
406
+ catches up by hand. The machine behind an order:
407
+
408
+ <!-- readme: machine-skeleton -->
409
+ ```ruby
410
+ class OrderFlow
411
+ include Statecraft::Machine
412
+
413
+ state :pending, initial: true
414
+ state :paid
415
+ state :refunded
416
+ state :cancelled
417
+
418
+ event :pay, from: :pending, to: :paid
419
+ event :refund, from: :paid, to: :refunded, record_guard: :refundable?
420
+
421
+ # One edge, the whole event layer: a guarded event, an unguarded privileged
422
+ # event and the bypass path all share pending -> cancelled — the log
423
+ # records HOW, not only WHAT. The cancel guards split by nature: the
424
+ # record layer judges the order (and the offering may ask it), the input
425
+ # layer judges what the operator typed (only fire! and the panel see it).
426
+ event :cancel, from: :pending, to: :cancelled,
427
+ record_guard: :customer_cancellable?, guard: :reason_present?
428
+ event :admin_override, from: :pending, to: :cancelled
429
+
430
+ private
431
+
432
+ # The machine keeps the registry "event -> predicate" and delegates the
433
+ # domain facts to the record.
434
+ def refundable?(record) = record.refundable?
435
+
436
+ def customer_cancellable?(record) = record.customer_cancellable?
437
+
438
+ def reason_present?(_record, metadata)
439
+ metadata["reason"].to_s.strip.present?
440
+ end
441
+ end
442
+ ```
443
+
444
+ The operator order desk, whole: authorize! on entry and per event, thin
445
+ actions over services, guard refusals local to their form:
446
+
447
+ <!-- readme: order-controller -->
448
+ ```ruby
449
+ # The operator's order desk — bang everywhere: an operator wants the gem's
450
+ # message for the flash, and a non-bang false carries no text. Staleness
451
+ # heals in ApplicationController; a guard refusal is local to this form.
452
+ class OrdersController < BaseController
453
+ def index
454
+ @orders = OrdersQuery.call(state: params[:state])
455
+ @active_state = params[:state].to_s
456
+ end
457
+
458
+ def show
459
+ @order = Order.find(params[:id])
460
+ @metadata = {}
461
+ end
462
+
463
+ def pay
464
+ fire(:pay)
465
+ end
466
+
467
+ def cancel
468
+ fire(:cancel)
469
+ end
470
+
471
+ def refund
472
+ fire(:refund)
473
+ end
474
+
475
+ # The non-mutating submit of the SAME fields: the panel recomputes from
476
+ # exactly the metadata a real fire would carry. Nothing is written.
477
+ def preview
478
+ @order = Order.find(params[:id])
479
+ @metadata = submitted_metadata
480
+ flash.now[:notice] = "Preview only — nothing was written."
481
+ render :show
482
+ end
483
+
484
+ # The privileged event: a SECOND event on the same edge, without a
485
+ # guard — the log will name it admin_override.
486
+ def admin_override
487
+ order = Order.find(params[:id])
488
+ authorize! :admin_override, order
489
+ order.admin_override!(metadata: { "reason" => "admin override" })
490
+ redirect_to admin_order_path(order),
491
+ notice: "admin_override fired: the order is now #{order[:state]}."
492
+ end
493
+
494
+ # The bypass: the same edge with the event layer skipped — the log
495
+ # writes event: nil and the history renders the muted
496
+ # "direct (bypassed events)".
497
+ def bypass_cancel
498
+ order = Order.find(params[:id])
499
+ authorize! :bypass_cancel, order
500
+ order.transition_to!(:cancelled, bypass_events: true,
501
+ metadata: { "reason" => "bypassed by admin" })
502
+ redirect_to admin_order_path(order),
503
+ notice: "bypassed: the order is now #{order[:state]}."
504
+ end
505
+
506
+ def create_shipment
507
+ order = Order.find(params[:id])
508
+ authorize! :create_shipment, order
509
+ shipment = CreateShipment.call(order: order)
510
+ redirect_to admin_shipment_path(shipment), notice: "Shipment created."
511
+ rescue ArgumentError => error
512
+ redirect_to admin_order_path(order), alert: error.message.capitalize + "."
513
+ end
514
+
515
+ private
516
+
517
+ def fire(event_name)
518
+ @order = Order.find(params[:id])
519
+ authorize! event_name, @order
520
+ @metadata = submitted_metadata
521
+ @order.fire!(event_name, metadata: @metadata)
522
+ redirect_to admin_order_path(@order),
523
+ notice: "#{event_name} fired: the order is now #{@order[:state]}."
524
+ rescue Statecraft::GuardFailed => error
525
+ # Local to the form: re-render THIS card with the panel computed from
526
+ # the metadata that were actually submitted — a guard refusal belongs
527
+ # to the scene, not to a global handler.
528
+ flash.now[:alert] = "Refused: #{error.message}"
529
+ render :show, status: :unprocessable_entity
530
+ end
531
+
532
+ def submitted_metadata
533
+ params.fetch(:metadata, {}).permit(:reason).to_h
534
+ end
535
+ end
536
+ ```
537
+
538
+ The preview button — a non-mutating submit of the same fields the
539
+ guard-aware panel predicts with, next to buttons that render only in the
540
+ possibility-times-permission intersection:
541
+
542
+ <!-- readme: preview-pattern -->
543
+ ```erb
544
+ <%= form_with url: preview_admin_order_path(@order), method: :post, local: true do %>
545
+ <fieldset>
546
+ <legend>Metadata for the next action</legend>
547
+ <label>
548
+ reason
549
+ <input type="text" name="metadata[reason]" value="<%= @metadata["reason"] %>">
550
+ </label>
551
+ </fieldset>
552
+
553
+ <%= render "shared/transition_buttons",
554
+ record: @order,
555
+ fire_url: ->(event_name) { public_send("#{event_name}_admin_order_path", @order) } %>
556
+
557
+ <button type="submit" class="preview-button">preview</button>
558
+ <% end %>
559
+ ```
560
+
561
+ Subscribing to the telemetry — the five-argument form is the one that
562
+ publish-style events actually deliver to:
563
+
564
+ <!-- readme: telemetry-subscriber -->
565
+ ```ruby
566
+ # The one executable example of subscribing to statecraft's telemetry: the
567
+ # Operations log feed is written here, with create!, into an ordinary table.
568
+ # The gem publishes with explicit start/finish, so subscribers take the
569
+ # five-argument block form. Payloads never carry metadata (the gem's PII
570
+ # decision) — whoever needs it reads the transition log record instead.
571
+ ActiveSupport::Notifications.subscribe("transition.statecraft") do |_name, _started, _finished, _id, payload|
572
+ OperationEntry.create!(
573
+ record_class: payload[:record_class],
574
+ record_id: payload[:record_id].to_s,
575
+ from_state: payload[:from],
576
+ to_state: payload[:to],
577
+ event_name: payload[:event],
578
+ outcome: "transition"
579
+ )
580
+ end
581
+
582
+ ActiveSupport::Notifications.subscribe("transition_failed.statecraft") do |_name, _started, _finished, _id, payload|
583
+ OperationEntry.create!(
584
+ record_class: payload[:record_class],
585
+ record_id: payload[:record_id].to_s,
586
+ from_state: payload[:from],
587
+ to_state: payload[:to],
588
+ event_name: payload[:event],
589
+ outcome: "refused",
590
+ reason: payload[:reason].to_s
591
+ )
592
+ end
593
+ ```
594
+
595
+ Seeding through the honest pipeline, refusal narrative included:
596
+
597
+ <!-- readme: seed-pattern -->
598
+ ```ruby
599
+ # The refusal scenario WITH its narrative: the rescue is part of the plot —
600
+ # a cancellation attempt without a reason lands in the operations feed as a
601
+ # refusal, then the reasoned retry succeeds.
602
+ def seed_disputed_order
603
+ order = place_order(number: "ORD-1009", customer: "Ivy Chen",
604
+ items: { "Ceramic vase" => 2 })
605
+ begin
606
+ order.cancel!(metadata: {})
607
+ rescue Statecraft::GuardFailed
608
+ # the refusal is the point: the feed keeps it
609
+ end
610
+ order.cancel!(metadata: { "reason" => "dispute resolved in the customer's favor" })
611
+ order
612
+ end
613
+ ```
614
+
358
615
  ## Running the tests
359
616
 
360
617
  Natively, against SQLite:
@@ -0,0 +1,26 @@
1
+ Description:
2
+ Creates everything one model needs for a statecraft state machine: the
3
+ migration (state column with a CHECK constraint for a fresh table, the
4
+ state_changed_at column, and the append-only per-model log table with a
5
+ cascade FK), the machine class, the readonly log model, the model itself
6
+ when it does not exist yet (an existing model gets the state_machine
7
+ mounting injected instead), and — once per application — the shared
8
+ ApplicationMachine parent.
9
+
10
+ Namespaced models are fully supported: every file lands by the full
11
+ path, and a table_name_prefix module is generated the way the Rails
12
+ model generator does it. For a model on a non-primary database the
13
+ migration lands in the migrations_paths configured for that database
14
+ (db/migrate when none is configured).
15
+
16
+ Example:
17
+ bin/rails generate statecraft:machine Order
18
+ bin/rails generate statecraft:machine Shop::Order
19
+
20
+ This will create:
21
+ app/state_machines/application_machine.rb (first run only)
22
+ app/state_machines/shop/order_flow.rb
23
+ app/models/shop.rb (namespace prefix module)
24
+ app/models/shop/order_transition.rb
25
+ app/models/shop/order.rb (or mounting injection)
26
+ db/migrate/XXXXXXXXXXXXXX_create_shop_order_state_machine.rb
@@ -3,14 +3,21 @@
3
3
  class <%= class_name %>Flow < ApplicationMachine
4
4
  state :pending, initial: true
5
5
  # state :paid
6
+ # state :refunded
6
7
  #
7
8
  # event :pay, from: :pending, to: :paid, guard: :payable?
9
+ # event :refund, from: :paid, to: :refunded, record_guard: :refundable?
8
10
  #
9
- # Guards and callbacks resolve to instance methods of this class:
11
+ # Guards and callbacks resolve to instance methods of this class, and a
12
+ # guard declares its nature: guard: judges the input and receives
13
+ # (record, metadata); record_guard: judges the record alone (arity 1,
14
+ # compiler-enforced) — delegate it to a domain predicate on the model:
10
15
  #
11
16
  # private
12
17
  #
13
18
  # def payable?(record, metadata)
14
19
  # metadata["amount"].to_i.positive?
15
20
  # end
21
+ #
22
+ # def refundable?(record) = record.refundable?
16
23
  end
@@ -9,6 +9,7 @@ module Statecraft
9
9
  # reject the transition later.
10
10
  module Introspection
11
11
  Availability = Struct.new(:to, :via, keyword_init: true)
12
+ Refusal = Struct.new(:event, :guard, :layer, keyword_init: true)
12
13
 
13
14
  def can_fire?(event_name, metadata: {})
14
15
  graph = statecraft_graph
@@ -45,8 +46,48 @@ module Statecraft
45
46
  history.where(to_state: state_name.to_s).exists?
46
47
  end
47
48
 
49
+ # The offering: which events the graph AND this record allow from here —
50
+ # the record layer alone, so an input-reading guard never hides the form
51
+ # its input arrives through. A snapshot, like every question here.
52
+ def offerable_events
53
+ statecraft_graph.events.filter_map do |event_name, branches|
54
+ edge = branches[statecraft_current_state]
55
+ next unless edge
56
+
57
+ event_name if statecraft_record_refusals(edge, event_name).empty?
58
+ end
59
+ end
60
+
61
+ # The structured "why not": every record-layer guard refusing the event
62
+ # right now. Guard handlers by name, no words — the words belong to the
63
+ # presentation. An unknown event or a missing branch answers [].
64
+ def refusals_for(event_name)
65
+ branches = statecraft_graph.events[event_name.to_sym]
66
+ return [].freeze unless branches
67
+
68
+ edge = branches[statecraft_current_state]
69
+ return [].freeze unless edge
70
+
71
+ statecraft_record_refusals(edge, event_name.to_sym)
72
+ end
73
+
48
74
  private
49
75
 
76
+ def statecraft_record_refusals(edge, event_name)
77
+ machine_instance = self.class.statecraft_mounting.machine_class.new
78
+ layers = {
79
+ edge_record: edge.edge_record_guards,
80
+ event_record: edge.event_record_guards.fetch(event_name, [])
81
+ }
82
+ layers.flat_map do |layer, guards|
83
+ guards.filter_map do |guard|
84
+ next if Machine::Handlers.invoke(machine_instance, guard, self, nil)
85
+
86
+ Refusal.new(event: event_name, guard: guard, layer: layer).freeze
87
+ end
88
+ end.freeze
89
+ end
90
+
50
91
  def statecraft_passable_via(edge, normalized_metadata)
51
92
  via = edge.event_names.select do |event_name|
52
93
  statecraft_guards_pass?(edge, event_name, normalized_metadata)
@@ -7,7 +7,12 @@ 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 = Struct.new(:from, :to, :lock, :edge_guards, :event_names, :event_guards, keyword_init: true)
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
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)
11
16
  Callback = Struct.new(:handler, :from, :to, :event, keyword_init: true)
12
17
  CompiledGraph = Struct.new(
13
18
  :states, :initial_state, :edges, :events, :callbacks,
@@ -40,24 +45,30 @@ module Statecraft
40
45
  end
41
46
 
42
47
  def self.unary?(callable)
43
- callable.arity == 1
48
+ arity = callable.respond_to?(:arity) ? callable.arity : callable.method(:call).arity
49
+ arity == 1
44
50
  end
45
51
  end
46
52
 
47
53
  # Class-level DSL collected declaratively and compiled by finalize!.
54
+ # Names arrive as symbols or strings interchangeably (the ActiveRecord
55
+ # idiom) and are normalized to symbols at the declaration line.
48
56
  module ClassMethods
49
57
  def state(name, initial: false)
50
- declared_states << { name: name, initial: initial }
58
+ declared_states << { name: name.to_sym, initial: initial }
51
59
  end
52
60
 
53
- def transition(from:, to:, guard: nil, lock: false)
61
+ def transition(from:, to:, guard: nil, record_guard: nil, lock: false)
54
62
  declared_edges << {
55
- from: from, to: to, guards: Array(guard), lock: lock || current_event_lock,
63
+ from: from.to_sym, to: to.to_sym,
64
+ guards: Array(guard), record_guards: Array(record_guard),
65
+ lock: lock || current_event_lock,
56
66
  event: current_event_name
57
67
  }
58
68
  end
59
69
 
60
- def event(name, from: nil, to: nil, guard: nil, lock: false, &declarations)
70
+ def event(name, from: nil, to: nil, guard: nil, record_guard: nil, lock: false, &declarations)
71
+ name = name.to_sym
61
72
  declared_event_names << name
62
73
  if declarations
63
74
  if from || to
@@ -75,7 +86,7 @@ module Statecraft
75
86
 
76
87
  @statecraft_current_event = { name: name, lock: false }
77
88
  begin
78
- transition(from: from, to: to, guard: guard, lock: lock)
89
+ transition(from: from, to: to, guard: guard, record_guard: record_guard, lock: lock)
79
90
  ensure
80
91
  @statecraft_current_event = nil
81
92
  end
@@ -89,7 +100,9 @@ module Statecraft
89
100
 
90
101
  declared_callbacks[phase] << Callback.new(
91
102
  handler: callback_handler,
92
- from: from && Array(from), to: to && Array(to), event: event && Array(event)
103
+ from: from && Array(from).map(&:to_sym),
104
+ to: to && Array(to).map(&:to_sym),
105
+ event: event && Array(event).map(&:to_sym)
93
106
  )
94
107
  end
95
108
  end
@@ -102,6 +115,18 @@ module Statecraft
102
115
  compiled_graph.events.keys
103
116
  end
104
117
 
118
+ # The graph's shape from one state: a frozen descriptor per outgoing
119
+ # edge, `events` empty for a bare edge. Shape, not a prediction — no
120
+ # guards are consulted, and a state outside the graph honestly owns no
121
+ # edges. Class-level on purpose: the answer is a property of the graph,
122
+ # never of a record.
123
+ def transitions_from(state)
124
+ normalized = state.to_sym
125
+ compiled_graph.edges.filter_map do |(from, _to), edge|
126
+ { to: edge.to, events: edge.event_names }.freeze if from == normalized
127
+ end.freeze
128
+ end
129
+
105
130
  def initial_state
106
131
  compiled_graph.initial_state
107
132
  end
@@ -114,8 +139,11 @@ module Statecraft
114
139
  !@statecraft_compiled_graph.nil?
115
140
  end
116
141
 
142
+ # Compilation memoizes the graph and freezes the declaration lists, so a
143
+ # reopened machine class fails loudly on any late state/transition/event
144
+ # instead of silently ignoring it (callbacks already freeze in compile).
117
145
  def finalize!
118
- @statecraft_compiled_graph ||= Compiler.new(self).compile
146
+ @statecraft_compiled_graph ||= Compiler.new(self).compile.tap { freeze_declarations }
119
147
  end
120
148
 
121
149
  def declared_states
@@ -136,6 +164,12 @@ module Statecraft
136
164
 
137
165
  private
138
166
 
167
+ def freeze_declarations
168
+ declared_states.freeze
169
+ declared_edges.freeze
170
+ declared_event_names.freeze
171
+ end
172
+
139
173
  def current_event_name
140
174
  @statecraft_current_event && @statecraft_current_event[:name]
141
175
  end
@@ -161,6 +195,7 @@ module Statecraft
161
195
  edges = compile_edges(states)
162
196
  events = compile_events(edges)
163
197
  resolve_symbols(edges)
198
+ assert_record_guards_unary(edges)
164
199
  CompiledGraph.new(
165
200
  states: states.freeze,
166
201
  initial_state: initial,
@@ -213,7 +248,9 @@ module Statecraft
213
248
  def build_edge(declaration)
214
249
  Edge.new(
215
250
  from: declaration[:from], to: declaration[:to], lock: declaration[:lock],
216
- edge_guards: declaration[:guards], event_names: [], event_guards: {}
251
+ edge_guards: declaration[:record_guards] + declaration[:guards],
252
+ edge_record_guards: declaration[:record_guards],
253
+ event_names: [], event_guards: {}, event_record_guards: {}
217
254
  )
218
255
  end
219
256
 
@@ -221,14 +258,16 @@ module Statecraft
221
258
  event_name = declaration[:event]
222
259
  edge ||= Edge.new(
223
260
  from: declaration[:from], to: declaration[:to], lock: false,
224
- edge_guards: [], event_names: [], event_guards: {}
261
+ edge_guards: [], edge_record_guards: [],
262
+ event_names: [], event_guards: {}, event_record_guards: {}
225
263
  )
226
264
  if edge.event_names.include?(event_name)
227
265
  raise CompilationError, "event #{event_name.inspect} declares edge #{pair_name(pair)} twice"
228
266
  end
229
267
 
230
268
  edge.event_names << event_name
231
- edge.event_guards[event_name] = declaration[:guards]
269
+ edge.event_guards[event_name] = declaration[:record_guards] + declaration[:guards]
270
+ edge.event_record_guards[event_name] = declaration[:record_guards]
232
271
  edge.lock ||= declaration[:lock]
233
272
  edge
234
273
  end
@@ -268,15 +307,41 @@ module Statecraft
268
307
  from_callbacks = machine_class.declared_callbacks.each_value.flat_map do |callbacks|
269
308
  callbacks.map(&:handler)
270
309
  end
271
- (from_edges + from_callbacks).select { |handler| handler.is_a?(Symbol) }
310
+ (from_edges + from_callbacks).grep(Symbol)
311
+ end
312
+
313
+ # A record guard promises to judge the record alone, and the promise is
314
+ # held by shape: it must accept exactly one argument, so it physically
315
+ # cannot read the input it claims not to need.
316
+ def assert_record_guards_unary(edges)
317
+ edges.each_value do |edge|
318
+ record_guards = edge.edge_record_guards + edge.event_record_guards.values.flatten
319
+ record_guards.each do |guard|
320
+ arity = record_guard_arity(guard)
321
+ next if arity == 1
322
+
323
+ label = guard.is_a?(Symbol) ? guard.inspect : "the callable"
324
+ raise CompilationError,
325
+ "record_guard #{label} must take exactly the record (arity 1), got arity #{arity}"
326
+ end
327
+ end
328
+ end
329
+
330
+ def record_guard_arity(guard)
331
+ return machine_class.instance_method(guard).arity if guard.is_a?(Symbol)
332
+
333
+ guard.respond_to?(:arity) ? guard.arity : guard.method(:call).arity
272
334
  end
273
335
 
274
336
  def deep_freeze_edges(edges)
275
337
  edges.each_value do |edge|
276
338
  edge.edge_guards.freeze
339
+ edge.edge_record_guards.freeze
277
340
  edge.event_names.freeze
278
341
  edge.event_guards.each_value(&:freeze)
279
342
  edge.event_guards.freeze
343
+ edge.event_record_guards.each_value(&:freeze)
344
+ edge.event_record_guards.freeze
280
345
  edge.freeze
281
346
  end
282
347
  edges.freeze
@@ -82,11 +82,26 @@ module Statecraft
82
82
  raise ConnectionMismatch.new(model: model, log_class: log_class)
83
83
  end
84
84
 
85
+ # Methods this mounting itself defines, so a verb check runs BEFORE the
86
+ # includes and still sees them: an event named fire or history would
87
+ # otherwise silently shadow the gem's own surface.
88
+ def mounted_surface_methods
89
+ Pipeline::Surface.instance_methods +
90
+ Introspection.instance_methods +
91
+ %i[history last_transition in_state?]
92
+ end
93
+
85
94
  def assert_no_verb_conflicts(graph)
86
95
  return unless @helpers
87
96
 
97
+ surface_methods = mounted_surface_methods
88
98
  graph.events.each_key do |event_name|
89
99
  verb_names(event_name).each do |verb|
100
+ if surface_methods.include?(verb.to_sym)
101
+ raise CompilationError,
102
+ "helper #{verb} for event #{event_name.inspect} conflicts with the " \
103
+ "#{verb} method statecraft itself mounts; rename the event"
104
+ end
90
105
  next unless model.method_defined?(verb) || model.private_method_defined?(verb)
91
106
 
92
107
  raise CompilationError,
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statecraft
4
+ class Pipeline
5
+ # Resolves the requested edge from the compiled graph and raises
6
+ # InvalidTransition with a diagnosis that names what actually failed:
7
+ # an undeclared edge, a bypass-policy refusal, an unknown event, or an
8
+ # event with no branch from the current state.
9
+ module EdgeResolution
10
+ private
11
+
12
+ def resolve_direct_edge(current, to_state, bypass_events)
13
+ edge = graph.edges[[current, to_state]]
14
+ raise_invalid_transition(current, to_state) if edge.nil?
15
+ guarding_events = edge.event_names.select { |name| edge.event_guards[name].any? }
16
+ if guarding_events.any? && !bypass_events
17
+ raise InvalidTransition.new(
18
+ record: record, from: current, requested: to_state,
19
+ allowed: allowed_targets(current),
20
+ message: "direct transition #{current} -> #{to_state} is guarded by " \
21
+ "event#{"s" if guarding_events.length > 1} #{guarding_events.join(", ")}; " \
22
+ "call fire!(:#{guarding_events.first}) or pass bypass_events: true"
23
+ )
24
+ end
25
+ edge
26
+ end
27
+
28
+ def resolve_event_edge(current, event_name)
29
+ branches = graph.events[event_name]
30
+ raise_unknown_event(current, event_name) if branches.nil?
31
+ edge = branches[current]
32
+ raise_event_without_branch(current, event_name, branches) if edge.nil?
33
+ edge
34
+ end
35
+
36
+ def raise_unknown_event(current, event_name)
37
+ known_events = graph.events.keys
38
+ raise InvalidTransition.new(
39
+ record: record, from: current, requested: event_name,
40
+ allowed: allowed_targets(current),
41
+ message: "unknown event #{event_name.inspect} for #{configuration.machine_class.name}; " \
42
+ "events: #{known_events.empty? ? "none" : known_events.map(&:inspect).join(", ")}"
43
+ )
44
+ end
45
+
46
+ def raise_event_without_branch(current, event_name, branches)
47
+ declared_branches = branches.map { |from, edge| "#{from} -> #{edge.to}" }.join(", ")
48
+ raise InvalidTransition.new(
49
+ record: record, from: current, requested: event_name,
50
+ allowed: allowed_targets(current),
51
+ message: "event #{event_name.inspect} has no branch from #{current} for " \
52
+ "#{record.class.name}; branches: #{declared_branches}"
53
+ )
54
+ end
55
+
56
+ def raise_invalid_transition(current, requested)
57
+ raise InvalidTransition.new(
58
+ record: record, from: current, requested: requested,
59
+ allowed: allowed_targets(current)
60
+ )
61
+ end
62
+
63
+ def allowed_targets(current)
64
+ graph.edges.keys.select { |from, _to| from == current }.map(&:last)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -28,6 +28,8 @@ module Statecraft
28
28
  STACK_KEY = :statecraft_transition_stack
29
29
  MAX_CHAIN_DEPTH = 16
30
30
 
31
+ include EdgeResolution
32
+
31
33
  def self.transition_stack
32
34
  ActiveSupport::IsolatedExecutionState[STACK_KEY] ||= []
33
35
  end
@@ -70,7 +72,7 @@ module Statecraft
70
72
  begin
71
73
  log_record = execute_transaction(edge, event, bypass, metadata, frame)
72
74
  ensure
73
- Pipeline.transition_stack.delete(frame)
75
+ Pipeline.transition_stack.delete_if { |open| open.equal?(frame) }
74
76
  end
75
77
  rescue GuardFailed, InvalidTransition, TransitionConflict => transition_error
76
78
  publish_failure(started_at, transition_error)
@@ -149,41 +151,6 @@ module Statecraft
149
151
  record[configuration.column].to_s.to_sym
150
152
  end
151
153
 
152
- def resolve_direct_edge(current, to_state, bypass_events)
153
- edge = graph.edges[[current, to_state]]
154
- raise_invalid_transition(current, to_state) if edge.nil?
155
- guarding_events = edge.event_names.select { |name| edge.event_guards[name].any? }
156
- if guarding_events.any? && !bypass_events
157
- raise InvalidTransition.new(
158
- record: record, from: current, requested: to_state,
159
- allowed: allowed_targets(current),
160
- message: "direct transition #{current} -> #{to_state} is guarded by " \
161
- "event#{"s" if guarding_events.length > 1} #{guarding_events.join(", ")}; " \
162
- "call fire!(:#{guarding_events.first}) or pass bypass_events: true"
163
- )
164
- end
165
- edge
166
- end
167
-
168
- def resolve_event_edge(current, event_name)
169
- branches = graph.events[event_name]
170
- raise_invalid_transition(current, event_name) if branches.nil?
171
- edge = branches[current]
172
- raise_invalid_transition(current, event_name) if edge.nil?
173
- edge
174
- end
175
-
176
- def raise_invalid_transition(current, requested)
177
- raise InvalidTransition.new(
178
- record: record, from: current, requested: requested,
179
- allowed: allowed_targets(current)
180
- )
181
- end
182
-
183
- def allowed_targets(current)
184
- graph.edges.keys.select { |from, _to| from == current }.map(&:last)
185
- end
186
-
187
154
  # Checked at the first transition, not at mounting time: resolving an
188
155
  # implicit primary key goes through the schema cache, and mounting must
189
156
  # stay safe without a database connection.
@@ -311,7 +278,7 @@ module Statecraft
311
278
  end
312
279
 
313
280
  def warn_when_row_locking_unavailable
314
- return unless base_class.connection.adapter_name.match?(/sqlite/i)
281
+ return unless base_class.connection_db_config.adapter.match?(/sqlite/i)
315
282
 
316
283
  Statecraft.warn(
317
284
  [configuration.machine_class.name, :sqlite_row_lock],
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Statecraft
4
- VERSION = "0.1.2"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/statecraft.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "statecraft/warnings"
9
9
  require_relative "statecraft/instrumentation"
10
10
  require_relative "statecraft/machine"
11
11
  require_relative "statecraft/metadata"
12
+ require_relative "statecraft/pipeline/edge_resolution"
12
13
  require_relative "statecraft/pipeline"
13
14
  require_relative "statecraft/pipeline/surface"
14
15
  require_relative "statecraft/introspection"
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.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor Pugachev
@@ -60,6 +60,7 @@ extra_rdoc_files: []
60
60
  files:
61
61
  - LICENSE.txt
62
62
  - README.md
63
+ - lib/generators/statecraft/machine/USAGE
63
64
  - lib/generators/statecraft/machine/machine_generator.rb
64
65
  - lib/generators/statecraft/machine/templates/add_migration.rb.tt
65
66
  - lib/generators/statecraft/machine/templates/application_machine.rb.tt
@@ -76,6 +77,7 @@ files:
76
77
  - lib/statecraft/metadata.rb
77
78
  - lib/statecraft/mounting.rb
78
79
  - lib/statecraft/pipeline.rb
80
+ - lib/statecraft/pipeline/edge_resolution.rb
79
81
  - lib/statecraft/pipeline/surface.rb
80
82
  - lib/statecraft/version.rb
81
83
  - lib/statecraft/warnings.rb