Class: SagaForge::Definition

Inherits:
Object
  • Object
show all
Defined in:
lib/saga_forge/definition.rb

Overview

Immutable boot-compiled metadata for one saga class: the chain, the event→state stall table, handler registry, compensation catalog.

Defined Under Namespace

Classes: Handler

Constant Summary collapse

START =
:__start__

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(klass) ⇒ Definition

Returns a new instance of Definition.



13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# File 'lib/saga_forge/definition.rb', line 13

def initialize(klass)
  @klass = klass
  @handlers_by_event = {}
  @compensations = {}
  @terminal_states = []
  during_states = []
  start_decls = []

  klass.declarations.each do |d|
    case d[:kind]
    when :start
      start_decls << d
      register_handler(START, d)
    when :during
      during_states << d[:state] unless during_states.include?(d[:state])
      register_handler(d[:state], d)
    when :finish
      @terminal_states << d[:state] unless @terminal_states.include?(d[:state])
    when :compensation
      @compensations[d[:name]] = d[:block]
    end
  end

  validate_shape!(start_decls)
  @start_event = start_decls.first[:event]
  @states = during_states + @terminal_states
  @successors = build_successors(during_states)
  validate_compensations!
  validate_timeouts!
  deep_freeze!
end

Instance Attribute Details

#compensationsObject (readonly)

Returns the value of attribute compensations.



9
10
11
# File 'lib/saga_forge/definition.rb', line 9

def compensations
  @compensations
end

#handlers_by_eventObject (readonly)

Returns the value of attribute handlers_by_event.



9
10
11
# File 'lib/saga_forge/definition.rb', line 9

def handlers_by_event
  @handlers_by_event
end

#klassObject (readonly)

Returns the value of attribute klass.



9
10
11
# File 'lib/saga_forge/definition.rb', line 9

def klass
  @klass
end

#start_eventObject (readonly)

Returns the value of attribute start_event.



9
10
11
# File 'lib/saga_forge/definition.rb', line 9

def start_event
  @start_event
end

#statesObject (readonly)

Returns the value of attribute states.



9
10
11
# File 'lib/saga_forge/definition.rb', line 9

def states
  @states
end

#terminal_statesObject (readonly)

Returns the value of attribute terminal_states.



9
10
11
# File 'lib/saga_forge/definition.rb', line 9

def terminal_states
  @terminal_states
end

Class Method Details

.compile(klass) ⇒ Object



11
# File 'lib/saga_forge/definition.rb', line 11

def self.compile(klass) = new(klass).freeze

Instance Method Details

#correlate(payload, event_name) ⇒ Object



65
66
67
68
69
70
71
72
# File 'lib/saga_forge/definition.rb', line 65

def correlate(payload, event_name)
  correlator = klass.correlator
  value = (correlator.arity == 1) ? correlator.call(payload) : correlator.call(payload, event_name)
  if value.nil?
    raise MissingCorrelationError, "#{klass} registered #{event_name.inspect} but correlate_by returned nil"
  end
  value.to_s
end

#declared?(state) ⇒ Boolean

Returns:

  • (Boolean)


60
61
62
63
# File 'lib/saga_forge/definition.rb', line 60

def declared?(state)
  s = state.to_sym
  @states.include?(s) || terminal?(s)
end

#eventsObject



49
# File 'lib/saga_forge/definition.rb', line 49

def events = @handlers_by_event.keys

#events_for_state(state) ⇒ Object



51
# File 'lib/saga_forge/definition.rb', line 51

def events_for_state(state) = @handlers_by_event.values.select { |h| h.state == state.to_sym }.map(&:event)

#handler_for(event) ⇒ Object



45
# File 'lib/saga_forge/definition.rb', line 45

def handler_for(event) = @handlers_by_event[event.to_sym]

#jump_targetsObject

