Soft delete
Soft delete for MongoDB, which marks documents as deleted instead of removing them and hides them from reads and updates, with the softDelete plugin.
softDelete marks a document as deleted with a deletedAt date, instead of removing it, and hides
the deleted documents from reads and updates. It is the official plugin of MongoZod, in
mongozod/plugins/soft-delete.
import { ObjectId } from 'mongodb'
import { mongozod } from 'mongozod'
import * as mz from 'mongozod/mzod'
import { softDelete } from 'mongozod/plugins/soft-delete'
import * as z from 'zod'
const postSchema = z.object({
_id: mz.objectId()
.default(() => new ObjectId()),
title: z.string(),
body: z.string(),
status: z.enum(['draft', 'published'])
.default('draft'),
tags: z.string()
.array()
.default([]),
authorId: mz.objectId(),
deletedAt: mz.date()
.optional()
})
const PostsModel = mongozod({
collection: 'posts',
db,
schema: postSchema,
plugins: [softDelete()]
})
const { insertedId: postId } = await PostsModel.insertOne({
title: 'Hello, MongoDB',
body: 'A first post.',
authorId: new ObjectId()
})
await PostsModel.deleteOne({ _id: postId })
// { acknowledged: true, deletedCount: 1 }: the post has a `deletedAt` now
await PostsModel.findOne({ _id: postId })
// null: the reads skip the deleted posts
await PostsModel.find({ deletedAt: { $exists: true } })
// the trash: the filter mentions `deletedAt`, so the plugin leaves it aloneThe field
The schema declares deletedAt, optional and taking a Date: mz.date().optional(), or
z.date().optional(). With several types of document, every type
declares it. A schema without it does not compile, and mongozod() throws an Error:
softDelete needs the field "deletedAt" in the schema. One with another type does not compile:
softDelete needs "deletedAt" with a compatible type.
The rule
If the filter mentions deletedAt, the plugin leaves it alone. It looks for it at the top level
of the filter and inside $and, $or and $nor. That is why the plugin has no options and no
methods: a MongoDB filter says whether a call wants the live documents, the deleted ones or all of
them.
What it does
- Reads and updates: when the filter does not mention
deletedAt, the plugin addsdeletedAt: { $exists: false }, in an$andwith your filter, which it does not change. This coversfind,findOne,countDocuments,distinct,updateOne,updateMany,replaceOne,findOneAndUpdateandfindOneAndReplace. - Deletes:
deleteOneanddeleteManybecome anupdateOneor anupdateManythat setsdeletedAtto the current date. The plugin dispatches it through the model, sotimestampswritesupdatedAtand the other plugins see an update, with the options of the delete,contextincluded. They return aDeleteResultwhosedeletedCountcounts the documents marked. A document already deleted is not marked again: deleting it twice givesdeletedCount: 0the second time. findOneAndDeletemarks the document with afindOneAndUpdateand returns it as it was before, as the driver returns the document it deletes.- A delete whose filter mentions
deletedAtremoves the documents for real. That is how you empty the trash. bulkWrite: each operation follows the same rule. The marked documents count inmatchedCountandmodifiedCount, as the driver reports updates, anddeletedCountcounts only the documents removed for real.populatereads through the model of the target, so a deleted document it points to isnull, or left out of an array. See Populate.aggregateandestimatedDocumentCountare left alone, so they see the deleted documents too. A pipeline filters them in its first$match.
Quick reference
| Task | Call |
|---|---|
| Read the live documents | find({ authorId }) |
| Read the trash | find({ authorId, deletedAt: { $exists: true } }) |
| Read every document | find({ $or: [{ deletedAt: { $exists: true } }, { deletedAt: { $exists: false } }] }) |
| Delete | deleteOne({ _id }), which sets deletedAt |
| Restore | updateOne({ _id, deletedAt: { $exists: true } }, { $unset: { deletedAt: '' } }) |
| Delete a live document for real | deleteOne({ _id, deletedAt: { $exists: false } }) |
| Delete a document for real | deleteOne({ _id, $or: [{ deletedAt: { $exists: true } }, { deletedAt: { $exists: false } }] }) |
| Empty the trash | deleteMany({ deletedAt: { $lt: cutoff } }) |
| Aggregate the live documents | aggregate([{ $match: { deletedAt: { $exists: false } } }, …]) |
Read the deleted documents too
A filter that matches both, and mentions deletedAt, reads every document. Keep it in a constant,
and put it in an $and with the rest of the filter, so that it does not clash with an $or of its
own:
/** Matches every document, deleted or not: it mentions `deletedAt`, so softDelete stays out. */
const withDeleted = {
$or: [{ deletedAt: { $exists: true } }, { deletedAt: { $exists: false } }]
}
const everyDraft = await PostsModel.find({ $and: [withDeleted, { status: 'draft' }] })
const total = await PostsModel.countDocuments(withDeleted)To restore a document, mention deletedAt in the filter too:
await PostsModel.updateOne(
{ _id: postId, deletedAt: { $exists: true } },
{ $unset: { deletedAt: '' } }
)Upserts
The condition is deletedAt: { $exists: false } and not deletedAt: null because MongoDB copies
the equalities of the filter of an upsert into the document it inserts: a null would end up in the
new document, which the schema does not accept.
An upsert on the _id of a deleted document does not find it, so it tries to insert it and the
driver throws its duplicate key error, instead of restoring it without telling you. Restore it
first.
Unique indexes
A unique index also counts the deleted documents, so a deleted post would keep its slug. A partial index on the live documents frees it:
import { mongozod } from 'mongozod'
import * as mz from 'mongozod/mzod'
import { softDelete } from 'mongozod/plugins/soft-delete'
import * as z from 'zod'
const postSchema = z.object({
slug: z.string(),
title: z.string(),
deletedAt: mz.date()
.optional()
})
const PostsModel = mongozod({
collection: 'posts',
db,
schema: postSchema,
plugins: [softDelete()],
indexes: (index) => [
index({ slug: 1 }, { unique: true, partialFilterExpression: { deletedAt: null } })
]
})
await PostsModel.sync()deletedAt: null matches the documents without the field. The plugin does not touch the indexes:
sync() creates the ones the model declares.
With other plugins
A plugin that asks for a context in the updates has to ask for it in the deletes too, since the update that marks a delete takes the options of the delete: see Plugins.
Reference
softDelete() takes no arguments.
| Part | Value |
|---|---|
| Fields | deletedAt: a Date, optional |
| Options | None |
| Context | None |
| Methods | None |
| Hooks | before of the read and update groups, of deleteOne, deleteMany, findOneAndDelete and bulkWrite |
Common mistakes
- Restoring with a filter that does not mention
deletedAt, such asupdateOne({ _id }, { $unset: { deletedAt: '' } }): the plugin skips the deleted documents, so it matches nothing and says nothing, withmatchedCount: 0. AdddeletedAt: { $exists: true }to the filter. - Counting with
aggregateorestimatedDocumentCount: both count the deleted documents. Add{ $match: { deletedAt: { $exists: false } } }to the pipeline, or usecountDocuments. - Joining with
$lookup: it reads the other collection directly, deleted documents included. Filter them in the pipeline of the$lookup. - Letting clients filter by
deletedAt: a filter that mentions it turns the plugin off for that call, so a client could read the trash, or turn a delete into a real one. LeavedeletedAtout of the allow list ofmz.filterunless the endpoint is for the trash. - A unique index without
partialFilterExpression: { deletedAt: null }: a deleted document keeps its key, and creating a new one with the same key fails. - Upserting the
_idof a deleted document: the driver throws a duplicate key error. Restore it with an update whose filter mentionsdeletedAt.
Last updated on
MongoZod is an independent open-source project. It is not affiliated with, endorsed by or sponsored by MongoDB, Inc. MongoDB® is a registered trademark of MongoDB, Inc.