active_record-undo 0.1.5 β†’ 0.3.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.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -2
  3. data/DOCUMENTATION.md +340 -133
  4. data/README.md +473 -190
  5. data/app/controllers/active_record/undo/application_controller.rb +100 -0
  6. data/app/controllers/active_record/undo/logs_controller/async_handler.rb +50 -0
  7. data/app/controllers/active_record/undo/logs_controller.rb +116 -0
  8. data/app/controllers/active_record/undo/restores_controller.rb +11 -0
  9. data/app/jobs/active_record/undo/restore_job.rb +72 -0
  10. data/app/jobs/active_record/undo/soft_delete_job.rb +75 -0
  11. data/config/routes.rb +13 -0
  12. data/db/migrate/20260920000000_add_status_and_error_message_to_undo_logs.rb +15 -0
  13. data/lib/active_record/undo/cascade_handler.rb +66 -0
  14. data/lib/active_record/undo/configuration.rb +9 -1
  15. data/lib/active_record/undo/engine.rb +14 -1
  16. data/lib/active_record/undo/model_extension/async_methods.rb +68 -0
  17. data/lib/active_record/undo/model_extension/attribution_helper.rb +8 -0
  18. data/lib/active_record/undo/model_extension/tenant_verification.rb +1 -1
  19. data/lib/active_record/undo/model_extension.rb +22 -6
  20. data/lib/active_record/undo/safe_redirect.rb +60 -0
  21. data/lib/active_record/undo/status_predicates.rb +39 -0
  22. data/lib/active_record/undo/undo_log/token_verifier.rb +48 -0
  23. data/lib/active_record/undo/undo_log.rb +77 -2
  24. data/lib/active_record/undo/version.rb +1 -1
  25. data/lib/active_record/undo/view_helpers/status_badge_helper.rb +84 -0
  26. data/lib/active_record/undo/view_helpers.rb +105 -0
  27. data/lib/active_record/undo.rb +37 -1
  28. data/sig/active_record/undo.rbs +50 -1
  29. metadata +29 -1
data/README.md CHANGED
@@ -3,48 +3,114 @@
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
- - πŸ‘€ **User Attribution (`whodunnit`):** Tracks who initiated soft deletions and restores automatically via ambient context (e.g. `Current.user`) or explicit arguments.
18
- - 🏒 **Multi-Tenant Isolation:** Scopes deletion audit logs to specific tenants/accounts and enforces strict tenant matching security on restoration.
19
- - βš™οΈ **Configurable Columns:** Supports custom soft-delete columns (e.g., `:archived_at`, `:discarded_at`) per model while defaulting to `:deleted_at`.
20
- - πŸ“¦ **Polymorphic Tracking:** Records deletion events via native `UndoLog` and `UndoLogItem` modelsβ€”no messy JSON payload parsing required.
21
- - πŸš‚ **Zero Generator Setup:** Built on top of `Rails::Engine`. Migrations automatically hook into `rails db:migrate`.
22
- - πŸ” **Default Scopes & Helpers:** Provides `.kept`, `.soft_deleted`, `#soft_deleted?`, and `#undoable?` query methods out of the box.
23
- - 🧹 **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`.
24
69
 
25
70
  ---
26
71
 
27
- ## Installation
72
+ ## Installation & Migrations
28
73
 
29
- Add this line to your application's `Gemfile`:
74
+ Add the gem to your application's `Gemfile`:
30
75
 
31
76
  ```ruby
32
77
  gem "active_record-undo"
33
78
  ```
34
79
 
35
- Then execute:
80
+ Then install dependencies:
36
81
 
37
82
  ```bash
38
83
  bundle install
39
84
  ```
40
85
 
41
- 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:
42
89
 
43
90
  ```bash
44
91
  rails db:migrate
45
92
  ```
46
93
 
47
- *(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:
48
114
 
49
115
  ```bash
50
116
  rails active_record_undo:install:migrations
@@ -52,14 +118,14 @@ rails active_record_undo:install:migrations
52
118
 
53
119
  ---
54
120
 
55
- ## Usage
121
+ ## Model Setup
56
122
 
57
- ### 1. Database Setup
123
+ ### 1. Add Timestamp Column to Tables
58
124
 
59
- 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:
60
126
 
61
127
  ```ruby
62
- class AddSoftDeleteColumnsToModels < ActiveRecord::Migration[7.0]
128
+ class AddDeletedAtToModels < ActiveRecord::Migration[7.0]
63
129
  def change
64
130
  add_column :posts, :deleted_at, :datetime
65
131
  add_column :comments, :deleted_at, :datetime
@@ -72,345 +138,556 @@ class AddSoftDeleteColumnsToModels < ActiveRecord::Migration[7.0]
72
138
  end
73
139
  ```
74
140
 
75
- ### 2. Configure Models
141
+ ### 2. Declare `acts_as_undoable`
76
142
 
77
- 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:
78
144
 
