active_record-undo 0.2.0 → 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.
data/README.md CHANGED
@@ -194,14 +194,115 @@ post.soft_deleted? # => false
194
194
  post.comments.count # => 2
195
195
  ```
196
196
 
197
- #### Option B: Restore from the `UndoLog`
198
-
199
197
  ```ruby
200
198
  undo_log.restore!
201
199
 
202
200
  post.reload.soft_deleted? # => false
203
201
  ```
204
202
 
203
+ ### Asynchronous Cascades (`soft_delete_later!` & `restore_later!`)
204
+
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
225
+ ```
226
+
227
+ ### Asynchronous Failure Recovery & Execution Logging
228
+
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.
230
+
231
+ #### 1. Lifecycle State Machine
232
+
233
+ Every asynchronous operation updates execution state directly on the database `undo_logs` record:
234
+
235
+ ```
236
+ [:pending] ────► [:processing] ────► [:completed] (Destroyed on restore completion)
237
+ │
238
+ ▼
239
+ [:failed] (with persistent error_message)
240
+ ```
241
+
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.
257
+
258
+ #### 3. Idempotency & Safe Resumption
259
+
260
+ Both jobs are engineered for safe re-entrancy, allowing failed or interrupted jobs to be retried without corrupting data or duplicating log items:
261
+
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:
268
+
269
+ ```ruby
270
+ # Query failed operations
271
+ failed_logs = ActiveRecord::Undo::UndoLog.failed # or .where(status: :failed)
272
+
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
282
+ ```
283
+
284
+ #### 5. Frontend Polling & Error Observability
285
+
286
+ Frontends can poll status endpoints using the numeric ID or signed token:
287
+ - `GET /undo/logs/:id/status`
288
+ - `GET /undo/status/:token`
289
+
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
+ }
301
+ ```
302
+
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
+
205
306
  ### Status & Eligibility Helpers
206
307
 
207
308
  `ActiveRecord::Undo` provides fast predicate methods and audit helpers on models:
@@ -210,14 +311,25 @@ post.reload.soft_deleted? # => false
210
311
  # Checks if the soft-delete column is set
211
312
  post.soft_deleted? # => true / false
212
313
 
213
- # Verifies record is soft-deleted AND has a valid undo log available in the database
214
- post.undoable? # => true / false
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
215
327
 
216
328
  # Checks if the soft-deleted record has exceeded the configured retention period
217
329
  post.expired? # => true / false
218
330
 
219
331
  # Retrieves the latest UndoLog audit record associated with this model
220
- post.undo_log # => #<ActiveRecord::Undo::UndoLog id: 14, ...>
332
+ post.undo_log # => #<ActiveRecord::Undo::UndoLog id: 14, status: "completed", ...>
221
333
  post.latest_undo_log # alias
222
334
 
223
335
  # Generates a cryptographic signed restoration token directly from the model
@@ -279,12 +391,14 @@ end
279
391
 
280
392
  ### Available Endpoints
281
393
 
282
- | Method | Route | Controller#Action | Description |
283
- | :----- | :----------------------- | :----------------------------------- | :----------------------------------------- |
284
- | `POST` | `/undo/logs/:id/restore` | `active_record/undo/logs#restore` | Restores an `UndoLog` by ID |
285
- | `POST` | `/undo/restore/:token` | `active_record/undo/restores#create` | Restores an `UndoLog` using a signed token |
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 |
286
400
 
287
- ### View Helpers (`undo_button_to` & `undo_link_to`)
401
+ ### View Helpers (`undo_button_to`, `undo_link_to`, `undo_status_badge`, `soft_delete_status_badge` & `restore_status_badge`)
288
402
 
289
403
  Clean view helpers are automatically available in all Rails views and forms:
290
404
 
@@ -295,6 +409,18 @@ Clean view helpers are automatically available in all Rails views and forms:
295
409
  <%# Pass model instances directly %>
296
410
  <%= undo_button_to(post, text: "Undo Delete", class: "btn btn-primary") %>
297
411
 
412
+ <%# Asynchronous restore button %>
413
+ <%= undo_button_to(post, params: { async: true }, text: "Restore Asynchronously") %>
414
+
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> -->
418
+
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...") %>
423
+
298
424
  <%# Secure direct link with signed token (prevents ID enumeration in emails or public links) %>
299
425
  <%= undo_button_to(@undo_log, signed: true, class: "btn btn-success") %>
300
426
 
@@ -512,6 +638,10 @@ ActiveRecord::Undo.configure do |config|
512
638
  # Defaults to "::ApplicationController" if defined, falling back to ActionController::Base
