@thenkei/prisma-soft-delete-extension
v1.1.0
Published
Soft-delete (paranoid) support for Prisma v7+ via client extensions
Downloads
282
Readme
@thenkei/prisma-soft-delete-extension
Soft-delete support for Prisma v7+ via client extensions.
This extension turns configured delete() / deleteMany() calls into timestamp updates, filters soft-deleted rows from the normal Prisma query surface, adds lifecycle helpers for restore and intentional hard delete, and blocks write paths that would otherwise mutate deleted rows silently.
Installation
npm install @thenkei/prisma-soft-delete-extensionPeer dependencies:
@prisma/client >=7.0.0prisma >=7.0.0
Quick Start
Add a nullable DateTime field to each soft-deleted model:
model User {
id Int @id @default(autoincrement())
name String
deletedAt DateTime?
}Extend your Prisma client:
import { PrismaClient } from '@prisma/client';
import {
createSoftDeleteExtension,
defineSoftDeleteConfig,
withSoftDeleteTypes,
} from '@thenkei/prisma-soft-delete-extension';
const config = defineSoftDeleteConfig({
models: {
User: true,
},
});
const prisma = withSoftDeleteTypes(
new PrismaClient().$extends(createSoftDeleteExtension(config)),
config
);Basic lifecycle:
const user = await prisma.user.create({ data: { name: 'Alice' } });
await prisma.user.delete({ where: { id: user.id } });
const hidden = await prisma.user.findUnique({ where: { id: user.id } });
// hidden === null
await prisma.user.restore({ where: { id: user.id } });
await prisma.user.delete({ where: { id: user.id } });
await prisma.user.hardDelete({ where: { id: user.id } });Configuration
field
Global soft-delete field name. Defaults to deletedAt.
createSoftDeleteExtension({
field: 'deletedAt',
models: { User: true },
});models
Model names must match the Prisma schema exactly.
true: use the global field{ field: 'customField' }: override the field for one model
createSoftDeleteExtension({
models: {
User: true,
Post: { field: 'archivedAt' },
Comment: { field: 'removedAt' },
},
});Models omitted from models are full passthrough.
If you want model-selective TypeScript support for includeSoftDeleted, preserve the config's model-name literals with either satisfies SoftDeleteConfig or defineSoftDeleteConfig():
import {
createSoftDeleteExtension,
defineSoftDeleteConfig,
type SoftDeleteConfig,
} from '@thenkei/prisma-soft-delete-extension';
const configWithSatisfies = {
models: {
User: true,
Post: { field: 'archivedAt' },
},
} satisfies SoftDeleteConfig;
const configWithHelper = defineSoftDeleteConfig({
models: {
User: true,
Post: { field: 'archivedAt' },
},
});
createSoftDeleteExtension(configWithSatisfies);
createSoftDeleteExtension(configWithHelper);Behavior Matrix
Delete
| Operation | Configured model | Unconfigured model |
|-----------|------------------|--------------------|
| delete() | Sets the soft-delete field to the current timestamp | Physical delete |
| deleteMany() | Sets the soft-delete field on matching rows | Physical delete |
Lifecycle
| Operation | Configured model | Unconfigured model |
|-----------|------------------|--------------------|
| restore() | Restores one previously deleted row; throws P2025 if the row is active or missing | Throws configuration error |
| restoreMany() | Restores only deleted matching rows; returns { count: 0 } when none match | Throws configuration error |
| hardDelete() | Physically deletes one previously deleted row; throws P2025 if the row is active or missing | Throws configuration error |
| hardDeleteMany() | Physically deletes only deleted matching rows; returns { count: 0 } when none match | Throws configuration error |
Query
| Operation | Configured model behavior |
|-----------|---------------------------|
| findMany() | Excludes soft-deleted rows |
| findFirst() | Excludes soft-deleted rows |
| findFirstOrThrow() | Excludes soft-deleted rows |
| findUnique() | Returns null for a soft-deleted row |
| findUniqueOrThrow() | Throws P2025 for a soft-deleted row |
| count() | Excludes soft-deleted rows |
| aggregate() | Excludes soft-deleted rows |
| groupBy() | Excludes soft-deleted rows through where; having stays caller-controlled |
Unconfigured models are passthrough for all query operations.
Update / Upsert
| Operation | Configured model behavior |
|-----------|---------------------------|
| root update() | Active-only by default; explicit deletedAt predicates override |
| root updateMany() | Active-only by default; explicit deletedAt predicates override |
| root updateManyAndReturn() | Active-only by default; explicit deletedAt predicates override |
| root upsert() | Throws if the target row exists and is soft-deleted |
| nested toMany update() | Active-only by default; explicit deletedAt predicates override |
| nested toMany updateMany() | Active-only by default; explicit deletedAt predicates override |
| nested toMany delete() | Throws to prevent accidental hard delete |
| nested toMany deleteMany() | Throws to prevent accidental hard delete |
| nested toMany upsert() | Throws if the target row exists and is soft-deleted |
| nested toOne delete() | Throws to prevent accidental hard delete |
| nested toOne update() | Throws |
| nested toOne upsert() | Throws |
Nested Read Rules
- Relation filters in
whereare rewritten recursively to exclude soft-deleted configured models. includeandselecton configured toMany relations automatically addwhere: { deletedAt: null }unless you already supply adeletedAtpredicate.- Included or selected configured toOne relations become
nullwhen the related row is soft-deleted. - Compound-unique
findUnique()andfindUniqueOrThrow()stay soft-delete aware.
includeSoftDeleted
Pass includeSoftDeleted: true to these operations:
findMany()findFirst()findFirstOrThrow()findUnique()findUniqueOrThrow()count()aggregate()groupBy()
For TypeScript projects, wrap the final extended client in withSoftDeleteTypes(client, config) to expose includeSoftDeleted only on configured soft-delete models. Omitting the second argument keeps the previous broad typing behavior and widens all model delegates.
Examples:
const allUsers = await prisma.user.findMany({
includeSoftDeleted: true,
});
const totalUsers = await prisma.user.count({
includeSoftDeleted: true,
});
const groupedUsers = await prisma.user.groupBy({
by: ['name'],
_count: { _all: true },
includeSoftDeleted: true,
});
// Property 'includeSoftDeleted' does not exist on Tag args because Tag is not configured.
// await prisma.tag.findMany({ includeSoftDeleted: true });When includeSoftDeleted: true is set, nested relation filtering is skipped for that operation as well.
This option does not affect lifecycle methods, raw queries, or write hardening.
Nested Delete Guardrails
Nested relation delete and deleteMany calls against configured soft-delete models are rejected. Allowing those Prisma envelopes through would physically remove rows and bypass the extension's safety guarantees.
For configured models:
- use the model delegate
delete()/deleteMany()to perform a soft delete - use
hardDelete()/hardDeleteMany()only when you intend a permanent removal
Unconfigured models remain passthrough, including nested deletes.
Restore and Hard Delete Examples
Restore one row:
await prisma.user.restore({
where: { id: 1 },
});Restore many deleted rows:
await prisma.user.restoreMany({
where: { name: 'archived-user' },
});Hard-delete one row that has already been soft-deleted:
await prisma.user.hardDelete({
where: { id: 1 },
});Hard-delete many deleted rows:
await prisma.user.hardDeleteMany({
where: {
deletedAt: { not: null },
},
});Raw Queries
These APIs are explicit passthrough and receive no soft-delete behavior:
$queryRaw$queryRawUnsafe$executeRaw$executeRawUnsafe
findRaw is also unsupported by this package. The extension targets Prisma's relational model query surface, not provider-specific raw document APIs.
Schema Requirements
Soft-delete fields must be nullable DateTime fields with no default:
model User {
id Int @id @default(autoincrement())
name String
deletedAt DateTime?
}If a configured model can be returned through a toOne relation that you include or select, make that relation optional in Prisma so runtime nulling is type-safe:
model Comment {
id Int @id @default(autoincrement())
authorId Int?
author User? @relation(fields: [authorId], references: [id])
}Prisma Version
This package requires Prisma v7+ and uses Prisma.defineExtension.
Release Process
Versioning and publishing are managed by GitHub Actions and semantic-release.
- Pull requests into
mainrun CI. - Pushes to
mainrun CI and then semantic-release. - Do not manually bump
package.jsonversions or hand-editCHANGELOG.mdfor releases. - Use conventional commits so semantic-release can determine the correct version bump:
fix:for patch releasesfeat:for minor releasesBREAKING CHANGE:or!for major releases
Local release verification:
npm run ci
npm packSee CHANGELOG.md for release history and UPGRADE.md for 0.x to 1.0.0 migration notes. The release workflow publishes to npm using secrets.NPM_TOKEN and creates the matching GitHub release automatically.
License
MIT
Contributing
- Install dependencies with
npm install. - Run
npm run cibefore you open a pull request. - Use conventional commits so semantic-release can calculate the next version correctly.
- Do not manually bump
package.jsonversions or hand-edit release entries inCHANGELOG.md. - Keep changes small and focused, and update
README.mdorUPGRADE.mdwhen public behavior changes.
Patch and minor releases are cut automatically from main by semantic-release after CI passes, so the only supported release input is a correctly formatted commit history.
