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
@@ -79,14 +79,13 @@ module Yes
79
79
  end
80
80
 
81
81
  # Executes a single command on its aggregate
82
- # @param cmd [Command] the command to execute
83
- # @return [Response] response from executing the command
82
+ # @param cmd [Command, Yes::Core::Commands::CommandGroup] the command to execute
83
+ # @return [Response, Yes::Core::Commands::CommandGroupResponse] response from executing the command
84
84
  def run_command(cmd)
85
85
  command_helper = Yes::Core::Commands::Helper.new(cmd)
86
86
  draft = draft?(cmd)
87
87
  aggregate = aggregate_class(cmd).new(cmd.aggregate_id, draft:)
88
88
  I18n.with_locale(command_helper.command_locale) do
89
- # Pass payload as first argument, guards as option
90
89
  aggregate.public_send(command_helper.command_name, cmd.to_h, guards: !draft)
91
90
  end
92
91
  end
@@ -103,21 +102,43 @@ module Yes
103
102
  end
104
103
 
105
104
  # Checks if a guard evaluator exists for the given command
106
- # @param cmd [Command] The command to check
105
+ #
106
+ # Aggregate-DSL command groups register their guard evaluator under a
107
+ # dedicated registry type, so they are resolved through a different
108
+ # configuration lookup than single commands.
109
+ #
110
+ # @param cmd [Command, Yes::Core::Commands::CommandGroup] The command to check
107
111
  # @return [Boolean] true if a guard evaluator exists
108
112
  # @raise [UnregisteredCommand] if no guard evaluator is found for the command
109
113
  def guard_evaluator_exists?(cmd)
110
114
  command_helper = Yes::Core::Commands::Helper.new(cmd)
111
115
 
112
- klass = Yes::Core.configuration.guard_evaluator_class(command_helper.command_context,
113
- command_helper.subject,
114
- command_helper.command_name)
116
+ klass = guard_evaluator_class_for(cmd, command_helper)
115
117
 
116
118
  raise UnregisteredCommand, "Unregistered command: #{cmd.class}" unless klass
117
119
 
118
120
  true
119
121
  end
120
122
 
123
+ # Resolves the guard evaluator class for a command, dispatching to the
124
+ # command-group registry for {Yes::Core::Commands::CommandGroup} and to
125
+ # the single-command registry otherwise.
126
+ #
127
+ # @param cmd [Command, Yes::Core::Commands::CommandGroup] The command
128
+ # @param command_helper [Yes::Core::Commands::Helper] helper for the command
129
+ # @return [Class, nil] the registered guard evaluator class or nil
130
+ def guard_evaluator_class_for(cmd, command_helper)
131
+ if cmd.is_a?(Yes::Core::Commands::CommandGroup)
132
+ Yes::Core.configuration.command_group_guard_evaluator_class(command_helper.command_context,
133
+ command_helper.subject,
134
+ command_helper.command_name)
135
+ else
136
+ Yes::Core.configuration.guard_evaluator_class(command_helper.command_context,
137
+ command_helper.subject,
138
+ command_helper.command_name)
139
+ end
140
+ end
141
+
121
142
  # Ensures handlers exist for all commands
122
143
  # @param commands [Array<Command>] The commands to check
123
144
  # @return [Boolean] true if handlers exist for all commands
@@ -118,7 +118,8 @@ module Yes
118
118
  PgEventstore.client.append_to_stream(
119
119
  stream,
120
120
  event,
121
- options: { expected_revision: subject_stream_revision }
121
+ options: { expected_revision: subject_stream_revision },
122
+ middlewares: Middlewares.for_write
122
123
  ).tap { otl_record_response(_1) }
123
124
  end
124
125
  otl_trackable :publish_event, OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Publish Event', span_kind: :producer)
@@ -198,7 +199,12 @@ module Yes
198
199
  # @param expected_revision [Integer]
199
200
  # @param stream [PgEventstore::Stream]
200
201
  def revision_error!(revision, expected_revision, stream)
