hubbado-sequence 0.4.0 → 0.6.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.
@@ -1,9 +1,9 @@
1
1
  # -*- encoding: utf-8 -*-
2
2
  Gem::Specification.new do |s|
3
3
  s.name = "hubbado-sequence"
4
- s.version = "0.4.0"
5
- s.summary = "A small framework for orchestrating units of business behaviour"
6
- s.description = "Sequencer takes input, runs a sequence of steps, and returns a Result indicating success or failure plus the working context that was built up during execution."
4
+ s.version = "0.6.0"
5
+ s.summary = "A small framework for the short sequences of common steps that controller actions usually boil down to"
6
+ s.description = "A sequencer takes input, runs an ordered sequence of steps, and returns a Result carrying a success-or-failure flag, a structured error, and the working context that was built up during execution. Built with Rails in mind but framework-agnostic."
7
7
 
8
8
  s.authors = ["Hubbado Devs"]
9
9
  s.email = ["devs@hubbado.com"]
@@ -13,7 +13,7 @@ module Hubbado
13
13
  def call(ctx, contract_class, attr_name = nil)
14
14
  model = attr_name && Path.resolve(ctx, attr_name)
15
15
  ctx[:contract] = contract_class.new(model)
16
- Result.ok(ctx)
16
+ Result.success(ctx)
17
17
  end
18
18
 
19
19
  module Substitute
@@ -31,10 +31,10 @@ module Hubbado
31
31
  end
32
32
 
33
33
  record def call(ctx, contract_class, attr_name = nil)
34
- return Result.fail(ctx, error: @configured_error) if @configured_error
34
+ return Result.failure(ctx, **@configured_error) if @configured_error
35
35
 
36
36
  ctx[:contract] = @return_value if @configured_success
37
- Result.ok(ctx)
37
+ Result.success(ctx)
38
38
  end
39
39
 
40
40
  def built?(**kwargs)
@@ -15,7 +15,7 @@ module Hubbado
15
15
 
16
16
  ctx[:contract].deserialize(params) if params
17
17
 
18
- Result.ok(ctx)
18
+ Result.success(ctx)
19
19
  end
20
20
 
21
21
  module Substitute
@@ -27,9 +27,9 @@ module Hubbado
27
27
  end
28
28
 
29
29
  record def call(ctx, from:)
30
- return Result.fail(ctx, error: @configured_error) if @configured_error
30
+ return Result.failure(ctx, **@configured_error) if @configured_error
31
31
 
32
- Result.ok(ctx)
32
+ Result.success(ctx)
33
33
  end
34
34
 
35
35
  def deserialized?(**kwargs)
@@ -14,9 +14,9 @@ module Hubbado
14
14
  contract = ctx[:contract]
15
15
 
16
16
  if contract.save
17
- Result.ok(ctx)
17
+ Result.success(ctx)
18
18
  else
19
- Result.fail(ctx, error: { code: :persist_failed })
19
+ Result.failure(ctx, code: :persist_failed)
20
20
  end
21
21
  end
22
22
 
@@ -34,9 +34,9 @@ module Hubbado
34
34
  end
35
35
 
36
36
  record def call(ctx)
37
- return Result.fail(ctx, error: @configured_error) if @configured_error
37
+ return Result.failure(ctx, **@configured_error) if @configured_error
38
38
 
39
- Result.ok(ctx)
39
+ Result.success(ctx)
40
40
  end
41
41
 
42
42
  def persisted?(**kwargs)
@@ -17,9 +17,9 @@ module Hubbado
17
17
  contract.validate(params)
18
18
 
19
19
  if contract.errors.empty?
20
- Result.ok(ctx)
20
+ Result.success(ctx)
21
21
  else
22
- Result.fail(ctx, error: { code: :validation_failed })
22
+ Result.failure(ctx, code: :validation_failed)
23
23
  end
24
24
  end
25
25
 
@@ -37,9 +37,9 @@ module Hubbado
37
37
  end
38
38
 
39
39
  record def call(ctx, from: nil)
40
- return Result.fail(ctx, error: @configured_error) if @configured_error
40
+ return Result.failure(ctx, **@configured_error) if @configured_error
41
41
 
42
- Result.ok(ctx)
42
+ Result.success(ctx)
43
43
  end
44
44
 
45
45
  def validated?(**kwargs)
@@ -17,7 +17,7 @@ module Hubbado
17
17
  else
18
18
  model.new(attributes)
19
19
  end
20
- Result.ok(ctx)
20
+ Result.success(ctx)
21
21
  end
22
22
 
23
23
  module Substitute
