model User {
id String @id @default(uuid())
email String @unique
name String
posts Post[]
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([deletedAt])
}
model Post {
id String @id @default(uuid())
title String
body String
authorId String
author User @relation(fields: [authorId], references: [id])
deletedAt DateTime?
createdAt DateTime @default(now())
@@index([deletedAt])
}
-- migrations/20240517_add_soft_delete/migration.sql
ALTER TABLE "User" ADD COLUMN "deletedAt" TIMESTAMP(3);
ALTER TABLE "Post" ADD COLUMN "deletedAt" TIMESTAMP(3);
-- Partial indexes cover only live rows, which is the common query path.
CREATE INDEX "User_live_idx"
ON "User" ("id")
WHERE "deletedAt" IS NULL;
CREATE INDEX "Post_live_idx"
ON "Post" ("authorId")
WHERE "deletedAt" IS NULL;
-- Full index on the timestamp for retention/cleanup jobs.
CREATE INDEX "User_deletedAt_idx" ON "User" ("deletedAt");
CREATE INDEX "Post_deletedAt_idx" ON "Post" ("deletedAt");
type Delegate = {
findMany: (args: any) => Promise<any[]>;
findFirst: (args: any) => Promise<any | null>;
update: (args: any) => Promise<any>;
delete: (args: any) => Promise<any>;
};
export abstract class SoftDeleteRepository<T, D extends Delegate> {
protected constructor(protected readonly model: D) {}
findMany(where: Record<string, unknown> = {}): Promise<T[]> {
return this.model.findMany({ where: { ...where, deletedAt: null } });
}
findById(id: string): Promise<T | null> {
return this.model.findFirst({ where: { id, deletedAt: null } });
}
findWithDeleted(id: string): Promise<T | null> {
return this.model.findFirst({ where: { id } });
}
softDelete(id: string): Promise<T> {
return this.model.update({
where: { id },
data: { deletedAt: new Date() },
});
}
restore(id: string): Promise<T> {
return this.model.update({
where: { id },
data: { deletedAt: null },
});
}
hardDelete(id: string): Promise<T> {
return this.model.delete({ where: { id } });
}
}
import { Injectable } from '@nestjs/common';
import { PrismaClient, User } from '@prisma/client';
import { SoftDeleteRepository } from './soft-delete.repository';
type UserDelegate = PrismaClient['user'];
@Injectable()
export class UsersService extends SoftDeleteRepository<User, UserDelegate> {
constructor(private readonly prisma: PrismaClient) {
super(prisma.user);
}
listActive(): Promise<User[]> {
return this.findMany();
}
async deactivate(id: string): Promise<User> {
const existing = await this.findById(id);
if (!existing) {
throw new Error(`User ${id} not found or already deleted`);
}
return this.softDelete(id);
}
async reactivate(id: string): Promise<User> {
const archived = await this.findWithDeleted(id);
if (!archived) {
throw new Error(`User ${id} does not exist`);
}
return this.restore(id);
}
purge(id: string): Promise<User> {
return this.hardDelete(id);
}
}
Soft deletion keeps rows in the database and marks them as deleted instead of physically removing them, which preserves referential history, enables restore, and supports auditing. This snippet shows a focused implementation built around a Prisma schema, a raw SQL migration, and a generic repository base class that hides the soft-delete mechanics from callers.
In schema.prisma, both the User and Post models carry a nullable deletedAt DateTime? column. A null value means the record is live; a timestamp means it was deleted at that instant. Using a timestamp instead of a boolean flag records when the deletion happened, which is valuable for retention windows and audit trails. The @@index([deletedAt]) matters because nearly every query filters on this column, and without an index those filters degrade as the table grows.
The soft_delete migration adds the column and, critically, creates a partial index (WHERE deleted_at IS NULL). A partial index is smaller and faster than a full index because it only covers live rows — the exact set queried most often — while the archived rows sit outside the hot path. The migration is written as plain SQL so the deployed schema is explicit and reviewable.
SoftDeleteRepository is the heart of the pattern. It is generic over a Prisma delegate type and centralises three behaviours so no caller ever forgets them. findMany and findById inject deletedAt: null into the where clause, so deleted rows are invisible by default. softDelete sets deletedAt to the current time rather than issuing a real DELETE, and restore clears it back to null. An explicit hardDelete remains available for compliance-driven purges. Concentrating the filtering logic here avoids the most common soft-delete bug: a stray query that forgets the filter and leaks tombstoned records into a normal listing.
UsersService shows the payoff. It extends the base repository by passing prisma.user to super, and its own methods read cleanly — deactivate calls softDelete, reactivate calls restore — with no deletedAt bookkeeping in sight. The trade-off is that unique constraints still apply to soft-deleted rows, so reusing a deleted user's email may require either a composite unique index including deletedAt or a pre-check. This approach fits systems needing recoverability and audit history; when storage or privacy law demands true erasure, hardDelete is the escape hatch.
Related snips
import os
import stat
for root, _dirs, files in os.walk('/etc'):
for name in files:
path = os.path.join(root, name)
Python security audit script for exposed risky filesystem state
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
class AddSettingsToAccounts < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
add_column :accounts, :settings, :jsonb, null: false, default: {}
Postgres JSONB Partial Index for Feature Flags
module EmailNormalization
extend ActiveSupport::Concern
included do
attr_accessor :soft_warnings
Soft Validation: Normalize + Validate Email
Share this code
Here's the card — post it anywhere.