waldit 0.0.25 → 0.0.26

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: ba555412a6c079daefae27957b707136b94b086ec4fb351c1243e7efb14470d0
4
- data.tar.gz: 189649ec353443111a932cb10fbe6e3beb59efee8550453bb282da148a1d8858
3
+ metadata.gz: 036035f234a35b9fd2c2c352a361a8826b54d0a0c6a86d93946c0b192523ea9d
4
+ data.tar.gz: d655ad860384e3e1c2dc5a8a6b3ac84f320c6210f14fcb53510cb016b1bf6e0d
5
5
  SHA512:
6
- metadata.gz: 2839fd6125953177e69c733438fb042a40364b236712f6e2a8d8c0c2490b4917d82be47d265422de4a19cad27c4ce58189fc922d0d28288a476775e85e119a20
7
- data.tar.gz: b49559cc7f60ab7b889fa3e183039214494f40dadc1ca50e00c006e8f62704d7b8ca709719ded76bf6c948835a9705617c7b673988ce97edd1e39fa332206bef
6
+ metadata.gz: c4dac68e52cb6ba0a72809f09fcffdc6425e6b154fe91d1b5c5f94e4bf3ea349fccf0ea43327b69cd2b94d968b832136c8fea9bfe5dc1a04b21ba50434e8195a
7
+ data.tar.gz: a10904c3cd2df88460cd83be6604edc6c4bbeb8a66e1e44a47966e40eb00c4222a1de3d1720055847a516ef0513c14109bd879fbd35909244db145f02f5e828e
data/README.md CHANGED
@@ -1,117 +1,231 @@
1
1
  # Waldit
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/waldit.svg)](https://badge.fury.io/rb/waldit)
3
+ Waldit is a Postgres-based audit trail for Rails.
4
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.
5
+ It hooks into [Postgres logical replication](https://www.postgresql.org/docs/current/logical-replication.html) via the [`wal`](https://github.com/reu/wal) gem to capture every `insert`, `update`, and `delete` directly from the WAL. Unlike ActiveRecord callbacks, these events are guaranteed by Postgres to be 100% consistent -- even changes that bypass Rails entirely are captured.
6
6
 
7
- ## Features
7
+ ## Getting started
8
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.
9
+ ### Installation
13
10
 
14
- ## Installation
15
-
16
- Add this line to your application's Gemfile:
11
+ Add `waldit` to your application's Gemfile:
17
12
 
18
13
  ```ruby
19
14
  gem "waldit"
20
15
  ```
21
16
 
22
- And then execute:
17
+ ### Database adapter
18
+
19
+ Waldit ships a custom database adapter that injects audit context into your transactions. Update your `config/database.yml`:
20
+
21
+ ```yaml
22
+ default: &default
23
+ adapter: waldit
24
+ # ... rest of your config
25
+ ```
26
+
27
+ ### Migrations
28
+
29
+ Waldit provides migration helpers. First, create the audit table and publication:
30
+
31
+ ```ruby
32
+ class SetupWaldit < ActiveRecord::Migration[7.0]
33
+ def change
34
+ create_waldit_table
35
+ create_waldit_publication
36
+ end
37
+ end
38
+ ```
39
+
40
+ Then, for each table you want to audit:
41
+
42
+ ```ruby
43
+ class AuditUsers < ActiveRecord::Migration[7.0]
44
+ def change
45
+ add_table_to_waldit :users
46
+ end
47
+ end
48
+ ```
49
+
50
+ This sets `REPLICA IDENTITY FULL` on the table and adds it to the Waldit publication.
23
51
 
24
- $ bundle
52
+ ### Running the watcher
25
53
 
26
- Or install it yourself as:
54
+ Create a `config/waldit.yml`:
27
55
 
28
- $ gem install waldit
56
+ ```yaml
57
+ slots:
58
+ audit:
59
+ publications: [waldit_publication]
60
+ watcher: Waldit::Watcher
61
+ ```
29
62
 
30
- ## Usage
63
+ Then start the process:
31
64
 
32
- 1. **Configure your database adapter:**
65
+ ```bash
66
+ bundle exec wal start config/waldit.yml
67
+ ```
33
68
 
34
- First step is to configure in your `config/database.yml` and change your adapter to `waldit`, which is a special adapter that allows injecting `waldit` contextual information on your transactions:
69
+ That's it. Every change to your audited tables is now being recorded.
35
70
 
36
- ```yaml
37
- default: &default
38
- adapter: waldit
39
- # ...
40
- ```
71
+ ## Adding context
41
72
 
42
- 2. **Create an audit table:**
73
+ Wrap your operations with `Waldit.with_context` to record who made the change and why:
43
74
 
44
- Generate a migration to create the `waldit` table:
75
+ ```ruby
76
+ Waldit.with_context(user_id: current_user.id, reason: "Profile update") do
77
+ user.update(name: "New Name")
78
+ end
79
+ ```
45
80
 
46
- ```bash
47
- rails generate migration create_waldit
48
- ```
81
+ Context can be nested and updated mid-transaction:
49
82
 
50
- And then add the following to your migration file:
83
+ ```ruby
84
+ Waldit.with_context(user_id: current_user.id) do
85
+ user.update(name: "New Name")
51
86
 
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
87
+ Waldit.with_context(via: "admin_panel") do
88
+ account.update(plan: "premium") # context: { user_id: 1, via: "admin_panel" }
89
+ end
65
90
 
66
- t.index [:table_name, :primary_key, :transaction_id], unique: true
67
- end
68
- end
69
- end
70
- ```
91
+ Waldit.add_context(batch: true)
92
+ other_user.update(name: "Other") # context: { user_id: 1, batch: true }
93
+ end
94
+ ```
71
95
 
72
- 3. **Configure Waldit:**
96
+ ### Sidekiq integration
73
97
 
74
- Create an initializer file at `config/initializers/waldit.rb`:
98
+ Waldit can propagate context into background jobs:
75
99
 
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" }
100
+ ```ruby
101
+ # config/initializers/sidekiq.rb
102
+ Sidekiq.configure_client do |config|
103
+ config.client_middleware do |chain|
104
+ chain.add Waldit::Sidekiq::SaveContext
105
+ end
106
+ end
107
+
108
+ Sidekiq.configure_server do |config|
109
+ config.server_middleware do |chain|
110
+ chain.add Waldit::Sidekiq::LoadContext
111
+ end
112
+ end
113
+ ```
80
114
 
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
- ```
115
+ ## Querying the audit trail
85
116
 
86
- 4. **Add context to your changes:**
117
+ Waldit provides scopes on the audit model:
87
118
 
88
- Use the `with_context` method to add context to your database operations:
119
+ ```ruby
120
+ # All audit records for a specific record
121
+ Waldit.model.for(user)
89
122
 
90
- ```ruby
91
- Waldit.with_context(user_id: 1, reason: "User updated their profile") do
92
- user.update(name: "New Name")
93
- end
94
- ```
123
+ # All audit records for a table
124
+ Waldit.model.from_model(User)
95
125
 
96
- 5. **Start the watcher:**
126
+ # All audit records with a specific context
127
+ Waldit.model.with_context(user_id: 1)
128
+ ```
129
+
130
+ Each audit record exposes:
131
+
132
+ ```ruby
133
+ audit = Waldit.model.for(user).last
134
+
135
+ audit.action # "insert", "update", or "delete"
136
+ audit.old # previous attributes (updates and deletes)
137
+ audit.new # new attributes (inserts and updates)
138
+ audit.diff # changed attributes as { "name" => ["old", "new"] }
139
+ audit.context # the context hash
140
+ audit.committed_at # when the transaction was committed
141
+ audit.primary_key # the record's primary key
142
+ ```
143
+
144
+ The `old`, `new`, and `diff` accessors are smart -- if you only store `:diff`, calling `.old` or `.new` will compute the values from the diff, and vice versa.
145
+
146
+ ## Configuration
147
+
148
+ ```ruby
149
+ # config/initializers/waldit.rb
150
+ Waldit.configure do |config|
151
+ # Which tables to watch (default: all except "waldit")
152
+ config.watched_tables = -> table { table != "waldit" }
153
+
154
+ # Columns to exclude from audit records (default: created_at, updated_at)
155
+ config.ignored_columns = -> table { %w[created_at updated_at] }
156
+
157
+ # What to store per table (default: [:old, :new])
158
+ # Options: :old, :new, :diff (any combination)
159
+ config.store_changes = [:old, :new]
160
+
161
+ # WAL byte threshold for switching to streaming mode (default: 10MB)
162
+ # Transactions smaller than this are processed in memory for better performance
163
+ config.large_transaction_threshold = 10_000_000
164
+ end
165
+ ```
166
+
167
+ ### Storage policies
168
+
169
+ By default, Waldit stores both `old` and `new` attributes for every change. You can reduce storage by only keeping what you need:
170
+
171
+ ```ruby
172
+ # Only store diffs for updates (most compact)
173
+ config.store_changes = :diff
174
+
175
+ # Per-table policies
176
+ config.store_changes = -> table {
177
+ case table
178
+ when "events" then [:new]
179
+ when "logs" then [:diff]
180
+ else [:old, :new]
181
+ end
182
+ }
183
+ ```
184
+
185
+ ### Per-table ignored columns
186
+
187
+ ```ruby
188
+ config.ignored_columns = -> table {
189
+ case table
190
+ when "users" then %w[created_at updated_at last_sign_in_at]
191
+ else %w[created_at updated_at]
192
+ end
193
+ }
194
+ ```
195
+
196
+ ### Custom audit model
197
+
198
+ You can provide your own model class if you need custom methods or a different table name:
199
+
200
+ ```ruby
201
+ class AuditRecord < ApplicationRecord
202
+ include Waldit::Record
203
+ self.table_name = "waldit"
204
+ end
205
+
206
+ Waldit.configure do |config|
207
+ config.model = AuditRecord
208
+ end
209
+ ```
97
210
 
98
- To process the events, you need to start a WAL watcher. The recommended way is to have a config/waldit.yml
211
+ ## How it works
99
212
 
100
- ```yml
101
- slots:
102
- audit:
103
- publications: [waldit_publication]
104
- watcher: Waldit::Watcher
105
- ```
213
+ Waldit uses Postgres logical replication to stream changes from the WAL (Write-Ahead Log). The flow is:
106
214
 
107
- And then run:
215
+ 1. The custom database adapter sets a `waldit_context` session variable before each write operation
216
+ 2. Postgres captures the change and the context in the WAL
217
+ 3. `Waldit::Watcher` receives the events via a replication slot
218
+ 4. Events are deduplicated per-transaction (multiple updates to the same record produce a single audit entry)
219
+ 5. The final audit records are persisted to the `waldit` table
108
220
 
109
- ```bash
110
- bundle exec wal start config/waldit.yml
111
- ```
221
+ For small transactions, events are accumulated in memory and persisted in a single batch insert. For large transactions (configurable via `large_transaction_threshold`), events are streamed and persisted individually to avoid memory pressure.
112
222
 
113
- ## How it Works
223
+ ### Transaction-level deduplication
114
224
 
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`.
225
+ Within a single database transaction, Waldit collapses events intelligently:
116
226
 
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.
227
+ - **Insert then update** -- recorded as a single `insert` with the final state
228
+ - **Multiple updates** -- recorded as a single `update` with the original `old` and final `new`
229
+ - **Insert then delete** -- not recorded (the record never existed outside the transaction)
230
+ - **Update then delete** -- recorded as a `delete` with the original `old` values
231
+ - **Update that reverts to original** -- not recorded (no net change)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Waldit
4
- VERSION = "0.0.25"
4
+ VERSION = "0.0.26"
5
5
  end
@@ -6,46 +6,204 @@ module Waldit
6
6
  class Watcher < Wal::StreamingWatcher
7
7
  include Wal
8
8
 
9
- def audit_event(event)
10
- return unless event.primary_key
11
- primary_key = event.primary_key.to_json
9
+ def initialize(*)
10
+ super
11
+ initialize_connection
12
+ @retry = false
13
+ end
12
14
 
13
- audit = [event.transaction_id, event.lsn, event.table, primary_key, event.context.to_json]
15
+ def on_transaction_events(events)
16
+ begin_event = events.next
17
+ if begin_event.estimated_size < Waldit.large_transaction_threshold
18
+ process_in_memory(events)
19
+ else
20
+ process_streaming(events)
21
+ end
22
+ rescue PG::ConnectionBad
23
+ raise if @retry
24
+ initialize_connection
25
+ @retry = true
26
+ retry
27
+ end
14
28
 
15
- case event
16
- when InsertEvent
17
- new_attributes = clean_attributes(event.table, event.new)
18
- @connection.exec_prepared("waldit_insert", audit + [new_attributes.to_json])
19
- true
29
+ def should_watch_table?(table)
30
+ Waldit.watched_tables.call(table)
31
+ end
20
32
 
21
- when UpdateEvent
22
- return if event.diff.without(ignored_columns(event.table)).empty?
23
- old_attributes = clean_attributes(event.table, event.old)
24
- new_attributes = clean_attributes(event.table, event.new)
33
+ def valid_context_prefix?(prefix)
34
+ prefix == Waldit.context_prefix
35
+ end
25
36
 
26
- @connection.exec_prepared("waldit_update", audit + [old_attributes.to_json, new_attributes.to_json])
27
- true
37
+ def ignored_columns(table)
38
+ (@ignored_columns_cache ||= {})[table] ||= Waldit.ignored_columns.call(table)
39
+ end
28
40
 
29
- when DeleteEvent
30
- case @connection.exec_prepared("waldit_delete_cleanup", [event.transaction_id, event.table, primary_key]).values
31
- in [["update", previous_old]]
32
- @connection.exec_prepared("waldit_delete", audit + [previous_old])
33
- in []
34
- @connection.exec_prepared("waldit_delete", audit + [clean_attributes(event.table, event.old).to_json])
35
- else
36
- # Don't need to audit anything on this case
41
+ def store_changes(table)
42
+ (@store_changes_cache ||= {})[table] ||= Waldit.store_changes.call(table)
43
+ end
44
+
45
+ def clean_attributes(table, attributes)
46
+ attributes.without(ignored_columns(table))
47
+ end
48
+
49
+ def record
50
+ Waldit.model
51
+ end
52
+
53
+ private
54
+
55
+ COLUMNS = %w[
56
+ transaction_id
57
+ lsn
58
+ table_name
59
+ primary_key
60
+ action
61
+ context
62
+ committed_at
63
+ old
64
+ new
65
+ diff
66
+ ].freeze
67
+
68
+ PARAMS_PER_ROW = COLUMNS.size
69
+ MAX_ROWS_PER_BATCH = 65535 / PARAMS_PER_ROW
70
+
71
+ def process_in_memory(events)
72
+ records = {}
73
+
74
+ events.each do |event|
75
+ case event
76
+ when InsertEvent
77
+ next unless event.primary_key
78
+ key = [event.full_table_name, event.primary_key.to_json]
79
+ records[key] = event
80
+
81
+ when UpdateEvent
82
+ next unless event.primary_key
83
+ next if event.diff.without(ignored_columns(event.table)).empty?
84
+ key = [event.full_table_name, event.primary_key.to_json]
85
+ records[key] = case (existing_event = records[key])
86
+ when InsertEvent
87
+ # A record inserted on this transaction is being updated, which means it should still reflect as a insert
88
+ # event, we just change the information to reflect the most current data that was just updated.
89
+ existing_event.with(new: event.new)
90
+ when UpdateEvent
91
+ # We are updating again a event that was already updated on this transaction.
92
+ # Same as the insert, we keep the old data from the previous update and the new data from the new one.
93
+ existing_event.with(new: event.new)
94
+ else
95
+ event
96
+ end
97
+
98
+ when DeleteEvent
99
+ next unless event.primary_key
100
+ key = [event.full_table_name, event.primary_key.to_json]
101
+ records[key] = case (existing_event = records[key])
102
+ when InsertEvent
103
+ # We are removing a record that was inserted on this transaction, we should not even report this change, as
104
+ # this record never existed outside this transaction anyways.
105
+ nil
106
+ when UpdateEvent
107
+ # Deleting a record that was previously updated by this transaction. Just store the previous data while
108
+ # keeping the record as deleted.
109
+ event.with(old: existing_event.old)
110
+ else
111
+ event
112
+ end
113
+
114
+ when CommitTransactionEvent
115
+ rows = records.compact.values.filter_map do |evt|
116
+ table = evt.full_table_name
117
+ store = store_changes(table)
118
+
119
+ rec = {
120
+ committed_at: event.timestamp,
121
+ transaction_id: event.transaction_id,
122
+ lsn: evt.lsn,
123
+ table_name: table,
124
+ primary_key: evt.primary_key.to_json,
125
+ context: evt.context,
126
+ }
127
+
128
+ case evt
129
+ when InsertEvent
130
+ { **rec, action: "insert", new: clean_attributes(table, evt.new) }
131
+ when UpdateEvent
132
+ rec = {
133
+ **rec,
134
+ action: "update",
135
+ old: evt.old&.then { |attrs| clean_attributes(table, attrs) } || {},
136
+ new: evt.new&.then { |attrs| clean_attributes(table, attrs) } || {},
137
+ }
138
+ next if rec[:old] == rec[:new]
139
+ rec[:old] = nil unless store.include? :old
140
+ rec[:new] = nil unless store.include? :new
141
+ rec[:diff] = clean_attributes(table, evt.diff) if store.include? :diff
142
+ rec
143
+ when DeleteEvent
144
+ { **rec, action: "delete", old: clean_attributes(table, evt.old) }
145
+ end
146
+ end
147
+
148
+ unless rows.empty?
149
+ if rows.size <= MAX_ROWS_PER_BATCH
150
+ insert_batch(rows)
151
+ else
152
+ @connection.transaction do
153
+ rows.each_slice(MAX_ROWS_PER_BATCH) { |batch| insert_batch(batch) }
154
+ end
155
+ end
156
+ end
157
+
158
+ @retry = false
37
159
  end
38
- true
39
160
  end
40
161
  end
41
162
 
42
- def initialize(*)
43
- super
44
- initialize_connection
45
- @retry = false
163
+ def insert_batch(batch)
164
+ rows = batch.each_with_index.map do |_, i|
165
+ o = i * PARAMS_PER_ROW
166
+ row = [
167
+ "$#{o + 1}",
168
+ "$#{o + 2}",
169
+ "$#{o + 3}",
170
+ "$#{o + 4}",
171
+ "$#{o + 5}::waldit_action",
172
+ "$#{o + 6}::jsonb",
173
+ "$#{o + 7}",
174
+ "$#{o + 8}::jsonb",
175
+ "$#{o + 9}::jsonb",
176
+ "$#{o + 10}::jsonb",
177
+ ].join(",")
178
+ "(#{row})"
179
+ end
180
+
181
+ params = batch.flat_map do |r|
182
+ [
183
+ r[:transaction_id],
184
+ r[:lsn],
185
+ r[:table_name],
186
+ r[:primary_key],
187
+ r[:action],
188
+ r[:context]&.to_json,
189
+ r[:committed_at],
190
+ r[:old]&.to_json,
191
+ r[:new]&.to_json,
192
+ r[:diff]&.to_json,
193
+ ]
194
+ end
195
+
196
+ @connection.exec_params(<<~SQL, params)
197
+ INSERT INTO #{record.table_name} (#{COLUMNS.join(",")})
198
+ VALUES #{rows.join(",")}
199
+ ON CONFLICT (table_name, primary_key, transaction_id)
200
+ DO NOTHING
201
+ SQL
46
202
  end
47
203
 
48
- def on_transaction_events(events)
204
+ def process_streaming(events)
205
+ ensure_streaming_statements_prepared
206
+
49
207
  @connection.transaction do
50
208
  tables = Set.new
51
209
 
@@ -54,7 +212,7 @@ module Waldit
54
212
  when CommitTransactionEvent
55
213
  unless tables.empty?
56
214
  changes = [:old, :new, :diff]
57
- .map { |diff| [diff, tables.filter { |table| Waldit.store_changes.call(table).include? diff }] }
215
+ .map { |diff| [diff, tables.filter { |table| store_changes(table).include? diff }] }
58
216
  .to_h
59
217
 
60
218
  log_new = (changes[:new] || []).map { |table| "#{table}" }
@@ -76,7 +234,6 @@ module Waldit
76
234
  ])
77
235
  end
78
236
 
79
- # We sucessful retried a connection, let's reset our retry state
80
237
  @retry = false
81
238
 
82
239
  when InsertEvent
@@ -90,44 +247,57 @@ module Waldit
90
247
  end
91
248
  end
92
249
  end
93
- rescue PG::ConnectionBad
94
- raise if @retry
95
- # Let's try to fetch a new connection and reprocess the transaction
96
- initialize_connection
97
- @retry = true
98
- retry
99
250
  end
100
251
 
101
- def should_watch_table?(table)
102
- Waldit.watched_tables.call(table)
103
- end
252
+ def audit_event(event)
253
+ return unless event.primary_key
254
+ primary_key = event.primary_key.to_json
255
+ table = event.full_table_name
104
256
 
105
- def valid_context_prefix?(prefix)
106
- prefix == Waldit.context_prefix
107
- end
257
+ audit = [event.transaction_id, event.lsn, table, primary_key, event.context.to_json]
108
258
 
109
- def ignored_columns(table)
110
- Waldit.ignored_columns.call(table)
111
- end
259
+ case event
260
+ when InsertEvent
261
+ new_attributes = clean_attributes(table, event.new)
262
+ @connection.exec_prepared("waldit_insert", audit + [new_attributes.to_json])
263
+ true
112
264
 
113
- def clean_attributes(table, attributes)
114
- attributes.without(ignored_columns(table))
115
- end
265
+ when UpdateEvent
266
+ return if event.diff.without(ignored_columns(table)).empty?
267
+ old_attributes = clean_attributes(table, event.old)
268
+ new_attributes = clean_attributes(table, event.new)
116
269
 
117
- def record
118
- Waldit.model
119
- end
270
+ @connection.exec_prepared("waldit_update", audit + [old_attributes.to_json, new_attributes.to_json])
271
+ true
120
272
 
121
- private
273
+ when DeleteEvent
274
+ case @connection.exec_prepared("waldit_delete_cleanup", [event.transaction_id, table, primary_key]).values
275
+ in [["update", previous_old]]
276
+ @connection.exec_prepared("waldit_delete", audit + [previous_old])
277
+ in []
278
+ @connection.exec_prepared("waldit_delete", audit + [clean_attributes(table, event.old).to_json])
279
+ else
280
+ # Don't need to audit anything on this case
281
+ end
282
+ true
283
+ end
284
+ end
122
285
 
123
286
  def initialize_connection
124
287
  @connection = record.connection_pool.checkout.raw_connection
288
+ @streaming_statements_prepared = false
289
+ end
290
+
291
+ def ensure_streaming_statements_prepared
292
+ return if @streaming_statements_prepared
293
+
125
294
  prepare_insert
126
295
  prepare_update
127
296
  prepare_delete
128
297
  prepare_delete_cleanup
129
298
  prepare_finish
130
299
  prepare_cleanup
300
+ @streaming_statements_prepared = true
131
301
  end
132
302
 
133
303
  def prepare_insert
data/lib/waldit.rb CHANGED
@@ -39,6 +39,7 @@ module Waldit
39
39
  attr_accessor :ignored_columns
40
40
  attr_accessor :model
41
41
  attr_accessor :context_prefix
42
+ attr_accessor :large_transaction_threshold
42
43
  end
43
44
 
44
45
  def self.configure(&block)
@@ -54,6 +55,8 @@ module Waldit
54
55
 
55
56
  config.ignored_columns = -> table { %w[created_at updated_at] }
56
57
 
58
+ config.large_transaction_threshold = 10_000_000
59
+
57
60
  config.model = Class.new(ActiveRecord::Base) do
58
61
  include Waldit::Record
59
62
  self.table_name = "waldit"
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.25"
5
+ VERSION = "0.0.26"
6
6
 
7
7
  class << self
8
8
  sig { returns(String) }
@@ -19,6 +19,9 @@ module Waldit
19
19
 
20
20
  sig { returns(T.class_of(ActiveRecord::Base)) }
21
21
  attr_accessor :model
22
+
23
+ sig { returns(Integer) }
24
+ attr_accessor :large_transaction_threshold
22
25
  end
23
26
 
24
27
  sig { params(tables: T.any(T::Array[String], T.proc.params(table: String).returns(T::Boolean))).void }
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.25
4
+ version: 0.0.26
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rodrigo Navarro
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-02-12 00:00:00.000000000 Z
10
+ date: 2026-02-13 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: wal