Skip to content

feat: soft delete #12656

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 26 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e5757bb
feat: exposes new deletedAt field on collection config
PatrikKozak Jun 2, 2025
aa62733
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 2, 2025
c3be174
feat: adds trash arg to collection find operation
PatrikKozak Jun 2, 2025
b693706
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 2, 2025
5113e38
feat: adds soft-delete test suite
PatrikKozak Jun 3, 2025
e8bf074
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 3, 2025
6a84864
feat: adds trash flag to find, update & delete local operations
PatrikKozak Jun 3, 2025
97a3890
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 3, 2025
667f326
feat: adds trash flag to find, update & delete endpoints
PatrikKozak Jun 3, 2025
52100bc
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 3, 2025
283f0f3
feat: adds softDeletes root level prop to collection configs to enabl…
PatrikKozak Jun 3, 2025
3499feb
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 3, 2025
48cf719
feat: only push query on trash arg if softDeletes is enabled on the c…
PatrikKozak Jun 3, 2025
8d38ac7
feat: adjust tests for sql adapters
PatrikKozak Jun 3, 2025
9fafda9
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 3, 2025
2d42035
feat: adds trash flag to find, update & delete graphql operations
PatrikKozak Jun 4, 2025
e638bb0
chore: re-adds commented out graphql tests for update & delete many o…
PatrikKozak Jun 4, 2025
58055c7
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 4, 2025
6b5e2fb
feat: adds trash arg to findVersions & findVersionByID operations
PatrikKozak Jun 4, 2025
f179c4c
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 4, 2025
bbd8dbb
feat: conditionally use string or int graphql literals for ids depend…
PatrikKozak Jun 5, 2025
a430aae
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 5, 2025
40eaaff
feat: adds delete access control in update operations to prevent soft…
PatrikKozak Jun 5, 2025
4a61a1f
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 5, 2025
18293ac
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 6, 2025
fcbaa5c
feat: adds deletedAt translations for bengali languages
PatrikKozak Jun 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/graphql/src/resolvers/collections/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type Resolver<TSlug extends CollectionSlug> = (
fallbackLocale?: string
id: number | string
locale?: string
trash?: boolean
},
context: {
req: PayloadRequest
Expand Down Expand Up @@ -49,6 +50,7 @@ export function getDeleteResolver<TSlug extends CollectionSlug>(
collection,
depth: 0,
req: isolateObjectProperty(req, 'transactionID'),
trash: args.trash,
}

const result = await deleteByIDOperation(options)
Expand Down
2 changes: 2 additions & 0 deletions packages/graphql/src/resolvers/collections/find.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type Resolver = (
page?: number
pagination?: boolean
sort?: string
trash?: boolean
where?: Where
},
context: {
Expand Down Expand Up @@ -57,6 +58,7 @@ export function findResolver(collection: Collection): Resolver {
pagination: args.pagination,
req,
sort: args.sort,
trash: args.trash,
where: args.where,
}

Expand Down
2 changes: 2 additions & 0 deletions packages/graphql/src/resolvers/collections/findByID.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type Resolver<TData> = (
fallbackLocale?: string
id: string
locale?: string
trash?: boolean
},
context: {
req: PayloadRequest
Expand Down Expand Up @@ -50,6 +51,7 @@ export function findByIDResolver<TSlug extends CollectionSlug>(
depth: 0,
draft: args.draft,
req: isolateObjectProperty(req, 'transactionID'),
trash: args.trash,
}

const result = await findByIDOperation(options)
Expand Down
2 changes: 2 additions & 0 deletions packages/graphql/src/resolvers/collections/findVersionByID.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type Resolver<T extends TypeWithID = any> = (
fallbackLocale?: string
id: number | string
locale?: string
trash?: boolean
},
context: {
req: PayloadRequest
Expand All @@ -33,6 +34,7 @@ export function findVersionByIDResolver(collection: Collection): Resolver {
collection,
depth: 0,
req: isolateObjectProperty(req, 'transactionID'),
trash: args.trash,
}

const result = await findVersionByIDOperation(options)
Expand Down
2 changes: 2 additions & 0 deletions packages/graphql/src/resolvers/collections/findVersions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type Resolver = (
page?: number
pagination?: boolean
sort?: string
trash?: boolean
where: Where
},
context: {
Expand Down Expand Up @@ -54,6 +55,7 @@ export function findVersionsResolver(collection: Collection): Resolver {
pagination: args.pagination,
req: isolateObjectProperty(req, 'transactionID'),
sort: args.sort,
trash: args.trash,
where: args.where,
}

Expand Down
2 changes: 2 additions & 0 deletions packages/graphql/src/resolvers/collections/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type Resolver<TSlug extends CollectionSlug> = (
fallbackLocale?: string
id: number | string
locale?: string
trash?: boolean
},
context: {
req: PayloadRequest
Expand Down Expand Up @@ -54,6 +55,7 @@ export function updateResolver<TSlug extends CollectionSlug>(
depth: 0,
draft: args.draft,
req: isolateObjectProperty(req, 'transactionID'),
trash: args.trash,
}

const result = await updateByIDOperation<TSlug>(options)
Expand Down
10 changes: 8 additions & 2 deletions packages/graphql/src/schema/initCollections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
locale: { type: graphqlResult.types.localeInputType },
}
: {}),
trash: { type: GraphQLBoolean },
},
resolve: findByIDResolver(collection),
}
Expand All @@ -224,6 +225,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
page: { type: GraphQLInt },
pagination: { type: GraphQLBoolean },
sort: { type: GraphQLString },
trash: { type: GraphQLBoolean },
},
resolve: findResolver(collection),
}
Expand Down Expand Up @@ -292,6 +294,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
locale: { type: graphqlResult.types.localeInputType },
}
: {}),
trash: { type: GraphQLBoolean },
},
resolve: updateResolver(collection),
}
Expand All @@ -300,6 +303,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
type: collection.graphQL.type,
args: {
id: { type: new GraphQLNonNull(idType) },
trash: { type: GraphQLBoolean },
},
resolve: getDeleteResolver(collection),
}
Expand Down Expand Up @@ -329,12 +333,12 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
{
name: 'createdAt',
type: 'date',
label: 'Created At',
label: ({ t }) => t('general:createdAt'),
},
{
name: 'updatedAt',
type: 'date',
label: 'Updated At',
label: ({ t }) => t('general:updatedAt'),
},
]

