active_record-undo 0.1.3 → 0.1.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3463f7d5263618417b5dc0bda22a13f8185b349c24d8b4e1610657afc5509564
4
- data.tar.gz: d89197a34b065986d1f0060a83b6d5284af254ec4e9d2710de48549f60078e55
3
+ metadata.gz: 16734d3b5f4910ac41564182b5cb70b05bf2013aeb488c1aac4475aefada4cc0
4
+ data.tar.gz: b302073f99a063d32c4ed4e811f3f5df651ac1c446c7d9ce082b03dedc44b1b6
5
5
  SHA512:
6
- metadata.gz: 6daed055a8e31d9ce9386c92943b9e07133ca69aa0f782412d032e090491cfcc080054359fa7e9340b78563addf9dc17cf3cc6dabed4baffda12a611bf32e5ff
7
- data.tar.gz: 3259b19bec3a4519ad5dda5b3d56dc94ff3ff6c4d4853fb81cdde33556cbbc530689875863a00893ac277bd042f9153261f4413ff0ad6a9dd87fcc26d09d6091
6
+ metadata.gz: f8fb0a5fa5ccd779460eb5211c2250fc40b7644f3d5c30739067b92e14caa1df582c547efab1c9aa36de12172fbf4235c7c469af90f22c6f30ab3e57a8be1690
7
+ data.tar.gz: 8a37a66af071b37b1e6fce2cd6dcc93c4365a25063d1c252a3f925e86f51f803561264a0182081abc0d6fecfcdb144f0e047233bf431e6d8a1e2827a4562e7cf
data/DOCUMENTATION.md CHANGED
@@ -39,16 +39,23 @@ graph TD
39
39
 
40
40
  ### Component Summary
41
41
 
42
- | Component | File Path | Class / Module | Core Responsibility |
43
- | :--- | :--- | :--- | :--- |
44
- | **Main Hook** | `lib/active_record/undo.rb` | `ActiveRecord::Undo` | Hooks into `ActiveSupport.on_load(:active_record)` |
45
- | **Model Extension** | `lib/active_record/undo/model_extension.rb` | `ModelExtension` | Injects DSL (`acts_as_undoable`), scopes (`kept`, `soft_deleted`), and methods (`soft_delete!`, `undoable?`, `restore!`) |
46
- | **Cascade Engine** | `lib/active_record/undo/cascade_handler.rb` | `CascadeHandler` | Inspects ActiveRecord reflections (`reflections`) and executes DFS traversal |
47
- | **Cascade Association Finder** | `lib/active_record/undo/cascade_handler/association_finder.rb` | `AssociationFinder` | Resolves which records should cascade based on dependency configuration |
48
- | **Cascade Record Updater** | `lib/active_record/undo/cascade_handler/record_updater.rb` | `RecordUpdater` | Updates the database timestamps directly bypassing callbacks |
49
- | **Audit Log Parent** | `lib/active_record/undo/undo_log.rb` | `UndoLog` | Represents the top-level deletion event and manages atomic batch restoration |
50
- | **Audit Log Child** | `lib/active_record/undo/undo_log_item.rb` | `UndoLogItem` | Maps polymorphic targets (`item_type`, `item_id`) to original deleted entities |
51
- | **Engine Link** | `lib/active_record/undo/engine.rb` | `Engine` | Appends `db/migrate/` directly to host app migration paths |
42
+ | Component | File Path | Class / Module | Core Responsibility |
43
+ | :----------------------------- | :------------------------------------------------------------- | :------------------- | :------------------------------------------------------------------------------------------------------------------------------------- |
44
+ | **Main Hook & Context** | `lib/active_record/undo.rb` | `ActiveRecord::Undo` | Hooks into `ActiveSupport.on_load(:active_record)`, manages ambient thread context (`whodunnit`, `current_tenant`), and defines errors |
45
+ | **Model Extension** | `lib/active_record/undo/model_extension.rb` | `ModelExtension` | Injects DSL (`acts_as_undoable`), scopes (`kept`, `soft_deleted`), and methods (`soft_delete!`, `undoable?`, `restore!`) |
46
+ | **Model Attribution Helper** | `lib/active_record/undo/model_extension/attribution_helper.rb` | `AttributionHelper` | Resolves user attribution and tenant context from parameters, configured procs, or thread contexts |
47
+ | **Tenant Verification** | `lib/active_record/undo/model_extension/tenant_verification.rb`| `TenantVerification` | Validates initiating context against log tenant on `#restore!`, raising `SecurityError` on mismatch |
48
+ | **Cascade Engine** | `lib/active_record/undo/cascade_handler.rb` | `CascadeHandler` | Inspects ActiveRecord reflections (`reflections`) and executes DFS traversal |
49
+ | **Cascade Association Finder** | `lib/active_record/undo/cascade_handler/association_finder.rb` | `AssociationFinder` | Resolves which records should cascade based on dependency configuration |
50
+ | **Cascade Record Updater** | `lib/active_record/undo/cascade_handler/record_updater.rb` | `RecordUpdater` | Updates the database timestamps directly bypassing callbacks |
51
+ | **Audit Log Parent** | `lib/active_record/undo/undo_log.rb` | `UndoLog` | Represents the top-level deletion event and manages atomic batch restoration, tenancy, and user attribution |
52
+ | **Audit Log Child** | `lib/active_record/undo/undo_log_item.rb` | `UndoLogItem` | Maps polymorphic targets (`item_type`, `item_id`) to original deleted entities |
53
+ | **Engine Link** | `lib/active_record/undo/engine.rb` | `Engine` | Appends `db/migrate/` directly to host app migration paths |
54
+ | **Configuration** | `lib/active_record/undo/configuration.rb` | `Configuration` | Houses retention_period, current_user_method, and current_tenant_method settings |
55
+ | **Purger Service** | `lib/active_record/undo/purger.rb` | `Purger` | Deletes expired UndoLog/Items and soft-deleted model records in batches, scoped to tenant context when present |
56
+ | **Purger Reflection Helper** | `lib/active_record/undo/purger/reflection_helper.rb` | `ReflectionHelper` | Determines cascade and nullify reflection behavior for purge routines |
57
+ | **Purge Job** | `lib/active_record/undo/purge_job.rb` | `PurgeJob` | ActiveJob background runner invoking Purger |
58
+ | **Rake Task** | `lib/active_record/undo/tasks/purge.rake` | Rake Task | Exposes `active_record_undo:purge_expired` command |
52
59
 