201
- PgEventstore::WrongExpectedRevisionError.new(revision:, expected_revision:, stream:).tap do |error|
202
+ # pg_eventstore 3.0 requires `verdict:`, which selects the error's
203
+ # message. verify_revisions! only calls this when the two revisions
204
+ # differ, which is exactly :unmatched_stream_revision.
205
+ PgEventstore::WrongExpectedRevisionError.new(
206
+ revision:, expected_revision:, stream:, verdict: :unmatched_stream_revision
207
+ ).tap do |error|
202
208
  self.class.current_span&.status = ::OpenTelemetry::Trace::Status.error('Wrong expected revision')
203
209
  self.class.current_span&.add_attributes(
204
210
  {
@@ -277,7 +283,7 @@ module Yes
277
283
  timestamp: result.created_at,
278
284
  attributes: {
279
285
  'event.type' => result.type,
280
- 'event.link_id' => result.link_id || '',
286
+ 'event.link_global_position' => result.link_global_position || '',
281
287
  'global_position' => result.global_position,
282
288
  'stream' => result.stream.to_json,
283
289
  'stream.revision' => result.stream_revision,
@@ -287,7 +287,7 @@ module Yes
287
287
  # @return [Enumerator]
288
288
  def load_events(stream, options: {}, skip_decryption: true)
289
289
  options = { direction: 'Backwards' }.merge(options)
290
- middlewares = Middlewares.without(:encryptor) if skip_decryption
290
+ middlewares = Middlewares.without(Middlewares::ENCRYPTOR) if skip_decryption
291
291
  PgEventstore.client.read_paginated(stream, options:, middlewares:)
292
292
  end
293
293
 
@@ -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
@@ -309,6 +331,23 @@ module Yes
309
331
  aggregate_class(context_name, aggregate_name, command_name.to_s.underscore.to_sym, :guard_evaluator)
310
332
  end
311
333
 
334
+ # Retrieve a guard evaluator class for a specific command group.
335
+ #
336
+ # Aggregate-DSL command groups register their guard evaluator under the
337
+ # dedicated `:command_group_guard_evaluator` type (see
338
+ # {#register_command_group_guard_evaluator_class}), not `:guard_evaluator`,
339
+ # so a group cannot be resolved through {#guard_evaluator_class}.
340
+ #
341
+ # @param context_name [Symbol, String] The context for the aggregate
342
+ # @param aggregate_name [Symbol, String] The name of the aggregate
343
+ # @param group_name [Symbol, String] The name of the command group
344
+ # @return [Class, nil] The registered guard evaluator class or nil if not found
345
+ # @example
346
+ # evaluator = command_group_guard_evaluator_class(:companies, :apprenticeship, :create_apprenticeship)
347
+ def command_group_guard_evaluator_class(context_name, aggregate_name, group_name)
348
+ aggregate_class(context_name, aggregate_name, group_name.to_s.underscore.to_sym, :command_group_guard_evaluator)
349
+ end
350
+
312
351
  # List all registered classes across all aggregates and contexts
313
352
  # @return [Hash] A complete hash of all registered classes
314
353
  # @example
@@ -40,13 +40,13 @@ module Yes
40
40
  def decrypt_attributes(key:, data:, attributes: {}) # rubocop:disable Lint/UnusedMethodArgument
41
41
  return data unless key
42
42
 
43
- res = key_repository.decrypt(key:, message: data['es_encrypted'])
43
+ res = key_repository.decrypt(key:, message: data[DataEncryptor::CIPHERTEXT_KEY])
44
44
  return data if res.failure?
45
45
 
46
46
  decrypted_text = res.value!
47
47
  decrypted = JSON.parse(decrypted_text.attributes[:message]).transform_keys(&:to_s)
48
48
  decrypted.each { |k, value| data[k] = value if data.key?(k) }
49
- data.delete('es_encrypted')
49
+ data.delete(DataEncryptor::CIPHERTEXT_KEY)
50
50
  data
51
51
  end
52
52
 
@@ -10,6 +10,10 @@ module Yes
10
10
  # encryptor.encrypted_data
11
11
  # encryptor.encryption_metadata
12
12
  class DataEncryptor
13
+ # Data key holding the ciphertext of all encrypted attributes. Its presence means the data is
14
+ # currently encrypted: it is written here and removed by {DataDecryptor}.
15
+ CIPHERTEXT_KEY = 'es_encrypted'
16
+
13
17
  # @return [Hash] the encrypted data
14
18
  attr_reader :encrypted_data
15
19
 
@@ -51,8 +55,8 @@ module Yes
51
55
  def encrypt_attributes(key:, data:, attributes:)
52
56
  text = JSON.generate(data.select { |hash_key, _value| attributes.include?(hash_key.to_s) })
53
57
  encrypted = key_repository.encrypt(key:, message: text).value!
54
- attributes.each { |att| data[att.to_s] = 'es_encrypted' if data.key?(att.to_s) }
55
- data['es_encrypted'] = encrypted.attributes[:message]
58
+ attributes.each { |att| data[att.to_s] = CIPHERTEXT_KEY if data.key?(att.to_s) }
59
+ data[CIPHERTEXT_KEY] = encrypted.attributes[:message]
56
60
  data
57
61
  end
58
62
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ # Reports a permanently dead pg_eventstore subscription to Sentry.
6
+ #
7
+ # pg_eventstore calls its +failed_subscription_notifier+ exactly once, when a
8
+ # subscription exhausts its restarts and stays dead — it is the gem's only
9
+ # death signal, and without it that death is silent. Per-failure
10
+ # errors are only recorded on the subscription row, never raised into Sentry.
11
+ class FailedSubscriptionNotifier
12
+ # @param subscription [PgEventstore::Subscription]
13
+ # @param error [StandardError]
14
+ # @return [void]
15
+ def call(subscription, error)
16
+ Sentry.with_scope do |scope|
17
+ scope.set_tags(failed_subscription_notifier: true) # used in Sentry Alerts
18
+
19
+ Sentry.capture_exception(
20
+ error,
21
+ # a death report must never be swallowed by Sentry's excluded_exceptions
22
+ hint: { ignore_exclusions: true },
23
+ extra: { id: subscription.id, set: subscription.set, name: subscription.name }
24
+ )
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -5,9 +5,12 @@ module Yes
5
5
  module Middlewares
6
6
  # PgEventstore middleware for encrypting/decrypting event data.
7
7
  #
8
+ # Register it through {Middlewares.register_encryptor} rather than by hand, so its write-only
9
+ # counterpart ({WriteEncryptor}) is always registered alongside it.
10
+ #
8
11
  # @example
9
12
  # PgEventstore.configure do |config|
10
- # config.middlewares[:encryptor] = Yes::Core::Middlewares::Encryptor.new(key_repository)
13
+ # Yes::Core::Middlewares.register_encryptor(key_repository, config:)
11
14
  # end
12
15
  class Encryptor
13
16
  include PgEventstore::Middleware
@@ -24,6 +27,11 @@ module Yes
24
27
  # @return [PgEventstore::Event]
25
28
  def serialize(event)
26
29
  return event unless event.class.respond_to?(:encryption_schema)
30
+ # Idempotence guard. With {WriteEncryptor} registered, the DEFAULT middleware list holds two
31
+ # serialize-capable encryptors, so an append that omits `middlewares:` would encrypt twice. The
32
+ # second pass would encrypt the sentinels written by the first and overwrite the real ciphertext,
33
+ # which cannot be recovered. It also guards re-appending an event that was read at rest.
34
+ return event if event.data[DataEncryptor::CIPHERTEXT_KEY].present?
27
35
 
28
36
  encryptor = DataEncryptor.new(
29
37
  data: event.data, schema: event.class.encryption_schema, repository: key_repository
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Middlewares
6
+ # Encrypts on the way in exactly like {Encryptor}, but does not decrypt on the way out.
7
+ #
8
+ # pg_eventstore 3.0 runs every registered middleware's #deserialize on the events returned by
9
+ # #append_to_stream, not only on the ones returned by reads. Nothing on the write path reads `data` off
10
+ # that returned event, so decrypting it costs two uncached HTTP calls to the encryptor service (a key
11
+ # lookup and a decrypt) per encrypted event, for a payload that is immediately discarded.
12
+ #
13
+ # #serialize is inherited rather than reimplemented on purpose: encryption at rest must never differ
14
+ # between the read and the write variant.
15
+ #
16
+ # Registered alongside {Encryptor} by {Middlewares.register_encryptor} and selected by
17
+ # {Middlewares.for_write}. The read path keeps using {Encryptor}, unchanged.
18
+ #
19
+ # @example
20
+ # PgEventstore.client.append_to_stream(stream, event, middlewares: Yes::Core::Middlewares.for_write)
21
+ class WriteEncryptor < Encryptor
22
+ # Deliberately does nothing: the caller does not read the returned event's data.
23
+ #
24
+ # Returns the event rather than nil. pg_eventstore itself ignores the return value, but middlewares
25
+ # are also invoked directly in places that use it.
26
+ #
27
+ # @param event [PgEventstore::Event]
28
+ # @return [PgEventstore::Event]
29
+ def deserialize(event)
30
+ event
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -3,9 +3,54 @@
3
3
  module Yes
4
4
  module Core
5
5
  module Middlewares
6
+ # Config key of the encryptor used on read paths: encrypts on #serialize, decrypts on #deserialize.
7
+ ENCRYPTOR = :encryptor
8
+ # Config key of the encryptor used on write paths: encrypts on #serialize, no-op on #deserialize.
9
+ WRITE_ENCRYPTOR = :write_encryptor
10
+
6
11
  class << self
12
+ # Registers both encryptor middlewares against the same key repository.
13
+ #
14
+ # Always use this instead of assigning config.middlewares[:encryptor] by hand: registering the
15
+ # decrypting encryptor without its write-only twin silently doubles the encryptor round trips every
16
+ # encrypted append performs (see {WriteEncryptor}).
17
+ #
18
+ # Mutates the given config in place rather than opening its own PgEventstore.configure block, because
19
+ # PgEventstore.configure takes a non-reentrant mutex - nesting one inside another deadlocks.
20
+ #
21
+ # @param key_repository [#find, #create, #encrypt, #decrypt]
22
+ # @param config [PgEventstore::Config] the config yielded by PgEventstore.configure
23
+ # @return [void]
24
+ def register_encryptor(key_repository, config: PgEventstore.config)
25
+ config.middlewares[ENCRYPTOR] = Encryptor.new(key_repository)
26
+ config.middlewares[WRITE_ENCRYPTOR] = WriteEncryptor.new(key_repository)
27
+ end
28
+
29
+ # Middleware keys to pass to #append_to_stream: every configured middleware, with the decrypting
30
+ # encryptor swapped for the write-only one.
31
+ #
32
+ # Derived from the live config, never hard-coded. PgEventstore::Client resolves a passed list with
33
+ # `config.middlewares.slice(*list)`, which silently drops names that are not registered - so a literal
34
+ # list would resolve to one with NO encryptor at all against a config that registered it under a
35
+ # different key, and would write plaintext at rest undetectably. Deriving the list makes that
36
+ # impossible, and picks up any middleware added later for free.
37
+ #
38
+ # Falls back to the full list when {WRITE_ENCRYPTOR} is not registered. That is correct, just as slow
39
+ # as before - unlike a hard-coded list, which would drop encryption altogether.
40
+ #
41
+ # @return [Array<Symbol>]
42
+ def for_write
43
+ keys = PgEventstore.config.middlewares.keys
44
+ return keys unless keys.include?(WRITE_ENCRYPTOR)
45
+
46
+ keys - [ENCRYPTOR]
47
+ end
48
+
7
49
  # Returns middleware keys excluding the specified one.
8
50
  #
51
+ # Note that excluding {ENCRYPTOR} still yields a list containing {WRITE_ENCRYPTOR}, whose #deserialize
52
+ # is a no-op - so `without(:encryptor)` keeps meaning "read the data as it is stored".
53
+ #
9
54
  # @param middleware_name [Symbol] the middleware key to exclude
10
55
  # @return [Array<Symbol>] remaining middleware keys
11
56
  def without(middleware_name)
@@ -16,6 +16,11 @@ module Yes
16
16
  with_indifferent_access: Yes::Core::Middlewares::WithIndifferentAccess.new,
17
17
  timestamp: Yes::Core::Middlewares::Timestamp.new
18
18
  }
19
+
20
+ # pg_eventstore's only death signal — without it a subscription that
21
+ # exhausts its restarts dies silently: per-failure errors are only recorded
22
+ # on the subscription row, never raised.
23
+ config.failed_subscription_notifier = FailedSubscriptionNotifier.new if defined?(Sentry)
19
24
  end
20
25
  end
21
26
 
@@ -49,6 +54,21 @@ module Yes
49
54
  PgEventstore.logger ||= Rails.logger if ENV['PG_ES_LOGGING'] == 'true'
50
55
  end
51
56
 
57
+ # The one misconfiguration Middlewares.for_write cannot fix by itself: a decrypting encryptor registered
58
+ # without its write-only twin. for_write then falls back to the full list, so writes stay CORRECT - they
59
+ # just keep paying an encryptor key lookup and decrypt per encrypted event, for data nobody reads.
60
+ # Warned about once at boot rather than on every append.
61
+ config.after_initialize do
62
+ middlewares = PgEventstore.config.middlewares
63
+ next unless middlewares.key?(Yes::Core::Middlewares::ENCRYPTOR)
64
+ next if middlewares.key?(Yes::Core::Middlewares::WRITE_ENCRYPTOR)
65
+
66
+ Rails.logger.warn(
67
+ 'PgEventstore middleware :encryptor is registered without :write_encryptor, so append_to_stream ' \
68
+ 'decrypts every event it returns. Register both with Yes::Core::Middlewares.register_encryptor.'
69
+ )
70
+ end
71
+
52
72
  # Load aggregate shortcuts when Rails console starts
53
73
  console do
54
74
  Yes::Core::Utils::AggregateShortcuts.load!
@@ -21,6 +21,28 @@ module Yes
21
21
  # end
22
22
  # end
23
23
  module CommandTestDsl
24
+ # Returns the event-type aggregate prefix that the runtime publishes
25
+ # for a given aggregate and draft flag. Mirrors
26
+ # `CommandUtils#aggregate_name_with_draft_suffix` so DSL-generated
27
+ # `expected_event_type` values match the runtime-published event
28
+ # types — including the case where `draftable changes_read_model:`
29
+ # was set explicitly, which makes the camelized read-model name the
30
+ # event-type prefix instead of the generic `<Aggregate>Draft`.
31
+ #
32
+ # @param aggregate_class [Class] The aggregate class under test
33
+ # @param draft [Boolean] Whether the test exercises a draft command
34
+ # @return [String] The event-type aggregate prefix
35
+ def self.expected_event_prefix(aggregate_class, draft:)
36
+ return aggregate_class.aggregate unless draft
37
+
38
+ if aggregate_class.respond_to?(:_changes_read_model_explicit) &&
39
+ aggregate_class._changes_read_model_explicit
40
+ aggregate_class.changes_read_model_name.camelize
41
+ else
42
+ "#{aggregate_class.aggregate}Draft"
43
+ end
44
+ end
45
+
24
46
  # Defines a test block for a command
25
47
  #
26
48
  # @param command_name [String, Symbol] the name of the command to test
@@ -41,8 +63,8 @@ module Yes
41
63
  end
42
64
  let(:command_data) { {} }
43
65
  let(:expected_event_type) do
44
- "#{aggregate_class.context}::#{aggregate_class.aggregate}" \
45
- "#{'Draft' if draft}#{aggregate_class.commands[command].event_name.to_s.classify}"
66
+ prefix = CommandTestDsl.expected_event_prefix(aggregate_class, draft:)
67
+ "#{aggregate_class.context}::#{prefix}#{aggregate_class.commands[command].event_name.to_s.classify}"
46
68
  end
47
69
  let(:expected_event_data) { command_data_with_id }
48
70
  let(:expected_event_metadata) { nil }
@@ -99,6 +121,59 @@ module Yes
99
121
  def setup(&)
100
122
  before(&)
101
123
  end
124
+
125
+ # Defines a test block for a command group, mirroring {.command}.
126
+ #
127
+ # @param group_name [String, Symbol] the command_group name
128
+ # @param options [Array<Hash>] additional options (e.g., `draft: true`)
129
+ # @yield block for configuring test cases (success, invalid, no_change)
130
+ def command_group(group_name, *options, &block)
131
+ describe group_name.to_s, *options do
132
+ let(:draft) { options.first&.dig(:draft) }
133
+ let(:aggregate) { described_class.new(draft:) } unless method_defined?(:aggregate)
134
+
135
+ subject { aggregate.public_send(group, command_data) }
136
+
137
+ let(:group) { group_name.to_sym }
138
+ let(:aggregate_class) { aggregate.class }
139
+ let(:command_data) { {} }
140
+ let(:expected_event_types) do
141
+ prefix = CommandTestDsl.expected_event_prefix(aggregate_class, draft:)
142
+ aggregate_class.command_groups[group].sub_command_names.map do |sub_name|
143
+ sub_event_name = aggregate_class.commands[sub_name].event_name.to_s.classify
144
+ "#{aggregate_class.context}::#{prefix}#{sub_event_name}"
145
+ end
146
+ end
147
+ let(:success_attributes) { command_data.without(:locale) } unless method_defined?(:success_attributes)
148
+
149
+ class_eval(&block) if block_given?
150
+ end
151
+ end
152
+
153
+ # Defines a successful test for a command group.
154
+ def success_group(description = 'when successfully executing command group', options = {}, &block)
155
+ context description, options do
156
+ instance_eval(&block) if block_given?
157
+ it_behaves_like 'successful command group'
158
+ end
159
+ end
160
+
161
+ # Defines an invalid-transition test for a command group.
162
+ def invalid_group(description, options = {}, &block)
163
+ context "when #{description}", options do
164
+ instance_eval(&block) if block_given?
165
+ it_behaves_like 'invalid command group transition'
166
+ end
167
+ end
168
+
169
+ # Defines a no-change test for a command group.
170
+ def no_change_group(description = 'when command group causes no change', options = {}, &block)
171
+ context description.to_s, options do
172
+ instance_eval(&block) if block_given?
173
+ before { aggregate.public_send(group, command_data) }
174
+ it_behaves_like 'no change command group transition'
175
+ end
176
+ end
102
177
  end
103
178
  end
104
179
  end
@@ -75,3 +75,48 @@ RSpec.shared_examples 'no change transition' do
75
75
  )
76
76
  end
77
77
  end
78
+
79
+ RSpec.shared_examples 'successful command group' do
80
+ it 'returns a successful CommandGroupResponse' do
81
+ expect(subject).to be_a(Yes::Core::Commands::CommandGroupResponse)
82
+ expect(subject).to be_success
83
+ end
84
+
85
+ it 'publishes one event per sub-command in declaration order' do
86
+ expect(subject.events.map(&:type)).to eq(expected_event_types)
87
+ end
88
+
89
+ it 'reflects the cumulative state on the read model' do
90
+ if success_attributes.any?
91
+ expect { subject }.to change {
92
+ aggregate.read_model.reload.attributes.to_h.symbolize_keys.slice(*success_attributes.keys)
93
+ }.to(success_attributes)
94
+ end
95
+ end
96
+ end
97
+
98
+ RSpec.shared_examples 'invalid command group transition' do
99
+ it 'returns an InvalidTransition error and no events' do
100
+ aggregate_failures do
101
+ expect(subject).not_to be_success
102
+ expect(subject.error).to be_a(Yes::Core::CommandHandling::GuardEvaluator::InvalidTransition)
103
+ expect(subject.events).to be_empty
104
+ end
105
+ end
106
+
107
+ it 'does not change the aggregate state' do
108
+ success_attributes.each_key do |attribute|
109
+ expect { subject }.not_to(change { aggregate.public_send(attribute) })
110
+ end
111
+ end
112
+ end
113
+
114
+ RSpec.shared_examples 'no change command group transition' do
115
+ it 'returns a NoChangeTransition error and no events' do
116
+ aggregate_failures do
117
+ expect(subject).not_to be_success
118
+ expect(subject.error).to be_a(Yes::Core::CommandHandling::GuardEvaluator::NoChangeTransition)
119
+ expect(subject.events).to be_empty
120
+ end
121
+ end
122
+ end
@@ -16,7 +16,7 @@ module Yes
16
16
  # @param event [Yes::Core::Event]
17
17
  # @return [void]
18
18
  def append_event(stream, event)
19
- PgEventstore.client.append_to_stream(stream, event)
19
+ PgEventstore.client.append_to_stream(stream, event, middlewares: Yes::Core::Middlewares.for_write)
20
20
  end
21
21
 
22
22
  # Appends an event to a stream and reloads it
@@ -20,7 +20,14 @@ module Yes
20
20
 
21
21
  EMPTY_STRING = /\A\s*\z/
22
22
 
23
- UUID_REGEXP_BASE = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}/
23
+ # Version nibble is [1-8], not a hard-coded 4. pg_eventstore 3.0.0 moved event-id
24
+ # generation off the database's gen_random_uuid() (always v4) to
25
+ # SecureRandom.uuid_v7, and those ids reach us as causation_id / correlation_id.
26
+ # A v4-only pattern makes TransactionDetails raise Dry::Struct::Error, which fails
27
+ # the event handler and kills the subscription once its restarts run out.
28
+ #
29
+ # Still a real constraint, per RFC 9562: version 1-8 and variant nibble 8/9/a/b.
30
+ UUID_REGEXP_BASE = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}/
24
31
  UUID_REGEXP = /\A#{UUID_REGEXP_BASE}\z/i
