Soft Delete
Patterns for implementing soft delete with Prisma.
Overview#
Soft delete preserves data by marking records as deleted rather than removing them:
- Data recovery capability
- Audit trail preservation
- Compliance requirements
- Referential integrity
Prerequisites:
- Prisma ORM setup
deletedAtcolumn in relevant tables
Implementation#
Schema Setup#
1// prisma/schema.prisma
2model Document {
3 id String @id @default(cuid())
4 title String
5 content String?
6 deletedAt DateTime? // Soft delete marker
7 createdAt DateTime @default(now())
8 updatedAt DateTime @updatedAt
9
10 @@index([deletedAt])
11}Prisma Client Extension#
1// lib/db.ts
2import { PrismaClient } from '@prisma/client'
3
4const prisma = new PrismaClient().$extends({
5 query: {
6 document: {
7 async findMany({ args, query }) {
8 args.where = { ...args.where, deletedAt: null }
9 return query(args)
10 },
11 async findFirst({ args, query }) {
12 args.where = { ...args.where, deletedAt: null }
13 return query(args)
14 },
15 async findUnique({ args, query }) {
16 // findUnique doesn't support compound where, use findFirst
17 return query(args)
18 },
19 async delete({ args }) {
20 return prisma.document.update({
21 where: args.where,
22 data: { deletedAt: new Date() }
23 })
24 },
25 async deleteMany({ args }) {
26 return prisma.document.updateMany({
27 where: args.where,
28 data: { deletedAt: new Date() }
29 })
30 }
31 }
32 }
33})
34
35export { prisma }Manual Soft Delete#
1// lib/documents.ts
2import { prisma } from '@/lib/db'
3
4export async function softDelete(id: string) {
5 return prisma.document.update({
6 where: { id },
7 data: { deletedAt: new Date() }
8 })
9}
10
11export async function restore(id: string) {
12 return prisma.document.update({
13 where: { id },
14 data: { deletedAt: null }
15 })
16}
17
18export async function hardDelete(id: string) {
19 return prisma.document.delete({
20 where: { id }
21 })
22}Query Helpers#
1// lib/documents.ts
2// Only active (non-deleted)
3export async function findActive() {
4 return prisma.document.findMany({
5 where: { deletedAt: null }
6 })
7}
8
9// Only deleted
10export async function findDeleted() {
11 return prisma.document.findMany({
12 where: { deletedAt: { not: null } }
13 })
14}
15
16// Include deleted (for admin)
17export async function findAll() {
18 return prisma.document.findMany()
19}
20
21// With deleted flag
22export async function findWithDeletedFlag(id: string) {
23 const doc = await prisma.document.findUnique({
24 where: { id }
25 })
26
27 return doc ? { ...doc, isDeleted: !!doc.deletedAt } : null
28}Cascade Soft Delete#
1// lib/documents.ts
2export async function softDeleteWithRelations(userId: string) {
3 const now = new Date()
4
5 return prisma.$transaction([
6 // Soft delete user
7 prisma.user.update({
8 where: { id: userId },
9 data: { deletedAt: now }
10 }),
11 // Soft delete user's posts
12 prisma.post.updateMany({
13 where: { authorId: userId },
14 data: { deletedAt: now }
15 }),
16 // Soft delete user's comments
17 prisma.comment.updateMany({
18 where: { authorId: userId },
19 data: { deletedAt: now }
20 })
21 ])
22}Middleware Approach#
1// lib/db.ts
2import { PrismaClient } from '@prisma/client'
3
4const prisma = new PrismaClient()
5
6// Add soft delete middleware
7prisma.$use(async (params, next) => {
8 // Models with soft delete
9 const softDeleteModels = ['User', 'Post', 'Comment']
10
11 if (softDeleteModels.includes(params.model || '')) {
12 // Override delete to soft delete
13 if (params.action === 'delete') {
14 params.action = 'update'
15 params.args.data = { deletedAt: new Date() }
16 }
17
18 if (params.action === 'deleteMany') {
19 params.action = 'updateMany'
20 params.args.data = { deletedAt: new Date() }
21 }
22
23 // Filter out soft-deleted records
24 if (['findUnique', 'findFirst', 'findMany'].includes(params.action)) {
25 if (!params.args.where) {
26 params.args.where = {}
27 }
28 if (params.args.where.deletedAt === undefined) {
29 params.args.where.deletedAt = null
30 }
31 }
32 }
33
34 return next(params)
35})
36
37export { prisma }Restore Functionality#
1// lib/documents.ts
2export async function restoreWithRelations(userId: string) {
3 // Get the deletion timestamp
4 const user = await prisma.user.findUnique({
5 where: { id: userId }
6 })
7
8 if (!user?.deletedAt) {
9 throw new Error('User is not deleted')
10 }
11
12 const deletedAt = user.deletedAt
13
14 return prisma.$transaction([
15 // Restore user
16 prisma.user.update({
17 where: { id: userId },
18 data: { deletedAt: null }
19 }),
20 // Restore posts deleted at the same time
21 prisma.post.updateMany({
22 where: {
23 authorId: userId,
24 deletedAt: deletedAt
25 },
26 data: { deletedAt: null }
27 }),
28 // Restore comments deleted at the same time
29 prisma.comment.updateMany({
30 where: {
31 authorId: userId,
32 deletedAt: deletedAt
33 },
34 data: { deletedAt: null }
35 })
36 ])
37}Trash View#
1// app/admin/trash/page.tsx
2import { findDeleted, restore, hardDelete } from '@/lib/documents'
3import { formatDistanceToNow } from 'date-fns'
4
5export default async function TrashPage() {
6 const deletedItems = await findDeleted()
7
8 return (
9 <div className="space-y-4">
10 <h1 className="text-2xl font-bold">Trash</h1>
11
12 {deletedItems.map(item => (
13 <div key={item.id} className="flex items-center justify-between border p-4 rounded">
14 <div>
15 <h3 className="font-medium">{item.title}</h3>
16 <p className="text-sm text-gray-500">
17 Deleted {formatDistanceToNow(item.deletedAt!, { addSuffix: true })}
18 </p>
19 </div>
20
21 <div className="flex gap-2">
22 <form action={async () => {
23 'use server'
24 await restore(item.id)
25 }}>
26 <button className="text-blue-600">Restore</button>
27 </form>
28
29 <form action={async () => {
30 'use server'
31 await hardDelete(item.id)
32 }}>
33 <button className="text-red-600">Delete Forever</button>
34 </form>
35 </div>
36 </div>
37 ))}
38 </div>
39 )
40}Cleanup Job#
1// scripts/cleanup-deleted.ts
2import { prisma } from '@/lib/db'
3
4const RETENTION_DAYS = 30
5
6async function cleanup() {
7 const cutoffDate = new Date()
8 cutoffDate.setDate(cutoffDate.getDate() - RETENTION_DAYS)
9
10 // Hard delete old soft-deleted records
11 const result = await prisma.document.deleteMany({
12 where: {
13 deletedAt: {
14 not: null,
15 lt: cutoffDate
16 }
17 }
18 })
19
20 console.log(`Permanently deleted ${result.count} records`)
21}
22
23cleanup()
24 .catch(console.error)
25 .finally(() => prisma.$disconnect())Best Practices#
- Add index on deletedAt - Improves query performance
- Use transactions for cascades - Ensure consistency
- Set retention policies - Don't keep deleted data forever
- Provide admin restore UI - Allow data recovery
- Document the behavior - Make soft delete explicit
Related Patterns#
- Prisma - Prisma setup and basics
- Transactions - Transaction handling
- Migrations - Adding deletedAt column