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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -226
- data/LICENSE +1 -1
- data/README.md +96 -599
- data/exe/whodunit-chronicles +6 -0
- data/lib/whodunit/chronicles/chronicler.rb +62 -0
- data/lib/whodunit/chronicles/cli.rb +131 -0
- data/lib/whodunit/chronicles/errors.rb +7 -33
- data/lib/whodunit/chronicles/ledger.rb +69 -0
- data/lib/whodunit/chronicles/ledger_entry.rb +143 -0
- data/lib/whodunit/chronicles/ledger_factory.rb +66 -0
- data/lib/whodunit/chronicles/ledgers/file_ledger.rb +56 -0
- data/lib/whodunit/chronicles/ledgers/memory_ledger.rb +29 -0
- data/lib/whodunit/chronicles/ledgers/sqlite_ledger.rb +172 -0
- data/lib/whodunit/chronicles/version.rb +2 -1
- data/lib/whodunit/chronicles.rb +12 -65
- data/lib/whodunit-chronicles.rb +0 -1
- data/sig/whodunit/chronicles/chronicler.rbs +14 -0
- data/sig/whodunit/chronicles/cli.rbs +17 -0
- data/sig/whodunit/chronicles/errors.rbs +15 -0
- data/sig/whodunit/chronicles/ledger.rbs +13 -0
- data/sig/whodunit/chronicles/ledger_entry.rbs +62 -0
- data/sig/whodunit/chronicles/ledger_factory.rbs +14 -0
- data/sig/whodunit/chronicles/ledgers/file_ledger.rbs +14 -0
- data/sig/whodunit/chronicles/ledgers/memory_ledger.rbs +12 -0
- data/sig/whodunit/chronicles/ledgers/sqlite_ledger.rbs +30 -0
- data/sig/whodunit/chronicles.rbs +5 -0
- metadata +40 -326
- data/.codeclimate.yml +0 -50
- data/.rubocop.yml +0 -93
- data/.yardopts +0 -14
- data/CODE_OF_CONDUCT.md +0 -132
- data/CONTRIBUTING.md +0 -186
- data/Rakefile +0 -18
- data/docker/mysql/init.sql +0 -33
- data/docker/postgres/init.sql +0 -40
- data/docker-compose.yml +0 -138
- data/examples/images/campaign-performance-analytics.png +0 -0
- data/examples/images/candidate-journey-analytics.png +0 -0
- data/examples/images/recruitment-funnel-analytics.png +0 -0
- data/lib/.gitkeep +0 -0
- data/lib/whodunit/chronicles/adapter_loader.rb +0 -69
- data/lib/whodunit/chronicles/adapters/mysql.rb +0 -261
- data/lib/whodunit/chronicles/adapters/postgresql.rb +0 -278
- data/lib/whodunit/chronicles/change_event.rb +0 -201
- data/lib/whodunit/chronicles/composite_processor.rb +0 -86
- data/lib/whodunit/chronicles/configuration.rb +0 -112
- data/lib/whodunit/chronicles/connection.rb +0 -88
- data/lib/whodunit/chronicles/persistence.rb +0 -129
- data/lib/whodunit/chronicles/processor.rb +0 -127
- data/lib/whodunit/chronicles/service.rb +0 -207
- data/lib/whodunit/chronicles/stream_adapter.rb +0 -91
- data/lib/whodunit/chronicles/table.rb +0 -120
- data/whodunit-chronicles.gemspec +0 -79
|
@@ -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
|
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'concurrent-ruby'
|
|
4
|
-
|
|
5
|
-
module Whodunit
|
|
6
|
-
module Chronicles
|
|
7
|
-
# Main service orchestrator for chronicle streaming
|
|
8
|
-
#
|
|
9
|
-
# Coordinates the stream adapter and processor to provide
|
|
10
|
-
# a complete chronicle 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 || Processor.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 chronicle streaming service
|
|
33
|
-
#
|
|
34
|
-
# @return [self]
|
|
35
|
-
def start
|
|
36
|
-
return self if running?
|
|
37
|
-
|
|
38
|
-
log(:info, 'Starting Chronicles 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 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 chronicle streaming service
|
|
57
|
-
#
|
|
58
|
-
# @return [void]
|
|
59
|
-
def stop
|
|
60
|
-
return unless running?
|
|
61
|
-
|
|
62
|
-
log(:info, 'Stopping Chronicles 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 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 chronicle streaming infrastructure
|
|
98
|
-
#
|
|
99
|
-
# @return [void]
|
|
100
|
-
def setup!
|
|
101
|
-
log(:info, 'Setting up chronicle streaming infrastructure')
|
|
102
|
-
adapter.setup
|
|
103
|
-
log(:info, 'Chronicle streaming infrastructure setup completed')
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
# Tear down the chronicle streaming infrastructure
|
|
107
|
-
#
|
|
108
|
-
# @return [void]
|
|
109
|
-
def teardown!
|
|
110
|
-
log(:info, 'Tearing down chronicle streaming infrastructure')
|
|
111
|
-
stop if running?
|
|
112
|
-
adapter.teardown
|
|
113
|
-
log(:info, 'Chronicle streaming infrastructure teardown completed')
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
private
|
|
117
|
-
|
|
118
|
-
def build_adapter
|
|
119
|
-
case Chronicles.config.adapter
|
|
120
|
-
when :postgresql
|
|
121
|
-
Whodunit::Chronicles::AdapterLoader.load(:postgresql, logger:)
|
|
122
|
-
when :mysql
|
|
123
|
-
Whodunit::Chronicles::AdapterLoader.load(:mysql, logger:)
|
|
124
|
-
else
|
|
125
|
-
raise ConfigurationError, "Unsupported adapter: #{Chronicles.config.adapter}"
|
|
126
|
-
end
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
def validate_setup!
|
|
130
|
-
Chronicles.config.validate!
|
|
131
|
-
|
|
132
|
-
return if adapter.test_connection
|
|
133
|
-
|
|
134
|
-
raise AdapterLoadError, 'Failed to connect to source database'
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def test_connections!
|
|
138
|
-
adapter.test_connection
|
|
139
|
-
# Test processor connection by creating a dummy connection
|
|
140
|
-
processor.send(:ensure_connection)
|
|
141
|
-
rescue StandardError => e
|
|
142
|
-
raise AdapterLoadError, "Connection test failed: #{e.message}"
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
def start_streaming_with_retry
|
|
146
|
-
@executor.post do
|
|
147
|
-
loop do
|
|
148
|
-
break unless running?
|
|
149
|
-
|
|
150
|
-
begin
|
|
151
|
-
adapter.start_streaming do |change_event|
|
|
152
|
-
process_change_event(change_event)
|
|
153
|
-
end
|
|
154
|
-
rescue StandardError => e
|
|
155
|
-
handle_streaming_error(e)
|
|
156
|
-
break unless should_retry?
|
|
157
|
-
end
|
|
158
|
-
end
|
|
159
|
-
end
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
def process_change_event(change_event)
|
|
163
|
-
return unless change_event
|
|
164
|
-
return unless should_chronicle_table?(change_event)
|
|
165
|
-
|
|
166
|
-
log(:debug, 'Processing change event',
|
|
167
|
-
table: change_event.qualified_table_name,
|
|
168
|
-
action: change_event.action
|
|
169
|
-
)
|
|
170
|
-
|
|
171
|
-
processor.process(change_event)
|
|
172
|
-
rescue StandardError => e
|
|
173
|
-
log(:error, 'Failed to process change event',
|
|
174
|
-
error: e.message,
|
|
175
|
-
event: change_event.to_s
|
|
176
|
-
)
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
def should_chronicle_table?(change_event)
|
|
180
|
-
Chronicles.config.chronicle_table?(
|
|
181
|
-
change_event.table_name,
|
|
182
|
-
change_event.schema_name,
|
|
183
|
-
)
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
def handle_streaming_error(error)
|
|
187
|
-
@retry_count += 1
|
|
188
|
-
log(:error, 'Streaming error occurred',
|
|
189
|
-
error: error.message,
|
|
190
|
-
retry_count: @retry_count,
|
|
191
|
-
max_retries: Chronicles.config.max_retry_attempts
|
|
192
|
-
)
|
|
193
|
-
|
|
194
|
-
# Wait before retry
|
|
195
|
-
sleep(Chronicles.config.retry_delay) if should_retry?
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
def should_retry?
|
|
199
|
-
running? && @retry_count < Chronicles.config.max_retry_attempts
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
def log(level, message, context = {})
|
|
203
|
-
logger.public_send(level, message, service: 'Chronicles::Service', **context)
|
|
204
|
-
end
|
|
205
|
-
end
|
|
206
|
-
end
|
|
207
|
-
end
|
|
@@ -1,91 +0,0 @@
|
|
|
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
|
|
@@ -1,120 +0,0 @@
|
|
|
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
|
data/whodunit-chronicles.gemspec
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative 'lib/whodunit/chronicles/version'
|
|
4
|
-
|
|
5
|
-
Gem::Specification.new do |spec|
|
|
6
|
-
spec.name = 'whodunit-chronicles'
|
|
7
|
-
spec.version = Whodunit::Chronicles::VERSION
|
|
8
|
-
spec.authors = ['Ken C. Demanawa', 'Spherical Cow']
|
|
9
|
-
spec.email = ['kenneth.c.demanawa@gmail.com']
|
|
10
|
-
|
|
11
|
-
spec.summary = 'The complete historical record of your data'
|
|
12
|
-
spec.description = 'While Whodunit tracks who made changes, Chronicles captures ' \
|
|
13
|
-
'what changed by streaming database events into comprehensive ' \
|
|
14
|
-
'audit trails with zero Rails application overhead.'
|
|
15
|
-
spec.homepage = 'https://github.com/kanutocd/whodunit-chronicles'
|
|
16
|
-
spec.license = 'MIT'
|
|
17
|
-
spec.required_ruby_version = '>= 3.2.0'
|
|
18
|
-
|
|
19
|
-
spec.metadata['allowed_push_host'] = 'https://rubygems.org'
|
|
20
|
-
spec.metadata['homepage_uri'] = spec.homepage
|
|
21
|
-
spec.metadata['source_code_uri'] = spec.homepage
|
|
22
|
-
spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
23
|
-
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
24
|
-
|
|
25
|
-
# Specify which files should be added to the gem when it is released.
|
|
26
|
-
spec.files = Dir.chdir(__dir__) do
|
|
27
|
-
`git ls-files -z`.split("\x0").reject do |f|
|
|
28
|
-
(File.expand_path(f) == __FILE__) ||
|
|
29
|
-
f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
spec.bindir = 'exe'
|
|
33
|
-
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
34
|
-
spec.require_paths = ['lib']
|
|
35
|
-
|
|
36
|
-
# ── Core runtime dependencies ───────────────────────────────
|
|
37
|
-
spec.add_dependency 'concurrent-ruby', '~> 1.2'
|
|
38
|
-
spec.add_dependency 'dry-configurable', '~> 1.0'
|
|
39
|
-
spec.add_dependency 'dry-logger', '~> 1.0'
|
|
40
|
-
|
|
41
|
-
# ── Database adapters — OPTIONAL at runtime ───────────────────────────────
|
|
42
|
-
#
|
|
43
|
-
# Chronicles lazy-loads the driver that matches your configured adapter.
|
|
44
|
-
# You only need to install the gem for the database(s) you actually use:
|
|
45
|
-
#
|
|
46
|
-
# PostgreSQL → gem 'pg', '~> 1.5'
|
|
47
|
-
# MySQL/MariaDB → gem 'trilogy', '~> 2.9'
|
|
48
|
-
#
|
|
49
|
-
# Both are listed here so `bundle install` in development installs them,
|
|
50
|
-
# but they are NOT required at gem load time — only when the adapter loads.
|
|
51
|
-
spec.add_development_dependency 'pg', '~> 1.5'
|
|
52
|
-
spec.add_development_dependency 'trilogy', '~> 2.9'
|
|
53
|
-
|
|
54
|
-
# bigdecimal: required by trilogy on Ruby 3.4+ (removed from stdlib).
|
|
55
|
-
# Declared here so CI on Ruby 3.4+ doesn't break. Trilogy should own this
|
|
56
|
-
# dependency — track https://github.com/trilogy-libraries/trilogy/issues
|
|
57
|
-
# Required for Ruby 3.4+ compatibility (trilogy dependency)
|
|
58
|
-
spec.add_development_dependency 'bigdecimal', '~> 3.1'
|
|
59
|
-
|
|
60
|
-
# ── Development tooling ───────────────────────────────
|
|
61
|
-
spec.add_development_dependency 'kramdown', '~> 2.5'
|
|
62
|
-
spec.add_development_dependency 'minitest', '~> 5.20'
|
|
63
|
-
spec.add_development_dependency 'mocha', '~> 2.1'
|
|
64
|
-
spec.add_development_dependency 'pry', '~> 0.14'
|
|
65
|
-
spec.add_development_dependency 'rake', '~> 13.0'
|
|
66
|
-
spec.add_development_dependency 'rspec_junit_formatter', '~> 0.6.0'
|
|
67
|
-
spec.add_development_dependency 'rubocop', '~> 1.60'
|
|
68
|
-
spec.add_development_dependency 'rubocop-minitest', '~> 0.34'
|
|
69
|
-
spec.add_development_dependency 'rubocop-performance', '~> 1.19'
|
|
70
|
-
spec.add_development_dependency 'rubocop-rake', '~> 0.6'
|
|
71
|
-
spec.add_development_dependency 'rubocop-thread_safety', '~> 0.5'
|
|
72
|
-
spec.add_development_dependency 'simplecov', '~> 0.22'
|
|
73
|
-
spec.add_development_dependency 'simplecov-cobertura', '~> 3.0'
|
|
74
|
-
spec.add_development_dependency 'yard', '~> 0.9'
|
|
75
|
-
|
|
76
|
-
# ── Security scanning dependencies ──────────────────────────────
|
|
77
|
-
spec.add_development_dependency 'brakeman', '~> 7.1'
|
|
78
|
-
spec.add_development_dependency 'bundler-audit', '~> 0.9'
|
|
79
|
-
end
|