yes-core 1.0.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 (128) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +69 -0
  5. data/lib/yes/core/active_job_serializers/command_group_serializer.rb +29 -0
  6. data/lib/yes/core/active_job_serializers/dry_struct_serializer.rb +57 -0
  7. data/lib/yes/core/aggregate/draftable.rb +205 -0
  8. data/lib/yes/core/aggregate/dsl/attribute_data.rb +37 -0
  9. data/lib/yes/core/aggregate/dsl/attribute_definer.rb +54 -0
  10. data/lib/yes/core/aggregate/dsl/attribute_definers/aggregate.rb +36 -0
  11. data/lib/yes/core/aggregate/dsl/attribute_definers/standard.rb +36 -0
  12. data/lib/yes/core/aggregate/dsl/class_name_convention.rb +80 -0
  13. data/lib/yes/core/aggregate/dsl/class_resolvers/authorizer.rb +132 -0
  14. data/lib/yes/core/aggregate/dsl/class_resolvers/base.rb +80 -0
  15. data/lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer.rb +30 -0
  16. data/lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer_factory.rb +34 -0
  17. data/lib/yes/core/aggregate/dsl/class_resolvers/command/base.rb +38 -0
  18. data/lib/yes/core/aggregate/dsl/class_resolvers/command/cerbos_authorizer.rb +114 -0
  19. data/lib/yes/core/aggregate/dsl/class_resolvers/command/command.rb +70 -0
  20. data/lib/yes/core/aggregate/dsl/class_resolvers/command/event.rb +88 -0
  21. data/lib/yes/core/aggregate/dsl/class_resolvers/command/guard_evaluator.rb +84 -0
  22. data/lib/yes/core/aggregate/dsl/class_resolvers/command/simple_authorizer.rb +50 -0
  23. data/lib/yes/core/aggregate/dsl/class_resolvers/command/state_updater.rb +46 -0
  24. data/lib/yes/core/aggregate/dsl/class_resolvers/read_model.rb +75 -0
  25. data/lib/yes/core/aggregate/dsl/class_resolvers/read_model_filter.rb +88 -0
  26. data/lib/yes/core/aggregate/dsl/class_resolvers/read_model_serializer.rb +76 -0
  27. data/lib/yes/core/aggregate/dsl/command_data.rb +54 -0
  28. data/lib/yes/core/aggregate/dsl/command_definer.rb +263 -0
  29. data/lib/yes/core/aggregate/dsl/command_shortcut_expander.rb +233 -0
  30. data/lib/yes/core/aggregate/dsl/constant_resolver.rb +67 -0
  31. data/lib/yes/core/aggregate/dsl/method_definers/attribute/accessor.rb +28 -0
  32. data/lib/yes/core/aggregate/dsl/method_definers/attribute/aggregate_accessor.rb +36 -0
  33. data/lib/yes/core/aggregate/dsl/method_definers/attribute/base.rb +42 -0
  34. data/lib/yes/core/aggregate/dsl/method_definers/command/base.rb +42 -0
  35. data/lib/yes/core/aggregate/dsl/method_definers/command/can_command.rb +41 -0
  36. data/lib/yes/core/aggregate/dsl/method_definers/command/command.rb +50 -0
  37. data/lib/yes/core/aggregate/has_authorizer.rb +86 -0
  38. data/lib/yes/core/aggregate/has_read_model.rb +169 -0
  39. data/lib/yes/core/aggregate/read_model_rebuilder.rb +40 -0
  40. data/lib/yes/core/aggregate/shared_read_model_rebuilder.rb +158 -0
  41. data/lib/yes/core/aggregate.rb +404 -0
  42. data/lib/yes/core/authentication_error.rb +8 -0
  43. data/lib/yes/core/authorization/cerbos_client_provider.rb +27 -0
  44. data/lib/yes/core/authorization/command_authorizer.rb +40 -0
  45. data/lib/yes/core/authorization/command_cerbos_authorizer.rb +182 -0
  46. data/lib/yes/core/authorization/read_model_authorizer.rb +22 -0
  47. data/lib/yes/core/authorization/read_models_authorizer.rb +49 -0
  48. data/lib/yes/core/authorization/read_request_authorizer.rb +32 -0
  49. data/lib/yes/core/authorization/read_request_cerbos_authorizer.rb +112 -0
  50. data/lib/yes/core/command.rb +35 -0
  51. data/lib/yes/core/command_handling/aggregate_tracker.rb +33 -0
  52. data/lib/yes/core/command_handling/command_executor.rb +171 -0
  53. data/lib/yes/core/command_handling/command_handler.rb +124 -0
  54. data/lib/yes/core/command_handling/event_publisher.rb +189 -0
  55. data/lib/yes/core/command_handling/guard_evaluator.rb +165 -0
  56. data/lib/yes/core/command_handling/guard_runner.rb +76 -0
  57. data/lib/yes/core/command_handling/payload_proxy.rb +159 -0
  58. data/lib/yes/core/command_handling/read_model_recovery_service.rb +264 -0
  59. data/lib/yes/core/command_handling/read_model_revision_guard.rb +198 -0
  60. data/lib/yes/core/command_handling/read_model_updater.rb +103 -0
  61. data/lib/yes/core/command_handling/state_updater.rb +113 -0
  62. data/lib/yes/core/commands/bus.rb +46 -0
  63. data/lib/yes/core/commands/group.rb +135 -0
  64. data/lib/yes/core/commands/group_response.rb +13 -0
  65. data/lib/yes/core/commands/helper.rb +126 -0
  66. data/lib/yes/core/commands/notifier.rb +65 -0
  67. data/lib/yes/core/commands/processor.rb +137 -0
  68. data/lib/yes/core/commands/response.rb +63 -0
  69. data/lib/yes/core/commands/stateless/group_handler.rb +186 -0
  70. data/lib/yes/core/commands/stateless/group_response.rb +15 -0
  71. data/lib/yes/core/commands/stateless/handler.rb +292 -0
  72. data/lib/yes/core/commands/stateless/handler_helpers.rb +321 -0
  73. data/lib/yes/core/commands/stateless/response.rb +14 -0
  74. data/lib/yes/core/commands/stateless/subject.rb +41 -0
  75. data/lib/yes/core/commands/validator.rb +28 -0
  76. data/lib/yes/core/configuration.rb +432 -0
  77. data/lib/yes/core/data_decryptor.rb +59 -0
  78. data/lib/yes/core/data_encryptor.rb +60 -0
  79. data/lib/yes/core/encryption_metadata.rb +33 -0
  80. data/lib/yes/core/error.rb +14 -0
  81. data/lib/yes/core/error_messages.rb +37 -0
  82. data/lib/yes/core/event.rb +222 -0
  83. data/lib/yes/core/event_class_resolver.rb +40 -0
  84. data/lib/yes/core/generators/read_models/add_pending_update_tracking_generator.rb +43 -0
  85. data/lib/yes/core/generators/read_models/templates/add_pending_update_tracking.rb.erb +122 -0
  86. data/lib/yes/core/generators/read_models/templates/migration.rb.erb +9 -0
  87. data/lib/yes/core/generators/read_models/update_generator.rb +147 -0
  88. data/lib/yes/core/jobs/read_model_recovery_job.rb +219 -0
  89. data/lib/yes/core/middlewares/encryptor.rb +48 -0
  90. data/lib/yes/core/middlewares/timestamp.rb +29 -0
  91. data/lib/yes/core/middlewares/with_indifferent_access.rb +22 -0
  92. data/lib/yes/core/models/application_record.rb +9 -0
  93. data/lib/yes/core/open_telemetry/otl_span.rb +110 -0
  94. data/lib/yes/core/open_telemetry/trackable.rb +101 -0
  95. data/lib/yes/core/payload_store/base.rb +33 -0
  96. data/lib/yes/core/payload_store/errors.rb +13 -0
  97. data/lib/yes/core/payload_store/lookup.rb +44 -0
  98. data/lib/yes/core/process_managers/access_token_client.rb +107 -0
  99. data/lib/yes/core/process_managers/base.rb +40 -0
  100. data/lib/yes/core/process_managers/command_runner.rb +109 -0
  101. data/lib/yes/core/process_managers/service_client.rb +57 -0
  102. data/lib/yes/core/process_managers/state.rb +118 -0
  103. data/lib/yes/core/railtie.rb +58 -0
  104. data/lib/yes/core/read_model/builder.rb +267 -0
  105. data/lib/yes/core/read_model/event_handler.rb +64 -0
  106. data/lib/yes/core/read_model/filter.rb +118 -0
  107. data/lib/yes/core/read_model/filter_query_builder.rb +104 -0
  108. data/lib/yes/core/serializer.rb +21 -0
  109. data/lib/yes/core/subscriptions.rb +94 -0
  110. data/lib/yes/core/test_support/event_helpers.rb +27 -0
  111. data/lib/yes/core/test_support/jwt_helpers.rb +30 -0
  112. data/lib/yes/core/test_support/subscriptions_helper.rb +88 -0
  113. data/lib/yes/core/test_support/test_helper.rb +27 -0
  114. data/lib/yes/core/test_support.rb +5 -0
  115. data/lib/yes/core/transaction_details.rb +90 -0
  116. data/lib/yes/core/type_lookup.rb +88 -0
  117. data/lib/yes/core/types.rb +110 -0
  118. data/lib/yes/core/utils/aggregate_shortcuts.rb +164 -0
  119. data/lib/yes/core/utils/caller_utils.rb +37 -0
  120. data/lib/yes/core/utils/command_utils.rb +226 -0
  121. data/lib/yes/core/utils/error_notifier.rb +101 -0
  122. data/lib/yes/core/utils/event_name_resolver.rb +67 -0
  123. data/lib/yes/core/utils/exponential_retrier.rb +180 -0
  124. data/lib/yes/core/utils/hash_utils.rb +63 -0
  125. data/lib/yes/core/version.rb +7 -0
  126. data/lib/yes/core.rb +85 -0
  127. data/lib/yes.rb +0 -0
  128. metadata +324 -0
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Commands
6
+ class Bus
7
+ include Yes::Core::OpenTelemetry::Trackable
8
+
9
+ attr_reader :command_processor, :perform_inline
10
+ private :command_processor, :perform_inline
11
+
12
+ def initialize(
13
+ command_processor: Processor,
14
+ perform_inline: Yes::Core.configuration.process_commands_inline
15
+ )
16
+ @command_processor = command_processor
17
+ @perform_inline = perform_inline
18
+ end
19
+
20
+ # Passees commands on to the command processor, in case origin is not provided,
21
+ # it will be derived from the caller. Also decides based on config whether to perform commands inline or not
22
+ # @param command_or_commands [Command, Array<Command>] Command(s) instance(s)
23
+ # @param origin [String] Origin of the command
24
+ # @param notifier_options [Hash] Options for command notifier
25
+ # @param batch_id [String] Batch ID
26
+ # @return [void]
27
+ def call(
28
+ command_or_commands,
29
+ origin: nil,
30
+ notifier_options: {},
31
+ batch_id: nil
32
+ )
33
+ origin ||= Utils::CallerUtils.origin_from_caller(caller_locations(1..1).first)
34
+
35
+ perform_method = perform_inline ? :perform_now : :perform_later
36
+ self.class.current_span&.add_attributes({ perform_method: perform_method.to_s, origin: }.stringify_keys)
37
+
38
+ command_processor.public_send(
39
+ perform_method, origin, command_or_commands, notifier_options, batch_id, perform_inline
40
+ )
41
+ end
42
+ otl_trackable :call, Yes::Core::OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Command Bus Schedule')
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Commands
6
+ # Represents a group of commands executed in a transaction.
7
+ # Provides DSL for defining which commands belong to the group
8
+ # and handles payload normalization across contexts and subjects.
9
+ class Group
10
+ RESERVED_KEYS = Yes::Core::Command::RESERVED_KEYS
11
+
12
+ # Meta attributes for the Group, compatible with the Command class.
13
+ class Attributes < Dry::Struct
14
+ class Invalid < Error; end
15
+
16
+ attribute? :transaction, Types.Instance(TransactionDetails).optional
17
+ attribute? :origin, Types::String.optional
18
+ attribute? :batch_id, Types::String.optional
19
+ attribute? :metadata, Types::Hash.optional
20
+ attribute(:command_id, Types::UUID.default { SecureRandom.uuid })
21
+
22
+ # @param attributes [Hash] constructor parameters
23
+ # @raise [Invalid] if the parameters are invalid
24
+ def self.new(attributes)
25
+ super
26
+ rescue Dry::Struct::Error => e
27
+ raise Invalid.new(extra: attributes), e
28
+ end
29
+ end
30
+
31
+ class << self
32
+ # @return [Array<Class>] List of command classes used in this group
33
+ attr_reader :commands
34
+
35
+ # @return [Array<Symbol>] List of unique command contexts
36
+ def command_contexts
37
+ commands.map { _1.to_s.split('::')[0].underscore.to_sym }.uniq
38
+ end
39
+
40
+ # @return [Array<Symbol>] List of subjects in the current context
41
+ def own_context_subjects
42
+ commands.
43
+ select { _1.to_s.split('::')[0].underscore.to_sym == own_context }.
44
+ map { _1.to_s.split('::')[1].underscore.to_sym }.uniq
45
+ end
46
+
47
+ # @return [Symbol] The context of the current command group
48
+ def own_context
49
+ to_s.split('::')[0].underscore.to_sym
50
+ end
51
+
52
+ # @return [Symbol] The subject of the current command group
53
+ def own_subject
54
+ to_s.split('::')[1].underscore.to_sym
55
+ end
56
+
57
+ # Defines a command for the command group.
58
+ #
59
+ # @param command_name [String] the command class name, e.g. 'NameChanged'
60
+ # @param context [String] the context of the command, defaults to the first module
61
+ # @param subject [String] the subject of the command, defaults to the second module
62
+ # @return [void]
63
+ def command(command_name, context: to_s.split('::')[0], subject: to_s.split('::')[1])
64
+ @commands ||= []
65
+ @commands <<
66
+ Object.const_get(
67
+ "#{context}::#{subject}::Commands::#{command_name}::Command"
68
+ )
69
+ end
70
+ end
71
+
72
+ # @return [Hash] The payload of the command group
73
+ attr_reader :payload
74
+
75
+ # @return [Attributes] The attributes of the command group
76
+ attr_reader :group_attributes
77
+
78
+ # @return [Array<Command>] Command instances of the group's commands
79
+ attr_reader :commands
80
+
81
+ delegate :transaction, :origin, :batch_id, :metadata, :command_id, to: :group_attributes
82
+
83
+ # Returns the aggregate ID from the first command in the group.
84
+ #
85
+ # @return [String, nil] the aggregate ID
86
+ def aggregate_id
87
+ commands.first&.aggregate_id
88
+ end
89
+
90
+ # Initialize a new Group.
91
+ #
92
+ # @param params [Hash] Parameters for the command group (meta attributes and command payload)
93
+ def initialize(params)
94
+ @group_attributes = Attributes.new(params.slice(*Yes::Core::Command::RESERVED_KEYS))
95
+ @payload = normalized_payloads(params)
96
+ @commands = self.class.commands.map do |command|
97
+ command.new(
98
+ payload.dig(command.to_s.split('::')[0].underscore.to_sym, command.to_s.split('::')[1].underscore.to_sym)
99
+ )
100
+ end
101
+ end
102
+
103
+ # Returns the command group as a hash for serialization.
104
+ #
105
+ # @return [Hash] payloads and meta attributes merged
106
+ def to_h
107
+ transaction = group_attributes.transaction
108
+ merged = payload.merge(group_attributes.to_h)
109
+ transaction ? merged.merge(transaction:) : merged
110
+ end
111
+
112
+ private
113
+
114
+ # Normalizes the payloads for the command group.
115
+ #
116
+ # @param params [Hash] the input parameters
117
+ # @return [Hash] the normalized payloads
118
+ def normalized_payloads(params)
119
+ params.without(RESERVED_KEYS).each_with_object({}) do |(key, value), norm_payloads|
120
+ if key.in?(self.class.command_contexts)
121
+ norm_payloads[key] = value
122
+ elsif key.in?(self.class.own_context_subjects)
123
+ norm_payloads[self.class.own_context] ||= {}
124
+ norm_payloads[self.class.own_context][key] = value
125
+ else
126
+ norm_payloads[self.class.own_context] ||= {}
127
+ norm_payloads[self.class.own_context][self.class.own_subject] ||= {}
128
+ norm_payloads[self.class.own_context][self.class.own_subject][key] = value
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Commands
6
+ class GroupResponse < Response
7
+ attribute :cmd, Yes::Core::Types.Instance(Yes::Core::Commands::Group)
8
+ attribute? :error,
9
+ Yes::Core::Types.Instance(Yes::Core::Commands::Stateless::GroupHandler::CommandsError).optional
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Commands
6
+ # Provides naming resolution helpers for commands following the V2 folder structure
7
+ # (e.g. Context::Subject::Commands::DoSomething::Command).
8
+ #
9
+ # @example
10
+ # helper = Yes::Core::Commands::Helper.new(command)
11
+ # helper.command_name
12
+ class Helper
13
+ AGGREGATE_CLASSNAME = 'Aggregate'
14
+ VERSION_REGEXP = /::(?<version>V\d+)::/
15
+
16
+ attr_reader :inflector, :cmd
17
+ private :inflector, :cmd
18
+
19
+ delegate :splitted_command, to: :class
20
+
21
+ class << self
22
+ # Splits the command class name into module parts.
23
+ #
24
+ # @param cmd [Yes::Core::Command] the command instance
25
+ # @return [Array<String>] the split class name parts
26
+ def splitted_command(cmd)
27
+ cmd.class.to_s.split('::')
28
+ end
29
+ end
30
+
31
+ # @param cmd [Yes::Core::Command] the command instance
32
+ def initialize(cmd)
33
+ @inflector = Dry::Inflector.new
34
+ @cmd = cmd
35
+ end
36
+
37
+ # Returns the top-level context module of the command.
38
+ #
39
+ # @return [String] the command context
40
+ def command_context
41
+ splitted_command(cmd).first
42
+ end
43
+ alias context command_context
44
+
45
+ # Returns the locale for the command.
46
+ #
47
+ # @return [Symbol] the locale
48
+ def command_locale
49
+ cmd.respond_to?(:locale) ? cmd.locale : I18n.locale
50
+ end
51
+ alias locale command_locale
52
+
53
+ # Extracts the version from the command class name.
54
+ #
55
+ # @return [String, nil] the version string (e.g. "V1") or nil
56
+ def command_version
57
+ VERSION_REGEXP.match(cmd.class.to_s)&.[](:version)
58
+ end
59
+
60
+ # Returns the event payload with stringified keys.
61
+ #
62
+ # @return [Hash] the deep stringified event payload
63
+ def event_payload
64
+ cmd.payload.deep_stringify_keys
65
+ end
66
+
67
+ # Returns the underscored command name.
68
+ #
69
+ # @return [String] the command name
70
+ def command_name
71
+ inflector.underscore(splitted_command(cmd)[-2])
72
+ end
73
+
74
+ # Returns the aggregate class name.
75
+ #
76
+ # @return [String] the aggregate class name
77
+ def aggregate_classname
78
+ AGGREGATE_CLASSNAME
79
+ end
80
+
81
+ # Returns the aggregate module name.
82
+ #
83
+ # @return [String] the aggregate module name
84
+ def aggregate_module
85
+ splitted_command(cmd)[-4]
86
+ end
87
+ alias subject aggregate_module
88
+
89
+ # Returns the fully qualified authorizer class name.
90
+ #
91
+ # @return [String] the authorizer class name
92
+ def authorizer_classname
93
+ spl = splitted_command(cmd)
94
+ spl[0] == 'CommandGroups' ? "#{spl[0..1].join('::')}::Authorizer" : "#{spl[0..3].join('::')}::Authorizer"
95
+ end
96
+
97
+ # Returns the fully qualified validator class name.
98
+ #
99
+ # @return [String] the validator class name
100
+ def validator_classname
101
+ spl = splitted_command(cmd)
102
+ spl[0] == 'CommandGroups' ? "#{spl[0..1].join('::')}::Validator" : "#{spl[0..3].join('::')}::Validator"
103
+ end
104
+
105
+ # Returns the aggregate class constant.
106
+ #
107
+ # @return [Class] the aggregate class
108
+ def aggregate_class
109
+ inflector.constantize(
110
+ [
111
+ command_context,
112
+ command_version,
113
+ aggregate_module,
114
+ aggregate_classname
115
+ ].compact.join('::')
116
+ )
117
+ end
118
+
119
+ # Returns the aggregate ID from the command.
120
+ #
121
+ # @return [String] the aggregate ID
122
+ delegate :aggregate_id, to: :cmd
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @abstract Command notifier base class. Subclass and override notification methods to implement
4
+ # a custom notifier.
5
+ module Yes
6
+ module Core
7
+ module Commands
8
+ class Notifier
9
+ attr_reader :channel
10
+ private :channel
11
+
12
+ # @param options [Hash] notifier options
13
+ # @option options [String] :channel the notification channel
14
+ def initialize(options = {})
15
+ @channel = options[:channel]
16
+ end
17
+
18
+ # Implement this method to notify that a batch has started processing
19
+ # @param batch_id [String] batch id of the batch that has started processing
20
+ # @param transaction [TransactionDetails] the transaction details of the current transaction
21
+ # @param commands [Array<Command>] the commands that are being processed
22
+ def notify_batch_started(batch_id, transaction = nil, commands = nil)
23
+ raise NotImplementedError
24
+ end
25
+
26
+ # Implement this method to notify that a batch has finished processing
27
+ # @param batch_id [String] batch id of the batch that has finished processing
28
+ # @param transaction [TransactionDetails] the transaction details of the current transaction
29
+ # @param responses [Array<Response>] the responses of the commands that were processed
30
+ def notify_batch_finished(batch_id, transaction = nil, responses = nil)
31
+ raise NotImplementedError
32
+ end
33
+
34
+ # Implement this method to notify that a command response has been received
35
+ # @param cmd_response [Yes::Core::Commands::Response] the command response to notify
36
+ def notify_command_response(cmd_response)
37
+ raise NotImplementedError
38
+ end
39
+
40
+ # Wraps the given block in a batch notification.
41
+ # @param batch_id [String] the batch id
42
+ # @param commands [Array<Command>] the commands being processed in the batch
43
+ # @param transaction [TransactionDetails] the transaction details of the current transaction
44
+ # @yield executes commands within the batch notification
45
+ # @yieldreturn [Array<Response>] responses from the executed commands
46
+ # @return [Array<Response>] responses from the executed commands
47
+ def with_batch_notification(batch_id, commands, transaction = nil)
48
+ notify_batch_started(batch_id, transaction, commands)
49
+ response = yield
50
+ notify_batch_finished(batch_id, transaction, response)
51
+
52
+ response
53
+ end
54
+
55
+ def self.with_batch_notification(notifiers, batch_id, commands, transaction = nil)
56
+ notifiers.each { _1.notify_batch_started(batch_id, transaction, commands) }
57
+ response = yield
58
+ notifiers.each { _1.notify_batch_finished(batch_id, transaction, response) }
59
+
60
+ response
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Commands
6
+ # Processes commands asynchronously through ActiveJob
7
+ # @since 0.1.0
8
+ class Processor < ActiveJob::Base
9
+ include Yes::Core::OpenTelemetry::Trackable
10
+
11
+ queue_as :commands
12
+
13
+ # Error raised when a command is not registered with a handler
14
+ UnregisteredCommand = Class.new(Error)
15
+
16
+ # @return [Object] The notifier for command processing events
17
+ attr_reader :command_notifiers, :custom_batch_id
18
+ private :command_notifiers, :custom_batch_id
19
+
20
+ # Processes the given commands by running them through their respective handlers.
21
+ # @param origin [String] a string identifying the origin of the commands
22
+ # @param command_or_commands [Command, Array<Command>] the command or commands to process
23
+ # @param notifier_options [Hash] options to pass to the command notifier
24
+ # @param custom_batch_id [String] Custom batch ID
25
+ # @return [Array<Response>] the responses from the performed commands
26
+ # @raise [UnregisteredCommand] if any command lacks a handler
27
+ def perform(origin, command_or_commands, notifier_options, custom_batch_id = nil, inline = false)
28
+ setup(notifier_options, custom_batch_id, inline)
29
+ singleton_class.current_span&.add_event('Command Processor Setup Done')
30
+
31
+ commands = [*command_or_commands]
32
+ ensure_guard_evaluators_exist?(commands)
33
+ singleton_class.current_span&.add_event('Ensured Guard Evaluators Exist')
34
+
35
+ commands.map! { |cmd| cmd.class.new(cmd.to_h.merge(origin:, batch_id:)) }
36
+ singleton_class.current_span&.add_event('Commands Mapped')
37
+
38
+ if command_notifiers.any?
39
+ singleton_class.with_otl_span 'Run Commands With Notifiers' do
40
+ Notifier.with_batch_notification(command_notifiers, batch_id, commands) do
41
+ singleton_class.with_otl_span 'Run Commands' do
42
+ run_commands(commands)
43
+ end
44
+ end
45
+ end
46
+ else
47
+ singleton_class.with_otl_span 'Run Commands' do
48
+ run_commands(commands)
49
+ end
50
+ end
51
+ end
52
+ otl_trackable :perform, Yes::Core::OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Command Processor Perform')
53
+
54
+ private
55
+
56
+ # Instantiates the command notifier from the config, using the given options.
57
+ # @param notifier_options [Hash] the options to pass to the command notifier
58
+ # @param custom_batch_id [String] Custom batch ID
59
+ # @param inline [Boolean] whether to process the commands inline
60
+ # @return [void]
61
+ def setup(notifier_options, custom_batch_id, inline = false)
62
+ @command_notifiers = [] if inline
63
+
64
+ @command_notifiers ||= Yes::Core.configuration.command_notifier_classes&.map do |notifier_class|
65
+ notifier_class.new(notifier_options)
66
+ end || []
67
+ @custom_batch_id = custom_batch_id
68
+ end
69
+
70
+ # Runs the given commands through their respective aggregates.
71
+ # @param commands [Array<Command>] the commands to run
72
+ # @return [Array<Response>] responses from the performed commands
73
+ def run_commands(commands, _inline = false)
74
+ commands.map do |cmd|
75
+ cmd_response = run_command(cmd)
76
+ command_notifiers.each { _1.notify_command_response(cmd_response) }
77
+ cmd_response
78
+ end
79
+ end
80
+
81
+ # Executes a single command on its aggregate
82
+ # @param cmd [Command] the command to execute
83
+ # @return [Response] response from executing the command
84
+ def run_command(cmd)
85
+ command_helper = Yes::Core::Commands::Helper.new(cmd)
86
+ draft = draft?(cmd)
87
+ aggregate = aggregate_class(cmd).new(cmd.aggregate_id, draft:)
88
+ I18n.with_locale(command_helper.command_locale) do
89
+ # Pass payload as first argument, guards as option
90
+ aggregate.public_send(command_helper.command_name, cmd.to_h, guards: !draft)
91
+ end
92
+ end
93
+
94
+ def draft?(cmd)
95
+ cmd.metadata&.dig(:draft) || cmd.metadata&.dig(:edit_template_command)
96
+ end
97
+
98
+ # Determines the aggregate class for a given command
99
+ # @param cmd [Command] The command to find the aggregate class for
100
+ # @return [Class] The aggregate class that handles this command
101
+ def aggregate_class(cmd)
102
+ Yes::Core::Commands::Helper.new(cmd).aggregate_class
103
+ end
104
+
105
+ # Checks if a guard evaluator exists for the given command
106
+ # @param cmd [Command] The command to check
107
+ # @return [Boolean] true if a guard evaluator exists
108
+ # @raise [UnregisteredCommand] if no guard evaluator is found for the command
109
+ def guard_evaluator_exists?(cmd)
110
+ command_helper = Yes::Core::Commands::Helper.new(cmd)
111
+
112
+ klass = Yes::Core.configuration.guard_evaluator_class(command_helper.command_context,
113
+ command_helper.subject,
114
+ command_helper.command_name)
115
+
116
+ raise UnregisteredCommand, "Unregistered command: #{cmd.class}" unless klass
117
+
118
+ true
119
+ end
120
+
121
+ # Ensures handlers exist for all commands
122
+ # @param commands [Array<Command>] The commands to check
123
+ # @return [Boolean] true if handlers exist for all commands
124
+ # @raise [UnregisteredCommand] if any command lacks a handler
125
+ def ensure_guard_evaluators_exist?(commands)
126
+ commands.all? { guard_evaluator_exists?(_1) }
127
+ end
128
+
129
+ # Returns the batch id of the current batch.
130
+ # @return [String] the batch id
131
+ def batch_id
132
+ custom_batch_id || job_id
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Commands
6
+ class Response < Dry::Struct
7
+ attribute :cmd, Yes::Core::Types.Instance(Command)
8
+ attribute? :event, Yes::Core::Types.Instance(PgEventstore::Event).optional
9
+ attribute? :error,
10
+ Yes::Core::Types.Instance(Yes::Core::CommandHandling::GuardEvaluator::TransitionError).
11
+ optional
12
+
13
+ # @return [TransactionDetails, nil] command's transaction info if present
14
+ #
15
+ delegate :transaction, :batch_id, :payload, :metadata, to: :cmd
16
+
17
+ # @return [Boolean] true in case the command was processed successfully
18
+ #
19
+ def success?
20
+ error.blank?
21
+ end
22
+
23
+ # @return [Hash] error details in case an error occurred
24
+ #
25
+ def error_details
26
+ return {} unless error
27
+
28
+ {
29
+ message: error.message,
30
+ type: error.message&.underscore&.tr(' ', '_'),
31
+ extra: (error.extra if error.respond_to?(:extra) && error.extra.present?)
32
+ }.compact
33
+ end
34
+
35
+ # @return [String] type of the command response
36
+ #
37
+ def type
38
+ success? ? 'command_success' : 'command_error'
39
+ end
40
+
41
+ # @return [Hash] command response as a hash
42
+ #
43
+ def to_notification
44
+ error = success? ? {} : { error_details: }
45
+ {
46
+ type:,
47
+ batch_id:,
48
+ payload:,
49
+ metadata:,
50
+ command: cmd.class.name,
51
+ id: cmd.command_id,
52
+ transaction: transaction.to_h
53
+ }.merge(error)
54
+ end
55
+
56
+ # @return [Hash]
57
+ def as_json(*)
58
+ to_notification.as_json
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end