yes-core 1.1.0 → 1.3.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 (27) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -0
  3. data/lib/yes/core/aggregate/dsl/class_name_convention.rb +8 -0
  4. data/lib/yes/core/aggregate/dsl/class_resolvers/command_group/base.rb +34 -0
  5. data/lib/yes/core/aggregate/dsl/class_resolvers/command_group/command.rb +43 -0
  6. data/lib/yes/core/aggregate/dsl/class_resolvers/command_group/guard_evaluator.rb +35 -0
  7. data/lib/yes/core/aggregate/dsl/command_data.rb +5 -1
  8. data/lib/yes/core/aggregate/dsl/command_group_data.rb +45 -0
  9. data/lib/yes/core/aggregate/dsl/command_group_definer.rb +100 -0
  10. data/lib/yes/core/aggregate/dsl/method_definers/command_group/base.rb +29 -0
  11. data/lib/yes/core/aggregate/dsl/method_definers/command_group/can_command_group.rb +41 -0
  12. data/lib/yes/core/aggregate/dsl/method_definers/command_group/command_group.rb +40 -0
  13. data/lib/yes/core/aggregate.rb +113 -9
  14. data/lib/yes/core/command_handling/command_group_executor.rb +236 -0
  15. data/lib/yes/core/command_handling/command_group_handler.rb +89 -0
  16. data/lib/yes/core/command_handling/guard_evaluator.rb +23 -0
  17. data/lib/yes/core/commands/command_group.rb +147 -0
  18. data/lib/yes/core/commands/command_group_response.rb +66 -0
  19. data/lib/yes/core/commands/group.rb +7 -12
  20. data/lib/yes/core/commands/group_payload_normalizer.rb +45 -0
  21. data/lib/yes/core/configuration.rb +22 -0
  22. data/lib/yes/core/test_support/aggregate/command_test_dsl.rb +77 -2
  23. data/lib/yes/core/test_support/aggregate/shared_examples.rb +45 -0
  24. data/lib/yes/core/utils/aggregate_shortcuts.rb +16 -7
  25. data/lib/yes/core/utils/command_utils.rb +21 -0
  26. data/lib/yes/core/version.rb +1 -1
  27. metadata +14 -1
@@ -0,0 +1,236 @@
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
+ )
174
+ end
175
+
176
+ def sub_event_metadata(sub_cmd)
177
+ meta = {}
178
+ meta['origin'] = sub_cmd.origin if sub_cmd.origin.present?
179
+ meta['batch_id'] = sub_cmd.batch_id if sub_cmd.batch_id.present?
180
+ meta['yes-dsl'] = true
181
+ meta.merge!(sub_cmd.metadata) if sub_cmd.metadata.present?
182
+ meta.merge!(sub_cmd.transaction.for_eventstore_metadata) if sub_cmd.transaction
183
+ meta.deep_transform_keys(&:to_s)
184
+ end
185
+
186
+ # Applies read-model updates for each (sub_cmd, event) pair in
187
+ # declaration order, so each sub-command's state-updater sees the
188
+ # state produced by the previous one.
189
+ #
190
+ # Intentionally NOT wrapped in an outer `ActiveRecord::Base.transaction`:
191
+ # if the third sub-command's read-model update raises after the first
192
+ # two succeed, the read model is left half-applied while the
193
+ # eventstore stream is fully committed. This matches today's behaviour
194
+ # for single commands (where any read-model failure after the event
195
+ # publishes leaves the read model behind the stream) and is healed by
196
+ # the standard event-replay/recovery path. An outer AR transaction
197
+ # could be added as a follow-up if partial application becomes a
198
+ # concrete problem in practice.
199
+ #
200
+ # @param cmd [Yes::Core::Commands::CommandGroup]
201
+ # @param events [Array<PgEventstore::Event>]
202
+ # @return [void]
203
+ def apply_read_model_updates(cmd, events)
204
+ cmd.commands.zip(events, cmd.class.sub_command_names).each do |sub_cmd, event, sub_name|
205
+ ReadModelUpdater.new(aggregate).call(event, sub_cmd.payload, sub_name)
206
+ end
207
+ end
208
+
209
+ # @see CommandExecutor#set_pending_update_state
210
+ def set_pending_update_state
211
+ return unless read_model
212
+
213
+ begin
214
+ ActiveRecord::Base.transaction(requires_new: true) do
215
+ read_model.update_column(:pending_update_since, Time.current)
216
+ end
217
+ rescue ActiveRecord::StatementInvalid => e
218
+ raise e unless e.message.include?('Concurrent pending update not allowed')
219
+
220
+ raise ConcurrentUpdateError.new(
221
+ aggregate_class: aggregate.class,
222
+ aggregate_id: read_model.id,
223
+ original_error: e
224
+ )
225
+ end
226
+ end
227
+
228
+ def clear_pending_update_state
229
+ return unless read_model
230
+
231
+ read_model.update_column(:pending_update_since, nil)
232
+ end
233
+ end
234
+ end
235
+ end
236
+ 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
@@ -52,6 +52,7 @@ module Yes
52
52
  # @raise [InvalidTransition] When a guard fails with an invalid transition
