Skip to content

Latest commit

 

History

10 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

prisma-discard

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.

Installation

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.

Schema setup

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_products

Setup

Extend 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;

Custom field name

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
  },
});

Usage

Discarding and restoring records

// 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() and deleteMany() are untouched — they still perform hard deletes.

Querying kept and discarded records

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" } });

Computed fields on results

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 null

These 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);

Association filtering

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.

Nested relation writes

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,
      },
    },
  });
});

Cascading discards

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 } });
  });
}

Why not prisma-extension-soft-delete?

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.

Roadmap

  • findUniqueKept / findUniqueDiscarded — scoped variants of findUnique for 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 where to filter records based on the discard state of their parent or related models

About

Discard-style soft deletion for Prisma.

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages