active_record-undo 0.1.4 β†’ 0.2.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.
data/README.md CHANGED
@@ -3,61 +3,129 @@
3
3
  [![Gem Version](https://img.shields.io/gem/v/active_record-undo.svg?color=blue)](https://rubygems.org/gems/active_record-undo)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
5
 
6
- **ActiveRecord::Undo** brings transactional, cascade-aware soft deletes and automatic restores to Rails applications.
6
+ **ActiveRecord::Undo** brings transactional, cascade-aware soft deletes, instant single-line restores, and a mountable HTTP route engine to Ruby on Rails applications.
7
7
 
8
- Unlike conventional soft-deletion gems, `active_record-undo` automatically captures an audit snapshot of all records affected across associations (e.g., dependent `has_many` or `has_one` relations) and tracks them in a dedicated polymorphic undo log. Restoring a deleted record cleanly restores its entire deleted child tree in a single atomic database transaction.
8
+ Unlike traditional soft-deletion gems that merely flip a timestamp on a single record, `active_record-undo` creates a polymorphic audit log capturing every affected child record across `dependent: :destroy` and `dependent: :delete_all` associations. Restoring a deleted record cleanly recovers the entire deleted subtree in a single atomic database transactionβ€”with out-of-the-box support for multi-tenancy, user attribution, background purging, and zero-boilerplate HTTP endpoints.
9
+
10
+ ---
11
+
12
+ ## Table of Contents
13
+
14
+ - [Features](#features)
15
+ - [Installation \& Migrations](#installation--migrations)
16
+ - [Database Migrations](#database-migrations)
17
+ - [Model Setup](#model-setup)
18
+ - [1. Add Timestamp Column to Tables](#1-add-timestamp-column-to-tables)
19
+ - [2. Declare `acts_as_undoable`](#2-declare-acts_as_undoable)
20
+ - [Basic Usage](#basic-usage)
21
+ - [Cascading Soft Deletes](#cascading-soft-deletes)
22
+ - [Restoring Records](#restoring-records)
23
+ - [Option A: Restore from the model (Recommended)](#option-a-restore-from-the-model-recommended)
24
+ - [Option B: Restore from the `UndoLog`](#option-b-restore-from-the-undolog)
25
+ - [Status \& Eligibility Helpers](#status--eligibility-helpers)
26
+ - [Query Scopes](#query-scopes)
27
+ - [Inspecting Deletion Logs](#inspecting-deletion-logs)
28
+ - [Mountable Route \& Controller Engine](#mountable-route--controller-engine)
29
+ - [Mounting the Engine](#mounting-the-engine)
30
+ - [Available Endpoints](#available-endpoints)
31
+ - [View Helpers (`undo_button_to` \& `undo_link_to`)](#view-helpers-undo_button_to--undo_link_to)
32
+ - [Cryptographic Signed Restore Tokens](#cryptographic-signed-restore-tokens)
33
+ - [Generating \& Using Signed Tokens](#generating--using-signed-tokens)
34
+ - [Key Security Properties](#key-security-properties)
35
+ - [Multi-Format Responses](#multi-format-responses)
36
+ - [Security \& Open-Redirect Protection](#security--open-redirect-protection)
37
+ - [Multi-Tenant Isolation \& User Attribution](#multi-tenant-isolation--user-attribution)
38
+ - [User Attribution (`whodunnit`)](#user-attribution-whodunnit)
39
+ - [Multi-Tenant Scoping](#multi-tenant-scoping)
40
+ - [Tenant Matching Security](#tenant-matching-security)
41
+ - [Configured Context Enforcement](#configured-context-enforcement)
42
+ - [Retention, Expiration \& Purging](#retention-expiration--purging)
43
+ - [Expiration Checks](#expiration-checks)
44
+ - [Purger Service](#purger-service)
45
+ - [ActiveJob Background Worker](#activejob-background-worker)
46
+ - [Rake Task](#rake-task)
47
+ - [Configuration Reference](#configuration-reference)
48
+ - [How It Works](#how-it-works)
49
+ - [Error Reference](#error-reference)
50
+ - [Development](#development)
51
+ - [Contributing](#contributing)
52
+ - [License](#license)
9
53
 
10
54
  ---
11
55
 
12
56
  ## Features
13
57
 
14
- - πŸ”„ **Cascading Soft Deletes:** Soft deletes parent models along with dependent associations (`dependent: :destroy` / `:delete_all`).
15
- - βͺ **Atomic Restores:** Reverses soft deletion for an entire object tree (`undo_log.restore!` or `record.restore!`) within a single database transaction.
16
- - πŸ” **Restoration Verification:** Provides `#undoable?` to check if a record is soft-deleted and has a valid undo log entry available for restoration.
17
- - βš™οΈ **Configurable Columns:** Supports custom soft-delete columns (e.g., `:archived_at`, `:discarded_at`) per model while defaulting to `:deleted_at`.
18
- - πŸ“¦ **Polymorphic Tracking:** Records deletion events via native `UndoLog` and `UndoLogItem` modelsβ€”no messy JSON payload parsing required.
19
- - πŸš‚ **Zero Generator Setup:** Built on top of `Rails::Engine`. Migrations automatically hook into `rails db:migrate`.
20
- - πŸ” **Default Scopes & Helpers:** Provides `.kept`, `.soft_deleted`, `#soft_deleted?`, and `#undoable?` query methods out of the box.
21
- - 🧹 **Automatic Expiration & Purging:** Configurable global retention window with automated background purging (`PurgeJob` and Rake task) to clean up old soft-deleted records and logs.
58
+ - πŸ”„ **Cascading Soft Deletes:** Soft deletes parent records along with dependent associations (`dependent: :destroy` / `:delete_all`).
59
+ - βͺ **Atomic Restores:** Reverses soft deletion for an entire object tree (`record.restore!` or `undo_log.restore!`) within a single database transaction.
60
+ - 🌐 **Mountable Engine & Endpoints:** Out-of-the-box controller actions for instant undo links with HTML flash redirects, Turbo Streams, and JSON responses.
61
+ - πŸ”’ **Cryptographic Signed Links:** Direct undo restoration tokens (`undo_log.signed_token`) preventing ID enumeration on public or email links.
62
+ - 🎨 **Clean View Helpers:** Drop-in `undo_button_to` and `undo_link_to` view helpers for seamless UI integration.
63
+ - πŸ” **Restoration Verification (`#undoable?`):** Instantly check if a soft-deleted record is eligible for restore before rendering UI buttons.
64
+ - πŸ‘€ **User Attribution (`whodunnit`):** Tracks who initiated soft deletes and restores automatically via ambient context or explicit parameters.
65
+ - 🏒 **Multi-Tenant Isolation:** Scopes deletion logs to specific tenants with strict tenant-matching security.
66
+ - βš™οΈ **Custom Soft-Delete Columns:** Supports custom columns (e.g., `:archived_at`, `:discarded_at`) per model while defaulting to `:deleted_at`.
67
+ - 🧹 **Retention & Auto-Purging:** Built-in expiration scopes, batch SQL purger, ActiveJob worker, and Rake task with foreign-key constraint protection.
68
+ - πŸš‚ **Zero Generator Setup:** Built as a `Rails::Engine`. Migrations automatically hook into `rails db:migrate`.
22
69
 
23
70
  ---
24
71
 
25
- ## Installation
72
+ ## Installation & Migrations
26
73
 
27
- Add this line to your application's `Gemfile`:
74
+ Add the gem to your application's `Gemfile`:
28
75
 
29
76
  ```ruby
30
77
  gem "active_record-undo"
31
78
  ```
32
79
 
33
- Then execute:
80
+ Then install dependencies:
34
81
 
35
82
  ```bash
36
- $ bundle install
83
+ bundle install
37
84
  ```
38
85
 
39
- Run database migrations. The gem automatically appends its tables (`undo_logs` and `undo_log_items`) to your app's migration path:
86
+ ### Database Migrations
87
+
88
+ `ActiveRecord::Undo` automatically appends its migrations (`undo_logs` and `undo_log_items`) to your application's migration path:
40
89
 
41
90
  ```bash
42
- $ rails db:migrate
91
+ rails db:migrate
43
92
  ```
44
93
 
45
- *(Optional)* If you need to customize the migration, copy it to your host application's `db/migrate` folder:
94
+ *(Optional)* If you plan to use **Multi-Tenant Isolation** or **User Attribution**, add the polymorphic columns to `undo_logs`:
95
+
96
+ ```ruby
97
+ # db/migrate/XXXXXX_add_tenant_and_whodunnit_to_undo_logs.rb
98
+ class AddTenantAndWhodunnitToUndoLogs < ActiveRecord::Migration[7.0]
99
+ def change
100
+ change_table :undo_logs, bulk: true do |t|
101
+ t.string :whodunnit_type, null: true
102
+ t.bigint :whodunnit_id, null: true
103
+ t.string :tenant_type, null: true
104
+ t.bigint :tenant_id, null: true
105
+ end
106
+
107
+ add_index :undo_logs, [:whodunnit_type, :whodunnit_id], name: "index_undo_logs_on_whodunnit"
108
+ add_index :undo_logs, [:tenant_type, :tenant_id], name: "index_undo_logs_on_tenant"
109
+ end
110
+ end
111
+ ```
112
+
113
+ *(Optional)* If you wish to customize the gem's core migration directly:
46
114
 
47
115
  ```bash
48
- $ rails active_record_undo:install:migrations
116
+ rails active_record_undo:install:migrations
49
117
  ```
50
118
 
51
119
  ---
52
120
 
53
- ## Usage
121
+ ## Model Setup
54
122
 
55
- ### 1. Database Setup
123
+ ### 1. Add Timestamp Column to Tables
56
124
 
57
- Ensure models using soft deletion have a timestamp column in their underlying database table:
125
+ Ensure every model using soft deletion has a timestamp column:
58
126
 
59
127
  ```ruby
60
- class AddSoftDeleteColumnsToModels < ActiveRecord::Migration[7.0]
128
+ class AddDeletedAtToModels < ActiveRecord::Migration[7.0]
61
129
  def change
62
130
  add_column :posts, :deleted_at, :datetime
63
131
  add_column :comments, :deleted_at, :datetime
@@ -70,244 +138,447 @@ class AddSoftDeleteColumnsToModels < ActiveRecord::Migration[7.0]
70
138
  end
71
139
  ```
72
140
 
73
- ### 2. Configure Models
141
+ ### 2. Declare `acts_as_undoable`
74
142
 
75
- Add `acts_as_undoable` to models where you want soft deletion enabled. By default, it uses `:deleted_at`, but you can pass a custom column name:
143
+ Add `acts_as_undoable` to your models. By default, it uses the `:deleted_at` column:
76
144
 
77
145
  ```ruby
78
146
  class Post < ApplicationRecord
79
- # Uses default :deleted_at column
80
147
  acts_as_undoable
81
148
 
82
149
  has_many :comments, dependent: :destroy
83
150
  end
84
151
 
85
152
  class Comment < ApplicationRecord
86
- # Uses default :deleted_at column
87
153
  acts_as_undoable
88
154
 
89
155
  belongs_to :post
90
156
  end
91
157
 
92
158
  class ArchiveItem < ApplicationRecord
93
- # Configured with a custom column
159
+ # Configured with a custom timestamp column
94
160
  acts_as_undoable column: :archived_at
95
161
  end
96
162
  ```
97
163
 
98
164
  ---
99
165
 
100
- ## Quick Start Guide
166
+ ## Basic Usage
101
167
 
102
- ### Soft Delete & Cascade
168
+ ### Cascading Soft Deletes
103
169
 
104
- Call `soft_delete!` on a record. It sets the configured soft-delete column across the record and its dependent relations inside a single transaction, returning an `ActiveRecord::Undo::UndoLog` instance:
170
+ Call `soft_delete!` on any undoable record. It updates the timestamp column across the record and all dependent associations inside a single database transaction, returning an `ActiveRecord::Undo::UndoLog` instance:
105
171
 
106
172
  ```ruby
107
173
  post = Post.find(1)
108
174
 
109
- # Soft deletes post and all associated comments
175
+ # Soft deletes post and cascades to all associated comments
110
176
  undo_log = post.soft_delete!
111
177
 
112
- post.soft_deleted? # => true
178
+ post.soft_deleted? # => true
113
179
  post.comments.kept.count # => 0
114
180
  ```
115
181
 
116
- ### Custom Column Usage
182
+ ### Restoring Records
183
+
184
+ You can restore an entire deleted tree either from the model instance or from the `UndoLog`:
185
+
186
+ #### Option A: Restore from the model (Recommended)
187
+
188
+ Calling `restore!` directly on the model automatically looks up its latest deletion log, executes the atomic restoration, destroys the log records, and reloads the model instance:
189
+
190
+ ```ruby
191
+ post.restore!
192
+
193
+ post.soft_deleted? # => false
194
+ post.comments.count # => 2
195
+ ```
196
+
197
+ #### Option B: Restore from the `UndoLog`
117
198
 
118
199
  ```ruby
119
- item = ArchiveItem.find(5)
120
- item.soft_delete!
200
+ undo_log.restore!
121
201
 
122
- item.soft_deleted? # => true
123
- item.archived_at # => 2026-08-08 22:20:16 UTC
202
+ post.reload.soft_deleted? # => false
124
203
  ```
125
204
 
126
- ### Checking Restoration Eligibility (`#undoable?`)
205
+ ### Status & Eligibility Helpers
127
206
 
128
- Use `#undoable?` to verify if a record is soft-deleted and has a corresponding `UndoLog` entry available in the database. This is ideal for conditionally rendering UI elements or validating controller actions:
207
+ `ActiveRecord::Undo` provides fast predicate methods and audit helpers on models:
129
208
 
130
209
  ```ruby
131
- post = Post.unscoped.find(1)
210
+ # Checks if the soft-delete column is set
211
+ post.soft_deleted? # => true / false
132
212
 
133
- if post.undoable?
134
- # Render "Undo Deletion" button or execute restore
135
- post.restore!
136
- end
213
+ # Verifies record is soft-deleted AND has a valid undo log available in the database
214
+ post.undoable? # => true / false
215
+
216
+ # Checks if the soft-deleted record has exceeded the configured retention period
217
+ post.expired? # => true / false
218
+
219
+ # Retrieves the latest UndoLog audit record associated with this model
220
+ post.undo_log # => #<ActiveRecord::Undo::UndoLog id: 14, ...>
221
+ post.latest_undo_log # alias
222
+
223
+ # Generates a cryptographic signed restoration token directly from the model
224
+ post.signed_token # => "eyJfcmFpbHMiOnsiZGF0YSI6..."
137
225
  ```
138
226
 
139
- `#undoable?` returns `false` if:
140
- * The record is currently active (not soft deleted).
141
- * The record was soft deleted manually via direct SQL/column updates without generating an undo log.
142
- * The corresponding `UndoLog` record was purged or already restored.
227
+ > [!TIP]
228
+ > Use `#undoable?` to conditionally display restore buttons in your user interface, passing the model instance directly:
229
+ >
230
+ > ```erb
231
+ > <% if post.undoable? %>
232
+ > <%= undo_button_to(post, text: "Restore") %>
233
+ > <% end %>
234
+ > ```
143
235
 
144
- ### Inspect Deletion Logs
236
+ ### Query Scopes
145
237
 
146
- Inspect affected records through standard Rails associations on the returned `UndoLog`:
238
+ Filter records easily without writing manual SQL:
239
+
240
+ ```ruby
241
+ # Only active (non-deleted) records
242
+ Post.kept
243
+
244
+ # Only soft-deleted records
245
+ Post.soft_deleted
246
+
247
+ # Soft-deleted records past the retention limit
248
+ Post.expired
249
+
250
+ # Retrieve all records including soft-deleted ones
251
+ Post.unscoped.all
252
+ ```
253
+
254
+ ### Inspecting Deletion Logs
255
+
256
+ Inspect affected records through standard Rails associations on `UndoLog`:
147
257
 
148
258
  ```ruby
149
- # List all items affected by this deletion event
150
259
  undo_log.undo_log_items.map(&:item)
151
- # => [#<Comment 101... id:>, #<Comment 102... id:>, #<Post 1... id:>]
260
+ # => [#<Comment id: 101>, #<Comment id: 102>, #<Post id: 1>]
152
261
  ```
153
262
 
154
- ### Restoring Records
263
+ ---
155
264
 
156
- To restore a deleted object tree, you can invoke `restore!` either on the corresponding `UndoLog` or directly on the model instance itself:
265
+ ## Mountable Route & Controller Engine
157
266
 
158
- #### Option A: Restore from the model instance (Recommended)
159
- Calling `restore!` directly on the soft-deleted model automatically resolves its latest deletion event log, performs the cascading restore, and cleans up the log database rows:
267
+ `active_record-undo` provides a mountable Rails engine route and controller endpoints so host applications can handle undo/restore actions via HTTP requests without writing boilerplate controller logic.
160
268
 
161
- ```ruby
162
- # Restores the post and all comments deleted in the same batch (automatically reloads in-memory)
163
- post.restore!
269
+ ### Mounting the Engine
164
270
 
165
- post.soft_deleted? # => false
166
- post.comments.count # => 2
167
- ```
271
+ Mount the engine in your application routes:
168
272
 
169
- #### Option B: Restore from the `UndoLog`
170
273
  ```ruby
171
- # Reverses soft deletes for the post and comments
172
- undo_log.restore!
274
+ # config/routes.rb
275
+ Rails.application.routes.draw do
276
+ mount ActiveRecord::Undo::Engine => "/undo"
277
+ end
278
+ ```
173
279
 
174
- post.reload.soft_deleted? # => false
175
- post.comments.count # => 2
280
+ ### Available Endpoints
281
+
282
+ | Method | Route | Controller#Action | Description |
283
+ | :----- | :----------------------- | :----------------------------------- | :----------------------------------------- |
284
+ | `POST` | `/undo/logs/:id/restore` | `active_record/undo/logs#restore` | Restores an `UndoLog` by ID |
285
+ | `POST` | `/undo/restore/:token` | `active_record/undo/restores#create` | Restores an `UndoLog` using a signed token |
286
+
287
+ ### View Helpers (`undo_button_to` & `undo_link_to`)
288
+
289
+ Clean view helpers are automatically available in all Rails views and forms:
290
+
291
+ ```erb
292
+ <%# Standard button_to targeting /undo/logs/:id/restore %>
293
+ <%= undo_button_to(@undo_log) %>
294
+
295
+ <%# Pass model instances directly %>
296
+ <%= undo_button_to(post, text: "Undo Delete", class: "btn btn-primary") %>
297
+
298
+ <%# Secure direct link with signed token (prevents ID enumeration in emails or public links) %>
299
+ <%= undo_button_to(@undo_log, signed: true, class: "btn btn-success") %>
300
+
301
+ <%# Turbo Stream / Hotwire compatible link %>
302
+ <%= undo_link_to(@undo_log, text: "Undo", class: "text-decoration-underline") %>
303
+ <%= undo_link_to(@undo_log, signed: true) %>
176
304
  ```
177
305
 
178
- ---
306
+ ### Cryptographic Signed Restore Tokens
307
+
308
+ When exposing restore actions in flash notifications, transactional emails, webhook alerts, or public interfaces, relying on sequential database IDs (e.g., `POST /undo/logs/42/restore`) can expose your application to ID enumeration attacks.
309
+
310
+ `active_record-undo` provides signed tokens for restoration via tamper-proof, opaque URLs: `POST /undo/restore/:token`.
311
+
312
+ #### Generating & Using Signed Tokens
179
313
 
180
- ## Scopes & Querying
314
+ **In Views (using helpers):**
181
315
 
182
- `ActiveRecord::Undo` provides scopes and predicate helpers for filtering and checking records based on the configured column:
316
+ ```erb
317
+ <%# Renders a form POST to /undo/restore/:token %>
318
+ <%= undo_button_to(post, signed: true, text: "Undo Delete", class: "btn btn-outline-primary") %>
319
+
320
+ <%# Renders an anchor tag for Turbo / Hotwire %>
321
+ <%= undo_link_to(post, signed: true, text: "Undo") %>
322
+ ```
323
+
324
+ **In Controllers, Background Jobs & Mailers:**
183
325
 
184
326
  ```ruby
185
- # Fetch only active (non-deleted) records
186
- Post.kept
327
+ # Generate token directly from the model instance
328
+ token = post.signed_token
187
329
 
188
- # Fetch soft-deleted records
189
- Post.soft_deleted
330
+ # Or with a custom expiration window
331
+ token = post.signed_token(expires_in: 2.hours)
332
+
333
+ # Or directly from an UndoLog instance
334
+ token = undo_log.signed_token
190
335
 
191
- # Check if a record is soft-deleted
192
- post.soft_deleted?
336
+ # Construct full URL for transactional emails or Slack webhooks
337
+ restore_url = active_record_undo.signed_restore_url(token: token)
338
+ ```
193
339
 
194
- # Check if a record is soft-deleted AND can be restored via an undo log
195
- post.undoable?
340
+ **Manual Verification & Retrieval:**
196
341
 
197
- # Retrieve records including soft-deleted ones via unscoped
198
- Post.unscoped.where(id: 1)
342
+ ```ruby
343
+ # Manually verify and retrieve the associated UndoLog
344
+ undo_log = ActiveRecord::Undo::UndoLog.find_by_signed_token(params[:token])
199
345
  ```
200
346
 
347
+ #### Key Security Properties
348
+
349
+ 1. **HMAC-SHA256 Cryptographic Tamper Resistance:** Tokens are signed using `Rails.application.message_verifier(:active_record_undo)` derived securely from your application's `secret_key_base`. Any payload alteration invalidates the cryptographic signature.
350
+ 2. **Purpose Isolation (`purpose: :restore`):** Tokens are strictly scoped to restoration. Tokens generated for other purposes (or by other verifiers) are rejected.
351
+ 3. **Time-Limited Lifespan:** Tokens include an embedded expiration timestamp (configurable via `config.token_expires_in`, defaults to `24.hours`). Expired tokens are rejected automatically.
352
+ 4. **Single-Use Replay Protection:** When `undo_log.restore!` succeeds, it automatically destroys the `UndoLog` and its associated items from the database (`destroy!`). Even if an attacker or user resubmits the signed token before its cryptographic expiration, the database lookup fails and returns `nil`, rendering a `404 Not Found`.
353
+ 5. **Multi-Tenant Authorization:** Cryptographic validity only proves the token was authentic. During execution, `undo_log.restore!` still enforces multi-tenant boundary matching against `ActiveRecord::Undo.current_tenant`. An authenticated user from Tenant B cannot use a token from Tenant A to restore data.
354
+
355
+ ### Multi-Format Responses
356
+
357
+ The engine controller seamlessly handles multiple response formats:
358
+
359
+ - **HTML:** Redirects to `params[:redirect_to]` (if a validated safe URL), `request.referer`, or `config.default_redirect_path` with a flash notice (`flash[:notice] = "Record successfully restored."`).
360
+ - **Turbo Stream (`text/vnd.turbo-stream.html`):** Renders inline `<turbo-stream>` notification elements with HTTP status `200 OK`.
361
+ - **JSON:** Returns `{ "success": true, "restored_items_count": count }` with HTTP status `200 OK`.
362
+
363
+ In error scenarios (missing record, expired action, or security mismatch):
364
+
365
+ - **`403 Forbidden`:** Rendered on `ActiveRecord::Undo::SecurityError` (or HTML redirected with `flash[:alert]`).
366
+ - **`404 Not Found`:** Rendered when the undo log does not exist or has already been restored.
367
+ - **`422 Unprocessable Content`:** Rendered when the undo action has expired beyond the retention period.
368
+
369
+ ### Security & Open-Redirect Protection
370
+
371
+ - **CSRF Protection:** Inherits from `ActionController::Base` (or your configured `base_controller`) with CSRF verification enabled.
372
+ - **Open-Redirect Mitigation:** `params[:redirect_to]` and `request.referer` undergo strict URL validation. Only relative paths or URLs matching the request's exact host and port are accepted; foreign or protocol-relative URLs (e.g., `//evil.com`) are rejected in favor of the safe fallback.
373
+ - **Signed Tokens:** `undo_log.signed_token` uses Rails' `message_verifier(:active_record_undo)` to sign tokens cryptographically, preventing tampering, replay, and ID enumeration.
374
+
201
375
  ---
202
376
 
203
- ## Expiration, Retention & Auto-Purging
377
+ ## Multi-Tenant Isolation & User Attribution
204
378
 
205
- To prevent database bloat, `active_record-undo` supports automated retention periods, expiration checks, and background purging for both soft-deleted records and their associated `UndoLog` audit entries.
379
+ To support enterprise-grade Rails applications, `active_record-undo` natively integrates user auditing and tenant scoping.
206
380
 
207
- ### 1. Global Configuration
381
+ ### User Attribution (`whodunnit`)
208
382
 
209
- You can configure a global retention period inside a Rails initializer:
383
+ Track which user initiated a soft deletion or restoration:
210
384
 
211
385
  ```ruby
212
- # config/initializers/active_record_undo.rb
386
+ # 1. Via explicit parameter
387
+ post.soft_delete!(whodunnit: current_user)
388
+ post.restore!(whodunnit: current_user)
389
+
390
+ # 2. Via Thread / Fiber ambient context
391
+ ActiveRecord::Undo.whodunnit = current_user
392
+ post.soft_delete!
393
+
394
+ # 3. Via global callable proc
213
395
  ActiveRecord::Undo.configure do |config|
214
- # Default retention period for all soft-deleted records and undo logs (defaults to 30.days)
215
- config.retention_period = 30.days
396
+ config.current_user_method = -> { Current.user }
216
397
  end
217
398
  ```
218
399
 
219
- If `retention_period` is set to `nil`, records and logs will never expire.
400
+ ### Multi-Tenant Scoping
220
401
 
221
- ### 2. Expiration Scopes & Helpers
402
+ Scope soft deletes and logs to accounts or organizations:
222
403
 
223
- The gem provides query scopes and instance predicate helpers:
404
+ ```ruby
405
+ # 1. Via explicit parameter
406
+ post.soft_delete!(tenant: current_account)
407
+
408
+ # 2. Via Thread / Fiber ambient context
409
+ ActiveRecord::Undo.current_tenant = current_account
410
+ post.soft_delete!
224
411
 
225
- - **Model `.expired` Scope**: Returns soft-deleted records older than the configured retention period.
226
- ```ruby
227
- Post.expired # => ActiveRecord::Relation of posts soft-deleted > 30 days ago
228
- ```
229
- - **Model `#expired?` Predicate**: Checks if a record is soft-deleted and past the retention period.
230
- ```ruby
231
- post.expired? # => true/false
232
- ```
233
- - **`UndoLog.expired` Scope**: Returns undo log entries older than the retention period.
234
- ```ruby
235
- ActiveRecord::Undo::UndoLog.expired # => logs created > 30 days ago
236
- ```
412
+ # 3. Via global callable proc
413
+ ActiveRecord::Undo.configure do |config|
414
+ config.current_tenant_method = -> { Current.account }
415
+ end
416
+ ```
237
417
 
238
- ### 3. Background Purging
418
+ ### Tenant Matching Security
239
419
 
240
- #### Purger Service
241
- The `ActiveRecord::Undo::Purger` class performs hard SQL deletes on expired records and logs using `delete_all` (bypassing callbacks and validations for efficiency):
420
+ When restoring a log belonging to a tenant, the gem verifies that the executing context's tenant matches the log's tenant. If there is a mismatch or missing tenant context, an `ActiveRecord::Undo::SecurityError` is raised:
242
421
 
243
422
  ```ruby
244
- # Purge all expired soft-deleted records and undo logs in batches
423
+ ActiveRecord::Undo.current_tenant = wrong_account
424
+ post.restore! # => raises ActiveRecord::Undo::SecurityError (Tenant mismatch)
425
+ ```
426
+
427
+ ### Configured Context Enforcement
428
+
429
+ When `current_user_method` or `current_tenant_method` is configured via `ActiveRecord::Undo.configure`, operations ensure the context does not evaluate to `nil`:
430
+
431
+ ```ruby
432
+ # If current_user_method evaluates to nil (e.g., unauthenticated request):
433
+ post.soft_delete! # => raises ActiveRecord::Undo::SecurityError: Configured current_user_method returned nil.
434
+ ```
435
+
436
+ *(If neither method is configured, operations proceed normally without requiring user or tenant context).*
437
+
438
+ ---
439
+
440
+ ## Retention, Expiration & Purging
441
+
442
+ To keep your database lean, `active_record-undo` provides retention management and automated background purging.
443
+
444
+ ### Expiration Checks
445
+
446
+ Query and check expired records using the configured retention window (default: `30.days`):
447
+
448
+ ```ruby
449
+ # Scopes
450
+ Post.expired # Soft-deleted posts older than retention period
451
+ ActiveRecord::Undo::UndoLog.expired # Undo logs older than retention period
452
+
453
+ # Predicates
454
+ post.expired? # => true / false
455
+ undo_log.expired? # => true / false
456
+ ```
457
+
458
+ ### Purger Service
459
+
460
+ `ActiveRecord::Undo::Purger` performs batch SQL deletes bypassing model callbacks for peak efficiency:
461
+
462
+ ```ruby
463
+ # Purge all expired records and logs across registered models
245
464
  ActiveRecord::Undo::Purger.purge_expired!(batch_size: 1000)
465
+
466
+ # Scoped purge for a specific tenant
467
+ ActiveRecord::Undo.current_tenant = account_1
468
+ ActiveRecord::Undo::Purger.purge_expired!
246
469
  ```
247
470
 
248
471
  > [!NOTE]
249
- > **Relational Integrity & Constraint Protection**: To prevent database-level foreign key constraint failures (e.g. `FOREIGN KEY constraint failed`), `Purger` dynamically resolves dependent associations (such as `dependent: :destroy`, `dependent: :delete_all`, or `dependent: :soft_delete`) and recursively purges associated child records bottom-up before deleting the parent record. For associations configured with `dependent: :nullify`, it nullifies the foreign key (or cascades the deletion if the foreign key column is database-restricted to be `NOT NULL`).
472
+ > **Relational Integrity Protection:** To prevent foreign-key constraint violations (`FOREIGN KEY constraint failed`), `Purger` dynamically resolves dependent associations (`:destroy`, `:delete_all`, `:soft_delete`) and recursively cleans up child records bottom-up before purging parent records. For `:nullify` associations, foreign keys are nullified (or cascaded if restricted by a `NOT NULL` constraint).
473
+
474
+ ### ActiveJob Background Worker
250
475
 
251
- #### ActiveJob Background Job
252
- The gem provides an ActiveJob class that calls the Purger service:
476
+ Enqueue purges easily via ActiveJob:
253
477
 
254
478
  ```ruby
255
- # Enqueue the purge job to run in the background
256
479
  ActiveRecord::Undo::PurgeJob.perform_later(batch_size: 1000)
257
480
  ```
258
481
 
259
- #### Engine Rake Task
260
- You can run the purge task via Rake. This task is automatically loaded into host applications:
482
+ ### Rake Task
483
+
484
+ Run purging from cron, cron-like schedulers, or CI:
261
485
 
262
486
  ```bash
263
- # Run with the default batch size of 1000
264
- $ rails active_record_undo:purge_expired
487
+ # Default batch size (1000)
488
+ rails active_record_undo:purge_expired
265
489
 
266
- # Run with a custom batch size
267
- $ BATCH_SIZE=500 rails active_record_undo:purge_expired
490
+ # Custom batch size
491
+ BATCH_SIZE=500 rails active_record_undo:purge_expired
492
+ ```
493
+
494
+ ---
495
+
496
+ ## Configuration Reference
497
+
498
+ Configure all gem options in a single initializer:
499
+
500
+ ```ruby
501
+ # config/initializers/active_record_undo.rb
502
+ ActiveRecord::Undo.configure do |config|
503
+ # Default retention period for soft-deleted records and undo logs (defaults to 30.days)
504
+ # Set to nil to disable expiration
505
+ config.retention_period = 30.days
506
+
507
+ # Callable procs to resolve ambient user and tenant context (defaults to nil)
508
+ config.current_user_method = -> { Current.user }
509
+ config.current_tenant_method = -> { Current.account }
510
+
511
+ # Base controller for engine authentication and authorization hooks
512
+ # Defaults to "::ApplicationController" if defined, falling back to ActionController::Base
513
+ config.base_controller = "::ApplicationController"
514
+
515
+ # Fallback redirect path after successful restore when no redirect_to or referer exists
516
+ config.default_redirect_path = ->(main_app) { main_app.root_path }
517
+
518
+ # Expiration duration for signed restore tokens (defaults to 24.hours)
519
+ config.token_expires_in = 24.hours
520
+
521
+ # Custom secret key for signed tokens (defaults to nil, utilizing Rails application verifier)
522
+ config.token_secret_key = nil
523
+
524
+ # Error handling strategy for HTML requests (:auto, :redirect, or :render)
525
+ # Defaults to :auto (redirects with flash alert if referer/redirect_to present, else renders status)
526
+ config.error_handling = :auto
527
+ end
268
528
  ```
269
529
 
270
530
  ---
271
531
 
272
532
  ## How It Works
273
533
 
274
- 1. **Cascade Inspection:** When `soft_delete!` is called, `ActiveRecord::Undo::CascadeHandler` reflects on `has_many`, `has_one`, and `belongs_to` associations configured with `dependent: :destroy` or `:delete_all`.
275
- 2. **Dynamic Column Resolution:** The handler checks `record.class.undoable_column` to set the correct timestamp column (`:deleted_at`, `:archived_at`, etc.) across all affected models.
276
- 3. **Polymorphic Logging:** An `ActiveRecord::Undo::UndoLog` record is created alongside multiple `ActiveRecord::Undo::UndoLogItem` entries mapping polymorphic references (`item_type`, `item_id`) to every affected record.
277
- 4. **Restoration Verification:** Calling `#undoable?` executes an efficient SQL query joining `undo_log_items` and `undo_logs` to ensure the entity is soft-deleted and its associated log entry exists before restoration.
278
- 5. **Atomic Operation:** All updates and log creations take place within an `ActiveRecord::Base.transaction`.
534
+ 1. **Cascade Inspection:** On calling `soft_delete!`, `ActiveRecord::Undo::CascadeHandler` reflects on `has_many`, `has_one`, and `belongs_to` associations configured with `dependent: :destroy` or `:delete_all`.
535
+ 2. **Column Resolution:** Identifies `record.class.undoable_column` to apply the correct timestamp column (`:deleted_at`, `:archived_at`, etc.) across all affected models.
536
+ 3. **Audit Logging:** Creates an `UndoLog` and individual `UndoLogItem` entries storing polymorphic references (`item_type`, `item_id`) for every soft-deleted record.
537
+ 4. **Atomic Operation:** Deletions and log creations execute within a single `ActiveRecord::Base.transaction`.
538
+ 5. **Reverse Restoration:** Calling `restore!` executes within a transaction, iterating over items in reverse order (`reverse_each`) so parent and dependent records are re-activated in the proper sequence before purging the log.
279
539
 
280
540
  ---
281
541
 
282
- ## Error Handling
542
+ ## Error Reference
283
543
 
284
- To ensure database integrity and provide clear debugging context, the gem raises an `ActiveRecord::Undo::Error` in the following scenarios:
285
- * **Missing Column at Runtime:** If the configured soft-delete column is missing from the database table when calling `soft_delete!` or `restore!`.
286
- * **Missing Model Class:** If a model class has been renamed or deleted, preventing the polymorphic log items from finding the target class during restore.
287
- * **Missing Column on Target Class:** If a model class exists but no longer has the target soft-delete column during restore.
544
+ | Error Class | Trigger Scenario |
545
+ | :---------------------------------- | :---------------------------------------------------------------------------- |
546
+ | `ActiveRecord::Undo::Error` | The configured soft-delete column does not exist on the table. |
547
+ | `ActiveRecord::Undo::Error` | A model class referenced by an `UndoLogItem` was renamed or deleted. |
548
+ | `ActiveRecord::Undo::SecurityError` | Attempted restore where the context's tenant does not match the log's tenant. |
549
+ | `ActiveRecord::Undo::SecurityError` | Attempted restore of a tenant-scoped log when no tenant is set in context. |
550
+ | `ActiveRecord::Undo::SecurityError` | Configured `current_user_method` or `current_tenant_method` returned `nil`. |
288
551
 
289
552
  ---
290
553
 
291
554
  ## Development
292
555
 
293
- After cloning the repository, install dependencies:
556
+ Clone the repository and install dependencies:
294
557
 
295
558
  ```bash
296
- $ bundle install
559
+ git clone https://github.com/saurabh-activecode/active_record-undo.git
560
+ cd active_record-undo
561
+ bundle install
297
562
  ```
298
563
 
299
564
  Run test suite via RSpec:
300
565
 
301
566
  ```bash
302
- $ bundle exec rspec
567
+ bundle exec rspec
568
+ ```
569
+
570
+ Run RuboCop linting:
571
+
572
+ ```bash
573
+ bundle exec rubocop
303
574
  ```
304
575
 
305
576
  ---
306
577
 
307
578
  ## Contributing
308
579
 
309
- Bug reports and pull requests are welcome on GitHub at https://github.com/saurabh-activecode/active_record-undo.
580
+ Bug reports and pull requests are welcome on GitHub at [https://github.com/saurabh-activecode/active_record-undo](https://github.com/saurabh-activecode/active_record-undo).
310
581
 
311
582
  ## License
312
583
 
313
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
584
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).