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,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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class UpdateReadModels < ActiveRecord::Migration[7.1]
4
+ def change
5
+ <% @aggregates.each do |aggregate| %>
6
+ <%= table_operations(aggregate) %>
7
+ <% end %>
8
+ end
9
+ 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