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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/lib/yes/core/aggregate/dsl/class_name_convention.rb +8 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command_group/base.rb +34 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command_group/command.rb +43 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command_group/guard_evaluator.rb +35 -0
- data/lib/yes/core/aggregate/dsl/command_data.rb +5 -1
- data/lib/yes/core/aggregate/dsl/command_group_data.rb +45 -0
- data/lib/yes/core/aggregate/dsl/command_group_definer.rb +100 -0
- data/lib/yes/core/aggregate/dsl/method_definers/command_group/base.rb +29 -0
- data/lib/yes/core/aggregate/dsl/method_definers/command_group/can_command_group.rb +41 -0
- data/lib/yes/core/aggregate/dsl/method_definers/command_group/command_group.rb +40 -0
- data/lib/yes/core/aggregate.rb +113 -9
- data/lib/yes/core/command_handling/command_group_executor.rb +236 -0
- data/lib/yes/core/command_handling/command_group_handler.rb +89 -0
- data/lib/yes/core/command_handling/guard_evaluator.rb +23 -0
- data/lib/yes/core/commands/command_group.rb +147 -0
- data/lib/yes/core/commands/command_group_response.rb +66 -0
- data/lib/yes/core/commands/group.rb +7 -12
- data/lib/yes/core/commands/group_payload_normalizer.rb +45 -0
- data/lib/yes/core/configuration.rb +22 -0
- data/lib/yes/core/test_support/aggregate/command_test_dsl.rb +77 -2
- data/lib/yes/core/test_support/aggregate/shared_examples.rb +45 -0
- data/lib/yes/core/utils/aggregate_shortcuts.rb +16 -7
- data/lib/yes/core/utils/command_utils.rb +21 -0
- data/lib/yes/core/version.rb +1 -1
- 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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|