yes-core 1.2.0 → 2.2.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +13 -0
  4. data/lib/yes/core/active_job_serializers/command_group_serializer.rb +7 -4
  5. data/lib/yes/core/aggregate/dsl/class_name_convention.rb +8 -0
  6. data/lib/yes/core/aggregate/dsl/class_resolvers/command_group/base.rb +34 -0
  7. data/lib/yes/core/aggregate/dsl/class_resolvers/command_group/command.rb +43 -0
  8. data/lib/yes/core/aggregate/dsl/class_resolvers/command_group/guard_evaluator.rb +35 -0
  9. data/lib/yes/core/aggregate/dsl/command_group_data.rb +45 -0
  10. data/lib/yes/core/aggregate/dsl/command_group_definer.rb +100 -0
  11. data/lib/yes/core/aggregate/dsl/method_definers/command_group/base.rb +29 -0
  12. data/lib/yes/core/aggregate/dsl/method_definers/command_group/can_command_group.rb +41 -0
  13. data/lib/yes/core/aggregate/dsl/method_definers/command_group/command_group.rb +40 -0
  14. data/lib/yes/core/aggregate.rb +50 -0
  15. data/lib/yes/core/authorization/command_cerbos_authorizer.rb +20 -5
  16. data/lib/yes/core/authorization/lookup_cache.rb +72 -0
  17. data/lib/yes/core/command_handling/command_group_executor.rb +237 -0
  18. data/lib/yes/core/command_handling/command_group_handler.rb +89 -0
  19. data/lib/yes/core/command_handling/event_publisher.rb +8 -3
  20. data/lib/yes/core/commands/command_group.rb +152 -0
  21. data/lib/yes/core/commands/command_group_response.rb +66 -0
  22. data/lib/yes/core/commands/group.rb +7 -12
  23. data/lib/yes/core/commands/group_payload_normalizer.rb +45 -0
  24. data/lib/yes/core/commands/processor.rb +28 -7
  25. data/lib/yes/core/commands/stateless/handler.rb +9 -3
  26. data/lib/yes/core/commands/stateless/handler_helpers.rb +1 -1
  27. data/lib/yes/core/configuration.rb +39 -0
  28. data/lib/yes/core/data_decryptor.rb +2 -2
  29. data/lib/yes/core/data_encryptor.rb +6 -2
  30. data/lib/yes/core/failed_subscription_notifier.rb +29 -0
  31. data/lib/yes/core/middlewares/encryptor.rb +9 -1
  32. data/lib/yes/core/middlewares/write_encryptor.rb +35 -0
  33. data/lib/yes/core/middlewares.rb +45 -0
  34. data/lib/yes/core/railtie.rb +20 -0
  35. data/lib/yes/core/test_support/aggregate/command_test_dsl.rb +77 -2
  36. data/lib/yes/core/test_support/aggregate/shared_examples.rb +45 -0
  37. data/lib/yes/core/test_support/event_helpers.rb +1 -1
  38. data/lib/yes/core/types.rb +8 -1
  39. data/lib/yes/core/utils/command_utils.rb +21 -0
  40. data/lib/yes/core/utils/hash_utils.rb +7 -2
  41. data/lib/yes/core/version.rb +1 -1
  42. metadata +19 -3
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module CommandHandling
6
+ # Low-level executor for command groups.
7
+ #
8
+ # Mirrors {CommandExecutor}, with two key differences:
9
+ #
10
+ # 1. Sub-command events publish inside a single
11
+ # `PgEventstore.client.multiple` block, so either all events commit
12
+ # atomically or none do.
13
+ # 2. Read-model updates run AFTER the eventstore commit succeeds, in
14
+ # declaration order, so a rolled-back transaction never leaves the
15
+ # read model ahead of the stream.
16
+ #
17
+ # Only the group's own guards run here — sub-command guards are
18
+ # bypassed by design.
19
+ class CommandGroupExecutor
20
+ MAX_RETRIES = 10
21
+ INLINE_RECOVERY_RETRY_THRESHOLD = 5
22
+
23
+ # @param aggregate [Yes::Core::Aggregate]
24
+ def initialize(aggregate)
25
+ @aggregate = aggregate
26
+ @read_model = aggregate.read_model if aggregate.class.read_model_enabled?
27
+ end
28
+
29
+ # @param cmd [Yes::Core::Commands::CommandGroup]
30
+ # @param group_name [Symbol]
31
+ # @param guard_evaluator_class [Class]
32
+ # @param skip_guards [Boolean]
33
+ # @return [Yes::Core::Commands::CommandGroupResponse]
34
+ def call(cmd, group_name, guard_evaluator_class, skip_guards: false)
35
+ retries = 0
36
+
37
+ begin
38
+ evaluator = GuardRunner.new(aggregate).call(cmd, group_name, guard_evaluator_class, skip_guards:)
39
+ external_aggregates = evaluator&.accessed_external_aggregates || []
40
+
41
+ set_pending_update_state if aggregate.class.read_model_enabled?
42
+
43
+ events = publish_events(cmd, external_aggregates)
44
+ apply_read_model_updates(cmd, events) if aggregate.class.read_model_enabled?
45
+
46
+ Yes::Core::Commands::CommandGroupResponse.new(cmd:, events:)
47
+ rescue PgEventstore::WrongExpectedRevisionError => e
48
+ retries += 1
49
+ clear_pending_update_state if aggregate.class.read_model_enabled?
50
+ retries <= MAX_RETRIES ? retry : raise(e)
51
+ rescue ConcurrentUpdateError => e
52
+ retries += 1
53
+ sleep([0.01 * (2**(retries - 1)), 1.0].min) if retries <= MAX_RETRIES
54
+
55
+ if aggregate.class.read_model_enabled? && retries >= INLINE_RECOVERY_RETRY_THRESHOLD
56
+ ReadModelRecoveryService.attempt_inline_recovery(read_model, aggregate: aggregate)
57
+ read_model.reload
58
+ end
59
+
60
+ retries <= MAX_RETRIES ? retry : raise(e)
61
+ rescue GuardEvaluator::InvalidTransition,
62
+ GuardEvaluator::NoChangeTransition,
63
+ Yes::Core::Command::Invalid => e
64
+ clear_pending_update_state if aggregate.class.read_model_enabled?
65
+ Yes::Core::Commands::CommandGroupResponse.new(cmd:, error: e)
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ attr_reader :aggregate, :read_model
72
+
73
+ # Publishes each sub-command's event inside a single eventstore
74
+ # transaction. If publishing fails partway through, the whole
75
+ # transaction rolls back via PgEventstore::Commands::Multiple.
76
+ #
77
+ # The FIRST sub-event goes through {EventPublisher}, which performs
78
+ # two concurrency checks that bring group flow up to parity with the
79
+ # per-command flow:
80
+ #
81
+ # 1. **Own-stream optimistic locking** — `expected_revision` is
82
+ # derived from `read_model.revision` (or the latest stream
83
+ # revision when read models are disabled), so a concurrent
84
+ # writer that committed to the same stream between guard
85
+ # evaluation and publish raises `WrongExpectedRevisionError`,
86
+ # which the outer rescue retries and re-runs guards against the
87
+ # fresh state.
88
+ # 2. **External-aggregate revision verification** —
89
+ # `verify_external_revisions!` checks every aggregate that the
90
+ # group's guard block touched (tracked by
91
+ # {AggregateTracker} via {GuardEvaluator}#method_missing and
92
+ # {PayloadProxy}#resolve_aggregate). A drifted external stream
93
+ # raises `WrongExpectedRevisionError`, same retry path.
94
+ #
95
+ # SUBSEQUENT sub-events go through the direct append path with
96
+ # `expected_revision: :any`. They don't need their own optimistic
97
+ # check because:
98
+ # - they're inside the same `multiple` block / PG transaction at
99
+ # serializable isolation,
100
+ # - any concurrent writer that committed during this transaction
101
+ # would have failed step 1 above (or surfaced as
102
+ # `PG::TRSerializationFailure`, which pg_eventstore retries
103
+ # transparently for the whole block),
104
+ # - their stream revisions chain naturally onto the first append
105
+ # because each `Append` reads `stream_revision` fresh inside the
106
+ # same transaction and sees the previous appends.
107
+ #
108
+ # We assign `published` from inside the block so pg_eventstore's
109
+ # internal retry on `MissingPartitions` / serialization failures
110
+ # doesn't accumulate duplicate event entries across retries.
111
+ #
112
+ # Pending-state cleanup is handled exclusively by the outer rescue
113
+ # chain in {#call} so that `ConcurrentUpdateError`'s "another process
114
+ # owns it" semantics are preserved.
115
+ #
116
+ # @param cmd [Yes::Core::Commands::CommandGroup]
117
+ # @param external_aggregates [Array<Hash>] aggregates accessed during
118
+ # guard evaluation; verified against their current stream revisions
119
+ # at publish time
120
+ # @return [Array<PgEventstore::Event>]
121
+ def publish_events(cmd, external_aggregates)
122
+ published = nil
123
+ PgEventstore.client.multiple do
124
+ published = cmd.commands.each_with_index.map do |sub_cmd, index|
125
+ if index.zero?
126
+ publish_first_sub_event(sub_cmd, external_aggregates)
127
+ else
128
+ publish_subsequent_sub_event(sub_cmd)
129
+ end
130
+ end
131
+ end
132
+ published
133
+ end
134
+
135
+ # Publishes the first sub-event via {EventPublisher}, getting both
136
+ # own-stream optimistic locking and external-aggregate revision
137
+ # verification for free.
138
+ #
139
+ # @param sub_cmd [Yes::Core::Command]
140
+ # @param external_aggregates [Array<Hash>]
141
+ # @return [PgEventstore::Event]
142
+ def publish_first_sub_event(sub_cmd, external_aggregates)
143
+ EventPublisher.new(
144
+ command: sub_cmd,
145
+ aggregate_data: EventPublisher::AggregateEventPublicationData.from_aggregate(aggregate),
146
+ accessed_external_aggregates: external_aggregates
147
+ ).call
148
+ end
149
+
150
+ # Publishes a subsequent (2nd+) sub-event with `expected_revision: :any`.
151
+ # Atomicity and revision sequencing are guaranteed by the surrounding
152
+ # `multiple` block — see {#publish_events} docstring for details.
153
+ #
154
+ # @param sub_cmd [Yes::Core::Command]
155
+ # @return [PgEventstore::Event]
156
+ def publish_subsequent_sub_event(sub_cmd)
157
+ utils = Utils::CommandUtils.new(
158
+ context: aggregate.class.context,
159
+ aggregate: aggregate.class.aggregate,
160
+ aggregate_id: aggregate.id
161
+ )
162
+ sub_command_name = sub_cmd.class.name.split('::')[-2].underscore.to_sym
163
+ event = utils.build_event(
164
+ command_name: sub_command_name,
165
+ payload: sub_cmd.payload,
166
+ metadata: sub_event_metadata(sub_cmd)
167
+ )
168
+
169
+ PgEventstore.client.append_to_stream(
170
+ utils.build_stream(metadata: sub_cmd.metadata || {}),
171
+ event,
172
+ options: { expected_revision: :any },
173
+ middlewares: Middlewares.for_write
174
+ )
175
+ end
176
+
177
+ def sub_event_metadata(sub_cmd)
178
+ meta = {}
179
+ meta['origin'] = sub_cmd.origin if sub_cmd.origin.present?
180
+ meta['batch_id'] = sub_cmd.batch_id if sub_cmd.batch_id.present?
181
+ meta['yes-dsl'] = true
182
+ meta.merge!(sub_cmd.metadata) if sub_cmd.metadata.present?
183
+ meta.merge!(sub_cmd.transaction.for_eventstore_metadata) if sub_cmd.transaction
184
+ meta.deep_transform_keys(&:to_s)
185
+ end
186
+
187
+ # Applies read-model updates for each (sub_cmd, event) pair in
188
+ # declaration order, so each sub-command's state-updater sees the
189
+ # state produced by the previous one.
190
+ #
191
+ # Intentionally NOT wrapped in an outer `ActiveRecord::Base.transaction`:
192
+ # if the third sub-command's read-model update raises after the first
193
+ # two succeed, the read model is left half-applied while the
194
+ # eventstore stream is fully committed. This matches today's behaviour
195
+ # for single commands (where any read-model failure after the event
196
+ # publishes leaves the read model behind the stream) and is healed by
197
+ # the standard event-replay/recovery path. An outer AR transaction
198
+ # could be added as a follow-up if partial application becomes a
199
+ # concrete problem in practice.
200
+ #
201
+ # @param cmd [Yes::Core::Commands::CommandGroup]
202
+ # @param events [Array<PgEventstore::Event>]
203
+ # @return [void]
204
+ def apply_read_model_updates(cmd, events)
205
+ cmd.commands.zip(events, cmd.class.sub_command_names).each do |sub_cmd, event, sub_name|
206
+ ReadModelUpdater.new(aggregate).call(event, sub_cmd.payload, sub_name)
207
+ end
208
+ end
209
+
210
+ # @see CommandExecutor#set_pending_update_state
211
+ def set_pending_update_state
212
+ return unless read_model
213
+
214
+ begin
215
+ ActiveRecord::Base.transaction(requires_new: true) do
216
+ read_model.update_column(:pending_update_since, Time.current)
217
+ end
218
+ rescue ActiveRecord::StatementInvalid => e
219
+ raise e unless e.message.include?('Concurrent pending update not allowed')
220
+
221
+ raise ConcurrentUpdateError.new(
222
+ aggregate_class: aggregate.class,
223
+ aggregate_id: read_model.id,
224
+ original_error: e
225
+ )
226
+ end
227
+ end
228
+
229
+ def clear_pending_update_state
230
+ return unless read_model
231
+
232
+ read_model.update_column(:pending_update_since, nil)
233
+ end
234
+ end
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module CommandHandling
6
+ # High-level orchestrator for executing a {Yes::Core::Commands::CommandGroup}.
7
+ #
8
+ # Mirrors {CommandHandler} but produces a
9
+ # {Yes::Core::Commands::CommandGroupResponse} carrying the array of
10
+ # published events. Group-level guards run; sub-command guards are
11
+ # bypassed (the executor publishes each sub-command's event directly).
12
+ #
13
+ # @example
14
+ # handler = CommandGroupHandler.new(aggregate)
15
+ # response = handler.call(:create_apprenticeship, {
16
+ # company_id:, user_id:, name:, description:
17
+ # })
18
+ class CommandGroupHandler
19
+ include Yes::Core::OpenTelemetry::Trackable
20
+
21
+ # @param aggregate [Yes::Core::Aggregate]
22
+ def initialize(aggregate)
23
+ @aggregate = aggregate
24
+ @command_utilities = aggregate.send(:command_utilities)
25
+ @read_model = aggregate.read_model if aggregate.class.read_model_enabled?
26
+ end
27
+
28
+ # Executes a command group and updates the aggregate's read model.
29
+ #
30
+ # @param group_name [Symbol] the command group name
31
+ # @param payload [Hash] flat / partially-nested input payload
32
+ # @param guards [Boolean] whether to evaluate the group's guards
33
+ # @param metadata [Hash, nil] optional metadata to merge into each event
34
+ # @return [Yes::Core::Commands::CommandGroupResponse]
35
+ def call(group_name, payload, guards: true, metadata: nil)
36
+ prepared = prepare_payload(payload, metadata)
37
+ cmd = command_utilities.build_group_command(group_name, prepared)
38
+ guard_evaluator_class = command_utilities.fetch_guard_evaluator_class_for_group(group_name)
39
+
40
+ ReadModelRecoveryService.check_and_recover_with_retries(read_model, aggregate:) if aggregate.class.read_model_enabled?
41
+
42
+ CommandGroupExecutor.new(aggregate).
43
+ call(cmd, group_name, guard_evaluator_class, skip_guards: !guards)
44
+ end
45
+ otl_trackable :call,
46
+ Yes::Core::OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Execute command group')
47
+
48
+ private
49
+
50
+ attr_reader :aggregate, :command_utilities, :read_model
51
+
52
+ # Prepares the payload before constructing the group command.
53
+ # Mirrors the metadata-injection logic in {CommandHandler#prepare_payload}.
54
+ #
55
+ # @param payload [Hash]
56
+ # @param metadata [Hash, nil]
57
+ # @return [Hash]
58
+ def prepare_payload(payload, metadata)
59
+ payload = payload.is_a?(Hash) ? payload.dup : {}
60
+
61
+ add_console_origin(payload)
62
+ add_draft_metadata(payload) if aggregate.draft?
63
+ add_custom_metadata(payload, metadata)
64
+
65
+ payload
66
+ end
67
+
68
+ def add_console_origin(payload)
69
+ return if payload[:origin].present?
70
+
71
+ console_origin = Utils::CallerUtils.console_origin
72
+ payload[:origin] = console_origin if console_origin
73
+ end
74
+
75
+ def add_draft_metadata(payload)
76
+ payload[:metadata] ||= {}
77
+ payload[:metadata][:draft] = true
78
+ end
79
+
80
+ def add_custom_metadata(payload, metadata)
81
+ return if metadata.blank?
82
+
83
+ payload[:metadata] ||= {}
84
+ payload[:metadata].merge!(metadata)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -90,7 +90,8 @@ module Yes
90
90
  PgEventstore.client.append_to_stream(
91
91
  command_utilities.build_stream(metadata:),
92
92
  event,
93
- options: { expected_revision: }
93
+ options: { expected_revision: },
94
+ middlewares: Middlewares.for_write
94
95
  ).tap { otl_record_response(_1) }
