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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8367ecd692940c28ead4bc255173137f7d9f34b9db3df262c15081ab64334ce0
4
- data.tar.gz: 84a7c6eeee1b62ff8a3f507e5ae6128759292a80d6e5bd1f1a936ca33c431326
3
+ metadata.gz: 81634f3ce5d00503e50882a14495a960dfc8fb1072b53c92e3cd2a08441666c9
4
+ data.tar.gz: b235c7ee87fda30c747db3a678a3b9f6c7b5f5b95861f998b037a63418bee29b
5
5
  SHA512:
6
- metadata.gz: 161fafcaba15339898929328f103ff1b659aa17aa89179e7e9124ecb7f7b0487da39d55402711f02f724326203a28c2f7e322ba97a28b3672007663114ac33fd
7
- data.tar.gz: cd4cc774d28e9f14732bc807478716c190fa52f6b2f4ec243512655db7fbd3d5aae6c712e7a9a301192702ea5b5a3753e315921ca62c64ea8c7c0119ffa27950
6
+ metadata.gz: ad16ce87f467600879c9358540143cb1ad4eeeb1fe278472647aad21e28db4420a5238b13e6d70c02a65014d72251d54acfdfb0fe6c448191644871a28d4ef45
7
+ data.tar.gz: e932e94f85304d7b8f756a5fce3a3ba0e2c07ca302766e0f8ff3563b4acac9e14b7943ae0bcc3030ee68567571315e635fa172c02c945e70d68e45c46fba4d41
data/README.md CHANGED
@@ -1 +1,117 @@
1
1
  # Waldit
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/waldit.svg)](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"])
@@ -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
@@ -1,5 +1,4 @@
1
1
  # frozen_string_literal: true
2
- # typed: false
3
2
 
4
3
  require "rails/railtie"
5
4
 
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
@@ -1,5 +1,4 @@
1
1
  # frozen_string_literal: true
2
- # typed: false
3
2
 
4
3
  module Waldit
5
4
  module Sidekiq
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
- # typed: true
3
2
 
4
3
  module Waldit
5
- VERSION = "0.0.2"
4
+ VERSION = "0.0.4"
6
5
  end
@@ -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
- extend T::Sig
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
@@ -2,7 +2,7 @@
2
2
  module Waldit
3
3
  extend T::Sig
4
4
  extend Waldit::Context
5
- VERSION = "0.0.2"
5
+ VERSION = "0.0.4"
6
6
 
7
7
  class << self
8
8
  sig { returns(String) }
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.2
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-07-02 00:00:00.000000000 Z
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.2
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.2
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