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,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module CommandHandling
6
+ # Handles publishing events with revision checks
7
+ class EventPublisher
8
+ include Yes::Core::OpenTelemetry::Trackable
9
+
10
+ # Value object containing aggregate data needed for event publication
11
+ AggregateEventPublicationData = Struct.new(:id, :context, :name, :revision, keyword_init: true) do
12
+ def self.from_aggregate(aggregate)
13
+ new(
14
+ id: aggregate.id,
15
+ context: aggregate.class.context,
16
+ name: aggregate.class.name.split('::')[1],
17
+ revision: lambda {
18
+ if aggregate.class.read_model_enabled?
19
+ aggregate.reload.revision
20
+ else
21
+ # When read models are disabled, get revision directly from event stream
22
+ begin
23
+ latest = aggregate.latest_event
24
+ latest ? latest.stream_revision : -1
25
+ rescue PgEventstore::StreamNotFoundError
26
+ # Stream doesn't exist yet - this is the first event
27
+ -1
28
+ end
29
+ end
30
+ }
31
+ )
32
+ end
33
+ end
34
+
35
+ # @param command [Object] The command instance
36
+ # @param aggregate_data [AggregateEventPublicationData] The aggregate publication data
37
+ # @param accessed_external_aggregates [Array<Hash>] List of accessed external aggregates with their revisions
38
+ # @param event_name [String, nil] Optional explicit event name to use
39
+ def initialize(command:, aggregate_data:, accessed_external_aggregates:, event_name: nil)
40
+ @command = command
41
+ @aggregate_data = aggregate_data
42
+ @accessed_external_aggregates = accessed_external_aggregates
43
+ @event_name = event_name
44
+ @command_utilities = Utils::CommandUtils.new(
45
+ context: aggregate_data.context,
46
+ aggregate: aggregate_data.name,
47
+ aggregate_id: aggregate_data.id
48
+ )
49
+ end
50
+
51
+ # Publishes the event after verifying revisions
52
+ #
53
+ # @return [PgEventstore::Event] The published event
54
+ # @raise [PgEventstore::WrongExpectedRevisionError] When revisions don't match
55
+ def call
56
+ verify_external_revisions!
57
+ publish_event
58
+ end
59
+
60
+ otl_trackable(
61
+ :call,
62
+ Yes::Core::OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Publish Event', span_kind: :producer, track_sql: true)
63
+ )
64
+
65
+ private
66
+
67
+ # @return [Object] The command instance
68
+ attr_reader :command
69
+ # @return [AggregateEventPublicationData] The aggregate publication data
70
+ attr_reader :aggregate_data
71
+ # @return [Array<Hash>] List of accessed external aggregates with their revisions
72
+ attr_reader :accessed_external_aggregates
73
+ # @return [String, nil] The explicit event name to use
74
+ attr_reader :event_name
75
+ # @return [CommandUtils] The command utilities instance
76
+ attr_reader :command_utilities
77
+
78
+ delegate :payload, :origin, :batch_id, :metadata, to: :command
79
+
80
+ # Publishes the event to the event store
81
+ #
82
+ # @return [PgEventstore::Event] The published event
83
+ def publish_event
84
+ revision = aggregate_data.revision.call
85
+ expected_revision = revision == -1 ? :no_stream : revision
86
+
87
+ event = event_with_metadata
88
+ otl_record_event_data(event)
89
+
90
+ PgEventstore.client.append_to_stream(
91
+ command_utilities.build_stream(metadata:),
92
+ event,
93
+ options: { expected_revision: }
94
+ ).tap { otl_record_response(_1) }
95
+ end
96
+
97
+ # Verifies revisions of all accessed external aggregates
98
+ #
99
+ # @return [void]
100
+ # @raise [PgEventstore::WrongExpectedRevisionError] When revisions don't match
101
+ def verify_external_revisions!
102
+ accessed_external_aggregates.each do |aggregate_data|
103
+ stream = command_utilities.build_stream(
104
+ context: aggregate_data[:context],
105
+ name: aggregate_data[:name],
106
+ id: aggregate_data[:id]
107
+ )
108
+ expected_revision = command_utilities.stream_revision(stream)
109
+ aggregate_revision = aggregate_data[:revision].call
110
+ normalized_revision = aggregate_revision == -1 ? :no_stream : aggregate_revision
111
+
112
+ next if normalized_revision == expected_revision
113
+
114
+ raise PgEventstore::WrongExpectedRevisionError.new(
115
+ revision: aggregate_revision,
116
+ expected_revision:,
117
+ stream:
118
+ )
119
+ end
120
+ end
121
+
122
+ # Builds an event with metadata from the command
123
+ #
124
+ # @return [PgEventstore::Event] The event with metadata
125
+ def event_with_metadata
126
+ command_utilities.build_event(
127
+ command_name: command.class.name.split('::')[-2].underscore.to_sym,
128
+ payload:,
129
+ metadata: event_metadata
130
+ )
131
+ end
132
+
133
+ # Builds the event metadata
134
+ #
135
+ # @return [Hash] The event metadata
136
+ def event_metadata
137
+ meta = {}
138
+ meta['origin'] = origin if origin.present?
139
+ meta['batch_id'] = batch_id if batch_id.present?
140
+ meta['yes-dsl'] = true
141
+ meta.merge!(metadata) if metadata.present?
142
+
143
+ meta[:otl_contexts][:publisher] = self.class.propagate_context(service_name: true) if meta[:otl_contexts].present?
144
+
145
+ meta
146
+ end
147
+
148
+ def otl_record_event_data(event)
149
+ self.class.current_span&.add_attributes(
150
+ {
151
+ 'event.type' => event.type,
152
+ 'event.data' => event.data.to_json,
153
+ 'event.metadata' => event.metadata.to_json
154
+ }
155
+ )
156
+ end
157
+
158
+ def otl_record_response(result)
159
+ if ENV['STATSD_ADDR'].present?
160
+ StatsD.increment(
161
+ 'events_processing_total',
162
+ tags: {
163
+ service: Rails.application.class.module_parent.name,
164
+ source: "#{Rails.application.class.module_parent.name}-#{result.type}",
165
+ target: "#{Rails.application.class.module_parent.name}-#{result.type}",
166
+ type: 'producer',
167
+ event: result.type
168
+ }
169
+ )
170
+ end
171
+
172
+ self.class.current_span&.status = ::OpenTelemetry::Trace::Status.ok
173
+ self.class.current_span&.add_event(
174
+ 'Event Published to PgEventstore',
175
+ timestamp: result.created_at,
176
+ attributes: {
177
+ 'event.type' => result.type,
178
+ 'event.link_id' => result.link_id || '',
179
+ 'global_position' => result.global_position,
180
+ 'stream' => result.stream.to_json,
181
+ 'stream.revision' => result.stream_revision,
182
+ 'timestamp_ms' => (result.created_at.to_f * 1000).to_i
183
+ }
184
+ )
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module CommandHandling
6
+ # Base class for evaluating guards on command attributes
7
+ class GuardEvaluator
8
+ class TransitionError < Yes::Core::Error; end
9
+ class InvalidTransition < TransitionError; end
10
+ class NoChangeTransition < TransitionError; end
11
+
12
+ class << self
13
+ # @return [Hash<Symbol, Proc>] Hash of registered guards with names as keys and blocks as values
14
+ def guards
15
+ @guards ||= {}
16
+ end
17
+
18
+ # Defines a new guard with a name and evaluation block
19
+ #
20
+ # @param name [Symbol] Name of the guard
21
+ # @param error_extra [Hash, Proc] The extra information to be added to the error message payload
22
+ # @yield Block to evaluate the guard condition
23
+ # @yieldreturn [Boolean] True if the guard passes, false otherwise
24
+ # @return [void]
25
+ def guard(name, error_extra: {}, &block)
26
+ guards[name] = { block:, error_extra: }
27
+ end
28
+ end
29
+
30
+ # @param payload [Hash] The command payload
31
+ # @param metadata [Hash] The command metadata
32
+ # @param aggregate [Yes::Core::Aggregate] The aggregate instance
33
+ # @param command_name [Symbol] The command name
34
+ def initialize(payload:, metadata:, aggregate:, command_name:)
35
+ @raw_payload = payload
36
+ @raw_metadata = metadata
37
+ @aggregate = aggregate
38
+ @aggregate_tracker = AggregateTracker.new
39
+ @command_name = command_name
40
+ @payload = PayloadProxy.new(
41
+ raw_payload:,
42
+ raw_metadata:,
43
+ context: aggregate.class.context,
44
+ aggregate_tracker:,
45
+ parent_aggregates: aggregate.class.parent_aggregates
46
+ )
47
+ end
48
+
49
+ # Evaluates all registered guards
50
+ #
51
+ # @return [void]
52
+ # @raise [InvalidTransition] When a guard fails with an invalid transition
53
+ # @raise [NoChangeTransition] When a guard fails with a no change transition
54
+ def call
55
+ self.class.guards.each do |name, guard_data|
56
+ evaluate_guard(name, error_extra: guard_data[:error_extra], block: guard_data[:block])
57
+ end
58
+ end
59
+
60
+ # @return [Array<Hash>] List of accessed external aggregates with their revisions
61
+ delegate :accessed_external_aggregates, to: :aggregate_tracker
62
+
63
+ private
64
+
65
+ attr_reader :raw_payload, :raw_metadata, :payload, :aggregate, :aggregate_tracker, :command_name
66
+
67
+ # Evaluates a single guard and raises appropriate error if it fails
68
+ #
69
+ # @param name [Symbol] The name of the guard
70
+ # @param error_extra [Hash, Proc] The extra information to be added to the error message payload
71
+ # @param block [Proc] The guard block to evaluate
72
+ # @return [void]
73
+ # @raise [InvalidTransition] When the guard fails with an invalid transition
74
+ # @raise [NoChangeTransition] When the guard fails with a no change transition
75
+ def evaluate_guard(name, block:, error_extra: {})
76
+ result = evaluate_with_locale(&block)
77
+ return if result
78
+
79
+ extra = error_extra.respond_to?(:call) ? evaluate_with_locale(&error_extra) : error_extra
80
+
81
+ error_class = name == :no_change ? NoChangeTransition : InvalidTransition
82
+ raise error_class.new(error_message(name), extra:)
83
+ end
84
+
85
+ def value_changed?(val1, val2)
86
+ return val1 != val2 unless val1.is_a?(Hash) && val2.is_a?(Hash)
87
+
88
+ val1.with_indifferent_access != val2.with_indifferent_access
89
+ end
90
+
91
+ # Looks up the error message for a guard from I18n translations
92
+ #
93
+ # @param guard_name [Symbol] The name of the guard
94
+ # @return [String] The error message
95
+ def error_message(guard_name)
96
+ context_name = aggregate.class.context
97
+ aggregate_name = aggregate.class.aggregate
98
+
99
+ Yes::Core::ErrorMessages.guard_error(context_name, aggregate_name, command_name.to_s, guard_name)
100
+ end
101
+
102
+ # Handles method missing to delegate attribute calls to the current aggregate
103
+ #
104
+ # @param method_name [Symbol] The method name being called
105
+ # @yield Optional block passed to the method (unused)
106
+ # @yieldreturn [void]
107
+ # @return [Object] The result of calling the method on the current aggregate
108
+ def method_missing(method_name, *, &)
109
+ if aggregate.respond_to?(method_name)
110
+ result = aggregate.public_send(method_name, *, &)
111
+ track_external_aggregate(method_name, result) if aggregate_attribute?(method_name)
112
+ result
113
+ else
114
+ super
115
+ end
116
+ end
117
+
118
+ # Checks if method is defined on the current aggregate
119
+ #
120
+ # @param method_name [Symbol] The method name to check
121
+ # @param include_private [Boolean] Whether to include private methods
122
+ # @return [Boolean] True if method exists
123
+ def respond_to_missing?(method_name, include_private = false)
124
+ aggregate.respond_to?(method_name, include_private) || super
125
+ end
126
+
127
+ # Checks if the given method is an aggregate attribute
128
+ #
129
+ # @param method_name [Symbol] The method name to check
130
+ # @return [Boolean] True if the method is an aggregate attribute
131
+ def aggregate_attribute?(method_name)
132
+ aggregate.class.attributes[method_name] == :aggregate
133
+ end
134
+
135
+ # Tracks an external aggregate access
136
+ #
137
+ # @param attribute_name [Symbol] The attribute name
138
+ # @param instance [Object] The aggregate instance
139
+ # @return [void]
140
+ def track_external_aggregate(attribute_name, instance)
141
+ return unless instance
142
+
143
+ aggregate_tracker.track(
144
+ attribute_name: attribute_name.to_s.camelize,
145
+ id: instance.id,
146
+ revision: -> { instance.reload.revision },
147
+ context: aggregate.class.context
148
+ )
149
+ end
150
+
151
+ # Evaluates a block with the locale from payload if present
152
+ #
153
+ # @yield Block to be evaluated
154
+ # @return [Object] Result of block evaluation
155
+ def evaluate_with_locale(&block)
156
+ if raw_payload[:locale].present?
157
+ I18n.with_locale(raw_payload[:locale]) { instance_eval(&block) }
158
+ else
159
+ instance_eval(&block)
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module CommandHandling
6
+ # Evaluates guards for commands and manages command-specific errors on aggregates
7
+ # Handles the decision of whether to skip guards and properly sets/clears error states
8
+ #
9
+ # @example
10
+ # runner = GuardRunner.new(aggregate)
11
+ # evaluator = runner.call(cmd, command_name, guard_evaluator_class, skip_guards: false)
12
+ #
13
+ class GuardRunner
14
+ include Yes::Core::OpenTelemetry::Trackable
15
+
16
+ # Initializes a new GuardRunner
17
+ #
18
+ # @param aggregate [Yes::Core::Aggregate] The aggregate instance for error management
19
+ def initialize(aggregate)
20
+ @aggregate = aggregate
21
+ end
22
+
23
+ # Evaluates guards for the command
24
+ #
25
+ # @param cmd [Yes::Core::Command] The command to be handled
26
+ # @param command_name [Symbol] The name of the command being executed
27
+ # @param guard_evaluator_class [Class] The guard evaluator class
28
+ # @param skip_guards [Boolean] Whether to skip guard evaluation
29
+ # @return [GuardEvaluator, nil] The guard evaluator instance or nil if guards skipped
30
+ # @raise [GuardEvaluator::InvalidTransition] When the transition is invalid
31
+ # @raise [GuardEvaluator::NoChangeTransition] When no change would occur
32
+ # @raise [Yes::Core::Command::Invalid] When the command is invalid
33
+ def call(cmd, command_name, guard_evaluator_class, skip_guards:)
34
+ if skip_guards
35
+ clear_command_error(command_name)
36
+ return nil
37
+ end
38
+
39
+ evaluator = guard_evaluator_class.new(
40
+ payload: cmd.payload,
41
+ metadata: cmd.metadata,
42
+ aggregate: aggregate,
43
+ command_name: command_name
44
+ )
45
+ evaluator.call
46
+
47
+ clear_command_error(command_name)
48
+
49
+ evaluator
50
+ rescue GuardEvaluator::InvalidTransition,
51
+ GuardEvaluator::NoChangeTransition,
52
+ Yes::Core::Command::Invalid => e
53
+ aggregate.send(:"#{command_name.to_s.underscore}_error=", e.message)
54
+ raise e
55
+ end
56
+
57
+ otl_trackable(
58
+ :call,
59
+ Yes::Core::OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Evaluate guards')
60
+ )
61
+
62
+ private
63
+
64
+ attr_reader :aggregate
65
+
66
+ # Clears command-specific error on aggregate
67
+ #
68
+ # @param command_name [Symbol, String] The command name
69
+ # @return [void]
70
+ def clear_command_error(command_name)
71
+ aggregate.send(:"#{command_name.to_s.underscore}_error=", nil)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module CommandHandling
6
+ # Provides proxy access to command payload with dynamic aggregate resolution
7
+ class PayloadProxy
8
+ # @param raw_payload [Hash] The raw command payload
9
+ # @param raw_metadata [Hash, nil] The raw command metadata (optional)
10
+ # @param context [String] The context name
11
+ # @param aggregate_tracker [AggregateTracker, nil] The tracker instance (optional)
12
+ def initialize(raw_payload:, context:, parent_aggregates:, raw_metadata: nil, aggregate_tracker: nil)
13
+ @raw_payload = raw_payload
14
+ @raw_metadata = raw_metadata
15
+ @context = context
16
+ @parent_aggregates = parent_aggregates
17
+ @aggregate_tracker = aggregate_tracker
18
+ end
19
+
20
+ # Access payload values by key
21
+ #
22
+ # @param key [Symbol, String] The key to access
23
+ # @return [Object] The value for the given key
24
+ delegate :[], to: :@raw_payload
25
+
26
+ # Access metadata through a proxy object
27
+ #
28
+ # @return [MetadataProxy] The metadata proxy object
29
+ def metadata
30
+ @metadata ||= MetadataProxy.new(@raw_metadata)
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :raw_payload, :raw_metadata, :context, :parent_aggregates, :aggregate_tracker
36
+
37
+ # Handles dynamic method calls to access payload values or resolve aggregates
38
+ #
39
+ # @param method_name [Symbol] The method being called
40
+ # @param args [Array] Method arguments (unused)
41
+ # @yield Optional block passed to the method (unused)
42
+ # @yieldreturn [void]
43
+ # @return [Object] The payload value or resolved aggregate
44
+ def method_missing(method_name, *args, &)
45
+ if raw_payload.key?(method_name)
46
+ raw_payload[method_name]
47
+ elsif raw_payload.key?(:"#{method_name}_id")
48
+ resolve_aggregate(method_name)
49
+ else
50
+ super
51
+ end
52
+ end
53
+
54
+ # Checks if method can be handled
55
+ #
56
+ # @param method_name [Symbol] The method to check
57
+ # @param include_private [Boolean] Whether to include private methods
58
+ # @return [Boolean] True if method can be handled
59
+ def respond_to_missing?(method_name, include_private = false)
60
+ raw_payload.key?(method_name) ||
61
+ raw_payload.key?(:"#{method_name}_id") ||
62
+ super
63
+ end
64
+
65
+ # Resolves an aggregate instance from its ID in the payload
66
+ #
67
+ # @param method_name [Symbol] The method name representing the aggregate
68
+ # @return [Object] The resolved aggregate instance
69
+ def resolve_aggregate(method_name)
70
+ id = raw_payload[:"#{method_name}_id"]
71
+ context = aggregate_context(method_name)
72
+ aggregate_class = "#{context}::#{method_name.to_s.camelize}::Aggregate".constantize
73
+ instance = aggregate_class.new(id)
74
+
75
+ aggregate_tracker&.track(
76
+ attribute_name: method_name,
77
+ id: instance.id,
78
+ revision: -> { instance.reload.revision },
79
+ context:
80
+ )
81
+
82
+ instance
83
+ end
84
+
85
+ def aggregate_context(aggregate_name)
86
+ parent_aggregates.with_indifferent_access.dig(aggregate_name, :context) || context
87
+ end
88
+ end
89
+
90
+ # Provides proxy access to command metadata
91
+ class MetadataProxy
92
+ # @param raw_metadata [Hash] The raw command metadata
93
+ def initialize(raw_metadata)
94
+ @raw_metadata = raw_metadata || {}
95
+ end
96
+
97
+ # Access metadata values by key (hash-style access)
98
+ #
99
+ # @param key [Symbol, String] The key to access
100
+ # @return [Object] The value for the given key
101
+ delegate :[], to: :raw_metadata
102
+
103
+ # Set metadata values by key (hash-style assignment)
104
+ #
105
+ # @param key [Symbol, String] The key to set
106
+ # @param value [Object] The value to set
107
+ # @return [Object] The value that was set
108
+ delegate :[]=, to: :raw_metadata
109
+
110
+ private
111
+
112
+ attr_reader :raw_metadata
113
+
114
+ # Handles dynamic method calls to access or set metadata values
115
+ #
116
+ # @param method_name [Symbol] The method being called
117
+ # @param args [Array] Method arguments
118
+ # @yield Optional block passed to the method (unused)
119
+ # @yieldreturn [void]
120
+ # @return [Object] The metadata value or the value being set
121
+ def method_missing(method_name, *args, &)
122
+ method_str = method_name.to_s
123
+
124
+ # Handle setter methods (e.g., xyz=)
125
+ if method_str.end_with?('=')
126
+ key = method_str.chomp('=').to_sym
127
+ raw_metadata[key] = args.first
128
+ # Handle getter methods
129
+ elsif args.empty?
130
+ if raw_metadata.key?(method_name)
131
+ raw_metadata[method_name]
132
+ elsif raw_metadata.key?(method_str)
133
+ raw_metadata[method_str]
134
+ end
135
+ else
136
+ super
137
+ end
138
+ end
139
+
140
+ # Checks if method can be handled
141
+ #
142
+ # @param method_name [Symbol] The method to check
143
+ # @param include_private [Boolean] Whether to include private methods
144
+ # @return [Boolean] True if method can be handled
145
+ def respond_to_missing?(method_name, include_private = false)
146
+ method_str = method_name.to_s
147
+
148
+ # Respond to setter methods
149
+ return true if method_str.end_with?('=')
150
+
151
+ # Respond to getter methods
152
+ raw_metadata.key?(method_name) ||
153
+ raw_metadata.key?(method_str) ||
154
+ super
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end