hubbado-sequence 0.5.0 → 0.7.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: 7f708335623135a67d05ecdf13e49c97f9d0a1d1a2c21c2fda731f83c69ecc65
4
- data.tar.gz: 011fcaa92a23f287b8800da473f37452c9305bd08a0e00833811817b50d858db
3
+ metadata.gz: 4c072f343cb764529d76c7babb7824217546cadeaf4cab59c305b40b0b1f7647
4
+ data.tar.gz: 15c860841338562d2ba8b39c8d49d1a95959345eec316489878676a0b9f4a3e4
5
5
  SHA512:
6
- metadata.gz: 440e563ba5e86174e51c186ca365d30f7160a93ca4d4d79669d23e0461acfc1030261b63d109223e5a37129cefc4c5c0cfd6433219dd0edf3be2d7c9576048e5
7
- data.tar.gz: c5a884e0a781e0b9794a3997ba1eaadbd03eeceaf02289ee33cab2528686281d89016ef9f82e7e04d6b71be5ce4378dc81336c5c7e1866e32d6b0f0f01cdff54
6
+ metadata.gz: 414d6011c8b468686ba3fe6f93adfa119d16196b25cc3313073463a56673aaf5a51100cb2b21ecd96e471511b4954cff6bbfd7e14806fc2c9370ac7c40a40020
7
+ data.tar.gz: df9d8e8d1c8ce2715f7b8582de9ed6a51106f5ff0f6c99caf596d9fb0f8b4ec5dc0818fecf01cb92149aa13f9813c40b7221b4cdcb1604d9a8ef3b0f43e037db
data/CHANGELOG.md CHANGED
@@ -4,6 +4,185 @@ 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.7.0] - Macros::Policy::Check record-less policies; Sequencer i18n_scope applied at boundary
8
+
9
+ ### Changed (breaking)
10
+
11
+ - **`Macros::Policy::Check#call` signature changed** from `(ctx, policy,
12
+ record_key, action)` to `(ctx, policy, action, record_key = nil)`.
13
+ `record_key` is now a trailing optional positional; omitting it
14
+ builds the policy with `nil` as the record, the shape required by
15
+ plural / collection policies (e.g. `Policies::Jobs`) that authorise
16
+ on a non-record subject rather than gating on a specific record:
17
+
18
+ ```ruby
19
+ # before
20
+ p.invoke(:check_policy, Policies::User, :user, :update)
21
+
22
+ # after
23
+ p.invoke(:check_policy, Policies::User, :update, :user) # singular
24
+ p.invoke(:check_policy, Policies::Jobs, :list) # record-less
25
+ ```
26
+
27
+ Migration: at every `p.invoke(:check_policy, ...)` call site, swap
28
+ the third and fourth positional arguments. Substitutes and the
29
+ underlying `policy.method_defined?(action)` typo-catch are
30
+ unchanged in behaviour; the parameter order on the substitute's
31
+ `call` is migrated to match.
32
+
33
+ See `docs/design.md` "Resolved Through Iteration" for the rationale
34
+ and the alternatives considered.
35
+
36
+ ### Added
37
+
38
+ - **`Macros::Policy::Check.failure(ctx, policy, policy_result)`** class
39
+ helper. Returns `Result.failure(ctx, code: :forbidden, data: { policy:,
40
+ policy_result: })` — the same failure shape the macro produces. Lets
41
+ hand-rolled policy-check steps (for policy actions that take arguments,
42
+ or for compound logic the macro doesn't cover) produce the standard
43
+ failure shape without duplicating framework knowledge.
44
+
45
+ - **`Macros::Policy::Check` now stores the built policy on `ctx[:policy]`.**
46
+ After building the policy instance and before invoking the action, the
47
+ macro writes it to ctx under `:policy` by default. Downstream steps (e.g.
48
+ contract construction that needs the policy injected) can read it
49
+ directly without re-building. Pass `as:` to store under a different key
50
+ when a sequencer runs multiple policy checks:
51
+
52
+ ```ruby
53
+ p.invoke(:check_policy, Policies::Document, :update, :document)
54
+ # ctx[:policy] is now the built Policies::Document instance
55
+
56
+ p.invoke(:check_policy, Policies::User, :show, :user, as: :user_policy)
57
+ # ctx[:user_policy] is the built Policies::User instance
58
+ ```
59
+
60
+ The substitute's `succeed_with` now accepts an optional policy instance;
61
+ passing one mirrors the production write to `ctx[as]` so substituted
62
+ specs can drive the same downstream paths.
63
+
64
+ ### Changed
65
+
66
+ - **`Macros::Contract::Build`'s second parameter renamed** from
67
+ `attr_name` to `model`. The positional shape is unchanged — this is
68
+ an internal rename only — and the name now describes what the
69
+ parameter is (the ctx key/path for the model the contract wraps)
70
+ rather than what it isn't (an "attribute name" on anything). Callers
71
+ passing the value positionally (the only in-tree shape) are
72
+ unaffected.
73
+
74
+ ### Fixed
75
+
76
+ - **`Sequencer#pipeline` and `Sequencer.()` now apply the sequencer's
77
+ auto-derived `i18n_scope` to the returned Result.** Closes a
78
+ documented-but-unimplemented step in the `Result#message`
79
+ translation fallback chain. Previously only `Sequencer#failure` (the
80
+ explicit helper) tagged a result with the sequencer's scope; macros
81
+ call `Result.failure` directly with no scope, so a sequencer body
82
+ that returned a macro's failure unchanged produced an unscoped
83
+ Result and `Result#message` fell through to the framework default
84
+ (`sequence.errors.<code>`) instead of the per-sequencer scoped
85
+ translation. Pure-macro sequencers (e.g. a body that's just
86
+ `pipeline(ctx) { |p| p.invoke(:check_policy, ...) }`) could never
87
+ produce a message translated under their own namespace. Tagging at
88
+ the boundary (`pipeline.result` and `Sequencer.()`) via
89
+ `Result#with_i18n_scope` preserves nested-sequencer "innermost scope
90
+ wins" semantics — `with_i18n_scope` is a no-op when the scope is
91
+ already set, so an inner sequencer's scope survives the outer
92
+ wrapper. See `docs/design.md` "Resolved Through Iteration" for the
93
+ rationale.
94
+
95
+ ## [0.6.0] - Result.failure flat kwargs; Dispatch delegates reads and exposes raise helpers
96
+
97
+ ### Changed (breaking)
98
+
99
+ - **`Result.failure` takes flat kwargs; the `error:` hash wrapper is
100
+ gone.** The previous shape — `Result.failure(ctx, error: { code:,
101
+ data:, ... })` — wrapped its keys in an `error:` hash for no reason
102
+ beyond convention. The fields are now first-class kwargs on
103
+ `Result.failure` and first-class attrs on `Result`:
104
+
105
+ ```ruby
106
+ # before
107
+ Result.failure(ctx, error: { code: :forbidden, data: { policy_result: pr } })
108
+ result.error[:code] # => :forbidden
109
+ result.error[:data][:policy_result]
110
+
111
+ # after
112
+ Result.failure(ctx, code: :forbidden, data: { policy_result: pr })
113
+ result.code # => :forbidden
114
+ result.data[:policy_result]
115
+ ```
116
+
117
+ `Result` exposes `code`, `data`, `step`, `message_override`,
118
+ `i18n_scope`, `i18n_key`, `i18n_args` as readers. `Result#error`
119
+ is removed.
120
+
121
+ Migration: in callers, replace `Result.failure(ctx, error: { code:
122
+ :X })` with `Result.failure(ctx, code: :X)`. Replace `result.error[:X]`
123
+ reads with `result.X`. The `Sequencer#failure(ctx, **error_attrs)`
124
+ helper is unchanged at the call site (it always took flat kwargs).
125
+ Macro substitutes' `fail_with(**error_attrs)` is unchanged at the call
126
+ site; arbitrary extra attrs that used to live in the error hash should
127
+ move into `data:` (`fail_with(code: :forbidden, data: { reason:
128
+ :not_owner })`).
129
+
130
+ - **The per-error `i18n_scope` override path is removed.** Previously a
131
+ caller could put `i18n_scope:` inside the `error:` hash *and* pass a
132
+ separate `i18n_scope:` to the surrounding wrapper, with the per-error
133
+ one winning. With flat kwargs there is one `i18n_scope` slot. The
134
+ `Sequencer#failure` helper still applies the sequencer's auto-derived
135
+ scope when the caller doesn't pass one (`error_attrs[:i18n_scope] ||=
136
+ i18n_scope`), preserving the "caller wins" semantics where it matters
137
+ in practice. `Result#with_i18n_scope` (used to apply a scope to an
138
+ already-built Result) is unchanged.
139
+
140
+ - **`Runner::Dispatch#result` is removed.** Master exposed the wrapped
141
+ Result via `attr_reader :result`, which was the source of the
142
+ `result.result.error.dig(...)` four-hop pattern. With the new
143
+ read-through delegations (`code`, `data`, `step`, `message`,
144
+ `successful_steps`, `ctx`) there's no reason for outcome blocks to
145
+ reach into the inner Result. Any caller still doing
146
+ `result.result.X` from inside a `run_sequence` block will now raise
147
+ `NoMethodError`; replace with the matching delegation on the
148
+ dispatch object itself.
149
+
150
+ - **The `message:` kwarg on `Result.failure` is removed.** It set a
151
+ literal-string fallback returned by `Result#message` when no
152
+ translation matched. No in-tree caller used it (the
153
+ i18n-translation chain plus `humanize_code` fallback covered every
154
+ real case), and the path was test-only. If a caller needs a custom
155
+ message they can supply `i18n_key:` and a matching translation, or
156
+ pass a humanizable `code:` symbol.
157
+
158
+ ### Added
159
+
160
+ - **`Runner::Dispatch` delegates reads to its wrapped `Result`.** Outcome
161
+ blocks can call `result.code`, `result.data`, `result.message`,
162
+ `result.step`, `result.successful_steps`, `result.ctx` on the
163
+ `Dispatch` object (the block argument) without hopping through an
164
+ inner `.result.` reference. The previous `result.result.error.dig(...)`
165
+ pattern collapses to one read.
166
+
167
+ ```ruby
168
+ result.policy_failed do |ctx|
169
+ if result.data[:policy_result].reason == :not_open
170
+ redirect_to public_path(ctx[:job])
171
+ else
172
+ result.raise_policy_failed
173
+ end
174
+ end
175
+ ```
176
+
177
+ - **Public raise helpers on `Runner::Dispatch`:** `raise_policy_failed`,
178
+ `raise_not_found`, and `raise_failed`. They produce the same exceptions
179
+ the safety net would raise, but can be called explicitly from inside an
180
+ outcome block — useful when a caller handles some failure cases inline
181
+ and wants the framework's standard escalation for the rest.
182
+ `enforce_safety_nets!` now delegates to the same helpers, so the
183
+ exception shapes stay aligned whether the caller invokes them directly
184
+ or the runner does it automatically.
185
+
7
186
  ## [0.5.0] - Result vocabulary renamed: success/failure and successful_steps
