Skip to content

Commit 4264153

Browse files
paulpopuszubricks
authored andcommitted
fix(storage-*): allow prefix to always exist as a field via alwaysInsertFields flag (#14949)
This PR adds a new top-level flag `alwaysInsertFields` in the storage adapter plugin options to ensure the prefix field is always present in the schema. Some configurations have prefix dynamically set by environment, but this can cause schema/db drift and issues where db migrations are needed, as well as generated types being different between environments. Now you can add `alwaysInsertFields: true` at the plugin level so that the prefix field is always present regardless of what you set in `prefix`, even when the plugin is disabled: ``` s3Storage({ alwaysInsertFields: true, // prefix field will always exist in schema collections: { 'media': true, 'media-with-prefix': { prefix: process.env.MEDIA_PREFIX, // can be undefined without causing schema drift }, }, enabled: process.env.USE_S3 === 'true', // works even when disabled // ... }) ``` This is particularly useful for: - Multi-tenant setups where prefix is set dynamically - Environments where cloud storage is conditionally enabled (e.g., local dev vs production) - Ensuring consistent database schema across all environments **This will be enabled by default and removed as a flag in Payload v4.**
1 parent 1079c43 commit 4264153

18 files changed

Lines changed: 315 additions & 44 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,8 @@ test/google-cloud-storage
344344
test/azurestoragedata/
345345
/media-without-delete-access
346346
/media-documents
347+
/media-with-always-insert-fields
348+
347349

348350

349351
licenses.csv

docs/upload/storage-adapters.mdx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -368,10 +368,11 @@ export default buildConfig({
368368

369369
This plugin is configurable to work across many different Payload collections. A `*` denotes that the property is required.
370370

371-
| Option | Type | Description |
372-
| ---------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
373-
| `collections` \* | `Record<string, CollectionOptions>` | Object with keys set to the slug of collections you want to enable the plugin for, and values set to collection-specific options. |
374-
| `enabled` | `boolean` | To conditionally enable/disable plugin. Default: `true`. |
371+
| Option | Type | Description |
372+
| -------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
373+
| `alwaysInsertFields` | `boolean` | When enabled, fields (like the prefix field) will always be inserted into the collection schema regardless of whether the plugin is enabled. This will be enabled by default in Payload v4. Default: `false`. |
374+
| `collections` \* | `Record<string, CollectionOptions>` | Object with keys set to the slug of collections you want to enable the plugin for, and values set to collection-specific options. |
375+
| `enabled` | `boolean` | To conditionally enable/disable plugin. Default: `true`. |
375376

376377
## Collection-specific options
377378

@@ -380,8 +381,8 @@ This plugin is configurable to work across many different Payload collections. A
380381
| `adapter` \* | [Adapter](https://github.com/payloadcms/payload/blob/main/packages/plugin-cloud-storage/src/types.ts#L49) | Pass in the adapter that you'd like to use for this collection. You can also set this field to `null` for local development if you'd like to bypass cloud storage in certain scenarios and use local storage. |
381382
| `disableLocalStorage` | `boolean` | Choose to disable local storage on this collection. Defaults to `true`. |
382383
| `disablePayloadAccessControl` | `true` | Set to `true` to disable Payload's Access Control. [More](#payload-access-control) |
383-
| `prefix` | `string` | Set to `media/images` to upload files inside `media/images` folder in the bucket. |
384384
| `generateFileURL` | [GenerateFileURL](https://github.com/payloadcms/payload/blob/main/packages/plugin-cloud-storage/src/types.ts#L67) | Override the generated file URL with one that you create. |
385+
| `prefix` | `string` | Set to `media/images` to upload files inside `media/images` folder in the bucket. |
385386

386387
## Payload Access Control
387388

packages/plugin-cloud-storage/src/admin/fields/getFields.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@ import type { CollectionConfig, Field, GroupField, TextField } from 'payload'
33
import path from 'path'
44

55
interface Args {
6+
/**
7+
* When true, always insert the prefix field regardless of whether a prefix is configured.
8+
*/
9+
alwaysInsertFields?: boolean
610
collection: CollectionConfig
711
prefix?: string
812
}
913

10-
export const getFields = ({ collection, prefix }: Args): Field[] => {
14+
export const getFields = ({ alwaysInsertFields, collection, prefix }: Args): Field[] => {
1115
const baseURLField: TextField = {
1216
name: 'url',
1317
type: 'text',
@@ -99,8 +103,8 @@ export const getFields = ({ collection, prefix }: Args): Field[] => {
99103
fields.push(sizesField)
100104
}
101105

102-
// If prefix is enabled, save it to db
103-
if (typeof prefix !== 'undefined') {
106+
// If prefix is enabled or alwaysInsertFields is true, save it to db
107+
if (typeof prefix !== 'undefined' || alwaysInsertFields) {
104108
let existingPrefixFieldIndex = -1
105109

106110
const existingPrefixField = fields.find((existingField, i) => {
@@ -118,7 +122,7 @@ export const getFields = ({ collection, prefix }: Args): Field[] => {
118122
fields.push({
119123
...basePrefixField,
120124
...(existingPrefixField || {}),
121-
defaultValue: path.posix.join(prefix),
125+
defaultValue: prefix ? path.posix.join(prefix) : '',
122126
} as TextField)
123127
}
124128

packages/plugin-cloud-storage/src/admin/index.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ import { getFields } from './fields/getFields.js'
1010
export const cloudStorage =
1111
(pluginOptions: PluginOptions) =>
1212
(incomingConfig: Config): Config => {
13-
const { collections: allCollectionOptions, enabled } = pluginOptions
13+
const { alwaysInsertFields, collections: allCollectionOptions, enabled } = pluginOptions
1414
const config = { ...incomingConfig }
1515

16-
// Return early if disabled. Only webpack config mods are applied.
17-
if (enabled === false) {
16+
// If disabled and alwaysInsertFields is not true, skip processing
17+
if (enabled === false && !alwaysInsertFields) {
1818
return config
1919
}
2020

@@ -23,8 +23,10 @@ export const cloudStorage =
2323
collections: (config.collections || []).map((existingCollection) => {
2424
const options = allCollectionOptions[existingCollection.slug]
2525

26-
if (options?.adapter) {
26+
// Process if adapter exists OR if alwaysInsertFields is true and this collection is configured
27+
if (options?.adapter || (alwaysInsertFields && options)) {
2728
const fields = getFields({
29+
alwaysInsertFields,
2830
collection: existingCollection,
2931
prefix: options.prefix,
3032
})

packages/plugin-cloud-storage/src/fields/getFields.ts

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import type { GeneratedAdapter, GenerateFileURL } from '../types.js'
77
import { getAfterReadHook } from '../hooks/afterRead.js'
88

99
interface Args {
10-
adapter: GeneratedAdapter
10+
adapter?: GeneratedAdapter
11+
/**
12+
* When true, always insert the prefix field regardless of whether a prefix is configured.
13+
*/
14+
alwaysInsertFields?: boolean
1115
collection: CollectionConfig
1216
disablePayloadAccessControl?: true
1317
generateFileURL?: GenerateFileURL
@@ -16,6 +20,7 @@ interface Args {
1620

1721
export const getFields = ({
1822
adapter,
23+
alwaysInsertFields,
1924
collection,
2025
disablePayloadAccessControl,
2126
generateFileURL,
@@ -40,7 +45,7 @@ export const getFields = ({
4045
},
4146
}
4247

43-
const fields = [...collection.fields, ...(adapter.fields || [])]
48+
const fields = [...collection.fields, ...(adapter?.fields || [])]
4449

4550
// Inject a hook into all URL fields to generate URLs
4651

@@ -58,16 +63,24 @@ export const getFields = ({
5863
fields.splice(existingURLFieldIndex, 1)
5964
}
6065

61-
fields.push({
62-
...baseURLField,
63-
...(existingURLField || {}),
64-
hooks: {
65-
afterRead: [
66-
getAfterReadHook({ adapter, collection, disablePayloadAccessControl, generateFileURL }),
67-
...(existingURLField?.hooks?.afterRead || []),
68-
],
69-
},
70-
} as TextField)
66+
// Only add afterRead hook if adapter is provided
67+
if (adapter) {
68+
fields.push({
69+
...baseURLField,
70+
...(existingURLField || {}),
71+
hooks: {
72+
afterRead: [
73+
getAfterReadHook({ adapter, collection, disablePayloadAccessControl, generateFileURL }),
74+
...(existingURLField?.hooks?.afterRead || []),
75+
],
76+
},
77+
} as TextField)
78+
} else {
79+
fields.push({
80+
...baseURLField,
81+
...(existingURLField || {}),
82+
} as TextField)
83+
}
7184

7285
if (typeof collection.upload === 'object' && collection.upload.imageSizes) {
7386
let existingSizesFieldIndex = -1
@@ -99,15 +112,11 @@ export const getFields = ({
99112

100113
const existingSizeURLField = existingSizeField?.fields.find(
101114
(existingField) => 'name' in existingField && existingField.name === 'url',
102-
) as GroupField
115+
) as TextField
103116

104-
return {
105-
...existingSizeField,
106-
name: size.name,
107-
type: 'group',
108-
fields: [
109-
...(adapter.fields || []),
110-
{
117+
// Only add afterRead hook if adapter is provided
118+
const sizeURLField: TextField = adapter
119+
? ({
111120
...(existingSizeURLField || {}),
112121
...baseURLField,
113122
hooks: {
@@ -125,17 +134,26 @@ export const getFields = ({
125134
[]),
126135
],
127136
},
128-
},
129-
],
137+
} as TextField)
138+
: ({
139+
...(existingSizeURLField || {}),
140+
...baseURLField,
141+
} as TextField)
142+
143+
return {
144+
...existingSizeField,
145+
name: size.name,
146+
type: 'group',
147+
fields: [...(adapter?.fields || []), sizeURLField],
130148
} as Field
131149
}),
132150
}
133151

134152
fields.push(sizesField)
135153
}
136154

137-
// If prefix is enabled, save it to db
138-
if (typeof prefix !== 'undefined') {
155+
// If prefix is enabled or alwaysInsertFields is true, save it to db
156+
if (typeof prefix !== 'undefined' || alwaysInsertFields) {
139157
let existingPrefixFieldIndex = -1
140158

141159
const existingPrefixField = fields.find((existingField, i) => {
@@ -153,7 +171,7 @@ export const getFields = ({
153171
fields.push({
154172
...basePrefixField,
155173
...(existingPrefixField || {}),
156-
defaultValue: path.posix.join(prefix),
174+
defaultValue: prefix ? path.posix.join(prefix) : '',
157175
} as TextField)
158176
}
159177

packages/plugin-cloud-storage/src/plugin.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,46 @@ import { getAfterDeleteHook } from './hooks/afterDelete.js'
1818
export const cloudStoragePlugin =
1919
(pluginOptions: PluginOptions) =>
2020
(incomingConfig: Config): Config => {
21-
const { collections: allCollectionOptions, enabled } = pluginOptions
21+
const { alwaysInsertFields, collections: allCollectionOptions, enabled } = pluginOptions
2222
const config = { ...incomingConfig }
2323

24-
// Return early if disabled. Only webpack config mods are applied.
24+
// If disabled but alwaysInsertFields is true, only insert fields without full plugin functionality
2525
if (enabled === false) {
26+
if (alwaysInsertFields) {
27+
return {
28+
...config,
29+
collections: (config.collections || []).map((existingCollection) => {
30+
const options = allCollectionOptions[existingCollection.slug]
31+
32+
if (options) {
33+
// If adapter is provided, use it to get fields
34+
const adapter = options.adapter
35+
? options.adapter({
36+
collection: existingCollection,
37+
prefix: options.prefix,
38+
})
39+
: undefined
40+
41+
const fields = getFields({
42+
adapter,
43+
alwaysInsertFields: true,
44+
collection: existingCollection,
45+
disablePayloadAccessControl: options.disablePayloadAccessControl,
46+
generateFileURL: options.generateFileURL,
47+
prefix: options.prefix,
48+
})
49+
50+
return {
51+
...existingCollection,
52+
fields,
53+
}
54+
}
55+
56+
return existingCollection
57+
}),
58+
}
59+
}
60+
2661
return config
2762
}
2863

packages/plugin-cloud-storage/src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,16 @@ export interface CollectionOptions {
106106
}
107107

108108
export interface PluginOptions {
109+
/**
110+
* When enabled, fields (like the prefix field) will always be inserted into
111+
* the collection schema regardless of whether the plugin is enabled. This
112+
* ensures a consistent schema across all environments.
113+
*
114+
* This will be enabled by default in Payload v4.
115+
*
116+
* @default false
117+
*/
118+
alwaysInsertFields?: boolean
109119
collections: Partial<Record<UploadCollectionSlug, CollectionOptions>>
110120
/**
111121
* Whether or not to enable the plugin

packages/storage-azure/src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@ export type AzureStorageOptions = {
2626
*/
2727
allowContainerCreate: boolean
2828

29+
/**
30+
* When enabled, fields (like the prefix field) will always be inserted into
31+
* the collection schema regardless of whether the plugin is enabled. This
32+
* ensures a consistent schema across all environments.
33+
*
34+
* This will be enabled by default in Payload v4.
35+
*
36+
* @default false
37+
*/
38+
alwaysInsertFields?: boolean
39+
2940
/**
3041
* Base URL for the Azure Blob storage account
3142
*/
@@ -136,6 +147,7 @@ export const azureStorage: AzureStoragePlugin =
136147
}
137148

138149
return cloudStoragePlugin({
150+
alwaysInsertFields: azureStorageOptions.alwaysInsertFields,
139151
collections: collectionsWithAdapter,
140152
})(config)
141153
}

packages/storage-gcs/src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,17 @@ import { getHandler } from './staticHandler.js'
2121
export interface GcsStorageOptions {
2222
acl?: 'Private' | 'Public'
2323

24+
/**
25+
* When enabled, fields (like the prefix field) will always be inserted into
26+
* the collection schema regardless of whether the plugin is enabled. This
27+
* ensures a consistent schema across all environments.
28+
*
29+
* This will be enabled by default in Payload v4.
30+
*
31+
* @default false
32+
*/
33+
alwaysInsertFields?: boolean
34+
2435
/**
2536
* The name of the bucket to use.
2637
*/
@@ -131,6 +142,7 @@ export const gcsStorage: GcsStoragePlugin =
131142
}
132143

133144
return cloudStoragePlugin({
145+
alwaysInsertFields: gcsStorageOptions.alwaysInsertFields,
134146
collections: collectionsWithAdapter,
135147
})(config)
136148
}

packages/storage-r2/src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ import { getHandleUpload } from './handleUpload.js'
1515
import { getHandler } from './staticHandler.js'
1616

1717
export interface R2StorageOptions {
18+
/**
19+
* When enabled, fields (like the prefix field) will always be inserted into
20+
* the collection schema regardless of whether the plugin is enabled. This
21+
* ensures a consistent schema across all environments.
22+
*
23+
* This will be enabled by default in Payload v4.
24+
*
25+
* @default false
26+
*/
27+
alwaysInsertFields?: boolean
28+
1829
bucket: R2Bucket
1930
/**
2031
* Collection options to apply the R2 adapter to.
@@ -69,6 +80,7 @@ export const r2Storage: R2StoragePlugin =
6980
}
7081

7182
return cloudStoragePlugin({
83+
alwaysInsertFields: r2StorageOptions.alwaysInsertFields,
7284
collections: collectionsWithAdapter,
7385
})(config)
7486
}

0 commit comments

Comments
 (0)