hubbado-sequence 0.3.0 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bf9d0a82e802923d5f0a04043400578eb8c90ad8c78a00073a30c99cab8784e6
4
- data.tar.gz: 91e67b7ed3f8fc5fe428bcfea0eb359d4f8c03edb090ccba56d1a16799d2a48d
3
+ metadata.gz: 7f708335623135a67d05ecdf13e49c97f9d0a1d1a2c21c2fda731f83c69ecc65
4
+ data.tar.gz: 011fcaa92a23f287b8800da473f37452c9305bd08a0e00833811817b50d858db
5
5
  SHA512:
6
- metadata.gz: 902c46876f2df354ce46f340ef0fa6432d34c867eaff764c14e5ab54f54a8bb1dc466601c1c3da8f3823d5c0637af3d5c279e7bdb3596c8ff6a785488a333d31
7
- data.tar.gz: 9edff4e5aa8e45dbdb20a31f7cfe360c6f03f8751986cef344146be16e5d5e771c1c8318ef89ab80baef61b89927aabe327ac29c175bbdc7246e00257db8086d
6
+ metadata.gz: 440e563ba5e86174e51c186ca365d30f7160a93ca4d4d79669d23e0461acfc1030261b63d109223e5a37129cefc4c5c0cfd6433219dd0edf3be2d7c9576048e5
7
+ data.tar.gz: c5a884e0a781e0b9794a3997ba1eaadbd03eeceaf02289ee33cab2528686281d89016ef9f82e7e04d6b71be5ce4378dc81336c5c7e1866e32d6b0f0f01cdff54
data/CHANGELOG.md CHANGED
@@ -4,6 +4,88 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](http://keepachangelog.com/)
5
5
  and this project adheres to [Semantic Versioning](http://semver.org/).
6
6
 
7
+ ## [0.5.0] - Result vocabulary renamed: success/failure and successful_steps
8
+
9
+ ### Changed (breaking)
10
+
11
+ - **`Result.ok` → `Result.success`** and **`Result.fail` → `Result.failure`**.
12
+ Aligns with the wider Ruby railway-oriented vocabulary (dry-monads,
13
+ dry-transaction) and replaces the asymmetric `ok`/`failure?` pair with a
14
+ consistent `success`/`failure` pair.
15
+
16
+ ```ruby
17
+ # before
18
+ Result.ok(ctx)
19
+ Result.fail(ctx, error: { code: :forbidden })
20
+ result.ok?
21
+
22
+ # after
23
+ Result.success(ctx)
24
+ Result.failure(ctx, error: { code: :forbidden })
25
+ result.success?
26
+ ```
27
+
28
+ `result.failure?` is unchanged.
29
+
30
+ Migration: search-and-replace `Result.ok(` → `Result.success(`,
31
+ `Result.fail(` → `Result.failure(`, and `.ok?` → `.success?`. RSpec
32
+ matchers `be_ok` become `be_success`.
33
+
34
+ - **`Result#trail` → `Result#successful_steps`** (and `with_trail` →
35
+ `with_successful_steps`, `trail:` kwarg → `successful_steps:`). The old
36
+ name was confusing because the failing step is *not* in the list — it
37
+ lives on `error[:step]`. The new name says exactly what's there.
38
+
39
+ ```ruby
40
+ # before
41
+ result.trail # => [:find, :build_contract]
42
+ result.with_trail([...])
43
+ Result.ok(ctx, trail: [...])
44
+
45
+ # after
46
+ result.successful_steps # => [:find, :build_contract]
47
+ result.with_successful_steps([...])
48
+ Result.success(ctx, successful_steps: [...])
49
+ ```
50
+
51
+ Migration: search-and-replace `.trail` → `.successful_steps`,
52
+ `with_trail(` → `with_successful_steps(`, and the keyword argument
53
+ `trail:` → `successful_steps:`.
54
+
55
+ ## [0.4.0] - Inline step blocks removed; Pipeline made internal
56
+
57
+ ### Removed (breaking)
58
+
59
+ - **Inline block form of `step` removed.** `p.step(:name) { |ctx| ... }`
60
+ is no longer supported. Every step must be a method on the sequencer
61
+ with the same name:
62
+
63
+ ```ruby
64
+ # before
65
+ p.step(:notify) { |ctx| UserMailer.updated(ctx[:user]).deliver_later }
66
+
67
+ # after
68
+ p.step(:notify) # dispatches to def notify(ctx)
69
+ ```
70
+
71
+ Migration: extract each inline block to a private method of the same
72
+ name. One method per step; one method per responsibility.
73
+
74
+ - **`Pipeline` is no longer part of the public API.** `Pipeline.(ctx)`
75
+ and `Pipeline.new` are internal to the framework. Sequencers build
76
+ pipelines exclusively through the `pipeline(ctx)` helper.
77
+
78
+ Migration: any direct `Pipeline.(ctx)` or `Pipeline.new(ctx, ...)`
79
+ call sites must be replaced with a sequencer that uses `pipeline(ctx)`.
80
+
81
+ ### Changed
82
+
83
+ - **`Pipeline#step` always auto-dispatches.** With inline blocks gone,
84
+ `step(:foo)` unconditionally dispatches to `self.foo(ctx)` on the
85
+ sequencer — no block-versus-dispatch ambiguity. Missing methods raise
86
+ `NoMethodError` with the step name and the sequencer class in the
87
+ message.
88
+
7
89
  ## [0.3.0] - Contract::Deserialize macro, Runner extraction, Path helper
8
90
 
9
91
  ### Added
data/README.md CHANGED
@@ -1,15 +1,23 @@
1
1
  # hubbado-sequence
2
2
 
3
- A small framework for orchestrating units of business behaviour. The eventual
4
- replacement for Trailblazer operations at Hubbado, designed to coexist with
5
- them during migration.
6
-
7
- A sequencer takes input, runs a sequence of steps, and returns a `Result`
8
- indicating success or failure plus the working context that was built up
9
- during execution.
10
-
11
- The full design rationale lives in [`docs/design.md`](docs/design.md). This
12
- README is a quick tour.
3
+ A small framework (about 200 lines of core code) for orchestrating the
4
+ short sequences of common steps that controller actions usually boil
5
+ down to — find a model, validate a contract, call a domain object, save
6
+ something, redirect. A sequencer takes input, runs an ordered sequence
7
+ of steps, and returns a `Result` carrying a success-or-failure flag, a
8
+ structured error, and the working context that was built up during
9
+ execution.
10
+
11
+ The DSL is deliberately minimal: every step is a regular method, every
12
+ dependency is a regular Ruby object, and control flow uses regular Ruby
13
+ (`if`, `unless`, `case`) rather than a conditional DSL. The framework
14
+ gets out of the way once you've described the high-level sequence; the
15
+ real work lives in the plain-Ruby objects the steps call.
16
+
17
+ Built with Rails in mind but framework-agnostic — the core has no Rails
18
+ dependency, and the included `RunSequence` mixin works in any host
19
+ (controllers, jobs, scripts). The full design rationale lives in
20
+ [`docs/design.md`](docs/design.md). This README is a quick tour.
13
21
 
14
22
  ## Installation
15
23
 
@@ -24,9 +32,8 @@ Then run `bundle install`.
24
32
  ## Requirements
25
33
 
26
34
  - Ruby >= 3.3
27
- - [evt-dependency](https://github.com/eventide-project/dependency) — powers
28
- the injectable macro / nested-sequencer pattern (declared as a runtime
29
- dependency of the gem).
35
+ - [evt-dependency](https://github.com/eventide-project/dependency) —
36
+ dependency injection (declared as a runtime dependency of the gem).
30
37
 
31
38
  Optional, depending on which macros you use:
32
39
 
@@ -39,29 +46,59 @@ Optional, depending on which macros you use:
39
46
 
40
47
  ## Philosophy
41
48
 
42
- Sequencers sit at the controller boundary. They receive input from a Rails
43
- action, orchestrate the work, and hand a `Result` back. The sequencer's job
44
- is **orchestration only** — it should not contain business logic itself. Real
45
- behaviour lives in the models, contracts, policies, and domain objects it
46
- calls.
47
-
48
- Nesting is intentionally shallow. The only nesting we use in practice is a
49
- `Present` sequencer inside an `Update` sequencer: Present loads the record,
50
- builds the contract, and checks the policy; Update calls Present and then
51
- validates and persists. Chains longer than one level are rare enough to be a
52
- signal that something should be a plain Ruby object instead.
53
-
54
- The framework uses [evt-dependency](https://github.com/eventide-project/dependency),
55
- which means every macro and every nested sequencer is an injectable
56
- dependency. Calling `.new` on a sequencer installs substitutes for all of
57
- them, so unit tests exercise the sequencer's orchestration logic what runs,
58
- in what order, what short-circuits without hitting the database, the policy
59
- gem, or Reform. The substitutes default to pass-through `ok`, so a test only
60
- configures the outcomes that matter for the scenario it's verifying.
61
-
62
- Integration coverage (using `.build` to wire real collaborators) is reserved
63
- for the controller boundaryone happy-path integration test per sequencer
64
- is usually enough to confirm the wiring is correct.
49
+ Sequencers sit at the controller boundary. They receive input, orchestrate
50
+ the work, and hand a `Result` back. The sequencer's job is **orchestration
51
+ only** — it should not contain business logic itself. Real behaviour lives
52
+ in the models, contracts, policies, and domain objects it calls.
53
+
54
+ The framework is built with Rails in mind but doesn't require it — the core
55
+ of the gem has no Rails dependency, and `Hubbado::Sequence::RunSequence` is
56
+ a plain mixin that works in any host that drives a sequencer from a fixed
57
+ lifecycle (Sinatra actions, Rack handlers, Hanami actions, job workers).
58
+ ActiveRecord and Reform are only needed if you use the macros that wrap
59
+ them.
60
+
61
+ In a Rails context the gem solves a specific pain: a controller action is
62
+ hard to unit-test because the framework owns its lifecycle, and that gets
63
+ worse the moment you want dependency injection. Sequencers lift the
64
+ testable work *out* of the controller into a plain Ruby object that
65
+ exposes its dependencies cleanly, and `run_sequence` keeps the controller
66
+ itself thin branching to redirect, render, or set a flash based on the
67
+ sequencer's outcome.
68
+
69
+ Most controller actions shouldn't contain much business logic anyway. They're
70
+ a short sequence of common steps find a model, validate a contract, save
71
+ something, redirect. The sequencer DSL is designed to make that high-level
72
+ sequence compact and easy to scan, without trying to be the home for the
73
+ business logic underneath. Regular Ruby is already excellent at that.
74
+
75
+ The DSL is deliberately minimal. A sequencer's `pipeline(ctx)` block is a
76
+ small set of conventions around how `ctx` flows and what each step
77
+ returns — nothing more. Steps are regular methods on a regular Ruby
78
+ object, dependencies are regular Ruby objects, and the pipeline lets you
79
+ use regular Ruby `if` / `unless` / `case` for control flow rather than
80
+ inventing a conditional DSL. Where the framework can get out of your way,
81
+ it does.
82
+
83
+ The gem doesn't impose a nesting depth, but in practice we keep nesting
84
+ very shallow — typically one level. The only nesting we use is a `Present`
85
+ sequencer inside an `Update` sequencer: Present loads the record, builds
86
+ the contract, and checks the policy; Update calls Present and then
87
+ validates and persists. Anything deeper is a signal that a chunk of the
88
+ work should be a plain Ruby object instead.
89
+
90
+ The framework uses [evt-dependency](https://github.com/eventide-project/dependency)
91
+ for dependency injection. Every macro and every nested sequencer is an
92
+ injected dependency, which means calling `.new` on a sequencer installs
93
+ substitutes for all of them. Unit tests exercise the sequencer's
94
+ orchestration logic — what runs, in what order, what short-circuits —
95
+ without hitting the database, the policy gem, or Reform. The substitutes
96
+ default to pass-through success, so a test only configures the outcomes
97
+ that matter for the scenario it's verifying.
98
+
99
+ Integration coverage (using `.build` to wire real collaborators) is
100
+ reserved for the controller boundary — one happy-path integration test
101
+ per sequencer is usually enough to confirm the wiring is correct.
65
102
 
66
103
  ## Quick start
67
104
 
@@ -114,38 +151,39 @@ class UsersController < ApplicationController
114
151
  end
115
152
  ```
116
153
 
117
- ## The three step shapes
154
+ ## The two step shapes
118
155
 
119
156
  ```ruby
120
157
  pipeline(ctx) do |p|
121
- p.invoke(:find, :user) # declared dependency (macro or sequencer)
122
- p.step(:scrub_params) # local method `def scrub_params(ctx)`
123
- p.step(:audit) { |c| AuditLog.append(c[:user]) } # inline block
158
+ p.invoke(:find, User, as: :user) # declared dependency (macro or sequencer)
159
+ p.step(:scrub_params) # local method `def scrub_params(ctx)`
124
160
  end
125
161
  ```
126
162
 
127
163
  - `p.invoke(:foo, *args, **kwargs)` — a `dependency :foo, …` declared on the
128
164
  sequencer (a macro or a nested sequencer). Calls
129
165
  `dispatcher.foo.(ctx, *args, **kwargs)`.
130
- - `p.step(:foo)` — a local instance method. Auto-dispatches to
131
- `self.foo(ctx)`.
132
- - `p.step(:foo) { |ctx| … }` — explicit inline block.
166
+ - `p.step(:foo)` — a local instance method. Dispatches to `self.foo(ctx)`.
133
167
 
134
- The `pipeline(ctx)` helper (lowercase `p`) is what enables blockless
135
- `p.step(:foo)` auto-dispatch it builds a Pipeline that knows which
136
- sequencer to dispatch back to. `Pipeline.(ctx)` (capital `P`) is the bare
137
- constructor with no dispatcher and requires every `step` to have a block.
138
- Use `pipeline(ctx)` inside a sequencer; `Pipeline.(ctx)` is mainly useful
139
- for framework tests.
168
+ Every `step` is a method on the sequencer with the same name as the step.
169
+ This makes the `call` body a table of contents scan `p.step(:...)` lines
170
+ to see the sequence shape, jump to the method for details.
171
+
172
+ `pipeline(ctx)` is the only way to build a pipeline. The underlying
173
+ Pipeline class is an implementation detail; sequencers do not construct
174
+ it directly.
140
175
 
141
176
  ## Built-in macros
142
177
 
143
178
  Each macro is a dependency declared on a sequencer with `dependency :name, Macros::...`
144
- and wired via `.configure(instance)` in `.build`.
179
+ and wired via `.configure(instance)` in `.build`. The built-in macros are
180
+ grouped by the gem they expect to be available.
181
+
182
+ ### ActiveRecord macros
145
183
 
146
- The model macros are designed to work with ActiveRecord models.
184
+ Designed to work with [ActiveRecord](https://github.com/rails/rails) models.
147
185
 
148
- ### Model::Find
186
+ #### Model::Find
149
187
 
150
188
  Fetches a record using `model.find_by(id:)` and writes it to `ctx[as]`.
151
189
 
@@ -161,7 +199,7 @@ p.invoke(:find, User, as: :user, id_key: %i[params id]) # nested path (default)
161
199
  | **Writes** | `ctx[as]` — the found record |
162
200
  | **Fails** | `:not_found` when `find_by` returns nil |
163
201
 
164
- ### Model::Build
202
+ #### Model::Build
165
203
 
166
204
  Instantiates a new record and writes it to `ctx[as]`.
167
205
 
@@ -176,9 +214,12 @@ p.invoke(:build_record, User, as: :user, attributes: { role: :admin })
176
214
  | **Writes** | `ctx[as]` — the new instance |
177
215
  | **Fails** | never |
178
216
 
179
- The contract macros are designed to work with [Reform](https://github.com/trailblazer/reform) form objects.
217
+ ### Reform macros
180
218
 
181
- ### Contract::Build
219
+ Designed to work with [Reform](https://github.com/trailblazer/reform) form
220
+ objects (contracts).
221
+
222
+ #### Contract::Build
182
223
 
183
224
  Wraps a model in a contract and writes it to `ctx[:contract]`.
184
225
 
@@ -193,7 +234,7 @@ p.invoke(:build_contract, Contracts::CreateUser) # no model
193
234
  | **Writes** | `ctx[:contract]` |
194
235
  | **Fails** | never |
195
236
 
196
- ### Contract::Deserialize
237
+ #### Contract::Deserialize
197
238
 
198
239
  Deserializes params into the contract via `contract.deserialize(params)`.
199
240
 
@@ -208,7 +249,7 @@ p.invoke(:deserialize_to_contract, from: :raw_params)
208
249
  | **Writes** | nothing (mutates the contract in place) |
209
250
  | **Fails** | never (no-op when the `from:` path is absent) |
210
251
 
211
- ### Contract::Validate
252
+ #### Contract::Validate
212
253
 
213
254
  Validates the contract via `contract.validate(params)` and checks `errors`.
214
255
 
@@ -223,7 +264,7 @@ p.invoke(:validate) # contract already deserialized; passes empty params
223
264
  | **Writes** | nothing (populates `contract.errors` on invalid) |
224
265
  | **Fails** | `:validation_failed` when `contract.errors` is non-empty |
225
266
 
226
- ### Contract::Persist
267
+ #### Contract::Persist
227
268
 
228
269
  Saves the contract via `contract.save`.
229
270
 
@@ -237,7 +278,12 @@ p.invoke(:persist)
237
278
  | **Writes** | nothing |
238
279
  | **Fails** | `:persist_failed` when `save` returns false |
239
280
 
240
- ### Policy::Check
281
+ ### hubbado-policy macros
282
+
283
+ Designed to work with the
284
+ [hubbado-policy](https://github.com/hubbado/hubbado-policy) gem.
285
+
286
+ #### Policy::Check
241
287
 
242
288
  Builds a policy and calls the named action to authorise the operation.
243
289
 
@@ -245,9 +291,9 @@ Builds a policy and calls the named action to authorise the operation.
245
291
  p.invoke(:check_policy, Policies::User, :user, :update)
246
292
  ```
247
293
 
248
- Designed to work with the [hubbado-policy](https://github.com/hubbado/hubbado-policy) gem.
249
- The policy class must respond to `.build(current_user, record)`; the instance must
250
- respond to the action method and return an object with `permitted?`.
294
+ The policy class must respond to `.build(current_user, record)`; the
295
+ instance must respond to the action method and return an object with
296
+ `permitted?`.
251
297
 
252
298
  | | |
253
299
  |---|---|
@@ -273,9 +319,15 @@ def call(ctx)
273
319
  t.invoke(:persist)
274
320
  end
275
321
 
276
- p.step(:notify) { |c| UserMailer.updated(c[:user]).deliver_later }
322
+ p.step(:notify)
277
323
  end
278
324
  end
325
+
326
+ private
327
+
328
+ def notify(ctx)
329
+ UserMailer.updated(ctx[:user]).deliver_later
330
+ end
279
331
  ```
280
332
 
281
333
  Steps before the transaction run outside it (read-only lookups, policy
@@ -289,46 +341,46 @@ as part of the same pipeline.
289
341
 
290
342
  The "find the record, build the contract, check the policy" shape is shared
291
343
  between an edit form and an update action — both need exactly that, and the
292
- update then validates and persists. Extract the shared part as a Present
293
- sequencer and nest it as a dependency:
344
+ update then validates and persists. Define Present as a nested class on the
345
+ outer sequencer so the two stay co-located:
294
346
 
295
347
  ```ruby
296
- class Seqs::PresentUser
297
- include Hubbado::Sequence::Sequencer
348
+ class Seqs::UpdateUser
349
+ class Present
350
+ include Hubbado::Sequence::Sequencer
298
351
 
299
- configure :present # so a parent can use `Seqs::PresentUser.configure(instance)`
352
+ configure :present # so a parent can use `Present.configure(instance)`
300
353
 
301
- dependency :find, Macros::Model::Find
302
- dependency :build_contract, Macros::Contract::Build
303
- dependency :check_policy, Macros::Policy::Check
354
+ dependency :find, Macros::Model::Find
355
+ dependency :build_contract, Macros::Contract::Build
356
+ dependency :check_policy, Macros::Policy::Check
304
357
 
305
- def self.build
306
- new.tap do |instance|
307
- Macros::Model::Find.configure(instance)
308
- Macros::Contract::Build.configure(instance)
309
- Macros::Policy::Check.configure(instance)
358
+ def self.build
359
+ new.tap do |instance|
360
+ Macros::Model::Find.configure(instance)
361
+ Macros::Contract::Build.configure(instance)
362
+ Macros::Policy::Check.configure(instance)
363
+ end
310
364
  end
311
- end
312
365
 
313
- def call(ctx)
314
- pipeline(ctx) do |p|
315
- p.invoke(:find, User, as: :user)
316
- p.invoke(:build_contract, Contracts::UpdateUser, :user)
317
- p.invoke(:check_policy, Policies::User, :user, :update)
366
+ def call(ctx)
367
+ pipeline(ctx) do |p|
368
+ p.invoke(:find, User, as: :user)
369
+ p.invoke(:build_contract, Contracts::UpdateUser, :user)
370
+ p.invoke(:check_policy, Policies::User, :user, :update)
371
+ end
318
372
  end
319
373
  end
320
- end
321
374
 
322
- class Seqs::UpdateUser
323
375
  include Hubbado::Sequence::Sequencer
324
376
 
325
- dependency :present, Seqs::PresentUser
377
+ dependency :present, Present
326
378
  dependency :validate, Macros::Contract::Validate
327
379
  dependency :persist, Macros::Contract::Persist
328
380
 
329
381
  def self.build
330
382
  new.tap do |instance|
331
- Seqs::PresentUser.configure(instance)
383
+ Present.configure(instance)
332
384
  Macros::Contract::Validate.configure(instance)
333
385
  Macros::Contract::Persist.configure(instance)
334
386
  end
@@ -355,7 +407,7 @@ class UsersController < ApplicationController
355
407
  include Hubbado::Sequence::RunSequence
356
408
 
357
409
  def edit
358
- run_sequence Seqs::PresentUser, params: params, current_user: current_user do |result|
410
+ run_sequence Seqs::UpdateUser::Present, params: params, current_user: current_user do |result|
359
411
  result.success { |ctx| render :edit, locals: { contract: ctx[:contract] } }
360
412
  result.policy_failed { |_| redirect_to root_path, alert: result.message }
361
413
  result.not_found { |_| render_404 }
@@ -375,15 +427,15 @@ end
375
427
 
376
428
  Inner writes (`ctx[:user]`, `ctx[:contract]`) are visible to outer steps —
377
429
  Present and Update share the same `Ctx`, so `:validate` and `:persist` see
378
- exactly what Present built. The outer trail records `:present` as a single
379
- step; Present's inner steps stay opaque to the parent.
430
+ exactly what Present built. The outer pipeline records `:present` as a single
431
+ entry in `successful_steps`; Present's inner steps stay opaque to the parent.
380
432
 
381
433
  ## Result, success, failure
382
434
 
383
435
  A step is **successful unless it explicitly returns a failed `Result`**. Any
384
- other return value (`nil`, `false`, a model, `Result.ok(...)`) is taken as
436
+ other return value (`nil`, `false`, a model, `Result.success(...)`) is taken as
385
437
  success and the pipeline continues with the same `ctx`. Only
386
- `Result.fail(...)` or the `failure(ctx, code: ...)` helper short-circuits.
438
+ `Result.failure(...)` or the `failure(ctx, code: ...)` helper short-circuits.
387
439
 
388
440
  ```ruby
389
441
  def call(ctx)
@@ -397,7 +449,7 @@ private
397
449
 
398
450
  def must_be_premium(ctx)
399
451
  return failure(ctx, code: :forbidden) unless ctx[:user].premium?
400
- # implicit ok if we get here
452
+ # implicit success if we get here
401
453
  end
402
454
  ```
403
455
 
@@ -408,46 +460,65 @@ same error attrs as the underlying error hash (`code:`, `i18n_key:`,
408
460
 
409
461
  ## Testing
410
462
 
411
- `described_class.new` returns a sequencer with all dependencies installed as
412
- substitutes. Tests configure the substitutes for the scenario at hand.
413
- `described_class.build` runs the production wiring (the real macros).
414
- Substitutes default to pass-through `Result.ok(ctx)` so a test only
463
+ The gem doesn't prescribe a testing library sequencers, macros, and
464
+ substitutes are plain Ruby objects that work with whatever you use.
465
+ The examples below are written in
466
+ [TestBench](https://github.com/test-bench/test-bench) (what we use at
467
+ Hubbado), but the same patterns translate directly to RSpec, Minitest,
468
+ or any other framework.
469
+
470
+ `Seqs::UpdateUser.new` returns a sequencer with all dependencies installed
471
+ as substitutes. Tests configure the substitutes for the scenario at hand.
472
+ `Seqs::UpdateUser.build` runs the production wiring (the real macros).
473
+ Substitutes default to pass-through `Result.success(ctx)` so a test only
415
474
  configures the ones whose return matters.
416
475
 
417
476
  ### Substituting macros directly
418
477
 
419
478
  ```ruby
420
- RSpec.describe Seqs::PresentUser do
421
- it "loads the user, builds the contract, and passes the policy" do
422
- seq = described_class.new
423
- user = User.new(id: 1, email: "old@example.com")
424
- contract = Contracts::UpdateUser.new(user)
425
- seq.find.succeed_with(user)
426
- seq.build_contract.succeed_with(contract)
427
-
428
- result = seq.(Hubbado::Sequence::Ctx.build(
429
- params: { id: 1 },
430
- current_user: User.new
431
- ))
432
-
433
- expect(result).to be_ok
434
- expect(seq.find.fetched?(as: :user)).to be true
435
- expect(seq.build_contract.built?).to be true
436
- expect(seq.check_policy.checked?).to be true
479
+ context "Seqs::UpdateUser::Present happy path" do
480
+ user = User.new(id: 1, email: "old@example.com")
481
+ contract = Contracts::UpdateUser.new(user)
482
+
483
+ seq = Seqs::UpdateUser::Present.new
484
+ seq.find.succeed_with(user)
485
+ seq.build_contract.succeed_with(contract)
486
+
487
+ result = seq.(params: { id: 1 }, current_user: User.new)
488
+
489
+ test "Is success" do
490
+ assert(result.success?)
491
+ end
492
+
493
+ test "Fetched the user from ctx[:user]" do
494
+ assert(seq.find.fetched?(as: :user))
495
+ end
496
+
497
+ test "Built the contract" do
498
+ assert(seq.build_contract.built?)
499
+ end
500
+
501
+ test "Checked the policy" do
502
+ assert(seq.check_policy.checked?)
437
503
  end
504
+ end
505
+
506
+ context "Seqs::UpdateUser::Present when the user is not found" do
507
+ seq = Seqs::UpdateUser::Present.new
508
+ seq.find.fail_with(code: :not_found)
438
509
 
439
- it "fails with :not_found when the user doesn't exist" do
440
- seq = described_class.new
441
- seq.find.fail_with(code: :not_found)
510
+ result = seq.(params: { id: 999 }, current_user: User.new)
511
+
512
+ test "Fails with :not_found" do
513
+ assert(result.error[:code] == :not_found)
514
+ end
442
515
 
443
- result = seq.(Hubbado::Sequence::Ctx.build(
444
- params: { id: 999 },
445
- current_user: User.new
446
- ))
516
+ test "Does not build the contract" do
517
+ refute(seq.build_contract.built?)
518
+ end
447
519
 
448
- expect(result.error[:code]).to eq(:not_found)
449
- expect(seq.build_contract.built?).to be false
450
- expect(seq.check_policy.checked?).to be false
520
+ test "Does not check the policy" do
521
+ refute(seq.check_policy.checked?)
451
522
  end
452
523
  end
453
524
  ```
@@ -460,84 +531,104 @@ Every sequencer ships a default `Substitute` module (installed by
460
531
  short-circuit a nested sequencer without reaching into its inner pieces:
461
532
 
462
533
  ```ruby
463
- RSpec.describe Seqs::UpdateUser do
464
- it "updates the user when present succeeds" do
465
- seq = described_class.new
466
-
467
- user = User.new(id: 1, email: "old@example.com")
468
- contract = Contracts::UpdateUser.new(user)
469
- seq.present.succeed_with(user: user, contract: contract)
470
-
471
- result = seq.(Hubbado::Sequence::Ctx.build(
472
- params: { user: { email: "new@example.com" } },
473
- current_user: User.new
474
- ))
475
-
476
- expect(result).to be_ok
477
- expect(seq.present.called?).to be true
478
- expect(seq.persist.persisted?).to be true
534
+ context "Seqs::UpdateUser happy path" do
535
+ user = User.new(id: 1, email: "old@example.com")
536
+ contract = Contracts::UpdateUser.new(user)
537
+
538
+ seq = Seqs::UpdateUser.new
539
+ seq.present.succeed_with(user: user, contract: contract)
540
+
541
+ result = seq.(
542
+ params: { user: { email: "new@example.com" } },
543
+ current_user: User.new
544
+ )
545
+
546
+ test "Is success" do
547
+ assert(result.success?)
479
548
  end
480
549
 
481
- it "stops when present denies access" do
482
- seq = described_class.new
483
- seq.present.fail_with(code: :forbidden)
550
+ test "Calls Present" do
551
+ assert(seq.present.called?)
552
+ end
553
+
554
+ test "Persists the contract" do
555
+ assert(seq.persist.persisted?)
556
+ end
557
+ end
558
+
559
+ context "Seqs::UpdateUser when Present denies access" do
560
+ seq = Seqs::UpdateUser.new
561
+ seq.present.fail_with(code: :forbidden)
562
+
563
+ result = seq.(params: { user: {} }, current_user: User.new)
564
+
565
+ test "Fails" do
566
+ assert(result.failure?)
567
+ end
484
568
 
485
- result = seq.(Hubbado::Sequence::Ctx.build(
486
- params: { user: {} },
487
- current_user: User.new
488
- ))
569
+ test "Fails with :forbidden" do
570
+ assert(result.error[:code] == :forbidden)
571
+ end
572
+
573
+ test "Does not validate" do
574
+ refute(seq.validate.validated?)
575
+ end
489
576
 
490
- expect(result.failure?).to be true
491
- expect(result.error[:code]).to eq(:forbidden)
492
- expect(seq.validate.validated?).to be false
493
- expect(seq.persist.persisted?).to be false
577
+ test "Does not persist" do
578
+ refute(seq.persist.persisted?)
494
579
  end
580
+ end
495
581
 
496
- it "stops when present cannot find the record" do
497
- seq = described_class.new
498
- seq.present.fail_with(code: :not_found)
582
+ context "Seqs::UpdateUser when Present cannot find the record" do
583
+ seq = Seqs::UpdateUser.new
584
+ seq.present.fail_with(code: :not_found)
499
585
 
500
- result = seq.(Hubbado::Sequence::Ctx.build(
501
- params: { id: 999, user: {} },
502
- current_user: User.new
503
- ))
586
+ result = seq.(params: { id: 999, user: {} }, current_user: User.new)
587
+
588
+ test "Fails with :not_found" do
589
+ assert(result.error[:code] == :not_found)
590
+ end
591
+
592
+ test "Does not validate" do
593
+ refute(seq.validate.validated?)
594
+ end
504
595
 
505
- expect(result.error[:code]).to eq(:not_found)
506
- expect(seq.validate.validated?).to be false
507
- expect(seq.persist.persisted?).to be false
596
+ test "Does not persist" do
597
+ refute(seq.persist.persisted?)
508
598
  end
509
599
  end
510
600
  ```
511
601
 
512
602
  `succeed_with(**ctx_writes)` writes the given keys into `ctx` and returns
513
- `Result.ok(ctx)`, so the outer steps see what the real Present would have
603
+ `Result.success(ctx)`, so the outer steps see what the real Present would have
514
604
  left behind. `fail_with(**error)` returns a failed `Result` with the given
515
605
  error, short-circuiting the outer pipeline. The Update spec doesn't need
516
606
  to exercise Find / Build / Policy::Check directly — those live in
517
- PresentUser's spec, where they belong.
607
+ `Seqs::UpdateUser::Present`'s spec, where they belong.
518
608
 
519
609
  ## Observability
520
610
 
521
- Every `Result` carries a **trail** — the list of step names that completed
522
- successfully, in order. On failure, the failing step is *not* in the trail;
523
- it's tagged on `error[:step]` instead.
611
+ Every `Result` carries **successful_steps** — the list of step names that
612
+ completed successfully, in order. On failure, the failing step is *not* in
613
+ `successful_steps`; it's tagged on `error[:step]` instead.
524
614
 
525
615
  ```ruby
526
- result.trail # => [:find, :build_contract, :check_policy, :validate, :persist] # success
527
- result.trail # => [:find, :build_contract] # failed at :check_policy
528
- result.error[:step] # => :check_policy
616
+ result.successful_steps # => [:find, :build_contract, :check_policy, :validate, :persist] # success
617
+ result.successful_steps # => [:find, :build_contract] # failed at :check_policy
618
+ result.error[:step] # => :check_policy
529
619
  ```
530
620
 
531
621
  When invoked via `run_sequence`, the dispatcher logs a single line per
532
- invocation summarising the trail and (on failure) where it stopped:
622
+ invocation summarising the successful steps and (on failure) where it
623
+ stopped:
533
624
 
534
625
  ```
535
626
  Sequencer Seqs::UpdateUser succeeded: find → build_contract → check_policy → validate → persist
536
627
  Sequencer Seqs::UpdateUser failed at :check_policy (forbidden): find → build_contract
537
628
  ```
538
629
 
539
- Nested sequencer trails are opaque to the parent: a parent's trail shows
540
- `:present` as a single step, not the sub-steps inside Present.
630
+ Nested sequencer steps are opaque to the parent: a parent's `successful_steps`
631
+ lists `:present` once, not Present's inner sub-steps.
541
632
  `error[:step]` carries the inner step name when a nested sequencer fails.
542
633
 
543
634
  ## Standard error codes
@@ -559,4 +650,4 @@ Sequencers can mint their own codes for domain-specific failures
559
650
 
560
651
  ## License
561
652
 
562
- Internal Hubbado gem.
653
+ Released under the [MIT License](LICENSE).
@@ -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.3.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.5.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, error: @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, error: @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, error: { 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, error: @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, error: { 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, error: @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, error: @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, error: { 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, error: @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,9 +18,9 @@ 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
25
  error: {
26
26
  code: :forbidden,
@@ -49,9 +49,9 @@ module Hubbado
49
49
  "Macros::Policy::Check substitute: #{policy} does not declare action :#{action}"
50
50
  end
51
51
 
52
- return Result.fail(ctx, error: @configured_error) if @configured_error
52
+ return Result.failure(ctx, error: @configured_error) if @configured_error
53
53
 
54
- Result.ok(ctx)
54
+ Result.success(ctx)
55
55
  end
56
56
 
57
57
  def checked?(**kwargs)
@@ -1,84 +1,45 @@
1
1
  module Hubbado
2
2
  module Sequence
3
+ # Railway-style step runner that backs the Sequencer mixin's
4
+ # `pipeline(ctx)` helper. Not part of the public API — sequencers reach
5
+ # it through the helper.
3
6
  class Pipeline
4
- # `Pipeline.(ctx) { |p| ... }` is the block form: yields the pipeline,
5
- # runs the block (so steps can be added in statement form), and returns
6
- # the final Result. The non-block form returns the Pipeline so chained
7
- # `.step(...)...result` calls still work.
8
- def self.call(ctx = nil, **kwargs, &block)
9
- if ctx.nil?
10
- ctx = Ctx.build(kwargs)
11
- elsif !kwargs.empty?
12
- raise ArgumentError, "Pipeline.() takes either a Ctx or keyword arguments, not both"
13
- elsif !ctx.is_a?(Ctx)
14
- ctx = Ctx.build(ctx)
15
- end
16
-
17
- pipe = new(ctx)
18
-
19
- if block
20
- block.call(pipe)
21
- pipe.result
22
- else
23
- pipe
24
- end
25
- end
26
-
27
- def initialize(ctx, dispatcher: nil)
7
+ def initialize(ctx, dispatcher:)
28
8
  @ctx = ctx
29
- @trail = []
9
+ @successful_steps = []
30
10
  @failed_result = nil
31
11
  @dispatcher = dispatcher
32
12
  end
33
13
 
34
- # `step(:name) { |ctx| ... }` runs the block. `step(:name)` with no
35
- # block dispatches to `dispatcher.send(name, ctx)` on the sequencer
36
- # that built this pipeline (via the mixin's `pipeline(ctx)` helper).
37
- # Block beats dispatch when both are available; raises if neither.
38
- #
39
- # Lenient return convention: a step is treated as successful unless it
40
- # explicitly returns a failed `Result`. Any other return value (nil,
41
- # false, a model, a hash, `Result.ok(...)`) is taken as success and the
42
- # pipeline continues with the same `@ctx`. Only `Result.fail(...)` /
43
- # `failure(ctx, code: ...)` short-circuits the pipeline.
44
- def step(name, &block)
14
+ # `step(:name)` dispatches to `dispatcher.send(name, ctx)`. The method
15
+ # is treated as successful unless it explicitly returns a failed
16
+ # `Result`; any other return value (nil, false, a model, `Result.success`)
17
+ # continues the pipeline with the same ctx. Only `Result.failure(...)` /
18
+ # `failure(ctx, code: ...)` short-circuits.
19
+ def step(name)
45
20
  return self if @failed_result
46
21
 
47
- return_value = invoke_step(name, block)
48
-
49
- if return_value.is_a?(Result) && return_value.failure?
50
- @failed_result = tag_failure(return_value, name)
51
- else
52
- @trail << name
53
- end
54
-
22
+ record(name, invoke_step(name))
55
23
  self
56
24
  end
57
25
 
58
26
  # `invoke(:name, *args, **kwargs)` calls a declared dependency on the
59
- # sequencer: gets the dependency via `dispatcher.send(name)` (the
60
- # reader), then invokes it with `(ctx, *args, **kwargs)`. Same trail
61
- # recording, failure short-circuiting, and lenient return convention as
62
- # `step`.
27
+ # dispatcher: gets it via `dispatcher.send(name)` (the reader), then
28
+ # invokes it with `(ctx, *args, **kwargs)`. Same step recording,
29
+ # failure short-circuiting, and lenient return convention as `step`.
63
30
  #
64
- # Use this for any declared dependency — macros (`Macros::Model::Find`)
65
- # and nested sequencers (`Seqs::Present`) alike. Use `step` for local
66
- # instance methods like `def deserialize_contract(ctx)`.
31
+ # Use this for any declared dependency — macros
32
+ # (`Macros::Model::Find`) and nested sequencers (`Seqs::Present`)
33
+ # alike. Use `step` for local instance methods like
34
+ # `def deserialize_contract(ctx)`.
67
35
  def invoke(name, *args, **kwargs)
68
36
  return self if @failed_result
69
37
 
70
- return_value = invoke_dependency(name, args, kwargs)
71
-
72
- if return_value.is_a?(Result) && return_value.failure?
73
- @failed_result = tag_failure(return_value, name)
74
- else
75
- @trail << name
76
- end
77
-
38
+ record(name, invoke_dependency(name, args, kwargs))
78
39
  self
79
40
  end
80
41
 
81
- def transaction(&block)
42
+ def transaction
82
43
  return self if @failed_result
83
44
 
84
45
  if defined?(::ActiveRecord::Base)
@@ -97,33 +58,22 @@ module Hubbado
97
58
  if @failed_result
98
59
  @failed_result
99
60
  else
100
- Result.ok(@ctx, trail: @trail.dup)
61
+ Result.success(@ctx, successful_steps: @successful_steps.dup)
101
62
  end
102
63
  end
103
64
 
104
65
  private
105
66
 
106
- def invoke_step(name, block)
107
- if block
108
- block.call(@ctx)
109
- elsif @dispatcher
110
- unless @dispatcher.respond_to?(name, true)
111
- raise NoMethodError,
112
- "Pipeline step :#{name} expects #{@dispatcher.class.name} to define ##{name}, but it does not"
113
- end
114
- @dispatcher.send(name, @ctx)
115
- else
116
- raise ArgumentError,
117
- "Pipeline step :#{name} needs either a block or a dispatcher (use the sequencer's `pipeline(ctx)` helper to enable auto-dispatch)"
67
+ def invoke_step(name)
68
+ unless @dispatcher.respond_to?(name, true)
69
+ raise NoMethodError,
70
+ "Pipeline step :#{name} expects #{@dispatcher.class.name} to define ##{name}, but it does not"
118
71
  end
72
+
73
+ @dispatcher.send(name, @ctx)
119
74
  end
120
75
 
121
76
  def invoke_dependency(name, args, kwargs)
122
- unless @dispatcher
123
- raise ArgumentError,
124
- "Pipeline#invoke :#{name} requires a dispatcher (use the sequencer's `pipeline(ctx)` helper)"
125
- end
126
-
127
77
  unless @dispatcher.respond_to?(name, true)
128
78
  raise NoMethodError,
129
79
  "Pipeline#invoke :#{name} expects #{@dispatcher.class.name} to declare a `dependency :#{name}, ...`"
@@ -132,9 +82,17 @@ module Hubbado
132
82
  @dispatcher.send(name).(@ctx, *args, **kwargs)
133
83
  end
134
84
 
85
+ def record(name, return_value)
86
+ if return_value.is_a?(Result) && return_value.failure?
87
+ @failed_result = tag_failure(return_value, name)
88
+ else
89
+ @successful_steps << name
90
+ end
91
+ end
92
+
135
93
  def tag_failure(result, step_name)
136
94
  tagged_error = result.error.merge(step: step_name)
137
- Result.fail(result.ctx, error: tagged_error, trail: @trail.dup, i18n_scope: result.i18n_scope)
95
+ Result.failure(result.ctx, error: tagged_error, successful_steps: @successful_steps.dup, i18n_scope: result.i18n_scope)
138
96
  end
139
97
  end
140
98
  end
@@ -5,49 +5,49 @@ module Hubbado
5
5
 
6
6
  attr_reader :ctx
7
7
  attr_reader :error
8
- attr_reader :trail
8
+ attr_reader :successful_steps
9
9
  attr_reader :i18n_scope
10
10
 
11
- def self.ok(ctx, trail: [], i18n_scope: nil)
12
- new(:ok, ctx, error: nil, trail: trail, i18n_scope: i18n_scope)
11
+ def self.success(ctx, successful_steps: [], i18n_scope: nil)
12
+ new(:success, ctx, error: nil, successful_steps: successful_steps, i18n_scope: i18n_scope)
13
13
  end
14
14
 
15
- def self.fail(ctx, error:, trail: [], i18n_scope: nil)
15
+ def self.failure(ctx, error:, successful_steps: [], i18n_scope: nil)
16
16
  unless error.is_a?(Hash) && error[:code]
17
- raise ArgumentError, "Result.fail requires error: { code: ... }"
17
+ raise ArgumentError, "Result.failure requires error: { code: ... }"
18
18
  end
19
19
 
20
- new(:fail, ctx, error: error, trail: trail, i18n_scope: i18n_scope)
20
+ new(:failure, ctx, error: error, successful_steps: successful_steps, i18n_scope: i18n_scope)
21
21
  end
22
22
 
23
- def initialize(status, ctx, error:, trail:, i18n_scope:)
23
+ def initialize(status, ctx, error:, successful_steps:, i18n_scope:)
24
24
  @status = status
25
25
  @ctx = ctx
26
26
  @error = error
27
- @trail = trail
27
+ @successful_steps = successful_steps
28
28
  @i18n_scope = i18n_scope
29
29
  end
30
30
 
31
- def ok?
32
- @status == :ok
31
+ def success?
32
+ @status == :success
33
33
  end
34
34
 
35
35
  def failure?
36
- @status == :fail
36
+ @status == :failure
37
37
  end
38
38
 
39
- def with_trail(trail)
40
- self.class.new(@status, @ctx, error: @error, trail: trail, i18n_scope: @i18n_scope)
39
+ def with_successful_steps(successful_steps)
40
+ self.class.new(@status, @ctx, error: @error, successful_steps: successful_steps, i18n_scope: @i18n_scope)
41
41
  end
42
42
 
43
43
  def with_i18n_scope(scope)
44
44
  return self unless @i18n_scope.nil?
45
45
 
46
- self.class.new(@status, @ctx, error: @error, trail: @trail, i18n_scope: scope)
46
+ self.class.new(@status, @ctx, error: @error, successful_steps: @successful_steps, i18n_scope: scope)
47
47
  end
48
48
 
49
49
  def message
50
- return nil if ok?
50
+ return nil if success?
51
51
 
52
52
  translation = translate_with_chain
53
53
  return translation if translation
@@ -34,39 +34,39 @@ module Hubbado
34
34
  end
35
35
 
36
36
  def success
37
- return unless @result.ok?
37
+ return unless @result.success?
38
38
  execute { yield(@result.ctx) }
39
- logger.info("Sequencer #{@sequencer_class.name} succeeded: #{trail_summary}")
39
+ logger.info("Sequencer #{@sequencer_class.name} succeeded: #{steps_summary}")
40
40
  end
41
41
 
42
42
  def policy_failed
43
43
  return unless code == :forbidden
44
44
  execute { yield(@result.ctx) }
45
- logger.info("Sequencer #{@sequencer_class.name} policy failed at #{step_label} (#{code}): #{trail_summary}")
45
+ logger.info("Sequencer #{@sequencer_class.name} policy failed at #{step_label} (#{code}): #{steps_summary}")
46
46
  end
47
47
 
48
48
  def not_found
49
49
  return unless code == :not_found
50
50
  execute { yield(@result.ctx) }
51
- logger.info("Sequencer #{@sequencer_class.name} not found at #{step_label}: #{trail_summary}")
51
+ logger.info("Sequencer #{@sequencer_class.name} not found at #{step_label}: #{steps_summary}")
52
52
  end
53
53
 
54
54
  def validation_failed
55
55
  return unless code == :validation_failed
56
56
  execute { yield(@result.ctx) }
57
- logger.info("Sequencer #{@sequencer_class.name} validation failed at #{step_label}: #{trail_summary}")
57
+ logger.info("Sequencer #{@sequencer_class.name} validation failed at #{step_label}: #{steps_summary}")
58
58
  end
59
59
 
60
60
  # otherwise deliberately does not catch policy denials or not_found —
61
61
  # those have their own required handlers.
62
62
  def otherwise
63
- return if @result.ok?
63
+ return if @result.success?
64
64
  return if code == :forbidden
65
65
  return if code == :not_found
66
66
  return if @handled
67
67
 
68
68
  execute { yield(@result.ctx) }
69
- logger.info("Sequencer #{@sequencer_class.name} failed at #{step_label} (#{code}): #{trail_summary}")
69
+ logger.info("Sequencer #{@sequencer_class.name} failed at #{step_label} (#{code}): #{steps_summary}")
70
70
  end
71
71
 
72
72
  def code
@@ -74,7 +74,7 @@ module Hubbado
74
74
  end
75
75
 
76
76
  def handled?
77
- @result.ok? || @handled
77
+ @result.success? || @handled
78
78
  end
79
79
 
80
80
  def enforce_safety_nets!
@@ -96,7 +96,7 @@ module Hubbado
96
96
  end
97
97
 
98
98
  def log_unhandled
99
- logger.error("Sequencer #{@sequencer_class.name} failed unhandled at #{step_label} (#{code}): #{trail_summary}")
99
+ logger.error("Sequencer #{@sequencer_class.name} failed unhandled at #{step_label} (#{code}): #{steps_summary}")
100
100
  end
101
101
 
102
102
  private
@@ -106,8 +106,8 @@ module Hubbado
106
106
  @returned = yield
107
107
  end
108
108
 
109
- def trail_summary
110
- @result.trail.empty? ? "(no steps)" : @result.trail.map(&:to_s).join(" → ")
109
+ def steps_summary
110
+ @result.successful_steps.empty? ? "(no steps)" : @result.successful_steps.map(&:to_s).join(" → ")
111
111
  end
112
112
 
113
113
  def step_label
@@ -173,9 +173,9 @@ module Hubbado
173
173
 
174
174
  if outcome[:kind] == :success
175
175
  outcome[:ctx_writes].each { |key, value| ctx[key] = value }
176
- Hubbado::Sequence::Result.ok(ctx)
176
+ Hubbado::Sequence::Result.success(ctx)
177
177
  else
178
- Hubbado::Sequence::Result.fail(ctx, error: outcome[:error])
178
+ Hubbado::Sequence::Result.failure(ctx, error: outcome[:error])
179
179
  end
180
180
  end
181
181
  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, error: @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,7 @@ 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
+ Result.failure(ctx, error: error_attrs, i18n_scope: i18n_scope)
87
87
  end
88
88
 
89
89
  # 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.3.0
4
+ version: 0.5.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-14 00:00:00.000000000 Z
11
+ date: 2026-05-15 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: []