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 +4 -4
- data/README.md +265 -8
- data/lib/generators/statecraft/machine/USAGE +26 -0
- data/lib/generators/statecraft/machine/templates/machine.rb.tt +8 -1
- data/lib/statecraft/introspection.rb +41 -0
- data/lib/statecraft/machine.rb +78 -13
- data/lib/statecraft/mounting.rb +15 -0
- data/lib/statecraft/pipeline/edge_resolution.rb +68 -0
- data/lib/statecraft/pipeline.rb +4 -37
- data/lib/statecraft/version.rb +1 -1
- data/lib/statecraft.rb +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6000e276c5fbe7a0ba8b33724c8cf6deea30dc0e8378931166e5123f799ee2a4
|
|
4
|
+
data.tar.gz: 40ab4510c265cb13a811faac8a3c04003222f1f57644233860a6f5d6031f8b12
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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`
|
|
117
|
-
declared
|
|
118
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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)
|
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,
|
|
@@ -40,24 +45,30 @@ module Statecraft
|
|
|
40
45
|
end
|
|
41
46
|
|
|
42
47
|
def self.unary?(callable)
|
|
43
|
-
callable.arity
|
|
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,
|
|
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)
|
|
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[:
|
|
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: [],
|
|
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).
|
|
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
|
data/lib/statecraft/mounting.rb
CHANGED
|
@@ -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
|
data/lib/statecraft/pipeline.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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],
|
data/lib/statecraft/version.rb
CHANGED
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.
|
|
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
|