hubbado-sequence 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.
181
+
182
+ ### ActiveRecord macros
143
183
 
144
- The model macros are designed to work with ActiveRecord models.
184
+ Designed to work with [ActiveRecord](https://github.com/rails/rails) models.
145
185
 
146
- ### Model::Find
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
178
218
 
179
- ### Contract::Build
219
+ Designed to work with [Reform](https://github.com/trailblazer/reform) form
220
+ objects (contracts).
221
+
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,15 +291,31 @@ 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 a
296
+ `Hubbado::Policy::Result`-shaped object (`permitted?`, `denied?`,
297
+ `reason`, `message`).
249
298
 
250
299
  | | |
251
300
  |---|---|
252
301
  | **Reads** | `ctx[:current_user]`, `ctx[record_key]` |
253
302
  | **Writes** | nothing |
254
- | **Fails** | `:forbidden` when `permitted?` is false; `error[:data]` carries `{ policy:, policy_result: }` |
303
+ | **Fails** | `:forbidden` when `permitted?` is false; `result.data` carries `{ policy:, policy_result: }` |
304
+
305
+ A controller can branch on the denial reason via `data`:
306
+
307
+ ```ruby
308
+ result.policy_failed do |ctx|
309
+ if result.data[:policy_result].reason == :not_open
310
+ redirect_to public_path(ctx[:job])
311
+ else
312
+ result.raise_policy_failed
313
+ end
314
+ end
315
+ ```
316
+
317
+ See [Handling specific failure reasons inside an outcome block](#handling-specific-failure-reasons-inside-an-outcome-block)
318
+ for when `raise_policy_failed` and its siblings come in handy.
255
319
 
256
320
  ## Transactions
257
321
 
@@ -293,46 +357,46 @@ as part of the same pipeline.
293
357
 
294
358
  The "find the record, build the contract, check the policy" shape is shared
295
359
  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:
360
+ update then validates and persists. Define Present as a nested class on the
361
+ outer sequencer so the two stay co-located:
298
362
 
299
363
  ```ruby
300
- class Seqs::PresentUser
301
- include Hubbado::Sequence::Sequencer
364
+ class Seqs::UpdateUser
365
+ class Present
366
+ include Hubbado::Sequence::Sequencer
302
367
 
303
- configure :present # so a parent can use `Seqs::PresentUser.configure(instance)`
368
+ configure :present # so a parent can use `Present.configure(instance)`
304
369
 
305
- dependency :find, Macros::Model::Find
306
- dependency :build_contract, Macros::Contract::Build
307
- dependency :check_policy, Macros::Policy::Check
370
+ dependency :find, Macros::Model::Find
371
+ dependency :build_contract, Macros::Contract::Build
372
+ dependency :check_policy, Macros::Policy::Check
308
373
 
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)
374
+ def self.build
375
+ new.tap do |instance|
376
+ Macros::Model::Find.configure(instance)
377
+ Macros::Contract::Build.configure(instance)
378
+ Macros::Policy::Check.configure(instance)
379
+ end
314
380
  end
315
- end
316
381
 
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)
382
+ def call(ctx)
383
+ pipeline(ctx) do |p|
384
+ p.invoke(:find, User, as: :user)
385
+ p.invoke(:build_contract, Contracts::UpdateUser, :user)
386
+ p.invoke(:check_policy, Policies::User, :user, :update)
387
+ end
322
388
  end
323
389
  end
324
- end
325
390
 
326
- class Seqs::UpdateUser
327
391
  include Hubbado::Sequence::Sequencer
328
392
 
329
- dependency :present, Seqs::PresentUser
393
+ dependency :present, Present
330
394
  dependency :validate, Macros::Contract::Validate
331
395
  dependency :persist, Macros::Contract::Persist
332
396
 
333
397
  def self.build
334
398
  new.tap do |instance|
335
- Seqs::PresentUser.configure(instance)
399
+ Present.configure(instance)
336
400
  Macros::Contract::Validate.configure(instance)
337
401
  Macros::Contract::Persist.configure(instance)
338
402
  end
@@ -359,7 +423,7 @@ class UsersController < ApplicationController
359
423
  include Hubbado::Sequence::RunSequence
360
424
 
361
425
  def edit
362
- run_sequence Seqs::PresentUser, params: params, current_user: current_user do |result|
426
+ run_sequence Seqs::UpdateUser::Present, params: params, current_user: current_user do |result|
363
427
  result.success { |ctx| render :edit, locals: { contract: ctx[:contract] } }
364
428
  result.policy_failed { |_| redirect_to root_path, alert: result.message }
365
429
  result.not_found { |_| render_404 }
@@ -379,15 +443,15 @@ end
379
443
 
380
444
  Inner writes (`ctx[:user]`, `ctx[:contract]`) are visible to outer steps —
381
445
  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.
446
+ exactly what Present built. The outer pipeline records `:present` as a single
447
+ entry in `successful_steps`; Present's inner steps stay opaque to the parent.
384
448
 
385
449
  ## Result, success, failure
386
450
 
387
451
  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
452
+ other return value (`nil`, `false`, a model, `Result.success(...)`) is taken as
389
453
  success and the pipeline continues with the same `ctx`. Only
390
- `Result.fail(...)` or the `failure(ctx, code: ...)` helper short-circuits.
454
+ `Result.failure(...)` or the `failure(ctx, code: ...)` helper short-circuits.
391
455
 
392
456
  ```ruby
393
457
  def call(ctx)
@@ -401,59 +465,128 @@ private
401
465
 
402
466
  def must_be_premium(ctx)
403
467
  return failure(ctx, code: :forbidden) unless ctx[:user].premium?
404
- # implicit ok if we get here
468
+ # implicit success if we get here
405
469
  end
406
470
  ```
407
471
 
408
472
  `failure(ctx, ...)` is a sequencer helper that builds a failed `Result`
409
- with the sequencer's auto-derived i18n scope already applied. It takes the
410
- same error attrs as the underlying error hash (`code:`, `i18n_key:`,
411
- `i18n_args:`, `data:`, `message:`).
473
+ with the sequencer's auto-derived i18n scope already applied. It takes
474
+ the same kwargs as `Result.failure` (`code:`, `data:`, `step:`,
475
+ `i18n_scope:`, `i18n_key:`, `i18n_args:`).
476
+
477
+ ## Outcome blocks and safety nets
478
+
479
+ `run_sequence` enforces that serious failures are addressed. Forgetting to
480
+ handle them raises rather than silently swallowing:
481
+
482
+ - An unhandled `:forbidden` raises `Hubbado::Sequence::Errors::Unauthorized`.
483
+ - An unhandled `:not_found` raises `Hubbado::Sequence::Errors::NotFound`.
484
+ - An unhandled non-policy / non-not-found failure (and an `otherwise` block
485
+ isn't given) raises `Hubbado::Sequence::Errors::Failed`.
486
+
487
+ `otherwise` deliberately does *not* catch `:forbidden` or `:not_found` —
488
+ that's what prevents a generic `otherwise` accidentally rendering a form
489
+ when the policy denied access.
490
+
491
+ ### Handling specific failure reasons inside an outcome block
492
+
493
+ The dispatch object exposes the standard escalation paths as public
494
+ methods, so an outcome block can handle some cases inline and fall back to
495
+ the framework's exception for the rest:
496
+
497
+ ```ruby
498
+ result.policy_failed do |ctx|
499
+ if result.data[:policy_result].reason == :not_open
500
+ redirect_to public_path(ctx[:job])
501
+ else
502
+ result.raise_policy_failed
503
+ end
504
+ end
505
+ ```
506
+
507
+ The available helpers mirror the safety nets:
508
+
509
+ - `result.raise_policy_failed` — raises `Errors::Unauthorized` with the
510
+ standard message.
511
+ - `result.raise_not_found` — raises `Errors::NotFound`.
512
+ - `result.raise_failed` — raises `Errors::Failed`.
513
+
514
+ Use them when you genuinely want the framework's default escalation;
515
+ prefer plain Ruby control flow (`return`, `if`/`else`) for ordinary
516
+ branching.
412
517
 
413
518
  ## Testing
414
519
 
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
520
+ The gem doesn't prescribe a testing library sequencers, macros, and
521
+ substitutes are plain Ruby objects that work with whatever you use.
522
+ The examples below are written in
523
+ [TestBench](https://github.com/test-bench/test-bench) (what we use at
524
+ Hubbado), but the same patterns translate directly to RSpec, Minitest,
525
+ or any other framework.
526
+
527
+ `Seqs::UpdateUser.new` returns a sequencer with all dependencies installed
528
+ as substitutes. Tests configure the substitutes for the scenario at hand.
529
+ `Seqs::UpdateUser.build` runs the production wiring (the real macros).
530
+ Substitutes default to pass-through `Result.success(ctx)` so a test only
419
531
  configures the ones whose return matters.
420
532
 
421
533
  ### Substituting macros directly
422
534
 
423
535
  ```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
536
+ context "Seqs::UpdateUser::Present happy path" do
537
+ user = User.new(id: 1, email: "old@example.com")
538
+ contract = Contracts::UpdateUser.new(user)
539
+
540
+ seq = Seqs::UpdateUser::Present.new
541
+ seq.find.succeed_with(user)
542
+ seq.build_contract.succeed_with(contract)
543
+
544
+ result = seq.(params: { id: 1 }, current_user: User.new)
545
+
546
+ test "Is success" do
547
+ assert(result.success?)
441
548
  end
442
549
 
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)
550
+ test "Fetched the user from ctx[:user]" do
551
+ assert(seq.find.fetched?(as: :user))
552
+ end
446
553
 
447
- result = seq.(Hubbado::Sequence::Ctx.build(
448
- params: { id: 999 },
449
- current_user: User.new
450
- ))
554
+ test "Built the contract" do
555
+ assert(seq.build_contract.built?)
556
+ end
451
557
 
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
558
+ test "Checked the policy" do
559
+ assert(seq.check_policy.checked?)
455
560
  end
456
561
  end
562
+
563
+ context "Seqs::UpdateUser::Present when the user is not found" do
564
+ seq = Seqs::UpdateUser::Present.new
565
+ seq.find.fail_with(code: :not_found)
566
+
567
+ result = seq.(params: { id: 999 }, current_user: User.new)
568
+
569
+ test "Fails with :not_found" do
570
+ assert(result.code == :not_found)
571
+ end
572
+
573
+ test "Does not build the contract" do
574
+ refute(seq.build_contract.built?)
575
+ end
576
+
577
+ test "Does not check the policy" do
578
+ refute(seq.check_policy.checked?)
579
+ end
580
+ end
581
+ ```
582
+
583
+ The `Policy::Check` substitute's `fail_with(**error)` accepts the same
584
+ attributes `Result.failure` does, so a test that needs an outcome block
585
+ to branch on `result.data[:policy_result].reason` can configure the data
586
+ payload directly:
587
+
588
+ ```ruby
589
+ seq.check_policy.fail_with(code: :forbidden, data: { policy_result: DeniedResult.new(:not_open) })
457
590
  ```
458
591
 
459
592
  ### Substituting a nested sequencer
@@ -464,84 +597,104 @@ Every sequencer ships a default `Substitute` module (installed by
464
597
  short-circuit a nested sequencer without reaching into its inner pieces:
465
598
 
466
599
  ```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
600
+ context "Seqs::UpdateUser happy path" do
601
+ user = User.new(id: 1, email: "old@example.com")
602
+ contract = Contracts::UpdateUser.new(user)
603
+
604
+ seq = Seqs::UpdateUser.new
605
+ seq.present.succeed_with(user: user, contract: contract)
606
+
607
+ result = seq.(
608
+ params: { user: { email: "new@example.com" } },
609
+ current_user: User.new
610
+ )
611
+
612
+ test "Is success" do
613
+ assert(result.success?)
483
614
  end
484
615
 
485
- it "stops when present denies access" do
486
- seq = described_class.new
487
- seq.present.fail_with(code: :forbidden)
616
+ test "Calls Present" do
617
+ assert(seq.present.called?)
618
+ end
488
619
 
489
- result = seq.(Hubbado::Sequence::Ctx.build(
490
- params: { user: {} },
491
- current_user: User.new
492
- ))
620
+ test "Persists the contract" do
621
+ assert(seq.persist.persisted?)
622
+ end
623
+ end
624
+
625
+ context "Seqs::UpdateUser when Present denies access" do
626
+ seq = Seqs::UpdateUser.new
627
+ seq.present.fail_with(code: :forbidden)
493
628
 
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
629
+ result = seq.(params: { user: {} }, current_user: User.new)
630
+
631
+ test "Fails" do
632
+ assert(result.failure?)
498
633
  end
499
634
 
500
- it "stops when present cannot find the record" do
501
- seq = described_class.new
502
- seq.present.fail_with(code: :not_found)
635
+ test "Fails with :forbidden" do
636
+ assert(result.code == :forbidden)
637
+ end
503
638
 
504
- result = seq.(Hubbado::Sequence::Ctx.build(
505
- params: { id: 999, user: {} },
506
- current_user: User.new
507
- ))
639
+ test "Does not validate" do
640
+ refute(seq.validate.validated?)
641
+ end
642
+
643
+ test "Does not persist" do
644
+ refute(seq.persist.persisted?)
645
+ end
646
+ end
647
+
648
+ context "Seqs::UpdateUser when Present cannot find the record" do
649
+ seq = Seqs::UpdateUser.new
650
+ seq.present.fail_with(code: :not_found)
651
+
652
+ result = seq.(params: { id: 999, user: {} }, current_user: User.new)
653
+
654
+ test "Fails with :not_found" do
655
+ assert(result.code == :not_found)
656
+ end
657
+
658
+ test "Does not validate" do
659
+ refute(seq.validate.validated?)
660
+ end
508
661
 
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
662
+ test "Does not persist" do
663
+ refute(seq.persist.persisted?)
512
664
  end
513
665
  end
514
666
  ```
515
667
 
516
668
  `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
669
+ `Result.success(ctx)`, so the outer steps see what the real Present would have
518
670
  left behind. `fail_with(**error)` returns a failed `Result` with the given
519
671
  error, short-circuiting the outer pipeline. The Update spec doesn't need
520
672
  to exercise Find / Build / Policy::Check directly — those live in
521
- PresentUser's spec, where they belong.
673
+ `Seqs::UpdateUser::Present`'s spec, where they belong.
522
674
 
523
675
  ## Observability
524
676
 
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.
677
+ Every `Result` carries **successful_steps** — the list of step names that
678
+ completed successfully, in order. On failure, the failing step is *not* in
679
+ `successful_steps`; it's tagged on `step` instead.
528
680
 
529
681
  ```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
682
+ result.successful_steps # => [:find, :build_contract, :check_policy, :validate, :persist] # success
683
+ result.successful_steps # => [:find, :build_contract] # failed at :check_policy
684
+ result.step # => :check_policy
533
685
  ```
534
686
 
535
687
  When invoked via `run_sequence`, the dispatcher logs a single line per
536
- invocation summarising the trail and (on failure) where it stopped:
688
+ invocation summarising the successful steps and (on failure) where it
689
+ stopped:
537
690
 
538
691
  ```
539
692
  Sequencer Seqs::UpdateUser succeeded: find → build_contract → check_policy → validate → persist
540
693
  Sequencer Seqs::UpdateUser failed at :check_policy (forbidden): find → build_contract
541
694
  ```
542
695
 
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.
696
+ Nested sequencer steps are opaque to the parent: a parent's `successful_steps`
697
+ lists `:present` once, not Present's inner sub-steps.
545
698
  `error[:step]` carries the inner step name when a nested sequencer fails.
546
699
 
547
700
  ## Standard error codes
@@ -563,4 +716,4 @@ Sequencers can mint their own codes for domain-specific failures
563
716
 
564
717
  ## License
565
718
 
566
- Internal Hubbado gem.
719
+ Released under the [MIT License](LICENSE).