statecraft 0.1.3 → 0.3.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: f809e9a1769e457d33d4b526703e4c3c7beb4ca09f8a7550cbbd31c97b07ba12
4
- data.tar.gz: 8193837939f39dd0de633330b7bfc36d7adbb821221072ce1cdf30890f3488d0
3
+ metadata.gz: 1a49958ae3b97e963ada9ee0cb6f719bd0fa5a784704d7f1479f0b045a921ec1
4
+ data.tar.gz: e6fe7115b639e25d33e752800af3324822497413865236be7f53e01dc56674bb
5
5
  SHA512:
6
- metadata.gz: a5da582f65eb60cd2f76fef2ec213ad6c09166b288f44d80f7065a943894a66c5f36134f6c094fd873e50562976a447d0183b3e0e47b5545bce707d63f965d83
7
- data.tar.gz: f1247259b3bc97de6663d9545e00015e1ea443b9e9f0ac33af6a179c09ba534140b0037863ddd2fa64e4992234a5fe14ea0d43e4a9ddc30d1018927c0d3158c5
6
+ metadata.gz: 92730425ebca1937db4f9e53bac01020102a7c6c90bc76fd5518697394bec18720cb70dd826017ca9b36d4e873cd0817a6399399565098e0dfc687db853ad68b
7
+ data.tar.gz: f7511de223aa42b0f44c5fc0b14a0be53965887262b01bb62c9a1954abb9e4e98b4a2b5688ee967ca672cd19514ddc2357fbcd796139074612ee2ac588975357
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:
@@ -126,6 +135,16 @@ one event, `from` is unique — an event is a partial function from state to
126
135
  edge, so `fire!` is structurally deterministic. Branching by outcome means
127
136
  two events (`pay` and `fail_payment`), not one event with two branches.
128
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
+
129
148
  Calling `transition_to!` directly over an edge that carries event guards is
130
149
  refused — the guards would be silently skipped. The escape hatch is explicit:
