hubbado-sequence 0.3.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,83 @@
1
+ module Hubbado
2
+ module Sequence
3
+ class Result
4
+ FRAMEWORK_I18N_SCOPE = "sequence.errors".freeze
5
+
6
+ attr_reader :ctx
7
+ attr_reader :error
8
+ attr_reader :trail
9
+ attr_reader :i18n_scope
10
+
11
+ def self.ok(ctx, trail: [], i18n_scope: nil)
12
+ new(:ok, ctx, error: nil, trail: trail, i18n_scope: i18n_scope)
13
+ end
14
+
15
+ def self.fail(ctx, error:, trail: [], i18n_scope: nil)
16
+ unless error.is_a?(Hash) && error[:code]
17
+ raise ArgumentError, "Result.fail requires error: { code: ... }"
18
+ end
19
+
20
+ new(:fail, ctx, error: error, trail: trail, i18n_scope: i18n_scope)
21
+ end
22
+
23
+ def initialize(status, ctx, error:, trail:, i18n_scope:)
24
+ @status = status
25
+ @ctx = ctx
26
+ @error = error
27
+ @trail = trail
28
+ @i18n_scope = i18n_scope
29
+ end
30
+
31
+ def ok?
32
+ @status == :ok
33
+ end
34
+
35
+ def failure?
36
+ @status == :fail
37
+ end
38
+
39
+ def with_trail(trail)
40
+ self.class.new(@status, @ctx, error: @error, trail: trail, i18n_scope: @i18n_scope)
41
+ end
42
+
43
+ def with_i18n_scope(scope)
44
+ return self unless @i18n_scope.nil?
45
+
46
+ self.class.new(@status, @ctx, error: @error, trail: @trail, i18n_scope: scope)
47
+ end
48
+
49
+ def message
50
+ return nil if ok?
51
+
52
+ translation = translate_with_chain
53
+ return translation if translation
54
+
55
+ @error[:message] || humanize_code
56
+ end
57
+
58
+ private
59
+
60
+ def translate_with_chain
61
+ scopes = []
62
+ scopes << @error[:i18n_scope] if @error[:i18n_scope]
63
+ scopes << @i18n_scope if @i18n_scope
64
+ scopes << FRAMEWORK_I18N_SCOPE
65
+ scopes.uniq!
66
+
67
+ key = @error[:i18n_key] || @error[:code]
68
+ args = @error[:i18n_args] || {}
69
+
70
+ scopes.each do |scope|
71
+ translated = ::I18n.t("#{scope}.#{key}", default: nil, **args)
72
+ return translated unless translated.nil?
73
+ end
74
+
75
+ nil
76
+ end
77
+
78
+ def humanize_code
79
+ @error[:code].to_s.tr("_", " ").capitalize
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,26 @@
1
+ module Hubbado
2
+ module Sequence
3
+ # Mixed into controllers (or other top-level boundaries that cannot take
4
+ # injected dependencies because their lifecycle is owned by the framework).
5
+ # Wraps Runner so the calling site reads `run_sequence SomeSeq do |r| ... end`
6
+ # while a single `sequencer_arguments` override handles per-host kwargs
7
+ # injection (current_user, params, etc.).
8
+ module RunSequence
9
+ def run_sequence(sequencer_class, **kwargs, &block)
10
+ sequence_runner.(sequencer_class, **sequencer_arguments(kwargs), &block)
11
+ end
12
+
13
+ # Override in the host (controller, job) to inject defaults like
14
+ # current_user without forcing every call site to pass them.
15
+ def sequencer_arguments(kwargs)
16
+ kwargs
17
+ end
18
+
19
+ private
20
+
21
+ def sequence_runner
22
+ @sequence_runner ||= Runner.new
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,184 @@
1
+ module Hubbado
2
+ module Sequence
3
+ # Invokes a sequencer and dispatches its Result to outcome blocks. Forgetting
4
+ # to handle a serious failure raises rather than silently swallowing it.
5
+ # Usable as a configurable dependency wherever a sequencer Result needs
6
+ # branch-style handling.
7
+ class Runner
8
+ configure :run_sequence
9
+
10
+ def self.build
11
+ new
12
+ end
13
+
14
+ def call(sequencer_class, **kwargs, &block)
15
+ result = sequencer_class.(**kwargs)
16
+
17
+ dispatch = Dispatch.new(sequencer_class, result)
18
+ block.call(dispatch) if block_given?
19
+
20
+ dispatch.enforce_safety_nets!
21
+
22
+ dispatch.returned
23
+ end
24
+
25
+ class Dispatch
26
+ include Hubbado::Log::Dependency
27
+
28
+ attr_reader :returned, :result, :sequencer_class
29
+
30
+ def initialize(sequencer_class, result)
31
+ @sequencer_class = sequencer_class
32
+ @result = result
33
+ @handled = false
34
+ end
35
+
36
+ def success
37
+ return unless @result.ok?
38
+ execute { yield(@result.ctx) }
39
+ logger.info("Sequencer #{@sequencer_class.name} succeeded: #{trail_summary}")
40
+ end
41
+
42
+ def policy_failed
43
+ return unless code == :forbidden
44
+ execute { yield(@result.ctx) }
45
+ logger.info("Sequencer #{@sequencer_class.name} policy failed at #{step_label} (#{code}): #{trail_summary}")
46
+ end
47
+
48
+ def not_found
49
+ return unless code == :not_found
50
+ execute { yield(@result.ctx) }
51
+ logger.info("Sequencer #{@sequencer_class.name} not found at #{step_label}: #{trail_summary}")
52
+ end
53
+
54
+ def validation_failed
55
+ return unless code == :validation_failed
56
+ execute { yield(@result.ctx) }
57
+ logger.info("Sequencer #{@sequencer_class.name} validation failed at #{step_label}: #{trail_summary}")
58
+ end
59
+
60
+ # otherwise deliberately does not catch policy denials or not_found —
61
+ # those have their own required handlers.
62
+ def otherwise
63
+ return if @result.ok?
64
+ return if code == :forbidden
65
+ return if code == :not_found
66
+ return if @handled
67
+
68
+ execute { yield(@result.ctx) }
69
+ logger.info("Sequencer #{@sequencer_class.name} failed at #{step_label} (#{code}): #{trail_summary}")
70
+ end
71
+
72
+ def code
73
+ @result.error&.[](:code)
74
+ end
75
+
76
+ def handled?
77
+ @result.ok? || @handled
78
+ end
79
+
80
+ def enforce_safety_nets!
81
+ return if handled?
82
+
83
+ log_unhandled
84
+
85
+ case code
86
+ when :forbidden
87
+ raise Errors::Unauthorized.new(
88
+ "#{@sequencer_class.name} denied: #{@result.message}",
89
+ @result
90
+ )
91
+ when :not_found
92
+ raise Errors::NotFound, "#{@sequencer_class.name} reported not_found"
93
+ else
94
+ raise Errors::Failed, "#{@sequencer_class.name} failed (#{code}): #{@result.message}"
95
+ end
96
+ end
97
+
98
+ def log_unhandled
99
+ logger.error("Sequencer #{@sequencer_class.name} failed unhandled at #{step_label} (#{code}): #{trail_summary}")
100
+ end
101
+
102
+ private
103
+
104
+ def execute
105
+ @handled = true
106
+ @returned = yield
107
+ end
108
+
109
+ def trail_summary
110
+ @result.trail.empty? ? "(no steps)" : @result.trail.map(&:to_s).join(" → ")
111
+ end
112
+
113
+ def step_label
114
+ step = @result.error && @result.error[:step]
115
+ step ? step.inspect : "(unknown step)"
116
+ end
117
+ end
118
+
119
+ module Substitute
120
+ include RecordInvocation
121
+
122
+ def succeed_with(**ctx_writes)
123
+ @configured_outcome = { kind: :success, ctx_writes: ctx_writes }
124
+ self
125
+ end
126
+
127
+ def policy_failure(**error_attrs)
128
+ configure_failure(:forbidden, error_attrs)
129
+ end
130
+
131
+ def not_found(**error_attrs)
132
+ configure_failure(:not_found, error_attrs)
133
+ end
134
+
135
+ def validation_failure(**error_attrs)
136
+ configure_failure(:validation_failed, error_attrs)
137
+ end
138
+
139
+ def other_error(code:, **error_attrs)
140
+ configure_failure(code, error_attrs)
141
+ end
142
+
143
+ def ran_with?(sequencer_class, **expected_kwargs)
144
+ records.any? do |invocation|
145
+ next false unless invocation.method_name == :call
146
+ next false unless invocation.arguments[:sequencer_class] == sequencer_class
147
+
148
+ captured = invocation.arguments[:kwargs] || {}
149
+ expected_kwargs.all? { |key, value| captured[key] == value }
150
+ end
151
+ end
152
+
153
+ record def call(sequencer_class, **kwargs, &block)
154
+ dispatch = Dispatch.new(sequencer_class, build_result)
155
+ block.call(dispatch) if block_given?
156
+ dispatch.enforce_safety_nets!
157
+ dispatch.returned
158
+ end
159
+
160
+ private
161
+
162
+ def configure_failure(code, error_attrs)
163
+ @configured_outcome = {
164
+ kind: :failure,
165
+ error: { code: code, **error_attrs }
166
+ }
167
+ self
168
+ end
169
+
170
+ def build_result
171
+ outcome = @configured_outcome || { kind: :success, ctx_writes: {} }
172
+ ctx = Hubbado::Sequence::Ctx.new
173
+
174
+ if outcome[:kind] == :success
175
+ outcome[:ctx_writes].each { |key, value| ctx[key] = value }
176
+ Hubbado::Sequence::Result.ok(ctx)
177
+ else
178
+ Hubbado::Sequence::Result.fail(ctx, error: outcome[:error])
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,109 @@
1
+ module Hubbado
2
+ module Sequence
3
+ module Sequencer
4
+ def self.included(cls)
5
+ cls.send(:include, ::Dependency)
6
+ cls.send(:include, ::Configure)
7
+ cls.extend(ClassMethods)
8
+
9
+ install_default_substitute(cls)
10
+ end
11
+
12
+ # Each sequencer gets a default `Substitute` module so it can be used as a
13
+ # dependency without bespoke test scaffolding. The user can reopen the
14
+ # module in their class body to add per-sequencer assertions; the defaults
15
+ # below are always available.
16
+ def self.install_default_substitute(cls)
17
+ return if cls.const_defined?(:Substitute, false)
18
+
19
+ cls.const_set(:Substitute, build_default_substitute_module)
20
+ end
21
+
22
+ def self.build_default_substitute_module
23
+ Module.new do
24
+ include ::RecordInvocation
25
+
26
+ def succeed_with(**ctx_writes)
27
+ @configured_writes = ctx_writes
28
+ self
29
+ end
30
+
31
+ def fail_with(**error_attrs)
32
+ @configured_error = error_attrs
33
+ self
34
+ end
35
+
36
+ record def call(ctx)
37
+ return ::Hubbado::Sequence::Result.fail(ctx, error: @configured_error) if @configured_error
38
+
39
+ if @configured_writes
40
+ @configured_writes.each { |k, v| ctx[k] = v }
41
+ end
42
+ ::Hubbado::Sequence::Result.ok(ctx)
43
+ end
44
+
45
+ def called?(**kwargs)
46
+ invoked?(:call, **kwargs)
47
+ end
48
+ end
49
+ end
50
+
51
+ module ClassMethods
52
+ # Bridge between the kwargs boundary (controllers and other top-level
53
+ # callers) and the ctx-passing convention used inside the framework.
54
+ # A caller can supply either an existing Ctx (the nested-sequencer case)
55
+ # or keyword arguments that become the initial ctx (the outermost case).
56
+ def call(ctx = nil, **kwargs)
57
+ if ctx.nil?
58
+ ctx = Ctx.build(kwargs)
59
+ elsif !kwargs.empty?
60
+ raise ArgumentError, "#{name}.() takes either a Ctx or keyword arguments, not both"
61
+ elsif !ctx.is_a?(Ctx)
62
+ ctx = Ctx.build(ctx)
63
+ end
64
+
65
+ build.call(ctx)
66
+ end
67
+
68
+ # Default factory: a sequencer with no configurable dependencies needs
69
+ # nothing more than `new`. Sequencers that have dependencies override
70
+ # `self.build` to run the corresponding `Macro.configure(instance, …)`
71
+ # calls.
72
+ def build
73
+ new
74
+ end
75
+
76
+ def i18n_scope
77
+ @i18n_scope ||= ::Casing::Underscore::String.(name).gsub('/', '.')
78
+ end
79
+ end
80
+
81
+ def i18n_scope
82
+ self.class.i18n_scope
83
+ end
84
+
85
+ def failure(ctx, **error_attrs)
86
+ Result.fail(ctx, error: error_attrs, i18n_scope: i18n_scope)
87
+ end
88
+
89
+ # Builds a Pipeline that auto-dispatches blockless `step(:foo)` calls to
90
+ # `self.foo(ctx)`. Use this inside a sequencer's `call` body in place of
91
+ # `Pipeline.(ctx)` whenever steps are local methods.
92
+ #
93
+ # Block form (`pipeline(ctx) { |p| ... }`) yields the pipeline, runs the
94
+ # block, and returns the final Result — no trailing `.result` needed. The
95
+ # non-block form returns the Pipeline so chained `.step(...)...result`
96
+ # calls still work.
97
+ def pipeline(ctx, &block)
98
+ pipe = Pipeline.new(ctx, dispatcher: self)
99
+
100
+ if block
101
+ block.call(pipe)
102
+ pipe.result
103
+ else
104
+ pipe
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,30 @@
1
+ require "i18n"
2
+ require "casing"
3
+ require "configure"; Configure.activate
4
+ require "dependency"; Dependency.activate
5
+ require "hubbado/log"
6
+ require "record_invocation"
7
+ require "template_method"; TemplateMethod.activate
8
+
9
+ I18n.load_path += Dir[File.expand_path("../../config/locales", __dir__) + "/*.yml"]
10
+
11
+ module Hubbado
12
+ module Sequence
13
+ end
14
+ end
15
+
16
+ require "hubbado/sequence/ctx"
17
+ require "hubbado/sequence/path"
18
+ require "hubbado/sequence/result"
19
+ require "hubbado/sequence/pipeline"
20
+ require "hubbado/sequence/sequencer"
21
+ require "hubbado/sequence/macros/model/find"
22
+ require "hubbado/sequence/macros/model/build"
23
+ require "hubbado/sequence/macros/contract/build"
24
+ require "hubbado/sequence/macros/contract/deserialize"
25
+ require "hubbado/sequence/macros/contract/validate"
26
+ require "hubbado/sequence/macros/contract/persist"
27
+ require "hubbado/sequence/macros/policy/check"
28
+ require "hubbado/sequence/errors"
29
+ require "hubbado/sequence/runner"
30
+ require "hubbado/sequence/run_sequence"
metadata ADDED
@@ -0,0 +1,227 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hubbado-sequence
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Hubbado Devs
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: evt-casing
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: evt-configure
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: evt-dependency
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: evt-record_invocation
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: evt-template_method
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: hubbado-log
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: i18n
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: debug
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: hubbado-style
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ - !ruby/object:Gem::Dependency
140
+ name: rake
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
153
+ - !ruby/object:Gem::Dependency
154
+ name: test_bench
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
167
+ description: Sequencer takes input, runs a sequence of steps, and returns a Result
168
+ indicating success or failure plus the working context that was built up during
169
+ execution.
170
+ email:
171
+ - devs@hubbado.com
172
+ executables: []
173
+ extensions: []
174
+ extra_rdoc_files: []
175
+ files:
176
+ - CHANGELOG.md
177
+ - LICENSE
178
+ - README.md
179
+ - config/locales/en.yml
180
+ - hubbado-sequence.gemspec
181
+ - lib/hubbado/sequence.rb
182
+ - lib/hubbado/sequence/controls.rb
183
+ - lib/hubbado/sequence/controls/contract.rb
184
+ - lib/hubbado/sequence/controls/model.rb
185
+ - lib/hubbado/sequence/controls/policy.rb
186
+ - lib/hubbado/sequence/ctx.rb
187
+ - lib/hubbado/sequence/errors.rb
188
+ - lib/hubbado/sequence/macros/contract/build.rb
189
+ - lib/hubbado/sequence/macros/contract/deserialize.rb
190
+ - lib/hubbado/sequence/macros/contract/persist.rb
191
+ - lib/hubbado/sequence/macros/contract/validate.rb
192
+ - lib/hubbado/sequence/macros/model/build.rb
193
+ - lib/hubbado/sequence/macros/model/find.rb
194
+ - lib/hubbado/sequence/macros/policy/check.rb
195
+ - lib/hubbado/sequence/path.rb
196
+ - lib/hubbado/sequence/pipeline.rb
197
+ - lib/hubbado/sequence/result.rb
198
+ - lib/hubbado/sequence/run_sequence.rb
199
+ - lib/hubbado/sequence/runner.rb
200
+ - lib/hubbado/sequence/sequencer.rb
201
+ homepage: https://github.com/hubbado/hubbado-sequence
202
+ licenses:
203
+ - MIT
204
+ metadata:
205
+ homepage_uri: https://github.com/hubbado/hubbado-sequence
206
+ source_code_uri: https://github.com/hubbado/hubbado-sequence
207
+ changelog_uri: https://github.com/hubbado/hubbado-sequence/blob/master/CHANGELOG.md
208
+ post_install_message:
209
+ rdoc_options: []
210
+ require_paths:
211
+ - lib
212
+ required_ruby_version: !ruby/object:Gem::Requirement
213
+ requirements:
214
+ - - ">="
215
+ - !ruby/object:Gem::Version
216
+ version: '3.3'
217
+ required_rubygems_version: !ruby/object:Gem::Requirement
218
+ requirements:
219
+ - - ">="
220
+ - !ruby/object:Gem::Version
221
+ version: '0'
222
+ requirements: []
223
+ rubygems_version: 3.5.22
224
+ signing_key:
225
+ specification_version: 4
226
+ summary: A small framework for orchestrating units of business behaviour
227
+ test_files: []