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 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
+ [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](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