79
145
  ```ruby
80
146
  class Post < ApplicationRecord
81
- # Uses default :deleted_at column
82
147
  acts_as_undoable
83
148
 
84
149
  has_many :comments, dependent: :destroy
85
150
  end
86
151
 
87
152
  class Comment < ApplicationRecord
88
- # Uses default :deleted_at column
89
153
  acts_as_undoable
90
154
 
91
155
  belongs_to :post
92
156
  end
93
157
 
94
158
  class ArchiveItem < ApplicationRecord
95
- # Configured with a custom column
159
+ # Configured with a custom timestamp column
96
160
  acts_as_undoable column: :archived_at
97
161
  end
98
162
  ```
99
163
 
100
164
  ---
101
165
 
102
- ## Quick Start Guide
166
+ ## Basic Usage
103
167
 
104
- ### Soft Delete & Cascade
168
+ ### Cascading Soft Deletes
105
169
 
106
- 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:
107
171
 
108
172
  ```ruby
109
173
  post = Post.find(1)
110
174
 
111
- # Soft deletes post and all associated comments
175
+ # Soft deletes post and cascades to all associated comments
112
176
  undo_log = post.soft_delete!
113
177
 
114
- post.soft_deleted? # => true
178
+ post.soft_deleted? # => true
115
179
  post.comments.kept.count # => 0
116
180
  ```
117
181
 
118
- ### 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:
119
189
 
120
190
  ```ruby
121
- item = ArchiveItem.find(5)
122
- item.soft_delete!
191
+ post.restore!
123
192
 
124
- item.soft_deleted? # => true
125
- item.archived_at # => 2026-08-08 22:20:16 UTC
193
+ post.soft_deleted? # => false
194
+ post.comments.count # => 2
126
195
  ```
127
196
 
128
- ### Checking Restoration Eligibility (`#undoable?`)
197
+ ```ruby
198
+ undo_log.restore!
129
199
 
130
- 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:
200
+ post.reload.soft_deleted? # => false
201
+ ```
131
202
 
132
- ```ruby
133
- post = Post.unscoped.find(1)
203
+ ### Asynchronous Cascades (`soft_delete_later!` & `restore_later!`)
134
204
 
135
- if post.undoable?
136
- # Render "Undo Deletion" button or execute restore
137
- post.restore!
138
- end
205
+ For deep or massive association trees (5,000+ items), synchronous processing can breach web request timeouts (e.g., Puma 30s limits, Cloudflare 524 timeouts). `active_record-undo` provides non-blocking asynchronous operations powered by ActiveJob, using batched DFS traversal to keep memory flat:
206
+
207
+ ```ruby
208
+ # 1. Soft delete asynchronously: hides root record immediately and enqueues SoftDeleteJob
209
+ undo_log = post.soft_delete_later!
210
+ # Or specify a custom ActiveJob queue:
211
+ undo_log = post.soft_delete_later!(queue: :low_priority)
212
+
213
+ post.soft_deleted? # => true (immediately hidden from Post.kept)
214
+ post.soft_delete_status # => "pending" -> "processing" -> "completed"
215
+ post.soft_delete_processing? # => true / false
216
+ post.undoable? # => false until SoftDeleteJob completes
217
+
218
+ # 2. Restore asynchronously: enqueues RestoreJob to restore all cascaded items bottom-up
219
+ post.restore_later!
220
+ # Or call directly on the UndoLog:
221
+ undo_log.restore_later!(queue: :critical)
222
+
223
+ post.restore_status # => "pending" -> "processing"
224
+ post.restore_processing? # => true / false
139
225
  ```
140
226
 
141
- `#undoable?` returns `false` if:
227
+ ### Asynchronous Failure Recovery & Execution Logging
142
228
 
143
- - The record is currently active (not soft deleted).
144
- - The record was soft deleted manually via direct SQL/column updates without generating an undo log.
145
- - The corresponding `UndoLog` record was purged or already restored.
229
+ When running asynchronous cascades over thousands of associated records, background jobs can encounter transient failures such as database connection drops, lock acquisition timeouts, deadlocks, or worker restarts. `active_record-undo` provides robust failure tracking, persistent error logging, and idempotent recovery semantics.
146
230
 
147
- ### Inspect Deletion Logs
231
+ #### 1. Lifecycle State Machine
148
232
 
149
- Inspect affected records through standard Rails associations on the returned `UndoLog`:
233
+ Every asynchronous operation updates execution state directly on the database `undo_logs` record:
150
234
 
