athar 0.1.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 +7 -0
- data/CHANGELOG.md +29 -0
- data/LICENSE.txt +21 -0
- data/README.md +417 -0
- data/lib/athar/actor_lookup.rb +31 -0
- data/lib/athar/configuration.rb +35 -0
- data/lib/athar/context.rb +121 -0
- data/lib/athar/deletion.rb +61 -0
- data/lib/athar/engine.rb +11 -0
- data/lib/athar/metadata_stack.rb +37 -0
- data/lib/athar/retention.rb +131 -0
- data/lib/athar/retention_job.rb +11 -0
- data/lib/athar/sql.rb +67 -0
- data/lib/athar/table_event.rb +27 -0
- data/lib/athar/version.rb +5 -0
- data/lib/athar.rb +61 -0
- data/lib/generators/athar/fx_helper.rb +60 -0
- data/lib/generators/athar/install/functions/athar_capture_delete.sql +111 -0
- data/lib/generators/athar/install/functions/athar_capture_truncate.sql.erb +51 -0
- data/lib/generators/athar/install/functions/athar_filter_keys.sql +29 -0
- data/lib/generators/athar/install/install_generator.rb +116 -0
- data/lib/generators/athar/install/templates/install_migration.rb.erb +80 -0
- data/lib/generators/athar/install/templates/install_migration_fx.rb.erb +73 -0
- data/lib/generators/athar/model/model_generator.rb +344 -0
- data/lib/generators/athar/model/templates/migration.rb.erb +47 -0
- data/lib/generators/athar/model/templates/migration_fx.rb.erb +29 -0
- data/lib/generators/athar/model/triggers/athar_delete.sql.erb +14 -0
- data/lib/generators/athar/model/triggers/athar_truncate.sql.erb +8 -0
- metadata +144 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6507cef01b889867badd523eec802ee9cf23fe868011de38e31dd82a48e056ab
|
|
4
|
+
data.tar.gz: b0dae015b5ddd83cc7865e0a94419c26c881256dadca6a3797b4b90b7b028610
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2c5cd3e8404cabdb04c852fe7ad134e905a2db5485dfe0c4e531ab90b3afaf03420ec82d826eaf9b0625fa6ac6b83bd881fb6a3295c6fdfee0727592cbd59b72
|
|
7
|
+
data.tar.gz: 11d4162da0257d8abb1a397c9e70080a39ddf6aaa85251d9493cfb6d989ceb25b8bed05ee3b5d9689a8c78ed2686556c7f3b7e7e9aa2c8d20b57ee0a6c704027
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-05-03
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Initial release of Athar, a Rails gem for PostgreSQL trigger-based deletion auditing without soft delete.
|
|
14
|
+
- `athar:install` generator for shared `athar_deletions` and `athar_table_events` audit tables.
|
|
15
|
+
- `athar:model` generator for installing, updating, and removing per-table delete triggers.
|
|
16
|
+
- Fx-backed migrations by default, with raw SQL migration support through `--no-fx`.
|
|
17
|
+
- Identity-only, selected-column (`--only`), and full-row (`--snapshot`) delete capture modes.
|
|
18
|
+
- STI-aware deleted-record type capture through `--record-type-column`.
|
|
19
|
+
- Schema-qualified table support, including automatic schema inference from model table names.
|
|
20
|
+
- Optional `--track-truncate` support for statement-level `TRUNCATE` events.
|
|
21
|
+
- Runtime context APIs: `Athar.with_actor`, `Athar.with_metadata`, `Athar.with_context`, and `Athar.without_capture`.
|
|
22
|
+
- `Athar::Deletion` and `Athar::TableEvent` read models for querying audit records.
|
|
23
|
+
- Actor lookup helpers for Active Record actors stored in `actor_type` and `actor_id`.
|
|
24
|
+
- Retention configuration, `Athar::Retention`, and `Athar::RetentionJob` for age and count pruning.
|
|
25
|
+
- Support for bigint and UUID audit schemas, with documented behavior for mixed-id applications.
|
|
26
|
+
- Local Docker Compose PostgreSQL setup for development and tests.
|
|
27
|
+
- Mise tasks for running tests, Docker helpers, and local benchmarks.
|
|
28
|
+
- SQL-level, Rails bulk-delete, and Rails single-record benchmark scripts with recorded baseline results.
|
|
29
|
+
- CI coverage across supported Rails versions, Ruby versions, Fx/no-Fx modes, and PostgreSQL 13/18.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MilkStraw AI
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="docs/logo.png" alt="Athar logo" width="160">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">Athar</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://badge.fury.io/rb/athar"><img src="https://badge.fury.io/rb/athar.svg" alt="Gem Version"></a>
|
|
9
|
+
<a href="https://github.com/milkstrawai/athar/actions"><img src="https://github.com/milkstrawai/athar/actions/workflows/ci.yml/badge.svg" alt="Build Status"></a>
|
|
10
|
+
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
|
11
|
+
<a href="https://www.ruby-lang.org"><img src="https://img.shields.io/badge/ruby-%3E%3D%203.2-blue" alt="Ruby >= 3.2"></a>
|
|
12
|
+
<a href="https://rubyonrails.org"><img src="https://img.shields.io/badge/rails-%3E%3D%207.2-red" alt="Rails >= 7.2"></a>
|
|
13
|
+
<a href="https://www.postgresql.org"><img src="https://img.shields.io/badge/postgresql-%3E%3D%2013-336791" alt="PostgreSQL >= 13"></a>
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
<p align="center">
|
|
17
|
+
<strong>Database-level deletion auditing for Rails applications without soft delete.</strong>
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
Athar (Arabic: أَثَر, "trace"; pronounced **A-thar**) records physical database deletions in Rails applications. Instead of leaving deleted rows in their original tables behind a `deleted_at` filter, Athar lets the row be removed and writes a separate audit row to `athar_deletions` using PostgreSQL triggers.
|
|
21
|
+
|
|
22
|
+
Athar answers one question:
|
|
23
|
+
|
|
24
|
+
> What was deleted, when was it deleted, and who or what caused the deletion?
|
|
25
|
+
|
|
26
|
+
It does not turn deleted rows into queryable models, and it does not provide full record version history.
|
|
27
|
+
|
|
28
|
+
## Table of Contents
|
|
29
|
+
|
|
30
|
+
- [The Problem](#the-problem)
|
|
31
|
+
- [The Solution](#the-solution)
|
|
32
|
+
- [Requirements](#requirements)
|
|
33
|
+
- [Installation](#installation)
|
|
34
|
+
- [Quick Start](#quick-start)
|
|
35
|
+
- [Usage](#usage)
|
|
36
|
+
- [Configuration](#configuration)
|
|
37
|
+
- [How It Works](#how-it-works)
|
|
38
|
+
- [Operational Notes](#operational-notes)
|
|
39
|
+
- [Troubleshooting](#troubleshooting)
|
|
40
|
+
- [Development](#development)
|
|
41
|
+
- [Contributing](#contributing)
|
|
42
|
+
- [License](#license)
|
|
43
|
+
|
|
44
|
+
## The Problem
|
|
45
|
+
|
|
46
|
+
Soft delete keeps deleted rows in the original table. That usually means `default_scope` filters, relaxed unique indexes, conditional foreign-key behavior, and query bugs when deleted rows accidentally leak into normal application reads.
|
|
47
|
+
|
|
48
|
+
It also changes what "delete" means. The application says a row is gone, but the database still has it. Over time, teams end up designing around the soft-delete column instead of using the database's normal integrity model.
|
|
49
|
+
|
|
50
|
+
## The Solution
|
|
51
|
+
|
|
52
|
+
Athar keeps deletion semantics simple: rows are physically deleted from their original tables, and PostgreSQL triggers write audit records into separate tables.
|
|
53
|
+
|
|
54
|
+
This gives you:
|
|
55
|
+
|
|
56
|
+
- Normal database deletes and constraints.
|
|
57
|
+
- Audit rows outside the original table.
|
|
58
|
+
- Capture for deletes from Active Record, raw SQL, cascades, and bulk deletes.
|
|
59
|
+
- A narrow audit model focused on deletions, not full version history.
|
|
60
|
+
|
|
61
|
+
## Requirements
|
|
62
|
+
|
|
63
|
+
- Ruby 3.2+
|
|
64
|
+
- Rails 7.2+
|
|
65
|
+
- PostgreSQL 13+
|
|
66
|
+
|
|
67
|
+
Athar supports apps configured with bigint or UUID primary keys. In apps with mixed primary key types, Athar supports tracking tables whose primary key type matches the shared audit table ID type. Tables using a different primary key type are not supported by the default audit schema.
|
|
68
|
+
|
|
69
|
+
## Installation
|
|
70
|
+
|
|
71
|
+
Add Athar to your Gemfile:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
gem "athar"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Install and run the generator:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
bundle install
|
|
81
|
+
bin/rails generate athar:install
|
|
82
|
+
bin/rails db:migrate
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
This creates the shared audit tables (`athar_deletions`, `athar_table_events`) and installs Athar's PostgreSQL functions.
|
|
86
|
+
|
|
87
|
+
By default, Athar uses the [`fx`][fx] gem so PostgreSQL functions and triggers can round-trip through `db/schema.rb`. If you cannot or do not want to use Fx, pass `--no-fx`; that path requires:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
# config/application.rb
|
|
91
|
+
config.active_record.schema_format = :sql
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The generators raise a clear error if you pass `--no-fx` while the host app still uses `schema.rb`. They never edit `config/application.rb` for you.
|
|
95
|
+
|
|
96
|
+
## Quick Start
|
|
97
|
+
|
|
98
|
+
Install capture for a model:
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
bin/rails generate athar:model User --only=email,name,account_id
|
|
102
|
+
bin/rails db:migrate
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Delete a record:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
Athar.with_actor(current_user) do
|
|
109
|
+
User.find(user_id).destroy!
|
|
110
|
+
end
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Query the audit row:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
deletion = Athar::Deletion.for_record(User, user_id).last
|
|
117
|
+
deletion.record_type # => "User"
|
|
118
|
+
deletion.record_id # => user_id
|
|
119
|
+
deletion.actor # => current_user
|
|
120
|
+
deletion.record_data # => { "email" => "...", "name" => "...", "account_id" => ... }
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The `users` row is gone. The audit row remains in `athar_deletions`.
|
|
124
|
+
|
|
125
|
+
## Usage
|
|
126
|
+
|
|
127
|
+
### Capture Per Model
|
|
128
|
+
|
|
129
|
+
Triggers are installed per table:
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
bin/rails generate athar:model User --only=email,name,account_id
|
|
133
|
+
bin/rails db:migrate
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Then deletes via any path are captured:
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
User.find(user_id).destroy! # captured
|
|
140
|
+
User.where(spam: true).delete_all # captured, one audit row per deleted row
|
|
141
|
+
ActiveRecord::Base.connection.execute(...) # captured
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The model file does not change. Capture policy is owned by the trigger. To change an existing trigger, run `generate athar:model` with `--update` and the new options.
|
|
145
|
+
|
|
146
|
+
### Capture Modes
|
|
147
|
+
|
|
148
|
+
| Mode | CLI | What is stored in `record_data` |
|
|
149
|
+
| ---------------- | ----------------------------------------- | -------------------------------- |
|
|
150
|
+
| Identity-only | `bin/rails g athar:model User` | `{}` (default) |
|
|
151
|
+
| Selected columns | `bin/rails g athar:model User --only=...` | Only listed columns |
|
|
152
|
+
| Full snapshot | `bin/rails g athar:model User --snapshot` | All row attributes including `id` |
|
|
153
|
+
|
|
154
|
+
Identity-only is the default and is recommended for high-churn tables. Prefer `--only` for PII-sensitive records over `--snapshot`. `--except` is not supported because it is too easy to accidentally retain new sensitive columns.
|
|
155
|
+
|
|
156
|
+
### Generator Options
|
|
157
|
+
|
|
158
|
+
- `--primary-key=id`
|
|
159
|
+
- `--record-type=User` (override stored `record_type` for non-STI models)
|
|
160
|
+
- `--record-type-column=type` (STI inheritance column; pass `false` to disable)
|
|
161
|
+
- `--schema=reporting` (override schema; inferred from schema-qualified model table names, otherwise `public`)
|
|
162
|
+
- `--track-truncate` (also install a statement-level `AFTER TRUNCATE` trigger)
|
|
163
|
+
- `--update` (drop and recreate the trigger with new arguments)
|
|
164
|
+
- `--remove` (drop the trigger)
|
|
165
|
+
- `--fx` / `--no-fx` (force Fx-backed or raw-SQL migrations; default is Fx when available)
|
|
166
|
+
|
|
167
|
+
> [!WARNING]
|
|
168
|
+
> `--update` and `--remove` migrations are intentionally irreversible by default. Athar cannot reconstruct the previous trigger arguments after the fact. Keep your old migrations as the source of truth for the previous state.
|
|
169
|
+
|
|
170
|
+
### Actor And Metadata
|
|
171
|
+
|
|
172
|
+
Wrap delete code to attach actor and request/job context:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
Athar.with_actor(current_user) do
|
|
176
|
+
user.destroy!
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
Athar.with_metadata(ip: request.remote_ip, request_id: request.request_id) do
|
|
180
|
+
user.destroy!
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
Athar.with_context(actor: current_user, reason: "GDPR request") do
|
|
184
|
+
user.destroy!
|
|
185
|
+
end
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
These methods write JSON into a transaction-scoped PostgreSQL setting that the trigger reads.
|
|
189
|
+
|
|
190
|
+
`Athar.with_actor` only accepts an `ActiveRecord::Base` instance. Symbolic actors must go in metadata:
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
Athar.with_metadata(actor: "cron", reason: "retention cleanup") do
|
|
194
|
+
User.where(inactive: true).delete_all
|
|
195
|
+
end
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
For STI actors, Athar stores the actor's base class in `actor_type` for stable lookup. Deleted records still store the concrete STI class in `record_type`.
|
|
199
|
+
|
|
200
|
+
If raw `athar.meta` JSON contains an `actor_id` value that cannot be cast to the configured id type (for example, `"cron"` in a bigint app), the trigger raises and the delete fails. Athar prefers a loud error over silently saving an invalid audit row.
|
|
201
|
+
|
|
202
|
+
### Disabling Capture
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
Athar.without_capture do
|
|
206
|
+
Session.where("expires_at < ?", 1.month.ago).delete_all
|
|
207
|
+
end
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Internally this issues `SET LOCAL athar.disabled TO 'on'` for the current transaction. The trigger's `WHEN` clause skips the function body while disabled.
|
|
211
|
+
|
|
212
|
+
### Querying The Audit Log
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
Athar::Deletion.for_record(User, user_id)
|
|
216
|
+
Athar::Deletion.for_record_type("Admin")
|
|
217
|
+
Athar::Deletion.for_table("users")
|
|
218
|
+
Athar::Deletion.by_actor(current_user)
|
|
219
|
+
Athar::Deletion.recent
|
|
220
|
+
Athar::Deletion.before(1.week.ago)
|
|
221
|
+
Athar::Deletion.after(Date.today)
|
|
222
|
+
|
|
223
|
+
deletion = Athar::Deletion.last
|
|
224
|
+
deletion.record_data
|
|
225
|
+
deletion.metadata
|
|
226
|
+
deletion.actor
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Athar does not define `belongs_to :record`. The deleted row is gone, so the audit row is the source of truth.
|
|
230
|
+
|
|
231
|
+
### TRUNCATE Events
|
|
232
|
+
|
|
233
|
+
`TRUNCATE` does not fire row-level `DELETE` triggers. If you need to know when a table was truncated, opt in:
|
|
234
|
+
|
|
235
|
+
```sh
|
|
236
|
+
bin/rails generate athar:model User --track-truncate
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
This installs a statement-level `AFTER TRUNCATE` trigger that writes one row to `athar_table_events`. The truncated row contents are not preserved because PostgreSQL does not expose them to statement-level triggers.
|
|
240
|
+
|
|
241
|
+
## Configuration
|
|
242
|
+
|
|
243
|
+
Athar can be configured from an initializer:
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
Athar.configure do |config|
|
|
247
|
+
config.retention.max_age = 1.year
|
|
248
|
+
config.retention.max_count = 1_000_000
|
|
249
|
+
config.retention.batch_size = 1_000
|
|
250
|
+
config.retention.max_batches_per_run = 100
|
|
251
|
+
config.retention.queue_name = :athar
|
|
252
|
+
end
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Then schedule the retention job with your job scheduler:
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
Athar::RetentionJob.perform_later
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
The job runs `Athar::Retention.prune!`, which:
|
|
262
|
+
|
|
263
|
+
1. Deletes audit rows older than `max_age` in batches.
|
|
264
|
+
2. Deletes rows beyond `max_count` (oldest first) in batches, after age pruning.
|
|
265
|
+
3. Stops at `max_batches_per_run` so a first cleanup on a huge audit table cannot monopolize a worker. The next scheduled run continues.
|
|
266
|
+
|
|
267
|
+
By default, age pruning also covers `athar_table_events`. Disable with `config.retention.prune_table_events = false`.
|
|
268
|
+
|
|
269
|
+
## How It Works
|
|
270
|
+
|
|
271
|
+
### Schema Dump Strategy
|
|
272
|
+
|
|
273
|
+
Athar relies on PostgreSQL functions and triggers. By default it uses [`fx`][fx] as a runtime dependency so your host app can keep using `db/schema.rb`:
|
|
274
|
+
|
|
275
|
+
- Functions land in `db/functions/<name>_v01.sql`.
|
|
276
|
+
- Triggers land in `db/triggers/<name>_v01.sql`.
|
|
277
|
+
- Migrations call `create_function`, `create_trigger`, `update_function`, `update_trigger`, and `drop_trigger`.
|
|
278
|
+
- Subsequent `--update` runs write `_v02.sql`, `_v03.sql`, etc., and emit `update_function` / `update_trigger` migrations.
|
|
279
|
+
- [`fx`][fx]'s schema dumper preserves them in `schema.rb` so Rails' default `bin/rails db:schema:load` round-trips correctly.
|
|
280
|
+
|
|
281
|
+
With `--no-fx`, Athar writes raw SQL migrations. Raw SQL migrations require `config.active_record.schema_format = :sql`.
|
|
282
|
+
|
|
283
|
+
### Delete Capture Flow
|
|
284
|
+
|
|
285
|
+
Each tracked table gets a `BEFORE DELETE` trigger. When PostgreSQL deletes a row, the trigger:
|
|
286
|
+
|
|
287
|
+
1. Reads the old row.
|
|
288
|
+
2. Computes `record_type` and `record_id`.
|
|
289
|
+
3. Builds `record_data` based on the capture mode.
|
|
290
|
+
4. Reads actor and metadata from `athar.meta`.
|
|
291
|
+
5. Inserts one row into `athar_deletions`.
|
|
292
|
+
|
|
293
|
+
Because the trigger runs in PostgreSQL, Athar captures deletes from Active Record callbacks, `delete_all`, raw SQL, and database cascades as long as the deleted table has an Athar trigger installed.
|
|
294
|
+
|
|
295
|
+
### STI
|
|
296
|
+
|
|
297
|
+
Athar reads the inheritance column at delete time. If the row's `type` is populated (for example `Admin < User`), `record_type` becomes `"Admin"`. The default inheritance column is auto-detected from the model. Override with `--record-type-column=mycolumn` or disable with `--record-type-column=false`.
|
|
298
|
+
|
|
299
|
+
### Prior Art
|
|
300
|
+
|
|
301
|
+
| Tool | Focus | Capture mechanism |
|
|
302
|
+
| ------------------------------ | -------------------------------------------------------------------------------------------------------- | ----------------------- |
|
|
303
|
+
| [Logidze][logidze] | Record versioning; history lives on the original row, so hard-deleted rows are not Athar deletion records | PostgreSQL triggers |
|
|
304
|
+
| [paper_trail][paper_trail] | Record versioning | Active Record callbacks |
|
|
305
|
+
| [discard][discard], [paranoia] | Soft delete | Default scope filters |
|
|
306
|
+
| [pg_audit_log][pg_audit_log] | Trigger-based audit log; no longer maintained for modern Rails | PostgreSQL triggers |
|
|
307
|
+
|
|
308
|
+
Athar focuses narrowly on deletion capture. It borrows the database-trigger/generator approach from tools like Logidze, but stores hard-delete records separately instead of keeping version history on the original row.
|
|
309
|
+
|
|
310
|
+
## Operational Notes
|
|
311
|
+
|
|
312
|
+
### Privacy
|
|
313
|
+
|
|
314
|
+
Athar stores data that the application deleted. That creates obligations.
|
|
315
|
+
|
|
316
|
+
- Default to identity-only capture.
|
|
317
|
+
- Prefer `--only` over `--snapshot`. New sensitive columns added later will not silently leak into the audit log.
|
|
318
|
+
- Treat `record_data` and `metadata` as PII unless you have audited them.
|
|
319
|
+
- Configure retention from day one in production.
|
|
320
|
+
|
|
321
|
+
### Performance
|
|
322
|
+
|
|
323
|
+
Every captured row deletion adds:
|
|
324
|
+
|
|
325
|
+
1. The original delete.
|
|
326
|
+
2. One insert into `athar_deletions`.
|
|
327
|
+
3. JSONB serialization of `record_data`.
|
|
328
|
+
4. Index writes on the audit row.
|
|
329
|
+
|
|
330
|
+
For high-churn tables (sessions, transient tokens, event buffers, job internals), either skip Athar entirely, use identity-only capture, or wrap operational cleanup in `Athar.without_capture`.
|
|
331
|
+
|
|
332
|
+
### Audit-Table Query Patterns
|
|
333
|
+
|
|
334
|
+
Athar creates indexes for common lookup paths:
|
|
335
|
+
|
|
336
|
+
- `(record_type, record_id)` for `Athar::Deletion.for_record(User, id)`
|
|
337
|
+
- `(actor_type, actor_id)` for `Athar::Deletion.by_actor(user)`
|
|
338
|
+
- `(deleted_at, id)` for retention and time-window queries
|
|
339
|
+
- `(table_name, deleted_at)` for table-scoped time-window queries
|
|
340
|
+
- `(schema_name, table_name, record_id)` for schema/table/id lookups
|
|
341
|
+
- `athar_table_events.occurred_at` for table-event retention
|
|
342
|
+
|
|
343
|
+
These indexes are aimed at specific audit lookups, not every possible reporting query. If your application frequently filters by a broad field such as `record_type` alone, `table_name` alone, actor type alone, or keys inside `record_data` / `metadata`, add application-specific indexes based on your real query patterns.
|
|
344
|
+
|
|
345
|
+
For large audit tables, prefer queries that include a selective id or time window, such as `for_record(User, id)` or `for_table("users").after(30.days.ago)`.
|
|
346
|
+
|
|
347
|
+
### Cascading Deletes
|
|
348
|
+
|
|
349
|
+
Athar captures deletes only for tables that have an Athar trigger installed. That includes:
|
|
350
|
+
|
|
351
|
+
- `dependent: :destroy` cascades, where Rails iterates and Athar's trigger fires per row.
|
|
352
|
+
- `dependent: :delete_all` cascades.
|
|
353
|
+
- Database `ON DELETE CASCADE` cascades, where PostgreSQL fires the child triggers.
|
|
354
|
+
|
|
355
|
+
If a child table has no Athar trigger, deletes on it are not captured even when the parent does.
|
|
356
|
+
|
|
357
|
+
### Benchmarks
|
|
358
|
+
|
|
359
|
+
There are local benchmark tasks for measuring SQL-level and Rails-level delete overhead:
|
|
360
|
+
|
|
361
|
+
```sh
|
|
362
|
+
mise run bench:delete_capture
|
|
363
|
+
mise run bench:rails_bulk
|
|
364
|
+
mise run bench:rails_single
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
The tasks start the local `postgres:18` Docker Compose service when needed, create the throwaway benchmark database, run the benchmark, and stop only the service they started.
|
|
368
|
+
|
|
369
|
+
Results are machine-dependent. The scripts are intentionally not part of CI; they exist so maintainers can spot large regressions. See [`bench/RESULTS.md`](bench/RESULTS.md) for the last measured baseline.
|
|
370
|
+
|
|
371
|
+
## Troubleshooting
|
|
372
|
+
|
|
373
|
+
### "Function `athar_capture_delete` does not exist"
|
|
374
|
+
|
|
375
|
+
Run the install generator and migrate:
|
|
376
|
+
|
|
377
|
+
```sh
|
|
378
|
+
bin/rails generate athar:install
|
|
379
|
+
bin/rails db:migrate
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
### "PG::DatatypeMismatch: column `record_id` is of type bigint but expression is of type uuid"
|
|
383
|
+
|
|
384
|
+
You generated a trigger for a table whose primary key type does not match the shared audit table ID type. Track tables with the matching ID type, or customize the audit schema.
|
|
385
|
+
|
|
386
|
+
### "Audit row missing for `delete_all`"
|
|
387
|
+
|
|
388
|
+
Confirm the table has an Athar trigger installed, and confirm the delete was not wrapped in `Athar.without_capture`.
|
|
389
|
+
|
|
390
|
+
## Development
|
|
391
|
+
|
|
392
|
+
The maintained local workflow is mise:
|
|
393
|
+
|
|
394
|
+
```sh
|
|
395
|
+
mise run test
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
The test task installs missing gems for the pinned Ruby, starts PostgreSQL 18 when needed, creates the needed test databases, and runs both the Fx-backed and raw-SQL test suites. The raw commands are still ordinary Bundler/Rake commands if you do not use mise.
|
|
399
|
+
|
|
400
|
+
The dummy app under `test/dummy` is a real Rails app. By default it uses `schema.rb` + Fx; `ATHAR_NO_FX=1` flips it to `structure.sql` and the raw-SQL generator path. Tests use real triggers against a real database in both modes.
|
|
401
|
+
|
|
402
|
+
## Contributing
|
|
403
|
+
|
|
404
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/milkstrawai/athar.
|
|
405
|
+
|
|
406
|
+
When contributing, include tests for behavior changes, keep generated SQL/migration behavior explicit, and update documentation when user-facing behavior changes.
|
|
407
|
+
|
|
408
|
+
## License
|
|
409
|
+
|
|
410
|
+
Athar is available as open source under the terms of the [MIT License](LICENSE.txt).
|
|
411
|
+
|
|
412
|
+
[logidze]: https://github.com/palkan/logidze
|
|
413
|
+
[paper_trail]: https://github.com/paper-trail-gem/paper_trail
|
|
414
|
+
[discard]: https://github.com/jhawthorn/discard
|
|
415
|
+
[paranoia]: https://github.com/rubysherpas/paranoia
|
|
416
|
+
[pg_audit_log]: https://github.com/Casecommons/pg_audit_log
|
|
417
|
+
[fx]: https://github.com/teoljungberg/fx
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Athar
|
|
4
|
+
# Shared actor querying behavior for audit read models. Mixed into
|
|
5
|
+
# Athar::Deletion and Athar::TableEvent.
|
|
6
|
+
module ActorLookup
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
class_methods do
|
|
10
|
+
def by_actor(actor_or_type, id = nil)
|
|
11
|
+
if id.nil? && actor_or_type.is_a?(ActiveRecord::Base)
|
|
12
|
+
where(actor_type: actor_or_type.class.base_class.name, actor_id: actor_or_type.id)
|
|
13
|
+
else
|
|
14
|
+
raise ArgumentError, "id is required when passing an actor class or type" if id.nil?
|
|
15
|
+
|
|
16
|
+
klass_name = actor_or_type.is_a?(Class) ? actor_or_type.base_class.name : actor_or_type.to_s
|
|
17
|
+
where(actor_type: klass_name, actor_id: id)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def actor
|
|
23
|
+
return nil if actor_type.blank? || actor_id.nil?
|
|
24
|
+
|
|
25
|
+
klass = actor_type.safe_constantize
|
|
26
|
+
return nil unless klass
|
|
27
|
+
|
|
28
|
+
klass.find_by(klass.primary_key => actor_id)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Athar
|
|
4
|
+
DELETIONS_TABLE_NAME = "athar_deletions"
|
|
5
|
+
TABLE_EVENTS_TABLE_NAME = "athar_table_events"
|
|
6
|
+
|
|
7
|
+
class Configuration
|
|
8
|
+
attr_accessor :logger
|
|
9
|
+
|
|
10
|
+
attr_reader :retention
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@logger = nil
|
|
14
|
+
@retention = RetentionConfiguration.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class RetentionConfiguration
|
|
18
|
+
attr_accessor :max_age,
|
|
19
|
+
:max_count,
|
|
20
|
+
:batch_size,
|
|
21
|
+
:max_batches_per_run,
|
|
22
|
+
:queue_name,
|
|
23
|
+
:prune_table_events
|
|
24
|
+
|
|
25
|
+
def initialize
|
|
26
|
+
@max_age = nil
|
|
27
|
+
@max_count = nil
|
|
28
|
+
@batch_size = 1_000
|
|
29
|
+
@max_batches_per_run = 100
|
|
30
|
+
@queue_name = :athar
|
|
31
|
+
@prune_table_events = true
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Athar
|
|
4
|
+
module Context
|
|
5
|
+
PG_META_KEY = "athar.meta"
|
|
6
|
+
PG_DISABLED_KEY = "athar.disabled"
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def with_actor(actor, &block)
|
|
10
|
+
raise ArgumentError, "block required" unless block
|
|
11
|
+
|
|
12
|
+
return block.call if actor.nil?
|
|
13
|
+
|
|
14
|
+
unless actor.is_a?(ActiveRecord::Base)
|
|
15
|
+
raise ArgumentError,
|
|
16
|
+
"Athar.with_actor only accepts ActiveRecord instances. " \
|
|
17
|
+
"Use Athar.with_metadata(actor: #{actor.inspect}) for symbolic actors."
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
meta = {
|
|
21
|
+
"actor_type" => actor.class.base_class.name,
|
|
22
|
+
"actor_id" => actor.public_send(actor.class.primary_key)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
with_metadata(meta, &block)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def with_metadata(meta = nil, **kwargs, &block) # rubocop:disable Metrics/MethodLength
|
|
29
|
+
raise ArgumentError, "block required" unless block
|
|
30
|
+
|
|
31
|
+
normalized = normalize_meta(meta, kwargs)
|
|
32
|
+
return block.call if normalized.empty?
|
|
33
|
+
|
|
34
|
+
run_in_transaction do
|
|
35
|
+
previous_merged = MetadataStack.current
|
|
36
|
+
MetadataStack.push(normalized)
|
|
37
|
+
begin
|
|
38
|
+
apply_meta(MetadataStack.current)
|
|
39
|
+
block.call
|
|
40
|
+
ensure
|
|
41
|
+
MetadataStack.pop
|
|
42
|
+
apply_meta(previous_merged)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def with_context(actor: nil, **metadata, &block)
|
|
48
|
+
raise ArgumentError, "block required" unless block
|
|
49
|
+
|
|
50
|
+
return with_actor(actor, &block) if metadata.empty?
|
|
51
|
+
return with_metadata(metadata, &block) if actor.nil?
|
|
52
|
+
|
|
53
|
+
with_actor(actor) { with_metadata(metadata, &block) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def without_capture(&block)
|
|
57
|
+
raise ArgumentError, "block required" unless block
|
|
58
|
+
|
|
59
|
+
run_in_transaction do
|
|
60
|
+
previous = read_disabled_setting
|
|
61
|
+
set_local(PG_DISABLED_KEY, "on")
|
|
62
|
+
begin
|
|
63
|
+
block.call
|
|
64
|
+
ensure
|
|
65
|
+
restore_disabled_setting(previous)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def normalize_meta(meta, kwargs)
|
|
73
|
+
result = {}
|
|
74
|
+
result.merge!(meta.transform_keys(&:to_s)) if meta.is_a?(Hash) && !meta.empty?
|
|
75
|
+
result.merge!(kwargs.transform_keys(&:to_s)) unless kwargs.empty?
|
|
76
|
+
result
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def apply_meta(merged)
|
|
80
|
+
if merged.empty?
|
|
81
|
+
reset_local(PG_META_KEY)
|
|
82
|
+
else
|
|
83
|
+
encoded = ActiveSupport::JSON.encode(merged)
|
|
84
|
+
set_local(PG_META_KEY, encoded)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def read_disabled_setting
|
|
89
|
+
connection.select_value("SELECT current_setting('#{PG_DISABLED_KEY}', true)").to_s
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def restore_disabled_setting(previous)
|
|
93
|
+
if previous.empty?
|
|
94
|
+
reset_local(PG_DISABLED_KEY)
|
|
95
|
+
else
|
|
96
|
+
set_local(PG_DISABLED_KEY, previous)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def run_in_transaction(&block)
|
|
101
|
+
if connection.transaction_open?
|
|
102
|
+
block.call
|
|
103
|
+
else
|
|
104
|
+
connection.transaction(requires_new: false, &block)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def reset_local(key)
|
|
109
|
+
connection.execute("SET LOCAL #{key} TO DEFAULT")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def set_local(key, value)
|
|
113
|
+
connection.execute("SET LOCAL #{key} = #{connection.quote(value)}")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def connection
|
|
117
|
+
ActiveRecord::Base.connection
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|