Discard-style soft deletion for Prisma. Inspired by the discard gem for Ruby on Rails.
Philosophy: No magic. No global query filtering. No overriding delete(). You get explicit named scopes and mutation methods. You decide when and where to use them.
Note: This package is not yet published to npm. Install directly from the GitHub repository:
npm install github:codemancers/prisma-discard
# or
pnpm add github:codemancers/prisma-discard
# or
yarn add github:codemancers/prisma-discard@prisma/client >= 5.0.0 is required as a peer dependency.
Add a discardedAt field to any model you want soft deletion on:
model Product {
id String @id @default(cuid())
name String
discardedAt DateTime?
@@index([discardedAt])
}Generate the migration:
npx prisma migrate dev --name add_discarded_at_to_productsExtend your Prisma client with withDiscard:
import { PrismaClient } from "@prisma/client";
import { withDiscard } from "prisma-discard";
const prisma = new PrismaClient().$extends(
withDiscard({
models: {
Product: true, // uses default field: discardedAt
User: true,
},
}),
);
export default prisma;If you prefer a different column name (e.g. deletedAt):
withDiscard({
models: {
Product: { field: "deletedAt" },
},
});You can also set a global default that applies to all models:
withDiscard({
defaultConfig: { field: "deletedAt" },
models: {
Product: true, // uses deletedAt
User: true, // uses deletedAt
Order: { field: "discardedAt" }, // overrides to discardedAt
},
});// Soft-delete a single record — sets discardedAt to now
await prisma.product.discard({ where: { id } });
// Restore a single record — sets discardedAt to null
await prisma.product.undiscard({ where: { id } });
// Soft-delete multiple records
await prisma.product.discardMany({ where: { category: "archived" } });
// Restore multiple records
await prisma.product.undiscardMany({ where: { category: "archived" } });
delete()anddeleteMany()are untouched — they still perform hard deletes.
Scoped queries are always explicit. Regular findMany() / findFirst() are unaffected and will return all records regardless of discardedAt.
// Only active (not discarded) records
await prisma.product.findManyKept();
await prisma.product.findManyKept({ where: { category: "electronics" } });
// Only soft-deleted records
await prisma.product.findManyDiscarded();
// First match
await prisma.product.findFirstKept({ where: { slug: "my-product" } });
await prisma.product.findFirstDiscarded({ where: { ownerId: userId } });
// Counts
await prisma.product.countKept();
await prisma.product.countDiscarded({ where: { category: "old" } });Every record returned by any Prisma query gets two computed boolean fields:
const product = await prisma.product.findFirst({ where: { id } });
product.isDiscarded; // true if discardedAt is set
product.isKept; // true if discardedAt is nullThese work on any query — not just the scoped helpers:
const products = await prisma.product.findMany();
const active = products.filter((p) => p.isKept);
const archived = products.filter((p) => p.isDiscarded);prisma-discard does not filter associations automatically. If you come from Rails, you might expect a .kept scope you can chain on any relation — Prisma has no equivalent. Instead, you pass the condition explicitly via Prisma's nested where.
Filter included relations
// Fetch kept posts, include only their kept comments
await prisma.post.findManyKept({
include: {
comments: {
where: { discardedAt: null },
},
},
});Filter through a parent relation
// Only fetch kept comments whose parent post is also kept
await prisma.comment.findManyKept({
where: {
post: { discardedAt: null },
},
include: { post: true },
});Combine both directions
// Kept posts owned by a kept user, with only kept comments included
await prisma.post.findManyKept({
where: {
user: { discardedAt: null },
},
include: {
user: true,
comments: {
where: { discardedAt: null },
},
},
});Using findUniqueKept with a relation filter
// Look up an active post by slug, include only its kept comments
const post = await prisma.post.findUniqueKept({
where: { slug },
include: {
comments: {
where: { discardedAt: null },
},
},
});If you use a custom field name (e.g. deletedAt), substitute that field name in the nested where conditions.
discard, undiscard, discardMany, and undiscardMany are not available inside Prisma's nested relation write syntax:
// ❌ This will not work
await tx.purchaseRequest.update({
where: { id },
data: {
lineItems: {
discardMany: {}, // Unknown argument — Prisma rejects this
},
},
});Why: Prisma's nested write API (data.lineItems.*) is a fixed set of built-in operations — create, update, updateMany, delete, deleteMany, connect, disconnect, upsert, set. These are part of Prisma's generated input types (e.g. LineItemUncheckedUpdateManyWithoutPurchaseRequestNestedInput) and there is no mechanism in Prisma's extension API to inject custom operations into them. The $allModels extension only adds methods to top-level model delegates (prisma.lineItem.*), not to nested write inputs.
The workaround is to perform the operations on the related model directly, either before or after the parent update:
// ✅ Perform the discard on the related model separately
await tx.$transaction(async (tx) => {
await tx.lineItem.discardMany({ where: { purchaseRequestId: id } });
await tx.purchaseRequest.update({
where: { id },
data: {
lineItems: {
create: data.lineItems,
},
},
});
});There is no automatic cascade. Handle it explicitly in your service layer:
async function discardPost(id: string) {
await prisma.$transaction(async (tx) => {
await tx.comment.discardMany({ where: { postId: id } });
await tx.post.discard({ where: { id } });
});
}prisma-extension-soft-delete |
prisma-discard |
|
|---|---|---|
| Filters reads globally | Yes | No — explicit scopes only |
Overrides delete() |
Yes | No — hard deletes still work |
| Named scoped queries | No | Yes |
| Computed result fields | No | Yes |
Global filtering (the approach prisma-extension-soft-delete takes) mirrors the paranoia gem in Rails, which was later superseded by discard precisely because implicit behaviour made debugging harder. prisma-discard follows discard's philosophy: make soft deletion visible and intentional.
-
findUniqueKept/findUniqueDiscarded— scoped variants offindUniquefor lookups by unique fields (e.g. slug, email) that should only match active records - Transaction-aware cascade helpers — utilities to discard/undiscard related records atomically within a
$transaction, without manually wiring each model - Association-aware scope documentation and examples — guide on using Prisma's nested
whereto filter records based on the discard state of their parent or related models