whodunit-chronicles 0.1.0 → 0.3.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 +4 -4
- data/.codeclimate.yml +50 -0
- data/.rubocop.yml +3 -3
- data/.yardopts +7 -5
- data/CHANGELOG.md +125 -1
- data/CONTRIBUTING.md +186 -0
- data/README.md +34 -22
- data/docker/mysql/init.sql +33 -0
- data/docker/postgres/init.sql +40 -0
- data/docker-compose.yml +138 -0
- data/lib/whodunit/chronicles/adapter_loader.rb +69 -0
- data/lib/whodunit/chronicles/adapters/mysql.rb +261 -0
- data/lib/whodunit/chronicles/adapters/postgresql.rb +1 -1
- data/lib/whodunit/chronicles/change_event.rb +2 -2
- data/lib/whodunit/chronicles/composite_processor.rb +86 -0
- data/lib/whodunit/chronicles/configuration.rb +23 -12
- data/lib/whodunit/chronicles/connection.rb +88 -0
- data/lib/whodunit/chronicles/errors.rb +43 -0
- data/lib/whodunit/chronicles/persistence.rb +129 -0
- data/lib/whodunit/chronicles/processor.rb +127 -0
- data/lib/whodunit/chronicles/service.rb +26 -24
- data/lib/whodunit/chronicles/table.rb +120 -0
- data/lib/whodunit/chronicles/version.rb +1 -1
- data/lib/whodunit/chronicles.rb +13 -8
- data/whodunit-chronicles.gemspec +28 -10
- metadata +106 -10
- data/lib/whodunit/chronicles/audit_processor.rb +0 -270
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module Whodunit
|
|
6
|
+
module Chronicles
|
|
7
|
+
# Handles database connections for chronicles processing
|
|
8
|
+
#
|
|
9
|
+
# Provides adapter-agnostic connection management for both PostgreSQL and MySQL
|
|
10
|
+
module Connection
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def create_connection
|
|
14
|
+
audit_url = @audit_database_url || Chronicles.config.database_url
|
|
15
|
+
|
|
16
|
+
case detect_database_type(audit_url)
|
|
17
|
+
when :postgresql
|
|
18
|
+
require 'pg'
|
|
19
|
+
PG.connect(audit_url)
|
|
20
|
+
when :mysql
|
|
21
|
+
require 'trilogy'
|
|
22
|
+
parsed = parse_mysql_url(audit_url)
|
|
23
|
+
Trilogy.new(
|
|
24
|
+
host: parsed[:host],
|
|
25
|
+
port: parsed[:port] || 3306,
|
|
26
|
+
username: parsed[:username],
|
|
27
|
+
password: parsed[:password],
|
|
28
|
+
database: parsed[:database],
|
|
29
|
+
ssl: parsed[:ssl],
|
|
30
|
+
)
|
|
31
|
+
else
|
|
32
|
+
raise ConfigurationError, 'Unsupported database type for connection'
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def detect_database_type(url)
|
|
37
|
+
return Chronicles.config.adapter unless url
|
|
38
|
+
return :postgresql if url.start_with?('postgres://', 'postgresql://')
|
|
39
|
+
return :mysql if url.start_with?('mysql://', 'mysql2://')
|
|
40
|
+
|
|
41
|
+
# Fallback to configured adapter
|
|
42
|
+
Chronicles.config.adapter
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def parse_mysql_url(url)
|
|
46
|
+
return {} if url.nil? || url.empty?
|
|
47
|
+
|
|
48
|
+
uri = URI.parse(url)
|
|
49
|
+
{
|
|
50
|
+
host: uri.host,
|
|
51
|
+
port: uri.port,
|
|
52
|
+
username: uri.user,
|
|
53
|
+
password: uri.password,
|
|
54
|
+
database: uri.path&.sub('/', ''),
|
|
55
|
+
ssl: uri.query&.include?('ssl=true'),
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def connection_active?
|
|
60
|
+
case detect_database_type(@audit_database_url || Chronicles.config.database_url)
|
|
61
|
+
when :postgresql
|
|
62
|
+
@connection && !@connection.finished?
|
|
63
|
+
when :mysql
|
|
64
|
+
@connection&.ping
|
|
65
|
+
else
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def setup_connection_specifics
|
|
71
|
+
case detect_database_type(@audit_database_url || Chronicles.config.database_url)
|
|
72
|
+
when :postgresql
|
|
73
|
+
@connection.type_map_for_results = PG::BasicTypeMapForResults.new(@connection)
|
|
74
|
+
when :mysql
|
|
75
|
+
# MySQL/Trilogy doesn't need special setup
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def ensure_connection
|
|
80
|
+
return if @connection && connection_active?
|
|
81
|
+
|
|
82
|
+
@connection = create_connection
|
|
83
|
+
setup_connection_specifics
|
|
84
|
+
ensure_table_exists
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whodunit
|
|
4
|
+
module Chronicles
|
|
5
|
+
# Base error class for all whodunit-chronicles errors.
|
|
6
|
+
#
|
|
7
|
+
# Rescuing this class catches every error raised by the gem:
|
|
8
|
+
#
|
|
9
|
+
# begin
|
|
10
|
+
# Whodunit::Chronicles.service.start
|
|
11
|
+
# rescue Whodunit::Chronicles::Error => e
|
|
12
|
+
# logger.error("Chronicles failure: #{e.message}")
|
|
13
|
+
# end
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
|
|
16
|
+
# Raised when the gem is configured with invalid or missing settings.
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
19
|
+
# Whodunit::Chronicles.configure do |c|
|
|
20
|
+
# c.adapter = :unknown_db # => raises ConfigurationError
|
|
21
|
+
# end
|
|
22
|
+
class ConfigurationError < Error; end
|
|
23
|
+
|
|
24
|
+
# Raised when a required database driver gem is not installed.
|
|
25
|
+
#
|
|
26
|
+
# @example message
|
|
27
|
+
# "Could not load the 'pg' gem required for the postgresql adapter.
|
|
28
|
+
# Add `gem 'pg', '~> 1.5'` to your Gemfile."
|
|
29
|
+
class AdapterLoadError < Error; end
|
|
30
|
+
|
|
31
|
+
# Raised when a streaming or replication connection fails.
|
|
32
|
+
class ConnectionError < Error; end
|
|
33
|
+
|
|
34
|
+
# Raised when processing a change event fails.
|
|
35
|
+
class ProcessingError < Error; end
|
|
36
|
+
|
|
37
|
+
# Raised when writing an audit record to the audit database fails.
|
|
38
|
+
class PersistenceError < Error; end
|
|
39
|
+
|
|
40
|
+
# Raised when replication related error occurs
|
|
41
|
+
class ReplicationError < Error; end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whodunit
|
|
4
|
+
module Chronicles
|
|
5
|
+
# Handles record persistence for different database adapters
|
|
6
|
+
#
|
|
7
|
+
# Provides adapter-specific SQL for inserting chronicle records
|
|
8
|
+
module Persistence
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def persist_record(record)
|
|
12
|
+
db_type = detect_database_type(@audit_database_url || Chronicles.config.database_url)
|
|
13
|
+
|
|
14
|
+
case db_type
|
|
15
|
+
when :postgresql
|
|
16
|
+
persist_record_postgresql(record)
|
|
17
|
+
when :mysql
|
|
18
|
+
persist_record_mysql(record)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def persist_record_postgresql(record)
|
|
23
|
+
sql = <<~SQL
|
|
24
|
+
INSERT INTO whodunit_chronicles_audits (
|
|
25
|
+
table_name, schema_name, record_id, action, old_data, new_data, changes,
|
|
26
|
+
user_id, user_type, transaction_id, sequence_number, occurred_at, created_at, metadata
|
|
27
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
|
28
|
+
RETURNING id
|
|
29
|
+
SQL
|
|
30
|
+
|
|
31
|
+
params = build_record_params(record)
|
|
32
|
+
result = @connection.exec_params(sql, params)
|
|
33
|
+
record[:id] = result.first['id'].to_i
|
|
34
|
+
result.clear
|
|
35
|
+
|
|
36
|
+
record
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def persist_record_mysql(record)
|
|
40
|
+
sql = <<~SQL
|
|
41
|
+
INSERT INTO whodunit_chronicles_audits (
|
|
42
|
+
table_name, schema_name, record_id, action, old_data, new_data, changes,
|
|
43
|
+
user_id, user_type, transaction_id, sequence_number, occurred_at, created_at, metadata
|
|
44
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
45
|
+
SQL
|
|
46
|
+
|
|
47
|
+
params = build_record_params(record)
|
|
48
|
+
@connection.execute(sql, *params)
|
|
49
|
+
record[:id] = @connection.last_insert_id
|
|
50
|
+
|
|
51
|
+
record
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def persist_records_batch(records)
|
|
55
|
+
return records if records.empty?
|
|
56
|
+
|
|
57
|
+
db_type = detect_database_type(@audit_database_url || Chronicles.config.database_url)
|
|
58
|
+
|
|
59
|
+
case db_type
|
|
60
|
+
when :postgresql
|
|
61
|
+
persist_records_batch_postgresql(records)
|
|
62
|
+
when :mysql
|
|
63
|
+
persist_records_batch_mysql(records)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def persist_records_batch_postgresql(records)
|
|
68
|
+
# Use multi-row INSERT for better performance
|
|
69
|
+
values_clauses = []
|
|
70
|
+
all_params = []
|
|
71
|
+
param_index = 1
|
|
72
|
+
|
|
73
|
+
records.each do |record|
|
|
74
|
+
param_positions = (param_index..(param_index + 13)).map { |i| "$#{i}" }.join(', ')
|
|
75
|
+
values_clauses << "(#{param_positions})"
|
|
76
|
+
all_params.concat(build_record_params(record))
|
|
77
|
+
param_index += 14
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
sql = <<~SQL
|
|
81
|
+
INSERT INTO whodunit_chronicles_audits (
|
|
82
|
+
table_name, schema_name, record_id, action, old_data, new_data, changes,
|
|
83
|
+
user_id, user_type, transaction_id, sequence_number, occurred_at, created_at, metadata
|
|
84
|
+
) VALUES #{values_clauses.join(', ')}
|
|
85
|
+
RETURNING id
|
|
86
|
+
SQL
|
|
87
|
+
|
|
88
|
+
result = @connection.exec_params(sql, all_params)
|
|
89
|
+
|
|
90
|
+
# Set IDs on the records
|
|
91
|
+
result.each_with_index do |row, index|
|
|
92
|
+
records[index][:id] = row['id'].to_i
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
result.clear
|
|
96
|
+
records
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def persist_records_batch_mysql(records)
|
|
100
|
+
# For MySQL, we'll use individual inserts in a transaction for simplicity
|
|
101
|
+
# A more optimized version could use VALUES() with multiple rows
|
|
102
|
+
records.each do |record|
|
|
103
|
+
persist_record_mysql(record)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
records
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def build_record_params(record)
|
|
110
|
+
[
|
|
111
|
+
record[:table_name],
|
|
112
|
+
record[:schema_name],
|
|
113
|
+
record[:record_id].to_json,
|
|
114
|
+
record[:action],
|
|
115
|
+
record[:old_data]&.to_json,
|
|
116
|
+
record[:new_data]&.to_json,
|
|
117
|
+
record[:changes].to_json,
|
|
118
|
+
record[:user_id],
|
|
119
|
+
record[:user_type],
|
|
120
|
+
record[:transaction_id],
|
|
121
|
+
record[:sequence_number],
|
|
122
|
+
record[:occurred_at],
|
|
123
|
+
record[:created_at],
|
|
124
|
+
record[:metadata].to_json,
|
|
125
|
+
]
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whodunit
|
|
4
|
+
module Chronicles
|
|
5
|
+
# Processes database change events and creates chronicle records
|
|
6
|
+
#
|
|
7
|
+
# Transforms ChangeEvent objects into structured chronicle records
|
|
8
|
+
# with complete object serialization and metadata.
|
|
9
|
+
class Processor
|
|
10
|
+
include Connection
|
|
11
|
+
include Table
|
|
12
|
+
include Persistence
|
|
13
|
+
|
|
14
|
+
attr_reader :logger, :connection
|
|
15
|
+
|
|
16
|
+
def initialize(
|
|
17
|
+
audit_database_url: Chronicles.config.audit_database_url,
|
|
18
|
+
logger: Chronicles.logger
|
|
19
|
+
)
|
|
20
|
+
@audit_database_url = audit_database_url
|
|
21
|
+
@logger = logger
|
|
22
|
+
@connection = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Process a change event and create chronicle record
|
|
26
|
+
#
|
|
27
|
+
# @param change_event [ChangeEvent] The database change to chronicle
|
|
28
|
+
# @return [Hash] The created chronicle record
|
|
29
|
+
def process(change_event)
|
|
30
|
+
ensure_connection
|
|
31
|
+
|
|
32
|
+
record = build_record(change_event)
|
|
33
|
+
persist_record(record)
|
|
34
|
+
|
|
35
|
+
log(:debug, 'Processed change event',
|
|
36
|
+
table: change_event.qualified_table_name,
|
|
37
|
+
action: change_event.action,
|
|
38
|
+
id: record[:id])
|
|
39
|
+
|
|
40
|
+
record
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
log(:error, 'Failed to process change event',
|
|
43
|
+
error: e.message,
|
|
44
|
+
event: change_event.to_s)
|
|
45
|
+
raise
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Process multiple change events in a batch
|
|
49
|
+
#
|
|
50
|
+
# @param change_events [Array<ChangeEvent>] Array of change events
|
|
51
|
+
# @return [Array<Hash>] Array of created chronicle records
|
|
52
|
+
def process_batch(change_events)
|
|
53
|
+
return [] if change_events.empty?
|
|
54
|
+
|
|
55
|
+
ensure_connection
|
|
56
|
+
|
|
57
|
+
records = change_events.map { |event| build_record(event) }
|
|
58
|
+
persist_records_batch(records)
|
|
59
|
+
|
|
60
|
+
log(:info, 'Processed batch of change events', count: change_events.size)
|
|
61
|
+
|
|
62
|
+
records
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
log(:error, 'Failed to process batch',
|
|
65
|
+
error: e.message,
|
|
66
|
+
count: change_events.size)
|
|
67
|
+
raise
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Close database connection
|
|
71
|
+
def close
|
|
72
|
+
@connection&.close
|
|
73
|
+
@connection = nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def build_record(change_event)
|
|
79
|
+
user_info = extract_user_info(change_event)
|
|
80
|
+
|
|
81
|
+
{
|
|
82
|
+
id: nil, # Will be set by database
|
|
83
|
+
table_name: change_event.table_name,
|
|
84
|
+
schema_name: change_event.schema_name,
|
|
85
|
+
record_id: change_event.primary_key,
|
|
86
|
+
action: change_event.action,
|
|
87
|
+
old_data: change_event.old_data,
|
|
88
|
+
new_data: change_event.new_data,
|
|
89
|
+
changes: change_event.changes,
|
|
90
|
+
user_id: user_info[:user_id],
|
|
91
|
+
user_type: user_info[:user_type],
|
|
92
|
+
transaction_id: change_event.transaction_id,
|
|
93
|
+
sequence_number: change_event.sequence_number,
|
|
94
|
+
occurred_at: change_event.timestamp,
|
|
95
|
+
created_at: Time.now,
|
|
96
|
+
metadata: build_metadata(change_event),
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def extract_user_info(change_event)
|
|
101
|
+
data = change_event.current_data || {}
|
|
102
|
+
|
|
103
|
+
# Look for Whodunit user attribution fields
|
|
104
|
+
user_id = data['creator_id'] || data['updater_id'] || data['deleter_id']
|
|
105
|
+
|
|
106
|
+
{
|
|
107
|
+
user_id: user_id,
|
|
108
|
+
user_type: user_id ? 'User' : nil,
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def build_metadata(change_event)
|
|
113
|
+
{
|
|
114
|
+
table_schema: change_event.schema_name,
|
|
115
|
+
qualified_table_name: change_event.qualified_table_name,
|
|
116
|
+
changed_columns: change_event.changed_columns,
|
|
117
|
+
adapter_metadata: change_event.metadata,
|
|
118
|
+
chronicles_version: Chronicles::VERSION,
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def log(level, message, context = {})
|
|
123
|
+
logger.public_send(level, message, processor: 'Processor', **context)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -4,10 +4,10 @@ require 'concurrent-ruby'
|
|
|
4
4
|
|
|
5
5
|
module Whodunit
|
|
6
6
|
module Chronicles
|
|
7
|
-
# Main service orchestrator for
|
|
7
|
+
# Main service orchestrator for chronicle streaming
|
|
8
8
|
#
|
|
9
|
-
# Coordinates the stream adapter and
|
|
10
|
-
# a complete
|
|
9
|
+
# Coordinates the stream adapter and processor to provide
|
|
10
|
+
# a complete chronicle streaming solution with error handling and monitoring.
|
|
11
11
|
class Service
|
|
12
12
|
attr_reader :adapter, :processor, :logger, :executor
|
|
13
13
|
|
|
@@ -17,7 +17,7 @@ module Whodunit
|
|
|
17
17
|
logger: Chronicles.logger
|
|
18
18
|
)
|
|
19
19
|
@adapter = adapter || build_adapter
|
|
20
|
-
@processor = processor ||
|
|
20
|
+
@processor = processor || Processor.new(logger: logger)
|
|
21
21
|
@logger = logger
|
|
22
22
|
@executor = Concurrent::ThreadPoolExecutor.new(
|
|
23
23
|
min_threads: 1,
|
|
@@ -29,13 +29,13 @@ module Whodunit
|
|
|
29
29
|
@retry_count = 0
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
# Start the
|
|
32
|
+
# Start the chronicle streaming service
|
|
33
33
|
#
|
|
34
34
|
# @return [self]
|
|
35
35
|
def start
|
|
36
36
|
return self if running?
|
|
37
37
|
|
|
38
|
-
log(:info, 'Starting Chronicles
|
|
38
|
+
log(:info, 'Starting Chronicles streaming service')
|
|
39
39
|
|
|
40
40
|
validate_setup!
|
|
41
41
|
test_connections!
|
|
@@ -45,7 +45,7 @@ module Whodunit
|
|
|
45
45
|
|
|
46
46
|
start_streaming_with_retry
|
|
47
47
|
|
|
48
|
-
log(:info, 'Chronicles
|
|
48
|
+
log(:info, 'Chronicles streaming service started successfully')
|
|
49
49
|
self
|
|
50
50
|
rescue StandardError => e
|
|
51
51
|
log(:error, 'Failed to start service', error: e.message)
|
|
@@ -53,13 +53,13 @@ module Whodunit
|
|
|
53
53
|
raise
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
-
# Stop the
|
|
56
|
+
# Stop the chronicle streaming service
|
|
57
57
|
#
|
|
58
58
|
# @return [void]
|
|
59
59
|
def stop
|
|
60
60
|
return unless running?
|
|
61
61
|
|
|
62
|
-
log(:info, 'Stopping Chronicles
|
|
62
|
+
log(:info, 'Stopping Chronicles streaming service')
|
|
63
63
|
@running = false
|
|
64
64
|
|
|
65
65
|
adapter.stop_streaming if adapter.streaming?
|
|
@@ -67,7 +67,7 @@ module Whodunit
|
|
|
67
67
|
@executor.wait_for_termination(timeout: 30)
|
|
68
68
|
|
|
69
69
|
processor.close
|
|
70
|
-
log(:info, 'Chronicles
|
|
70
|
+
log(:info, 'Chronicles streaming service stopped')
|
|
71
71
|
end
|
|
72
72
|
|
|
73
73
|
# Check if service is running
|
|
@@ -94,23 +94,23 @@ module Whodunit
|
|
|
94
94
|
}
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
-
# Set up the
|
|
97
|
+
# Set up the chronicle streaming infrastructure
|
|
98
98
|
#
|
|
99
99
|
# @return [void]
|
|
100
100
|
def setup!
|
|
101
|
-
log(:info, 'Setting up
|
|
101
|
+
log(:info, 'Setting up chronicle streaming infrastructure')
|
|
102
102
|
adapter.setup
|
|
103
|
-
log(:info, '
|
|
103
|
+
log(:info, 'Chronicle streaming infrastructure setup completed')
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
-
# Tear down the
|
|
106
|
+
# Tear down the chronicle streaming infrastructure
|
|
107
107
|
#
|
|
108
108
|
# @return [void]
|
|
109
109
|
def teardown!
|
|
110
|
-
log(:info, 'Tearing down
|
|
110
|
+
log(:info, 'Tearing down chronicle streaming infrastructure')
|
|
111
111
|
stop if running?
|
|
112
112
|
adapter.teardown
|
|
113
|
-
log(:info, '
|
|
113
|
+
log(:info, 'Chronicle streaming infrastructure teardown completed')
|
|
114
114
|
end
|
|
115
115
|
|
|
116
116
|
private
|
|
@@ -118,7 +118,9 @@ module Whodunit
|
|
|
118
118
|
def build_adapter
|
|
119
119
|
case Chronicles.config.adapter
|
|
120
120
|
when :postgresql
|
|
121
|
-
|
|
121
|
+
Whodunit::Chronicles::AdapterLoader.load(:postgresql, logger:)
|
|
122
|
+
when :mysql
|
|
123
|
+
Whodunit::Chronicles::AdapterLoader.load(:mysql, logger:)
|
|
122
124
|
else
|
|
123
125
|
raise ConfigurationError, "Unsupported adapter: #{Chronicles.config.adapter}"
|
|
124
126
|
end
|
|
@@ -129,15 +131,15 @@ module Whodunit
|
|
|
129
131
|
|
|
130
132
|
return if adapter.test_connection
|
|
131
133
|
|
|
132
|
-
raise
|
|
134
|
+
raise AdapterLoadError, 'Failed to connect to source database'
|
|
133
135
|
end
|
|
134
136
|
|
|
135
137
|
def test_connections!
|
|
136
138
|
adapter.test_connection
|
|
137
|
-
# Test
|
|
138
|
-
processor.send(:
|
|
139
|
+
# Test processor connection by creating a dummy connection
|
|
140
|
+
processor.send(:ensure_connection)
|
|
139
141
|
rescue StandardError => e
|
|
140
|
-
raise
|
|
142
|
+
raise AdapterLoadError, "Connection test failed: #{e.message}"
|
|
141
143
|
end
|
|
142
144
|
|
|
143
145
|
def start_streaming_with_retry
|
|
@@ -159,7 +161,7 @@ module Whodunit
|
|
|
159
161
|
|
|
160
162
|
def process_change_event(change_event)
|
|
161
163
|
return unless change_event
|
|
162
|
-
return unless
|
|
164
|
+
return unless should_chronicle_table?(change_event)
|
|
163
165
|
|
|
164
166
|
log(:debug, 'Processing change event',
|
|
165
167
|
table: change_event.qualified_table_name,
|
|
@@ -174,8 +176,8 @@ module Whodunit
|
|
|
174
176
|
)
|
|
175
177
|
end
|
|
176
178
|
|
|
177
|
-
def
|
|
178
|
-
Chronicles.config.
|
|
179
|
+
def should_chronicle_table?(change_event)
|
|
180
|
+
Chronicles.config.chronicle_table?(
|
|
179
181
|
change_event.table_name,
|
|
180
182
|
change_event.schema_name,
|
|
181
183
|
)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whodunit
|
|
4
|
+
module Chronicles
|
|
5
|
+
# Handles table creation for different database adapters
|
|
6
|
+
#
|
|
7
|
+
# Provides adapter-specific SQL for creating chronicles tables
|
|
8
|
+
module Table
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def ensure_table_exists
|
|
12
|
+
db_type = detect_database_type(@audit_database_url || Chronicles.config.database_url)
|
|
13
|
+
|
|
14
|
+
case db_type
|
|
15
|
+
when :postgresql
|
|
16
|
+
create_postgresql_table
|
|
17
|
+
when :mysql
|
|
18
|
+
create_mysql_table
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def create_postgresql_table
|
|
23
|
+
create_sql = <<~SQL
|
|
24
|
+
CREATE TABLE IF NOT EXISTS whodunit_chronicles_audits (
|
|
25
|
+
id BIGSERIAL PRIMARY KEY,
|
|
26
|
+
table_name TEXT NOT NULL,
|
|
27
|
+
schema_name TEXT NOT NULL DEFAULT 'public',
|
|
28
|
+
record_id JSONB,
|
|
29
|
+
action TEXT NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
|
|
30
|
+
old_data JSONB,
|
|
31
|
+
new_data JSONB,
|
|
32
|
+
changes JSONB,
|
|
33
|
+
user_id BIGINT,
|
|
34
|
+
user_type TEXT,
|
|
35
|
+
transaction_id TEXT,
|
|
36
|
+
sequence_number INTEGER,
|
|
37
|
+
occurred_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
38
|
+
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
|
39
|
+
metadata JSONB DEFAULT '{}'::jsonb,
|
|
40
|
+
#{' '}
|
|
41
|
+
-- Indexes for performance
|
|
42
|
+
CONSTRAINT valid_data_for_action CHECK (
|
|
43
|
+
(action = 'INSERT' AND old_data IS NULL AND new_data IS NOT NULL) OR
|
|
44
|
+
(action = 'UPDATE' AND old_data IS NOT NULL AND new_data IS NOT NULL) OR#{' '}
|
|
45
|
+
(action = 'DELETE' AND old_data IS NOT NULL AND new_data IS NULL)
|
|
46
|
+
)
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
-- Performance indexes
|
|
50
|
+
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chronicles_audits_table_record#{' '}
|
|
51
|
+
ON whodunit_chronicles_audits (table_name, (record_id->>'id'));
|
|
52
|
+
|
|
53
|
+
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chronicles_audits_occurred_at#{' '}
|
|
54
|
+
ON whodunit_chronicles_audits (occurred_at DESC);
|
|
55
|
+
|
|
56
|
+
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chronicles_audits_user#{' '}
|
|
57
|
+
ON whodunit_chronicles_audits (user_id, user_type);
|
|
58
|
+
|
|
59
|
+
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chronicles_audits_action#{' '}
|
|
60
|
+
ON whodunit_chronicles_audits (action);
|
|
61
|
+
|
|
62
|
+
-- GIN index for JSONB columns
|
|
63
|
+
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chronicles_audits_record_id_gin#{' '}
|
|
64
|
+
ON whodunit_chronicles_audits USING GIN (record_id);
|
|
65
|
+
|
|
66
|
+
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chronicles_audits_changes_gin#{' '}
|
|
67
|
+
ON whodunit_chronicles_audits USING GIN (changes);
|
|
68
|
+
SQL
|
|
69
|
+
|
|
70
|
+
@connection.exec(create_sql)
|
|
71
|
+
rescue PG::Error => e
|
|
72
|
+
# Ignore "already exists" errors from CONCURRENTLY
|
|
73
|
+
raise unless e.message.include?('already exists')
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def create_mysql_table
|
|
77
|
+
create_sql = <<~SQL
|
|
78
|
+
CREATE TABLE IF NOT EXISTS whodunit_chronicles_audits (
|
|
79
|
+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
80
|
+
table_name TEXT NOT NULL,
|
|
81
|
+
schema_name TEXT NOT NULL DEFAULT 'public',
|
|
82
|
+
record_id JSON,
|
|
83
|
+
action TEXT NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
|
|
84
|
+
old_data JSON,
|
|
85
|
+
new_data JSON,
|
|
86
|
+
changes JSON,
|
|
87
|
+
user_id BIGINT,
|
|
88
|
+
user_type TEXT,
|
|
89
|
+
transaction_id TEXT,
|
|
90
|
+
sequence_number INTEGER,
|
|
91
|
+
occurred_at TIMESTAMP NOT NULL,
|
|
92
|
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
93
|
+
metadata JSON DEFAULT (JSON_OBJECT()),
|
|
94
|
+
#{' '}
|
|
95
|
+
-- Constraint for data integrity
|
|
96
|
+
CONSTRAINT valid_data_for_action CHECK (
|
|
97
|
+
(action = 'INSERT' AND old_data IS NULL AND new_data IS NOT NULL) OR
|
|
98
|
+
(action = 'UPDATE' AND old_data IS NOT NULL AND new_data IS NOT NULL) OR
|
|
99
|
+
(action = 'DELETE' AND old_data IS NOT NULL AND new_data IS NULL)
|
|
100
|
+
),
|
|
101
|
+
#{' '}
|
|
102
|
+
-- Performance indexes
|
|
103
|
+
INDEX idx_chronicles_audits_table_record (table_name(255), (JSON_UNQUOTE(JSON_EXTRACT(record_id, '$.id')))),
|
|
104
|
+
INDEX idx_chronicles_audits_occurred_at (occurred_at DESC),
|
|
105
|
+
INDEX idx_chronicles_audits_user (user_id, user_type(255)),
|
|
106
|
+
INDEX idx_chronicles_audits_action (action(50))
|
|
107
|
+
);
|
|
108
|
+
SQL
|
|
109
|
+
|
|
110
|
+
@connection.query(create_sql)
|
|
111
|
+
rescue StandardError => e
|
|
112
|
+
# Ignore "already exists" errors
|
|
113
|
+
unless e.message.include?('already exists') ||
|
|
114
|
+
(e.message.include?('Table') && e.message.include?('already exists'))
|
|
115
|
+
raise
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|