pigeon-rb 0.1.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 (40) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +343 -0
  3. data/lib/pigeon/active_job_integration.rb +32 -0
  4. data/lib/pigeon/api.rb +200 -0
  5. data/lib/pigeon/configuration.rb +161 -0
  6. data/lib/pigeon/core.rb +104 -0
  7. data/lib/pigeon/encryption.rb +213 -0
  8. data/lib/pigeon/generators/hanami/migration_generator.rb +89 -0
  9. data/lib/pigeon/generators/rails/install_generator.rb +32 -0
  10. data/lib/pigeon/generators/rails/migration_generator.rb +20 -0
  11. data/lib/pigeon/generators/rails/templates/create_outbox_messages.rb.erb +34 -0
  12. data/lib/pigeon/generators/rails/templates/initializer.rb.erb +88 -0
  13. data/lib/pigeon/hanami_integration.rb +78 -0
  14. data/lib/pigeon/health_check/kafka.rb +37 -0
  15. data/lib/pigeon/health_check/processor.rb +70 -0
  16. data/lib/pigeon/health_check/queue.rb +69 -0
  17. data/lib/pigeon/health_check.rb +63 -0
  18. data/lib/pigeon/logging/structured_logger.rb +181 -0
  19. data/lib/pigeon/metrics/collector.rb +200 -0
  20. data/lib/pigeon/mock_producer.rb +18 -0
  21. data/lib/pigeon/models/adapters/active_record_adapter.rb +133 -0
  22. data/lib/pigeon/models/adapters/rom_adapter.rb +150 -0
  23. data/lib/pigeon/models/outbox_message.rb +182 -0
  24. data/lib/pigeon/monitoring.rb +113 -0
  25. data/lib/pigeon/outbox.rb +61 -0
  26. data/lib/pigeon/processor/background_processor.rb +109 -0
  27. data/lib/pigeon/processor.rb +798 -0
  28. data/lib/pigeon/publisher.rb +524 -0
  29. data/lib/pigeon/railtie.rb +29 -0
  30. data/lib/pigeon/schema.rb +35 -0
  31. data/lib/pigeon/security.rb +30 -0
  32. data/lib/pigeon/serializer.rb +77 -0
  33. data/lib/pigeon/tasks/pigeon.rake +64 -0
  34. data/lib/pigeon/trace_api.rb +37 -0
  35. data/lib/pigeon/tracing/core.rb +119 -0
  36. data/lib/pigeon/tracing/messaging.rb +144 -0
  37. data/lib/pigeon/tracing.rb +107 -0
  38. data/lib/pigeon/version.rb +5 -0
  39. data/lib/pigeon.rb +52 -0
  40. metadata +127 -0
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-configurable"
4
+ require "logger"
5
+
6
+ module Pigeon
7
+ # Configuration class for Pigeon using dry-configurable
8
+ class Configuration
9
+ extend Dry::Configurable
10
+
11
+ # Karafka client ID
12
+ setting :client_id, default: "pigeon"
13
+
14
+ # Kafka seed brokers configuration
15
+ setting :kafka_brokers, default: ["localhost:9092"]
16
+
17
+ # Maximum number of retries for failed messages
18
+ setting :max_retries, default: 10
19
+
20
+ # Base retry delay (will be used with exponential backoff)
21
+ setting :retry_delay, default: 30 # seconds
22
+
23
+ # Maximum retry delay
24
+ setting :max_retry_delay, default: 86_400 # 24 hours
25
+
26
+ # Whether to encrypt message payloads
27
+ setting :encrypt_payload, default: false
28
+
29
+ # Encryption key for payload encryption
30
+ setting :encryption_key, default: nil
31
+
32
+ # Sensitive fields to mask in logs
33
+ setting :sensitive_fields, default: []
34
+
35
+ # Retention period for processed messages
36
+ setting :retention_period, default: 7 # days
37
+
38
+ # Logger instance
39
+ setting :logger, default: Logger.new($stdout).tap { |l| l.level = Logger::INFO }
40
+
41
+ # Metrics collector
42
+ setting :metrics_collector, default: nil
43
+
44
+ # Tracer for OpenTelemetry
45
+ setting :tracer, default: nil
46
+
47
+ # Karafka additional configuration
48
+ setting :karafka_config, default: {}
49
+
50
+ # Schema validation configuration
51
+ setting :schema_validation_enabled, default: false
52
+
53
+ # Registered JSON schemas for validation
54
+ setting :schemas, default: {}
55
+
56
+ # Schema registry URL (if using a schema registry)
57
+ setting :schema_registry_url, default: nil
58
+
59
+ # Dead letter queue configuration
60
+ setting :dead_letter_queue_enabled, default: true
61
+
62
+ # Custom dead letter queue topic suffix
63
+ setting :dead_letter_queue_suffix, default: ".dlq"
64
+
65
+ # Error classification for retry strategy
66
+ setting :error_classifications, default: {
67
+ # Transient errors - retry with normal backoff
68
+ transient: [
69
+ "Kafka::ConnectionError",
70
+ "Kafka::NetworkError",
71
+ "Kafka::UnknownError",
72
+ "Kafka::LeaderNotAvailable",
73
+ "Kafka::NotLeaderForPartition"
74
+ ],
75
+ # Permanent errors - mark as failed after fewer retries
76
+ permanent: [
77
+ "Kafka::InvalidTopic",
78
+ "Kafka::TopicAuthorizationFailed",
79
+ "Kafka::MessageSizeTooLarge"
80
+ ],
81
+ # Application errors - mark as failed immediately
82
+ application: [
83
+ "Pigeon::Serializer::ValidationError",
84
+ "JSON::ParserError"
85
+ ]
86
+ }
87
+
88
+ class << self
89
+ # Reset the configuration to default values
90
+ # @return [void]
91
+ def reset_config
92
+ # For dry-configurable 1.x
93
+ if respond_to?(:settings)
94
+ settings.each_key do |key|
95
+ config[key] = settings[key].default
96
+ end
97
+ # For dry-configurable 0.x
98
+ elsif respond_to?(:config_option_definitions)
99
+ config_option_definitions.each_key do |key|
100
+ config[key] = config_option_definitions[key].default_value
101
+ end
102
+ else
103
+ # Simplest approach - just create a new configuration
104
+ @config = Dry::Configurable::Config.new
105
+ end
106
+ end
107
+
108
+ # Register a JSON schema for validation
109
+ # @param name [String, Symbol] Schema name
110
+ # @param schema [Hash, String] JSON schema
111
+ # @return [void]
112
+ def register_schema(name, schema)
113
+ config.schemas[name.to_sym] = schema
114
+ end
115
+
116
+ # Get a registered schema
117
+ # @param name [String, Symbol] Schema name
118
+ # @return [Hash, String, nil] JSON schema or nil if not found
119
+ def schema(name)
120
+ config.schemas[name.to_sym]
121
+ end
122
+
123
+ # Register a sensitive field for masking
124
+ # @param field [String, Symbol] Field name
125
+ # @return [void]
126
+ def register_sensitive_field(field)
127
+ config.sensitive_fields << field.to_sym unless config.sensitive_fields.include?(field.to_sym)
128
+ end
129
+
130
+ # Register multiple sensitive fields for masking
131
+ # @param fields [Array<String, Symbol>] Field names
132
+ # @return [void]
133
+ def register_sensitive_fields(fields)
134
+ fields.each { |field| register_sensitive_field(field) }
135
+ end
136
+
137
+ # Register an error class for a specific classification
138
+ # @param error_class [String, Class] Error class name or class
139
+ # @param classification [Symbol] Error classification (:transient, :permanent, :application)
140
+ # @return [void]
141
+ def register_error_classification(error_class, classification)
142
+ error_name = error_class.is_a?(Class) ? error_class.name : error_class.to_s
143
+ config.error_classifications[classification.to_sym] ||= []
144
+ config.error_classifications[classification.to_sym] << error_name
145
+ end
146
+
147
+ # Get the classification for an error
148
+ # @param error [StandardError] Error instance
149
+ # @return [Symbol] Error classification (:transient, :permanent, :application, or :unknown)
150
+ def classify_error(error)
151
+ error_name = error.class.name
152
+
153
+ config.error_classifications.each do |classification, error_classes|
154
+ return classification if error_classes.include?(error_name)
155
+ end
156
+
157
+ :unknown
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pigeon
4
+ # Core functionality for Pigeon
5
+ module Core
6
+ # Configure the gem
7
+ # @yield [config] Configuration instance
8
+ # @example
9
+ # Pigeon.configure do |config|
10
+ # config.client_id = "my-application"
11
+ # config.kafka_brokers = ["kafka1:9092", "kafka2:9092"]
12
+ # config.max_retries = 5
13
+ # end
14
+ def self.configure
15
+ yield(Configuration.config) if block_given?
16
+ initialize_karafka if @karafka_initialized.nil?
17
+ end
18
+
19
+ # Get the configuration
20
+ # @return [Dry::Configurable::Config]
21
+ def self.config
22
+ Configuration.config
23
+ end
24
+
25
+ # Initialize the Karafka producer
26
+ # @return [Karafka::Producer]
27
+ def self.initialize_karafka # rubocop:disable Metrics/AbcSize
28
+ return @karafka_producer if @karafka_initialized
29
+
30
+ # Configure Karafka
31
+ begin
32
+ Karafka::Setup::Config.setup do |karafka_config|
33
+ karafka_config.client_id = config.client_id
34
+
35
+ # Set required Kafka configuration if not provided
36
+ if !config.karafka_config[:kafka] || config.karafka_config[:kafka].empty?
37
+ karafka_config.kafka = {
38
+ "bootstrap.servers": config.kafka_brokers.join(",")
39
+ }
40
+ end
41
+
42
+ # Apply any additional Karafka configuration
43
+ config.karafka_config.each do |key, value|
44
+ karafka_config.public_send("#{key}=", value) if karafka_config.respond_to?("#{key}=")
45
+ end
46
+ end
47
+
48
+ @karafka_initialized = true
49
+ @karafka_producer = Karafka.producer
50
+ rescue StandardError => e
51
+ config.logger.error("Failed to initialize Karafka: #{e.message}")
52
+ # Return a mock producer for testing
53
+ @karafka_initialized = true
54
+ @karafka_producer = MockProducer.new
55
+ end
56
+
57
+ @karafka_producer
58
+ end
59
+
60
+ # Get the Karafka producer instance
61
+ # @return [Karafka::Producer]
62
+ def self.karafka_producer
63
+ initialize_karafka unless @karafka_initialized
64
+ @karafka_producer
65
+ end
66
+
67
+ # Create a new publisher instance
68
+ # @return [Pigeon::Publisher]
69
+ def self.publisher
70
+ Publisher.new
71
+ end
72
+
73
+ # Create a new processor instance
74
+ # @param auto_start [Boolean] Whether to automatically start processing pending messages
75
+ # @return [Pigeon::Processor]
76
+ def self.processor(auto_start: false)
77
+ Processor.new(auto_start: auto_start)
78
+ end
79
+
80
+ # Start processing pending messages
81
+ # @param batch_size [Integer] Number of messages to process in one batch
82
+ # @param interval [Integer] Interval in seconds between processing batches
83
+ # @param thread_count [Integer] Number of threads to use for processing
84
+ # @return [Boolean] Whether processing was started
85
+ def self.start_processing(batch_size: 100, interval: 5, thread_count: 2)
86
+ @processor ||= processor
87
+ @processor.start_processing(batch_size: batch_size, interval: interval, thread_count: thread_count)
88
+ end
89
+
90
+ # Stop processing pending messages
91
+ # @return [Boolean] Whether processing was stopped
92
+ def self.stop_processing
93
+ return false unless @processor
94
+
95
+ @processor.stop_processing
96
+ end
97
+
98
+ # Check if processing is running
99
+ # @return [Boolean] Whether processing is running
100
+ def self.processing?
101
+ @processor&.processing? || false
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+
6
+ module Pigeon
7
+ # Encryption module for handling payload encryption and decryption
8
+ module Encryption
9
+ DEFAULT_CIPHER = "aes-256-gcm"
10
+
11
+ # Encrypt a payload
12
+ # @param payload [String] Payload to encrypt
13
+ # @param encryption_key [String] Encryption key
14
+ # @param encryption_iv [String, nil] Initialization vector (optional)
15
+ # @return [Hash] Encrypted payload with metadata
16
+ def self.encrypt(payload, encryption_key = nil, encryption_iv = nil)
17
+ # Return the payload as is if encryption is disabled
18
+ return payload unless Pigeon.config.encrypt_payload
19
+
20
+ # Get the encryption key
21
+ key = encryption_key || self.encryption_key
22
+ raise EncryptionError, "Encryption key is required" unless key
23
+
24
+ # Generate a random IV if not provided (GCM uses 12-byte IV)
25
+ iv = encryption_iv || OpenSSL::Random.random_bytes(12)
26
+
27
+ # Encrypt the payload using AES-256-GCM
28
+ cipher = OpenSSL::Cipher.new(DEFAULT_CIPHER)
29
+ cipher.encrypt
30
+ cipher.key = key
31
+ cipher.iv = iv
32
+
33
+ # Encrypt the payload
34
+ encrypted_data = cipher.update(payload) + cipher.final
35
+
36
+ # Get the authentication tag
37
+ auth_tag = cipher.auth_tag
38
+
39
+ # Encode the data, IV, and auth tag
40
+ encoded_data = Base64.strict_encode64(encrypted_data)
41
+ encoded_iv = Base64.strict_encode64(iv)
42
+ encoded_auth_tag = Base64.strict_encode64(auth_tag)
43
+
44
+ # Return the encrypted payload with metadata
45
+ {
46
+ data: encoded_data,
47
+ iv: encoded_iv,
48
+ auth_tag: encoded_auth_tag,
49
+ cipher: DEFAULT_CIPHER
50
+ }
51
+ rescue StandardError => e
52
+ raise EncryptionError, "Failed to encrypt payload: #{e.message}"
53
+ end
54
+
55
+ # Decrypt a payload
56
+ # @param encrypted_payload [Hash, String] Encrypted payload with metadata
57
+ # @param encryption_key [String] Encryption key
58
+ # @return [String] Decrypted payload
59
+ def self.decrypt(encrypted_payload, encryption_key = nil)
60
+ # Return the payload as is if it's not encrypted
61
+ return encrypted_payload if encrypted_payload.is_a?(String)
62
+
63
+ # Get the encryption key
64
+ key = encryption_key || self.encryption_key
65
+ raise EncryptionError, "Encryption key is required" unless key
66
+
67
+ begin
68
+ # Extract the encrypted data, IV, and auth tag
69
+ encoded_data = encrypted_payload[:data]
70
+ encoded_iv = encrypted_payload[:iv]
71
+ encoded_auth_tag = encrypted_payload[:auth_tag]
72
+
73
+ # Decode the data, IV, and auth tag
74
+ encrypted_data = Base64.strict_decode64(encoded_data)
75
+ iv = Base64.strict_decode64(encoded_iv)
76
+ auth_tag = Base64.strict_decode64(encoded_auth_tag) if encoded_auth_tag
77
+
78
+ # Decrypt using AES-256-GCM
79
+ decipher = OpenSSL::Cipher.new(DEFAULT_CIPHER)
80
+ decipher.decrypt
81
+ decipher.key = key
82
+ decipher.iv = iv
83
+ decipher.auth_tag = auth_tag if auth_tag
84
+
85
+ # Decrypt the payload
86
+ decipher.update(encrypted_data) + decipher.final
87
+ rescue StandardError => e
88
+ raise DecryptionError, "Failed to decrypt payload: #{e.message}"
89
+ end
90
+ end
91
+
92
+ # Mask sensitive data in a payload
93
+ # @param payload [Hash, String] Payload to mask
94
+ # @param sensitive_fields [Array<String, Symbol>] Fields to mask
95
+ # @return [Hash, String] Masked payload
96
+ def self.mask_payload(payload, sensitive_fields = nil)
97
+ # Return the payload as is if no sensitive fields are specified
98
+ return payload if payload.is_a?(String) || sensitive_fields.nil? || sensitive_fields.empty?
99
+
100
+ # Convert the payload to a hash if it's a string
101
+ payload_hash = payload.is_a?(String) ? ::JSON.parse(payload) : payload.dup
102
+
103
+ # Mask sensitive fields
104
+ MaskingHelper.mask_fields(payload_hash, sensitive_fields)
105
+ rescue StandardError => e
106
+ Pigeon.config.logger.warn("Failed to mask payload: #{e.message}")
107
+ payload
108
+ end
109
+
110
+ # Get the encryption key from the configuration
111
+ # @return [String, nil] Encryption key
112
+ def self.encryption_key
113
+ key = key_from_config
114
+ key ||= key_from_environment
115
+ key ||= key_from_rails_credentials
116
+ key ||= key_from_hanami_settings
117
+ key
118
+ end
119
+
120
+ # Custom error classes
121
+ class EncryptionError < StandardError; end
122
+ class DecryptionError < StandardError; end
123
+
124
+ private_class_method def self.key_from_config
125
+ Pigeon.config.encryption_key
126
+ end
127
+
128
+ private_class_method def self.key_from_environment
129
+ ENV.fetch("PIGEON_ENCRYPTION_KEY", nil)
130
+ end
131
+
132
+ private_class_method def self.key_from_rails_credentials
133
+ return nil unless defined?(Rails)
134
+ return nil unless Rails.respond_to?(:application)
135
+ return nil unless Rails.application.respond_to?(:credentials)
136
+ return nil unless Rails.application.credentials.respond_to?(:pigeon)
137
+
138
+ Rails.application.credentials.pigeon[:encryption_key]
139
+ end
140
+
141
+ private_class_method def self.key_from_hanami_settings
142
+ return nil unless defined?(Hanami)
143
+ return nil unless Hanami.respond_to?(:app)
144
+ return nil unless Hanami.app.respond_to?(:[])
145
+ return nil unless Hanami.app["settings"].respond_to?(:pigeon)
146
+
147
+ Hanami.app["settings"].pigeon.encryption_key
148
+ end
149
+ end
150
+
151
+ # Helper module for masking sensitive data
152
+ module MaskingHelper
153
+ # Mask fields in a hash
154
+ # @param hash [Hash] Hash to mask
155
+ # @param sensitive_fields [Array<String, Symbol>] Fields to mask
156
+ # @return [Hash] Masked hash
157
+ def self.mask_fields(hash, sensitive_fields)
158
+ hash.each_with_object({}) do |(key, value), result|
159
+ result[key] = determine_masked_value(key, value, sensitive_fields)
160
+ end
161
+ end
162
+
163
+ # Determine the masked value for a key-value pair
164
+ # @param key [Object] The key
165
+ # @param value [Object] The value
166
+ # @param sensitive_fields [Array<String, Symbol>] Fields to mask
167
+ # @return [Object] The masked or original value
168
+ def self.determine_masked_value(key, value, sensitive_fields)
169
+ key_sym = key.to_sym
170
+ key_str = key.to_s
171
+
172
+ if sensitive_fields.include?(key_sym) || sensitive_fields.include?(key_str)
173
+ # Mask the value
174
+ mask_value(value)
175
+ elsif value.is_a?(Hash)
176
+ # Recursively mask nested hashes
177
+ mask_fields(value, sensitive_fields)
178
+ elsif value.is_a?(Array)
179
+ # Recursively mask arrays of hashes
180
+ value.map { |v| v.is_a?(Hash) ? mask_fields(v, sensitive_fields) : v }
181
+ else
182
+ # Keep the value as is
183
+ value
184
+ end
185
+ end
186
+
187
+ # Mask a value
188
+ # @param value [Object] Value to mask
189
+ # @return [String] Masked value
190
+ def self.mask_value(value)
191
+ return "[REDACTED]" if value.nil?
192
+
193
+ case value
194
+ when String
195
+ # Mask the string with asterisks, keeping the first and last characters
196
+ if value.length <= 4
197
+ "*" * value.length
198
+ else
199
+ "#{value[0]}#{value[1..-2].gsub(/[^[:space:]]/, '*')}#{value[-1]}"
200
+ end
201
+ when Numeric
202
+ # Mask numbers with asterisks
203
+ "*" * value.to_s.length
204
+ when TrueClass, FalseClass
205
+ # Don't mask booleans
206
+ value
207
+ else
208
+ # Mask everything else
209
+ "[REDACTED]"
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Pigeon
6
+ module Generators
7
+ module Hanami
8
+ # Generator for creating the outbox message table migration for Hanami applications
9
+ class MigrationGenerator
10
+ attr_reader :app_name
11
+
12
+ def initialize(app_name = nil)
13
+ @app_name = app_name || detect_app_name
14
+ end
15
+
16
+ # Generate the migration file
17
+ # @return [String] Path to the generated file
18
+ def generate
19
+ timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
20
+ filename = "db/migrations/#{timestamp}_create_outbox_messages.rb"
21
+
22
+ # Create the migrations directory if it doesn't exist
23
+ FileUtils.mkdir_p("db/migrations")
24
+
25
+ # Write the migration file
26
+ File.write(filename, migration_content)
27
+
28
+ filename
29
+ end
30
+
31
+ private
32
+
33
+ # Detect the Hanami application name
34
+ # @return [String] Application name
35
+ def detect_app_name
36
+ if defined?(Hanami) && Hanami.respond_to?(:app)
37
+ Hanami.app.name.to_s
38
+ else
39
+ "App"
40
+ end
41
+ end
42
+
43
+ # Generate the migration content
44
+ # @return [String] Migration content
45
+ def migration_content
46
+ <<~RUBY
47
+ # frozen_string_literal: true
48
+
49
+ ROM::SQL.migration do
50
+ change do
51
+ create_table :outbox_messages do
52
+ primary_key :id, type: :uuid
53
+
54
+ # Message metadata
55
+ column :topic, String, null: false
56
+ column :key, String
57
+ column :headers, :jsonb
58
+ column :partition, Integer
59
+
60
+ # Message content
61
+ column :payload, String, text: true, null: false
62
+
63
+ # Processing metadata
64
+ column :status, String, null: false, default: "pending"
65
+ column :retry_count, Integer, null: false, default: 0
66
+ column :max_retries, Integer
67
+ column :error_message, String, text: true
68
+ column :correlation_id, String
69
+
70
+ # Timestamps
71
+ column :created_at, DateTime, null: false
72
+ column :updated_at, DateTime, null: false
73
+ column :published_at, DateTime
74
+ column :next_retry_at, DateTime
75
+
76
+ # Indexes
77
+ index :status
78
+ index :next_retry_at
79
+ index :correlation_id
80
+ index :created_at
81
+ end
82
+ end
83
+ end
84
+ RUBY
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Pigeon
6
+ module Generators
7
+ module Rails
8
+ # Generator for installing Pigeon in a Rails application
9
+ class InstallGenerator < ::Rails::Generators::Base
10
+ source_root File.expand_path("templates", __dir__)
11
+ desc "Installs Pigeon configuration files"
12
+
13
+ def create_initializer_file
14
+ template "initializer.rb.erb", "config/initializers/pigeon.rb"
15
+ end
16
+
17
+ def create_migration_file
18
+ generate "pigeon:rails:migration"
19
+ end
20
+
21
+ def display_post_install_message
22
+ say "\nPigeon has been installed! 🐦", :green
23
+ say "\nNext steps:"
24
+ say " 1. Review the configuration in config/initializers/pigeon.rb"
25
+ say " 2. Run migrations with: rails db:migrate"
26
+ say " 3. Start using Pigeon in your application"
27
+ say "\nFor more information, visit: https://github.com/khaile/pigeon"
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Pigeon
6
+ module Generators
7
+ module Rails
8
+ # Generator for creating the outbox message table migration for Rails applications
9
+ class MigrationGenerator < ::Rails::Generators::Base
10
+ source_root File.expand_path("templates", __dir__)
11
+ desc "Creates a migration for the outbox message table"
12
+
13
+ def create_migration_file
14
+ timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
15
+ template "create_outbox_messages.rb.erb", "db/migrate/#{timestamp}_create_outbox_messages.rb"
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateOutboxMessages < ActiveRecord::Migration[<%= ::Rails::VERSION::MAJOR %>.<%= ::Rails::VERSION::MINOR %>]
4
+ def change
5
+ create_table :outbox_messages, id: :uuid do |t|
6
+ # Message metadata
7
+ t.string :topic, null: false
8
+ t.string :key
9
+ t.jsonb :headers
10
+ t.integer :partition
11
+
12
+ # Message content
13
+ t.text :payload, null: false
14
+
15
+ # Processing metadata
16
+ t.string :status, null: false, default: "pending"
17
+ t.integer :retry_count, null: false, default: 0
18
+ t.integer :max_retries
19
+ t.text :error_message
20
+ t.string :correlation_id
21
+
22
+ # Timestamps
23
+ t.timestamps
24
+ t.datetime :published_at
25
+ t.datetime :next_retry_at
26
+ end
27
+
28
+ # Add indexes for efficient querying
29
+ add_index :outbox_messages, :status
30
+ add_index :outbox_messages, :next_retry_at
31
+ add_index :outbox_messages, :correlation_id
32
+ add_index :outbox_messages, :created_at
33
+ end
34
+ end