8
187
 
9
188
  ### Changed (breaking)
data/README.md CHANGED
@@ -126,7 +126,7 @@ class Seqs::UpdateUser
126
126
  pipeline(ctx) do |p|
127
127
  p.invoke(:find, User, as: :user)
128
128
  p.invoke(:build_contract, Contracts::UpdateUser, :user)
129
- p.invoke(:check_policy, Policies::User, :user, :update)
129
+ p.invoke(:check_policy, Policies::User, :update, :user)
130
130
 
131
131
  p.transaction do |t|
132
132
  t.invoke(:validate, from: %i[params user])
@@ -230,7 +230,7 @@ p.invoke(:build_contract, Contracts::CreateUser) # no model
230
230
 
231
231
  | | |
232
232
  |---|---|
233
- | **Reads** | `ctx[attr_name]` for the model (optional) |
233
+ | **Reads** | `ctx` at `model` for the model (optional) |
234
234
  | **Writes** | `ctx[:contract]` |
235
235
  | **Fails** | never |
236
236
 
@@ -288,18 +288,64 @@ Designed to work with the
288
288
  Builds a policy and calls the named action to authorise the operation.
289
289
 
290
290
  ```ruby
291
- p.invoke(:check_policy, Policies::User, :user, :update)
291
+ p.invoke(:check_policy, Policies::User, :update, :user) # policy on a record
292
+ p.invoke(:check_policy, Policies::Jobs, :list) # plural / record-less
292
293
  ```