@@ -40,10 +40,10 @@ module Hubbado
40
40
  "Macros::Model::Build substitute: #{model} does not respond to :new"
41
41
  end
42
42
 
43
- return Result.fail(ctx, error: @configured_error) if @configured_error
43
+ return Result.failure(ctx, **@configured_error) if @configured_error
44
44
 
45
45
  ctx[as] = @return_value if @configured_success
46
- Result.ok(ctx)
46
+ Result.success(ctx)
47
47
  end
48
48
 
49
49
  def built?(**kwargs)
@@ -16,9 +16,9 @@ module Hubbado
16
16
 
17
17
  if record
18
18
  ctx[as] = record
19
- Result.ok(ctx)
19
+ Result.success(ctx)
20
20
  else
21
- Result.fail(ctx, error: { code: :not_found })
21
+ Result.failure(ctx, code: :not_found)
22
22
  end
23
23
  end
24
24
 
@@ -42,10 +42,10 @@ module Hubbado
42
42
  "Macros::Model::Find substitute: #{model} does not respond to :find_by"
43
43
  end
44
44
 
45
- return Result.fail(ctx, error: @configured_error) if @configured_error
45
+ return Result.failure(ctx, **@configured_error) if @configured_error
46
46
 
47
47
  ctx[as] = @return_value if @configured_success
48
- Result.ok(ctx)
48
+ Result.success(ctx)
49
49
  end
50
50
 
51
51
  def fetched?(**kwargs)
@@ -18,14 +18,12 @@ module Hubbado
18
18
  policy_result = policy_instance.public_send(action)
19
19
 
20
20
  if policy_result.permitted?
21
- Result.ok(ctx)
21
+ Result.success(ctx)
22
22
  else
23
- Result.fail(
23
+ Result.failure(
24
24
  ctx,
25
- error: {
26
- code: :forbidden,
27
- data: { policy: policy_instance, policy_result: policy_result }
28
- }
25
+ code: :forbidden,
26
+ data: { policy: policy_instance, policy_result: policy_result }
29
27
  )
30
28
  end
31
29
  end
@@ -49,9 +47,9 @@ module Hubbado
49
47
  "Macros::Policy::Check substitute: #{policy} does not declare action :#{action}"
50
48
  end
51
49
 
52
- return Result.fail(ctx, error: @configured_error) if @configured_error
50
+ return Result.failure(ctx, **@configured_error) if @configured_error
53
51
 
54
- Result.ok(ctx)
52
+ Result.success(ctx)
55
53
  end
56
54
 
57
55
  def checked?(**kwargs)
@@ -6,15 +6,15 @@ module Hubbado
6
6
  class Pipeline
7
7
  def initialize(ctx, dispatcher:)
8
8
  @ctx = ctx
9
- @trail = []
9
+ @successful_steps = []
10
10
  @failed_result = nil
11
11
  @dispatcher = dispatcher
12
12
  end
13
13
 
14
14
  # `step(:name)` dispatches to `dispatcher.send(name, ctx)`. The method
15
15
  # is treated as successful unless it explicitly returns a failed
16
- # `Result`; any other return value (nil, false, a model, `Result.ok`)
17
- # continues the pipeline with the same ctx. Only `Result.fail(...)` /
16
+ # `Result`; any other return value (nil, false, a model, `Result.success`)
17
+ # continues the pipeline with the same ctx. Only `Result.failure(...)` /
18
18
  # `failure(ctx, code: ...)` short-circuits.
19
19
  def step(name)
20
20
  return self if @failed_result
@@ -25,7 +25,7 @@ module Hubbado
25
25
 
26
26
  # `invoke(:name, *args, **kwargs)` calls a declared dependency on the
27
27
  # dispatcher: gets it via `dispatcher.send(name)` (the reader), then
28
- # invokes it with `(ctx, *args, **kwargs)`. Same trail recording,
28
+ # invokes it with `(ctx, *args, **kwargs)`. Same step recording,
29
29
  # failure short-circuiting, and lenient return convention as `step`.
30
30
  #
31
31
  # Use this for any declared dependency — macros
@@ -58,7 +58,7 @@ module Hubbado
58
58
  if @failed_result
59
59
  @failed_result
60
60
  else
61
- Result.ok(@ctx, trail: @trail.dup)
61
+ Result.success(@ctx, successful_steps: @successful_steps.dup)
62
62
  end
63
63
  end
64
64
 
@@ -84,16 +84,13 @@ module Hubbado
84
84
 
85
85
  def record(name, return_value)
86
86
  if return_value.is_a?(Result) && return_value.failure?
87
- @failed_result = tag_failure(return_value, name)
87
+ @failed_result = return_value
88
+ .with_step(name)
89
+ .with_successful_steps(@successful_steps.dup)
88
90
  else
89
- @trail << name
91
+ @successful_steps << name
90
92
  end
91
93
  end
92
-
93
- def tag_failure(result, step_name)
94
- tagged_error = result.error.merge(step: step_name)
95
- Result.fail(result.ctx, error: tagged_error, trail: @trail.dup, i18n_scope: result.i18n_scope)
96
- end
97
94
  end
98
95
  end
99
96
  end
@@ -4,68 +4,107 @@ module Hubbado
4
4
  FRAMEWORK_I18N_SCOPE = "sequence.errors".freeze
5
5
 
6
6
  attr_reader :ctx
7
- attr_reader :error
8
- attr_reader :trail
7
+ attr_reader :code
8
+ attr_reader :data
9
+ attr_reader :step
10
+ attr_reader :successful_steps
9
11
  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)
