whodunit-chronicles 0.3.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -226
  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 +7 -33
  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 +12 -65
  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 +40 -326
  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/CONTRIBUTING.md +0 -186
  34. data/Rakefile +0 -18
  35. data/docker/mysql/init.sql +0 -33
  36. data/docker/postgres/init.sql +0 -40
  37. data/docker-compose.yml +0 -138
  38. data/examples/images/campaign-performance-analytics.png +0 -0
  39. data/examples/images/candidate-journey-analytics.png +0 -0
  40. data/examples/images/recruitment-funnel-analytics.png +0 -0
  41. data/lib/.gitkeep +0 -0
  42. data/lib/whodunit/chronicles/adapter_loader.rb +0 -69
  43. data/lib/whodunit/chronicles/adapters/mysql.rb +0 -261
  44. data/lib/whodunit/chronicles/adapters/postgresql.rb +0 -278
  45. data/lib/whodunit/chronicles/change_event.rb +0 -201
  46. data/lib/whodunit/chronicles/composite_processor.rb +0 -86
  47. data/lib/whodunit/chronicles/configuration.rb +0 -112
  48. data/lib/whodunit/chronicles/connection.rb +0 -88
  49. data/lib/whodunit/chronicles/persistence.rb +0 -129
  50. data/lib/whodunit/chronicles/processor.rb +0 -127
  51. data/lib/whodunit/chronicles/service.rb +0 -207
  52. data/lib/whodunit/chronicles/stream_adapter.rb +0 -91
  53. data/lib/whodunit/chronicles/table.rb +0 -120
  54. data/whodunit-chronicles.gemspec +0 -79
@@ -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.to_h do |column|
99
- [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,86 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Whodunit
4
- module Chronicles
5
- # Fans out a single change event to multiple processors in sequence.
6
- #
7
- # Use this to build pipelines — e.g. simultaneously storing audit records,
8
- # streaming to Grafana, and triggering alerts — without coupling any single
9
- # processor to the others.
10
- #
11
- # Each processor runs independently. If one raises, the error is logged and
12
- # the remaining processors still execute (fail-open by default). Set
13
- # `fail_fast: true` to instead halt the chain on the first error.
14
- #
15
- # @example Basic pipeline
16
- # service = Whodunit::Chronicles::Service.new(
17
- # adapter: adapter,
18
- # processor: Whodunit::Chronicles::CompositeProcessor.new([
19
- # AuditStoreProcessor.new,
20
- # AlertingProcessor.new,
21
- # GrafanaProcessor.new
22
- # ])
23
- # )
24
- #
25
- # @example Halt on first error
26
- # CompositeProcessor.new(processors, fail_fast: true)
27
- #
28
- class CompositeProcessor
29
- # @param processors [Array<#process>] ordered list of processors to invoke
30
- # @param fail_fast [Boolean] when true, halt the chain on the first error
31
- # @param logger [Logger, nil] optional logger; defaults to Chronicles logger
32
- def initialize(processors, fail_fast: false, logger: nil)
33
- raise ArgumentError, 'processors must be an Array' unless processors.is_a?(Array)
34
- raise ArgumentError, 'processors cannot be empty' if processors.empty?
35
-
36
- @processors = processors
37
- @fail_fast = fail_fast
38
- @logger = logger || Whodunit::Chronicles.logger
39
- end
40
-
41
- # Process a change event through every processor in the chain.
42
- #
43
- # @param change_event [ChangeEvent] the event to process
44
- # @return [void]
45
- # @raise [ProcessingError] only when fail_fast is true and a child raises
46
- def process(change_event)
47
- errors = []
48
-
49
- @processors.each do |processor|
50
- processor.process(change_event)
51
- rescue StandardError => e
52
- raise ProcessingError, "#{processor.class} failed: #{e.message}" if @fail_fast
53
-
54
- @logger.error { "CompositeProcessor: #{processor.class} raised #{e.class}: #{e.message}" }
55
- errors << e
56
- end
57
-
58
- return if errors.empty?
59
-
60
- @logger.warn do
61
- "CompositeProcessor: #{errors.size} processor(s) failed for #{change_event.table_name}##{change_event.action}"
62
- end
63
- end
64
-
65
- # @return [Integer] the number of processors in the chain
66
- def size
67
- @processors.size
68
- end
69
-
70
- # @return [Array<Class>] the processor classes in chain order
71
- def processor_classes
72
- @processors.map(&:class)
73
- end
74
-
75
- # Append a processor to the end of the chain.
76
- #
77
- # @param processor [#process] the processor to add
78
- # @return [self]
79
- def add(processor)
80
- @processors << processor
81
- self
82
- end
83
- alias << add
84
- end
85
- end
86
- 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