hubbado-sequence 0.4.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: 225c967e73e28a28effbc26e9c3bbdfce3357932a8374046dc46056d0dd8dd7d
4
- data.tar.gz: 7a446faedf4bb88018c17a6130f29ec85f1fc354929864aaa2dd55f86753ffca
3
+ metadata.gz: 7f708335623135a67d05ecdf13e49c97f9d0a1d1a2c21c2fda731f83c69ecc65
4
+ data.tar.gz: 011fcaa92a23f287b8800da473f37452c9305bd08a0e00833811817b50d858db
5
5
  SHA512:
6
- metadata.gz: f5ae7e6141a9d45576b0b93a0c25ac23a63a9d3a5b4ea5ba134103a91db20633a17a1dc4e41a7a73570dc6745446d55e4e22612249dd75211d9c2be1c02a3143
7
- data.tar.gz: a741030d0046941b128a18bc8bb0f58f10bdb677cd8941857d8937263e4dd2c58aaaac6fd2c816deac3f8d310a256c67af516dc7e8428aa3e5e29791cafa3676
6
+ metadata.gz: 440e563ba5e86174e51c186ca365d30f7160a93ca4d4d79669d23e0461acfc1030261b63d109223e5a37129cefc4c5c0cfd6433219dd0edf3be2d7c9576048e5
7
+ data.tar.gz: c5a884e0a781e0b9794a3997ba1eaadbd03eeceaf02289ee33cab2528686281d89016ef9f82e7e04d6b71be5ce4378dc81336c5c7e1866e32d6b0f0f01cdff54
data/CHANGELOG.md CHANGED
@@ -4,6 +4,54 @@ 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
+
7
55
  ## [0.4.0] - Inline step blocks removed; Pipeline made internal
8
56
 
9
57
  ### Removed (breaking)
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
 
@@ -139,11 +176,14 @@ it directly.
139
176
  ## Built-in macros
140
177
 
141
178
  Each macro is a dependency declared on a sequencer with `dependency :name, Macros::...`
142
- 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.
143
181
 
144
- The model macros are designed to work with ActiveRecord models.
182
+ ### ActiveRecord macros
145
183
 