25
32
 
26
33
  DATE_TIME_REGEXP = /\A\d{4}-\d{1,2}-\d{1,2} ([0-1][0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?\z/i
@@ -51,6 +51,27 @@ module Yes
51
51
  fetch_class(name, :guard_evaluator)
52
52
  end
53
53
 
54
+ # Builds a command_group instance for a given group name and flat payload.
55
+ # The aggregate_id is injected automatically.
56
+ #
57
+ # @param group_name [Symbol] The command group name
58
+ # @param payload [Hash] The flat / partially-nested input payload
59
+ # @return [Yes::Core::Commands::CommandGroup] The instantiated group command
60
+ # @raise [RuntimeError] If the command_group class cannot be found
61
+ def build_group_command(group_name, payload)
62
+ group_class = fetch_class(group_name, :command_group)
63
+ group_class.new("#{aggregate.underscore}_id": aggregate_id, **payload)
64
+ end
65
+
66
+ # Fetches the guard evaluator class for a given command group name.
67
+ #
68
+ # @param group_name [Symbol] The command group name
69
+ # @return [Class] The guard evaluator class
70
+ # @raise [RuntimeError] If the guard evaluator class cannot be found
71
+ def fetch_guard_evaluator_class_for_group(group_name)
72
+ fetch_class(group_name, :command_group_guard_evaluator)
73
+ end
74
+
54
75
  # Fetches the state updater class for a given command name
55
76
  #
56
77
  # @param name [Symbol] The command name
@@ -22,7 +22,8 @@ module Yes
22
22
  dupl
23
23
  end
24
24
 
25
- # Returns a hash with the keys flattened
25
+ # Returns a hash with the keys flattened. Scalar, nested-hash and
26
+ # array values all honour the `prefix` consistently.
26
27
  #
27
28
  # @param obj [Hash, Array] the object to flatten
28
29
  # @param prefix [String] the key to use as a prefix for the keys in the hash
@@ -32,6 +33,10 @@ module Yes
32
33
  # @example
33
34
  # HashUtils.deep_flatten_hash({ name: 'A', otl_contexts: { root: { attr: 10, available: true } } })
34
35
  # => {"name"=>"A", "otl_contexts.root.attr"=>10, "otl_contexts.root.available"=>true}
36
+ #
37
+ # @example with a prefix (arrays included)
38
+ # HashUtils.deep_flatten_hash({ id: 1, tags: [{ k: 'v' }] }, 'span')
39
+ # => {"span.id"=>1, "span.tags"=>[{"k"=>"v"}]}
35
40
  def deep_flatten_hash(obj, prefix = nil, memo = {})
36
41
  case obj
37
42
  when Hash
@@ -42,7 +47,7 @@ module Yes
42
47
  in String | Symbol, Hash
43
48
  deep_flatten_hash(value, prefix ? "#{prefix}.#{key}" : key.to_s, memo)
44
49
  in String | Symbol, Array
45
- memo[key.to_s] = deep_flatten_hash(value)
50
+ memo[prefix ? "#{prefix}.#{key}" : key.to_s] = deep_flatten_hash(value)
46
51
  in Array, _
47
52
  memo[deep_flatten_hash(key)] = deep_flatten_hash(value)
48
53
  else
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Yes
4
4
  module Core
5
- VERSION = '1.2.0'
5
+ VERSION = '2.2.0'
6
6
  end
7
7
  end