Files
payloadcms/test/config/int.spec.ts
Alessio Gravili 13c24afa63 feat: allow multiple, different payload instances using getPayload in same process (#13603)
Fixes https://github.com/payloadcms/payload/issues/13433. Testing
release: `3.54.0-internal.90cf7d5`

Previously, when calling `getPayload`, you would always use the same,
cached payload instance within a single process, regardless of the
arguments passed to the `getPayload` function. This resulted in the
following issues - both are fixed by this PR:

- If, in your frontend you're calling `getPayload` without `cron: true`,
and you're hosting the Payload Admin Panel in the same process, crons
will not be enabled even if you visit the admin panel which calls
`getPayload` with `cron: true`. This will break jobs autorun depending
on which page you visit first - admin panel or frontend
- Within the same process, you are unable to use `getPayload` twice for
different instances of payload with different Payload Configs.
On postgres, you can get around this by manually calling new
`BasePayload()` which skips the cache. This did not work on mongoose
though, as mongoose was caching the models on a global singleton (this
PR addresses this).
In order to bust the cache for different Payload Config, this PR
introduces a new, optional `key` property to `getPayload`.


## Mongoose - disable using global singleton

This PR refactors the Payload Mongoose adapter to stop relying on the
global mongoose singleton. Instead, each adapter instance now creates
and manages its own scoped Connection object.

### Motivation

Previously, calling `getPayload()` more than once in the same process
would throw `Cannot overwrite model` errors because models were compiled
into the global singleton. This prevented running multiple Payload
instances side-by-side, even when pointing at different databases.

### Changes
- Replace usage of `mongoose.connect()` / `mongoose.model()` with
instance-scoped `createConnection()` and `connection.model()`.
- Ensure models, globals, and versions are compiled per connection, not
globally.
- Added proper `close()` handling on `this.connection` instead of
`mongoose.disconnect()`.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211114366468745
2025-08-27 10:24:37 -07:00

188 lines
5.4 KiB
TypeScript

import { execSync } from 'child_process'
import { existsSync, readFileSync, rmSync } from 'fs'
import path from 'path'
import { type BlocksField, getPayload, type Payload } from 'payload'
import { fileURLToPath } from 'url'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { testFilePath } from './testFilePath.js'
let restClient: NextRESTClient
let payload: Payload
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
describe('Config', () => {
beforeAll(async () => {
;({ payload, restClient } = await initPayloadInt(dirname))
})
afterAll(async () => {
await payload.destroy()
})
describe('payload config', () => {
it('allows a custom field at the config root', () => {
const { config } = payload
expect(config.custom).toEqual({
name: 'Customer portal',
})
})
it('allows a custom field in the root endpoints', () => {
const [endpoint] = payload.config.endpoints
expect(endpoint.custom).toEqual({
description: 'Get the sanitized payload config',
})
})
it('should allow multiple getPayload calls using different configs in same process', async () => {
const payload2 = await getPayload({
key: 'payload2',
config: await buildConfigWithDefaults({
collections: [
{
slug: 'payload2',
fields: [{ name: 'title2', type: 'text' }],
},
],
}),
})
// Use payload2 instance before creating payload3 instance, as we share the same db connection => each instance
// creation will reset the db schema.
const result2: any = await payload2.create({
collection: 'payload2',
data: {
title2: 'Payload 2',
},
} as any)
expect(result2.title2).toBe('Payload 2')
const payload3 = await getPayload({
key: 'payload3',
config: await buildConfigWithDefaults({
collections: [
{
slug: 'payload3',
fields: [{ name: 'title3', type: 'text' }],
},
],
}),
})
// If payload was still incorrectly cached, this would fail, as the old payload config would still be used
const result3: any = await payload3.create({
collection: 'payload3',
data: {
title3: 'Payload 3',
},
} as any)
expect(result3.title3).toBe('Payload 3')
await payload2.destroy()
await payload3.destroy()
})
})
describe('collection config', () => {
it('allows a custom field in collections', () => {
const [collection] = payload.config.collections
expect(collection.custom).toEqual({
externalLink: 'https://foo.bar',
})
})
it('allows a custom field in collection endpoints', () => {
const [collection] = payload.config.collections
const [endpoint] = collection.endpoints || []
expect(endpoint.custom).toEqual({
examples: [{ type: 'response', value: { message: 'hi' } }],
})
})
it('allows a custom field in collection fields', () => {
const [collection] = payload.config.collections
const [field] = collection.fields
expect(field.custom).toEqual({
description: 'The title of this page',
})
})
it('allows a custom field in blocks in collection fields', () => {
const [collection] = payload.config.collections
const [, blocksField] = collection.fields
expect((blocksField as BlocksField).blocks[0].custom).toEqual({
description: 'The blockOne of this page',
})
})
})
describe('global config', () => {
it('allows a custom field in globals', () => {
const [global] = payload.config.globals
expect(global.custom).toEqual({ foo: 'bar' })
})
it('allows a custom field in global endpoints', () => {
const [global] = payload.config.globals
const [endpoint] = global.endpoints || []
expect(endpoint.custom).toEqual({ params: [{ name: 'name', type: 'string', in: 'query' }] })
})
it('allows a custom field in global fields', () => {
const [global] = payload.config.globals
const [field] = global.fields
expect(field.custom).toEqual({
description: 'The title of my global',
})
})
})
describe('cors config', () => {
it('includes a custom header in Access-Control-Allow-Headers', async () => {
const response = await restClient.GET(`/pages`)
expect(response.headers.get('Access-Control-Allow-Headers')).toContain('x-custom-header')
})
})
describe('bin config', () => {
const executeCLI = (command: string) => {
execSync(`pnpm tsx "${path.resolve(dirname, 'bin.ts')}" ${command}`, {
env: {
...process.env,
PAYLOAD_CONFIG_PATH: path.resolve(dirname, 'config.ts'),
PAYLOAD_DROP_DATABASE: 'false',
},
stdio: 'inherit',
cwd: path.resolve(dirname, '../..'), // from root
})
}
const deleteTestFile = () => {
if (existsSync(testFilePath)) {
rmSync(testFilePath)
}
}
it.skip('should execute a custom script', () => {
deleteTestFile()
executeCLI('start-server')
expect(JSON.parse(readFileSync(testFilePath, 'utf-8')).docs).toHaveLength(1)
deleteTestFile()
})
})
})