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,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octorail
4
+ class Trace
5
+ # Trace#to_s: the path as a tree.
6
+ #
7
+ # Orders::Checkout 12.4 ms
8
+ # ├── ✓ validate 0.1 ms
9
+ # ├── switch flow_for → :admin
10
+ # │ └── ✗ grant_all
11
+ # └── failure error=:failure failed_step=:grant_all
12
+ class Rendering
13
+ MARKS = {
14
+ success: "✓", failure: "✗", ran: "·", recovered: "↺", not_recovered: "↺",
15
+ failed: "✗", raised: "!"
16
+ }.freeze
17
+ KINDS = { nested: "nested ", failure: "failure " }.freeze
18
+
19
+ def initialize(trace, lines = [])
20
+ @trace = trace
21
+ @lines = lines
22
+ end
23
+
24
+ def to_s
25
+ @lines << with_duration(@trace.root.name, @trace.duration)
26
+ render_body("")
27
+ "#{@lines.join("\n")}\n"
28
+ end
29
+
30
+ # The tree under the root line; also used for a trace carried in from a separate call.
31
+ def render_body(indent)
32
+ nodes = @trace.root.children
33
+ last = summary? ? nil : nodes.size - 1
34
+ nodes.each_with_index { |node, index| render(node, indent, index == last, []) }
35
+ @lines << "#{indent}└── #{summary}" if summary?
36
+ end
37
+
38
+ private
39
+
40
+ def render(node, indent, last, wraps)
41
+ @lines << "#{indent}#{branch_mark(last)}#{with_duration(label(node, wraps), node.duration)}"
42
+ render_children(node, indent + child_mark(last), node.type == :wrap ? [*wraps, node] : wraps)
43
+ end
44
+
45
+ def render_children(node, indent, wraps)
46
+ render_inner(node.inner, indent) if node.inner
47
+ node.children.each_with_index do |child, index|
48
+ render(child, indent, index == node.children.size - 1, wraps)
49
+ end
50
+ end
51
+
52
+ def branch_mark(last) = last ? "└── " : "├── "
53
+ def child_mark(last) = last ? " " : "│ "
54
+
55
+ def render_inner(inner, indent)
56
+ @lines << "#{indent}└── #{with_duration(inner.root.name, inner.duration)}"
57
+ Rendering.new(inner, @lines).render_body("#{indent} ")
58
+ end
59
+
60
+ def label(node, wraps)
61
+ case node.type
62
+ when :switch then "switch #{node.name}#{" → #{node.branch.inspect}" if node.taken?}#{suffix(node, wraps)}"
63
+ when :wrap then "wrap #{node.name} → #{wrap_result(node)}"
64
+ when :recover then "#{mark(node)} recover #{node.name} (#{node.outcome.to_s.tr("_", " ")})"
65
+ else plain_label(node, wraps)
66
+ end
67
+ end
68
+
69
+ def plain_label(node, wraps)
70
+ "#{mark(node)} #{KINDS[node.type]}#{node.name}#{suffix(node, wraps) unless node.type == :nested}"
71
+ end
72
+
73
+ def mark(node) = MARKS.fetch(node.outcome, "?")
74
+
75
+ def wrap_result(node)
76
+ node.outcome == :raised ? "raised" : node.value.inspect
77
+ end
78
+
79
+ def suffix(node, wraps = [])
80
+ case node.outcome
81
+ when :raised then " (raised #{(node.exception || @trace.exception).class}#{rescuer(wraps)})"
82
+ when :failed then " (left on failure)"
83
+ else ""
84
+ end
85
+ end
86
+
87
+ # A raise that didn't leave the call was rescued by the innermost enclosing wrap that ended normally.
88
+ def rescuer(wraps)
89
+ wrap = wraps.reverse.find { |node| node.outcome != :raised }
90
+ wrap ? ", rescued by #{wrap.name}" : ""
91
+ end
92
+
93
+ def summary?
94
+ @trace.exception || @trace.terminus
95
+ end
96
+
97
+ def summary
98
+ return "raised #{@trace.exception.class}: #{@trace.exception.message}" if @trace.exception
99
+
100
+ details = [("error=#{@trace.error.inspect}" if @trace.error),
101
+ ("failed_step=#{@trace.failed_step.inspect}" if @trace.failed_step)].compact
102
+ [@trace.terminus, *details].join(" ")
103
+ end
104
+
105
+ def with_duration(text, seconds)
106
+ seconds ? "#{text} #{Trace.ms(seconds)} ms" : text
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octorail
4
+ # The path one traced call took through an operation (docs/tracing.md). Built by the traced
5
+ # variant of the generated code: every step, wrap, switch and nested operation becomes a Node.
6
+ # Holds names, outcomes, branch keys and durations only, never ctx values.
7
+ class Trace
8
+ LEVELS = %i[off path profile].freeze
9
+
10
+ # One traced node. Containers (wrap, switch, nested, the operation itself) hold child nodes.
11
+ class Node
12
+ NONE = [].freeze
13
+ private_constant :NONE
14
+
15
+ # `value`: true / false only, what the node returned taken as a condition (never the object
16
+ # itself: it may hold ctx data, and must not stop the exception carrying the trace from
17
+ # being serialized). `exception`: what a step raised. `inner`: the trace of a separate
18
+ # traced operation that step called, carried in by the exception.
19
+ attr_reader :type, :name, :value, :branch, :duration, :exception, :inner
20
+
21
+ def initialize(type, name, profile)
22
+ @type = type
23
+ @name = name
24
+ @profile = profile
25
+ @started = Process.clock_gettime(Process::CLOCK_MONOTONIC) if profile
26
+ end
27
+
28
+ # Generated code for a step call: `trace.run(:step, :charge) { charge(ctx) }`.
29
+ # A raise is recorded on the step's own node, even if a wrapper rescues it later.
30
+ def run(type, name)
31
+ node = start(type, name)
32
+ value = yield
33
+ node.close(value)
34
+ value
35
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
36
+ node&.raised(e)
37
+ raise
38
+ end
39
+
40
+ # Generated code for a container: `__tw1 = trace.start(:wrap, "Transaction")`, then `done`.
41
+ def start(type, name)
42
+ node = Node.new(type, name, @profile)
43
+ (@children ||= []) << node
44
+ node
45
+ end
46
+
47
+ # Only containers get an array: steps are leaves.
48
+ def children = @children || NONE
49
+
50
+ # Records what the node returned and passes it through unchanged.
51
+ def done(node, value)
52
+ node.close(value)
53
+ value
54
+ end
55
+
56
+ # The switch decider's result: recorded, then passed through to `case`.
57
+ def taken(value)
58
+ @taken = true
59
+ @branch = value
60
+ value
61
+ end
62
+
63
+ # False when the switch decider raised.
64
+ def taken? = @taken == true
65
+
66
+ # Children still open were left by a failure exit (`next false` in a wrap, a nested
67
+ # operation's `return`): they end here, not when the whole call ends.
68
+ def close(value)
69
+ return if closed?
70
+
71
+ @children&.each { |child| child.close_open(:failed) }
72
+ @closed = true
73
+ @value = value ? true : false
74
+ @duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started if @profile
75
+ end
76
+
77
+ def raised(exception)
78
+ @exception = exception
79
+ @inner = exception.octorail_trace if exception.respond_to?(:octorail_trace)
80
+ @outcome = :raised
81
+ close(nil)
82
+ end
83
+
84
+ def closed? = @closed == true
85
+
86
+ # Nodes still open when the call ended were left early: by a failure exit (`failed`) or by an
87
+ # exception (`raised`, only for the innermost open path).
88
+ def close_open(outcome)
89
+ children.each { |child| child.close_open(outcome) }
90
+ return if closed?
91
+
92
+ @outcome = outcome
93
+ close(nil)
94
+ end
95
+
96
+ def outcome
97
+ @outcome || OUTCOMES.fetch(type).call(self)
98
+ end
99
+
100
+ OUTCOMES = {
101
+ step: ->(node) { node.value ? :success : :failure },
102
+ pass: ->(_node) { :ran },
103
+ failure: ->(_node) { :ran },
104
+ recover: ->(node) { node.value ? :recovered : :not_recovered },
105
+ wrap: ->(node) { node.value ? :success : :failure },
106
+ nested: ->(node) { node.value ? :success : :failure },
107
+ switch: ->(_node) { :success }
108
+ }.freeze
109
+ private_constant :OUTCOMES
110
+
111
+ def to_h
112
+ hash = { type:, name:, outcome:, **details }
113
+ hash[:duration_ms] = Trace.ms(duration) if duration
114
+ hash[:nodes] = children.map(&:to_h) unless children.empty?
115
+ hash
116
+ end
117
+
118
+ private
119
+
120
+ def details
121
+ { branch: (branch if type == :switch && taken?), exception: exception&.class&.name, trace: inner&.to_h }
122
+ .compact
123
+ end
124
+ end
125
+
126
+ # Attached to an exception that left a traced call: `e.octorail_trace`.
127
+ module TracedException
128
+ attr_accessor :octorail_trace
129
+ end
130
+
131
+ def self.ms(seconds) = (seconds * 1000).round(3)
132
+
133
+ attr_reader :level, :root, :terminus, :error, :failed_step, :exception
134
+
135
+ # The operation's name, not the class: a trace holds no objects (see Node#value).
136
+ def initialize(operation, level)
137
+ @level = level
138
+ @root = Node.new(:operation, operation.name || operation.inspect, level == :profile)
139
+ end
140
+
141
+ def operation = root.name
142
+
143
+ def profile? = level == :profile
144
+
145
+ def duration = root.duration
146
+
147
+ # The call returned: close what is still open (left by a failure exit) and attach to the result.
148
+ def finish(result)
149
+ @terminus = result.terminus
150
+ @error = result.error
151
+ @failed_step = result.failed_step
152
+ root.close_open(:failed)
153
+ result.instance_variable_set(:@trace, self)
154
+ result
155
+ end
156
+
157
+ # The call raised: the open nodes are the path to where it happened. A trace the exception
158
+ # already carries (from a separate traced call inside a step) moves under that step's node.
159
+ def fail(exception)
160
+ @exception = exception
161
+ root.close_open(:raised)
162
+ return if exception.frozen?
163
+
164
+ exception.extend(TracedException)
165
+ exception.octorail_trace = self
166
+ end
167
+
168
+ def to_h
169
+ {
170
+ operation: root.name, level:, terminus:, error:, failed_step:,
171
+ exception: exception&.class&.name, duration_ms: duration && Trace.ms(duration),
172
+ nodes: root.children.map(&:to_h)
173
+ }.compact
174
+ end
175
+
176
+ def to_s = Rendering.new(self).to_s
177
+
178
+ def inspect = "#<#{self.class} #{operation} #{exception ? "raised #{exception.class}" : terminus}>"
179
+ end
180
+ end
181
+
182
+ require_relative "trace/rendering"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octorail
4
+ VERSION = "1.0.0"
5
+ end
data/lib/octorail.rb ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "octorail/version"
4
+ require_relative "octorail/errors"
5
+ require_relative "octorail/result"
6
+ require_relative "octorail/trace"
7
+ require_relative "octorail/config"
8
+ require_relative "octorail/nodes"
9
+ require_relative "octorail/dsl"
10
+ require_relative "octorail/compiler"
11
+ require_relative "octorail/runtime"
12
+ require_relative "octorail/invalidation"
13
+ require_relative "octorail/operation"
14
+
15
+ module Octorail
16
+ # Compiles every operation under `root` (all of them by default), so compile errors
17
+ # surface at boot or in CI instead of on the first call. Returns the compiled classes.
18
+ def self.compile_all!(root = Operation)
19
+ root.descendants.reject(&:abstract?).each(&:compile!)
20
+ end
21
+ end
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: octorail
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Dmytro Bereza
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: |
13
+ Octorail provides a strict, Trailblazer-like railway DSL (step, pass,
14
+ failure, wrap, nested, switch, recover) that is compiled into a plain Ruby
15
+ method when the operation is first used, so a call costs about as much as
16
+ hand-written code. Optional tracing shows the path a call took.
17
+ email:
18
+ - dmitrybereza2001@gmail.com
19
+ executables: []
20
+ extensions: []
21
+ extra_rdoc_files: []
22
+ files:
23
+ - CHANGELOG.md
24
+ - LICENSE.txt
25
+ - README.md
26
+ - lib/octorail.rb
27
+ - lib/octorail/compiler.rb
28
+ - lib/octorail/compiler/code.rb
29
+ - lib/octorail/compiler/nested_cycles.rb
30
+ - lib/octorail/compiler/nested_emitter.rb
31
+ - lib/octorail/compiler/recovery_emitter.rb
32
+ - lib/octorail/compiler/scopes.rb
33
+ - lib/octorail/compiler/signature.rb
34
+ - lib/octorail/compiler/step_emitter.rb
35
+ - lib/octorail/compiler/switch_emitter.rb
36
+ - lib/octorail/compiler/tracing.rb
37
+ - lib/octorail/compiler/wrap_emitter.rb
38
+ - lib/octorail/config.rb
39
+ - lib/octorail/dsl.rb
40
+ - lib/octorail/dsl/nested.rb
41
+ - lib/octorail/dsl/outputs.rb
42
+ - lib/octorail/dsl/settings.rb
43
+ - lib/octorail/dsl/steps.rb
44
+ - lib/octorail/dsl/switches.rb
45
+ - lib/octorail/dsl/wraps.rb
46
+ - lib/octorail/errors.rb
47
+ - lib/octorail/invalidation.rb
48
+ - lib/octorail/nodes.rb
49
+ - lib/octorail/nodes/nested.rb
50
+ - lib/octorail/nodes/step.rb
51
+ - lib/octorail/nodes/switch.rb
52
+ - lib/octorail/nodes/wrap.rb
53
+ - lib/octorail/operation.rb
54
+ - lib/octorail/result.rb
55
+ - lib/octorail/runtime.rb
56
+ - lib/octorail/trace.rb
57
+ - lib/octorail/trace/rendering.rb
58
+ - lib/octorail/version.rb
59
+ homepage: https://github.com/Octorail-team/octorail
60
+ licenses:
61
+ - MIT
62
+ metadata:
63
+ source_code_uri: https://github.com/Octorail-team/octorail
64
+ changelog_uri: https://github.com/Octorail-team/octorail/blob/main/CHANGELOG.md
65
+ documentation_uri: https://github.com/Octorail-team/octorail/tree/main/docs
66
+ bug_tracker_uri: https://github.com/Octorail-team/octorail/issues
67
+ rubygems_mfa_required: 'true'
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '3.3'
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 3.6.9
83
+ specification_version: 4
84
+ summary: Lightweight railway-oriented operations compiled to plain Ruby at load time.
85
+ test_files: []