feat(database): complete soft-delete recovery surface (closes #165)#189
Merged
antosubash merged 3 commits intoMay 10, 2026
Conversation
Adds the recovery operations missing from the existing ISoftDelete pipeline: WithTrashed/OnlyTrashed query extensions, ForceDelete bypass, an ISoftDeleteService<T> with Restore/ForceDelete/Purge, and CrudEndpoints helpers — modelled on Laravel's SoftDeletes trait.
Deploying simplemodule-website with
|
| Latest commit: |
6f4cbd1
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://84632b7c.simplemodule-website.pages.dev |
| Branch Preview URL: | https://feature-feat-complete-soft-d.simplemodule-website.pages.dev |
…d-onlytrashed-restore-force-0abjh
…d-onlytrashed-restore-force-0abjh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #165
Summary
Adds the recovery operations missing from the existing
ISoftDeletepipeline — modelled on Laravel'sSoftDeletestrait. Before this PR, soft-deleted rows were hidden by a global query filter but there was no fluent way to query trashed rows, noRestore, no force-delete bypass, and developers were reaching forIgnoreQueryFilters()directly, leaking the abstraction.What's new
Query extensions (
SimpleModule.Database.SoftDelete.SoftDeleteQueryExtensions)Both ignore only the named soft-delete filter (
DatabaseConstants.SoftDeleteQueryFilterKey), so multi-tenant filters stay active when admins browse the trash.Force-delete bypass (
ForceDeleteExtensions)Uses a
ConditionalWeakTable<DbContext, HashSet<object>>keyed marker. The existingEntityInterceptorconsults it before flippingDeleted→Modified, so a realDELETEis issued for explicitly opted-out entities. Markers are consumed on observation and never leak across saves.Recovery service (
ISoftDeleteService<T>)Default implementation
SoftDeleteService<T, TContext>— register one per soft-deletable entity:Implementation notes:
FindEntityType/FindPrimaryKeywalks happen once per closed generic, then(KeyName, KeyType)is reused.WHERE Id IN (...)query — dynamic LINQ expression built once, no N+1.DeletedAt < cutoffin memory (SQLite cannot translateDateTimeOffsetordering or comparison), force-deletes the stale slice, advances the cursor only past kept rows. Bounds both DB read size and EF Core ChangeTracker memory.Endpoint helpers (
CrudEndpoints)Returns
204 No Contenton success,404 Not Foundif the id isn't found among the appropriate set.Docs (
docs/site/guide/soft-delete.md)New guide page covering the full recovery surface with a worked example for wiring
PurgeOlderThanAsyncintoIBackgroundJobs.AddRecurringAsyncand reading the retention window from a per-entity setting (SoftDelete.{Entity}.RetainDays). Sidebar entry added under Core Concepts → Database.Why the background-purge job isn't shipped
The framework
SimpleModule.Databasecannot depend on theBackgroundJobsmodule (cycle). The service primitive (PurgeOlderThanAsync) is in the framework; the docs show the recurring-job wiring pattern for module owners.Test plan
tests/SimpleModule.Database.Tests/SoftDeleteTests.cs(9 new tests):WithTrashedreturns live + trashed rowsOnlyTrashedreturns only trashed rowsRestoreAsyncclearsIsDeleted/DeletedAt/DeletedBy; returns 0 on missForceDeleteextension issues realDELETEForceDeleteAsync/ForceDeleteRangeAsyncpurge by idPurgeOlderThanAsyncdeletes only stale rows; skips non-trashed; respects cutoffTreatWarningsAsErrors=true— 0 errors, 0 warningsnpm run check✅ (lint + format + page registry + i18n + typecheck)npm run build✅dotnet build✅dotnet test --no-build✅ (~970 tests across 23 projects)npm run test:smoke -w tests/e2e✅ (47/47 Playwright smoke tests)https://localhost:5001:Remove()correctly through the new force-delete bypass branch)/health/live,/health/ready,/swagger,/admin/users,/admin/jobs,/files,/settings/meall 200Acceptance criteria from #165
SimpleModule.DatabaseDeletedBy/DeletedAtpopulated by interceptor (current user fromIHttpContextAccessor) — already in place; no behavior changeForceDeleteopt-out pathCrudEndpointshelper for restore + force-deletePurgeOlderThanAsync); job lives in module-owner code per docs example, since the framework cannot depend on the BackgroundJobs moduleFiles
framework/SimpleModule.Database/SoftDelete/SoftDeleteQueryExtensions.cs(new)framework/SimpleModule.Database/SoftDelete/ForceDeleteExtensions.cs(new)framework/SimpleModule.Database/SoftDelete/ISoftDeleteService.cs(new)framework/SimpleModule.Database/SoftDelete/SoftDeleteService.cs(new)framework/SimpleModule.Database/SoftDelete/SoftDeleteServiceCollectionExtensions.cs(new)framework/SimpleModule.Database/Interceptors/EntityInterceptor.cs(force-delete bypass branch)framework/SimpleModule.Core/Endpoints/CrudEndpoints.cs(Restore,ForceDeletehelpers)tests/SimpleModule.Database.Tests/SoftDeleteTests.cs(new, 9 tests)docs/site/guide/soft-delete.md(new)docs/site/.vitepress/config.ts(sidebar entry)