146
- ### Model::Find
184
+ Designed to work with [ActiveRecord](https://github.com/rails/rails) models.
185
+
186
+ #### Model::Find
147
187
 
148
188
  Fetches a record using `model.find_by(id:)` and writes it to `ctx[as]`.
149
189
 
@@ -159,7 +199,7 @@ p.invoke(:find, User, as: :user, id_key: %i[params id]) # nested path (default)
159
199
  | **Writes** | `ctx[as]` — the found record |
160
200
  | **Fails** | `:not_found` when `find_by` returns nil |
161
201
 
162
- ### Model::Build
202
+ #### Model::Build
163
203
 
164
204
  Instantiates a new record and writes it to `ctx[as]`.
165
205
 
@@ -174,9 +214,12 @@ p.invoke(:build_record, User, as: :user, attributes: { role: :admin })
174
214
  | **Writes** | `ctx[as]` — the new instance |
175
215
  | **Fails** | never |
176
216
 
177
- The contract macros are designed to work with [Reform](https://github.com/trailblazer/reform) form objects.
217
+ ### Reform macros
218
+
219
+ Designed to work with [Reform](https://github.com/trailblazer/reform) form
220
+ objects (contracts).
178
221
 
179
- ### Contract::Build
222
+ #### Contract::Build
180
223
 
181
224
  Wraps a model in a contract and writes it to `ctx[:contract]`.
182
225
 
@@ -191,7 +234,7 @@ p.invoke(:build_contract, Contracts::CreateUser) # no model
191
234
  | **Writes** | `ctx[:contract]` |
192
235
  | **Fails** | never |
193
236
 
194
- ### Contract::Deserialize
237
+ #### Contract::Deserialize
195
238
 
196
239
  Deserializes params into the contract via `contract.deserialize(params)`.
197
240
 
@@ -206,7 +249,7 @@ p.invoke(:deserialize_to_contract, from: :raw_params)
206
249
  | **Writes** | nothing (mutates the contract in place) |
207
250
  | **Fails** | never (no-op when the `from:` path is absent) |
208
251
 
209
- ### Contract::Validate
252
+ #### Contract::Validate
210
253
 
211
254
  Validates the contract via `contract.validate(params)` and checks `errors`.
212
255
 
@@ -221,7 +264,7 @@ p.invoke(:validate) # contract already deserialized; passes empty params
221
264
  | **Writes** | nothing (populates `contract.errors` on invalid) |
222
265
  | **Fails** | `:validation_failed` when `contract.errors` is non-empty |
223
266
 
224
- ### Contract::Persist
267
+ #### Contract::Persist
225
268
 
226
269
  Saves the contract via `contract.save`.
227
270
 
@@ -235,7 +278,12 @@ p.invoke(:persist)
235
278
  | **Writes** | nothing |
236
279
  | **Fails** | `:persist_failed` when `save` returns false |
237
280
 
238
- ### 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
239
287
 
240
288
  Builds a policy and calls the named action to authorise the operation.
241
289
 
@@ -243,9 +291,9 @@ Builds a policy and calls the named action to authorise the operation.
243
291
  p.invoke(:check_policy, Policies::User, :user, :update)
244
292
  ```
245
293
 
246
- Designed to work with the [hubbado-policy](https://github.com/hubbado/hubbado-policy) gem.
247
- The policy class must respond to `.build(current_user, record)`; the instance must
248
- 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?`.
249
297
 
250
298
  | | |
251
299
  |---|---|
@@ -293,46 +341,46 @@ as part of the same pipeline.
293
341
 
294
342
  The "find the record, build the contract, check the policy" shape is shared
295
343
  between an edit form and an update action — both need exactly that, and the
296
- update then validates and persists. Extract the shared part as a Present
297
- 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:
298
346
 
299
347
  ```ruby
300
- class Seqs::PresentUser
301
- include Hubbado::Sequence::Sequencer
348
+ class Seqs::UpdateUser
349
+ class Present
350
+ include Hubbado::Sequence::Sequencer
302
351
 
303
- configure :present # so a parent can use `Seqs::PresentUser.configure(instance)`
352
+ configure :present # so a parent can use `Present.configure(instance)`
304
353
 
305
- dependency :find, Macros::Model::Find
306
- dependency :build_contract, Macros::Contract::Build
307
- 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
308
357
 
309
- def self.build
310
- new.tap do |instance|
311
- Macros::Model::Find.configure(instance)
312
- Macros::Contract::Build.configure(instance)
313
- 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
314
364
  end
315
- end
316
365
 
317
- def call(ctx)
318
- pipeline(ctx) do |p|
319
- p.invoke(:find, User, as: :user)
320
- p.invoke(:build_contract, Contracts::UpdateUser, :user)
321
- 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
322
372
  end
323
373
  end
324
- end
325
374
 
326
- class Seqs::UpdateUser
327
375
  include Hubbado::Sequence::Sequencer
328
376
 
329
- dependency :present, Seqs::PresentUser
377
+ dependency :present, Present
330
378
  dependency :validate, Macros::Contract::Validate
331
379
  dependency :persist, Macros::Contract::Persist
332
380
 
333
381
  def self.build
334
382
  new.tap do |instance|
335
- Seqs::PresentUser.configure(instance)
383
+ Present.configure(instance)
336
384
  Macros::Contract::Validate.configure(instance)
337
385
  Macros::Contract::Persist.configure(instance)
338
386
  end
@@ -359,7 +407,7 @@ class UsersController < ApplicationController
359
407
  include Hubbado::Sequence::RunSequence
360
408
 
361
409
  def edit
362
- 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|
363
411
  result.success { |ctx| render :edit, locals: { contract: ctx[:contract] } }
364
412
  result.policy_failed { |_| redirect_to root_path, alert: result.message }
365
413
  result.not_found { |_| render_404 }
@@ -379,15 +427,15 @@ end
379
427
 
380
428
  Inner writes (`ctx[:user]`, `ctx[:contract]`) are visible to outer steps —
381
429
  Present and Update share the same `Ctx`, so `:validate` and `:persist` see
382
- exactly what Present built. The outer trail records `:present` as a single
383
- 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.
384
432
 
385
433
  ## Result, success, failure
386
434
 
387
435
  A step is **successful unless it explicitly returns a failed `Result`**. Any
388
- 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
389
437
  success and the pipeline continues with the same `ctx`. Only
390
- `Result.fail(...)` or the `failure(ctx, code: ...)` helper short-circuits.
438
+ `Result.failure(...)` or the `failure(ctx, code: ...)` helper short-circuits.
391
439
 
392
440
  ```ruby
393
441
  def call(ctx)
@@ -401,7 +449,7 @@ private
401
449
 
402
450
  def must_be_premium(ctx)
403
451
  return failure(ctx, code: :forbidden) unless ctx[:user].premium?
404
- # implicit ok if we get here
452
+ # implicit success if we get here
405
453
  end
406
454
  ```
407
455
 
@@ -412,46 +460,65 @@ same error attrs as the underlying error hash (`code:`, `i18n_key:`,
412
460
 
413
461
  ## Testing
414
462
 
415
- `described_class.new` returns a sequencer with all dependencies installed as
416
- substitutes. Tests configure the substitutes for the scenario at hand.
417
- `described_class.build` runs the production wiring (the real macros).
418
- 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
419
474
  configures the ones whose return matters.
420
475
 
421
476
  ### Substituting macros directly
422
477
 
423
478
  ```ruby
424
- RSpec.describe Seqs::PresentUser do
425
- it "loads the user, builds the contract, and passes the policy" do
426
- seq = described_class.new
427
- user = User.new(id: 1, email: "old@example.com")
428
- contract = Contracts::UpdateUser.new(user)
429
- seq.find.succeed_with(user)
430
- seq.build_contract.succeed_with(contract)
431
-
432
- result = seq.(Hubbado::Sequence::Ctx.build(
433
- params: { id: 1 },
434
- current_user: User.new
435
- ))
436
-
437
- expect(result).to be_ok
438
- expect(seq.find.fetched?(as: :user)).to be true
439
- expect(seq.build_contract.built?).to be true
440
- 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?)
441
491
  end
442
492
 
443
- it "fails with :not_found when the user doesn't exist" do
444
- seq = described_class.new
445
- seq.find.fail_with(code: :not_found)
493
+ test "Fetched the user from ctx[:user]" do
494
+ assert(seq.find.fetched?(as: :user))
495
+ end
446
496
 
447
- result = seq.(Hubbado::Sequence::Ctx.build(
448
- params: { id: 999 },
449
- current_user: User.new
450
- ))
497
+ test "Built the contract" do
498
+ assert(seq.build_contract.built?)
499
+ end
451
500
 
452
- expect(result.error[:code]).to eq(:not_found)
453
- expect(seq.build_contract.built?).to be false
454
- expect(seq.check_policy.checked?).to be false
501
+ test "Checked the policy" do
502
+ assert(seq.check_policy.checked?)
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)
509
+
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
515
+
516
+ test "Does not build the contract" do
517
+ refute(seq.build_contract.built?)
518
+ end
519
+
520
+ test "Does not check the policy" do
521
+ refute(seq.check_policy.checked?)
455
522
  end
456
523
  end
457
524
  ```
@@ -464,84 +531,104 @@ Every sequencer ships a default `Substitute` module (installed by
464
531
  short-circuit a nested sequencer without reaching into its inner pieces:
465
532
 
466
533
  ```ruby
467
- RSpec.describe Seqs::UpdateUser do
468
- it "updates the user when present succeeds" do
469
- seq = described_class.new
470
-
471
- user = User.new(id: 1, email: "old@example.com")
472
- contract = Contracts::UpdateUser.new(user)
473
- seq.present.succeed_with(user: user, contract: contract)
474
-
475
- result = seq.(Hubbado::Sequence::Ctx.build(
476
- params: { user: { email: "new@example.com" } },
477
- current_user: User.new
478
- ))
479
-
480
- expect(result).to be_ok
481
- expect(seq.present.called?).to be true
482
- 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?)
483
548
  end
