Full API documentation for prisma-safe-delete. For a quick overview, see the README.
All read operations automatically inject deleted_at: null filters:
await safePrisma.user.findMany();
await safePrisma.user.findFirst({ where: { name: 'John' } });
await safePrisma.user.findUnique({ where: { id: 'user-1' } });
await safePrisma.user.findFirstOrThrow({ where: { email: 'john@example.com' } });
await safePrisma.user.findUniqueOrThrow({ where: { id: 'user-1' } });
await safePrisma.user.count();
await safePrisma.user.aggregate({ _count: true });
await safePrisma.user.groupBy({ by: ['name'], _count: true });Filters are automatically injected into relation queries:
// Posts in include are filtered
const user = await safePrisma.user.findUnique({
where: { id: 'user-1' },
include: { posts: true } // Only returns non-deleted posts
});
// Nested relations are filtered too
const user = await safePrisma.user.findUnique({
where: { id: 'user-1' },
include: {
posts: {
include: { comments: true } // Only non-deleted comments
}
}
});
// select works the same way
const user = await safePrisma.user.findUnique({
where: { id: 'user-1' },
select: {
email: true,
posts: { select: { title: true } } // Only non-deleted posts
}
});
// _count is filtered
const user = await safePrisma.user.findUnique({
where: { id: 'user-1' },
include: {
_count: { select: { posts: true } } // Counts only non-deleted posts
}
});The some, every, and none relation filters exclude soft-deleted records:
// Find users who have at least one active post
const users = await safePrisma.user.findMany({
where: {
posts: { some: { title: { contains: 'hello' } } } // Ignores deleted posts
}
});
// Find users where all their posts are published
const users = await safePrisma.user.findMany({
where: {
posts: { every: { published: true } } // Only considers non-deleted posts
}
});// Create operations pass through unchanged
await safePrisma.user.create({ data: { email: 'new@example.com' } });
await safePrisma.user.createMany({ data: [...] });
// update and updateMany filter out soft-deleted records
// (you cannot accidentally modify a deleted record)
await safePrisma.user.update({ where: { id: 'user-1' }, data: { name: 'Jane' } });
await safePrisma.user.updateMany({ where: { ... }, data: { ... } });
// upsert filters out soft-deleted records from the where clause
// (if a soft-deleted record matches, the create branch fires instead)
await safePrisma.user.upsert({ where: { ... }, create: { ... }, update: { ... } });// Soft delete a single record (with cascade)
const { record, cascaded } = await safePrisma.user.softDelete({ where: { id: 'user-1' } });
console.log(record); // The deleted user record (or null if not found)
console.log(cascaded); // { Post: 3, Comment: 7 } — cascade-deleted counts by model
// Soft delete multiple records (with cascade)
const { count, cascaded } = await safePrisma.user.softDeleteMany({ where: { name: 'Test' } });
console.log(count); // Number of matched records soft-deleted
console.log(cascaded); // Aggregated cascade counts across all matched recordsThe cascaded field is a Record<string, number> mapping model names to the number of records that were cascade-deleted. It's empty ({}) when there are no cascade children.
Audit trail: The cascaded return value is ephemeral — it is not persisted anywhere. For automatic audit logging of mutations, see Audit Logging. For manual logging, use an interactive transaction:
await safePrisma.$transaction(async (tx) => {
const { record, cascaded } = await tx.user.softDelete({
where: { id: 'user-1' },
deletedBy: currentUserId,
});
await tx.auditLog.create({
data: {
action: 'SOFT_DELETE',
modelName: 'User',
recordId: record?.id,
cascaded: JSON.stringify(cascaded),
performedBy: currentUserId,
},
});
});Preview what would be cascade-deleted without making any changes:
const { wouldDelete } = await safePrisma.user.softDeletePreview({ where: { name: 'Test' } });
console.log(wouldDelete); // { User: 2, Post: 5, Comment: 12 }If a model has a nullable String field named deleted_by or deletedBy, the deletedBy parameter becomes required on softDelete and softDeleteMany (enforced at compile time):
model Customer {
id String @id @default(cuid())
email String @unique
deleted_at DateTime?
deleted_by String? // Enables deletedBy tracking
}// TypeScript requires deletedBy for models with a deleted_by field
await safePrisma.customer.softDelete({
where: { id: 'cust-1' },
deletedBy: 'admin-user-id', // Required — won't compile without it
});
// Models without deleted_by don't need it
await safePrisma.user.softDelete({ where: { id: 'user-1' } });Restore soft-deleted records by setting deleted_at back to null and unmangling unique fields:
// Restore a single record (does NOT restore children)
const user = await safePrisma.user.restore({ where: { id: 'user-1' } });
// Restore multiple records
const result = await safePrisma.user.restoreMany({ where: { name: 'Test' } });
console.log(result.count); // Number of records restored
// Restore with cascade - restores parent AND all cascade-deleted children
const { record, cascaded } = await safePrisma.user.restoreCascade({ where: { id: 'user-1' } });
// ^ Restores the user AND all their posts AND comments that were cascade-deleted
console.log(cascaded); // { Post: 2, Comment: 5 } — restored counts by modelHow cascade restore works:
- Children are identified by having the exact same
deleted_attimestamp as the parent - This matches the behavior of cascade soft-delete, which uses a single timestamp for the whole tree
- All operations are wrapped in a transaction
Conflict handling: If the unmangled unique value already exists in an active record, restore throws an error. You must delete or modify the conflicting record first.
// This will throw if 'john@example.com' is already taken by another active user
await safePrisma.user.restore({ where: { id: 'deleted-user-id' } });
// Error: Cannot restore User: unique field "email" with value "john@example.com"
// already exists in an active record.// Permanently delete when needed (intentionally ugly name to discourage use)
await safePrisma.user.__dangerousHardDelete({ where: { id: 'user-1' } });
await safePrisma.user.__dangerousHardDeleteMany({ where: { createdAt: { lt: oldDate } } });When you need to query deleted records or bypass filtering:
// Access the raw Prisma client (no filtering at all)
const allUsers = await safePrisma.$prisma.user.findMany();
// Query including soft-deleted records (with filter propagation)
const allUsers = await safePrisma.$includingDeleted.user.findMany({
include: { posts: true } // Includes both deleted and active posts
});
// Query only soft-deleted records (with filter propagation)
const deletedUsers = await safePrisma.$onlyDeleted.user.findMany({
include: { posts: true } // Includes only deleted posts
});
// Per-model escape hatch
const allUserPosts = await safePrisma.user.includingDeleted.findMany();Filter modes automatically propagate through relation includes for consistent behavior:
// $onlyDeleted propagates to all relations
const user = await safePrisma.$onlyDeleted.user.findFirst({
where: { id: 'user-123' },
include: {
posts: true, // Only deleted posts
comments: {
include: {
replies: true // Only deleted replies (nested propagation)
}
}
}
});
// $includingDeleted propagates too
const user = await safePrisma.$includingDeleted.user.findFirst({
where: { id: 'user-123' },
include: {
posts: true // All posts (deleted + active)
}
});You can override automatic propagation with explicit where clauses:
// Query deleted users with their ACTIVE posts
const user = await safePrisma.$onlyDeleted.user.findFirst({
where: { id: 'user-123' },
include: {
posts: {
where: { deleted_at: null } // Override: only active posts
}
}
});
// Mixed filtering at different levels
const user = await safePrisma.$onlyDeleted.user.findFirst({
include: {
posts: true, // Deleted posts (auto-propagated)
comments: {
where: { deleted_at: null }, // But active comments (override)
include: {
replies: true // Deleted replies (resumes propagation)
}
}
}
});For complex where clauses where escape hatches don't work:
import { onlyDeleted, excludeDeleted, includingDeleted } from './generated/soft-delete';
// Find users who have deleted memberships
const users = await safePrisma.user.findMany({
where: {
memberships: {
some: onlyDeleted('Membership', {
organizationId: 'org-123'
})
}
}
});
// Find active posts with deleted comments
const posts = await safePrisma.post.findMany({
where: {
comments: {
some: onlyDeleted('Comment', {
content: { contains: 'spam' }
})
}
}
});
// Explicit active filter (overrides propagation)
const user = await safePrisma.$onlyDeleted.user.findFirst({
include: {
posts: {
where: excludeDeleted('Post', { published: true })
}
}
});| Function | Purpose | Example |
|---|---|---|
onlyDeleted(model, where) |
Filter for deleted records | onlyDeleted('Post', { author_id: '123' }) |
excludeDeleted(model, where) |
Filter for active records | excludeDeleted('User', { role: 'admin' }) |
includingDeleted(where) |
No-op for clarity | includingDeleted({ status: 'premium' }) |
| Feature | safePrisma.model |
$includingDeleted |
$onlyDeleted |
model.includingDeleted |
$prisma |
|---|---|---|---|---|---|
| Filter mode | Exclude deleted | Include all | Only deleted | Include all | No filtering |
| Propagates to relations | Yes | Yes | Yes | No | No |
| Can override per-relation | Yes | Yes | Yes | Yes | N/A |
| Available in transactions | Yes | Yes | Yes | Yes | No |
| Soft delete methods | Yes | No | No | No | No |
| TypeScript types | Full safe client | Read-only | Read-only | Read-only | Raw Prisma |
| Use case | Normal queries | View full history | Restore operations | Quick override | Raw SQL/migrations |
safePrisma.model: Default for all normal queries$onlyDeleted: Restore workflows, viewing deleted records with their cascade children$includingDeleted: Analytics, full history views (both active and deleted)model.includingDeleted: Quick access to all records for a specific model$prisma: Raw queries, migrations, or when you need to bypass all filtering
Full support for compound primary keys:
model TenantUser {
tenantId String
userId String
email String
deleted_at DateTime?
@@id([tenantId, userId])
}await safePrisma.tenantUser.softDelete({
where: {
tenantId_userId: { tenantId: 'tenant-1', userId: 'user-1' }
}
});Interactive transactions have full soft-delete support including escape hatches:
await safePrisma.$transaction(async (tx) => {
// Standard filtering (excludes deleted)
const users = await tx.user.findMany();
const posts = await tx.post.findMany();
// Use escape hatches in transactions
const deletedUsers = await tx.$onlyDeleted.user.findMany();
const allPosts = await tx.$includingDeleted.post.findMany();
// Restore with atomic audit logging
const { record, cascaded } = await tx.user.restoreCascade({
where: { id: 'user-123' }
});
await tx.auditLog.create({
data: {
action: `RESTORE:User:${record!.id}`,
entityId: record!.id,
},
});
});Important: Only interactive transactions ($transaction(async (tx) => { ... })) receive the soft-delete wrapper. Sequential transactions ($transaction([promise1, promise2])) pass through to raw Prisma with no soft-delete filtering. Always use the interactive form when soft-delete behavior is needed.
Audit logging automatically captures mutation events (create, update, delete) for marked models. Events are written atomically in the same transaction as the mutation.
Three schema annotations control audit logging:
| Annotation | Purpose |
|---|---|
/// @audit |
Mark a model as auditable for all actions (create, update, delete) |
/// @audit(create, delete) |
Mark a model as auditable for specific actions only |
/// @audit-table |
Designate a model as the audit event table |
Audit table: Exactly one model must be marked with /// @audit-table. This model stores all audit events. It is never itself auditable or soft-deletable.
/// @audit
model Project {
id String @id @default(cuid())
name String
deleted_at DateTime?
deleted_by String?
}
/// @audit(create, delete)
model Webhook {
id String @id @default(cuid())
url String
}
/// @audit-table
model AuditEvent {
id String @id @default(cuid())
entity_type String
entity_id String
action String
actor_id String?
event_data Json
created_at DateTime @default(now())
parent_event_id String?
}| Field | Type | Purpose |
|---|---|---|
entity_type |
String |
Model name (e.g., "Project") |
entity_id |
String |
Primary key of the affected record (JSON string for compound PKs) |
action |
String |
"create", "update", or "delete" |
actor_id |
String? |
Who performed the action (from actorId parameter) |
event_data |
Json |
Snapshot of the record or before/after diff |
created_at |
DateTime |
When the event occurred |
| Field | Type | Purpose |
|---|---|---|
parent_event_id |
String? |
Links child audit events to a parent event. Automatically populated during cascade operations — when a softDelete cascades to children, each child's audit event references the parent's audit event ID. Also accepted as a parameter on $writeAuditEvent for manual linking. |
You can add additional fields to the audit table (e.g., ip_address, user_agent). Extra fields are populated via auditContext (see below).
When your schema has auditable models, wrapPrismaClient accepts an optional WrapOptions parameter:
import { wrapPrismaClient, type WrapOptions } from './generated/soft-delete';
const safePrisma = wrapPrismaClient(prisma, {
auditContext: async () => ({
ip_address: req.ip,
user_agent: req.headers['user-agent'],
}),
});The auditContext callback is called once per audit event and its return value is spread into the audit event record. This lets you inject request-scoped context (IP address, user agent, trace IDs) into every audit event.
All audited mutation methods and $writeAuditEvent accept an optional auditContext parameter that merges with (and overrides) the global WrapOptions.auditContext:
// Global context provides defaults
const safePrisma = wrapPrismaClient(prisma, {
auditContext: async () => ({ ip_address: req.ip }),
});
// Per-call context overrides or extends the global context
await safePrisma.project.update({
where: { id: project.id },
data: { name: 'Renamed' },
actorId: currentUserId,
auditContext: { trace_id: 'req-abc-123' },
});
// Resulting audit event has both ip_address (from global) and trace_id (from per-call)Per-call auditContext values are merged shallowly — if the same key appears in both global and per-call context, the per-call value wins. Only keys matching columns in your audit table are written; extra keys are silently ignored.
All mutation methods on audited models accept an optional actorId parameter:
// Create — actorId is optional
const project = await safePrisma.project.create({
data: { name: 'New Project' },
actorId: currentUserId,
});
// Update — captures before/after snapshot
const updated = await safePrisma.project.update({
where: { id: project.id },
data: { name: 'Renamed' },
actorId: currentUserId,
});
// Delete (soft-delete for soft-deletable models, hard delete for audit-only)
await safePrisma.webhook.delete({
where: { id: 'wh-1' },
actorId: currentUserId,
});actorId is always optional (string | null). If omitted, the audit event's actor_id is null.
Which methods are intercepted depends on the model's @audit actions:
| Method | Action | Audited when |
|---|---|---|
create |
create |
@audit or @audit(create, ...) |
createMany |
create |
@audit or @audit(create, ...) |
createManyAndReturn |
create |
@audit or @audit(create, ...) |
update |
update |
@audit or @audit(update, ...) |
updateMany |
update |
@audit or @audit(update, ...) |
updateManyAndReturn |
update |
@audit or @audit(update, ...) |
upsert |
runtime | Always intercepted; action determined at runtime (create if new, update if existing). Audit event is written only if the resulting action is in the model's audit actions. |
delete |
delete |
@audit or @audit(delete, ...) (audit-only models only; soft-deletable models use softDelete) |
deleteMany |
delete |
@audit or @audit(delete, ...) (audit-only models only) |
Methods whose action is not in the model's audit actions pass through without wrapping in a transaction or capturing events. They still strip the actorId parameter before forwarding to Prisma.
The event_data field captures different snapshots depending on the action:
| Action | event_data contents |
|---|---|
create |
The full created record |
update |
{ before: <record before>, after: <record after> } |
delete |
The full deleted record |
upsert (created) |
The full created record |
upsert (updated) |
{ before: <existing record>, after: <updated record> } |
For batch operations (createMany, updateMany, deleteMany), one audit event is written per affected record. Audit events for batch operations are written in parallel within the same transaction.
Limitations:
event_dataonly contains scalar fields — relation data is not included in before/after snapshots. If you need relation data in audit events, query relations separately in your application code.createManyis internally implemented usingcreateManyAndReturnto capture record data for auditing. This means auditedcreateManyrequires database support forRETURNING(PostgreSQL, SQLite 3.35+). If your database does not support this, auditedcreateManywill fail at runtime.- For large batch operations, audit event writes run in parallel but are still bounded by the transaction timeout. Consider batching very large operations.
Models can be both soft-deletable and auditable:
/// @audit
model Project {
id String @id @default(cuid())
name String
deleted_at DateTime?
deleted_by String?
}When a model is both:
softDeleteusesactorIdinstead ofdeletedByfor the identity parameter. TheactorIdvalue is written to thedeleted_bycolumn (if present) and also recorded in the audit event.- All write methods (create, update, upsert, etc.) are wrapped with audit logging
- Soft-delete filtering still applies to all reads and updates
Audited methods work in transactions with the same API:
await safePrisma.$transaction(async (tx) => {
const project = await tx.project.create({
data: { name: 'In Transaction' },
actorId: currentUserId,
});
await tx.project.update({
where: { id: project.id },
data: { name: 'Updated in Transaction' },
actorId: currentUserId,
});
// Both audit events are committed atomically with the mutations
});Write a custom audit event outside of automatic mutation tracking. Available on both SafePrismaClient and SafeTransactionClient when the schema has auditable models.
await safePrisma.$writeAuditEvent({
entityType: 'Project',
entityId: project.id,
action: 'archive',
actorId: currentUserId,
eventData: { reason: 'inactive for 90 days' },
});| Parameter | Type | Required | Description |
|---|---|---|---|
entityType |
string |
Yes | Model or entity name (e.g., "Project") |
entityId |
string |
Yes | Primary key of the affected record |
action |
string |
Yes | Action name (e.g., "archive", "export", any custom string) |
actorId |
string | null |
No | Who performed the action (defaults to null) |
eventData |
Prisma.InputJsonValue |
Yes | Arbitrary JSON payload for the event |
parentEventId |
string |
No | Link this event to a parent audit event |
auditContext |
AuditContext |
No | Per-call context that merges with global WrapOptions.auditContext |
- On
SafePrismaClient: Wraps the write in a$transactionautomatically. - On
SafeTransactionClient(inside$transaction): Writes directly within the existing transaction — the audit event is atomic with the rest of the transaction.
// In a transaction — audit event is atomic with the business logic
await safePrisma.$transaction(async (tx) => {
await tx.project.update({
where: { id: project.id },
data: { status: 'archived' },
});
await tx.$writeAuditEvent({
entityType: 'Project',
entityId: project.id,
action: 'archive',
actorId: currentUserId,
eventData: { previousStatus: 'active' },
});
});Only keys matching columns in your audit table are written from auditContext; extra keys are silently ignored.