2445 Commits

Author SHA1 Message Date
Jarrod Flesch 9391c20de8 feat: add profiling utilities for performance analysis (#16198)
## Summary

Adds lightweight profiling utilities for measuring execution time of
sync and async functions. This is a developer tool for debugging
performance issues during development — wrap any function with
`timeSync` or `timeAsync` to measure its duration, then call
`printProfileResults` to see a formatted table of timings.

Not intended for production use or to be imported by default, just
available when needed for performance investigations.

These utilities were used to generate results for:
- https://github.com/payloadcms/payload/pull/16134
- https://github.com/payloadcms/payload/pull/16129
2026-04-13 15:09:23 +01:00
Bob Bass e8502faab8 fix: handle concurrent autosave write conflicts gracefully (#16216)
### What?

Two-part fix for intermittent 500s during autosave on serverless
environments.

1. `updateLatestVersion` catches errors from
`updateVersion`/`updateGlobalVersion` and checks whether a concurrent
write already succeeded before falling back to `createVersion`
2. `saveVersion` re-throws instead of `return undefined!` so genuine
failures still surface as real errors

### Why?

On serverless, concurrent autosave requests race to update the same
version row. The losing request gets a DB error. Previously
`saveVersion` caught it and returned `undefined!`, which was assigned to
`result`. Downstream field hooks then accessed properties on
`undefined`:

```
TypeError: Cannot read properties of undefined (reading '_status')
```

### How?

When `updateVersion` fails, we re-query the row and check if `updatedAt`
has moved — if it has, a concurrent request already committed and we
return that result. If not, we fall back to `createVersion`. If that
also fails, `saveVersion` re-throws.

Fixes #16217

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1213985147428664

---------

Co-authored-by: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com>
Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
2026-04-13 09:50:19 -04:00
Mahmoud Hamdy 216d16296a fix: use instanceof instead of constructor name in formatErrors (#16089)
## What

Fixes `formatErrors` silently dropping the `data` field from `APIError`
responses in production builds.

Closes #16050

## Why

`formatErrors` used `proto.constructor.name` string comparison to
identify `APIError` and `ValidationError` instances. In production,
webpack/terser minifies class names (e.g., `APIError` → `t`), causing
the check to fail. This silently drops `error.data`, breaking any
feature that relies on it — for example, 2FA flows that check
`data.requires2FA`.

The `APIErrorName`/`ValidationErrorName` module variables were designed
to handle this via dynamic reassignment in constructors, but minifiers
can inline the initial string value at import sites, defeating the
mechanism.

## Changes

- Replaced `proto.constructor.name === APIErrorName` /
`ValidationErrorName` checks with `instanceof APIError` / `instanceof
ValidationError`
- For the Mongoose `ValidationError` fallback (which isn't a Payload
class), switched to checking `incoming.name === 'ValidationError'` — a
string property that Mongoose sets explicitly and isn't affected by
minification
- Removed unused `APIErrorName` / `ValidationErrorName` imports

## Why instanceof is safe here

The original code avoided `instanceof` citing
[TypeScript#13965](https://github.com/microsoft/TypeScript/issues/13965),
where `instanceof` fails for classes extending `Error` when transpiled
to ES5. However:

- Payload's `tsconfig` targets **ES2022**, so native class syntax is
preserved
- `instanceof` works correctly with ES2015+ classes extending `Error`
- The original workaround itself was broken by minification — a worse
failure mode

## Testing

- `npx tsc --noEmit` passes with no errors
- Verified that Payload's `ValidationError` (which has `data`) is caught
by the `instanceof` branch and never falls through to the Mongoose
branch
- The Mongoose `ValidationError` check uses the `name` property which
Mongoose sets explicitly (`this.name = 'ValidationError'`) — not
affected by minification

---------

Co-authored-by: Paul Popus <paul@payloadcms.com>
2026-04-13 13:00:57 +01:00
Alessio Gravili 5f5694f526 feat: add internal plugin priority and slug api for cross-plugin discovery (#16244)
Adds a temporary, internal API for plugin execution ordering and
cross-plugin discovery. Plugins can now attach three optional properties
to their function:

- `priority` - controls execution order (ascending, default 0), so a
high-priority plugin is guaranteed to see config changes from all
lower-priority plugins
- `slug` - unique identifier so plugins can find each other in
`config.plugins` without importing each other. Useful for checking
within plugin A whether plugin B is installed
- `options` - exposes the plugin's closure args, allowing other plugins
to mutate them before the plugin executes (e.g. injecting tools into
plugin-mcp's options)

Marked as `@internal` so we can start using this across our own plugins
without committing to a public API yet.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1214027832433527
2026-04-10 20:45:13 +00:00
Alessio Gravili e49c224a9f refactor: clean up dead code and minor bugs in job queue system (#16234)
- replaces a hardcoded `'payload-jobs'` string
- uses `getCurrentDate()` consistently instead of `new Date()`
- removes dead code: redundant `taskStatus` reassignment in
`jobAfterRead`, commented-out debug logging in
`calculateBackoffWaitUntil`, and a redundant queue reassignment in the
run endpoint.
- reduces `getCurrentDate()` calls in backoff calculation from four to
one,
- guards and simplifies logging-only array allocations behind the
`silent` check in `runJobs`


---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1214013522421649
2026-04-10 02:06:49 +01:00
Hugo Duarte 2a26b5ac6e fix: parse JSON string where query param (#15745)
### What?

Support `where` sent as a JSON string in REST API requests (e.g.
`?where={"read":{"equals":false}}`). Previously the filter was silently
ignored; now it's parsed correctly.

### Why?

GET query params are always strings. `parseParams` parsed `data` this
way already but skipped `where`, so string-encoded filters had no
effect.

### How?

After the `data` block in `parseParams`: if `where` is a non-empty
string, `JSON.parse` it. If it's already an object (e.g. from bracket
notation), leave it alone.

---------

Co-authored-by: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com>
2026-04-09 12:17:23 -07:00
Boyan Bratvanov d9b3c07bfa fix: expose type for field's position (#14390)
Adds `FieldPosition = 'main' | 'sidebar'` and uses it in `FieldAdmin`
and `AdminClient,` replacing the previous `'sidebar'`-only type.

This is especially useful when overriding a plugin field's default
`position: 'sidebar'` back to `'main'`, which already worked at runtime
but failed type-checking.

Also updates the docs to list both options and exports `FieldPosition`
from the package index.

---------

Co-authored-by: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com>
2026-04-09 18:25:33 +00:00
Jens Becker 72396f6c1b fix: default virtual fields to readOnly in admin UI (#16016)
### What?

Virtual fields (`virtual: true` or `virtual: "path"`) now default to
`admin.readOnly: true` during field sanitization.

### Why?

Virtual fields are populated server-side and not persisted from user
input. Without `readOnly`, users can type into them but edits are
silently discarded on save — confusing UX with no indication the field
is virtual.

### How?

In `sanitize.ts`, after initializing `field.admin`, set `readOnly: true`
for virtual fields unless the user has explicitly set `readOnly: false`.

Fixes #16013
Related #16012
2026-04-09 10:23:27 -07:00
jakobpevec 067e14feae fix: localized array,group,blocks fields duplicate with empty values (#15849)
### What?
  Localized blocks, array, and group fields are empty when           
  duplicating a selected locale.

### Why?
filterDataToSelectedLocales assumed field types (blocks,
  array, group) always stored data directly as arrays. When
  these fields are localized, data is wrapped in a locale map ({ en:
  [...], de: [...] }), which was not being unwrapped before
  processing.

### How?
 - Added locale-aware branching for blocks, array, and group field
  types — when the field is localized, the locale map is filtered
  first, then each locale's value is recursed into
  - Extracted a filterLocaleMap helper to consistently filter and
  transform locale maps across all field types
  - Added unit tests

Fixes #15847

---------

Co-authored-by: pevecj <jakob.pevec@renderspace.si>
2026-04-09 12:57:03 -04:00
W1ckh3ad ac01e82bcd feat(drizzle): add uuidv7 support (#16113)
## Goal

Let projects use **UUID v7** (time-ordered UUIDs) for collection IDs via
`idType: 'uuidv7'` on Postgres-related adapters (and SQLite wiring),
while keeping the same storage shape as v4 UUIDs (native `uuid` on
Postgres, text on SQLite).

### What?

- Adds a new adapter option `idType: 'uuidv7'` alongside existing
`'serial' | 'uuid'` (Postgres) and `'number' | 'uuid'` (SQLite).
- Generates IDs in application code with `uuid` package `v7()` (not
database-native), so older Postgres versions are still supported.
- Reuses the same Drizzle column modeling as UUID v4 where appropriate
(`defaultV7` on the raw column, `$defaultFn` / generated schema code).
- Adds integration tests behind `PAYLOAD_DATABASE=postgres-uuidv7` and a
test adapter preset `postgres-uuidv7`.

### Why?

UUID v7 is sortable by creation time and tends to be friendlier for
B-tree indexes than random UUID v4, without changing the wire format or
Postgres column type.

### How?

- Bump `uuid` in `@payloadcms/drizzle` to a version that exports `v7`.
- Extend TypeScript `idType` unions and `UUIDRawColumn` with
`defaultV7`.
- In schema builders (`setColumnID`, `buildDrizzleTable`,
`columnToCodeConverter`), set app-side defaults for v7; treat `uuidv7`
like `uuid` for relationships and query sanitization via a small
`isUUIDType` helper and `getCollectionIdType` mapping to `'text'`.



Fixes #11449

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1213991890379567

---------

Co-authored-by: Sasha Rakhmatulin <sasha@ritsuko.dev>
2026-04-09 14:32:01 +01:00
Shane Friedman 79f4cc7fae fix: fixes importMap type in monorepo usage (#15203)
### What?

Adds a `/** @type import("payload").ImportMap */` annotation to the
generated importMap.

### Why?

In a monorepo, it's common to use [Project
References](https://www.typescriptlang.org/docs/handbook/project-references.html)
so that Typescript can do dependency analysis on the packages in your
project. Often, projects will have a single root `tsconfig.json` that
`references` all of the packages, including any apps. To do so, all
dependent `tsconfig.json`s must set `"composite": true`.

When `"composite": true` is set in a package, Typescript does
portability analysis on all exports to ensure that Typescript will be
able to properly determine the type of those exports if they are
imported into a consuming package as a dependency. This is true even if
that package never ultimately exports that type through its `main` or
`exports` fields, or is private, or is not depended on by any other
projects. This portability check happens _even if checkJs is disabled_.

Because `importMap` relies on type inference on transient dependencies,
Typescript requires a type annotation to conclude that its type is
portable.

Ultimately, there's no reason for Payload consumers to care about or
enforce the type of `importMap`. It's automatically generated by
Payload, never hand edited, and only consumed by other auto-generated
Payload code.

Since Payload already has a type for `ImportMap`, we can type
`importMap` with that type. It will satisfy the type, and is also
considered portable.

### How?

Updated the generateImportMap template.

Fixes #9914

Co-authored-by: Paul Popus <paul@payloadcms.com>
2026-04-09 14:27:50 +01:00
Dan Ribbens ade4501836 chore(release): v3.82.1 [skip ci] 2026-04-09 09:15:20 -04:00
Dan Ribbens 43f0d20ce5 chore(release): v3.82.0 [skip ci] 2026-04-08 12:02:24 -04:00
Tyler Schwartz 05ddec18f9 feat: disambiguate media files by prefix via query parameter (#15844)
Add optional `?prefix=` query string support to the existing
`/api/:collection/file/:filename` endpoint to disambiguate files that
share the same filename but have different storage prefixes.

When a storage prefix (e.g., UUID or folder path) is used to make S3
keys unique, multiple documents can share the same `filename`. The
current endpoint resolves by filename alone, which can match the wrong
document in both access control and file retrieval.

- `checkFileAccess` accepts optional `prefix` and adds it to the `where`
clause alongside existing access filters
- `getFile` handler reads `prefix` from `req.searchParams` and threads
it to `checkFileAccess` and handler `params`
- `getFilePrefix` accepts `explicitPrefix` to skip the DB query when the
prefix is already known from the URL
- S3 `staticHandler` forwards `prefix` from params to `getFilePrefix`
- `afterRead` hook appends `?prefix=` to Payload-proxied URLs
- Added unit tests for `checkFileAccess` and `getFilePrefix`, and
integration tests for the endpoint'

---------

Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
2026-04-07 17:32:04 -04:00
Riley Langbein c8332e7756 feat: add missing disableErrors to globals findOne operation (#14913)
### What?

This PR introduces the `disableErrors` option into the global `findOne`
operation.

### Why?

`disableErrors` is handy for certain flows where a user may be
unauthorised but you don't need an error to be thrown, instead you would
prefer an empty result.

Without this, the developer needs to catch the error themselves, leading
to inconsistencies as some operations have `disableErrors` and others do
not.

### How?

Introduced the `disableErrors` option to the global `findOne` operation.

### Additional information

- Initial [discord
discussion](https://discord.com/channels/967097582721572934/1102950643259424828/1438504179961430059).
- A [PR](https://github.com/payloadcms/payload/pull/6357) was created to
address this in mid 2024 but was later closed.
- There is opportunity here to include `disableErrors` in a number of
operations that seem to be lacking it, especially in the `sdk` package.
Originally I included these changes in this PR but have since removed to
instead allow for some discussion before we consider introducing them in
a separate PR.
- There is opportunity to introduce better test coverage in a separate
PR for `disableErrors` across existing operations that use it.
- In `packages/payload/src/collections/operations/restoreVersion.ts`,
`disableErrors` exists in the types but is not actually implemented. I
have left this as is for now but wanted to flag it.
2026-04-02 20:15:02 +01:00
Tim @ erdee f42b7674f1 fix: incorrect 24 hour format comment example (#15649)
### What?

The code comment for type
`CollectionConfig.versions.drafts.schedulePublish.timeFormat` suggests
using 'hh:mm' for a 24 hour clock format. I changed this comment to
instead suggest 'HH:mm'.

### Why?

The lowercase 'hh' makes a 12 hour format, not the suggested 24 hour
format.

### How?

I replaced the comment to suggest using uppercase 'HH' instead.

```diff
-   * @example 'hh:mm' will give a 24 hour clock
+   * @example 'HH:mm' will give a 24 hour clock
 ```

Co-authored-by: Paul Popus <paul@payloadcms.com>
2026-04-02 19:04:49 +00:00
MichalKormanik fdaa051d03 feat: improve default slugify utility by triming whitespace first (#14421)
Currently, when you are creating slugs and leave spaces on beggining or
the end, they will be replaced with hyphen, this enhancement avoids
trims unwanted spaces first.

### What?
Utilities for creating slugs from titles

### Why?
Badly created slug with hyphen in the end, when user added title with
space.

### How?
Adding simple `trim()` function before replacing spaces with hyphens

Co-authored-by: Paul Popus <paul@payloadcms.com>
2026-04-02 17:43:49 +00:00
Anders Semb Hermansen c3ec73f586 chore: export FieldAccessArgs from payload (#14755)
### What?

Export `FieldAccessArgs` from payload

### Why?

Consumers can currently import `FieldAccess` from payload but not the
args type it uses.
This also makes it consistent with `Access` which is exported alongside
`AccessArgs`.

### How?

Added `FieldAccessArgs` to the root exports.
2026-04-02 18:25:51 +01:00
Wes DiMiceli 84090accdf fix: JSDoc string for focalPoint option in uploads so it correctly mentions defaults to true (#13070)
<!--

Thank you for the PR! Please go through the checklist below and make
sure you've completed all the steps.

Please review the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository if you haven't already.

The following items will ensure that your PR is handled as smoothly as
possible:

- PR Title must follow conventional commits format. For example, `feat:
my new feature`, `fix(plugin-seo): my fix`.
- Minimal description explained as if explained to someone not
immediately familiar with the code.
- Provide before/after screenshots or code diffs if applicable.
- Link any related issues/discussions from GitHub or Discord.
- Add review comments if necessary to explain to the reviewer the logic
behind a change

### What?

### Why?

### How?

Fixes #

-->

This docstring appears to contradict the underlying behavior? Suggest a
quick edit here.

Co-authored-by: Paul Popus <paul@payloadcms.com>
2026-04-02 16:03:31 +00:00
Jarrod Flesch b705af4d4f chore: extract sanitizeField from sanitizeFields (#16133)
Extracts per-field sanitization logic into a standalone `sanitizeField`
function that can be called independently. The `sanitizeFields` function
now delegates to `sanitizeField` in a loop, maintaining identical
behavior while exposing the core logic.

This enables sanitizing individual fields after initial config
sanitization, which is needed for the orderable optimization in the
follow-up PR.

Exports `sanitizeField` and `SanitizeFieldArgs` from the payload
package.
2026-04-02 07:17:37 -07:00
Omar Yusuf Abdi 24b9e13d72 fix: skip validation for unpublish operations with localized required fields (#16120)
## Problem

Unpublishing documents with `required: true` + `localized: true` fields
fails with a validation error when `localization.fallback: false`.

The `UnpublishButton` sends only `{ _status: 'draft' }` with
`unpublishAllLocales=true` but no field data, causing `beforeChange`
validation to fail on missing required fields.

## Solution

Added `unpublishAllLocales` to the `skipValidation` condition in
`beforeChange` for both collections and globals — same pattern already
used for `isSavingDraft`.

## Why

Unpublish is a metadata-only operation (changing `_status`). Field
validation is unnecessary since data already exists in the database.

Fixes #15651

---------

Co-authored-by: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com>
2026-04-01 13:35:49 -07:00
Jarrod Flesch 76f3693451 perf: skip validation for default timezones (#16129)
Payload's 48 hardcoded default timezones were being validated with `new
Intl.DateTimeFormat()` on every cold start. This is unnecessary since
they're known to be valid.

This change pre-computes a `Set` of default timezone values at module
load and skips validation for any timezone in that set.
Custom/user-provided timezones are still validated.

### Before/After

| Operation | Before | After |
|-----------|--------|-------|
| `sanitizeAdminConfig` | 9.18ms | 0.17ms |
| `validateTimezones` | 9.06ms | 0.03ms |
2026-04-01 15:04:13 -04:00
Jens Becker e09c619190 fix: skip default validation for virtual fields (#16015)
### What?

Virtual fields (`virtual: true` or `virtual: "path"`) no longer get a
default type-based validator during field sanitization. They receive a
noop validator instead.

### Why?

Virtual fields are populated server-side (via hooks, built-in
population, or plugins), so their value is `undefined` in form state.
The default validator causes false validation errors in the admin UI
that users cannot resolve.

### How?

In `sanitize.ts`, when assigning a default validator, check if the field
is virtual first. If so, assign `() => true` instead of the type-based
validator. Explicit `validate` functions are still preserved.

Fixes #16012
Related #16013

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1213743778134481
2026-04-01 11:22:35 -04:00
Jarrod Flesch 67b5dc3e69 feat: add typescript.postProcess hook for type generation (#16103)
## Summary

This pr would allow us to import a generic from the plugin and then use
it. The limitation with json-schema-to-typescript is the inability to
generate a type in the file that takes generics.

There are some generated generics plopped in, internally by our
addSelectGenericsToGeneratedTypes function.

This PR exposes a way for external plugins to manipulate the generated
config post-processing of json-schema-to-typescript which is not
possible today.

## Implementation

In `packages/payload/src/bin/generateTypes.ts`, after
`addSelectGenericsToGeneratedTypes`:

```ts
if (config.typescript?.postProcess?.length) {
  for (const fn of config.typescript.postProcess) {
    compiled = fn(compiled);
  }
}
```

Add to TypeScript config type:
```ts
typescript?: {
  postProcess?: Array<(compiled: string) => string>;
}
```

**Use cases**

- Inject imports from plugin packages
- Add utility types

**Example**

```ts
// Plugin adds an import and uses it via tsType on fields

typescript: {
  postProcess: [
    ...(config.typescript?.postProcess || []),
    (compiled) => {
      const importStmt = `import type { MyPluginType } from '@payloadcms/my-plugin';`;
      return compiled.replace(/(\/\*[\s\S]*?\*\/\n)/, `$1\n${importStmt}\n`);
    },
  ],
}
```

**Notes**

- Follows existing pattern (Payload already post-processes for Select
generics)
2026-04-01 13:39:46 +00:00
Dan Ribbens d7c07b3e8f chore(release): v3.81.0 [skip ci] 2026-03-31 21:10:56 -04:00
Dan Ribbens d09ed5454c fix: update file-type, ajv and jose packages (#16118)
## Summary

Updates three key dependencies to their latest versions to address
security vulnerabilities, bug fixes, and compatibility improvements.

Fixes https://github.com/payloadcms/payload/issues/16072

## Changes

### file-type (19.3.0 → 21.3.4)

  - Updated in @payloadcms/payload, @payloadcms/next, and test suite
- Major version bump includes new file type detection capabilities and
improved performance
  - Now requires Node.js >=20 (up from >=18)

### ajv (8.17.1 → 8.18.0)

  - Updated in @payloadcms/payload
  - Minor version bump with bug fixes and schema validation improvements

### jose (5.9.6 → 5.10.0)

  - Updated in @payloadcms/payload
  - Patch version bump for JWT/JWS/JWE handling library
  - Includes security patches and standards compliance updates

### nodemailer (7.0.12 → 8.0.4)

  - Updated in test suite only (dev dependency)
  - Major version bump with improved email sending capabilities

## Impact

- All packages remain compatible with the project's Node.js version
requirements (^18.20.2 || >=20.9.0)
  - No breaking API changes expected for end users
  - Lock file updated to resolve dependency tree
2026-03-31 15:02:08 -04:00
Jarrod Flesch 4ae5577357 fix: add field-level access control to internal auth fields (#16119)
Adds field-level access control to internal auth fields (`hash`, `salt`,
`resetPasswordToken`, `resetPasswordExpiration`, `loginAttempts`,
`lockUntil`, `_verificationToken`) to prevent external writes.

This is a preventative measure. Users should configure collection-level
access control on auth collections (e.g., restricting who can update
users) rather than relying solely on field-level protections. Internal
auth operations are unaffected as they use direct DB operations.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1213885887366085
2026-03-31 15:01:57 -04:00
Alessio Gravili d6862fe84e chore(deps): bump qs-esm from 7.0.2 to 8.0.1 (#16110)
Bumps qs-esm from 7.0.2 to 8.0.1. [qs-esm 8 removes some legacy code
that is not used in
payload](https://github.com/payloadcms/qs-esm/pull/5), making the
package a bit smaller.
2026-03-31 10:42:35 -04:00
Patrik aa143e6e8f fix: trashed documents still show as IDs in relationship responses (#16102)
# Overview

When a relationship points to a collection with `trash: true`,
soft-deleted documents were showing as raw IDs in responses instead of
being omitted.

## Key Changes

In the dataloader, trash-enabled collections now fetch with `trash:
true` so soft-deleted documents are returned alongside live ones.
`deletedAt` is merged into the select to ensure it's always present in
the result.

In `populate()`, the returned doc is checked for `deletedAt`. If set,
the doc is trashed and `relationshipValue` is set to null. For hasMany,
null entries are filtered from the array after all row promises settle.
For single relationships, the field is set to null.

This correctly distinguishes trashed documents from inaccessible ones —
access-denied or permanently deleted documents still fall back to the
raw ID, unchanged from existing behavior. `depth: 0` is also unaffected.


Fixes #16022

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1213860470196517
2026-03-30 10:25:33 -07:00
Elliot DeNolf e90fb963f9 chore(release): v3.80.0 [skip ci] 2026-03-20 14:06:03 -04:00
German Jablonski 67063ab5e3 fix: unpublish updates existing version instead of creating a new one (#15985)
## Summary

Unpublishing a document currently creates a new version in the versions
table, even though the content hasn't changed. Only `_status`
transitions from `published` to `draft`. This clutters the version
history with redundant entries and has been a source of confusion (see
#10838, #12342, #15125).

This PR makes unpublish update the latest existing version in-place
instead of creating a new one, for both collections and globals.

Closes #10838 (again)

## Related

#10838
#12342
#15125 (Discussion)

## Changes

- `saveVersion` now accepts an `unpublish` flag. When set, it finds the
latest version and updates it in-place (same pattern as the existing
autosave overwrite path) instead of creating a new version.
- Collection and global update operations pass `unpublish:
unpublishAllLocales` to `saveVersion`.

No UI or `next` package changes are needed. The admin UI already sends
`unpublishAllLocales=true` in the query string when the user clicks
"Unpublish." This parameter already flowed through the endpoint handler,
the operation, and into `updateDocument`. The only thing missing was
that `saveVersion` didn't use it. This PR makes `saveVersion` check for
it.

For normal updates (saving drafts, publishing, editing content),
`unpublishAllLocales` is `undefined`, so `!!undefined` evaluates to
`false` and the new code path is skipped entirely.

## Scope and limitations

This PR covers the main "Unpublish" action, which sends
`unpublishAllLocales=true` even for non-localized collections. This is
the case reported in #10838 and #12342.

Single-locale unpublish (the "Unpublish in [locale]" button, available
when `localizeStatus` is enabled) is **not** covered by this PR. That
flow does not send `unpublishAllLocales=true`. Instead, it falls into
the single-locale publish/unpublish code path, which involves data
merging and snapshots. The current code does not distinguish between
"publish locale X" and "unpublish locale X," so fixing it requires a new
flag (e.g. `unpublishLocale`) that the UI would need to send. This
should be addressed in a follow-up PR.

## Testing

Run the new tests with:

```
PAYLOAD_DATABASE=postgres pnpm run test:int versions -- --testNamePattern="Unpublish"
```

Without this PR's changes, 2 of the 3 tests fail:

```
FAIL  should not create a new version when unpublishing a collection document
AssertionError: expected [ { id: 249, parent: 24, …(6) }, …(1) ] to have a length of 1 but got 2

FAIL  should not create a new version when unpublishing a global
AssertionError: expected [ …(2) ] to have a length of 1 but got 2
```

The third test (`should update main table _status to draft when
unpublishing`) passes both before and after, confirming the main table
was already updated correctly.

---------

Co-authored-by: German Jablonski <GermanJablo@users.noreply.github.com>
2026-03-20 07:34:40 -04:00
Alessio Gravili a188556e99 chore: bump monorepo Next.js version to 16.2.0 (#15992)
This PR does not bump the Next.js version in the templates yet. First,
let's run our test suite against 16.2.0 and make sure it's fully
compatible.
2026-03-19 11:21:02 -04:00
Patrik 57a0edc6ff fix: use latest draft version data when trashing unpublished documents (#15981)
# Overview

Fixes document data being silently lost when bulk-trashing draft-only
documents on collections with `trash: true` and `versions.drafts`
enabled.

Localized fields are the most visible symptom, but any field data that
exists only in the versions table (i.e. never published) is affected.

## Key Changes

- In the bulk `update` operation, `queryDrafts` (versions table) is now
also used when fetching documents for a trash attempt, not only when
`shouldSaveDraft` is true

## Root Cause

Draft saves skip `updateOne` on the main collection table
(`isSavingDraft === true`), so the main table only reflects the last
published state. When bulk-trashing, the operation was reading
`docWithLocales` from the main table via `find()` — picking up
stale/empty data — and creating a new version from that stale snapshot.

Single-document trash (`updateByID`) was unaffected because it uses
`getLatestCollectionVersion()`, which always reads from the versions
table.

## Fix

```typescript
// Before
if (hasDraftsEnabled(collectionConfig) && shouldSaveDraft) {

// After
if (hasDraftsEnabled(collectionConfig) && (shouldSaveDraft || isTrashAttempt)) {
```

Fixes #15980
2026-03-19 07:17:44 -07:00
Sasha 395e1ed1a4 feat: add disableUnique property to the slug field for better multi tenant plugin support (#15963)
Fixes https://github.com/payloadcms/payload/issues/14938

This PR adds a new property `disableUnique` which is useful when you
want to add a compound unique index to the collection `indexes` config
instead, for example with the multi-tenant plugin where the slug is only
unique per tenant, not globally.
2026-03-17 14:20:38 +00:00
Elliot DeNolf da212fd849 fix(deps): resolve high severity audit vulnerabilities (#15961)
# Overview

Fixes all high/critical severity vulnerabilities reported by `pnpm audit
--prod`.

## Key Changes

- **Bump `undici` in `packages/payload`**
- 7.18.2 → 7.24.4. Fixes 6 CVEs:
[CVE-2026-1526](https://nvd.nist.gov/vuln/detail/CVE-2026-1526),
[CVE-2026-1528](https://nvd.nist.gov/vuln/detail/CVE-2026-1528),
[CVE-2026-2229](https://nvd.nist.gov/vuln/detail/CVE-2026-2229)
(WebSocket memory/crash),
[CVE-2026-2581](https://nvd.nist.gov/vuln/detail/CVE-2026-2581)
(response buffering DoS),
[CVE-2026-1525](https://nvd.nist.gov/vuln/detail/CVE-2026-1525) (request
smuggling),
[CVE-2026-1527](https://nvd.nist.gov/vuln/detail/CVE-2026-1527) (CRLF
injection). Minor version bump, same major. Payload only uses `Agent`
and `fetch`.

- **Bump `rollup` in `packages/payload`**
- 4.52.3 → 4.59.0. Fixes
[CVE-2026-27606](https://nvd.nist.gov/vuln/detail/CVE-2026-27606)
(arbitrary file write via path traversal in output filenames). Minor
version bump within ^4. Used only for `rollup-plugin-dts` type bundling.

- **Bump `@sentry/nextjs` and `@sentry/types` in
`packages/plugin-sentry`**
- ^8.33.1 → ^9.5.0. All 8.x versions pin rollup 3.29.5, vulnerable to
[CVE-2026-27606](https://nvd.nist.gov/vuln/detail/CVE-2026-27606);
9.5.0+ switched to rollup ^4.34.9. The plugin uses a small API surface
(one type import, `ErrorBoundary`, `captureException`) — none have
breaking changes in v9.

- **Bump `changelogen` in `tools/releaser` and `tools/scripts`**
- ^0.5.5 → ^0.6.2. The new version pulls in giget 2.x which dropped the
vulnerable `tar` dependency entirely. Resolves 6 high-severity node-tar
CVEs: [CVE-2026-31802](https://nvd.nist.gov/vuln/detail/CVE-2026-31802),
[CVE-2026-29786](https://nvd.nist.gov/vuln/detail/CVE-2026-29786),
[CVE-2026-26960](https://nvd.nist.gov/vuln/detail/CVE-2026-26960),
[CVE-2026-24842](https://nvd.nist.gov/vuln/detail/CVE-2026-24842),
[CVE-2026-23950](https://nvd.nist.gov/vuln/detail/CVE-2026-23950),
[CVE-2026-23745](https://nvd.nist.gov/vuln/detail/CVE-2026-23745)
(symlink/hardlink traversal, path sanitization bypasses).

- **Override `@vercel/blob>undici`**
- 5.29.0 → 6.24.1 via pnpm override. Fixes the same undici CVEs listed
above. undici 6.x had no API changes vs 5.x (only dropped Node 14/16
support), and `@vercel/blob` only imports `fetch` and types. Bumping
`@vercel/blob` itself (0.x → 2.x) requires `allowOverwrite` handling and
has no test coverage, so that will be a separate PR.

## Design Decisions

Where possible, vulnerabilities are resolved through direct dependency
bumps rather than pnpm overrides. This is more maintainable since future
`pnpm install` runs will naturally resolve to patched versions. The one
exception is `@vercel/blob>undici`, kept as an override because
upgrading `@vercel/blob` from 0.x to 2.x changes overwrite semantics on
`put()` and the adapter has no integration tests. That upgrade warrants
its own PR with proper testing.
2026-03-16 13:17:09 -04:00
Elliot DeNolf 25a0e1659d chore(release): v3.79.1 [skip ci] 2026-03-16 10:30:19 -04:00
Jessica Rynkar e4742054db fix: stricter input validation (#15868)
Misc input validation improvements, sanitizing path segments in both SQL
and JSON queries, standardizing the processing of column and JSON paths
across different adapters, and making adjustments to traversal and alias
generation to align behavior across components.
2026-03-16 10:48:24 +00:00
Omar Yusuf Abdi fac59c8515 fix: text field validation rejecting localized object values (#15932)
## Summary

Fixes #15828

When using `locale: 'all'` with a required localized text field, the
`text` validator incorrectly rejects the value. This happens because:

1. With `locale: 'all'`, the value passed to the validator is an object
like `{en: "Hello", es: "World"}` (locale unflattening via
`mergeLocaleActions` happens *after* validation in `promise.ts`)
2. The old condition `!(typeof value === 'string' ||
Array.isArray(value))` evaluates to `true` for objects, triggering the
required error
3. The `textarea` validator already handles this correctly by using
`!value` (objects are truthy)

### Fix

Changed the text validator's required check from:
```ts
if (!(typeof value === 'string' || Array.isArray(value)) || value?.length === 0)
```
to:
```ts
if (!value || ((typeof value === 'string' || Array.isArray(value)) && value.length === 0))
```

This aligns with the textarea validator's approach:
- `!value` catches `null`, `undefined`, and empty string `""`
- The second clause catches empty arrays `[]` (truthy but length 0)
- Localized objects like `{en: "Hello"}` pass because `!obj` is `false`

### Changes

- **`packages/payload/src/fields/validations.ts`** — 1-line fix to the
required check condition
- **`packages/payload/src/fields/validations.spec.ts`** — 3 unit tests
(localized object accepted, null rejected, empty string rejected)
- **`test/fields/collections/Text/index.ts`** — Added
`localizedRequiredText` test field
- **`test/fields/int.spec.ts`** — Integration test verifying `locale:
'all'` with required localized text

## Test plan

- [x] Unit tests: 88/88 passed (85 existing + 3 new)
- [x] Integration tests (SQLite): 154/154 passed
- [ ] CI passes on all database adapters
2026-03-12 16:37:33 -07:00
Jarrod Flesch ef507a6907 fix: use Sec-Fetch-Site header for cookie authentication validation (#15751)
## Summary

- Adds `Sec-Fetch-Site` header validation for cookie-based
authentication when `Origin` header is absent
- Accepts cookies for `same-origin` and `same-site` requests
- Rejects cookies for `cross-site` and `none` requests
- Falls back to rejecting cookies when `Sec-Fetch-Site` is absent and
`csrf` is configured (non-browser clients should use `Authorization`
header)

---------

Co-authored-by: Jessica Chowdhury <jessica@trbl.design>
2026-03-12 16:31:36 +00:00
Sasha f30d34f9fc fix: improved request origin retrieval (#15919)
Adds a new function `getRequestOrigin` that properly retrieves the
origin of the request, either from `serverURL` or a valid `host` header
2026-03-11 22:38:33 +00:00
Sasha 6a9e367341 fix: generate:types inlines all blocks, add forceInlineBlocks property to use in plugin mcp (#15892)
Fixes https://github.com/payloadcms/payload/issues/15820 which is a
regression from https://github.com/payloadcms/payload/pull/15675.

This PR adds a new property to use it for plugin mcp internal purposes
instead of modifying the behaviour globally.
2026-03-11 11:39:00 +00:00
Divyam gupta 17aa1b570f fix: update broken custom components docs link in config types jsdoc (#15794)
<!--

Thank you for the PR! Please go through the checklist below and make
sure you've completed all the steps.

Please review the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository if you haven't already.

The following items will ensure that your PR is handled as smoothly as
possible:

- PR Title must follow conventional commits format. For example, `feat:
my new feature`, `fix(plugin-seo): my fix`.
- Minimal description explained as if explained to someone not
immediately familiar with the code.
- Provide before/after screenshots or code diffs if applicable.
- Link any related issues/discussions from GitHub or Discord.
- Add review comments if necessary to explain to the reviewer the logic
behind a change

### What?

### Why?

### How?

Fixes #

-->

### What?

Fix a broken documentation link in the `Config` type JSDoc for admin
custom components.

- Updated:
  - `https://payloadcms.com/docs/admin/custom-components/overview`
- To:
  - `https://payloadcms.com/docs/custom-components/overview`

### Why?

The previous URL was broken/outdated, so developers reading type
definitions were sent to an invalid docs path.

### How?

Changed the `@see` link in:

- `packages/payload/src/config/types.ts`

No runtime behavior or API logic changed; this is a docs-link correction
only.
2026-03-10 15:42:26 -07:00
Sasha dc049fe00f fix: run sanitizeWhereQuery for join query access result (#15891)
This PR properly runs `sanitizeWhereQuery` for joined collections which
is important specifically for virtual relationship fields path
transformation.

Fixes https://github.com/payloadcms/payload/issues/15332
2026-03-10 17:04:33 -04:00
Paul c6054c5601 fix: early return out of me access (#15883)
Closes https://github.com/payloadcms/payload/issues/15822

Simple change, moves up the logic check to before the fetch so it
returns early skipping a database call in some cases
2026-03-10 14:09:29 +00:00
German Jablonski 17a0d196f4 fix: scope orderable join reordering by parent relation (#15842)
## Context
The root problem was scope leakage in join-based ordering. Reorder
operations were effectively treating join items as one shared list, so
moving an item in one parent context could influence ordering in a
different parent context.

## Changes
- Reorder key generation, adjacent-row lookup, and initial missing-key
backfill now run in the same join `on` scope as the target item.
- Fast drag/drop cases that send `pending` now resolve against scoped
context before computing new keys.
- `index.ts` keeps orchestration, and scoped query logic is extracted
into focused helpers to keep behavior changes easier to review.
- Replaced a local where-wrapper with the shared
`combineWhereConstraints` utility.

## Impact
Users can reorder join rows without cross-parent side effects. Items in
unrelated parent relations no longer jump or get new keys from another
parent's ordering context.

---------

Co-authored-by: German Jablonski <GermanJablo@users.noreply.github.com>
2026-03-06 11:54:50 -05:00
Elliot DeNolf 6ae6f1093d chore(release): v3.79.0 [skip ci] 2026-03-04 14:26:05 -05:00
German Jablonski e89918266d fix: restoreVersion validation for localized required fields (#15821)
## Summary
- fix collection `restoreVersion` to read version/current docs with all
locales before restore processing
- validate restore data in the default locale context while preserving
full locale data for persistence
- add an integration regression test for restoring versions when
required localized fields are empty in a non-default locale

Closes #15698

## Related
payloadcms/payload#15698

## Changes
- update `packages/payload/src/collections/operations/restoreVersion.ts`
- add coverage in `test/versions/int.spec.ts`

## Testing
- [x] `PAYLOAD_DATABASE=sqlite NODE_OPTIONS="--no-deprecation
--no-experimental-strip-types" NODE_NO_WARNINGS=1 DISABLE_LOGGING=true
pnpm exec vitest --project int test/versions/int.spec.ts -t "should
restore published version with correct data|should restore a published
version when required localized fields are empty in a non-default
locale"`

---------

Co-authored-by: German Jablonski <GermanJablo@users.noreply.github.com>
2026-03-03 17:14:51 +00:00
Luiz Cláudio f47069900d fix: draft doc validation when duplicating docs (#15816)
### What?
This PR defaults the `draft` value to true when it is not set during a
duplicate operation.

### Why?
It validates the required fields even if the user is trying to duplicate
a draft doc.

The regression was introduced when
[`packages/payload/src/collections/endpoints/duplicate.ts`](https://github.com/payloadcms/payload/blob/v3.77.0/packages/payload/src/collections/endpoints/duplicate.ts)
was refactored to use the shared `parseParams` utility instead of
manually parsing query parameters.

**v3.62.0 (working)** — In [v3.62.0 of
`duplicate.ts`](https://github.com/payloadcms/payload/blob/v3.62.0/packages/payload/src/collections/endpoints/duplicate.ts),
the `draft` parameter was explicitly defaulted to `true`:

```typescript
// draft defaults to true, unless explicitly set requested as false
// to prevent the newly duplicated document from being published
const draft = searchParams.get('draft') !== 'false'
```

**v3.77.0 (broken)** — In [v3.77.0 of
`duplicate.ts`](https://github.com/payloadcms/payload/blob/v3.77.0/packages/payload/src/collections/endpoints/duplicate.ts),
the endpoint uses
[`parseParams`](https://github.com/payloadcms/payload/blob/v3.77.0/packages/payload/src/utilities/parseParams/index.ts):

```typescript
const { depth, draft, populate, select, selectedLocales } = parseParams(req.query)
```

`parseParams` only sets `draft` to `true` if the query string explicitly
contains `?draft=true`. The admin UI's
[`DuplicateDocument`](https://github.com/payloadcms/payload/blob/v3.77.0/packages/ui/src/elements/DuplicateDocument/index.tsx)
component does **not** send `?draft=true` as a query parameter, so
`draft` is `undefined`.

The UI does send `{ _status: 'draft' }` in the request body, but that
does not influence the `draft` flag. In
[`createOperation`](https://github.com/payloadcms/payload/blob/v3.77.0/packages/payload/src/collections/operations/create.ts),
the validation skip logic depends entirely on the `draft` argument:

```typescript
const isSavingDraft = Boolean(draft && hasDraftsEnabled(collectionConfig) && !publishAllLocales)
// ...
skipValidation: isSavingDraft && !hasDraftValidationEnabled(collectionConfig),
```

Since `draft` is `undefined`, `isSavingDraft` is `false`,
`skipValidation` is `false`, and full validation runs — rejecting the
duplicate when any required fields are empty.

### How?
By setting the `draft` value as `true` by default when it is not set
during a duplicate operation:

Fixes #15815

---------

Co-authored-by: German Jablonski <GermanJablo@users.noreply.github.com>
2026-03-03 13:06:24 +00:00
Palamar Roman f0498f2c0f feat(richtext-lexical): separate configuration for lexical block icons (#15632)
This PR adds a new images property to BlockConfig, allowing developers
to specify separate images for different UI contexts:
- **images.icon** — small icon displayed in Lexical editor slash menus
and toolbars (20x20px)
- **images.thumbnail** — larger thumbnail shown in the block selection
drawer (3:2 aspect ratio)

## Why

Currently, `imageURL` serves dual purposes:
1. Block selection drawer thumbnails (3:2 aspect ratio, e.g., 480x320px)
2. Lexical editor icons (scaled down to 20x20px)

Citing documentation:

> Display Contexts:
> Block Selection Drawer: Images appear as thumbnails in a responsive
grid when editors add blocks
> Lexical Editor: Images are scaled down to 20x20px icons in menus and
toolbars

This creates problem , where developer needs to choose either good
looking icons (but worse UX on block drawers) or good looking thumnails,
but scaled down images as icons.

## Solution

Add a dedicated `images` property with a fallback chain:
```
Lexical editor icon: images.icon → images.thumbnail → imageURL (deprecated) → default BlockIcon
Block drawer thumbnail: images.thumbnail → imageURL (deprecated) → default placeholder
```
Now you can set different URL for block icons in lexical blocks:
<img width="562" height="370" alt="image"
src="https://github.com/user-attachments/assets/912e1482-0b37-40f7-a144-e21e43fc7aec"
/>

Example:

```ts
  const QuoteBlock: Block = {
    slug: 'quote',
    images: {
      icon: 'https://example.com/icons/quote-20x20.svg',
      thumbnail: { url: 'https://example.com/thumbnails/quote-480x320.jpg', alt: 'Quote block' },
    },
    fields: [...],
  }
  ```

## Backwards Compatibility

**Fully backwards compatible** — imageURL and imageAltText still work (marked as @deprecated), with automatic fallback to the new images property behavior.
2026-03-02 13:16:26 -08:00
Alessio Gravili f7d0d04bfb refactor!: rename widget ComponentPath to Component for consistency (#15780)
- Renames `Widget.ComponentPath` to `Widget.Component` and types it as
`PayloadComponent` instead of `string`
- This aligns dashboard widgets with every other component reference in
Payload (collections, globals, fields, admin components) - none of them
use _path_ in the property name, and all of them are typed as
`PayloadComponent`
- Enables new [typescript
plugin](https://github.com/payloadcms/payload/pull/15779) to work for
widget paths (the plugin uses `PayloadComponent` contextual type
detection - `string`-typed properties were invisible to it)

## Breaking Change

`Widget.ComponentPath` has been renamed to `Widget.Component`.

### Migration

Find and replace in your Payload config:

```diff
 widgets: [
   {
     slug: 'my-widget',
-    ComponentPath: './components/MyWidget#MyWidget',
+    Component: './components/MyWidget#MyWidget',
   },
 ]
```
2026-03-02 15:40:35 +00:00