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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +78 -2
- data/DOCUMENTATION.md +340 -133
- data/README.md +473 -190
- data/app/controllers/active_record/undo/application_controller.rb +100 -0
- data/app/controllers/active_record/undo/logs_controller/async_handler.rb +50 -0
- data/app/controllers/active_record/undo/logs_controller.rb +116 -0
- data/app/controllers/active_record/undo/restores_controller.rb +11 -0
- data/app/jobs/active_record/undo/restore_job.rb +72 -0
- data/app/jobs/active_record/undo/soft_delete_job.rb +75 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20260920000000_add_status_and_error_message_to_undo_logs.rb +15 -0
- data/lib/active_record/undo/cascade_handler.rb +66 -0
- data/lib/active_record/undo/configuration.rb +9 -1
- data/lib/active_record/undo/engine.rb +14 -1
- data/lib/active_record/undo/model_extension/async_methods.rb +68 -0
- data/lib/active_record/undo/model_extension/attribution_helper.rb +8 -0
- data/lib/active_record/undo/model_extension/tenant_verification.rb +1 -1
- data/lib/active_record/undo/model_extension.rb +22 -6
- data/lib/active_record/undo/safe_redirect.rb +60 -0
- data/lib/active_record/undo/status_predicates.rb +39 -0
- data/lib/active_record/undo/undo_log/token_verifier.rb +48 -0
- data/lib/active_record/undo/undo_log.rb +77 -2
- data/lib/active_record/undo/version.rb +1 -1
- data/lib/active_record/undo/view_helpers/status_badge_helper.rb +84 -0
- data/lib/active_record/undo/view_helpers.rb +105 -0
- data/lib/active_record/undo.rb +37 -1
- data/sig/active_record/undo.rbs +50 -1
- metadata +29 -1
data/README.md
CHANGED
|
@@ -3,48 +3,114 @@
|
|
|
3
3
|
[](https://rubygems.org/gems/active_record-undo)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
|
|
6
|
-
**ActiveRecord::Undo** brings transactional, cascade-aware soft deletes
|
|
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
|
|
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
|
|
15
|
-
- βͺ **Atomic Restores:** Reverses soft deletion for an entire object tree (`
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
- π§Ή **
|
|
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
|
|
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
|
|
80
|
+
Then install dependencies:
|
|
36
81
|
|
|
37
82
|
```bash
|
|
38
83
|
bundle install
|
|
39
84
|
```
|
|
40
85
|
|
|
41
|
-
|
|
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
|
|
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
|
-
##
|
|
121
|
+
## Model Setup
|
|
56
122
|
|
|
57
|
-
### 1.
|
|
123
|
+
### 1. Add Timestamp Column to Tables
|
|
58
124
|
|
|
59
|
-
Ensure
|
|
125
|
+
Ensure every model using soft deletion has a timestamp column:
|
|
60
126
|
|
|
61
127
|
```ruby
|
|
62
|
-
class
|
|
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.
|
|
141
|
+
### 2. Declare `acts_as_undoable`
|
|
76
142
|
|
|
77
|
-
Add `acts_as_undoable` to models
|
|
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
|
-
##
|
|
166
|
+
## Basic Usage
|
|
103
167
|
|
|
104
|
-
### Soft
|
|
168
|
+
### Cascading Soft Deletes
|
|
105
169
|
|
|
106
|
-
Call `soft_delete!` on
|
|
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?
|
|
178
|
+
post.soft_deleted? # => true
|
|
115
179
|
post.comments.kept.count # => 0
|
|
116
180
|
```
|
|
117
181
|
|
|
118
|
-
###
|
|
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
|
-
|
|
122
|
-
item.soft_delete!
|
|
191
|
+
post.restore!
|
|
123
192
|
|
|
124
|
-
|
|
125
|
-
|
|
193
|
+
post.soft_deleted? # => false
|
|
194
|
+
post.comments.count # => 2
|
|
126
195
|
```
|
|
127
196
|
|
|
128
|
-
|
|
197
|
+
```ruby
|
|
198
|
+
undo_log.restore!
|
|
129
199
|
|
|
130
|
-
|
|
200
|
+
post.reload.soft_deleted? # => false
|
|
201
|
+
```
|
|
131
202
|
|
|
132
|
-
|
|
133
|
-
post = Post.unscoped.find(1)
|
|
203
|
+
### Asynchronous Cascades (`soft_delete_later!` & `restore_later!`)
|
|
134
204
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
227
|
+
### Asynchronous Failure Recovery & Execution Logging
|
|
142
228
|
|
|
143
|
-
-
|
|
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
|
-
|
|
231
|
+
#### 1. Lifecycle State Machine
|
|
148
232
|
|
|
149
|
-
|
|
233
|
+
Every asynchronous operation updates execution state directly on the database `undo_logs` record:
|
|
150
234
|
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
235
|
+
```
|
|
236
|
+
[:pending] βββββΊ [:processing] βββββΊ [:completed] (Destroyed on restore completion)
|
|
237
|
+
β
|
|
238
|
+
βΌ
|
|
239
|
+
[:failed] (with persistent error_message)
|
|
155
240
|
```
|
|
156
241
|
|
|
157
|
-
|
|
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
|
-
|
|
258
|
+
#### 3. Idempotency & Safe Resumption
|
|
160
259
|
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
167
|
-
|
|
270
|
+
# Query failed operations
|
|
271
|
+
failed_logs = ActiveRecord::Undo::UndoLog.failed # or .where(status: :failed)
|
|
168
272
|
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
####
|
|
284
|
+
#### 5. Frontend Polling & Error Observability
|
|
174
285
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
348
|
+
### Query Scopes
|
|
186
349
|
|
|
187
|
-
|
|
350
|
+
Filter records easily without writing manual SQL:
|
|
188
351
|
|
|
189
352
|
```ruby
|
|
190
|
-
#
|
|
353
|
+
# Only active (non-deleted) records
|
|
191
354
|
Post.kept
|
|
192
355
|
|
|
193
|
-
#
|
|
356
|
+
# Only soft-deleted records
|
|
194
357
|
Post.soft_deleted
|
|
195
358
|
|
|
196
|
-
#
|
|
197
|
-
|
|
359
|
+
# Soft-deleted records past the retention limit
|
|
360
|
+
Post.expired
|
|
198
361
|
|
|
199
|
-
#
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
##
|
|
377
|
+
## Mountable Route & Controller Engine
|
|
209
378
|
|
|
210
|
-
|
|
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
|
-
###
|
|
381
|
+
### Mounting the Engine
|
|
213
382
|
|
|
214
|
-
|
|
383
|
+
Mount the engine in your application routes:
|
|
215
384
|
|
|
216
385
|
```ruby
|
|
217
|
-
# config/
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
392
|
+
### Available Endpoints
|
|
225
393
|
|
|
226
|
-
|
|
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
|
-
|
|
401
|
+
### View Helpers (`undo_button_to`, `undo_link_to`, `undo_status_badge`, `soft_delete_status_badge` & `restore_status_badge`)
|
|
229
402
|
|
|
230
|
-
|
|
403
|
+
Clean view helpers are automatically available in all Rails views and forms:
|
|
231
404
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
405
|
+
```erb
|
|
406
|
+
<%# Standard button_to targeting /undo/logs/:id/restore %>
|
|
407
|
+
<%= undo_button_to(@undo_log) %>
|
|
235
408
|
|
|
236
|
-
|
|
409
|
+
<%# Pass model instances directly %>
|
|
410
|
+
<%= undo_button_to(post, text: "Undo Delete", class: "btn btn-primary") %>
|
|
237
411
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
```
|
|
412
|
+
<%# Asynchronous restore button %>
|
|
413
|
+
<%= undo_button_to(post, params: { async: true }, text: "Restore Asynchronously") %>
|
|
241
414
|
|
|
242
|
-
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
432
|
+
### Cryptographic Signed Restore Tokens
|
|
253
433
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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
|
-
|
|
456
|
+
# Or with a custom expiration window
|
|
457
|
+
token = post.signed_token(expires_in: 2.hours)
|
|
263
458
|
|
|
264
|
-
|
|
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
|
-
#
|
|
268
|
-
ActiveRecord::Undo::
|
|
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
|
-
####
|
|
473
|
+
#### Key Security Properties
|
|
272
474
|
|
|
273
|
-
|
|
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
|
-
|
|
276
|
-
# Run with the default batch size of 1000
|
|
277
|
-
$ rails active_record_undo:purge_expired
|
|
481
|
+
### Multi-Format Responses
|
|
278
482
|
|
|
279
|
-
|
|
280
|
-
|
|
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`
|
|
505
|
+
To support enterprise-grade Rails applications, `active_record-undo` natively integrates user auditing and tenant scoping.
|
|
288
506
|
|
|
289
|
-
###
|
|
507
|
+
### User Attribution (`whodunnit`)
|
|
290
508
|
|
|
291
|
-
|
|
509
|
+
Track which user initiated a soft deletion or restoration:
|
|
292
510
|
|
|
293
511
|
```ruby
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
###
|
|
526
|
+
### Multi-Tenant Scoping
|
|
310
527
|
|
|
311
|
-
|
|
528
|
+
Scope soft deletes and logs to accounts or organizations:
|
|
312
529
|
|
|
313
530
|
```ruby
|
|
314
|
-
#
|
|
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
|
-
###
|
|
544
|
+
### Tenant Matching Security
|
|
323
545
|
|
|
324
|
-
|
|
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.
|
|
328
|
-
ActiveRecord::Undo
|
|
549
|
+
ActiveRecord::Undo.current_tenant = wrong_account
|
|
550
|
+
post.restore! # => raises ActiveRecord::Undo::SecurityError (Tenant mismatch)
|
|
329
551
|
```
|
|
330
552
|
|
|
331
|
-
|
|
553
|
+
### Configured Context Enforcement
|
|
332
554
|
|
|
333
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
|
|
562
|
+
*(If neither method is configured, operations proceed normally without requiring user or tenant context).*
|
|
342
563
|
|
|
343
|
-
|
|
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
|
-
|
|
570
|
+
### Expiration Checks
|
|
571
|
+
|
|
572
|
+
Query and check expired records using the configured retention window (default: `30.days`):
|
|
346
573
|
|
|
347
574
|
```ruby
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
579
|
+
# Predicates
|
|
580
|
+
post.expired? # => true / false
|
|
581
|
+
undo_log.expired? # => true / false
|
|
582
|
+
```
|
|
353
583
|
|
|
354
|
-
|
|
584
|
+
### Purger Service
|
|
355
585
|
|
|
356
|
-
|
|
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
|
-
#
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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
|
-
|
|
600
|
+
### ActiveJob Background Worker
|
|
601
|
+
|
|
602
|
+
Enqueue purges easily via ActiveJob:
|
|
370
603
|
|
|
371
604
|
```ruby
|
|
372
|
-
ActiveRecord::Undo::
|
|
373
|
-
ActiveRecord::Undo::UndoLog.for_tenant(account)
|
|
605
|
+
ActiveRecord::Undo::PurgeJob.perform_later(batch_size: 1000)
|
|
374
606
|
```
|
|
375
607
|
|
|
376
|
-
|
|
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
|
-
#
|
|
380
|
-
ActiveRecord::Undo.
|
|
381
|
-
|
|
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:**
|
|
389
|
-
2. **
|
|
390
|
-
3. **
|
|
391
|
-
4. **
|
|
392
|
-
5. **
|
|
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
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|