513
639
  config.base_controller = "::ApplicationController"
514
640
 
641
+ # Base job class for asynchronous workers (SoftDeleteJob, RestoreJob, PurgeJob)
642
+ # Defaults to "::ActiveJob::Base"
643
+ config.base_job = "::ActiveJob::Base"
644
+
515
645
  # Fallback redirect path after successful restore when no redirect_to or referer exists
516
646
  config.default_redirect_path = ->(main_app) { main_app.root_path }
517
647
 
@@ -9,6 +9,7 @@ module ActiveRecord
9
9
 
10
10
  protect_from_forgery with: :exception, unless: -> { request.format.json? }
11
11
 
12
+ rescue_from ActiveRecord::Undo::Error, with: :handle_undo_error
12
13
  rescue_from ActiveRecord::Undo::SecurityError, with: :handle_security_error
13
14
 
14
15
  private
@@ -41,6 +42,14 @@ module ActiveRecord
41
42
  end
42
43
  end
43
44
 
45
+ def handle_undo_error(error)
46
+ respond_to do |format|
47
+ format.html { handle_html_error(error.message, :unprocessable_entity) }
48
+ format.turbo_stream { render_turbo_stream_error(error.message, :unprocessable_entity) }
49
+ format.json { render json: { error: error.message }, status: :unprocessable_entity }
50
+ end
51
+ end
52
+
44
53
  def handle_not_found
45
54
  msg = 'Undo log not found or already restored.'
46
55
  respond_to do |format|
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Undo
5
+ class LogsController < ApplicationController
6
+ module AsyncHandler
7
+ private
8
+
9
+ def async_request?
10
+ params[:async].to_s == 'true' || request.headers['X-Undo-Async'].to_s == 'true'
11
+ end
12
+
13
+ def execute_async_restore_action
14
+ tenant = resolve_current_tenant
15
+ with_tenant_context(tenant) do
16
+ @undo_log.restore_later!(whodunnit: resolve_whodunnit_user)
17
+ end
18
+ end
19
+
20
+ def respond_with_async_restore_scheduled
21
+ notice = 'Restoration has been scheduled and is processing.'
22
+ respond_to do |format|
23
+ format.html { redirect_to determine_redirect_path, notice: notice, status: :see_other }
24
+ format.turbo_stream { render_turbo_stream_notice(notice, :accepted) }
25
+ format.json { render json: { success: true, status: 'pending', log_id: @undo_log.id }, status: :accepted }
26
+ end
27
+ end
28
+
29
+ def render_turbo_stream_notice(notice, status)
30
+ render inline: turbo_stream_template('notice', notice),
31
+ content_type: 'text/vnd.turbo-stream.html',
32
+ status: status
33
+ end
34
+
35
+ def log_status_payload
36
+ {
37
+ id: @undo_log.id,
38
+ action: @undo_log.action,
39
+ status: @undo_log.status,
40
+ soft_delete_status: @undo_log.soft_delete_status,
41
+ restore_status: @undo_log.restore_status,
42
+ error_message: @undo_log.error_message,
43
+ completed: @undo_log.completed?,
44
+ failed: @undo_log.failed?
45
+ }
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -1,18 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'logs_controller/async_handler'
4
+
3
5
  module ActiveRecord
4
6
  module Undo
5
7
  class LogsController < ApplicationController
8
+ include AsyncHandler
9
+
6
10
  before_action :set_undo_log
7
11
  before_action :check_not_found
8
12
  before_action :check_expired
13
+ before_action :check_tenant_access
9
14
 
10
15
  def restore
11
16
  return unless @undo_log
12
17
 
13
- count = @undo_log.undo_log_items.count
14
- execute_restore_action
15
- respond_with_restored_log(count)
18
+ if async_request?
19
+ execute_async_restore_action
20
+ respond_with_async_restore_scheduled
21
+ else
22
+ count = @undo_log.undo_log_items.count
23
+ execute_restore_action
24
+ respond_with_restored_log(count)
25
+ end
26
+ end
27
+
28
+ def status
29
+ return unless @undo_log
30
+
31
+ respond_to do |format|
32
+ format.json { render json: log_status_payload, status: :ok }
33
+ format.html { render plain: @undo_log.status, status: :ok }
34
+ end
16
35
  end
17
36
 
18
37
  private
@@ -29,6 +48,16 @@ module ActiveRecord
29
48
  handle_expired if @undo_log&.expired?
30
49
  end
31
50
 
