Lawhive Framework
FrameworkPrisma

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

OptionTypeDefaultDescription
deletedAtFieldstring"deletedAt"Field name for soft delete timestamp
excludeModelsstring[][]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`)
  }
}