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
  • deletedAt column 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#

  1. Add index on deletedAt - Improves query performance
  2. Use transactions for cascades - Ensure consistency
  3. Set retention policies - Don't keep deleted data forever
  4. Provide admin restore UI - Allow data recovery
  5. Document the behavior - Make soft delete explicit