whodunit-chronicles 0.2.0 → 0.4.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -179
  3. data/LICENSE +1 -1
  4. data/README.md +96 -599
  5. data/exe/whodunit-chronicles +6 -0
  6. data/lib/whodunit/chronicles/chronicler.rb +62 -0
  7. data/lib/whodunit/chronicles/cli.rb +131 -0
  8. data/lib/whodunit/chronicles/errors.rb +17 -0
  9. data/lib/whodunit/chronicles/ledger.rb +69 -0
  10. data/lib/whodunit/chronicles/ledger_entry.rb +143 -0
  11. data/lib/whodunit/chronicles/ledger_factory.rb +66 -0
  12. data/lib/whodunit/chronicles/ledgers/file_ledger.rb +56 -0
  13. data/lib/whodunit/chronicles/ledgers/memory_ledger.rb +29 -0
  14. data/lib/whodunit/chronicles/ledgers/sqlite_ledger.rb +172 -0
  15. data/lib/whodunit/chronicles/version.rb +2 -1
  16. data/lib/whodunit/chronicles.rb +13 -71
  17. data/lib/whodunit-chronicles.rb +0 -1
  18. data/sig/whodunit/chronicles/chronicler.rbs +14 -0
  19. data/sig/whodunit/chronicles/cli.rbs +17 -0
  20. data/sig/whodunit/chronicles/errors.rbs +15 -0
  21. data/sig/whodunit/chronicles/ledger.rbs +13 -0
  22. data/sig/whodunit/chronicles/ledger_entry.rbs +62 -0
  23. data/sig/whodunit/chronicles/ledger_factory.rbs +14 -0
  24. data/sig/whodunit/chronicles/ledgers/file_ledger.rbs +14 -0
  25. data/sig/whodunit/chronicles/ledgers/memory_ledger.rbs +12 -0
  26. data/sig/whodunit/chronicles/ledgers/sqlite_ledger.rbs +30 -0
  27. data/sig/whodunit/chronicles.rbs +5 -0
  28. metadata +44 -295
  29. data/.codeclimate.yml +0 -50
  30. data/.rubocop.yml +0 -93
  31. data/.yardopts +0 -14
  32. data/CODE_OF_CONDUCT.md +0 -132
  33. data/Rakefile +0 -18
  34. data/examples/images/campaign-performance-analytics.png +0 -0
  35. data/examples/images/candidate-journey-analytics.png +0 -0
  36. data/examples/images/recruitment-funnel-analytics.png +0 -0
  37. data/lib/.gitkeep +0 -0
  38. data/lib/whodunit/chronicles/adapters/mysql.rb +0 -261
  39. data/lib/whodunit/chronicles/adapters/postgresql.rb +0 -278
  40. data/lib/whodunit/chronicles/change_event.rb +0 -201
  41. data/lib/whodunit/chronicles/configuration.rb +0 -112
  42. data/lib/whodunit/chronicles/connection.rb +0 -88
  43. data/lib/whodunit/chronicles/persistence.rb +0 -129
  44. data/lib/whodunit/chronicles/processor.rb +0 -127
  45. data/lib/whodunit/chronicles/service.rb +0 -207
  46. data/lib/whodunit/chronicles/stream_adapter.rb +0 -91
  47. data/lib/whodunit/chronicles/table.rb +0 -120
  48. data/whodunit-chronicles.gemspec +0 -65
@@ -1,201 +0,0 @@
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
@@ -1,112 +0,0 @@
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 or :mysql' unless %i[postgresql mysql].include?(adapter)
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_adapter_specific_settings!
39
- end
40
-
41
- # Check if a table should be chronicled based on filters
42
- #
43
- # @param table_name [String] The table name to check
44
- # @param schema_name [String] The schema name to check
45
- # @return [Boolean] true if the table should be chronicled
46
- def chronicle_table?(table_name, schema_name = 'public')
47
- return false if filtered_by_schema?(schema_name)
48
- return false if filtered_by_table?(table_name)
49
-
50
- true
51
- end
52
-
53
- private
54
-
55
- def validate_adapter_specific_settings!
56
- case adapter
57
- when :postgresql
58
- validate_postgresql_settings!
59
- when :mysql
60
- validate_mysql_settings!
61
- end
62
- end
63
-
64
- def validate_postgresql_settings!
65
- if publication_name && !/\A[a-zA-Z_][a-zA-Z0-9_]*\z/.match?(publication_name)
66
- raise ConfigurationError, 'publication_name must be a valid PostgreSQL identifier'
67
- end
68
-
69
- return unless replication_slot_name && !/\A[a-zA-Z_][a-zA-Z0-9_]*\z/.match?(replication_slot_name)
70
-
71
- raise ConfigurationError, 'replication_slot_name must be a valid PostgreSQL identifier'
72
- end
73
-
74
- def validate_mysql_settings!
75
- # MySQL-specific validations can be added here in the future
76
- # For now, MySQL settings are less restrictive
77
- end
78
-
79
- def filtered_by_schema?(schema_name)
80
- return false unless schema_filter
81
-
82
- case schema_filter
83
- when Array
84
- !schema_filter.include?(schema_name)
85
- when String, Symbol
86
- schema_name != schema_filter.to_s
87
- when Proc
88
- !schema_filter.call(schema_name)
89
- else
90
- false
91
- end
92
- end
93
-
94
- def filtered_by_table?(table_name)
95
- return false unless table_filter
96
-
97
- case table_filter
98
- when Array
99
- !table_filter.include?(table_name)
100
- when String, Symbol
101
- table_name != table_filter.to_s
102
- when Regexp
103
- !table_filter.match?(table_name)
104
- when Proc
105
- !table_filter.call(table_name)
106
- else
107
- false
108
- end
109
- end
110
- end
111
- end
112
- end
@@ -1,88 +0,0 @@
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
@@ -1,129 +0,0 @@
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
@@ -1,127 +0,0 @@
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