Expand All @@ -359,6 +363,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
locale: { type: graphqlResult.types.localeInputType },
}
: {}),
trash: { type: GraphQLBoolean },
},
resolve: findVersionByIDResolver(collection),
}
Expand All @@ -385,6 +390,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
page: { type: GraphQLInt },
pagination: { type: GraphQLBoolean },
sort: { type: GraphQLString },
trash: { type: GraphQLBoolean },
},
resolve: findVersionsResolver(collection),
}
Expand Down
20 changes: 19 additions & 1 deletion packages/payload/src/collections/config/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export const sanitizeCollection = async (
// add default timestamps fields only as needed
let hasUpdatedAt: boolean | null = null
let hasCreatedAt: boolean | null = null
let hasDeletedAt: boolean | null = null

sanitized.fields.some((field) => {
if (fieldAffectsData(field)) {
Expand All @@ -107,9 +108,13 @@ export const sanitizeCollection = async (
if (field.name === 'createdAt') {
hasCreatedAt = true
}

if (field.name === 'deletedAt') {
hasDeletedAt = true
}
}

return hasCreatedAt && hasUpdatedAt
return hasCreatedAt && hasUpdatedAt && (!sanitized.softDeletes || hasDeletedAt)
})

if (!hasUpdatedAt) {
Expand Down Expand Up @@ -138,6 +143,19 @@ export const sanitizeCollection = async (
label: ({ t }) => t('general:createdAt'),
})
}

if (sanitized.softDeletes && !hasDeletedAt) {
sanitized.fields.push({
name: 'deletedAt',
type: 'date',
admin: {
disableBulkEdit: true,
hidden: true,
},
index: true,
label: ({ t }) => t('general:deletedAt'),
})
}
}

