Claude-skill-registry add-soft-delete
Implement soft-delete pattern with grace period and restoration for entities (project)
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/add-soft-delete" ~/.claude/skills/majiayu000-claude-skill-registry-add-soft-delete && rm -rf "$T"
manifest:
skills/data/add-soft-delete/SKILL.mdsource content
Add Soft-Delete Pattern Skill
Implement soft-delete semantics with grace period and restoration capabilities for NovaTune entities.
Overview
Soft-delete provides:
- Data recovery: Users can restore deleted items within grace period
- Audit trail: Deletion timestamps preserved for compliance
- Deferred cleanup: Physical deletion happens asynchronously
- Quota preservation: Storage quota released only after physical deletion
Steps
1. Add Soft-Delete Fields to Entity
Location: Extend existing entity model (e.g.,
Track.cs)
public sealed class Track { // ... existing fields ... // Soft-delete fields /// <summary> /// Timestamp when the entity was soft-deleted. /// Null if not deleted. /// </summary> public DateTimeOffset? DeletedAt { get; set; } /// <summary> /// Timestamp when physical deletion will occur. /// Null if not deleted. /// </summary> public DateTimeOffset? ScheduledDeletionAt { get; set; } /// <summary> /// Status before deletion, used for restoration. /// Null if not deleted. /// </summary> public TrackStatus? StatusBeforeDeletion { get; set; } /// <summary> /// Indicates if the entity is soft-deleted. /// </summary> [JsonIgnore] public bool IsDeleted => Status == TrackStatus.Deleted; /// <summary> /// Indicates if the entity can be restored. /// </summary> [JsonIgnore] public bool CanRestore => IsDeleted && ScheduledDeletionAt.HasValue && ScheduledDeletionAt.Value > DateTimeOffset.UtcNow; }
2. Add Status Enum Value
public enum TrackStatus { Unknown = 0, Processing = 1, Ready = 2, Failed = 3, Deleted = 4 // Add if not present }
3. Create Configuration Options
Location:
src/NovaTuneApp/NovaTuneApp.ApiService/Configuration/SoftDeleteOptions.cs
namespace NovaTuneApp.ApiService.Configuration; public class SoftDeleteOptions { public const string SectionName = "SoftDelete"; /// <summary> /// Grace period before physical deletion. /// Default: 30 days. /// </summary> public TimeSpan GracePeriod { get; set; } = TimeSpan.FromDays(30); /// <summary> /// Whether soft-delete is enabled (vs immediate delete). /// Default: true. /// </summary> public bool Enabled { get; set; } = true; }
4. Create Custom Exceptions
Location:
src/NovaTuneApp/NovaTuneApp.ApiService/Infrastructure/Exceptions/
namespace NovaTuneApp.ApiService.Infrastructure.Exceptions; /// <summary> /// Thrown when attempting to operate on a deleted entity. /// </summary> public class EntityDeletedException : Exception { public string EntityId { get; } public string EntityType { get; } public DateTimeOffset DeletedAt { get; } public EntityDeletedException(string entityType, string entityId, DateTimeOffset deletedAt) : base($"{entityType} '{entityId}' has been deleted.") { EntityType = entityType; EntityId = entityId; DeletedAt = deletedAt; } } /// <summary> /// Thrown when entity is already deleted. /// </summary> public class AlreadyDeletedException : Exception { public string EntityId { get; } public AlreadyDeletedException(string entityId) : base($"Entity '{entityId}' is already deleted.") { EntityId = entityId; } } /// <summary> /// Thrown when restoration grace period has expired. /// </summary> public class RestorationExpiredException : Exception { public string EntityId { get; } public DateTimeOffset DeletedAt { get; } public DateTimeOffset ScheduledDeletionAt { get; } public RestorationExpiredException( string entityId, DateTimeOffset deletedAt, DateTimeOffset scheduledDeletionAt) : base($"Entity '{entityId}' cannot be restored. Grace period expired at {scheduledDeletionAt}.") { EntityId = entityId; DeletedAt = deletedAt; ScheduledDeletionAt = scheduledDeletionAt; } } /// <summary> /// Thrown when trying to restore non-deleted entity. /// </summary> public class NotDeletedException : Exception { public string EntityId { get; } public NotDeletedException(string entityId) : base($"Entity '{entityId}' is not deleted and cannot be restored.") { EntityId = entityId; } }
5. Implement Soft-Delete in Service
public class TrackManagementService : ITrackManagementService { private readonly IAsyncDocumentSession _session; private readonly IOutboxService _outboxService; private readonly IOptions<SoftDeleteOptions> _softDeleteOptions; private readonly IStreamingService _streamingService; private readonly ILogger<TrackManagementService> _logger; /// <summary> /// Soft-deletes a track. /// </summary> public async Task DeleteTrackAsync( string trackId, string userId, CancellationToken ct = default) { var track = await _session.LoadAsync<Track>($"Tracks/{trackId}", ct); if (track is null) throw new TrackNotFoundException(trackId); if (track.UserId != userId) throw new TrackAccessDeniedException(trackId); if (track.Status == TrackStatus.Deleted) throw new AlreadyDeletedException(trackId); var now = DateTimeOffset.UtcNow; var scheduledDeletion = now.Add(_softDeleteOptions.Value.GracePeriod); // Preserve current status for potential restoration track.StatusBeforeDeletion = track.Status; track.Status = TrackStatus.Deleted; track.DeletedAt = now; track.ScheduledDeletionAt = scheduledDeletion; track.UpdatedAt = now; // Queue event for physical deletion worker var evt = new TrackDeletedEvent { TrackId = trackId, UserId = userId, ObjectKey = track.ObjectKey, WaveformObjectKey = track.WaveformObjectKey, FileSizeBytes = track.FileSizeBytes, DeletedAt = now, ScheduledDeletionAt = scheduledDeletion, CorrelationId = Activity.Current?.Id ?? Ulid.NewUlid().ToString(), Timestamp = now }; await _outboxService.WriteAsync(evt, partitionKey: trackId, ct: ct); // Save atomically await _session.SaveChangesAsync(ct); // Invalidate cache immediately (best effort) try { await _streamingService.InvalidateCacheAsync(trackId, userId, ct); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to invalidate cache for track {TrackId}", trackId); } _logger.LogInformation( "Track {TrackId} soft-deleted for user {UserId}, scheduled for physical deletion at {ScheduledAt}", trackId, userId, scheduledDeletion); } /// <summary> /// Restores a soft-deleted track within the grace period. /// </summary> public async Task<TrackDetails> RestoreTrackAsync( string trackId, string userId, CancellationToken ct = default) { var track = await _session.LoadAsync<Track>($"Tracks/{trackId}", ct); if (track is null) throw new TrackNotFoundException(trackId); if (track.UserId != userId) throw new TrackAccessDeniedException(trackId); if (track.Status != TrackStatus.Deleted) throw new NotDeletedException(trackId); if (!track.CanRestore) { throw new RestorationExpiredException( trackId, track.DeletedAt!.Value, track.ScheduledDeletionAt!.Value); } // Restore to previous status track.Status = track.StatusBeforeDeletion ?? TrackStatus.Ready; track.StatusBeforeDeletion = null; track.DeletedAt = null; track.ScheduledDeletionAt = null; track.UpdatedAt = DateTimeOffset.UtcNow; await _session.SaveChangesAsync(ct); _logger.LogInformation( "Track {TrackId} restored for user {UserId}", trackId, userId); return MapToDetails(track); } }
6. Add API Endpoints
// DELETE /tracks/{trackId} - Soft delete group.MapDelete("/{trackId}", async ( [FromRoute] string trackId, [FromServices] ITrackManagementService trackService, ClaimsPrincipal user, CancellationToken ct) => { if (!Ulid.TryParse(trackId, out _)) { return Results.Problem( title: "Invalid track ID", detail: "Track ID must be a valid ULID.", statusCode: StatusCodes.Status400BadRequest, type: "https://novatune.dev/errors/invalid-track-id"); } var userId = user.FindFirstValue(ClaimTypes.NameIdentifier)!; try { await trackService.DeleteTrackAsync(trackId, userId, ct); return Results.NoContent(); } catch (TrackNotFoundException) { return Results.Problem( title: "Track not found", statusCode: StatusCodes.Status404NotFound, type: "https://novatune.dev/errors/track-not-found"); } catch (TrackAccessDeniedException) { return Results.Problem( title: "Access denied", statusCode: StatusCodes.Status403Forbidden, type: "https://novatune.dev/errors/forbidden"); } catch (AlreadyDeletedException ex) { return Results.Problem( title: "Track already deleted", detail: "This track has already been deleted.", statusCode: StatusCodes.Status409Conflict, type: "https://novatune.dev/errors/already-deleted", extensions: new Dictionary<string, object?> { ["trackId"] = ex.EntityId }); } }) .WithName("DeleteTrack") .Produces(StatusCodes.Status204NoContent) .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict); // POST /tracks/{trackId}/restore - Restore soft-deleted track group.MapPost("/{trackId}/restore", async ( [FromRoute] string trackId, [FromServices] ITrackManagementService trackService, ClaimsPrincipal user, CancellationToken ct) => { if (!Ulid.TryParse(trackId, out _)) { return Results.Problem( title: "Invalid track ID", statusCode: StatusCodes.Status400BadRequest, type: "https://novatune.dev/errors/invalid-track-id"); } var userId = user.FindFirstValue(ClaimTypes.NameIdentifier)!; try { var track = await trackService.RestoreTrackAsync(trackId, userId, ct); return Results.Ok(track); } catch (TrackNotFoundException) { return Results.Problem( title: "Track not found", statusCode: StatusCodes.Status404NotFound, type: "https://novatune.dev/errors/track-not-found"); } catch (TrackAccessDeniedException) { return Results.Problem( title: "Access denied", statusCode: StatusCodes.Status403Forbidden, type: "https://novatune.dev/errors/forbidden"); } catch (NotDeletedException ex) { return Results.Problem( title: "Track not deleted", detail: "This track is not deleted and cannot be restored.", statusCode: StatusCodes.Status409Conflict, type: "https://novatune.dev/errors/not-deleted", extensions: new Dictionary<string, object?> { ["trackId"] = ex.EntityId }); } catch (RestorationExpiredException ex) { return Results.Problem( title: "Restoration period expired", detail: $"The track cannot be restored because the grace period has expired.", statusCode: StatusCodes.Status410Gone, type: "https://novatune.dev/errors/restoration-expired", extensions: new Dictionary<string, object?> { ["trackId"] = ex.EntityId, ["deletedAt"] = ex.DeletedAt, ["scheduledDeletionAt"] = ex.ScheduledDeletionAt }); } }) .WithName("RestoreTrack") .Produces<TrackDetails>(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status410Gone);
7. Create RavenDB Index for Scheduled Deletions
using Raven.Client.Documents.Indexes; using NovaTuneApp.ApiService.Models; namespace NovaTuneApp.ApiService.Infrastructure.Indexes; public class Tracks_ByScheduledDeletion : AbstractIndexCreationTask<Track> { public Tracks_ByScheduledDeletion() { Map = tracks => from track in tracks where track.Status == TrackStatus.Deleted && track.ScheduledDeletionAt != null select new { track.Status, track.ScheduledDeletionAt }; } }
8. Add Configuration to appsettings.json
{ "SoftDelete": { "GracePeriod": "30.00:00:00", "Enabled": true } }
9. Register Configuration
builder.Services.Configure<SoftDeleteOptions>( builder.Configuration.GetSection(SoftDeleteOptions.SectionName));
State Transitions
┌──────────────┐ DELETE ┌─────────────┐ RESTORE ┌──────────────┐ │ Processing │ ───────────────►│ Deleted │ ───────────────►│ Ready │ │ or Ready │ │ │ │ (previous) │ └──────────────┘ └──────┬──────┘ └──────────────┘ │ │ Grace period expires ▼ ┌─────────────┐ │ Physically │ │ Deleted │ └─────────────┘
Query Patterns
Exclude Deleted by Default
var activeTracks = await session .Query<Track>() .Where(t => t.UserId == userId && t.Status != TrackStatus.Deleted) .ToListAsync(ct);
Include Deleted (for restore UI)
var allTracks = await session .Query<Track>() .Where(t => t.UserId == userId) .ToListAsync(ct);
Find Tracks Ready for Physical Deletion
var expiredTracks = await session .Query<Track, Tracks_ByScheduledDeletion>() .Where(t => t.Status == TrackStatus.Deleted && t.ScheduledDeletionAt <= DateTimeOffset.UtcNow) .Take(batchSize) .ToListAsync(ct);
Best Practices
- Preserve previous status: Store
for accurate restorationStatusBeforeDeletion - Use transactions: Write entity update and outbox message atomically
- Validate ownership: Always check user owns entity before delete/restore
- Log state transitions: Include timestamps and correlation IDs
- Rate limit deletions: Prevent abuse (10 req/min per user)
- Exclude deleted by default: List endpoints should not show deleted items unless requested
- Cache invalidation: Invalidate immediately on soft-delete
Testing
[Fact] public async Task DeleteTrack_Should_SoftDelete_WithGracePeriod() { // Arrange var track = await CreateTestTrack(TrackStatus.Ready); // Act await _service.DeleteTrackAsync(track.TrackId, _userId, CancellationToken.None); // Assert var deleted = await _session.LoadAsync<Track>($"Tracks/{track.TrackId}"); deleted.Status.ShouldBe(TrackStatus.Deleted); deleted.DeletedAt.ShouldNotBeNull(); deleted.ScheduledDeletionAt.ShouldNotBeNull(); deleted.StatusBeforeDeletion.ShouldBe(TrackStatus.Ready); } [Fact] public async Task RestoreTrack_Should_RestorePreviousStatus() { // Arrange var track = await CreateTestTrack(TrackStatus.Processing); await _service.DeleteTrackAsync(track.TrackId, _userId, CancellationToken.None); // Act var restored = await _service.RestoreTrackAsync(track.TrackId, _userId, CancellationToken.None); // Assert restored.Status.ShouldBe(TrackStatus.Processing); } [Fact] public async Task RestoreTrack_Should_Throw_WhenGracePeriodExpired() { // Arrange - create track with expired scheduled deletion var track = await CreateTestTrack(TrackStatus.Ready); track.Status = TrackStatus.Deleted; track.DeletedAt = DateTimeOffset.UtcNow.AddDays(-31); track.ScheduledDeletionAt = DateTimeOffset.UtcNow.AddDays(-1); await _session.SaveChangesAsync(); // Act & Assert await Should.ThrowAsync<RestorationExpiredException>( () => _service.RestoreTrackAsync(track.TrackId, _userId, CancellationToken.None)); }