151
- ```ruby
152
- # List all items affected by this deletion event
153
- undo_log.undo_log_items.map(&:item)
154
- # => [#<Comment 101... id:>, #<Comment 102... id:>, #<Post 1... id:>]
235
+ ```
236
+ [:pending] ────► [:processing] ────► [:completed] (Destroyed on restore completion)
237
+ β”‚
238
+ β–Ό
239
+ [:failed] (with persistent error_message)
155
240
  ```
156
241
 
157
- ### Restoring Records
242
+ - **`status`**: `:pending` (0), `:processing` (1), `:completed` (2), `:failed` (3).
243
+ - **`action`**: `:soft_delete` (0), `:restore` (1).
244
+ - **`error_message`**: Captures `exception.message` when a job fails.
245
+
246
+ #### 2. Worker Re-raising & Queue Integration
247
+
248
+ When an exception occurs during `SoftDeleteJob` or `RestoreJob`:
249
+ 1. The job catches `StandardError`.
250
+ 2. It immediately updates the `UndoLog` with `status: :failed` and `error_message: exception.message`.
251
+ 3. It **re-raises** the exception (`raise e`).
252
+
253
+ Re-raising guarantees full integration with your ActiveJob backend (Sidekiq, Solid Queue, GoodJob, DelayedJob) and application monitoring:
254
+ - **Queue Retries**: Native queue retries (e.g., `retry_on ActiveRecord::Deadlocked, wait: 5.seconds, attempts: 3`) function normally.
255
+ - **Error Tracking**: Exceptions are automatically sent to Sentry, Honeybadger, Bugsnag, Datadog, etc., with full backtraces.
256
+ - **Custom Base Job**: Set `config.base_job = 'ApplicationJob'` to inherit your application's logging tags, retry strategies, and discard handlers.
158
257
 
159
- To restore a deleted object tree, you can invoke `restore!` either on the corresponding `UndoLog` or directly on the model instance itself:
258
+ #### 3. Idempotency & Safe Resumption
160
259
 
161
- #### Option A: Restore from the model instance (Recommended)
260
+ Both jobs are engineered for safe re-entrancy, allowing failed or interrupted jobs to be retried without corrupting data or duplicating log items:
162
261
 
163
- 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:
262
+ - **Soft Delete Re-entrancy**: `CascadeHandler` uses `in_batches` and checks `associated.reload.soft_deleted?`. If a worker dies halfway or encounters a transient error, retrying the job safely skips already soft-deleted records and continues cascade processing for the remaining associations.
263
+ - **Restore Batches & Row Locking**: `RestoreJob` acquires a database row lock using `undo_log.with_lock` (`SELECT FOR UPDATE`). This prevents duplicate or concurrent worker runs on the same log. Restorations update `deleted_at: nil` in batches (`find_each(batch_size: 1000)`). The `UndoLog` and its `undo_log_items` are only pruned after all batches finish. If an error interrupts restoration, the log and all un-restored items remain preserved in the database for inspection, retry, or manual recovery.
264
+
265
+ #### 4. Inspecting & Retrying Failed Operations
266
+
267
+ Engineers or automated recovery tasks can query and inspect failed jobs directly via ActiveRecord:
164
268
 
165
269
  ```ruby
166
- # Restores the post and all comments deleted in the same batch (automatically reloads in-memory)
167
- post.restore!
270
+ # Query failed operations
271
+ failed_logs = ActiveRecord::Undo::UndoLog.failed # or .where(status: :failed)
168
272
 
169
- post.soft_deleted? # => false
170
- post.comments.count # => 2
273
+ failed_logs.each do |log|
274
+ puts "UndoLog ##{log.id} (#{log.action}) failed: #{log.error_message}"
275
+
276
+ # Re-enqueue restoration
277
+ log.restore_later! if log.action == "restore"
278
+
279
+ # Or execute synchronously to inspect immediately
280
+ log.restore! if log.action == "restore"
281
+ end
171
282
  ```
172
283
 
173
- #### Option B: Restore from the `UndoLog`
284
+ #### 5. Frontend Polling & Error Observability
174
285
 
175
- ```ruby
176
- # Reverses soft deletes for the post and comments
177
- undo_log.restore!
286
+ Frontends can poll status endpoints using the numeric ID or signed token:
287
+ - `GET /undo/logs/:id/status`
288
+ - `GET /undo/status/:token`
178
289
 
179
- post.reload.soft_deleted? # => false
180
- post.comments.count # => 2
290
+ On failure, the endpoint returns a `200 OK` JSON payload with error details:
291
+
292
+ ```json
293
+ {
294
+ "id": 42,
295
+ "status": "failed",
296
+ "action": "restore",
297
+ "error_message": "PG::QueryCanceled: canceling statement due to statement timeout",
298
+ "completed": false,
299
+ "failed": true
300
+ }
181
301
  ```
182
302
 