12
+ attr_reader :i18n_key
13
+ attr_reader :i18n_args
14
+
15
+ def self.success(ctx, successful_steps: [], i18n_scope: nil)
16
+ new(
17
+ :success,
18
+ ctx: ctx,
19
+ successful_steps: successful_steps,
20
+ i18n_scope: i18n_scope
21
+ )
13
22
  end
14
23
 
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)
24
+ def self.failure(ctx, code:, data: nil, step: nil,
25
+ i18n_scope: nil, i18n_key: nil, i18n_args: nil, successful_steps: [])
26
+ raise ArgumentError, "Result.failure requires code:" unless code
27
+
28
+ new(
29
+ :failure,
30
+ ctx: ctx,
31
+ code: code,
32
+ data: data,
33
+ step: step,
34
+ successful_steps: successful_steps,
35
+ i18n_scope: i18n_scope,
36
+ i18n_key: i18n_key,
37
+ i18n_args: i18n_args
38
+ )
21
39
  end
22
40
 
23
- def initialize(status, ctx, error:, trail:, i18n_scope:)
41
+ def initialize(status, ctx:, successful_steps:, i18n_scope:,
42
+ code: nil, data: nil, step: nil,
43
+ i18n_key: nil, i18n_args: nil)
24
44
  @status = status
25
45
  @ctx = ctx
26
- @error = error
27
- @trail = trail
46
+ @code = code
47
+ @data = data
48
+ @step = step
49
+ @successful_steps = successful_steps
28
50
  @i18n_scope = i18n_scope
51
+ @i18n_key = i18n_key
52
+ @i18n_args = i18n_args
29
53
  end
30
54
 
31
- def ok?
32
- @status == :ok
55
+ def success?
56
+ @status == :success
33
57
  end
34
58
 
35
59
  def failure?
36
- @status == :fail
60
+ @status == :failure
37
61
  end
38
62
 
39
- def with_trail(trail)
40
- self.class.new(@status, @ctx, error: @error, trail: trail, i18n_scope: @i18n_scope)
63
+ def with_successful_steps(successful_steps)
64
+ copy(successful_steps: successful_steps)
41
65
  end
42
66
 
43
67
  def with_i18n_scope(scope)
44
68
  return self unless @i18n_scope.nil?
45
69
 
46
- self.class.new(@status, @ctx, error: @error, trail: @trail, i18n_scope: scope)
70
+ copy(i18n_scope: scope)
47
71
  end
48
72
 
49
- def message
50
- return nil if ok?
73
+ def with_step(step)
74
+ copy(step: step)
75
+ end
51
76
 
52
- translation = translate_with_chain
53
- return translation if translation
77
+ def message
78
+ return nil if success?
54
79
 
55
- @error[:message] || humanize_code
80
+ translate_with_chain || humanize_code
56
81
  end
57
82
 
58
83
  private
59
84
 
85
+ def copy(**overrides)
86
+ self.class.new(
87
+ @status,
88
+ ctx: @ctx,
89
+ code: @code,
90
+ data: @data,
91
+ step: @step,
92
+ successful_steps: @successful_steps,
93
+ i18n_scope: @i18n_scope,
94
+ i18n_key: @i18n_key,
95
+ i18n_args: @i18n_args,
96
+ **overrides
97
+ )
98
+ end
99
+
60
100
  def translate_with_chain
61
101
  scopes = []
62
- scopes << @error[:i18n_scope] if @error[:i18n_scope]
63
102
  scopes << @i18n_scope if @i18n_scope
64
103
  scopes << FRAMEWORK_I18N_SCOPE
