whodunit-chronicles 0.1.0.pre

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.
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whodunit
4
+ module Chronicles
5
+ # Represents a database change event in a common format
6
+ #
7
+ # This class normalizes database changes from different sources
8
+ # (PostgreSQL WAL, MariaDB binlog, etc.) into a consistent format
9
+ # for processing by audit systems.
10
+ class ChangeEvent
11
+ # Supported database actions
12
+ ACTIONS = %w[INSERT UPDATE DELETE].freeze
13
+
14
+ attr_reader :table_name, :schema_name, :action, :primary_key, :old_data, :new_data,
15
+ :timestamp, :transaction_id, :sequence_number, :metadata
16
+
17
+ # Initialize a new change event
18
+ #
19
+ # @param table_name [String] The name of the table that changed
20
+ # @param action [String] The type of change (INSERT, UPDATE, DELETE)
21
+ # @param primary_key [Hash] The primary key values for the changed row
22
+ # @param old_data [Hash, nil] The row data before the change (nil for INSERT)
23
+ # @param new_data [Hash, nil] The row data after the change (nil for DELETE)
24
+ # @param timestamp [Time] When the change occurred
25
+ # @param schema_name [String] The schema name (optional, defaults to 'public')
26
+ # @param transaction_id [String, Integer] Database transaction identifier
27
+ # @param sequence_number [Integer] Sequence number within the transaction
28
+ # @param metadata [Hash] Additional adapter-specific metadata
29
+ def initialize(
30
+ table_name:,
31
+ action:,
32
+ primary_key:,
33
+ old_data: nil,
34
+ new_data: nil,
35
+ timestamp: Time.now,
36
+ schema_name: 'public',
37
+ transaction_id: nil,
38
+ sequence_number: nil,
39
+ metadata: {}
40
+ )
41
+ @table_name = table_name.to_s
42
+ @schema_name = schema_name.to_s
43
+ @action = validate_action(action.to_s.upcase)
44
+ @primary_key = primary_key || {}
45
+ @old_data = old_data
46
+ @new_data = new_data
47
+ @timestamp = timestamp
48
+ @transaction_id = transaction_id
49
+ @sequence_number = sequence_number
50
+ @metadata = metadata || {}
51
+
52
+ validate_data_consistency
53
+ end
54
+
55
+ # Get the qualified table name (schema.table)
56
+ #
57
+ # @return [String]
58
+ def qualified_table_name
59
+ "#{schema_name}.#{table_name}"
60
+ end
61
+
62
+ # Check if this is a create event
63
+ #
64
+ # @return [Boolean]
65
+ def create?
66
+ action == 'INSERT'
67
+ end
68
+
69
+ # Check if this is an update event
70
+ #
71
+ # @return [Boolean]
72
+ def update?
73
+ action == 'UPDATE'
74
+ end
75
+
76
+ # Check if this is a delete event
77
+ #
78
+ # @return [Boolean]
79
+ def delete?
80
+ action == 'DELETE'
81
+ end
82
+
83
+ # Get the changed columns for UPDATE events
84
+ #
85
+ # @return [Array<String>] Array of column names that changed
86
+ def changed_columns
87
+ return [] unless update? && old_data && new_data
88
+
89
+ old_data.keys.reject { |key| old_data[key] == new_data[key] }
90
+ end
91
+
92
+ # Get a hash of changes in [old_value, new_value] format
93
+ #
94
+ # @return [Hash] Hash of column_name => [old_value, new_value]
95
+ def changes
96
+ return {} unless update? && old_data && new_data
97
+
98
+ changed_columns.each_with_object({}) do |column, changes_hash|
99
+ changes_hash[column] = [old_data[column], new_data[column]]
100
+ end
101
+ end
102
+
103
+ # Get the current data for this event
104
+ #
105
+ # @return [Hash] The new_data for INSERT/UPDATE, old_data for DELETE
106
+ def current_data
107
+ case action
108
+ when 'INSERT', 'UPDATE'
109
+ new_data
110
+ when 'DELETE'
111
+ old_data
112
+ end
113
+ end
114
+
115
+ # Get all available data for this event
116
+ #
117
+ # @return [Hash] Combined old and new data
118
+ def all_data
119
+ (old_data || {}).merge(new_data || {})
120
+ end
121
+
122
+ # Convert to hash representation
123
+ #
124
+ # @return [Hash]
125
+ def to_h
126
+ {
127
+ table_name: table_name,
128
+ schema_name: schema_name,
129
+ qualified_table_name: qualified_table_name,
130
+ action: action,
131
+ primary_key: primary_key,
132
+ old_data: old_data,
133
+ new_data: new_data,
134
+ current_data: current_data,
135
+ changes: changes,
136
+ changed_columns: changed_columns,
137
+ timestamp: timestamp,
138
+ transaction_id: transaction_id,
139
+ sequence_number: sequence_number,
140
+ metadata: metadata,
141
+ }
142
+ end
143
+
144
+ # String representation
145
+ #
146
+ # @return [String]
147
+ def to_s
148
+ pk_str = primary_key.map { |k, v| "#{k}=#{v}" }.join(', ')
149
+ "#{action} #{qualified_table_name}(#{pk_str}) at #{timestamp}"
150
+ end
151
+
152
+ # Detailed string representation
153
+ #
154
+ # @return [String]
155
+ def inspect
156
+ "#<#{self.class.name} #{self}>"
157
+ end
158
+
159
+ # Compare events for equality
160
+ #
161
+ # @param other [ChangeEvent]
162
+ # @return [Boolean]
163
+ def ==(other)
164
+ return false unless other.is_a?(ChangeEvent)
165
+
166
+ table_name == other.table_name &&
167
+ schema_name == other.schema_name &&
168
+ action == other.action &&
169
+ primary_key == other.primary_key &&
170
+ old_data == other.old_data &&
171
+ new_data == other.new_data &&
172
+ timestamp == other.timestamp &&
173
+ transaction_id == other.transaction_id &&
174
+ sequence_number == other.sequence_number
175
+ end
176
+
177
+ private
178
+
179
+ def validate_action(action)
180
+ unless ACTIONS.include?(action)
181
+ raise ArgumentError, "Invalid action: #{action}. Must be one of: #{ACTIONS.join(', ')}"
182
+ end
183
+
184
+ action
185
+ end
186
+
187
+ def validate_data_consistency
188
+ case action
189
+ when 'INSERT'
190
+ raise ArgumentError, 'INSERT events must have new_data' if new_data.nil? || new_data.empty?
191
+ raise ArgumentError, 'INSERT events should not have old_data' unless old_data.nil?
192
+ when 'UPDATE'
193
+ raise ArgumentError, 'UPDATE events must have both old_data and new_data' if old_data.nil? || new_data.nil?
194
+ when 'DELETE'
195
+ raise ArgumentError, 'DELETE events must have old_data' if old_data.nil? || old_data.empty?
196
+ raise ArgumentError, 'DELETE events should not have new_data' unless new_data.nil?
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whodunit
4
+ module Chronicles
5
+ # Configuration management for Chronicles
6
+ #
7
+ # Provides a centralized configuration system with sensible defaults
8
+ # and validation for all Chronicles settings.
9
+ class Configuration
10
+ attr_accessor :database_url, :audit_database_url, :adapter, :publication_name,
11
+ :replication_slot_name, :batch_size, :max_retry_attempts, :retry_delay,
12
+ :logger, :table_filter, :schema_filter
13
+
14
+ def initialize
15
+ @database_url = ENV.fetch('DATABASE_URL', nil)
16
+ @audit_database_url = ENV.fetch('AUDIT_DATABASE_URL', nil)
17
+ @adapter = :postgresql
18
+ @publication_name = 'whodunit_audit'
19
+ @replication_slot_name = 'whodunit_audit_slot'
20
+ @batch_size = 100
21
+ @max_retry_attempts = 3
22
+ @retry_delay = 5
23
+ @logger = Dry::Logger.new
24
+ @table_filter = nil
25
+ @schema_filter = nil
26
+ end
27
+
28
+ # Validate configuration settings
29
+ #
30
+ # @raise [ConfigurationError] if configuration is invalid
31
+ def validate!
32
+ raise ConfigurationError, 'database_url is required' if database_url.nil?
33
+ raise ConfigurationError, 'adapter must be :postgresql' unless adapter == :postgresql
34
+ raise ConfigurationError, 'batch_size must be positive' unless batch_size.positive?
35
+ raise ConfigurationError, 'max_retry_attempts must be positive' unless max_retry_attempts.positive?
36
+ raise ConfigurationError, 'retry_delay must be positive' unless retry_delay.positive?
37
+
38
+ validate_publication_name!
39
+ validate_slot_name!
40
+ end
41
+
42
+ # Check if a table should be audited based on filters
43
+ #
44
+ # @param table_name [String] The table name to check
45
+ # @param schema_name [String] The schema name to check
46
+ # @return [Boolean] true if the table should be audited
47
+ def audit_table?(table_name, schema_name = 'public')
48
+ return false if filtered_by_schema?(schema_name)
49
+ return false if filtered_by_table?(table_name)
50
+
51
+ true
52
+ end
53
+
54
+ private
55
+
56
+ def validate_publication_name!
57
+ return if /\A[a-zA-Z_][a-zA-Z0-9_]*\z/.match?(publication_name)
58
+
59
+ raise ConfigurationError, 'publication_name must be a valid PostgreSQL identifier'
60
+ end
61
+
62
+ def validate_slot_name!
63
+ return if /\A[a-zA-Z_][a-zA-Z0-9_]*\z/.match?(replication_slot_name)
64
+
65
+ raise ConfigurationError, 'replication_slot_name must be a valid PostgreSQL identifier'
66
+ end
67
+
68
+ def filtered_by_schema?(schema_name)
69
+ return false unless schema_filter
70
+
71
+ case schema_filter
72
+ when Array
73
+ !schema_filter.include?(schema_name)
74
+ when String, Symbol
75
+ schema_name != schema_filter.to_s
76
+ when Proc
77
+ !schema_filter.call(schema_name)
78
+ else
79
+ false
80
+ end
81
+ end
82
+
83
+ def filtered_by_table?(table_name)
84
+ return false unless table_filter
85
+
86
+ case table_filter
87
+ when Array
88
+ !table_filter.include?(table_name)
89
+ when String, Symbol
90
+ table_name != table_filter.to_s
91
+ when Regexp
92
+ !table_filter.match?(table_name)
93
+ when Proc
94
+ !table_filter.call(table_name)
95
+ else
96
+ false
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent-ruby'
4
+
5
+ module Whodunit
6
+ module Chronicles
7
+ # Main service orchestrator for audit streaming
8
+ #
9
+ # Coordinates the stream adapter and audit processor to provide
10
+ # a complete audit streaming solution with error handling and monitoring.
11
+ class Service
12
+ attr_reader :adapter, :processor, :logger, :executor
13
+
14
+ def initialize(
15
+ adapter: nil,
16
+ processor: nil,
17
+ logger: Chronicles.logger
18
+ )
19
+ @adapter = adapter || build_adapter
20
+ @processor = processor || AuditProcessor.new(logger: logger)
21
+ @logger = logger
22
+ @executor = Concurrent::ThreadPoolExecutor.new(
23
+ min_threads: 1,
24
+ max_threads: 4,
25
+ max_queue: 100,
26
+ fallback_policy: :caller_runs,
27
+ )
28
+ @running = false
29
+ @retry_count = 0
30
+ end
31
+
32
+ # Start the audit streaming service
33
+ #
34
+ # @return [self]
35
+ def start
36
+ return self if running?
37
+
38
+ log(:info, 'Starting Chronicles audit streaming service')
39
+
40
+ validate_setup!
41
+ test_connections!
42
+
43
+ @running = true
44
+ @retry_count = 0
45
+
46
+ start_streaming_with_retry
47
+
48
+ log(:info, 'Chronicles audit streaming service started successfully')
49
+ self
50
+ rescue StandardError => e
51
+ log(:error, 'Failed to start service', error: e.message)
52
+ @running = false
53
+ raise
54
+ end
55
+
56
+ # Stop the audit streaming service
57
+ #
58
+ # @return [void]
59
+ def stop
60
+ return unless running?
61
+
62
+ log(:info, 'Stopping Chronicles audit streaming service')
63
+ @running = false
64
+
65
+ adapter.stop_streaming if adapter.streaming?
66
+ @executor.shutdown
67
+ @executor.wait_for_termination(timeout: 30)
68
+
69
+ processor.close
70
+ log(:info, 'Chronicles audit streaming service stopped')
71
+ end
72
+
73
+ # Check if service is running
74
+ #
75
+ # @return [Boolean]
76
+ def running?
77
+ @running
78
+ end
79
+
80
+ # Get service status information
81
+ #
82
+ # @return [Hash]
83
+ def status
84
+ {
85
+ running: running?,
86
+ adapter_streaming: adapter.streaming?,
87
+ adapter_position: adapter.current_position,
88
+ retry_count: @retry_count,
89
+ executor_status: {
90
+ active_count: @executor.active_count,
91
+ completed_task_count: @executor.completed_task_count,
92
+ queue_length: @executor.queue_length,
93
+ },
94
+ }
95
+ end
96
+
97
+ # Set up the audit streaming infrastructure
98
+ #
99
+ # @return [void]
100
+ def setup!
101
+ log(:info, 'Setting up audit streaming infrastructure')
102
+ adapter.setup
103
+ log(:info, 'Audit streaming infrastructure setup completed')
104
+ end
105
+
106
+ # Tear down the audit streaming infrastructure
107
+ #
108
+ # @return [void]
109
+ def teardown!
110
+ log(:info, 'Tearing down audit streaming infrastructure')
111
+ stop if running?
112
+ adapter.teardown
113
+ log(:info, 'Audit streaming infrastructure teardown completed')
114
+ end
115
+
116
+ private
117
+
118
+ def build_adapter
119
+ case Chronicles.config.adapter
120
+ when :postgresql
121
+ Adapters::PostgreSQL.new(logger: logger)
122
+ else
123
+ raise ConfigurationError, "Unsupported adapter: #{Chronicles.config.adapter}"
124
+ end
125
+ end
126
+
127
+ def validate_setup!
128
+ Chronicles.config.validate!
129
+
130
+ return if adapter.test_connection
131
+
132
+ raise AdapterError, 'Failed to connect to source database'
133
+ end
134
+
135
+ def test_connections!
136
+ adapter.test_connection
137
+ # Test audit processor connection by creating a dummy connection
138
+ processor.send(:ensure_audit_connection)
139
+ rescue StandardError => e
140
+ raise AdapterError, "Connection test failed: #{e.message}"
141
+ end
142
+
143
+ def start_streaming_with_retry
144
+ @executor.post do
145
+ loop do
146
+ break unless running?
147
+
148
+ begin
149
+ adapter.start_streaming do |change_event|
150
+ process_change_event(change_event)
151
+ end
152
+ rescue StandardError => e
153
+ handle_streaming_error(e)
154
+ break unless should_retry?
155
+ end
156
+ end
157
+ end
158
+ end
159
+
160
+ def process_change_event(change_event)
161
+ return unless change_event
162
+ return unless should_audit_table?(change_event)
163
+
164
+ log(:debug, 'Processing change event',
165
+ table: change_event.qualified_table_name,
166
+ action: change_event.action
167
+ )
168
+
169
+ processor.process(change_event)
170
+ rescue StandardError => e
171
+ log(:error, 'Failed to process change event',
172
+ error: e.message,
173
+ event: change_event.to_s
174
+ )
175
+ end
176
+
177
+ def should_audit_table?(change_event)
178
+ Chronicles.config.audit_table?(
179
+ change_event.table_name,
180
+ change_event.schema_name,
181
+ )
182
+ end
183
+
184
+ def handle_streaming_error(error)
185
+ @retry_count += 1
186
+ log(:error, 'Streaming error occurred',
187
+ error: error.message,
188
+ retry_count: @retry_count,
189
+ max_retries: Chronicles.config.max_retry_attempts
190
+ )
191
+
192
+ # Wait before retry
193
+ sleep(Chronicles.config.retry_delay) if should_retry?
194
+ end
195
+
196
+ def should_retry?
197
+ running? && @retry_count < Chronicles.config.max_retry_attempts
198
+ end
199
+
200
+ def log(level, message, context = {})
201
+ logger.public_send(level, message, service: 'Chronicles::Service', **context)
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whodunit
4
+ module Chronicles
5
+ # Abstract base class for database streaming adapters
6
+ #
7
+ # Defines the interface that all database-specific adapters must implement
8
+ # for streaming database changes into audit events.
9
+ class StreamAdapter
10
+ attr_reader :logger
11
+
12
+ def initialize(logger: Chronicles.logger)
13
+ @logger = logger
14
+ @running = false
15
+ @position = nil
16
+ end
17
+
18
+ # Start streaming database changes
19
+ #
20
+ # @param block [Proc] Block to call for each change event
21
+ # @return [void]
22
+ # @raise [NotImplementedError] Must be implemented by subclasses
23
+ def start_streaming(&)
24
+ raise NotImplementedError, "#{self.class} must implement #start_streaming"
25
+ end
26
+
27
+ # Stop streaming database changes
28
+ #
29
+ # @return [void]
30
+ # @raise [NotImplementedError] Must be implemented by subclasses
31
+ def stop_streaming
32
+ raise NotImplementedError, "#{self.class} must implement #stop_streaming"
33
+ end
34
+
35
+ # Get current replication position
36
+ #
37
+ # @return [String, nil] Current position or nil if not available
38
+ # @raise [NotImplementedError] Must be implemented by subclasses
39
+ def current_position
40
+ raise NotImplementedError, "#{self.class} must implement #current_position"
41
+ end
42
+
43
+ # Check if adapter is currently streaming
44
+ #
45
+ # @return [Boolean]
46
+ def streaming?
47
+ @running
48
+ end
49
+
50
+ # Set up the database for streaming (create publications, slots, etc.)
51
+ #
52
+ # @return [void]
53
+ # @raise [NotImplementedError] Must be implemented by subclasses
54
+ def setup
55
+ raise NotImplementedError, "#{self.class} must implement #setup"
56
+ end
57
+
58
+ # Tear down streaming setup (remove publications, slots, etc.)
59
+ #
60
+ # @return [void]
61
+ # @raise [NotImplementedError] Must be implemented by subclasses
62
+ def teardown
63
+ raise NotImplementedError, "#{self.class} must implement #teardown"
64
+ end
65
+
66
+ # Test connection to the database
67
+ #
68
+ # @return [Boolean] true if connection is successful
69
+ # @raise [NotImplementedError] Must be implemented by subclasses
70
+ def test_connection
71
+ raise NotImplementedError, "#{self.class} must implement #test_connection"
72
+ end
73
+
74
+ protected
75
+
76
+ attr_writer :running, :position
77
+
78
+ # Log a message with context
79
+ #
80
+ # @param level [Symbol] Log level (:info, :warn, :error, etc.)
81
+ # @param message [String] Log message
82
+ # @param context [Hash] Additional context
83
+ def log(level, message, context = {})
84
+ logger.public_send(level, message,
85
+ adapter: self.class.name.split('::').last,
86
+ position: current_position,
87
+ **context)
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whodunit
4
+ module Chronicles
5
+ VERSION = '0.1.0.pre'
6
+ end
7
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent-ruby'
4
+ require 'dry/configurable'
5
+ require 'dry/logger'
6
+
7
+ require_relative 'chronicles/version'
8
+ require_relative 'chronicles/configuration'
9
+ require_relative 'chronicles/change_event'
10
+ require_relative 'chronicles/stream_adapter'
11
+ require_relative 'chronicles/audit_processor'
12
+ require_relative 'chronicles/service'
13
+
14
+ # Adapters
15
+ require_relative 'chronicles/adapters/postgresql'
16
+
17
+ module Whodunit
18
+ # Chronicles - The complete historical record of `whodunit did what?` data
19
+ #
20
+ # While Whodunit tracks who made changes, Chronicles captures what changed
21
+ # by streaming database events into comprehensive audit trails with zero
22
+ # Rails application overhead.
23
+ module Chronicles
24
+ extend Dry::Configurable
25
+
26
+ # Configuration settings
27
+ setting :logger, default: Dry::Logger.new
28
+ setting :database_url, default: ENV.fetch('DATABASE_URL', nil)
29
+ setting :audit_database_url, default: ENV.fetch('AUDIT_DATABASE_URL', nil)
30
+ setting :adapter, default: :postgresql
31
+ setting :publication_name, default: 'whodunit_audit'
32
+ setting :replication_slot_name, default: 'whodunit_audit_slot'
33
+ setting :batch_size, default: 100
34
+ setting :max_retry_attempts, default: 3
35
+ setting :retry_delay, default: 5
36
+
37
+ class Error < StandardError; end
38
+ class ConfigurationError < Error; end
39
+ class AdapterError < Error; end
40
+ class ReplicationError < Error; end
41
+
42
+ # Configure Chronicles
43
+ #
44
+ # @example
45
+ # Whodunit::Chronicles.configure do |config|
46
+ # config.database_url = "postgresql://localhost/myapp"
47
+ # config.audit_database_url = "postgresql://localhost/myapp_audit"
48
+ # config.adapter = :postgresql
49
+ # end
50
+ def self.configure
51
+ yield(config) if block_given?
52
+ config
53
+ end
54
+
55
+ # Get the configured logger
56
+ #
57
+ # @return [Dry::Logger]
58
+ def self.logger
59
+ config.logger
60
+ end
61
+
62
+ # Start the audit streaming service
63
+ #
64
+ # @return [Service]
65
+ def self.start
66
+ Service.new.start
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Main gem entry point
4
+ require_relative 'whodunit/chronicles'