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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +69 -0
- data/lib/yes/core/active_job_serializers/command_group_serializer.rb +29 -0
- data/lib/yes/core/active_job_serializers/dry_struct_serializer.rb +57 -0
- data/lib/yes/core/aggregate/draftable.rb +205 -0
- data/lib/yes/core/aggregate/dsl/attribute_data.rb +37 -0
- data/lib/yes/core/aggregate/dsl/attribute_definer.rb +54 -0
- data/lib/yes/core/aggregate/dsl/attribute_definers/aggregate.rb +36 -0
- data/lib/yes/core/aggregate/dsl/attribute_definers/standard.rb +36 -0
- data/lib/yes/core/aggregate/dsl/class_name_convention.rb +80 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/authorizer.rb +132 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/base.rb +80 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer.rb +30 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer_factory.rb +34 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/base.rb +38 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/cerbos_authorizer.rb +114 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/command.rb +70 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/event.rb +88 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/guard_evaluator.rb +84 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/simple_authorizer.rb +50 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/state_updater.rb +46 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/read_model.rb +75 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/read_model_filter.rb +88 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/read_model_serializer.rb +76 -0
- data/lib/yes/core/aggregate/dsl/command_data.rb +54 -0
- data/lib/yes/core/aggregate/dsl/command_definer.rb +263 -0
- data/lib/yes/core/aggregate/dsl/command_shortcut_expander.rb +233 -0
- data/lib/yes/core/aggregate/dsl/constant_resolver.rb +67 -0
- data/lib/yes/core/aggregate/dsl/method_definers/attribute/accessor.rb +28 -0
- data/lib/yes/core/aggregate/dsl/method_definers/attribute/aggregate_accessor.rb +36 -0
- data/lib/yes/core/aggregate/dsl/method_definers/attribute/base.rb +42 -0
- data/lib/yes/core/aggregate/dsl/method_definers/command/base.rb +42 -0
- data/lib/yes/core/aggregate/dsl/method_definers/command/can_command.rb +41 -0
- data/lib/yes/core/aggregate/dsl/method_definers/command/command.rb +50 -0
- data/lib/yes/core/aggregate/has_authorizer.rb +86 -0
- data/lib/yes/core/aggregate/has_read_model.rb +169 -0
- data/lib/yes/core/aggregate/read_model_rebuilder.rb +40 -0
- data/lib/yes/core/aggregate/shared_read_model_rebuilder.rb +158 -0
- data/lib/yes/core/aggregate.rb +404 -0
- data/lib/yes/core/authentication_error.rb +8 -0
- data/lib/yes/core/authorization/cerbos_client_provider.rb +27 -0
- data/lib/yes/core/authorization/command_authorizer.rb +40 -0
- data/lib/yes/core/authorization/command_cerbos_authorizer.rb +182 -0
- data/lib/yes/core/authorization/read_model_authorizer.rb +22 -0
- data/lib/yes/core/authorization/read_models_authorizer.rb +49 -0
- data/lib/yes/core/authorization/read_request_authorizer.rb +32 -0
- data/lib/yes/core/authorization/read_request_cerbos_authorizer.rb +112 -0
- data/lib/yes/core/command.rb +35 -0
- data/lib/yes/core/command_handling/aggregate_tracker.rb +33 -0
- data/lib/yes/core/command_handling/command_executor.rb +171 -0
- data/lib/yes/core/command_handling/command_handler.rb +124 -0
- data/lib/yes/core/command_handling/event_publisher.rb +189 -0
- data/lib/yes/core/command_handling/guard_evaluator.rb +165 -0
- data/lib/yes/core/command_handling/guard_runner.rb +76 -0
- data/lib/yes/core/command_handling/payload_proxy.rb +159 -0
- data/lib/yes/core/command_handling/read_model_recovery_service.rb +264 -0
- data/lib/yes/core/command_handling/read_model_revision_guard.rb +198 -0
- data/lib/yes/core/command_handling/read_model_updater.rb +103 -0
- data/lib/yes/core/command_handling/state_updater.rb +113 -0
- data/lib/yes/core/commands/bus.rb +46 -0
- data/lib/yes/core/commands/group.rb +135 -0
- data/lib/yes/core/commands/group_response.rb +13 -0
- data/lib/yes/core/commands/helper.rb +126 -0
- data/lib/yes/core/commands/notifier.rb +65 -0
- data/lib/yes/core/commands/processor.rb +137 -0
- data/lib/yes/core/commands/response.rb +63 -0
- data/lib/yes/core/commands/stateless/group_handler.rb +186 -0
- data/lib/yes/core/commands/stateless/group_response.rb +15 -0
- data/lib/yes/core/commands/stateless/handler.rb +292 -0
- data/lib/yes/core/commands/stateless/handler_helpers.rb +321 -0
- data/lib/yes/core/commands/stateless/response.rb +14 -0
- data/lib/yes/core/commands/stateless/subject.rb +41 -0
- data/lib/yes/core/commands/validator.rb +28 -0
- data/lib/yes/core/configuration.rb +432 -0
- data/lib/yes/core/data_decryptor.rb +59 -0
- data/lib/yes/core/data_encryptor.rb +60 -0
- data/lib/yes/core/encryption_metadata.rb +33 -0
- data/lib/yes/core/error.rb +14 -0
- data/lib/yes/core/error_messages.rb +37 -0
- data/lib/yes/core/event.rb +222 -0
- data/lib/yes/core/event_class_resolver.rb +40 -0
- data/lib/yes/core/generators/read_models/add_pending_update_tracking_generator.rb +43 -0
- data/lib/yes/core/generators/read_models/templates/add_pending_update_tracking.rb.erb +122 -0
- data/lib/yes/core/generators/read_models/templates/migration.rb.erb +9 -0
- data/lib/yes/core/generators/read_models/update_generator.rb +147 -0
- data/lib/yes/core/jobs/read_model_recovery_job.rb +219 -0
- data/lib/yes/core/middlewares/encryptor.rb +48 -0
- data/lib/yes/core/middlewares/timestamp.rb +29 -0
- data/lib/yes/core/middlewares/with_indifferent_access.rb +22 -0
- data/lib/yes/core/models/application_record.rb +9 -0
- data/lib/yes/core/open_telemetry/otl_span.rb +110 -0
- data/lib/yes/core/open_telemetry/trackable.rb +101 -0
- data/lib/yes/core/payload_store/base.rb +33 -0
- data/lib/yes/core/payload_store/errors.rb +13 -0
- data/lib/yes/core/payload_store/lookup.rb +44 -0
- data/lib/yes/core/process_managers/access_token_client.rb +107 -0
- data/lib/yes/core/process_managers/base.rb +40 -0
- data/lib/yes/core/process_managers/command_runner.rb +109 -0
- data/lib/yes/core/process_managers/service_client.rb +57 -0
- data/lib/yes/core/process_managers/state.rb +118 -0
- data/lib/yes/core/railtie.rb +58 -0
- data/lib/yes/core/read_model/builder.rb +267 -0
- data/lib/yes/core/read_model/event_handler.rb +64 -0
- data/lib/yes/core/read_model/filter.rb +118 -0
- data/lib/yes/core/read_model/filter_query_builder.rb +104 -0
- data/lib/yes/core/serializer.rb +21 -0
- data/lib/yes/core/subscriptions.rb +94 -0
- data/lib/yes/core/test_support/event_helpers.rb +27 -0
- data/lib/yes/core/test_support/jwt_helpers.rb +30 -0
- data/lib/yes/core/test_support/subscriptions_helper.rb +88 -0
- data/lib/yes/core/test_support/test_helper.rb +27 -0
- data/lib/yes/core/test_support.rb +5 -0
- data/lib/yes/core/transaction_details.rb +90 -0
- data/lib/yes/core/type_lookup.rb +88 -0
- data/lib/yes/core/types.rb +110 -0
- data/lib/yes/core/utils/aggregate_shortcuts.rb +164 -0
- data/lib/yes/core/utils/caller_utils.rb +37 -0
- data/lib/yes/core/utils/command_utils.rb +226 -0
- data/lib/yes/core/utils/error_notifier.rb +101 -0
- data/lib/yes/core/utils/event_name_resolver.rb +67 -0
- data/lib/yes/core/utils/exponential_retrier.rb +180 -0
- data/lib/yes/core/utils/hash_utils.rb +63 -0
- data/lib/yes/core/version.rb +7 -0
- data/lib/yes/core.rb +85 -0
- data/lib/yes.rb +0 -0
- metadata +324 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'i18n'
|
|
4
|
+
|
|
5
|
+
begin
|
|
6
|
+
require 'trx_ext'
|
|
7
|
+
rescue LoadError
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module Yes
|
|
11
|
+
module Core
|
|
12
|
+
module ReadModel
|
|
13
|
+
# Base class for read model builders that distribute events to proper event handlers
|
|
14
|
+
# and manage read model lifecycle including rebuilding.
|
|
15
|
+
class Builder
|
|
16
|
+
include OpenTelemetry::Trackable
|
|
17
|
+
|
|
18
|
+
EventValidationErrorsPresent = Class.new(Error)
|
|
19
|
+
InvalidReadModelBuilderClass = Class.new(Error)
|
|
20
|
+
MissingReadModelId = Class.new(Error)
|
|
21
|
+
RebuildError = Class.new(Error)
|
|
22
|
+
|
|
23
|
+
READ_MODEL_CLASS_REGEXP =
|
|
24
|
+
/(?<context>\w+)::ReadModels::(?<version>V\d+)?(::)?(?<aggregate>\w+)/
|
|
25
|
+
|
|
26
|
+
# Based on event type class it distributes event to a proper event handler
|
|
27
|
+
# @param event [Yes::Core::Event]
|
|
28
|
+
# @param read_model [ActiveRecord::Base] AR object
|
|
29
|
+
# @return [void]
|
|
30
|
+
def call(event, read_model: nil)
|
|
31
|
+
read_model ||= read_model(event)
|
|
32
|
+
|
|
33
|
+
otl_record_data(read_model, event)
|
|
34
|
+
|
|
35
|
+
handler = handler_class(event)&.new(read_model)
|
|
36
|
+
return unless handler
|
|
37
|
+
|
|
38
|
+
locale = event.data['locale']&.to_sym || I18n.default_locale
|
|
39
|
+
return unless correct_locale?(locale)
|
|
40
|
+
|
|
41
|
+
I18n.with_locale(locale) do
|
|
42
|
+
next handler.call(event) unless self.class.otl_tracer
|
|
43
|
+
|
|
44
|
+
OpenTelemetry::OtlSpan.new(otl_data: otl_data(handler), otl_tracer: self.class.otl_tracer).otl_span do
|
|
45
|
+
handler.call(event)
|
|
46
|
+
end
|
|
47
|
+
ensure
|
|
48
|
+
otl_record_processed(read_model, event, handler)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
otl_trackable :call, OpenTelemetry::OtlSpan::OtlData.new(
|
|
52
|
+
span_kind: :consumer,
|
|
53
|
+
links_extractor: ->(event, **) { event.metadata['otl_contexts'] },
|
|
54
|
+
track_sql: true
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# @param eventstore [PgEventstore::Client] eventstore client
|
|
58
|
+
# @param ids [Array<String>] ids to rebuild
|
|
59
|
+
def rebuild_many(
|
|
60
|
+
eventstore: PgEventstore.client,
|
|
61
|
+
ids: read_model_class.pluck(:id)
|
|
62
|
+
)
|
|
63
|
+
ids.each { |id| rebuild(eventstore:, id:) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @param id [String] id of the read model to rebuild
|
|
67
|
+
# @param eventstore [PgEventstore::Client] eventstore client
|
|
68
|
+
# @return [ActiveRecord::Base, nil]
|
|
69
|
+
# @raise [Yes::Core::ReadModel::Builder::RebuildError]
|
|
70
|
+
def rebuild(id:, eventstore: PgEventstore.client)
|
|
71
|
+
# delete is intentional to avoid any callbacks
|
|
72
|
+
read_model_class.delete(id) if read_model_class.exists?(id)
|
|
73
|
+
context = read_model_class_name_match[:context]
|
|
74
|
+
stream_name = read_model_class_name_match[:aggregate]
|
|
75
|
+
enum = eventstore.read_paginated(
|
|
76
|
+
PgEventstore::Stream.new(context:, stream_name:, stream_id: id), options: { resolve_link_tos: true }
|
|
77
|
+
)
|
|
78
|
+
read_model = read_model_class.find_or_create_by(id:)
|
|
79
|
+
|
|
80
|
+
enum.each do |events|
|
|
81
|
+
events.each do |event|
|
|
82
|
+
# pass in read model for efficiency
|
|
83
|
+
call(event, read_model:)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
read_model
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @return [String]
|
|
90
|
+
def aggregate_id_key
|
|
91
|
+
"#{underscore(read_model_class_name_match[:aggregate].to_s)}_id"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def initialize
|
|
95
|
+
self.read_model_class = default_read_model_class
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
protected
|
|
99
|
+
|
|
100
|
+
attr_accessor :read_model_class
|
|
101
|
+
|
|
102
|
+
# @param event [Yes::Core::Event] event to find handler for
|
|
103
|
+
# @return [Class, nil] handler class for the event
|
|
104
|
+
def handler_class(event)
|
|
105
|
+
handler_classes = possible_handler_class_names(event)
|
|
106
|
+
handler_instance = handler_classes.lazy.filter_map do |class_name|
|
|
107
|
+
Kernel.const_get(class_name)
|
|
108
|
+
rescue NameError
|
|
109
|
+
next
|
|
110
|
+
end.first
|
|
111
|
+
|
|
112
|
+
notify_missing_event_handler(event, handler_classes) unless handler_instance
|
|
113
|
+
|
|
114
|
+
handler_instance
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @param event [Yes::Core::Event] event to find handler names for
|
|
118
|
+
# @return [Array<String>] possible handler class names
|
|
119
|
+
def possible_handler_class_names(event)
|
|
120
|
+
parent_modules = parent_modules_string
|
|
121
|
+
event_type = event_type_name(event)
|
|
122
|
+
event_context = event_context_name(event)
|
|
123
|
+
# in case event has no context
|
|
124
|
+
return ["#{parent_modules}::On#{event_type}"] if event_type == event_context
|
|
125
|
+
|
|
126
|
+
[
|
|
127
|
+
"#{parent_modules}::#{event_context}::On#{event_type}",
|
|
128
|
+
"#{parent_modules}::On#{event_type}"
|
|
129
|
+
]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @param event [Yes::Core::Event] event that has no handler
|
|
133
|
+
# @param possible_handler_classes_name [Array<String>] handler class names that were tried
|
|
134
|
+
def notify_missing_event_handler(event, possible_handler_classes_name)
|
|
135
|
+
msg = "The event handler #{possible_handler_classes_name} is not defined."
|
|
136
|
+
Utils::ErrorNotifier.new.event_handler_not_defined(msg, event)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# @param event [Yes::Core::Event] event to extract type name from
|
|
140
|
+
# @return [String]
|
|
141
|
+
def event_type_name(event)
|
|
142
|
+
event.type.split('::').last
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @param event [Yes::Core::Event] event to extract context name from
|
|
146
|
+
# @return [String]
|
|
147
|
+
def event_context_name(event)
|
|
148
|
+
event.type.split('::').first
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @return [String]
|
|
152
|
+
def parent_modules_string
|
|
153
|
+
self.class.to_s.split('::')[0..-2].join('::')
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @return [Class] default read model class inferred from builder class name
|
|
157
|
+
# @raise [InvalidReadModelBuilderClass] if class name doesn't match expected pattern
|
|
158
|
+
def default_read_model_class
|
|
159
|
+
match = read_model_class_name_match
|
|
160
|
+
klass = [match[:version], match[:aggregate]].compact.join('::')
|
|
161
|
+
Kernel.const_get klass
|
|
162
|
+
rescue NameError, TypeError
|
|
163
|
+
msg = "The Read Model Builder Class #{self.class} does not match modules structure"
|
|
164
|
+
raise InvalidReadModelBuilderClass, msg
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
# @return [MatchData] match data from class name regex
|
|
170
|
+
def read_model_class_name_match
|
|
171
|
+
@read_model_class_name_match ||=
|
|
172
|
+
READ_MODEL_CLASS_REGEXP.match(self.class.to_s)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# @param locale [Symbol] locale to check
|
|
176
|
+
# @return [Boolean] true if locale is available
|
|
177
|
+
def correct_locale?(locale)
|
|
178
|
+
return true unless locale
|
|
179
|
+
|
|
180
|
+
I18n.available_locales.map(&:to_sym).include?(locale.to_sym)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# @param event [Yes::Core::Event] event to find read model for
|
|
184
|
+
# @return [ActiveRecord::Base] the read model instance
|
|
185
|
+
# @raise [MissingReadModelId] if event data doesn't contain aggregate id
|
|
186
|
+
def read_model(event)
|
|
187
|
+
read_model_id = event.data[aggregate_id_key]
|
|
188
|
+
raise MissingReadModelId.new(extra: event) unless read_model_id
|
|
189
|
+
|
|
190
|
+
find_or_create_read_model(read_model_id)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
if defined?(TrxExt)
|
|
194
|
+
def find_or_create_read_model(read_model_id)
|
|
195
|
+
read_model_class.trx do
|
|
196
|
+
read_model_class.find_by(id: read_model_id) || read_model_class.create(id: read_model_id)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
else
|
|
200
|
+
def find_or_create_read_model(read_model_id)
|
|
201
|
+
read_model_class.create_or_find_by!(id: read_model_id)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# derived from https://api.rubyonrails.org/classes/ActiveSupport/Inflector.html#method-i-underscore
|
|
206
|
+
# @param word [String] word to underscore
|
|
207
|
+
# @return [String] underscored word
|
|
208
|
+
def underscore(word)
|
|
209
|
+
w = word.dup
|
|
210
|
+
w.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
|
|
211
|
+
w.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
|
|
212
|
+
w.tr!('-', '_')
|
|
213
|
+
w.downcase
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# @param read_model [ActiveRecord::Base] the read model
|
|
217
|
+
# @param event [Yes::Core::Event] the event
|
|
218
|
+
# @param handler [Yes::Core::ReadModel::EventHandler] the handler that processed the event
|
|
219
|
+
def otl_record_processed(_read_model, event, handler)
|
|
220
|
+
return if ENV['STATSD_ADDR'].blank?
|
|
221
|
+
|
|
222
|
+
StatsD.increment(
|
|
223
|
+
'events_processing_total',
|
|
224
|
+
tags: {
|
|
225
|
+
type: 'consumer',
|
|
226
|
+
service: Rails.application.class.module_parent.name,
|
|
227
|
+
source: "#{event.metadata.dig('otl_contexts', 'root', 'service') || 'unknown'}-#{event.type}",
|
|
228
|
+
target: "#{Rails.application.class.module_parent.name}-#{self.class.name}",
|
|
229
|
+
read_model_builder_name: self.class.name,
|
|
230
|
+
event_handler_name: handler.class.name,
|
|
231
|
+
event: event.type
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# @param read_model [ActiveRecord::Base] the read model
|
|
237
|
+
# @param event [Yes::Core::Event] the event
|
|
238
|
+
def otl_record_data(read_model, event)
|
|
239
|
+
return unless self.class.otl_tracer
|
|
240
|
+
return if event.created_at.blank?
|
|
241
|
+
|
|
242
|
+
span_time = (Time.at(0, self.class.current_span.start_timestamp, :nanosecond).to_f * 1000).to_i
|
|
243
|
+
event_publish_delay_ms = span_time - (event.created_at.utc.to_f * 1000).to_i
|
|
244
|
+
|
|
245
|
+
self.class.current_span&.add_attributes(
|
|
246
|
+
{
|
|
247
|
+
'event_published_delay_ms' => event_publish_delay_ms,
|
|
248
|
+
'event_published_at_ms' => (event.created_at.to_f * 1000).to_i,
|
|
249
|
+
'command_request_started_at_ms' => event.metadata.dig('otl_contexts', 'timestamps',
|
|
250
|
+
'command_request_started_at_ms'),
|
|
251
|
+
'command_handling_started_at_ms' => event.metadata.dig('otl_contexts', 'timestamps',
|
|
252
|
+
'command_handling_started_at_ms'),
|
|
253
|
+
'read_model_builder.name' => self.class.name,
|
|
254
|
+
'read_model_builder.read_model.class' => read_model.class.name
|
|
255
|
+
}.compact_blank
|
|
256
|
+
)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# @param handler [Yes::Core::ReadModel::EventHandler] handler to build OTL data for
|
|
260
|
+
# @return [Yes::Core::OpenTelemetry::OtlSpan::OtlData]
|
|
261
|
+
def otl_data(handler)
|
|
262
|
+
OpenTelemetry::OtlSpan::OtlData.new(span_name: handler.class.name, span_kind: :consumer, track_sql: true)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module ReadModel
|
|
6
|
+
# Base class for event handlers that process events and update read models.
|
|
7
|
+
class EventHandler
|
|
8
|
+
include OpenTelemetry::Trackable
|
|
9
|
+
|
|
10
|
+
attr_accessor :read_model, :payload_store_lookup
|
|
11
|
+
private :read_model, :payload_store_lookup, :read_model=, :payload_store_lookup=
|
|
12
|
+
|
|
13
|
+
# @param read_model [ActiveRecord::Base] AR object
|
|
14
|
+
# @param payload_store_lookup [#call] payload store lookup instance
|
|
15
|
+
def initialize(
|
|
16
|
+
read_model,
|
|
17
|
+
payload_store_lookup: Yes::Core::PayloadStore::Lookup.new
|
|
18
|
+
)
|
|
19
|
+
raise ArgumentError unless read_model
|
|
20
|
+
|
|
21
|
+
self.read_model = read_model
|
|
22
|
+
self.payload_store_lookup = payload_store_lookup
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param event [Yes::Core::Event] event to handle
|
|
26
|
+
# @return [Yes::Core::Event] the processed event
|
|
27
|
+
def call(event)
|
|
28
|
+
otl_record_event_data(event) if self.class.otl_tracer
|
|
29
|
+
|
|
30
|
+
payload_store_lookup.call(event).each do |key, value|
|
|
31
|
+
event.data[key.to_s] = value
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
event
|
|
35
|
+
end
|
|
36
|
+
otl_trackable :call, OpenTelemetry::OtlSpan::OtlData.new(span_kind: :consumer, track_sql: true)
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# @param event [Yes::Core::Event] event to record telemetry data for
|
|
41
|
+
def otl_record_event_data(event)
|
|
42
|
+
return if event.created_at.blank?
|
|
43
|
+
|
|
44
|
+
span_time = (Time.at(0, self.class.current_span.start_timestamp, :nanosecond).to_f * 1000).to_i
|
|
45
|
+
event_publish_delay_ms = span_time - (event.created_at.utc.to_f * 1000).to_i
|
|
46
|
+
|
|
47
|
+
self.class.current_span&.add_attributes(
|
|
48
|
+
{
|
|
49
|
+
'event_published_delay_ms' => event_publish_delay_ms,
|
|
50
|
+
'event_published_at_ms' => (event.created_at.utc.to_f * 1000).to_i,
|
|
51
|
+
'command_request_started_at_ms' => event.metadata.dig('otl_contexts', 'timestamps',
|
|
52
|
+
'command_request_started_at_ms'),
|
|
53
|
+
'command_handling_started_at_ms' => event.metadata.dig('otl_contexts', 'timestamps',
|
|
54
|
+
'command_handling_started_at_ms'),
|
|
55
|
+
'event.type' => event.type,
|
|
56
|
+
'event.data' => event.data.to_json,
|
|
57
|
+
'event.metadata' => event.metadata.to_json
|
|
58
|
+
}.compact_blank
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'has_scope'
|
|
4
|
+
|
|
5
|
+
module Yes
|
|
6
|
+
module Core
|
|
7
|
+
module ReadModel
|
|
8
|
+
# Inherit from this class to define your implementation. Your class should implement #read_model_class instance
|
|
9
|
+
# method should determine what ActiveRecord model class to use.
|
|
10
|
+
# Example usage:
|
|
11
|
+
# ```ruby
|
|
12
|
+
# class JobAppFilter < Yes::Core::ReadModel::Filter
|
|
13
|
+
# has_scope :ids do |_controller, scope, value|
|
|
14
|
+
# scope.where(id: value.split(','))
|
|
15
|
+
# end
|
|
16
|
+
# has_scope :order_by_name do |_controller, scope, value|
|
|
17
|
+
# scope.order(name: value)
|
|
18
|
+
# end
|
|
19
|
+
# has_scope :order_by_id do |_controller, scope, value|
|
|
20
|
+
# scope.order(id: value)
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# private
|
|
24
|
+
#
|
|
25
|
+
# def read_model_class
|
|
26
|
+
# ::JobApp
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
# ```
|
|
30
|
+
#
|
|
31
|
+
# Now you can use it as follows:
|
|
32
|
+
# ```ruby
|
|
33
|
+
# JobAppFilter.new(order: { name: 'asc', id: 'desc' }, filters: { ids: '1,2,3' }).call
|
|
34
|
+
# ```
|
|
35
|
+
class Filter
|
|
36
|
+
include HasScope
|
|
37
|
+
include FilterQueryBuilder
|
|
38
|
+
|
|
39
|
+
ORDER_DIRECTIONS = {
|
|
40
|
+
'asc' => 'asc', 'desc' => 'desc', '0' => 'asc', '1' => 'desc'
|
|
41
|
+
}.tap { |h| h.default = 'asc' }.freeze
|
|
42
|
+
|
|
43
|
+
attr_accessor :options, :type
|
|
44
|
+
private :options, :type
|
|
45
|
+
|
|
46
|
+
# Returns scope of persisted filters if given filter supports saving.
|
|
47
|
+
# e. g. `AdvancedFilter.where(read_model: 'apprenticeships')`
|
|
48
|
+
# @return [ActiveRecord::Relation]
|
|
49
|
+
def self.persisted_filter_scope
|
|
50
|
+
raise NotImplementedError
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @param options [Hash]
|
|
54
|
+
# @option [Hash] :order defines an order of records of your query.
|
|
55
|
+
# Example: { order: { name: 'asc', id: 'desc' } }.
|
|
56
|
+
# Note: you should have `:order_by_name` and `:order_by_id` scopes defined using `#has_scope` class method
|
|
57
|
+
# @option [Hash] :filters defines a filtering of records of your query.
|
|
58
|
+
# Example: { filters: { ids: '1,2,3', name: 'Awasome-4000' } }
|
|
59
|
+
# Note: you should have `:ids` scope defined using `#has_scope` class method
|
|
60
|
+
# @option [Hash] :filter_definition defines an advanced filter supporting linking multiple filters with AND/OR
|
|
61
|
+
# logic.
|
|
62
|
+
# Example:
|
|
63
|
+
# {
|
|
64
|
+
# filter_definition: {
|
|
65
|
+
# type: 'filter_set',
|
|
66
|
+
# logical_operator: 'or',
|
|
67
|
+
# filters: [
|
|
68
|
+
# { type: 'filter', attribute: 'first_name', operator: 'is', value: 'Foo' },
|
|
69
|
+
# { type: 'filter', attribute: 'last_name', operator: 'is_not', value: 'Bar' }
|
|
70
|
+
# ]
|
|
71
|
+
# }
|
|
72
|
+
# }
|
|
73
|
+
# @param [Symbol] :type defines the type of filter to use, default is :basic. Passing :advanced is required to
|
|
74
|
+
# use the :filter_definition option.
|
|
75
|
+
def initialize(options, type: :basic)
|
|
76
|
+
@options = options
|
|
77
|
+
@type = type
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [ActiveRecord::Relation]
|
|
81
|
+
def call
|
|
82
|
+
scope = read_model_class.order(id: :asc)
|
|
83
|
+
if type == :basic
|
|
84
|
+
scope = apply_scopes(scope, options[:filters]) if options[:filters].is_a?(Hash)
|
|
85
|
+
else
|
|
86
|
+
scope = process_advanced_filter(scope, options[:filter_definition])
|
|
87
|
+
end
|
|
88
|
+
scope = apply_scopes(scope.reorder(nil), ordering_options(options[:order])) if options[:order].is_a?(Hash)
|
|
89
|
+
scope
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# Transforms a hash into a set of scopes names and normalized directions.
|
|
95
|
+
# Example:
|
|
96
|
+
# Given options:
|
|
97
|
+
# ```ruby
|
|
98
|
+
# { name: '1' }
|
|
99
|
+
# ```
|
|
100
|
+
# Will result in:
|
|
101
|
+
# ```ruby
|
|
102
|
+
# { order_by_name: 'desc' }
|
|
103
|
+
# ```
|
|
104
|
+
# @param options [Hash] order options
|
|
105
|
+
# @return [Hash]
|
|
106
|
+
def ordering_options(options)
|
|
107
|
+
options.each_with_object({}) do |(column_name, direction), result|
|
|
108
|
+
result[:"order_by_#{column_name}"] = ORDER_DIRECTIONS[direction]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def read_model_class
|
|
113
|
+
raise NotImplementedError
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module ReadModel
|
|
6
|
+
# Provides advanced filter query building capabilities using AND/OR logic.
|
|
7
|
+
# Include this module in filter classes that need to support advanced filtering.
|
|
8
|
+
module FilterQueryBuilder
|
|
9
|
+
AND_LOGICAL_OPERATOR = 'and'
|
|
10
|
+
OR_LOGICAL_OPERATOR = 'or'
|
|
11
|
+
IS_OPERATOR = 'is'
|
|
12
|
+
IS_NOT_OPERATOR = 'is_not'
|
|
13
|
+
|
|
14
|
+
FilterSchema = Dry::Schema.Params do
|
|
15
|
+
required(:type).value(eql?: 'filter')
|
|
16
|
+
required(:attribute).filled(:string)
|
|
17
|
+
required(:operator).value(included_in?: [IS_NOT_OPERATOR, IS_OPERATOR])
|
|
18
|
+
required(:value).value(:any)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
FilterSetSchema = Dry::Schema.Params do
|
|
22
|
+
required(:type).value(eql?: 'filter_set')
|
|
23
|
+
required(:logical_operator).value(included_in?: [AND_LOGICAL_OPERATOR, OR_LOGICAL_OPERATOR])
|
|
24
|
+
required(:filters).array(FilterSchema)
|
|
25
|
+
optional(:scope).value(:hash)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# Builds an advanced filter query based on the filter definition.
|
|
31
|
+
#
|
|
32
|
+
# @param base_advanced_filter_scope [ActiveRecord::Relation] The base scope to apply the filter to.
|
|
33
|
+
# @param filter_definition [Hash] The filter definition to use.
|
|
34
|
+
#
|
|
35
|
+
# @return [ActiveRecord::Relation]
|
|
36
|
+
def process_advanced_filter(base_advanced_filter_scope, filter_definition)
|
|
37
|
+
return base_advanced_filter_scope if filter_definition.blank?
|
|
38
|
+
raise 'Invalid filter definition' unless FilterSetSchema.call(filter_definition).success?
|
|
39
|
+
|
|
40
|
+
@base_advanced_filter_scope = base_advanced_filter_scope
|
|
41
|
+
@filter_definition = filter_definition.deep_symbolize_keys
|
|
42
|
+
apply_main_scope
|
|
43
|
+
|
|
44
|
+
return build_and_query if @filter_definition[:logical_operator] == AND_LOGICAL_OPERATOR
|
|
45
|
+
|
|
46
|
+
build_or_query
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Chains multiple filters with AND logic.
|
|
50
|
+
#
|
|
51
|
+
# @return [ActiveRecord::Relation]
|
|
52
|
+
def build_and_query
|
|
53
|
+
scope = @base_advanced_filter_scope
|
|
54
|
+
@filter_definition[:filters].each do |filter|
|
|
55
|
+
scope =
|
|
56
|
+
if filter[:operator] == IS_OPERATOR
|
|
57
|
+
apply_is_filter(scope, filter)
|
|
58
|
+
else
|
|
59
|
+
apply_is_not_filter(scope, filter)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
scope
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Chains multiple filters with OR logic.
|
|
66
|
+
#
|
|
67
|
+
# @return [ActiveRecord::Relation]
|
|
68
|
+
def build_or_query
|
|
69
|
+
scopes = @filter_definition[:filters].map do |filter|
|
|
70
|
+
if filter[:operator] == IS_OPERATOR
|
|
71
|
+
apply_is_filter(@base_advanced_filter_scope, filter)
|
|
72
|
+
else
|
|
73
|
+
apply_is_not_filter(@base_advanced_filter_scope, filter)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
scope = scopes.first
|
|
78
|
+
scopes[1..].each do |additional_scope|
|
|
79
|
+
scope = scope.or(additional_scope)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
scope
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Applies given scope to the base advanced filter scope to support authorization.
|
|
86
|
+
def apply_main_scope
|
|
87
|
+
return if @filter_definition[:scope].blank?
|
|
88
|
+
|
|
89
|
+
@base_advanced_filter_scope = apply_scopes(@base_advanced_filter_scope, @filter_definition[:scope])
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Applies a single filter to the scope.
|
|
93
|
+
def apply_is_filter(scope, filter)
|
|
94
|
+
apply_scopes(scope, { filter[:attribute].to_sym => filter[:value] })
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Applies negation of a single filter to the scope.
|
|
98
|
+
def apply_is_not_filter(scope, filter)
|
|
99
|
+
scope.where.not(id: apply_is_filter(@base_advanced_filter_scope, filter))
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jsonapi/serializer'
|
|
4
|
+
|
|
5
|
+
module Yes
|
|
6
|
+
module Core
|
|
7
|
+
# Base JSON:API serializer for read models.
|
|
8
|
+
#
|
|
9
|
+
# All auto-generated and user-defined serializers inherit from this class.
|
|
10
|
+
# Wraps the jsonapi-serializer gem.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# class UserSerializer < Yes::Core::Serializer
|
|
14
|
+
# set_type 'users'
|
|
15
|
+
# attributes :id, :email, :first_name, :last_name
|
|
16
|
+
# end
|
|
17
|
+
class Serializer
|
|
18
|
+
include JSONAPI::Serializer
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
|
|
5
|
+
module Yes
|
|
6
|
+
module Core
|
|
7
|
+
# Manages PgEventstore subscriptions with optional heartbeat and OpenTelemetry tracing.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# subscriptions = Yes::Core::Subscriptions.new
|
|
11
|
+
# subscriptions.subscribe_to_all(handler, filter_opts)
|
|
12
|
+
# subscriptions.start
|
|
13
|
+
class Subscriptions
|
|
14
|
+
include Yes::Core::OpenTelemetry::Trackable
|
|
15
|
+
|
|
16
|
+
# @return [Integer] Timeout in seconds for subscriptions to start
|
|
17
|
+
SUBSCRIPTIONS_START_TIMEOUT = 20
|
|
18
|
+
|
|
19
|
+
# @return [PgEventstore::SubscriptionsManager] the subscriptions manager
|
|
20
|
+
attr_reader :subscriptions_manager
|
|
21
|
+
|
|
22
|
+
# @return [PgEventstore::Client] the event store client
|
|
23
|
+
attr_reader :client
|
|
24
|
+
|
|
25
|
+
# Initializes subscriptions with the given PgEventstore config.
|
|
26
|
+
#
|
|
27
|
+
# @param config_name [Symbol] the PgEventstore configuration name
|
|
28
|
+
def initialize(config_name: :default)
|
|
29
|
+
@subscriptions_manager = PgEventstore.subscriptions_manager(
|
|
30
|
+
config_name,
|
|
31
|
+
subscription_set: Rails.application.class.name.split('::').first
|
|
32
|
+
)
|
|
33
|
+
@client = PgEventstore.client(config_name)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Subscribes a handler to all events matching the given filter.
|
|
37
|
+
#
|
|
38
|
+
# @param handler [#call] the event handler
|
|
39
|
+
# @param filter_opts [Hash] PgEventstore filter options
|
|
40
|
+
# @param subscription_opts [Hash] additional subscription options
|
|
41
|
+
# @return [void]
|
|
42
|
+
def subscribe_to_all(handler, filter_opts, **subscription_opts)
|
|
43
|
+
subscriptions_manager.subscribe(
|
|
44
|
+
handler.class.to_s,
|
|
45
|
+
handler: self.class.otl_tracer ? otl_trackable_handler(handler) : handler,
|
|
46
|
+
options: { filter: filter_opts, resolve_link_tos: true },
|
|
47
|
+
**subscription_opts
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Starts the subscriptions manager and optional heartbeat.
|
|
52
|
+
#
|
|
53
|
+
# @return [void]
|
|
54
|
+
def start
|
|
55
|
+
start_heartbeat if Yes::Core.configuration.subscriptions_heartbeat_url.present?
|
|
56
|
+
subscriptions_manager.start
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# Wraps a handler with OpenTelemetry tracing.
|
|
62
|
+
#
|
|
63
|
+
# @param handler [#call] the event handler to wrap
|
|
64
|
+
# @return [Proc] the wrapped handler
|
|
65
|
+
def otl_trackable_handler(handler)
|
|
66
|
+
proc do |event|
|
|
67
|
+
otl_data = Yes::Core::OpenTelemetry::OtlSpan::OtlData.new(
|
|
68
|
+
span_name: handler.class.name,
|
|
69
|
+
span_kind: :consumer,
|
|
70
|
+
links_extractor: ->(ev, **) { ev.metadata['otl_contexts'] },
|
|
71
|
+
track_sql: true
|
|
72
|
+
)
|
|
73
|
+
Yes::Core::OpenTelemetry::OtlSpan.new(otl_data:, otl_tracer: self.class.otl_tracer).
|
|
74
|
+
otl_span(event) { handler.call(event) }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Starts a background thread that periodically sends a heartbeat HTTP GET request.
|
|
79
|
+
#
|
|
80
|
+
# @return [Thread] the heartbeat thread
|
|
81
|
+
def start_heartbeat
|
|
82
|
+
Thread.new do
|
|
83
|
+
loop do
|
|
84
|
+
sleep Yes::Core.configuration.subscriptions_heartbeat_interval
|
|
85
|
+
uri = URI(Yes::Core.configuration.subscriptions_heartbeat_url)
|
|
86
|
+
Net::HTTP.get(uri)
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
Rails.logger.warn("Subscriptions heartbeat failed: #{e.message}") if defined?(Rails)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module TestSupport
|
|
6
|
+
# Helpers for working with PgEventstore events in tests.
|
|
7
|
+
module EventHelpers
|
|
8
|
+
# @param stream [PgEventstore::Stream]
|
|
9
|
+
# @param event [Yes::Core::Event]
|
|
10
|
+
# @return [Yes::Core::Event]
|
|
11
|
+
def append_and_reload_event(stream, event)
|
|
12
|
+
PgEventstore.client.append_to_stream(stream, event)
|
|
13
|
+
PgEventstore.client.read(stream, options: { max_count: 1, direction: :desc }).first
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Reads eventstore and returns events from the stream or an empty array if stream does not exist
|
|
17
|
+
# @param stream [PgEventstore::Stream]
|
|
18
|
+
# @return [Array<Yes::Core::Event>]
|
|
19
|
+
def safe_read(stream)
|
|
20
|
+
PgEventstore.client.read(stream)
|
|
21
|
+
rescue PgEventstore::StreamNotFoundError
|
|
22
|
+
[]
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|