51
+ def check_tenant_access
52
+ tenant = resolve_current_tenant
53
+ with_tenant_context(tenant) do
54
+ ActiveRecord::Undo.verify_configured_context!
55
+ return unless @undo_log && (@undo_log.tenant_id.present? || @undo_log.tenant_type.present?)
56
+
57
+ @undo_log.send(:verify_tenant_match!, @undo_log)
58
+ end
59
+ end
60
+
32
61
  def find_undo_log
33
62
  if params[:token].present?
34
63
  ActiveRecord::Undo::UndoLog.find_by_signed_token(params[:token])
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Undo
5
+ class RestoreJob < ActiveRecord::Undo.base_job_class
6
+ queue_as :default
7
+
8
+ def perform(undo_log_id, whodunnit: nil)
9
+ undo_log = find_undo_log(undo_log_id)
10
+ return unless undo_log
11
+
12
+ acquired = lock_and_start_processing!(undo_log)
13
+ return unless acquired
14
+
15
+ execute_restore_batches(undo_log, whodunnit)
16
+ rescue StandardError => e
17
+ handle_failure(undo_log, e)
18
+ raise e
19
+ end
20
+
21
+ private
22
+
23
+ def find_undo_log(undo_log_id)
24
+ ActiveRecord::Undo::UndoLog.find_by(id: undo_log_id)
25
+ end
26
+
27
+ def lock_and_start_processing!(undo_log)
28
+ undo_log.with_lock do
29
+ return false if undo_log.processing?
30
+ return false if undo_log.completed?
31
+
32
+ undo_log.update!(status: :processing)
33
+ true
34
+ end
35
+ end
36
+
37
+ def execute_restore_batches(undo_log, whodunnit)
38
+ actor = undo_log.send(:resolve_whodunnit, whodunnit)
39
+ undo_log.send(:with_whodunnit_context, actor) do
40
+ with_tenant_context(undo_log) do
41
+ restore_all_items_in_batches(undo_log)
42
+ cleanup_log!(undo_log)
43
+ end
44
+ end
45
+ end
46
+
47
+ def with_tenant_context(undo_log)
48
+ ten = undo_log.tenant || undo_log.tenant_id
49
+ orig = ActiveRecord::Undo.current_tenant
50
+ ActiveRecord::Undo.current_tenant = ten if ten.present?
51
+ yield
52
+ ensure
53
+ ActiveRecord::Undo.current_tenant = orig
54
+ end
55
+
56
+ def restore_all_items_in_batches(undo_log)
57
+ undo_log.undo_log_items.find_each(batch_size: 1000, order: :desc, &:restore_item!)
58
+ end
59
+
60
+ def cleanup_log!(undo_log)
61
+ undo_log.undo_log_items.in_batches(of: 1000).delete_all
62
+ undo_log.destroy!
63
+ end
64
+
65
+ def handle_failure(undo_log, exception)
66
+ return unless undo_log && ActiveRecord::Undo::UndoLog.exists?(undo_log.id)
67
+
68
+ undo_log.update!(status: :failed, error_message: exception.message)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Undo
5
+ class SoftDeleteJob < ActiveRecord::Undo.base_job_class
6
+ queue_as :default
7
+
8
+ def perform(record_class_name, record_id, undo_log_id, whodunnit: nil, tenant: nil)
9
+ undo_log = find_undo_log(undo_log_id)
10
+ return unless undo_log
11
+
12
+ acquired = lock_and_start_processing!(undo_log)
13
+ return unless acquired
14
+
15
+ execute_soft_delete_cascade(record_class_name, record_id, undo_log, whodunnit, tenant)
16
+ rescue StandardError => e
17
+ handle_failure(undo_log, e)
18
+ raise e
19
+ end
20
+
21
+ private
22
+
23
+ def find_undo_log(undo_log_id)
24
+ ActiveRecord::Undo::UndoLog.find_by(id: undo_log_id)
25
+ end
26
+
27
+ def lock_and_start_processing!(undo_log)
28
+ undo_log.with_lock do
29
+ return false if undo_log.processing?
30
+ return false if undo_log.completed?
31
+
32
+ undo_log.update!(status: :processing)
33
+ true
34
+ end
35
+ end
36
+
37
+ def execute_soft_delete_cascade(class_name, record_id, undo_log, whodunnit, tenant)
38
+ record = class_name.constantize.unscoped.find(record_id)
39
+ timestamp = record_timestamp(record, undo_log)
40
+
41
+ run_cascade_with_context(record, timestamp, undo_log, whodunnit, tenant)
42
+ undo_log.update!(status: :completed)
43
+ end
44
+
45
+ def record_timestamp(record, undo_log)
46
+ col = record.class.respond_to?(:undoable_column) ? record.class.undoable_column : :deleted_at
47
+ record.public_send(col) || undo_log.created_at || Time.current
48
+ end
49
+
50
+ def run_cascade_with_context(record, timestamp, undo_log, whodunnit, tenant)
51
+ with_execution_context(record, whodunnit, tenant) do
52
+ CascadeHandler.new(record).soft_delete_associations_batched!(timestamp, undo_log)
53
+ end
54
+ end
55
+
56
+ def with_execution_context(record, whodunnit, tenant)
57
+ actor = whodunnit || record.send(:resolve_whodunnit, nil)
58
+ ten = tenant || record.send(:resolve_tenant, nil)
59
+ record.send(:with_whodunnit_context, actor) do
60
+ orig = ActiveRecord::Undo.current_tenant
61
+ ActiveRecord::Undo.current_tenant = ten if ten.present?
62
+ yield
63
+ ensure
64
+ ActiveRecord::Undo.current_tenant = orig
65
+ end
66
+ end
67
+
68
+ def handle_failure(undo_log, exception)
69
+ return unless undo_log && ActiveRecord::Undo::UndoLog.exists?(undo_log.id)
70
+
71
+ undo_log.update!(status: :failed, error_message: exception.message)
72
+ end
73
+ end
74
+ end
75
+ end
data/config/routes.rb CHANGED
@@ -4,8 +4,10 @@ ActiveRecord::Undo::Engine.routes.draw do
4
4
  resources :logs, only: [] do
