waldit 0.0.2 → 0.0.4
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/README.md +116 -0
- data/Rakefile +0 -7
- data/lib/waldit/context.rb +0 -13
- data/lib/waldit/postgresql_adapter.rb +40 -1
- data/lib/waldit/railtie.rb +0 -1
- data/lib/waldit/record.rb +0 -12
- data/lib/waldit/sidekiq.rb +0 -1
- data/lib/waldit/version.rb +1 -2
- data/lib/waldit/watcher.rb +2 -10
- data/lib/waldit.rb +1 -15
- data/rbi/waldit.rbi +1 -1
- metadata +4 -60
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 81634f3ce5d00503e50882a14495a960dfc8fb1072b53c92e3cd2a08441666c9
|
4
|
+
data.tar.gz: b235c7ee87fda30c747db3a678a3b9f6c7b5f5b95861f998b037a63418bee29b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ad16ce87f467600879c9358540143cb1ad4eeeb1fe278472647aad21e28db4420a5238b13e6d70c02a65014d72251d54acfdfb0fe6c448191644871a28d4ef45
|
7
|
+
data.tar.gz: e932e94f85304d7b8f756a5fce3a3ba0e2c07ca302766e0f8ff3563b4acac9e14b7943ae0bcc3030ee68567571315e635fa172c02c945e70d68e45c46fba4d41
|
data/README.md
CHANGED
@@ -1 +1,117 @@
|
|
1
1
|
# Waldit
|
2
|
+
|
3
|
+
[](https://badge.fury.io/rb/waldit)
|
4
|
+
|
5
|
+
Waldit is a Ruby gem that provides a simple and extensible way to audit changes to your ActiveRecord models. It leverages PostgreSQL's logical replication capabilities to capture changes directly from your database with 100% consistency.
|
6
|
+
|
7
|
+
## Features
|
8
|
+
|
9
|
+
- **Automatic Auditing:** Automatically track `create`, `update`, and `delete` operations on your models.
|
10
|
+
- **Contextual Auditing:** Add custom context to your audit records to understand who made the change and why.
|
11
|
+
- **Flexible Configuration:** Configure which tables and columns to watch, and how to store audit information.
|
12
|
+
- **High Performance:** Built on top of [`wal`](https://github.com/reu/wal), which uses PostgreSQL's logical replication for minimal overhead.
|
13
|
+
|
14
|
+
## Installation
|
15
|
+
|
16
|
+
Add this line to your application's Gemfile:
|
17
|
+
|
18
|
+
```ruby
|
19
|
+
gem "waldit"
|
20
|
+
```
|
21
|
+
|
22
|
+
And then execute:
|
23
|
+
|
24
|
+
$ bundle
|
25
|
+
|
26
|
+
Or install it yourself as:
|
27
|
+
|
28
|
+
$ gem install waldit
|
29
|
+
|
30
|
+
## Usage
|
31
|
+
|
32
|
+
1. **Configure your database adapter:**
|
33
|
+
|
34
|
+
First step is to configure in your `config/database.yml` and change your adapter to `postgresqlwaldit`, which is a special adapter that allows injecting `waldit` contextual information on your transactions:
|
35
|
+
|
36
|
+
```yaml
|
37
|
+
default: &default
|
38
|
+
adapter: postgresqlwaldit
|
39
|
+
# ...
|
40
|
+
```
|
41
|
+
|
42
|
+
2. **Create an audit table:**
|
43
|
+
|
44
|
+
Generate a migration to create the `waldit` table:
|
45
|
+
|
46
|
+
```bash
|
47
|
+
rails generate migration create_waldit
|
48
|
+
```
|
49
|
+
|
50
|
+
And then add the following to your migration file:
|
51
|
+
|
52
|
+
```ruby
|
53
|
+
class CreateWalditTable < ActiveRecord::Migration[7.0]
|
54
|
+
def change
|
55
|
+
create_table :waldit do |t|
|
56
|
+
t.bigint :transaction_id, null: false
|
57
|
+
t.bigint :lsn, null: false
|
58
|
+
t.string :action, null: false
|
59
|
+
t.jsonb :context, default: {}
|
60
|
+
t.string :table_name, null: false
|
61
|
+
t.string :primary_key, null: false
|
62
|
+
t.jsonb :old, default: {}
|
63
|
+
t.jsonb :new, default: {}
|
64
|
+
t.timestamp :commited_at
|
65
|
+
|
66
|
+
t.index [:table_name, :primary_key, :transaction_id], unique: true
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
```
|
71
|
+
|
72
|
+
3. **Configure Waldit:**
|
73
|
+
|
74
|
+
Create an initializer file at `config/initializers/waldit.rb`:
|
75
|
+
|
76
|
+
```ruby
|
77
|
+
Waldit.configure do |config|
|
78
|
+
# A callback that returns true if a table should be watched.
|
79
|
+
config.watched_tables = ->(table) { table != "waldit" }
|
80
|
+
|
81
|
+
# A callback that returns an array of columns to ignore for a given table.
|
82
|
+
config.ignored_columns = ->(table) { %w[created_at updated_at] }
|
83
|
+
end
|
84
|
+
```
|
85
|
+
|
86
|
+
4. **Add context to your changes:**
|
87
|
+
|
88
|
+
Use the `with_context` method to add context to your database operations:
|
89
|
+
|
90
|
+
```ruby
|
91
|
+
Waldit.with_context(user_id: 1, reason: "User updated their profile") do
|
92
|
+
user.update(name: "New Name")
|
93
|
+
end
|
94
|
+
```
|
95
|
+
|
96
|
+
5. **Start the watcher:**
|
97
|
+
|
98
|
+
To process the events, you need to start a WAL watcher. The recommended way is to have a config/waldit.yml
|
99
|
+
|
100
|
+
```yml
|
101
|
+
slots:
|
102
|
+
audit:
|
103
|
+
publications: [waldit_publication]
|
104
|
+
watcher: Waldit::Watcher
|
105
|
+
```
|
106
|
+
|
107
|
+
And then run:
|
108
|
+
|
109
|
+
```bash
|
110
|
+
bundle exec wal start config/waldit.yml
|
111
|
+
```
|
112
|
+
|
113
|
+
## How it Works
|
114
|
+
|
115
|
+
Waldit uses a custom PostgreSQL adapter to set the `waldit_context` session variable before each transaction. This context is then captured by the logical replication slot and stored in the `waldit` table by the `Waldit::Watcher`.
|
116
|
+
|
117
|
+
The `Waldit::Watcher` is a streaming watcher that listens for changes in the logical replication slot and creates audit records in the `waldit` table. It processes events in batches to minimize the number of database transactions.
|
data/Rakefile
CHANGED
@@ -3,10 +3,3 @@
|
|
3
3
|
require "bundler/gem_tasks"
|
4
4
|
|
5
5
|
task(:test) { sh "bundle exec rspec" }
|
6
|
-
|
7
|
-
task default: %i[build]
|
8
|
-
|
9
|
-
task("sig/waldit.rbi") { sh "bundle exec parlour" }
|
10
|
-
task("rbi/waldit.rbs" => "sig/waldit.rbi") { sh "rbs prototype rbi rbi/waldit.rbi > sig/waldit.rbs" }
|
11
|
-
|
12
|
-
Rake::Task["build"].enhance(["sig/waldit.rbi", "rbi/waldit.rbs"])
|
data/lib/waldit/context.rb
CHANGED
@@ -1,17 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
-
# typed: true
|
3
2
|
|
4
3
|
module Waldit
|
5
4
|
module Context
|
6
|
-
extend T::Sig
|
7
|
-
|
8
|
-
Context = T.type_alias { T::Hash[T.any(String, Symbol), T.untyped] }
|
9
|
-
|
10
|
-
sig do
|
11
|
-
type_parameters(:U)
|
12
|
-
.params(context: Context, block: T.proc.returns(T.type_parameter(:U)))
|
13
|
-
.returns(T.type_parameter(:U))
|
14
|
-
end
|
15
5
|
def with_context(context, &block)
|
16
6
|
current_context = self.context || {}
|
17
7
|
Thread.current[:waldit_context] ||= []
|
@@ -21,12 +11,10 @@ module Waldit
|
|
21
11
|
Thread.current[:waldit_context].pop
|
22
12
|
end
|
23
13
|
|
24
|
-
sig { returns(T.nilable(Context)) }
|
25
14
|
def context
|
26
15
|
Thread.current[:waldit_context]&.last
|
27
16
|
end
|
28
17
|
|
29
|
-
sig { params(added_context: Context).void }
|
30
18
|
def add_context(added_context)
|
31
19
|
if (context = self.context)
|
32
20
|
context.merge!(added_context.as_json)
|
@@ -35,7 +23,6 @@ module Waldit
|
|
35
23
|
end
|
36
24
|
end
|
37
25
|
|
38
|
-
sig { params(context: Context).void }
|
39
26
|
def new_context(context = {})
|
40
27
|
Thread.current[:waldit_context] ||= []
|
41
28
|
Thread.current[:waldit_context].push(context.as_json)
|
@@ -1,5 +1,4 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
-
# typed: ignore
|
3
2
|
|
4
3
|
require "active_record/connection_adapters/postgresql_adapter"
|
5
4
|
|
@@ -27,6 +26,46 @@ module Waldit
|
|
27
26
|
end
|
28
27
|
end
|
29
28
|
|
29
|
+
def exec_no_cache(sql, ...)
|
30
|
+
return super if READ_QUERY_REGEXP.match? sql
|
31
|
+
return super if @current_waldit_context == Waldit.context.hash
|
32
|
+
|
33
|
+
if transaction_open?
|
34
|
+
set_waldit_context!
|
35
|
+
super
|
36
|
+
|
37
|
+
elsif Waldit.context
|
38
|
+
# We are trying to execute a query with waldit context while not in a transaction, so we start one
|
39
|
+
transaction do
|
40
|
+
set_waldit_context!
|
41
|
+
super
|
42
|
+
end
|
43
|
+
|
44
|
+
else
|
45
|
+
super
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def exec_cache(sql, ...)
|
50
|
+
return super if READ_QUERY_REGEXP.match? sql
|
51
|
+
return super if @current_waldit_context == Waldit.context.hash
|
52
|
+
|
53
|
+
if transaction_open?
|
54
|
+
set_waldit_context!
|
55
|
+
super
|
56
|
+
|
57
|
+
elsif Waldit.context
|
58
|
+
# We are trying to execute a query with waldit context while not in a transaction, so we start one
|
59
|
+
transaction do
|
60
|
+
set_waldit_context!
|
61
|
+
super
|
62
|
+
end
|
63
|
+
|
64
|
+
else
|
65
|
+
super
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
30
69
|
def begin_db_transaction(...)
|
31
70
|
@current_waldit_context = nil.hash
|
32
71
|
super
|
data/lib/waldit/railtie.rb
CHANGED
data/lib/waldit/record.rb
CHANGED
@@ -1,19 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
-
# typed: true
|
3
2
|
|
4
3
|
module Waldit
|
5
4
|
module Record
|
6
|
-
extend T::Sig
|
7
|
-
extend T::Helpers
|
8
|
-
abstract!
|
9
|
-
|
10
|
-
sig { abstract.returns(T::Hash[T.any(String, Symbol), T.untyped]) }
|
11
|
-
def new; end
|
12
|
-
|
13
|
-
sig { abstract.returns(T::Hash[T.any(String, Symbol), T.untyped]) }
|
14
|
-
def old; end
|
15
|
-
|
16
|
-
sig { returns(T::Hash[T.any(String, Symbol), [T.untyped, T.untyped]]) }
|
17
5
|
def diff
|
18
6
|
(old.keys | new.keys).reduce({}.with_indifferent_access) do |diff, key|
|
19
7
|
old[key] != new[key] ? diff.merge(key => [old[key], new[key]]) : diff
|
data/lib/waldit/sidekiq.rb
CHANGED
data/lib/waldit/version.rb
CHANGED
data/lib/waldit/watcher.rb
CHANGED
@@ -1,13 +1,11 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
-
# typed: true
|
3
2
|
|
4
3
|
require "wal"
|
5
4
|
|
6
5
|
module Waldit
|
7
6
|
class Watcher < Wal::StreamingWatcher
|
8
|
-
|
7
|
+
include Wal
|
9
8
|
|
10
|
-
sig { params(event: T.any(InsertEvent, UpdateEvent, DeleteEvent)).void }
|
11
9
|
def audit_event(event)
|
12
10
|
return unless event.primary_key
|
13
11
|
|
@@ -60,7 +58,7 @@ module Waldit
|
|
60
58
|
else
|
61
59
|
# Finally the most common case: just deleting a record not created or updated on this transaction
|
62
60
|
record.upsert(
|
63
|
-
audit.merge(action: "delete", old:),
|
61
|
+
audit.merge(action: "delete", old: event.old),
|
64
62
|
unique_by:,
|
65
63
|
on_duplicate: :update,
|
66
64
|
)
|
@@ -68,7 +66,6 @@ module Waldit
|
|
68
66
|
end
|
69
67
|
end
|
70
68
|
|
71
|
-
sig { override.params(events: T::Enumerator[Event]).void }
|
72
69
|
def on_transaction_events(events)
|
73
70
|
counter = 0
|
74
71
|
catch :finish do
|
@@ -96,27 +93,22 @@ module Waldit
|
|
96
93
|
end
|
97
94
|
end
|
98
95
|
|
99
|
-
sig { params(table: String).returns(T::Boolean) }
|
100
96
|
def should_watch_table?(table)
|
101
97
|
Waldit.watched_tables.call(table)
|
102
98
|
end
|
103
99
|
|
104
|
-
sig { params(prefix: String).returns(T::Boolean) }
|
105
100
|
def valid_context_prefix?(prefix)
|
106
101
|
prefix == Waldit.context_prefix
|
107
102
|
end
|
108
103
|
|
109
|
-
sig { params(table: String).returns(T::Array[String]) }
|
110
104
|
def ignored_columns(table)
|
111
105
|
Waldit.ignored_columns.call(table)
|
112
106
|
end
|
113
107
|
|
114
|
-
sig { returns(Integer) }
|
115
108
|
def max_transaction_size
|
116
109
|
Waldit.max_transaction_size
|
117
110
|
end
|
118
111
|
|
119
|
-
sig { returns(T.class_of(ActiveRecord::Base)) }
|
120
112
|
def record
|
121
113
|
Waldit.model
|
122
114
|
end
|
data/lib/waldit.rb
CHANGED
@@ -1,5 +1,4 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
-
# typed: true
|
3
2
|
|
4
3
|
require_relative "waldit/version"
|
5
4
|
require_relative "waldit/context"
|
@@ -8,19 +7,11 @@ require_relative "waldit/record"
|
|
8
7
|
require_relative "waldit/watcher"
|
9
8
|
|
10
9
|
module Waldit
|
11
|
-
extend T::Sig
|
12
10
|
extend Waldit::Context
|
13
11
|
|
14
12
|
class << self
|
15
|
-
extend T::Sig
|
16
|
-
|
17
|
-
sig { returns(String) }
|
18
|
-
attr_accessor :context_prefix
|
19
|
-
|
20
|
-
sig { returns(T.proc.params(table: String).returns(T::Boolean)) }
|
21
13
|
attr_reader :watched_tables
|
22
14
|
|
23
|
-
sig { params(tables: T.any(T::Array[String], T.proc.params(table: String).returns(T::Boolean))).void }
|
24
15
|
def watched_tables=(tables)
|
25
16
|
case tables
|
26
17
|
when Array
|
@@ -30,17 +21,12 @@ module Waldit
|
|
30
21
|
end
|
31
22
|
end
|
32
23
|
|
33
|
-
sig { returns(T.proc.params(table: String).returns(T::Array[String])) }
|
34
24
|
attr_accessor :ignored_columns
|
35
|
-
|
36
|
-
sig { returns(Integer) }
|
37
25
|
attr_accessor :max_transaction_size
|
38
|
-
|
39
|
-
sig { returns(T.class_of(ActiveRecord::Base)) }
|
40
26
|
attr_accessor :model
|
27
|
+
attr_accessor :context_prefix
|
41
28
|
end
|
42
29
|
|
43
|
-
sig { params(block: T.proc.params(config: T.class_of(Waldit)).void).void }
|
44
30
|
def self.configure(&block)
|
45
31
|
yield self
|
46
32
|
end
|
data/rbi/waldit.rbi
CHANGED
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: waldit
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.4
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Rodrigo Navarro
|
8
8
|
bindir: exe
|
9
9
|
cert_chain: []
|
10
|
-
date: 2025-
|
10
|
+
date: 2025-08-02 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: wal
|
@@ -15,14 +15,14 @@ dependencies:
|
|
15
15
|
requirements:
|
16
16
|
- - ">="
|
17
17
|
- !ruby/object:Gem::Version
|
18
|
-
version: 0.0.
|
18
|
+
version: 0.0.3
|
19
19
|
type: :runtime
|
20
20
|
prerelease: false
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
22
22
|
requirements:
|
23
23
|
- - ">="
|
24
24
|
- !ruby/object:Gem::Version
|
25
|
-
version: 0.0.
|
25
|
+
version: 0.0.3
|
26
26
|
- !ruby/object:Gem::Dependency
|
27
27
|
name: activerecord
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
@@ -37,62 +37,6 @@ dependencies:
|
|
37
37
|
- - ">="
|
38
38
|
- !ruby/object:Gem::Version
|
39
39
|
version: '7'
|
40
|
-
- !ruby/object:Gem::Dependency
|
41
|
-
name: rbs
|
42
|
-
requirement: !ruby/object:Gem::Requirement
|
43
|
-
requirements:
|
44
|
-
- - ">="
|
45
|
-
- !ruby/object:Gem::Version
|
46
|
-
version: '0'
|
47
|
-
type: :development
|
48
|
-
prerelease: false
|
49
|
-
version_requirements: !ruby/object:Gem::Requirement
|
50
|
-
requirements:
|
51
|
-
- - ">="
|
52
|
-
- !ruby/object:Gem::Version
|
53
|
-
version: '0'
|
54
|
-
- !ruby/object:Gem::Dependency
|
55
|
-
name: sorbet
|
56
|
-
requirement: !ruby/object:Gem::Requirement
|
57
|
-
requirements:
|
58
|
-
- - ">="
|
59
|
-
- !ruby/object:Gem::Version
|
60
|
-
version: '0'
|
61
|
-
type: :development
|
62
|
-
prerelease: false
|
63
|
-
version_requirements: !ruby/object:Gem::Requirement
|
64
|
-
requirements:
|
65
|
-
- - ">="
|
66
|
-
- !ruby/object:Gem::Version
|
67
|
-
version: '0'
|
68
|
-
- !ruby/object:Gem::Dependency
|
69
|
-
name: tapioca
|
70
|
-
requirement: !ruby/object:Gem::Requirement
|
71
|
-
requirements:
|
72
|
-
- - ">="
|
73
|
-
- !ruby/object:Gem::Version
|
74
|
-
version: '0'
|
75
|
-
type: :development
|
76
|
-
prerelease: false
|
77
|
-
version_requirements: !ruby/object:Gem::Requirement
|
78
|
-
requirements:
|
79
|
-
- - ">="
|
80
|
-
- !ruby/object:Gem::Version
|
81
|
-
version: '0'
|
82
|
-
- !ruby/object:Gem::Dependency
|
83
|
-
name: parlour
|
84
|
-
requirement: !ruby/object:Gem::Requirement
|
85
|
-
requirements:
|
86
|
-
- - ">="
|
87
|
-
- !ruby/object:Gem::Version
|
88
|
-
version: '0'
|
89
|
-
type: :development
|
90
|
-
prerelease: false
|
91
|
-
version_requirements: !ruby/object:Gem::Requirement
|
92
|
-
requirements:
|
93
|
-
- - ">="
|
94
|
-
- !ruby/object:Gem::Version
|
95
|
-
version: '0'
|
96
40
|
- !ruby/object:Gem::Dependency
|
97
41
|
name: sidekiq
|
98
42
|
requirement: !ruby/object:Gem::Requirement
|