53
60
  ---
54
61
 
@@ -58,9 +65,15 @@ graph TD
58
65
  erDiagram
59
66
  UNDO_LOGS ||--|{ UNDO_LOG_ITEMS : "has_many"
60
67
  UNDO_LOG_ITEMS }|--|| TARGET_MODEL : "belongs_to (polymorphic)"
68
+ UNDO_LOGS }o--o| WHODUNNIT_MODEL : "belongs_to (polymorphic)"
69
+ UNDO_LOGS }o--o| TENANT_MODEL : "belongs_to (polymorphic)"
61
70
 
62
71
  UNDO_LOGS {
63
72
  bigint id PK
73
+ string whodunnit_type "optional"
74
+ bigint whodunnit_id "optional"
75
+ string tenant_type "optional"
76
+ bigint tenant_id "optional"
64
77
  datetime created_at
65
78
  datetime updated_at
66
79
  }
@@ -175,118 +188,198 @@ sequenceDiagram
175
188
  4. **Bypassing Callbacks:** Soft-deletion updates use `update_columns`. This executes a direct SQL `UPDATE` query without firing standard ActiveRecord persistence callbacks (`save`, `validate`), preventing unintended side effects during soft deletes.
176
189
  5. **Unscoped Model Resolution:** `#restore_item!` uses `klass.unscoped.find_by(id: item_id)` to locate records. This guarantees records are retrieved even when models define default scopes that filter out soft-deleted records.
177
190
  6. **Class Inheritance Security Check:** When constantizing stored class strings, the gem validates that target models inherit from `ActiveRecord::Base` to prevent arbitrary non-model constant manipulation.
191
+ 7. **Tenant Matching Verification:** During `#restore!`, if `UndoLog` is associated with a tenant, the current tenant context is verified against the log's tenant before initiating restoration, raising `ActiveRecord::Undo::SecurityError` on mismatch to prevent cross-tenant data restores.
192
+ 8. **User Attribution Transparency:** `#soft_delete!` and `#restore!` support transparent user tracking via explicit keyword arguments or ambient context resolution (`Current.user` / Thread context).
193
+ 9. **Configured Context Enforcement:** When `current_user_method` or `current_tenant_method` is configured via `ActiveRecord::Undo.configure`, operations verify that the evaluated context is not `nil`. If any configured method evaluates to `nil`, `soft_delete!` and `restore!` immediately raise `ActiveRecord::Undo::SecurityError` to prevent unauthenticated or tenantless mutations.
178
194
 
179
195
  ---
180
196
 
181
197
  ## 6. Method-by-Method Implementation Reference
182
198
 
183
- ### 6.1 `lib/active_record/undo.rb` (Entrypoint)
199
+ ### 6.1 `lib/active_record/undo.rb` (Entrypoint & Context)
184
200
 
185
201
  * **`require 'active_record'` Loader Check**
186
- - *Function*: Safely imports ActiveRecord. If ActiveRecord is not in the load path, it catches the `LoadError` and throws a detailed error instructing the developer to add `activerecord` to their `Gemfile`.
202
+ * *Function*: Safely imports ActiveRecord. If ActiveRecord is not in the load path, it catches the `LoadError` and throws a detailed error instructing the developer to add `activerecord` to their `Gemfile`.
187
203
  * **Loader Hook Block**
188
- - *Function*: Detects if `ActiveSupport` is loaded:
189
- - If present, registers `ActiveSupport.on_load(:active_record)` to inject the extension module when ActiveRecord boots.
190
- - If absent (e.g. running in simple Ruby scripts), directly includes `ModelExtension` into `ActiveRecord::Base` as a fallback.
204
+ * *Function*: Detects if `ActiveSupport` is loaded:
205
+ * If present, registers `ActiveSupport.on_load(:active_record)` to inject the extension module when ActiveRecord boots.
206
+ * If absent (e.g. running in simple Ruby scripts), directly includes `ModelExtension` into `ActiveRecord::Base` as a fallback.
207
+ * **`whodunnit` / `whodunnit=`**
208
+ * *Function*: Thread/Fiber-safe accessor for the active actor performing deletions or restorations.
209
+ * **`current_tenant` / `current_tenant=`**
210
+ * *Function*: Thread/Fiber-safe accessor for the active tenant context.
211
+ * **`verify_configured_context!`**
212
+ * *Function*: Enforces that configured `current_user_method` and `current_tenant_method` do not return `nil`. Raises `ActiveRecord::Undo::SecurityError` if any configured method evaluates to `nil`.
213
+ * **`SecurityError` Exception Class**
214
+ * *Function*: Custom error inheriting from `ActiveRecord::Undo::Error`, raised when a cross-tenant restoration attempt, missing tenant context, or configured context violation occurs.
191
215
 
192
216
  ### 6.2 `lib/active_record/undo/engine.rb` (Rails Integration)
193
217
 
194
218
  * **`initializer 'active_record_undo.migrations'`**
