whodunit-chronicles 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9b961b73fe4f8a8b195a51a4c37078b87ebd55effbb47bef6c1128896fc567b2
4
- data.tar.gz: ec689969f3bc3acea94f92eeca518d756b06313f8833e8af0f29066e0574199c
3
+ metadata.gz: fa4438fb3140657b7b9d92db2988c0ca511c9fc0138ba42cfad061ab91dc0ac4
4
+ data.tar.gz: e3c5339e3ecce1b882be854bc906f9e1644bc812eb16302266d7b8ef68ba0f12
5
5
  SHA512:
6
- metadata.gz: 4f32308b39334f4fc2b40fc178ce06d49cc98cd20eb284a6c45affc3561397b738781d20395284ff81b79d93c35daa2eceda598a6f057df1c655b1cea0e6dc59
7
- data.tar.gz: 85719789afd7e4de120ec6534473d39238d9b4d58e0355cdf00e983bd51af9292e9b23cb89a7d7acce086f610527dc41fb01bd699169520c77881ffee32d185c
6
+ metadata.gz: '03590c69049ab8670411b6beb0e5bbb8d3fcf0989b163541fa606be5fc1c7b41f20c4e6cb5160632ed411b056664f16aa153499c947cd24c612912ff58504354'
7
+ data.tar.gz: 26d9cbc11e8efbf0aa716034b9ec46604d5bb78e066584f3e9674ef00732daf60fc7ba6a5506c3f9524b0ad216fcfb52e4418b2452451787b6c4498e84bab08a
data/.rubocop.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # Ruby version
2
2
  AllCops:
3
- TargetRubyVersion: 3.1
3
+ TargetRubyVersion: 3.2
4
4
  NewCops: enable
5
5
  Exclude:
6
6
  - 'vendor/**/*'
@@ -54,8 +54,8 @@ Style/FrozenStringLiteralComment:
54
54
  Enabled: true
55
55
  EnforcedStyle: always
56
56
 
57
- Style/StringLiterals:
58
- EnforcedStyle: single_quotes
57
+ # Style/StringLiterals:
58
+ # EnforcedStyle: double_quotes
59
59
 
60
60
  Style/HashSyntax:
61
61
  EnforcedStyle: ruby19
data/CHANGELOG.md CHANGED
@@ -5,7 +5,54 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+
9
+ ## [0.3.0] - 2026-05-17
10
+
11
+ ### Added
12
+
13
+ - **`CompositeProcessor`** — fans out a single change event to multiple processors
14
+ in sequence, enabling pipeline architectures (e.g. simultaneously storing audit
15
+ records, streaming to Grafana, and triggering alerts) without coupling processors
16
+ to each other. Fail-open by default: if one processor raises, the error is logged
17
+ and the remaining processors still execute. Set `fail_fast: true` to halt the
18
+ chain on the first error.
19
+ - **Typed error hierarchy** — base `Whodunit::Chronicles::Error` class with typed
20
+ subclasses: `ConfigurationError`, `AdapterLoadError`, `ConnectionError`,
21
+ `ProcessingError`, `PersistenceError`. Callers can now rescue the base class to
22
+ catch all gem errors, or rescue specific subclasses for targeted handling.
23
+ - **Lazy adapter loading** — `pg` and `trilogy` are now required only when the
24
+ matching adapter is actually used. A friendly `AdapterLoadError` with install
25
+ instructions is raised if the gem is missing, instead of a cryptic `LoadError`.
26
+ - **`bin/console`** — loads the gem with a sane test config and drops into Pry
27
+ for interactive development.
28
+ - **Docker Compose test environment** — spins up PostgreSQL 16 (`wal_level=logical`),
29
+ MySQL 8, and MariaDB 11 with matching audit databases, init SQL scripts, test
30
+ tables, publication setup, and replication role grants.
31
+ - Development environment setup guide added to docs.
32
+
33
+ ### Fixed
34
+
35
+ - Gemspec `homepage_uri` metadata corrected.
36
+ - `pg`, `trilogy`, and `bigdecimal` moved from runtime to `add_development_dependency`
37
+ — consuming apps are no longer forced to install database adapters they don't use.
38
+ - `rubocop-rake` and `rubocop-thread_safety` added to development dependencies.
39
+
40
+ ### Chore
41
+
42
+ - `Gemfile.lock` removed from repo.
43
+ - `.gitignore` updated with recommended exclusions.
44
+
45
+ ### Docs
46
+
47
+ - Broken links in README fixed.
48
+ - Development environment setup guide added.
49
+
50
+ ### ⚠️ Breaking Changes
51
+
52
+ - `pg`, `trilogy`, and `bigdecimal` are no longer runtime dependencies. If your
53
+ application relies on them being pulled in transitively via `whodunit-chronicles`,
54
+ add them explicitly to your own Gemfile.
55
+ - remove ruby@3.1 from the supported versions of Ruby
9
56
 
