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,432 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ # Returns the singleton instance of the configuration
6
+ # @return [Yes::Core::Configuration] The configuration instance
7
+ # @example
8
+ # config = Yes::Core.configuration
9
+ # config.register_command_class(:user, :create, CreateUserCommand)
10
+ def self.configuration
11
+ @configuration ||= Configuration.new
12
+ end
13
+
14
+ # Configures Yes::Core
15
+ # @yield [Yes::Core::Configuration] The configuration instance
16
+ # @example
17
+ # Yes::Core.configure do |config|
18
+ # config.aggregate_shortcuts = true
19
+ # end
20
+ def self.configure
21
+ yield configuration
22
+ end
23
+
24
+ class Configuration
25
+ # @return [Boolean] Enable aggregate shortcuts in Rails console (default: false)
26
+ attr_accessor :aggregate_shortcuts
27
+
28
+ # @return [#call] A callable that receives auth_data and returns boolean indicating super admin status
29
+ attr_accessor :super_admin_check
30
+
31
+ # @return [#call] A callable that receives auth_data and returns a principal data hash for Cerbos (commands)
32
+ attr_accessor :cerbos_principal_data_builder
33
+
34
+ # @return [#call] A callable that receives auth_data and returns a principal data hash for Cerbos (read requests)
35
+ # Falls back to cerbos_principal_data_builder if not set.
36
+ attr_writer :cerbos_read_principal_data_builder
37
+
38
+ def cerbos_read_principal_data_builder
39
+ @cerbos_read_principal_data_builder || @cerbos_principal_data_builder
40
+ end
41
+
42
+ # @return [String] URL of the Cerbos server
43
+ attr_accessor :cerbos_url
44
+
45
+ # @return [Boolean] Whether to use TLS for Cerbos connections (default: true)
46
+ attr_accessor :cerbos_tls
47
+
48
+ # @return [Boolean] Whether to include metadata in Cerbos command authorizer responses
49
+ attr_accessor :cerbos_commands_authorizer_include_metadata
50
+
51
+ # @return [Boolean] Whether to include metadata in Cerbos read authorizer responses
52
+ attr_accessor :cerbos_read_authorizer_include_metadata
53
+
54
+ # @return [Array<String>] Default actions for Cerbos read authorizer
55
+ attr_accessor :cerbos_read_authorizer_actions
56
+
57
+ # @return [String] Prefix for Cerbos read authorizer resource ids
58
+ attr_accessor :cerbos_read_authorizer_resource_id_prefix
59
+
60
+ # @return [Object] Logger instance
61
+ attr_accessor :logger
62
+
63
+ # @return [#call, nil] A callable error reporter responding to #call(error, context:).
64
+ # When nil, errors are only logged.
65
+ # Example: ->(error, context:) { Sentry.capture_exception(error, extra: context) }
66
+ attr_accessor :error_reporter
67
+
68
+ # @return [Object, nil] Payload store client for resolving large payload references
69
+ attr_accessor :payload_store_client
70
+
71
+ # @return [Boolean] Whether to process commands inline (synchronously) or via ActiveJob
72
+ attr_accessor :process_commands_inline
73
+
74
+ # @return [Array<Class>] Command notifier classes to instantiate for batch notifications
75
+ attr_accessor :command_notifier_classes
76
+
77
+ # @return [Object, nil] OpenTelemetry tracer instance. When nil, all tracing is no-op.
78
+ attr_accessor :otl_tracer
79
+
80
+ # @return [String] Anonymous principal ID for Cerbos read authorizer
81
+ attr_accessor :cerbos_read_authorizer_principal_anonymous_id
82
+
83
+ # @return [Boolean] Whether to raise on missing handler methods in aggregate state
84
+ attr_accessor :raise_on_missing_handler_method
85
+
86
+ # @return [String, nil] URL for subscription heartbeat pings (default: nil, disables heartbeat)
87
+ attr_accessor :subscriptions_heartbeat_url
88
+
89
+ # @return [Integer] Interval in seconds between heartbeat pings (default: 30)
90
+ attr_accessor :subscriptions_heartbeat_interval
91
+
92
+ # @return [String] Service name for telemetry and identification
93
+ attr_accessor :service_name
94
+
95
+ # @return [String] Service version for telemetry
96
+ attr_accessor :service_version
97
+
98
+ # @return [#call, nil] Authentication adapter for API controllers.
99
+ # Must respond to #authenticate(request) (returns auth data hash, raises on failure),
100
+ # #verify_token(token) and #error_classes (returns array of error classes).
101
+ attr_accessor :auth_adapter
102
+
103
+ # Initializes a new configuration instance with nested hashes for class storage.
104
+ def initialize
105
+ @registered_classes = Hash.new do |h, k|
106
+ h[k] = Hash.new { |h2, k2| h2[k2] = {} }
107
+ end
108
+ @aggregate_shortcuts = false
109
+ @super_admin_check = ->(_auth_data) { false }
110
+ @cerbos_principal_data_builder = lambda { |auth_data|
111
+ { id: auth_data[:identity_id], roles: [], attributes: {} }
112
+ }
113
+ @cerbos_url = ENV.fetch('CERBOS_URL', 'cerbos-cluster-ip-service:3593')
114
+ @cerbos_tls = true
115
+ @cerbos_commands_authorizer_include_metadata = false
116
+ @cerbos_read_authorizer_include_metadata = false
117
+ @cerbos_read_authorizer_actions = %w[read]
118
+ @cerbos_read_authorizer_resource_id_prefix = 'read-'
119
+ @cerbos_read_authorizer_principal_anonymous_id = 'anonymous'
120
+ @logger = nil
121
+ @error_reporter = nil
122
+ @payload_store_client = nil
123
+ @process_commands_inline = true
124
+ @command_notifier_classes = []
125
+ @otl_tracer = nil
126
+ @raise_on_missing_handler_method = defined?(Rails) ? Rails.env.local? : false
127
+ @subscriptions_heartbeat_url = nil
128
+ @subscriptions_heartbeat_interval = 30
129
+ @service_name = ENV.fetch('SERVICE_NAME', nil)
130
+ @service_version = ENV.fetch('APP_VERSION', '')
131
+ @auth_adapter = nil
132
+ end
133
+
134
+ # Register a class for a specific aggregate and type
135
+ # @param context_name [Symbol, String] The context for the aggregate
136
+ # @param aggregate_name [Symbol, String] The name of the aggregate
137
+ # @param action_name [Symbol, String] The name of the command/event
138
+ # @param type [Symbol] The type (:command, :event, or :guard_evaluator)
139
+ # @param klass [Class] The class to register
140
+ # @example Register a command class
141
+ # register_aggregate_class(:authentication, :user, :create, :command, CreateUserCommand)
142
+ def register_aggregate_class(context_name, aggregate_name, action_name, type, klass)
143
+ key = [context_name, aggregate_name]
144
+ @registered_classes[key][type][action_name] = klass
145
+ end
146
+
147
+ # Register a read model class for a specific aggregate
148
+ # @param context_name [Symbol, String] The context for the aggregate
149
+ # @param aggregate_name [Symbol, String] The name of the aggregate
150
+ # @param klass [Class] The class to register
151
+ # @example
152
+ # register_read_model_class(:authentication, :user, UserReadModel)
153
+ def register_read_model_class(context_name, aggregate_name, klass, draft: false)
154
+ key = [context_name, aggregate_name]
155
+ read_model_key = draft ? :draft_read_model : :read_model
156
+ @registered_classes[key][read_model_key] = klass
157
+ end
158
+
159
+ # Register a read model filter class for a specific aggregate
160
+ # @param context_name [Symbol, String] The context for the aggregate
161
+ # @param aggregate_name [Symbol, String] The name of the aggregate
162
+ # @param klass [Class] The class to register
163
+ # @example
164
+ # register_read_model_filter_class(:authentication, :user, UserReadModelFilter)
165
+ def register_read_model_filter_class(context_name, aggregate_name, klass)
166
+ key = [context_name, aggregate_name]
167
+ @registered_classes[key][:read_model_filter] = klass
168
+ end
169
+
170
+ # Register a command class for a specific aggregate
171
+ # @param context_name [Symbol, String] The context for the aggregate
172
+ # @param aggregate_name [Symbol, String] The name of the aggregate
173
+ # @param command_name [Symbol, String] The name of the command
174
+ # @param klass [Class] The class to register
175
+ # @example
176
+ # register_command_class(:authentication, :user, :create, CreateUserCommand)
177
+ def register_command_class(context_name, aggregate_name, command_name, klass)
178
+ register_aggregate_class(context_name, aggregate_name, command_name, :command, klass)
179
+ end
180
+
181
+ # Register an event class for a specific aggregate
182
+ # @param context_name [Symbol, String] The context for the aggregate
183
+ # @param aggregate_name [Symbol, String] The name of the aggregate
184
+ # @param event_name [Symbol, String] The name of the event
185
+ # @param klass [Class] The class to register
186
+ # @example
187
+ # register_event_class(:authentication, :user, :created, UserCreatedEvent)
188
+ def register_event_class(context_name, aggregate_name, event_name, klass)
189
+ register_aggregate_class(context_name, aggregate_name, event_name, :event, klass)
190
+ end
191
+
192
+ # Register a guard evaluator class for a specific aggregate
193
+ # @param context_name [Symbol, String] The context for the aggregate
194
+ # @param aggregate_name [Symbol, String] The name of the aggregate
195
+ # @param command_name [Symbol, String] The name of the command
196
+ # @param klass [Class] The class to register
197
+ # @example
198
+ # register_guard_evaluator_class(:authentication, :user, :create, CreateUserGuardEvaluator)
199
+ def register_guard_evaluator_class(context_name, aggregate_name, command_name, klass)
200
+ register_aggregate_class(context_name, aggregate_name, command_name, :guard_evaluator, klass)
201
+ end
202
+
203
+ # Register an aggregate authorizer class for a specific aggregate
204
+ # @param context_name [Symbol, String] The context for the aggregate
205
+ # @param aggregate_name [Symbol, String] The name of the aggregate
206
+ # @param klass [Class] The authorizer class to register
207
+ # @example
208
+ # register_aggregate_authorizer_class(:authentication, :user, UserAuthorizer)
209
+ def register_aggregate_authorizer_class(context_name, aggregate_name, klass)
210
+ key = [context_name, aggregate_name]
211
+ @registered_classes[key][:aggregate_authorizer] = klass
212
+ end
213
+
214
+ # Register a command authorizer class for a specific aggregate
215
+ # @param context_name [Symbol, String] The context for the aggregate
216
+ # @param aggregate_name [Symbol, String] The name of the aggregate
217
+ # @param command_name [Symbol, String] The name of the command
218
+ # @param klass [Class] The authorizer class to register
219
+ # @example
220
+ # register_command_authorizer_class(:sales, :user, :create, CreateUserAuthorizer)
221
+ def register_command_authorizer_class(context_name, aggregate_name, command_name, klass)
222
+ register_aggregate_class(context_name, aggregate_name, command_name, :authorizer, klass)
223
+ end
224
+
225
+ # Register the event(s) associated with a specific command
226
+ # @param context_name [Symbol, String] The context for the aggregate
227
+ # @param aggregate_name [Symbol, String] The name of the aggregate
228
+ # @param command_name [Symbol, String] The name of the command
229
+ # @param event_names [Array<Symbol, String>] An array of event names
230
+ # @example
231
+ # register_command_events(:authentication, :user, :create, [:user_created, :welcome_email_sent])
232
+ def register_command_events(context_name, aggregate_name, command_name, event_names)
233
+ key = [context_name, aggregate_name]
234
+ mappings = @registered_classes[key][:command_event_mappings] ||= {}
235
+ mappings[command_name] = event_names
236
+ end
237
+
238
+ # Retrieve all command-to-event mappings for a specific aggregate
239
+ # @param context_name [Symbol, String] The context for the aggregate
240
+ # @param aggregate_name [Symbol, String] The name of the aggregate
241
+ # @return [Hash] A hash where keys are command names and values are arrays of event names
242
+ # @example
243
+ # mappings = command_event_mappings(:authentication, :user)
244
+ def command_event_mappings(context_name, aggregate_name)
245
+ key = [context_name, aggregate_name]
246
+ @registered_classes[key][:command_event_mappings] || {}
247
+ end
248
+
249
+ # Retrieve the event names associated with a specific command
250
+ # @param context_name [Symbol, String] The context for the aggregate
251
+ # @param aggregate_name [Symbol, String] The name of the aggregate
252
+ # @param command_name [Symbol, String] The name of the command
253
+ # @return [Array<Symbol, String>] An array of event names associated with the command, or an empty array if none
254
+ # @example
255
+ # event_names = command_event_mapping(:authentication, :user, :create)
256
+ def command_event_mapping(context_name, aggregate_name, command_name)
257
+ command_event_mappings(context_name, aggregate_name)[command_name] || []
258
+ end
259
+
260
+ # Retrieve the actual event classes associated with a specific command
261
+ # @param context_name [Symbol, String] The context for the aggregate
262
+ # @param aggregate_name [Symbol, String] The name of the aggregate
263
+ # @param command_name [Symbol, String] The name of the command
264
+ # @return [Array<Class>] An array of event classes associated with the command
265
+ # @example
266
+ # event_classes = event_classes_for_command(:authentication, :user, :create)
267
+ def event_classes_for_command(context_name, aggregate_name, command_name)
268
+ command_event_mapping(context_name, aggregate_name, command_name).map do |event_name|
269
+ aggregate_class(context_name, aggregate_name, event_name, :event)
270
+ end
271
+ end
272
+
273
+ # Retrieve a registered class for a given aggregate, action, and type
274
+ # @param context_name [Symbol, String] The context for the aggregate
275
+ # @param aggregate_name [Symbol, String] The name of the aggregate
276
+ # @param action_name [Symbol, String, nil] The name of the action (command/event), nil for read_model types
277
+ # @param type [Symbol] The type of the class (:command, :event, :guard_evaluator, :read_model, :draft_read_model)
278
+ # @return [Class, nil] The registered class or nil if not found
279
+ # @example Get a command class
280
+ # command_class = aggregate_class(:authentication, :user, :create, :command)
281
+ # @example Get a read model class
282
+ # read_model_class = aggregate_class(:authentication, :user, nil, :read_model)
283
+ def aggregate_class(context_name, aggregate_name, action_name, type)
284
+ if action_name.nil?
285
+ @registered_classes.dig([context_name, aggregate_name], type)
286
+ else
287
+ @registered_classes.dig([context_name, aggregate_name], type, action_name)
288
+ end
289
+ end
290
+
291
+ # List all registered classes for a specific aggregate in a context
292
+ # @param context_name [Symbol, String] The context for the aggregate
293
+ # @param aggregate_name [Symbol, String] The name of the aggregate
294
+ # @return [Hash] A hash of registered classes grouped by type
295
+ # @example
296
+ # classes = list_aggregate_classes("Authentication", "User)
297
+ def list_aggregate_classes(context_name, aggregate_name)
298
+ @registered_classes[[context_name, aggregate_name]]
299
+ end
300
+
301
+ # Retrieve a guard evaluator class for a specific command
302
+ # @param context_name [Symbol, String] The context for the aggregate
303
+ # @param aggregate_name [Symbol, String] The name of the aggregate
304
+ # @param command_name [Symbol, String] The name of the command
305
+ # @return [Class, nil] The registered guard evaluator class or nil if not found
306
+ # @example
307
+ # evaluator = guard_evaluator_class(:authentication, :user, :create)
308
+ def guard_evaluator_class(context_name, aggregate_name, command_name)
309
+ aggregate_class(context_name, aggregate_name, command_name.to_s.underscore.to_sym, :guard_evaluator)
310
+ end
311
+
312
+ # List all registered classes across all aggregates and contexts
313
+ # @return [Hash] A complete hash of all registered classes
314
+ # @example
315
+ # all_classes = list_all_registered_classes
316
+ # # Returns:
317
+ # # {
318
+ # # [:authentication, :user] => {
319
+ # # command: { create: CreateUserCommand },
320
+ # # event: { created: UserCreatedEvent },
321
+ # # guard_evaluator: { create: CreateUserGuardEvaluator }
322
+ # # }
323
+ # # }
324
+ def list_all_registered_classes
325
+ @registered_classes
326
+ end
327
+
328
+ # Get all read model class names from registered aggregates
329
+ # @return [Array<String>] Array of read model class names
330
+ # @example
331
+ # read_model_classes = all_read_model_class_names
332
+ # # Returns: ["UserReadModel", "UserChangesReadModel", "ProfileReadModel", ...]
333
+ def all_read_model_class_names
334
+ list_all_registered_classes.keys.flat_map do |context_aggregate|
335
+ context_name, aggregate_name = context_aggregate
336
+ aggregate_class_name = "#{context_name.to_s.camelize}::#{aggregate_name.to_s.camelize}::Aggregate"
337
+
338
+ begin
339
+ aggregate_class = aggregate_class_name.constantize
340
+ models = []
341
+
342
+ # Add main read model if it exists
343
+ models << aggregate_class.read_model_name.camelize.to_s if aggregate_class.respond_to?(:read_model_name) && aggregate_class.read_model_name
344
+
345
+ # Add changes read model if aggregate is draftable
346
+ models << aggregate_class.changes_read_model_name.camelize.to_s if aggregate_class.respond_to?(:changes_read_model_name) && aggregate_class.changes_read_model_name
347
+
348
+ models
349
+ rescue NameError
350
+ # Skip if aggregate class doesn't exist
351
+ []
352
+ end
353
+ end.compact.uniq
354
+ end
355
+
356
+ # Get all read model classes (constantized)
357
+ # @return [Array<Class>] Array of read model classes
358
+ # @example
359
+ # read_model_classes = all_read_model_classes
360
+ # # Returns: [UserReadModel, UserChangesReadModel, ProfileReadModel, ...]
361
+ def all_read_model_classes
362
+ all_read_model_class_names.filter_map do |class_name|
363
+ class_name.constantize
364
+ rescue NameError
365
+ nil
366
+ end
367
+ end
368
+
369
+ # Get all read model classes with their associated aggregate classes
370
+ # @return [Array<Hash>] Array of hashes with read_model_class, aggregate_class, and is_draft flag
371
+ # @example
372
+ # mappings = all_read_models_with_aggregate_classes
373
+ # # Returns: [
374
+ # # { read_model_class: UserReadModel, aggregate_class: User::Aggregate, is_draft: false },
375
+ # # { read_model_class: UserChangesReadModel, aggregate_class: User::Aggregate, is_draft: true }
376
+ # # ]
377
+ def all_read_models_with_aggregate_classes
378
+ list_all_registered_classes.keys.flat_map do |context_aggregate|
379
+ context_name, aggregate_name = context_aggregate
380
+ aggregate_class_name = "#{context_name.to_s.camelize}::#{aggregate_name.to_s.camelize}::Aggregate"
381
+
382
+ begin
383
+ aggregate_class = aggregate_class_name.constantize
384
+ models = []
385
+
386
+ # Main read model (not draft)
387
+ if aggregate_class.respond_to?(:read_model_name) && aggregate_class.read_model_name
388
+ begin
389
+ read_model_class = aggregate_class.read_model_name.camelize.constantize
390
+ models << {
391
+ read_model_class: read_model_class,
392
+ aggregate_class: aggregate_class,
393
+ is_draft: false
394
+ }
395
+ rescue NameError
396
+ # Skip if read model class doesn't exist
397
+ end
398
+ end
399
+
400
+ # Changes read model (draft)
401
+ if aggregate_class.respond_to?(:changes_read_model_name) && aggregate_class.changes_read_model_name
402
+ begin
403
+ changes_model_class = aggregate_class.changes_read_model_name.camelize.constantize
404
+ models << {
405
+ read_model_class: changes_model_class,
406
+ aggregate_class: aggregate_class,
407
+ is_draft: true
408
+ }
409
+ rescue NameError
410
+ # Skip if changes read model class doesn't exist
411
+ end
412
+ end
413
+
414
+ models
415
+ rescue NameError
416
+ # Skip if aggregate class doesn't exist
417
+ []
418
+ end
419
+ end.compact
420
+ end
421
+
422
+ # Get all read model table names
423
+ # @return [Array<String>] Array of read model table names
424
+ # @example
425
+ # table_names = all_read_model_table_names
426
+ # # Returns: ["user_read_models", "user_changes_read_models", "profile_read_models", ...]
427
+ def all_read_model_table_names
428
+ all_read_model_classes.map(&:table_name).uniq
429
+ end
430
+ end
431
+ end
432
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ # Decrypts event data attributes using a key from the key repository.
6
+ #
7
+ # @example
8
+ # decryptor = DataDecryptor.new(data: event.data, schema: event.metadata['encryption'], repository: repo)
9
+ # decrypted_data = decryptor.call
10
+ class DataDecryptor
11
+ # Decrypts the data attributes specified in the encryption metadata.
12
+ #
13
+ # @return [Hash] the decrypted data
14
+ def call
15
+ return encrypted_data if encryption_metadata.empty?
16
+
17
+ result = find_key(encryption_metadata['key'])
18
+ return encrypted_data unless result.success?
19
+
20
+ decrypt_attributes(
21
+ key: result.value!,
22
+ data: encrypted_data,
23
+ attributes: encryption_metadata['attributes']
24
+ )
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :key_repository, :encryption_metadata, :encrypted_data
30
+
31
+ # @param data [Hash] the encrypted event data
32
+ # @param schema [Hash, nil] the encryption metadata from the event
33
+ # @param repository [#find, #decrypt] the key repository
34
+ def initialize(data:, schema:, repository:)
35
+ @encrypted_data = Yes::Core::Utils::HashUtils.deep_dup(data).transform_keys!(&:to_s)
36
+ @key_repository = repository
37
+ @encryption_metadata = schema&.transform_keys(&:to_s) || {}
38
+ end
39
+
40
+ def decrypt_attributes(key:, data:, attributes: {}) # rubocop:disable Lint/UnusedMethodArgument
41
+ return data unless key
42
+
43
+ res = key_repository.decrypt(key:, message: data['es_encrypted'])
44
+ return data if res.failure?
45
+
46
+ decrypted_text = res.value!
47
+ decrypted = JSON.parse(decrypted_text.attributes[:message]).transform_keys(&:to_s)
48
+ decrypted.each { |k, value| data[k] = value if data.key?(k) }
49
+ data.delete('es_encrypted')
50
+ data
51
+ end
52
+
53
+ # @return [Dry::Monads::Result]
54
+ def find_key(identifier)
55
+ key_repository.find(identifier)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ # Encrypts event data attributes using a key from the key repository.
6
+ #
7
+ # @example
8
+ # encryptor = DataEncryptor.new(data: event.data, schema: event.class.encryption_schema, repository: repo)
9
+ # encryptor.call
10
+ # encryptor.encrypted_data
11
+ # encryptor.encryption_metadata
12
+ class DataEncryptor
13
+ # @return [Hash] the encrypted data
14
+ attr_reader :encrypted_data
15
+
16
+ # @return [Hash] the encryption metadata (key, iv, attributes)
17
+ attr_reader :encryption_metadata
18
+
19
+ # Encrypts the data attributes specified in the schema.
20
+ #
21
+ # @return [Hash] the encrypted data
22
+ def call
23
+ return encrypted_data if encryption_metadata.empty?
24
+
25
+ key_id = encryption_metadata[:key]
26
+ res = key_repository.find(key_id)
27
+ res = key_repository.create(key_id) if res.failure?
28
+ key = res.value!
29
+
30
+ encryption_metadata[:iv] = key.attributes[:iv]
31
+ encrypt_attributes(
32
+ key:,
33
+ data: encrypted_data,
34
+ attributes: encryption_metadata[:attributes].map(&:to_s)
35
+ )
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :key_repository
41
+
42
+ # @param data [Hash] the event data
43
+ # @param schema [Hash] the encryption schema
44
+ # @param repository [#find, #create, #encrypt, #decrypt] the key repository
45
+ def initialize(data:, schema:, repository:)
46
+ @encrypted_data = Yes::Core::Utils::HashUtils.deep_dup(data).transform_keys!(&:to_s)
47
+ @key_repository = repository
48
+ @encryption_metadata = EncryptionMetadata.new(data:, schema:).call
49
+ end
50
+
51
+ def encrypt_attributes(key:, data:, attributes:)
52
+ text = JSON.generate(data.select { |hash_key, _value| attributes.include?(hash_key.to_s) })
53
+ encrypted = key_repository.encrypt(key:, message: text).value!
54
+ attributes.each { |att| data[att.to_s] = 'es_encrypted' if data.key?(att.to_s) }
55
+ data['es_encrypted'] = encrypted.attributes[:message]
56
+ data
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ # Extracts encryption metadata from a command/event's encryption schema.
6
+ #
7
+ # @example
8
+ # metadata = EncryptionMetadata.new(data: event.data, schema: event.class.encryption_schema)
9
+ # metadata.call # => { key: 'user-123', attributes: [:email, :phone] }
10
+ class EncryptionMetadata
11
+ # @return [Hash] the encryption metadata (key, attributes) or empty hash
12
+ def call
13
+ return {} unless schema
14
+
15
+ {
16
+ key: schema[:key].call(data),
17
+ attributes: schema[:attributes].map(&:to_sym)
18
+ }
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :data, :schema
24
+
25
+ # @param data [Hash] the event data
26
+ # @param schema [Hash, nil] the encryption schema with :key (callable) and :attributes (array)
27
+ def initialize(data:, schema:)
28
+ @data = data.transform_keys(&:to_sym)
29
+ @schema = schema
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class Error < StandardError
6
+ attr_reader :extra
7
+
8
+ def initialize(message = nil, extra: nil)
9
+ super(message)
10
+ @extra = extra
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ # Helper class for looking up error messages from I18n
6
+ class ErrorMessages
7
+ class << self
8
+ # Looks up the error message for a guard from I18n translations
9
+ #
10
+ # @param context_name [String] The context name
11
+ # @param aggregate_name [String] The aggregate name
12
+ # @param command_name [String] The command name
13
+ # @param guard_name [Symbol] The name of the guard
14
+ # @return [String] The error message
15
+ def guard_error(context_name, aggregate_name, command_name, guard_name)
16
+ context_key, aggregate_key, command_key, guard_key = normalize_keys(
17
+ context_name, aggregate_name, command_name, guard_name
18
+ )
19
+
20
+ # Try to find the error message in the following order:
21
+ # 1. Specific translation for this aggregate attribute guard
22
+ # 2. Default fallback message
23
+ I18n.t(
24
+ "aggregates.#{context_key}.#{aggregate_key}.commands.#{command_key}.guards.#{guard_key}.error",
25
+ default: "Guard '#{guard_key}' failed"
26
+ )
27
+ end
28
+
29
+ private
30
+
31
+ def normalize_keys(*keys)
32
+ keys.map { _1.to_s.underscore }
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end