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,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Utils
6
+ # Handles command and handler class operations for aggregates
7
+ #
8
+ # @since 0.1.0
9
+ # @api private
10
+ class CommandUtils
11
+ class CommandNotFoundError < Yes::Core::Error; end
12
+
13
+ ASSIGN_COMMAND_PREFIX = 'assign_'
14
+
15
+ # @param context [String] The context namespace
16
+ # @param aggregate [String] The aggregate name
17
+ # @param aggregate_id [String] The ID of the aggregate
18
+ def initialize(context:, aggregate:, aggregate_id:)
19
+ @context = context
20
+ @aggregate = aggregate
21
+ @aggregate_id = aggregate_id
22
+ end
23
+
24
+ # Builds a change command instance for the given attribute and payload
25
+ #
26
+ # @param attribute [Symbol] The attribute name
27
+ # @param payload [Hash] The command payload
28
+ # @return [Yes::Core::Command] The instantiated command
29
+ # @raise [RuntimeError] If the command class cannot be found
30
+ def build_attribute_command(attribute, payload)
31
+ build_command(:"change_#{attribute}", payload)
32
+ end
33
+
34
+ # Builds a command instance for the given command name and payload
35
+ #
36
+ # @param command_name [Symbol] The command name
37
+ # @param payload [Hash] The command payload
38
+ # @return [Yes::Core::Command] The instantiated command
39
+ # @raise [RuntimeError] If the command class cannot be found
40
+ def build_command(command_name, payload)
41
+ command_class = fetch_class(command_name, :command)
42
+ command_class.new("#{aggregate.underscore}_id": aggregate_id, **payload)
43
+ end
44
+
45
+ # Fetches the guard evaluator class for a given command name
46
+ #
47
+ # @param name [Symbol] The command name
48
+ # @return [Class] The guard evaluator class
49
+ # @raise [RuntimeError] If the guard evaluator class cannot be found
50
+ def fetch_guard_evaluator_class(name)
51
+ fetch_class(name, :guard_evaluator)
52
+ end
53
+
54
+ # Fetches the state updater class for a given command name
55
+ #
56
+ # @param name [Symbol] The command name
57
+ # @return [Class] The state updater class
58
+ def fetch_state_updater_class(name)
59
+ fetch_class(name, :state_updater)
60
+ end
61
+
62
+ # Builds a PgEventstore::Event instance
63
+ #
64
+ # @param command_name [Symbol] The command name
65
+ # @param payload [Hash] The event payload
66
+ # @param metadata [Hash] Event metadata
67
+ # @return [PgEventstore::Event] The event instance
68
+ def build_event(command_name:, payload:, metadata: {})
69
+ event_class = Yes::Core.configuration.event_classes_for_command(context, aggregate, command_name).first
70
+ event_class.new(
71
+ type: "#{context}::#{aggregate_name_with_draft_suffix(aggregate, metadata)}#{event_class.name.demodulize}",
72
+ data: payload,
73
+ metadata:
74
+ )
75
+ end
76
+
77
+ # Builds a PgEventstore::Stream instance
78
+ #
79
+ # @param context [String] The context name
80
+ # @param name [String] The stream name
81
+ # @param id [String] The stream ID
82
+ # @return [PgEventstore::Stream] The stream instance
83
+ def build_stream(context: @context, name: @aggregate, id: @aggregate_id, metadata: {})
84
+ PgEventstore::Stream.new(
85
+ context:,
86
+ stream_name: aggregate_name_with_draft_suffix(name, metadata),
87
+ stream_id: id
88
+ )
89
+ end
90
+
91
+ # Gets the current revision of a stream
92
+ #
93
+ # @param stream [PgEventstore::Stream] The stream to check
94
+ # @return [Integer] The current revision
95
+ def stream_revision(stream)
96
+ PgEventstore.client.read(
97
+ stream,
98
+ options: { direction: 'Backwards', max_count: 1 },
99
+ middlewares: []
100
+ ).first&.stream_revision || 0
101
+ rescue PgEventstore::StreamNotFoundError
102
+ :no_stream
103
+ end
104
+
105
+ def prepare_assign_command_payload(command_name, payload)
106
+ return payload unless command_name.to_s.starts_with?(ASSIGN_COMMAND_PREFIX)
107
+
108
+ attribute_name = command_name.to_s.split(ASSIGN_COMMAND_PREFIX).last.to_sym
109
+ name_with_id = :"#{attribute_name}_id"
110
+ key = payload.key?(attribute_name) ? attribute_name : name_with_id
111
+
112
+ return payload unless payload[key].is_a?(Yes::Core::Aggregate)
113
+
114
+ payload[name_with_id] = payload.delete(key).id
115
+ payload
116
+ end
117
+
118
+ # @param event [PgEventstore::Event] The event
119
+ # @param aggregate_class [Class] The aggregate class
120
+ # @return [Symbol] The command name
121
+ # @raise [CommandNotFoundError] If the command is not found
122
+ def command_name_from_event(event, aggregate_class)
123
+ event_name = event.type.split('::').last.sub(event.stream.stream_name.sub(/(Draft|EditTemplate)/, ''), '').underscore
124
+ command = aggregate_class.commands.values.find { _1.event_name.to_s == event_name }
125
+ raise CommandNotFoundError, "Command not found for event #{event_name}" unless command
126
+
127
+ command.name
128
+ end
129
+
130
+ # Prepares the payload for a command
131
+ #
132
+ # @param command_name [Symbol] The command name
133
+ # @param payload [Hash] The command payload
134
+ # @param aggregate_class [Class] The aggregate class
135
+ # @return [Hash] The prepared payload
136
+ def prepare_command_payload(command_name, payload, aggregate_class)
137
+ return append_locale_param(command_name, payload, aggregate_class) if payload.is_a?(Hash)
138
+
139
+ payload_attributes = aggregate_class.commands[command_name].payload_attributes.except(:locale)
140
+ raise 'Payload attributes must be a Hash with a single key (not including locale key)' if payload_attributes.length > 1
141
+
142
+ append_locale_param(command_name, { payload_attributes.keys.first => payload }, aggregate_class)
143
+ end
144
+
145
+ # Prefills default values in the payload if they are not provided
146
+ #
147
+ # @param command_name [Symbol] The command name
148
+ # @param payload [Hash] The command payload
149
+ # @param aggregate_class [Class] The aggregate class
150
+ # @return [Hash] The prepared payload
151
+ def prepare_default_payload(command_name, payload, aggregate_class)
152
+ return payload unless payload.is_a?(Hash)
153
+
154
+ attributes_with_defaults = aggregate_class.commands[command_name].payload_attributes.select do |_key, value|
155
+ value.is_a?(Hash) && value.key?(:default)
156
+ end
157
+
158
+ return payload unless attributes_with_defaults.any?
159
+
160
+ additions = {}
161
+ attributes_with_defaults.each do |key, value|
162
+ next if payload.key?(key)
163
+
164
+ default = value[:default]
165
+ additions[key] = default.respond_to?(:call) ? default.call : default
166
+ end
167
+ payload.merge(additions)
168
+ end
169
+
170
+ private
171
+
172
+ attr_reader :context, :aggregate, :aggregate_id
173
+
174
+ # Fetches a class based on the command name and type
175
+ #
176
+ # @param command [Symbol] The command name
177
+ # @param type [Symbol] The type of class to fetch (:command or :guard_evaluator)
178
+ # @return [Class] The requested class
179
+ # @raise [RuntimeError] If the requested class cannot be found
180
+ def fetch_class(command, type)
181
+ klass = Yes::Core.configuration.aggregate_class(context, aggregate, command, type)
182
+ raise "#{type.to_s.tr('_', ' ').capitalize} class not found for #{command}" unless klass
183
+
184
+ klass
185
+ end
186
+
187
+ # Removes the '_id' suffix from an attribute name
188
+ #
189
+ # @param attribute [Symbol, String] The attribute name that might contain an '_id' suffix
190
+ # @return [String] The attribute name without the '_id' suffix
191
+ # @example
192
+ # command_name(:user_id) # => "user"
193
+ # command_name("company_id") # => "company"
194
+ # command_name(:name) # => "name"
195
+ def command_name(attribute)
196
+ attribute.to_s.sub('_id', '')
197
+ end
198
+
199
+ # Adds locale param to payload if required and not present
200
+ #
201
+ # @param command_name [Symbol] The command name
202
+ # @param payload [Hash] The command payload
203
+ # @param aggregate_class [Class] The aggregate class
204
+ # @return [Hash] The prepared payload
205
+ def append_locale_param(command_name, payload, aggregate_class)
206
+ return payload if payload.key?(:locale)
207
+ return payload unless aggregate_class.commands[command_name].payload_attributes.key?(:locale)
208
+
209
+ payload.merge(locale: I18n.locale.to_s)
210
+ end
211
+
212
+ # Builds the aggregate name with the draft suffix
213
+ #
214
+ # @param aggregate_name [String] The name of the aggregate
215
+ # @param metadata [Hash] The command metadata
216
+ # @return [String] The stream name
217
+ def aggregate_name_with_draft_suffix(aggregate_name, metadata = {})
218
+ return "#{aggregate_name}Draft" if metadata&.dig(:draft)
219
+ return "#{aggregate_name}EditTemplate" if metadata&.dig(:edit_template_command)
220
+
221
+ aggregate_name
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Utils
6
+ # Notifies about errors via a pluggable error reporter.
7
+ #
8
+ # By default, errors are logged via Rails.logger. To integrate with an external
9
+ # error tracking service (e.g. Sentry), configure the error reporter:
10
+ #
11
+ # Yes::Core.configure do |config|
12
+ # config.error_reporter = ->(error, context:) { Sentry.capture_exception(error, extra: context) }
13
+ # end
14
+ #
15
+ # The error_reporter must respond to #call(error, context:).
16
+ class ErrorNotifier
17
+ # @param event [PgEventstore::Event]
18
+ # @return [void]
19
+ def invalid_event_data(event)
20
+ msg = 'Event with invalid data found in stream'
21
+ data = { event: event.to_json }
22
+
23
+ logger&.info("#{msg} data: #{data}")
24
+ capture_message(msg, extra: data)
25
+ end
26
+
27
+ # @param error [Error]
28
+ # @return [void]
29
+ def payload_extraction_failed(error)
30
+ msg = 'Large payload extraction failed'
31
+
32
+ logger&.info("#{msg} data: #{error.extra}")
33
+ capture_message(msg, extra: error.extra)
34
+ end
35
+
36
+ # @param error [Error]
37
+ # @return [void]
38
+ def decryption_key_error(error)
39
+ data = {
40
+ encryptor_response: error.encryptor_response,
41
+ event: error.event
42
+ }
43
+
44
+ logger&.info("#{error.message} data: #{data}")
45
+ capture_message(error.message, extra: data)
46
+ end
47
+
48
+ # @param error [Error]
49
+ # @return [void]
50
+ def decryption_error(error)
51
+ logger&.info("#{error.message} data: #{error.extra}")
52
+ capture_message(error.message, extra: error.extra)
53
+ end
54
+
55
+ # @param message [String]
56
+ # @param event [PgEventstore::Event]
57
+ # @return [void]
58
+ def event_handler_not_defined(message, event)
59
+ data = { event: event.to_json }
60
+
61
+ logger&.info("#{message} data: #{data}")
62
+ capture_message(message, extra: data) if ENV['CAPTURE_EVENTSOURCING_ERRORS'] == 'true'
63
+ end
64
+
65
+ # @return [void]
66
+ def missing_payload_store_client_error
67
+ msg = 'Missing PayloadStore Client. Please configure it.'
68
+
69
+ logger&.info(msg)
70
+ capture_message(msg)
71
+ end
72
+
73
+ # @param error [Exception]
74
+ # @param extra [Hash, nil]
75
+ # @return [void]
76
+ def notify(error, extra: nil)
77
+ error_reporter&.call(error, context: extra || {})
78
+ end
79
+
80
+ private
81
+
82
+ attr_reader :error_reporter, :logger
83
+
84
+ def initialize(logger: Yes::Core.configuration.logger)
85
+ @error_reporter = Yes::Core.configuration.error_reporter
86
+ @logger = logger
87
+ end
88
+
89
+ # @param msg [String]
90
+ # @param options [Hash]
91
+ # @return [void]
92
+ def capture_message(msg, options = {})
93
+ return unless error_reporter
94
+
95
+ error = StandardError.new(msg)
96
+ error_reporter.call(error, context: options.fetch(:extra, {}))
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Utils
6
+ # Resolves event names from command names by converting command verbs to their past tense form
7
+ # @example
8
+ # EventNameResolver.call('ChangeLocation') # => :location_changed
9
+ # EventNameResolver.call('AddUser') # => :user_added
10
+ class EventNameResolver
11
+ # @return [Hash<String, String>] Mapping of command verbs to their corresponding event (past tense) forms
12
+ COMMAND_TO_EVENT_VERBS = {
13
+ 'Activate' => 'Activated',
14
+ 'Add' => 'Added',
15
+ 'Approve' => 'Approved',
16
+ 'Archive' => 'Archived',
17
+ 'Assign' => 'Assigned',
18
+ 'Cancel' => 'Cancelled',
19
+ 'Change' => 'Changed',
20
+ 'Close' => 'Closed',
21
+ 'Complete' => 'Completed',
22
+ 'Confirm' => 'Confirmed',
23
+ 'Deactivate' => 'Deactivated',
24
+ 'Delete' => 'Deleted',
25
+ 'Disable' => 'Disabled',
26
+ 'Enable' => 'Enabled',
27
+ 'Fail' => 'Failed',
28
+ 'Open' => 'Opened',
29
+ 'Publish' => 'Published',
30
+ 'Reactivate' => 'Reactivated',
31
+ 'Reject' => 'Rejected',
32
+ 'Remove' => 'Removed',
33
+ 'Reopen' => 'Reopened',
34
+ 'Resolve' => 'Resolved',
35
+ 'Restore' => 'Restored',
36
+ 'Start' => 'Started',
37
+ 'Stop' => 'Stopped',
38
+ 'Submit' => 'Submitted',
39
+ 'Unassign' => 'Unassigned',
40
+ 'Unpublish' => 'Unpublished',
41
+ 'Update' => 'Updated'
42
+ }.freeze
43
+
44
+ # Converts a command name to its corresponding event name
45
+ # @param command_name [String, Symbol] The name of the command to convert
46
+ # @return [Symbol, nil] The converted event name as an underscored symbol, or nil if no conversion is possible
47
+ # @example
48
+ # EventNameResolver.call('ChangeLocation') # => :location_changed
49
+ # EventNameResolver.call(:add_user) # => :user_added
50
+ # EventNameResolver.call('InvalidCommand') # => nil
51
+ def self.call(command_name)
52
+ normalized_command_name = command_name.to_s.camelize
53
+ COMMAND_TO_EVENT_VERBS.each do |command_verb, event_verb|
54
+ next unless normalized_command_name.start_with?(command_verb)
55
+
56
+ # Extract the subject (e.g. "Location" from "ChangeLocation")
57
+ subject = normalized_command_name.delete_prefix(command_verb)
58
+ # Return subject + verb (e.g. "LocationChanged")
59
+ return "#{subject}#{event_verb}".underscore.to_sym
60
+ end
61
+
62
+ nil
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Utils
6
+ # Generic exponential backoff retry utility
7
+ # Provides configurable retry logic with exponential backoff, jitter, and timeout
8
+ class ExponentialRetrier
9
+ # Default configuration constants
10
+ DEFAULT_MAX_RETRIES = 6
11
+ DEFAULT_BASE_SLEEP_TIME = 0.1
12
+ DEFAULT_MAX_SLEEP_TIME = 5.0
13
+ DEFAULT_JITTER_FACTOR = 0.1
14
+ DEFAULT_TIMEOUT = 30
15
+
16
+ # Error raised when retry fails after maximum attempts
17
+ class RetryFailedError < StandardError; end
18
+
19
+ # Error raised when timeout is exceeded
20
+ class TimeoutError < StandardError; end
21
+
22
+ # Configuration for the retrier
23
+ #
24
+ # @param max_retries [Integer] Maximum number of retry attempts
25
+ # @param base_sleep_time [Float] Base sleep time in seconds for exponential backoff
26
+ # @param max_sleep_time [Float] Maximum sleep time to prevent excessive waiting
27
+ # @param jitter_factor [Float] Jitter factor for randomizing sleep times
28
+ # @param timeout [Integer] Maximum time to wait before timing out
29
+ # @param logger [Object] Logger instance for retry information
30
+ def initialize(
31
+ max_retries: DEFAULT_MAX_RETRIES,
32
+ base_sleep_time: DEFAULT_BASE_SLEEP_TIME,
33
+ max_sleep_time: DEFAULT_MAX_SLEEP_TIME,
34
+ jitter_factor: DEFAULT_JITTER_FACTOR,
35
+ timeout: DEFAULT_TIMEOUT,
36
+ logger: nil
37
+ )
38
+ @max_retries = max_retries
39
+ @base_sleep_time = base_sleep_time
40
+ @max_sleep_time = max_sleep_time
41
+ @jitter_factor = jitter_factor
42
+ @timeout = timeout
43
+ @logger = logger || (defined?(Rails) ? Rails.logger : nil)
44
+ @start_time = Time.current
45
+ end
46
+
47
+ # Executes the retry logic with exponential backoff
48
+ #
49
+ # @param condition_check [Proc] Block that returns true when condition is met
50
+ # @param action [Proc] Block to execute when condition is met
51
+ # @param failure_message [String] Custom failure message for RetryFailedError
52
+ # @param timeout_message [String] Custom timeout message for TimeoutError
53
+ # @yield Alternative to action parameter - block to execute when condition is met
54
+ # @return [Object] Result of the action/block execution
55
+ # @raise [RetryFailedError] When condition is not met after maximum retries
56
+ # @raise [TimeoutError] When timeout is exceeded
57
+ def call(condition_check:, action: nil, failure_message: nil, timeout_message: nil, &block)
58
+ action_block = action || block
59
+ raise ArgumentError, 'Either action parameter or block must be provided' unless action_block
60
+
61
+ attempts = 0
62
+ total_wait_time = 0
63
+
64
+ loop do
65
+ check_timeout!(timeout_message)
66
+
67
+ if condition_check.call
68
+ log_success(attempts, total_wait_time) if attempts.positive?
69
+ return action_block.call
70
+ end
71
+
72
+ attempts += 1
73
+ if attempts >= max_retries
74
+ log_failure(attempts, total_wait_time)
75
+ raise RetryFailedError, failure_message || default_failure_message(attempts)
76
+ end
77
+
78
+ sleep_time = sleep_with_backoff(attempts)
79
+ total_wait_time += sleep_time
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ attr_reader :max_retries, :base_sleep_time, :max_sleep_time, :jitter_factor,
86
+ :timeout, :logger, :start_time
87
+
88
+ # Checks if the timeout has been exceeded
89
+ #
90
+ # @param custom_message [String] Custom timeout message
91
+ # @raise [TimeoutError] When timeout is exceeded
92
+ def check_timeout!(custom_message = nil)
93
+ elapsed_time = Time.current - start_time
94
+
95
+ return unless elapsed_time > timeout
96
+
97
+ message = custom_message || "Timeout after #{elapsed_time.round(2)}s"
98
+ raise TimeoutError, message
99
+ end
100
+
101
+ # Sleeps with exponential backoff and jitter
102
+ #
103
+ # @param attempt_number [Integer] Current attempt number
104
+ # @return [Float] The actual sleep time used
105
+ def sleep_with_backoff(attempt_number)
106
+ sleep_time = calculate_sleep_time(attempt_number)
107
+ log_retry(attempt_number, sleep_time)
108
+ sleep(sleep_time)
109
+ sleep_time
110
+ end
111
+
112
+ # Calculates sleep time with exponential backoff, jitter, and maximum cap
113
+ #
114
+ # @param attempt_number [Integer] Current attempt number
115
+ # @return [Float] Sleep time in seconds
116
+ def calculate_sleep_time(attempt_number)
117
+ # Calculate base exponential backoff
118
+ base_sleep = base_sleep_time * (2**(attempt_number - 1))
119
+
120
+ # Add jitter to prevent thundering herd
121
+ jitter = base_sleep * jitter_factor * rand(-1.0..1.0)
122
+ sleep_with_jitter = base_sleep + jitter
123
+
124
+ # Cap at maximum sleep time
125
+ [sleep_with_jitter, max_sleep_time].min
126
+ end
127
+
128
+ # Default failure message when none is provided
129
+ #
130
+ # @param attempts [Integer] Number of attempts made
131
+ # @return [String] Default failure message
132
+ def default_failure_message(attempts)
133
+ "Retry failed after #{attempts} attempts"
134
+ end
135
+
136
+ # Logs successful retry after multiple attempts
137
+ #
138
+ # @param attempts [Integer] Number of attempts made
139
+ # @param total_wait_time [Float] Total time spent waiting
140
+ # @return [void]
141
+ def log_success(attempts, total_wait_time)
142
+ return unless logger
143
+
144
+ logger.info(
145
+ "ExponentialRetrier succeeded after #{attempts} attempts " \
146
+ "(waited #{total_wait_time.round(2)}s)"
147
+ )
148
+ end
149
+
150
+ # Logs failure after maximum retries
151
+ #
152
+ # @param attempts [Integer] Number of attempts made
153
+ # @param total_wait_time [Float] Total time spent waiting
154
+ # @return [void]
155
+ def log_failure(attempts, total_wait_time)
156
+ return unless logger
157
+
158
+ logger.error(
159
+ "ExponentialRetrier failed after #{attempts} attempts " \
160
+ "(waited #{total_wait_time.round(2)}s)"
161
+ )
162
+ end
163
+
164
+ # Logs retry attempt
165
+ #
166
+ # @param attempt_number [Integer] Current attempt number
167
+ # @param sleep_time [Float] Time to sleep before retry
168
+ # @return [void]
169
+ def log_retry(attempt_number, sleep_time)
170
+ return unless logger&.debug?
171
+
172
+ logger.debug(
173
+ "ExponentialRetrier retry #{attempt_number}/#{max_retries}. " \
174
+ "Sleeping #{sleep_time.round(3)}s"
175
+ )
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Utils
6
+ # Utility class for deep hash operations
7
+ class HashUtils
8
+ class << self
9
+ # Returns a deep duplicate of a hash, recursively duplicating nested hashes.
10
+ #
11
+ # @param hash [Hash] the hash to deep duplicate
12
+ # @return [Hash, Object] the deep duplicated hash, or the original object if not a Hash
13
+ #
14
+ # @example
15
+ # HashUtils.deep_dup({ a: { b: 1 } })
16
+ # # => { a: { b: 1 } } (a completely independent copy)
17
+ def deep_dup(hash)
18
+ return hash unless hash.instance_of?(Hash)
19
+
20
+ dupl = hash.dup
21
+ dupl.each { |k, v| dupl[k] = v.instance_of?(Hash) ? deep_dup(v) : v }
22
+ dupl
23
+ end
24
+
25
+ # Returns a hash with the keys flattened
26
+ #
27
+ # @param obj [Hash, Array] the object to flatten
28
+ # @param prefix [String] the key to use as a prefix for the keys in the hash
29
+ # @param memo [Hash] the hash to store the flattened keys and values
30
+ # @return [Hash] the flattened hash
31
+ #
32
+ # @example
33
+ # HashUtils.deep_flatten_hash({ name: 'A', otl_contexts: { root: { attr: 10, available: true } } })
34
+ # => {"name"=>"A", "otl_contexts.root.attr"=>10, "otl_contexts.root.available"=>true}
35
+ def deep_flatten_hash(obj, prefix = nil, memo = {})
36
+ case obj
37
+ when Hash
38
+ obj.each do |key, value|
39
+ case [key, value]
40
+ in Hash, Array
41
+ memo[deep_flatten_hash(key)] = memo[deep_flatten_hash(value)]
42
+ in String | Symbol, Hash
43
+ deep_flatten_hash(value, prefix ? "#{prefix}.#{key}" : key.to_s, memo)
44
+ in String | Symbol, Array
45
+ memo[key.to_s] = deep_flatten_hash(value)
46
+ in Array, _
47
+ memo[deep_flatten_hash(key)] = deep_flatten_hash(value)
48
+ else
49
+ memo[prefix ? "#{prefix}.#{key}" : key.to_s] = value
50
+ end
51
+ end
52
+ memo
53
+ when Array
54
+ obj.map { deep_flatten_hash(_1) }
55
+ else
56
+ obj
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ VERSION = '1.0.0'
6
+ end
7
+ end