183
- ---
303
+ The view helpers (`undo_status_badge`, `soft_delete_status_badge`, `restore_status_badge`) automatically apply the `.undo-status-failed` CSS class so your UI can display a failure indicator or retry button.
304
+
305
+
306
+ ### Status & Eligibility Helpers
307
+
308
+ `ActiveRecord::Undo` provides fast predicate methods and audit helpers on models:
309
+
310
+ ```ruby
311
+ # Checks if the soft-delete column is set
312
+ post.soft_deleted? # => true / false
313
+
314
+ # Verifies record is soft-deleted AND has a completed undo log ready to restore
315
+ post.undoable? # => true / false (false while async soft-delete/restore is in progress)
316
+
317
+ # Soft-delete & Restore operation statuses (for frontend badges / live polling)
318
+ post.soft_delete_status # => "pending", "processing", "completed", "failed", or nil
319
+ post.restore_status # => "pending", "processing", "completed", "failed", or nil
320
+ post.soft_delete_pending? # => true / false
321
+ post.soft_delete_processing? # => true / false
322
+ post.soft_delete_completed? # => true / false
323
+ post.soft_delete_failed? # => true / false
324
+ post.restore_pending? # => true / false
325
+ post.restore_processing? # => true / false
326
+ post.restore_failed? # => true / false
327
+
328
+ # Checks if the soft-deleted record has exceeded the configured retention period
329
+ post.expired? # => true / false
330
+
331
+ # Retrieves the latest UndoLog audit record associated with this model
332
+ post.undo_log # => #<ActiveRecord::Undo::UndoLog id: 14, status: "completed", ...>
333
+ post.latest_undo_log # alias
334
+
335
+ # Generates a cryptographic signed restoration token directly from the model
336
+ post.signed_token # => "eyJfcmFpbHMiOnsiZGF0YSI6..."
337
+ ```
338
+
339
+ > [!TIP]
340
+ > Use `#undoable?` to conditionally display restore buttons in your user interface, passing the model instance directly:
341
+ >
342
+ > ```erb
343
+ > <% if post.undoable? %>
344
+ > <%= undo_button_to(post, text: "Restore") %>
345
+ > <% end %>
346
+ > ```
184
347
 
185
- ## Scopes & Querying
348
+ ### Query Scopes
186
349
 
187
- `ActiveRecord::Undo` provides scopes and predicate helpers for filtering and checking records based on the configured column:
350
+ Filter records easily without writing manual SQL:
188
351
 
189
352
  ```ruby
190
- # Fetch only active (non-deleted) records
353
+ # Only active (non-deleted) records
191
354
  Post.kept
192
355
 
193
- # Fetch soft-deleted records
356
+ # Only soft-deleted records
194
357
  Post.soft_deleted
195
358
 
196
- # Check if a record is soft-deleted
197
- post.soft_deleted?
359
+ # Soft-deleted records past the retention limit
360
+ Post.expired
198
361
 
199
- # Check if a record is soft-deleted AND can be restored via an undo log
200
- post.undoable?
362
+ # Retrieve all records including soft-deleted ones
363
+ Post.unscoped.all
364
+ ```
365
+
366
+ ### Inspecting Deletion Logs
367
+
368
+ Inspect affected records through standard Rails associations on `UndoLog`:
201
369
 
202
- # Retrieve records including soft-deleted ones via unscoped
203
- Post.unscoped.where(id: 1)
370
+ ```ruby
371
+ undo_log.undo_log_items.map(&:item)
372
+ # => [#<Comment id: 101>, #<Comment id: 102>, #<Post id: 1>]
204
373
  ```
205
374
 
206
375
  ---
207
376
 
208
- ## Expiration, Retention & Auto-Purging
377
+ ## Mountable Route & Controller Engine
209
378
 
210
- 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
+ `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.
211
380
 
212
- ### 1. Global Configuration
381
+ ### Mounting the Engine
213
382
 
214
- You can configure a global retention period inside a Rails initializer:
383
+ Mount the engine in your application routes:
215
384
 
216
385
  ```ruby
217
- # config/initializers/active_record_undo.rb
218
- ActiveRecord::Undo.configure do |config|
219
- # Default retention period for all soft-deleted records and undo logs (defaults to 30.days)
220
- config.retention_period = 30.days
386
+ # config/routes.rb
387
+ Rails.application.routes.draw do
388
+ mount ActiveRecord::Undo::Engine => "/undo"
221
389
  end
222
390
  ```
223
391
 
224
- If `retention_period` is set to `nil`, records and logs will never expire.
392
+ ### Available Endpoints
225
393
 
226
- ### 2. Expiration Scopes & Helpers
394
+ | Method | Route | Controller#Action | Description |
395
+ | :----- | :----------------------- | :----------------------------------- | :-------------------------------------------------- |
396
+ | `POST` | `/undo/logs/:id/restore` | `active_record/undo/logs#restore` | Restores an `UndoLog` by ID (supports `async: true`)|
397
+ | `POST` | `/undo/restore/:token` | `active_record/undo/restores#create` | Restores using signed token (supports `async: true`)|
398
+ | `GET` | `/undo/logs/:id/status` | `active_record/undo/logs#status` | Polls restoration or soft delete status JSON |
399
+ | `GET` | `/undo/status/:token` | `active_record/undo/restores#status` | Polls status JSON using signed token |
227
400
 
228
- The gem provides query scopes and instance predicate helpers:
401
+ ### View Helpers (`undo_button_to`, `undo_link_to`, `undo_status_badge`, `soft_delete_status_badge` & `restore_status_badge`)
229
402
 
230
- - **Model `.expired` Scope**: Returns soft-deleted records older than the configured retention period.
403
+ Clean view helpers are automatically available in all Rails views and forms:
231
404
 