5
5
  member do
6
6
  post :restore
7
+ get :status
7
8
  end
8
9
  end
9
10
 
10
11
  post '/restore/:token', to: 'restores#create', as: :signed_restore
12
+ get '/status/:token', to: 'restores#status', as: :signed_status
11
13
  end
@@ -0,0 +1,15 @@
1
+ # db/migrate/20260920000000_add_status_and_error_message_to_undo_logs.rb
2
+ # frozen_string_literal: true
3
+
4
+ class AddStatusAndErrorMessageToUndoLogs < ActiveRecord::Migration[7.0]
5
+ def change
6
+ change_table :undo_logs, bulk: true do |t|
7
+ t.integer :status, default: 2, null: false unless column_exists?(:undo_logs, :status)
8
+ t.integer :action, default: 0, null: false unless column_exists?(:undo_logs, :action)
9
+ t.text :error_message unless column_exists?(:undo_logs, :error_message)
10
+ end
11
+
12
+ add_index :undo_logs, :status unless index_exists?(:undo_logs, :status)
13
+ add_index :undo_logs, :action unless index_exists?(:undo_logs, :action)
14
+ end
15
+ end
@@ -20,8 +20,74 @@ module ActiveRecord
20
20
  undo_log.undo_log_items.build(item: @record)
21
21
  end
22
22
 
23
+ def soft_delete_associations_batched!(timestamp, undo_log, batch_size: 1000)
24
+ associations_to_cascade.each do |reflection|
25
+ cascade_association_batched!(reflection, timestamp, undo_log, batch_size)
26
+ end
27
+ end
28
+
23
29
  private
24
30
 
31
+ def cascade_association_batched!(reflection, timestamp, undo_log, batch_size)
32
+ target = @record.public_send(reflection.name)
33
+ return if target.nil?
34
+
35
+ if reflection.collection? || target.is_a?(ActiveRecord::Relation)
36
+ cascade_collection_batched!(target, reflection, timestamp, undo_log, batch_size)
37
+ else
38
+ cascade_record_batched!(target, reflection, timestamp, undo_log, batch_size)
39
+ end
40
+ end
41
+
42
+ def cascade_collection_batched!(target, reflection, timestamp, undo_log, batch_size)
43
+ each_collection_batch(target, batch_size) do |batch|
44
+ batch.each do |associated|
45
+ cascade_record_batched!(associated, reflection, timestamp, undo_log, batch_size)
46
+ end
47
+ end
48
+ end
49
+
50
+ def each_collection_batch(target, batch_size, &)
51
+ if target.respond_to?(:in_batches)
52
+ target.in_batches(of: batch_size, &)
53
+ else
54
+ target.each_slice(batch_size, &)
55
+ end
56
+ end
57
+
58
+ def cascade_record_batched!(associated, reflection, timestamp, undo_log, batch_size)
59
+ return if skip_cascade?(associated)
60
+
61
+ if undoable_record?(associated)
62
+ process_undoable_child!(associated, timestamp, undo_log, batch_size)
63
+ elsif reflection.options[:dependent] == :destroy
64
+ associated.destroy!
65
+ end
66
+ end
67
+
68
+ def skip_cascade?(associated)
69
+ return true if associated.nil?
70
+ return true if associated.respond_to?(:soft_deleted?) && associated.soft_deleted?
71
+
72
+ if associated.respond_to?(:soft_deleted?) && associated.persisted?
73
+ associated.reload
74
+ return true if associated.soft_deleted?
75
+ end
76
+
77
+ false
78
+ end
79
+
80
+ def undoable_record?(associated)
81
+ associated.respond_to?(:acts_as_undoable) || associated.class.respond_to?(:undoable_column)
82
+ end
83
+
84
+ def process_undoable_child!(associated, timestamp, undo_log, batch_size)
85
+ self.class.new(associated).soft_delete_associations_batched!(timestamp, undo_log, batch_size: batch_size)
86
+ column_name = associated.class.respond_to?(:undoable_column) ? associated.class.undoable_column : :deleted_at
87
+ associated.update_columns(column_name => timestamp, updated_at: timestamp)
88
+ ActiveRecord::Undo::UndoLogItem.create!(undo_log_id: undo_log.id, item: associated)
89
+ end
90
+
25
91
  def cascade_to_associations!(timestamp, undo_log)
