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,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
# Base event class for all events in the system.
|
|
6
|
+
# Inherits from PgEventstore::Event for PostgreSQL-based event storage.
|
|
7
|
+
class Event < PgEventstore::Event
|
|
8
|
+
DEFAULT_VERSION = 1
|
|
9
|
+
PAYLOAD_STORE_VALUE_PREFIX = 'payload-store:'
|
|
10
|
+
|
|
11
|
+
InvalidDataError = Class.new(StandardError)
|
|
12
|
+
|
|
13
|
+
# Stores the event version blocks
|
|
14
|
+
@versions = {}
|
|
15
|
+
|
|
16
|
+
# Module included in versioned event subclasses to provide correct naming
|
|
17
|
+
module VersionedEvent
|
|
18
|
+
# Class methods for versioned events
|
|
19
|
+
module ClassMethods
|
|
20
|
+
# Overwrite name so that it reports the correct class name which is set from parent class
|
|
21
|
+
# @return [String]
|
|
22
|
+
def name
|
|
23
|
+
@_class_name
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Add class methods
|
|
28
|
+
# @param base [Class]
|
|
29
|
+
def self.included(base)
|
|
30
|
+
base.extend(ClassMethods)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Overwrite inspect so that it reports the correct class name
|
|
34
|
+
# @return [String]
|
|
35
|
+
def inspect
|
|
36
|
+
variables = attributes_hash.keys.map { |v| "#{v}=#{public_send(v).inspect}" }
|
|
37
|
+
"#<#{self.class.name} #{variables.join(' ')}>"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
# Instantiates a new event. If no version is given, the default version is used.
|
|
43
|
+
# @param args [Hash]
|
|
44
|
+
# @return [Yes::Core::Event]
|
|
45
|
+
def new(**args)
|
|
46
|
+
# Prevent creating infinite subclasses of versioned events
|
|
47
|
+
return super if include?(VersionedEvent)
|
|
48
|
+
|
|
49
|
+
options = args.dup
|
|
50
|
+
version = options[:version] || options.dig(:metadata, 'version')
|
|
51
|
+
|
|
52
|
+
# Add version to custom metadata if provided
|
|
53
|
+
if options[:version]
|
|
54
|
+
options[:metadata] = options[:metadata]&.dup || {}
|
|
55
|
+
options[:metadata]['version'] ||= version
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
versioned_event_class(version).new(**options)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Returns a dynamic event class as a subclass of the current event class.
|
|
62
|
+
# This is done so that we can have different versions of the same event with different
|
|
63
|
+
# schemas.
|
|
64
|
+
# @param version [Integer]
|
|
65
|
+
# @return [Class<Yes::Core::Event>] versioned event class
|
|
66
|
+
def versioned_event_class(version)
|
|
67
|
+
Class.new(self, &@versions[version || DEFAULT_VERSION]).tap do |versioned_event_class|
|
|
68
|
+
versioned_event_class.instance_variable_set(:@_class_name, name)
|
|
69
|
+
versioned_event_class.include(VersionedEvent)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Defines the fields that are stored in the payload store
|
|
74
|
+
# @param fields [Array<Symbol>]
|
|
75
|
+
def payload_store_fields(fields)
|
|
76
|
+
@ps_store_fields = [*fields].map(&:to_s)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @return [Array<String>] the fields that are stored in the payload store
|
|
80
|
+
def ps_store_fields
|
|
81
|
+
@ps_store_fields&.map(&:to_s) || []
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Defines a new version of the event. The block is evaluated in the context of the new
|
|
85
|
+
# event class.
|
|
86
|
+
# @param number [Integer] The version number
|
|
87
|
+
# @param blk [Proc] The block containing schema and transformations
|
|
88
|
+
def version(number, &blk)
|
|
89
|
+
raise ArgumentError, 'Version number must be an integer' unless number.is_a?(Integer)
|
|
90
|
+
|
|
91
|
+
undefined_versions = (1...number).reject { |n| @versions.key?(n) }
|
|
92
|
+
raise ArgumentError, "Previous versions not defined: #{undefined_versions.join(', ')}" if undefined_versions.any?
|
|
93
|
+
|
|
94
|
+
@versions[number] = blk
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Initializes class variables for subclasses.
|
|
98
|
+
# @param subclass [Class]
|
|
99
|
+
def inherited(subclass)
|
|
100
|
+
subclass.instance_variable_set(:@versions, {})
|
|
101
|
+
subclass.instance_variable_set(:@ps_store_fields, @ps_store_fields)
|
|
102
|
+
|
|
103
|
+
super
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def initialize(**attrs)
|
|
108
|
+
validate(attrs[:data]) unless attrs[:skip_validation]
|
|
109
|
+
super
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
alias to_h options_hash
|
|
113
|
+
|
|
114
|
+
# @return [Hash]
|
|
115
|
+
def as_json(*)
|
|
116
|
+
to_h.transform_keys(&:to_s)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @return [String]
|
|
120
|
+
def to_json(*)
|
|
121
|
+
as_json.to_json(*)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @return [Hash] payload store fields with their values
|
|
125
|
+
def ps_fields_with_values
|
|
126
|
+
data.select do |_, value|
|
|
127
|
+
next unless value.is_a?(String)
|
|
128
|
+
|
|
129
|
+
value.start_with?(PAYLOAD_STORE_VALUE_PREFIX) && value.split(':').last =~ Types::UUID_REGEXP
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Checks whether the event contains encrypted data
|
|
134
|
+
# @return [Boolean]
|
|
135
|
+
def encrypted?
|
|
136
|
+
metadata&.key?('encryption') && !metadata['encryption'].empty?
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# @return [Integer] the event version
|
|
140
|
+
def version
|
|
141
|
+
metadata['version'] || DEFAULT_VERSION
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @param version [Integer] the event version
|
|
145
|
+
def version=(version)
|
|
146
|
+
metadata['version'] = version
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @return [Hash] the otl context
|
|
150
|
+
# @param type [Symbol, nil] :publisher or :root
|
|
151
|
+
def otl_context(type:)
|
|
152
|
+
return metadata['otl_contexts'] unless type
|
|
153
|
+
|
|
154
|
+
metadata.dig('otl_contexts', type.to_s) || {}
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# @param context [Hash] the otl context
|
|
158
|
+
# @option context [String] :traceparent the standard open https://www.w3.org/TR/trace-context/#header-name
|
|
159
|
+
# @param type [Symbol, nil] :publisher or :root
|
|
160
|
+
def otl_context=(context, type:)
|
|
161
|
+
return metadata['otl_contexts'] = context unless type
|
|
162
|
+
|
|
163
|
+
metadata['otl_contexts'] ||= {}
|
|
164
|
+
metadata['otl_contexts'][type.to_s] = context
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Transforms the event to a new version
|
|
168
|
+
# @param direction [Symbol] :up or :down
|
|
169
|
+
# @return [Event] the transformed event
|
|
170
|
+
def transform(direction)
|
|
171
|
+
return self unless self.class.include?(VersionedEvent)
|
|
172
|
+
raise ArgumentError, 'Direction is not valid' unless %i[up down].include?(direction)
|
|
173
|
+
|
|
174
|
+
transformed_event = DataTransformation.new(send(direction), data).call
|
|
175
|
+
v = direction == :up ? version + 1 : version - 1
|
|
176
|
+
Yes::Core::Event.new(data: transformed_event, version: v)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# event schema
|
|
180
|
+
def schema; end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
# @param data [Hash]
|
|
185
|
+
# @return [void]
|
|
186
|
+
def validate(data)
|
|
187
|
+
validation_schema = schema
|
|
188
|
+
return unless validation_schema
|
|
189
|
+
|
|
190
|
+
validation = validation_schema.call(data || {})
|
|
191
|
+
errors = validation.errors.to_h.dup
|
|
192
|
+
validate_ps_values(data, errors)
|
|
193
|
+
return if errors.empty?
|
|
194
|
+
|
|
195
|
+
raise(InvalidDataError.new(message: "#{validation_schema.class.name} #{errors}"))
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Check if a field trapped to errors list because it now has payload store value. If so - remove it from errors
|
|
199
|
+
# list. Otherwise - add another error that it must be a valid payload store value.
|
|
200
|
+
# @param data [Hash]
|
|
201
|
+
# @param errors [Hash<Symbol => Array<String>>]
|
|
202
|
+
# @return [void]
|
|
203
|
+
def validate_ps_values(data, errors)
|
|
204
|
+
errors.each do |field, messages|
|
|
205
|
+
next unless self.class.ps_store_fields.include?(field.to_s)
|
|
206
|
+
next errors.delete(field) if valid_ps_value?(data[field] || data[field.to_s])
|
|
207
|
+
|
|
208
|
+
messages.push('or must be a correct payload store value')
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# @param value [Object]
|
|
213
|
+
# @return [Boolean]
|
|
214
|
+
def valid_ps_value?(value)
|
|
215
|
+
Types::PAYLOAD_STORE_TYPE.call(value)
|
|
216
|
+
true
|
|
217
|
+
rescue Dry::Types::ConstraintError
|
|
218
|
+
false
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
# Resolves PgEventstore event types back into their proper class.
|
|
6
|
+
# When configured as the event_class_resolver in PgEventstore, events
|
|
7
|
+
# deserialized from the store will be instances of their registered class
|
|
8
|
+
# (e.g., `Test::UserCreated < Yes::Core::Event`) instead of raw `PgEventstore::Event`.
|
|
9
|
+
class EventClassResolver
|
|
10
|
+
# Proxy that skips schema validation when deserializing events from the store,
|
|
11
|
+
# since stored events are already validated.
|
|
12
|
+
class SkipValidationProxy < BasicObject
|
|
13
|
+
# @param klass [Class] the event class to proxy
|
|
14
|
+
def initialize(klass)
|
|
15
|
+
@klass = klass
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @param attrs [Hash] event attributes from the store
|
|
19
|
+
# @return [Yes::Core::Event] the event instance with validation skipped
|
|
20
|
+
def new(**attrs)
|
|
21
|
+
@klass.new(**attrs, skip_validation: true)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Resolves an event type string to its class.
|
|
26
|
+
#
|
|
27
|
+
# @param event_type [String] the event type (e.g., "Test::UserCreated")
|
|
28
|
+
# @return [#new] the event class or a proxy that skips validation
|
|
29
|
+
def call(event_type)
|
|
30
|
+
SkipValidationProxy.new(Object.const_get(event_type))
|
|
31
|
+
rescue NameError, TypeError
|
|
32
|
+
PgEventstore.logger&.debug(<<~TEXT.strip)
|
|
33
|
+
Unable to resolve class by `#{event_type}' event type. \
|
|
34
|
+
Picking #{Event} event class to instantiate the event.
|
|
35
|
+
TEXT
|
|
36
|
+
Event
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
|
|
5
|
+
module Yes
|
|
6
|
+
module Core
|
|
7
|
+
module Generators
|
|
8
|
+
module ReadModels
|
|
9
|
+
# Generator to add pending_update_since tracking to read models
|
|
10
|
+
# Usage: rails generate yes:core:read_models:add_pending_update_tracking
|
|
11
|
+
class AddPendingUpdateTrackingGenerator < Rails::Generators::Base
|
|
12
|
+
include Rails::Generators::Migration
|
|
13
|
+
|
|
14
|
+
source_root File.expand_path('templates', __dir__)
|
|
15
|
+
|
|
16
|
+
# Generator description for help text
|
|
17
|
+
desc 'Adds pending_update_since column and constraints to read models for consistency tracking'
|
|
18
|
+
|
|
19
|
+
# Main generator method
|
|
20
|
+
def create_migration
|
|
21
|
+
migration_number = self.class.next_migration_number(File.join(destination_root, 'db/migrate'))
|
|
22
|
+
path = File.join(destination_root, "db/migrate/#{migration_number}_add_pending_update_tracking_to_read_models.rb")
|
|
23
|
+
template('add_pending_update_tracking.rb.erb', path)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Required by Rails::Generators::Migration
|
|
27
|
+
def self.next_migration_number(dirname)
|
|
28
|
+
next_migration_number = current_migration_number(dirname) + 1
|
|
29
|
+
ActiveRecord::Migration.next_migration_number(next_migration_number)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Returns the Rails migration version
|
|
35
|
+
# @return [String] The migration version string
|
|
36
|
+
def migration_version
|
|
37
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddPendingUpdateTrackingToReadModels < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def up
|
|
5
|
+
# Get all read model tables from the configuration
|
|
6
|
+
read_model_tables = Yes::Core.configuration.all_read_model_table_names
|
|
7
|
+
|
|
8
|
+
read_model_tables.each do |table_name|
|
|
9
|
+
# Skip if table doesn't exist (might not have been migrated yet)
|
|
10
|
+
next unless ActiveRecord::Base.connection.table_exists?(table_name)
|
|
11
|
+
|
|
12
|
+
add_pending_tracking_to_table(table_name)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def down
|
|
17
|
+
# Get all read model tables from the configuration
|
|
18
|
+
read_model_tables = Yes::Core.configuration.all_read_model_table_names
|
|
19
|
+
|
|
20
|
+
read_model_tables.each do |table_name|
|
|
21
|
+
# Skip if table doesn't exist
|
|
22
|
+
next unless ActiveRecord::Base.connection.table_exists?(table_name)
|
|
23
|
+
|
|
24
|
+
remove_pending_tracking_from_table(table_name)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# Truncates an index name to fit within PostgreSQL's 63 character limit
|
|
31
|
+
# @param name [String] The index name to potentially truncate
|
|
32
|
+
# @return [String] The truncated index name
|
|
33
|
+
def truncate_index_name(name)
|
|
34
|
+
return name if name.length <= 62
|
|
35
|
+
|
|
36
|
+
# Create a short hash for uniqueness
|
|
37
|
+
require 'digest'
|
|
38
|
+
hash = Digest::SHA256.hexdigest(name)[0..7]
|
|
39
|
+
|
|
40
|
+
# Take first part of name and append hash
|
|
41
|
+
"#{name[0..52]}_#{hash}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Adds pending_update_since tracking to a table
|
|
45
|
+
# @param table_name [String] The table to modify
|
|
46
|
+
def add_pending_tracking_to_table(table_name)
|
|
47
|
+
# Add pending_update_since column if it doesn't exist
|
|
48
|
+
unless column_exists?(table_name, :pending_update_since)
|
|
49
|
+
add_column table_name, :pending_update_since, :datetime
|
|
50
|
+
say "Added pending_update_since column to #{table_name}", :green
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Create trigger function if it doesn't exist
|
|
54
|
+
create_pending_update_trigger_function
|
|
55
|
+
|
|
56
|
+
# Create trigger on the table
|
|
57
|
+
trigger_name = "trg_#{table_name}_prevent_concurrent_pending"
|
|
58
|
+
# Truncate trigger name if too long
|
|
59
|
+
trigger_name = trigger_name[0..62] if trigger_name.length > 63
|
|
60
|
+
|
|
61
|
+
execute <<-SQL
|
|
62
|
+
DROP TRIGGER IF EXISTS #{trigger_name} ON #{table_name};
|
|
63
|
+
CREATE TRIGGER #{trigger_name}
|
|
64
|
+
BEFORE UPDATE ON #{table_name}
|
|
65
|
+
FOR EACH ROW
|
|
66
|
+
EXECUTE FUNCTION prevent_concurrent_pending_update();
|
|
67
|
+
SQL
|
|
68
|
+
say "Added concurrent update prevention trigger on #{table_name}", :green
|
|
69
|
+
|
|
70
|
+
# Create regular index for efficient recovery queries
|
|
71
|
+
recovery_index_name = truncate_index_name("idx_#{table_name}_pending_recovery")
|
|
72
|
+
unless index_exists?(table_name, :pending_update_since, name: recovery_index_name)
|
|
73
|
+
add_index table_name,
|
|
74
|
+
:pending_update_since,
|
|
75
|
+
where: 'pending_update_since IS NOT NULL',
|
|
76
|
+
name: recovery_index_name
|
|
77
|
+
say "Added recovery index on #{table_name}.pending_update_since", :green
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Creates the PostgreSQL function to prevent concurrent pending updates
|
|
82
|
+
def create_pending_update_trigger_function
|
|
83
|
+
execute <<-SQL
|
|
84
|
+
CREATE OR REPLACE FUNCTION prevent_concurrent_pending_update()
|
|
85
|
+
RETURNS TRIGGER AS $$
|
|
86
|
+
BEGIN
|
|
87
|
+
-- If trying to set pending_update_since when it's already set
|
|
88
|
+
IF NEW.pending_update_since IS NOT NULL AND
|
|
89
|
+
OLD.pending_update_since IS NOT NULL AND
|
|
90
|
+
NEW.pending_update_since != OLD.pending_update_since THEN
|
|
91
|
+
RAISE EXCEPTION 'Concurrent pending update not allowed for record %', NEW.id
|
|
92
|
+
USING ERRCODE = 'unique_violation';
|
|
93
|
+
END IF;
|
|
94
|
+
|
|
95
|
+
-- Allow clearing pending_update_since (setting to NULL)
|
|
96
|
+
-- Allow initial setting when OLD value is NULL
|
|
97
|
+
RETURN NEW;
|
|
98
|
+
END;
|
|
99
|
+
$$ LANGUAGE plpgsql;
|
|
100
|
+
SQL
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Removes pending tracking from a table
|
|
104
|
+
# @param table_name [String] The table to modify
|
|
105
|
+
def remove_pending_tracking_from_table(table_name)
|
|
106
|
+
# Remove trigger
|
|
107
|
+
trigger_name = "trg_#{table_name}_prevent_concurrent_pending"
|
|
108
|
+
trigger_name = trigger_name[0..62] if trigger_name.length > 63
|
|
109
|
+
execute "DROP TRIGGER IF EXISTS #{trigger_name} ON #{table_name};"
|
|
110
|
+
|
|
111
|
+
# Remove recovery index
|
|
112
|
+
recovery_index_name = truncate_index_name("idx_#{table_name}_pending_recovery")
|
|
113
|
+
remove_index table_name, name: recovery_index_name if index_exists?(table_name, nil, name: recovery_index_name)
|
|
114
|
+
|
|
115
|
+
# Remove column
|
|
116
|
+
remove_column table_name, :pending_update_since if column_exists?(table_name, :pending_update_since)
|
|
117
|
+
|
|
118
|
+
# Note: We don't remove the trigger function as it might be used by other tables
|
|
119
|
+
|
|
120
|
+
say "Removed pending tracking from #{table_name}", :yellow
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
|
|
5
|
+
module Yes
|
|
6
|
+
module Core
|
|
7
|
+
module Generators
|
|
8
|
+
module ReadModels
|
|
9
|
+
class UpdateGenerator < Rails::Generators::Base
|
|
10
|
+
include Rails::Generators::Migration
|
|
11
|
+
|
|
12
|
+
source_root File.expand_path('templates', __dir__)
|
|
13
|
+
|
|
14
|
+
desc 'Creates or updates read models for aggregates'
|
|
15
|
+
|
|
16
|
+
def create_migration
|
|
17
|
+
@aggregates = find_aggregates
|
|
18
|
+
return if @aggregates.empty?
|
|
19
|
+
|
|
20
|
+
migration_number = self.class.next_migration_number(File.join(destination_root, 'db/migrate'))
|
|
21
|
+
|
|
22
|
+
path = File.join(destination_root, "db/migrate/#{migration_number}_update_read_models.rb")
|
|
23
|
+
template('migration.rb.erb', path)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Required by Rails::Generators::Migration
|
|
27
|
+
def self.next_migration_number(dirname)
|
|
28
|
+
next_migration_number = current_migration_number(dirname) + 1
|
|
29
|
+
ActiveRecord::Migration.next_migration_number(next_migration_number)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def find_aggregates
|
|
35
|
+
root_path = Rails.root || Pathname.new(destination_root)
|
|
36
|
+
|
|
37
|
+
Dir.glob(root_path.join('app/contexts/**/**/aggregate.rb')).map do |file|
|
|
38
|
+
require file
|
|
39
|
+
context, aggregate = file.split('contexts/').last.split('/')
|
|
40
|
+
klass = "#{context.camelize}::#{aggregate.camelize}::Aggregate".constantize
|
|
41
|
+
{
|
|
42
|
+
context:,
|
|
43
|
+
aggregate:,
|
|
44
|
+
klass:,
|
|
45
|
+
attributes: klass.attributes,
|
|
46
|
+
read_model_name: klass.read_model_name
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def table_operations(aggregate)
|
|
52
|
+
table_name = aggregate[:read_model_name].to_s.pluralize
|
|
53
|
+
|
|
54
|
+
if table_exists?(table_name)
|
|
55
|
+
alter_table_block(aggregate, table_name)
|
|
56
|
+
else
|
|
57
|
+
create_table_block(aggregate)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def create_table_block(aggregate)
|
|
62
|
+
table_name = aggregate[:read_model_name].to_s.pluralize
|
|
63
|
+
<<~RUBY
|
|
64
|
+
create_table :#{table_name} do |t|
|
|
65
|
+
#{column_definitions(aggregate[:attributes])}
|
|
66
|
+
t.integer :revision, null: false, default: -1
|
|
67
|
+
t.timestamps
|
|
68
|
+
end
|
|
69
|
+
RUBY
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def alter_table_block(aggregate, table_name)
|
|
73
|
+
existing_columns = fetch_existing_columns(table_name)
|
|
74
|
+
defined_columns = build_defined_columns(aggregate)
|
|
75
|
+
|
|
76
|
+
build_alter_statements(table_name, existing_columns, defined_columns, aggregate[:attributes])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def fetch_existing_columns(table_name)
|
|
80
|
+
ActiveRecord::Base.connection.columns(table_name).map(&:name) - %w[id created_at updated_at]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_defined_columns(aggregate)
|
|
84
|
+
aggregate[:attributes].keys.map do |key|
|
|
85
|
+
type = aggregate[:attributes][key]
|
|
86
|
+
type == :aggregate ? "#{key}_id" : key.to_s
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def build_alter_statements(table_name, existing_columns, defined_columns, attributes)
|
|
91
|
+
statements = []
|
|
92
|
+
statements << add_revision_statement(table_name) unless existing_columns.include?('revision')
|
|
93
|
+
statements.concat(build_add_column_statements(table_name, existing_columns, defined_columns, attributes))
|
|
94
|
+
statements.concat(build_remove_column_statements(table_name, existing_columns, defined_columns))
|
|
95
|
+
statements.join("\n ")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def add_revision_statement(table_name)
|
|
99
|
+
"add_column :#{table_name}, :revision, :integer, null: false, default: -1"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def build_add_column_statements(table_name, existing_columns, defined_columns, attributes)
|
|
103
|
+
(defined_columns - existing_columns).map do |column|
|
|
104
|
+
attribute_name = column.end_with?('_id') ? column.chomp('_id').to_sym : column.to_sym
|
|
105
|
+
type = attributes[attribute_name]
|
|
106
|
+
"add_column :#{table_name}, :#{column}, :#{database_type(type)}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def build_remove_column_statements(table_name, existing_columns, defined_columns)
|
|
111
|
+
(existing_columns - defined_columns - ['revision']).map do |column|
|
|
112
|
+
"remove_column :#{table_name}, :#{column}"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def column_definitions(attributes)
|
|
117
|
+
attributes.map do |name, type|
|
|
118
|
+
if type == :aggregate
|
|
119
|
+
"t.uuid :#{name}_id"
|
|
120
|
+
else
|
|
121
|
+
"t.#{database_type(type)} :#{name}"
|
|
122
|
+
end
|
|
123
|
+
end.join("\n ")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def table_exists?(table_name)
|
|
127
|
+
ActiveRecord::Base.connection.table_exists?(table_name)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def database_type(type)
|
|
131
|
+
case type
|
|
132
|
+
when :string, :email, :url then :string
|
|
133
|
+
when :integer then :integer
|
|
134
|
+
when :uuid then :uuid
|
|
135
|
+
when :boolean then :boolean
|
|
136
|
+
when :float then :float
|
|
137
|
+
when :datetime then :datetime
|
|
138
|
+
when :hash then :jsonb
|
|
139
|
+
when :aggregate then :uuid
|
|
140
|
+
else :string # default to string for unknown types
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|