232
- ```ruby
233
- Post.expired # => ActiveRecord::Relation of posts soft-deleted > 30 days ago
234
- ```
405
+ ```erb
406
+ <%# Standard button_to targeting /undo/logs/:id/restore %>
407
+ <%= undo_button_to(@undo_log) %>
235
408
 
236
- - **Model `#expired?` Predicate**: Checks if a record is soft-deleted and past the retention period.
409
+ <%# Pass model instances directly %>
410
+ <%= undo_button_to(post, text: "Undo Delete", class: "btn btn-primary") %>
237
411
 
238
- ```ruby
239
- post.expired? # => true/false
240
- ```
412
+ <%# Asynchronous restore button %>
413
+ <%= undo_button_to(post, params: { async: true }, text: "Restore Asynchronously") %>
241
414
 
242
- - **`UndoLog.expired` Scope**: Returns undo log entries older than the retention period.
415
+ <%# Auto-detecting status badge helper for frontend progress %>
416
+ <%= undo_status_badge(post) %>
417
+ <!-- Renders: <span class="undo-status-badge undo-soft-delete-badge undo-status-processing">Processing</span> -->
243
418
 
244
- ```ruby
245
- ActiveRecord::Undo::UndoLog.expired # => logs created > 30 days ago
246
- ```
419
+ <%# Explicit status badge helpers for soft delete and restore %>
420
+ <%= soft_delete_status_badge(post) %>
421
+ <%= restore_status_badge(post) %>
422
+ <%= soft_delete_status_badge(post, text: "Deleting...") %>
247
423
 
248
- ### 3. Background Purging
424
+ <%# Secure direct link with signed token (prevents ID enumeration in emails or public links) %>
425
+ <%= undo_button_to(@undo_log, signed: true, class: "btn btn-success") %>
249
426
 
250
- #### Purger Service
427
+ <%# Turbo Stream / Hotwire compatible link %>
428
+ <%= undo_link_to(@undo_log, text: "Undo", class: "text-decoration-underline") %>
429
+ <%= undo_link_to(@undo_log, signed: true) %>
430
+ ```
251
431
 
252
- The `ActiveRecord::Undo::Purger` class performs hard SQL deletes on expired records and logs using `delete_all` (bypassing callbacks and validations for efficiency):
432
+ ### Cryptographic Signed Restore Tokens
253
433
 
254
- ```ruby
255
- # Purge all expired soft-deleted records and undo logs in batches
256
- ActiveRecord::Undo::Purger.purge_expired!(batch_size: 1000)
434
+ 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.
435
+
436
+ `active_record-undo` provides signed tokens for restoration via tamper-proof, opaque URLs: `POST /undo/restore/:token`.
437
+
438
+ #### Generating & Using Signed Tokens
439
+
440
+ **In Views (using helpers):**
441
+
442
+ ```erb
443
+ <%# Renders a form POST to /undo/restore/:token %>
444
+ <%= undo_button_to(post, signed: true, text: "Undo Delete", class: "btn btn-outline-primary") %>
445
+
446
+ <%# Renders an anchor tag for Turbo / Hotwire %>
447
+ <%= undo_link_to(post, signed: true, text: "Undo") %>
257
448
  ```
258
449
 
259
- > [!NOTE]
260
- > **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`).
450
+ **In Controllers, Background Jobs & Mailers:**
451
+
452
+ ```ruby
453
+ # Generate token directly from the model instance
454
+ token = post.signed_token
261
455
 
262
- #### ActiveJob Background Job
456
+ # Or with a custom expiration window
457
+ token = post.signed_token(expires_in: 2.hours)
263
458
 
264
- The gem provides an ActiveJob class that calls the Purger service:
459
+ # Or directly from an UndoLog instance
460
+ token = undo_log.signed_token
461
+
462
+ # Construct full URL for transactional emails or Slack webhooks
463
+ restore_url = active_record_undo.signed_restore_url(token: token)
464
+ ```
465
+
466
+ **Manual Verification & Retrieval:**
265
467
 
266
468
  ```ruby
267
- # Enqueue the purge job to run in the background
268
- ActiveRecord::Undo::PurgeJob.perform_later(batch_size: 1000)
469
+ # Manually verify and retrieve the associated UndoLog
470
+ undo_log = ActiveRecord::Undo::UndoLog.find_by_signed_token(params[:token])
269
471
  ```
270
472
 
271
- #### Engine Rake Task
473
+ #### Key Security Properties
272
474
 
273
- You can run the purge task via Rake. This task is automatically loaded into host applications:
475
+ 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.
476
+ 2. **Purpose Isolation (`purpose: :restore`):** Tokens are strictly scoped to restoration. Tokens generated for other purposes (or by other verifiers) are rejected.
477
+ 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.
478
+ 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`.
479
+ 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.
274
480
 