Best-effort literal scan for transition_to :sym in handler blocks (jumps are opaque Ruby; unresolvable ones are simply not drawn). Each match is attributed to a handler only if the match's line falls within that handler's EXACT block extent (via RubyVM::InstructionSequence's code_location), so two sagas sharing one file never cross-attribute a jump. Anything we can't precisely locate is simply not drawn — a wrong edge is worse than a missing one.



109
110
111
112
113
114
115
116
# File 'lib/saga_forge/definition.rb', line 109

def jump_targets
  scan_handlers(/transition_to[\s(]+:(\w+)/).filter_map do |(state, captures)|
    target = captures.first
    next unless declared?(target)
    from = (state == START) ? "[*]" : state
    [from, target.to_sym]
  end.uniq
end

#retry_policy_for(handler) ⇒ Object

handler override → class default → step_default. (Compensation blocks use RetryPolicy.compensation_default — see CompensationRunner, Task 8.)



76
77
78
79
80
81
82
83
84
85
86
# File 'lib/saga_forge/definition.rb', line 76

def retry_policy_for(handler)
  override = handler.retry_policy
  policy =
    case override
    when nil then nil
    when Hash then RetryPolicy.new(**override)
    when Array then CompositeRetryPolicy.new(override)
    else override
    end
  policy || klass.default_retry_policy || RetryPolicy.step_default
end

#state_for_event(event) ⇒ Object



47
# File 'lib/saga_forge/definition.rb', line 47

def state_for_event(event) = handler_for(event)&.state

#successor_of(state) ⇒ Object



53
# File 'lib/saga_forge/definition.rb', line 53

def successor_of(state) = @successors.fetch(state.to_sym)

#terminal?(state) ⇒ Boolean

Returns:

  • (Boolean)


55
56
57
58
# File 'lib/saga_forge/definition.rb', line 55

def terminal?(state)
  s = state.to_sym
  @terminal_states.include?(s) || %i[compensated cancelled].include?(s)
end

#to_graphObject

Structured graph (chain + jump), the sibling of to_mermaid.



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
# File 'lib/saga_forge/definition.rb', line 119

def to_graph
  nodes = [SagaForge::Dashboard::Node.new(id: START.to_s, label: "start", kind: :start)]
  (@states - @terminal_states).each do |s|
    nodes << SagaForge::Dashboard::Node.new(id: s.to_s, label: s.to_s, kind: :state)
  end
  # Only @terminal_states.first is on the chain below (build_successors
  # picks it as the chain's sink too). Additional terminals are only
  # wired in when a handler literally `transition_to`s them (jump_targets,
  # below) — the normal multi-terminal pattern. A terminal reached only
  # by a computed/conditional transition, or not reached at all, has no
  # edge and renders as an isolated node: that's deliberate, not a bug —
  # it's the same best-effort honesty jump_targets already carries (a
  # wrong edge is worse than a missing one).
  @terminal_states.each do |s|
    nodes << SagaForge::Dashboard::Node.new(id: s.to_s, label: s.to_s, kind: :terminal)
  end

  edges = []
  chain = [START] + (@states - @terminal_states) + [@terminal_states.first]
  chain.each_cons(2) do |from, to|
    label = (from == START) ? @start_event.to_s : events_for_state(from).join(" / ")
    edges << SagaForge::Dashboard::Edge.new(from: from.to_s, to: to.to_s, kind: :chain, label: label)
  end
  jump_targets.each do |(from, to)|
    from_id = (from == "[*]") ? START.to_s : from.to_s
    edges << SagaForge::Dashboard::Edge.new(from: from_id, to: to.to_s, kind: :jump, label: "jump")
  end

  nodes.each(&:freeze)
  edges.each(&:freeze)
  SagaForge::Dashboard::Graph.new(nodes.freeze, edges.freeze).freeze
end

#to_mermaidObject



88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/saga_forge/definition.rb', line 88

def to_mermaid
  lines = ["stateDiagram-v2"]
  chain = [START] + @states.reject { |s| @terminal_states.include?(s) } + [@terminal_states.first]
  chain.each_cons(2) do |from, to|
    events_from = (from == START) ? [@start_event] : events_for_state(from)
    label = events_from.join(" / ")
    from_name = (from == START) ? "[*]" : from
    lines << "    #{from_name} --> #{to}: #{label}"
  end
  @terminal_states.each { |t| lines << "    #{t} --> [*]" }
  jump_targets.each { |(from, to)| lines << "    #{from} --> #{to}: jump" }
  lines.join("\n")
end