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,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Authorization
6
+ # Authorizes a collection of read model records by delegating to per-record authorizers.
7
+ class ReadModelsAuthorizer
8
+ NotAuthorized = Class.new(Yes::Core::Error)
9
+
10
+ class << self
11
+ # @param read_model_name [String] name of the read model
12
+ # @param records [Array<ApplicationRecord>] records to authorize
13
+ # @param auth_data [Hash] authorization data
14
+ # @raise [NotAuthorized] if any records are not authorized
15
+ def call(read_model_name, records, auth_data)
16
+ authorizer = authorizer_for(read_model_name)
17
+
18
+ return unless authorizer
19
+
20
+ unauthorized = []
21
+ records.each do |record|
22
+ authorizer.call(record, auth_data)
23
+ rescue ReadModelAuthorizer::NotAuthorized => e
24
+ unauthorized << {
25
+ message: e.message,
26
+ model_type: record.class.to_s,
27
+ model_id: record.id
28
+ }
29
+ end
30
+
31
+ raise NotAuthorized.new(extra: unauthorized) if unauthorized.any?
32
+ end
33
+
34
+ private
35
+
36
+ # @param read_model_name [String] name of the read model
37
+ # @return [Yes::Core::Authorization::ReadModelAuthorizer, nil] authorizer for read model if existing
38
+ def authorizer_for(read_model_name)
39
+ class_name = "ReadModels::#{read_model_name.classify}::Authorizer"
40
+
41
+ Kernel.const_get(class_name)
42
+ rescue NameError
43
+ nil # defining a per record authorizer is optional
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Authorization
6
+ # @abstract Read request authorizer base class. Subclass and override call method to implement
7
+ # a custom authorizer.
8
+ class ReadRequestAuthorizer
9
+ NotAuthorized = Class.new(Yes::Core::Error)
10
+
11
+ class << self
12
+ # Implement this method to authorize a read request.
13
+ # Needs to return true if read request is authorized, otherwise raise NotAuthorized.
14
+ # @param params [Hash] request params to authorize
15
+ # @param auth_data [Hash] authorization data
16
+ # @return [Boolean] true if read request is authorized raises NotAuthorized otherwise
17
+ def call(_params, _auth_data)
18
+ raise NotAuthorized
19
+ end
20
+
21
+ private
22
+
23
+ # @param auth_data [Hash] authorization data
24
+ # @return [Boolean] true if user is a super admin
25
+ def super_admin?(auth_data)
26
+ Yes::Core.configuration.super_admin_check.call(auth_data)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Authorization
6
+ # @abstract Read request Cerbos authorizer base class. Subclass and override call method to implement
7
+ # a custom authorizer.
8
+ class ReadRequestCerbosAuthorizer < Yes::Core::Authorization::ReadRequestAuthorizer
9
+ class << self
10
+ include OpenTelemetry::Trackable
11
+ include CerbosClientProvider
12
+
13
+ # Implement this method to authorize a read request.
14
+ # Needs to return true if read request is authorized, otherwise raise NotAuthorized.
15
+ # @param params [Hash] request params to authorize
16
+ # @param auth_data [Hash] authorization data
17
+ # @return [Boolean] true if read request is authorized raises NotAuthorized otherwise
18
+ # @raise [NotAuthorized] if read request is not authorized
19
+ def call(params, auth_data)
20
+ singleton_class.current_span&.add_attributes(
21
+ { params: params.to_json, auth_data: auth_data.to_json }.stringify_keys
22
+ )
23
+ auth_data = auth_data.with_indifferent_access
24
+
25
+ check_authorization_data(params) unless super_admin?(auth_data)
26
+
27
+ decision = authorize(params, auth_data)
28
+ singleton_class.current_span&.add_event('Cerbos Decision', attributes: { 'decision' => decision.to_json })
29
+ return true if decision.allow_all?
30
+
31
+ raise_unauthorized_error!(params, decision)
32
+ end
33
+ otl_trackable :call, OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Cerbos Authorize Read Request')
34
+
35
+ private
36
+
37
+ # @param params [Hash]
38
+ # @param decision [Cerbos::Decision] decision from Cerbos
39
+ # raise [NotAuthorized]
40
+ def raise_unauthorized_error!(params, decision)
41
+ msg = "You don't have access to these #{params[:model]}"
42
+ singleton_class.current_span&.status = ::OpenTelemetry::Trace::Status.error(msg)
43
+
44
+ raise self::NotAuthorized.new(msg, extra: { decision: decision.outputs.map(&:value) })
45
+ end
46
+
47
+ # @param params [Hash] request params to authorize
48
+ # @return [Boolean] true if user is a super admin
49
+ # @raise [NotAuthorized]
50
+ def check_authorization_data(_params)
51
+ raise NotImplementedError, 'You need to implement check_authorization_data'
52
+ end
53
+
54
+ def authorize(...)
55
+ cerbos_client.check_resource(**cerbos_payload(...))
56
+ end
57
+
58
+ # @param params [Hash] request params to authorize
59
+ # @param auth_data [Hash] authorization data
60
+ # @return [Hash] payload for Cerbos check_resource
61
+ def cerbos_payload(params, auth_data)
62
+ {
63
+ principal: principal_data(auth_data),
64
+ resource: resource_data(params),
65
+ actions: actions(params),
66
+ include_metadata: Yes::Core.configuration.cerbos_read_authorizer_include_metadata
67
+ }.deep_symbolize_keys.tap { singleton_class.current_span&.set_attribute('cerbos_payload', _1.to_json) }
68
+ end
69
+
70
+ # @param params [Hash] request params to authorize
71
+ # @return [Hash] resource data for Cerbos check_resource
72
+ def resource_data(params)
73
+ {
74
+ scope:,
75
+ kind: params[:model],
76
+ id: resource_id(params),
77
+ attributes: resource_attributes(params)
78
+ }
79
+ end
80
+
81
+ # @param params [Hash]
82
+ # @return [Hash]
83
+ def resource_attributes(_params)
84
+ {}
85
+ end
86
+
87
+ # @param auth_data [Hash] authorization data
88
+ # @return [Hash] principal data for Cerbos check_resource
89
+ def principal_data(auth_data)
90
+ Yes::Core.configuration.cerbos_read_principal_data_builder.call(auth_data)
91
+ end
92
+
93
+ # @return [String] scope for Cerbos check_resource
94
+ def scope
95
+ Rails.application.class.module_parent_name.underscore
96
+ end
97
+
98
+ # @param params [Hash] request params to authorize
99
+ def actions(_params)
100
+ Yes::Core.configuration.cerbos_read_authorizer_actions
101
+ end
102
+
103
+ # @param params [Hash] request params to authorize
104
+ # @return [String] resource id for Cerbos check_resource
105
+ def resource_id(params)
106
+ "#{Yes::Core.configuration.cerbos_read_authorizer_resource_id_prefix}#{params[:model]}"
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ # Base command class for all commands in the system.
6
+ # Inherits from Dry::Struct for type-safe attribute definitions.
7
+ class Command < Dry::Struct
8
+ # Raised when a command fails validation
9
+ class Invalid < Error; end
10
+
11
+ RESERVED_KEYS = %i[transaction origin batch_id command_id metadata es_encrypted].freeze
12
+
13
+ attribute? :transaction, Types.Instance(TransactionDetails).optional
14
+ attribute? :origin, Types::String.optional
15
+ attribute? :batch_id, Types::String.optional
16
+ attribute? :metadata, Types::Hash.optional
17
+ attribute(:command_id, Types::UUID.default { SecureRandom.uuid })
18
+
19
+ # @param attributes [Hash] constructor parameters
20
+ # @raise [Invalid] if the parameters are invalid
21
+ def self.new(attributes)
22
+ super
23
+ rescue Dry::Struct::Error => e
24
+ raise Invalid.new(extra: attributes), e
25
+ end
26
+
27
+ # Returns the command payload excluding reserved keys.
28
+ #
29
+ # @return [Hash] command payload as a hash
30
+ def payload
31
+ to_h.except(*RESERVED_KEYS)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module CommandHandling
6
+ # Tracks external aggregate access during command handling
7
+ class AggregateTracker
8
+ # @return [Array<Hash>] List of accessed external aggregates with their revisions
9
+ attr_reader :accessed_external_aggregates
10
+
11
+ def initialize
12
+ @accessed_external_aggregates = []
13
+ end
14
+
15
+ # Tracks an external aggregate access
16
+ #
17
+ # @param attribute_name [Symbol] The attribute name
18
+ # @param id [String] The aggregate ID
19
+ # @param revision [Integer] The aggregate revision
20
+ # @param context [String] The context name
21
+ # @return [void]
22
+ def track(attribute_name:, id:, revision:, context:)
23
+ accessed_external_aggregates << {
24
+ id:,
25
+ context:,
26
+ name: attribute_name.to_s.camelize,
27
+ revision:
28
+ }
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module CommandHandling
6
+ # Raised when multiple processes attempt to update the same aggregate concurrently
7
+ # This error is thrown when the pending_update_since mechanism detects a conflict
8
+ class ConcurrentUpdateError < Yes::Core::Error
9
+ # Initializes a new ConcurrentUpdateError
10
+ #
11
+ # @param aggregate_class [Class] The aggregate class
12
+ # @param aggregate_id [String] The ID of the aggregate being updated
13
+ # @param original_error [Exception] The underlying database error
14
+ def initialize(aggregate_class:, aggregate_id:, original_error: nil)
15
+ message = build_error_message(aggregate_class, aggregate_id, original_error)
16
+
17
+ super(message, extra: {
18
+ aggregate_class: aggregate_class.name,
19
+ aggregate_id: aggregate_id,
20
+ context: aggregate_class.context,
21
+ stream_name: aggregate_class.aggregate,
22
+ original_error: original_error&.message
23
+ })
24
+ end
25
+
26
+ private
27
+
28
+ # Builds the error message
29
+ #
30
+ # @param aggregate_class [Class] The aggregate class
31
+ # @param aggregate_id [String] The aggregate ID
32
+ # @param original_error [Exception, nil] The underlying error if any
33
+ # @return [String] The formatted error message
34
+ def build_error_message(aggregate_class, aggregate_id, original_error)
35
+ context = aggregate_class.context
36
+ stream_name = aggregate_class.aggregate
37
+
38
+ base_message = "Concurrent update detected for #{context}::#{stream_name} with ID #{aggregate_id}. " \
39
+ 'Another process is currently updating this aggregate.'
40
+
41
+ if original_error
42
+ "#{base_message} Original error: #{original_error.message}"
43
+ else
44
+ base_message
45
+ end
46
+ end
47
+ end
48
+
49
+ # Executes commands with retry logic and pending state management
50
+ # Handles the core command execution including guard evaluation, event publishing,
51
+ # and error handling with optimistic concurrency control
52
+ #
53
+ # @example
54
+ # executor = CommandExecutor.new(aggregate)
55
+ # response = executor.call(command, guard_evaluator_class)
56
+ #
57
+ class CommandExecutor
58
+ MAX_RETRIES = 10
59
+ INLINE_RECOVERY_RETRY_THRESHOLD = 5
60
+
61
+ # Initializes a new CommandExecutor
62
+ #
63
+ # @param aggregate [Yes::Core::Aggregate] The aggregate instance to execute commands for
64
+ def initialize(aggregate)
65
+ @aggregate = aggregate
66
+ @read_model = aggregate.read_model if aggregate.class.read_model_enabled?
67
+ end
68
+
69
+ # Executes a command with retry logic and error handling
70
+ #
71
+ # @param cmd [Yes::Core::Command] The command to execute
72
+ # @param command_name [Symbol] The name of the command being executed
73
+ # @param guard_evaluator_class [Class] The guard evaluator class to process the command
74
+ # @param skip_guards [Boolean] Whether to skip guard evaluation (default: false)
75
+ # @return [Yes::Core::Commands::Response] The command response
76
+ def call(cmd, command_name, guard_evaluator_class, skip_guards: false)
77
+ retries = 0
78
+
79
+ begin
80
+ evaluator = GuardRunner.new(aggregate).call(cmd, command_name, guard_evaluator_class, skip_guards:)
81
+
82
+ set_pending_update_state if aggregate.class.read_model_enabled?
83
+
84
+ begin
85
+ event = EventPublisher.new(
86
+ command: cmd,
87
+ aggregate_data: EventPublisher::AggregateEventPublicationData.from_aggregate(aggregate),
88
+ accessed_external_aggregates: evaluator&.accessed_external_aggregates || []
89
+ ).call
90
+ rescue StandardError => e
91
+ clear_pending_update_state if aggregate.class.read_model_enabled?
92
+ raise e
93
+ end
94
+
95
+ command_response_class(cmd).new(cmd:, event:)
96
+ rescue PgEventstore::WrongExpectedRevisionError => e
97
+ retries += 1
98
+ clear_pending_update_state if aggregate.class.read_model_enabled?
99
+
100
+ retries <= MAX_RETRIES ? retry : raise(e)
101
+ rescue ConcurrentUpdateError => e
102
+ retries += 1
103
+ # Don't clear pending state - another process owns it
104
+ # Sleep with exponential backoff to give the other process time to finish
105
+ sleep([0.01 * (2**(retries - 1)), 1.0].min) if retries <= MAX_RETRIES
106
+
107
+ # After several retries, check if pending state is stuck and attempt recovery
108
+ # This prevents infinite retry loops when a process crashes leaving the flag set
109
+ if aggregate.class.read_model_enabled? && retries >= INLINE_RECOVERY_RETRY_THRESHOLD
110
+ ReadModelRecoveryService.attempt_inline_recovery(read_model, aggregate: aggregate)
111
+ read_model.reload
112
+ end
113
+
114
+ retries <= MAX_RETRIES ? retry : raise(e)
115
+ rescue GuardEvaluator::InvalidTransition,
116
+ GuardEvaluator::NoChangeTransition,
117
+ Yes::Core::Command::Invalid => e
118
+ command_response_class(cmd).new(cmd: cmd, error: e, batch_id: cmd.batch_id)
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ attr_reader :aggregate, :read_model
125
+
126
+ # Sets pending update state on read model
127
+ #
128
+ # @return [void]
129
+ # @raise [ConcurrentUpdateError] If another process is already updating
130
+ def set_pending_update_state
131
+ return unless read_model
132
+
133
+ begin
134
+ ActiveRecord::Base.transaction(requires_new: true) do
135
+ read_model.update_column(:pending_update_since, Time.current)
136
+ end
137
+ rescue ActiveRecord::StatementInvalid => e
138
+ raise e unless e.message.include?('Concurrent pending update not allowed')
139
+
140
+ raise ConcurrentUpdateError.new(
141
+ aggregate_class: aggregate.class,
142
+ aggregate_id: read_model.id,
143
+ original_error: e
144
+ )
145
+ end
146
+ end
147
+
148
+ # Clears pending update state on read model
149
+ #
150
+ # @return [void]
151
+ def clear_pending_update_state
152
+ return unless read_model
153
+
154
+ read_model.update_column(:pending_update_since, nil)
155
+ end
156
+
157
+ # Determines the appropriate response class for the command
158
+ #
159
+ # @param cmd [Yes::Core::Command] The command
160
+ # @return [Class] GroupResponse or Response class
161
+ def command_response_class(cmd)
162
+ if cmd.is_a?(Yes::Core::Commands::Group)
163
+ Yes::Core::Commands::Stateless::GroupResponse
164
+ else
165
+ Yes::Core::Commands::Response
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module CommandHandling
6
+ # Handles the complete command execution flow for aggregates
7
+ # This class orchestrates command preparation, execution, and read model updates
8
+ #
9
+ # @example
10
+ # handler = CommandHandler.new(aggregate)
11
+ # response = handler.call(:approve_documents, { document_ids: '123', another: 'value' })
12
+ #
13
+ class CommandHandler
14
+ include Yes::Core::OpenTelemetry::Trackable
15
+
16
+ # Initializes a new CommandHandler
17
+ #
18
+ # @param aggregate [Yes::Core::Aggregate] The aggregate instance to handle commands for
19
+ def initialize(aggregate)
20
+ @aggregate = aggregate
21
+ @command_utilities = aggregate.send(:command_utilities)
22
+ @read_model = aggregate.read_model if aggregate.class.read_model_enabled?
23
+ end
24
+
25
+ # Executes a command and updates the aggregate state
26
+ #
27
+ # @param command_name [Symbol] The name of the command to execute
28
+ # @param payload [Hash] The command payload
29
+ # @param guards [Boolean] Whether to evaluate guards (default: true)
30
+ # @param metadata [Hash] Optional custom metadata to add to the event
31
+ # @return [Yes::Core::Commands::Response] The command response
32
+ def call(command_name, payload, guards: true, metadata: nil)
33
+ prepared_payload = prepare_payload(command_name, payload, metadata)
34
+ cmd = command_utilities.build_command(command_name, prepared_payload)
35
+
36
+ guard_evaluator_class = command_utilities.fetch_guard_evaluator_class(command_name)
37
+
38
+ ReadModelRecoveryService.check_and_recover_with_retries(read_model, aggregate:) if aggregate.class.read_model_enabled?
39
+
40
+ response = CommandExecutor.new(aggregate).
41
+ call(cmd, command_name, guard_evaluator_class, skip_guards: !guards)
42
+
43
+ ReadModelUpdater.new(aggregate).call(response.event, prepared_payload, command_name) if aggregate.class.read_model_enabled? && response.success?
44
+
45
+ response
46
+ end
47
+ otl_trackable :call,
48
+ Yes::Core::OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Execute command')
49
+
50
+ private
51
+
52
+ attr_reader :aggregate, :command_utilities, :read_model
53
+
54
+ # Prepares the command payload
55
+ #
56
+ # @param command_name [Symbol] The command name
57
+ # @param payload [Hash] The raw payload
58
+ # @param metadata [Hash, nil] Optional custom metadata
59
+ # @return [Hash] The prepared payload
60
+ def prepare_payload(command_name, payload, metadata = nil)
61
+ prepared = command_utilities.prepare_default_payload(
62
+ command_name,
63
+ payload,
64
+ aggregate.class
65
+ )
66
+ prepared = command_utilities.prepare_command_payload(
67
+ command_name,
68
+ prepared,
69
+ aggregate.class
70
+ )
71
+ prepared = command_utilities.prepare_assign_command_payload(
72
+ command_name,
73
+ prepared
74
+ )
75
+
76
+ add_console_origin(prepared)
77
+ add_draft_metadata(prepared) if aggregate.draft?
78
+ add_otl_metadata(prepared)
79
+ add_custom_metadata(prepared, metadata)
80
+
81
+ prepared
82
+ end
83
+
84
+ # Adds console origin to payload when not already present and running in Rails console
85
+ #
86
+ # @param payload [Hash] The payload to modify
87
+ # @return [void]
88
+ def add_console_origin(payload)
89
+ return if payload[:origin].present?
90
+
91
+ console_origin = Utils::CallerUtils.console_origin
92
+ payload[:origin] = console_origin if console_origin
93
+ end
94
+
95
+ # Adds draft metadata to payload if aggregate is draft
96
+ #
97
+ # @param payload [Hash] The payload to modify
98
+ # @return [void]
99
+ def add_draft_metadata(payload)
100
+ payload[:metadata] ||= {}
101
+ payload[:metadata][:draft] = true
102
+ end
103
+
104
+ def add_otl_metadata(payload)
105
+ return if payload.dig(:metadata, :otl_contexts).blank?
106
+
107
+ payload[:metadata][:otl_contexts][:timestamps][:command_handling_started_at_ms] = (Time.now.utc.to_f * 1000).to_i
108
+ end
109
+
110
+ # Adds custom metadata to payload
111
+ #
112
+ # @param payload [Hash] The payload to modify
113
+ # @param metadata [Hash, nil] The custom metadata to add
114
+ # @return [void]
115
+ def add_custom_metadata(payload, metadata)
116
+ return if metadata.blank?
117
+
118
+ payload[:metadata] ||= {}
119
+ payload[:metadata].merge!(metadata)
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end