ActiveRecord::Undo
ActiveRecord::Undo brings transactional, cascade-aware soft deletes, instant single-line restores, and a mountable HTTP route engine to Ruby on Rails applications.
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.
Table of Contents
- Features
- Installation & Migrations
- Database Migrations
- Model Setup
- 1. Add Timestamp Column to Tables
- 2. Declare
acts_as_undoable
- Basic Usage
- Cascading Soft Deletes
- Restoring Records
- Option A: Restore from the model (Recommended)
- Option B: Restore from the
UndoLog
- Status & Eligibility Helpers
- Query Scopes
- Inspecting Deletion Logs
- Mountable Route & Controller Engine
- Mounting the Engine
- Available Endpoints
- View Helpers (
undo_button_to&undo_link_to) - Cryptographic Signed Restore Tokens
- Generating & Using Signed Tokens
- Key Security Properties
- Multi-Format Responses
- Security & Open-Redirect Protection
- Multi-Tenant Isolation & User Attribution
- User Attribution (
whodunnit) - Multi-Tenant Scoping
- Tenant Matching Security
- Configured Context Enforcement
- User Attribution (
- Retention, Expiration & Purging
- Expiration Checks
- Purger Service
- ActiveJob Background Worker
- Rake Task
- Configuration Reference
- How It Works
- Error Reference
- Development
- Contributing
- License
Features
- π Cascading Soft Deletes: Soft deletes parent records along with dependent associations (
dependent: :destroy/:delete_all). - βͺ Atomic Restores: Reverses soft deletion for an entire object tree (
record.restore!orundo_log.restore!) within a single database transaction. - π Mountable Engine & Endpoints: Out-of-the-box controller actions for instant undo links with HTML flash redirects, Turbo Streams, and JSON responses.
- π Cryptographic Signed Links: Direct undo restoration tokens (
undo_log.signed_token) preventing ID enumeration on public or email links. - π¨ Clean View Helpers: Drop-in
undo_button_toandundo_link_toview helpers for seamless UI integration. - π Restoration Verification (
#undoable?): Instantly check if a soft-deleted record is eligible for restore before rendering UI buttons. - π€ User Attribution (
whodunnit): Tracks who initiated soft deletes and restores automatically via ambient context or explicit parameters. - π’ Multi-Tenant Isolation: Scopes deletion logs to specific tenants with strict tenant-matching security.
- βοΈ Custom Soft-Delete Columns: Supports custom columns (e.g.,
:archived_at,:discarded_at) per model while defaulting to:deleted_at. - π§Ή Retention & Auto-Purging: Built-in expiration scopes, batch SQL purger, ActiveJob worker, and Rake task with foreign-key constraint protection.
- π Zero Generator Setup: Built as a
Rails::Engine. Migrations automatically hook intorails db:migrate.
Installation & Migrations
Add the gem to your application's Gemfile:
gem "active_record-undo"Then install dependencies:
bundle installDatabase Migrations
ActiveRecord::Undo automatically appends its migrations (undo_logs and undo_log_items) to your application's migration path:
rails db:migrate(Optional) If you plan to use Multi-Tenant Isolation or User Attribution, add the polymorphic columns to undo_logs:
# db/migrate/XXXXXX_add_tenant_and_whodunnit_to_undo_logs.rb
class AddTenantAndWhodunnitToUndoLogs < ActiveRecord::Migration[7.0]
def change
change_table :undo_logs, bulk: true do |t|
t.string :whodunnit_type, null: true
t.bigint :whodunnit_id, null: true
t.string :tenant_type, null: true
t.bigint :tenant_id, null: true
end
add_index :undo_logs, [:whodunnit_type, :whodunnit_id], name: "index_undo_logs_on_whodunnit"
add_index :undo_logs, [:tenant_type, :tenant_id], name: "index_undo_logs_on_tenant"
end
end(Optional) If you wish to customize the gem's core migration directly:
rails active_record_undo:install:migrationsModel Setup
1. Add Timestamp Column to Tables
Ensure every model using soft deletion has a timestamp column:
class AddDeletedAtToModels < ActiveRecord::Migration[7.0]
def change
add_column :posts, :deleted_at, :datetime
add_column :comments, :deleted_at, :datetime
add_column :archive_items, :archived_at, :datetime
add_index :posts, :deleted_at
add_index :comments, :deleted_at
add_index :archive_items, :archived_at
end
end2. Declare acts_as_undoable
Add acts_as_undoable to your models. By default, it uses the :deleted_at column:
class Post < ApplicationRecord
acts_as_undoable
has_many :comments, dependent: :destroy
end
class Comment < ApplicationRecord
acts_as_undoable
belongs_to :post
end
class ArchiveItem < ApplicationRecord
# Configured with a custom timestamp column
acts_as_undoable column: :archived_at
endBasic Usage
Cascading Soft Deletes
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:
post = Post.find(1)
# Soft deletes post and cascades to all associated comments
undo_log = post.soft_delete!
post.soft_deleted? # => true
post.comments.kept.count # => 0Restoring Records
You can restore an entire deleted tree either from the model instance or from the UndoLog:
Option A: Restore from the model (Recommended)
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:
post.restore!
post.soft_deleted? # => false
post.comments.count # => 2undo_log.restore!
post.reload.soft_deleted? # => falseAsynchronous Cascades (soft_delete_later! & restore_later!)
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:
# 1. Soft delete asynchronously: hides root record immediately and enqueues SoftDeleteJob
undo_log = post.soft_delete_later!
# Or specify a custom ActiveJob queue:
undo_log = post.soft_delete_later!(queue: :low_priority)
post.soft_deleted? # => true (immediately hidden from Post.kept)
post.soft_delete_status # => "pending" -> "processing" -> "completed"
post.soft_delete_processing? # => true / false
post.undoable? # => false until SoftDeleteJob completes
# 2. Restore asynchronously: enqueues RestoreJob to restore all cascaded items bottom-up
post.restore_later!
# Or call directly on the UndoLog:
undo_log.restore_later!(queue: :critical)
post.restore_status # => "pending" -> "processing"
post.restore_processing? # => true / falseAsynchronous Failure Recovery & Execution Logging
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.
1. Lifecycle State Machine
Every asynchronous operation updates execution state directly on the database undo_logs record:
[:pending] βββββΊ [:processing] βββββΊ [:completed] (Destroyed on restore completion)
β
βΌ
[:failed] (with persistent error_message)
-
status::pending(0),:processing(1),:completed(2),:failed(3). -
action::soft_delete(0),:restore(1). -
error_message: Capturesexception.messagewhen a job fails.
2. Worker Re-raising & Queue Integration
When an exception occurs during SoftDeleteJob or RestoreJob:
- The job catches
StandardError. - It immediately updates the
UndoLogwithstatus: :failedanderror_message: exception.message. - It re-raises the exception (
raise e).
Re-raising guarantees full integration with your ActiveJob backend (Sidekiq, Solid Queue, GoodJob, DelayedJob) and application monitoring:
-
Queue Retries: Native queue retries (e.g.,
retry_on ActiveRecord::Deadlocked, wait: 5.seconds, attempts: 3) function normally. - Error Tracking: Exceptions are automatically sent to Sentry, Honeybadger, Bugsnag, Datadog, etc., with full backtraces.
-
Custom Base Job: Set
config.base_job = 'ApplicationJob'to inherit your application's logging tags, retry strategies, and discard handlers.
3. Idempotency & Safe Resumption
Both jobs are engineered for safe re-entrancy, allowing failed or interrupted jobs to be retried without corrupting data or duplicating log items:
-
Soft Delete Re-entrancy:
CascadeHandlerusesin_batchesand checksassociated.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. -
Restore Batches & Row Locking:
RestoreJobacquires a database row lock usingundo_log.with_lock(SELECT FOR UPDATE). This prevents duplicate or concurrent worker runs on the same log. Restorations updatedeleted_at: nilin batches (find_each(batch_size: 1000)). TheUndoLogand itsundo_log_itemsare 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.
4. Inspecting & Retrying Failed Operations
Engineers or automated recovery tasks can query and inspect failed jobs directly via ActiveRecord:
# Query failed operations
failed_logs = ActiveRecord::Undo::UndoLog.failed # or .where(status: :failed)
failed_logs.each do |log|
puts "UndoLog ##{log.id} (#{log.action}) failed: #{log.error_message}"
# Re-enqueue restoration
log.restore_later! if log.action == "restore"
# Or execute synchronously to inspect immediately
log.restore! if log.action == "restore"
end5. Frontend Polling & Error Observability
Frontends can poll status endpoints using the numeric ID or signed token:
GET /undo/logs/:id/statusGET /undo/status/:token
On failure, the endpoint returns a 200 OK JSON payload with error details:
{
"id": 42,
"status": "failed",
"action": "restore",
"error_message": "PG::QueryCanceled: canceling statement due to statement timeout",
"completed": false,
"failed": true
}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.
Status & Eligibility Helpers
ActiveRecord::Undo provides fast predicate methods and audit helpers on models:
# Checks if the soft-delete column is set
post.soft_deleted? # => true / false
# Verifies record is soft-deleted AND has a completed undo log ready to restore
post.undoable? # => true / false (false while async soft-delete/restore is in progress)
# Soft-delete & Restore operation statuses (for frontend badges / live polling)
post.soft_delete_status # => "pending", "processing", "completed", "failed", or nil
post.restore_status # => "pending", "processing", "completed", "failed", or nil
post.soft_delete_pending? # => true / false
post.soft_delete_processing? # => true / false
post.soft_delete_completed? # => true / false
post.soft_delete_failed? # => true / false
post.restore_pending? # => true / false
post.restore_processing? # => true / false
post.restore_failed? # => true / false
# Checks if the soft-deleted record has exceeded the configured retention period
post.expired? # => true / false
# Retrieves the latest UndoLog audit record associated with this model
post.undo_log # => #<ActiveRecord::Undo::UndoLog id: 14, status: "completed", ...>
post.latest_undo_log # alias
# Generates a cryptographic signed restoration token directly from the model
post.signed_token # => "eyJfcmFpbHMiOnsiZGF0YSI6..."Tip
Use #undoable? to conditionally display restore buttons in your user interface, passing the model instance directly:
<% if post.undoable? %>
<%= undo_button_to(post, text: "Restore") %>
<% end %>Query Scopes
Filter records easily without writing manual SQL:
# Only active (non-deleted) records
Post.kept
# Only soft-deleted records
Post.soft_deleted
# Soft-deleted records past the retention limit
Post.expired
# Retrieve all records including soft-deleted ones
Post.unscoped.allInspecting Deletion Logs
Inspect affected records through standard Rails associations on UndoLog:
undo_log.undo_log_items.map(&:item)
# => [#<Comment id: 101>, #<Comment id: 102>, #<Post id: 1>]Mountable Route & Controller Engine
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.
Mounting the Engine
Mount the engine in your application routes:
# config/routes.rb
Rails.application.routes.draw do
mount ActiveRecord::Undo::Engine => "/undo"
endAvailable Endpoints
| Method | Route | Controller#Action | Description |
|---|---|---|---|
POST |
/undo/logs/:id/restore |
active_record/undo/logs#restore |
Restores an UndoLog by ID (supports async: true) |
POST |
/undo/restore/:token |
active_record/undo/restores#create |
Restores using signed token (supports async: true) |
GET |
/undo/logs/:id/status |
active_record/undo/logs#status |
Polls restoration or soft delete status JSON |
GET |
/undo/status/:token |
active_record/undo/restores#status |
Polls status JSON using signed token |
View Helpers (undo_button_to, undo_link_to, undo_status_badge, soft_delete_status_badge & restore_status_badge)
Clean view helpers are automatically available in all Rails views and forms:
<%# Standard button_to targeting /undo/logs/:id/restore %>
<%= undo_button_to(@undo_log) %>
<%# Pass model instances directly %>
<%= undo_button_to(post, text: "Undo Delete", class: "btn btn-primary") %>
<%# Asynchronous restore button %>
<%= undo_button_to(post, params: { async: true }, text: "Restore Asynchronously") %>
<%# Auto-detecting status badge helper for frontend progress %>
<%= undo_status_badge(post) %>
<!-- Renders: <span class="undo-status-badge undo-soft-delete-badge undo-status-processing">Processing</span> -->
<%# Explicit status badge helpers for soft delete and restore %>
<%= soft_delete_status_badge(post) %>
<%= restore_status_badge(post) %>
<%= soft_delete_status_badge(post, text: "Deleting...") %>
<%# Secure direct link with signed token (prevents ID enumeration in emails or public links) %>
<%= undo_button_to(@undo_log, signed: true, class: "btn btn-success") %>
<%# Turbo Stream / Hotwire compatible link %>
<%= undo_link_to(@undo_log, text: "Undo", class: "text-decoration-underline") %>
<%= undo_link_to(@undo_log, signed: true) %>Cryptographic Signed Restore Tokens
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.
active_record-undo provides signed tokens for restoration via tamper-proof, opaque URLs: POST /undo/restore/:token.
Generating & Using Signed Tokens
In Views (using helpers):
<%# Renders a form POST to /undo/restore/:token %>
<%= undo_button_to(post, signed: true, text: "Undo Delete", class: "btn btn-outline-primary") %>
<%# Renders an anchor tag for Turbo / Hotwire %>
<%= undo_link_to(post, signed: true, text: "Undo") %>In Controllers, Background Jobs & Mailers:
# Generate token directly from the model instance
token = post.signed_token
# Or with a custom expiration window
token = post.signed_token(expires_in: 2.hours)
# Or directly from an UndoLog instance
token = undo_log.signed_token
# Construct full URL for transactional emails or Slack webhooks
restore_url = active_record_undo.signed_restore_url(token: token)Manual Verification & Retrieval:
# Manually verify and retrieve the associated UndoLog
undo_log = ActiveRecord::Undo::UndoLog.find_by_signed_token(params[:token])Key Security Properties
-
HMAC-SHA256 Cryptographic Tamper Resistance: Tokens are signed using
Rails.application.message_verifier(:active_record_undo)derived securely from your application'ssecret_key_base. Any payload alteration invalidates the cryptographic signature. -
Purpose Isolation (
purpose: :restore): Tokens are strictly scoped to restoration. Tokens generated for other purposes (or by other verifiers) are rejected. -
Time-Limited Lifespan: Tokens include an embedded expiration timestamp (configurable via
config.token_expires_in, defaults to24.hours). Expired tokens are rejected automatically. -
Single-Use Replay Protection: When
undo_log.restore!succeeds, it automatically destroys theUndoLogand 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 returnsnil, rendering a404 Not Found. -
Multi-Tenant Authorization: Cryptographic validity only proves the token was authentic. During execution,
undo_log.restore!still enforces multi-tenant boundary matching againstActiveRecord::Undo.current_tenant. An authenticated user from Tenant B cannot use a token from Tenant A to restore data.
Multi-Format Responses
The engine controller seamlessly handles multiple response formats:
-
HTML: Redirects to
params[:redirect_to](if a validated safe URL),request.referer, orconfig.default_redirect_pathwith a flash notice (flash[:notice] = "Record successfully restored."). -
Turbo Stream (
text/vnd.turbo-stream.html): Renders inline<turbo-stream>notification elements with HTTP status200 OK. -
JSON: Returns
{ "success": true, "restored_items_count": count }with HTTP status200 OK.
In error scenarios (missing record, expired action, or security mismatch):
-
403 Forbidden: Rendered onActiveRecord::Undo::SecurityError(or HTML redirected withflash[:alert]). -
404 Not Found: Rendered when the undo log does not exist or has already been restored. -
422 Unprocessable Content: Rendered when the undo action has expired beyond the retention period.
Security & Open-Redirect Protection
-
CSRF Protection: Inherits from
ActionController::Base(or your configuredbase_controller) with CSRF verification enabled. -
Open-Redirect Mitigation:
params[:redirect_to]andrequest.refererundergo 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. -
Signed Tokens:
undo_log.signed_tokenuses Rails'message_verifier(:active_record_undo)to sign tokens cryptographically, preventing tampering, replay, and ID enumeration.
Multi-Tenant Isolation & User Attribution
To support enterprise-grade Rails applications, active_record-undo natively integrates user auditing and tenant scoping.
User Attribution (whodunnit)
Track which user initiated a soft deletion or restoration:
# 1. Via explicit parameter
post.soft_delete!(whodunnit: current_user)
post.restore!(whodunnit: current_user)
# 2. Via Thread / Fiber ambient context
ActiveRecord::Undo.whodunnit = current_user
post.soft_delete!
# 3. Via global callable proc
ActiveRecord::Undo.configure do |config|
config.current_user_method = -> { Current.user }
endMulti-Tenant Scoping
Scope soft deletes and logs to accounts or organizations:
# 1. Via explicit parameter
post.soft_delete!(tenant: current_account)
# 2. Via Thread / Fiber ambient context
ActiveRecord::Undo.current_tenant = current_account
post.soft_delete!
# 3. Via global callable proc
ActiveRecord::Undo.configure do |config|
config.current_tenant_method = -> { Current.account }
endTenant Matching Security
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:
ActiveRecord::Undo.current_tenant = wrong_account
post.restore! # => raises ActiveRecord::Undo::SecurityError (Tenant mismatch)Configured Context Enforcement
When current_user_method or current_tenant_method is configured via ActiveRecord::Undo.configure, operations ensure the context does not evaluate to nil:
# If current_user_method evaluates to nil (e.g., unauthenticated request):
post.soft_delete! # => raises ActiveRecord::Undo::SecurityError: Configured current_user_method returned nil.(If neither method is configured, operations proceed normally without requiring user or tenant context).
Retention, Expiration & Purging
To keep your database lean, active_record-undo provides retention management and automated background purging.
Expiration Checks
Query and check expired records using the configured retention window (default: 30.days):
# Scopes
Post.expired # Soft-deleted posts older than retention period
ActiveRecord::Undo::UndoLog.expired # Undo logs older than retention period
# Predicates
post.expired? # => true / false
undo_log.expired? # => true / falsePurger Service
ActiveRecord::Undo::Purger performs batch SQL deletes bypassing model callbacks for peak efficiency:
# Purge all expired records and logs across registered models
ActiveRecord::Undo::Purger.purge_expired!(batch_size: 1000)
# Scoped purge for a specific tenant
ActiveRecord::Undo.current_tenant = account_1
ActiveRecord::Undo::Purger.purge_expired!Note
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).
ActiveJob Background Worker
Enqueue purges easily via ActiveJob:
ActiveRecord::Undo::PurgeJob.perform_later(batch_size: 1000)Rake Task
Run purging from cron, cron-like schedulers, or CI:
# Default batch size (1000)
rails active_record_undo:purge_expired
# Custom batch size
BATCH_SIZE=500 rails active_record_undo:purge_expiredConfiguration Reference
Configure all gem options in a single initializer:
# config/initializers/active_record_undo.rb
ActiveRecord::Undo.configure do |config|
# Default retention period for soft-deleted records and undo logs (defaults to 30.days)
# Set to nil to disable expiration
config.retention_period = 30.days
# Callable procs to resolve ambient user and tenant context (defaults to nil)
config.current_user_method = -> { Current.user }
config.current_tenant_method = -> { Current.account }
# Base controller for engine authentication and authorization hooks
# Defaults to "::ApplicationController" if defined, falling back to ActionController::Base
config.base_controller = "::ApplicationController"
# Base job class for asynchronous workers (SoftDeleteJob, RestoreJob, PurgeJob)
# Defaults to "::ActiveJob::Base"
config.base_job = "::ActiveJob::Base"
# Fallback redirect path after successful restore when no redirect_to or referer exists
config.default_redirect_path = ->(main_app) { main_app.root_path }
# Expiration duration for signed restore tokens (defaults to 24.hours)
config.token_expires_in = 24.hours
# Custom secret key for signed tokens (defaults to nil, utilizing Rails application verifier)
config.token_secret_key = nil
# Error handling strategy for HTML requests (:auto, :redirect, or :render)
# Defaults to :auto (redirects with flash alert if referer/redirect_to present, else renders status)
config.error_handling = :auto
endHow It Works
-
Cascade Inspection: On calling
soft_delete!,ActiveRecord::Undo::CascadeHandlerreflects onhas_many,has_one, andbelongs_toassociations configured withdependent: :destroyor:delete_all. -
Column Resolution: Identifies
record.class.undoable_columnto apply the correct timestamp column (:deleted_at,:archived_at, etc.) across all affected models. -
Audit Logging: Creates an
UndoLogand individualUndoLogItementries storing polymorphic references (item_type,item_id) for every soft-deleted record. -
Atomic Operation: Deletions and log creations execute within a single
ActiveRecord::Base.transaction. -
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.
Error Reference
| Error Class | Trigger Scenario |
|---|---|
ActiveRecord::Undo::Error |
The configured soft-delete column does not exist on the table. |
ActiveRecord::Undo::Error |
A model class referenced by an UndoLogItem was renamed or deleted. |
ActiveRecord::Undo::SecurityError |
Attempted restore where the context's tenant does not match the log's tenant. |
ActiveRecord::Undo::SecurityError |
Attempted restore of a tenant-scoped log when no tenant is set in context. |
ActiveRecord::Undo::SecurityError |
Configured current_user_method or current_tenant_method returned nil. |
Development
Clone the repository and install dependencies:
git clone https://github.com/saurabh-activecode/active_record-undo.git
cd active_record-undo
bundle installRun test suite via RSpec:
bundle exec rspecRun RuboCop linting:
bundle exec rubocopContributing
Bug reports and pull requests are welcome on GitHub at https://github.com/saurabh-activecode/active_record-undo.
License
The gem is available as open source under the terms of the MIT License.