waldit 0.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: afad3b16943e3984c6584b02ef8edb2fb8abb8413687726a657110018e9fcbd4
4
+ data.tar.gz: 934aacd6442c00b7fd40287360e2d8a1b52e0f37328711e029d36812d9cf94d1
5
+ SHA512:
6
+ metadata.gz: ff61b8c0d473390f5197ae4544ad932014483ba68cdb8972e79eee35d4747814c5e64ae60cc53a078653de82f483ae94e98db69b40796a8be0d38b1a9db694d1
7
+ data.tar.gz: a9e6c961b67f1be3335d773af2c42d76b7f74fd5d0eb48bd3cb5dce4cf355ce88144caa626ede05de357af47ce03986893bd0b9accab687bf4490c077fc8c787
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --require spec_helper
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Rodrigo Navarro
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1 @@
1
+ # Waldit
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+
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"])
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ module Waldit
5
+ 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
+ def with_context(context, &block)
16
+ current_context = self.context || {}
17
+ Thread.current[:waldit_context] ||= []
18
+ Thread.current[:waldit_context].push(current_context.merge(context.as_json))
19
+ block.call
20
+ ensure
21
+ Thread.current[:waldit_context].pop
22
+ end
23
+
24
+ sig { returns(T.nilable(Context)) }
25
+ def context
26
+ Thread.current[:waldit_context]&.last
27
+ end
28
+
29
+ sig { params(added_context: Context).void }
30
+ def add_context(added_context)
31
+ if (context = self.context)
32
+ context.merge!(added_context.as_json)
33
+ else
34
+ new_context(added_context)
35
+ end
36
+ end
37
+
38
+ sig { params(context: Context).void }
39
+ def new_context(context = {})
40
+ Thread.current[:waldit_context] ||= []
41
+ Thread.current[:waldit_context].push(context.as_json)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+ # typed: ignore
3
+
4
+ require "active_record/connection_adapters/postgresql_adapter"
5
+
6
+ module Waldit
7
+ class PostgreSQLAdapter < ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
8
+ READ_QUERY_REGEXP = build_read_query_regexp(%i[close declare fetch move set show])
9
+
10
+ def raw_execute(sql, ...)
11
+ return super if READ_QUERY_REGEXP.match? sql
12
+ return super if @current_waldit_context == Waldit.context.hash
13
+
14
+ if transaction_open?
15
+ set_waldit_context!
16
+ super
17
+
18
+ elsif Waldit.context
19
+ # We are trying to execute a query with waldit context while not in a transaction, so we start one
20
+ transaction do
21
+ set_waldit_context!
22
+ super
23
+ end
24
+
25
+ else
26
+ super
27
+ end
28
+ end
29
+
30
+ def begin_db_transaction(...)
31
+ @current_waldit_context = nil.hash
32
+ super
33
+ end
34
+
35
+ def begin_isolated_db_transaction(...)
36
+ @current_waldit_context = nil.hash
37
+ super
38
+ end
39
+
40
+ def commit_db_transaction
41
+ @current_waldit_context = nil.hash
42
+ super
43
+ end
44
+
45
+ private
46
+
47
+ def set_waldit_context!
48
+ context = Waldit.context
49
+ prefix = Waldit.context_prefix
50
+ context_hash = context.hash
51
+ set_wal_watcher_context(context, prefix:) if context_hash != @current_waldit_context
52
+ @current_waldit_context = context_hash
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+ # typed: false
3
+
4
+ require "rails/railtie"
5
+
6
+ module Waldit
7
+ class Railtie < Rails::Railtie
8
+ config.before_configuration do
9
+ ActiveRecord::ConnectionAdapters.register(
10
+ "postgresqlwaldit",
11
+ "Waldit::PostgreSQLAdapter",
12
+ "waldit/postgresql_adapter",
13
+ )
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ module Waldit
5
+ 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
+ def diff
18
+ (old.keys | new.keys).reduce({}.with_indifferent_access) do |diff, key|
19
+ old[key] != new[key] ? diff.merge(key => [old[key], new[key]]) : diff
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ module Waldit
5
+ VERSION = "0.0.1"
6
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ require "wal"
5
+
6
+ module Waldit
7
+ class Watcher < Wal::StreamingWatcher
8
+ extend T::Sig
9
+
10
+ sig { params(event: T.any(InsertEvent, UpdateEvent, DeleteEvent)).void }
11
+ def audit_event(event)
12
+ return unless event.primary_key
13
+
14
+ audit = {
15
+ transaction_id: event.transaction_id,
16
+ lsn: event.lsn,
17
+ context: event.context,
18
+ table_name: event.table,
19
+ primary_key: event.primary_key,
20
+ }
21
+
22
+ unique_by = %i[table_name primary_key transaction_id]
23
+
24
+ case event
25
+ when InsertEvent
26
+ record.upsert(
27
+ audit.merge(action: "insert", new: event.new),
28
+ unique_by:,
29
+ on_duplicate: :update,
30
+ )
31
+
32
+ when UpdateEvent
33
+ return if event.diff.without(ignored_columns(event.table)).empty?
34
+ record.upsert(
35
+ audit.merge(action: "update", old: event.old, new: event.new),
36
+ unique_by:,
37
+ on_duplicate: :update,
38
+ update_only: %w[new],
39
+ )
40
+
41
+ when DeleteEvent
42
+ case record.where(audit.slice(*unique_by)).pluck(:action, :old).first
43
+ in ["insert", _]
44
+ # We are deleting a record that was inserted on this transaction, which means we don't need to audit anything,
45
+ # as the record was never commited
46
+ record.where(audit.slice(*unique_by)).delete_all
47
+
48
+ in ["update", old]
49
+ # We are deleting a record we updated on this transaction. Here we are making sure we keep the correct previous
50
+ # state, and not the state at the moment of the deletion
51
+ record.upsert(
52
+ audit.merge(action: "delete", old:, new: {}),
53
+ unique_by:,
54
+ on_duplicate: :update,
55
+ )
56
+
57
+ in ["delete", _]
58
+ # This should never happend, we wouldn't be able to delete a record that was already deleted on this transaction
59
+
60
+ else
61
+ # Finally the most common case: just deleting a record not created or updated on this transaction
62
+ record.upsert(
63
+ audit.merge(action: "delete", old:),
64
+ unique_by:,
65
+ on_duplicate: :update,
66
+ )
67
+ end
68
+ end
69
+ end
70
+
71
+ sig { override.params(events: T::Enumerator[Event]).void }
72
+ def on_transaction_events(events)
73
+ counter = 0
74
+ catch :finish do
75
+ loop do
76
+ record.transaction do
77
+ events.each do |event|
78
+ case event
79
+ when CommitTransactionEvent
80
+ record
81
+ .where(transaction_id: event.transaction_id)
82
+ .update_all(commited_at: event.timestamp) if counter > 0
83
+ # Using throw to break the outside loop and finish the thread gracefully
84
+ throw :finish
85
+
86
+ when InsertEvent, UpdateEvent, DeleteEvent
87
+ audit_event(event)
88
+
89
+ counter += 1
90
+ # We break here to force a commit, so we don't keep a single big transaction pending
91
+ break if counter % max_transaction_size == 0
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+
99
+ sig { params(table: String).returns(T::Boolean) }
100
+ def should_watch_table?(table)
101
+ Waldit.watched_tables.call(table)
102
+ end
103
+
104
+ sig { params(prefix: String).returns(T::Boolean) }
105
+ def valid_context_prefix?(prefix)
106
+ prefix == Waldit.context_prefix
107
+ end
108
+
109
+ sig { params(table: String).returns(T::Array[String]) }
110
+ def ignored_columns(table)
111
+ Waldit.ignored_columns.call(table)
112
+ end
113
+
114
+ sig { returns(Integer) }
115
+ def max_transaction_size
116
+ Waldit.max_transaction_size
117
+ end
118
+
119
+ sig { returns(T.class_of(ActiveRecord::Base)) }
120
+ def record
121
+ Waldit.model
122
+ end
123
+ end
124
+ end
data/lib/waldit.rb ADDED
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ require_relative "waldit/version"
5
+ require_relative "waldit/context"
6
+ require_relative "waldit/railtie"
7
+ require_relative "waldit/record"
8
+ require_relative "waldit/watcher"
9
+
10
+ module Waldit
11
+ extend T::Sig
12
+ extend Waldit::Context
13
+
14
+ 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
+ attr_reader :watched_tables
22
+
23
+ sig { params(tables: T.any(T::Array[String], T.proc.params(table: String).returns(T::Boolean))).void }
24
+ def watched_tables=(tables)
25
+ case tables
26
+ when Array
27
+ @watched_tables = -> table { tables.include? table }
28
+ else
29
+ @watched_tables = tables
30
+ end
31
+ end
32
+
33
+ sig { returns(T.proc.params(table: String).returns(T::Array[String])) }
34
+ attr_accessor :ignored_columns
35
+
36
+ sig { returns(Integer) }
37
+ attr_accessor :max_transaction_size
38
+
39
+ sig { returns(T.class_of(ActiveRecord::Base)) }
40
+ attr_accessor :model
41
+ end
42
+
43
+ sig { params(block: T.proc.params(config: T.class_of(Waldit)).void).void }
44
+ def self.configure(&block)
45
+ yield self
46
+ end
47
+
48
+ configure do |config|
49
+ config.context_prefix = "waldit_context"
50
+
51
+ config.watched_tables = -> table { table != "waldit" }
52
+
53
+ config.ignored_columns = -> table { %w[created_at updated_at] }
54
+
55
+ config.max_transaction_size = 10_000
56
+
57
+ config.model = Class.new(ActiveRecord::Base) do
58
+ include Waldit::Record
59
+ self.table_name = "waldit"
60
+ end
61
+ end
62
+ end
data/rbi/waldit.rbi ADDED
@@ -0,0 +1,90 @@
1
+ # typed: strong
2
+ module Waldit
3
+ extend T::Sig
4
+ extend Waldit::Context
5
+ VERSION = "0.0.1"
6
+
7
+ class << self
8
+ sig { returns(String) }
9
+ attr_accessor :context_prefix
10
+
11
+ sig { returns(T.proc.params(table: String).returns(T::Boolean)) }
12
+ attr_reader :watched_tables
13
+
14
+ sig { returns(T.proc.params(table: String).returns(T::Array[String])) }
15
+ attr_accessor :ignored_columns
16
+
17
+ sig { returns(Integer) }
18
+ attr_accessor :max_transaction_size
19
+
20
+ sig { returns(T.class_of(ActiveRecord::Base)) }
21
+ attr_accessor :model
22
+ end
23
+
24
+ sig { params(tables: T.any(T::Array[String], T.proc.params(table: String).returns(T::Boolean))).void }
25
+ def self.watched_tables=(tables); end
26
+
27
+ sig { params(block: T.proc.params(config: T.class_of(Waldit)).void).void }
28
+ def self.configure(&block); end
29
+
30
+ module Context
31
+ extend T::Sig
32
+ Context = T.type_alias { T::Hash[T.any(String, Symbol), T.untyped] }
33
+
34
+ sig { type_parameters(:U).params(context: Context, block: T.proc.returns(T.type_parameter(:U))).returns(T.type_parameter(:U)) }
35
+ def with_context(context, &block); end
36
+
37
+ sig { returns(T.nilable(Context)) }
38
+ def context; end
39
+
40
+ sig { params(added_context: Context).void }
41
+ def add_context(added_context); end
42
+
43
+ sig { params(context: Context).void }
44
+ def new_context(context = {}); end
45
+ end
46
+
47
+ class Railtie < Rails::Railtie
48
+ end
49
+
50
+ module Record
51
+ abstract!
52
+
53
+ extend T::Sig
54
+ extend T::Helpers
55
+
56
+ sig { abstract.returns(T::Hash[T.any(String, Symbol), T.untyped]) }
57
+ def new; end
58
+
59
+ sig { abstract.returns(T::Hash[T.any(String, Symbol), T.untyped]) }
60
+ def old; end
61
+
62
+ sig { returns(T::Hash[T.any(String, Symbol), [T.untyped, T.untyped]]) }
63
+ def diff; end
64
+ end
65
+
66
+ class Watcher < Wal::StreamingWatcher
67
+ extend T::Sig
68
+
69
+ sig { params(event: T.any(InsertEvent, UpdateEvent, DeleteEvent)).void }
70
+ def audit_event(event); end
71
+
72
+ sig { override.params(events: T::Enumerator[Event]).void }
73
+ def on_transaction_events(events); end
74
+
75
+ sig { params(table: String).returns(T::Boolean) }
76
+ def should_watch_table?(table); end
77
+
78
+ sig { params(prefix: String).returns(T::Boolean) }
79
+ def valid_context_prefix?(prefix); end
80
+
81
+ sig { params(table: String).returns(T::Array[String]) }
82
+ def ignored_columns(table); end
83
+
84
+ sig { returns(Integer) }
85
+ def max_transaction_size; end
86
+
87
+ sig { returns(T.class_of(ActiveRecord::Base)) }
88
+ def record; end
89
+ end
90
+ end
data/sig/waldit.rbs ADDED
@@ -0,0 +1,56 @@
1
+ # typed: strong
2
+ module Waldit
3
+ extend Waldit::Context
4
+
5
+ def self.watched_tables=: () -> String
6
+ | () -> ^(String table) -> bool
7
+ | () -> ^(String table) -> ::Array[String]
8
+ | () -> Integer
9
+ | () -> singleton(ActiveRecord::Base)
10
+ | (::Array[String] | ^(String table) -> bool tables) -> void
11
+
12
+ def self.configure: () { (singleton(Waldit) config) -> void } -> void
13
+ end
14
+
15
+ Waldit::VERSION: untyped
16
+
17
+ module Waldit::Context
18
+ def with_context: [U] (Context context) { () -> U } -> U
19
+
20
+ def context: () -> Context?
21
+
22
+ def add_context: (Context added_context) -> void
23
+
24
+ def new_context: (?Context context) -> void
25
+ end
26
+
27
+ Waldit::Waldit::Context::Context: untyped
28
+
29
+ class Waldit::Railtie < Rails::Railtie
30
+ end
31
+
32
+ module Waldit::Record
33
+ extend T::Helpers
34
+
35
+ def new: () -> ::Hash[String | Symbol, untyped]
36
+
37
+ def old: () -> ::Hash[String | Symbol, untyped]
38
+
39
+ def diff: () -> ::Hash[String | Symbol, [ untyped, untyped ]]
40
+ end
41
+
42
+ class Waldit::Watcher < Wal::StreamingWatcher
43
+ def audit_event: (InsertEvent | UpdateEvent | DeleteEvent event) -> void
44
+
45
+ def on_transaction_events: (::Enumerator[Event] events) -> void
46
+
47
+ def should_watch_table?: (String table) -> bool
48
+
49
+ def valid_context_prefix?: (String prefix) -> bool
50
+
51
+ def ignored_columns: (String table) -> ::Array[String]
52
+
53
+ def max_transaction_size: () -> Integer
54
+
55
+ def record: () -> singleton(ActiveRecord::Base)
56
+ end
metadata ADDED
@@ -0,0 +1,139 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: waldit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Rodrigo Navarro
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2025-06-29 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: wal
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 0.0.2
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 0.0.2
26
+ - !ruby/object:Gem::Dependency
27
+ name: activerecord
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
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
+ description: Postgres based audit trail for your Active Records, with 100% consistency.
97
+ email:
98
+ - rnavarro@rnavarro.com.br
99
+ executables: []
100
+ extensions: []
101
+ extra_rdoc_files: []
102
+ files:
103
+ - ".rspec"
104
+ - LICENSE.txt
105
+ - README.md
106
+ - Rakefile
107
+ - lib/waldit.rb
108
+ - lib/waldit/context.rb
109
+ - lib/waldit/postgresql_adapter.rb
110
+ - lib/waldit/railtie.rb
111
+ - lib/waldit/record.rb
112
+ - lib/waldit/version.rb
113
+ - lib/waldit/watcher.rb
114
+ - rbi/waldit.rbi
115
+ - sig/waldit.rbs
116
+ homepage: https://github.com/reu/waldit
117
+ licenses:
118
+ - MIT
119
+ metadata:
120
+ homepage_uri: https://github.com/reu/waldit
121
+ source_code_uri: https://github.com/reu/waldit
122
+ rdoc_options: []
123
+ require_paths:
124
+ - lib
125
+ required_ruby_version: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - ">="
128
+ - !ruby/object:Gem::Version
129
+ version: 3.2.0
130
+ required_rubygems_version: !ruby/object:Gem::Requirement
131
+ requirements:
132
+ - - ">="
133
+ - !ruby/object:Gem::Version
134
+ version: '0'
135
+ requirements: []
136
+ rubygems_version: 3.6.2
137
+ specification_version: 4
138
+ summary: Postgres based audit trail for Rails.
139
+ test_files: []