Frank's Dev Blog

Implementing Soft Deletion in Prisma with Client Extensions

Summary

This post, my first, walks through extending the popular TypeScript ORM Prisma to enable "soft deleting" records in a database with ease. It assumes basic familiarity with NodeJS, TypeScript, and Prisma ORM.


Prerequisites / Background


Intended Audience

Developers who already use Prisma ORM in their TypeScript applications.


Intro

Prisma ORM is a Node.js and TypeScript ORM with an intuitive data model, automated migrations, type-safety, and auto-completion.

Prisma has been my go-to TypeScript ORM for a long time now. I love defining my database schema in a schema file and automating database migrations with Prisma Migrate. Writing queries is just a very pleasant experience with an IDE's autocomplete.

Soft Deletion

Soft deletion is a technique where you mark database records as "deleted" without actually deleting them from the database.5 This lets you easily restore data, preserve detailed history of data, and sometimes is required for compliance. Typically this is done by:

  1. adding a deletedAt timestamp column (which provides more context than a boolean column isDeleted)
  2. updating all SELECT and UPDATE queries in your application code explicitly exclude records with a value in deletedAt
  3. replacing DELETE statements with UPDATEs that set the deletedAt column to the current datetime

How might we do this is Prisma? There's an error-prone manual way and a proper way.