195
- - *Function*: Automatically runs on Rails boot to append the gem's engine migrations directory to the host application's migrations search paths. This allows host applications to detect and run gem database migrations without needing to manually copy them into the application's workspace.
219
+ * *Function*: Automatically runs on Rails boot to append the gem's engine migrations directory to the host application's migrations search paths. This allows host applications to detect and run gem database migrations without needing to manually copy them into the application's workspace.
196
220
 
197
221
  ### 6.3 `lib/active_record/undo/model_extension.rb` (Model Extension Module)
198
222
 
199
223
  * **`acts_as_undoable(column: :deleted_at)`**
200
- - *Function*: Class-level DSL macro injected into models to enable soft-deletion.
201
- - *Details*: Defines class-level configurations:
202
- - `undoable_column`: Caches the name of the column (defaults to `:deleted_at`).
203
- - `kept` scope: Returns records that are not soft-deleted (`where(column => nil)`).
204
- - `soft_deleted` scope: Returns records that are soft-deleted (`where.not(column => nil)`).
224
+ * *Function*: Class-level DSL macro injected into models to enable soft-deletion.
225
+ * *Details*: Defines class-level configurations:
226
+ * `undoable_column`: Caches the name of the column (defaults to `:deleted_at`).
227
+ * `kept` scope: Returns records that are not soft-deleted (`where(column => nil)`).
228
+ * `soft_deleted` scope: Returns records that are soft-deleted (`where.not(column => nil)`).
229
+ * `expired` scope: Returns records soft-deleted before the global retention period threshold.
205
230
  * **`soft_deleted?`**
206
- - *Function*: Checks if the current record instance has been soft-deleted. Returns `true` if the configured deletion column is populated with a timestamp.
231
+ * *Function*: Checks if the current record instance has been soft-deleted. Returns `true` if the configured deletion column is populated with a timestamp.
207
232
  * **`undoable?`**
208
- - *Function*: Checks if the soft-deleted record has a valid undo log entry available for restoration.
209
- - *Steps*:
233
+ * *Function*: Checks if the soft-deleted record has a valid undo log entry available for restoration.
234
+ * *Steps*:
210
235
  1. Returns `false` immediately if `soft_deleted?` is `false`.
211
236
  2. Performs a lightweight SQL `EXISTS` query (`ActiveRecord::Undo::UndoLogItem.joins(:undo_log).exists?(item_type: self.class.name, item_id: id)`).
212
237
  3. Returns `true` if both the log item and its parent `UndoLog` exist in the database.