10
57
  ## [0.2.0] - 2025-01-28
11
58
 
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,186 @@
1
+ # Contributing to Whodunit Chronicles
2
+
3
+ Thank you for your interest in contributing! This document walks you through getting a working development environment set up, running the test suite, and submitting a pull request.
4
+
5
+ ---
6
+
7
+ ## Table of Contents
8
+
9
+ 1. [Prerequisites](#prerequisites)
10
+ 2. [Setting Up Your Environment](#setting-up-your-environment)
11
+ 3. [Database Setup](#database-setup)
12
+ 4. [Running the Tests](#running-the-tests)
13
+ 5. [Code Style](#code-style)
14
+ 6. [Opening a Pull Request](#opening-a-pull-request)
15
+ 7. [Contributing Custom Processors](#contributing-custom-processors)
16
+
17
+ ---
18
+
19
+ ## Prerequisites
20
+
21
+ | Tool | Minimum Version | Notes |
22
+ |------|----------------|-------|
23
+ | Ruby | 3.1.0 | `rbenv` or `asdf` recommended |
24
+ | Docker | 24+ | For running test databases |
25
+ | Docker Compose | v2 plugin | Bundled with Docker Desktop |
26
+ | Git | Any recent | |
27
+
28
+ ---
29
+
30
+ ## Setting Up Your Environment
31
+
32
+ ```bash
33
+ git clone https://github.com/kanutocd/whodunit-chronicles.git
34
+ cd whodunit-chronicles
35
+
36
+ # Install dependencies
37
+ bundle install
38
+
39
+ # Verify the console works
40
+ bundle exec bin/console
41
+ ```
42
+
43
+ ---
44
+
45
+ ## Database Setup
46
+
47
+ Chronicles streams directly from PostgreSQL logical replication and MySQL/MariaDB binary logs. The test suite needs real database processes — no SQLite or in-memory substitute.
48
+
49
+ **Start all test databases with Docker Compose:**
50
+
51
+ ```bash
52
+ docker compose up -d
53
+ ```
54
+
55
+ This starts:
56
+
57
+ | Service | Port | Purpose |
58
+ |---------|------|---------|
59
+ | PostgreSQL 16 (wal_level=logical) | 5432 | Source DB for pg tests |
60
+ | PostgreSQL 16 (audit store) | 5433 | Audit DB for pg tests |
61
+ | MySQL 8.0 (binlog ROW format) | 3306 | Source DB for MySQL tests |
62
+ | MySQL 8.0 (audit store) | 3307 | Audit DB for MySQL tests |
63
+ | MariaDB 11 (binlog ROW format) | 3308 | Source DB for MariaDB tests |
64
+
65
+ Wait for all services to be healthy before running tests:
66
+
67
+ ```bash
68
+ docker compose ps # all should show "(healthy)"
69
+ ```
70
+
71
+ ### PostgreSQL — enabling logical replication
72
+
73
+ The Docker image is pre-configured (`wal_level=logical`). If you're using a system Postgres instead, add to `postgresql.conf`:
74
+
75
+ ```
76
+ wal_level = logical
77
+ max_replication_slots = 10
78
+ max_wal_senders = 10
79
+ ```
80
+
81
+ Then restart Postgres and grant the replication role to your user:
82
+
83
+ ```sql
84
+ ALTER ROLE your_user WITH REPLICATION;
85
+ ```
86
+
87
+ ### MySQL — enabling binary logging
88
+
89
+ The Docker image launches with `--binlog-format=ROW --binlog-row-image=FULL`. If using a system MySQL, add to `my.cnf`:
90
+
91
+ ```ini
92
+ [mysqld]
93
+ server-id = 1
94
+ log-bin = mysql-bin
95
+ binlog-format = ROW
96
+ binlog-row-image = FULL
97
+ ```
98
+
99
+ ---
100
+
101
+ ## Running the Tests
102
+
103
+ ```bash
104
+ # Full suite
105
+ bundle exec rake test
106
+
107
+ # Single file
108
+ bundle exec ruby test/whodunit/chronicles/composite_processor_test.rb
109
+
110
+ # With coverage report
111
+ bundle exec rake test
112
+ open coverage/index.html
113
+
114
+ # Code style
115
+ bundle exec rubocop
116
+
117
+ # Security scan
118
+ bundle exec bundler-audit check --update
119
+ bundle exec brakeman
120
+ ```
121
+
122
+ ### Environment variables for test databases
123
+
124
+ The test suite reads these environment variables (defaults match the Docker Compose setup):
125
+
126
+ | Variable | Default |
127
+ |----------|---------|
128
+ | `CHRONICLES_PG_URL` | `postgresql://chronicles:chronicles@localhost/chronicles_test` |
129
+ | `CHRONICLES_PG_AUDIT_URL` | `postgresql://chronicles:chronicles@localhost:5433/chronicles_audit_test` |
130
+ | `CHRONICLES_MYSQL_URL` | `mysql://chronicles:chronicles@localhost:3306/chronicles_test` |
131
+ | `CHRONICLES_MYSQL_AUDIT_URL` | `mysql://chronicles:chronicles@localhost:3307/chronicles_audit_test` |
132
+
133
+ ---
134
+
135
+ ## Code Style
136
+
137
+ - All files must have `# frozen_string_literal: true`
138
+ - Public methods require YARD documentation
139
+ - Follow the existing RuboCop configuration (`.rubocop.yml`)
140
+ - Tests live in `test/` and use Minitest
141
+
142
+ ---
143
+
144
+ ## Opening a Pull Request
145
+
146
+ 1. Fork the repository and create a feature branch:
147
+ ```bash
148
+ git checkout -b feature/my-improvement
149
+ ```
150
+
151
+ 2. Make your changes with tests. Coverage should not drop below 90%.
152
+
153
+ 3. Run the full quality check before pushing:
154
+ ```bash
155
+ bundle exec rake test
156
+ bundle exec rubocop
157
+ bundle exec bundler-audit check --update
158
+ ```
159
+
160
+ 4. Push and open a PR with:
161
+ - A clear description of what changed and why
162
+ - Any migration steps if you changed configuration
163
+ - A note on which databases you tested against
164
+
165
+ ---
166
+
167
+ ## Contributing Custom Processors
168
+
169
+ The best contributions are custom processors for real-world domains. Here are domains we'd love to see:
170
+
171
+ - **E-commerce** — order tracking, inventory changes, cart events
172
+ - **Financial services** — transaction monitoring, compliance audit trails
173
+ - **Healthcare** — patient record changes, HIPAA-relevant audit events
174
+ - **SaaS** — feature flag changes, subscription events, seat management
175
+ - **Education** — student progress, grade changes, enrollment events
176
+
177
+ A good processor contribution includes:
178
+
179
+ 1. The processor class in `lib/whodunit/chronicles/processors/`
180
+ 2. Tests in `test/whodunit/chronicles/processors/`
181
+ 3. A usage example in `examples/`
182
+ 4. An entry in `CHANGELOG.md`
183
+
184
+ ---
185
+
186
+ Questions? Open an issue or start a discussion on GitHub.
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # 📚 Whodunit Chronicles
1
+ # 📜 Whodunit Chronicles
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/whodunit-chronicles.svg)](https://badge.fury.io/rb/whodunit-chronicles)
4
4
  [![CI](https://github.com/kanutocd/whodunit-chronicles/workflows/CI/badge.svg)](https://github.com/kanutocd/whodunit-chronicles/actions)
@@ -446,7 +446,7 @@ end
446
446
  # Chain multiple processors for different purposes
447
447
  service = Whodunit::Chronicles::Service.new(
448
448
  adapter: Adapters::PostgreSQL.new,
449
- processor: CompositeProcessor.new([
449
+ processor: Whodunit::Chronicles::CompositeProcessor.new([
450
450
  AnalyticsProcessor.new, # For business intelligence
451
451
  AlertingProcessor.new, # For real-time monitoring
452
452
  ComplianceProcessor.new, # For regulatory requirements
@@ -641,11 +641,11 @@ We especially welcome custom processors for different business domains. Consider
641
641
  ## 📚 Documentation
642
642
 
643
643
  - **[API Documentation](https://kanutocd.github.io/whodunit-chronicles/)**
644
- - **[Configuration Guide](docs/configuration-todo.md)**
645
- - **[Architecture Deep Dive](docs/architecture-todo.md)**
646
- - **[PostgreSQL Setup](docs/postgresql-setup-todo.md)**
647
- - **[MySQL/MariaDB Setup](docs/mysql-setup.md)**
648
- - **[Production Deployment](docs/production-todo.md)**
644
+ - [ ] TODO: **Configuration Guide** _(docs/configuration-todo.md)_
645
+ - [ ] TODO: **Architecture Deep Dive** _(docs/architecture-todo.md)_
646
+ - [ ] TODO: **PostgreSQL Setup** _(docs/postgresql-setup-todo.md)_
647
+ - [ ] TODO: **MySQL/MariaDB Setup** _(docs/mysql-setup.md)_
648
+ - [ ] TODO: **Production Deployment** _(docs/production-todo.md)_
649
649
 
650
650
  ## 📄 License
651
651
 
@@ -0,0 +1,33 @@
1
+ -- docker/mysql/init.sql
2
+ -- Run once when the MySQL/MariaDB container is first created.
3
+
4
+ USE chronicles_test;
5
+
6
+ CREATE TABLE IF NOT EXISTS users (
7
+ id INT AUTO_INCREMENT PRIMARY KEY,
8
+ name VARCHAR(255) NOT NULL,
9
+ email VARCHAR(255) NOT NULL UNIQUE,
10
+ creator_id INT DEFAULT NULL,
11
+ updater_id INT DEFAULT NULL,
12
+ deleter_id INT DEFAULT NULL,
13
+ created_at DATETIME(6) NOT NULL DEFAULT NOW(6),
14
+ updated_at DATETIME(6) NOT NULL DEFAULT NOW(6) ON UPDATE NOW(6)
15
+ );
16
+
17
+ CREATE TABLE IF NOT EXISTS posts (
18
+ id INT AUTO_INCREMENT PRIMARY KEY,
19
+ user_id INT NOT NULL,
20
+ title VARCHAR(500) NOT NULL,
21
+ body TEXT,
22
+ status VARCHAR(50) DEFAULT 'draft',
23
+ creator_id INT DEFAULT NULL,
24
+ updater_id INT DEFAULT NULL,
25
+ deleter_id INT DEFAULT NULL,
26
+ created_at DATETIME(6) NOT NULL DEFAULT NOW(6),
27
+ updated_at DATETIME(6) NOT NULL DEFAULT NOW(6) ON UPDATE NOW(6),
28
+ FOREIGN KEY (user_id) REFERENCES users (id)
29
+ );
30
+
31
+ -- Grant replication privileges to the chronicles user
32
+ GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'chronicles'@'%';
33
+ FLUSH PRIVILEGES;
@@ -0,0 +1,40 @@
1
+ -- docker/postgres/init.sql
2
+ -- Run once when the container is first created.
3
+ -- Sets up the replication role and test tables used by the test suite.
4
+
5
+ -- Allow the chronicles user to create replication slots and use logical replication
6
+ ALTER ROLE chronicles WITH REPLICATION;
7
+
8
+ -- Grant superuser for test convenience (never do this in production)
9
+ -- Remove this line and grant only necessary permissions for a tighter setup.
10
+ ALTER ROLE chronicles WITH SUPERUSER;
11
+
12
+ -- Create the test tables Chronicles will stream from
13
+ \c chronicles_test
14
+
15
+ CREATE TABLE IF NOT EXISTS users (
16
+ id SERIAL PRIMARY KEY,
17
+ name VARCHAR(255) NOT NULL,
18
+ email VARCHAR(255) NOT NULL UNIQUE,
19
+ creator_id INTEGER,
20
+ updater_id INTEGER,
21
+ deleter_id INTEGER,
22
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
23
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
24
+ );
25
+
26
+ CREATE TABLE IF NOT EXISTS posts (
27
+ id SERIAL PRIMARY KEY,
28
+ user_id INTEGER NOT NULL REFERENCES users(id),
29
+ title VARCHAR(500) NOT NULL,
30
+ body TEXT,
31
+ status VARCHAR(50) DEFAULT 'draft',
32
+ creator_id INTEGER,
33
+ updater_id INTEGER,
34
+ deleter_id INTEGER,
35
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
36
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
37
+ );
38
+
39
+ -- Publication used by the Chronicles PostgreSQL adapter
40
+ CREATE PUBLICATION chronicles_test_pub FOR TABLE users, posts;
@@ -0,0 +1,138 @@
1
+ name: whodunit-chronicles
2
+
3
+ # ──────────────────────────────────────────────────────────────────────────────
4
+ # Local development + CI test databases
5
+ #
6
+ # Quickstart:
7
+ # docker compose up -d
8
+ # bundle exec rake test
9
+ #
10
+ # Stop + wipe volumes:
11
+ # docker compose down -v
12
+ # ──────────────────────────────────────────────────────────────────────────────
13
+
14
+ services:
15
+ # ── PostgreSQL (logical replication enabled) ─────────────────────────────
16
+ postgres:
17
+ image: postgres:16-alpine
18
+ environment:
19
+ POSTGRES_USER: chronicles
20
+ POSTGRES_PASSWORD: chronicles
21
+ POSTGRES_DB: chronicles_test
22
+ ports:
23
+ - "${POSTGRES_PORT:-5432}:5432"
24
+ command: >
25
+ postgres
26
+ -c wal_level=logical
27
+ -c max_replication_slots=10
28
+ -c max_wal_senders=10
29
+ -c log_replication_commands=on
30
+ volumes:
31
+ - pg_data:/var/lib/postgresql/data
32
+ - ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
33
+ healthcheck:
34
+ test: ["CMD-SHELL", "pg_isready -U chronicles -d chronicles_test"]
35
+ interval: 5s
36
+ timeout: 5s
37
+ retries: 10
38
+ start_period: 10s
39
+
40
+ # Separate audit database (mirrors production split)
41
+ postgres_audit:
42
+ image: postgres:16-alpine
43
+ environment:
44
+ POSTGRES_USER: chronicles
45
+ POSTGRES_PASSWORD: chronicles
46
+ POSTGRES_DB: chronicles_audit_test
47
+ ports:
48
+ - "${POSTGRES_AUDIT_PORT:-5433}:5432"
49
+ volumes:
50
+ - pg_audit_data:/var/lib/postgresql/data
51
+ healthcheck:
52
+ test: ["CMD-SHELL", "pg_isready -U chronicles -d chronicles_audit_test"]
53
+ interval: 5s
54
+ timeout: 5s
55
+ retries: 10
56
+ start_period: 10s
57
+
58
+ # ── MySQL (binary logging enabled) ───────────────────────────────────────
59
+ mysql:
60
+ image: mysql:8.0
61
+ environment:
62
+ MYSQL_ROOT_PASSWORD: chronicles_root
63
+ MYSQL_USER: chronicles
64
+ MYSQL_PASSWORD: chronicles
65
+ MYSQL_DATABASE: chronicles_test
66
+ ports:
67
+ - "3306:3306"
68
+ command: >
69
+ mysqld
70
+ --server-id=1
71
+ --log-bin=mysql-bin
72
+ --binlog-format=ROW
73
+ --binlog-row-image=FULL
74
+ --expire-logs-days=1
75
+ --gtid-mode=ON
76
+ --enforce-gtid-consistency=ON
77
+ volumes:
78
+ - mysql_data:/var/lib/mysql
79
+ - ./docker/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
80
+ healthcheck:
81
+ test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "chronicles", "-pchronicles"]
82
+ interval: 5s
83
+ timeout: 5s
84
+ retries: 15
85
+ start_period: 30s
86
+
87
+ # Separate audit database for MySQL
88
+ mysql_audit:
89
+ image: mysql:8.0
90
+ environment:
91
+ MYSQL_ROOT_PASSWORD: chronicles_root
92
+ MYSQL_USER: chronicles
93
+ MYSQL_PASSWORD: chronicles
94
+ MYSQL_DATABASE: chronicles_audit_test
95
+ ports:
96
+ - "3307:3306"
97
+ volumes:
98
+ - mysql_audit_data:/var/lib/mysql
99
+ healthcheck:
100
+ test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "chronicles", "-pchronicles"]
101
+ interval: 5s
102
+ timeout: 5s
103
+ retries: 15
104
+ start_period: 30s
105
+
106
+ # ── MariaDB (binary logging enabled) ─────────────────────────────────────
107
+ mariadb:
108
+ image: mariadb:11
109
+ environment:
110
+ MARIADB_ROOT_PASSWORD: chronicles_root
111
+ MARIADB_USER: chronicles
112
+ MARIADB_PASSWORD: chronicles
113
+ MARIADB_DATABASE: chronicles_test
114
+ ports:
115
+ - "3308:3306"
116
+ command: >
117
+ mariadbd
118
+ --server-id=2
119
+ --log-bin=mariadb-bin
120
+ --binlog-format=ROW
121
+ --binlog-row-image=FULL
122
+ --expire-logs-days=1
123
+ volumes:
124
+ - mariadb_data:/var/lib/mysql
125
+ - ./docker/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
126
+ healthcheck:
127
+ test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
128
+ interval: 5s
129
+ timeout: 5s
130
+ retries: 15
131
+ start_period: 30s
132
+
133
+ volumes:
134
+ pg_data:
135
+ pg_audit_data:
136
+ mysql_data:
137
+ mysql_audit_data:
138
+ mariadb_data:
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whodunit
4
+ module Chronicles
5
+ # Lazily loads the correct database adapter gem at runtime.
6
+ #
7
+ # This avoids forcing all users to install both `pg` and `trilogy`
8
+ # regardless of which database they actually use.
9
+ #
10
+ # @example
11
+ # adapter = AdapterLoader.load(:postgresql)
12
+ # adapter = AdapterLoader.load(:mysql)
13
+ # adapter = AdapterLoader.load(:mariadb)
14
+ module AdapterLoader
15
+ # Map of adapter type symbols to their required gem and class path.
16
+ ADAPTER_REGISTRY = {
17
+ postgresql: {
18
+ gem: 'pg',
19
+ require: 'whodunit/chronicles/adapters/postgresql',
20
+ class: 'Whodunit::Chronicles::Adapters::PostgreSQL',
21
+ hint: "Add `gem 'pg', '~> 1.5'` to your Gemfile.",
22
+ },
23
+ mysql: {
24
+ gem: 'trilogy',
25
+ require: 'whodunit/chronicles/adapters/mysql',
26
+ class: 'Whodunit::Chronicles::Adapters::MySQL',
27
+ hint: "Add `gem 'trilogy', '~> 2.9'` to your Gemfile.",
28
+ },
29
+ mariadb: {
30
+ gem: 'trilogy',
31
+ require: 'whodunit/chronicles/adapters/mysql',
32
+ class: 'Whodunit::Chronicles::Adapters::MySQL',
33
+ hint: "Add `gem 'trilogy', '~> 2.9'` to your Gemfile.",
34
+ },
35
+ }.freeze
36
+
37
+ # Load and instantiate an adapter by type.
38
+ #
39
+ # @param type [Symbol] one of :postgresql, :mysql, :mariadb
40
+ # @param options [Hash] options forwarded to the adapter constructor
41
+ # @return [Adapters::Base] the instantiated adapter
42
+ # @raise [Whodunit::Chronicles::ConfigurationError] for unknown adapter types
43
+ # @raise [Whodunit::Chronicles::AdapterLoadError] when the required gem is missing
44
+ def self.load(type, **)
45
+ config = ADAPTER_REGISTRY[type.to_sym]
46
+
47
+ unless config
48
+ known = ADAPTER_REGISTRY.keys.map(&:inspect).join(', ')
49
+ raise ConfigurationError,
50
+ "Unknown adapter type #{type.inspect}. Known adapters: #{known}"
51
+ end
52
+
53
+ load_gem!(config)
54
+ require config[:require]
55
+ Object.const_get(config[:class]).new(**)
56
+ rescue LoadError => e
57
+ raise AdapterLoadError,
58
+ "Could not load the '#{config[:gem]}' gem required for the " \
59
+ "#{type} adapter.\n#{config[:hint]}\nOriginal error: #{e.message}"
60
+ end
61
+
62
+ # @api private
63
+ def self.load_gem!(config)
64
+ require config[:gem]
65
+ end
66
+ private_class_method :load_gem!
67
+ end
68
+ end
69
+ end
@@ -10,7 +10,7 @@ module Whodunit
10
10
  #
11
11
  # Uses MySQL's binary log replication to stream database changes
12
12
  # without impacting application performance.
13
- class MySQL < StreamAdapter
13
+ class MySQL < Chronicles::StreamAdapter
14
14
  DEFAULT_SERVER_ID = 1001
15
15
 
16
16
  attr_reader :connection, :database_url, :server_id, :binlog_file, :binlog_position
@@ -126,7 +126,7 @@ module Whodunit
126
126
  database: parsed_url[:database])
127
127
  rescue StandardError => e
128
128
  log(:error, 'Failed to establish connection', error: e.message)
129
- raise AdapterError, "Connection failed: #{e.message}"
129
+ raise AdapterLoadError, "Connection failed: #{e.message}"
130
130
  end
131
131
 
132
132
  def close_connection
@@ -9,7 +9,7 @@ module Whodunit
9
9
  #
10
10
  # Uses PostgreSQL's logical replication functionality to stream
11
11
  # database changes via WAL decoding without impacting application performance.
12
- class PostgreSQL < StreamAdapter
12
+ class PostgreSQL < Chronicles::StreamAdapter
13
13
  DEFAULT_PLUGIN = 'pgoutput'
14
14
 
15
15
  attr_reader :connection, :replication_connection, :publication_name, :slot_name
@@ -95,8 +95,8 @@ module Whodunit
95
95
  def changes
96
96
  return {} unless update? && old_data && new_data
97
97
 
98
- changed_columns.each_with_object({}) do |column, changes_hash|
99
- changes_hash[column] = [old_data[column], new_data[column]]
98
+ changed_columns.to_h do |column|
99
+ [column, [old_data[column], new_data[column]]]
100
100
  end
101
101
  end
102
102
 
@@ -0,0 +1,86 @@
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
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whodunit
4
+ module Chronicles
5
+ # Base error class for all whodunit-chronicles errors.
6
+ #
7
+ # Rescuing this class catches every error raised by the gem:
8
+ #
9
+ # begin
10
+ # Whodunit::Chronicles.service.start
11
+ # rescue Whodunit::Chronicles::Error => e
12
+ # logger.error("Chronicles failure: #{e.message}")
13
+ # end
14
+ class Error < StandardError; end
15
+
16
+ # Raised when the gem is configured with invalid or missing settings.
17
+ #
18
+ # @example
19
+ # Whodunit::Chronicles.configure do |c|
20
+ # c.adapter = :unknown_db # => raises ConfigurationError
21
+ # end
22
+ class ConfigurationError < Error; end
23
+
24
+ # Raised when a required database driver gem is not installed.
25
+ #
26
+ # @example message
27
+ # "Could not load the 'pg' gem required for the postgresql adapter.
28
+ # Add `gem 'pg', '~> 1.5'` to your Gemfile."
29
+ class AdapterLoadError < Error; end
30
+
31
+ # Raised when a streaming or replication connection fails.
32
+ class ConnectionError < Error; end
33
+
34
+ # Raised when processing a change event fails.
35
+ class ProcessingError < Error; end
36
+
37
+ # Raised when writing an audit record to the audit database fails.
38
+ class PersistenceError < Error; end
39
+
40
+ # Raised when replication related error occurs
41
+ class ReplicationError < Error; end
42
+ end
43
+ end
@@ -118,9 +118,9 @@ module Whodunit
118
118
  def build_adapter
119
119
  case Chronicles.config.adapter
120
120
  when :postgresql
121
- Adapters::PostgreSQL.new(logger: logger)
121
+ Whodunit::Chronicles::AdapterLoader.load(:postgresql, logger:)
122
122
  when :mysql
123
- Adapters::MySQL.new(logger: logger)
123
+ Whodunit::Chronicles::AdapterLoader.load(:mysql, logger:)
124
124
  else
125
125
  raise ConfigurationError, "Unsupported adapter: #{Chronicles.config.adapter}"
126
126
  end
@@ -131,7 +131,7 @@ module Whodunit
131
131
 
132
132
  return if adapter.test_connection
133
133
 
134
- raise AdapterError, 'Failed to connect to source database'
134
+ raise AdapterLoadError, 'Failed to connect to source database'
135
135
  end
136
136
 
137
137
  def test_connections!
@@ -139,7 +139,7 @@ module Whodunit
139
139
  # Test processor connection by creating a dummy connection
140
140
  processor.send(:ensure_connection)
141
141
  rescue StandardError => e
142
- raise AdapterError, "Connection test failed: #{e.message}"
142
+ raise AdapterLoadError, "Connection test failed: #{e.message}"
143
143
  end
144
144
 
145
145
  def start_streaming_with_retry
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Whodunit
4
4
  module Chronicles
5
- VERSION = '0.2.0'
5
+ VERSION = '0.3.0'
6
6
  end
7
7
  end
@@ -14,9 +14,9 @@ require_relative 'chronicles/persistence'
14
14
  require_relative 'chronicles/processor'
15
15
  require_relative 'chronicles/service'
16
16
 
17
- # Adapters
18
- require_relative 'chronicles/adapters/postgresql'
19
- require_relative 'chronicles/adapters/mysql'
17
+ require_relative 'chronicles/errors'
18
+ require_relative 'chronicles/adapter_loader'
19
+ require_relative 'chronicles/composite_processor'
20
20
 
21
21
  module Whodunit
22
22
  # Chronicles - The complete historical record of `whodunit did what?` data
@@ -41,11 +41,6 @@ module Whodunit
41
41
  setting :max_retry_attempts, default: 3
42
42
  setting :retry_delay, default: 5
43
43
 
44
- class Error < StandardError; end
45
- class ConfigurationError < Error; end
46
- class AdapterError < Error; end
47
- class ReplicationError < Error; end
48
-
49
44
  # Configure Chronicles
50
45
  #
51
46
  # @example
@@ -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/whodunit-gem/whodunit-chronicles'
15
+ spec.homepage = 'https://github.com/kanutocd/whodunit-chronicles'
16
16
  spec.license = 'MIT'
17
- spec.required_ruby_version = '>= 3.1.0'
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,19 +33,31 @@ 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 dependencies
42
- spec.add_dependency 'pg', '~> 1.5'
43
- # Driver for MySQL-compatible database
44
- spec.add_dependency 'trilogy', '~> 2.9'
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
45
57
  # Required for Ruby 3.4+ compatibility (trilogy dependency)
46
- spec.add_dependency 'bigdecimal', '~> 3.1'
58
+ spec.add_development_dependency 'bigdecimal', '~> 3.1'
47
59
 
48
- # Development dependencies
60
+ # ── Development tooling ───────────────────────────────
49
61
  spec.add_development_dependency 'kramdown', '~> 2.5'
50
62
  spec.add_development_dependency 'minitest', '~> 5.20'
51
63
  spec.add_development_dependency 'mocha', '~> 2.1'
@@ -55,11 +67,13 @@ Gem::Specification.new do |spec|
55
67
  spec.add_development_dependency 'rubocop', '~> 1.60'
56
68
  spec.add_development_dependency 'rubocop-minitest', '~> 0.34'
57
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'
58
72
  spec.add_development_dependency 'simplecov', '~> 0.22'
59
73
  spec.add_development_dependency 'simplecov-cobertura', '~> 3.0'
60
74
  spec.add_development_dependency 'yard', '~> 0.9'
61
75
 
62
- # Security scanning dependencies
76
+ # ── Security scanning dependencies ──────────────────────────────
63
77
  spec.add_development_dependency 'brakeman', '~> 7.1'
64
78
  spec.add_development_dependency 'bundler-audit', '~> 0.9'
65
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.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ken C. Demanawa
@@ -59,7 +59,7 @@ dependencies:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
61
  version: '1.5'
62
- type: :runtime
62
+ type: :development
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
@@ -73,7 +73,7 @@ dependencies:
73
73
  - - "~>"
74
74
  - !ruby/object:Gem::Version
75
75
  version: '2.9'
76
- type: :runtime
76
+ type: :development
77
77
  prerelease: false
78
78
  version_requirements: !ruby/object:Gem::Requirement
79
79
  requirements:
@@ -87,7 +87,7 @@ dependencies:
87
87
  - - "~>"
88
88
  - !ruby/object:Gem::Version
89
89
  version: '3.1'
90
- type: :runtime
90
+ type: :development
91
91
  prerelease: false
92
92
  version_requirements: !ruby/object:Gem::Requirement
93
93
  requirements:
@@ -220,6 +220,34 @@ dependencies:
220
220
  - - "~>"
221
221
  - !ruby/object:Gem::Version
222
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'
223
251
  - !ruby/object:Gem::Dependency
224
252
  name: simplecov
225
253
  requirement: !ruby/object:Gem::Requirement
@@ -304,20 +332,27 @@ files:
304
332
  - ".yardopts"
305
333
  - CHANGELOG.md
306
334
  - CODE_OF_CONDUCT.md
335
+ - CONTRIBUTING.md
307
336
  - LICENSE
308
337
  - README.md
309
338
  - Rakefile
339
+ - docker-compose.yml
340
+ - docker/mysql/init.sql
341
+ - docker/postgres/init.sql
310
342
  - examples/images/campaign-performance-analytics.png
311
343
  - examples/images/candidate-journey-analytics.png
312
344
  - examples/images/recruitment-funnel-analytics.png
313
345
  - lib/.gitkeep
314
346
  - lib/whodunit-chronicles.rb
315
347
  - lib/whodunit/chronicles.rb
348
+ - lib/whodunit/chronicles/adapter_loader.rb
316
349
  - lib/whodunit/chronicles/adapters/mysql.rb
317
350
  - lib/whodunit/chronicles/adapters/postgresql.rb
318
351
  - lib/whodunit/chronicles/change_event.rb
352
+ - lib/whodunit/chronicles/composite_processor.rb
319
353
  - lib/whodunit/chronicles/configuration.rb
320
354
  - lib/whodunit/chronicles/connection.rb
355
+ - lib/whodunit/chronicles/errors.rb
321
356
  - lib/whodunit/chronicles/persistence.rb
322
357
  - lib/whodunit/chronicles/processor.rb
323
358
  - lib/whodunit/chronicles/service.rb
@@ -325,14 +360,14 @@ files:
325
360
  - lib/whodunit/chronicles/table.rb
326
361
  - lib/whodunit/chronicles/version.rb
327
362
  - whodunit-chronicles.gemspec
328
- homepage: https://github.com/whodunit-gem/whodunit-chronicles
363
+ homepage: https://github.com/kanutocd/whodunit-chronicles
329
364
  licenses:
330
365
  - MIT
331
366
  metadata:
332
367
  allowed_push_host: https://rubygems.org
333
- homepage_uri: https://github.com/whodunit-gem/whodunit-chronicles
334
- source_code_uri: https://github.com/whodunit-gem/whodunit-chronicles
335
- changelog_uri: https://github.com/whodunit-gem/whodunit-chronicles/blob/main/CHANGELOG.md
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
336
371
  rubygems_mfa_required: 'true'
337
372
  rdoc_options: []
338
373
  require_paths:
@@ -341,7 +376,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
341
376
  requirements:
342
377
  - - ">="
343
378
  - !ruby/object:Gem::Version
344
- version: 3.1.0
379
+ version: 3.2.0
345
380
  required_rubygems_version: !ruby/object:Gem::Requirement
346
381
  requirements:
347
382
  - - ">="