275
- ```bash
276
- # Run with the default batch size of 1000
277
- $ rails active_record_undo:purge_expired
481
+ ### Multi-Format Responses
278
482
 
279
- # Run with a custom batch size
280
- $ BATCH_SIZE=500 rails active_record_undo:purge_expired
281
- ```
483
+ The engine controller seamlessly handles multiple response formats:
484
+
485
+ - **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."`).
486
+ - **Turbo Stream (`text/vnd.turbo-stream.html`):** Renders inline `<turbo-stream>` notification elements with HTTP status `200 OK`.
487
+ - **JSON:** Returns `{ "success": true, "restored_items_count": count }` with HTTP status `200 OK`.
488
+
489
+ In error scenarios (missing record, expired action, or security mismatch):
490
+
491
+ - **`403 Forbidden`:** Rendered on `ActiveRecord::Undo::SecurityError` (or HTML redirected with `flash[:alert]`).
492
+ - **`404 Not Found`:** Rendered when the undo log does not exist or has already been restored.
493
+ - **`422 Unprocessable Content`:** Rendered when the undo action has expired beyond the retention period.
494
+
495
+ ### Security & Open-Redirect Protection
496
+
497
+ - **CSRF Protection:** Inherits from `ActionController::Base` (or your configured `base_controller`) with CSRF verification enabled.
498
+ - **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.
499
+ - **Signed Tokens:** `undo_log.signed_token` uses Rails' `message_verifier(:active_record_undo)` to sign tokens cryptographically, preventing tampering, replay, and ID enumeration.
282
500
 
283
501
  ---
284
502
 
285
503
  ## Multi-Tenant Isolation & User Attribution
286
504
 
287
- To support enterprise-grade Rails applications, `active_record-undo` provides native mechanisms for multi-tenant data isolation and user auditing.
505
+ To support enterprise-grade Rails applications, `active_record-undo` natively integrates user auditing and tenant scoping.
288
506
 
289
- ### 1. Database Setup
507
+ ### User Attribution (`whodunnit`)
290
508
 
291
- Add a migration to introduce polymorphic tenant and user attribution columns to the `undo_logs` table:
509
+ Track which user initiated a soft deletion or restoration:
292
510
 
293
511
  ```ruby
294
- class AddTenantAndUserAttributionToUndoLogs < ActiveRecord::Migration[7.0]
295
- def change
296
- change_table :undo_logs, bulk: true do |t|
297
- t.string :whodunnit_type, null: true
298
- t.bigint :whodunnit_id, null: true
299
- t.string :tenant_type, null: true
300
- t.bigint :tenant_id, null: true
301
- end
512
+ # 1. Via explicit parameter
513
+ post.soft_delete!(whodunnit: current_user)
514
+ post.restore!(whodunnit: current_user)
302
515
 
303
- add_index :undo_logs, [:whodunnit_type, :whodunnit_id], name: 'index_undo_logs_on_whodunnit'
304
- add_index :undo_logs, [:tenant_type, :tenant_id], name: 'index_undo_logs_on_tenant'
305
- end
516
+ # 2. Via Thread / Fiber ambient context
517
+ ActiveRecord::Undo.whodunnit = current_user
518
+ post.soft_delete!
519
+
520
+ # 3. Via global callable proc
521
+ ActiveRecord::Undo.configure do |config|
522
+ config.current_user_method = -> { Current.user }
306
523
  end
307
524
  ```
308
525
 
309
- ### 2. Global Configuration
526
+ ### Multi-Tenant Scoping
310
527
 
311
- Configure global procs (e.g. evaluating `CurrentAttributes`) in your initializer:
528
+ Scope soft deletes and logs to accounts or organizations:
312
529
 
313
530
  ```ruby
314
- # config/initializers/active_record_undo.rb
531
+ # 1. Via explicit parameter
532
+ post.soft_delete!(tenant: current_account)
533
+
534
+ # 2. Via Thread / Fiber ambient context
535
+ ActiveRecord::Undo.current_tenant = current_account
536
+ post.soft_delete!
537
+
538
+ # 3. Via global callable proc
315
539
  ActiveRecord::Undo.configure do |config|
316
- # Callable accessors to automatically resolve context
317
- config.current_user_method = -> { Current.user }
318
540
  config.current_tenant_method = -> { Current.account }
319
541
  end
320
542
  ```
321
543
 
322
- ### 3. Usage & Thread Context
544
+ ### Tenant Matching Security
323
545
 
324
- You can set thread/fiber context explicitly:
546
+ 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:
325
547
 
326
548
  ```ruby
327
- ActiveRecord::Undo.whodunnit = current_user
328
- ActiveRecord::Undo.current_tenant = current_account
549
+ ActiveRecord::Undo.current_tenant = wrong_account
550
+ post.restore! # => raises ActiveRecord::Undo::SecurityError (Tenant mismatch)
329
551
  ```
330
552
 
331
- Or pass values explicitly as parameters to model operations:
553
+ ### Configured Context Enforcement
332
554
 
