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 +4 -4
- data/README.md +267 -4
- data/lib/generators/statecraft/machine/templates/machine.rb.tt +8 -1
- data/lib/statecraft/diagram.rb +18 -0
- data/lib/statecraft/introspection.rb +41 -0
- data/lib/statecraft/machine.rb +59 -8
- data/lib/statecraft/version.rb +1 -1
- data/lib/statecraft.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1a49958ae3b97e963ada9ee0cb6f719bd0fa5a784704d7f1479f0b045a921ec1
|
|
4
|
+
data.tar.gz: e6fe7115b639e25d33e752800af3324822497413865236be7f53e01dc56674bb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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)
|
data/lib/statecraft/machine.rb
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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[:
|
|
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: [],
|
|
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
|
data/lib/statecraft/version.rb
CHANGED
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.
|
|
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
|