213
- * **`soft_delete!`**
214
- - *Function*: Starts the cascade soft-deletion sequence for the record.
215
- - *Steps*:
238
+ * **`soft_delete!(whodunnit: nil, tenant: nil)`**
239
+ * *Function*: Starts the cascade soft-deletion sequence for the record.
240
+ * *Parameters*:
241
+ * `whodunnit`: User/Actor executing the delete (falls back to `current_user_method` or Thread/Fiber context).
242
+ * `tenant`: Tenant context (falls back to `current_tenant_method`, Thread/Fiber context, or record's tenant association).
243
+ * *Steps*:
216
244
  1. Calls `ensure_undoable_column_exists!` to verify the database column is present.
217
245
  2. Aborts and returns `false` if the record is already soft-deleted.
218
- 3. Opens an ActiveRecord database transaction block.
219
- 4. Creates a new parent `UndoLog` object.
220
- 5. Recursively invokes cascading soft-deletes on associations and marks the record itself as soft-deleted via `CascadeHandler`.
221
- 6. Saves the transaction and returns the constructed `UndoLog`.
222
- * **`restore!`**
223
- - *Function*: Restores the record from its soft-deleted state.
224
- - *Steps*:
246
+ 3. Invokes `ActiveRecord::Undo.verify_configured_context!` to enforce that configured context methods do not evaluate to `nil`.
247
+ 4. Resolves user and tenant attributes via `AttributionHelper`.
248
+ 5. Opens an ActiveRecord database transaction block, creates a parent `UndoLog`, and cascades deletion via `CascadeHandler`.
249
+ * **`restore!(whodunnit: nil)`**
250
+ * *Function*: Restores the record from its soft-deleted state.
251
+ * *Parameters*:
252
+ * `whodunnit`: User/Actor executing the restore.
253
+ * *Steps*:
225
254
  1. Verifies column presence via `ensure_undoable_column_exists!`.
226
255
  2. Aborts and returns `false` if the record is not soft-deleted.
227
- 3. Resolves the latest `UndoLogItem` that records the soft-deletion of this instance.
228
- 4. If a log item is found, it calls `restore!` on the parent `UndoLog` (which restores the entire deleted tree).
229
- 5. If no log item is found, it falls back to a simple, direct restore by setting the deletion column back to `nil`.
256
+ 3. Invokes `ActiveRecord::Undo.verify_configured_context!` to enforce that configured context methods do not evaluate to `nil`.
257
+ 4. Resolves the latest `UndoLogItem` that records the soft-deletion of this instance.
258
+ 5. If a log item is found, verifies that the current tenant context matches the log's tenant via `TenantVerification` (raises `ActiveRecord::Undo::SecurityError` if mismatched).
259
+ 6. Attributes the restoration transaction context to `whodunnit`.
260
+ 7. Calls `restore!` on the parent `UndoLog` (which restores the entire deleted tree).
261
+ 8. If no log item is found, it falls back to a simple, direct restore by setting the deletion column back to `nil`.
262
+ 9. Automatically reloads the instance attributes in-memory (using `reload`) to refresh the model state before returning `true`.
230
263
  * **`ensure_undoable_column_exists!` (Private)**
231
- - *Function*: Asserts that the configured soft-delete column exists in the database schema table. Raises `ActiveRecord::Undo::Error` if missing.
264
+ * *Function*: Asserts that the configured soft-delete column exists in the database schema table. Raises `ActiveRecord::Undo::Error` if missing.
232
265
  * **`find_latest_undo_log_item` (Private)**
233
- - *Function*: Queries `UndoLogItem` records pointing to this record, ordering by `created_at DESC` to find the most recent deletion event.
266
+ * *Function*: Queries `UndoLogItem` records pointing to this record, ordering by `created_at DESC` to find the most recent deletion event.
234
267
  * **`soft_delete_cascade_internal!(timestamp, undo_log)` (Private)**
235
- - *Function*: Wraps instantiation and invocation of `CascadeHandler` to encapsulate cascade traversal.
268
+ * *Function*: Wraps instantiation and invocation of `CascadeHandler` to encapsulate cascade traversal.
269
+
270
+ ### 6.4 `lib/active_record/undo/model_extension/attribution_helper.rb` (Attribution Helper)
271
+
272
+ * **`resolve_whodunnit(whodunnit)` (Private)**
273
+ * *Function*: Resolves the actor executing the action in priority order: explicit parameter -> `config.current_user_method.call` -> `ActiveRecord::Undo.whodunnit` -> `nil`.
274
+ * **`resolve_tenant(tenant)` (Private)**
275
+ * *Function*: Resolves the tenant context in priority order: explicit parameter -> `config.current_tenant_method.call` -> `ActiveRecord::Undo.current_tenant` -> model instance `tenant` association -> `nil`.
276
+ * **`build_undo_log_attributes(whodunnit, tenant)` (Private)**
277
+ * *Function*: Builds attributes hash supporting polymorphic model instances and scalar IDs for both actor and tenant.
278
+
279
+ ### 6.5 `lib/active_record/undo/model_extension/tenant_verification.rb` (Tenant Verification)
236
280
 
237
- ### 6.4 `lib/active_record/undo/cascade_handler.rb` (Cascade Execution)
281
+ * **`verify_tenant_match!(undo_log)` (Private)**
282
+ * *Function*: Asserts that the initiating context's tenant matches the log's tenant before restoration. Raises `ActiveRecord::Undo::SecurityError` if the context tenant is missing or mismatched.
283
+ * **`tenant_matches?(undo_log, ctx)` (Private)**
284
+ * *Function*: Compares `undo_log` tenant type/id against active context, supporting polymorphic model instances and raw scalar identifiers.
285
+
286
+ ### 6.6 `lib/active_record/undo/cascade_handler.rb` (Cascade Execution)
238
287
 
239
288
  * **`initialize(record)`**
240
- - *Function*: Caches the record instance to be cascade deleted.
289
+ * *Function*: Caches the record instance to be cascade deleted.
241
290
  * **`soft_delete_with_cascade!(timestamp, undo_log)`**
242
- - *Function*: Coordinates the cascade deletion of the current record.
243
- - *Steps*:
291
+ * *Function*: Coordinates the cascade deletion of the current record.
292
+ * *Steps*:
244
293
  1. Invokes `#cascade_to_associations!` to recurse into child tables.
245
294
  2. Invokes `#update_record_timestamps!` to mark the current record as soft-deleted.
246
295
  3. Appends an `UndoLogItem` pointing to this record to the `UndoLog` transaction.
247
296
  * **`cascade_to_associations!(timestamp, undo_log)` (Private)**
248
- - *Function*: Iterates over reflections retrieved by `AssociationFinder`, fetches their records, and calls `#cascade_to_record!` on each associated record.
297
+ * *Function*: Iterates over reflections retrieved by `AssociationFinder`, fetches their records, and calls `#cascade_to_record!` on each associated record.
249
298
  * **`cascade_to_record!(associated, reflection, timestamp, undo_log)` (Private)**
250
- - *Function*: Handles deletion of a single associated child record:
251
- - Excludes it if it is already soft-deleted.
252
- - If the child model is also configured with `acts_as_undoable`, calls its private `#soft_delete_cascade_internal!` recursively.
253
- - If it is not undoable, but configured with `dependent: :destroy`, it invokes `#destroy!` to perform a hard-deletion.
299
+ * *Function*: Handles deletion of a single associated child record:
300
+ * Excludes it if it is already soft-deleted.
301
+ * If the child model is also configured with `acts_as_undoable`, calls its private `#soft_delete_cascade_internal!` recursively.
302
+ * If it is not undoable, but configured with `dependent: :destroy`, it invokes `#destroy!` to perform a hard-deletion.
254
303
 
255
- ### 6.5 `lib/active_record/undo/cascade_handler/association_finder.rb` (Reflections Finder)
304
+ ### 6.7 `lib/active_record/undo/cascade_handler/association_finder.rb` (Reflections Finder)
256
305
 
257
306
  * **`associations_to_cascade` (Private)**
258
- - *Function*: Reflects on the model's association metadata and filters list of associations to select only those configured with `dependent: :destroy`, `dependent: :soft_delete`, or `dependent: :delete_all`.
307
+ * *Function*: Reflects on the model's association metadata and filters list of associations to select only those configured with `dependent: :destroy`, `dependent: :soft_delete`, or `dependent: :delete_all`.
259
308
  * **`associated_records_for(reflection)` (Private)**
260
- - *Function*: Fetches associated target records. Normalizes single relations and collection associations (like `has_many`) into a flat array structure.
309
+ * *Function*: Fetches associated target records. Normalizes single relations and collection associations (like `has_many`) into a flat array structure.
261
310
 
262
- ### 6.6 `lib/active_record/undo/cascade_handler/record_updater.rb` (Timestamps Updater)
311
+ ### 6.8 `lib/active_record/undo/cascade_handler/record_updater.rb` (Timestamps Updater)
263
312
 
264
313
  * **`update_record_timestamps!(timestamp)` (Private)**
265
- - *Function*: Bypasses ActiveRecord validations, callbacks, and dirty checking to directly write updates for the soft-delete column and `updated_at` timestamps using database-level `update_columns`.
314
+ * *Function*: Bypasses ActiveRecord validations, callbacks, and dirty checking to directly write updates for the soft-delete column and `updated_at` timestamps using database-level `update_columns`.
266
315
 
267
- ### 6.7 `lib/active_record/undo/undo_log.rb` (Batch Restoration)
316
+ ### 6.9 `lib/active_record/undo/undo_log.rb` (Batch Restoration)
268
317
 
269
318
  * **`restore!`**
270
- - *Function*: Triggers database restoration of the entire tree recorded under this log.
271
- - *Steps*:
272
- 1. Opens a database transaction block.
273
- 2. Iterates over associated `undo_log_items` in *reverse order* (`reverse_each`), guaranteeing parent records are restored before child records.
274
- 3. Invokes `#restore_item!` on each item.
275
- 4. Automatically calls `#destroy!` on completion to purge the audit records (`UndoLog` and nested `UndoLogItem` rows) from the database.
276
-
277
- ### 6.8 `lib/active_record/undo/undo_log_item.rb` (Item Restoration)
319
+ * *Function*: Triggers database restoration of the entire tree recorded under this log.
320
+ * *Steps*:
321
+ 1. Invokes `ActiveRecord::Undo.verify_configured_context!` to enforce that configured context methods do not evaluate to `nil`.
322
+ 2. Opens a database transaction block.
323
+ 3. Iterates over associated `undo_log_items` in *reverse order* (`reverse_each`), guaranteeing parent records are restored before child records.
324
+ 4. Invokes `#restore_item!` on each item.
325
+ 5. Automatically calls `#destroy!` on completion to purge the audit records (`UndoLog` and nested `UndoLogItem` rows) from the database.
326
+ * **`.for_whodunnit(user)`**
327
+ * *Function*: Scopes logs to those deleted by a specific user/actor. Supports both model instances and raw IDs.
328
+ * **`.for_tenant(tenant)`**
329
+ * *Function*: Scopes logs to those belonging to a specific tenant. Supports both model instances and raw IDs.
330
+
331
+ ### 6.10 `lib/active_record/undo/undo_log_item.rb` (Item Restoration)
278
332
 
279
333
  * **`restore_item!`**
280
- - *Function*: Performs restoration of the individual record referenced by the audit log item.
281
- - *Steps*:
334
+ * *Function*: Performs restoration of the individual record referenced by the audit log item.
335
+ * *Steps*:
282
336
  1. Resolves model class via `#resolve_model_class`.
283
337
  2. Resolves target record using `unscoped.find_by(id: item_id)` (unscoping ignores default scopes filtering soft-deleted records).
284
338
  3. Ensures the soft-delete column exists on the model table.
285
339
  4. Resets the soft-delete column to `nil` using `#reset_soft_delete_column!`.
286
340
  * **`resolve_model_class` (Private)**
287
- - *Function*: Constantizes the stored `item_type` string.
288
- - *Security*: Asserts that the constant is a valid class that inherits from `ActiveRecord::Base`. Raises `ActiveRecord::Undo::Error` if constantization fails or targets non-model classes.
341
+ * *Function*: Constantizes the stored `item_type` string.
342
+ * *Security*: Asserts that the constant is a valid class that inherits from `ActiveRecord::Base`. Raises `ActiveRecord::Undo::Error` if constantization fails or targets non-model classes.
289
343
  * **`ensure_column_exists!(klass, column_name)` (Private)**
290
- - *Function*: Confirms that the target soft-delete column exists in the class's table schema. Throws `ActiveRecord::Undo::Error` if missing.
344
+ * *Function*: Confirms that the target soft-delete column exists in the class's table schema. Throws `ActiveRecord::Undo::Error` if missing.
291
345
  * **`reset_soft_delete_column!(target, column_name)` (Private)**
292
- - *Function*: Bypasses standard callbacks and validations to write a `nil` value to the soft-delete column directly in the database.
346
+ * *Function*: Bypasses standard callbacks and validations to write a `nil` value to the soft-delete column directly in the database.
347
+
348
+ ### 6.11 `lib/active_record/undo/configuration.rb` (Global Configuration)
349
+
350
+ * **`Configuration#initialize`**
351
+ * *Function*: Instantiates default configurations for the gem.
352
+ * *Details*:
353
+ * `@retention_period`: Defaults to `30.days`.
354
+ * `@current_user_method`: Defaults to `nil`. Callable proc to resolve the current user/actor.
355
+ * `@current_tenant_method`: Defaults to `nil`. Callable proc to resolve the current tenant.
356
+
357
+ ### 6.12 `lib/active_record/undo/purger.rb` (Hard Purging Engine)
358
+
359
+ * **`Purger.purge_expired!(batch_size: 1000)`**
360
+ * *Function*: Cleans up all database records and logs that are past their retention limits.
361
+ * *Steps*:
362
+ 1. Delegates to `purge_expired_logs!(batch_size)` to find and clean up expired `UndoLog` and `UndoLogItem` entries in batches using direct SQL `delete_all` execution.
363
+ 2. Delegates to `purge_expired_records!(batch_size)` to eager-load the host Rails application models (preventing Zeitwerk lazy-load gaps), identify expired records via the `.expired` scope, and hard-delete them and their cascading dependent associations in batches.
364
+ * *Relational Integrity & Constraint Protection*: Bottom-up recursive cascading deletes are performed first for associations marked as `:destroy`, `:delete_all`, or `:soft_delete`. For `:nullify` configurations, foreign key values are updated to `nil`, falling back to cascading deletion if the column contains a database-level `NOT NULL` constraint.
365
+
366
+ ### 6.13 `lib/active_record/undo/purger/reflection_helper.rb` (Purger Reflection Helper)
367
+
368
+ * **`nullify_reflections(model)` (Private)**
369
+ * *Function*: Filters model reflections for `:has_many` or `:has_one` associations configured with `dependent: :nullify` where foreign key is nullable.
370
+ * **`cascade_reflections(model)` (Private)**
371
+ * *Function*: Filters model reflections for associations requiring cascade deletion (`:destroy`, `:delete_all`, `:soft_delete`, or non-nullable `:nullify`).
372
+ * **`foreign_key_nullable?(ref)` (Private)**
373
+ * *Function*: Checks table column schema to determine if the foreign key column allows null values.
374
+
375
+ ### 6.14 `lib/active_record/undo/purge_job.rb` (Background Task Worker)
376
+
377
+ * **`PurgeJob#perform(batch_size: 1000)`**
378
+ * *Function*: Runs inside ActiveJob (when available) to execute purging asynchronously.
379
+ * *Details*: Invokes `ActiveRecord::Undo::Purger.purge_expired!(batch_size: batch_size)`.
380
+
381
+ ### 6.15 `lib/active_record/undo/tasks/purge.rake` (Command Line Utility)
382
+
383
+ * **`active_record_undo:purge_expired`**
384
+ * *Function*: Exposes CLI Rake task for the purger engine.
385
+ * *Details*: Checks `ENV['BATCH_SIZE']` for custom batch sizes, falling back to 1000, and triggers `ActiveRecord::Undo::Purger.purge_expired!`.
data/README.md CHANGED
@@ -14,10 +14,13 @@ Unlike conventional soft-deletion gems, `active_record-undo` automatically captu
14
14
  - 🔄 **Cascading Soft Deletes:** Soft deletes parent models along with dependent associations (`dependent: :destroy` / `:delete_all`).
15
15
  - ⏪ **Atomic Restores:** Reverses soft deletion for an entire object tree (`undo_log.restore!` or `record.restore!`) within a single database transaction.
16
16
  - 🔍 **Restoration Verification:** Provides `#undoable?` to check if a record is soft-deleted and has a valid undo log entry available for restoration.
17
+ - 👤 **User Attribution (`whodunnit`):** Tracks who initiated soft deletions and restores automatically via ambient context (e.g. `Current.user`) or explicit arguments.
18
+ - 🏢 **Multi-Tenant Isolation:** Scopes deletion audit logs to specific tenants/accounts and enforces strict tenant matching security on restoration.
17
19
  - ⚙️ **Configurable Columns:** Supports custom soft-delete columns (e.g., `:archived_at`, `:discarded_at`) per model while defaulting to `:deleted_at`.
18
20
  - 📦 **Polymorphic Tracking:** Records deletion events via native `UndoLog` and `UndoLogItem` models—no messy JSON payload parsing required.
19
21
  - 🚂 **Zero Generator Setup:** Built on top of `Rails::Engine`. Migrations automatically hook into `rails db:migrate`.
20
22
  - 🔍 **Default Scopes & Helpers:** Provides `.kept`, `.soft_deleted`, `#soft_deleted?`, and `#undoable?` query methods out of the box.
23
+ - 🧹 **Automatic Expiration & Purging:** Configurable global retention window with automated background purging (`PurgeJob` and Rake task) to clean up old soft-deleted records and logs.
21
24
 
22
25
  ---
23
26
 
@@ -32,19 +35,19 @@ gem "active_record-undo"
32
35
  Then execute:
33
36
 
34
37
  ```bash
35
- $ bundle install
38
+ bundle install
36
39
  ```
37
40
 
38
41
  Run database migrations. The gem automatically appends its tables (`undo_logs` and `undo_log_items`) to your app's migration path:
39
42
 
40
43
  ```bash
41
- $ rails db:migrate
44
+ rails db:migrate
42
45
  ```
43
46
 
44
47
  *(Optional)* If you need to customize the migration, copy it to your host application's `db/migrate` folder:
45
48
 
46
49
  ```bash
47
- $ rails active_record_undo:install:migrations
50
+ rails active_record_undo:install:migrations
48
51
  ```
49
52
 
50
53
  ---
@@ -136,9 +139,10 @@ end
136
139
  ```
137
140
 
138
141
  `#undoable?` returns `false` if:
139
- * The record is currently active (not soft deleted).
140
- * The record was soft deleted manually via direct SQL/column updates without generating an undo log.
141
- * The corresponding `UndoLog` record was purged or already restored.
142
+
143
+ - The record is currently active (not soft deleted).
144
+ - The record was soft deleted manually via direct SQL/column updates without generating an undo log.
145
+ - The corresponding `UndoLog` record was purged or already restored.
142
146
 
143
147
  ### Inspect Deletion Logs
144
148
 
@@ -155,17 +159,19 @@ undo_log.undo_log_items.map(&:item)
155
159
  To restore a deleted object tree, you can invoke `restore!` either on the corresponding `UndoLog` or directly on the model instance itself:
156
160
 
157
161
  #### Option A: Restore from the model instance (Recommended)
162
+
158
163
  Calling `restore!` directly on the soft-deleted model automatically resolves its latest deletion event log, performs the cascading restore, and cleans up the log database rows:
159
164
 
160
165
  ```ruby
161
- # Restores the post and all comments deleted in the same batch
166
+ # Restores the post and all comments deleted in the same batch (automatically reloads in-memory)
162
167
  post.restore!
163
168
 
164
- post.reload.soft_deleted? # => false
165
- post.comments.count # => 2
169
+ post.soft_deleted? # => false
170
+ post.comments.count # => 2
166
171
  ```
167
172
 
168
173
  #### Option B: Restore from the `UndoLog`
174
+
169
175
  ```ruby
170
176
  # Reverses soft deletes for the post and comments
171
177
  undo_log.restore!
@@ -199,6 +205,184 @@ Post.unscoped.where(id: 1)
199
205
 
200
206
  ---
201
207
 
208
+ ## Expiration, Retention & Auto-Purging
209
+
210
+ To prevent database bloat, `active_record-undo` supports automated retention periods, expiration checks, and background purging for both soft-deleted records and their associated `UndoLog` audit entries.
211
+
212
+ ### 1. Global Configuration
213
+
214
+ You can configure a global retention period inside a Rails initializer:
215
+
216
+ ```ruby
217
+ # config/initializers/active_record_undo.rb
218
+ ActiveRecord::Undo.configure do |config|
219
+ # Default retention period for all soft-deleted records and undo logs (defaults to 30.days)
220
+ config.retention_period = 30.days
221
+ end
222
+ ```
223
+
224
+ If `retention_period` is set to `nil`, records and logs will never expire.
225
+
226
+ ### 2. Expiration Scopes & Helpers
227
+
228
+ The gem provides query scopes and instance predicate helpers:
229
+
230
+ - **Model `.expired` Scope**: Returns soft-deleted records older than the configured retention period.
231
+
232
+ ```ruby
233
+ Post.expired # => ActiveRecord::Relation of posts soft-deleted > 30 days ago
234
+ ```
235
+
236
+ - **Model `#expired?` Predicate**: Checks if a record is soft-deleted and past the retention period.
237
+
238
+ ```ruby
239
+ post.expired? # => true/false
240
+ ```
241
+
242
+ - **`UndoLog.expired` Scope**: Returns undo log entries older than the retention period.
243
+
244
+ ```ruby
245
+ ActiveRecord::Undo::UndoLog.expired # => logs created > 30 days ago
246
+ ```
247
+
248
+ ### 3. Background Purging
249
+
250
+ #### Purger Service
251
+
252
+ The `ActiveRecord::Undo::Purger` class performs hard SQL deletes on expired records and logs using `delete_all` (bypassing callbacks and validations for efficiency):
253
+
254
+ ```ruby
255
+ # Purge all expired soft-deleted records and undo logs in batches
256
+ ActiveRecord::Undo::Purger.purge_expired!(batch_size: 1000)
257
+ ```
258
+
259
+ > [!NOTE]
260
+ > **Relational Integrity & Constraint Protection**: To prevent database-level foreign key constraint failures (e.g. `FOREIGN KEY constraint failed`), `Purger` dynamically resolves dependent associations (such as `dependent: :destroy`, `dependent: :delete_all`, or `dependent: :soft_delete`) and recursively purges associated child records bottom-up before deleting the parent record. For associations configured with `dependent: :nullify`, it nullifies the foreign key (or cascades the deletion if the foreign key column is database-restricted to be `NOT NULL`).
261
+
262
+ #### ActiveJob Background Job
263
+
264
+ The gem provides an ActiveJob class that calls the Purger service:
265
+
266
+ ```ruby
267
+ # Enqueue the purge job to run in the background
268
+ ActiveRecord::Undo::PurgeJob.perform_later(batch_size: 1000)
269
+ ```
270
+
271
+ #### Engine Rake Task
272
+
273
+ You can run the purge task via Rake. This task is automatically loaded into host applications:
274
+
275
+ ```bash
276
+ # Run with the default batch size of 1000
277
+ $ rails active_record_undo:purge_expired
278
+
279
+ # Run with a custom batch size
280
+ $ BATCH_SIZE=500 rails active_record_undo:purge_expired
281
+ ```
282
+
283
+ ---
284
+
285
+ ## Multi-Tenant Isolation & User Attribution
286
+
287
+ To support enterprise-grade Rails applications, `active_record-undo` provides native mechanisms for multi-tenant data isolation and user auditing.
288
+
289
+ ### 1. Database Setup
290
+
291
+ Add a migration to introduce polymorphic tenant and user attribution columns to the `undo_logs` table:
292
+
293
+ ```ruby
294
+ class AddTenantAndUserAttributionToUndoLogs < ActiveRecord::Migration[7.0]
295
+ def change
296
+ change_table :undo_logs, bulk: true do |t|
297
+ t.string :whodunnit_type, null: true
298
+ t.bigint :whodunnit_id, null: true
299
+ t.string :tenant_type, null: true
300
+ t.bigint :tenant_id, null: true
301
+ end
302
+
303
+ add_index :undo_logs, [:whodunnit_type, :whodunnit_id], name: 'index_undo_logs_on_whodunnit'
304
+ add_index :undo_logs, [:tenant_type, :tenant_id], name: 'index_undo_logs_on_tenant'
305
+ end
306
+ end
307
+ ```
308
+
309
+ ### 2. Global Configuration
310
+
311
+ Configure global procs (e.g. evaluating `CurrentAttributes`) in your initializer:
312
+
313
+ ```ruby
314
+ # config/initializers/active_record_undo.rb
315
+ ActiveRecord::Undo.configure do |config|
316
+ # Callable accessors to automatically resolve context
317
+ config.current_user_method = -> { Current.user }
318
+ config.current_tenant_method = -> { Current.account }
319
+ end
320
+ ```
321
+
322
+ ### 3. Usage & Thread Context
323
+
324
+ You can set thread/fiber context explicitly:
325
+
326
+ ```ruby
327
+ ActiveRecord::Undo.whodunnit = current_user
328
+ ActiveRecord::Undo.current_tenant = current_account
329
+ ```
330
+
331
+ Or pass values explicitly as parameters to model operations:
332
+
333
+ ```ruby
334
+ # Capture user and tenant during deletion
335
+ post.soft_delete!(whodunnit: current_user, tenant: current_account)
336
+
337
+ # Track who performed the restoration
338
+ post.restore!(whodunnit: current_user)
339
+ ```
340
+
341
+ ### 4. Tenant Matching Security & Context Enforcement
342
+
343
+ #### Tenant Matching Security
344
+
345
+ When restoring a log that belongs to a tenant, the gem verifies that the initiating context's tenant matches the log's tenant. If there is a mismatch (or if no tenant is set in the context), the gem raises an `ActiveRecord::Undo::SecurityError`:
346
+
347
+ ```ruby
348
+ ActiveRecord::Undo.current_tenant = wrong_account
349
+ post.restore! # => raises ActiveRecord::Undo::SecurityError: Tenant mismatch
350
+ ```
351
+
352
+ #### Configured Context Enforcement
353
+
354
+ When `current_user_method` or `current_tenant_method` is configured via `ActiveRecord::Undo.configure`, operations enforce that the evaluated context is not `nil`:
355
+
356
+ - Calling `soft_delete!` or `restore!` when a configured method evaluates to `nil` immediately raises an `ActiveRecord::Undo::SecurityError`.
357
+ - If both user and tenant methods are configured, both must return valid non-nil objects for operations to proceed.
358
+ - If neither method is configured, operations proceed normally without requiring user or tenant context.
359
+
360
+ ```ruby
361
+ # If current_user_method is configured (e.g. -> { Current.user }),
362
+ # but the request is unauthenticated (evaluates to nil):
363
+ post.soft_delete! # => raises ActiveRecord::Undo::SecurityError: Configured current_user_method returned nil.
364
+ post.restore! # => raises ActiveRecord::Undo::SecurityError: Configured current_user_method returned nil.
365
+ ```
366
+
367
+ ### 5. Query Scopes & Purging
368
+
369
+ Query logs scoped to a specific user or tenant:
370
+
371
+ ```ruby
372
+ ActiveRecord::Undo::UndoLog.for_whodunnit(user)
373
+ ActiveRecord::Undo::UndoLog.for_tenant(account)
374
+ ```
375
+
376
+ If `current_tenant` is set in the context when running the `Purger`, purging of both logs and model records will be strictly scoped to that tenant:
377
+
378
+ ```ruby
379
+ # Scoped purge for account_1
380
+ ActiveRecord::Undo.current_tenant = account_1
381
+ ActiveRecord::Undo::Purger.purge_expired!
382
+ ```
383
+
384
+ ---
385
+
202
386
  ## How It Works
203
387
 
204
388
  1. **Cascade Inspection:** When `soft_delete!` is called, `ActiveRecord::Undo::CascadeHandler` reflects on `has_many`, `has_one`, and `belongs_to` associations configured with `dependent: :destroy` or `:delete_all`.
@@ -211,10 +395,14 @@ Post.unscoped.where(id: 1)
211
395
 
212
396
  ## Error Handling
213
397
 
214
- To ensure database integrity and provide clear debugging context, the gem raises an `ActiveRecord::Undo::Error` in the following scenarios:
215
- * **Missing Column at Runtime:** If the configured soft-delete column is missing from the database table when calling `soft_delete!` or `restore!`.
216
- * **Missing Model Class:** If a model class has been renamed or deleted, preventing the polymorphic log items from finding the target class during restore.
217
- * **Missing Column on Target Class:** If a model class exists but no longer has the target soft-delete column during restore.
398
+ To ensure database integrity and provide clear debugging context, the gem raises errors in the following scenarios:
399
+
400
+ - **Missing Column at Runtime (`ActiveRecord::Undo::Error`):** If the configured soft-delete column is missing from the database table when calling `soft_delete!` or `restore!`.
401
+ - **Missing Model Class (`ActiveRecord::Undo::Error`):** If a model class has been renamed or deleted, preventing the polymorphic log items from finding the target class during restore.
402
+ - **Missing Column on Target Class (`ActiveRecord::Undo::Error`):** If a model class exists but no longer has the target soft-delete column during restore.
403
+ - **Cross-Tenant Restoration Attempt (`ActiveRecord::Undo::SecurityError`):** If attempting to restore a record whose `UndoLog` belongs to a tenant different from the current context tenant.
404
+ - **Missing Tenant Context on Restoration (`ActiveRecord::Undo::SecurityError`):** If attempting to restore a tenant-scoped record when no tenant is set in the context.
405
+ - **Configured Context Evaluated to Nil (`ActiveRecord::Undo::SecurityError`):** If `current_user_method` or `current_tenant_method` is configured but returns `nil` during `soft_delete!` or `restore!`.
218
406
 
219
407
  ---
220
408
 
@@ -223,21 +411,21 @@ To ensure database integrity and provide clear debugging context, the gem raises
223
411
  After cloning the repository, install dependencies:
224
412
 
225
413
  ```bash
226
- $ bundle install
414
+ bundle install
227
415
  ```
228
416
 
229
417
  Run test suite via RSpec:
230
418
 
231
419
  ```bash
232
- $ bundle exec rspec
420
+ bundle exec rspec
233
421
  ```
234
422
 
235
423
  ---
236
424
 
237
425
  ## Contributing
238
426
 
239
- Bug reports and pull requests are welcome on GitHub at https://github.com/saurabh-activecode/active_record-undo.
427
+ Bug reports and pull requests are welcome on GitHub at <https://github.com/saurabh-activecode/active_record-undo>.
240
428
 
241
429
  ## License
242
430
 
243
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
431
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).