333
- ```ruby
334
- # Capture user and tenant during deletion
335
- post.soft_delete!(whodunnit: current_user, tenant: current_account)
555
+ When `current_user_method` or `current_tenant_method` is configured via `ActiveRecord::Undo.configure`, operations ensure the context does not evaluate to `nil`:
336
556
 
337
- # Track who performed the restoration
338
- post.restore!(whodunnit: current_user)
557
+ ```ruby
558
+ # If current_user_method evaluates to nil (e.g., unauthenticated request):
559
+ post.soft_delete! # => raises ActiveRecord::Undo::SecurityError: Configured current_user_method returned nil.
339
560
  ```
340
561
 
341
- ### 4. Tenant Matching Security & Context Enforcement
562
+ *(If neither method is configured, operations proceed normally without requiring user or tenant context).*
342
563
 
343
- #### Tenant Matching Security
564
+ ---
565
+
566
+ ## Retention, Expiration & Purging
567
+
568
+ To keep your database lean, `active_record-undo` provides retention management and automated background purging.
344
569
 
345
- When restoring a log that belongs to a tenant, the gem verifies that the initiating context's tenant matches the log's tenant. If there is a mismatch (or if no tenant is set in the context), the gem raises an `ActiveRecord::Undo::SecurityError`:
570
+ ### Expiration Checks
571
+
572
+ Query and check expired records using the configured retention window (default: `30.days`):
346
573
 
347
574
  ```ruby
348
- ActiveRecord::Undo.current_tenant = wrong_account
349
- post.restore! # => raises ActiveRecord::Undo::SecurityError: Tenant mismatch
350
- ```
575
+ # Scopes
576
+ Post.expired # Soft-deleted posts older than retention period
577
+ ActiveRecord::Undo::UndoLog.expired # Undo logs older than retention period
351
578
 
352
- #### Configured Context Enforcement
579
+ # Predicates
580
+ post.expired? # => true / false
581
+ undo_log.expired? # => true / false
582
+ ```
353
583
 
354
- When `current_user_method` or `current_tenant_method` is configured via `ActiveRecord::Undo.configure`, operations enforce that the evaluated context is not `nil`:
584
+ ### Purger Service
355
585
 
356
- - Calling `soft_delete!` or `restore!` when a configured method evaluates to `nil` immediately raises an `ActiveRecord::Undo::SecurityError`.
357
- - If both user and tenant methods are configured, both must return valid non-nil objects for operations to proceed.
358
- - If neither method is configured, operations proceed normally without requiring user or tenant context.
586
+ `ActiveRecord::Undo::Purger` performs batch SQL deletes bypassing model callbacks for peak efficiency:
359
587
 
360
588
  ```ruby
361
- # If current_user_method is configured (e.g. -> { Current.user }),
362
- # but the request is unauthenticated (evaluates to nil):
363
- post.soft_delete! # => raises ActiveRecord::Undo::SecurityError: Configured current_user_method returned nil.
364
- post.restore! # => raises ActiveRecord::Undo::SecurityError: Configured current_user_method returned nil.
589
+ # Purge all expired records and logs across registered models
590
+ ActiveRecord::Undo::Purger.purge_expired!(batch_size: 1000)
591
+
592
+ # Scoped purge for a specific tenant
593
+ ActiveRecord::Undo.current_tenant = account_1
594
+ ActiveRecord::Undo::Purger.purge_expired!
365
595
  ```
366
596
 
367
- ### 5. Query Scopes & Purging
597
+ > [!NOTE]
598
+ > **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).
368
599
 
369
- Query logs scoped to a specific user or tenant:
600
+ ### ActiveJob Background Worker
601
+
602
+ Enqueue purges easily via ActiveJob:
370
603
 
371
604
  ```ruby
372
- ActiveRecord::Undo::UndoLog.for_whodunnit(user)
373
- ActiveRecord::Undo::UndoLog.for_tenant(account)
605
+ ActiveRecord::Undo::PurgeJob.perform_later(batch_size: 1000)
374
606
  ```
375
607
 
376
- If `current_tenant` is set in the context when running the `Purger`, purging of both logs and model records will be strictly scoped to that tenant:
608
+ ### Rake Task
609
+
610
+ Run purging from cron, cron-like schedulers, or CI:
611
+
612
+ ```bash
613
+ # Default batch size (1000)
614
+ rails active_record_undo:purge_expired
615
+
616
+ # Custom batch size
617
+ BATCH_SIZE=500 rails active_record_undo:purge_expired
618
+ ```
619
+
620
+ ---
621
+
622
+ ## Configuration Reference
623
+
624
+ Configure all gem options in a single initializer:
377
625
 
