Skip to content

feat(database): complete soft-delete recovery surface (closes #165)#189

Merged
antosubash merged 3 commits into
mainfrom
feature/feat-complete-soft-delete-withtrashed-onlytrashed-restore-force-0abjh
May 10, 2026
Merged

feat(database): complete soft-delete recovery surface (closes #165)#189
antosubash merged 3 commits into
mainfrom
feature/feat-complete-soft-delete-withtrashed-onlytrashed-restore-force-0abjh

Conversation

@antosubash
Copy link
Copy Markdown
Owner

Closes #165

Summary

Adds the recovery operations missing from the existing ISoftDelete pipeline — modelled on Laravel's SoftDeletes trait. Before this PR, soft-deleted rows were hidden by a global query filter but there was no fluent way to query trashed rows, no Restore, no force-delete bypass, and developers were reaching for IgnoreQueryFilters() directly, leaking the abstraction.

What's new

Query extensions (SimpleModule.Database.SoftDelete.SoftDeleteQueryExtensions)

db.Customers.WithTrashed()    // live + soft-deleted rows
db.Customers.OnlyTrashed()    // soft-deleted rows only

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)

db.ForceDelete(customer);          // single hard delete
db.ForceDeleteRange(customers);    // batch hard delete
await db.SaveChangesAsync();

Uses a ConditionalWeakTable<DbContext, HashSet<object>> keyed marker. The existing EntityInterceptor consults it before flipping DeletedModified, so a real DELETE is issued for explicitly opted-out entities. Markers are consumed on observation and never leak across saves.

Recovery service (ISoftDeleteService<T>)

public interface ISoftDeleteService<T> where T : class, ISoftDelete
{
    Task<int> RestoreAsync(object id, CancellationToken ct = default);
    Task<int> ForceDeleteAsync(object id, CancellationToken ct = default);
    Task<int> ForceDeleteRangeAsync(IEnumerable<object> ids, CancellationToken ct = default);
    Task<int> PurgeOlderThanAsync(TimeSpan age, CancellationToken ct = default);
}

Default implementation SoftDeleteService<T, TContext> — register one per soft-deletable entity:

services.AddSoftDelete<Customer, CustomersDbContext>();

Implementation notes:

  • Cached key metadataFindEntityType/FindPrimaryKey walks happen once per closed generic, then (KeyName, KeyType) is reused.
  • Range delete is a single WHERE Id IN (...) query — dynamic LINQ expression built once, no N+1.
  • Purge is paged + chunked — orders by primary key, pulls 1000-row pages, filters DeletedAt < cutoff in memory (SQLite cannot translate DateTimeOffset ordering 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)

group.MapPost("/{id:int}/restore",
    (int id, ISoftDeleteService<Customer> svc, CancellationToken ct) =>
        CrudEndpoints.Restore(() => svc.RestoreAsync(id, ct)));

group.MapDelete("/{id:int}/force",
    (int id, ISoftDeleteService<Customer> svc, CancellationToken ct) =>
        CrudEndpoints.ForceDelete(() => svc.ForceDeleteAsync(id, ct)));

Returns 204 No Content on success, 404 Not Found if 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 PurgeOlderThanAsync into IBackgroundJobs.AddRecurringAsync and 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.Database cannot depend on the BackgroundJobs module (cycle). The service primitive (PurgeOlderThanAsync) is in the framework; the docs show the recurring-job wiring pattern for module owners.

Test plan

  • Unit tests in tests/SimpleModule.Database.Tests/SoftDeleteTests.cs (9 new tests):
    • WithTrashed returns live + trashed rows
    • OnlyTrashed returns only trashed rows
    • RestoreAsync clears IsDeleted/DeletedAt/DeletedBy; returns 0 on miss
    • ForceDelete extension issues real DELETE
    • ForceDeleteAsync/ForceDeleteRangeAsync purge by id
    • PurgeOlderThanAsync deletes only stale rows; skips non-trashed; respects cutoff
  • Full database test suite passes (86 tests including the new 9)
  • Full solution build with TreatWarningsAsErrors=true — 0 errors, 0 warnings
  • CI mirror locally:
    • npm 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)
  • Live app smoke walk-through on https://localhost:5001:
    • Login, navigate Admin → Tenants → delete a row → list refreshes (interceptor still routes Remove() correctly through the new force-delete bypass branch)
    • /health/live, /health/ready, /swagger, /admin/users, /admin/jobs, /files, /settings/me all 200

Acceptance criteria from #165

  • Extensions + service in SimpleModule.Database
  • DeletedBy/DeletedAt populated by interceptor (current user from IHttpContextAccessor) — already in place; no behavior change
  • ForceDelete opt-out path
  • xUnit tests: filter on/off, restore, force-delete, purge
  • CrudEndpoints helper for restore + force-delete
  • Docs page
  • Background purge job + per-entity retention setting — primitive shipped (PurgeOlderThanAsync); job lives in module-owner code per docs example, since the framework cannot depend on the BackgroundJobs module

Files

  • 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, ForceDelete helpers)
  • tests/SimpleModule.Database.Tests/SoftDeleteTests.cs (new, 9 tests)
  • docs/site/guide/soft-delete.md (new)
  • docs/site/.vitepress/config.ts (sidebar entry)

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.
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 10, 2026

Deploying simplemodule-website with  Cloudflare Pages  Cloudflare Pages

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

View logs

@antosubash antosubash enabled auto-merge (squash) May 10, 2026 21:10
@antosubash antosubash merged commit 3b98a75 into main May 10, 2026
6 checks passed
@antosubash antosubash deleted the feature/feat-complete-soft-delete-withtrashed-onlytrashed-restore-force-0abjh branch May 10, 2026 21:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Complete soft-delete: WithTrashed/OnlyTrashed/Restore + force-delete helpers

1 participant