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
data/lib/whodunit/chronicles.rb
CHANGED
|
@@ -8,11 +8,15 @@ require_relative 'chronicles/version'
|
|
|
8
8
|
require_relative 'chronicles/configuration'
|
|
9
9
|
require_relative 'chronicles/change_event'
|
|
10
10
|
require_relative 'chronicles/stream_adapter'
|
|
11
|
-
require_relative 'chronicles/
|
|
11
|
+
require_relative 'chronicles/connection'
|
|
12
|
+
require_relative 'chronicles/table'
|
|
13
|
+
require_relative 'chronicles/persistence'
|
|
14
|
+
require_relative 'chronicles/processor'
|
|
12
15
|
require_relative 'chronicles/service'
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
require_relative 'chronicles/
|
|
17
|
+
require_relative 'chronicles/errors'
|
|
18
|
+
require_relative 'chronicles/adapter_loader'
|
|
19
|
+
require_relative 'chronicles/composite_processor'
|
|
16
20
|
|
|
17
21
|
module Whodunit
|
|
18
22
|
# Chronicles - The complete historical record of `whodunit did what?` data
|
|
@@ -28,17 +32,15 @@ module Whodunit
|
|
|
28
32
|
setting :database_url, default: ENV.fetch('DATABASE_URL', nil)
|
|
29
33
|
setting :audit_database_url, default: ENV.fetch('AUDIT_DATABASE_URL', nil)
|
|
30
34
|
setting :adapter, default: :postgresql
|
|
35
|
+
# PostgreSQL-specific settings
|
|
31
36
|
setting :publication_name, default: 'whodunit_audit'
|
|
32
37
|
setting :replication_slot_name, default: 'whodunit_audit_slot'
|
|
38
|
+
# MySQL-specific settings
|
|
39
|
+
setting :mysql_server_id, default: 1001
|
|
33
40
|
setting :batch_size, default: 100
|
|
34
41
|
setting :max_retry_attempts, default: 3
|
|
35
42
|
setting :retry_delay, default: 5
|
|
36
43
|
|
|
37
|
-
class Error < StandardError; end
|
|
38
|
-
class ConfigurationError < Error; end
|
|
39
|
-
class AdapterError < Error; end
|
|
40
|
-
class ReplicationError < Error; end
|
|
41
|
-
|
|
42
44
|
# Configure Chronicles
|
|
43
45
|
#
|
|
44
46
|
# @example
|
|
@@ -46,6 +48,9 @@ module Whodunit
|
|
|
46
48
|
# config.database_url = "postgresql://localhost/myapp"
|
|
47
49
|
# config.audit_database_url = "postgresql://localhost/myapp_audit"
|
|
48
50
|
# config.adapter = :postgresql
|
|
51
|
+
# # OR for MySQL:
|
|
52
|
+
# config.adapter = :mysql
|
|
53
|
+
# config.mysql_server_id = 1001
|
|
49
54
|
# end
|
|
50
55
|
def self.configure
|
|
51
56
|
yield(config) if block_given?
|
data/whodunit-chronicles.gemspec
CHANGED
|
@@ -12,9 +12,9 @@ Gem::Specification.new do |spec|
|
|
|
12
12
|
spec.description = 'While Whodunit tracks who made changes, Chronicles captures ' \
|
|
13
13
|
'what changed by streaming database events into comprehensive ' \
|
|
14
14
|
'audit trails with zero Rails application overhead.'
|
|
15
|
-
spec.homepage = 'https://github.com/
|
|
15
|
+
spec.homepage = 'https://github.com/kanutocd/whodunit-chronicles'
|
|
16
16
|
spec.license = 'MIT'
|
|
17
|
-
spec.required_ruby_version = '>= 3.
|
|
17
|
+
spec.required_ruby_version = '>= 3.2.0'
|
|
18
18
|
|
|
19
19
|
spec.metadata['allowed_push_host'] = 'https://rubygems.org'
|
|
20
20
|
spec.metadata['homepage_uri'] = spec.homepage
|
|
@@ -33,29 +33,47 @@ Gem::Specification.new do |spec|
|
|
|
33
33
|
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
34
34
|
spec.require_paths = ['lib']
|
|
35
35
|
|
|
36
|
-
# Core dependencies
|
|
36
|
+
# ── Core runtime dependencies ───────────────────────────────
|
|
37
37
|
spec.add_dependency 'concurrent-ruby', '~> 1.2'
|
|
38
38
|
spec.add_dependency 'dry-configurable', '~> 1.0'
|
|
39
39
|
spec.add_dependency 'dry-logger', '~> 1.0'
|
|
40
40
|
|
|
41
|
-
# Database
|
|
42
|
-
|
|
43
|
-
#
|
|
44
|
-
#
|
|
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'
|
|
45
53
|
|
|
46
|
-
#
|
|
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 ───────────────────────────────
|
|
47
61
|
spec.add_development_dependency 'kramdown', '~> 2.5'
|
|
48
62
|
spec.add_development_dependency 'minitest', '~> 5.20'
|
|
49
63
|
spec.add_development_dependency 'mocha', '~> 2.1'
|
|
50
64
|
spec.add_development_dependency 'pry', '~> 0.14'
|
|
51
65
|
spec.add_development_dependency 'rake', '~> 13.0'
|
|
66
|
+
spec.add_development_dependency 'rspec_junit_formatter', '~> 0.6.0'
|
|
52
67
|
spec.add_development_dependency 'rubocop', '~> 1.60'
|
|
53
68
|
spec.add_development_dependency 'rubocop-minitest', '~> 0.34'
|
|
54
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'
|
|
55
72
|
spec.add_development_dependency 'simplecov', '~> 0.22'
|
|
73
|
+
spec.add_development_dependency 'simplecov-cobertura', '~> 3.0'
|
|
56
74
|
spec.add_development_dependency 'yard', '~> 0.9'
|
|
57
75
|
|
|
58
|
-
# Security scanning dependencies
|
|
59
|
-
spec.add_development_dependency 'brakeman', '~>
|
|
76
|
+
# ── Security scanning dependencies ──────────────────────────────
|
|
77
|
+
spec.add_development_dependency 'brakeman', '~> 7.1'
|
|
60
78
|
spec.add_development_dependency 'bundler-audit', '~> 0.9'
|
|
61
79
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: whodunit-chronicles
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ken C. Demanawa
|
|
@@ -59,13 +59,41 @@ dependencies:
|
|
|
59
59
|
- - "~>"
|
|
60
60
|
- !ruby/object:Gem::Version
|
|
61
61
|
version: '1.5'
|
|
62
|
-
type: :
|
|
62
|
+
type: :development
|
|
63
63
|
prerelease: false
|
|
64
64
|
version_requirements: !ruby/object:Gem::Requirement
|
|
65
65
|
requirements:
|
|
66
66
|
- - "~>"
|
|
67
67
|
- !ruby/object:Gem::Version
|
|
68
68
|
version: '1.5'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: trilogy
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '2.9'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '2.9'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: bigdecimal
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '3.1'
|
|
90
|
+
type: :development
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - "~>"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '3.1'
|
|
69
97
|
- !ruby/object:Gem::Dependency
|
|
70
98
|
name: kramdown
|
|
71
99
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -136,6 +164,20 @@ dependencies:
|
|
|
136
164
|
- - "~>"
|
|
137
165
|
- !ruby/object:Gem::Version
|
|
138
166
|
version: '13.0'
|
|
167
|
+
- !ruby/object:Gem::Dependency
|
|
168
|
+
name: rspec_junit_formatter
|
|
169
|
+
requirement: !ruby/object:Gem::Requirement
|
|
170
|
+
requirements:
|
|
171
|
+
- - "~>"
|
|
172
|
+
- !ruby/object:Gem::Version
|
|
173
|
+
version: 0.6.0
|
|
174
|
+
type: :development
|
|
175
|
+
prerelease: false
|
|
176
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
177
|
+
requirements:
|
|
178
|
+
- - "~>"
|
|
179
|
+
- !ruby/object:Gem::Version
|
|
180
|
+
version: 0.6.0
|
|
139
181
|
- !ruby/object:Gem::Dependency
|
|
140
182
|
name: rubocop
|
|
141
183
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -178,6 +220,34 @@ dependencies:
|
|
|
178
220
|
- - "~>"
|
|
179
221
|
- !ruby/object:Gem::Version
|
|
180
222
|
version: '1.19'
|
|
223
|
+
- !ruby/object:Gem::Dependency
|
|
224
|
+
name: rubocop-rake
|
|
225
|
+
requirement: !ruby/object:Gem::Requirement
|
|
226
|
+
requirements:
|
|
227
|
+
- - "~>"
|
|
228
|
+
- !ruby/object:Gem::Version
|
|
229
|
+
version: '0.6'
|
|
230
|
+
type: :development
|
|
231
|
+
prerelease: false
|
|
232
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
233
|
+
requirements:
|
|
234
|
+
- - "~>"
|
|
235
|
+
- !ruby/object:Gem::Version
|
|
236
|
+
version: '0.6'
|
|
237
|
+
- !ruby/object:Gem::Dependency
|
|
238
|
+
name: rubocop-thread_safety
|
|
239
|
+
requirement: !ruby/object:Gem::Requirement
|
|
240
|
+
requirements:
|
|
241
|
+
- - "~>"
|
|
242
|
+
- !ruby/object:Gem::Version
|
|
243
|
+
version: '0.5'
|
|
244
|
+
type: :development
|
|
245
|
+
prerelease: false
|
|
246
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
247
|
+
requirements:
|
|
248
|
+
- - "~>"
|
|
249
|
+
- !ruby/object:Gem::Version
|
|
250
|
+
version: '0.5'
|
|
181
251
|
- !ruby/object:Gem::Dependency
|
|
182
252
|
name: simplecov
|
|
183
253
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -192,6 +262,20 @@ dependencies:
|
|
|
192
262
|
- - "~>"
|
|
193
263
|
- !ruby/object:Gem::Version
|
|
194
264
|
version: '0.22'
|
|
265
|
+
- !ruby/object:Gem::Dependency
|
|
266
|
+
name: simplecov-cobertura
|
|
267
|
+
requirement: !ruby/object:Gem::Requirement
|
|
268
|
+
requirements:
|
|
269
|
+
- - "~>"
|
|
270
|
+
- !ruby/object:Gem::Version
|
|
271
|
+
version: '3.0'
|
|
272
|
+
type: :development
|
|
273
|
+
prerelease: false
|
|
274
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
275
|
+
requirements:
|
|
276
|
+
- - "~>"
|
|
277
|
+
- !ruby/object:Gem::Version
|
|
278
|
+
version: '3.0'
|
|
195
279
|
- !ruby/object:Gem::Dependency
|
|
196
280
|
name: yard
|
|
197
281
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -212,14 +296,14 @@ dependencies:
|
|
|
212
296
|
requirements:
|
|
213
297
|
- - "~>"
|
|
214
298
|
- !ruby/object:Gem::Version
|
|
215
|
-
version: '
|
|
299
|
+
version: '7.1'
|
|
216
300
|
type: :development
|
|
217
301
|
prerelease: false
|
|
218
302
|
version_requirements: !ruby/object:Gem::Requirement
|
|
219
303
|
requirements:
|
|
220
304
|
- - "~>"
|
|
221
305
|
- !ruby/object:Gem::Version
|
|
222
|
-
version: '
|
|
306
|
+
version: '7.1'
|
|
223
307
|
- !ruby/object:Gem::Dependency
|
|
224
308
|
name: bundler-audit
|
|
225
309
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -243,35 +327,47 @@ executables: []
|
|
|
243
327
|
extensions: []
|
|
244
328
|
extra_rdoc_files: []
|
|
245
329
|
files:
|
|
330
|
+
- ".codeclimate.yml"
|
|
246
331
|
- ".rubocop.yml"
|
|
247
332
|
- ".yardopts"
|
|
248
333
|
- CHANGELOG.md
|
|
249
334
|
- CODE_OF_CONDUCT.md
|
|
335
|
+
- CONTRIBUTING.md
|
|
250
336
|
- LICENSE
|
|
251
337
|
- README.md
|
|
252
338
|
- Rakefile
|
|
339
|
+
- docker-compose.yml
|
|
340
|
+
- docker/mysql/init.sql
|
|
341
|
+
- docker/postgres/init.sql
|
|
253
342
|
- examples/images/campaign-performance-analytics.png
|
|
254
343
|
- examples/images/candidate-journey-analytics.png
|
|
255
344
|
- examples/images/recruitment-funnel-analytics.png
|
|
256
345
|
- lib/.gitkeep
|
|
257
346
|
- lib/whodunit-chronicles.rb
|
|
258
347
|
- lib/whodunit/chronicles.rb
|
|
348
|
+
- lib/whodunit/chronicles/adapter_loader.rb
|
|
349
|
+
- lib/whodunit/chronicles/adapters/mysql.rb
|
|
259
350
|
- lib/whodunit/chronicles/adapters/postgresql.rb
|
|
260
|
-
- lib/whodunit/chronicles/audit_processor.rb
|
|
261
351
|
- lib/whodunit/chronicles/change_event.rb
|
|
352
|
+
- lib/whodunit/chronicles/composite_processor.rb
|
|
262
353
|
- lib/whodunit/chronicles/configuration.rb
|
|
354
|
+
- lib/whodunit/chronicles/connection.rb
|
|
355
|
+
- lib/whodunit/chronicles/errors.rb
|
|
356
|
+
- lib/whodunit/chronicles/persistence.rb
|
|
357
|
+
- lib/whodunit/chronicles/processor.rb
|
|
263
358
|
- lib/whodunit/chronicles/service.rb
|
|
264
359
|
- lib/whodunit/chronicles/stream_adapter.rb
|
|
360
|
+
- lib/whodunit/chronicles/table.rb
|
|
265
361
|
- lib/whodunit/chronicles/version.rb
|
|
266
362
|
- whodunit-chronicles.gemspec
|
|
267
|
-
homepage: https://github.com/
|
|
363
|
+
homepage: https://github.com/kanutocd/whodunit-chronicles
|
|
268
364
|
licenses:
|
|
269
365
|
- MIT
|
|
270
366
|
metadata:
|
|
271
367
|
allowed_push_host: https://rubygems.org
|
|
272
|
-
homepage_uri: https://github.com/
|
|
273
|
-
source_code_uri: https://github.com/
|
|
274
|
-
changelog_uri: https://github.com/
|
|
368
|
+
homepage_uri: https://github.com/kanutocd/whodunit-chronicles
|
|
369
|
+
source_code_uri: https://github.com/kanutocd/whodunit-chronicles
|
|
370
|
+
changelog_uri: https://github.com/kanutocd/whodunit-chronicles/blob/main/CHANGELOG.md
|
|
275
371
|
rubygems_mfa_required: 'true'
|
|
276
372
|
rdoc_options: []
|
|
277
373
|
require_paths:
|
|
@@ -280,7 +376,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
280
376
|
requirements:
|
|
281
377
|
- - ">="
|
|
282
378
|
- !ruby/object:Gem::Version
|
|
283
|
-
version: 3.
|
|
379
|
+
version: 3.2.0
|
|
284
380
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
285
381
|
requirements:
|
|
286
382
|
- - ">="
|
|
@@ -1,270 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Whodunit
|
|
4
|
-
module Chronicles
|
|
5
|
-
# Processes database change events and creates audit records
|
|
6
|
-
#
|
|
7
|
-
# Transforms ChangeEvent objects into structured audit records
|
|
8
|
-
# with complete object serialization and metadata.
|
|
9
|
-
class AuditProcessor
|
|
10
|
-
attr_reader :logger, :audit_connection
|
|
11
|
-
|
|
12
|
-
def initialize(
|
|
13
|
-
audit_database_url: Chronicles.config.audit_database_url,
|
|
14
|
-
logger: Chronicles.logger
|
|
15
|
-
)
|
|
16
|
-
@audit_database_url = audit_database_url
|
|
17
|
-
@logger = logger
|
|
18
|
-
@audit_connection = nil
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
# Process a change event and create audit record
|
|
22
|
-
#
|
|
23
|
-
# @param change_event [ChangeEvent] The database change to audit
|
|
24
|
-
# @return [Hash] The created audit record
|
|
25
|
-
def process(change_event)
|
|
26
|
-
ensure_audit_connection
|
|
27
|
-
|
|
28
|
-
audit_record = build_audit_record(change_event)
|
|
29
|
-
persist_audit_record(audit_record)
|
|
30
|
-
|
|
31
|
-
log(:debug, 'Processed change event',
|
|
32
|
-
table: change_event.qualified_table_name,
|
|
33
|
-
action: change_event.action,
|
|
34
|
-
audit_id: audit_record[:id])
|
|
35
|
-
|
|
36
|
-
audit_record
|
|
37
|
-
rescue StandardError => e
|
|
38
|
-
log(:error, 'Failed to process change event',
|
|
39
|
-
error: e.message,
|
|
40
|
-
event: change_event.to_s)
|
|
41
|
-
raise
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
# Process multiple change events in a batch
|
|
45
|
-
#
|
|
46
|
-
# @param change_events [Array<ChangeEvent>] Array of change events
|
|
47
|
-
# @return [Array<Hash>] Array of created audit records
|
|
48
|
-
def process_batch(change_events)
|
|
49
|
-
return [] if change_events.empty?
|
|
50
|
-
|
|
51
|
-
ensure_audit_connection
|
|
52
|
-
|
|
53
|
-
audit_records = change_events.map { |event| build_audit_record(event) }
|
|
54
|
-
persist_audit_records_batch(audit_records)
|
|
55
|
-
|
|
56
|
-
log(:info, 'Processed batch of change events', count: change_events.size)
|
|
57
|
-
|
|
58
|
-
audit_records
|
|
59
|
-
rescue StandardError => e
|
|
60
|
-
log(:error, 'Failed to process batch',
|
|
61
|
-
error: e.message,
|
|
62
|
-
count: change_events.size)
|
|
63
|
-
raise
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
# Close audit database connection
|
|
67
|
-
def close
|
|
68
|
-
@audit_connection&.close
|
|
69
|
-
@audit_connection = nil
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
private
|
|
73
|
-
|
|
74
|
-
def ensure_audit_connection
|
|
75
|
-
return if @audit_connection && !@audit_connection.finished?
|
|
76
|
-
|
|
77
|
-
@audit_connection = PG.connect(@audit_database_url || Chronicles.config.database_url)
|
|
78
|
-
@audit_connection.type_map_for_results = PG::BasicTypeMapForResults.new(@audit_connection)
|
|
79
|
-
|
|
80
|
-
ensure_audit_table_exists
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def ensure_audit_table_exists
|
|
84
|
-
create_sql = <<~SQL
|
|
85
|
-
CREATE TABLE IF NOT EXISTS whodunit_chronicles_audits (
|
|
86
|
-
id BIGSERIAL PRIMARY KEY,
|
|
87
|
-
table_name TEXT NOT NULL,
|
|
88
|
-
schema_name TEXT NOT NULL DEFAULT 'public',
|
|
89
|
-
record_id JSONB,
|
|
90
|
-
action TEXT NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
|
|
91
|
-
old_data JSONB,
|
|
92
|
-
new_data JSONB,
|
|
93
|
-
changes JSONB,
|
|
94
|
-
user_id BIGINT,
|
|
95
|
-
user_type TEXT,
|
|
96
|
-
transaction_id TEXT,
|
|
97
|
-
sequence_number INTEGER,
|
|
98
|
-
occurred_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
99
|
-
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
|
100
|
-
metadata JSONB DEFAULT '{}'::jsonb,
|
|
101
|
-
#{' '}
|
|
102
|
-
-- Indexes for performance
|
|
103
|
-
CONSTRAINT valid_data_for_action CHECK (
|
|
104
|
-
(action = 'INSERT' AND old_data IS NULL AND new_data IS NOT NULL) OR
|
|
105
|
-
(action = 'UPDATE' AND old_data IS NOT NULL AND new_data IS NOT NULL) OR#{' '}
|
|
106
|
-
(action = 'DELETE' AND old_data IS NOT NULL AND new_data IS NULL)
|
|
107
|
-
)
|
|
108
|
-
);
|
|
109
|
-
|
|
110
|
-
-- Performance indexes
|
|
111
|
-
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chronicles_audits_table_record#{' '}
|
|
112
|
-
ON whodunit_chronicles_audits (table_name, (record_id->>'id'));
|
|
113
|
-
|
|
114
|
-
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chronicles_audits_occurred_at#{' '}
|
|
115
|
-
ON whodunit_chronicles_audits (occurred_at DESC);
|
|
116
|
-
|
|
117
|
-
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chronicles_audits_user#{' '}
|
|
118
|
-
ON whodunit_chronicles_audits (user_id, user_type);
|
|
119
|
-
|
|
120
|
-
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chronicles_audits_action#{' '}
|
|
121
|
-
ON whodunit_chronicles_audits (action);
|
|
122
|
-
|
|
123
|
-
-- GIN index for JSONB columns
|
|
124
|
-
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chronicles_audits_record_id_gin#{' '}
|
|
125
|
-
ON whodunit_chronicles_audits USING GIN (record_id);
|
|
126
|
-
|
|
127
|
-
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chronicles_audits_changes_gin#{' '}
|
|
128
|
-
ON whodunit_chronicles_audits USING GIN (changes);
|
|
129
|
-
SQL
|
|
130
|
-
|
|
131
|
-
@audit_connection.exec(create_sql)
|
|
132
|
-
rescue PG::Error => e
|
|
133
|
-
# Ignore "already exists" errors from CONCURRENTLY
|
|
134
|
-
raise unless e.message.include?('already exists')
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def build_audit_record(change_event)
|
|
138
|
-
user_info = extract_user_info(change_event)
|
|
139
|
-
|
|
140
|
-
{
|
|
141
|
-
id: nil, # Will be set by database
|
|
142
|
-
table_name: change_event.table_name,
|
|
143
|
-
schema_name: change_event.schema_name,
|
|
144
|
-
record_id: change_event.primary_key,
|
|
145
|
-
action: change_event.action,
|
|
146
|
-
old_data: change_event.old_data,
|
|
147
|
-
new_data: change_event.new_data,
|
|
148
|
-
changes: change_event.changes,
|
|
149
|
-
user_id: user_info[:user_id],
|
|
150
|
-
user_type: user_info[:user_type],
|
|
151
|
-
transaction_id: change_event.transaction_id,
|
|
152
|
-
sequence_number: change_event.sequence_number,
|
|
153
|
-
occurred_at: change_event.timestamp,
|
|
154
|
-
created_at: Time.now,
|
|
155
|
-
metadata: build_metadata(change_event),
|
|
156
|
-
}
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def extract_user_info(change_event)
|
|
160
|
-
data = change_event.current_data || {}
|
|
161
|
-
|
|
162
|
-
# Look for Whodunit user attribution fields
|
|
163
|
-
user_id = data['creator_id'] || data['updater_id'] || data['deleter_id']
|
|
164
|
-
|
|
165
|
-
{
|
|
166
|
-
user_id: user_id,
|
|
167
|
-
user_type: user_id ? 'User' : nil,
|
|
168
|
-
}
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
def build_metadata(change_event)
|
|
172
|
-
{
|
|
173
|
-
table_schema: change_event.schema_name,
|
|
174
|
-
qualified_table_name: change_event.qualified_table_name,
|
|
175
|
-
changed_columns: change_event.changed_columns,
|
|
176
|
-
adapter_metadata: change_event.metadata,
|
|
177
|
-
chronicles_version: Chronicles::VERSION,
|
|
178
|
-
}
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def persist_audit_record(audit_record)
|
|
182
|
-
sql = <<~SQL
|
|
183
|
-
INSERT INTO whodunit_chronicles_audits (
|
|
184
|
-
table_name, schema_name, record_id, action, old_data, new_data, changes,
|
|
185
|
-
user_id, user_type, transaction_id, sequence_number, occurred_at, created_at, metadata
|
|
186
|
-
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
|
187
|
-
RETURNING id
|
|
188
|
-
SQL
|
|
189
|
-
|
|
190
|
-
params = [
|
|
191
|
-
audit_record[:table_name],
|
|
192
|
-
audit_record[:schema_name],
|
|
193
|
-
audit_record[:record_id].to_json,
|
|
194
|
-
audit_record[:action],
|
|
195
|
-
audit_record[:old_data]&.to_json,
|
|
196
|
-
audit_record[:new_data]&.to_json,
|
|
197
|
-
audit_record[:changes].to_json,
|
|
198
|
-
audit_record[:user_id],
|
|
199
|
-
audit_record[:user_type],
|
|
200
|
-
audit_record[:transaction_id],
|
|
201
|
-
audit_record[:sequence_number],
|
|
202
|
-
audit_record[:occurred_at],
|
|
203
|
-
audit_record[:created_at],
|
|
204
|
-
audit_record[:metadata].to_json,
|
|
205
|
-
]
|
|
206
|
-
|
|
207
|
-
result = @audit_connection.exec_params(sql, params)
|
|
208
|
-
audit_record[:id] = result.first['id'].to_i
|
|
209
|
-
result.clear
|
|
210
|
-
|
|
211
|
-
audit_record
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
def persist_audit_records_batch(audit_records)
|
|
215
|
-
return audit_records if audit_records.empty?
|
|
216
|
-
|
|
217
|
-
# Use multi-row INSERT for better performance
|
|
218
|
-
values_clauses = []
|
|
219
|
-
all_params = []
|
|
220
|
-
param_index = 1
|
|
221
|
-
|
|
222
|
-
audit_records.each do |record|
|
|
223
|
-
param_positions = (param_index..(param_index + 13)).map { |i| "$#{i}" }.join(', ')
|
|
224
|
-
values_clauses << "(#{param_positions})"
|
|
225
|
-
|
|
226
|
-
all_params.push(
|
|
227
|
-
record[:table_name],
|
|
228
|
-
record[:schema_name],
|
|
229
|
-
record[:record_id].to_json,
|
|
230
|
-
record[:action],
|
|
231
|
-
record[:old_data]&.to_json,
|
|
232
|
-
record[:new_data]&.to_json,
|
|
233
|
-
record[:changes].to_json,
|
|
234
|
-
record[:user_id],
|
|
235
|
-
record[:user_type],
|
|
236
|
-
record[:transaction_id],
|
|
237
|
-
record[:sequence_number],
|
|
238
|
-
record[:occurred_at],
|
|
239
|
-
record[:created_at],
|
|
240
|
-
record[:metadata].to_json,
|
|
241
|
-
)
|
|
242
|
-
|
|
243
|
-
param_index += 14
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
sql = <<~SQL
|
|
247
|
-
INSERT INTO whodunit_chronicles_audits (
|
|
248
|
-
table_name, schema_name, record_id, action, old_data, new_data, changes,
|
|
249
|
-
user_id, user_type, transaction_id, sequence_number, occurred_at, created_at, metadata
|
|
250
|
-
) VALUES #{values_clauses.join(', ')}
|
|
251
|
-
RETURNING id
|
|
252
|
-
SQL
|
|
253
|
-
|
|
254
|
-
result = @audit_connection.exec_params(sql, all_params)
|
|
255
|
-
|
|
256
|
-
# Set IDs on the audit records
|
|
257
|
-
result.each_with_index do |row, index|
|
|
258
|
-
audit_records[index][:id] = row['id'].to_i
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
result.clear
|
|
262
|
-
audit_records
|
|
263
|
-
end
|
|
264
|
-
|
|
265
|
-
def log(level, message, context = {})
|
|
266
|
-
logger.public_send(level, message, processor: 'AuditProcessor', **context)
|
|
267
|
-
end
|
|
268
|
-
end
|
|
269
|
-
end
|
|
270
|
-
end
|