131
150
  `transition_to!(:paid, bypass_events: true)` skips event guards (edge guards
@@ -193,12 +212,35 @@ retry policy belongs to whoever chose the isolation level.
193
212
 
194
213
  ## Introspection
195
214
 
215
+ <!-- illustrative -->
196
216
  ```ruby
197
217
  order.can_fire?(:pay, metadata: { amount: 100 }) # would the guards pass right now?
198
218
  order.may_pay?(metadata: { amount: 100 }) # alias, with helpers: true
199
219
  order.available_events(metadata: { amount: 100 }) # => [:pay]
200
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>]
201
223
  order.transitioned_to?(:paid) # strictly log-based
224
+ OrderFlow.transitions_from(:pending) # => [{ to: :paid, events: [:pay] }, ...]
225
+ OrderFlow.to_mermaid # the graph as Mermaid stateDiagram-v2 text
226
+ ```
227
+
228
+ `transitions_from` is class-level and answers the graph's shape, not a
229
+ prediction: no guards are consulted, a bare edge carries an empty `events`
230
+ list, and a state outside the graph owns no edges. Pair it with
231
+ `available_transitions` for the prediction.
232
+
233
+ The shape also draws itself: `to_mermaid` returns the graph as Mermaid
234
+ `stateDiagram-v2` text — guards stay out of the picture, exactly like
235
+ `transitions_from`. Paste it into a markdown fence and GitHub renders the
236
+ diagram; the quick-start machine above comes out as:
237
+
238
+ <!-- illustrative -->
239
+ ```mermaid
240
+ stateDiagram-v2
241
+ [*] --> pending
242
+ pending --> paid : pay
243
+ pending --> cancelled
202
244
  ```
203
245
 
204
246
  `available_transitions` tells you not only *where* you can go but *how*:
@@ -206,10 +248,17 @@ order.transitioned_to?(:paid) # strictly log-based
206
248
  free of event guards and its edge guards pass. Every answer is a snapshot —
207
249
  CAS may still reject the transition a moment later.
208
250
 
209
- A guard that reads metadata makes `may_*?` depend on the metadata you pass.
210
- For a UI "is this button available" question, either do not hang input
211
- validation on a guard, or pass the same metadata to `may_*?` that you will
212
- collect for `fire!`.
251
+ For a UI "is this button available" question, ask `offerable_events`: the
252
+ graph filtered by the record layer only. An input-reading `guard:` never
253
+ hides a button hiding it would hide the form its input arrives through
254
+ while a `record_guard:` honestly strips an event this record is not offered.
255
+ `refusals_for(:event)` returns the refusing record-layer guards as frozen
256
+ structures (event, guard name, layer) and answers `[]` for an unknown event
257
+ or a missing branch. It carries names, never words: human-readable reasons
258
+ belong to the application's presentation layer, not to the machine.
259
+
260
+ A guard that reads metadata makes `may_*?` depend on the metadata you pass —
261
+ pass the same metadata to `may_*?` that you will collect for `fire!`.
213
262
 
214
263
  ## Metadata
215
264
 
@@ -334,6 +383,7 @@ that also fails the check, correctly.
334
383
  No railties at runtime — the hygiene is enforced by a test, not a promise.
335
384
  Without the generator, create the schema by hand; the reference shape:
336
385
 
386
+ <!-- illustrative -->
337
387
  ```ruby
338
388
  create_table :orders do |t|
339
389
  t.string :state, null: false, default: "pending", index: true
@@ -363,6 +413,219 @@ degrades — the locking clause is dropped, the reload still runs, and
363
413
  statecraft warns once per machine per process that row-locking guarantees
364
414
  require PostgreSQL.
365
415
 
416
+ ## Example app patterns
417
+
418
+ These blocks are copied verbatim from the example store and locked by
419
+ `example/script/readme_drift_check.rb` — the code is right, the README
420
+ catches up by hand. The machine behind an order:
421
+
422
+ <!-- readme: machine-skeleton -->
423
+ ```ruby
424
+ class OrderFlow
425
+ include Statecraft::Machine
426
+
427
+ state :pending, initial: true
428
+ state :paid
429
+ state :refunded
430
+ state :cancelled
431
+
432
+ event :pay, from: :pending, to: :paid
433
+ event :refund, from: :paid, to: :refunded, record_guard: :refundable?
434
+
435
+ # One edge, the whole event layer: a guarded event, an unguarded privileged
436
+ # event and the bypass path all share pending -> cancelled — the log
437
+ # records HOW, not only WHAT. The cancel guards split by nature: the
438
+ # record layer judges the order (and the offering may ask it), the input
439
+ # layer judges what the operator typed (only fire! and the panel see it).
440
+ event :cancel, from: :pending, to: :cancelled,
441
+ record_guard: :customer_cancellable?, guard: :reason_present?
442
+ event :admin_override, from: :pending, to: :cancelled
443
+
444
+ private
445
+
446
+ # The machine keeps the registry "event -> predicate" and delegates the
447
+ # domain facts to the record.
448
+ def refundable?(record) = record.refundable?
449
+
450
+ def customer_cancellable?(record) = record.customer_cancellable?
451
+
452
+ def reason_present?(_record, metadata)
453
+ metadata["reason"].to_s.strip.present?
454
+ end
455
+ end
456
+ ```
457
+
458
+ The operator order desk, whole: authorize! on entry and per event, thin
459
+ actions over services, guard refusals local to their form:
460
+
461
+ <!-- readme: order-controller -->
462
+ ```ruby
463
+ # The operator's order desk — bang everywhere: an operator wants the gem's
464
+ # message for the flash, and a non-bang false carries no text. Staleness
465
+ # heals in ApplicationController; a guard refusal is local to this form.
466
+ class OrdersController < BaseController
467
+ def index
468
+ @orders = OrdersQuery.call(state: params[:state])
469
+ @active_state = params[:state].to_s
470
+ end
471
+
472
+ def show
473
+ @order = Order.find(params[:id])
474
+ @metadata = {}
475
+ end
476
+
477
+ def pay
478
+ fire(:pay)
479
+ end
480
+
481
+ def cancel
482
+ fire(:cancel)
483
+ end
484
+
485
+ def refund
486
+ fire(:refund)
487
+ end
488
+
489
+ # The non-mutating submit of the SAME fields: the panel recomputes from
490
+ # exactly the metadata a real fire would carry. Nothing is written.
491
+ def preview
492
+ @order = Order.find(params[:id])
493
+ @metadata = submitted_metadata
494
+ flash.now[:notice] = "Preview only — nothing was written."
495
+ render :show
496
+ end
497
+
498
+ # The privileged event: a SECOND event on the same edge, without a
499
+ # guard — the log will name it admin_override.
500
+ def admin_override
501
+ order = Order.find(params[:id])
502
+ authorize! :admin_override, order
503
+ order.admin_override!(metadata: { "reason" => "admin override" })
504
+ redirect_to admin_order_path(order),
505
+ notice: "admin_override fired: the order is now #{order[:state]}."
506
+ end
507
+
508
+ # The bypass: the same edge with the event layer skipped — the log
509
+ # writes event: nil and the history renders the muted
510
+ # "direct (bypassed events)".
511
+ def bypass_cancel
512
+ order = Order.find(params[:id])
513
+ authorize! :bypass_cancel, order
514
+ order.transition_to!(:cancelled, bypass_events: true,
515
+ metadata: { "reason" => "bypassed by admin" })
516
+ redirect_to admin_order_path(order),
517
+ notice: "bypassed: the order is now #{order[:state]}."
518
+ end
519
+
520
+ def create_shipment
521
+ order = Order.find(params[:id])
522
+ authorize! :create_shipment, order
523
+ shipment = CreateShipment.call(order: order)
524
+ redirect_to admin_shipment_path(shipment), notice: "Shipment created."
525
+ rescue ArgumentError => error
526
+ redirect_to admin_order_path(order), alert: error.message.capitalize + "."
527
+ end
528
+
529
+ private
530
+
531
+ def fire(event_name)
532
+ @order = Order.find(params[:id])
533
+ authorize! event_name, @order
534
+ @metadata = submitted_metadata
535
+ @order.fire!(event_name, metadata: @metadata)
536
+ redirect_to admin_order_path(@order),
537
+ notice: "#{event_name} fired: the order is now #{@order[:state]}."
538
+ rescue Statecraft::GuardFailed => error
539
+ # Local to the form: re-render THIS card with the panel computed from
540
+ # the metadata that were actually submitted — a guard refusal belongs
541
+ # to the scene, not to a global handler.
542
+ flash.now[:alert] = "Refused: #{error.message}"
543
+ render :show, status: :unprocessable_entity
544
+ end
545
+
546
+ def submitted_metadata
547
+ params.fetch(:metadata, {}).permit(:reason).to_h
548
+ end
549
+ end
550
+ ```
551
+
552
+ The preview button — a non-mutating submit of the same fields the
553
+ guard-aware panel predicts with, next to buttons that render only in the
554
+ possibility-times-permission intersection:
555
+
556
+ <!-- readme: preview-pattern -->
557
+ ```erb
558
+ <%= form_with url: preview_admin_order_path(@order), method: :post, local: true do %>
559
+ <fieldset>
560
+ <legend>Metadata for the next action</legend>
561
+ <label>
562
+ reason
563
+ <input type="text" name="metadata[reason]" value="<%= @metadata["reason"] %>">
564
+ </label>
565
+ </fieldset>
566
+
567
+ <%= render "shared/transition_buttons",
568
+ record: @order,
569
+ fire_url: ->(event_name) { public_send("#{event_name}_admin_order_path", @order) } %>
570
+
571
+ <button type="submit" class="preview-button">preview</button>
572
+ <% end %>
573
+ ```
574
+
575
+ Subscribing to the telemetry — the five-argument form is the one that
576
+ publish-style events actually deliver to:
577
+
578
+ <!-- readme: telemetry-subscriber -->
579
+ ```ruby
580
+ # The one executable example of subscribing to statecraft's telemetry: the
581
+ # Operations log feed is written here, with create!, into an ordinary table.
582
+ # The gem publishes with explicit start/finish, so subscribers take the
583
+ # five-argument block form. Payloads never carry metadata (the gem's PII
584
+ # decision) — whoever needs it reads the transition log record instead.
585
+ ActiveSupport::Notifications.subscribe("transition.statecraft") do |_name, _started, _finished, _id, payload|
586
+ OperationEntry.create!(
587
+ record_class: payload[:record_class],
588
+ record_id: payload[:record_id].to_s,
589
+ from_state: payload[:from],
590
+ to_state: payload[:to],
591
+ event_name: payload[:event],
592
+ outcome: "transition"
593
+ )
594
+ end
595
+
596
+ ActiveSupport::Notifications.subscribe("transition_failed.statecraft") do |_name, _started, _finished, _id, payload|
597
+ OperationEntry.create!(
598
+ record_class: payload[:record_class],
599
+ record_id: payload[:record_id].to_s,
600
+ from_state: payload[:from],
601
+ to_state: payload[:to],
602
+ event_name: payload[:event],
603
+ outcome: "refused",
604
+ reason: payload[:reason].to_s
605
+ )
606
+ end
607
+ ```
608
+
609
+ Seeding through the honest pipeline, refusal narrative included:
610
+
611
+ <!-- readme: seed-pattern -->
612
+ ```ruby
613
+ # The refusal scenario WITH its narrative: the rescue is part of the plot —
614
+ # a cancellation attempt without a reason lands in the operations feed as a
615
+ # refusal, then the reasoned retry succeeds.
616
+ def seed_disputed_order
617
+ order = place_order(number: "ORD-1009", customer: "Ivy Chen",
618
+ items: { "Ceramic vase" => 2 })
619
+ begin
620
+ order.cancel!(metadata: {})
621
+ rescue Statecraft::GuardFailed
622
+ # the refusal is the point: the feed keeps it
623
+ end
624
+ order.cancel!(metadata: { "reason" => "dispute resolved in the customer's favor" })
625
+ order
626
+ end
627
+ ```
628
+
366
629
  ## Running the tests
367
630
 
368
631
  Natively, against SQLite:
@@ -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
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statecraft
4
+ # Renders the compiled graph as Mermaid stateDiagram-v2 text: the states,
5
+ # the initial marker and event-labeled edges in declaration order. The
6
+ # shape only — like transitions_from, no guards are consulted or rendered.
7
+ module Diagram
8
+ def to_mermaid
9
+ graph = finalize!
10
+ lines = ["stateDiagram-v2", " [*] --> #{graph.initial_state}"]
11
+ graph.edges.each_value do |edge|
12
+ arrow = " #{edge.from} --> #{edge.to}"
13
+ lines << (edge.event_names.empty? ? arrow : "#{arrow} : #{edge.event_names.join(" / ")}")
14
+ end
15
+ "#{lines.join("\n")}\n"
16
+ end
17
+ end
18
+ 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,
@@ -18,6 +23,7 @@ module Statecraft
18
23
 
19
24
  def self.included(machine_class)
20
25
  machine_class.extend(ClassMethods)
26
+ machine_class.extend(Diagram)
21
27
  end
22
28
 
23
29
  # Invokes a guard or callback handler with the honest-call convention:
@@ -53,14 +59,16 @@ module Statecraft
53
59
  declared_states << { name: name.to_sym, initial: initial }
54
60
  end
55
61
 
56
- def transition(from:, to:, guard: nil, lock: false)
62
+ def transition(from:, to:, guard: nil, record_guard: nil, lock: false)
57
63
  declared_edges << {
58
- from: from.to_sym, to: to.to_sym, guards: Array(guard), lock: lock || current_event_lock,
64
+ from: from.to_sym, to: to.to_sym,
65
+ guards: Array(guard), record_guards: Array(record_guard),
66
+ lock: lock || current_event_lock,
59
67
  event: current_event_name
60
68
  }
61
69
  end
62
70
 
63
- def event(name, from: nil, to: nil, guard: nil, lock: false, &declarations)
71
+ def event(name, from: nil, to: nil, guard: nil, record_guard: nil, lock: false, &declarations)
64
72
  name = name.to_sym
65
73
  declared_event_names << name
66
74
  if declarations
@@ -79,7 +87,7 @@ module Statecraft
79
87
 
80
88
  @statecraft_current_event = { name: name, lock: false }
81
89
  begin
82
- transition(from: from, to: to, guard: guard, lock: lock)
90
+ transition(from: from, to: to, guard: guard, record_guard: record_guard, lock: lock)
83
91
  ensure
84
92
  @statecraft_current_event = nil
85
93
  end
@@ -108,6 +116,18 @@ module Statecraft
108
116
  compiled_graph.events.keys
109
117
  end
110
118
 
119
+ # The graph's shape from one state: a frozen descriptor per outgoing
120
+ # edge, `events` empty for a bare edge. Shape, not a prediction — no
121
+ # guards are consulted, and a state outside the graph honestly owns no
122
+ # edges. Class-level on purpose: the answer is a property of the graph,
123
+ # never of a record.
124
+ def transitions_from(state)
125
+ normalized = state.to_sym
126
+ compiled_graph.edges.filter_map do |(from, _to), edge|
127
+ { to: edge.to, events: edge.event_names }.freeze if from == normalized
128
+ end.freeze
129
+ end
130
+
111
131
  def initial_state
112
132
  compiled_graph.initial_state
113
133
  end
@@ -176,6 +196,7 @@ module Statecraft
176
196
  edges = compile_edges(states)
177
197
  events = compile_events(edges)
178
198
  resolve_symbols(edges)
199
+ assert_record_guards_unary(edges)
179
200
  CompiledGraph.new(
180
201
  states: states.freeze,
181
202
  initial_state: initial,
@@ -228,7 +249,9 @@ module Statecraft
228
249
  def build_edge(declaration)
229
250
  Edge.new(
230
251
  from: declaration[:from], to: declaration[:to], lock: declaration[:lock],
231
- edge_guards: declaration[:guards], event_names: [], event_guards: {}
252
+ edge_guards: declaration[:record_guards] + declaration[:guards],
253
+ edge_record_guards: declaration[:record_guards],
254
+ event_names: [], event_guards: {}, event_record_guards: {}
232
255
  )
233
256
  end
234
257
 
@@ -236,14 +259,16 @@ module Statecraft
236
259
  event_name = declaration[:event]
237
260
  edge ||= Edge.new(
238
261
  from: declaration[:from], to: declaration[:to], lock: false,
239
- edge_guards: [], event_names: [], event_guards: {}
262
+ edge_guards: [], edge_record_guards: [],
263
+ event_names: [], event_guards: {}, event_record_guards: {}
240
264
  )
241
265
  if edge.event_names.include?(event_name)
242
266
  raise CompilationError, "event #{event_name.inspect} declares edge #{pair_name(pair)} twice"
243
267
  end
244
268
 
245
269
  edge.event_names << event_name
246
- edge.event_guards[event_name] = declaration[:guards]
270
+ edge.event_guards[event_name] = declaration[:record_guards] + declaration[:guards]
271
+ edge.event_record_guards[event_name] = declaration[:record_guards]
247
272
  edge.lock ||= declaration[:lock]
248
273
  edge
249
274
  end
@@ -286,12 +311,38 @@ module Statecraft
286
311
  (from_edges + from_callbacks).grep(Symbol)
287
312
  end
288
313
 
314
+ # A record guard promises to judge the record alone, and the promise is
315
+ # held by shape: it must accept exactly one argument, so it physically
316
+ # cannot read the input it claims not to need.
317
+ def assert_record_guards_unary(edges)
318
+ edges.each_value do |edge|
319
+ record_guards = edge.edge_record_guards + edge.event_record_guards.values.flatten
320
+ record_guards.each do |guard|
321
+ arity = record_guard_arity(guard)
322
+ next if arity == 1
323
+
324
+ label = guard.is_a?(Symbol) ? guard.inspect : "the callable"
325
+ raise CompilationError,
326
+ "record_guard #{label} must take exactly the record (arity 1), got arity #{arity}"
327
+ end
328
+ end
329
+ end
330
+
331
+ def record_guard_arity(guard)
332
+ return machine_class.instance_method(guard).arity if guard.is_a?(Symbol)
333
+
334
+ guard.respond_to?(:arity) ? guard.arity : guard.method(:call).arity
335
+ end
336
+
289
337
  def deep_freeze_edges(edges)
290
338
  edges.each_value do |edge|
291
339
  edge.edge_guards.freeze
340
+ edge.edge_record_guards.freeze
292
341
  edge.event_names.freeze
293
342
  edge.event_guards.each_value(&:freeze)
294
343
  edge.event_guards.freeze
344
+ edge.event_record_guards.each_value(&:freeze)
345
+ edge.event_record_guards.freeze
295
346
  edge.freeze
296
347
  end
297
348
  edges.freeze
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Statecraft
4
- VERSION = "0.1.3"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/statecraft.rb CHANGED
@@ -13,6 +13,7 @@ require_relative "statecraft/pipeline/edge_resolution"
13
13
  require_relative "statecraft/pipeline"
14
14
  require_relative "statecraft/pipeline/surface"
15
15
  require_relative "statecraft/introspection"
16
+ require_relative "statecraft/diagram"
16
17
  require_relative "statecraft/mounting"
17
18
 
18
19
  ActiveSupport.on_load(:active_record) do
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.3
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor Pugachev
@@ -70,6 +70,7 @@ files:
70
70
  - lib/generators/statecraft/machine/templates/model.rb.tt
71
71
  - lib/generators/statecraft/machine/templates/namespace_module.rb.tt
72
72
  - lib/statecraft.rb
73
+ - lib/statecraft/diagram.rb
73
74
  - lib/statecraft/errors.rb
74
75
  - lib/statecraft/instrumentation.rb
75
76
  - lib/statecraft/introspection.rb