diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.spec.ts b/packages/next/src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.spec.ts
index fa32c364963..f3ed248ec75 100644
--- a/packages/next/src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.spec.ts
+++ b/packages/next/src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.spec.ts
@@ -1,5 +1,7 @@
-import { countChangedFields, countChangedFieldsInRows } from './countChangedFields.js'
import type { ClientField } from 'payload'
+import { describe, it, expect } from 'vitest'
+
+import { countChangedFields, countChangedFieldsInRows } from './countChangedFields.js'
describe('countChangedFields', () => {
// locales can be undefined when not configured in payload.config.js
diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/utilities/fieldHasChanges.spec.ts b/packages/next/src/views/Version/RenderFieldsToDiff/utilities/fieldHasChanges.spec.ts
index 153fe1c5d70..72d85c1cb6c 100644
--- a/packages/next/src/views/Version/RenderFieldsToDiff/utilities/fieldHasChanges.spec.ts
+++ b/packages/next/src/views/Version/RenderFieldsToDiff/utilities/fieldHasChanges.spec.ts
@@ -1,3 +1,5 @@
+import { describe, it, expect } from 'vitest'
+
import { fieldHasChanges } from './fieldHasChanges.js'
describe('hasChanges', () => {
diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/utilities/getFieldsForRowComparison.spec.ts b/packages/next/src/views/Version/RenderFieldsToDiff/utilities/getFieldsForRowComparison.spec.ts
index db3d6f7c068..44a8216a102 100644
--- a/packages/next/src/views/Version/RenderFieldsToDiff/utilities/getFieldsForRowComparison.spec.ts
+++ b/packages/next/src/views/Version/RenderFieldsToDiff/utilities/getFieldsForRowComparison.spec.ts
@@ -1,5 +1,7 @@
-import { getFieldsForRowComparison } from './getFieldsForRowComparison'
import type { ArrayFieldClient, BlocksFieldClient, ClientField } from 'payload'
+import { describe, it, expect } from 'vitest'
+
+import { getFieldsForRowComparison } from './getFieldsForRowComparison'
describe('getFieldsForRowComparison', () => {
describe('array fields', () => {
diff --git a/packages/next/src/views/Version/Restore/index.tsx b/packages/next/src/views/Version/Restore/index.tsx
index 45c11f622cc..434031f19ba 100644
--- a/packages/next/src/views/Version/Restore/index.tsx
+++ b/packages/next/src/views/Version/Restore/index.tsx
@@ -66,7 +66,10 @@ export const Restore: React.FC
= ({
const canRestoreAsDraft = status !== 'draft' && collectionConfig?.versions?.drafts
const handleRestore = useCallback(async () => {
- let fetchURL = `${serverURL}${apiRoute}`
+ let fetchURL = formatAdminURL({
+ apiRoute,
+ path: '',
+ })
let redirectURL: string
if (collectionConfig) {
@@ -74,7 +77,6 @@ export const Restore: React.FC = ({
redirectURL = formatAdminURL({
adminRoute,
path: `/collections/${collectionConfig.slug}/${originalDocID}`,
- serverURL,
})
}
@@ -83,7 +85,6 @@ export const Restore: React.FC = ({
redirectURL = formatAdminURL({
adminRoute,
path: `/globals/${globalConfig.slug}`,
- serverURL,
})
}
@@ -101,7 +102,6 @@ export const Restore: React.FC = ({
toast.error(t('version:problemRestoringVersion'))
}
}, [
- serverURL,
apiRoute,
collectionConfig,
globalConfig,
diff --git a/packages/next/src/views/Versions/cells/CreatedAt/index.tsx b/packages/next/src/views/Versions/cells/CreatedAt/index.tsx
index 6be93092ef6..15abcd988fe 100644
--- a/packages/next/src/views/Versions/cells/CreatedAt/index.tsx
+++ b/packages/next/src/views/Versions/cells/CreatedAt/index.tsx
@@ -26,7 +26,6 @@ export const CreatedAtCell: React.FC = ({
config: {
admin: { dateFormat },
routes: { admin: adminRoute },
- serverURL,
},
} = useConfig()
@@ -40,7 +39,6 @@ export const CreatedAtCell: React.FC = ({
to = formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}/${trashedDocPrefix}${docID}/versions/${id}`,
- serverURL,
})
}
@@ -48,7 +46,6 @@ export const CreatedAtCell: React.FC = ({
to = formatAdminURL({
adminRoute,
path: `/globals/${globalSlug}/versions/${id}`,
- serverURL,
})
}
diff --git a/packages/next/src/views/Versions/index.tsx b/packages/next/src/views/Versions/index.tsx
index 064acc2c0a4..affd6abd369 100644
--- a/packages/next/src/views/Versions/index.tsx
+++ b/packages/next/src/views/Versions/index.tsx
@@ -1,7 +1,7 @@
import { Gutter, ListQueryProvider, SetDocumentStepNav } from '@payloadcms/ui'
import { notFound } from 'next/navigation.js'
import { type DocumentViewServerProps, type PaginatedDocs, type Where } from 'payload'
-import { formatApiURL, hasDraftsEnabled, isNumber } from 'payload/shared'
+import { formatAdminURL, hasDraftsEnabled, isNumber } from 'payload/shared'
import React from 'react'
import { fetchLatestVersion, fetchVersions } from '../Version/fetchVersions.js'
@@ -115,10 +115,9 @@ export async function VersionsView(props: DocumentViewServerProps) {
: Promise.resolve(null),
])
- const fetchURL = formatApiURL({
+ const fetchURL = formatAdminURL({
apiRoute,
path: collectionSlug ? `/${collectionSlug}/versions` : `/${globalSlug}/versions`,
- serverURL,
})
const columns = buildVersionColumns({
diff --git a/packages/next/src/withPayload/withPayload.utils.ts b/packages/next/src/withPayload/withPayload.utils.ts
index 657706a68e9..bad87065bc5 100644
--- a/packages/next/src/withPayload/withPayload.utils.ts
+++ b/packages/next/src/withPayload/withPayload.utils.ts
@@ -114,7 +114,7 @@ export function supportsTurbopackExternalizeTransitiveDependencies(
return false
}
- const { canaryVersion, major, minor } = version
+ const { canaryVersion, major, minor, patch } = version
if (major === undefined || minor === undefined) {
return false
@@ -129,11 +129,15 @@ export function supportsTurbopackExternalizeTransitiveDependencies(
return true
}
if (minor === 1) {
+ // 16.1.1+ and canaries support this feature
+ if (patch > 0) {
+ return true
+ }
if (canaryVersion !== undefined) {
// 16.1.0-canary.3+
return canaryVersion >= 3
} else {
- // Assume that Next.js 16.1 inherits support for this feature from the canary release
+ // Next.js 16.1.0
return true
}
}
diff --git a/packages/next/src/withPayload/withPayloadLegacy.ts b/packages/next/src/withPayload/withPayloadLegacy.ts
index d942f1a23a3..d906568f494 100644
--- a/packages/next/src/withPayload/withPayloadLegacy.ts
+++ b/packages/next/src/withPayload/withPayloadLegacy.ts
@@ -48,7 +48,7 @@ export const withPayloadLegacy = (nextConfig: NextConfig = {}): NextConfig => {
if (isBuild && (isTurbopackNextjs15 || isTurbopackNextjs16)) {
throw new Error(
- 'Your Next.js and Payload versions do not support using Turbopack for production builds. Please upgrade to Next.js 16.1.0 or, if not yet released, the latest canary release.',
+ 'Your Next.js version does not support using Turbopack for production builds. The *minimum* Next.js version required for Turbopack Builds is 16.1.0. Please upgrade to the latest supported Next.js version to resolve this error.',
)
}
diff --git a/packages/payload-cloud/jest.config.js b/packages/payload-cloud/jest.config.js
deleted file mode 100644
index b0539a17900..00000000000
--- a/packages/payload-cloud/jest.config.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import baseConfig from '../../jest.config.js'
-
-/** @type {import('jest').Config} */
-const customJestConfig = {
- ...baseConfig,
- setupFilesAfterEnv: null,
- testMatch: ['**/src/**/?(*.)+(spec|test|it-test).[tj]s?(x)'],
- testTimeout: 20000,
- transform: {
- '^.+\\.(t|j)sx?$': [
- '@swc/jest',
- {
- $schema: 'https://json.schemastore.org/swcrc',
- sourceMaps: true,
- exclude: ['/**/mocks'],
- jsc: {
- target: 'esnext',
- parser: {
- syntax: 'typescript',
- tsx: true,
- dts: true,
- },
- },
- module: {
- type: 'es6',
- },
- },
- ],
- },
-}
-
-export default customJestConfig
diff --git a/packages/payload-cloud/package.json b/packages/payload-cloud/package.json
index 5ebb856b142..dfcf4f4d5a7 100644
--- a/packages/payload-cloud/package.json
+++ b/packages/payload-cloud/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/payload-cloud",
- "version": "3.68.2",
+ "version": "3.70.0",
"description": "The official Payload Cloud plugin",
"homepage": "https://payloadcms.com",
"repository": {
@@ -48,10 +48,9 @@
"@aws-sdk/lib-storage": "^3.614.0",
"@payloadcms/email-nodemailer": "workspace:*",
"amazon-cognito-identity-js": "^6.1.2",
- "nodemailer": "7.0.9"
+ "nodemailer": "7.0.12"
},
"devDependencies": {
- "@types/jest": "29.5.12",
"@types/nodemailer": "7.0.2",
"payload": "workspace:*"
},
diff --git a/packages/payload-cloud/src/email.spec.ts b/packages/payload-cloud/src/email.spec.ts
index c104aa8ce7b..1b764f63ae3 100644
--- a/packages/payload-cloud/src/email.spec.ts
+++ b/packages/payload-cloud/src/email.spec.ts
@@ -1,6 +1,5 @@
import type { Config, Payload } from 'payload'
-
-import { jest } from '@jest/globals'
+import { describe, beforeAll, beforeEach, it, expect, vitest } from 'vitest'
import nodemailer from 'nodemailer'
import { defaults } from 'payload'
@@ -12,11 +11,11 @@ describe('email', () => {
const defaultDomain = 'test.com'
const apiKey = 'test'
- const mockedPayload: Payload = jest.fn() as unknown as Payload
+ const mockedPayload: Payload = vitest.fn() as unknown as Payload
beforeAll(() => {
// Mock createTestAccount to prevent calling external services
- jest.spyOn(nodemailer, 'createTestAccount').mockImplementation(() => {
+ vitest.spyOn(nodemailer, 'createTestAccount').mockImplementation(() => {
return Promise.resolve({
imap: { host: 'imap.test.com', port: 993, secure: true },
pass: 'testpass',
@@ -75,7 +74,7 @@ describe('email', () => {
skipVerify,
})
- const initializedEmail = email({ payload: mockedPayload })
+ const initializedEmail = email!({ payload: mockedPayload })
expect(initializedEmail.defaultFromName).toStrictEqual(defaultFromName)
expect(initializedEmail.defaultFromAddress).toStrictEqual(defaultFromAddress)
diff --git a/packages/payload-cloud/src/plugin.spec.ts b/packages/payload-cloud/src/plugin.spec.ts
index f69d90cb506..09f7f63dfc3 100644
--- a/packages/payload-cloud/src/plugin.spec.ts
+++ b/packages/payload-cloud/src/plugin.spec.ts
@@ -1,6 +1,6 @@
import type { Config, Payload } from 'payload'
+import { describe, beforeAll, beforeEach, it, expect, test, vitest, Mock } from 'vitest'
-import { jest } from '@jest/globals'
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
import nodemailer from 'nodemailer'
import { defaults } from 'payload'
@@ -13,20 +13,20 @@ import { defaults } from 'payload'
// }))
const mockedPayload: Payload = {
- updateGlobal: jest.fn(),
- findGlobal: jest.fn().mockReturnValue('instance'),
+ updateGlobal: vitest.fn(),
+ findGlobal: vitest.fn().mockReturnValue('instance'),
} as unknown as Payload
import { payloadCloudPlugin } from './plugin.js'
describe('plugin', () => {
- let createTransportSpy: jest.Spied
+ let createTransportSpy: Mock
const skipVerify = true
beforeAll(() => {
// Mock createTestAccount to prevent calling external services
- jest.spyOn(nodemailer, 'createTestAccount').mockImplementation(() => {
+ vitest.spyOn(nodemailer, 'createTestAccount').mockImplementation(() => {
return Promise.resolve({
imap: { host: 'imap.test.com', port: 993, secure: true },
pass: 'testpass',
@@ -39,12 +39,12 @@ describe('plugin', () => {
})
beforeEach(() => {
- createTransportSpy = jest.spyOn(nodemailer, 'createTransport').mockImplementationOnce(() => {
+ createTransportSpy = vitest.spyOn(nodemailer, 'createTransport').mockImplementationOnce(() => {
return {
transporter: {
name: 'Nodemailer - SMTP',
},
- verify: jest.fn(),
+ verify: vitest.fn(),
} as unknown as ReturnType
})
})
@@ -114,7 +114,7 @@ describe('plugin', () => {
})
it('should not modify existing email transport', async () => {
- const logSpy = jest.spyOn(console, 'log')
+ const logSpy = vitest.spyOn(console, 'log')
const existingTransport = nodemailer.createTransport({
name: 'existing-transport',
diff --git a/packages/payload-cloud/vitest.config.ts b/packages/payload-cloud/vitest.config.ts
new file mode 100644
index 00000000000..16c70c33538
--- /dev/null
+++ b/packages/payload-cloud/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineProject } from 'vitest/config'
+
+export default defineProject({
+ test: {
+ testTimeout: 20000,
+ },
+})
diff --git a/packages/payload/package.json b/packages/payload/package.json
index 8f91b956a70..c096d10f9d4 100644
--- a/packages/payload/package.json
+++ b/packages/payload/package.json
@@ -1,6 +1,6 @@
{
"name": "payload",
- "version": "3.68.2",
+ "version": "3.70.0",
"description": "Node, React, Headless CMS and Application Framework built on Next.js",
"keywords": [
"admin panel",
@@ -64,6 +64,10 @@
"import": "./src/exports/i18n/*.ts",
"types": "./src/exports/i18n/*.ts",
"default": "./src/exports/i18n/*.ts"
+ },
+ "./__testing__/predefinedMigration": {
+ "import": "./src/__testing__/predefinedMigration.js",
+ "default": "./src/__testing__/predefinedMigration.js"
}
},
"main": "./src/index.ts",
@@ -114,6 +118,7 @@
"pino-pretty": "13.1.2",
"pluralize": "8.0.0",
"qs-esm": "7.0.2",
+ "range-parser": "1.2.1",
"sanitize-filename": "1.6.3",
"scmp": "2.1.0",
"ts-essentials": "10.0.3",
@@ -130,6 +135,7 @@
"@types/minimist": "1.2.2",
"@types/nodemailer": "7.0.2",
"@types/pluralize": "0.0.33",
+ "@types/range-parser": "1.2.7",
"@types/uuid": "10.0.0",
"@types/ws": "^8.5.10",
"copyfiles": "2.4.1",
@@ -174,6 +180,10 @@
"import": "./dist/exports/i18n/*.js",
"types": "./dist/exports/i18n/*.d.ts",
"default": "./dist/exports/i18n/*.js"
+ },
+ "./__testing__/predefinedMigration": {
+ "import": "./dist/__testing__/predefinedMigration.js",
+ "default": "./dist/__testing__/predefinedMigration.js"
}
},
"main": "./dist/index.js",
diff --git a/packages/payload/src/__testing__/predefinedMigration.js b/packages/payload/src/__testing__/predefinedMigration.js
new file mode 100644
index 00000000000..15f19c841b1
--- /dev/null
+++ b/packages/payload/src/__testing__/predefinedMigration.js
@@ -0,0 +1,18 @@
+/**
+ * Test predefined migration for testing plugin-style module specifier imports.
+ * This is used in integration tests to verify that external packages (without @payloadcms/db-* prefix)
+ * can export predefined migrations via their package.json exports.
+ *
+ * This tests the second code path in getPredefinedMigration.ts (lines 56-72)
+ *
+ * NOTE: This is a .js file (not .ts) because Node.js with --no-experimental-strip-types
+ * cannot import .ts files via module specifiers. Absolute file paths work because Vitest's
+ * loader intercepts file:// URLs, but module specifiers are resolved by Node.js first.
+ */
+const imports = ``
+const upSQL = ` // Test predefined migration from payload/__testing__/predefinedMigration
+ payload.logger.info('Test migration UP from payload package executed')`
+const downSQL = ` // Test predefined migration DOWN from payload package
+ payload.logger.info('Test migration DOWN from payload package executed')`
+
+export { downSQL, imports, upSQL }
diff --git a/packages/payload/src/admin/types.ts b/packages/payload/src/admin/types.ts
index 265ff233279..082b45e083a 100644
--- a/packages/payload/src/admin/types.ts
+++ b/packages/payload/src/admin/types.ts
@@ -590,6 +590,8 @@ export type { LanguageOptions } from './LanguageOptions.js'
export type { RichTextAdapter, RichTextAdapterProvider, RichTextHooks } from './RichText.js'
+export { type WidgetServerProps } from './views/dashboard.js'
+
export type {
BeforeDocumentControlsClientProps,
BeforeDocumentControlsServerProps,
diff --git a/packages/payload/src/admin/views/dashboard.ts b/packages/payload/src/admin/views/dashboard.ts
new file mode 100644
index 00000000000..65e6d40067f
--- /dev/null
+++ b/packages/payload/src/admin/views/dashboard.ts
@@ -0,0 +1,12 @@
+import type { PayloadRequest } from '../../index.js'
+
+export enum EntityType {
+ collection = 'collections',
+ global = 'globals',
+}
+
+export type WidgetServerProps = {
+ req: PayloadRequest
+ widgetData?: Record
+ widgetSlug: string
+}
diff --git a/packages/payload/src/auth/cookies.spec.ts b/packages/payload/src/auth/cookies.spec.ts
index f3cc3402e3c..dbdbc4f656d 100644
--- a/packages/payload/src/auth/cookies.spec.ts
+++ b/packages/payload/src/auth/cookies.spec.ts
@@ -1,3 +1,4 @@
+import { describe, it, expect } from 'vitest'
import { parseCookies } from './cookies.js'
describe('parseCookies', () => {
diff --git a/packages/payload/src/auth/operations/refresh.ts b/packages/payload/src/auth/operations/refresh.ts
index 4c2fe5ee8aa..466da9cd675 100644
--- a/packages/payload/src/auth/operations/refresh.ts
+++ b/packages/payload/src/auth/operations/refresh.ts
@@ -1,5 +1,3 @@
-import url from 'url'
-
import type { Collection } from '../../collections/config/types.js'
import type { Document, PayloadRequest } from '../../types/index.js'
@@ -64,8 +62,9 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise
throw new Forbidden(args.req.t)
}
- const parsedURL = url.parse(args.req.url!)
- const isGraphQL = parsedURL.pathname === config.routes.graphQL
+ const pathname = new URL(args.req.url!).pathname
+
+ const isGraphQL = pathname === config.routes.graphQL
let user = await req.payload.db.findOne({
collection: collectionConfig.slug,
diff --git a/packages/payload/src/auth/sendVerificationEmail.ts b/packages/payload/src/auth/sendVerificationEmail.ts
index 3734f879443..ede0597b861 100644
--- a/packages/payload/src/auth/sendVerificationEmail.ts
+++ b/packages/payload/src/auth/sendVerificationEmail.ts
@@ -7,6 +7,8 @@ import type { TypedUser } from '../index.js'
import type { PayloadRequest } from '../types/index.js'
import type { VerifyConfig } from './types.js'
+import { formatAdminURL } from '../utilities/formatAdminURL.js'
+
type Args = {
collection: Collection
config: SanitizedConfig
@@ -36,7 +38,11 @@ export async function sendVerificationEmail(args: Args): Promise {
? config.serverURL
: `${protocol}//${req.headers.get('host')}`
- const verificationURL = `${serverURL}${config.routes.admin}/${collectionConfig.slug}/verify/${token}`
+ const verificationURL = formatAdminURL({
+ adminRoute: config.routes.admin,
+ path: `/${collectionConfig.slug}/verify/${token}`,
+ serverURL,
+ })
let html = `${req.t('authentication:newAccountCreated', {
serverURL: config.serverURL,
diff --git a/packages/payload/src/bin/generateImportMap/generateImportMap.spec.ts b/packages/payload/src/bin/generateImportMap/generateImportMap.spec.ts
index ddd1e9d0e7d..7f7c10caf71 100644
--- a/packages/payload/src/bin/generateImportMap/generateImportMap.spec.ts
+++ b/packages/payload/src/bin/generateImportMap/generateImportMap.spec.ts
@@ -1,3 +1,4 @@
+import { describe, beforeEach, expect, it, vitest } from 'vitest'
import type { PayloadComponent } from '../../index.js'
import { addPayloadComponentToImportMap } from './utilities/addPayloadComponentToImportMap.js'
import { getImportMapToBaseDirPath } from './utilities/getImportMapToBaseDirPath.js'
@@ -15,7 +16,7 @@ describe('addPayloadComponentToImportMap', () => {
beforeEach(() => {
importMap = {}
imports = {}
- jest.restoreAllMocks()
+ vitest.restoreAllMocks()
})
function componentPathTest({
diff --git a/packages/payload/src/bin/generateImportMap/iterateConfig.ts b/packages/payload/src/bin/generateImportMap/iterateConfig.ts
index 50163790baf..54f4c245b7d 100644
--- a/packages/payload/src/bin/generateImportMap/iterateConfig.ts
+++ b/packages/payload/src/bin/generateImportMap/iterateConfig.ts
@@ -80,6 +80,12 @@ export function iterateConfig({
}
}
+ if (config.admin?.dashboard?.widgets?.length) {
+ for (const dashboardWidget of config.admin.dashboard.widgets) {
+ addToImportMap(dashboardWidget.ComponentPath)
+ }
+ }
+
if (config?.admin?.importMap?.generators?.length) {
for (const generator of config.admin.importMap.generators) {
generator({
diff --git a/packages/payload/src/bin/generateImportMap/iterateGlobals.ts b/packages/payload/src/bin/generateImportMap/iterateGlobals.ts
index 366b27eca42..a6728915695 100644
--- a/packages/payload/src/bin/generateImportMap/iterateGlobals.ts
+++ b/packages/payload/src/bin/generateImportMap/iterateGlobals.ts
@@ -29,6 +29,7 @@ export function iterateGlobals({
imports,
})
+ addToImportMap(global.admin?.components?.elements?.beforeDocumentControls)
addToImportMap(global.admin?.components?.elements?.Description)
addToImportMap(global.admin?.components?.elements?.PreviewButton)
addToImportMap(global.admin?.components?.elements?.PublishButton)
diff --git a/packages/payload/src/bin/migrate.ts b/packages/payload/src/bin/migrate.ts
index 624e693d3f3..52d21ad9071 100644
--- a/packages/payload/src/bin/migrate.ts
+++ b/packages/payload/src/bin/migrate.ts
@@ -28,10 +28,15 @@ const availableCommandsMsg = `Available commands: ${availableCommands.join(', ')
type Args = {
config: SanitizedConfig
+ /**
+ * Override the migration directory. Useful for testing when the CWD differs
+ * from where the test config expects migrations to be stored.
+ */
+ migrationDir?: string
parsedArgs: ParsedArgs
}
-export const migrate = async ({ config, parsedArgs }: Args): Promise => {
+export const migrate = async ({ config, migrationDir, parsedArgs }: Args): Promise => {
const { _: args, file, forceAcceptWarning: forceAcceptFromProps, help } = parsedArgs
const formattedArgs = Object.keys(parsedArgs)
@@ -75,6 +80,11 @@ export const migrate = async ({ config, parsedArgs }: Args): Promise => {
throw new Error('No database adapter found')
}
+ // Override migrationDir if provided (useful for testing)
+ if (migrationDir) {
+ adapter.migrationDir = migrationDir
+ }
+
if (!args.length) {
payload.logger.error({
msg: `No migration command provided. ${availableCommandsMsg}`,
diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts
index 41af41882e2..37468240c87 100644
--- a/packages/payload/src/collections/config/types.ts
+++ b/packages/payload/src/collections/config/types.ts
@@ -80,17 +80,32 @@ export type RequiredDataFromCollectionSlug =
RequiredDataFromCollection>
/**
- * Helper type for draft data - makes all fields optional except auto-generated ones
+ * Helper type for draft data INPUT (e.g., create operations) - makes all fields optional except system fields
* When creating a draft, required fields don't need to be provided as validation is skipped
+ * The id field is optional since it's auto-generated
*/
export type DraftDataFromCollection = Partial<
- MarkOptional
->
+ Omit
+> &
+ Partial>
export type DraftDataFromCollectionSlug = DraftDataFromCollection<
DataFromCollectionSlug
>
+/**
+ * Helper type for draft data OUTPUT (e.g., query results) - makes user fields optional but keeps id required
+ * When querying drafts, required fields may be null/undefined as validation is skipped, but system fields like id are always present
+ */
+export type QueryDraftDataFromCollection = Partial<
+ Omit
+> &
+ Partial> &
+ Pick
+
+export type QueryDraftDataFromCollectionSlug =
+ QueryDraftDataFromCollection>
+
export type HookOperationType =
| 'autosave'
| 'count'
diff --git a/packages/payload/src/collections/config/useAsTitle.spec.ts b/packages/payload/src/collections/config/useAsTitle.spec.ts
index 1d8913d6773..d77fe1c7e9f 100644
--- a/packages/payload/src/collections/config/useAsTitle.spec.ts
+++ b/packages/payload/src/collections/config/useAsTitle.spec.ts
@@ -3,6 +3,7 @@ import type { CollectionConfig } from '../../index.js'
import { InvalidConfiguration } from '../../errors/InvalidConfiguration.js'
import { sanitizeCollection } from './sanitize.js'
+import { describe, it, expect } from 'vitest'
describe('sanitize - collections -', () => {
const config = {
diff --git a/packages/payload/src/collections/operations/local/find.ts b/packages/payload/src/collections/operations/local/find.ts
index 849d06c820b..72914a3f598 100644
--- a/packages/payload/src/collections/operations/local/find.ts
+++ b/packages/payload/src/collections/operations/local/find.ts
@@ -1,6 +1,7 @@
import type { PaginatedDocs } from '../../../database/types.js'
import type {
CollectionSlug,
+ GeneratedTypes,
JoinQuery,
Payload,
RequestContext,
@@ -9,6 +10,7 @@ import type {
} from '../../../index.js'
import type {
Document,
+ DraftTransformCollectionWithSelect,
PayloadRequest,
PopulateType,
SelectType,
@@ -137,10 +139,19 @@ export type Options =
export async function findLocal<
TSlug extends CollectionSlug,
TSelect extends SelectFromCollectionSlug,
+ TDraft extends boolean = false,
>(
payload: Payload,
- options: Options,
-): Promise>> {
+ options: { draft?: TDraft } & Options,
+): Promise<
+ PaginatedDocs<
+ TDraft extends true
+ ? GeneratedTypes extends { strictDraftTypes: true }
+ ? DraftTransformCollectionWithSelect
+ : TransformCollectionWithSelect
+ : TransformCollectionWithSelect
+ >
+> {
const {
collection: collectionSlug,
currentDepth,
diff --git a/packages/payload/src/config/client.ts b/packages/payload/src/config/client.ts
index 6f09d5a3a76..f05a902de41 100644
--- a/packages/payload/src/config/client.ts
+++ b/packages/payload/src/config/client.ts
@@ -1,4 +1,4 @@
-import type { I18nClient } from '@payloadcms/translations'
+import type { I18nClient, TFunction } from '@payloadcms/translations'
import type { DeepPartial } from 'ts-essentials'
import type { ImportMap } from '../bin/generateImportMap/index.js'
@@ -7,6 +7,7 @@ import type { BlockSlug, TypedUser } from '../index.js'
import type {
RootLivePreviewConfig,
SanitizedConfig,
+ SanitizedDashboardConfig,
ServerOnlyLivePreviewProperties,
} from './types.js'
@@ -45,8 +46,9 @@ export type ServerOnlyRootAdminProperties = keyof Pick
- } & Omit
+ } & Omit
blocks: ClientBlock[]
blocksMap: Record
collections: ClientCollectionConfig[]
@@ -176,6 +178,20 @@ export const createClientConfig = ({
user: config.admin.user,
}
+ if (config.admin.dashboard?.widgets) {
+ ;(clientConfig.admin.dashboard ??= {}).widgets = config.admin.dashboard.widgets.map(
+ (widget) => {
+ const { ComponentPath: _, label, ...rest } = widget
+ return {
+ ...rest,
+ // Resolve label function to string for client
+ label:
+ typeof label === 'function' ? label({ i18n, t: i18n.t as TFunction }) : label,
+ }
+ },
+ )
+ }
+
if (config.admin.livePreview) {
clientConfig.admin.livePreview = {}
diff --git a/packages/payload/src/config/defaults.ts b/packages/payload/src/config/defaults.ts
index b8493fc708f..c4312e009f7 100644
--- a/packages/payload/src/config/defaults.ts
+++ b/packages/payload/src/config/defaults.ts
@@ -150,7 +150,7 @@ export const addDefaultsToConfig = (config: Config): Config => {
config.maxDepth = config.maxDepth ?? 10
config.routes = {
admin: '/admin',
- api: (process.env.NEXT_BASE_PATH ?? '') + '/api',
+ api: '/api',
graphQL: '/graphql',
graphQLPlayground: '/graphql-playground',
...(config.routes || {}),
diff --git a/packages/payload/src/config/sanitize.ts b/packages/payload/src/config/sanitize.ts
index dd0afcc1916..bedf2281a82 100644
--- a/packages/payload/src/config/sanitize.ts
+++ b/packages/payload/src/config/sanitize.ts
@@ -1,4 +1,4 @@
-import type { AcceptedLanguages, Language } from '@payloadcms/translations'
+import type { AcceptedLanguages } from '@payloadcms/translations'
import { en } from '@payloadcms/translations/languages/en'
import { deepMergeSimple } from '@payloadcms/translations/utilities'
@@ -11,6 +11,7 @@ import type {
LocalizationConfigWithNoLabels,
SanitizedConfig,
Timezone,
+ WidgetInstance,
} from './types.js'
import { defaultUserCollection } from '../auth/defaultUser.js'
@@ -53,6 +54,17 @@ const sanitizeAdminConfig = (configToSanitize: Config): Partial
ValidationError: 'info',
...(sanitizedConfig.loggingLevels || {}),
}
+ ;(sanitizedConfig.admin!.dashboard ??= { widgets: [] }).widgets.push({
+ slug: 'collections',
+ ComponentPath: '@payloadcms/ui/rsc#CollectionCards',
+ minWidth: 'full',
+ })
+ sanitizedConfig.admin!.dashboard.defaultLayout ??= [
+ {
+ widgetSlug: 'collections',
+ width: 'full',
+ } satisfies WidgetInstance,
+ ]
// add default user collection if none provided
if (!sanitizedConfig?.admin?.user) {
diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts
index ffe355b7273..9f4c606e21f 100644
--- a/packages/payload/src/config/types.ts
+++ b/packages/payload/src/config/types.ts
@@ -736,6 +736,53 @@ export type AfterErrorHook = (
args: AfterErrorHookArgs,
) => AfterErrorResult | Promise
+export type WidgetWidth = 'full' | 'large' | 'medium' | 'small' | 'x-large' | 'x-small'
+
+export type Widget = {
+ ComponentPath: string
+ /**
+ * Human-friendly label for the widget.
+ * Supports i18n by passing an object with locale keys, or a function with `t` for translations.
+ * If not provided, the label will be auto-generated from the slug.
+ */
+ label?: LabelFunction | StaticLabel
+ maxWidth?: WidgetWidth
+ minWidth?: WidgetWidth
+ slug: string
+ // TODO: Add fields
+ // fields?: Field[]
+ // Maybe:
+ // ImageURL?: string // similar to Block
+}
+
+/**
+ * Client-side widget type with resolved label (no functions).
+ */
+export type ClientWidget = {
+ label?: StaticLabel
+ maxWidth?: WidgetWidth
+ minWidth?: WidgetWidth
+ slug: string
+}
+
+export type WidgetInstance = {
+ // TODO: should be inferred from Widget Fields
+ // data: Record
+ widgetSlug: string
+ width?: WidgetWidth
+}
+
+export type DashboardConfig = {
+ defaultLayout?:
+ | ((args: { req: PayloadRequest }) => Array | Promise>)
+ | Array
+ widgets: Array
+}
+
+export type SanitizedDashboardConfig = {
+ widgets: Array>
+}
+
/**
* This is the central configuration
*
@@ -859,6 +906,11 @@ export type Config = {
}
/** Extension point to add your custom data. Available in server and client. */
custom?: Record
+ /**
+ * Customize the dashboard widgets
+ * @experimental This prop is subject to change in future releases.
+ */
+ dashboard?: DashboardConfig
/** Global date format that will be used for all dates in the Admin panel. Any valid date-fns format pattern can be used. */
dateFormat?: string
/**
@@ -1346,6 +1398,14 @@ export type Config = {
jsonSchema: JSONSchema4
}) => JSONSchema4
>
+
+ /**
+ * Enable strict type safety for draft mode queries.
+ * When enabled, find operations with draft: true will type required fields as optional.
+ * @default false
+ * @todo Remove in v4. Strict draft types will become the default behavior.
+ */
+ strictDraftTypes?: boolean
}
/**
* Customize the handling of incoming file uploads for collections that have uploads enabled.
diff --git a/packages/payload/src/database/migrations/findMigrationDir.spec.ts b/packages/payload/src/database/migrations/findMigrationDir.spec.ts
index 15c5026142b..f0f968d3c76 100644
--- a/packages/payload/src/database/migrations/findMigrationDir.spec.ts
+++ b/packages/payload/src/database/migrations/findMigrationDir.spec.ts
@@ -1,3 +1,4 @@
+import { afterEach, beforeEach, describe, expect, it, vitest } from 'vitest'
import { findMigrationDir } from './findMigrationDir'
import fs from 'fs'
import path from 'path'
@@ -6,7 +7,7 @@ const workDir = path.resolve(import.meta.dirname, '_tmp')
describe('findMigrationDir', () => {
beforeEach(() => {
- const cwdSpy = jest.spyOn(process, 'cwd')
+ const cwdSpy = vitest.spyOn(process, 'cwd')
cwdSpy.mockReturnValue(workDir)
fs.mkdirSync(workDir, { recursive: true })
})
diff --git a/packages/payload/src/database/migrations/getPredefinedMigration.ts b/packages/payload/src/database/migrations/getPredefinedMigration.ts
index eaa425f9d61..a3d63b05216 100644
--- a/packages/payload/src/database/migrations/getPredefinedMigration.ts
+++ b/packages/payload/src/database/migrations/getPredefinedMigration.ts
@@ -1,12 +1,19 @@
import fs from 'fs'
import path from 'path'
-import { pathToFileURL } from 'url'
import type { Payload } from '../../index.js'
import type { MigrationTemplateArgs } from '../types.js'
+import { dynamicImport } from '../../utilities/dynamicImport.js'
+
/**
- * Get predefined migration 'up', 'down' and 'imports'
+ * Get predefined migration 'up', 'down' and 'imports'.
+ *
+ * Supports two import methods:
+ * 1. @payloadcms/db-* packages: Loads from adapter's predefinedMigrations folder directly (no package.json export needed)
+ * Example: `--file @payloadcms/db-mongodb/relationships-v2-v3`
+ * 2. Any other package/path: Uses dynamic import via package.json exports or absolute file paths
+ * Example: `--file @payloadcms/plugin-seo/someMigration` or `--file /absolute/path/to/migration.ts`
*/
export const getPredefinedMigration = async ({
dirname,
@@ -19,18 +26,20 @@ export const getPredefinedMigration = async ({
migrationName?: string
payload: Payload
}): Promise => {
- // Check for predefined migration.
- // Either passed in via --file or prefixed with '@payloadcms/db-mongodb/' for example
const importPath = file ?? migrationNameArg
+ // Path 1: @payloadcms/db-* adapters - load directly from predefinedMigrations folder
+ // These don't need package.json exports; files are resolved relative to adapter's dirname
if (importPath?.startsWith('@payloadcms/db-')) {
- // removes the package name from the migrationName.
const migrationName = importPath.split('/').slice(2).join('/')
let cleanPath = path.join(dirname, `./predefinedMigrations/${migrationName}`)
if (fs.existsSync(`${cleanPath}.mjs`)) {
cleanPath = `${cleanPath}.mjs`
} else if (fs.existsSync(`${cleanPath}.js`)) {
cleanPath = `${cleanPath}.js`
+ } else if (fs.existsSync(`${cleanPath}.ts`)) {
+ // Support .ts in development when running from source
+ cleanPath = `${cleanPath}.ts`
} else {
payload.logger.error({
msg: `Canned migration ${migrationName} not found.`,
@@ -38,9 +47,8 @@ export const getPredefinedMigration = async ({
process.exit(1)
}
cleanPath = cleanPath.replaceAll('\\', '/')
- const moduleURL = pathToFileURL(cleanPath)
try {
- const { downSQL, imports, upSQL } = await eval(`import('${moduleURL.href}')`)
+ const { downSQL, imports, upSQL } = await dynamicImport(cleanPath)
return {
downSQL,
imports,
@@ -54,8 +62,10 @@ export const getPredefinedMigration = async ({
process.exit(1)
}
} else if (importPath) {
+ // Path 2: Any other package or file path - use dynamic import
+ // Supports: package.json exports (e.g. @payloadcms/plugin-seo/migration) or absolute file paths
try {
- const { downSQL, imports, upSQL } = await eval(`import('${importPath}')`)
+ const { downSQL, imports, upSQL } = await dynamicImport(importPath)
return {
downSQL,
imports,
diff --git a/packages/payload/src/database/migrations/readMigrationFiles.ts b/packages/payload/src/database/migrations/readMigrationFiles.ts
index 0d74492b9b1..abefa39a0ea 100644
--- a/packages/payload/src/database/migrations/readMigrationFiles.ts
+++ b/packages/payload/src/database/migrations/readMigrationFiles.ts
@@ -1,10 +1,11 @@
import fs from 'fs'
-import { pathToFileURL } from 'node:url'
import path from 'path'
import type { Payload } from '../../index.js'
import type { Migration } from '../types.js'
+import { dynamicImport } from '../../utilities/dynamicImport.js'
+
/**
* Read the migration files from disk
*/
@@ -36,14 +37,13 @@ export const readMigrationFiles = async ({
return Promise.all(
files.map(async (filePath) => {
- // eval used to circumvent errors bundling
- let migration =
- typeof require === 'function'
- ? await eval(`require('${filePath.replaceAll('\\', '/')}')`)
- : await eval(`import('${pathToFileURL(filePath).href}')`)
- if ('default' in migration) {
- migration = migration.default
- }
+ const migrationModule = await dynamicImport<
+ | {
+ default: Migration
+ }
+ | Migration
+ >(filePath)
+ const migration = 'default' in migrationModule ? migrationModule.default : migrationModule
const result: Migration = {
name: path.basename(filePath).split('.')[0]!,
diff --git a/packages/payload/src/exports/shared.ts b/packages/payload/src/exports/shared.ts
index a93deeb1fee..d5174c5532d 100644
--- a/packages/payload/src/exports/shared.ts
+++ b/packages/payload/src/exports/shared.ts
@@ -83,7 +83,6 @@ export { flattenAllFields } from '../utilities/flattenAllFields.js'
export { flattenTopLevelFields } from '../utilities/flattenTopLevelFields.js'
export { formatAdminURL } from '../utilities/formatAdminURL.js'
-export { formatApiURL } from '../utilities/formatApiURL.js'
export { formatLabels, toWords } from '../utilities/formatLabels.js'
export { getBestFitFromSizes } from '../utilities/getBestFitFromSizes.js'
diff --git a/packages/payload/src/fields/config/reservedFieldNames.spec.ts b/packages/payload/src/fields/config/reservedFieldNames.spec.ts
index cfaaeb1f316..cb712b3ffff 100644
--- a/packages/payload/src/fields/config/reservedFieldNames.spec.ts
+++ b/packages/payload/src/fields/config/reservedFieldNames.spec.ts
@@ -3,6 +3,7 @@ import type { CollectionConfig, Field } from '../../index.js'
import { ReservedFieldName } from '../../errors/index.js'
import { sanitizeCollection } from '../../collections/config/sanitize.js'
+import { describe, it, expect } from 'vitest'
describe('reservedFieldNames - collections -', () => {
const config = {
diff --git a/packages/payload/src/fields/config/sanitize.spec.ts b/packages/payload/src/fields/config/sanitize.spec.ts
index 6871f3c605b..77666d4c7a3 100644
--- a/packages/payload/src/fields/config/sanitize.spec.ts
+++ b/packages/payload/src/fields/config/sanitize.spec.ts
@@ -17,6 +17,7 @@ import {
} from '../../errors/index.js'
import { sanitizeFields } from './sanitize.js'
import { CollectionConfig } from '../../index.js'
+import { describe, it, expect } from 'vitest'
describe('sanitizeFields', () => {
const config = {} as Config
diff --git a/packages/payload/src/fields/hooks/afterChange/promise.ts b/packages/payload/src/fields/hooks/afterChange/promise.ts
index dd67284b87a..e38d17dc57e 100644
--- a/packages/payload/src/fields/hooks/afterChange/promise.ts
+++ b/packages/payload/src/fields/hooks/afterChange/promise.ts
@@ -72,6 +72,10 @@ export const promise = async ({
const indexPathSegments = indexPath ? indexPath.split('-').filter(Boolean)?.map(Number) : []
const getNestedValue = (data: JsonObject, path: string[]) =>
path.reduce((acc, key) => (acc && acc[key] !== undefined ? acc[key] : undefined), data)
+ const previousValData =
+ previousSiblingDoc && Object.keys(previousSiblingDoc).length > 0
+ ? previousSiblingDoc
+ : previousDoc
if (fieldAffectsData(field)) {
// Execute hooks
@@ -90,7 +94,8 @@ export const promise = async ({
path: pathSegments,
previousDoc,
previousSiblingDoc,
- previousValue: getNestedValue(previousDoc, pathSegments) ?? previousDoc?.[field.name],
+ previousValue:
+ getNestedValue(previousValData, pathSegments) ?? previousValData?.[field.name],
req,
schemaPath: schemaPathSegments,
siblingData,
@@ -172,7 +177,7 @@ export const promise = async ({
parentPath: path + '.' + rowIndex,
parentSchemaPath: schemaPath + '.' + block.slug,
previousDoc,
- previousSiblingDoc: previousDoc?.[field.name]?.[rowIndex] || ({} as JsonObject),
+ previousSiblingDoc: previousValData?.[field.name]?.[rowIndex] || ({} as JsonObject),
req,
siblingData: siblingData?.[field.name]?.[rowIndex] || {},
siblingDoc: row ? { ...row } : {},
diff --git a/packages/payload/src/fields/validations.spec.ts b/packages/payload/src/fields/validations.spec.ts
index e8b540a76ce..dfcdc5d2e6f 100644
--- a/packages/payload/src/fields/validations.spec.ts
+++ b/packages/payload/src/fields/validations.spec.ts
@@ -1,5 +1,3 @@
-import { jest } from '@jest/globals'
-
import type { SelectField, ValidateOptions } from './config/types.js'
import {
@@ -15,8 +13,9 @@ import {
type PointFieldValidation,
type SelectFieldValidation,
} from './validations.js'
+import { describe, expect, it, vitest } from 'vitest'
-const t = jest.fn((string) => string)
+const t = vitest.fn((string) => string)
let options: ValidateOptions = {
data: undefined,
diff --git a/packages/payload/src/globals/endpoints/findVersions.ts b/packages/payload/src/globals/endpoints/findVersions.ts
index 2f2c25b2289..902fdb9e331 100644
--- a/packages/payload/src/globals/endpoints/findVersions.ts
+++ b/packages/payload/src/globals/endpoints/findVersions.ts
@@ -8,6 +8,7 @@ import { headersWithCors } from '../../utilities/headersWithCors.js'
import { isNumber } from '../../utilities/isNumber.js'
import { sanitizePopulateParam } from '../../utilities/sanitizePopulateParam.js'
import { sanitizeSelectParam } from '../../utilities/sanitizeSelectParam.js'
+import { sanitizeSortParams } from '../../utilities/sanitizeSortParams.js'
import { findVersionsOperation } from '../operations/findVersions.js'
export const findVersionsHandler: PayloadHandler = async (req) => {
@@ -19,7 +20,7 @@ export const findVersionsHandler: PayloadHandler = async (req) => {
pagination?: string
populate?: Record
select?: Record
- sort?: string
+ sort?: string | string[]
where?: Where
}
@@ -32,7 +33,7 @@ export const findVersionsHandler: PayloadHandler = async (req) => {
populate: sanitizePopulateParam(populate),
req,
select: sanitizeSelectParam(select),
- sort: typeof sort === 'string' ? sort.split(',') : undefined,
+ sort: sanitizeSortParams(sort),
where,
})
diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts
index c9a5f123956..f3b74fa6b49 100644
--- a/packages/payload/src/index.ts
+++ b/packages/payload/src/index.ts
@@ -45,6 +45,7 @@ import type { InitializedEmailAdapter } from './email/types.js'
import type { DataFromGlobalSlug, Globals, SelectFromGlobalSlug } from './globals/config/types.js'
import type {
ApplyDisableErrors,
+ DraftTransformCollectionWithSelect,
JsonObject,
SelectType,
TransformCollectionWithSelect,
@@ -119,6 +120,7 @@ import {
type Options as UpdateGlobalOptions,
} from './globals/operations/local/update.js'
export type * from './admin/types.js'
+export { EntityType } from './admin/views/dashboard.js'
import type { SupportedLanguages } from '@payloadcms/translations'
import { Cron } from 'croner'
@@ -139,6 +141,7 @@ import { consoleEmailAdapter } from './email/consoleEmailAdapter.js'
import { fieldAffectsData, type FlattenedBlock } from './fields/config/types.js'
import { getJobsLocalAPI } from './queues/localAPI.js'
import { _internal_jobSystemGlobals } from './queues/utilities/getCurrentDate.js'
+import { formatAdminURL } from './utilities/formatAdminURL.js'
import { isNextBuild } from './utilities/isNextBuild.js'
import { getLogger } from './utilities/logger.js'
import { serverInit as serverInitTelemetry } from './utilities/telemetry/events/serverInit.js'
@@ -455,10 +458,22 @@ export class BasePayload {
* @param options
* @returns documents satisfying query
*/
- find = async >(
- options: FindOptions,
- ): Promise>> => {
- return findLocal(this, options)
+ find = async <
+ TSlug extends CollectionSlug,
+ TSelect extends SelectFromCollectionSlug,
+ TDraft extends boolean = false,
+ >(
+ options: { draft?: TDraft } & FindOptions,
+ ): Promise<
+ PaginatedDocs<
+ TDraft extends true
+ ? GeneratedTypes extends { strictDraftTypes: true }
+ ? DraftTransformCollectionWithSelect
+ : TransformCollectionWithSelect
+ : TransformCollectionWithSelect
+ >
+ > => {
+ return findLocal(this, options)
}
/**
@@ -546,9 +561,19 @@ export class BasePayload {
return forgotPasswordLocal(this, options)
}
- getAdminURL = (): string => `${this.config.serverURL}${this.config.routes.admin}`
+ getAdminURL = (): string =>
+ formatAdminURL({
+ adminRoute: this.config.routes.admin,
+ path: '',
+ serverURL: this.config.serverURL,
+ })
- getAPIURL = (): string => `${this.config.serverURL}${this.config.routes.api}`
+ getAPIURL = (): string =>
+ formatAdminURL({
+ apiRoute: this.config.routes.api,
+ path: '',
+ serverURL: this.config.serverURL,
+ })
globals!: Globals
diff --git a/packages/payload/src/queues/config/types/taskTypes.ts b/packages/payload/src/queues/config/types/taskTypes.ts
index 2ea90d4fe17..2f51b951b2d 100644
--- a/packages/payload/src/queues/config/types/taskTypes.ts
+++ b/packages/payload/src/queues/config/types/taskTypes.ts
@@ -10,7 +10,13 @@ export type TaskHandlerResult<
TTaskSlugOrInputOutput extends keyof TypedJobs['tasks'] | TaskInputOutput,
> =
| {
+ /**
+ * @deprecated Returning `state: 'failed'` is deprecated. Throw an error instead.
+ */
errorMessage?: string
+ /**
+ * @deprecated Returning `state: 'failed'` is deprecated. Throw an error instead.
+ */
state: 'failed'
}
| {
@@ -53,7 +59,7 @@ export type TaskHandler<
TWorkflowSlug extends keyof TypedJobs['workflows'] = string,
> = (
args: TaskHandlerArgs,
-) => Promise> | TaskHandlerResult
+) => MaybePromise>
/**
* @todo rename to TaskSlug in 4.0, similar to CollectionSlug
@@ -116,7 +122,13 @@ export type RunInlineTaskFunction = MaybePromise<
| {
+ /**
+ * @deprecated Returning `state: 'failed'` is deprecated. Throw an error instead.
+ */
errorMessage?: string
+ /**
+ * @deprecated Returning `state: 'failed'` is deprecated. Throw an error instead.
+ */
state: 'failed'
}
| {
@@ -140,7 +152,7 @@ export type TaskCallbackArgs = {
export type ShouldRestoreFn = (
args: { taskStatus: SingleTaskStatus } & Omit,
) => boolean | Promise
-export type TaskCallbackFn = (args: TaskCallbackArgs) => Promise | void
+export type TaskCallbackFn = (args: TaskCallbackArgs) => MaybePromise
export type RetryConfig = {
/**
diff --git a/packages/payload/src/queues/operations/runJobs/runJob/importHandlerPath.ts b/packages/payload/src/queues/operations/runJobs/runJob/importHandlerPath.ts
index b7d23d1f3cc..87941c98ce0 100644
--- a/packages/payload/src/queues/operations/runJobs/runJob/importHandlerPath.ts
+++ b/packages/payload/src/queues/operations/runJobs/runJob/importHandlerPath.ts
@@ -1,7 +1,7 @@
-import { pathToFileURL } from 'url'
-
import type { TaskConfig, TaskHandler, TaskType } from '../../../config/types/taskTypes.js'
+import { dynamicImport } from '../../../../utilities/dynamicImport.js'
+
/**
* Imports a handler function from a given path.
*/
@@ -9,14 +9,9 @@ export async function importHandlerPath(path: string): Promise {
let runner!: T
const [runnerPath, runnerImportName] = path.split('#')
- let runnerModule
+ let runnerModule: Record
try {
- // We need to check for `require` for compatibility with outdated frameworks that do not
- // properly support ESM, like Jest. This is not done to support projects without "type": "module" set
- runnerModule =
- typeof require === 'function'
- ? await eval(`require('${runnerPath!.replaceAll('\\', '/')}')`)
- : await eval(`import('${pathToFileURL(runnerPath!).href}')`)
+ runnerModule = await dynamicImport>(runnerPath!)
} catch (e) {
throw new Error(
`Error importing job queue handler module for path ${path}. This is an advanced feature that may require a sophisticated build pipeline, especially when using it in production or within Next.js, e.g. by calling opening the /api/payload-jobs/run endpoint. You will have to transpile the handler files separately and ensure they are available in the same location when the job is run. If you're using an endpoint to execute your jobs, it's recommended to define your handlers as functions directly in your Payload Config, or use import paths handlers outside of Next.js. Import Error: \n${e instanceof Error ? e.message : 'Unknown error'}`,
@@ -25,17 +20,17 @@ export async function importHandlerPath(path: string): Promise {
// If the path has indicated an #exportName, try to get it
if (runnerImportName && runnerModule[runnerImportName]) {
- runner = runnerModule[runnerImportName]
+ runner = runnerModule[runnerImportName] as T
}
// If there is a default export, use it
if (!runner && runnerModule.default) {
- runner = runnerModule.default
+ runner = runnerModule.default as T
}
// Finally, use whatever was imported
if (!runner) {
- runner = runnerModule
+ runner = runnerModule as T
}
return runner
diff --git a/packages/payload/src/types/index.ts b/packages/payload/src/types/index.ts
index ead5f57351b..e3fa3cc8c92 100644
--- a/packages/payload/src/types/index.ts
+++ b/packages/payload/src/types/index.ts
@@ -5,6 +5,7 @@ import type { URL } from 'url'
import type {
DataFromCollectionSlug,
+ QueryDraftDataFromCollectionSlug,
TypeWithID,
TypeWithTimestamps,
} from '../collections/config/types.js'
@@ -258,6 +259,13 @@ export type TransformCollectionWithSelect<
? TransformDataWithSelect, TSelect>
: DataFromCollectionSlug
+export type DraftTransformCollectionWithSelect<
+ TSlug extends CollectionSlug,
+ TSelect extends SelectType,
+> = TSelect extends SelectType
+ ? TransformDataWithSelect, TSelect>
+ : QueryDraftDataFromCollectionSlug
+
export type TransformGlobalWithSelect<
TSlug extends GlobalSlug,
TSelect extends SelectType,
diff --git a/packages/payload/src/uploads/checkFileRestrictions.ts b/packages/payload/src/uploads/checkFileRestrictions.ts
index e2943c65439..3717dd6ba15 100644
--- a/packages/payload/src/uploads/checkFileRestrictions.ts
+++ b/packages/payload/src/uploads/checkFileRestrictions.ts
@@ -6,6 +6,8 @@ import { ValidationError } from '../errors/index.js'
import { validateMimeType } from '../utilities/validateMimeType.js'
import { validatePDF } from '../utilities/validatePDF.js'
import { detectSvgFromXml } from './detectSvgFromXml.js'
+import { getFileTypeFallback } from './getFileTypeFallback.js'
+import { validateSvg } from './validateSvg.js'
/**
* Restricted file types and their extensions.
@@ -103,8 +105,37 @@ export const checkFileRestrictions = async ({
}
}
- if (!detected && expectsDetectableType(typeFromExtension) && !useTempFiles) {
- errors.push(`File buffer returned no detectable MIME type.`)
+ if (!detected && !useTempFiles) {
+ const mimeTypeFromExtension = getFileTypeFallback(file.name).mime
+ const extIsValid = validateMimeType(mimeTypeFromExtension, configMimeTypes)
+
+ if (!extIsValid) {
+ errors.push(
+ `File type ${mimeTypeFromExtension} (from extension ${typeFromExtension}) is not allowed.`,
+ )
+ } else {
+ // SVG security check (text-based files not detectable by buffer)
+ if (typeFromExtension.toLowerCase() === 'svg') {
+ const isSafeSvg = validateSvg(file.data)
+ if (!isSafeSvg) {
+ errors.push('SVG file contains potentially harmful content.')
+ }
+ }
+
+ // PDF validation
+ if (mimeTypeFromExtension === 'application/pdf') {
+ const isValidPDF = validatePDF(file.data)
+ if (!isValidPDF) {
+ errors.push('Invalid or corrupted PDF file.')
+ }
+ }
+ }
+
+ if (expectsDetectableType(mimeTypeFromExtension)) {
+ req.payload.logger.warn(
+ `File buffer returned no detectable MIME type for ${file.name}. Falling back to extension-based validation.`,
+ )
+ }
}
const passesMimeTypeCheck = detected?.mime && validateMimeType(detected.mime, configMimeTypes)
diff --git a/packages/payload/src/uploads/endpoints/getFile.ts b/packages/payload/src/uploads/endpoints/getFile.ts
index 6b6d63edb35..658cb2c3b54 100644
--- a/packages/payload/src/uploads/endpoints/getFile.ts
+++ b/packages/payload/src/uploads/endpoints/getFile.ts
@@ -11,6 +11,7 @@ import { APIError } from '../../errors/APIError.js'
import { checkFileAccess } from '../../uploads/checkFileAccess.js'
import { streamFile } from '../../uploads/fetchAPI-stream-file/index.js'
import { getFileTypeFallback } from '../../uploads/getFileTypeFallback.js'
+import { parseRangeHeader } from '../../uploads/parseRangeHeader.js'
import { getRequestCollection } from '../../utilities/getRequestEntity.js'
import { headersWithCors } from '../../utilities/headersWithCors.js'
@@ -94,7 +95,6 @@ export const getFileHandler: PayloadHandler = async (req) => {
throw err
}
- const data = streamFile(filePath)
const fileTypeResult = (await fileTypeFromFile(filePath)) || getFileTypeFallback(filePath)
let mimeType = fileTypeResult.mime
@@ -102,9 +102,50 @@ export const getFileHandler: PayloadHandler = async (req) => {
mimeType = 'image/svg+xml'
}
+ // Parse Range header for byte range requests
+ const rangeHeader = req.headers.get('range')
+ const rangeResult = parseRangeHeader({
+ fileSize: stats.size,
+ rangeHeader,
+ })
+
+ if (rangeResult.type === 'invalid') {
+ let headers = new Headers()
+ headers.set('Content-Range', `bytes */${stats.size}`)
+ headers = collection.config.upload?.modifyResponseHeaders
+ ? collection.config.upload.modifyResponseHeaders({ headers }) || headers
+ : headers
+
+ return new Response(null, {
+ headers: headersWithCors({
+ headers,
+ req,
+ }),
+ status: httpStatus.REQUESTED_RANGE_NOT_SATISFIABLE,
+ })
+ }
+
let headers = new Headers()
headers.set('Content-Type', mimeType)
- headers.set('Content-Length', stats.size + '')
+ headers.set('Accept-Ranges', 'bytes')
+
+ let data: ReadableStream
+ let status: number
+ const isPartial = rangeResult.type === 'partial'
+ const range = rangeResult.range
+
+ if (isPartial && range) {
+ const contentLength = range.end - range.start + 1
+ headers.set('Content-Length', String(contentLength))
+ headers.set('Content-Range', `bytes ${range.start}-${range.end}/${stats.size}`)
+ data = streamFile({ filePath, options: { end: range.end, start: range.start } })
+ status = httpStatus.PARTIAL_CONTENT
+ } else {
+ headers.set('Content-Length', String(stats.size))
+ data = streamFile({ filePath })
+ status = httpStatus.OK
+ }
+
headers = collection.config.upload?.modifyResponseHeaders
? collection.config.upload.modifyResponseHeaders({ headers }) || headers
: headers
@@ -114,6 +155,6 @@ export const getFileHandler: PayloadHandler = async (req) => {
headers,
req,
}),
- status: httpStatus.OK,
+ status,
})
}
diff --git a/packages/payload/src/uploads/fetchAPI-stream-file/index.ts b/packages/payload/src/uploads/fetchAPI-stream-file/index.ts
index 4745b6177b5..3f4cd7640cd 100644
--- a/packages/payload/src/uploads/fetchAPI-stream-file/index.ts
+++ b/packages/payload/src/uploads/fetchAPI-stream-file/index.ts
@@ -19,8 +19,14 @@ export async function* nodeStreamToIterator(stream: fs.ReadStream) {
}
}
-export function streamFile(path: string): ReadableStream {
- const nodeStream = fs.createReadStream(path)
+export function streamFile({
+ filePath,
+ options,
+}: {
+ filePath: string
+ options?: { end?: number; start?: number }
+}): ReadableStream {
+ const nodeStream = fs.createReadStream(filePath, options)
const data: ReadableStream = iteratorToStream(nodeStreamToIterator(nodeStream))
return data
}
diff --git a/packages/payload/src/uploads/getBaseFields.ts b/packages/payload/src/uploads/getBaseFields.ts
index 443ef91d2c0..581b86d625d 100644
--- a/packages/payload/src/uploads/getBaseFields.ts
+++ b/packages/payload/src/uploads/getBaseFields.ts
@@ -3,6 +3,7 @@ import type { Config } from '../config/types.js'
import type { Field } from '../fields/config/types.js'
import type { UploadConfig } from './types.js'
+import { formatAdminURL } from '../utilities/formatAdminURL.js'
import { mimeTypeValidator } from './mimeTypeValidator.js'
type GenerateURLArgs = {
@@ -12,7 +13,11 @@ type GenerateURLArgs = {
}
const generateURL = ({ collectionSlug, config, filename }: GenerateURLArgs) => {
if (filename) {
- return `${config.serverURL || ''}${config.routes?.api || ''}/${collectionSlug}/file/${encodeURIComponent(filename)}`
+ return formatAdminURL({
+ apiRoute: config.routes?.api || '',
+ path: `/${collectionSlug}/file/${encodeURIComponent(filename)}`,
+ serverURL: config.serverURL,
+ })
}
return undefined
}
@@ -223,7 +228,11 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] =>
const sizeFilename = data?.sizes?.[size.name]?.filename
if (sizeFilename) {
- return `${config.serverURL}${config.routes?.api}/${collection.slug}/file/${encodeURIComponent(sizeFilename)}`
+ return formatAdminURL({
+ apiRoute: config.routes?.api || '',
+ path: `/${collection.slug}/file/${encodeURIComponent(sizeFilename)}`,
+ serverURL: config.serverURL,
+ })
}
return null
diff --git a/packages/payload/src/uploads/mimeTypeValidator.spec.ts b/packages/payload/src/uploads/mimeTypeValidator.spec.ts
index 895ddcc7cb8..7a9f9e488ae 100644
--- a/packages/payload/src/uploads/mimeTypeValidator.spec.ts
+++ b/packages/payload/src/uploads/mimeTypeValidator.spec.ts
@@ -1,3 +1,4 @@
+import { describe, it, expect } from 'vitest'
import type { ValidateOptions } from '../fields/config/types'
import { mimeTypeValidator } from './mimeTypeValidator'
diff --git a/packages/payload/src/uploads/parseRangeHeader.ts b/packages/payload/src/uploads/parseRangeHeader.ts
new file mode 100644
index 00000000000..8e50a0df3a6
--- /dev/null
+++ b/packages/payload/src/uploads/parseRangeHeader.ts
@@ -0,0 +1,60 @@
+import parseRange from 'range-parser'
+
+export type ByteRange = {
+ end: number
+ start: number
+}
+
+export type ParseRangeResult =
+ | { range: ByteRange; type: 'partial' }
+ | { range: null; type: 'full' }
+ | { range: null; type: 'invalid' }
+
+/**
+ * Parses HTTP Range header according to RFC 7233
+ *
+ * @returns Result object indicating whether to serve full file, partial content, or invalid range
+ */
+export function parseRangeHeader({
+ fileSize,
+ rangeHeader,
+}: {
+ fileSize: number
+ rangeHeader: null | string
+}): ParseRangeResult {
+ // No Range header - serve full file
+ if (!rangeHeader) {
+ return { type: 'full', range: null }
+ }
+
+ const result = parseRange(fileSize, rangeHeader)
+
+ // Invalid range syntax or unsatisfiable range
+ if (result === -1 || result === -2) {
+ return { type: 'invalid', range: null }
+ }
+
+ // Must be bytes range type
+ if (result.type !== 'bytes') {
+ return { type: 'invalid', range: null }
+ }
+
+ // Multi-range requests: use first range only (standard simplification)
+ if (result.length === 0) {
+ return { type: 'invalid', range: null }
+ }
+
+ const range = result[0]
+
+ if (range) {
+ return {
+ type: 'partial',
+ range: {
+ end: range.end,
+ start: range.start,
+ },
+ }
+ }
+
+ return { type: 'invalid', range: null }
+}
diff --git a/packages/payload/src/uploads/validateSvg.ts b/packages/payload/src/uploads/validateSvg.ts
new file mode 100644
index 00000000000..c4ae88e2490
--- /dev/null
+++ b/packages/payload/src/uploads/validateSvg.ts
@@ -0,0 +1,58 @@
+/**
+ * Validate SVG content for security vulnerabilities
+ * Detects and blocks malicious patterns commonly used in SVG-based attacks
+ */
+export function validateSvg(buffer: Buffer): boolean {
+ try {
+ const content = buffer.toString('utf8')
+
+ const dangerousPatterns = [
+ // Script tags
+ /
+
diff --git a/test/uploads/e2e.spec.ts b/test/uploads/e2e.spec.ts
index 5adb4ec8227..dd562f2a8a4 100644
--- a/test/uploads/e2e.spec.ts
+++ b/test/uploads/e2e.spec.ts
@@ -1549,6 +1549,34 @@ describe('Uploads', () => {
const resizeOptionMedia = page.locator('.file-meta .file-meta__size-type')
await expect(resizeOptionMedia).toContainText('200x200')
})
+
+ test('should allow incrementing crop dimensions back to original maximum size', async () => {
+ await page.goto(mediaURL.create)
+
+ await page.setInputFiles('input[type="file"]', path.join(dirname, 'test-image.jpg'))
+
+ await page.locator('.file-field__edit').click()
+
+ const widthInput = page.locator('.edit-upload__input input[name="Width (px)"]')
+ const heightInput = page.locator('.edit-upload__input input[name="Height (px)"]')
+
+ await expect(widthInput).toHaveValue('800')
+ await expect(heightInput).toHaveValue('800')
+
+ await widthInput.fill('799')
+ await expect(widthInput).toHaveValue('799')
+
+ // Increment back to original using arrow up
+ await widthInput.press('ArrowUp')
+ await expect(widthInput).toHaveValue('800')
+
+ await heightInput.fill('799')
+ await expect(heightInput).toHaveValue('799')
+
+ // Increment back to original using arrow up
+ await heightInput.press('ArrowUp')
+ await expect(heightInput).toHaveValue('800')
+ })
})
test('should see upload previews in relation list if allowed in config', async () => {
diff --git a/test/uploads/image with spaces.png b/test/uploads/image with spaces.png
new file mode 100644
index 00000000000..23787ee3d71
Binary files /dev/null and b/test/uploads/image with spaces.png differ
diff --git a/test/uploads/int.spec.ts b/test/uploads/int.spec.ts
index be9b16d3fb2..d92a28095ff 100644
--- a/test/uploads/int.spec.ts
+++ b/test/uploads/int.spec.ts
@@ -8,6 +8,7 @@ import path from 'path'
import { _internal_safeFetchGlobal, createPayloadRequest, getFileByPath } from 'payload'
import { fileURLToPath } from 'url'
import { promisify } from 'util'
+import { afterAll, beforeAll, describe, expect, it, vitest } from 'vitest'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import type { Enlarge, Media } from './payload-types.js'
@@ -287,6 +288,21 @@ describe('Collections - Uploads', () => {
expect(response.status).toBe(400)
})
+ it('should not allow html file to be uploaded to PDF only collection', async () => {
+ const formData = new FormData()
+ const filePath = path.join(dirname, './test.html')
+ const { file, handle } = await createStreamableFile(filePath, 'application/pdf')
+ formData.append('file', file)
+ formData.append('contentType', 'application/pdf')
+
+ const response = await restClient.POST(`/${pdfOnlySlug}`, {
+ body: formData,
+ })
+ await handle.close()
+
+ expect(response.status).toBe(400)
+ })
+
it('should not allow invalid mimeType to be created', async () => {
const formData = new FormData()
const filePath = path.join(dirname, './image.jpg')
@@ -302,6 +318,20 @@ describe('Collections - Uploads', () => {
expect(response.status).toBe(400)
})
+
+ it('should not allow corrupted SVG to be created', async () => {
+ const formData = new FormData()
+ const filePath = path.join(dirname, './corrupt.svg')
+ const { file, handle } = await createStreamableFile(filePath)
+ formData.append('file', file)
+
+ const response = await restClient.POST(`/${svgOnlySlug}`, {
+ body: formData,
+ })
+ await handle.close()
+
+ expect(response.status).toBe(400)
+ })
})
describe('update', () => {
it('should replace image and delete old files - by ID', async () => {
@@ -739,7 +769,7 @@ describe('Collections - Uploads', () => {
'; ',
)
- const fetchSpy = jest.spyOn(global, 'fetch')
+ const fetchSpy = vitest.spyOn(global, 'fetch')
await payload.create({
collection: skipSafeFetchMediaSlug,
@@ -769,7 +799,7 @@ describe('Collections - Uploads', () => {
'; ',
)
- const fetchSpy = jest.spyOn(global, 'fetch')
+ const fetchSpy = vitest.spyOn(global, 'fetch')
// spin up a temporary server so fetch to the local doesn't fail
const server = createServer((req, res) => {
@@ -813,7 +843,7 @@ describe('Collections - Uploads', () => {
'; ',
)
- const fetchSpy = jest.spyOn(global, 'fetch')
+ const fetchSpy = vitest.spyOn(global, 'fetch')
await payload.create({
collection: skipSafeFetchHeaderFilterSlug,
@@ -870,7 +900,6 @@ describe('Collections - Uploads', () => {
const isIPV6 = hostname.includes('::')
// Strip brackets from IPv6 addresses
- // eslint-disable-next-line jest/no-conditional-in-test
if (isIPV6) {
hostname = hostname.slice(1, -1)
}
@@ -879,7 +908,6 @@ describe('Collections - Uploads', () => {
// we'd like to test for
// @ts-expect-error this does not need to be mocked 100% correctly
_internal_safeFetchGlobal.lookup = (_hostname, _options, callback) => {
- // eslint-disable-next-line jest/no-conditional-in-test
callback(null, hostname as any, isIPV6 ? 6 : 4)
}
@@ -1285,6 +1313,116 @@ describe('Collections - Uploads', () => {
expect(await fileExists(path.join(expectedPath, duplicatedDoc.filename))).toBe(true)
})
})
+
+ describe('HTTP Range Requests', () => {
+ let uploadedDoc: Media
+ let uploadedFilename: string
+ let fileSize: number
+
+ beforeAll(async () => {
+ // Upload a test file for range request testing
+ const filePath = path.join(dirname, './audio.mp3')
+ const file = await getFileByPath(filePath)
+
+ uploadedDoc = (await payload.create({
+ collection: mediaSlug,
+ data: {},
+ file,
+ })) as unknown as Media
+
+ uploadedFilename = uploadedDoc.filename
+ const stats = await stat(filePath)
+ fileSize = stats.size
+ })
+
+ it('should return Accept-Ranges header on full file request', async () => {
+ const response = await restClient.GET(`/${mediaSlug}/file/${uploadedFilename}`)
+
+ expect(response.status).toBe(200)
+ expect(response.headers.get('Accept-Ranges')).toBe('bytes')
+ expect(response.headers.get('Content-Length')).toBe(String(fileSize))
+ })
+
+ it('should handle range request with single byte range', async () => {
+ const response = await restClient.GET(`/${mediaSlug}/file/${uploadedFilename}`, {
+ headers: { Range: 'bytes=0-1023' },
+ })
+
+ expect(response.status).toBe(206)
+ expect(response.headers.get('Content-Range')).toBe(`bytes 0-1023/${fileSize}`)
+ expect(response.headers.get('Content-Length')).toBe('1024')
+ expect(response.headers.get('Accept-Ranges')).toBe('bytes')
+
+ const arrayBuffer = await response.arrayBuffer()
+ expect(arrayBuffer.byteLength).toBe(1024)
+ })
+
+ it('should handle range request with open-ended range', async () => {
+ const response = await restClient.GET(`/${mediaSlug}/file/${uploadedFilename}`, {
+ headers: { Range: 'bytes=1024-' },
+ })
+
+ expect(response.status).toBe(206)
+ expect(response.headers.get('Content-Range')).toBe(`bytes 1024-${fileSize - 1}/${fileSize}`)
+ expect(response.headers.get('Content-Length')).toBe(String(fileSize - 1024))
+
+ const arrayBuffer = await response.arrayBuffer()
+ expect(arrayBuffer.byteLength).toBe(fileSize - 1024)
+ })
+
+ it('should handle range request for suffix bytes', async () => {
+ const response = await restClient.GET(`/${mediaSlug}/file/${uploadedFilename}`, {
+ headers: { Range: 'bytes=-512' },
+ })
+
+ expect(response.status).toBe(206)
+ expect(response.headers.get('Content-Range')).toBe(
+ `bytes ${fileSize - 512}-${fileSize - 1}/${fileSize}`,
+ )
+ expect(response.headers.get('Content-Length')).toBe('512')
+
+ const arrayBuffer = await response.arrayBuffer()
+ expect(arrayBuffer.byteLength).toBe(512)
+ })
+
+ it('should return 416 for invalid range (start > file size)', async () => {
+ const response = await restClient.GET(`/${mediaSlug}/file/${uploadedFilename}`, {
+ headers: { Range: `bytes=${fileSize + 1000}-` },
+ })
+
+ expect(response.status).toBe(416)
+ expect(response.headers.get('Content-Range')).toBe(`bytes */${fileSize}`)
+ })
+
+ it('should handle multi-range requests by returning first range', async () => {
+ const response = await restClient.GET(`/${mediaSlug}/file/${uploadedFilename}`, {
+ headers: { Range: 'bytes=0-1023,2048-3071' },
+ })
+
+ expect(response.status).toBe(206)
+ expect(response.headers.get('Content-Range')).toBe(`bytes 0-1023/${fileSize}`)
+ expect(response.headers.get('Content-Length')).toBe('1024')
+
+ const arrayBuffer = await response.arrayBuffer()
+ expect(arrayBuffer.byteLength).toBe(1024)
+ })
+
+ it('should handle range at end of file', async () => {
+ const lastByte = fileSize - 1
+ const response = await restClient.GET(`/${mediaSlug}/file/${uploadedFilename}`, {
+ headers: { Range: `bytes=${lastByte}-${lastByte}` },
+ })
+
+ expect(response.status).toBe(206)
+ expect(response.headers.get('Content-Range')).toBe(
+ `bytes ${lastByte}-${lastByte}/${fileSize}`,
+ )
+ expect(response.headers.get('Content-Length')).toBe('1')
+
+ const arrayBuffer = await response.arrayBuffer()
+ expect(arrayBuffer.byteLength).toBe(1)
+ })
+ })
})
async function fileExists(fileName: string): Promise {
diff --git a/test/uploads/test.html b/test/uploads/test.html
new file mode 100644
index 00000000000..1005b0ad3fd
--- /dev/null
+++ b/test/uploads/test.html
@@ -0,0 +1 @@
+hello
diff --git a/test/versions/collections/DraftsNoReadVersions.ts b/test/versions/collections/DraftsNoReadVersions.ts
new file mode 100644
index 00000000000..cf1d026bcf4
--- /dev/null
+++ b/test/versions/collections/DraftsNoReadVersions.ts
@@ -0,0 +1,30 @@
+import type { CollectionConfig } from 'payload'
+
+import { draftsNoReadVersionsSlug } from '../slugs.js'
+
+const DraftsNoReadVersions: CollectionConfig = {
+ slug: draftsNoReadVersionsSlug,
+ access: {
+ readVersions: () => false,
+ },
+ admin: {
+ defaultColumns: ['title', 'createdAt', '_status'],
+ useAsTitle: 'title',
+ },
+ fields: [
+ {
+ name: 'title',
+ type: 'text',
+ required: true,
+ },
+ {
+ name: 'description',
+ type: 'textarea',
+ },
+ ],
+ versions: {
+ drafts: true,
+ },
+}
+
+export default DraftsNoReadVersions
diff --git a/test/versions/config.ts b/test/versions/config.ts
index cde27db4c23..5efe626f96f 100644
--- a/test/versions/config.ts
+++ b/test/versions/config.ts
@@ -11,6 +11,7 @@ import CustomIDs from './collections/CustomIDs.js'
import { Diff } from './collections/Diff/index.js'
import DisablePublish from './collections/DisablePublish.js'
import DraftPosts from './collections/Drafts.js'
+import DraftsNoReadVersions from './collections/DraftsNoReadVersions.js'
import DraftWithChangeHook from './collections/DraftsWithChangeHook.js'
import DraftWithMax from './collections/DraftsWithMax.js'
import DraftsWithValidate from './collections/DraftsWithValidate.js'
@@ -30,7 +31,8 @@ import DraftWithMaxGlobal from './globals/DraftWithMax.js'
import LocalizedGlobal from './globals/LocalizedGlobal.js'
import { MaxVersions } from './globals/MaxVersions.js'
import { seed } from './seed.js'
-
+import { BASE_PATH } from './shared.js'
+process.env.NEXT_BASE_PATH = BASE_PATH
export default buildConfigWithDefaults({
admin: {
importMap: {
@@ -47,6 +49,7 @@ export default buildConfigWithDefaults({
AutosaveWithMultiSelectPosts,
AutosaveWithDraftValidate,
DraftPosts,
+ DraftsNoReadVersions,
DraftWithMax,
DraftWithChangeHook,
DraftsWithValidate,
diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts
index f81f60ae4d6..4ef054a5613 100644
--- a/test/versions/e2e.spec.ts
+++ b/test/versions/e2e.spec.ts
@@ -30,7 +30,7 @@ import { checkFocusIndicators } from 'helpers/e2e/checkFocusIndicators.js'
import { runAxeScan } from 'helpers/e2e/runAxeScan.js'
import mongoose from 'mongoose'
import path from 'path'
-import { wait } from 'payload/shared'
+import { formatAdminURL, wait } from 'payload/shared'
import { fileURLToPath } from 'url'
import type { PayloadTestSDK } from '../helpers/sdk/index.js'
@@ -40,6 +40,7 @@ import {
changeLocale,
ensureCompilationIsDone,
exactText,
+ getRoutes,
initPageConsoleErrorCatch,
openDocDrawer,
saveDocAndAssert,
@@ -51,6 +52,7 @@ import { waitForAutoSaveToRunAndComplete } from '../helpers/e2e/waitForAutoSaveT
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { reInitializeDB } from '../helpers/reInitializeDB.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
+import { BASE_PATH } from './shared.js'
import {
autosaveCollectionSlug,
autoSaveGlobalSlug,
@@ -63,6 +65,7 @@ import {
disablePublishSlug,
draftCollectionSlug,
draftGlobalSlug,
+ draftsNoReadVersionsSlug,
draftWithChangeHookCollectionSlug,
draftWithMaxCollectionSlug,
draftWithMaxGlobalSlug,
@@ -75,6 +78,7 @@ import {
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
+process.env.NEXT_BASE_PATH = BASE_PATH
const { beforeAll, beforeEach, describe } = test
@@ -96,6 +100,8 @@ describe('Versions', () => {
let customIDURL: AdminUrlUtil
let postURL: AdminUrlUtil
let errorOnUnpublishURL: AdminUrlUtil
+ let draftsNoReadVersionsURL: AdminUrlUtil
+ let adminRoute: string
beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
@@ -105,6 +111,11 @@ describe('Versions', () => {
context = await browser.newContext()
page = await context.newPage()
+ const {
+ routes: { admin: adminRouteFromConfig },
+ } = getRoutes({})
+ adminRoute = adminRouteFromConfig
+
initPageConsoleErrorCatch(page)
await ensureCompilationIsDone({ page, serverURL })
})
@@ -134,6 +145,7 @@ describe('Versions', () => {
customIDURL = new AdminUrlUtil(serverURL, customIDSlug)
postURL = new AdminUrlUtil(serverURL, postCollectionSlug)
errorOnUnpublishURL = new AdminUrlUtil(serverURL, errorOnUnpublishSlug)
+ draftsNoReadVersionsURL = new AdminUrlUtil(serverURL, draftsNoReadVersionsSlug)
})
test('collection — should show "has published version" status in list view when draft is saved after publish', async () => {
@@ -233,17 +245,7 @@ describe('Versions', () => {
await titleField.fill('test')
await descriptionField.fill('test')
- const createdDate = await page.textContent(
- 'li:has(p:has-text("Created:")) .doc-controls__value',
- )
-
- // wait for modified date and created date to be different
- await expect(async () => {
- const modifiedDateLocator = page.locator(
- 'li:has(p:has-text("Last Modified:")) .doc-controls__value',
- )
- await expect(modifiedDateLocator).not.toHaveText(createdDate ?? '')
- }).toPass({ timeout: POLL_TOPASS_TIMEOUT, intervals: [100] })
+ await waitForAutoSaveToRunAndComplete(page)
const closeDrawer = page.locator('.doc-drawer__header-close')
await closeDrawer.click()
@@ -447,7 +449,11 @@ describe('Versions', () => {
await assertNetworkRequests(
page,
- `${serverURL}/admin/collections/${postCollectionSlug}/${postID}`,
+ formatAdminURL({
+ adminRoute,
+ path: `/collections/${postCollectionSlug}/${postID}`,
+ serverURL,
+ }),
async () => {
await page
.locator(
@@ -492,7 +498,11 @@ describe('Versions', () => {
await assertNetworkRequests(
page,
// Important: assert that depth is 0 in this request
- `${serverURL}/api/autosave-posts/${docID}?depth=0&draft=true&autosave=true&locale=en&fallback-locale=null`,
+ formatAdminURL({
+ apiRoute: '/api',
+ path: `/autosave-posts/${docID}?autosave=true&depth=0&draft=true&fallback-locale=null&locale=en`,
+ serverURL,
+ }),
async () => {
await page.locator('#field-title').fill('changed title')
},
@@ -937,6 +947,54 @@ describe('Versions', () => {
expect(scanResults.elementsWithoutIndicators).toBe(0)
})
})
+
+ describe('without readVersions permission', () => {
+ test('should show Draft status when creating and saving a new draft document', async () => {
+ await page.goto(draftsNoReadVersionsURL.create)
+ await page.locator('#field-title').fill('Test Draft Title')
+ await page.locator('#field-description').fill('Test Draft Description')
+
+ await saveDocAndAssert(page, '#action-save-draft')
+
+ await expect(page.locator('.doc-controls__status .status__value')).toContainText('Draft')
+
+ await expect(page.locator('#action-unpublish')).toBeHidden()
+ })
+
+ test('should show Published status after publishing a draft document', async () => {
+ await page.goto(draftsNoReadVersionsURL.create)
+ await page.locator('#field-title').fill('Test Publish Title')
+ await page.locator('#field-description').fill('Test Publish Description')
+
+ await saveDocAndAssert(page, '#action-save-draft')
+
+ await expect(page.locator('.doc-controls__status .status__value')).toContainText('Draft')
+
+ await page.locator('#action-save').click()
+
+ await expect(page.locator('.doc-controls__status .status__value')).toContainText(
+ 'Published',
+ )
+
+ await expect(page.locator('#action-unpublish')).toBeVisible()
+ })
+
+ test('should maintain Draft status when saving draft multiple times', async () => {
+ await page.goto(draftsNoReadVersionsURL.create)
+ await page.locator('#field-title').fill('Test Multiple Saves')
+ await page.locator('#field-description').fill('Initial Description')
+
+ await saveDocAndAssert(page, '#action-save-draft')
+
+ await expect(page.locator('.doc-controls__status .status__value')).toContainText('Draft')
+
+ await page.locator('#field-description').fill('Updated Description')
+ await saveDocAndAssert(page, '#action-save-draft')
+
+ await expect(page.locator('.doc-controls__status .status__value')).toContainText('Draft')
+ await expect(page.locator('#action-unpublish')).toBeHidden()
+ })
+ })
})
describe('draft globals', () => {
@@ -1200,7 +1258,7 @@ describe('Versions', () => {
const publishOptions = page.locator('.doc-controls__controls .popup')
await publishOptions.click()
- const publishSpecificLocale = page.locator('.doc-controls__controls .popup__content')
+ const publishSpecificLocale = page.locator('.popup__content')
await expect(publishSpecificLocale).toContainText('English')
})
@@ -1644,7 +1702,7 @@ describe('Versions', () => {
const publishOptions = page.locator('.doc-controls__controls .popup')
await publishOptions.click()
- const publishSpecificLocale = page.locator('.doc-controls__controls .popup__content')
+ const publishSpecificLocale = page.locator('.popup__content')
await expect(publishSpecificLocale).toContainText('English')
})
@@ -1729,13 +1787,21 @@ describe('Versions', () => {
})
async function navigateToDraftVersionView(versionID: string) {
- const versionURL = `${serverURL}/admin/collections/${draftCollectionSlug}/${postID}/versions/${versionID}`
+ const versionURL = formatAdminURL({
+ adminRoute,
+ path: `/collections/${draftCollectionSlug}/${postID}/versions/${versionID}`,
+ serverURL,
+ })
await page.goto(versionURL)
await expect(page.locator('.render-field-diffs').first()).toBeVisible()
}
async function navigateToDiffVersionView(versionID?: string) {
- const versionURL = `${serverURL}/admin/collections/${diffCollectionSlug}/${diffID}/versions/${versionID ?? versionDiffID}`
+ const versionURL = formatAdminURL({
+ adminRoute,
+ path: `/collections/${diffCollectionSlug}/${diffID}/versions/${versionID ?? versionDiffID}`,
+ serverURL,
+ })
await page.goto(versionURL)
await expect(page.locator('.render-field-diffs').first()).toBeVisible()
}
@@ -2363,13 +2429,19 @@ describe('Versions', () => {
},
})
- await page.goto(`${serverURL}/admin/collections/${draftCollectionSlug}/${post.id}`)
+ await page.goto(
+ formatAdminURL({
+ adminRoute,
+ path: `/collections/${draftCollectionSlug}/${post.id}`,
+ serverURL,
+ }),
+ )
const publishDropdown = page.locator('.doc-controls__controls .popup-button')
await publishDropdown.click()
const schedulePublishButton = page.locator(
- '.popup-button-list__button:has-text("Schedule Publish")',
+ '.popup__content .popup-button-list__button:has-text("Schedule Publish")',
)
await schedulePublishButton.click()
diff --git a/test/versions/int.spec.ts b/test/versions/int.spec.ts
index dfb9560f576..d48214cdb0e 100644
--- a/test/versions/int.spec.ts
+++ b/test/versions/int.spec.ts
@@ -6,6 +6,7 @@ import { createLocalReq, ValidationError } from 'payload'
import { wait } from 'payload/shared'
import * as qs from 'qs-esm'
import { fileURLToPath } from 'url'
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import type { AutosaveMultiSelectPost, DraftPost } from './payload-types.js'
diff --git a/test/versions/payload-types.ts b/test/versions/payload-types.ts
index 0b819e98afd..91d5d7ab762 100644
--- a/test/versions/payload-types.ts
+++ b/test/versions/payload-types.ts
@@ -74,6 +74,7 @@ export interface Config {
'autosave-multi-select-posts': AutosaveMultiSelectPost;
'autosave-with-validate-posts': AutosaveWithValidatePost;
'draft-posts': DraftPost;
+ 'drafts-no-read-versions': DraftsNoReadVersion;
'draft-with-max-posts': DraftWithMaxPost;
'draft-posts-with-change-hook': DraftPostsWithChangeHook;
'draft-with-validate-posts': DraftWithValidatePost;
@@ -101,6 +102,7 @@ export interface Config {
'autosave-multi-select-posts': AutosaveMultiSelectPostsSelect | AutosaveMultiSelectPostsSelect;
'autosave-with-validate-posts': AutosaveWithValidatePostsSelect | AutosaveWithValidatePostsSelect;
'draft-posts': DraftPostsSelect | DraftPostsSelect;
+ 'drafts-no-read-versions': DraftsNoReadVersionsSelect | DraftsNoReadVersionsSelect;
'draft-with-max-posts': DraftWithMaxPostsSelect | DraftWithMaxPostsSelect;
'draft-posts-with-change-hook': DraftPostsWithChangeHookSelect | DraftPostsWithChangeHookSelect;
'draft-with-validate-posts': DraftWithValidatePostsSelect | DraftWithValidatePostsSelect;
@@ -122,6 +124,7 @@ export interface Config {
db: {
defaultIDType: string;
};
+ fallbackLocale: ('false' | 'none' | 'null') | false | null | ('en' | 'es' | 'de') | ('en' | 'es' | 'de')[];
globals: {
'autosave-global': AutosaveGlobal;
'autosave-with-draft-button-global': AutosaveWithDraftButtonGlobal;
@@ -312,6 +315,18 @@ export interface AutosaveWithValidatePost {
createdAt: string;
_status?: ('draft' | 'published') | null;
}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "drafts-no-read-versions".
+ */
+export interface DraftsNoReadVersion {
+ id: string;
+ title: string;
+ description?: string | null;
+ updatedAt: string;
+ createdAt: string;
+ _status?: ('draft' | 'published') | null;
+}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "draft-with-max-posts".
@@ -775,6 +790,10 @@ export interface PayloadLockedDocument {
relationTo: 'draft-posts';
value: string | DraftPost;
} | null)
+ | ({
+ relationTo: 'drafts-no-read-versions';
+ value: string | DraftsNoReadVersion;
+ } | null)
| ({
relationTo: 'draft-with-max-posts';
value: string | DraftWithMaxPost;
@@ -965,6 +984,17 @@ export interface DraftPostsSelect {
createdAt?: T;
_status?: T;
}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "drafts-no-read-versions_select".
+ */
+export interface DraftsNoReadVersionsSelect {
+ title?: T;
+ description?: T;
+ updatedAt?: T;
+ createdAt?: T;
+ _status?: T;
+}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "draft-with-max-posts_select".
diff --git a/test/versions/shared.ts b/test/versions/shared.ts
index e69de29bb2d..9ed786de650 100644
--- a/test/versions/shared.ts
+++ b/test/versions/shared.ts
@@ -0,0 +1 @@
+export const BASE_PATH: '' | `/${string}` = ''
diff --git a/test/versions/slugs.ts b/test/versions/slugs.ts
index 0831f3cd82b..50d741ce3f4 100644
--- a/test/versions/slugs.ts
+++ b/test/versions/slugs.ts
@@ -9,6 +9,8 @@ export const customIDSlug = 'custom-ids'
export const draftCollectionSlug = 'draft-posts'
+export const draftsNoReadVersionsSlug = 'drafts-no-read-versions'
+
export const draftWithValidateCollectionSlug = 'draft-with-validate-posts'
export const draftWithMaxCollectionSlug = 'draft-with-max-posts'
diff --git a/test/jest.setup.js b/test/vitest.setup.ts
similarity index 59%
rename from test/jest.setup.js
rename to test/vitest.setup.ts
index 0a965121df4..62dafacd18d 100644
--- a/test/jest.setup.js
+++ b/test/vitest.setup.ts
@@ -1,11 +1,7 @@
-import { jest } from '@jest/globals'
-import console from 'console'
-global.console = console
-
import dotenv from 'dotenv'
dotenv.config()
-import nodemailer from 'nodemailer'
+// import nodemailer from 'nodemailer'
import { generateDatabaseAdapter } from './generateDatabaseAdapter.js'
@@ -20,17 +16,17 @@ process.env.PAYLOAD_CI_DEPENDENCY_CHECKER = 'true'
// @todo remove in 4.0 - will behave like this by default in 4.0
process.env.PAYLOAD_DO_NOT_SANITIZE_LOCALIZED_PROPERTY = 'true'
-// Mock createTestAccount to prevent calling external services
-jest.spyOn(nodemailer, 'createTestAccount').mockImplementation(() => {
- return Promise.resolve({
- imap: { host: 'imap.test.com', port: 993, secure: true },
- pass: 'testpass',
- pop3: { host: 'pop3.test.com', port: 995, secure: true },
- smtp: { host: 'smtp.test.com', port: 587, secure: false },
- user: 'testuser',
- web: 'https://webmail.test.com',
- })
-})
+// // Mock createTestAccount to prevent calling external services
+// jest.spyOn(nodemailer, 'createTestAccount').mockImplementation(() => {
+// return Promise.resolve({
+// imap: { host: 'imap.test.com', port: 993, secure: true },
+// pass: 'testpass',
+// pop3: { host: 'pop3.test.com', port: 995, secure: true },
+// smtp: { host: 'smtp.test.com', port: 587, secure: false },
+// user: 'testuser',
+// web: 'https://webmail.test.com',
+// })
+// })
if (!process.env.PAYLOAD_DATABASE) {
// Mutate env so we can use conditions by DB adapter in tests properly without ignoring // eslint no-jest-conditions.
diff --git a/tools/claude-plugin/skills/payload/SKILL.md b/tools/claude-plugin/skills/payload/SKILL.md
index 67e81af1066..6d6c1200b41 100644
--- a/tools/claude-plugin/skills/payload/SKILL.md
+++ b/tools/claude-plugin/skills/payload/SKILL.md
@@ -73,7 +73,7 @@ export default buildConfig({
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
db: mongooseAdapter({
- url: process.env.DATABASE_URI,
+ url: process.env.DATABASE_URL,
}),
})
```
diff --git a/tools/claude-plugin/skills/payload/reference/ACCESS-CONTROL-ADVANCED.md b/tools/claude-plugin/skills/payload/reference/ACCESS-CONTROL-ADVANCED.md
index 77da117acd7..557e3e66da6 100644
--- a/tools/claude-plugin/skills/payload/reference/ACCESS-CONTROL-ADVANCED.md
+++ b/tools/claude-plugin/skills/payload/reference/ACCESS-CONTROL-ADVANCED.md
@@ -67,7 +67,7 @@ export const MobileContent: CollectionConfig = {
Restrict access from specific IP addresses (requires middleware/proxy headers).
```ts
-import type: Access } from 'payload'
+import type { Access } from 'payload'
export const restrictedIpAccess = (allowedIps: string[]): Access => {
return ({ req: { headers } }) => {
diff --git a/tools/claude-plugin/skills/payload/reference/ADAPTERS.md b/tools/claude-plugin/skills/payload/reference/ADAPTERS.md
index e575ba73b88..04b900c6cf8 100644
--- a/tools/claude-plugin/skills/payload/reference/ADAPTERS.md
+++ b/tools/claude-plugin/skills/payload/reference/ADAPTERS.md
@@ -11,7 +11,7 @@ import { mongooseAdapter } from '@payloadcms/db-mongodb'
export default buildConfig({
db: mongooseAdapter({
- url: process.env.DATABASE_URI,
+ url: process.env.DATABASE_URL,
}),
})
```
@@ -24,7 +24,7 @@ import { postgresAdapter } from '@payloadcms/db-postgres'
export default buildConfig({
db: postgresAdapter({
pool: {
- connectionString: process.env.DATABASE_URI,
+ connectionString: process.env.DATABASE_URL,
},
push: false, // Don't auto-push schema changes
migrationDir: './migrations',
diff --git a/tools/claude-plugin/skills/payload/reference/PLUGIN-DEVELOPMENT.md b/tools/claude-plugin/skills/payload/reference/PLUGIN-DEVELOPMENT.md
index 1feb6280b7b..416e89e25d9 100644
--- a/tools/claude-plugin/skills/payload/reference/PLUGIN-DEVELOPMENT.md
+++ b/tools/claude-plugin/skills/payload/reference/PLUGIN-DEVELOPMENT.md
@@ -149,7 +149,7 @@ plugin-/
"copyfiles": "^2.4.1",
"cross-env": "^7.0.3",
"eslint": "^9.0.0",
- "next": "^15.4.8",
+ "next": "^15.4.10",
"payload": "^3.0.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
@@ -516,13 +516,19 @@ export const myPlugin =
'use client'
import { useConfig } from '@payloadcms/ui'
import { useEffect, useState } from 'react'
+import { formatAdminURL } from 'payload/shared'
export const BeforeDashboardClient = () => {
const { config } = useConfig()
const [data, setData] = useState('')
useEffect(() => {
- fetch(`${config.serverURL}${config.routes.api}/my-endpoint`)
+ fetch(
+ formatAdminURL({
+ apiRoute: config.routes.api,
+ path: '/my-endpoint',
+ }),
+ )
.then((res) => res.json())
.then(setData)
}, [config.serverURL, config.routes.api])
@@ -1296,7 +1302,7 @@ Include a `dev/` directory with a complete Payload project for local development
1. Create `dev/.env` from `.env.example`:
```bash
-DATABASE_URI=mongodb://127.0.0.1/plugin-dev
+DATABASE_URL=mongodb://127.0.0.1/plugin-dev
PAYLOAD_SECRET=your-secret-here
```
@@ -1309,7 +1315,7 @@ import { myPlugin } from '../src/index.js'
export default buildConfig({
secret: process.env.PAYLOAD_SECRET!,
- db: mongooseAdapter({ url: process.env.DATABASE_URI! }),
+ db: mongooseAdapter({ url: process.env.DATABASE_URL! }),
plugins: [
myPlugin({
collections: ['posts'],
diff --git a/tools/scripts/src/build-template-with-local-pkgs.ts b/tools/scripts/src/build-template-with-local-pkgs.ts
index 25e1f250314..34a6b527cbe 100644
--- a/tools/scripts/src/build-template-with-local-pkgs.ts
+++ b/tools/scripts/src/build-template-with-local-pkgs.ts
@@ -1,6 +1,6 @@
import { TEMPLATES_DIR } from '@tools/constants'
import chalk from 'chalk'
-import { execSync } from 'child_process'
+import { execSync, spawn } from 'child_process'
import fs from 'fs/promises'
import path from 'path'
@@ -10,12 +10,16 @@ main().catch((error) => {
})
async function main() {
- const templateName = process.argv[2]
+ const args = process.argv.slice(2)
+ const allowWarnings = args.includes('--allow-warnings')
+ const positionalArgs = args.filter((arg) => !arg.startsWith('--'))
+
+ const templateName = positionalArgs[0]
if (!templateName) {
throw new Error('Please provide a template name')
}
const templatePath = path.join(TEMPLATES_DIR, templateName)
- const databaseConnection = process.argv[3] || 'mongodb://127.0.0.1/your-database-name'
+ const databaseConnection = positionalArgs[1] || 'mongodb://127.0.0.1/your-database-name'
console.log({
templatePath,
@@ -83,7 +87,7 @@ async function main() {
path.resolve(templatePath, '.env'),
// Populate POSTGRES_URL just in case it's needed
`PAYLOAD_SECRET=secret
-DATABASE_URI=${databaseConnection}
+DATABASE_URL=${databaseConnection}
POSTGRES_URL=${databaseConnection}
BLOB_READ_WRITE_TOKEN=vercel_blob_rw_TEST_asdf`,
)
@@ -94,7 +98,7 @@ BLOB_READ_WRITE_TOKEN=vercel_blob_rw_TEST_asdf`,
execSync('pnpm --ignore-workspace run generate:importmap', execOpts)
}
- execSync('pnpm --ignore-workspace run build', execOpts)
+ await runBuildWithWarningsCheck({ cwd: templatePath, allowWarnings })
header(`\n🎉 Done!`)
}
@@ -103,6 +107,60 @@ function header(message: string, opts?: { enable?: boolean }) {
console.log(chalk.bold.green(`${message}\n`))
}
+/**
+ *
+ * Runs the build command and checks for warnings. If there are any warnings, and the user
+ * has not allowed them, the build will fail.
+ *
+ * This ensures that if any new code introduces warnings in the template build process, it will fail our CI.
+ * Without this, the warnings will be ignored and the build will pass, even if
+ * the new code introduces warnings.
+ */
+async function runBuildWithWarningsCheck(args: {
+ allowWarnings: boolean
+ cwd: string
+}): Promise {
+ const { allowWarnings, cwd } = args
+
+ return new Promise((resolve, reject) => {
+ const buildProcess = spawn('pnpm', ['--ignore-workspace', 'run', 'build'], {
+ cwd,
+ shell: true,
+ })
+
+ let output = ''
+
+ buildProcess.stdout.on('data', (data: Buffer) => {
+ process.stdout.write(data)
+ output += data.toString()
+ })
+
+ buildProcess.stderr.on('data', (data: Buffer) => {
+ process.stderr.write(data)
+ output += data.toString()
+ })
+
+ buildProcess.on('close', (code) => {
+ if (code !== 0) {
+ reject(new Error(`Build failed with exit code ${code}`))
+ return
+ }
+
+ if (!allowWarnings && output.includes('Compiled with warnings')) {
+ console.error(
+ chalk.red(
+ '\n❌ Build compiled with warnings. Use --allow-warnings to bypass this check.',
+ ),
+ )
+ reject(new Error('Build compiled with warnings'))
+ return
+ }
+
+ resolve()
+ })
+ })
+}
+
/**
* Recursively updates a JSON object to replace all instances of `workspace:` with the latest version pinned.
*
diff --git a/tools/scripts/src/generate-template-variations.ts b/tools/scripts/src/generate-template-variations.ts
index e2ecc94a679..df686dfd80d 100644
--- a/tools/scripts/src/generate-template-variations.ts
+++ b/tools/scripts/src/generate-template-variations.ts
@@ -47,6 +47,10 @@ type TemplateVariation = {
* @default false
*/
skipReadme?: boolean
+ /**
+ * @default false
+ */
+ skipAgents?: boolean
storage: StorageAdapterType
vercelDeployButtonLink?: string
/**
@@ -77,12 +81,13 @@ async function main() {
db: 'vercel-postgres',
dirname: 'with-vercel-postgres',
envNames: {
- // This will replace the process.env.DATABASE_URI to process.env.POSTGRES_URL
+ // This will replace the process.env.DATABASE_URL to process.env.POSTGRES_URL
dbUri: 'POSTGRES_URL',
},
sharp: false,
skipDockerCompose: true,
skipReadme: true,
+ skipAgents: false,
storage: 'vercelBlobStorage',
targetDeployment: 'vercel',
vercelDeployButtonLink:
@@ -101,12 +106,13 @@ async function main() {
db: 'vercel-postgres',
dirname: 'with-vercel-website',
envNames: {
- // This will replace the process.env.DATABASE_URI to process.env.POSTGRES_URL
+ // This will replace the process.env.DATABASE_URL to process.env.POSTGRES_URL
dbUri: 'POSTGRES_URL',
},
sharp: true,
skipDockerCompose: true,
skipReadme: true,
+ skipAgents: false,
storage: 'vercelBlobStorage',
targetDeployment: 'vercel',
vercelDeployButtonLink:
@@ -125,6 +131,7 @@ async function main() {
dirname: 'with-postgres',
sharp: true,
skipDockerCompose: true,
+ skipAgents: false,
storage: 'localDisk',
},
{
@@ -132,11 +139,12 @@ async function main() {
db: 'mongodb',
dirname: 'with-vercel-mongodb',
envNames: {
- dbUri: 'MONGODB_URI',
+ dbUri: 'MONGODB_URL',
},
sharp: false,
storage: 'vercelBlobStorage',
skipReadme: true,
+ skipAgents: false,
targetDeployment: 'vercel',
vercelDeployButtonLink:
`https://vercel.com/new/clone?repository-url=` +
@@ -157,6 +165,7 @@ async function main() {
sharp: true,
skipConfig: true, // Do not copy the payload.config.ts file from the base template
skipReadme: true, // Do not copy the README.md file from the base template
+ skipAgents: false,
storage: 'localDisk',
// The blank template is used as a base for create-payload-app functionality,
// so we do not configure the payload.config.ts file, which leaves the placeholder comments.
@@ -171,6 +180,7 @@ async function main() {
generateLockfile: true,
sharp: true,
skipConfig: true, // Do not copy the payload.config.ts file from the base template
+ skipAgents: false,
storage: 'localDisk',
// The blank template is used as a base for create-payload-app functionality,
// so we do not configure the payload.config.ts file, which leaves the placeholder comments.
@@ -187,6 +197,7 @@ async function main() {
generateLockfile: true,
sharp: true,
skipConfig: true, // Do not copy the payload.config.ts file from the base template
+ skipAgents: false,
storage: 'localDisk',
// The blank template is used as a base for create-payload-app functionality,
// so we do not configure the payload.config.ts file, which leaves the placeholder comments.
@@ -203,6 +214,7 @@ async function main() {
generateLockfile: false,
sharp: false,
skipConfig: true, // Do not copy the payload.config.ts file from the base template
+ skipAgents: false,
storage: 'r2Storage',
// The blank template is used as a base for create-payload-app functionality,
// so we do not configure the payload.config.ts file, which leaves the placeholder comments.
@@ -237,6 +249,7 @@ async function main() {
skipConfig = false,
skipDockerCompose = false,
skipReadme = false,
+ skipAgents = false,
storage,
vercelDeployButtonLink,
targetDeployment = 'default',
@@ -260,6 +273,11 @@ async function main() {
log(`Copied to ${destDir}`)
+ // Copy _agents files
+ if (!skipAgents) {
+ await copyAgentsFiles({ destDir })
+ }
+
if (configureConfig !== false) {
log('Configuring payload.config.ts')
const configureArgs = {
@@ -274,7 +292,7 @@ async function main() {
await configurePayloadConfig(configureArgs)
log('Configuring .env.example')
- // Replace DATABASE_URI with the correct env name if set
+ // Replace DATABASE_URL with the correct env name if set
await writeEnvExample({
dbType: db,
destDir,
@@ -343,7 +361,7 @@ async function main() {
env: {
...process.env,
BLOB_READ_WRITE_TOKEN: 'vercel_blob_rw_TEST_asdf',
- DATABASE_URI: process.env.POSTGRES_URL || 'postgres://localhost:5432/your-database-name',
+ DATABASE_URL: process.env.POSTGRES_URL || 'postgres://localhost:5432/your-database-name',
PAYLOAD_SECRET: 'asecretsolongnotevensantacouldguessit',
},
})
@@ -417,6 +435,34 @@ ${description}
log('Generated README.md')
}
+async function copyAgentsFiles({ destDir }: { destDir: string }) {
+ const agentsSourceDir = path.join(TEMPLATES_DIR, '_agents')
+
+ if (!(await fs.stat(agentsSourceDir).catch(() => null))) {
+ log(`Skipping agents copy: ${agentsSourceDir} does not exist`)
+ return
+ }
+
+ log('Copying agents files')
+
+ // Copy AGENTS.md
+ const agentsMdSource = path.join(agentsSourceDir, 'AGENTS.md')
+ const agentsMdDest = path.join(destDir, 'AGENTS.md')
+ if (await fs.stat(agentsMdSource).catch(() => null)) {
+ await fs.copyFile(agentsMdSource, agentsMdDest)
+ log('Copied AGENTS.md')
+ }
+
+ // Copy .cursor directory
+ const cursorSourceDir = path.join(agentsSourceDir, 'rules')
+ const cursorDestDir = path.join(destDir, '.cursor', 'rules')
+ if (await fs.stat(cursorSourceDir).catch(() => null)) {
+ await fs.mkdir(path.dirname(cursorDestDir), { recursive: true })
+ await fs.cp(cursorSourceDir, cursorDestDir, { recursive: true })
+ log('Copied .cursor/rules/')
+ }
+}
+
async function handleDeploymentTarget({
targetDeployment,
destDir,
@@ -460,25 +506,25 @@ async function writeEnvExample({
if (
dbType === 'vercel-postgres' &&
(l.startsWith('# Or use a PG connection string') ||
- l.startsWith('#DATABASE_URI=postgresql://'))
+ l.startsWith('#DATABASE_URL=postgresql://'))
) {
return false // Skip this line
}
return true // Keep other lines
})
.map((l) => {
- if (l.startsWith('DATABASE_URI')) {
+ if (l.startsWith('DATABASE_URL')) {
if (dbType === 'mongodb') {
- l = 'MONGODB_URI=mongodb://127.0.0.1/your-database-name'
+ l = 'MONGODB_URL=mongodb://127.0.0.1/your-database-name'
}
// Use db-appropriate connection string
if (dbType.includes('postgres')) {
- l = 'DATABASE_URI=postgresql://127.0.0.1:5432/your-database-name'
+ l = 'DATABASE_URL=postgresql://127.0.0.1:5432/your-database-name'
}
- // Replace DATABASE_URI with the correct env name if set
+ // Replace DATABASE_URL with the correct env name if set
if (envNames?.dbUri) {
- l = l.replace('DATABASE_URI', envNames.dbUri)
+ l = l.replace('DATABASE_URL', envNames.dbUri)
}
}
return l
diff --git a/tsconfig.base.json b/tsconfig.base.json
index c79d70dc84f..f6fc213b7b1 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -22,7 +22,7 @@
"emitDeclarationOnly": true,
"sourceMap": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
- "types": ["node", "jest"],
+ "types": ["node"],
"incremental": true,
"isolatedModules": true,
"plugins": [
@@ -95,4 +95,4 @@
},
"include": ["${configDir}/src"],
"exclude": ["${configDir}/dist", "${configDir}/build", "${configDir}/temp", "**/*.spec.ts"]
-}
+}
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
index ec0ce83f7c8..4e094666f3e 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -94,6 +94,6 @@
"include": [
"${configDir}/src",
".next/types/**/*.ts",
- "./scripts/**/*.ts",
+ "./scripts/**/*.ts"
]
}
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 00000000000..83e0e173b48
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,32 @@
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ watch: false, // too troublesome especially with the in memory DB setup
+ projects: [
+ {
+ test: {
+ include: ['packages/**/*.spec.ts'],
+ name: 'unit',
+ environment: 'node',
+ },
+ },
+ {
+ resolve: {
+ alias: {
+ graphql: 'node_modules/graphql/index.js', // https://github.com/vitest-dev/vitest/issues/4605
+ },
+ },
+ test: {
+ include: ['test/**/*int.spec.ts'],
+ name: 'int',
+ environment: 'node',
+ fileParallelism: false,
+ hookTimeout: 90000,
+ testTimeout: 90000,
+ setupFiles: ['./test/vitest.setup.ts'],
+ },
+ },
+ ],
+ },
+})