293
294
 
294
295
  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?`.
296
+ instance must respond to the action method and return a
297
+ `Hubbado::Policy::Result`-shaped object (`permitted?`, `denied?`,
298
+ `reason`, `message`). When `record_key` is omitted the policy is built
299
+ with `nil` as the record — the shape for plural / collection policies
300
+ that authorise against a non-record subject (e.g. a company id read
301
+ from `current_user`).
302
+
303
+ The built policy instance is written to `ctx[:policy]` so downstream
304
+ steps (e.g. contract construction that needs the policy injected) can
305
+ read it directly. Pass `as:` to store under a different key when a
306
+ sequencer runs more than one policy check:
307
+
308
+ ```ruby
309
+ p.invoke(:check_policy, Policies::User, :show, :user, as: :user_policy)
310
+ # ctx[:user_policy] — the built Policies::User instance
311
+ ```
297
312
 
298
313
  | | |
299
314
  |---|---|
300
- | **Reads** | `ctx[:current_user]`, `ctx[record_key]` |
301
- | **Writes** | nothing |
302
- | **Fails** | `:forbidden` when `permitted?` is false; `error[:data]` carries `{ policy:, policy_result: }` |
315
+ | **Reads** | `ctx[:current_user]`, `ctx[record_key]` when `record_key` is supplied |
316
+ | **Writes** | `ctx[as]` — the built policy instance (`as:` defaults to `:policy`) |
317
+ | **Fails** | `:forbidden` when `permitted?` is false; `result.data` carries `{ policy:, policy_result: }` |
318
+
319
+ The macro only covers zero-arg policy actions. For actions that take
320
+ arguments (e.g. `Policies::Jobs#create(company_id)`), hand-roll a step
321
+ and use `Macros::Policy::Check.failure(ctx, policy, policy_result)` to
322
+ produce the standard failure shape:
323
+
324
+ ```ruby
325
+ def check_create_policy(ctx)
326
+ policy = Policies::Jobs.build(ctx[:current_user], nil)
327
+ result = policy.create(ctx[:company_id])
328
+
329
+ return Macros::Policy::Check.failure(ctx, policy, result) unless result.permitted?
330
+
331
+ Result.success(ctx)
332
+ end
333
+ ```
334
+
335
+ A controller can branch on the denial reason via `data`:
336
+
337
+ ```ruby
338
+ result.policy_failed do |ctx|
339
+ if result.data[:policy_result].reason == :not_open
340
+ redirect_to public_path(ctx[:job])
341
+ else
342
+ result.raise_policy_failed
343
+ end
344
+ end
345
+ ```
346
+
347
+ See [Handling specific failure reasons inside an outcome block](#handling-specific-failure-reasons-inside-an-outcome-block)
348
+ for when `raise_policy_failed` and its siblings come in handy.
303
349
 
304
350
  ## Transactions
305
351
 
@@ -312,7 +358,7 @@ def call(ctx)
312
358
  pipeline(ctx) do |p|
313
359
  p.invoke(:find, User, as: :user)
314
360
  p.invoke(:build_contract, Contracts::UpdateUser, :user)
315
- p.invoke(:check_policy, Policies::User, :user, :update)
361
+ p.invoke(:check_policy, Policies::User, :update, :user)
316
362
 
317
363
  p.transaction do |t|
318
364
  t.invoke(:validate, from: %i[params user])
@@ -367,7 +413,7 @@ class Seqs::UpdateUser
367
413
  pipeline(ctx) do |p|
368
414
  p.invoke(:find, User, as: :user)
369
415
  p.invoke(:build_contract, Contracts::UpdateUser, :user)
370
- p.invoke(:check_policy, Policies::User, :user, :update)
416
+ p.invoke(:check_policy, Policies::User, :update, :user)
371
417
  end
372
418
  end
373
419
  end
@@ -454,9 +500,118 @@ end
454
500
  ```
455
501
 
456
502
  `failure(ctx, ...)` is a sequencer helper that builds a failed `Result`
457
- with the sequencer's auto-derived i18n scope already applied. It takes the
458
- same error attrs as the underlying error hash (`code:`, `i18n_key:`,
459
- `i18n_args:`, `data:`, `message:`).
503
+ with the sequencer's auto-derived i18n scope already applied. It takes
504
+ the same kwargs as `Result.failure` (`code:`, `data:`, `step:`,
505
+ `i18n_scope:`, `i18n_key:`, `i18n_args:`).
506
+
507
+ ## Translations
508
+
509
+ `Result#message` translates the failure `code` through a fallback chain:
510
+
511
+ 1. **Per-error scope** — whatever the failure set as `i18n_scope:` (or
512
+ `i18n_key:` for an explicit key override).
513
+ 2. **Sequencer's auto-derived scope** — the class name underscored, with
514
+ `/` → `.`. `Seqs::UpdateUser` becomes `seqs.update_user`;
515
+ `Jobadder::Seqs::AuthorizationCallback` becomes
516
+ `jobadder.seqs.authorization_callback`.
517
+ 3. **Framework default** — `sequence.errors.<code>` (the gem ships
518
+ translations for the standard codes; see "Standard error codes" below).
519
+ 4. **Humanized code** — `:not_found` → `"Not found"`.
520
+
521
+ The sequencer's scope is applied automatically. Both the `failure(ctx, ...)`
522
+ helper *and* the boundary itself (`Sequencer#pipeline` and `Sequencer.()`)
523
+ tag the returned `Result` with `i18n_scope` via `Result#with_i18n_scope`.
524
+ That means an unscoped failure produced inside a macro, a hand-rolled
525
+ step, or anywhere else in the sequencer body picks up the sequencer's
526
+ scope when the Result bubbles out — no `failure` call required.
527
+
528
+ ### Defining translations for a sequencer
529
+
530
+ Drop translations under the sequencer's auto-derived scope in your locale
531
+ file:
532
+
533
+ ```yaml
534
+ en:
535
+ jobadder:
536
+ seqs:
537
+ authorization_callback:
538
+ forbidden: "You are not allowed to connect this company to JobAdder"
539
+ authorization_failed: "Could not authorize with JobAdder"
540
+ ```
541
+
542
+ Now `result.message` returns the scoped string when the sequencer fails
543
+ with `code: :forbidden` or `code: :authorization_failed`. Missing
544
+ translations fall through to the framework default, then to the humanized
545
+ code — so a fresh app gets sensible behaviour with zero config.
546
+
547
+ ### Per-error overrides
548
+
549
+ A specific failure can override the scope or key. The error's own scope
550
+ beats the sequencer's:
551
+
552
+ ```ruby
553
+ failure(
554
+ ctx,
555
+ code: :not_shippable,
556
+ i18n_scope: "checkout.errors", # used instead of seqs.place_order
557
+ i18n_key: :address_invalid, # used instead of :not_shippable
558
+ i18n_args: { region: ctx[:country] }
559
+ )
560
+ ```
561
+
562
+ Resolves `checkout.errors.address_invalid` with the `%{region}`
563
+ interpolation supplied.
564
+
565
+ ### Nested sequencers: innermost scope wins
566
+
567
+ `Result#with_i18n_scope` is a no-op when the scope is already set, so a
568
+ nested sequencer's scope sticks. If `UpdateUser` calls `Present` and
569
+ Present's `Model::Find` macro fails, the failure is tagged with
570
+ `seqs.present` first (Present's boundary); `UpdateUser`'s boundary tries
571
+ to retag with `seqs.update_user` but the no-op preserves the inner scope.
572
+ Messages resolve under the namespace of the sequencer that actually
573
+ produced the failure, not the outermost wrapper.
574
+
575
+ ## Outcome blocks and safety nets
576
+
577
+ `run_sequence` enforces that serious failures are addressed. Forgetting to
578
+ handle them raises rather than silently swallowing:
579
+
580
+ - An unhandled `:forbidden` raises `Hubbado::Sequence::Errors::Unauthorized`.
581
+ - An unhandled `:not_found` raises `Hubbado::Sequence::Errors::NotFound`.
582
+ - An unhandled non-policy / non-not-found failure (and an `otherwise` block
583
+ isn't given) raises `Hubbado::Sequence::Errors::Failed`.
584
+
585
+ `otherwise` deliberately does *not* catch `:forbidden` or `:not_found` —
586
+ that's what prevents a generic `otherwise` accidentally rendering a form
587
+ when the policy denied access.
588
+
589
+ ### Handling specific failure reasons inside an outcome block
590
+
591
+ The dispatch object exposes the standard escalation paths as public
592
+ methods, so an outcome block can handle some cases inline and fall back to
593
+ the framework's exception for the rest:
594
+
595
+ ```ruby
596
+ result.policy_failed do |ctx|
597
+ if result.data[:policy_result].reason == :not_open
598
+ redirect_to public_path(ctx[:job])
599
+ else
600
+ result.raise_policy_failed
601
+ end
602
+ end
603
+ ```
604
+
605
+ The available helpers mirror the safety nets:
606
+
607
+ - `result.raise_policy_failed` — raises `Errors::Unauthorized` with the
608
+ standard message.
609
+ - `result.raise_not_found` — raises `Errors::NotFound`.
610
+ - `result.raise_failed` — raises `Errors::Failed`.
611
+
612
+ Use them when you genuinely want the framework's default escalation;
613
+ prefer plain Ruby control flow (`return`, `if`/`else`) for ordinary
614
+ branching.
460
615
 
461
616
  ## Testing
462
617
 
@@ -510,7 +665,7 @@ context "Seqs::UpdateUser::Present when the user is not found" do
510
665
  result = seq.(params: { id: 999 }, current_user: User.new)
511
666
 
512
667
  test "Fails with :not_found" do
513
- assert(result.error[:code] == :not_found)
668
+ assert(result.code == :not_found)
514
669
  end
515
670
 
516
671
  test "Does not build the contract" do
@@ -523,6 +678,15 @@ context "Seqs::UpdateUser::Present when the user is not found" do
523
678
  end
524
679
  ```
525
680
 
681
+ The `Policy::Check` substitute's `fail_with(**error)` accepts the same
682
+ attributes `Result.failure` does, so a test that needs an outcome block
683
+ to branch on `result.data[:policy_result].reason` can configure the data
684
+ payload directly:
685
+
686
+ ```ruby
687
+ seq.check_policy.fail_with(code: :forbidden, data: { policy_result: DeniedResult.new(:not_open) })
688
+ ```
689
+
526
690
  ### Substituting a nested sequencer
527
691
 
528
692
  Every sequencer ships a default `Substitute` module (installed by
@@ -567,7 +731,7 @@ context "Seqs::UpdateUser when Present denies access" do
567
731
  end
568
732
 
569
733
  test "Fails with :forbidden" do
570
- assert(result.error[:code] == :forbidden)
734
+ assert(result.code == :forbidden)
571
735
  end
572
736
 
573
737
  test "Does not validate" do
@@ -586,7 +750,7 @@ context "Seqs::UpdateUser when Present cannot find the record" do
586
750
  result = seq.(params: { id: 999, user: {} }, current_user: User.new)
587
751
 
588
752
  test "Fails with :not_found" do
589
- assert(result.error[:code] == :not_found)
753
+ assert(result.code == :not_found)
590
754
  end
591
755
 
592
756
  test "Does not validate" do
@@ -610,12 +774,12 @@ to exercise Find / Build / Policy::Check directly — those live in
610
774
 
611
775
  Every `Result` carries **successful_steps** — the list of step names that
612
776
  completed successfully, in order. On failure, the failing step is *not* in
613
- `successful_steps`; it's tagged on `error[:step]` instead.
777
+ `successful_steps`; it's tagged on `step` instead.
614
778
 
615
779
  ```ruby
616
780
  result.successful_steps # => [:find, :build_contract, :check_policy, :validate, :persist] # success
617
781
  result.successful_steps # => [:find, :build_contract] # failed at :check_policy
618
- result.error[:step] # => :check_policy
782
+ result.step # => :check_policy
619
783
  ```
620
784
 
621
785
  When invoked via `run_sequence`, the dispatcher logs a single line per
@@ -1,7 +1,7 @@
1
1
  # -*- encoding: utf-8 -*-
2
2
  Gem::Specification.new do |s|
3
3
  s.name = "hubbado-sequence"
4
- s.version = "0.5.0"
4
+ s.version = "0.7.0"
5
5
  s.summary = "A small framework for the short sequences of common steps that controller actions usually boil down to"
6
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
 
@@ -10,9 +10,9 @@ module Hubbado
10
10
  new
11
11
  end
12
12
 
13
- def call(ctx, contract_class, attr_name = nil)
14
- model = attr_name && Path.resolve(ctx, attr_name)
15
- ctx[:contract] = contract_class.new(model)
13
+ def call(ctx, contract_class, model = nil)
14
+ resolved_model = model && Path.resolve(ctx, model)
15
+ ctx[:contract] = contract_class.new(resolved_model)
16
16
  Result.success(ctx)
17
17
  end
18
18
 
@@ -30,8 +30,8 @@ module Hubbado
30
30
  self
31
31
  end
32
32
 
33
- record def call(ctx, contract_class, attr_name = nil)
34
- return Result.failure(ctx, error: @configured_error) if @configured_error
33
+ record def call(ctx, contract_class, model = nil)
34
+ return Result.failure(ctx, **@configured_error) if @configured_error
35
35
 
36
36
  ctx[:contract] = @return_value if @configured_success
37
37
  Result.success(ctx)
@@ -27,7 +27,7 @@ module Hubbado
27
27
  end
28
28
 
29
29
  record def call(ctx, from:)
30
- return Result.failure(ctx, error: @configured_error) if @configured_error
30
+ return Result.failure(ctx, **@configured_error) if @configured_error
31
31
 
32
32
  Result.success(ctx)
33
33
  end
@@ -16,7 +16,7 @@ module Hubbado
16
16
  if contract.save
17
17
  Result.success(ctx)
18
18
  else
19
- Result.failure(ctx, error: { code: :persist_failed })
19
+ Result.failure(ctx, code: :persist_failed)
20
20
  end
21
21
  end
22
22
 
@@ -34,7 +34,7 @@ module Hubbado
34
34
  end
35
35
 
36
36
  record def call(ctx)
37
- return Result.failure(ctx, error: @configured_error) if @configured_error
37
+ return Result.failure(ctx, **@configured_error) if @configured_error
38
38
 
39
39
  Result.success(ctx)
40
40
  end
@@ -19,7 +19,7 @@ module Hubbado
19
19
  if contract.errors.empty?
20
20
  Result.success(ctx)
21
21
  else
22
- Result.failure(ctx, error: { code: :validation_failed })
22
+ Result.failure(ctx, code: :validation_failed)
23
23
  end
24
24
  end
25
25
 
@@ -37,7 +37,7 @@ module Hubbado
37
37
  end
38
38
 
39
39
  record def call(ctx, from: nil)
40
- return Result.failure(ctx, error: @configured_error) if @configured_error
40
+ return Result.failure(ctx, **@configured_error) if @configured_error
41
41
 
42
42
  Result.success(ctx)
43
43
  end
@@ -40,7 +40,7 @@ module Hubbado
40
40
  "Macros::Model::Build substitute: #{model} does not respond to :new"
41
41
  end
42
42
 
43
- return Result.failure(ctx, error: @configured_error) if @configured_error
43
+ return Result.failure(ctx, **@configured_error) if @configured_error
44
44
 
45
45
  ctx[as] = @return_value if @configured_success
46
46
  Result.success(ctx)
@@ -18,7 +18,7 @@ module Hubbado
18
18
  ctx[as] = record
19
19
  Result.success(ctx)
20
20
  else
21
- Result.failure(ctx, error: { code: :not_found })
21
+ Result.failure(ctx, code: :not_found)
22
22
  end
23
23
  end
24
24
 
@@ -42,7 +42,7 @@ module Hubbado
42
42
  "Macros::Model::Find substitute: #{model} does not respond to :find_by"
43
43
  end
44
44
 
45
- return Result.failure(ctx, error: @configured_error) if @configured_error
45
+ return Result.failure(ctx, **@configured_error) if @configured_error
46
46
 
47
47
  ctx[as] = @return_value if @configured_success
48
48
  Result.success(ctx)
@@ -10,31 +10,36 @@ module Hubbado
10
10
  new
11
11
  end
12
12
 
13
- def call(ctx, policy, record_key, action)
13
+ def self.failure(ctx, policy, policy_result)
14
+ Result.failure(
15
+ ctx,
16
+ code: :forbidden,
17
+ data: { policy: policy, policy_result: policy_result }
18
+ )
19
+ end
20
+
21
+ def call(ctx, policy, action, record_key = nil, as: nil)
22
+ as ||= :policy
14
23
  current_user = ctx[:current_user]
15
- record = ctx[record_key]
24
+ record = record_key && ctx[record_key]
16
25
 
17
26
  policy_instance = policy.build(current_user, record)
27
+ ctx[as] = policy_instance
18
28
  policy_result = policy_instance.public_send(action)
19
29
 
20
30
  if policy_result.permitted?
21
31
  Result.success(ctx)
22
32
  else
23
- Result.failure(
24
- ctx,
25
- error: {
26
- code: :forbidden,
27
- data: { policy: policy_instance, policy_result: policy_result }
28
- }
29
- )
33
+ self.class.failure(ctx, policy_instance, policy_result)
30
34
  end
31
35
  end
32
36
 
33
37
  module Substitute
34
38
  include ::RecordInvocation
35
39
 
36
- def succeed_with
40
+ def succeed_with(policy_instance = nil)
37
41
  @configured_success = true
42
+ @return_policy = policy_instance
38
43
  self
39
44
  end
40
45
 
@@ -43,14 +48,17 @@ module Hubbado
43
48
  self
44
49
  end
45
50
 
46
- record def call(ctx, policy, record_key, action)
51
+ record def call(ctx, policy, action, record_key = nil, as: nil)
52
+ as ||= :policy
53
+
47
54
  unless policy.method_defined?(action)
48
55
  raise ArgumentError,
49
56
  "Macros::Policy::Check substitute: #{policy} does not declare action :#{action}"
50
57
  end
51
58
 
52
- return Result.failure(ctx, error: @configured_error) if @configured_error
59
+ return Result.failure(ctx, **@configured_error) if @configured_error
53
60
 
61
+ ctx[as] = @return_policy if @return_policy
54
62
  Result.success(ctx)
55
63
  end
56
64
 
@@ -84,16 +84,13 @@ module Hubbado
84
84
 
85
85
  def record(name, return_value)
86
86
  if return_value.is_a?(Result) && return_value.failure?
87
- @failed_result = tag_failure(return_value, name)
87
+ @failed_result = return_value
88
+ .with_step(name)
89
+ .with_successful_steps(@successful_steps.dup)
88
90
  else
89
91
  @successful_steps << name
90
92
  end
91
93
  end
92
-
93
- def tag_failure(result, step_name)
94
- tagged_error = result.error.merge(step: step_name)
95
- Result.failure(result.ctx, error: tagged_error, successful_steps: @successful_steps.dup, i18n_scope: result.i18n_scope)
96
- end
97
94
  end
98
95
  end
99
96
  end
@@ -4,28 +4,52 @@ module Hubbado
4
4
  FRAMEWORK_I18N_SCOPE = "sequence.errors".freeze
5
5
 
6
6
  attr_reader :ctx
7
- attr_reader :error
7
+ attr_reader :code
8
+ attr_reader :data
9
+ attr_reader :step
8
10
  attr_reader :successful_steps
9
11
  attr_reader :i18n_scope
12
+ attr_reader :i18n_key
13
+ attr_reader :i18n_args
10
14
 
11
15
  def self.success(ctx, successful_steps: [], i18n_scope: nil)
12
- new(:success, ctx, error: nil, successful_steps: successful_steps, i18n_scope: i18n_scope)
16
+ new(
17
+ :success,
18
+ ctx: ctx,
19
+ successful_steps: successful_steps,
20
+ i18n_scope: i18n_scope
21
+ )
13
22
  end
14
23
 
15
- def self.failure(ctx, error:, successful_steps: [], i18n_scope: nil)
16
- unless error.is_a?(Hash) && error[:code]
17
- raise ArgumentError, "Result.failure requires error: { code: ... }"
18
- end
19
-
20
- new(:failure, ctx, error: error, successful_steps: successful_steps, i18n_scope: i18n_scope)
24
+ def self.failure(ctx, code:, data: nil, step: nil,
25
+ i18n_scope: nil, i18n_key: nil, i18n_args: nil, successful_steps: [])
26
+ raise ArgumentError, "Result.failure requires code:" unless code
27
+
28
+ new(
29
+ :failure,
30
+ ctx: ctx,
31
+ code: code,
32
+ data: data,
33
+ step: step,
34
+ successful_steps: successful_steps,
35
+ i18n_scope: i18n_scope,
36
+ i18n_key: i18n_key,
37
+ i18n_args: i18n_args
38
+ )
21
39
  end
22
40
 
23
- def initialize(status, ctx, error:, successful_steps:, i18n_scope:)
41
+ def initialize(status, ctx:, successful_steps:, i18n_scope:,
42
+ code: nil, data: nil, step: nil,
43
+ i18n_key: nil, i18n_args: nil)
24
44
  @status = status
25
45
  @ctx = ctx
26
- @error = error
46
+ @code = code
47
+ @data = data
48
+ @step = step
27
49
  @successful_steps = successful_steps
28
50
  @i18n_scope = i18n_scope
51
+ @i18n_key = i18n_key
52
+ @i18n_args = i18n_args
29
53
  end
30
54
 
31
55
  def success?
@@ -37,35 +61,50 @@ module Hubbado
37
61
  end
38
62
 
39
63
  def with_successful_steps(successful_steps)
40
- self.class.new(@status, @ctx, error: @error, successful_steps: successful_steps, i18n_scope: @i18n_scope)
64
+ copy(successful_steps: successful_steps)
41
65
  end
42
66
 
43
67
  def with_i18n_scope(scope)
44
68
  return self unless @i18n_scope.nil?
45
69
 
46
- self.class.new(@status, @ctx, error: @error, successful_steps: @successful_steps, i18n_scope: scope)
70
+ copy(i18n_scope: scope)
71
+ end
72
+
73
+ def with_step(step)
74
+ copy(step: step)
47
75
  end
48
76
 
49
77
  def message
50
78
  return nil if success?
51
79
 
52
- translation = translate_with_chain
53
- return translation if translation
54
-
55
- @error[:message] || humanize_code
80
+ translate_with_chain || humanize_code
56
81
  end
57
82
 
58
83
  private
59
84
 
85
+ def copy(**overrides)
86
+ self.class.new(
87
+ @status,
88
+ ctx: @ctx,
89
+ code: @code,
90
+ data: @data,
91
+ step: @step,
92
+ successful_steps: @successful_steps,
93
+ i18n_scope: @i18n_scope,
94
+ i18n_key: @i18n_key,
95
+ i18n_args: @i18n_args,
96
+ **overrides
97
+ )
98
+ end
99
+
60
100
  def translate_with_chain
61
101
  scopes = []
62
- scopes << @error[:i18n_scope] if @error[:i18n_scope]
63
102
  scopes << @i18n_scope if @i18n_scope
64
103
  scopes << FRAMEWORK_I18N_SCOPE
65
104
  scopes.uniq!
66
105
 
67
- key = @error[:i18n_key] || @error[:code]
68
- args = @error[:i18n_args] || {}
106
+ key = @i18n_key || @code
107
+ args = @i18n_args || {}
69
108
 
70
109
  scopes.each do |scope|
71
110
  translated = ::I18n.t("#{scope}.#{key}", default: nil, **args)
@@ -76,7 +115,7 @@ module Hubbado
76
115
  end
77
116
 
78
117
  def humanize_code
79
- @error[:code].to_s.tr("_", " ").capitalize
118
+ @code.to_s.tr("_", " ").capitalize
80
119
  end
81
120
  end
82
121
  end
@@ -25,7 +25,7 @@ module Hubbado
25
25
  class Dispatch
26
26
  include Hubbado::Log::Dependency
27
27
 
28
- attr_reader :returned, :result, :sequencer_class
28
+ attr_reader :returned, :sequencer_class
29
29
 
30
30
  def initialize(sequencer_class, result)
31
31
  @sequencer_class = sequencer_class
@@ -33,6 +33,16 @@ module Hubbado
33
33
  @handled = false
34
34
  end
35
35
 
36
+ # Read-throughs to the wrapped Result. Outcome blocks read these on
37
+ # the Dispatch object (the block argument) without hopping through
38
+ # an inner Result reference.
39
+ def code = @result.code
40
+ def data = @result.data
41
+ def step = @result.step
42
+ def message = @result.message
43
+ def successful_steps = @result.successful_steps
44
+ def ctx = @result.ctx
45
+
36
46
  def success
37
47
  return unless @result.success?
38
48
  execute { yield(@result.ctx) }
@@ -69,29 +79,38 @@ module Hubbado
69
79
  logger.info("Sequencer #{@sequencer_class.name} failed at #{step_label} (#{code}): #{steps_summary}")
70
80
  end
71
81
 
72
- def code
73
- @result.error&.[](:code)
74
- end
75
-
76
82
  def handled?
77
83
  @result.success? || @handled
78
84
  end
79
85
 
86
+ # Raise the standard policy-denial exception. Available inside an
87
+ # outcome block (e.g. for callers that handle some policy reasons
88
+ # inline and want the framework's standard escalation for the rest)
89
+ # and used internally by enforce_safety_nets! when no handler ran.
90
+ def raise_policy_failed
91
+ raise Errors::Unauthorized.new(
92
+ "#{@sequencer_class.name} denied: #{@result.message}",
93
+ @result
94
+ )
95
+ end
96
+
97
+ def raise_not_found
98
+ raise Errors::NotFound, "#{@sequencer_class.name} reported not_found"
99
+ end
100
+
101
+ def raise_failed
102
+ raise Errors::Failed, "#{@sequencer_class.name} failed (#{code}): #{@result.message}"
103
+ end
104
+
80
105
  def enforce_safety_nets!
81
106
  return if handled?
82
107
 
83
108
  log_unhandled
84
109
 
85
110
  case code
86
- when :forbidden
87
- raise Errors::Unauthorized.new(
88
- "#{@sequencer_class.name} denied: #{@result.message}",
89
- @result
90
- )
91
- when :not_found
92
- raise Errors::NotFound, "#{@sequencer_class.name} reported not_found"
93
- else
94
- raise Errors::Failed, "#{@sequencer_class.name} failed (#{code}): #{@result.message}"
111
+ when :forbidden then raise_policy_failed
112
+ when :not_found then raise_not_found
113
+ else raise_failed
95
114
  end
96
115
  end
97
116
 
@@ -107,11 +126,10 @@ module Hubbado
107
126
  end
108
127
 
109
128
  def steps_summary
110
- @result.successful_steps.empty? ? "(no steps)" : @result.successful_steps.map(&:to_s).join(" → ")
129
+ successful_steps.empty? ? "(no steps)" : successful_steps.map(&:to_s).join(" → ")
111
130
  end
112
131
 
113
132
  def step_label
114
- step = @result.error && @result.error[:step]
115
133
  step ? step.inspect : "(unknown step)"
116
134
  end
117
135
  end
@@ -162,7 +180,8 @@ module Hubbado
162
180
  def configure_failure(code, error_attrs)
163
181
  @configured_outcome = {
164
182
  kind: :failure,
165
- error: { code: code, **error_attrs }
183
+ code: code,
184
+ error_attrs: error_attrs
166
185
  }
167
186
  self
168
187
  end
@@ -175,7 +194,7 @@ module Hubbado
175
194
  outcome[:ctx_writes].each { |key, value| ctx[key] = value }
176
195
  Hubbado::Sequence::Result.success(ctx)
177
196
  else
178
- Hubbado::Sequence::Result.failure(ctx, error: outcome[:error])
197
+ Hubbado::Sequence::Result.failure(ctx, code: outcome[:code], **outcome[:error_attrs])
179
198
  end
180
199
  end
181
200
  end
@@ -34,7 +34,7 @@ module Hubbado
34
34
  end
35
35
 
36
36
  record def call(ctx)
37
- return ::Hubbado::Sequence::Result.failure(ctx, error: @configured_error) if @configured_error
37
+ return ::Hubbado::Sequence::Result.failure(ctx, **@configured_error) if @configured_error
38
38
 
39
39
  if @configured_writes
40
40
  @configured_writes.each { |k, v| ctx[k] = v }
@@ -62,7 +62,7 @@ module Hubbado
62
62
  ctx = Ctx.build(ctx)
63
63
  end
64
64
 
65
- build.call(ctx)
65
+ build.call(ctx).with_i18n_scope(i18n_scope)
66
66
  end
67
67
 
68
68
  # Default factory: a sequencer with no configurable dependencies needs
@@ -83,7 +83,8 @@ module Hubbado
83
83
  end
84
84
 
85
85
  def failure(ctx, **error_attrs)
86
- Result.failure(ctx, error: error_attrs, i18n_scope: i18n_scope)
86
+ error_attrs[:i18n_scope] ||= i18n_scope
87
+ Result.failure(ctx, **error_attrs)
87
88
  end
88
89
 
89
90
  # Builds a Pipeline that auto-dispatches blockless `step(:foo)` calls to
@@ -99,7 +100,7 @@ module Hubbado
99
100
 
100
101
  if block
101
102
  block.call(pipe)
102
- pipe.result
103
+ pipe.result.with_i18n_scope(i18n_scope)
103
104
  else
104
105
  pipe
105
106
  end
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.5.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hubbado Devs
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-15 00:00:00.000000000 Z
11
+ date: 2026-05-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: evt-casing