95
96
  end
96
97
 
@@ -111,10 +112,14 @@ module Yes
111
112
 
112
113
  next if normalized_revision == expected_revision
113
114
 
115
+ # pg_eventstore 3.0 requires `verdict:`, which selects the error's
116
+ # message. This branch is only reached when the revision we hold
117
+ # differs from the store's, which is exactly :unmatched_stream_revision.
114
118
  raise PgEventstore::WrongExpectedRevisionError.new(
115
119
  revision: aggregate_revision,
116
120
  expected_revision:,
117
- stream:
121
+ stream:,
122
+ verdict: :unmatched_stream_revision
118
123
  )
119
124
  end
120
125
  end
@@ -175,7 +180,7 @@ module Yes
175
180
  timestamp: result.created_at,
176
181
  attributes: {
177
182
  'event.type' => result.type,
178
- 'event.link_id' => result.link_id || '',
183
+ 'event.link_global_position' => result.link_global_position || '',
179
184
  'global_position' => result.global_position,
180
185
  'stream' => result.stream.to_json,
181
186
  'stream.revision' => result.stream_revision,
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Commands
6
+ # Base class for aggregate-DSL command groups.
7
+ #
8
+ # A {CommandGroup} represents a compound, transactional action declared
9
+ # inside an aggregate via the `command_group :name do … end` macro. It
10
+ # references its sub-commands by symbol (not by class constant) so
11
+ # declaration order in the aggregate body does not matter — resolution
12
+ # happens lazily against the Yes configuration registry.
13
+ #
14
+ # The legacy {Yes::Core::Commands::Group} is intentionally untouched and
15
+ # continues to serve the stateless command flow.
16
+ class CommandGroup
17
+ # Reuse the legacy Group's reserved-key Attributes struct — the shape
18
+ # is identical for both group flavours.
19
+ Attributes = Yes::Core::Commands::Group::Attributes
20
+
21
+ class << self
22
+ # @return [String] the group's owning context, e.g. "Companies"
23
+ attr_accessor :context
24
+
25
+ # @return [String] the group's owning aggregate, e.g. "Apprenticeship"
26
+ attr_accessor :aggregate
27
+
28
+ # @return [Symbol] the group's name as defined by `command_group :name`
29
+ attr_accessor :group_name
30
+
31
+ # @return [Array<Symbol>] ordered list of sub-command names referenced
32
+ # by the group; execution order matches this list
33
+ attr_writer :sub_command_names
34
+
35
+ def sub_command_names
36
+ @sub_command_names ||= []
37
+ end
38
+
39
+ # @return [Symbol] the own context as a snake-cased symbol
40
+ def own_context
41
+ context.to_s.underscore.to_sym
42
+ end
43
+
44
+ # @return [Symbol] the own subject (aggregate name) as a snake-cased symbol
45
+ def own_subject
46
+ aggregate.to_s.underscore.to_sym
47
+ end
48
+
49
+ # Resolves the sub-command classes lazily so declaration order in the
50
+ # aggregate body does not matter.
51
+ #
52
+ # @return [Array<Class>] sub-command classes in declaration order
53
+ def sub_command_classes
54
+ sub_command_names.map do |name|
55
+ Yes::Core.configuration.aggregate_class(context, aggregate, name, :command) ||
56
+ raise(ArgumentError, "Sub-command '#{name}' is not defined on #{context}::#{aggregate}")
57
+ end
58
+ end
59
+
60
+ # @return [Array<Symbol>] unique contexts of all sub-commands
61
+ def command_contexts
62
+ sub_command_classes.map { |c| c.name.split('::').first.underscore.to_sym }.uniq
63
+ end
64
+
65
+ # @return [Array<Symbol>] subjects in the own context
66
+ def own_context_subjects
67
+ sub_command_classes.
68
+ select { |c| c.name.split('::').first.underscore.to_sym == own_context }.
69
+ map { |c| c.name.split('::')[1].underscore.to_sym }.
70
+ uniq
71
+ end
72
+ end
73
+
74
+ # @return [Hash] the flat input payload (reserved keys stripped). This
75
+ # is what guard blocks see via `payload.<attr>` through PayloadProxy.
76
+ attr_reader :payload
77
+
78
+ # @return [Hash] the payload normalized into per-context/per-subject
79
+ # buckets. Used internally to construct sub-command instances and
80
+ # exposed so callers can introspect the group's per-sub-command shape.
81
+ attr_reader :normalized_payload
82
+
83
+ # @return [Attributes] the reserved-key attributes of the group
84
+ attr_reader :group_attributes
85
+
86
+ # @return [Array<Yes::Core::Command>] sub-command instances in execution order
87
+ attr_reader :commands
88
+
89
+ delegate :transaction, :origin, :batch_id, :metadata, :command_id, to: :group_attributes
90
+
91
+ # @return [String, nil] the aggregate ID derived from the first sub-command
92
+ def aggregate_id
93
+ commands.first&.aggregate_id
94
+ end
95
+
96
+ # @param params [Hash] flat / partially-nested input payload, optionally
97
+ # carrying reserved keys (transaction, origin, batch_id, metadata,
98
+ # command_id, es_encrypted)
99
+ def initialize(params)
100
+ @group_attributes = Attributes.new(params.slice(*Yes::Core::Command::RESERVED_KEYS))
101
+ @payload = params.except(*Yes::Core::Command::RESERVED_KEYS).symbolize_keys
102
+ @normalized_payload = GroupPayloadNormalizer.call(
103
+ params,
104
+ command_contexts: self.class.command_contexts,
105
+ own_context_subjects: self.class.own_context_subjects,
106
+ own_context: self.class.own_context,
107
+ own_subject: self.class.own_subject
108
+ )
109
+ @commands = build_commands
110
+ end
111
+
112
+ # @return [Hash] hash form for serialization — the FLAT input payload
113
+ # merged with reserved keys (transaction/origin/batch_id/metadata/
114
+ # command_id). This shape round-trips cleanly: calling
115
+ # `self.class.new(cmd.to_h)` produces an equivalent group with the
116
+ # same `payload`. Used by
117
+ # {Yes::Core::ActiveJobSerializers::CommandGroupSerializer} and by
118
+ # the Command API controller's `add_metadata`.
119
+ def to_h
120
+ merged = payload.merge(group_attributes.to_h)
121
+ transaction ? merged.merge(transaction:) : merged
122
+ end
123
+
124
+ private
125
+
126
+ # @return [Array<Yes::Core::Command>] sub-command instances populated with
127
+ # the matching payload subset and the propagated reserved keys
128
+ def build_commands
129
+ self.class.sub_command_classes.map do |klass|
130
+ sub_context = klass.name.split('::').first.underscore.to_sym
131
+ sub_subject = klass.name.split('::')[1].underscore.to_sym
132
+ subject_payload = normalized_payload.dig(sub_context, sub_subject) || {}
133
+ attribute_payload = subject_payload.slice(*klass.attribute_names)
134
+ klass.new(attribute_payload.merge(propagated_reserved_keys))
135
+ end
136
+ end
137
+
138
+ # @return [Hash] reserved keys (excluding command_id) shared with each
139
+ # sub-command; command_id is intentionally omitted so each sub-command
140
+ # gets its own auto-generated ID via {Yes::Core::Command}'s default
141
+ def propagated_reserved_keys
142
+ {
143
+ transaction: transaction,
144
+ origin: origin,
145
+ batch_id: batch_id,
146
+ metadata: metadata
147
+ }.compact
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Commands
6
+ # Response returned by {Yes::Core::CommandHandling::CommandGroupHandler}.
7
+ #
8
+ # Mirrors the surface of {Yes::Core::Commands::Response} (success?,
9
+ # error_details, type, to_notification) but carries an array of
10
+ # published events instead of a single one — one per sub-command in the
11
+ # group.
12
+ class CommandGroupResponse < Dry::Struct
13
+ attribute :cmd, Yes::Core::Types.Instance(Yes::Core::Commands::CommandGroup)
14
+ attribute :events,
15
+ Yes::Core::Types::Array.of(
16
+ Yes::Core::Types.Instance(PgEventstore::Event)
17
+ ).default([].freeze)
18
+ attribute? :error,
19
+ Yes::Core::Types.Instance(Yes::Core::CommandHandling::GuardEvaluator::TransitionError).
20
+ optional
21
+
22
+ delegate :transaction, :batch_id, :payload, :metadata, to: :cmd
23
+
24
+ # @return [Boolean] true when no error is attached
25
+ def success?
26
+ error.blank?
27
+ end
28
+
29
+ # @return [Hash] structured error info, or empty when successful
30
+ def error_details
31
+ return {} unless error
32
+
33
+ {
34
+ message: error.message,
35
+ type: error.message&.underscore&.tr(' ', '_'),
36
+ extra: (error.extra if error.respond_to?(:extra) && error.extra.present?)
37
+ }.compact
38
+ end
39
+
40
+ # @return [String] the response type tag
41
+ def type
42
+ success? ? 'command_success' : 'command_error'
43
+ end
44
+
45
+ # @return [Hash] notification-shaped hash for downstream consumers
46
+ def to_notification
47
+ error_payload = success? ? {} : { error_details: }
48
+ {
49
+ type:,
50
+ batch_id:,
51
+ payload:,
52
+ metadata:,
53
+ command: cmd.class.name,
54
+ id: cmd.command_id,
55
+ transaction: transaction.to_h
56
+ }.merge(error_payload)
57
+ end
58
+
59
+ # @return [Hash] JSON-friendly representation
60
+ def as_json(*)
61
+ to_notification.as_json
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -116,18 +116,13 @@ module Yes
116
116
  # @param params [Hash] the input parameters
117
117
  # @return [Hash] the normalized payloads
118
118
  def normalized_payloads(params)
119
- params.without(RESERVED_KEYS).each_with_object({}) do |(key, value), norm_payloads|
120
- if key.in?(self.class.command_contexts)
121
- norm_payloads[key] = value
122
- elsif key.in?(self.class.own_context_subjects)
123
- norm_payloads[self.class.own_context] ||= {}
124
- norm_payloads[self.class.own_context][key] = value
125
- else
126
- norm_payloads[self.class.own_context] ||= {}
127
- norm_payloads[self.class.own_context][self.class.own_subject] ||= {}
128
- norm_payloads[self.class.own_context][self.class.own_subject][key] = value
129
- end
130
- end
119
+ GroupPayloadNormalizer.call(
120
+ params,
121
+ command_contexts: self.class.command_contexts,
122
+ own_context_subjects: self.class.own_context_subjects,
123
+ own_context: self.class.own_context,
124
+ own_subject: self.class.own_subject
125
+ )
131
126
  end
132
127
  end
133
128
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Commands
6
+ # Normalizes a flat or partially-nested input hash into the per-context /
7
+ # per-subject shape expected by a command group's sub-commands.
8
+ #
9
+ # Supports three input forms simultaneously:
10
+ # * keys that match a command context are passed through verbatim,
11
+ # * keys that match a subject of the own context are nested one level
12
+ # under the own context,
13
+ # * any other key is nested two levels under
14
+ # <own_context>.<own_subject>.
15
+ #
16
+ # This is the shared payload-shaping primitive for
17
+ # {Yes::Core::Commands::Group} (legacy stateless flow) and
18
+ # {Yes::Core::Commands::CommandGroup} (aggregate-DSL flow).
19
+ module GroupPayloadNormalizer
20
+ # @param params [Hash] the raw input hash, including reserved keys
21
+ # @param command_contexts [Array<Symbol>] unique contexts of all
22
+ # commands in the group
23
+ # @param own_context_subjects [Array<Symbol>] subjects in the group's
24
+ # own context
25
+ # @param own_context [Symbol] the group's own context
26
+ # @param own_subject [Symbol] the group's own subject
27
+ # @return [Hash] the normalized payload (reserved keys stripped)
28
+ def self.call(params, command_contexts:, own_context_subjects:, own_context:, own_subject:)
29
+ params.without(*Yes::Core::Command::RESERVED_KEYS).each_with_object({}) do |(key, value), out|
30
+ if command_contexts.include?(key)
31
+ out[key] = value
32
+ elsif own_context_subjects.include?(key)
33
+ out[own_context] ||= {}
34
+ out[own_context][key] = value
35
+ else
36
+ out[own_context] ||= {}
37
+ out[own_context][own_subject] ||= {}
38
+ out[own_context][own_subject][key] = value
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end