484
549
 
485
- it "stops when present denies access" do
486
- seq = described_class.new
487
- seq.present.fail_with(code: :forbidden)
550
+ test "Calls Present" do
551
+ assert(seq.present.called?)
552
+ end
488
553
 
489
- result = seq.(Hubbado::Sequence::Ctx.build(
490
- params: { user: {} },
491
- current_user: User.new
492
- ))
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
568
+
569
+ test "Fails with :forbidden" do
570
+ assert(result.error[:code] == :forbidden)
571
+ end
493
572
 
494
- expect(result.failure?).to be true
495
- expect(result.error[:code]).to eq(:forbidden)
496
- expect(seq.validate.validated?).to be false
497
- expect(seq.persist.persisted?).to be false
573
+ test "Does not validate" do
574
+ refute(seq.validate.validated?)
498
575
  end
499
576
 
500
- it "stops when present cannot find the record" do
501
- seq = described_class.new
502
- seq.present.fail_with(code: :not_found)
577
+ test "Does not persist" do
578
+ refute(seq.persist.persisted?)
579
+ end
580
+ end
581
+
582
+ context "Seqs::UpdateUser when Present cannot find the record" do
583
+ seq = Seqs::UpdateUser.new
584
+ seq.present.fail_with(code: :not_found)
585
+
586
+ result = seq.(params: { id: 999, user: {} }, current_user: User.new)
503
587
 
