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,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class Aggregate
6
+ # Provides read model functionality for aggregates
7
+ #
8
+ # @example Include in an aggregate
9
+ # class UserAggregate < Yes::Core::Aggregate
10
+ # include Yes::Core::Concerns::HasReadModel
11
+ # end
12
+ module HasReadModel
13
+ extend ActiveSupport::Concern
14
+
15
+ included do
16
+ class << self
17
+ attr_accessor :_read_model_name, :_read_model_public, :_read_model_class, :_read_model_filter_class,
18
+ :_read_model_serializer_class, :_read_model_enabled
19
+ end
20
+
21
+ # Default to enabled
22
+ self._read_model_enabled = true
23
+ end
24
+
25
+ class_methods do
26
+ # Returns the read model class associated with the current aggregate.
27
+ # The class is resolved using ReadModelClassResolver, which either uses an explicitly set
28
+ # class name or derives it from the current namespace.
29
+ #
30
+ # @return [Class] The read model class
31
+ # @raise [NameError] If the read model class cannot be found
32
+ def read_model_class
33
+ _read_model_class
34
+ end
35
+
36
+ # Sets up all read model related classes for the aggregate
37
+ #
38
+ # @return [void]
39
+ # @note This method initializes three components:
40
+ # - The read model class itself
41
+ # - The read model filter class for querying
42
+ # - The read model serializer class for data transformation
43
+ def setup_read_model_classes
44
+ setup_read_model
45
+ setup_read_model_filter
46
+ setup_read_model_serializer
47
+ end
48
+
49
+ # @return [String] The name of the read model
50
+ def read_model_name
51
+ self._read_model_name ||= "#{context}_#{aggregate}".underscore
52
+ end
53
+
54
+ # Sets the read model configuration for this aggregate
55
+ # @param name [String, false] The name for the read model, or false to disable read models
56
+ # @param public [Boolean] Whether the read model should be public via read API
57
+ def read_model(name, public: true)
58
+ if name == false
59
+ self._read_model_enabled = false
60
+ return
61
+ end
62
+
63
+ self._read_model_name = name.to_s.underscore
64
+ self._read_model_public = public
65
+ end
66
+
67
+ # @return [Boolean] Whether the read model is public
68
+ def read_model_public?
69
+ _read_model_public.nil? || _read_model_public
70
+ end
71
+
72
+ # @return [Boolean] Whether the read model is enabled
73
+ def read_model_enabled?
74
+ _read_model_enabled != false
75
+ end
76
+
77
+ private
78
+
79
+ def setup_read_model
80
+ self._read_model_class = resolve_read_model_class
81
+ end
82
+
83
+ def setup_read_model_filter
84
+ self._read_model_filter_class = resolve_read_model_filter_class
85
+ end
86
+
87
+ def setup_read_model_serializer
88
+ self._read_model_serializer_class = resolve_read_model_serializer_class
89
+ end
90
+
91
+ def resolve_read_model_class
92
+ Yes::Core::Aggregate::Dsl::ClassResolvers::ReadModel.new(read_model_name, context, aggregate).call
93
+ end
94
+
95
+ def resolve_read_model_filter_class
96
+ Yes::Core::Aggregate::Dsl::ClassResolvers::ReadModelFilter.new(read_model_name, context, aggregate).call
97
+ end
98
+
99
+ def resolve_read_model_serializer_class
100
+ Yes::Core::Aggregate::Dsl::ClassResolvers::ReadModelSerializer.new(
101
+ read_model_name, context, aggregate, attributes.keys
102
+ ).call
103
+ end
104
+ end
105
+
106
+ # Updates or creates a read model with the given attributes
107
+ #
108
+ # @param attributes [Hash] The attributes to update the read model with
109
+ # @return [Boolean] Returns true if the record is saved successfully
110
+ # @raise [ActiveRecord::RecordInvalid] If the record is invalid
111
+ def update_read_model(attributes)
112
+ locale = attributes.delete(:locale) || I18n.locale
113
+ I18n.with_locale(locale) do
114
+ read_model.update!(attributes)
115
+ end
116
+ end
117
+
118
+ # Retrieves or creates a read model instance for this aggregate
119
+ #
120
+ # @return [ApplicationRecord, nil] The read model instance associated with this aggregate's ID, or nil if disabled
121
+ # @example
122
+ # user_aggregate = UserAggregate.new(1)
123
+ # user_aggregate.read_model #=> #<User id: 1>
124
+ def read_model
125
+ return nil unless self.class.read_model_enabled?
126
+
127
+ @read_model ||= self.class.read_model_class.find_or_create_by(id:)
128
+ end
129
+
130
+ # Removes the read model instance for this aggregate
131
+ def remove_read_model
132
+ read_model.destroy
133
+ @read_model = nil
134
+ end
135
+
136
+ # Rebuilds the read model by processing all events
137
+ # @param remove [Boolean] Whether to remove the read model before rebuilding
138
+ # @return [void]
139
+ def rebuild_read_model(remove: true)
140
+ Yes::Core::Aggregate::ReadModelRebuilder.new(self).call(remove:)
141
+ end
142
+
143
+ def revision_column
144
+ return :revision unless self.class.read_model_enabled?
145
+
146
+ aggregate_revision_column = "#{self.class.context.underscore}_#{self.class.aggregate.underscore}_revision"
147
+ return aggregate_revision_column.to_sym if read_model.class.column_names.include?(aggregate_revision_column)
148
+
149
+ :revision
150
+ end
151
+
152
+ # Returns the current revision number from the read model
153
+ # @return [Integer, nil] The revision number stored in the read model, or -1 if read models are disabled
154
+ def revision
155
+ return -1 unless self.class.read_model_enabled?
156
+
157
+ read_model.send(revision_column)
158
+ end
159
+
160
+ # Initializes the read model's revision column with the current event stream revision
161
+ # @return [Boolean] True if the update was successful
162
+ # @note This method bypasses validations and callbacks by using update_column
163
+ def init_revision_from_stream
164
+ read_model.update_column(revision_column, event_revision)
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class Aggregate
6
+ # Handles rebuilding of read models for aggregates
7
+ #
8
+ # @example Using the rebuilder
9
+ # rebuilder = ReadModelRebuilder.new(aggregate)
10
+ # rebuilder.call
11
+ class ReadModelRebuilder
12
+ attr_reader :aggregate
13
+ private :aggregate
14
+
15
+ delegate :events, :remove_read_model, :read_model, :revision_column, to: :aggregate
16
+
17
+ # @param aggregate [Yes::Core::Aggregate] The aggregate whose read model needs rebuilding
18
+ def initialize(aggregate)
19
+ @aggregate = aggregate
20
+ end
21
+
22
+ # Rebuilds the read model by processing all events
23
+ # @param remove [Boolean] Whether to remove the read model before rebuilding
24
+ # @return [void]
25
+ def call(remove: true)
26
+ remove ? remove_read_model : read_model.update(revision_column => -1)
27
+ events.each { |events_page| events_page.each { |event| process_event(event) } }
28
+ end
29
+
30
+ private
31
+
32
+ # @param event [Object] The event to process for read model rebuilding
33
+ # @return [void]
34
+ def process_event(event)
35
+ CommandHandling::ReadModelUpdater.new(aggregate).call(event, nil, resolve_payload: true)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class Aggregate
6
+ # Handles rebuilding of read models that are shared by multiple aggregates
7
+ #
8
+ # @example Using the shared rebuilder
9
+ # rebuilder = SharedReadModelRebuilder.new(SharedUserReadModel)
10
+ # rebuilder.call
11
+ class SharedReadModelRebuilder
12
+ # Value object to hold event data with aggregate information
13
+ EventWithAggregate = Struct.new(:event, :aggregate, keyword_init: true) do
14
+ # @return [Time] The creation timestamp of the event
15
+ delegate :created_at, to: :event
16
+ end
17
+
18
+ # @param read_model_class [Class] The Active Record read model class to rebuild
19
+ # @param ids [Array<String>] Array of IDs to rebuild
20
+ # @example
21
+ # rebuilder = SharedReadModelRebuilder.new(SharedUserProfile, ['user-1', 'user-2'])
22
+ # rebuilder.call
23
+ def initialize(read_model_class, ids)
24
+ @read_model_class = read_model_class
25
+ @ids = ids
26
+ @aggregate_types = find_aggregates_using_read_model
27
+ end
28
+
29
+ # Rebuilds the shared read model by processing all events from all aggregates
30
+ # @return [void]
31
+ def call
32
+ ids.each do |id|
33
+ rebuild_read_model_for_id(id)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ # @return [Class] The read model class being rebuilt
40
+ # @return [Array<String>] The IDs to rebuild
41
+ # @return [Array<Array<String>>] The aggregate types that use this read model
42
+ attr_reader :read_model_class, :ids, :aggregate_types
43
+
44
+ # Finds all aggregates that use the given read model class
45
+ # @return [Array<Array<String>>] Array of [context_name, aggregate_name] pairs
46
+ def find_aggregates_using_read_model
47
+ aggregates = []
48
+
49
+ Yes::Core.configuration.list_all_registered_classes.each do |key, classes|
50
+ next unless classes[:read_model] == read_model_class
51
+
52
+ context_name, aggregate_name = key
53
+ aggregates << [context_name, aggregate_name]
54
+ end
55
+
56
+ aggregates
57
+ end
58
+
59
+ # Rebuilds the read model for a single ID
60
+ # @param id [String] The ID to rebuild
61
+ # @return [void]
62
+ def rebuild_read_model_for_id(id)
63
+ remove_read_model_for_id(id)
64
+
65
+ aggregates = instantiate_aggregates_for_id(id)
66
+ events_with_aggregates = collect_events_from_aggregates(aggregates)
67
+ sorted_events = events_with_aggregates.sort_by(&:created_at)
68
+
69
+ sorted_events.each do |event_with_aggregate|
70
+ process_event_with_aggregate(event_with_aggregate)
71
+ end
72
+ end
73
+
74
+ # Removes the read model instance for a specific ID
75
+ # @param id [String] The ID to remove
76
+ # @return [void]
77
+ def remove_read_model_for_id(id)
78
+ read_model_class.find_by(id: id)&.destroy
79
+ end
80
+
81
+ # Instantiates all aggregate instances for a given ID
82
+ # @param id [String] The ID to instantiate aggregates for
83
+ # @return [Array<Aggregate>] The instantiated aggregates
84
+ def instantiate_aggregates_for_id(id)
85
+ aggregate_types.map do |context_name, aggregate_name|
86
+ aggregate_class = build_aggregate_class(context_name, aggregate_name)
87
+ aggregate_class.new(id)
88
+ end
89
+ end
90
+
91
+ # Collects all events from the given aggregates
92
+ # @param aggregates [Array<Aggregate>] The aggregates to collect events from
93
+ # @return [Array<EventWithAggregate>] All events with their aggregate references
94
+ def collect_events_from_aggregates(aggregates)
95
+ events_with_aggregates = []
96
+
97
+ aggregates.each do |aggregate|
98
+ aggregate.events.each do |events_page|
99
+ events_page.each do |event|
100
+ events_with_aggregates << EventWithAggregate.new(
101
+ event: event,
102
+ aggregate: aggregate
103
+ )
104
+ end
105
+ end
106
+ end
107
+
108
+ events_with_aggregates
109
+ end
110
+
111
+ # Builds the aggregate class from context and aggregate names
112
+ # @param context_name [String] The context name
113
+ # @param aggregate_name [String] The aggregate name
114
+ # @return [Class] The aggregate class
115
+ def build_aggregate_class(context_name, aggregate_name)
116
+ "#{context_name}::#{aggregate_name}::Aggregate".constantize
117
+ end
118
+
119
+ # Processes a single event with its aggregate information
120
+ # @param event_with_aggregate [EventWithAggregate] The event with aggregate data
121
+ # @return [void]
122
+ def process_event_with_aggregate(event_with_aggregate)
123
+ aggregate = event_with_aggregate.aggregate
124
+ command_utilities = aggregate.send(:command_utilities)
125
+
126
+ command_name = command_utilities.command_name_from_event(
127
+ event_with_aggregate.event,
128
+ aggregate.class
129
+ )
130
+
131
+ payload = event_with_aggregate.event.data
132
+ locale = payload.delete(:locale)
133
+
134
+ state_updater = command_utilities.fetch_state_updater_class(command_name).new(
135
+ payload: payload,
136
+ aggregate: aggregate
137
+ )
138
+
139
+ # Determine which revision column to use
140
+ context_revision_column =
141
+ "#{aggregate.class.context.underscore}_#{aggregate.class.aggregate.underscore}_revision"
142
+ revision_column = if aggregate.read_model.class.column_names.include?(context_revision_column)
143
+ context_revision_column.to_sym
144
+ else
145
+ :revision
146
+ end
147
+
148
+ aggregate.update_read_model(
149
+ state_updater.call.merge(
150
+ revision_column => event_with_aggregate.event.stream_revision,
151
+ locale: locale
152
+ )
153
+ )
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end