53
53
  # @raise [NoChangeTransition] When a guard fails with a no change transition
54
54
  def call
55
+ check_not_removed!
55
56
  self.class.guards.each do |name, guard_data|
56
57
  evaluate_guard(name, error_extra: guard_data[:error_extra], block: guard_data[:block])
57
58
  end
@@ -64,6 +65,28 @@ module Yes
64
65
 
65
66
  attr_reader :raw_payload, :raw_metadata, :payload, :aggregate, :aggregate_tracker, :command_name
66
67
 
68
+ # Pre-check that fires before any registered guard. Blocks every command on a removable
69
+ # aggregate once `removed_at` (or the configured attr) has been set, except where the
70
+ # command opts out via `skip_default_guards: %i[not_removed]`.
71
+ #
72
+ # The check runs ahead of the user-defined guards (including the auto-injected `:no_change`)
73
+ # so post-remove mutations consistently surface "X has been removed" instead of misleading
74
+ # downstream errors like "X does not change".
75
+ #
76
+ # @return [void]
77
+ # @raise [InvalidTransition] When the aggregate has been removed and the command is not opted out.
78
+ def check_not_removed!
79
+ config = aggregate.class.respond_to?(:removable_config) ? aggregate.class.removable_config : nil
80
+ return unless config && config[:not_removed_guards]
81
+
82
+ command_data = aggregate.class.commands[command_name]
83
+ return if command_data&.skip_default_guards&.include?(:not_removed)
84
+
85
+ return if aggregate.public_send(config[:attr_name]).blank?
86
+
87
+ raise InvalidTransition, error_message(:not_removed)
88
+ end
89
+
67
90
  # Evaluates a single guard and raises appropriate error if it fails
68
91
  #
69
92
  # @param name [Symbol] The name of the guard
@@ -0,0 +1,147 @@
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, merging normalized payload
113
+ # and reserved keys (matches legacy {Yes::Core::Commands::Group#to_h})
114
+ def to_h
115
+ merged = normalized_payload.merge(group_attributes.to_h)
116
+ transaction ? merged.merge(transaction:) : merged
117
+ end
118
+
119
+ private
120
+
121
+ # @return [Array<Yes::Core::Command>] sub-command instances populated with
122
+ # the matching payload subset and the propagated reserved keys
123
+ def build_commands
124
+ self.class.sub_command_classes.map do |klass|
125
+ sub_context = klass.name.split('::').first.underscore.to_sym
126
+ sub_subject = klass.name.split('::')[1].underscore.to_sym
127
+ subject_payload = normalized_payload.dig(sub_context, sub_subject) || {}
128
+ attribute_payload = subject_payload.slice(*klass.attribute_names)
129
+ klass.new(attribute_payload.merge(propagated_reserved_keys))
130
+ end
131
+ end
132
+
133
+ # @return [Hash] reserved keys (excluding command_id) shared with each
134
+ # sub-command; command_id is intentionally omitted so each sub-command
135
+ # gets its own auto-generated ID via {Yes::Core::Command}'s default
136
+ def propagated_reserved_keys
137
+ {
138
+ transaction: transaction,
139
+ origin: origin,
140
+ batch_id: batch_id,
141
+ metadata: metadata
142
+ }.compact
143
+ end
144
+ end
145
+ end
146
+ end
147
+ 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
@@ -200,6 +200,28 @@ module Yes
200
200
  register_aggregate_class(context_name, aggregate_name, command_name, :guard_evaluator, klass)
201
201
  end
202
202
 
203
+ # Register a command_group command class for a specific aggregate
204
+ # @param context_name [Symbol, String] The context for the aggregate
205
+ # @param aggregate_name [Symbol, String] The name of the aggregate
206
+ # @param group_name [Symbol, String] The name of the command group
207
+ # @param klass [Class] The generated CommandGroup subclass
208
+ # @example
209
+ # register_command_group_class(:companies, :apprenticeship, :create_apprenticeship, klass)
210
+ def register_command_group_class(context_name, aggregate_name, group_name, klass)
211
+ register_aggregate_class(context_name, aggregate_name, group_name, :command_group, klass)
212
+ end
213
+
214
+ # Register a command_group guard evaluator class for a specific aggregate
215
+ # @param context_name [Symbol, String] The context for the aggregate
216
+ # @param aggregate_name [Symbol, String] The name of the aggregate
217
+ # @param group_name [Symbol, String] The name of the command group
218
+ # @param klass [Class] The generated GuardEvaluator subclass
219
+ # @example
220
+ # register_command_group_guard_evaluator_class(:companies, :apprenticeship, :create_apprenticeship, klass)
221
+ def register_command_group_guard_evaluator_class(context_name, aggregate_name, group_name, klass)
222
+ register_aggregate_class(context_name, aggregate_name, group_name, :command_group_guard_evaluator, klass)
223
+ end
224
+
203
225
  # Register an aggregate authorizer class for a specific aggregate
204
226
  # @param context_name [Symbol, String] The context for the aggregate
205
227
  # @param aggregate_name [Symbol, String] The name of the aggregate