statecraft 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +354 -0
- data/lib/generators/statecraft/machine/machine_generator.rb +105 -0
- data/lib/statecraft/errors.rb +120 -0
- data/lib/statecraft/instrumentation.rb +47 -0
- data/lib/statecraft/introspection.rb +78 -0
- data/lib/statecraft/machine.rb +315 -0
- data/lib/statecraft/metadata.rb +51 -0
- data/lib/statecraft/mounting.rb +184 -0
- data/lib/statecraft/pipeline.rb +326 -0
- data/lib/statecraft/version.rb +5 -0
- data/lib/statecraft/warnings.rb +30 -0
- data/lib/statecraft.rb +18 -0
- metadata +96 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1694416ac7a88eff03c4f73c1fab5c0b2d8bd182f7c8d88d72c4eb1da2574ecc
|
|
4
|
+
data.tar.gz: 4fa98301ac81c1d5b2cee6007048fe05f2ea6db36400cb2d6844d8f19fef9011
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6cc41535452dc33140e39f342c07488491b595c945538fc71f661e2233d03f2e922a52a3182e6d2022f57c2eed5cc494810126189f280fc532aae973d5eb2f4b
|
|
7
|
+
data.tar.gz: bc485f03bcee46d53f027d955def314aa7f2836211811dba751c93b2a29eaa8887a944479d2fb62ab62f540aa0e43aca0813c95f115212488e9495f80a8b8f90
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Igor Pugachev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
# statecraft
|
|
2
|
+
|
|
3
|
+
[](LICENSE.txt)
|
|
4
|
+
|
|
5
|
+
A state machine for ActiveRecord where the current state lives in a column as
|
|
6
|
+
the single source of truth, history is an append-only per-model log with
|
|
7
|
+
write-once metadata, and every transition is guarded by a compare-and-swap
|
|
8
|
+
update inside a savepoint. Event-aware guards, an explicit bypass policy, and
|
|
9
|
+
telemetry for both successes and refusals.
|
|
10
|
+
|
|
11
|
+
statecraft is an **ActiveRecord gem**, not a Rails gem: its runtime depends on
|
|
12
|
+
`activerecord` and `activesupport` only. Anything that can call
|
|
13
|
+
`ActiveRecord::Base.establish_connection` gets the full pipeline; Rails adds
|
|
14
|
+
the generator as a progressive enhancement.
|
|
15
|
+
|
|
16
|
+
## Why
|
|
17
|
+
|
|
18
|
+
- `in_state?` and state scopes are a plain `WHERE` on a column — zero joins,
|
|
19
|
+
unlike log-derived current state (`most_recent`-style schemas).
|
|
20
|
+
- Concurrency safety comes from a CAS `UPDATE ... WHERE state = :expected`:
|
|
21
|
+
of N concurrent writers exactly one wins, the rest get a deterministic
|
|
22
|
+
`Statecraft::TransitionConflict`, and the log records exactly one row.
|
|
23
|
+
- The log carries first-class, write-once `jsonb` metadata: what the guards
|
|
24
|
+
checked is byte-for-byte what the log stored.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
gem "statecraft"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Requires Ruby >= 3.3 and ActiveRecord/ActiveSupport >= 7.2, < 9.
|
|
33
|
+
PostgreSQL is the first-class database; SQLite works for development and
|
|
34
|
+
tests (see [SQLite limits](#sqlite-limits)); MySQL is out of scope for v0.
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
bin/rails generate statecraft:machine Order
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The generator creates the migration (state column, `state_changed_at`, the
|
|
43
|
+
log table with a cascade FK — and a CHECK constraint when the table is
|
|
44
|
+
freshly created), the machine class, the readonly log model, and mounts the
|
|
45
|
+
machine into the model. By hand it looks like this:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
class OrderFlow < ApplicationMachine
|
|
49
|
+
state :pending, initial: true
|
|
50
|
+
state :paid
|
|
51
|
+
state :cancelled
|
|
52
|
+
|
|
53
|
+
event :pay, from: :pending, to: :paid, guard: :payable?
|
|
54
|
+
transition from: :pending, to: :cancelled
|
|
55
|
+
|
|
56
|
+
after_commit :enqueue_receipt, event: [:pay]
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def payable?(order, metadata)
|
|
61
|
+
metadata["amount"].to_i.positive?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def enqueue_receipt(order, transition)
|
|
65
|
+
ReceiptJob.perform_later(order.id, transition.log_record.id)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class Order < ApplicationRecord
|
|
70
|
+
state_machine OrderFlow, changed_at: true, helpers: true, scopes: true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
order = Order.create! # born :pending via the column default
|
|
74
|
+
order.pay!(metadata: { amount: 100, reason: :web }) # => the created OrderTransition row
|
|
75
|
+
order.in_state?(:paid) # => true
|
|
76
|
+
Order.paid.count # plain WHERE, zero joins
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Mounting options: `log:` (defaults to the `<Model>Transition` convention),
|
|
80
|
+
`column:` (default `:state`), `changed_at:` (off by default; `true` derives
|
|
81
|
+
`<column>_changed_at`), `touch:` (default `true` — `updated_at` is written in
|
|
82
|
+
the same CAS update), `helpers:` and `scopes:` (both off by default; the
|
|
83
|
+
generator turns them on for new code).
|
|
84
|
+
|
|
85
|
+
## The transition pipeline
|
|
86
|
+
|
|
87
|
+
`transition_to!` / `fire!` run one strict order:
|
|
88
|
+
|
|
89
|
+
1. `persisted?` check — transitioning an unsaved record raises
|
|
90
|
+
`Statecraft::UnsavedRecordError`: initial state comes from the column
|
|
91
|
+
default, so there is nothing to transition.
|
|
92
|
+
2. Metadata normalization and deep-freeze (see [Metadata](#metadata)).
|
|
93
|
+
3. Edge resolution and the bypass policy check.
|
|
94
|
+
4. Dirty check on `lock: true` edges — unsaved changes raise
|
|
95
|
+
`Statecraft::DirtyRecordError` instead of being silently destroyed by the
|
|
96
|
+
reload.
|
|
97
|
+
5. `transaction(requires_new: true)` — a savepoint inside your transaction,
|
|
98
|
+
a real transaction otherwise: optional `SELECT ... FOR UPDATE` + reload
|
|
99
|
+
(with edge re-resolution from the fresh state), guards, `before_transition`
|
|
100
|
+
callbacks, the CAS update (touching `updated_at` and the `changed_at`
|
|
101
|
+
column in the same statement), the log INSERT, `after_transition`.
|
|
102
|
+
6. `after_commit` callbacks are registered on the outermost real
|
|
103
|
+
transaction's commit.
|
|
104
|
+
|
|
105
|
+
A transition is **not** a record save: model validations and model callbacks
|
|
106
|
+
do not run, and unsaved changes on other attributes are neither saved nor
|
|
107
|
+
(without `lock:`) touched. Guards are the transition's validations; machine
|
|
108
|
+
callbacks are the transition's callbacks.
|
|
109
|
+
|
|
110
|
+
Bang variants return the created log record. Non-bang variants return it too,
|
|
111
|
+
or `false` — and `false` means exactly "a guard said no or the edge is not
|
|
112
|
+
declared" (`GuardFailed` / `InvalidTransition`). Everything else — including
|
|
113
|
+
`TransitionConflict` — always raises, in both variants.
|
|
114
|
+
|
|
115
|
+
## Guards, events and the bypass policy
|
|
116
|
+
|
|
117
|
+
A guard is attached to `(from, to, event-or-nil)`: an edge guard always runs,
|
|
118
|
+
an event guard runs only when the transition goes through that event. Within
|
|
119
|
+
one event, `from` is unique — an event is a partial function from state to
|
|
120
|
+
edge, so `fire!` is structurally deterministic. Branching by outcome means
|
|
121
|
+
two events (`pay` and `fail_payment`), not one event with two branches.
|
|
122
|
+
|
|
123
|
+
Calling `transition_to!` directly over an edge that carries event guards is
|
|
124
|
+
refused — the guards would be silently skipped. The escape hatch is explicit:
|
|
125
|
+
`transition_to!(:paid, bypass_events: true)` skips event guards (edge guards
|
|
126
|
+
still run) and records `event: nil` in the log, so audited bypasses stay
|
|
127
|
+
visible.
|
|
128
|
+
|
|
129
|
+
## Callbacks and chains
|
|
130
|
+
|
|
131
|
+
`before_transition`, `after_transition` and `after_commit` accept `from:`,
|
|
132
|
+
`to:` and `event:` filters (arrays welcome). Handlers — symbols resolving to
|
|
133
|
+
machine instance methods, or callables — receive `(record, transition)` where
|
|
134
|
+
`transition` carries `from`, `to`, `event`, `metadata` and (after the INSERT)
|
|
135
|
+
`log_record`.
|
|
136
|
+
|
|
137
|
+
Launching the next transition from `after_transition` is a supported pattern:
|
|
138
|
+
the chain writes one log row per hop and every `after_commit` waits for the
|
|
139
|
+
outermost commit. Two facts to know:
|
|
140
|
+
|
|
141
|
+
- **The after-commit order is inverted.** A nested transition registers its
|
|
142
|
+
`after_commit` before its parent does, so on commit the chain's callbacks
|
|
143
|
+
run innermost-first (`fulfill` before `pay`). This is documented, tested
|
|
144
|
+
semantics — do not "fix" it.
|
|
145
|
+
- **The chain depth ceiling is 16.** Deeper nesting raises
|
|
146
|
+
`Statecraft::ChainDepthExceeded` with the printed chain, which makes an
|
|
147
|
+
accidental cycle visible at a glance.
|
|
148
|
+
|
|
149
|
+
Transitioning the *same record* from a guard or from `before_transition`
|
|
150
|
+
raises `Statecraft::NestedTransitionError` immediately — the CAS would
|
|
151
|
+
otherwise reject itself and masquerade as a phantom race. Transitioning from
|
|
152
|
+
`after_commit` is an independent pipeline and is always legal.
|
|
153
|
+
|
|
154
|
+
## after_commit and transactional tests
|
|
155
|
+
|
|
156
|
+
`after_commit` follows Active Record transaction-callback semantics
|
|
157
|
+
everywhere, including transactional tests. **Inside your own transaction,
|
|
158
|
+
"the transition succeeded" does not mean "after_commit ran"** — the callback
|
|
159
|
+
waits for the outermost real commit and silently never runs if that
|
|
160
|
+
transaction rolls back, exactly like a model's `after_commit`. In a
|
|
161
|
+
transactional test, `order.pay!` behaves the way `record.save` with a model
|
|
162
|
+
after_commit callback behaves in that same test — one invariant, no special
|
|
163
|
+
cases to learn. Exceptions raised inside `after_commit` propagate as-is: the
|
|
164
|
+
transition is already committed, so they are handler errors, not transition
|
|
165
|
+
errors.
|
|
166
|
+
|
|
167
|
+
## Concurrency and isolation
|
|
168
|
+
|
|
169
|
+
The CAS update is the concurrency contract: on the default READ COMMITTED
|
|
170
|
+
isolation a race deterministically produces `TransitionConflict` for every
|
|
171
|
+
loser, with the expected `from` in the error. Rescuing the conflict inside
|
|
172
|
+
your transaction is safe: the savepoint has already rolled back the
|
|
173
|
+
pipeline's effects, your outer work stays intact, and you may retry from the
|
|
174
|
+
fresh state or take another branch.
|
|
175
|
+
|
|
176
|
+
`lock: true` on an edge or event adds `SELECT ... FOR UPDATE` plus a reload
|
|
177
|
+
before the guards. The row lock lives until your outermost transaction
|
|
178
|
+
commits — releasing the savepoint does not release it.
|
|
179
|
+
|
|
180
|
+
On stricter isolation levels (SERIALIZABLE) the same race may surface as
|
|
181
|
+
`ActiveRecord::SerializationFailure` before the CAS ever sees it — up to and
|
|
182
|
+
including commit time. statecraft passes the whole
|
|
183
|
+
`ActiveRecord::TransactionRollbackError` family (including `Deadlocked`)
|
|
184
|
+
through untouched: those errors mean "restart the whole transaction", a
|
|
185
|
+
different protocol than the conflict's "continue from clean state", and the
|
|
186
|
+
retry policy belongs to whoever chose the isolation level.
|
|
187
|
+
|
|
188
|
+
## Introspection
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
order.can_fire?(:pay, metadata: { amount: 100 }) # would the guards pass right now?
|
|
192
|
+
order.may_pay?(metadata: { amount: 100 }) # alias, with helpers: true
|
|
193
|
+
order.available_events(metadata: { amount: 100 }) # => [:pay]
|
|
194
|
+
order.available_transitions(metadata: {}) # => [#<to: :cancelled, via: [:direct]>]
|
|
195
|
+
order.transitioned_to?(:paid) # strictly log-based
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
`available_transitions` tells you not only *where* you can go but *how*:
|
|
199
|
+
`via` lists the events whose guards pass, plus `:direct` when the edge is
|
|
200
|
+
free of event guards and its edge guards pass. Every answer is a snapshot —
|
|
201
|
+
CAS may still reject the transition a moment later.
|
|
202
|
+
|
|
203
|
+
A guard that reads metadata makes `may_*?` depend on the metadata you pass.
|
|
204
|
+
For a UI "is this button available" question, either do not hang input
|
|
205
|
+
validation on a guard, or pass the same metadata to `may_*?` that you will
|
|
206
|
+
collect for `fire!`.
|
|
207
|
+
|
|
208
|
+
## Metadata
|
|
209
|
+
|
|
210
|
+
Metadata is normalized on pipeline entry with a full JSON round-trip —
|
|
211
|
+
symbol keys and values become strings, times become ISO-8601 strings — and
|
|
212
|
+
then deep-frozen: **the guards see exactly what the log will store**, and a
|
|
213
|
+
guard that mutates metadata dies with `FrozenError` in a transition and in a
|
|
214
|
+
check alike. Unserializable values (a `Proc`, a model instance) fail
|
|
215
|
+
instantly at the entrance, not inside the transaction.
|
|
216
|
+
|
|
217
|
+
Facts of the transition moment (a price snapshot, a rules version) are
|
|
218
|
+
collected by the caller: `order.pay!(metadata: { price: order.total })`.
|
|
219
|
+
There is no metadata schema mechanism — required fields are enforced by
|
|
220
|
+
guards — and the shape evolves by convention: carry a `v:` key when you need
|
|
221
|
+
versioned readers.
|
|
222
|
+
|
|
223
|
+
## Initial state is not a transition
|
|
224
|
+
|
|
225
|
+
Creating a record is not a transition: rows enter the initial state through
|
|
226
|
+
the column default (which also covers `insert_all`, fixtures, seeds and ETL),
|
|
227
|
+
and the log stays silent about births — a consistently silent audit beats an
|
|
228
|
+
inconsistently chatty one.
|
|
229
|
+
|
|
230
|
+
| Question | Answer |
|
|
231
|
+
|---|---|
|
|
232
|
+
| When did the record enter the initial state? | `created_at` |
|
|
233
|
+
| Has it ever *transitioned to* `:pending`? | `transitioned_to?(:pending)` — `false` until a real transition (a loop or a return counts) |
|
|
234
|
+
| Is it in `:pending` now? | `in_state?(:pending)` / `where(state: :pending)` |
|
|
235
|
+
|
|
236
|
+
## The log model is a read-model
|
|
237
|
+
|
|
238
|
+
Scopes, reading methods and serializers on the log class are yours. Writing
|
|
239
|
+
is not: the pipeline inserts rows through the insert path, so validations and
|
|
240
|
+
callbacks declared on the log model never run — guards and machine callbacks
|
|
241
|
+
are the single channel of truth. The generated `readonly?` makes persisted
|
|
242
|
+
rows reject `update!` and `destroy`; it protects against accidental edits,
|
|
243
|
+
not malicious ones (`update_all` and raw SQL still work — the real guarantee
|
|
244
|
+
would be database triggers, which are out of v0). Deleting `readonly?` in
|
|
245
|
+
your generated class is a supported customization.
|
|
246
|
+
|
|
247
|
+
Deleting the parent record cascades to its log rows at the database level
|
|
248
|
+
(`ON DELETE CASCADE`): an audit without its subject is not an audit. If your
|
|
249
|
+
requirement is "history survives deletion", the answer is soft-deleting the
|
|
250
|
+
record, not an FK mode — and swapping `:cascade` for `:restrict` in your
|
|
251
|
+
generated migration is supported if you disagree.
|
|
252
|
+
|
|
253
|
+
## Configuration
|
|
254
|
+
|
|
255
|
+
There is none — deliberately. Every knob lives with its subject: mounting
|
|
256
|
+
options on `state_machine`, `lock:` on edges and events, schema choices in
|
|
257
|
+
your generated migration, `readonly?` in your generated log class. No
|
|
258
|
+
initializer, no `Statecraft.configure`. If a future feature genuinely needs
|
|
259
|
+
process-level configuration it will arrive as a designed decision, not as a
|
|
260
|
+
convenience knob.
|
|
261
|
+
|
|
262
|
+
## Renaming a state
|
|
263
|
+
|
|
264
|
+
The log is never migrated: history is written in the words of its time, and
|
|
265
|
+
every reading API handles log rows whose state names are no longer in the
|
|
266
|
+
graph. To rename `:pending` to `:awaiting_payment`, follow the three-phase
|
|
267
|
+
rolling rename recipe on the column:
|
|
268
|
+
|
|
269
|
+
1. Declare **both** states in the machine with duplicated edges; widen the
|
|
270
|
+
CHECK constraint to both names (adding a value is cheap).
|
|
271
|
+
2. Batch-`UPDATE` the column. Races are loud by construction: a row
|
|
272
|
+
repainted under a live transition makes its CAS miss and raise
|
|
273
|
+
`TransitionConflict` — retry from the new name. For a large table, add
|
|
274
|
+
the widened constraint as `NOT VALID` first and `VALIDATE CONSTRAINT`
|
|
275
|
+
after the cleanup.
|
|
276
|
+
3. Drop the old state and its edges; narrow the CHECK back.
|
|
277
|
+
|
|
278
|
+
## PII and erasure
|
|
279
|
+
|
|
280
|
+
Metadata is the only place personal data can live — `from_state`, `to_state`
|
|
281
|
+
and `event` never carry it, and telemetry payloads exclude metadata entirely.
|
|
282
|
+
Three layers, in order of preference:
|
|
283
|
+
|
|
284
|
+
1. **References, not values.** Store `{ user_id: 42 }`, not an email. Erasure
|
|
285
|
+
then touches the referent, never the log.
|
|
286
|
+
2. **Hard delete.** `destroy` cascades to the log rows for free.
|
|
287
|
+
3. **Soft delete + scrubbing.** Administrative erasure works at the relation
|
|
288
|
+
level, past `readonly?`:
|
|
289
|
+
`order.history.where(...).update_all(metadata: { scrubbed_at: Time.current.iso8601 })`.
|
|
290
|
+
The tombstone convention keeps the audit honest: "there was data here,
|
|
291
|
+
erased on request" is a legally different statement than "there was
|
|
292
|
+
nothing".
|
|
293
|
+
|
|
294
|
+
## STI
|
|
295
|
+
|
|
296
|
+
Mounting a machine on an STI base class is promised behavior: subclasses
|
|
297
|
+
inherit the machine, helpers and scopes (`CreditOrder.pending` scopes the
|
|
298
|
+
subclass by `type` + `state`, exactly like an enum scope would), CAS and the
|
|
299
|
+
log FK hit the base class's table, and guards receive the actual subclass
|
|
300
|
+
instance. Two honest boundaries: name-conflict checks at mounting cover the
|
|
301
|
+
mounting class only — a subclass method shadowing a generated verb is plain
|
|
302
|
+
Ruby method overriding; and mounting a *different* machine in a subclass is
|
|
303
|
+
the multi-machine feature, out of v0 — it raises `Statecraft::AlreadyMounted`
|
|
304
|
+
at the threshold.
|
|
305
|
+
|
|
306
|
+
## Multiple databases
|
|
307
|
+
|
|
308
|
+
The log lives next to its model, always — a cascade FK cannot cross
|
|
309
|
+
databases, so this is a definition, not a restriction. The generated log
|
|
310
|
+
class inherits the model's connection-owning ancestor (base, roles and
|
|
311
|
+
horizontal shards follow automatically), and the generator drops the
|
|
312
|
+
migration into that connection's migration path. Mounting verifies connection
|
|
313
|
+
identity — same pool, same per-thread connection, one real transaction — and
|
|
314
|
+
raises `Statecraft::ConnectionMismatch` with a fix hint otherwise. Two
|
|
315
|
+
`connects_to` blocks pointing at one physical database are still two pools:
|
|
316
|
+
that also fails the check, correctly.
|
|
317
|
+
|
|
318
|
+
## Outside Rails
|
|
319
|
+
|
|
320
|
+
No railties at runtime — the hygiene is enforced by a test, not a promise.
|
|
321
|
+
Without the generator, create the schema by hand; the reference shape:
|
|
322
|
+
|
|
323
|
+
```ruby
|
|
324
|
+
create_table :orders do |t|
|
|
325
|
+
t.string :state, null: false, default: "pending", index: true
|
|
326
|
+
t.datetime :state_changed_at
|
|
327
|
+
t.timestamps null: false
|
|
328
|
+
end
|
|
329
|
+
add_check_constraint :orders, "state IN ('pending')", name: "orders_state_check"
|
|
330
|
+
|
|
331
|
+
create_table :order_transitions do |t|
|
|
332
|
+
t.references :order, null: false,
|
|
333
|
+
foreign_key: { on_delete: :cascade }, index: false
|
|
334
|
+
t.string :from_state, null: false
|
|
335
|
+
t.string :to_state, null: false
|
|
336
|
+
t.string :event
|
|
337
|
+
t.jsonb :metadata, null: false, default: {}
|
|
338
|
+
t.datetime :created_at, null: false
|
|
339
|
+
t.index %i[order_id id]
|
|
340
|
+
end
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
## SQLite limits
|
|
344
|
+
|
|
345
|
+
SQLite is a development and test database: `metadata` falls back to
|
|
346
|
+
`json`/text, concurrency specs skip (the concurrency proof runs on
|
|
347
|
+
PostgreSQL in CI), and `lock: true` degrades the way ActiveRecord itself
|
|
348
|
+
degrades — the locking clause is dropped, the reload still runs, and
|
|
349
|
+
statecraft warns once per machine per process that row-locking guarantees
|
|
350
|
+
require PostgreSQL.
|
|
351
|
+
|
|
352
|
+
## License
|
|
353
|
+
|
|
354
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Statecraft
|
|
7
|
+
module Generators
|
|
8
|
+
# `rails generate statecraft:machine Order` — the single statecraft
|
|
9
|
+
# generator. Creates the migration (state column, optional changed_at,
|
|
10
|
+
# the per-model log table with a cascade FK and a CHECK constraint for a
|
|
11
|
+
# freshly created table), the machine class, the readonly log model, the
|
|
12
|
+
# model mounting with helpers and scopes on, and lazily the shared
|
|
13
|
+
# ApplicationMachine parent. The migration lands in the migration path of
|
|
14
|
+
# the model's connection owner, so multi-database apps get it next to
|
|
15
|
+
# their model.
|
|
16
|
+
class MachineGenerator < Rails::Generators::NamedBase
|
|
17
|
+
include ActiveRecord::Generators::Migration
|
|
18
|
+
|
|
19
|
+
source_root File.expand_path("templates", __dir__)
|
|
20
|
+
|
|
21
|
+
def detect_model_presence
|
|
22
|
+
@existing_model = File.exist?(File.join(destination_root, "app/models/#{file_name}.rb"))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def create_application_machine
|
|
26
|
+
application_machine_path = "app/state_machines/application_machine.rb"
|
|
27
|
+
return if File.exist?(File.join(destination_root, application_machine_path))
|
|
28
|
+
|
|
29
|
+
template "application_machine.rb.tt", application_machine_path
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def create_machine_class
|
|
33
|
+
template "machine.rb.tt", "app/state_machines/#{file_name}_flow.rb"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def create_log_model
|
|
37
|
+
template "log_model.rb.tt", "app/models/#{file_name}_transition.rb"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def create_or_mount_model
|
|
41
|
+
if existing_model?
|
|
42
|
+
inject_into_class "app/models/#{file_name}.rb", class_name, mounting_line
|
|
43
|
+
else
|
|
44
|
+
template "model.rb.tt", "app/models/#{file_name}.rb"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def create_migration_file
|
|
49
|
+
migration_source = existing_model? ? "add_migration.rb.tt" : "create_migration.rb.tt"
|
|
50
|
+
migration_template migration_source,
|
|
51
|
+
"#{migration_directory}/create_#{file_name}_state_machine.rb"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def existing_model?
|
|
57
|
+
@existing_model
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def mounting_line
|
|
61
|
+
" state_machine #{class_name}Flow, changed_at: true, helpers: true, scopes: true\n"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def migration_directory
|
|
65
|
+
specification_name = model_connection_specification_name
|
|
66
|
+
return "db/migrate" if specification_name.nil? || specification_name == "ActiveRecord::Base"
|
|
67
|
+
|
|
68
|
+
"db/#{specification_name.underscore.tr("/", "_")}_migrate"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def model_connection_specification_name
|
|
72
|
+
model_class = class_name.safe_constantize
|
|
73
|
+
model_class&.connection_specification_name
|
|
74
|
+
rescue StandardError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def log_table_name
|
|
79
|
+
"#{table_name.singularize}_transitions"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def parent_class_name
|
|
83
|
+
specification_name = model_connection_specification_name
|
|
84
|
+
return "ApplicationRecord" if specification_name.nil? || specification_name == "ActiveRecord::Base"
|
|
85
|
+
|
|
86
|
+
specification_name
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def foreign_key_column
|
|
90
|
+
"#{file_name}_id"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def migration_class_name
|
|
94
|
+
"Create#{class_name}StateMachine"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def metadata_column_type
|
|
98
|
+
adapter = ActiveRecord::Base.connection.adapter_name
|
|
99
|
+
adapter.match?(/postg/i) ? "jsonb" : "json"
|
|
100
|
+
rescue ActiveRecord::ActiveRecordError
|
|
101
|
+
"jsonb"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class CompilationError < Error; end
|
|
7
|
+
|
|
8
|
+
class GuardFailed < Error
|
|
9
|
+
attr_reader :record, :guard, :from, :to, :event
|
|
10
|
+
|
|
11
|
+
def initialize(record:, guard:, from:, to:, event:)
|
|
12
|
+
@record = record
|
|
13
|
+
@guard = guard
|
|
14
|
+
@from = from
|
|
15
|
+
@to = to
|
|
16
|
+
@event = event
|
|
17
|
+
via = event ? " (event #{event})" : nil
|
|
18
|
+
super("guard #{guard.inspect} failed for #{record.class.name}##{record.id} " \
|
|
19
|
+
"on #{from} -> #{to}#{via}")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class InvalidTransition < Error
|
|
24
|
+
attr_reader :record, :from, :requested, :allowed
|
|
25
|
+
|
|
26
|
+
def initialize(record:, from:, requested:, allowed:, message: nil)
|
|
27
|
+
@record = record
|
|
28
|
+
@from = from
|
|
29
|
+
@requested = requested
|
|
30
|
+
@allowed = allowed
|
|
31
|
+
default_message = "transition #{from} -> #{requested} is not declared for " \
|
|
32
|
+
"#{record.class.name}; allowed from #{from}: " \
|
|
33
|
+
"#{allowed.empty? ? "none" : allowed.join(", ")}"
|
|
34
|
+
super(message || default_message)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class TransitionConflict < Error
|
|
39
|
+
attr_reader :record, :expected_from
|
|
40
|
+
|
|
41
|
+
def initialize(record:, expected_from:)
|
|
42
|
+
@record = record
|
|
43
|
+
@expected_from = expected_from
|
|
44
|
+
super("concurrent update detected for #{record.class.name}##{record.id}: " \
|
|
45
|
+
"expected state #{expected_from.inspect}, another writer got there first")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class UnsavedRecordError < Error
|
|
50
|
+
attr_reader :record
|
|
51
|
+
|
|
52
|
+
def initialize(record:)
|
|
53
|
+
@record = record
|
|
54
|
+
super("cannot transition an unsaved #{record.class.name}: save the record first; " \
|
|
55
|
+
"initial state comes from the column default")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class DirtyRecordError < Error
|
|
60
|
+
attr_reader :record, :changed_attributes
|
|
61
|
+
|
|
62
|
+
def initialize(record:, changed_attributes:)
|
|
63
|
+
@record = record
|
|
64
|
+
@changed_attributes = changed_attributes
|
|
65
|
+
super("cannot lock-transition #{record.class.name}##{record.id} with unsaved changes " \
|
|
66
|
+
"(#{changed_attributes.join(", ")}): save or reload before transitioning")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
class NestedTransitionError < Error
|
|
71
|
+
attr_reader :record
|
|
72
|
+
|
|
73
|
+
def initialize(record:)
|
|
74
|
+
@record = record
|
|
75
|
+
super("transition initiated from before_transition or a guard of another transition " \
|
|
76
|
+
"on #{record.class.name}##{record.id}; move it to after_transition or outside")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class ChainDepthExceeded < Error
|
|
81
|
+
attr_reader :chain
|
|
82
|
+
|
|
83
|
+
def initialize(chain:)
|
|
84
|
+
@chain = chain
|
|
85
|
+
super("transition chain exceeded depth #{chain.length}: #{chain.join(" -> ")}")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
class AlreadyMounted < Error
|
|
90
|
+
attr_reader :model
|
|
91
|
+
|
|
92
|
+
def initialize(model:)
|
|
93
|
+
@model = model
|
|
94
|
+
super("#{model.name} already carries a state machine; inheritance shares the base " \
|
|
95
|
+
"machine, and per-subclass machines are out of v0")
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
class CompositePrimaryKeyUnsupported < Error
|
|
100
|
+
attr_reader :model
|
|
101
|
+
|
|
102
|
+
def initialize(model:)
|
|
103
|
+
@model = model
|
|
104
|
+
super("#{model.name} uses a composite primary key; statecraft v0 supports " \
|
|
105
|
+
"single-column primary keys only")
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
class ConnectionMismatch < Error
|
|
110
|
+
attr_reader :model, :log_class
|
|
111
|
+
|
|
112
|
+
def initialize(model:, log_class:)
|
|
113
|
+
@model = model
|
|
114
|
+
@log_class = log_class
|
|
115
|
+
super("#{log_class.name} does not share #{model.name}'s connection " \
|
|
116
|
+
"(#{log_class.connection_specification_name} vs #{model.connection_specification_name}); " \
|
|
117
|
+
"make the log model inherit from the model's connection-owning ancestor")
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
# Publishes the two ActiveSupport::Notifications events of the pipeline.
|
|
5
|
+
# Success and refusal need different event names, so timing is measured
|
|
6
|
+
# manually and published with explicit start/finish instead of a naive
|
|
7
|
+
# instrument block. Metadata never enters a payload: subscribers routinely
|
|
8
|
+
# log payloads whole, and metadata is the one place PII can live — whoever
|
|
9
|
+
# needs it reads the log record. Refusal events are published after the
|
|
10
|
+
# savepoint rollback, outside the transaction, so a subscriber writing to
|
|
11
|
+
# the database is never rolled back together with the pipeline.
|
|
12
|
+
module Instrumentation
|
|
13
|
+
TRANSITION_EVENT = "transition.statecraft"
|
|
14
|
+
FAILURE_EVENT = "transition_failed.statecraft"
|
|
15
|
+
|
|
16
|
+
def self.publish_transition(started_at:, record:, machine_class:, log_record:)
|
|
17
|
+
publish(
|
|
18
|
+
TRANSITION_EVENT, started_at,
|
|
19
|
+
record_class: record.class.base_class.name,
|
|
20
|
+
record_id: record.id,
|
|
21
|
+
machine: machine_class.name,
|
|
22
|
+
from: log_record.from_state.to_sym,
|
|
23
|
+
to: log_record.to_state.to_sym,
|
|
24
|
+
event: log_record.event&.to_sym
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.publish_failure(started_at:, record:, machine_class:, reason:, details:)
|
|
29
|
+
publish(
|
|
30
|
+
FAILURE_EVENT, started_at,
|
|
31
|
+
{
|
|
32
|
+
record_class: record.class.base_class.name,
|
|
33
|
+
record_id: record.id,
|
|
34
|
+
machine: machine_class.name,
|
|
35
|
+
reason: reason
|
|
36
|
+
}.merge(details)
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.publish(event_name, started_at, payload)
|
|
41
|
+
ActiveSupport::Notifications.publish(
|
|
42
|
+
event_name, started_at, Time.current, "statecraft", payload
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
private_class_method :publish
|
|
46
|
+
end
|
|
47
|
+
end
|