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.
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statecraft
4
+ # The record-facing questions: "would the guards pass if I fired this
5
+ # transition with these metadata right now". Normalization and freeze are
6
+ # identical to the pipeline's, so a guard that mutates metadata fails the
7
+ # same way in a check as in a transition, and the answer never diverges from
8
+ # what fire! would actually do. Every answer is a snapshot: CAS may still
9
+ # reject the transition later.
10
+ module Introspection
11
+ Availability = Struct.new(:to, :via, keyword_init: true)
12
+
13
+ def can_fire?(event_name, metadata: {})
14
+ graph = statecraft_graph
15
+ branches = graph.events[event_name.to_sym]
16
+ return false unless branches
17
+
18
+ edge = branches[statecraft_current_state]
19
+ return false unless edge
20
+
21
+ statecraft_guards_pass?(edge, event_name.to_sym, Metadata.normalize(metadata))
22
+ end
23
+
24
+ def available_events(metadata: {})
25
+ normalized = Metadata.normalize(metadata)
26
+ statecraft_graph.events.filter_map do |event_name, branches|
27
+ edge = branches[statecraft_current_state]
28
+ next unless edge
29
+
30
+ event_name if statecraft_guards_pass?(edge, event_name, normalized)
31
+ end
32
+ end
33
+
34
+ def available_transitions(metadata: {})
35
+ normalized = Metadata.normalize(metadata)
36
+ statecraft_graph.edges.filter_map do |(from, _to), edge|
37
+ next unless from == statecraft_current_state
38
+
39
+ via = statecraft_passable_via(edge, normalized)
40
+ Availability.new(to: edge.to, via: via) unless via.empty?
41
+ end
42
+ end
43
+
44
+ def transitioned_to?(state_name)
45
+ history.where(to_state: state_name.to_s).exists?
46
+ end
47
+
48
+ private
49
+
50
+ def statecraft_passable_via(edge, normalized_metadata)
51
+ via = edge.event_names.select do |event_name|
52
+ statecraft_guards_pass?(edge, event_name, normalized_metadata)
53
+ end
54
+ direct = statecraft_direct_legal?(edge) && statecraft_guards_pass?(edge, nil, normalized_metadata)
55
+ direct ? via + [:direct] : via
56
+ end
57
+
58
+ def statecraft_direct_legal?(edge)
59
+ edge.event_guards.values.all?(&:empty?)
60
+ end
61
+
62
+ def statecraft_guards_pass?(edge, event_name, normalized_metadata)
63
+ machine_instance = self.class.statecraft_mounting.machine_class.new
64
+ guards = edge.edge_guards + (event_name ? edge.event_guards.fetch(event_name, []) : [])
65
+ guards.all? do |guard|
66
+ Machine::Handlers.invoke(machine_instance, guard, self, normalized_metadata)
67
+ end
68
+ end
69
+
70
+ def statecraft_graph
71
+ self.class.statecraft_mounting.machine_class.finalize!
72
+ end
73
+
74
+ def statecraft_current_state
75
+ self[self.class.statecraft_mounting.column].to_s.to_sym
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statecraft
4
+ # The machine DSL and its compiler. A machine is declared in a dedicated
5
+ # class (`include Statecraft::Machine`), collects states, edges, events and
6
+ # callbacks, and compiles them into a deep-frozen graph on finalization.
7
+ # Guard and callback symbols resolve to instance methods of the machine
8
+ # class; callables are honored with a plain `call` and arity dispatch.
9
+ module Machine
10
+ Edge = Struct.new(:from, :to, :lock, :edge_guards, :event_names, :event_guards, keyword_init: true)
11
+ Callback = Struct.new(:handler, :from, :to, :event, keyword_init: true)
12
+ CompiledGraph = Struct.new(
13
+ :states, :initial_state, :edges, :events, :callbacks,
14
+ keyword_init: true
15
+ )
16
+
17
+ CALLBACK_PHASES = %i[before_transition after_transition after_commit].freeze
18
+
19
+ def self.included(machine_class)
20
+ machine_class.extend(ClassMethods)
21
+ end
22
+
23
+ # Invokes a guard or callback handler with the honest-call convention:
24
+ # a Symbol resolves to a (possibly private) instance method of the machine,
25
+ # a callable is called as-is with `self` untouched. Arity 1 receives the
26
+ # record alone, everything else receives (record, payload) — the same
27
+ # dispatch Active Record validators use.
28
+ module Handlers
29
+ def self.invoke(machine_instance, handler, record, payload)
30
+ callable = resolve(machine_instance, handler)
31
+ if unary?(callable)
32
+ callable.call(record)
33
+ else
34
+ callable.call(record, payload)
35
+ end
36
+ end
37
+
38
+ def self.resolve(machine_instance, handler)
39
+ handler.is_a?(Symbol) ? machine_instance.method(handler) : handler
40
+ end
41
+
42
+ def self.unary?(callable)
43
+ callable.arity == 1
44
+ end
45
+ end
46
+
47
+ # Class-level DSL collected declaratively and compiled by finalize!.
48
+ module ClassMethods
49
+ def state(name, initial: false)
50
+ declared_states << { name: name, initial: initial }
51
+ end
52
+
53
+ def transition(from:, to:, guard: nil, lock: false)
54
+ declared_edges << {
55
+ from: from, to: to, guards: Array(guard), lock: lock || current_event_lock,
56
+ event: current_event_name
57
+ }
58
+ end
59
+
60
+ def event(name, from: nil, to: nil, guard: nil, lock: false, &declarations)
61
+ declared_event_names << name
62
+ if declarations
63
+ if from || to
64
+ raise CompilationError, "event #{name.inspect} takes either inline from:/to: or a block, not both"
65
+ end
66
+
67
+ begin
68
+ @statecraft_current_event = { name: name, lock: lock }
69
+ yield
70
+ ensure
71
+ @statecraft_current_event = nil
72
+ end
73
+ else
74
+ raise CompilationError, "event #{name.inspect} needs from:/to: or a block" if from.nil? || to.nil?
75
+
76
+ @statecraft_current_event = { name: name, lock: false }
77
+ begin
78
+ transition(from: from, to: to, guard: guard, lock: lock)
79
+ ensure
80
+ @statecraft_current_event = nil
81
+ end
82
+ end
83
+ end
84
+
85
+ CALLBACK_PHASES.each do |phase|
86
+ define_method(phase) do |handler = nil, from: nil, to: nil, event: nil, &block|
87
+ callback_handler = handler || block
88
+ raise CompilationError, "#{phase} needs a method name or a callable" if callback_handler.nil?
89
+
90
+ declared_callbacks[phase] << Callback.new(
91
+ handler: callback_handler,
92
+ from: from && Array(from), to: to && Array(to), event: event && Array(event)
93
+ )
94
+ end
95
+ end
96
+
97
+ def states
98
+ compiled_graph.states
99
+ end
100
+
101
+ def events
102
+ compiled_graph.events.keys
103
+ end
104
+
105
+ def initial_state
106
+ compiled_graph.initial_state
107
+ end
108
+
109
+ def compiled_graph
110
+ finalize!
111
+ end
112
+
113
+ def finalized?
114
+ !@statecraft_compiled_graph.nil?
115
+ end
116
+
117
+ def finalize!
118
+ @statecraft_compiled_graph ||= Compiler.new(self).compile
119
+ end
120
+
121
+ def declared_states
122
+ @statecraft_declared_states ||= []
123
+ end
124
+
125
+ def declared_edges
126
+ @statecraft_declared_edges ||= []
127
+ end
128
+
129
+ def declared_event_names
130
+ @statecraft_declared_event_names ||= []
131
+ end
132
+
133
+ def declared_callbacks
134
+ @statecraft_declared_callbacks ||= CALLBACK_PHASES.to_h { |phase| [phase, []] }
135
+ end
136
+
137
+ private
138
+
139
+ def current_event_name
140
+ @statecraft_current_event && @statecraft_current_event[:name]
141
+ end
142
+
143
+ def current_event_lock
144
+ @statecraft_current_event ? @statecraft_current_event[:lock] : false
145
+ end
146
+ end
147
+
148
+ # Validates the declarations and produces the deep-frozen graph. All five
149
+ # compilation errors live here, plus symbol resolution: every Symbol guard
150
+ # or callback must exist as an instance method of the machine class at
151
+ # finalization time (never checked at the declaration line, so a guard may
152
+ # be declared above its def).
153
+ class Compiler
154
+ def initialize(machine_class)
155
+ @machine_class = machine_class
156
+ end
157
+
158
+ def compile
159
+ states = compile_states
160
+ initial = compile_initial(states)
161
+ edges = compile_edges(states)
162
+ events = compile_events(edges)
163
+ resolve_symbols(edges)
164
+ CompiledGraph.new(
165
+ states: states.freeze,
166
+ initial_state: initial,
167
+ edges: deep_freeze_edges(edges),
168
+ events: freeze_events(events),
169
+ callbacks: freeze_callbacks
170
+ ).freeze
171
+ end
172
+
173
+ private
174
+
175
+ attr_reader :machine_class
176
+
177
+ def compile_states
178
+ names = machine_class.declared_states.map { |declaration| declaration[:name] }
179
+ duplicate = names.tally.find { |_name, count| count > 1 }
180
+ raise CompilationError, "state #{duplicate.first.inspect} is declared twice" if duplicate
181
+
182
+ names
183
+ end
184
+
185
+ def compile_initial(states)
186
+ initials = machine_class.declared_states.select { |declaration| declaration[:initial] }.map { |d| d[:name] }
187
+ raise CompilationError, "exactly one initial state is required, got none" if initials.empty?
188
+ if initials.length > 1
189
+ raise CompilationError, "exactly one initial state is required, got #{initials.join(", ")}"
190
+ end
191
+
192
+ initials.first.tap { |initial| assert_known_state(initial, states) }
193
+ end
194
+
195
+ def compile_edges(states)
196
+ edges = {}
197
+ machine_class.declared_edges.each do |declaration|
198
+ assert_known_state(declaration[:from], states)
199
+ assert_known_state(declaration[:to], states)
200
+ pair = [declaration[:from], declaration[:to]]
201
+ edge = edges[pair]
202
+ if declaration[:event].nil?
203
+ raise CompilationError, "duplicate edge #{pair_name(pair)}" if edge
204
+
205
+ edges[pair] = build_edge(declaration)
206
+ else
207
+ edges[pair] = attach_event(edge, declaration, pair)
208
+ end
209
+ end
210
+ edges
211
+ end
212
+
213
+ def build_edge(declaration)
214
+ Edge.new(
215
+ from: declaration[:from], to: declaration[:to], lock: declaration[:lock],
216
+ edge_guards: declaration[:guards], event_names: [], event_guards: {}
217
+ )
218
+ end
219
+
220
+ def attach_event(edge, declaration, pair)
221
+ event_name = declaration[:event]
222
+ edge ||= Edge.new(
223
+ from: declaration[:from], to: declaration[:to], lock: false,
224
+ edge_guards: [], event_names: [], event_guards: {}
225
+ )
226
+ if edge.event_names.include?(event_name)
227
+ raise CompilationError, "event #{event_name.inspect} declares edge #{pair_name(pair)} twice"
228
+ end
229
+
230
+ edge.event_names << event_name
231
+ edge.event_guards[event_name] = declaration[:guards]
232
+ edge.lock ||= declaration[:lock]
233
+ edge
234
+ end
235
+
236
+ def compile_events(edges)
237
+ events = machine_class.declared_event_names.to_h { |name| [name, {}] }
238
+ edges.each_value do |edge|
239
+ edge.event_names.each do |event_name|
240
+ branches = events[event_name]
241
+ if branches.key?(edge.from)
242
+ raise CompilationError,
243
+ "event #{event_name.inspect} has two edges from #{edge.from.inspect}: " \
244
+ "within one event, from must be unique"
245
+ end
246
+ branches[edge.from] = edge
247
+ end
248
+ end
249
+ events.each do |name, branches|
250
+ raise CompilationError, "event #{name.inspect} has no edges" if branches.empty?
251
+ end
252
+ events
253
+ end
254
+
255
+ def resolve_symbols(edges)
256
+ symbol_handlers(edges).each do |symbol|
257
+ next if machine_class.method_defined?(symbol) || machine_class.private_method_defined?(symbol)
258
+
259
+ raise CompilationError,
260
+ "guard or callback #{symbol.inspect} is not defined on #{machine_class.name || "the machine class"}"
261
+ end
262
+ end
263
+
264
+ def symbol_handlers(edges)
265
+ from_edges = edges.each_value.flat_map do |edge|
266
+ edge.edge_guards + edge.event_guards.values.flatten
267
+ end
268
+ from_callbacks = machine_class.declared_callbacks.each_value.flat_map do |callbacks|
269
+ callbacks.map(&:handler)
270
+ end
271
+ (from_edges + from_callbacks).select { |handler| handler.is_a?(Symbol) }
272
+ end
273
+
274
+ def deep_freeze_edges(edges)
275
+ edges.each_value do |edge|
276
+ edge.edge_guards.freeze
277
+ edge.event_names.freeze
278
+ edge.event_guards.each_value(&:freeze)
279
+ edge.event_guards.freeze
280
+ edge.freeze
281
+ end
282
+ edges.freeze
283
+ end
284
+
285
+ def freeze_events(events)
286
+ events.each_value(&:freeze)
287
+ events.freeze
288
+ end
289
+
290
+ def freeze_callbacks
291
+ callbacks = machine_class.declared_callbacks
292
+ callbacks.each_value do |list|
293
+ list.each do |callback|
294
+ callback.from&.freeze
295
+ callback.to&.freeze
296
+ callback.event&.freeze
297
+ callback.freeze
298
+ end
299
+ list.freeze
300
+ end
301
+ callbacks.freeze
302
+ end
303
+
304
+ def assert_known_state(name, states)
305
+ return if states.include?(name)
306
+
307
+ raise CompilationError, "unknown state #{name.inspect}: declare it with state #{name.inspect}"
308
+ end
309
+
310
+ def pair_name(pair)
311
+ "#{pair.first} -> #{pair.last}"
312
+ end
313
+ end
314
+ end
315
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statecraft
4
+ # Normalizes transition metadata to exactly what will survive the jsonb
5
+ # round-trip, then deep-freezes it: what the guards checked is what the log
6
+ # stored. Symbols become strings (keys and values), times become ISO-8601
7
+ # strings, and anything that JSON cannot represent fails instantly at the
8
+ # pipeline entrance instead of inside the transaction.
9
+ module Metadata
10
+ def self.normalize(raw_metadata)
11
+ deep_freeze(round_trip(raw_metadata))
12
+ end
13
+
14
+ def self.round_trip(value)
15
+ case value
16
+ when Hash
17
+ value.to_h { |key, nested| [round_trip_key(key), round_trip(nested)] }
18
+ when Array
19
+ value.map { |element| round_trip(element) }
20
+ when String then value.dup
21
+ when Symbol then value.to_s
22
+ when Integer, Float, true, false, nil then value
23
+ when Time, Date, DateTime then value.iso8601
24
+ else
25
+ raise ArgumentError,
26
+ "metadata value #{value.inspect} (#{value.class}) is not JSON-serializable; " \
27
+ "metadata must round-trip through jsonb"
28
+ end
29
+ end
30
+
31
+ def self.round_trip_key(key)
32
+ case key
33
+ when String then key.dup
34
+ when Symbol, Integer then key.to_s
35
+ else
36
+ raise ArgumentError,
37
+ "metadata key #{key.inspect} (#{key.class}) is not a JSON object key"
38
+ end
39
+ end
40
+
41
+ def self.deep_freeze(value)
42
+ case value
43
+ when Hash
44
+ value.each { |key, nested| key.freeze && deep_freeze(nested) }
45
+ when Array
46
+ value.each { |element| deep_freeze(element) }
47
+ end
48
+ value.freeze
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statecraft
4
+ # The model-side macro. `state_machine OrderFlow, ...` mounts a compiled
5
+ # machine onto an ActiveRecord model: resolves the log class (convention
6
+ # `<base_class>Transition`, overridable via log:), runs the mounting-time
7
+ # checks (AlreadyMounted, composite primary keys, connection-class identity),
8
+ # finalizes the machine, verifies helper and scope name conflicts, generates
9
+ # scopes, and defines the reading surface (history, last_transition,
10
+ # in_state?). Everything internal goes through base_class.
11
+ module Mounting
12
+ Configuration = Struct.new(
13
+ :machine_class, :log_class, :column, :changed_at_column, :touch, :helpers, :scopes,
14
+ :log_foreign_key,
15
+ keyword_init: true
16
+ )
17
+
18
+ def state_machine(machine_class, log: nil, column: :state, changed_at: false, touch: true,
19
+ helpers: false, scopes: false)
20
+ Builder.new(
21
+ model: self, machine_class: machine_class, log: log, column: column,
22
+ changed_at: changed_at, touch: touch, helpers: helpers, scopes: scopes
23
+ ).mount
24
+ end
25
+
26
+ # Performs the mounting steps in threshold order. Every check here works on
27
+ # class hierarchies only — never on the schema and never on a live
28
+ # connection, so mounting is safe at load time.
29
+ class Builder
30
+ def initialize(model:, machine_class:, log:, column:, changed_at:, touch:, helpers:, scopes:)
31
+ @model = model
32
+ @machine_class = machine_class
33
+ @log_option = log
34
+ @column = column
35
+ @changed_at = changed_at
36
+ @touch = touch
37
+ @helpers = helpers
38
+ @scopes = scopes
39
+ end
40
+
41
+ def mount
42
+ assert_not_mounted
43
+ assert_single_column_primary_key
44
+ log_class = resolve_log_class
45
+ assert_shared_connection_class(log_class)
46
+ graph = machine_class.finalize!
47
+ assert_no_verb_conflicts(graph)
48
+ assert_no_scope_conflicts(graph)
49
+ configuration = build_configuration(log_class)
50
+ store_configuration(configuration)
51
+ define_scopes(graph, configuration)
52
+ define_reading_surface
53
+ model.include(Pipeline::Surface)
54
+ model.include(Introspection)
55
+ define_verbs(graph) if @helpers
56
+ configuration
57
+ end
58
+
59
+ private
60
+
61
+ attr_reader :model, :machine_class
62
+
63
+ def assert_not_mounted
64
+ raise AlreadyMounted.new(model: model) if model.respond_to?(:statecraft_mounting)
65
+ end
66
+
67
+ def assert_single_column_primary_key
68
+ raise CompositePrimaryKeyUnsupported.new(model: model) if model.primary_key.is_a?(Array)
69
+ end
70
+
71
+ def resolve_log_class
72
+ return @log_option if @log_option
73
+
74
+ conventional_name = "#{model.base_class.name}Transition"
75
+ begin
76
+ Object.const_get(conventional_name)
77
+ rescue NameError
78
+ raise CompilationError,
79
+ "log class #{conventional_name} not found for #{model.name}: " \
80
+ "create it or pass log: explicitly"
81
+ end
82
+ end
83
+
84
+ def assert_shared_connection_class(log_class)
85
+ return if model.connection_specification_name == log_class.connection_specification_name
86
+
87
+ raise ConnectionMismatch.new(model: model, log_class: log_class)
88
+ end
89
+
90
+ def assert_no_verb_conflicts(graph)
91
+ return unless @helpers
92
+
93
+ graph.events.each_key do |event_name|
94
+ verb_names(event_name).each do |verb|
95
+ next unless model.method_defined?(verb) || model.private_method_defined?(verb)
96
+
97
+ raise CompilationError,
98
+ "helper #{verb} for event #{event_name.inspect} conflicts with an existing " \
99
+ "instance method on #{model.name}"
100
+ end
101
+ end
102
+ end
103
+
104
+ def assert_no_scope_conflicts(graph)
105
+ return unless @scopes
106
+
107
+ graph.states.each do |state_name|
108
+ next unless model.respond_to?(state_name, true)
109
+
110
+ raise CompilationError,
111
+ "scope #{state_name} conflicts with an existing class method on #{model.name}"
112
+ end
113
+ end
114
+
115
+ def verb_names(event_name)
116
+ ["#{event_name}!", event_name.to_s, "may_#{event_name}?"]
117
+ end
118
+
119
+ def build_configuration(log_class)
120
+ Configuration.new(
121
+ machine_class: machine_class,
122
+ log_class: log_class,
123
+ column: @column.to_sym,
124
+ changed_at_column: resolve_changed_at_column,
125
+ touch: @touch,
126
+ helpers: @helpers,
127
+ scopes: @scopes,
128
+ log_foreign_key: model.base_class.name.foreign_key.to_sym
129
+ ).freeze
130
+ end
131
+
132
+ def resolve_changed_at_column
133
+ case @changed_at
134
+ when false then nil
135
+ when true then :"#{@column}_changed_at"
136
+ else @changed_at.to_sym
137
+ end
138
+ end
139
+
140
+ def store_configuration(configuration)
141
+ model.define_singleton_method(:statecraft_mounting) { configuration }
142
+ end
143
+
144
+ def define_scopes(graph, configuration)
145
+ return unless configuration.scopes
146
+
147
+ column = configuration.column
148
+ graph.states.each do |state_name|
149
+ model.scope state_name, -> { where(column => state_name.to_s) }
150
+ end
151
+ end
152
+
153
+ def define_verbs(graph)
154
+ verbs = Module.new do
155
+ graph.events.each_key do |event_name|
156
+ define_method("#{event_name}!") { |metadata: {}| fire!(event_name, metadata: metadata) }
157
+ define_method(event_name) { |metadata: {}| fire(event_name, metadata: metadata) }
158
+ define_method("may_#{event_name}?") { |metadata: {}| can_fire?(event_name, metadata: metadata) }
159
+ end
160
+ end
161
+ model.include(verbs)
162
+ end
163
+
164
+ def define_reading_surface
165
+ reading_surface = Module.new do
166
+ define_method(:history) do
167
+ config = self.class.statecraft_mounting
168
+ config.log_class.where(config.log_foreign_key => id).order(:id)
169
+ end
170
+
171
+ define_method(:last_transition) do
172
+ history.last
173
+ end
174
+
175
+ define_method(:in_state?) do |state_name|
176
+ config = self.class.statecraft_mounting
177
+ self[config.column].to_s == state_name.to_s
178
+ end
179
+ end
180
+ model.include(reading_surface)
181
+ end
182
+ end
183
+ end
184
+ end