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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +13 -0
- data/lib/yes/core/active_job_serializers/command_group_serializer.rb +7 -4
- 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_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 +50 -0
- data/lib/yes/core/authorization/command_cerbos_authorizer.rb +20 -5
- data/lib/yes/core/authorization/lookup_cache.rb +72 -0
- data/lib/yes/core/command_handling/command_group_executor.rb +237 -0
- data/lib/yes/core/command_handling/command_group_handler.rb +89 -0
- data/lib/yes/core/command_handling/event_publisher.rb +8 -3
- data/lib/yes/core/commands/command_group.rb +152 -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/commands/processor.rb +28 -7
- data/lib/yes/core/commands/stateless/handler.rb +9 -3
- data/lib/yes/core/commands/stateless/handler_helpers.rb +1 -1
- data/lib/yes/core/configuration.rb +39 -0
- data/lib/yes/core/data_decryptor.rb +2 -2
- data/lib/yes/core/data_encryptor.rb +6 -2
- data/lib/yes/core/failed_subscription_notifier.rb +29 -0
- data/lib/yes/core/middlewares/encryptor.rb +9 -1
- data/lib/yes/core/middlewares/write_encryptor.rb +35 -0
- data/lib/yes/core/middlewares.rb +45 -0
- data/lib/yes/core/railtie.rb +20 -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/test_support/event_helpers.rb +1 -1
- data/lib/yes/core/types.rb +8 -1
- data/lib/yes/core/utils/command_utils.rb +21 -0
- data/lib/yes/core/utils/hash_utils.rb +7 -2
- data/lib/yes/core/version.rb +1 -1
- 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
|
-
#
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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[
|
|
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(
|
|
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] =
|
|
55
|
-
data[
|
|
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
|
-
#
|
|
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
|
data/lib/yes/core/middlewares.rb
CHANGED
|
@@ -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)
|
data/lib/yes/core/railtie.rb
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
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
|
data/lib/yes/core/types.rb
CHANGED
|
@@ -20,7 +20,14 @@ module Yes
|
|
|
20
20
|
|
|
21
21
|
EMPTY_STRING = /\A\s*\z/
|
|
22
22
|
|
|
23
|
-
|
|
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
|