The Manual Approach (don't do this!)

The simple way, and most error prone, is to remember to add deletedAt: null to every single Prisma query's where clause and in every nested where clause in nested queries, includes, and selects.

// schema.prisma

model User {
  id String @id
  email String
  name String

  accounts Account[]

  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt
  deletedAt DateTime?
}

model Account {
  userId            String
  type              String
  provider          String
  providerAccountId String
  // ...

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@id([provider, providerAccountId])
}
async function findAllActiveGitHubUsers() {
  return await prisma.user.findMany({
    where: {
      deletedAt: null,
      accounts: {
        some: {
          provider: "github",
          deletedAt: null // also don't forget it here!
        }
      }
    },
    include: {
      accounts: {
        where: { deletedAt: null } // or here, you can no 
      }
      // you can no longer simply do:
      // accounts: true
    }
  });
}

async function deleteUser(userId: string) {
  // weird, do an update instead of an delete? kind of gross!
  return await prisma.user.update({
    where: { id: userId },
    data: { deletedAt: new Date() }
  });
} 

I promise you will forget to include deletedAt in multiple places. It's easy to do, very hard to remember, and hard to detect as the database records marked as deleted likely won't throw errors because they are totally normal records. At a previous position, I inherited a large codebase that had half-implemented soft deletion using Prisma and as a result only about 50% of queries, many of which contained deeply nested queries, and selects/includes, I'd come across properly had deletedAt: null everywhere.

The Proper Approach

It would be great if the deletedAt: null field could automatically be added to every Prisma query, and delete and deleteMany knew to update the deletedAt field if it exists. Thankfully, Prisma provides "client extensions"6 for this exact use case. Extensions let us hook into the Prisma client operations (find, findMany, update, delete, etc.) to run custom code and/or modify the actual operation Prisma will perform.

Target State

We want to write a Prisma client extension that is aware of what tables use soft deletion and which don't, and factor that in to every read, update, and delete operation without us having to do anything special.

// This should find all user records that have not been marked deleted
await prisma.user.findMany();

// This should find the first record with name "Frank" that has not been marked deleted
await prisma.user.findFirst({
  where: { name: "Frank" }
});

// This should find the first record with name "Frank" that has not been marked deleted, and include all related account records that have not been marked deleted
await prisma.user.findFirst({
  where: { name: "Frank" },
  include: { accounts: true }
});

// You get the idea by now.
await prisma.user.findFirst({
  where: { name: "Frank" },
  include: { accounts: { where: { provider: "github" } } }
});


// This should return a count of records not marked as deleted
await prisma.user.count();

// This should actually set the deletedAt column to the current timestamp and leave the records in the database
await prisma.account.deleteMany({
  where: { /** ... **/ },
});

// This should return all "deleted" records that match the where clause
await prisma.user.findMany({
  where: { /** ... **/ },
  onlyDeleted: true
});

// This should return ALL records that match the where clause, even those marked as deleted
await prisma.user.findMany({
  where: { /** ... **/ },
  includeDeleted: true
})

The Prisma docs actually already have a page7 about implementing soft deletion with middleware, but it is incomplete and frankly8 not helpful at all. In their defense, there's a disclaimer that states:

This page demonstrates a sample use of middleware. We do not intend the sample to be a fully functional soft delete feature and it does not cover all edge cases.

But still, it isn't that hard to provide a complete implementation in the docs for those that need it! Since Prisma is open source, including its docs, I will be opening a PR to improve that page with a full solution.

Implementing a Recursive Soft Delete Extension

First, we can extend an existing Prisma client and give it a handy name.

// db.ts

import { PrismaClient } from "@prisma/client";

const basePrisma = new PrismaClient();

export const prisma = basePrisma.$extends({
  name: "softDeleteExtension",
});

Be sure to export the extended Prisma client and not the base one. Now, we can extend the query methods for all models:

...
export const prisma = basePrisma.$extends({
  name: "softDeleteExtension",
  query: {
    $allModels: {
      findMany({ model, args, query }) {
        // For now changes nothing, we will next add custom logic here to change the args passed to the query method
        return query(args);
      },
    }
  }
});
...

We can define a method that will add deletedAt: null recursively to queries and nested queries. Not all of our models will necessarily have a deletedAt column. For some tables, we might not care about keeping deleted records around. As far as I can tell, we cannot determine at runtime with the Prisma client which models have which fields. Therefore, we need to keep an array of the names of models that have a deletedAt field. If anyone knows of a way to do this without hardcoding the array, please let me know!

We also want to be able to explicitly query for deleted records sometimes -- for example for a "trash" page in your application that shows user deleted records. For this use case, we want to support adding new fields to queries includeDeleted: boolean and onlyDeleted: boolean to manually opt-out of automatic filtering when needed.

Lastly, we want to make the delete and deleteMany operations actually call update and updateMany when they are being called on models using soft deletion.

The Full Solution

// db.ts

import { PrismaClient } from "@prisma/client";

// from schema.prisma
const softDeleteModels = [
  "User",
  "user", // from the Account model
  "Account",
] as const;

// from schema.prisma
const nestedModelNamesWithSoftDelete = [
  "accounts" // from the User model
];

function injectDeletedAtFilter(
  model: string,
  args: any,
  isNested: boolean = false
) {
  if (!args) return args;

  if (
    (isNested ? nestedModelNamesWithSoftDelete : softDeleteModels).includes(
      model as any
    )
  ) {
    if (args === true) {
      return {
        where: {
          deletedAt: null,
        },
      };
    } else if (typeof args === "object") {
      if (args.includeDeleted) {
        // If explicit override flags exist, skip default filtering
        delete args.includeDeleted;
        return args;
      }

      if (args.onlyDeleted) {
        args.where = { ...args.where, deletedAt: { not: null } };
        delete args.onlyDeleted;
        return args;
      }

      // Default: filter out deleted
      if (args.where) {
        args.where = { ...args.where, deletedAt: null };
      } else {
        args.where = { deletedAt: null };
      }
    }
  }

  if (typeof args === "object") {
    if (args.include) {
      // Recursively apply to nested includes
      for (const key of Object.keys(args.include)) {
        args.include[key] = injectDeletedAtFilter(key, args.include[key], true);
      }
    }

    if (args.select) {
      for (const key of Object.keys(args.select)) {
        if (typeof args.select[key] === "object") {
          args.select[key] = injectDeletedAtFilter(key, args.select[key], true);
        }
      }
    }
  }

  return args;
}

export const prisma = basePrisma.$extends({
  name: "softDeleteExtension",
  query: {
    $allModels: {
      findMany({ model, args, query }) {
        args = injectDeletedAtFilter(model, args);
        return query(args);
      },
      findFirst({ model, args, query }) {
        args = injectDeletedAtFilter(model, args);
        return query(args);
      },
      findUnique({ model, args, query }) {
        // Prisma throws if deletedAt is added to unique, so convert to findFirst
        args = injectDeletedAtFilter(model, args);
        if (softDeleteModels.includes(model as any)) {
          //@ts-expect-error Can't figure out the proper type here
          return basePrisma[model as any].findFirst(args);
        }
        return query(args);
      },
      delete({ model, args, query }) {
        if (softDeleteModels.includes(model as any)) {
          //@ts-expect-error Can't figure out the proper type here
          return basePrisma[model as any].update({
            ...args,
            data: { deletedAt: new Date() },
          });
        } else {
          return query(args);
        }
      },
      deleteMany({ model, args, query }) {
        if (softDeleteModels.includes(model as any)) {
          //@ts-expect-error Can't figure out the proper type here
          return basePrisma[model as any].updateMany({
            where: args.where,
            data: { deletedAt: new Date() },
          });
        } else {
          return query(args);
        }
      },
      count({ model, args, query }) {
        args = injectDeletedAtFilter(model, args);
        return query(args);
      },
    },
  },
});

Now all of the queries above in the "Target State" section will operate as expected! You can write Prisma queries naturally without having to remember to add deletedAt everywhere. All you need to remember to do is to add deletedAt to new models when you want, and keep the arrays softDeleteModels and nestedModelNamesWithSoftDelete.

Next Steps

While writing this and referencing the Prisma docs, I realized it might make more sense to extend the Prisma client to add entirely new operations to replace the includeDeleted and onlyDeleted fields. Instead, it should be possible to implement:

await prisma.findManyDeleted({
  where: { /** ... */ }
})

await prisma.findManyIncludingDeleted({
  where: { /** ... */ }
})

If I have time I will update my own implementation in my project and this article.

  1. NodeJS

  2. TypeScript

  3. ORM

  4. Prisma ORM

  5. Soft deletion

  6. Prisma Client Extensions

  7. Prisma Soft Delete Extension Sample

  8. Pun intended

#database #nodejs #prisma #typescript