65
104
  scopes.uniq!
66
105
 
67
- key = @error[:i18n_key] || @error[:code]
68
- args = @error[:i18n_args] || {}
106
+ key = @i18n_key || @code
107
+ args = @i18n_args || {}
69
108
 
70
109
  scopes.each do |scope|
71
110
  translated = ::I18n.t("#{scope}.#{key}", default: nil, **args)
@@ -76,7 +115,7 @@ module Hubbado
76
115
  end
77
116
 
78
117
  def humanize_code
79
- @error[:code].to_s.tr("_", " ").capitalize
118
+ @code.to_s.tr("_", " ").capitalize
80
119
  end
81
120
  end
82
121
  end
@@ -25,7 +25,7 @@ module Hubbado
25
25
  class Dispatch
26
26
  include Hubbado::Log::Dependency
27
27
 
28
- attr_reader :returned, :result, :sequencer_class
28
+ attr_reader :returned, :sequencer_class
29
29
 
30
30
  def initialize(sequencer_class, result)
31
31
  @sequencer_class = sequencer_class
@@ -33,48 +33,73 @@ module Hubbado
33
33
  @handled = false
34
34
  end
35
35
 
36
+ # Read-throughs to the wrapped Result. Outcome blocks read these on
37
+ # the Dispatch object (the block argument) without hopping through
38
+ # an inner Result reference.
39
+ def code = @result.code
40
+ def data = @result.data
41
+ def step = @result.step
42
+ def message = @result.message
43
+ def successful_steps = @result.successful_steps
44
+ def ctx = @result.ctx
45
+
36
46
  def success
37
- return unless @result.ok?
47
+ return unless @result.success?
38
48
  execute { yield(@result.ctx) }
39
- logger.info("Sequencer #{@sequencer_class.name} succeeded: #{trail_summary}")
49
+ logger.info("Sequencer #{@sequencer_class.name} succeeded: #{steps_summary}")
40
50
  end
41
51
 
42
52
  def policy_failed
43
53
  return unless code == :forbidden
44
54
  execute { yield(@result.ctx) }
45
- logger.info("Sequencer #{@sequencer_class.name} policy failed at #{step_label} (#{code}): #{trail_summary}")
55
+ logger.info("Sequencer #{@sequencer_class.name} policy failed at #{step_label} (#{code}): #{steps_summary}")
46
56
  end
47
57
 
48
58
  def not_found
49
59
  return unless code == :not_found
50
60
  execute { yield(@result.ctx) }
51
- logger.info("Sequencer #{@sequencer_class.name} not found at #{step_label}: #{trail_summary}")
61
+ logger.info("Sequencer #{@sequencer_class.name} not found at #{step_label}: #{steps_summary}")
52
62
  end
53
63
 
54
64
  def validation_failed
55
65
  return unless code == :validation_failed
56
66
  execute { yield(@result.ctx) }
57
- logger.info("Sequencer #{@sequencer_class.name} validation failed at #{step_label}: #{trail_summary}")
67
+ logger.info("Sequencer #{@sequencer_class.name} validation failed at #{step_label}: #{steps_summary}")
58
68
  end
59
69
 
60
70
  # otherwise deliberately does not catch policy denials or not_found —
61
71
  # those have their own required handlers.
62
72
  def otherwise
63
- return if @result.ok?
73
+ return if @result.success?
64
74
  return if code == :forbidden
65
75
  return if code == :not_found
66
76
  return if @handled
67
77
 
68
78
  execute { yield(@result.ctx) }
69
- logger.info("Sequencer #{@sequencer_class.name} failed at #{step_label} (#{code}): #{trail_summary}")
79
+ logger.info("Sequencer #{@sequencer_class.name} failed at #{step_label} (#{code}): #{steps_summary}")
70
80
  end
71
81
 
72
- def code
73
- @result.error&.[](:code)
82
+ def handled?
83
+ @result.success? || @handled
74
84
  end
75
85
 
76
- def handled?
77
- @result.ok? || @handled
86
+ # Raise the standard policy-denial exception. Available inside an
87
+ # outcome block (e.g. for callers that handle some policy reasons
88
+ # inline and want the framework's standard escalation for the rest)
89
+ # and used internally by enforce_safety_nets! when no handler ran.
90
+ def raise_policy_failed
91
+ raise Errors::Unauthorized.new(
92
+ "#{@sequencer_class.name} denied: #{@result.message}",
93
+ @result
94
+ )
95
+ end
96
+
97
+ def raise_not_found
98
+ raise Errors::NotFound, "#{@sequencer_class.name} reported not_found"
99
+ end
100
+
101
+ def raise_failed
102
+ raise Errors::Failed, "#{@sequencer_class.name} failed (#{code}): #{@result.message}"
78
103
  end
79
104
 
80
105
  def enforce_safety_nets!
@@ -83,20 +108,14 @@ module Hubbado
83
108
  log_unhandled
84
109
 
85
110
  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}"
111
+ when :forbidden then raise_policy_failed
112
+ when :not_found then raise_not_found
113
+ else raise_failed
95
114
  end
96
115
  end
97
116
 
98
117
  def log_unhandled
99
- logger.error("Sequencer #{@sequencer_class.name} failed unhandled at #{step_label} (#{code}): #{trail_summary}")
118
+ logger.error("Sequencer #{@sequencer_class.name} failed unhandled at #{step_label} (#{code}): #{steps_summary}")
100
119
  end
101
120
 
102
121
  private
@@ -106,12 +125,11 @@ module Hubbado
106
125
  @returned = yield
107
126
  end
108
127
 
109
- def trail_summary
110
- @result.trail.empty? ? "(no steps)" : @result.trail.map(&:to_s).join(" → ")
128
+ def steps_summary
129
+ successful_steps.empty? ? "(no steps)" : successful_steps.map(&:to_s).join(" → ")
111
130
  end
112
131
 
113
132
  def step_label
114
- step = @result.error && @result.error[:step]
115
133
  step ? step.inspect : "(unknown step)"
116
134
  end
117
135
  end
@@ -162,7 +180,8 @@ module Hubbado
162
180
  def configure_failure(code, error_attrs)
163
181
  @configured_outcome = {
164
182
  kind: :failure,
165
- error: { code: code, **error_attrs }
183
+ code: code,
184
+ error_attrs: error_attrs
166
185
  }
167
186
  self
168
187
  end
@@ -173,9 +192,9 @@ module Hubbado
173
192
 
174
193
  if outcome[:kind] == :success
175
194
  outcome[:ctx_writes].each { |key, value| ctx[key] = value }
176
- Hubbado::Sequence::Result.ok(ctx)
195
+ Hubbado::Sequence::Result.success(ctx)
177
196
  else
178
- Hubbado::Sequence::Result.fail(ctx, error: outcome[:error])
197
+ Hubbado::Sequence::Result.failure(ctx, code: outcome[:code], **outcome[:error_attrs])
179
198
  end
180
199
  end
181
200
  end
@@ -34,12 +34,12 @@ module Hubbado
34
34
  end
35
35
 
36
36
  record def call(ctx)
37
- return ::Hubbado::Sequence::Result.fail(ctx, error: @configured_error) if @configured_error
37
+ return ::Hubbado::Sequence::Result.failure(ctx, **@configured_error) if @configured_error
38
38
 
39
39
  if @configured_writes
40
40
  @configured_writes.each { |k, v| ctx[k] = v }
41
41
  end
42
- ::Hubbado::Sequence::Result.ok(ctx)
42
+ ::Hubbado::Sequence::Result.success(ctx)
43
43
  end
44
44
 
45
45
  def called?(**kwargs)
@@ -83,7 +83,8 @@ module Hubbado
83
83
  end
84
84
 
85
85
  def failure(ctx, **error_attrs)
86
- Result.fail(ctx, error: error_attrs, i18n_scope: i18n_scope)
86
+ error_attrs[:i18n_scope] ||= i18n_scope
87
+ Result.failure(ctx, **error_attrs)
87
88
  end
88
89
 
89
90
  # Builds a Pipeline that auto-dispatches blockless `step(:foo)` calls to
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hubbado-sequence
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hubbado Devs
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-15 00:00:00.000000000 Z
11
+ date: 2026-05-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: evt-casing
@@ -164,9 +164,9 @@ dependencies:
164
164
  - - ">="
165
165
  - !ruby/object:Gem::Version
166
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.
167
+ description: A sequencer takes input, runs an ordered sequence of steps, and returns
168
+ a Result carrying a success-or-failure flag, a structured error, and the working
169
+ context that was built up during execution. Built with Rails in mind but framework-agnostic.
170
170
  email:
171
171
  - devs@hubbado.com
172
172
  executables: []
@@ -223,5 +223,6 @@ requirements: []
223
223
  rubygems_version: 3.5.22
224
224
  signing_key:
225
225
  specification_version: 4
226
- summary: A small framework for orchestrating units of business behaviour
226
+ summary: A small framework for the short sequences of common steps that controller
227
+ actions usually boil down to
227
228
  test_files: []