378
626
  ```ruby
379
- # Scoped purge for account_1
380
- ActiveRecord::Undo.current_tenant = account_1
381
- ActiveRecord::Undo::Purger.purge_expired!
627
+ # config/initializers/active_record_undo.rb
628
+ ActiveRecord::Undo.configure do |config|
629
+ # Default retention period for soft-deleted records and undo logs (defaults to 30.days)
630
+ # Set to nil to disable expiration
631
+ config.retention_period = 30.days
632
+
633
+ # Callable procs to resolve ambient user and tenant context (defaults to nil)
634
+ config.current_user_method = -> { Current.user }
635
+ config.current_tenant_method = -> { Current.account }
636
+
637
+ # Base controller for engine authentication and authorization hooks
638
+ # Defaults to "::ApplicationController" if defined, falling back to ActionController::Base
639
+ config.base_controller = "::ApplicationController"
640
+
641
+ # Base job class for asynchronous workers (SoftDeleteJob, RestoreJob, PurgeJob)
642
+ # Defaults to "::ActiveJob::Base"
643
+ config.base_job = "::ActiveJob::Base"
644
+
645
+ # Fallback redirect path after successful restore when no redirect_to or referer exists
646
+ config.default_redirect_path = ->(main_app) { main_app.root_path }
647
+
648
+ # Expiration duration for signed restore tokens (defaults to 24.hours)
649
+ config.token_expires_in = 24.hours
650
+
651
+ # Custom secret key for signed tokens (defaults to nil, utilizing Rails application verifier)
652
+ config.token_secret_key = nil
653
+
654
+ # Error handling strategy for HTML requests (:auto, :redirect, or :render)
655
+ # Defaults to :auto (redirects with flash alert if referer/redirect_to present, else renders status)
656
+ config.error_handling = :auto
657
+ end
382
658
  ```
383
659
 
384
660
  ---
385
661
 
386
662
  ## How It Works
387
663
 
388
- 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`.
389
- 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.
390
- 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.
391
- 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.
392
- 5. **Atomic Operation:** All updates and log creations take place within an `ActiveRecord::Base.transaction`.
664
+ 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`.
665
+ 2. **Column Resolution:** Identifies `record.class.undoable_column` to apply the correct timestamp column (`:deleted_at`, `:archived_at`, etc.) across all affected models.
666
+ 3. **Audit Logging:** Creates an `UndoLog` and individual `UndoLogItem` entries storing polymorphic references (`item_type`, `item_id`) for every soft-deleted record.
667
+ 4. **Atomic Operation:** Deletions and log creations execute within a single `ActiveRecord::Base.transaction`.
668
+ 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.
393
669
 
394
670
  ---
395
671
 
396
- ## Error Handling
397
-
398
- To ensure database integrity and provide clear debugging context, the gem raises errors in the following scenarios:
672
+ ## Error Reference
399
673
 
400
- - **Missing Column at Runtime (`ActiveRecord::Undo::Error`):** If the configured soft-delete column is missing from the database table when calling `soft_delete!` or `restore!`.
401
- - **Missing Model Class (`ActiveRecord::Undo::Error`):** If a model class has been renamed or deleted, preventing the polymorphic log items from finding the target class during restore.
402
- - **Missing Column on Target Class (`ActiveRecord::Undo::Error`):** If a model class exists but no longer has the target soft-delete column during restore.
403
- - **Cross-Tenant Restoration Attempt (`ActiveRecord::Undo::SecurityError`):** If attempting to restore a record whose `UndoLog` belongs to a tenant different from the current context tenant.
404
- - **Missing Tenant Context on Restoration (`ActiveRecord::Undo::SecurityError`):** If attempting to restore a tenant-scoped record when no tenant is set in the context.
405
- - **Configured Context Evaluated to Nil (`ActiveRecord::Undo::SecurityError`):** If `current_user_method` or `current_tenant_method` is configured but returns `nil` during `soft_delete!` or `restore!`.
674
+ | Error Class | Trigger Scenario |
675
+ | :---------------------------------- | :---------------------------------------------------------------------------- |
676
+ | `ActiveRecord::Undo::Error` | The configured soft-delete column does not exist on the table. |
677
+ | `ActiveRecord::Undo::Error` | A model class referenced by an `UndoLogItem` was renamed or deleted. |
678
+ | `ActiveRecord::Undo::SecurityError` | Attempted restore where the context's tenant does not match the log's tenant. |
679
+ | `ActiveRecord::Undo::SecurityError` | Attempted restore of a tenant-scoped log when no tenant is set in context. |
680
+ | `ActiveRecord::Undo::SecurityError` | Configured `current_user_method` or `current_tenant_method` returned `nil`. |
406
681
 
407
682
  ---
408
683
 
409
684
  ## Development
410
685
 
411
- After cloning the repository, install dependencies:
686
+ Clone the repository and install dependencies:
412
687
 
413
688
  ```bash
689
+ git clone https://github.com/saurabh-activecode/active_record-undo.git
690
+ cd active_record-undo
414
691
  bundle install
415
692
  ```
416
693
 
@@ -420,11 +697,17 @@ Run test suite via RSpec:
420
697
  bundle exec rspec
421
698
  ```
422
699
 
700
+ Run RuboCop linting:
701
+
702
+ ```bash
703
+ bundle exec rubocop
704
+ ```
705
+
423
706
  ---
424
707
 
425
708
  ## Contributing
426
709
 
427
- Bug reports and pull requests are welcome on GitHub at <https://github.com/saurabh-activecode/active_record-undo>.
710
+ 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).
428
711
 
429
712
  ## License
430
713