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,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
- Adapters::PostgreSQL.new(logger: logger)
122
- when :mysql
123
- Adapters::MySQL.new(logger: 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 AdapterError, '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 AdapterError, "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
@@ -1,65 +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/whodunit-gem/whodunit-chronicles'
16
- spec.license = 'MIT'
17
- spec.required_ruby_version = '>= 3.1.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 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 dependencies
42
- spec.add_dependency 'pg', '~> 1.5'
43
- # Driver for MySQL-compatible database
44
- spec.add_dependency 'trilogy', '~> 2.9'
45
- # Required for Ruby 3.4+ compatibility (trilogy dependency)
46
- spec.add_dependency 'bigdecimal', '~> 3.1'
47
-
48
- # Development dependencies
49
- spec.add_development_dependency 'kramdown', '~> 2.5'
50
- spec.add_development_dependency 'minitest', '~> 5.20'
51
- spec.add_development_dependency 'mocha', '~> 2.1'
52
- spec.add_development_dependency 'pry', '~> 0.14'
53
- spec.add_development_dependency 'rake', '~> 13.0'
54
- spec.add_development_dependency 'rspec_junit_formatter', '~> 0.6.0'
55
- spec.add_development_dependency 'rubocop', '~> 1.60'
56
- spec.add_development_dependency 'rubocop-minitest', '~> 0.34'
57
- spec.add_development_dependency 'rubocop-performance', '~> 1.19'
58
- spec.add_development_dependency 'simplecov', '~> 0.22'
59
- spec.add_development_dependency 'simplecov-cobertura', '~> 3.0'
60
- spec.add_development_dependency 'yard', '~> 0.9'
61
-
62
- # Security scanning dependencies
63
- spec.add_development_dependency 'brakeman', '~> 7.1'
64
- spec.add_development_dependency 'bundler-audit', '~> 0.9'
65
- end