504
- result = seq.(Hubbado::Sequence::Ctx.build(
505
- params: { id: 999, user: {} },
506
- current_user: User.new
507
- ))
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
508
595
 
509
- expect(result.error[:code]).to eq(:not_found)
510
- expect(seq.validate.validated?).to be false
511
- expect(seq.persist.persisted?).to be false
596
+ test "Does not persist" do
597
+ refute(seq.persist.persisted?)
512
598
  end
513
599
  end
514
600
  ```
515
601
 
516
602
  `succeed_with(**ctx_writes)` writes the given keys into `ctx` and returns
517
- `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
518
604
  left behind. `fail_with(**error)` returns a failed `Result` with the given
519
605
  error, short-circuiting the outer pipeline. The Update spec doesn't need
520
606
  to exercise Find / Build / Policy::Check directly — those live in
521
- PresentUser's spec, where they belong.
607
+ `Seqs::UpdateUser::Present`'s spec, where they belong.
522
608
 
523
609
  ## Observability
524
610
 
525
- Every `Result` carries a **trail** — the list of step names that completed
526
- successfully, in order. On failure, the failing step is *not* in the trail;
527
- 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.
528
614
 
529
615
  ```ruby
530
- result.trail # => [:find, :build_contract, :check_policy, :validate, :persist] # success
531
- result.trail # => [:find, :build_contract] # failed at :check_policy
532
- 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
533
619
  ```
534
620
 
535
621
  When invoked via `run_sequence`, the dispatcher logs a single line per
536
- invocation summarising the trail and (on failure) where it stopped:
622
+ invocation summarising the successful steps and (on failure) where it
623
+ stopped:
537
624
 
538
625
  ```
539
626
  Sequencer Seqs::UpdateUser succeeded: find → build_contract → check_policy → validate → persist
540
627
  Sequencer Seqs::UpdateUser failed at :check_policy (forbidden): find → build_contract
541
628
  ```
542
629
 
543
- Nested sequencer trails are opaque to the parent: a parent's trail shows
544
- `: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.
545
632
  `error[:step]` carries the inner step name when a nested sequencer fails.
546
633
 
547
634
  ## Standard error codes
@@ -563,4 +650,4 @@ Sequencers can mint their own codes for domain-specific failures
563
650
 
564
651
  ## License
565
652
 
566
- 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.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.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)
@@ -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
 
@@ -86,13 +86,13 @@ module Hubbado
86
86
  if return_value.is_a?(Result) && return_value.failure?
87
87
  @failed_result = tag_failure(return_value, name)
88
88
  else
89
- @trail << name
89
+ @successful_steps << name
90
90
  end
91
91
  end
92
92
 
93
93
  def tag_failure(result, step_name)
94
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)
95
+ Result.failure(result.ctx, error: tagged_error, successful_steps: @successful_steps.dup, i18n_scope: result.i18n_scope)
96
96
  end
97
97
  end
98
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,7 +1,7 @@
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.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hubbado Devs
@@ -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: []