Soft Delete
Prisma extension for soft delete with automatic filtering
The soft delete extension intercepts delete operations and converts them to updates that set a deletedAt timestamp, while automatically filtering out soft-deleted records on reads.
Installation
import { PrismaClient } from "@prisma/client"
import { createSoftDeleteClient } from "@lawhive/framework"
const basePrisma = new PrismaClient()
const prisma = createSoftDeleteClient(basePrisma, {
deletedAtField: "deletedAt", // default
excludeModels: ["AuditLog", "Setting"],
})Schema Requirements
Add a nullable deletedAt field to models you want to soft delete:
model Task {
id String @id @default(cuid())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime? // Required for soft delete
}
model Setting {
id String @id
value String
// No deletedAt - will hard delete
}Behavior
Delete Operations
// This sets deletedAt instead of deleting
await prisma.task.delete({ where: { id: "task_123" } })
// Same for deleteMany
await prisma.task.deleteMany({ where: { status: "completed" } })Read Filtering
Soft-deleted records are automatically excluded:
// Only returns records where deletedAt is null
const tasks = await prisma.task.findMany()
// Same for all read operations
await prisma.task.findFirst({ where: { name: "Test" } })
await prisma.task.findUnique({ where: { id: "task_123" } })
await prisma.task.count()Querying Deleted Records
Explicitly query deletedAt to see deleted records:
// Get only deleted records
const deleted = await prisma.task.findMany({
where: { deletedAt: { not: null } },
})
// Get all records including deleted
const all = await prisma.task.findMany({
where: {
OR: [{ deletedAt: null }, { deletedAt: { not: null } }],
},
})Configuration
Options
| Option | Type | Default | Description |
|---|---|---|---|
deletedAtField | string | "deletedAt" | Field name for soft delete timestamp |
excludeModels | string[] | [] | Models to exclude from soft delete |
Excluding Models
Some models should hard delete:
const prisma = createSoftDeleteClient(basePrisma, {
excludeModels: [
"AuditLog", // Audit logs should be permanent
"Session", // Sessions can be hard deleted
"Outbox", // Outbox entries are processed and deleted
],
})
// These will hard delete
await prisma.session.delete({ where: { id: "sess_123" } })Integration with Audit
When using with the audit extension, pass softDelete config directly to createAuditedPrismaClient instead of stacking extensions:
import {
createAuditedPrismaClient,
getAuditContext,
} from "@lawhive/framework"
const EXCLUDED = ["AuditLog", "Outbox", "Event"]
const prisma = createAuditedPrismaClient(basePrisma, {
getContext: getAuditContext,
excludeModels: EXCLUDED,
writer: auditWriter,
softDelete: {
excludeModels: EXCLUDED,
},
})The audit extension handles soft deletes internally -- intercepting delete() calls, converting them to deletedAt updates, and logging them as delete operations in the audit trail.
Transaction Support
Soft delete works correctly within transactions:
await prisma.$transaction(async (tx) => {
// Creates task
const task = await tx.task.create({ data: { name: "Test" } })
// Soft deletes (sets deletedAt)
await tx.task.delete({ where: { id: task.id } })
// Won't find it (filtered out)
const found = await tx.task.findUnique({ where: { id: task.id } })
// found === null
})Restoring Records
To restore a soft-deleted record:
await prisma.task.update({
where: { id: "task_123", deletedAt: { not: null } },
data: { deletedAt: null },
})Advanced: Custom Delete Field
Use a different field name:
const prisma = createSoftDeleteClient(basePrisma, {
deletedAtField: "archivedAt",
})model Document {
id String @id
archivedAt DateTime? // Custom field name
}Linting Recommendations
Add a Prisma linting rule to ensure models have deletedAt:
// In your schema validation
const modelsRequiringSoftDelete = ["Task", "User", "Project"]
for (const model of modelsRequiringSoftDelete) {
const hasDeletedAt = schema.models[model].fields.some(
(f) => f.name === "deletedAt" && f.type === "DateTime" && !f.isRequired
)
if (!hasDeletedAt) {
throw new Error(`Model ${model} must have optional deletedAt field`)
}
}