sanitized.labels = sanitized.labels || formatLabels(sanitized.slug)
Expand Down
15 changes: 13 additions & 2 deletions packages/payload/src/collections/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export type AuthOperationsFromCollectionSlug<TSlug extends CollectionSlug> =

export type RequiredDataFromCollection<TData extends JsonObject> = MarkOptional<
TData,
'createdAt' | 'id' | 'sizes' | 'updatedAt'
'createdAt' | 'deletedAt' | 'id' | 'sizes' | 'updatedAt'
>

export type RequiredDataFromCollectionSlug<TSlug extends CollectionSlug> =
Expand Down Expand Up @@ -543,7 +543,17 @@ export type CollectionConfig<TSlug extends CollectionSlug = any> = {
orderable?: boolean
slug: string
/**
* Add `createdAt` and `updatedAt` fields
* Enables soft delete support for this collection.
*
* When enabled, documents will include a `deletedAt` timestamp field.
* This allows documents to be marked as deleted without being permanently removed.
* The `deletedAt` field will be set to the current date and time when a document is deleted.
*
* @default false
*/
softDeletes?: boolean
/**
* Add `createdAt`, `deletedAt` and `updatedAt` fields
*
* @default true
*/
Expand Down Expand Up @@ -666,6 +676,7 @@ export type TypeWithID = {
export type TypeWithTimestamps = {
[key: string]: unknown
createdAt: string
deletedAt?: string
id: number | string
updatedAt: string
}
Expand Down
4 changes: 3 additions & 1 deletion packages/payload/src/collections/endpoints/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ import { deleteOperation } from '../operations/delete.js'

export const deleteHandler: PayloadHandler = async (req) => {
const collection = getRequestCollection(req)
const { depth, overrideLock, populate, select, where } = req.query as {
const { depth, overrideLock, populate, select, trash, where } = req.query as {
depth?: string
overrideLock?: string
populate?: Record<string, unknown>
select?: Record<string, unknown>
trash?: string
where?: Where
}

Expand All @@ -28,6 +29,7 @@ export const deleteHandler: PayloadHandler = async (req) => {
populate: sanitizePopulateParam(populate),
req,
select: sanitizeSelectParam(select),
trash: trash === 'true',
where: where!,
})

Expand Down
2 changes: 2 additions & 0 deletions packages/payload/src/collections/endpoints/deleteByID.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const deleteByIDHandler: PayloadHandler = async (req) => {
const { searchParams } = req
const depth = searchParams.get('depth')
const overrideLock = searchParams.get('overrideLock')
const trash = searchParams.get('trash') === 'true'

const doc = await deleteByIDOperation({
id,
Expand All @@ -23,6 +24,7 @@ export const deleteByIDHandler: PayloadHandler = async (req) => {
populate: sanitizePopulateParam(req.query.populate),
req,
select: sanitizeSelectParam(req.query.select),
trash,
})

const headers = headersWithCors({
Expand Down
4 changes: 3 additions & 1 deletion packages/payload/src/collections/endpoints/find.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { findOperation } from '../operations/find.js'

export const findHandler: PayloadHandler = async (req) => {
const collection = getRequestCollection(req)
const { depth, draft, joins, limit, page, pagination, populate, select, sort, where } =
const { depth, draft, joins, limit, page, pagination, populate, select, sort, trash, where } =
req.query as {
depth?: string
draft?: string
Expand All @@ -25,6 +25,7 @@ export const findHandler: PayloadHandler = async (req) => {
populate?: Record<string, unknown>
select?: Record<string, unknown>
sort?: string
trash?: string
where?: Where
}

Expand All @@ -40,6 +41,7 @@ export const findHandler: PayloadHandler = async (req) => {
req,
select: sanitizeSelectParam(select),
sort: typeof sort === 'string' ? sort.split(',') : undefined,
trash: trash === 'true',
where,
})

Expand Down
2 changes: 2 additions & 0 deletions packages/payload/src/collections/endpoints/findByID.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const findByIDHandler: PayloadHandler = async (req) => {
const { searchParams } = req
const { id, collection } = getRequestCollectionWithID(req)
const depth = searchParams.get('depth')
const trash = searchParams.get('trash') === 'true'

const result = await findByIDOperation({
id,
Expand All @@ -24,6 +25,7 @@ export const findByIDHandler: PayloadHandler = async (req) => {
populate: sanitizePopulateParam(req.query.populate),
req,
select: sanitizeSelectParam(req.query.select),
trash,
})

return Response.json(result, {
Expand Down
2 changes: 2 additions & 0 deletions packages/payload/src/collections/endpoints/findVersionByID.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { findVersionByIDOperation } from '../operations/findVersionByID.js'
export const findVersionByIDHandler: PayloadHandler = async (req) => {
const { searchParams } = req
const depth = searchParams.get('depth')
const trash = searchParams.get('trash') === 'true'

const { id, collection } = getRequestCollectionWithID(req)

Expand All @@ -22,6 +23,7 @@ export const findVersionByIDHandler: PayloadHandler = async (req) => {
populate: sanitizePopulateParam(req.query.populate),
req,
select: sanitizeSelectParam(req.query.select),
trash,
})

return Response.json(result, {
Expand Down
4 changes: 3 additions & 1 deletion packages/payload/src/collections/endpoints/findVersions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ import { findVersionsOperation } from '../operations/findVersions.js'

export const findVersionsHandler: PayloadHandler = async (req) => {
const collection = getRequestCollection(req)
const { depth, limit, page, pagination, populate, select, sort, where } = req.query as {
const { depth, limit, page, pagination, populate, select, sort, trash, where } = req.query as {
depth?: string
limit?: string
page?: string
pagination?: string
populate?: Record<string, unknown>
select?: Record<string, unknown>
sort?: string
trash?: string
where?: Where
}

Expand All @@ -33,6 +34,7 @@ export const findVersionsHandler: PayloadHandler = async (req) => {
req,
select: sanitizeSelectParam(select),
sort: typeof sort === 'string' ? sort.split(',') : undefined,
trash: trash === 'true',
where,
})

Expand Down
4 changes: 3 additions & 1 deletion packages/payload/src/collections/endpoints/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@ import { updateOperation } from '../operations/update.js'

export const updateHandler: PayloadHandler = async (req) => {
const collection = getRequestCollection(req)
const { depth, draft, limit, overrideLock, populate, select, sort, where } = req.query as {
const { depth, draft, limit, overrideLock, populate, select, sort, trash, where } = req.query as {
depth?: string
draft?: string
limit?: string
overrideLock?: string
populate?: Record<string, unknown>
select?: Record<string, unknown>
sort?: string
trash?: string
where?: Where
}

Expand All @@ -35,6 +36,7 @@ export const updateHandler: PayloadHandler = async (req) => {
req,
select: sanitizeSelectParam(select),
sort: typeof sort === 'string' ? sort.split(',') : undefined,
trash: trash === 'true',
where: where!,
})

Expand Down
2 changes: 2 additions & 0 deletions packages/payload/src/collections/endpoints/updateByID.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const updateByIDHandler: PayloadHandler = async (req) => {
const autosave = searchParams.get('autosave') === 'true'
const draft = searchParams.get('draft') === 'true'
const overrideLock = searchParams.get('overrideLock')
const trash = searchParams.get('trash') === 'true'
const publishSpecificLocale = req.query.publishSpecificLocale as string | undefined

const doc = await updateByIDOperation({
Expand All @@ -30,6 +31,7 @@ export const updateByIDHandler: PayloadHandler = async (req) => {
publishSpecificLocale,
req,
select: sanitizeSelectParam(req.query.select),
trash,
})

let message = req.t('general:updatedSuccessfully')
Expand Down
Loading
Loading