octorail 1.0.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,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octorail
4
+ module DSL
5
+ # wrap: run a group of steps inside a wrapper (transaction, lock, timeout, ...).
6
+ module Wraps
7
+ def wrap(wrapper, error: nil, &block)
8
+ check_not_abstract!("wrap #{wrapper.inspect}")
9
+ check_wrap!(wrapper, error, block)
10
+ location = declaration_location
11
+ nodes = collect_nodes(&block)
12
+ raise ArgumentError, "#{self}: wrap #{wrapper.inspect} has no steps" if nodes.empty?
13
+
14
+ current_nodes << Nodes::Wrap.new(wrapper:, nodes:, error:, location:)
15
+ invalidate!
16
+ nil
17
+ end
18
+
19
+ private
20
+
21
+ def check_wrap!(wrapper, error, block)
22
+ check_wrapper!(wrapper)
23
+ raise ArgumentError, "#{self}: wrap #{wrapper.inspect} needs a block with steps" unless block
24
+ return if error.nil? || error.is_a?(Symbol)
25
+
26
+ raise ArgumentError, "#{self}: wrap #{wrapper.inspect}: error: must be a Symbol, got #{error.inspect}"
27
+ end
28
+
29
+ def check_wrapper!(wrapper)
30
+ return check_name!(wrapper, "wrap") if wrapper.is_a?(Symbol)
31
+ if wrapper.is_a?(Class) && wrapper < Operation
32
+ raise ArgumentError, "#{self}: wrap #{wrapper}: is an operation; to run it as a step use nested #{wrapper}"
33
+ end
34
+ return if wrapper.respond_to?(:call)
35
+
36
+ raise ArgumentError, "#{self}: wrap: wrapper must be a method name (Symbol) or respond to .call, " \
37
+ "got #{wrapper.inspect}"
38
+ end
39
+
40
+ # Steps declared inside a wrap block go into a buffer instead of the class's own list.
41
+ def collect_nodes
42
+ (@__octorail_nesting ||= []).push([])
43
+ yield
44
+ @__octorail_nesting.last
45
+ ensure
46
+ @__octorail_nesting.pop
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dsl/steps"
4
+ require_relative "dsl/outputs"
5
+ require_relative "dsl/wraps"
6
+ require_relative "dsl/nested"
7
+ require_relative "dsl/switches"
8
+ require_relative "dsl/settings"
9
+
10
+ module Octorail
11
+ # Class-level DSL, extended into Operation. Only records declarations; compilation happens later.
12
+ # Each feature lives in its own module under dsl/.
13
+ module DSL
14
+ include Steps
15
+ include Outputs
16
+ include Wraps
17
+ include Nested
18
+ include Switches
19
+ include Settings
20
+
21
+ METHOD_NAME = /\A[a-z_][a-zA-Z0-9_]*[?!]?\z/
22
+ # Called bare in generated code, these would parse as syntax (`not(ctx)`, `defined?(ctx)`, `super`).
23
+ RUBY_KEYWORDS = %i[
24
+ __ENCODING__ __FILE__ __LINE__ alias and begin break case class def defined? do else elsif end ensure
25
+ false for if in module next nil not or redo rescue retry return self super then true undef unless
26
+ until when while yield
27
+ ].freeze
28
+ # Frames from the gem itself are skipped when looking for where a node was declared.
29
+ GEM_DIR = File.expand_path("..", __dir__)
30
+ LOCATION_FRAMES = 50
31
+
32
+ private
33
+
34
+ # An abstract operation holds shared methods and macros only; the flow is declared in the
35
+ # operations that inherit from it, so each shows its whole flow.
36
+ def check_not_abstract!(what)
37
+ return unless abstract?
38
+
39
+ raise ArgumentError, "#{self}: is abstract (shared methods and macros only), so it can't declare #{what}; " \
40
+ "declare it in the operations that inherit from it"
41
+ end
42
+
43
+ # "path:line" of the DSL call outside the gem. When a macro declared it (a method called from
44
+ # the class body), also the class body line: "macros.rb:6 (via app/operations/x.rb:12)".
45
+ def declaration_location
46
+ frames = caller_locations(1, LOCATION_FRAMES).reject { |frame| path_of(frame).start_with?(GEM_DIR) }
47
+ return if frames.empty?
48
+
49
+ site = frames.first
50
+ body = frames.find { |frame| frame.label.to_s.start_with?("<class:", "<module:") }
51
+ return format_location(site) if body.nil? || body.equal?(site)
52
+
53
+ "#{format_location(site)} (via #{format_location(body)})"
54
+ end
55
+
56
+ def format_location(frame)
57
+ "#{path_of(frame).delete_prefix("#{Dir.pwd}/")}:#{frame.lineno}"
58
+ end
59
+
60
+ # absolute_path is nil for eval'd code; path then holds the file name given to eval.
61
+ def path_of(frame) = (frame.absolute_path || frame.path).to_s
62
+
63
+ def check_name!(name, kind)
64
+ unless name.is_a?(Symbol) && METHOD_NAME.match?(name)
65
+ raise ArgumentError, "#{self}: #{kind} name must be a Symbol method name, got #{name.inspect}"
66
+ end
67
+ return unless RUBY_KEYWORDS.include?(name)
68
+
69
+ raise ArgumentError, "#{self}: #{kind} #{name.inspect} is a Ruby keyword, pick another name"
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octorail
4
+ # Every Octorail error is a mistake in code, not a business failure (those are failure results).
5
+ # Like NotImplementedError, they derive from ScriptError so that a bare `rescue` / `rescue
6
+ # StandardError` (a timeout wrapper, a controller, `retry_on StandardError`) can't swallow or
7
+ # retry them: they reach the top-level handler (Rails, Sidekiq, Sentry rescue Exception).
8
+ class Error < ScriptError; end
9
+
10
+ # Suffix for error messages about a declared node: where its step/wrap/nested line is.
11
+ def self.declared_at(node)
12
+ node&.location ? "\n declared at #{node.location}" : ""
13
+ end
14
+
15
+ # Key lists in error messages: ":a, :b", or "none".
16
+ def self.key_list(keys)
17
+ keys.empty? ? "none" : keys.map(&:inspect).join(", ")
18
+ end
19
+
20
+ # Raised while compiling an operation: unsupported step signature, unknown step method, etc.
21
+ class CompileError < Error; end
22
+
23
+ # Raised at runtime when a step requires a keyword that ctx doesn't have.
24
+ class MissingKeyError < Error; end
25
+
26
+ # Raised when ctx holds a key that collides with a Result field.
27
+ class ReservedKeyError < Error; end
28
+
29
+ # Raised when an operation with declared outputs succeeds without setting one of them.
30
+ class MissingOutputError < Error; end
31
+
32
+ # Raised by `result[key]` when the operation declares outputs and `key` isn't one of them.
33
+ class UnknownOutputError < Error; end
34
+
35
+ # Raised when a switch decider returns a value that has no on(...) branch.
36
+ class UnknownBranchError < Error; end
37
+
38
+ # Raised when a step writes an instance variable: the operation instance is shared and frozen.
39
+ class FrozenOperationError < Error; end
40
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octorail
4
+ # Drops compiled code when what it was compiled from changes; extended into Operation.
5
+ # These hooks run at code load time only.
6
+ module Invalidation
7
+ # Drops the compiled code of this operation and its descendants; their next call recompiles.
8
+ def invalidate!
9
+ COMPILE_LOCK.synchronize do
10
+ # The next `call` sees no instance and compiles again.
11
+ @__octorail_instance = nil
12
+ subclasses.each(&:invalidate!)
13
+ end
14
+ self
15
+ end
16
+
17
+ def include(*)
18
+ super.tap { invalidate! }
19
+ end
20
+
21
+ def prepend(*)
22
+ super.tap { invalidate! }
23
+ end
24
+
25
+ private
26
+
27
+ def method_added(name)
28
+ super
29
+ step_method_changed(name)
30
+ end
31
+
32
+ def method_removed(name)
33
+ super
34
+ step_method_changed(name)
35
+ end
36
+
37
+ def method_undefined(name)
38
+ super
39
+ step_method_changed(name)
40
+ end
41
+
42
+ # A step method was (re)defined or removed: recompile every compiled operation that calls it.
43
+ def step_method_changed(name)
44
+ [self, *descendants].each do |operation|
45
+ operation.invalidate! if operation.compiled? && operation.step_method_names.include?(name)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octorail
4
+ module Nodes
5
+ # Another operation run as a step on the same ctx (`nested Billing::Charge`).
6
+ Nested = Data.define(:operation, :error, :fail_fast, :location)
7
+
8
+ class Nested
9
+ def initialize(operation:, error: nil, fail_fast: false, location: nil)
10
+ super
11
+ end
12
+
13
+ def type = :nested
14
+
15
+ def children = []
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octorail
4
+ module Nodes
5
+ # A declared step. `type` is :step, :pass or :failure; `name` is the instance method to call;
6
+ # `recover` (failure only): a truthy result returns to the success track;
7
+ # `location` is where it was declared ("path:line"), for error messages.
8
+ Step = Data.define(:type, :name, :error, :fail_fast, :recover, :location)
9
+
10
+ class Step
11
+ def initialize(type:, name:, error: nil, fail_fast: false, recover: false, location: nil) # rubocop:disable Metrics/ParameterLists
12
+ super
13
+ end
14
+
15
+ def children = []
16
+
17
+ def failure? = type == :failure
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octorail
4
+ module Nodes
5
+ # A fork: `decider` is the instance method whose result (a Symbol) picks the branch to run.
6
+ Switch = Data.define(:decider, :branches, :location)
7
+
8
+ class Switch
9
+ def initialize(decider:, branches:, location: nil)
10
+ super
11
+ end
12
+
13
+ def name = decider
14
+
15
+ def type = :switch
16
+
17
+ def children = branches.flat_map(&:nodes)
18
+
19
+ def keys = branches.flat_map(&:keys)
20
+ end
21
+
22
+ # One `on(:a, :b) { ... }` of a switch.
23
+ Branch = Data.define(:keys, :nodes)
24
+ end
25
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octorail
4
+ module Nodes
5
+ # A group of nodes run inside `wrapper`: an operation method name (Symbol) or an object
6
+ # responding to `.call(ctx) { }`. The wrapper yields to run the nodes (the block returns
7
+ # true/false) and its own return value decides whether the wrap succeeded.
8
+ Wrap = Data.define(:wrapper, :nodes, :error, :location)
9
+
10
+ class Wrap
11
+ def initialize(wrapper:, nodes:, error: nil, location: nil)
12
+ super
13
+ end
14
+
15
+ # Used as failed_step when the wrapper itself fails without a step failing inside.
16
+ def name = wrapper.is_a?(Symbol) ? wrapper : nil
17
+
18
+ def type = :wrap
19
+
20
+ def children = nodes
21
+
22
+ # Steps and nested operations inside, at any depth, in declaration order.
23
+ def steps = Nodes.leaves(nodes)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "nodes/step"
4
+ require_relative "nodes/wrap"
5
+ require_relative "nodes/nested"
6
+ require_relative "nodes/switch"
7
+
8
+ module Octorail
9
+ # What the DSL records and the compiler reads. Wrap and Switch hold other nodes (`children`).
10
+ module Nodes
11
+ # Every node in `nodes` and inside them, depth-first, in declaration order.
12
+ def self.walk(nodes)
13
+ nodes.flat_map { |node| [node, *walk(node.children)] }
14
+ end
15
+
16
+ # Nodes that run something themselves (a step method or a nested operation), not containers.
17
+ def self.leaves(nodes)
18
+ walk(nodes).select { |node| node.is_a?(Step) || node.is_a?(Nested) }
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Octorail
6
+ # Guards compilation and invalidation; reentrant because compile_all! nests compile!.
7
+ COMPILE_LOCK = Monitor.new
8
+
9
+ class Operation
10
+ include Runtime
11
+ extend DSL
12
+ # Last: its `include` / `method_added` hooks expect the rest of the class to be defined.
13
+ extend Invalidation
14
+
15
+ class << self
16
+ # `call` and `nested_call` are defined once, here, and never redefined: each class reads its
17
+ # own compiled instance (nil until compiled or after invalidation). Redefining them per class
18
+ # would replace a test's stub of them and break it when the stub is restored.
19
+ def call(**input)
20
+ (@__octorail_instance || __octorail_compiled_instance).__octorail_entry(input)
21
+ end
22
+
23
+ # One call with a :profile trace, whatever the trace setting (console, tests).
24
+ def call_traced(**input)
25
+ (@__octorail_instance || __octorail_compiled_instance).__octorail_trace_call(input, :profile)
26
+ end
27
+
28
+ # Runs on the given ctx Hash itself (no copy): how `nested` calls an operation, and what
29
+ # a test stubs (or calls with a hand-built ctx) to fake or check a nested operation.
30
+ # `trace`: the outer trace's node, passed by a traced outer operation (docs/tracing.md);
31
+ # without it the plain variant runs, whatever this operation's own trace setting.
32
+ # `**nil`: `nested_call(id: 1)` (meant as `call`) is an ArgumentError, not a ctx Hash.
33
+ def nested_call(ctx, trace = nil, **nil)
34
+ raise ArgumentError, "#{self}.nested_call takes the ctx Hash, got #{ctx.inspect}" unless ctx.is_a?(Hash)
35
+ return (@__octorail_instance || __octorail_compiled_instance).__octorail_call(ctx) unless trace
36
+
37
+ __octorail_ensure_traced.__octorail_traced(ctx, trace)
38
+ end
39
+
40
+ # Marks a base class that holds shared methods and macros, not a flow: it can't declare steps,
41
+ # can't be called, and compile_all! skips it. Not inherited.
42
+ def abstract!
43
+ if steps.any? || outputs.any?
44
+ raise ArgumentError, "#{self}: abstract! must come before any declaration, and an abstract operation " \
45
+ "can't inherit steps or outputs"
46
+ end
47
+
48
+ @__octorail_abstract = true
49
+ nil
50
+ end
51
+
52
+ def abstract? = equal?(Operation) || @__octorail_abstract == true
53
+
54
+ def compile!
55
+ COMPILE_LOCK.synchronize do
56
+ return self if compiled?
57
+ raise CompileError, "#{self}: is abstract, call an operation that inherits from it" if abstract?
58
+
59
+ install_compiled_code
60
+ install_traced_code unless setting(:trace) == :off # `call` itself runs the traced variant
61
+ # Set last: a non-nil instance is what makes `call` skip compilation.
62
+ @__octorail_instance = new.freeze
63
+ end
64
+ self
65
+ end
66
+
67
+ def compiled?
68
+ !@__octorail_instance.nil?
69
+ end
70
+
71
+ def descendants
72
+ subclasses.flat_map { |subclass| [subclass, *subclass.descendants] }
73
+ end
74
+
75
+ def to_ruby(traced: false)
76
+ Compiler.new(self, traced:).source
77
+ end
78
+
79
+ private
80
+
81
+ # First call (or first after invalidation): compile, then hand back the instance.
82
+ def __octorail_compiled_instance
83
+ compile!
84
+ @__octorail_instance
85
+ end
86
+
87
+ # Both variants are generated now, from the same nodes, so they share OctorailRefs; the traced
88
+ # one is installed only when needed (a traced setting, call_traced, a traced outer operation).
89
+ def install_compiled_code
90
+ define_result_class
91
+ traced = Compiler.new(self, traced: true)
92
+ @__octorail_traced_source = traced.source
93
+ @__octorail_traced_installed = false
94
+ define_refs_const(traced.anonymous_refs)
95
+ redefine_method(Compiler::METHOD_NAME, Compiler.new(self).source, "octorail-generated:#{self}")
96
+ install_entry
97
+ end
98
+
99
+ # With tracing on, `call` runs the traced variant (the trace level comes from the settings);
100
+ # off, `call` runs the plain method itself (an alias: no extra frame).
101
+ def install_entry
102
+ level = setting(:trace)
103
+ entry = Compiler::ENTRY_METHOD_NAME.to_sym
104
+ remove_method(entry) if method_defined?(entry, false)
105
+ return alias_method(entry, Compiler::METHOD_NAME) if level == :off
106
+
107
+ source = "def #{entry}(ctx) = __octorail_trace_call(ctx, #{level.inspect})\n"
108
+ class_eval(source, "octorail-entry:#{self}", 1)
109
+ end
110
+
111
+ def install_traced_code
112
+ redefine_method(Compiler::TRACED_METHOD_NAME, @__octorail_traced_source, "octorail-traced:#{self}")
113
+ @__octorail_traced_installed = true
114
+ end
115
+
116
+ # The compiled instance, with the traced variant installed.
117
+ def __octorail_ensure_traced
118
+ instance = @__octorail_instance || __octorail_compiled_instance
119
+ return instance if @__octorail_traced_installed
120
+
121
+ COMPILE_LOCK.synchronize { install_traced_code unless @__octorail_traced_installed }
122
+ instance
123
+ end
124
+
125
+ # Removed first so redefining doesn't warn under -w.
126
+ def redefine_method(name, source, file)
127
+ name = name.to_sym
128
+ remove_method(name) if method_defined?(name, false) || private_method_defined?(name, false)
129
+ class_eval(source, file, 1)
130
+ end
131
+
132
+ def define_refs_const(refs)
133
+ const = Compiler::REFS_CONST
134
+ remove_const(const) if const_defined?(const, false)
135
+ const_set(const, refs.freeze) if refs.any?
136
+ end
137
+
138
+ def define_result_class
139
+ const = Compiler::RESULT_CONST
140
+ remove_const(const) if const_defined?(const, false)
141
+ const_set(const, Result.with_outputs(outputs)) if outputs.any?
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octorail
4
+ # A plain class rather than Data: Data.new always goes through a keyword Hash, which made
5
+ # building the result cost more than all the compiled steps together.
6
+ class Result
7
+ # Result fields that must not appear as ctx keys (`:errors` is reserved with a meaning).
8
+ RESERVED_CTX_KEYS = %i[success terminus error failed_step].freeze
9
+ # Keys `result[]` refuses to read from ctx: they have dedicated readers.
10
+ BUILTIN_KEYS = (RESERVED_CTX_KEYS + [:errors]).freeze
11
+
12
+ attr_reader :terminus, :ctx, :error, :failed_step
13
+ # The Trace of a traced call (docs/tracing.md), else nil. Not part of equality / to_h.
14
+ attr_reader :trace
15
+
16
+ def initialize(terminus, ctx, error = nil, failed_step = nil)
17
+ @terminus = terminus
18
+ @ctx = ctx
19
+ @error = error
20
+ @failed_step = failed_step
21
+ end
22
+
23
+ def success?
24
+ terminus == :success
25
+ end
26
+
27
+ def failure?
28
+ !success?
29
+ end
30
+
31
+ def errors
32
+ ctx[:errors] unless success?
33
+ end
34
+
35
+ def [](key)
36
+ if BUILTIN_KEYS.include?(key)
37
+ reader = key == :success ? "success?" : key
38
+ raise ReservedKeyError, "#{key.inspect} is a Result field, use result.#{reader}"
39
+ end
40
+
41
+ ctx[key]
42
+ end
43
+
44
+ def deconstruct_keys(keys)
45
+ check_reserved_keys!
46
+ return ctx.merge(builtin_fields) unless keys
47
+
48
+ pick(keys)
49
+ end
50
+
51
+ def to_h
52
+ { terminus:, ctx:, error:, failed_step: }
53
+ end
54
+
55
+ def ==(other)
56
+ other.instance_of?(self.class) && to_h == other.to_h
57
+ end
58
+ alias eql? ==
59
+
60
+ def hash
61
+ [self.class, *to_h.values].hash
62
+ end
63
+
64
+ def inspect
65
+ "#<#{self.class.name || "Octorail::Result"} terminus=#{terminus.inspect} error=#{error.inspect} " \
66
+ "failed_step=#{failed_step.inspect} ctx=#{ctx.inspect}>"
67
+ end
68
+
69
+ # Builds the Result subclass for an operation with declared outputs:
70
+ # a reader per output, and `[]` / pattern matching limited to those outputs.
71
+ def self.with_outputs(outputs)
72
+ Class.new(self) do
73
+ const_set(:OUTPUTS, outputs.dup.freeze)
74
+ include Outputs
75
+
76
+ outputs.each do |name|
77
+ class_eval("def #{name} = ctx[#{name.inspect}]", __FILE__, __LINE__) # def model = ctx[:model]
78
+ end
79
+ end
80
+ end
81
+
82
+ # A key outside the declared outputs is a mistake (a typo, or reaching into the operation's
83
+ # internals), so `[]` and pattern matching both raise for it.
84
+ module Outputs
85
+ def [](key)
86
+ return super if BUILTIN_KEYS.include?(key)
87
+
88
+ check_output!(key)
89
+ super
90
+ end
91
+
92
+ # Outputs can't be named like Result fields and other ctx keys are hidden,
93
+ # so there is nothing to collide: no reserved-key check here.
94
+ def deconstruct_keys(keys)
95
+ return pick([*BUILTIN_KEYS, *self.class::OUTPUTS]) unless keys
96
+
97
+ keys.each { |key| check_output!(key) unless BUILTIN_KEYS.include?(key) }
98
+ pick(keys)
99
+ end
100
+
101
+ private
102
+
103
+ def check_output!(key)
104
+ outputs = self.class::OUTPUTS
105
+ return if outputs.include?(key)
106
+
107
+ raise UnknownOutputError, "#{key.inspect} is not an output (outputs: #{Octorail.key_list(outputs)}); " \
108
+ "use result.ctx[#{key.inspect}] to debug"
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ def pick(keys)
115
+ keys.each_with_object({}) do |key, hash|
116
+ case key
117
+ when :success then hash[key] = success?
118
+ when :terminus, :error, :errors, :failed_step then hash[key] = public_send(key)
119
+ else hash[key] = ctx[key] if ctx.key?(key)
120
+ end
121
+ end
122
+ end
123
+
124
+ def builtin_fields
125
+ { success: success?, terminus:, error:, errors:, failed_step: }
126
+ end
127
+
128
+ def check_reserved_keys!
129
+ clash = RESERVED_CTX_KEYS.select { |key| ctx.key?(key) }
130
+ return if clash.empty?
131
+
132
+ raise ReservedKeyError, "ctx keys #{Octorail.key_list(clash)} collide with Result fields; rename them"
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octorail
4
+ # Private instance helpers that generated code calls on the failure paths.
5
+ module Runtime
6
+ # A traced call: runs the traced variant into a new Trace, attaches it to the result, or to the
7
+ # exception that left the call (then re-raised as is).
8
+ def __octorail_trace_call(ctx, level)
9
+ self.class.__send__(:__octorail_ensure_traced)
10
+ trace = Trace.new(self.class, level)
11
+ trace.finish(__octorail_traced(ctx, trace.root))
12
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
13
+ trace&.fail(e)
14
+ raise
15
+ end
16
+
17
+ private
18
+
19
+ # Called from generated code on FrozenError: only an ivar write on this very instance is ours to explain.
20
+ def __octorail_frozen!(error)
21
+ raise error unless error.receiver.equal?(self)
22
+
23
+ raise FrozenOperationError, "#{self.class}: a step tried to set an instance variable, but operations " \
24
+ "are stateless (one frozen instance is shared by all calls). " \
25
+ "Store data in ctx, pass dependencies through ctx with keyword defaults."
26
+ end
27
+
28
+ # Called from generated code when an operation succeeds without a declared output.
29
+ def __octorail_missing_output(name, ctx)
30
+ raise MissingOutputError, "#{self.class}: succeeded without setting output #{name.inspect} " \
31
+ "(ctx keys: #{Octorail.key_list(ctx.keys)})"
32
+ end
33
+
34
+ # Called from generated code when a switch decider returns a value with no branch.
35
+ # `index`: the switch's position in method_nodes (the declaration is looked up only here).
36
+ def __octorail_unknown_branch(index, value)
37
+ node = self.class.method_nodes.fetch(index)
38
+ raise UnknownBranchError, "#{self.class}: switch #{node.decider.inspect} returned #{value.inspect}, " \
39
+ "branches: #{Octorail.key_list(node.keys)}#{Octorail.declared_at(node)}"
40
+ end
41
+
42
+ # Called from generated code when a step's required keyword is absent from ctx.
43
+ # `index`: the step's position in method_nodes, as for __octorail_unknown_branch;
44
+ # the step's name is passed too, only so that the generated code reads well.
45
+ def __octorail_missing_key(index, key, ctx, _step = nil)
46
+ node = self.class.method_nodes.fetch(index)
47
+ raise MissingKeyError, "#{self.class}: #{node.type} #{node.name.inspect} requires #{key.inspect}, " \
48
+ "but ctx has no #{key.inspect} (ctx keys: #{Octorail.key_list(ctx.keys)})" \
49
+ "#{Octorail.declared_at(node)}"
50
+ end
51
+ end
52
+ end