From harness-claude
Implements soft deletes in Prisma using $extends query extensions and deletedAt field. Converts deletes to updates, auto-filters queries excluding deleted records for audit trails, trash features, and data retention.
npx claudepluginhub intense-visions/harness-engineering --plugin harness-claudeThis skill uses the workspace's default tool permissions.
> Implement soft deletes in Prisma with middleware or $extends query extensions and deletedAt pattern
Executes type-safe Prisma Client queries: findUnique/findMany for reads, create/update/delete/upsert for writes, select fields, include relations. Use for database CRUD operations.
Provides expert guidance on Prisma ORM schema design, migrations, query optimization, relations modeling, and database operations for PostgreSQL, MySQL, SQLite.
Provides expert guidance on Prisma ORM for TypeScript apps: schema design, migrations, Prisma Client queries, relations, edge deployment, and performance optimization.
Share bugs, ideas, or general feedback.
Implement soft deletes in Prisma with middleware or $extends query extensions and deletedAt pattern
deletedAt field to models that need soft delete:model Post {
id String @id @default(cuid())
title String
content String
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([deletedAt])
}
$extends (recommended) to intercept delete operations and convert them to updates:const prisma = new PrismaClient().$extends({
query: {
post: {
async delete({ args, query }) {
return prisma.post.update({
...args,
data: { deletedAt: new Date() },
});
},
async deleteMany({ args, query }) {
return prisma.post.updateMany({
...args,
data: { deletedAt: new Date() },
});
},
},
},
});
findMany, findFirst, and findUnique:const prisma = new PrismaClient().$extends({
query: {
post: {
async findMany({ args, query }) {
args.where = { ...args.where, deletedAt: null };
return query(args);
},
async findFirst({ args, query }) {
args.where = { ...args.where, deletedAt: null };
return query(args);
},
async findUnique({ args, query }) {
// findUnique cannot filter on non-unique fields;
// fall back to findFirst
return prisma.post.findFirst({
where: { ...args.where, deletedAt: null },
});
},
},
},
});
function softDeleteExtension<T extends string>(modelName: T) {
return {
query: {
[modelName]: {
async delete({ args, query }: any) {
return (prisma as any)[modelName].update({
...args,
data: { deletedAt: new Date() },
});
},
async findMany({ args, query }: any) {
args.where = { ...args.where, deletedAt: null };
return query(args);
},
},
},
};
}
// Restore a soft-deleted record
await prisma.post.update({
where: { id: postId },
data: { deletedAt: null },
});
$executeRaw:await prisma.$executeRaw`DELETE FROM "Post" WHERE id = ${postId}`;
const user = await prisma.user.findUnique({
where: { id: userId },
include: { posts: { where: { deletedAt: null } } },
});
Soft delete replaces physical row deletion with a timestamp marker. The record remains in the database but is excluded from normal queries. This pattern is widely used for audit compliance, undo functionality, and data recovery.
$extends vs middleware: Prisma deprecated middleware in favor of $extends (client extensions). Extensions are type-safe, composable, and scoped to specific models. Middleware applied globally and was hard to type correctly.
Index strategy: Always add an index on deletedAt. Most queries filter on WHERE "deletedAt" IS NULL, so a partial index is ideal:
CREATE INDEX idx_post_active ON "Post" (id) WHERE "deletedAt" IS NULL;
Add this as a custom migration after Prisma generates the base migration.
Unique constraints with soft delete: A unique constraint on email breaks if you soft-delete a user and create a new one with the same email. Solutions:
CREATE UNIQUE INDEX ON "User" (email) WHERE "deletedAt" IS NULLemail_deleted_<timestamp>@@unique([email, deletedAt]) (but null handling varies by database)Cascading soft deletes: Unlike onDelete: Cascade, soft deletes do not automatically cascade to related records. Implement cascading manually in the $extends delete handler or use database triggers.
Trade-offs:
deletedAt — the extension approach prevents this but adds overheadhttps://prisma.io/docs/orm/prisma-client/queries/middleware/soft-delete-middleware