Commit Graph

2419 Commits

Author SHA1 Message Date
Alessio Gravili 35fe09d024 feat: add exclusive concurrency controls for workflows and tasks (#15177)
Adds exclusive concurrency controls to prevent race conditions when
multiple jobs operate on the same resource. Jobs with the same
concurrency key will not run in parallel.

## Key Changes

- Added `concurrency` option to workflows and tasks for defining
resource keys
- Added `enableConcurrencyControl` feature flag (defaults to `false`,
will be `true` in 4.0)
- Added indexed `concurrencyKey` field to jobs collection when
`enableConcurrencyControl` is set to `true`

## Usage Example

```typescript
export default buildConfig({
  jobs: {
    enableConcurrencyControl: true,
    workflows: [{
      slug: 'syncDocument',
      concurrency: ({ input }) => `sync:${input.documentId}`,
      handler: async ({ job }) => {
        // Only one job per documentId runs at a time
      }
    }]
  }
})
```
2026-01-13 19:02:20 +00:00
Jarrod Flesch dd494becc6 fix: adds missing transactions to login and logout operations (#15134)
Fixes https://github.com/payloadcms/payload/issues/14610

Adds transactions to login and logout operations.

Note: the github diff viewer is very misleading. If you look at the diff
locally all that was changed was initializing a transaction and killing
it if an error is thrown in the login/logout operations.
2026-01-13 13:51:47 -05:00
German Jablonski ea15f0039a chore: deprecate skip parameter from db-adapters (#15183)
`skip` is an internal API. It is used only by the database adapters and
is not publicly exposed through the local API, only when accessing the
database directly. It does exactly the same thing as `page`, but with
different semantics. In this PR I am deprecating it because it adds
nothing but complexity. It is used only once in the entire repository
and conflicts with the `page` parameter.
2026-01-13 13:42:15 -05:00
Paul d7351d5710 chore: add v4 comments for relationTo types (#15187)
Adds v4 todo comment for relationTo, reminder to make [] arrays fail
when they should contain at least one value
2026-01-13 16:28:24 +00:00
Jarrod Flesch ef90811112 fix(storage-*): add range headers to storage adapters (#14890)
## Summary

Extension of https://github.com/payloadcms/payload/pull/14887

Add HTTP Range request support to all storage adapters (S3, GCS, Vercel
Blob, Uploadthing) to enable video streaming with scrubbing/seeking
capabilities.

## Changes

### Core Utilities

**`packages/payload/src/uploads/getRangeRequestInfo.ts`** (new)
- Created shared utility function that encapsulates range request logic
- Accepts `fileSize` and optional `rangeHeader`
- Returns discriminated union with three types:
  - `'full'`: No range header, serve complete file (200 OK)
- `'partial'`: Valid range request, serve partial content (206 Partial
Content)
  - `'invalid'`: Invalid/unsatisfiable range (416 Range Not Satisfiable)
- Includes all necessary headers (`Accept-Ranges`, `Content-Length`,
`Content-Range`)

**`packages/payload/src/exports/internal.ts`**
- Export `getRangeRequestInfo` for use by storage adapters
- Export `parseRangeHeader` (already existed, now available to adapters)

### Storage Adapters

All four adapters now follow a consistent pattern:

1. Get file size/metadata
2. Call `getRangeRequestInfo({ fileSize, rangeHeader })`
3. Return 416 if range is invalid
4. Use `rangeResult.headers` for response headers
5. Use `rangeResult.status` for response status code
6. Pass range parameters to storage SDK when `type === 'partial'`

**`packages/storage-s3/src/staticHandler.ts`**
- Always call `getRangeRequestInfo` (simplified from conditional
approach)
- Use S3 `Range` parameter when partial: `bytes=${start}-${end}`
- Set all range-related headers from `rangeResult.headers`

**`packages/storage-gcs/src/staticHandler.ts`**
- Call `getRangeRequestInfo` with file size from metadata
- Pass `{ start, end }` options to `createReadStream()` when partial
- Set all range-related headers from `rangeResult.headers`

**`packages/storage-vercel-blob/src/staticHandler.ts`**
- Call `getRangeRequestInfo` with blob size
- Send `Range` header in fetch when partial
- Set all range-related headers from `rangeResult.headers`

**`packages/storage-uploadthing/src/staticHandler.ts`**
- Call `getRangeRequestInfo` with file size from HEAD response
- Send `Range` header in fetch when partial
- Set all range-related headers from `rangeResult.headers`

## Benefits

- **Video streaming**: Browsers can now seek/scrub video files served
through Payload
- **Reduced bandwidth**: Clients only download requested byte ranges
- **Standards compliant**: Implements RFC 7233 HTTP Range Requests
- **Consistent implementation**: All adapters use shared utility,
reducing code duplication
- **Backward compatible**: Non-range requests work exactly as before
(200 OK with full file)

## Notes

- Azure and R2 storage adapters already had complete range support
- `Accept-Ranges: bytes` header is now always present on file responses
- Invalid ranges return 416 with `Content-Range: bytes */{size}` header
- Multi-range requests use first range only (standard simplification)
2026-01-13 10:46:10 -05:00
Paul 9521ec62b0 fix: throw error on empty relationTo array and allow disabling lockDocuments on all collections (#14871)
This PR fixes some issues reported from Discord:

1. An empty relationTo array is valid via TS but it will now throw an
error as it's not valid config
```ts
relationTo: [], // Now throws error in sanitisation
```

2. Setting lockDocuments: false on all collections and globals is now
supported, it will skip the creation of the lock documents collection
altogether instead of creating an invalid collection
2026-01-13 10:45:55 -05:00
Tobias Odendahl 92da9faade feat: add bulkOperations.singleTransaction config option (#14387)
## Summary

Adds a `bulkOperationsSingleTransaction` config option to the MongoDB
adapter to handle database transaction limitations when processing large
numbers of documents in bulk operations.

## Problem

Some databases (e.g., DocumentDB, Cosmos DB) have transaction
limitations when working with large datasets. DocumentDB throws an error
when a transaction attempts to use a cursor for more than 100 documents:
`Feature not supported: cursor within transaction. Try increasing the
batchsize.`

## Solution

- Adds `bulkOperationsSingleTransaction` boolean option to MongoDB
adapter (`@payloadcms/db-mongodb`)
- Added to `BaseDatabaseAdapter` interface for cross-adapter
compatibility
- Included in `compatibilityOptions` for DocumentDB and Cosmos DB
(default: `true`)
- When enabled, bulk update and delete operations process documents
sequentially in separate transactions
- When disabled (default: `false`), maintains current behavior with
parallel processing in a single transaction
- Fully backward compatible - no changes to existing behavior unless
explicitly configured

## Changes

- **MongoDB Adapter**: Add `bulkOperationsSingleTransaction` config
option
- **Base Adapter Interface**: Add optional property to
`BaseDatabaseAdapter`
- **Compatibility Options**: Enable by default for DocumentDB and Cosmos
DB
- **Core Operations**: Modify `updateOperation` and `deleteOperation` to
support per-document transactions
- **Documentation**: Add to MongoDB adapter options documentation
- **Tests**: Add integration tests for both update and delete operations

## Testing

Tested with both `bulkOperationsSingleTransaction: true` and `false` to
ensure:
- Documents are correctly updated/deleted in both modes
- Sequential processing works without transaction conflicts
- Parallel processing (default) maintains optimal performance
2026-01-13 09:13:56 -05:00
Said Akhrarov d68e75ada7 fix: add distinct validate to richtext field type definition (#15069)
### What?
This PR adds the `RichTextFieldValidation` type to the `validate`
property of the `RichTextField`.

### Why?
Previously, adding the default `richText` validate function to a typical
`type: 'richText'` field would result in a TS error due to mismatching
types, specifically on the `options` argument. With these changes, you
can now simply thread the default `richText` validation through to the
`validate` property as expected with no unnecessary type manipulation.

Previously, this would result in a TS error:
```ts
import type { CollectionConfig } from 'payload'

import { richText } from 'payload/shared'

export const PostsCollection: CollectionConfig = {
  slug: 'posts',
  // ...
  fields: [
    // ...
    {
      name: 'content',
      type: 'richText',
      validate: richText, // Type error here
    },
  ],
}
```
With the changes introduced here, the TS error is resolved.

### How?
By threading the existing `RichTextFieldValidation` type to the
`validate` property of the `RichTextField`.

Fixes #15068
2026-01-12 22:57:02 -05:00
Alessio Gravili 51d6cd25e9 refactor: clean up generated type resolution (#15163)
Previously, `GeneratedTypes` contained both augmented properties and
`*Untyped` fallbacks in the same interface. This required complex
`Resolve*` conditional types with numerous `@ts-expect-error`
suppressions to handle both cases.

This PR restructures the type system into three parts: `GeneratedTypes`
(empty, module-augmented by payload-types.ts), `UntypedPayloadTypes`
(fallbacks), and `PayloadTypes` (merges both automatically).

A new `PayloadTypesShape` interface defines the expected structure,
enabling simple property access like `T['collections']` instead of
complex conditional types. All `Typed*` types are now one-liners.

This not only eliminates 10+ `@ts-expect-error` suppressions, but also
makes more sense semantically. GENERATEDTypes now exclusively contain
types that were GENERATED. Previously, they also contained fallbacks.

`CollectionSlug`, `GlobalSlug`, and `AuthCollectionSlug` now accept an
optional generic parameter, enabling the SDK to use custom Config types
without type duplication.
2026-01-13 01:59:43 +02:00
Sasha aaea133a53 fix: exclude files from being sent to the form-state action (#15174)
Fixes https://github.com/payloadcms/payload/issues/15073
Surprasses https://github.com/payloadcms/payload/pull/15078

We use the `deepCopyObjectSimpleWithoutReactComponents` function to
serialize data for the form state action. This PR adds a new option to
`deepCopyObjectSimpleWithoutReactComponents` - `excludeFiles: boolean`
and adds usage of it in places where we call the form state action to:

* Avoid overhead of sending the file on every form state calculation
when the collection has uploads enabled, since we don't actually use the
file inside the form state action.
* Fix the issue with client uploads when the file exceeds the platform
limit.
2026-01-12 23:19:29 +02:00
Jarrod Flesch d462f9bcf4 fix: full image urls stored in DB (#15089)
### Issue

URL field values can be set as full URLs, but should be stored as paths
if they are on the local file system. Previously this was not being
handled which is not portable, you should be able to change your
serverURL without needing to update the DB documents.

This PR adds a beforeChange hook to the url fields that converts the
data to the correct relative url for local files. If the url is
external, then that gets returned as is.

### Why 
- fixes https://github.com/payloadcms/payload/issues/14562 where urls
would not be set on create but only after the fixes document was updated
(fixed with beforeChange hook)
- fixes https://github.com/payloadcms/payload/issues/14945 where URLs
are being fetched via fetch instead of with local filesystem reads
(fixed with beforeChange hook)

#### Flow before:
- create --> no URL present --> afterRead returns URL --> update happens
with that returned URL and it is saved

#### New Flow:
- create --> beforeChange hook creates the URL --> saved in DB

The new flow also ensures URLs are saved as relative URLs when stored on
the local filesystem. This makes files more portable, removing the need
to update the DB i.e. if you change your domain for example.

**URL fields are stored in the DB as:**
- relative when on the local filesystem i.e.
`/:apiRoute/:collectionSlug/file/:filename`
- absolute when external i.e.
`https://some-fake-external-website.com/images/1`

**URL field values are returned as:**
- absolute if serverURL is set on the config i.e.
`https://your-domain.com/:apiRoute/:collectionSlug/file/:filename`
- relative if serverURL is not set and the value is a relative path i.e.
`/:apiRoute/:collectionSlug/file/:filename`
- absolute if the url is external i.e.
`https://some-fake-external-website.com/images/1`


The majority of the files added in this PR were moved from the
imageResizer file.
-
[createImageSize.ts](https://github.com/payloadcms/payload/pull/15089/changes#diff-c9bea14458c4f95b4a07c143e13ad40e9d76f69a78ed1e0aaa42660d874ef7ff)
-
[createImageSizes.ts](https://github.com/payloadcms/payload/pull/15089/changes#diff-c3ef20c6b9afc09034860d4a2c65ee0f0c05a448c0b8e41c064d30a07f39cf48)
-
[extractHeightFromImage.ts](https://github.com/payloadcms/payload/pull/15089/changes#diff-42b5eac52d268d5b6ac72299557ce8fe12acbd564ff7e516d727bfc36010e677)
-
[generateImageSizeFilename.ts](https://github.com/payloadcms/payload/pull/15089/changes#diff-672120daaa59eec3cf36428851e5440929db80ac99e352fc17640b73db87879a)
-
[getImageResizeAction.ts](https://github.com/payloadcms/payload/pull/15089/changes#diff-16dfbbd365a6e05ea42ba10716d038f369d27fd090fe718759c082e5c79eef37)
-
[parseFilename.ts](https://github.com/payloadcms/payload/pull/15089/changes#diff-c3b8939f4acd59871ceebe38e591c3028b36834d3baf08ce94a01d454d610f64)
-
[sanitizeResizeConfig.ts](https://github.com/payloadcms/payload/pull/15089/changes#diff-9f567e4597f13ff207789dcaa41399a2e0878c652a30a7973a44b09e2d6f5dde)
2026-01-12 15:59:05 -05:00
Jake d288752f68 fix: field schema map paths (#10852)
Continuation of #10638.

Field paths within the schema map are not correct.

For example, an unnamed tab containing a text field should have the
schema path:
- `_index-0.fieldWithinUnnamedTab`

However, within the schema map that path is formatted as:
- `fieldWithinUnnamedTab`

The leading index in the first example _should_ exist, as this field is
nested within another field, regardless of this field being unnamed.
Otherwise, it would be impossible to traverse the schema and lookup this
field.

This discrepancy is not an issue in the admin panel because fields are
_also_ provided the wrong schema paths. They can properly locate their
schema within the schema map because they match despite being incorrect.
Both are wrong and that's why it works.

Here's comprehensive example of how field paths _should_ be formatted:

```ts
{
  // ...
  fields: [
    {
      // path: 'topLevelNamedField'
      // schemaPath: 'topLevelNamedField'
      // indexPath: ''
      name: 'topLevelNamedField',
      type: 'text',
    },
    {
      // path: 'array'
      // schemaPath: 'array'
      // indexPath: ''
      name: 'array',
      type: 'array',
      fields: [
        {
          // path: 'array.[n].fieldWithinArray'
          // schemaPath: 'array.fieldWithinArray'
          // indexPath: ''
          name: 'fieldWithinArray',
          type: 'text',
        },
        {
          // path: 'array.[n].nestedArray'
          // schemaPath: 'array.nestedArray'
          // indexPath: ''
          name: 'nestedArray',
          type: 'array',
          fields: [
            {
              // path: 'array.[n].nestedArray.[n].fieldWithinNestedArray'
              // schemaPath: 'array.nestedArray.fieldWithinNestedArray'
              // indexPath: ''
              name: 'fieldWithinNestedArray',
              type: 'text',
            },
          ],
        },
        {
          // path: 'array.[n]._index-2'
          // schemaPath: 'array._index-2'
          // indexPath: '2'
          type: 'row',
          fields: [
            {
              // path: 'array.[n].fieldWithinRowWithinArray'
              // schemaPath: 'array._index-2.fieldWithinRowWithinArray'
              // indexPath: ''
              name: 'fieldWithinRowWithinArray',
              type: 'text',
            },
          ],
        },
      ],
    },
    {
      // path: '_index-2'
      // schemaPath: '_index-2'
      // indexPath: '2'
      type: 'row',
      fields: [
        {
          // path: 'fieldWithinRow'
          // schemaPath: '_index-2.fieldWithinRow'
          // indexPath: ''
          name: 'fieldWithinRow',
          type: 'text',
        },
      ],
    },
    {
      // path: '_index-3'
      // schemaPath: '_index-3'
      // indexPath: '3'
      type: 'tabs',
      tabs: [
        {
          // path: '_index-3-0'
          // schemaPath: '_index-3-0'
          // indexPath: '3-0'
          label: 'Unnamed Tab',
          fields: [
            {
              // path: 'fieldWithinUnnamedTab'
              // schemaPath: '_index-3-0.fieldWithinUnnamedTab'
              // indexPath: ''
              name: 'fieldWithinUnnamedTab',
              type: 'text',
            },
            {
              // path: '_index-3-0-1'
              // schemaPath: '_index-3-0-1'
              // indexPath: '3-0-1'
              type: 'tabs',
              tabs: [
                {
                  // path: '_index-3-0-1-0'
                  // schemaPath: '_index-3-0-1-0'
                  // indexPath: '3-0-1-0'
                  label: 'Nested Unnamed Tab',
                  fields: [
                    {
                      // path: 'fieldWithinNestedUnnamedTab'
                      // schemaPath: '_index-3-0-1-0.fieldWithinNestedUnnamedTab'
                      // indexPath: ''
                      name: 'fieldWithinNestedUnnamedTab',
                      type: 'text',
                    },
                  ],
                },
              ],
            },
          ],
        },
        {
          // path: 'namedTab'
          // schemaPath: '_index-3.namedTab'
          // indexPath: ''
          label: 'Named Tab',
          name: 'namedTab',
          fields: [
            {
              // path: 'namedTab.fieldWithinNamedTab'
              // schemaPath: '_index-3.namedTab.fieldWithinNamedTab'
              // indexPath: ''
              name: 'fieldWithinNamedTab',
              type: 'text',
            },
          ],
        },
      ],
    },
  ]
}
```
2026-01-09 17:21:17 -05:00
Alessio Gravili bfe21542ef feat(sdk): add proper error handling (#15148)
Fixes #14495

The SDK now throws a proper `PayloadSDKError` class on failed API
requests instead of either returning undefined or a generic `Error`.
Error handling is centralized in the `request()` method, which adds
error handling for operations where it was previously missing (like
`create`, `update`, `delete`, `find`, etc.).

**Changes:**
- Added `PayloadSDKError` class with `status`, `errors`, `response`, and
`message` properties
- Centralized error handling in `request()` - all operations now
properly throw on failed responses. Previously, some operations (like
`findByID`) just threw a generic Error that did not explain what
actually went wrong and some operations (like `create`) did not throw an
error at all and simply returned undefined.
- Lots of new int tests

**Usage:**
```ts
import { PayloadSDKError } from '@payloadcms/sdk'

try {
  await sdk.create({ collection: 'posts', data: { ... } })
} catch (err) {
  if (err instanceof PayloadSDKError) {
    console.log(err.status)  // 400
    console.log(err.errors)  // [{ name: 'ValidationError', message: '...', data: {...} }]
  }
}
```
2026-01-09 20:25:44 +02:00
Patrik c103667e59 fix: custom OPTIONS endpoints are intercepted and cannot set custom CORS headers (#15153)
### What?

Fixes an issue where custom OPTIONS endpoint handlers were never called
because Payload intercepted all OPTIONS requests early and returned
default CORS headers.

### Why?

Users need to define custom CORS headers for specific endpoints in some
cases (like allowing different origins for different routes). The
previous behavior made this impossible since OPTIONS requests were
handled globally before checking if a custom handler existed.

### How?

Moved the default OPTIONS/CORS handling to after the endpoint matching
logic.

If a custom OPTIONS handler is found, it gets called and can set its own
CORS headers. If no custom handler exists, Payload falls back to the
default CORS response.

Also updated `headersWithCors` to only set CORS headers if they haven't
already been set by the custom handler, preventing them from being
overwritten.

Fixes #14551
2026-01-09 08:35:21 -08:00
Ricardo Tavares 25813a7ba3 perf: optimized collection create when versions are enabled (#14885)
### What?
Optimized the collection create operation when versions are enabled.

### Why?
To reduce the total request time when creating a new entry on a
collection with versions enabled.

### How?
By passing returning=false to the saveVersion method, as the result is
not needed. Also updated drizzle's createVersion operation to ignore the
result when returning==false. This saves multiple database queries,
specially on collections with relationships and other complex fields, as
it no longer needs to load the new entry after creation.

---------

Co-authored-by: Ricardo Tavares <rtavares@cloudflare.com>
2026-01-09 09:42:09 -05:00
Jarrod Flesch 40081f457d fix(db-mongodb): find id field from flattened fields (#15110)
Fixes #14927

## Summary
Fixes custom ID fields that are nested within tabs/groups to work
correctly with MongoDB adapter.

## Problem
When a custom id field was nested inside tabs or other unnamed grouping
fields, MongoDB's schema builder couldn't find it because it only
searched top-level fields. This caused the custom ID to be ignored and
MongoDB would fall back to auto-generating ObjectIDs.

## Solution
- MongoDB adapter: Pass flattenedFields to schema builder so it can find
custom ID fields at the flattened top level
- afterRead hooks: Track field depth and prevent hidden custom ID fields
from being removed when they're at the top level (depth 0), since they
need to be accessible even if marked as hidden

## Testing
Added new CustomIDNested collection and comprehensive tests covering:
- Creating documents with numeric custom IDs nested in tabs
- Retrieving documents by custom ID
- Updating documents with custom ID
2026-01-08 14:55:42 -05:00
Paul a8785ba0a3 feat: add support for additional IANA timezones, custom UTC offsets and overriding the timezone field (#15120)
This PR is quite extensive and comes with several bugfixes and new
features as listed.

### Additional IANA timezones

Previously we were only checking the `supportedValuesOf` for validating
the IANA timezones however this list of names is not comprehensive, nor
is it canonical for what's supported by the runtime. We now check the
`DateTimeFormat` API as well which means timezones like
`Asia/Ho_Chi_Minh` and `Asia/Saigon` are both supported.

### Custom UTC offsets support

In addition to IANA timezone names (like `America/New_York`), you can
also use fixed UTC offsets in the `±HH:mm` format:

```ts
{
  name: 'eventTime',
  type: 'date',
  timezone: {
    supportedTimezones: [
      { label: 'UTC+5:30 (India)', value: '+05:30' },
      { label: 'UTC-8 (Pacific)', value: '-08:00' },
      { label: 'UTC+0', value: '+00:00' },
    ],
  },
}
```

NOTE: Since GraphQL does not allow special symbols like +- in the enums
we format them to _TZOFFSET_MINUS_05_00. The `_TZOFFSET_` prefix is to
ensure that we show this is an internal enum and avoid potential
conflicts for any custom enums.

### Overriding the timezone field

You can now override the timezone field via `override` on timezone
config:

```ts
{
  name: 'publishedAt',
  type: 'date',
  label: 'Published At',
  timezone: {
    override: ({ baseField }) => ({
      ...baseField,
      admin: {
        ...baseField.admin,
        disableListColumn: true, // Hide from list view columns
      },
    }),
  },
}
```

### Bugfixes

- The timezone field can now correctly inherit or display a formatted
label from the parent, for label functions or complex translations you
should use the override as above
- The timezone field can now be hidden from list column
- Types are now generated correctly per field if they have their own
timezones, previously it was assigned to the global SupportedTimezones
list
- Field's own timezones are now run through validation as well
2026-01-08 11:05:25 -06:00
Alessio Gravili 1bd31465b6 feat: ability to cancel current job from within workflow or task handlers (#15119)
This PR exports a new `JobCancelledError` error class. Throwing it from
within a task or workflow handler behaves similarly to throwing any
other error, with the difference that the job won't retry anymore.
2026-01-07 20:23:44 -06:00
Alessio Gravili e214debe20 fix: cannot read private member #headers error on Node.js 24 when using isolateObjectProperty (#15116)
Fixes `TypeError: Cannot read private member #headers` in Node 24. This
error occurred [when calling createLocalReq with existing
`req.headers`](https://github.com/payloadcms/payload/blob/main/packages/payload/src/utilities/createLocalReq.ts#L125).
Happened in a few int tests if we run them using Node 24 (we currently
run tests using Node 23).

**Root cause:**

Node 24's `Request` uses private class fields (`#headers`). When
accessing properties through our Proxy, the getter's execution context
was incorrectly bound to the Proxy instead of the original object,
preventing access to private fields.

```ts
class Request {
  #headers: Headers
  get headers() {
    // private properties can be accessed from within the class
    return this.#headers
  }
}

const req = new Request()
const proxy = isolateObjectProperty(req, 'transactionID')

// The getter runs as `this.#headers` - but what is `this`?
// Before fix: `this` = proxy (doesn't have #headers) -> TypeError
// After fix: `this` = original req (owns #headers) -> Works
proxy.headers
```

**The fix:**

For non-isolated properties, the Proxy now correctly delegates to the
original object's context. This matches the behavior as if no Proxy was
used - which is the intended design, since the Proxy should be
transparent for everything except the isolated properties (usually
`transactionID`).
2026-01-08 00:14:40 +00:00
Alessio Gravili 070ded796f refactor: deprecate returning failed state from job queue handlers (#15102)
This PR deprecates returning `{ state: 'failed' }`, which unnecessarily
inflates the API.

**Reasons:**
- It duplicates existing behavior, making the API and docs more
confusing.
- It creates ambiguity about which approach developers should use.
- It increases maintenance cost.
- Throwing errors is more flexible: for example, cancelling a job from a
task handler currently requires throwing `JobCancelledError`, which
cannot be done via a return value.


# Migration Example

## Before

```ts
import type { TaskConfig } from 'payload'

export const ReturnCustomErrorTask: TaskConfig<'ReturnCustomError'> = {
  retries: 0,
  slug: 'ReturnCustomError',
  inputSchema: [],
  outputSchema: [],
  handler: ({ input }) => {
    return {
      state: 'failed',
      errorMessage: 'oh no! this failed'
    }
  },
}
```

## After

```ts
import type { TaskConfig } from 'payload'

export const ReturnCustomErrorTask: TaskConfig<'ReturnCustomError'> = {
  retries: 0,
  slug: 'ReturnCustomError',
  inputSchema: [],
  outputSchema: [],
  handler: ({ input }) => {
    throw new Error('oh no! this failed')
  },
}
```
2026-01-06 15:43:30 -08:00
Thomas Coldwell 58faafd718 feat: add typescript.strictDraftTypes flag for opt-in draft query type safety (#14388)
### What?

This PR adds an opt-in `typescript.strictDraftTypes` config flag that
enables strict type safety for draft mode queries. When enabled, `find`
operations with `draft: true` will correctly type required fields as
optional.

```typescript
export default buildConfig({
  typescript: {
    strictDraftTypes: true,  // <-- defaults to false
  },
  // ...
})
```

### Why?

Support for correct types when creating a draft document was added in
#14271, however, the types returned when querying a document (e.g. via
Local API `find`) with `draft: true` still return incorrect types—many
required fields are possibly null but typed as non-null.

Making this change without a flag would be a breaking change for
existing user code, as any queries using a dynamic `draft` parameter
would start showing required fields as optional, requiring null checks
throughout their codebase. By introducing this behind a flag, users can
opt-in when ready, and it will become the default behavior in v4.0.

### How?

- Added `strictDraftTypes` config flag to `typescript` config object
(defaults to `false`)
- Added a generic `TDraft extends boolean` parameter to `find()` and
`findLocal()`
- Added conditional return types that check both `TDraft` and
`GeneratedTypes extends { strictDraftTypes: true }`
- Created `QueryDraftDataFromCollection` type for draft query results
where:
  - `id` is required (always present in query results)
- User-defined required fields are optional (validation skipped for
drafts)
  - System fields like `createdAt`, `updatedAt` are optional
- Updated `DraftTransformCollectionWithSelect` to use the new type for
draft queries
- Modified type generation to add `strictDraftTypes: true` to
`GeneratedTypes` when enabled

---------

Co-authored-by: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com>
2026-01-06 10:13:36 -08:00
Elliot DeNolf 9e9a45f2a3 chore(release): v3.70.0 [skip ci] 2026-01-05 15:00:47 -05:00
Anders Semb Hermansen 2ccf898d64 feat: support qs-esm sort arrays in REST API (#15065)
### What?

Support qs-esm array in sort parameter to sort by multiple columns in
REST API

### Why?

To align input of Local API and REST API so that REST API also accepts
array like Local API do.

### How?

Extract parsing/sanitizing of sort input to it's own method which also
accept array.
Added unit and integration test. Updated docs with example.

Fixes #15052
2025-12-30 21:46:47 +00:00
Riley Langbein 4468197305 fix: add beforeDocumentControls to globals generate importmap (#15036)
### What?

When adding `beforeDocumentControls` as a custom component to a global,
it does not get picked up by the import map, even when running
`generate:importmap`.

### Why?

`beforeDocumentControls` is missing from `addToImportMap` in
`iterateGlobals`.

### How?

Adds `beforeDocumentControls` to `addToImportMap` in `iterateGlobals`.

While doing this I've used the opportunity to add `Description` to the
globals custom component documentation as it was missing.

Fixes #15035

Co-authored-by: Riley Langbein <rilrom2@hotmail.com>
2025-12-29 23:35:15 +00:00
Alessio Gravili cd87ab4517 fix: warning during Next.js build "the request of a dependency is an expression" (#15007)
Fixes https://github.com/payloadcms/payload/issues/15006

https://github.com/payloadcms/payload/pull/14337 introduced a next build
warning, because the `eval` around `await import` statements has been
removed.

Unlike jest, vitest is fine with it. But during Next.js build, the
bundler will throw a `"Critical dependency: the request of a dependency
is an expression"` warning.

This PR fixes this warning by adding back the eval statement around the
import. It does not add back the conditional require statement - that
part was only necessary to appease jest.

For vitest, we have to directly call `await import()`. Using eval in
vitest will throw an `ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING` error.

## Preventing this regression in the future

This PR adds a comment above the eval explaining why it's needed.
Previously this was missing, and we only had a comment explaining why
the `require` was needed.

Additionally, the reason this was not caught in CI was because warnings
during next build did not fail the build script. This PR adds an
additional check in `build-template-with-local-pkgs.ts` that throws an
error if a warning was detected during next build. You can find the CI
failure here:
https://github.com/payloadcms/payload/actions/runs/20402288354/job/58626594247?pr=15007
(CI run from commit that added this check, before the commit that fixed
it)

Now, if this ever happens again, our CI will fail and prevent a
regression.

## More Tests

This PR adds additional database and job queue tests that verify that
the job queue handler and migration file imports work correctly.
2025-12-23 11:52:54 -05:00
Elliot DeNolf 7a4d37ae94 chore(release): v3.69.0 [skip ci] 2025-12-19 16:37:22 -05:00
Sasha 4e45432592 test: migrate to vitest (#14337)
Continuation of https://github.com/payloadcms/payload/pull/10200

* For VSCode - you'll need to install the official plugin
https://marketplace.visualstudio.com/items?itemName=vitest.explorer
* Every test suite now explicitly imports the test runner functions like
`describe`, `expect`, instead of using it from `global`. While `vitest`
supports globals with https://vitest.dev/config/#globals, the main
benefit of not doing this is that we don't conflict with test runners
that have the same functions, e.g `playwright` and `tstyche`.
* Now we have only 1 test config file that uses
[projects](https://vitest.dev/guide/projects) for unit and integration
tests

---------

Co-authored-by: Alessio Gravili <github@gravili.net>
2025-12-19 16:07:14 -05:00
Jarrod Flesch fa6b503896 fix: basePath not working properly with admin routes (#14967)
This PR adds a base-path test suite, updates the formatAdminURL helper
function.

The helper function allows you to create routes inside the admin panel
that take `basePath` into account when set.

There are a lot of touched files here, most have the same treatment.

## API URL construction changes
Previously we were appending the basePath in the default api
configuration. That is no longer the case and is more predictable to use
the helper function that will generate a correct path/url.

## Admin URL construction changes
The default admin route differed from the default api route, the default
admin route did not prepend the basePath. The reason it did not/should
not, is because `usePathname` from NextJS excludes basePath.

So if we were to prepend it to `routes.admin` and then for example use
`pathname.startsWith(config.routes.admin)` it would always return false
when using a basePath, since `/route` will never start with
`/basePath/route`.

Also, when you do something like `router.push(/adminRoute)` NextJS will
prepend the basePath and push you to `/basePath/adminRoute`. If we
prepended it in the config it would push you to
`/basePath/basePath/adminRoute`.


## Helper function usage

The helper is especially useful for plugin work, this way your plugin
will generate correct urls for users using basePath.

Before we were doing:
```ts
// worked because we prepended basePath to apiRoute
const apiURL = `${serverURL}${apiRoute}/collections/${slug}/${id}`

// never worked since basePath was never prepended to adminRoute
const adminURL = `${serverURL}${adminRoute}/collections/${slug}/${id}`
```

Now we can do:
```ts
import { formatAdminURL } from 'payload/shared'

// Admin url example
const adminURL = formatAdminURL({
  adminRoute: config.routes.admin,
  path: `/collections/${slug}/${id}`,
})

// API url example
const apiURL = formatAdminURL({
  apiRoute: config.routes.api,
  path: `/collections/${slug}/${id}`,
  // serverURL, when supplied returns a full url
})
```

If you pass `serverURL` the result will be a full URL. If excluded the
result will be a relative path.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-19 11:13:21 -05:00
German Jablonski f1116247ed feat: modular dashboards - widgets (#13683)
[RFC Here](https://github.com/payloadcms/payload/discussions/11862).

You can test the feature by running `pnpm dev dashboard` on this branch.

<details>
<summary>Old (obsolete) example</summary>

See the [comment
below](https://github.com/payloadcms/payload/pull/13683#issuecomment-3581457901)
explaining the change in approach we took


https://github.com/user-attachments/assets/96157f83-c5d7-4350-9f31-c014daedb2a8

</details>

### New demo


https://github.com/user-attachments/assets/6c08d8d6-c989-4845-b56f-6d3fbd30b1af

## Future Work 

The following improvements are planned but will be added in the future: 

- fields: You'll be able to define `fields` that a widget receives,
which will serve as props in the component. Why might this be useful?
Imagine a chart widget that can have a weekly, daily, or yearly view. Or
a "count" widget that shows how many documents there are in a collection
(the collection could be a field).
- A11y (EDITED): Okay, I finally went the extra mile here, and you can
reorder and resize it with the keyboard. The screen reader works,
although there's room for improvement.
- Dashboard presets: we're planning to add the ability to create and
share dashboard presets, similar to how [query
presets](https://payloadcms.com/docs/query-presets/overview) work today.
For example, you could build dashboards that adjust based on a variable,
such as a "daily, weekly, or monthly" interval. You could also create
dashboards tailored to different focus areas, like "marketing, sales, or
product."
2025-12-19 15:22:03 +00:00
Jake 519a3c6015 fix: get field by path for blocks (#14984)
It's currently not possible to use `getFieldByPath` to find a field
nested within a block.

For example:

```ts
// This return undefined
const fieldInBlock = getFieldByPath({
  path: 'blocks.block2.text2' ,
  fields: [
    {
      type: 'blocks',
      name: 'blocks',
      blocks: [
        {
          slug: 'block1',
          fields: [
            {
              name: 'text1',
              type: 'text',
            },
          ],
        },
        {
          slug: 'block2',
          fields: [
            {
              name: 'text2',
              type: 'text',
            },
          ],
        },
      ],
    }
  ]
})
```

This is because the function is ignoring block slugs and looping over
each block's fields until one is found. Except they are never found
because the path is not trimmed for the next iteration. If the same
field exists across different blocks, I would also imagine the wrong
schema could get matched.
2025-12-19 09:26:40 -05:00
Alessio Gravili 446cbb14ab refactor: replace deprecated url.parse api usage (#14980)
Replaces `URL.parse()` usage which is now deprecated with `new URL()`.
Fixes https://github.com/payloadcms/payload/issues/14978
2025-12-18 21:15:15 +00:00
Jessica Rynkar 61298c6b59 fix: improves upload security for PDFs and SVGs (#14929)
#### What?
Improves upload security by closing validation gaps for `PDF` and `SVG`
files, preventing malicious files from bypassing MIME type restrictions.

#### Why?

- **PDFs**: Corrupted PDFs with a manipulated content type could bypass
detection when no `fileTypeFromBuffer` was undefined, allowing
unauthorized file types pass.

- **SVGs**: SVGs can contain malicious scripts that execute when
rendered, currently we do not check the SVG for anything potentially
harmful

#### How?
- Added extension validation: If the PDF returns no buffer, we have an
additional check on the `ext` which closes the validation gap
- Added SVG sanitization: New `validateSvg` utility checks for attack
elements including scripts, event handlers, foreign objects, iframes etc
2025-12-18 12:58:23 -05:00
Jarrod Flesch ec7c1920a8 fix: missing range headers (#14887)
## Summary

Adds HTTP Range request support (RFC 7233) to Payload's core file
serving, enabling video streaming with scrubbing/seeking capabilities in
browsers.

## Changes

- **Added `Accept-Ranges: bytes` header** to all file responses
- **Created `parseRangeHeader` utility** for parsing Range headers using
`range-parser` package
- **Enhanced `streamFile` function** to support byte range streaming via
`fs.createReadStream` options
- **Added integration tests** covering various range request scenarios

## Technical Details

- Uses `range-parser` library for RFC 7233 compliant parsing
  - Supports single byte ranges (e.g., `bytes=0-1023`)
  - Handles open-ended ranges (e.g., `bytes=1024-`)
  - Handles suffix ranges (e.g., `bytes=-512`)
- Multi-range requests return first range only (standard simplification)

## Testing

Added comprehensive integration test suite covering:
- Full file requests with Accept-Ranges header
- Partial content requests (206 responses)
- Invalid range handling (416 responses)
- Response body size verification
- Edge cases (out-of-bounds, malformed headers)


### Before


https://github.com/user-attachments/assets/065060ae-35db-4c3d-bc72-cfb976b57349

### After


https://github.com/user-attachments/assets/b70caa49-e055-47b2-87f8-31f02a42c86a
2025-12-16 08:27:17 -05:00
Elliot DeNolf c20f6d4973 chore(release): v3.68.5 [skip ci] 2025-12-15 15:06:27 -05:00
Jake 4f12bee5ef refactor: add relative flag to formatAdminURL util (#14925)
Adds a `relative` flag to the `formatAdminURL` util.

There are cases where this function can produce a fully qualified URL,
like for client-side routing, and other times when it must remain
relative, like when matching routes.

This flag differentiates these two behaviors declaratively so we don't
have to rely on the omission of `serverURL`.

```ts
const result = formatAdminURL({
  adminRoute: '/admin',
  basePath: '/v1',
  path: '/collections/posts',
  serverURL: 'http://payloadcms.com',
  relative: true,
})

// returns '/v1/admin/collections/posts'
```

Related: https://github.com/payloadcms/payload/pull/14919 and
https://github.com/payloadcms/payload/pull/14907
2025-12-15 15:02:29 -05:00
Elliot DeNolf bb29c49e7c chore(release): v3.68.4 [skip ci] 2025-12-14 15:01:17 -05:00
Jessica Rynkar bb1501ea14 fix: previousValue from hooks should be populated within lexical blocks (#14856)
### What?
Updates the `previousValue` data sent to the `afterChange` hook to
accomodate **lexical nested** paths.

### Why?
Currently the `afterChange` hook uses the previousDoc:
```ts
previousValue: getNestedValue(previousDoc, pathSegments) ?? previousDoc?.[field.name],
```

However, with fields from within Lexical, the data on the full
`previousDoc` has a complex nesting structure. In this case, we should
be using the `previousSiblingDoc` to only return the sibling data and
easily de-structure the data.

### How?
Checks if sibling data exists, and uses it if it does:
```ts
 const previousValData =
    previousSiblingDoc && Object.keys(previousSiblingDoc).length > 0
      ? previousSiblingDoc
      : previousDoc
```

**Reported by client.**

Related PR: https://github.com/payloadcms/payload/pull/14582

---------

Co-authored-by: German Jablonski <43938777+GermanJablo@users.noreply.github.com>
2025-12-12 10:58:54 +00:00
Elliot DeNolf be0e9b5915 chore(release): v3.68.3 [skip ci] 2025-12-11 17:36:04 -05:00
Jarrod Flesch b82356b9bf fix: passes serverURL through to all formatAdminURL calls (#14869)
Fixes issues with basePath and NextJS. Many of the links created in the
admin panel were not passing a `serverURL` through to the
`formatAdminURL` helper.

This PR adds all the missing `serverURL` instances and adds a new helper
`formatApiURL` that allows for similar route creation but for api
routes.
2025-12-10 14:42:07 -05:00
Elliot DeNolf 309ae51ae8 chore(release): v3.68.2 [skip ci] 2025-12-10 14:26:55 -05:00
Alessio Gravili c9a8aa0344 refactor: replace generic Error('Unauthorized') with UnauthorizedError (#14879)
Refactors all auth checks to throw `UnauthorizedError` instead of the
generic `Error('Unauthorized')`. This ensures consistent unauthorized
errors being thrown
2025-12-10 11:07:42 -06:00
Elliot DeNolf 92dcb836b6 chore(release): v3.68.1 [skip ci] 2025-12-09 16:19:33 -05:00
Elliot DeNolf beb87a0eb4 chore(release): v3.68.0 [skip ci] 2025-12-09 14:27:37 -05:00
Alessio Gravili 20914ccec4 ci: remove prepack and prepublishonly scripts (#14832)
This PR removes the `prepack` and `prepublishonly` scripts, which have
become unnecessary and counterproductive.

PR https://github.com/payloadcms/payload/pull/14820 introduced a new
`pnpm build:debug` command. However, when running our pre-release
publish workflow, each package is currently built twice: once via `pnpm
build:debug` (as intended) and again via the hard-coded `prepublishonly`
script. Because prepublishonly always runs a clean, non-debug build, it
both nullifies the debug output and adds significant overhead by
rebuilding everything.

All of our release and pack-to-destination scripts already perform a
full build ahead of time, so these additional prepublish hooks provide
no benefit and actively interfere with the intended debug workflow. This
PR removes the redundant scripts to ensure debug builds work as expected
and to speed up the release process.
2025-12-08 15:07:18 -05:00
Jarrod Flesch 0da0da8693 fix: adds read and readDistinct to hook mapping (#14853)
Fixes https://github.com/payloadcms/payload/issues/14847
2025-12-08 16:46:12 +00:00
Patrik 3a29cec318 fix: undefined drafts error in collections with join fields (#14855)
Fixes #14846
2025-12-08 08:22:21 -08:00
Rot4tion 9c9c7c525b fix: use hasDraftsEnabled utility instead of direct check (#14843)
Replaces direct checks of `collectionConfig.versions?.drafts` with the
`hasDraftsEnabled` utility function for consistency across document
permissions, update operations, and duplicate document functionality.
2025-12-08 10:33:19 -05:00
Elliot DeNolf ccfaee0b1b fix(storage-*): upload filename respects prefix (#14566)
## Overview

Fixes filename collision detection to respect storage adapter
prefix/suffix patterns. When using cloud storage with prefixes (e.g.,
multi-tenant scenarios), files with identical names under different
prefixes were incorrectly
  triggering deduplication.

Resolves #14561 

  ## Changes

  ### Core Upload Logic
- **`docWithFilenameExists`**: Now accepts and checks `prefix` parameter
in WHERE clause, scoping collision detection to prefix namespace
- **`getSafeFileName`**: Accepts `prefix` parameter and passes to
collision check
- **`generateFileData`**: Extracts `prefix` from data (set by storage
plugins) and threads through to collision detection

  ### Test Coverage
- **New collection `MediaWithDynamicPrefix`**: Tests multi-tenant
scenario with hook-based prefix assignment
- **Updated `MediaWithPrefix`**: Added `prefix` field and
`filenameCompoundIndex: true`
- **Comprehensive test suite**: Validates collision within prefix, no
collision across prefixes, backward compatibility for collections
without prefix, and dynamic prefix from hooks
2025-12-08 09:57:27 -05:00
Patrik d86c17455d fix: file objects being serialized to plain objects in upload form state (#14818)
### What?

Fixes a bug where saving an upload document would fail with `"[object
Object]" is not valid JSON` error after changing the file and then
modifying another field.

### Why?

When a file was changed in an upload document and saved, the File object
would persist in the client-side form state.

On the next save (when modifying a different field), the form state
would be deep copied and serialized before submission. This
serialization converted the File object into a plain object `{name,
size, type, ...}`, which was then stringified to `"[object Object]"` and
sent to the server, causing a JSON parsing error.

### How?

1. **Preserve File objects during serialization**
- Added instanceof File check in
`deepCopyObjectSimpleWithoutReactComponents` to return File objects
as-is instead of serializing them to plain objects
   - Similar to how Date objects are already handled

2. **Clear file field after successful save**
- Delete the `file` field from form state after save in upload
collections
   - Prevents stale File objects from persisting between saves

I could of just added an instanceof File check in
[createFormData](https://github.com/payloadcms/payload/blob/1340818097c90904ff9e0facb5b30329bbeb430e/packages/ui/src/forms/Form/index.tsx#L528)
but it would be just a bandaid fix. If a non-File object reaches that
code, it indicates a bug elsewhere that should fail loudly. The root
causes were:
- File objects being incorrectly serialized during form submission
- File objects persisting in form state after they should be cleared

Fixes #14814
2025-12-08 12:24:47 +00:00
Alessio Gravili ce934b455d ci: add internal-debug release mode (#14820)
This PR adds support for publishing internal-debug pre-releases,
designed to make debugging Payload packages easier during development.

## Usage

When manually triggering the publish-prerelease workflow, check the
"Enable debug mode" checkbox to publish an internal-debug release.

## Why?

Debug builds skip bundling, React Compiler transformations, and
minification. This makes it easier to:
- Inspect Payload source code directly in node_modules
- Set breakpoints and step through unminified code
- Make local modifications for testing/debugging purposes
2025-12-05 21:28:48 +00:00