26
92
  associations_to_cascade.each do |reflection|
27
93
  associated_records_for(reflection).each do |associated|
@@ -4,7 +4,7 @@ module ActiveRecord
4
4
  module Undo
5
5
  class Configuration
6
6
  attr_accessor :retention_period, :current_user_method, :current_tenant_method,
7
- :base_controller, :default_redirect_path, :token_secret_key,
7
+ :base_controller, :base_job, :default_redirect_path, :token_secret_key,
8
8
  :token_expires_in, :error_handling
9
9
 
10
10
  def initialize
@@ -12,6 +12,7 @@ module ActiveRecord
12
12
  @current_user_method = nil
13
13
  @current_tenant_method = nil
14
14
  @base_controller = '::ApplicationController'
15
+ @base_job = '::ActiveJob::Base'
15
16
  @default_redirect_path = ->(main_app) { main_app.respond_to?(:root_path) ? main_app.root_path : '/' }
16
17
  @token_secret_key = nil
17
18
  @token_expires_in = 24.hours
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Undo
5
+ module ModelExtension
6
+ module AsyncMethods
7
+ def soft_delete_status
8
+ return nil unless soft_deleted?
9
+ return 'completed' unless undo_log
10
+
11
+ undo_log.soft_delete_status
12
+ end
13
+
14
+ def restore_status
15
+ return nil unless undo_log
16
+
17
+ undo_log.restore_status
18
+ end
19
+
20
+ def soft_delete_later!(whodunnit: nil, tenant: nil, queue: nil)
21
+ ensure_undoable_column_exists!
22
+ return false if soft_deleted?
23
+
24
+ ActiveRecord::Undo.verify_configured_context!
25
+ attrs = build_undo_log_attributes(whodunnit, tenant)
26
+ undo_log = execute_initial_soft_delete_later!(attrs)
27
+ enqueue_soft_delete_job!(undo_log, whodunnit, tenant, queue)
28
+ undo_log
29
+ end
30
+
31
+ def restore_later!(whodunnit: nil, queue: nil)
32
+ ensure_undoable_column_exists!
33
+ return false unless soft_deleted?
34
+
35
+ ActiveRecord::Undo.verify_configured_context!
36
+ log = undo_log
37
+ raise ActiveRecord::Undo::Error, 'No undo log found to restore.' unless log
38
+
39
+ log.restore_later!(whodunnit: whodunnit, queue: queue)
40
+ log
41
+ end
42
+
43
+ private
44
+
45
+ def execute_initial_soft_delete_later!(attrs)
46
+ transaction do
47
+ timestamp = Time.current
48
+ undo_log = ActiveRecord::Undo::UndoLog.create!(attrs.merge(action: :soft_delete, status: :pending))
49
+ update_soft_delete_timestamps!(timestamp)
50
+ ActiveRecord::Undo::UndoLogItem.create!(undo_log_id: undo_log.id, item: self)
51
+ undo_log
52
+ end
53
+ end
54
+
55
+ def update_soft_delete_timestamps!(timestamp)
56
+ column_name = self.class.undoable_column
57
+ update_columns(column_name => timestamp, updated_at: timestamp)
58
+ end
59
+
60
+ def enqueue_soft_delete_job!(undo_log, whodunnit, tenant, queue)
61
+ job_class = ActiveRecord::Undo::SoftDeleteJob
62
+ job = queue.present? ? job_class.set(queue: queue) : job_class
63
+ job.perform_later(self.class.name, id, undo_log.id, whodunnit: whodunnit, tenant: tenant)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end